fix(desktop): don't overwrite sticky $currentProvider with backend billing-class "custom" or "custom:<name>" slugs - #50151
Closed
lost9999 wants to merge 1 commit into
Conversation
…lling-class "custom" or "custom:<name>" slugs
The sticky atom (localStorage key
hermes.desktop.composer.provider) is the next session.create's
provider field. Backend's session.info event pushes agent.provider,
which is the resolved billing class:
- _resolve_named_custom_runtime writes bare "custom" for every
named providers:/custom_providers: entry
- _runtime_model_config recovers "custom:<name>" via
canonical_custom_identity
Neither shape is a routable user-facing slug; either one written
to localStorage bricks every subsequent session.create round-trip
until the user manually re-picks a model in the picker (Desktop 400
"unexpected model name format", caused by the Gemini fallback
endpoint rejecting the upstream model's name).
Gate the setCurrentProvider call in use-message-stream.ts on a new
isStickySafeProvider() helper that rejects both shapes (any case,
any whitespace). Keeps the existing sticky atom value, which is the
right behavior until the desktop receives a real user-facing slug.
Vitest: 6/6 pass in src/app/session/hooks/use-message-stream.test.ts.
Full session/hooks suite: 74/74 pass. Pre-existing failures in 39
unrelated test files verified not introduced by this commit (git
stash round-trip). No Python / gateway / backend changes; minimal
diff (+92/-1 across 2 files).
lost9999
marked this pull request as ready for review
June 21, 2026 13:13
Contributor
|
Closing as wrong-direction on current main. Canonical |
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.
fix(desktop): don't overwrite sticky $currentProvider with backend's billing-class "custom"
What does this PR do?
Fixes a Desktop regression where every new chat fails with
HTTP 400: ... GenerateContentRequest.model: unexpected model name formatuntil the user manually re-picks a model in the picker — even though the chat was started on a fully configuredcustom_providers:entry (e.g.tokenrouter).A new helper
isStickySafeProvider()is introduced inuse-message-stream.ts. Thesession.infohandler now uses it to refuse the literal billing-class string"custom"(which backend writes intoagent.providerfor every namedproviders:/custom_providers:entry), keeping the existing sticky$currentProvideratom value instead.Related Issue
This addresses a recurrence of the cross-surface staleness pattern already covered by PR #40163 / #43702 ("keep model runtime state per session"). The previous fixes bail-when-active-session; this fix is the missing sibling: bail-when-payload-provider-is-not-user-facing.
Root cause
model+providerUI state to everysession.createcall (use-session-actions.ts:462).tui_gateway/server.py:_session_info(line 2642) setsinfo["provider"] = getattr(agent, "provider", ""), andagent.provideris the resolved billing class —runtime_provider.py:_resolve_named_custom_runtimereturns{"provider": "custom", ...}for every namedcustom_providers:entry.use-message-stream.ts:744then wrote that literal"custom"intolocalStorageviasetCurrentProvider("custom").session.create, the desktop sent{model: "MiniMax-M3", provider: "custom"}. Backend'sresolve_runtime_provider("custom")could not matchcustom_providers:tokenrouterfrom"custom", fell through to the OpenAI-compat fallback chain, hithttps://generativelanguage.googleapis.com/v1beta, and Gemini returned400 unexpected model name formatforMiniMax-M3.cb6b4127e refactor(desktop): make composer model picker sticky session state(June 16) makes this persistent: once the invalid value is inlocalStorage,refreshCurrentModel()bails (!force && $currentModel.get()short-circuit), so the desktop never re-syncs from the gateway until the user manually picks a model in the UI again.backend.logevidence from a reproducer on Windows 11 /hermes-agentmain @fcdefb418:desktop Local Storage/leveldbevidence — two different shapes observed across reproducer runs (provider atom written verbatim by the buggy handler):Either shape in
localStoragecauses the nextsession.createto send an unresolvableproviderand surface as the same 400 error. The fix therefore rejects both"custom"and"custom:<x>"(any case, with surrounding whitespace).Type of Change
Changes Made
apps/desktop/src/app/session/hooks/use-message-stream.ts— addisStickySafeProvider()exported helper; replace the unguardedsetCurrentProvider(payload!.provider || '')with the helper-gated call. Net +38 / -1.apps/desktop/src/app/session/hooks/use-message-stream.test.ts— new vitest suite, 6 cases covering the literal"custom"rejection, namedcustom_providers:acceptance, built-in provider acceptance, canonicalcustom:<name>acceptance, and the empty/whitespace/null/undefined guards.How to Test
Automated (the regression guard):
Manual (full reproducer on Windows + Telegram gateway + Desktop):
custom_providers:entry in~/.hermes/config.yaml(e.g.tokenrouterpointing at a working endpoint with a validTOKENROUTER_API_KEY).tokenrouter:MiniMax-M3(or equivalent)./model grok-build-0.1(any model switch via/modelwill do).HTTP 400 ... GenerateContentRequest.model: unexpected model name format. With the fix: the chat starts normally on the previously-picked custom provider, exactly as it did before the Telegram switch.custom). Subsequent new chats continue to work.Why this scope and not "fix the backend instead"
I considered two alternative fix sites, both deliberately not in this PR:
_session_inforeturns the user-facing name instead ofagent.provider. Cleaner in principle, but_session_infois also the canonical source for resume, footer labels, and themodel_configDB row; changing its shape touches PR fix(desktop): keep model runtime state per session #40163 / fix(desktop): keep model runtime state per session #43702 semantics and the_runtime_model_configwrite-back path. Out of scope for a one-file Desktop fix.canonical_custom_identitypath to emitcustom:<name>from_resolve_named_custom_runtimeconsistently (so the same shape lands in bothagent.providerand_runtime_model_configrecovery). This would let Desktop acceptcustom:<name>rather than reject it. Out of scope because it changes the wire format mid-stream and would break existing sticky-state values for every user already on the buggy code path.selectModelstops callingconfig.setwhen there is an active session. Removes the trigger but not the symptom — any other surface that pushessession.infowould still brick the sticky atom.The Desktop-side guard is the minimum-diff, minimum-blast-radius fix that closes the user-visible regression without changing the wire format. Follow-up PRs can move the trust boundary further upstream.
Checklist
vitest run --environment jsdom src/app/session/hooks/→ 74/74)fix(desktop): ...)src/app/right-sidebar/files/ipc.test.tsetc.) verified not introduced by this change (git stashround-trip confirmed)Platforms tested
hermes-agent@fcdefb418(June 20), Electron desktop release built from same source.Out of scope
model.persist_switch_by_default: Truelets/modelon any surface (Telegram, CLI, Web UI) rewrite the globalconfig.yamlmodel block and silently leak into other surfaces — that's a separate cross-surface stale-state class (tracked locally; will be filed as a separate issue).