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()