fix(model-switch): distinct-named custom providers stay reachable in the picker and keep their own extra_body - #86180
Conversation
…entity providers:/custom_providers: entries sharing (api_url, credential, api_mode, headers) but differing only by extra_body (e.g. a vLLM endpoint listed twice with chat_template_kwargs.enable_thinking toggled) were silently collapsed into a single picker row, and one of the two configured models disappeared. Fold extra_body into the group identity, mirroring the existing extra_headers handling in both grouping sections.
…the picker and keep their own extra_body Two related bugs found while tracking down why a second providers: entry pointing at the same endpoint (used to toggle vLLM's chat_template_kwargs.enable_thinking for a hybrid-thinking model) was practically unusable: 1. Picker (hermes_cli/model_switch.py): section 3 (providers:) grouped same-endpoint entries purely on connection identity, never on name, unlike section 4 (custom_providers:) which already grouped on a version-stripped name prefix. Two providers: entries with deliberately different names collapsed into one picker row, making the second entry's extra_body unreachable from /model. Promoted the existing (cosmetic-only) version-suffix-stripping heuristic into section 3's grouping key, mirroring section 4, while still folding Palantir-style "same provider, more models" names into one row. 2. Runtime (agent/agent_init.py, agent/agent_runtime_helpers.py): _merge_custom_provider_extra_body matched on agent.provider, which is always the bare canonical "custom" for every named custom provider — carrying no per-entry identity — so it could apply the WRONG entry's extra_body regardless of which was selected. A live /model switch also resolves the provider identity differently (the entry's own raw name, e.g. "vllm", not "custom"), which the matcher didn't recognize at all, and switch_model() never reconciled request_overrides['extra_body'], leaving a stale value from a previous provider stuck for the rest of the session.
fix(model-switch): distinct-named custom providers stay reachable in the picker and keep their own extra_body The two-convention identity handling and the idempotent reconcile-on-switch are well covered by tests. Observations:
No blocking issues. |
Closes #86179
Summary
Two related bugs, found while tracking down why a second
providers:entry pointing at the same endpoint (used to toggle vLLM'schat_template_kwargs.enable_thinkingfor a hybrid-thinking model) was practically unusable:1. Picker: distinctly-named
providers:entries silently collapsed into one rowlist_authenticated_providers()groups same-endpointproviders:entries into one picker row so a provider with several models doesn't produce N near-duplicate rows (the "Palantir Claude 4.6/4.7 Opus" → "Palantir Claude" case, from #36998). Section 3 (providers:) grouped purely on connection identity (api_url, credential, api_mode, headers) — never on name — while section 4 (custom_providers:) already grouped on a version-stripped name prefix too. That asymmetry meant twoproviders:entries at the same endpoint with deliberately different names (e.g. "vLLM" / "vLLM No-Think") collapsed into a single row, and the second entry'sextra_bodybecame unreachable from the picker even though it resolved correctly via--provider <name>or a direct config edit.Fix: promote the existing (cosmetic-only) version-suffix-stripping heuristic into section 3's grouping key, mirroring section 4. Two entries whose names share no separator/version pattern now stay distinct rows; entries whose names differ only by an embedded version number (the Palantir case) still fold into one row with multiple models.
extra_body/extra_headersremain additional identity signals for the rarer case of identical names with different configs.2. Runtime:
extra_bodycould apply to the wrong provider, or stop applying after a live switchIndependent of the picker,
_merge_custom_provider_extra_body()(agent/agent_init.py) matched a named custom provider'sextra_bodyusingagent.provider, which is always the bare canonical"custom"for everyproviders:/custom_providers:entry — it carries no per-entry identity. With two entries sharing (base_url, model), the matcher fell through to "first entry with a non-empty extra_body" and could silently apply the WRONG entry's extra_body regardless of which one was actually selected.While fixing that, found a second layer: a live
/modelswitch resolves the new provider identity differently than agent-init does —hermes_cli/model_switch.py's pureswitch_model()setstarget_providerto the entry's own raw name (e.g."vllm"), not"custom". The extra_body matcher only recognized the"custom"/"custom:<name>"shapes, so after ANY live switch to a named custom provider, extra_body resolution silently stopped applying at all. On top of that,switch_model()(agent/agent_runtime_helpers.py) never touchedrequest_overrides['extra_body'], so a provider switched away from would leave its extra_body stuck on every request for the rest of the session even after switching to a provider with none configured.Fix:
_custom_provider_extra_body_for_agent()now acceptsrequested_providerand uses it (notagent.provider) to disambiguate entries at agent-init.providernorrequested_providerlooks like"custom"/"custom:<name>", fall back to treating a bare name as the filter only when it matches a configuredcustom_providersidentity (provider_key or display name) — never for a builtin provider, since those never appear incustom_providers._merge_custom_provider_extra_body()is now idempotent/repeatable: it tracks which keys it previously contributed (agent._custom_provider_extra_body_keys) and strips them before re-merging, so switching to a provider with noextra_bodycorrectly clears the stale value while any unrelated caller-set override (e.g. an explicit fast-modeservice_tier) survives.switch_model()now calls this reconciliation on every live switch, using the freshly-loadedcustom_providerslist it already fetches for context-length resolution.Repro
Before this fix: the picker showed a single "vLLM" row with one model;
vllm-no-thinkwas unreachable from/model. Separately, even selectingvllm-no-thinkdirectly (or switching to it live), itsextra_bodycould be silently dropped or applied to the wrong provider, and switching back tovllmmid-session left theenable_thinking: falseoverride stuck on every subsequent request.Test plan
tests/hermes_cli/test_provider_section3_grouping.py— picker grouping: distinct names stay separate rows (with and without differingextra_body), Palantir-style version-suffix names still collapse into one row.tests/hermes_cli/test_model_switch_custom_providers.py— same invariants for thecustom_providers:(section 4) grouping.tests/agent/test_custom_provider_extra_body.py—_merge_custom_provider_extra_bodydisambiguates byrequested_provider(agent-init convention) and by bare raw name (live-switch convention), never leaks onto a builtin provider, clears stale keys on re-merge, preserves unrelated caller overrides.tests/agent/test_custom_provider_extra_body_matching.py— existing matching tests still pass unchanged.tests/run_agent/test_switch_model_extra_body.py(new) — end-to-end through the realswitch_model(): switching to the no-think variant picks up its extra_body; switching away clears it; the agent-init"custom"convention still resolves correctly too.vllmorvllm-no-thinksends the correct (or absent)chat_template_kwargs.enable_thinking; live/modelswitching back and forth in the same running session correctly toggles it on the outgoing request (checked via proxy).