fix(gateway): build the composer's model picker under the target profile - #329
Merged
OmarB97 merged 1 commit intoAug 2, 2026
Merged
Conversation
Sibling of #325, same root cause on the other half of the composer. Provider names are per-profile vocabulary — `providers:` / `custom_providers:` live in each profile's own config.yaml — and three RPC handlers resolved them on the RPC thread with no HERMES_HOME bound, so they all answered for the LAUNCH profile. Measured on two real temp profiles ("launcher" and "worker", each with its own provider), driving the real RPCs: model.options for a WORKER session → provider 'launch-router', model 'launcher/model-A', and the provider list contained launch-router but not worker-local config.set key=model applying worker's OWN provider to a WORKER session → error: "Unknown provider 'worker-local'" So the picker could only offer names the session's profile does not define, and refused the one name it does. Picking from that list is how a session ends up stamped with a provider its own profile cannot resolve — the turn then dies in agent init on "Unknown provider", leaving the 0-message husk #321 fixed for `hermes desktop spawn --provider`. This is the desktop composer's path to the same dead end. Three sites, one binding each: * `model.options` — build the picker under the profile it is FOR. * `config.set key=model` — apply the pick under the same profile the picker offered it from. `_apply_model_switch` reads `providers:` through `load_config()` and asks `resolve_runtime_provider` to name the target, all HERMES_HOME reads. Its body moves to `_config_set_model` unchanged so the caller can wrap it; `switch_model` persists through `save_config()`, which resolves the same home, so a `--global` write from a foreign-profile session now lands in that profile's config.yaml rather than the launcher's. * `model.save_key` — the picker's "connect" action. `.env` is per-profile, so the key has to land in the profile whose providers are being shown; the session lookup moves above the write to make that possible. Without this the model.options fix would leave a just-connected row still unauthenticated. `model.disconnect` is deliberately untouched: it takes no session and is a global provider disconnect, so there is nothing to scope it by. A live session is resolved by its OWN home — that is where its turn will run. For the picker's other opening, a chat that does not exist yet, there is no session to ask, so `model.options` and `model.save_key` now also accept `profile`. That is not a new policy: the REST twin `/api/model/options` has always taken it and documents it ("``profile`` scopes the picker context"), so this closes a divergence between two surfaces whose response shapes are otherwise 1:1. The desktop sends it via a new `apiRequestProfile()` accessor over the same `_apiProfile` its REST calls already scope with — which is what fixes the picker opened before a chat exists, the one path that reaches the gateway with no session id. Session first, `profile` second, so an existing session can never be mis-scoped by a stale client hint. Verified against real temp profiles in tests/test_profile_isolation_runtime.py (no mocks) plus the desktop unit tests. Four of the seven new Python tests fail on the parent commit; the rest are controls — a launch-profile session must still take the unbound path, and a provider only the LAUNCH profile defines must still be rejected (it was ACCEPTED before this, which is the exact mechanism that stamped a foreign provider onto a session row). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's wrong
Sibling of #325, same root cause on the other half of the composer.
Provider names are per-profile vocabulary —
providers:/custom_providers:live in each profile's ownconfig.yaml. Three RPC handlers resolved them on the RPC thread with noHERMES_HOMEbound, so all three answered for the launch profile.Measured on two real temp profiles (
launcherandworker, each with its own provider), driving the real RPCs:So the picker could only offer names the session's profile does not define, and refused the one name it does.
Picking from that list is how a session ends up stamped with a provider its own profile cannot resolve. The turn then dies in agent init on "Unknown provider", leaving the 0-message husk #321 fixed for
hermes desktop spawn --provider. This is the desktop composer's path to the same dead end — and the mechanism behind the foreignai-routerprovider on themeshboard-game-devrows in #325.The fix
Three sites, one binding each (
_profile_home_bound, added in #325):model.optionsconfig.set key=model_apply_model_switchreadsproviders:viaload_config()and asksresolve_runtime_providerto name the target — allHERMES_HOMEreads.model.save_key.envis per-profile, so the key must land in the profile whose providers are shown.config.set's model branch moves to_config_set_modelunchanged so the caller can wrap it.switch_modelpersists throughsave_config(), which resolves the same home — so a--globalwrite from a foreign-profile session now lands in that profile'sconfig.yamlrather than the launcher's.model.save_key's session lookup moves above the credential write to make the binding possible. Without this, themodel.optionsfix alone would leave a just-connected row still showing unauthenticated — the write and the refresh would target different profiles.model.disconnectis deliberately untouched: it takes no session and is a global provider disconnect, so there is nothing to scope it by.The no-session picker
A live session is resolved by its own home — that is where its turn will run.
For the picker's other opening — a chat that does not exist yet — there is no session to ask.
model.optionsandmodel.save_keynow also acceptprofile.That is not a new policy. The REST twin
/api/model/optionshas always taken it and documents it:So this closes a divergence between two surfaces whose response shapes are otherwise 1:1. The desktop sends it via a new
apiRequestProfile()accessor over the same_apiProfileits REST calls already scope with — fixing the picker opened before a chat exists, which is the one path that reaches the gateway with no session id (model-menu-panelis gateway-first even with no session, per NousResearch#53817).Session first,
profilesecond, so an existing session can never be mis-scoped by a stale client hint.Verification
Real temp profiles, no mocks (
tests/test_profile_isolation_runtime.py), plus desktop unit tests.4 of the 7 new Python tests fail on the parent commit. The others are deliberate controls:
Suites:
tests/test_tui_gateway_server.py+tests/tui_gateway/+tests/test_profile_isolation_runtime.py→ 866 passed, 4 failed, the same 4 this machine produces on cleanmain(host-config-dependent, baselined in the #325 session).tests/hermes_cli/model-switch, inventory, runtime-provider, config and profile-unification suites → 506 passed. Desktopsrc/lib→ 364 passed across 46 files.One desktop markdown-fuzz test timed out on the first run at load average 94 (other agents' pytest runs saturating the box) and passes clean once the machine settled — not related to this change, which touches no markdown code.
Risk
Low. A session with no
profile_homeand a client that sends noprofileresolves exactly as before —_profile_home_bound(None)is a no-op and_profile_home(None)returnsNone. The behavior change is confined to sessions that already carry a foreign profile home, plus the newly-scoped no-session picker, where the previous answer was wrong.The one deliberate behavior change beyond that: a key saved from a foreign-profile session's picker now lands in that profile's
.envinstead of the launcher's. That is required for the picker to be self-consistent, and matches what/api/model/set+/api/model/optionsalready do on the REST side.