You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Released switchyard-server 0.2.0 (crates.io): not affected (see below)
Upstream: Fireworks AI (https://api.fireworks.ai/inference/v1), openai_chat format
Client: curl / plain HTTP, no launcher
Description
When two targets share one model id and are reachable from the same route, the extra_body declared
on one target is applied to calls that the router sends to the other one.
In an llm_classifier deployment this is easy to hit, because using the cheap tier as its own judge
is a natural configuration: the classifier target and the weak target point at the same model, and
only their extra_body differs. The result is that user-facing generations on the weak tier are
served with the judge's parameters — in our case reasoning_effort: "none" and temperature: 0,
i.e. every routed weak answer came back with reasoning suppressed, while the same model called
through a passthrough route reasoned normally.
Nothing in the logs or the routing stats indicates this. The route reports the correct model, the
decision is correct, and the response is a valid completion — just generated under parameters the
operator declared for a different target.
Giving the two targets separate llm_clients (the workaround for the (llm client, model id)
dedupe warning added in #180) does not help: the per-route map is keyed by model id alone.
Reproduction
schema_version = 1
[llm_clients.fireworks]
format = "openai_chat"base_url = "https://api.fireworks.ai/inference/v1"api_key_env = "FIREWORKS_API_KEY"# Distinct client, so the #180 dedupe warning does not fire and neither target is dropped.
[llm_clients.fireworks_judge]
format = "openai_chat"base_url = "https://api.fireworks.ai/inference/v1"api_key_env = "FIREWORKS_API_KEY"
[targets.weak]
id = "accounts/fireworks/models/deepseek-v4-flash-0731"llm_client = "fireworks"# no extra_body: this target should reason at provider defaults
[targets.strong]
id = "accounts/fireworks/models/kimi-k3"llm_client = "fireworks"
[targets.classifier]
id = "accounts/fireworks/models/deepseek-v4-flash-0731"# same id as [targets.weak]llm_client = "fireworks_judge"extra_body = { reasoning_effort = "none", temperature = 0 }
[routes.auto]
id = "auto"type = "llm_classifier"mode = "capability"classifier_target = "classifier"strong_target = "strong"weak_target = "weak"base_threshold = 0.75threshold_step = 0.1session_affinity = true
[routes.weak-only]
id = "weak-only"type = "passthrough"target = "weak"
Send the same prompt to auto (judged onto the weak tier) and to weak-only, and compare reasoning_content on the response. Both are served by deepseek-v4-flash-0731.
Expected: both reason at provider defaults, because [targets.weak] declares no extra_body.
Actual on f30498d3: the auto response has no reasoning at all.
The released 0.2.0 build does not reproduce it
Running the identical config against two builds side by side, same prompts, same minute:
The released crate was published from d0b9d50b, which is 57 minutes before #268 merged, so the
regression window is #268 itself. Note that both binaries report switchyard-server 0.2.0, so the
version string does not distinguish them.
Root cause (as far as we traced it)
crates/switchyard-server/src/config.rs — LlmTarget.semantic_name is set to the model id
(config.id.clone()), not the target's TOML key, so two targets on one model are indistinguishable
downstream.
crates/switchyard-server/src/config.rs — build_client_router() builds a per-route map keyed by target.id. Two targets sharing a model id collide on insert, and the surviving entry (with its extra_body) serves both.
crates/libsy/src/algorithms/fall_through.rs — route() resolves the decision by model-id string
(score.target), so no target identity survives to the call site.
Before #268, build_targets() attached the client to each LlmTarget directly
(llm_client: Some(client)), so each target carried its own backend and the ids never had to be
disambiguated. The per-route model-id map was introduced with the #268 refactor.
#180 warns when two targets reuse a model id on one llm client, and the workaround is to give
them separate clients. That prevents the drop, but the per-route map added in #268 is keyed by model
id regardless of client, so the collision reappears one layer down — with no warning this time,
because at the client level the config is now legal.
Impact
Silent, and in the direction that is hardest to notice: the response is well-formed, the routing
decision is right, the reported model is right, and the only difference is the sampling/reasoning
parameters. We ran an A/B, a rubric calibration and a 52-run benchmark before noticing, and had to
re-derive our classifier threshold afterwards because the judge's own configuration had to change to
work around it.
Happy to test any patch against the reproduction above.
Environment
NVIDIA-NeMo/Switchyard @ f30498d3(refactor(python): remove legacy routing profiles #268) — also present onmain @ fb3fc308switchyard-server 0.2.0(crates.io): not affected (see below)https://api.fireworks.ai/inference/v1),openai_chatformatcurl/ plain HTTP, no launcherDescription
When two targets share one model id and are reachable from the same route, the
extra_bodydeclaredon one target is applied to calls that the router sends to the other one.
In an
llm_classifierdeployment this is easy to hit, because using the cheap tier as its own judgeis a natural configuration: the classifier target and the weak target point at the same model, and
only their
extra_bodydiffers. The result is that user-facing generations on the weak tier areserved with the judge's parameters — in our case
reasoning_effort: "none"andtemperature: 0,i.e. every routed weak answer came back with reasoning suppressed, while the same model called
through a
passthroughroute reasoned normally.Nothing in the logs or the routing stats indicates this. The route reports the correct model, the
decision is correct, and the response is a valid completion — just generated under parameters the
operator declared for a different target.
Giving the two targets separate
llm_clients(the workaround for the(llm client, model id)dedupe warning added in #180) does not help: the per-route map is keyed by model id alone.
Reproduction
Send the same prompt to
auto(judged onto the weak tier) and toweak-only, and comparereasoning_contenton the response. Both are served bydeepseek-v4-flash-0731.Expected: both reason at provider defaults, because
[targets.weak]declares noextra_body.Actual on
f30498d3: theautoresponse has no reasoning at all.The released 0.2.0 build does not reproduce it
Running the identical config against two builds side by side, same prompts, same minute:
weak-onlyreasoningautoreasoningswitchyard-server 0.2.0(crates.io)f30498d3(#268)autoloses its reasoningThe released crate was published from
d0b9d50b, which is 57 minutes before #268 merged, so theregression window is #268 itself. Note that both binaries report
switchyard-server 0.2.0, so theversion string does not distinguish them.
Root cause (as far as we traced it)
crates/switchyard-server/src/config.rs—LlmTarget.semantic_nameis set to the model id(
config.id.clone()), not the target's TOML key, so two targets on one model are indistinguishabledownstream.
crates/switchyard-server/src/config.rs—build_client_router()builds a per-route map keyed bytarget.id. Two targets sharing a model id collide on insert, and the surviving entry (with itsextra_body) serves both.crates/libsy/src/algorithms/fall_through.rs—route()resolves the decision by model-id string(
score.target), so no target identity survives to the call site.Before #268,
build_targets()attached the client to eachLlmTargetdirectly(
llm_client: Some(client)), so each target carried its own backend and the ids never had to bedisambiguated. The per-route model-id map was introduced with the #268 refactor.
Why the #180 warning does not catch this
#180 warns when two targets reuse a model id on one llm client, and the workaround is to give
them separate clients. That prevents the drop, but the per-route map added in #268 is keyed by model
id regardless of client, so the collision reappears one layer down — with no warning this time,
because at the client level the config is now legal.
Impact
Silent, and in the direction that is hardest to notice: the response is well-formed, the routing
decision is right, the reported model is right, and the only difference is the sampling/reasoning
parameters. We ran an A/B, a rubric calibration and a 52-run benchmark before noticing, and had to
re-derive our classifier threshold afterwards because the judge's own configuration had to change to
work around it.
Happy to test any patch against the reproduction above.