Merge pull request #37738 from NousResearch/bb/statusbar-model-menu
feat(desktop): inline model picker in the status bar
This commit is contained in:
@ -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' ? (
|
||||
<ModelMenuPanel
|
||||
gateway={gatewayRef.current || undefined}
|
||||
onSelectModel={selectModel}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
) : 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}
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
31
apps/desktop/src/app/model-visibility-overlay.tsx
Normal file
31
apps/desktop/src/app/model-visibility-overlay.tsx
Normal file
@ -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 (
|
||||
<ModelVisibilityDialog
|
||||
gw={gateway}
|
||||
onOpenChange={setModelVisibilityOpen}
|
||||
onOpenProviders={onOpenProviders}
|
||||
open={open}
|
||||
sessionId={activeSessionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import { $currentModel, $currentProvider, setCurrentModel, setCurrentProvider } from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
@ -48,38 +48,53 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Returns whether the switch succeeded so callers can await it before
|
||||
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
|
||||
// on the right active model — bail rather than write to the previous one).
|
||||
const selectModel = useCallback(
|
||||
(selection: ModelSelection) => {
|
||||
async (selection: ModelSelection): Promise<boolean> => {
|
||||
const includeGlobal = selection.persistGlobal || !activeSessionId
|
||||
// Snapshot for rollback: the switch is applied optimistically, so a
|
||||
// failure must restore the prior model/provider (store + query cache)
|
||||
// rather than leave the UI showing a model the backend never selected.
|
||||
const prevModel = $currentModel.get()
|
||||
const prevProvider = $currentProvider.get()
|
||||
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
|
||||
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
|
||||
|
||||
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 true
|
||||
}
|
||||
})()
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
setCurrentModel(prevModel)
|
||||
setCurrentProvider(prevProvider)
|
||||
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
||||
notifyError(err, 'Model switch failed')
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
@ -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: <Cpu className="size-3" />,
|
||||
id: 'model-summary',
|
||||
label: currentModel || 'No model selected',
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider ? `Switch model · ${currentProvider}: ${currentModel || ''}` : 'Open model picker',
|
||||
variant: 'action'
|
||||
label: (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, {
|
||||
fastMode: currentFastMode,
|
||||
reasoningEffort: currentReasoningEffort
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</span>
|
||||
),
|
||||
...(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(
|
||||
|
||||
248
apps/desktop/src/app/shell/model-edit-submenu.tsx
Normal file
248
apps/desktop/src/app/shell/model-edit-submenu.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
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 }
|
||||
}
|
||||
|
||||
// Fast isn't natively offered here, but if the session still has the speed
|
||||
// param on (carried over from a previous model), expose the toggle so it can
|
||||
// be turned off rather than stranded.
|
||||
if (currentFastMode) {
|
||||
return { kind: 'param', on: true }
|
||||
}
|
||||
|
||||
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 (resolves false on failure). Awaited before applying
|
||||
* edits when not active so a failed switch doesn't write to the old model. */
|
||||
onActivate: () => Promise<boolean> | void
|
||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||
onSelectModel: (model: string) => Promise<boolean> | void
|
||||
/** Whether this model supports reasoning effort. */
|
||||
reasoning: boolean
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
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. Returns false if the
|
||||
// switch failed, so callers skip applying to the wrong (previous) model.
|
||||
const ensureActive = async (): Promise<boolean> => {
|
||||
if (isActive) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (await onActivate()) !== false
|
||||
}
|
||||
|
||||
const patchReasoning = async (next: string, rollback: string) => {
|
||||
setCurrentReasoningEffort(next)
|
||||
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentFastMode(!enabled)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenuSubContent className="w-52 p-0" sideOffset={4}>
|
||||
{!hasFast && !reasoning ? (
|
||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">No options for this model</div>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Thinking
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto cursor-pointer"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
Fast
|
||||
<Switch checked={fastOn} className="ml-auto cursor-pointer" onCheckedChange={toggleFast} />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{reasoning ? (
|
||||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Effort</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
||||
value={effort}
|
||||
>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
key={option.value}
|
||||
onSelect={event => event.preventDefault()}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
289
apps/desktop/src/app/shell/model-menu-panel.tsx
Normal file
289
apps/desktop/src/app/shell/model-menu-panel.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
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<boolean> | void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
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<ModelOptionsResponse> => {
|
||||
if (gateway && activeSessionId) {
|
||||
return gateway.request<ModelOptionsResponse>('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 (
|
||||
<>
|
||||
<DropdownMenuSearch
|
||||
aria-label="Search models"
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search models"
|
||||
value={search}
|
||||
/>
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
{loading ? (
|
||||
<DropdownMenuGroup className="py-1">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
disabled
|
||||
key={index}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : error ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
{error}
|
||||
</DropdownMenuItem>
|
||||
) : groups.length === 0 ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
No models found
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto py-0.5">
|
||||
{groups.map(group => (
|
||||
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
|
||||
{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]
|
||||
|
||||
// Single source of truth for the active row's fast state — keeps
|
||||
// the row label in lock-step with the submenu's Fast toggle and
|
||||
// handles the standalone `-fast` id case.
|
||||
const fastControl = resolveFastControl(
|
||||
activeId ?? family.id,
|
||||
group.provider.models ?? [],
|
||||
caps?.fast ?? false,
|
||||
currentFastMode
|
||||
)
|
||||
|
||||
// Grayed text: active row shows live state (Fast + effort);
|
||||
// others show a fast-capability hint.
|
||||
const meta = isCurrent
|
||||
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: caps?.fast || family.fastId
|
||||
? 'Fast'
|
||||
: ''
|
||||
|
||||
// Every row is a hover-Edit submenu trigger. Activating it
|
||||
// (pointer or keyboard) switches to the family's base model;
|
||||
// the Fast toggle inside swaps to the -fast sibling (or flips
|
||||
// the speed param). The sub-trigger has no `onSelect`, so wire
|
||||
// both click and Enter/Space for keyboard parity.
|
||||
const activate = () => {
|
||||
if (!isCurrent) {
|
||||
void switchTo(family.id, group.provider.slug)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
||||
<DropdownMenuSubTrigger
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer')}
|
||||
hideChevron
|
||||
onClick={activate}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
activate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
|
||||
</span>
|
||||
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
onActivate={() => switchTo(family.id, group.provider.slug)}
|
||||
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
||||
reasoning={caps?.reasoning ?? true}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'cursor-pointer text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
Edit Models…
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<string> | 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<string>
|
||||
|
||||
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
|
||||
}
|
||||
@ -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:
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
align={item.menuAlign ?? 'start'}
|
||||
className={cn('w-56', item.menuContent && 'p-0', item.menuClassName)}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
|
||||
148
apps/desktop/src/components/model-visibility-dialog.tsx
Normal file
148
apps/desktop/src/components/model-visibility-dialog.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
modelVisibilityKey,
|
||||
setVisibleModels
|
||||
} from '@/store/model-visibility'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelVisibilityDialogProps {
|
||||
gw?: HermesGateway
|
||||
onOpenChange: (open: boolean) => 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<ModelOptionsResponse> => {
|
||||
if (gw && sessionId) {
|
||||
return gw.request<ModelOptionsResponse>('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 (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-xs gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="px-3 pb-1 pt-3">
|
||||
<DialogTitle className="text-[0.8125rem]">Models</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-3 py-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
className="h-5 w-full bg-transparent text-xs text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
|
||||
onChange={event => setSearch(event.target.value)}
|
||||
placeholder="Search models"
|
||||
type="text"
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? 'Loading…' : 'No authenticated providers.'}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
|
||||
matches(provider, family.id)
|
||||
)
|
||||
|
||||
if (models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-0.5" key={provider.slug}>
|
||||
<div className="px-3 pb-0.5 pt-1 text-[0.625rem] font-medium uppercase tracking-wide text-(--ui-text-tertiary)">
|
||||
{provider.name}
|
||||
</div>
|
||||
{models.map(family => {
|
||||
const { name, tag } = modelDisplayParts(family.id)
|
||||
const key = modelVisibilityKey(provider.slug, family.id)
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1 text-xs hover:bg-accent/50"
|
||||
key={key}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{name}
|
||||
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible.has(key)}
|
||||
className="cursor-pointer"
|
||||
onCheckedChange={() => toggle(provider, family.id)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<button
|
||||
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
onOpenProviders()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add provider…
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -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<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
@ -16,8 +27,49 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<React.ComponentProps<'input'>, 'type'> & {
|
||||
onValueChange?: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="px-2.5 py-1.5" data-slot="dropdown-menu-search">
|
||||
<input
|
||||
autoFocus
|
||||
className={cn(
|
||||
'h-4 w-full bg-transparent text-xs leading-none text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none',
|
||||
className
|
||||
)}
|
||||
onChange={event => {
|
||||
onChange?.(event)
|
||||
onValueChange?.(event.target.value)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (!DROPDOWN_NAV_KEYS.has(event.key)) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
onKeyDown?.(event)
|
||||
}}
|
||||
type="text"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
@ -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({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
checked={checked}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Codicon name="check" size="1rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
@ -104,18 +157,16 @@ function DropdownMenuRadioItem({
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-md py-1 pr-2 pl-7 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs outline-hidden select-none focus:bg-(--ui-control-active-background) focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Codicon name="primitive-dot" size="0.5rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator className="ml-auto flex items-center pl-2 text-foreground">
|
||||
<Codicon name="check" size="0.75rem" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
@ -164,10 +215,13 @@ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuP
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
hideChevron = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
/** Suppress the trailing caret — for triggers that own their right-side affordance. */
|
||||
hideChevron?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@ -180,29 +234,40 @@ function DropdownMenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />
|
||||
{!hideChevron && <Codicon className="ml-auto text-(--ui-text-tertiary)" name="chevron-right" size="1rem" />}
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
// SubContent inherits the same portal/scrollbar issue as Content (Radix
|
||||
// renders it under document.body), so apply `dt-portal-scrollbar`. Use
|
||||
// a fixed `max-h-80` rather than the Radix available-height variable:
|
||||
// that variable is only published on Content, NOT SubContent — using
|
||||
// it here collapses the submenu to 0px height.
|
||||
className={cn(
|
||||
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) 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
|
||||
)}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
// 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.
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
// `dt-portal-scrollbar` reproduces the themed scrollbar for portaled
|
||||
// overlays (rendered under document.body). Use a fixed `max-h-80`
|
||||
// rather than the Radix available-height variable: that variable is
|
||||
// only published on Content, NOT SubContent — using it here collapses
|
||||
// the submenu to 0px height.
|
||||
className={cn(
|
||||
'dt-portal-scrollbar z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) 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
|
||||
)}
|
||||
// Flip to the other side / shift vertically when near a viewport edge
|
||||
// (e.g. the status bar menu opening from the bottom-right corner) so
|
||||
// the submenu never gets clipped.
|
||||
collisionPadding={collisionPadding}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -216,6 +281,7 @@ export {
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSearch,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
|
||||
31
apps/desktop/src/lib/model-status-label.test.ts
Normal file
31
apps/desktop/src/lib/model-status-label.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
103
apps/desktop/src/lib/model-status-label.ts
Normal file
103
apps/desktop/src/lib/model-status-label.ts
Normal file
@ -0,0 +1,103 @@
|
||||
const REASONING_LABELS: Record<string, string> = {
|
||||
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<readonly [RegExp, string]> = [
|
||||
[/-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(' ')}`
|
||||
}
|
||||
108
apps/desktop/src/store/model-visibility.ts
Normal file
108
apps/desktop/src/store/model-visibility.ts
Normal file
@ -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<string>()
|
||||
|
||||
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<string> | 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<Set<string> | null>(loadVisible())
|
||||
|
||||
export const $modelVisibilityOpen = atom(false)
|
||||
|
||||
export function setVisibleModels(keys: Set<string>): 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<string> {
|
||||
const keys = new Set<string>()
|
||||
|
||||
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<string> | null,
|
||||
providers: readonly ModelOptionProvider[]
|
||||
): Set<string> {
|
||||
return stored ?? defaultVisibleKeys(providers)
|
||||
}
|
||||
@ -216,6 +216,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<string, ModelCapabilities>
|
||||
}
|
||||
|
||||
export interface ModelCapabilities {
|
||||
fast: boolean
|
||||
reasoning: boolean
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
|
||||
@ -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 ──────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -1665,7 +1665,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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user