Skip to content

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

Description

@himorishige

Environment

  • Repo / commit: NVIDIA-NeMo/Switchyard @ f30498d3 (refactor(python): remove legacy routing profiles #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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions