NVIDIA-NeMo / NVIDIA-NeMo/Switchyard
[Bug] A target's extra_body is applied to another target that shares its model id
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 3.2k
- Forks
- 291
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 182
Description
Environment
- Repo / commit:
NVIDIA-NeMo/Switchyard @ f30498d3(#268) — also present onmain @ fb3fc308 - Released
switchyard-server 0.2.0(crates.io): not affected (see below) - Upstream: Fireworks AI (
https://api.fireworks.ai/inference/v1),openai_chatformat - 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.75
threshold_step = 0.1
session_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:
| build | weak-only reasoning |
auto reasoning |
verdict |
|---|---|---|---|
switchyard-server 0.2.0 (crates.io) |
881 / 1,565 / 993 / 1,070 | 289 / 2,358 / 1,360 / 1,346 | both tiers reason |
f30498d3 (#268) |
951 / 1,629 / 1,439 / 297 | 0 / 0 / 0 / 0 | auto loses its reasoning |
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_nameis 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.
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with build_client_router() and LlmTarget.semantic_name in crates/switchyard-server/src/config.rs, then trace route() in crates/libsy/src/algorithms/fall_through.rs. Use the provided two-target reproduction to verify that targets sharing a model id retain distinct extra_body settings, and confirm both weak-only and auto requests behave as expected.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100