NVIDIA-NeMo / NVIDIA-NeMo/Switchyard

[Bug] A target's extra_body is applied to another target that shares its model id

Open
#354 5 comments 0 reactions 0 assignees View on GitHub

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 on main @ fb3fc308
  • 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.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.rsLlmTarget.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.rsbuild_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.rsroute() 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.