diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 9e70acc31..8aedb6b46 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -1,6 +1,6 @@ import { useStore } from '@nanostores/react' import { useQueryClient } from '@tanstack/react-query' -import { lazy, Suspense, useCallback, useEffect, useRef } from 'react' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from 'react' import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' import { BootFailureOverlay } from '@/components/boot-failure-overlay' @@ -59,10 +59,11 @@ import { ChatSidebar } from './chat/sidebar' import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { ModelPickerOverlay } from './model-picker-overlay' +import { ModelVisibilityOverlay } from './model-visibility-overlay' import { RightSidebarPane } from './right-sidebar' import { $terminalTakeover } from './right-sidebar/store' import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' -import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes' +import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' import { useContextSuggestions } from './session/hooks/use-context-suggestions' import { useCwdActions } from './session/hooks/use-cwd-actions' import { useHermesConfig } from './session/hooks/use-hermes-config' @@ -77,6 +78,7 @@ import { AppShell } from './shell/app-shell' import { useOverlayRouting } from './shell/hooks/use-overlay-routing' import { useStatusSnapshot } from './shell/hooks/use-status-snapshot' import { useStatusbarItems } from './shell/hooks/use-statusbar-items' +import { ModelMenuPanel } from './shell/model-menu-panel' import type { StatusbarItem } from './shell/statusbar-controls' import type { TitlebarTool } from './shell/titlebar-controls' import { useGroupRegistry } from './shell/use-group-registry' @@ -274,6 +276,22 @@ export function DesktopController() { requestGateway }) + const openProviderSettings = useCallback(() => { + navigate(`${SETTINGS_ROUTE}?tab=keys`) + }, [navigate]) + + const modelMenuContent = useMemo( + () => + gatewayState === 'open' ? ( + + ) : null, + [gatewayRef, gatewayState, requestGateway, selectModel] + ) + useContextSuggestions({ activeSessionId, activeSessionIdRef, @@ -497,6 +515,7 @@ export function DesktopController() { gatewayLogLines, gatewayState, inferenceStatus, + modelMenuContent, openAgents, openCommandCenterSection, statusSnapshot, @@ -531,6 +550,7 @@ export function DesktopController() { requestGateway={requestGateway} /> + diff --git a/apps/desktop/src/app/model-visibility-overlay.tsx b/apps/desktop/src/app/model-visibility-overlay.tsx new file mode 100644 index 000000000..80691a580 --- /dev/null +++ b/apps/desktop/src/app/model-visibility-overlay.tsx @@ -0,0 +1,31 @@ +import { useStore } from '@nanostores/react' + +import { ModelVisibilityDialog } from '@/components/model-visibility-dialog' +import type { HermesGateway } from '@/hermes' +import { $modelVisibilityOpen, setModelVisibilityOpen } from '@/store/model-visibility' +import { $activeSessionId, $gatewayState } from '@/store/session' + +interface ModelVisibilityOverlayProps { + gateway?: HermesGateway + onOpenProviders: () => void +} + +export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibilityOverlayProps) { + const activeSessionId = useStore($activeSessionId) + const gatewayOpen = useStore($gatewayState) === 'open' + const open = useStore($modelVisibilityOpen) + + if (!gatewayOpen) { + return null + } + + return ( + + ) +} 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 44dc54cef..6d30297db 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -48,38 +48,39 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway } }, []) + // Returns a promise so callers can await the switch before applying + // follow-up changes (e.g. editing a model's reasoning/fast must land on the + // right active model). Resolves even on failure (error is surfaced inline). const selectModel = useCallback( - (selection: ModelSelection) => { + async (selection: ModelSelection): Promise => { setCurrentModel(selection.model) setCurrentProvider(selection.provider) updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId) - void (async () => { - try { - if (activeSessionId) { - await requestGateway('slash.exec', { - session_id: activeSessionId, - command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` - }) + try { + if (activeSessionId) { + await requestGateway('slash.exec', { + session_id: activeSessionId, + command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` + }) - if (selection.persistGlobal) { - void refreshCurrentModel() - } - - void queryClient.invalidateQueries({ - queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId] - }) - - return + if (selection.persistGlobal) { + void refreshCurrentModel() } - await setGlobalModel(selection.provider, selection.model) - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) - } catch (err) { - notifyError(err, 'Model switch failed') + void queryClient.invalidateQueries({ + queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId] + }) + + return } - })() + + await setGlobalModel(selection.provider, selection.model) + void refreshCurrentModel() + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + } catch (err) { + notifyError(err, 'Model switch failed') + } }, [activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache] ) diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 66e7a19fe..7a2155d78 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,9 +1,11 @@ import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' import { useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' -import { Activity, AlertCircle, Clock, Command, Cpu, Hash, Loader2, Sparkles } from '@/lib/icons' +import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles } from '@/lib/icons' +import { formatModelStatusLabel } from '@/lib/model-status-label' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' @@ -11,8 +13,10 @@ import { $desktopActionTasks } from '@/store/activity' import { $previewServerRestartStatus } from '@/store/preview' import { $busy, + $currentFastMode, $currentModel, $currentProvider, + $currentReasoningEffort, $currentUsage, $sessionStartedAt, $turnStartedAt, @@ -34,6 +38,7 @@ interface StatusbarItemsOptions { gatewayLogLines: readonly string[] gatewayState: string inferenceStatus: RuntimeReadinessResult | null + modelMenuContent?: ReactNode openAgents: () => void openCommandCenterSection: (section: CommandCenterSection) => void statusSnapshot: StatusResponse | null @@ -48,14 +53,17 @@ export function useStatusbarItems({ gatewayLogLines, gatewayState, inferenceStatus, + modelMenuContent, openAgents, openCommandCenterSection, statusSnapshot, toggleCommandCenter }: StatusbarItemsOptions) { const busy = useStore($busy) + const currentFastMode = useStore($currentFastMode) const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) + const currentReasoningEffort = useStore($currentReasoningEffort) const currentUsage = useStore($currentUsage) const desktopActionTasks = useStore($desktopActionTasks) const previewServerRestartStatus = useStore($previewServerRestartStatus) @@ -269,17 +277,51 @@ export function useStatusbarItems({ variant: 'text' }, { - detail: currentProvider || '', - icon: , id: 'model-summary', - label: currentModel || 'No model selected', - onSelect: () => setModelPickerOpen(true), - title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker', - variant: 'action' + label: ( + + + {formatModelStatusLabel(currentModel, { + fastMode: currentFastMode, + reasoningEffort: currentReasoningEffort + })} + + + + ), + ...(modelMenuContent + ? { + menuAlign: 'end' as const, + menuClassName: 'w-64', + menuContent: modelMenuContent, + title: currentProvider + ? `Model · ${currentProvider}: ${currentModel || 'none'}` + : 'Switch model', + variant: 'menu' as const + } + : { + onSelect: () => setModelPickerOpen(true), + title: currentProvider + ? `${currentProvider} · ${currentModel || 'no model'}` + : 'Open model picker', + variant: 'action' as const + }) }, versionItem ], - [busy, contextBar, contextUsage, currentModel, currentProvider, sessionStartedAt, turnStartedAt, versionItem] + [ + busy, + contextBar, + contextUsage, + currentFastMode, + currentModel, + currentProvider, + currentReasoningEffort, + modelMenuContent, + sessionStartedAt, + turnStartedAt, + versionItem + ] ) const leftStatusbarItems = useMemo( diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx new file mode 100644 index 000000000..df3d3ffa3 --- /dev/null +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -0,0 +1,228 @@ +import { useStore } from '@nanostores/react' + +import { + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + dropdownMenuRow, + dropdownMenuSectionLabel, + DropdownMenuSeparator, + DropdownMenuSubContent +} from '@/components/ui/dropdown-menu' +import { Switch } from '@/components/ui/switch' +import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' +import { + $activeSessionId, + $currentReasoningEffort, + setCurrentFastMode, + setCurrentReasoningEffort +} from '@/store/session' + +// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned +// by the Thinking toggle, not the radio. +const EFFORT_OPTIONS = [ + { value: 'minimal', label: 'Minimal' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'Max' } +] as const + +/** How "fast" is achieved for a given model — two different mechanisms: + * - `param`: the Anthropic/OpenAI `speed=fast` request parameter. + * - `variant`: a separate `…-fast` sibling model selected via the model field. + */ +export type FastControl = + | { kind: 'none' } + | { kind: 'param'; on: boolean } + | { kind: 'variant'; baseId: string; fastId: string; on: boolean } + +/** Resolve the fast mechanism for a model: prefer the speed=fast parameter + * when the backend supports it, else fall back to a `…-fast` sibling model. */ +export function resolveFastControl( + model: string, + providerModels: readonly string[], + paramSupported: boolean, + currentFastMode: boolean +): FastControl { + if (paramSupported) { + return { kind: 'param', on: currentFastMode } + } + + if (/-fast$/i.test(model)) { + const baseId = model.replace(/-fast$/i, '') + + // Only a toggle if there's a base to switch back to; otherwise it's a + // standalone fast model with no "off" state. + return providerModels.includes(baseId) + ? { kind: 'variant', baseId, fastId: model, on: true } + : { kind: 'none' } + } + + const fastId = `${model}-fast` + + if (providerModels.includes(fastId)) { + return { kind: 'variant', baseId: model, fastId, on: false } + } + + return { kind: 'none' } +} + +interface ModelEditSubmenuProps { + /** How fast mode is offered for this model (param toggle vs. variant swap). */ + fastControl: FastControl + /** Whether this row's model is the active one. */ + isActive: boolean + /** Switch to this model. Awaited before applying edits when not active. */ + onActivate: () => Promise | void + /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ + onSelectModel: (model: string) => Promise | void + /** Whether this model supports reasoning effort. */ + reasoning: boolean + requestGateway: (method: string, params?: Record) => Promise +} + +export function ModelEditSubmenu({ + fastControl, + isActive, + onActivate, + onSelectModel, + reasoning, + requestGateway +}: ModelEditSubmenuProps) { + // Reactive session state comes straight from the stores rather than being + // drilled through the panel, so editing it re-renders only this submenu. + const activeSessionId = useStore($activeSessionId) + const currentReasoningEffort = useStore($currentReasoningEffort) + + const effort = normalizeEffort(currentReasoningEffort) + const thinkingOn = isThinkingEnabled(currentReasoningEffort) + + // Reasoning/fast are session-scoped (they apply to the active model), so + // editing a non-active model first switches to it — otherwise the change + // would land on (and be capability-checked against) the wrong model. + const ensureActive = async () => { + if (!isActive) { + await onActivate() + } + } + + const patchReasoning = async (next: string, rollback: string) => { + setCurrentReasoningEffort(next) + + try { + await ensureActive() + await requestGateway('config.set', { + key: 'reasoning', + session_id: activeSessionId ?? '', + value: next + }) + } catch (err) { + setCurrentReasoningEffort(rollback) + notifyError(err, 'Model option update failed') + } + } + + const toggleFast = (enabled: boolean) => { + if (fastControl.kind === 'variant') { + // Fast is a separate model id — swap to it (or back to the base). + void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) + + return + } + + if (fastControl.kind === 'param') { + setCurrentFastMode(enabled) + + void (async () => { + try { + await ensureActive() + await requestGateway('config.set', { + key: 'fast', + session_id: activeSessionId ?? '', + value: enabled ? 'fast' : 'normal' + }) + } catch (err) { + setCurrentFastMode(!enabled) + notifyError(err, 'Fast mode update failed') + } + })() + } + } + + const hasFast = fastControl.kind !== 'none' + const fastOn = fastControl.kind === 'none' ? false : fastControl.on + + return ( + + {!hasFast && !reasoning ? ( +
No options for this model
+ ) : ( + <> + Options + {reasoning ? ( + event.preventDefault()} + > + Thinking + void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)} + /> + + ) : null} + {hasFast ? ( + event.preventDefault()} + > + Fast + + + ) : null} + {reasoning ? ( + <> + + Effort + void patchReasoning(value, currentReasoningEffort)} + value={effort} + > + {EFFORT_OPTIONS.map(option => ( + event.preventDefault()} + value={option.value} + > + {option.label} + + ))} + + + ) : null} + + )} +
+ ) +} + +function isThinkingEnabled(effort: string): boolean { + // Empty = Hermes default (medium) = on; only an explicit "none" is off. + return (effort || 'medium').trim().toLowerCase() !== 'none' +} + +function normalizeEffort(effort: string): string { + const value = (effort || 'medium').trim().toLowerCase() + + // Thinking off → no effort selected in the radio group. + if (value === 'none') { + return '' + } + + return EFFORT_OPTIONS.some(option => option.value === value) ? value : 'medium' +} diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx new file mode 100644 index 000000000..7ee757ba7 --- /dev/null +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -0,0 +1,276 @@ +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + dropdownMenuRow, + DropdownMenuSearch, + dropdownMenuSectionLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { Skeleton } from '@/components/ui/skeleton' +import type { HermesGateway } from '@/hermes' +import { getGlobalModelOptions } from '@/hermes' +import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label' +import { cn } from '@/lib/utils' +import { + $visibleModels, + collapseModelFamilies, + DEFAULT_VISIBLE_PER_PROVIDER, + type ModelFamily, + modelVisibilityKey, + setModelVisibilityOpen +} from '@/store/model-visibility' +import { + $activeSessionId, + $currentFastMode, + $currentModel, + $currentProvider, + $currentReasoningEffort +} from '@/store/session' +import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' + +import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' + +interface ModelMenuPanelProps { + gateway?: HermesGateway + onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise | void + requestGateway: (method: string, params?: Record) => Promise +} + +interface ProviderGroup { + families: ModelFamily[] + provider: ModelOptionProvider +} + +export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) { + const [search, setSearch] = useState('') + // Reactive session state is read from the stores here (not drilled in), so + // toggling effort/fast/model re-renders this panel in place without forcing + // the parent to rebuild the menu content (which would close the dropdown). + const activeSessionId = useStore($activeSessionId) + const currentFastMode = useStore($currentFastMode) + const currentModel = useStore($currentModel) + const currentProvider = useStore($currentProvider) + const currentReasoningEffort = useStore($currentReasoningEffort) + const visibleModels = useStore($visibleModels) + + const modelOptions = useQuery({ + queryKey: ['model-options', activeSessionId || 'global'], + queryFn: (): Promise => { + if (gateway && activeSessionId) { + return gateway.request('model.options', { session_id: activeSessionId }) + } + + return getGlobalModelOptions() + } + }) + + const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') + const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') + const loading = modelOptions.isPending && !modelOptions.data + + const error = modelOptions.error + ? modelOptions.error instanceof Error + ? modelOptions.error.message + : String(modelOptions.error) + : null + + const providers = modelOptions.data?.providers + + const switchTo = (model: string, provider: string) => + onSelectModel({ model, persistGlobal: !activeSessionId, provider }) + + const groups = useMemo( + () => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels), + [providers, search, optionsModel, optionsProvider, visibleModels] + ) + + return ( + <> + + + + + {loading ? ( + + {Array.from({ length: 4 }, (_, index) => ( + event.preventDefault()} + > + + + ))} + + ) : error ? ( + + {error} + + ) : groups.length === 0 ? ( + + No models found + + ) : ( +
+ {groups.map(group => ( + + {group.provider.name} + {group.families.map(family => { + // The active id may be the base or its -fast sibling; either + // way this one family row represents both. + const activeId = + group.provider.slug === optionsProvider && + (optionsModel === family.id || optionsModel === family.fastId) + ? optionsModel + : null + + const isCurrent = activeId !== null + const name = modelDisplayParts(family.id).name + // Capabilities are looked up against the active/base id; the + // -fast variant carries the same param support as its base. + const caps = group.provider.capabilities?.[family.id] + const fastActive = optionsModel === family.fastId || (isCurrent && currentFastMode) + + // Grayed text: active row shows live state (Fast + effort); + // others show a fast-capability hint. + const meta = isCurrent + ? [fastActive ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med'] + .filter(Boolean) + .join(' ') + : caps?.fast || family.fastId + ? 'Fast' + : '' + + // Every row is a hover-Edit submenu trigger. Clicking switches + // to the family's base model; the Fast toggle inside swaps to + // the -fast sibling (or flips the speed param) as appropriate. + return ( + + { + if (!isCurrent) { + void switchTo(family.id, group.provider.slug) + } + }} + > + + {name} + {meta ? {meta} : null} + + {isCurrent ? : null} + + switchTo(family.id, group.provider.slug)} + onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)} + reasoning={caps?.reasoning ?? true} + requestGateway={requestGateway} + /> + + ) + })} + + ))} +
+ )} + + + + setModelVisibilityOpen(true)} + > + Edit Models… + + + ) +} + +// Collapsed we show the user's chosen models (or the curated default); typing +// spans every available model so anything is reachable past the cut. +const PER_PROVIDER_SEARCH = 12 + +function groupModels( + providers: ModelOptionProvider[], + search: string, + current: { model: string; provider: string }, + visible: Set | null +): ProviderGroup[] { + const q = search.trim().toLowerCase() + const groups: ProviderGroup[] = [] + + for (const provider of providers) { + const allFamilies = collapseModelFamilies(provider.models ?? []) + + if (allFamilies.length === 0) { + continue + } + + const matches = (family: ModelFamily) => + `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` + .toLowerCase() + .includes(q) + + // Which model ids to show (the active one is always added on top of this). + let shown: Set + + if (q) { + // Search spans every family, regardless of visibility. + shown = new Set(allFamilies.filter(matches).map(family => family.id)) + } else if (visible) { + // User has customized which models show — honor their selection exactly. + shown = new Set( + allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) + ) + } else { + // Default: curated top-N families per provider. + shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) + } + + // Always include the active model — but keep every row in the provider's + // stable curated order (filter `allFamilies`, never reorder), so selecting + // a model can't shuffle the list. + const activeId = + provider.slug === current.provider && current.model + ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id + : undefined + + let families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) + + if (q) { + families = families.slice(0, PER_PROVIDER_SEARCH) + } + + if (families.length > 0) { + groups.push({ families, provider }) + } + } + + // Stable, logical group order: alphabetical by provider name. (The backend + // floats the current provider first, which would reshuffle on every switch.) + groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) + + return groups +} diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx index 084614960..ced408167 100644 --- a/apps/desktop/src/app/shell/statusbar-controls.tsx +++ b/apps/desktop/src/app/shell/statusbar-controls.tsx @@ -26,6 +26,7 @@ export interface StatusbarItem { disabled?: boolean hidden?: boolean href?: string + menuAlign?: 'center' | 'end' | 'start' menuClassName?: string menuContent?: ReactNode menuItems?: readonly StatusbarMenuItem[] @@ -104,7 +105,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: void + onOpenProviders: () => void + open: boolean + sessionId?: string | null +} + +export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) { + const [search, setSearch] = useState('') + 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 }) + } + + return getGlobalModelOptions() + }, + enabled: open + }) + + const providers = useMemo( + () => (modelOptions.data?.providers ?? []).filter(provider => (provider.models ?? []).length > 0), + [modelOptions.data] + ) + + const visible = effectiveVisibleKeys(stored, providers) + + const toggle = (provider: ModelOptionProvider, model: string) => { + const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers)) + const key = modelVisibilityKey(provider.slug, model) + + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + + setVisibleModels(next) + } + + const q = search.trim().toLowerCase() + + const matches = (provider: ModelOptionProvider, model: string) => + !q || `${model} ${provider.name} ${provider.slug} ${displayModelName(model)}`.toLowerCase().includes(q) + + return ( + + + + Models + + +
+ setSearch(event.target.value)} + placeholder="Search models" + type="text" + value={search} + /> +
+ +
+ {providers.length === 0 ? ( +
+ {modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'} +
+ ) : ( + providers.map(provider => { + const models = collapseModelFamilies(provider.models ?? []).filter(family => + matches(provider, family.id) + ) + + if (models.length === 0) { + return null + } + + return ( +
+
+ {provider.name} +
+ {models.map(family => { + const { name, tag } = modelDisplayParts(family.id) + const key = modelVisibilityKey(provider.slug, family.id) + + return ( + + ) + })} +
+ ) + }) + )} +
+ +
+ +
+
+
+ ) +} diff --git a/apps/desktop/src/components/ui/dropdown-menu.tsx b/apps/desktop/src/components/ui/dropdown-menu.tsx index 04118a2b4..818843ec9 100644 --- a/apps/desktop/src/components/ui/dropdown-menu.tsx +++ b/apps/desktop/src/components/ui/dropdown-menu.tsx @@ -4,6 +4,17 @@ import * as React from 'react' import { Codicon } from '@/components/ui/codicon' import { cn } from '@/lib/utils' +// Shared class tokens for edge-to-edge menus (use with `p-0` content): rows go +// full-width, square, and compact so the highlight spans the whole surface. +// Reuse these instead of re-deriving per menu so every searchable/compact menu +// reads identically. +export const dropdownMenuRow = 'gap-2 rounded-none px-2.5 py-1 text-xs' +export const dropdownMenuSectionLabel = 'px-2.5 pt-1 pb-0.5 text-[0.625rem] font-medium uppercase tracking-wide' + +// Keys that must reach Radix's menu handler (navigation/close). Everything else +// is a filter keystroke and is stopped so the menu's typeahead doesn't hijack it. +const DROPDOWN_NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', 'Escape', 'Tab']) + function DropdownMenu({ ...props }: React.ComponentProps) { return } @@ -16,8 +27,49 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps } +/** + * Borderless filter input for a searchable dropdown. Autofocuses, keeps the + * menu's typeahead from eating keystrokes, and still lets arrow/enter/escape + * drive the list. Drop it in as the first child of a `DropdownMenuContent`. + */ +function DropdownMenuSearch({ + className, + onChange, + onKeyDown, + onValueChange, + ...props +}: Omit, 'type'> & { + onValueChange?: (value: string) => void +}) { + return ( +
+ { + onChange?.(event) + onValueChange?.(event.target.value) + }} + onKeyDown={event => { + if (!DROPDOWN_NAV_KEYS.has(event.key)) { + event.stopPropagation() + } + + onKeyDown?.(event) + }} + type="text" + {...props} + /> +
+ ) +} + function DropdownMenuContent({ className, + collisionPadding = 8, sideOffset = 4, ...props }: React.ComponentProps) { @@ -31,6 +83,9 @@ function DropdownMenuContent({ 'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95', className )} + // Keep the menu inside the viewport: Radix flips/shifts away from edges + // (avoidCollisions defaults on); the padding stops it kissing the edge. + collisionPadding={collisionPadding} data-slot="dropdown-menu-content" sideOffset={sideOffset} {...props} @@ -76,18 +131,16 @@ function DropdownMenuCheckboxItem({ - - - - - {children} + + + ) } @@ -104,18 +157,16 @@ function DropdownMenuRadioItem({ return ( - - - - - {children} + + + ) } @@ -164,10 +215,13 @@ function DropdownMenuSub({ ...props }: React.ComponentProps & { inset?: boolean + /** Suppress the trailing caret — for triggers that own their right-side affordance. */ + hideChevron?: boolean }) { return ( {children} - + {!hideChevron && } ) } function DropdownMenuSubContent({ className, + collisionPadding = 8, ...props }: React.ComponentProps) { return ( - + // Portal the submenu out of the parent Content so it escapes that Content's + // `overflow` clip. Without this, a submenu opening from a scrollable menu + // gets visually cut off at the parent's edges. Radix Popper still anchors + // it to the SubTrigger and handles collision/flip, so portaling is safe. + + + ) } @@ -216,6 +281,7 @@ export { DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSearch, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts new file mode 100644 index 000000000..6c0bac912 --- /dev/null +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label' + +describe('model-status-label', () => { + it('formats display names consistently', () => { + expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8') + expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5') + }) + + it('maps reasoning effort to compact labels', () => { + expect(reasoningEffortLabel('high')).toBe('High') + expect(reasoningEffortLabel('xhigh')).toBe('Max') + expect(reasoningEffortLabel('')).toBe('') + }) + + it('appends fast + effort session state to the status label', () => { + expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe( + 'GPT-5.5 · Fast High' + ) + }) + + it('always surfaces the effort (default medium) so the level is visible', () => { + expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med') + expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med') + }) + + it('returns just the placeholder name when there is no model', () => { + expect(formatModelStatusLabel('')).toBe('No model') + }) +}) diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts new file mode 100644 index 000000000..3a7d065cf --- /dev/null +++ b/apps/desktop/src/lib/model-status-label.ts @@ -0,0 +1,103 @@ +const REASONING_LABELS: Record = { + none: 'Off', + minimal: 'Min', + low: 'Low', + medium: 'Med', + high: 'High', + xhigh: 'Max' +} + +export function reasoningEffortLabel(effort: string): string { + const key = effort.trim().toLowerCase() + + if (!key) { + return '' + } + + return REASONING_LABELS[key] ?? effort +} + +/** Strip provider prefix and normalize for display. */ +export function modelBaseId(model: string): string { + const trimmed = model.trim() + const slash = trimmed.lastIndexOf('/') + + return slash >= 0 ? trimmed.slice(slash + 1) : trimmed +} + +// Trailing model-id variants that should render as a grayed tag beside the +// name (e.g. "Opus 4.8" + "Fast") rather than collapsing two distinct ids to +// the same display name. +const VARIANT_TAGS: ReadonlyArray = [ + [/-fast$/i, 'Fast'], + [/-thinking$/i, 'Thinking'], + [/-preview$/i, 'Preview'], + [/-latest$/i, 'Latest'] +] + +const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim() + +function prettifyBase(base: string): string { + if (/^claude-/i.test(base)) { + return titleCase(base.replace(/^claude-/i, '').replace(/-/g, ' ')) + } + + if (/^gpt-/i.test(base)) { + return base.replace(/^gpt-/i, 'GPT-') + } + + if (/^gemini-/i.test(base)) { + return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ') + } + + return titleCase(base.replace(/-/g, ' ')) +} + +/** Split a model id into a clean display name plus an optional grayed variant + * tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */ +export function modelDisplayParts(model: string): { name: string; tag: string } { + let base = modelBaseId(model) + let tag = '' + + for (const [pattern, label] of VARIANT_TAGS) { + if (pattern.test(base)) { + tag = label + base = base.replace(pattern, '') + + break + } + } + + return { name: prettifyBase(base) || model.trim() || 'No model', tag } +} + +/** Friendly one-line model name for menus and the status bar. */ +export function displayModelName(model: string): string { + return modelDisplayParts(model).name +} + +/** Status bar trigger label — model name plus the live session state (effort/fast). */ +export function formatModelStatusLabel( + model: string, + options?: { fastMode?: boolean; reasoningEffort?: string } +): string { + const name = displayModelName(model) + + if (!model.trim()) { + return name + } + + const parts: string[] = [] + + // Fast is shown when the speed=fast param is on (options.fastMode) OR the + // active model is a `…-fast` variant (fast via a separate model id). + if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) { + parts.push('Fast') + } + + // Always surface the effort (empty = Hermes default of medium) so the + // current reasoning level is visible at a glance, not just when non-default. + parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med') + + return `${name} · ${parts.join(' ')}` +} diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts new file mode 100644 index 000000000..b6d3d51dc --- /dev/null +++ b/apps/desktop/src/store/model-visibility.ts @@ -0,0 +1,108 @@ +import { atom } from 'nanostores' + +import { persistString, storedString } from '@/lib/storage' +import type { ModelOptionProvider } from '@/types/hermes' + +const STORAGE_KEY = 'hermes.desktop.visible-models' + +/** Models shown per provider in the status-bar dropdown before the user has + * customized the list. Backend `models` are already relevance-ordered. */ +export const DEFAULT_VISIBLE_PER_PROVIDER = 5 + +/** Stable key for a provider/model pair (`::` avoids colliding with model ids + * that contain a single colon, e.g. `model:tag`). */ +export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}` + +/** A model and its optional `…-fast` sibling, collapsed into one logical row. + * `id` is the canonical (base) model; `fastId` is the fast variant if present. */ +export interface ModelFamily { + fastId: string | null + id: string +} + +/** Collapse a provider's model list so a base model and its `…-fast` variant + * become a single family (one row, one toggle). Order is preserved by the + * base model's position. A `…-fast` model with no base stands on its own. */ +export function collapseModelFamilies(models: readonly string[]): ModelFamily[] { + const present = new Set(models) + const families: ModelFamily[] = [] + const consumed = new Set() + + for (const model of models) { + if (consumed.has(model)) { + continue + } + + if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) { + // Represented by its base entry — the base attaches it as `fastId`. + continue + } + + const fastId = `${model}-fast` + const hasFast = present.has(fastId) + families.push({ fastId: hasFast ? fastId : null, id: model }) + consumed.add(model) + + if (hasFast) { + consumed.add(fastId) + } + } + + return families +} + +function loadVisible(): Set | null { + const raw = storedString(STORAGE_KEY) + + if (!raw) { + return null + } + + try { + const parsed = JSON.parse(raw) + + return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null + } catch { + return null + } +} + +/** Explicit set of visible `provider::model` keys, or null when the user + * hasn't customized — in which case the curated default applies. */ +export const $visibleModels = atom | null>(loadVisible()) + +export const $modelVisibilityOpen = atom(false) + +export function setVisibleModels(keys: Set): void { + $visibleModels.set(new Set(keys)) + persistString(STORAGE_KEY, JSON.stringify([...keys])) +} + +export function setModelVisibilityOpen(open: boolean): void { + $modelVisibilityOpen.set(open) +} + +/** The default-visible key set: the curated top-N per provider. Used both as + * the dropdown fallback and to seed the Edit Models dialog. */ +export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set { + const keys = new Set() + + for (const provider of providers) { + const families = collapseModelFamilies(provider.models ?? []) + + for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) { + keys.add(modelVisibilityKey(provider.slug, family.id)) + } + } + + return keys +} + +/** Resolve which keys are currently visible: the user's explicit set when + * configured, otherwise the curated default for the given providers. */ +export function effectiveVisibleKeys( + stored: Set | null, + providers: readonly ModelOptionProvider[] +): Set { + return stored ?? defaultVisibleKeys(providers) +} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 0fbad5f25..1541aaf07 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -210,6 +210,14 @@ export interface ModelOptionProvider { free_tier?: boolean /** Nous only: paid models a free-tier user cannot select (shown disabled). */ unavailable_models?: string[] + /** Per-model option support, keyed by model id (present when the picker + * requested capabilities). Lets the UI gate fast/reasoning controls. */ + capabilities?: Record +} + +export interface ModelCapabilities { + fast: boolean + reasoning: boolean } export interface ModelOptionsResponse { diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 1e7fc8620..89e3bd702 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -115,6 +115,7 @@ def build_models_payload( picker_hints: bool = False, canonical_order: bool = False, pricing: bool = False, + capabilities: bool = False, max_models: int = 50, ) -> dict: """Build the ``{providers, model, provider}`` shape every consumer @@ -134,6 +135,10 @@ def build_models_payload( show $/Mtok columns and gate paid models on free accounts — mirroring the ``hermes model`` CLI picker. Adds network calls (pricing fetch + Nous tier check); only set for interactive pickers. + - ``capabilities``: add a per-row ``capabilities`` map + ``{model: {fast, reasoning}}`` so pickers can gate the model-options + controls (fast toggle / reasoning) to what each model actually + supports, instead of offering knobs the backend would reject. """ from hermes_cli.model_switch import list_authenticated_providers @@ -154,6 +159,8 @@ def build_models_payload( rows = _reorder_canonical(rows) if pricing: _apply_pricing(rows) + if capabilities: + _apply_capabilities(rows) return { "providers": rows, @@ -162,6 +169,44 @@ def build_models_payload( } +def _apply_capabilities(rows: list[dict]) -> None: + """Attach a ``{model: {fast, reasoning}}`` map to each provider row. + + `fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime + enforces). `reasoning` comes from the models.dev catalog when known and + defaults to True otherwise — the effort dial is broadly accepted and a + no-op on models that ignore it, whereas hiding it from a capable-but- + uncatalogued model is the worse failure. + """ + from hermes_cli.models import model_supports_fast_mode + + try: + from agent.models_dev import get_model_capabilities + except Exception: + get_model_capabilities = None # type: ignore[assignment] + + for row in rows: + slug = row.get("slug") or "" + caps: dict[str, dict[str, bool]] = {} + + for model in row.get("models") or []: + reasoning = True + if get_model_capabilities is not None and slug: + try: + meta = get_model_capabilities(slug, model) + if meta is not None: + reasoning = bool(meta.supports_reasoning) + except Exception: + reasoning = True + + caps[model] = { + "fast": bool(model_supports_fast_mode(model)), + "reasoning": reasoning, + } + + row["capabilities"] = caps + + # ─── Internal: row post-processing ────────────────────────────────────── diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f8f541c89..558eb008a 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1868,19 +1868,21 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool: def _is_anthropic_fast_model(model_id: Optional[str]) -> bool: - """Return True if the model is a Claude model eligible for Anthropic Fast Mode. + """Return True if the model accepts the Anthropic Fast Mode ``speed`` param. - Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's - docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode): - "Fast mode is currently supported on Opus 4.6 only. Sending speed: fast - with an unsupported model returns an error." Opus 4.7 explicitly rejects - the ``speed`` parameter with HTTP 400. + This gates the *speed=fast request parameter*, which Anthropic supports on + Opus 4.6 only (Opus 4.7 explicitly 400s). It is deliberately NOT a general + "is this a fast model" check: for Opus 4.8 the fast offering is a SEPARATE + model id (``…-opus-4.8-fast``) selected via the model field, not the speed + parameter — see ``agent.anthropic_adapter._supports_fast_mode`` and its + test. Keep this in lock-step with that adapter gate so the UI never shows a + Fast toggle that the runtime would silently drop. """ raw = _strip_vendor_prefix(str(model_id or "")) base = raw.split(":")[0] if not base.startswith("claude-"): return False - # Only Opus 4.6 supports fast mode at present. + # Only Opus 4.6 supports the speed=fast parameter at present. return "opus-4-6" in base or "opus-4.6" in base diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fc868227a..fa5d28a0d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1627,7 +1627,9 @@ def get_model_options(): try: from hermes_cli.inventory import build_models_payload, load_picker_context - return build_models_payload(load_picker_context(), max_models=50, pricing=True) + return build_models_payload( + load_picker_context(), max_models=50, pricing=True, capabilities=True + ) except Exception: _log.exception("GET /api/model/options failed") raise HTTPException(status_code=500, detail="Failed to list model options") diff --git a/tests/cli/test_fast_command.py b/tests/cli/test_fast_command.py index a98ae7544..7745737c4 100644 --- a/tests/cli/test_fast_command.py +++ b/tests/cli/test_fast_command.py @@ -128,11 +128,11 @@ class TestPriorityProcessingModels(unittest.TestCase): assert model_supports_fast_mode(model), f"{model} should support fast mode" def test_all_anthropic_models_supported(self): - """Per Anthropic docs, fast mode is currently Opus 4.6 only. + """The speed=fast parameter is gated to Opus 4.6. Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400. - Pre-fix this test asserted all Claude variants supported fast mode, - which mirrored the bug rather than the API contract. + (Opus 4.8's fast offering is a separate ``…-fast`` model id selected + via the model field, not this parameter — see the adapter test.) """ from hermes_cli.models import model_supports_fast_mode @@ -144,16 +144,15 @@ class TestPriorityProcessingModels(unittest.TestCase): for model in supported: assert model_supports_fast_mode(model), f"{model} should support fast mode" - # Unsupported per Anthropic API: Opus 4.7, Sonnet, Haiku + # Unsupported per Anthropic API: Opus 4.7/4.8, Sonnet, Haiku unsupported = [ - "claude-opus-4-7", + "claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8", "claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku", ] for model in unsupported: assert not model_supports_fast_mode(model), ( - f"{model} should NOT support fast mode — Anthropic restricts " - f"speed=fast to Opus 4.6" + f"{model} should NOT support the speed=fast parameter" ) def test_codex_models_excluded(self): @@ -275,10 +274,11 @@ class TestAnthropicFastMode(unittest.TestCase): assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True def test_anthropic_non_opus46_models_excluded(self): - """Anthropic restricts fast mode to Opus 4.6 — others must be excluded. + """The speed=fast parameter is gated to Opus 4.6 — others excluded. Per https://platform.claude.com/docs/en/build-with-claude/fast-mode, sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400. + Opus 4.8 uses a separate ``…-fast`` model id, not this parameter. """ from hermes_cli.models import model_supports_fast_mode @@ -286,6 +286,7 @@ class TestAnthropicFastMode(unittest.TestCase): assert model_supports_fast_mode("claude-sonnet-4.6") is False assert model_supports_fast_mode("claude-haiku-4-5") is False assert model_supports_fast_mode("claude-opus-4-7") is False + assert model_supports_fast_mode("claude-opus-4-8") is False assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False @@ -314,13 +315,15 @@ class TestAnthropicFastMode(unittest.TestCase): assert result == {"speed": "fast"} def test_resolve_overrides_returns_none_for_unsupported_claude(self): - """Opus 4.7 and other Claude models don't support fast mode (API 400s). + """Opus 4.7/4.8 and other Claude models don't take the speed param. - Per Anthropic docs, fast mode is currently Opus 4.6 only. + The speed=fast parameter is Opus 4.6 only (Opus 4.8 uses a separate + ``…-fast`` model id instead). """ from hermes_cli.models import resolve_fast_mode_overrides assert resolve_fast_mode_overrides("claude-opus-4-7") is None + assert resolve_fast_mode_overrides("claude-opus-4-8") is None assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None assert resolve_fast_mode_overrides("claude-haiku-4-5") is None @@ -332,7 +335,7 @@ class TestAnthropicFastMode(unittest.TestCase): assert result == {"service_tier": "priority"} def test_is_anthropic_fast_model(self): - """Fast mode is currently Opus 4.6 only — other Claude variants must be excluded.""" + """The speed=fast parameter is Opus 4.6 only — other Claude excluded.""" from hermes_cli.models import _is_anthropic_fast_model # Supported: Opus 4.6 in any form @@ -341,8 +344,9 @@ class TestAnthropicFastMode(unittest.TestCase): assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True - # Unsupported per Anthropic API contract — would 400 if we sent speed=fast + # Unsupported — would 400 (4.7) or uses a separate model id (4.8) assert _is_anthropic_fast_model("claude-opus-4-7") is False + assert _is_anthropic_fast_model("claude-opus-4-8") is False assert _is_anthropic_fast_model("claude-sonnet-4-6") is False assert _is_anthropic_fast_model("claude-haiku-4-5") is False @@ -368,7 +372,7 @@ class TestAnthropicFastMode(unittest.TestCase): assert cli_mod.HermesCLI._fast_command_available(stub) is False def test_fast_command_hidden_for_anthropic_opus_47(self): - """Opus 4.7 doesn't support fast mode — /fast must be hidden.""" + """Opus 4.7 doesn't take the speed=fast parameter — /fast must hide.""" cli_mod = _import_cli() stub = SimpleNamespace( provider="anthropic", requested_provider="anthropic", diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7ef2a4cd1..a70dd3efe 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6659,6 +6659,7 @@ def _(rid, params: dict) -> dict: picker_hints=True, canonical_order=True, pricing=True, + capabilities=True, max_models=50, ) return _ok(rid, payload)