diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index a0b1afdc958..afe267b5fda 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn() const setModelAssignment = vi.fn() const getRecommendedDefaultModel = vi.fn() const setEnvVar = vi.fn() +const getHermesConfigRecord = vi.fn() +const saveHermesConfig = vi.fn() const startManualProviderOAuth = vi.fn() vi.mock('@/hermes', () => ({ @@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({ getAuxiliaryModels: () => getAuxiliaryModels(), setModelAssignment: (body: unknown) => setModelAssignment(body), getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), - setEnvVar: (key: string, value: string) => setEnvVar(key, value) + setEnvVar: (key: string, value: string) => setEnvVar(key, value), + getHermesConfigRecord: () => getHermesConfigRecord(), + saveHermesConfig: (config: unknown) => saveHermesConfig(config) })) vi.mock('@/store/onboarding', () => ({ @@ -35,7 +39,13 @@ beforeEach(() => { getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' }) getGlobalModelOptions.mockResolvedValue({ providers: [ - { name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true }, + { + name: 'Nous', + slug: 'nous', + models: ['hermes-4', 'hermes-4-mini'], + authenticated: true, + capabilities: { 'hermes-4': { reasoning: true, fast: true } } + }, // An unconfigured api_key provider — surfaced by the full-universe payload. { name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' } ] @@ -47,6 +57,8 @@ beforeEach(() => { setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null }) setEnvVar.mockResolvedValue({ ok: true }) + getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } }) + saveHermesConfig.mockResolvedValue({ ok: true }) }) afterEach(() => { @@ -100,6 +112,31 @@ describe('ModelSettings', () => { await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123')) }) + it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => { + await renderModelSettings() + await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled()) + + const fastSwitch = await screen.findByRole('switch') + fireEvent.click(fastSwitch) + + await waitFor(() => + expect(saveHermesConfig).toHaveBeenCalledWith( + expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) }) + ) + ) + }) + + it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => { + getGlobalModelOptions.mockResolvedValueOnce({ + providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }] + }) + + await renderModelSettings() + await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled()) + + expect(screen.queryByRole('switch')).toBeNull() + }) + it('renders the auxiliary task rows', async () => { await renderModelSettings() diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index c55fa6b4773..e88938def06 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, + getHermesConfigRecord, getRecommendedDefaultModel, + saveHermesConfig, setEnvVar, setModelAssignment } from '@/hermes' @@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } import { useI18n } from '@/i18n' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding' +import type { HermesConfigRecord } from '@/types/hermes' import { CONTROL_TEXT } from './constants' +import { getNested, setNested } from './helpers' import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' +// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off. +// Empty config = Hermes default (medium), shown as Medium. +const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const + +// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is +// normal (mirrors tui_gateway _load_service_tier). +const isFastTier = (tier: unknown): boolean => + ['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase()) + +// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1). +const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal' + // A provider row is "ready" to pick a model from when it reports models. The // backend now surfaces the full `hermes model` universe (every canonical // provider), so unconfigured providers come back with `authenticated:false` @@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const [selectedProvider, setSelectedProvider] = useState('') const [selectedModel, setSelectedModel] = useState('') const [auxiliary, setAuxiliary] = useState(null) + // Full profile config, kept so the reasoning/speed defaults round-trip + // (read agent.* → write back the whole record) like the generic config page. + const [config, setConfig] = useState(null) const [applying, setApplying] = useState(false) const [editingAuxTask, setEditingAuxTask] = useState(null) const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) @@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setError('') try { - const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ + const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([ getGlobalModelInfo(), getGlobalModelOptions(), - getAuxiliaryModels() + getAuxiliaryModels(), + getHermesConfigRecord() ]) setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) @@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setSelectedProvider(prev => prev || modelInfo.provider) setSelectedModel(prev => prev || modelInfo.model) setAuxiliary(auxiliaryModels) + setConfig(cfg) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { .map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model })) }, [auxiliary, mainModel]) + // Capabilities of the APPLIED main model — gates the profile-default + // reasoning/speed controls the same way the composer picker gates per-model + // edits (reasoning defaults on, fast defaults off when unreported). + const mainCaps = useMemo(() => { + const row = providers.find(provider => provider.slug === mainModel?.provider) + + return mainModel ? row?.capabilities?.[mainModel.model] : undefined + }, [providers, mainModel]) + + const reasoningSupported = mainCaps?.reasoning ?? true + const fastSupported = mainCaps?.fast ?? false + const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium' + const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier')) + + // Persist a single agent.* default by round-tripping the whole config record + // (PUT /api/config replaces it) — optimistic, with rollback on failure. + const writeAgentDefault = useCallback( + async (key: string, value: string) => { + if (!config) { + return + } + + const prev = config + const next = setNested(config, key, value) + setConfig(next) + + try { + await saveHermesConfig(next) + } catch (err) { + setConfig(prev) + notifyError(err, m.defaultsFailed) + } + }, + [config, m.defaultsFailed] + ) + // Paste an API key for the selected `api_key` provider, persist it, then // refresh so the now-authenticated provider's models populate. Auto-selects // the recommended default model so the user can Apply in one more click. @@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { : `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}

)} + {config && mainModel && (reasoningSupported || fastSupported) && ( +
+ {m.defaultsLabel} + {reasoningSupported && ( +
+ {m.reasoning} + +
+ )} + {fastSupported && ( + + )} +
+ )} {error &&
{error}
} {switchStaleAux.length > 0 && (
diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index a9795564aab..c3d20ebd878 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton' import type { HermesGateway } from '@/hermes' import { getGlobalModelOptions } from '@/hermes' import { useI18n } from '@/i18n' -import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label' +import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label' import { cn } from '@/lib/utils' import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets' import { @@ -84,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model } }) - const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') - const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') + const { model: optionsModel, provider: optionsProvider } = currentPickerSelection( + !!activeSessionId, + { model: currentModel, provider: currentProvider }, + modelOptions.data + ) + const loading = modelOptions.isPending && !modelOptions.data const error = modelOptions.error diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index be941e23d06..11c83f7f293 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useI18n } from '@/i18n' +import { currentPickerSelection } from '@/lib/model-status-label' import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes' import type { HermesGateway } from '../hermes' @@ -66,8 +67,13 @@ export function ModelPickerDialog({ }) const providers = modelOptions.data?.providers ?? [] - const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') - const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') + + const { model: optionsModel, provider: optionsProvider } = currentPickerSelection( + !!sessionId, + { model: currentModel, provider: currentProvider }, + modelOptions.data + ) + const loading = modelOptions.isPending && !modelOptions.data const error = modelOptions.error diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c1fbf90bcb7..fa6465c3388 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -538,6 +538,10 @@ export const en: Translations = { provider: 'Provider', model: 'Model', applying: 'Applying...', + defaultsLabel: 'Defaults', + reasoning: 'Reasoning', + reasoningOff: 'Off', + defaultsFailed: 'Failed to save model defaults', auxiliaryTitle: 'Auxiliary models', resetAllToMain: 'Reset all to main', auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index cc76b30d346..2e9cc76ab98 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -430,6 +430,10 @@ export interface Translations { provider: string model: string applying: string + defaultsLabel: string + reasoning: string + reasoningOff: string + defaultsFailed: string auxiliaryTitle: string resetAllToMain: string auxiliaryDesc: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0387a6be5bc..db13108d8f7 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -733,6 +733,10 @@ export const zh: Translations = { provider: '提供方', model: '模型', applying: '应用中...', + defaultsLabel: '默认值', + reasoning: '推理', + reasoningOff: '关闭', + defaultsFailed: '保存模型默认值失败', auxiliaryTitle: '辅助模型', resetAllToMain: '全部重置为主模型', auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。', diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts index 78fe51492b1..f46282d00b2 100644 --- a/apps/desktop/src/lib/model-status-label.test.ts +++ b/apps/desktop/src/lib/model-status-label.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label' +import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label' describe('model-status-label', () => { it('formats display names consistently', () => { @@ -35,4 +35,25 @@ describe('model-status-label', () => { it('returns just the placeholder name when there is no model', () => { expect(formatModelStatusLabel('')).toBe('No model') }) + + describe('currentPickerSelection', () => { + const store = { model: 'opus', provider: 'anthropic' } + const options = { model: 'hermes-4', provider: 'nous' } + + it('prefers the sticky composer pick over the profile default pre-session', () => { + expect(currentPickerSelection(false, store, options)).toEqual(store) + }) + + it('lets the live session model.options win when a session exists', () => { + expect(currentPickerSelection(true, store, options)).toEqual(options) + }) + + it('falls back to options when the store is empty', () => { + expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options) + }) + + it('falls back to the store while options are still loading', () => { + expect(currentPickerSelection(true, store, undefined)).toEqual(store) + }) + }) }) diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts index 60f0e81a959..9b0e8df7a64 100644 --- a/apps/desktop/src/lib/model-status-label.ts +++ b/apps/desktop/src/lib/model-status-label.ts @@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string { return REASONING_LABELS[key] ?? effort } +/** Which model/provider a picker should mark "current". With a live session the + * gateway's `model.options` is authoritative; pre-session there is no server + * "current", so the sticky composer pick wins over the profile default the + * global options query returns — else the checkmark snaps back to the default + * and the pick looks ignored. */ +export function currentPickerSelection( + hasSession: boolean, + store: { model: string; provider: string }, + options?: { model?: string; provider?: string } +): { model: string; provider: string } { + return { + model: String((hasSession && options?.model) || store.model || options?.model || ''), + provider: String((hasSession && options?.provider) || store.provider || options?.provider || '') + } +} + /** Strip provider prefix and normalize for display. */ export function modelBaseId(model: string): string { const trimmed = model.trim()