fix(desktop): scope model options by profile (#62795)

Co-authored-by: embwl0x <embwl0x@users.noreply.github.com>
This commit is contained in:
embw_l0x 2026-07-22 09:40:50 -04:00 committed by GitHub
parent 8967e73e67
commit 9fed768b56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 330 additions and 82 deletions

View file

@ -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<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
explicit_only: true
})
},
queryKey: modelOptionsQueryKey(activeGatewayProfile, activeSessionId),
queryFn: () => requestModelOptions({ gateway: gateway || undefined, sessionId: activeSessionId }),
enabled: gatewayOpen
})

View file

@ -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({
<ModelMenuPanel
gateway={gatewayRef.current || undefined}
onSelectModel={selectModel}
profile={activeGatewayProfile}
requestGateway={requestGateway}
/>
) : null,
[gatewayOpen, gatewayRef, requestGateway, selectModel]
[activeGatewayProfile, gatewayOpen, gatewayRef, requestGateway, selectModel]
)
return (

View file

@ -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({
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={actions.selectModel}
profile={activeGatewayProfile}
requestGateway={actions.requestGateway}
/>
) : null,
[actions, gateway, gatewayState]
[actions, activeGatewayProfile, gateway, gatewayState]
)
const chatView = (

View file

@ -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}
/>
)}
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<ModelPickerOverlay
gateway={gatewayRef.current || undefined}
onSelect={selectModel}
profile={activeGatewayProfile}
/>
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<ModelVisibilityOverlay
gateway={gatewayRef.current || undefined}
onOpenProviders={openProviderSettings}
profile={activeGatewayProfile}
/>
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />

View file

@ -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}
/>
)

View file

@ -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}
/>
)

View file

@ -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<string | null>
compactedTurnRef: MutableRefObject<Set<string>>
lastCwdInfoSessionRef: MutableRefObject<string | null>
@ -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,

View file

@ -32,6 +32,7 @@ import { useGatewayEventHandler } from './gateway-event'
import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils'
interface MessageStreamOptions {
activeGatewayProfile?: string
activeSessionIdRef: MutableRefObject<string | null>
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,

View file

@ -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<typeof vi.fn<() => Promise<void>>>
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
@ -26,6 +28,7 @@ function Harness() {
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
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) })
})
})

View file

@ -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(<Harness onReady={value => (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()
})
})

View file

@ -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<ModelOptionsResponse>(['model-options', sessionId || 'global'], patch)
queryClient.setQueryData<ModelOptionsResponse>(modelOptionsQueryKey(profile, sessionId), patch)
if (includeGlobal) {
queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)
queryClient.setQueryData<ModelOptionsResponse>(modelOptionsQueryKey(profile), patch)
}
},
[queryClient]
@ -78,7 +85,9 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
return false
}
const options = queryClient.getQueryData<ModelOptionsResponse>(['model-options', 'global'])
const options = queryClient.getQueryData<ModelOptionsResponse>(
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

View file

@ -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<boolean> | void
profile?: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
@ -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 })

View file

@ -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
})

View file

@ -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<ModelOptionsResponse> => {
if (gw && sessionId) {
return gw.request<ModelOptionsResponse>('model.options', {
session_id: sessionId,
explicit_only: true
})
}
return getGlobalModelOptions()
},
queryKey: modelOptionsQueryKey(profile, sessionId),
queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway: gw, sessionId }),
enabled: open
})

View file

@ -52,7 +52,7 @@ export function FlowPanel({
}
if (flow.status === 'confirming_model') {
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} profile={ctx.profile} />
}
if (flow.status === 'error') {
@ -217,11 +217,13 @@ function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
function ConfirmingModelPanel({
flow,
leaving,
onBegin
onBegin,
profile
}: {
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
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}
/>
</div>
)

View file

@ -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<OnboardingContext>({ requestGateway, onCompleted })
ctxRef.current = { requestGateway, onCompleted }
const ctxRef = useRef<OnboardingContext>({ requestGateway, onCompleted, profile })
ctxRef.current = { requestGateway, onCompleted, profile }
const ctx = useMemo<OnboardingContext>(
() => ({
requestGateway: (...args) => ctxRef.current.requestGateway(...args),
onCompleted: () => ctxRef.current.onCompleted?.()
onCompleted: () => ctxRef.current.onCompleted?.(),
get profile() {
return ctxRef.current.profile
}
}),
[]
)

View file

@ -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'] },

View file

@ -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,

View file

@ -76,6 +76,7 @@ export interface DesktopOnboardingState {
export interface OnboardingContext {
onCompleted?: () => void
profile?: string
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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,
)