From 9fed768b567cf326d7790a85d417889ceb5c1b7e Mon Sep 17 00:00:00 2001 From: embw_l0x <262193448+embwl0x@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:40:50 -0400 Subject: [PATCH] fix(desktop): scope model options by profile (#62795) Co-authored-by: embwl0x --- apps/desktop/src/app/chat/index.tsx | 23 +--- apps/desktop/src/app/chat/session-tile.tsx | 5 +- apps/desktop/src/app/contrib/surfaces.tsx | 5 +- apps/desktop/src/app/contrib/wiring.tsx | 16 ++- apps/desktop/src/app/model-picker-overlay.tsx | 4 +- .../src/app/model-visibility-overlay.tsx | 4 +- .../hooks/use-message-stream/gateway-event.ts | 7 +- .../session/hooks/use-message-stream/index.ts | 3 + .../session-info-side-effects.test.tsx | 5 +- .../session/hooks/use-model-controls.test.tsx | 45 +++++- .../app/session/hooks/use-model-controls.ts | 38 ++++-- .../src/app/shell/model-menu-panel.tsx | 9 +- apps/desktop/src/components/model-picker.tsx | 6 +- .../components/model-visibility-dialog.tsx | 17 +-- .../src/components/onboarding/flow.tsx | 7 +- .../src/components/onboarding/index.tsx | 17 ++- apps/desktop/src/lib/model-options.test.ts | 14 +- apps/desktop/src/lib/model-options.ts | 6 + apps/desktop/src/store/onboarding.ts | 1 + tests/test_tui_gateway_server.py | 128 +++++++++++++++++- tui_gateway/server.py | 52 ++++--- 21 files changed, 330 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 900e80ead270..bd18be0c4834 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -14,18 +14,19 @@ import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { ErrorState } from '@/components/ui/error-state' import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger' -import { getGlobalModelOptions, type HermesGateway } from '@/hermes' +import { type HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' +import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { cn } from '@/lib/utils' import { migrateSessionDraft } from '@/store/composer' import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' -import { $gatewaySwapTarget, $profiles } from '@/store/profile' +import { $activeGatewayProfile, $gatewaySwapTarget, $profiles } from '@/store/profile' import { $contextSuggestions, $freshDraftReady, @@ -254,6 +255,7 @@ export function ChatView({ const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}` const awaitingResponse = useStore(view.$awaitingResponse) const busy = useStore(view.$busy) + const activeGatewayProfile = useStore($activeGatewayProfile) const contextSuggestions = useStore($contextSuggestions) // Per-session (SessionView) reads — a tile IS its session, so these come // from the view slice, not the global atoms (which track the primary only). @@ -356,21 +358,8 @@ export function ChatView({ const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new') const modelOptionsQuery = useQuery({ - queryKey: ['model-options', activeSessionId || 'global'], - queryFn: () => { - if (!activeSessionId) { - return getGlobalModelOptions() - } - - if (!gateway) { - throw new Error('Hermes gateway unavailable') - } - - return gateway.request('model.options', { - session_id: activeSessionId, - explicit_only: true - }) - }, + queryKey: modelOptionsQueryKey(activeGatewayProfile, activeSessionId), + queryFn: () => requestModelOptions({ gateway: gateway || undefined, sessionId: activeSessionId }), enabled: gatewayOpen }) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index efb326ebdc29..e9057ab16963 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -35,6 +35,7 @@ import type { ChatMessage } from '@/lib/chat-messages' import { sessionTitle } from '@/lib/chat-runtime' import { createComposerAttachmentScope } from '@/store/composer' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' +import { $activeGatewayProfile } from '@/store/profile' import { sessionAwaitingInput } from '@/store/prompts' import { $gatewayState, @@ -112,6 +113,7 @@ function TileChat({ const { gatewayRef, requestGateway } = useGatewayRequest() const queryClient = useQueryClient() const { selectModel } = useModelControls({ queryClient, requestGateway }) + const activeGatewayProfile = useStore($activeGatewayProfile) const cwd = useStore(view.$cwd) const gatewayOpen = useStore($gatewayState) === 'open' @@ -148,10 +150,11 @@ function TileChat({ ) : null, - [gatewayOpen, gatewayRef, requestGateway, selectModel] + [activeGatewayProfile, gatewayOpen, gatewayRef, requestGateway, selectModel] ) return ( diff --git a/apps/desktop/src/app/contrib/surfaces.tsx b/apps/desktop/src/app/contrib/surfaces.tsx index 1d3d475fc166..482d37a4bba8 100644 --- a/apps/desktop/src/app/contrib/surfaces.tsx +++ b/apps/desktop/src/app/contrib/surfaces.tsx @@ -13,6 +13,7 @@ import { Navigate, Route, Routes, useParams } from 'react-router-dom' import { ContribBoundary } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' +import { $activeGatewayProfile } from '@/store/profile' import { $freshDraftReady, $gatewayState } from '@/store/session' import { ChatView } from '../chat' @@ -109,6 +110,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({ actions: WiringActions maxVoiceRecordingSeconds?: number }) { + const activeGatewayProfile = useStore($activeGatewayProfile) const gatewayState = useStore($gatewayState) useContributions(ROUTES_AREA) const routeContributions = contributedRoutes() @@ -128,10 +130,11 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({ ) : null, - [actions, gateway, gatewayState] + [actions, activeGatewayProfile, gateway, gatewayState] ) const chatView = ( diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 47beed69bcbc..b2d0c03a9881 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -139,6 +139,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) const selectedStoredSessionId = useStore($selectedStoredSessionId) const messagingSessions = useStore($messagingSessions) + const activeGatewayProfile = useStore($activeGatewayProfile) const profileScope = useStore($profileScope) const routedSessionId = routeSessionId(location.pathname) @@ -341,6 +342,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { }, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]) const { handleGatewayEvent } = useMessageStream({ + activeGatewayProfile, activeSessionIdRef, hydrateFromStoredSession, queryClient, @@ -427,7 +429,6 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Swapping the live gateway to another profile must re-pull that profile's // global model + active-profile pill (both are nanostores — the blanket // invalidateQueries on swap doesn't touch them). - const activeGatewayProfile = useStore($activeGatewayProfile) const lastGatewayProfileRef = useRef(activeGatewayProfile) useEffect(() => { @@ -899,12 +900,21 @@ export function ContribWiring({ children }: { children: ReactNode }) { void refreshCurrentModel() void queryClient.invalidateQueries({ queryKey: ['model-options'] }) }} + profile={activeGatewayProfile} requestGateway={requestGateway} /> )} - + - + diff --git a/apps/desktop/src/app/model-picker-overlay.tsx b/apps/desktop/src/app/model-picker-overlay.tsx index 51d7e783afbc..e51a0e4de1be 100644 --- a/apps/desktop/src/app/model-picker-overlay.tsx +++ b/apps/desktop/src/app/model-picker-overlay.tsx @@ -16,9 +16,10 @@ import { $focusedRuntimeId, $focusedSessionState } from '@/store/session-states' interface ModelPickerOverlayProps { gateway?: HermesGateway onSelect: (selection: ModelSelection) => void + profile: string } -export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) { +export function ModelPickerOverlay({ gateway, onSelect, profile }: ModelPickerOverlayProps) { const primarySessionId = useStore($activeSessionId) const primaryModel = useStore($currentModel) const primaryProvider = useStore($currentProvider) @@ -45,6 +46,7 @@ export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProp onOpenChange={setModelPickerOpen} onSelect={selection => onSelect({ ...selection, sessionId })} open={open} + profile={profile} sessionId={sessionId} /> ) diff --git a/apps/desktop/src/app/model-visibility-overlay.tsx b/apps/desktop/src/app/model-visibility-overlay.tsx index 80691a580c02..a20dd7c4a5da 100644 --- a/apps/desktop/src/app/model-visibility-overlay.tsx +++ b/apps/desktop/src/app/model-visibility-overlay.tsx @@ -8,9 +8,10 @@ import { $activeSessionId, $gatewayState } from '@/store/session' interface ModelVisibilityOverlayProps { gateway?: HermesGateway onOpenProviders: () => void + profile: string } -export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibilityOverlayProps) { +export function ModelVisibilityOverlay({ gateway, onOpenProviders, profile }: ModelVisibilityOverlayProps) { const activeSessionId = useStore($activeSessionId) const gatewayOpen = useStore($gatewayState) === 'open' const open = useStore($modelVisibilityOpen) @@ -25,6 +26,7 @@ export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibi onOpenChange={setModelVisibilityOpen} onOpenProviders={onOpenProviders} open={open} + profile={profile} sessionId={activeSessionId} /> ) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 2f7f50cec716..7a019bb4230c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -12,6 +12,7 @@ import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from import { playCompletionSound } from '@/lib/completion-sound' import { resolveGatewayEventSessionId } from '@/lib/gateway-events' import { triggerHaptic } from '@/lib/haptics' +import { modelOptionsQueryKey } from '@/lib/model-options' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' @@ -70,6 +71,7 @@ const COMPACTION_RESUME_EVENT_TYPES = new Set([ ]) interface GatewayEventDeps { + activeGatewayProfile: string activeSessionIdRef: MutableRefObject compactedTurnRef: MutableRefObject> lastCwdInfoSessionRef: MutableRefObject @@ -102,6 +104,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const { appendAssistantDelta, appendReasoningDelta, + activeGatewayProfile, activeSessionIdRef, compactedTurnRef, lastCwdInfoSessionRef, @@ -372,7 +375,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (modelValueChanged || providerValueChanged) { void queryClient.invalidateQueries({ - queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options'] + queryKey: + explicitSid && sessionId ? modelOptionsQueryKey(activeGatewayProfile, sessionId) : ['model-options'] }) } } else if (event.type === 'message.start') { @@ -837,6 +841,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { appendAssistantDelta, appendReasoningDelta, activeSessionIdRef, + activeGatewayProfile, compactedTurnRef, completeAssistantMessage, failAssistantMessage, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 11a7878af069..d72633ad7e58 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -32,6 +32,7 @@ import { useGatewayEventHandler } from './gateway-event' import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils' interface MessageStreamOptions { + activeGatewayProfile?: string activeSessionIdRef: MutableRefObject hydrateFromStoredSession: ( attempts?: number, @@ -55,6 +56,7 @@ interface QueuedStreamDeltas { } export function useMessageStream({ + activeGatewayProfile = 'default', activeSessionIdRef, hydrateFromStoredSession, queryClient, @@ -613,6 +615,7 @@ export function useMessageStream({ ) const handleGatewayEvent = useGatewayEventHandler({ + activeGatewayProfile, appendAssistantDelta, appendReasoningDelta, activeSessionIdRef, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/session-info-side-effects.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/session-info-side-effects.test.tsx index 46bcd9ec3239..305d31f8334c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/session-info-side-effects.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/session-info-side-effects.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { createClientSessionState } from '@/lib/chat-runtime' +import { modelOptionsQueryKey } from '@/lib/model-options' import { setCurrentModel, setCurrentProvider } from '@/store/session' import type { RpcEvent } from '@/types/hermes' @@ -16,6 +17,7 @@ import { useMessageStream } from './index' // actually changed. message.complete must coalesce sidebar refreshes. const ACTIVE_SID = 'session-active' +const ACTIVE_PROFILE = 'compass' let handleEvent: ((event: RpcEvent) => void) | null = null let refreshHermesConfig: ReturnType Promise>> let refreshSessions: ReturnType Promise>> @@ -26,6 +28,7 @@ function Harness() { const sessionStateByRuntimeIdRef = useRef(new Map()) const stream = useMessageStream({ + activeGatewayProfile: ACTIVE_PROFILE, activeSessionIdRef, hydrateFromStoredSession: vi.fn(async () => undefined), queryClient, @@ -132,7 +135,7 @@ describe('session.info model-options invalidation gating', () => { sessionInfo(ACTIVE_SID, { model: 'm2', provider: 'p1', running: true }) - expect(invalidate).toHaveBeenCalledWith({ queryKey: ['model-options', ACTIVE_SID] }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: modelOptionsQueryKey(ACTIVE_PROFILE, ACTIVE_SID) }) }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 41f7d77d136e..bf4ad80eb472 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -3,6 +3,8 @@ import { cleanup, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getGlobalModelInfo } from '@/hermes' +import { modelOptionsQueryKey } from '@/lib/model-options' +import { $activeGatewayProfile } from '@/store/profile' import { $activeSessionId, $currentModel, @@ -79,6 +81,7 @@ function Harness({ describe('useModelControls', () => { beforeEach(() => { + $activeGatewayProfile.set('default') $activeSessionId.set(null) setCurrentModel('') setCurrentModelSource('') @@ -88,6 +91,7 @@ describe('useModelControls', () => { afterEach(() => { cleanup() vi.restoreAllMocks() + $activeGatewayProfile.set('default') $activeSessionId.set(null) setCurrentModel('') setCurrentModelSource('') @@ -201,6 +205,26 @@ describe('useModelControls', () => { expect(setGlobalModel).not.toHaveBeenCalled() }) + it('updates only the active profile new-chat cache', async () => { + const queryClient = new QueryClient() + $activeGatewayProfile.set('compass') + + const { result } = renderHook(() => + useModelControls({ + queryClient, + requestGateway: vi.fn() + }) + ) + + await result.current.selectModel({ model: 'qwen3.6:35b-65k', provider: 'custom:local-ollama' }) + + expect(queryClient.getQueryData(modelOptionsQueryKey('compass'))).toMatchObject({ + model: 'qwen3.6:35b-65k', + provider: 'custom:local-ollama' + }) + expect(queryClient.getQueryData(modelOptionsQueryKey('default'))).toBeUndefined() + }) + it('seeds an empty composer model from global but never clobbers a pick', async () => { vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) @@ -232,7 +256,11 @@ describe('useModelControls', () => { vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) const queryClient = new QueryClient() - queryClient.setQueryData(['model-options', 'global'], { + $activeGatewayProfile.set('compass') + queryClient.setQueryData(modelOptionsQueryKey('default'), { + providers: [{ models: ['openrouter/owl-alpha'], name: 'OpenRouter', slug: 'openrouter' }] + }) + queryClient.setQueryData(modelOptionsQueryKey('compass'), { providers: [{ models: ['openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }] }) @@ -253,7 +281,7 @@ describe('useModelControls', () => { vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) const queryClient = new QueryClient() - queryClient.setQueryData(['model-options', 'global'], { + queryClient.setQueryData(modelOptionsQueryKey('default'), { providers: [{ models: ['openrouter/glm-4.7', 'openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }] }) @@ -346,16 +374,16 @@ describe('useModelControls', () => { }) it('targets an explicit tile sessionId without clobbering the primary model', async () => { + const queryClient = new QueryClient() + $activeGatewayProfile.set('compass') $activeSessionId.set('primary-runtime') setCurrentModel('primary/model') setCurrentProvider('openai') const requestGateway = vi.fn(async () => ({ key: 'model', value: 'tile-model' }) as never) - let controls!: Controls - - render( (controls = value)} requestGateway={requestGateway} />) + const { result } = renderHook(() => useModelControls({ queryClient, requestGateway })) await expect( - controls.selectModel({ + result.current.selectModel({ model: 'tile-model', provider: 'anthropic', sessionId: 'tile-runtime' @@ -370,5 +398,10 @@ describe('useModelControls', () => { // Primary footer untouched — the busy primary must not absorb a tile pick. expect($currentModel.get()).toBe('primary/model') expect($currentProvider.get()).toBe('openai') + expect(queryClient.getQueryData(modelOptionsQueryKey('compass', 'tile-runtime'))).toMatchObject({ + model: 'tile-model', + provider: 'anthropic' + }) + expect(queryClient.getQueryData(modelOptionsQueryKey('default', 'tile-runtime'))).toBeUndefined() }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index c833d3a3e15f..b9c27aa22a18 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -4,8 +4,9 @@ import { useCallback, useRef } from 'react' import type { ModelSelection } from '@/app/shell/model-menu-panel' import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' -import { manualPickRemoved } from '@/lib/model-options' +import { manualPickRemoved, modelOptionsQueryKey } from '@/lib/model-options' import { notifyError } from '@/store/notifications' +import { $activeGatewayProfile } from '@/store/profile' import { $activeSessionId, $currentModel, @@ -36,13 +37,19 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO // callbacks once and never re-evaluate — a captured prop would be stale // forever. The store read is always current. const updateModelOptionsCache = useCallback( - (sessionId: null | string, provider: string, model: string, includeGlobal: boolean) => { + ( + sessionId: null | string, + provider: string, + model: string, + includeGlobal: boolean, + profile = $activeGatewayProfile.get() + ) => { const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model }) - queryClient.setQueryData(['model-options', sessionId || 'global'], patch) + queryClient.setQueryData(modelOptionsQueryKey(profile, sessionId), patch) if (includeGlobal) { - queryClient.setQueryData(['model-options', 'global'], patch) + queryClient.setQueryData(modelOptionsQueryKey(profile), patch) } }, [queryClient] @@ -78,7 +85,9 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO return false } - const options = queryClient.getQueryData(['model-options', 'global']) + const options = queryClient.getQueryData( + modelOptionsQueryKey($activeGatewayProfile.get()) + ) return !manualPickRemoved(options?.providers, $currentProvider.get(), $currentModel.get()) } @@ -144,6 +153,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO : ($sessionStates.get()[liveSessionId!]?.provider ?? '') const prevSource = getCurrentModelSource() + const liveGatewayProfile = $activeGatewayProfile.get() if (touchesPrimary) { setCurrentModel(selection.model) @@ -158,7 +168,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO })) } - updateModelOptionsCache(liveSessionId, selection.provider, selection.model, touchesPrimary && !liveSessionId) + updateModelOptionsCache( + liveSessionId, + selection.provider, + selection.model, + touchesPrimary && !liveSessionId, + liveGatewayProfile + ) // No live session yet: the pick is pure UI state. session.create reads // $currentModel/$currentProvider and applies it as that session's override. @@ -173,7 +189,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO value: `${selection.model} --provider ${selection.provider} --session` }) - void queryClient.invalidateQueries({ queryKey: ['model-options', liveSessionId] }) + void queryClient.invalidateQueries({ queryKey: modelOptionsQueryKey(liveGatewayProfile, liveSessionId) }) return true } catch (err) { @@ -189,7 +205,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO })) } - updateModelOptionsCache(liveSessionId, prevProvider, prevModel, touchesPrimary && !liveSessionId) + updateModelOptionsCache( + liveSessionId, + prevProvider, + prevModel, + touchesPrimary && !liveSessionId, + liveGatewayProfile + ) notifyError(err, copy.modelSwitchFailed) return false diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 21eb9a6a7f1c..9c3c60e5cdaf 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -19,7 +19,7 @@ import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import { ChevronDown, ChevronRight } from '@/lib/icons' -import { requestModelOptions } from '@/lib/model-options' +import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { currentPickerSelection, displayModelName, @@ -59,6 +59,7 @@ export interface ModelSelection { interface ModelMenuPanelProps { gateway?: HermesGateway onSelectModel: (selection: ModelSelection) => Promise | void + profile?: string requestGateway: (method: string, params?: Record) => Promise } @@ -67,7 +68,7 @@ interface ProviderGroup { provider: ModelOptionProvider } -export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) { +export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', requestGateway }: ModelMenuPanelProps) { const { t } = useI18n() const copy = t.shell.modelMenu const closeMenu = useContext(ModelMenuCloseContext) @@ -87,7 +88,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const collapsedProviders = useStore($collapsedProviders) const modelOptions = useQuery({ - queryKey: ['model-options', activeSessionId || 'global'], + queryKey: modelOptionsQueryKey(profile, activeSessionId), // Gateway-first even with no session yet: a connected (possibly remote) // gateway owns the model catalog, including virtual providers like `moa` // that the local REST fallback can't know about (#53817). @@ -148,7 +149,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model setRefreshing(true) try { - const queryKey = ['model-options', activeSessionId || 'global'] + const queryKey = modelOptionsQueryKey(profile, activeSessionId) const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId }) diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 37de510c6537..c7e095e61ea1 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useI18n } from '@/i18n' -import { requestModelOptions } from '@/lib/model-options' +import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { currentPickerSelection } from '@/lib/model-status-label' import { normalize } from '@/lib/text' import type { ModelOptionProvider, ModelPricing } from '@/types/hermes' @@ -25,6 +25,7 @@ interface ModelPickerDialogProps { currentModel: string currentProvider: string onSelect: (selection: { provider: string; model: string }) => void + profile?: string /** * Optional class to apply to DialogContent. Use to override z-index when * stacking the picker on top of another fixed overlay (e.g. the desktop @@ -42,6 +43,7 @@ export function ModelPickerDialog({ currentModel, currentProvider, onSelect, + profile = 'default', contentClassName }: ModelPickerDialogProps) { const { t } = useI18n() @@ -54,7 +56,7 @@ export function ModelPickerDialog({ const [search, setSearch] = useState('') const modelOptions = useQuery({ - queryKey: ['model-options', sessionId || 'global'], + queryKey: modelOptionsQueryKey(profile, sessionId), queryFn: () => requestModelOptions({ gateway: gw, sessionId }), enabled: open }) diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 09c11b838bde..1cb29a0fa00a 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -7,8 +7,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Switch } from '@/components/ui/switch' import type { HermesGateway } from '@/hermes' -import { getGlobalModelOptions } from '@/hermes' import { useI18n } from '@/i18n' +import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' import { normalize } from '@/lib/text' import { @@ -26,6 +26,7 @@ interface ModelVisibilityDialogProps { onOpenChange: (open: boolean) => void onOpenProviders: () => void open: boolean + profile?: string sessionId?: string | null } @@ -34,6 +35,7 @@ export function ModelVisibilityDialog({ onOpenChange, onOpenProviders, open, + profile = 'default', sessionId }: ModelVisibilityDialogProps) { const { t } = useI18n() @@ -42,17 +44,8 @@ export function ModelVisibilityDialog({ const stored = useStore($visibleModels) const modelOptions = useQuery({ - queryKey: ['model-options', sessionId || 'global'], - queryFn: (): Promise => { - if (gw && sessionId) { - return gw.request('model.options', { - session_id: sessionId, - explicit_only: true - }) - } - - return getGlobalModelOptions() - }, + queryKey: modelOptionsQueryKey(profile, sessionId), + queryFn: (): Promise => requestModelOptions({ gateway: gw, sessionId }), enabled: open }) diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx index 780576670f47..a6499010c1a6 100644 --- a/apps/desktop/src/components/onboarding/flow.tsx +++ b/apps/desktop/src/components/onboarding/flow.tsx @@ -52,7 +52,7 @@ export function FlowPanel({ } if (flow.status === 'confirming_model') { - return + return } if (flow.status === 'error') { @@ -217,11 +217,13 @@ function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) { function ConfirmingModelPanel({ flow, leaving, - onBegin + onBegin, + profile }: { flow: Extract leaving: boolean onBegin: () => void + profile?: string }) { const { t } = useI18n() const scrambledModel = useScramble(flow.currentModel, leaving) @@ -323,6 +325,7 @@ function ConfirmingModelPanel({ setPickerOpen(false) }} open={pickerOpen} + profile={profile} /> ) diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 87cc31648474..208efcb962f9 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -49,6 +49,7 @@ export { interface DesktopOnboardingOverlayProps { enabled: boolean onCompleted?: () => void + profile: string requestGateway: OnboardingContext['requestGateway'] } @@ -177,17 +178,25 @@ function useApiKeyCatalog(): ApiKeyOption[] { // → surface-out (520ms, held back by [transition-delay:660ms]). Finalize after. const ONBOARDING_EXIT_MS = 1180 -export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) { +export function DesktopOnboardingOverlay({ + enabled, + onCompleted, + profile, + requestGateway +}: DesktopOnboardingOverlayProps) { const { t } = useI18n() const onboarding = useStore($desktopOnboarding) const boot = useStore($desktopBoot) - const ctxRef = useRef({ requestGateway, onCompleted }) - ctxRef.current = { requestGateway, onCompleted } + const ctxRef = useRef({ requestGateway, onCompleted, profile }) + ctxRef.current = { requestGateway, onCompleted, profile } const ctx = useMemo( () => ({ requestGateway: (...args) => ctxRef.current.requestGateway(...args), - onCompleted: () => ctxRef.current.onCompleted?.() + onCompleted: () => ctxRef.current.onCompleted?.(), + get profile() { + return ctxRef.current.profile + } }), [] ) diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts index 4bbc9fa08a87..c855b6d2340a 100644 --- a/apps/desktop/src/lib/model-options.test.ts +++ b/apps/desktop/src/lib/model-options.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getGlobalModelOptions } from '@/hermes' -import { manualPickRemoved, requestModelOptions } from './model-options' +import { manualPickRemoved, modelOptionsQueryKey, requestModelOptions } from './model-options' const globalOptions = { model: 'hermes-4', provider: 'nous', providers: [] } @@ -49,6 +49,18 @@ describe('requestModelOptions', () => { }) }) +describe('modelOptionsQueryKey', () => { + it('isolates new-chat catalogs by active gateway profile', () => { + expect(modelOptionsQueryKey('default')).toEqual(['model-options', 'default', 'global']) + expect(modelOptionsQueryKey('compass')).toEqual(['model-options', 'compass', 'global']) + expect(modelOptionsQueryKey('default')).not.toEqual(modelOptionsQueryKey('compass')) + }) + + it('keeps session catalogs inside the owning profile namespace', () => { + expect(modelOptionsQueryKey(' compass ', 'session-1')).toEqual(['model-options', 'compass', 'session-1']) + }) +}) + describe('manualPickRemoved', () => { const providers = [ { name: 'OpenRouter', slug: 'openrouter', models: ['owl-alpha', 'gpt-5.5'] }, diff --git a/apps/desktop/src/lib/model-options.ts b/apps/desktop/src/lib/model-options.ts index 36b9f47a4276..0173b6f2b4fa 100644 --- a/apps/desktop/src/lib/model-options.ts +++ b/apps/desktop/src/lib/model-options.ts @@ -44,6 +44,12 @@ interface ModelOptionsRequest { sessionId?: null | string } +export function modelOptionsQueryKey(profile: null | string | undefined, sessionId?: null | string) { + const profileKey = (profile ?? '').trim() || 'default' + + return ['model-options', profileKey, sessionId || 'global'] as const +} + export function requestModelOptions({ explicitOnly = true, gateway, diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index f257af1e5fe7..2472ae8910bc 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -76,6 +76,7 @@ export interface DesktopOnboardingState { export interface OnboardingContext { onCompleted?: () => void + profile?: string requestGateway: (method: string, params?: Record) => Promise } diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ca189607c5df..264089643229 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7,7 +7,7 @@ import time import types from datetime import datetime from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -8156,6 +8156,132 @@ def test_model_options_hides_unconfigured_providers_by_default(monkeypatch): assert calls[-1]["include_unconfigured"] is True +def test_model_options_preserves_canonical_custom_row_after_agent_init(monkeypatch): + from hermes_cli.inventory import ConfigContext + + class _Agent: + provider = "custom" + model = "qwen3.6:35b-65k" + base_url = "http://127.0.0.1:11434/v1" + + server._sessions["custom-session"] = _session(agent=_Agent()) + monkeypatch.setattr(server, "_resolve_model", lambda: "") + monkeypatch.setattr( + "hermes_cli.inventory.load_picker_context", + lambda: ConfigContext( + current_provider="custom:local-ollama", + current_model="qwen3.6:35b-65k", + current_base_url="http://127.0.0.1:11434/v1", + user_providers={}, + custom_providers=[], + ), + ) + canonical = Mock(return_value="custom:local-ollama") + monkeypatch.setattr( + "hermes_cli.runtime_provider.canonical_custom_identity", + canonical, + ) + monkeypatch.setattr( + "hermes_cli.model_switch.list_authenticated_providers", + lambda **_kwargs: [ + { + "slug": "custom:local-ollama", + "name": "Local Ollama", + "is_current": True, + "is_user_defined": True, + "models": ["qwen3.6:35b-65k"], + "total_models": 1, + }, + { + "slug": "anthropic", + "name": "Anthropic", + "is_current": False, + "is_user_defined": False, + "models": ["claude-sonnet-4.6"], + "total_models": 1, + }, + ], + ) + monkeypatch.setattr( + "hermes_cli.auth.is_provider_explicitly_configured", + lambda _slug: False, + ) + monkeypatch.setattr("hermes_cli.inventory._apply_pricing", lambda *_args, **_kwargs: None) + monkeypatch.setattr("hermes_cli.inventory._apply_capabilities", lambda *_args, **_kwargs: None) + + resp = server._methods["model.options"]( + 102, + {"session_id": "custom-session", "explicit_only": True}, + ) + + assert "result" in resp, resp + assert resp["result"]["provider"] == "custom:local-ollama" + assert [row["slug"] for row in resp["result"]["providers"]] == [ + "custom:local-ollama" + ] + canonical.assert_called_once_with( + base_url="http://127.0.0.1:11434/v1", + config_provider="custom:local-ollama", + ) + + +def test_model_save_key_uses_credential_lifecycle_and_picker_context(monkeypatch): + env_var = "TEST_PROVIDER_API_KEY" + agent = object() + picker_ctx = object() + provider = { + "slug": "test-provider", + "name": "Test Provider", + "models": ["test-model"], + "total_models": 1, + } + server._sessions["save-key-session"] = _session(agent=agent) + monkeypatch.setattr( + "hermes_cli.auth.PROVIDER_REGISTRY", + { + "test-provider": types.SimpleNamespace( + name="Test Provider", + auth_type="api_key", + api_key_env_vars=(env_var,), + ) + }, + ) + monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False) + save_credential = Mock() + monkeypatch.setattr( + "hermes_cli.credential_lifecycle.save_provider_env_credential", + save_credential, + ) + picker_context = Mock(return_value=picker_ctx) + monkeypatch.setattr(server, "_model_picker_context", picker_context) + build_payload = Mock(return_value={"providers": [provider]}) + monkeypatch.setattr( + "hermes_cli.inventory.build_models_payload", + build_payload, + ) + monkeypatch.setenv(env_var, "previous-value") + fake_key = "replacement-" + "value" + + resp = server._methods["model.save_key"]( + 103, + { + "slug": "test-provider", + "api_key": fake_key, + "session_id": "save-key-session", + }, + ) + + assert "result" in resp, resp + assert resp["result"]["provider"] == {**provider, "authenticated": True} + save_credential.assert_called_once_with(env_var, fake_key) + picker_context.assert_called_once_with(agent) + build_payload.assert_called_once_with( + picker_ctx, + picker_hints=True, + max_models=50, + ) + + # --------------------------------------------------------------------------- # prompt.submit — auto-title # --------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 96e9484767d6..9902c4cee00b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -14542,10 +14542,42 @@ def _(rid, params: dict) -> dict: return _err(rid, 5020, str(e)) +def _model_picker_context(agent): + """Layer live session state onto config without losing custom identity.""" + from hermes_cli.inventory import load_picker_context + + ctx = load_picker_context() + provider = getattr(agent, "provider", "") if agent else "" + base_url = getattr(agent, "base_url", "") if agent else "" + if str(provider or "").strip().lower() == "custom": + try: + from hermes_cli.runtime_provider import canonical_custom_identity + + provider = ( + canonical_custom_identity( + base_url=base_url or None, + config_provider=ctx.current_provider, + ) + or provider + ) + except Exception: + logger.debug( + "custom provider identity recovery failed (model picker)", + exc_info=True, + ) + + return ctx.with_overrides( + current_provider=provider, + current_model=(getattr(agent, "model", "") if agent else "") + or _resolve_model(), + current_base_url=base_url, + ) + + @method("model.options") def _(rid, params: dict) -> dict: try: - from hermes_cli.inventory import build_models_payload, load_picker_context + from hermes_cli.inventory import build_models_payload session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None @@ -14553,13 +14585,7 @@ def _(rid, params: dict) -> dict: # is spawned, IT owns the live provider/model/base_url. Empty # agent attributes must NOT clobber disk config (with_overrides # is truthy-only). - ctx = load_picker_context().with_overrides( - current_provider=getattr(agent, "provider", "") if agent else "", - current_model=( - (getattr(agent, "model", "") if agent else "") or _resolve_model() - ), - current_base_url=getattr(agent, "base_url", "") if agent else "", - ) + ctx = _model_picker_context(agent) # picker_hints + canonical_order produce the TUI/desktop picker shape: # `authenticated`/`auth_type`/`key_env`/`warning` per row, in # CANONICAL_PROVIDERS declaration order. Desktop pickers default to the @@ -14599,7 +14625,7 @@ def _(rid, params: dict) -> dict: try: from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.config import is_managed - from hermes_cli.inventory import build_models_payload, load_picker_context + from hermes_cli.inventory import build_models_payload slug = (params.get("slug") or "").strip() api_key = (params.get("api_key") or "").strip() @@ -14640,13 +14666,7 @@ def _(rid, params: dict) -> dict: # carries `authenticated` for the TUI frontend. session = _sessions.get(params.get("session_id", "")) agent = session.get("agent") if session else None - ctx = load_picker_context().with_overrides( - current_provider=getattr(agent, "provider", "") if agent else "", - current_model=( - (getattr(agent, "model", "") if agent else "") or _resolve_model() - ), - current_base_url=getattr(agent, "base_url", "") if agent else "", - ) + ctx = _model_picker_context(agent) payload = build_models_payload( ctx, picker_hints=True, max_models=50, )