diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 2653419e..1a03397c 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -134,7 +134,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const currentModel = useSettingsStore((state) => state.currentModel) const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior) const runtimeSelectionKey = runtimeSelection - ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}` + ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}:${runtimeSelection.effortLevel ?? 'auto'}` : undefined const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx index 78277703..632a0a9d 100644 --- a/desktop/src/components/controls/ModelSelector.test.tsx +++ b/desktop/src/components/controls/ModelSelector.test.tsx @@ -57,16 +57,14 @@ describe('ModelSelector', () => { expect(onChange).toHaveBeenCalledWith('beta') }) - it('routes uncontrolled model and effort changes through settings actions', async () => { + it('routes uncontrolled model changes through settings actions', async () => { const setModel = vi.fn(async () => {}) - const setEffort = vi.fn(async () => {}) useSettingsStore.setState({ locale: 'en', availableModels: MODELS, currentModel: MODELS[0], - effortLevel: 'medium', + effortLevel: 'max', setModel, - setEffort, }) render() @@ -74,10 +72,6 @@ describe('ModelSelector', () => { await clickByRole(/alpha/i) await clickByRole(/Beta/) expect(setModel).toHaveBeenCalledWith('beta') - - await clickByRole(/Alpha/) - await clickByRole(/^High$/) - expect(setEffort).toHaveBeenCalledWith('high') }) it('selects provider-scoped runtime models and mirrors session selections', async () => { @@ -122,13 +116,75 @@ describe('ModelSelector', () => { expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({ providerId: 'provider-a', modelId: 'provider-fast', + effortLevel: 'max', }) expect(setSessionRuntime).toHaveBeenCalledWith('session-1', { providerId: 'provider-a', modelId: 'provider-fast', + effortLevel: 'max', }) }) + it('keeps runtime effort scoped to the selected session', async () => { + const setSessionRuntime = vi.fn() + useSettingsStore.setState({ + locale: 'en', + availableModels: MODELS, + currentModel: MODELS[0], + activeProviderName: 'Provider A', + effortLevel: 'max', + }) + useProviderStore.setState({ + providers: [{ + id: 'provider-a', + presetId: 'custom', + name: 'Provider A', + apiKey: '***', + baseUrl: 'https://api.example.com', + apiFormat: 'anthropic', + models: { + main: 'provider-main', + haiku: 'provider-fast', + sonnet: 'provider-main', + opus: '', + }, + }], + activeId: 'provider-a', + hasLoadedProviders: true, + isLoading: true, + }) + useSessionRuntimeStore.getState().setSelection('session-2', { + providerId: 'provider-a', + modelId: 'provider-main', + effortLevel: 'max', + }) + useChatStore.setState({ + setSessionRuntime, + } as Partial>) + + render() + + await clickByRole(/alpha/i) + await clickByRole(/^High$/) + + expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({ + providerId: 'provider-a', + modelId: 'alpha', + effortLevel: 'high', + }) + expect(useSessionRuntimeStore.getState().selections['session-2']).toEqual({ + providerId: 'provider-a', + modelId: 'provider-main', + effortLevel: 'max', + }) + expect(setSessionRuntime).toHaveBeenCalledWith('session-1', { + providerId: 'provider-a', + modelId: 'alpha', + effortLevel: 'high', + }) + expect(useSettingsStore.getState().effortLevel).toBe('max') + }) + it('uses the ChatGPT Official catalog when that built-in provider is active', async () => { const openAIModels: ModelInfo[] = [ { @@ -176,10 +232,12 @@ describe('ModelSelector', () => { expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({ providerId: OPENAI_OFFICIAL_PROVIDER_ID, modelId: 'gpt-5.5', + effortLevel: 'max', }) expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', { providerId: OPENAI_OFFICIAL_PROVIDER_ID, modelId: 'gpt-5.5', + effortLevel: 'max', }) }) diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx index 45e651d2..f4b86fdf 100644 --- a/desktop/src/components/controls/ModelSelector.tsx +++ b/desktop/src/components/controls/ModelSelector.tsx @@ -179,7 +179,6 @@ export function ModelSelector({ effortLevel, activeProviderName, setModel, - setEffort, } = useSettingsStore() const { providers, @@ -211,6 +210,7 @@ export function ModelSelector({ const isRuntimeScoped = !isControlled && (runtimeKey !== undefined || onRuntimeSelectionChange !== undefined) + const canEditRuntimeEffort = runtimeKey !== undefined useEffect(() => { if (!isRuntimeScoped || providersLoading || requestedProvidersRef.current) return @@ -351,6 +351,7 @@ export function ModelSelector({ const buttonProviderLabel = isRuntimeScoped ? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName') : null + const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel ?? effortLevel const handleRuntimeSelect = (selection: RuntimeSelection) => { onRuntimeSelectionChange?.(selection) @@ -363,6 +364,14 @@ export function ModelSelector({ setOpen(false) } + const handleRuntimeEffortSelect = (level: EffortLevel) => { + if (!activeRuntimeSelection) return + handleRuntimeSelect({ + ...activeRuntimeSelection, + effortLevel: level, + }) + } + const dropdownContent = ( <>
@@ -395,7 +404,11 @@ export function ModelSelector({ return (
- {!isControlled && !isRuntimeScoped && ( + {canEditRuntimeEffort && (
{t('model.effort')}
{EFFORT_OPTIONS.map((opt) => { - const isSelected = opt.value === effortLevel + const isSelected = opt.value === selectedRuntimeEffort return ( - ))} -
-

{t('settings.general.thinkingTitle')}

{t('settings.general.thinkingDescription')}

diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 6aba3d3e..688f3cb9 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1619,6 +1619,7 @@ describe('chatStore history mapping', () => { useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, { providerId: 'provider-1', modelId: 'kimi-k2.6', + effortLevel: 'high', }) useChatStore.getState().connectToSession(TEST_SESSION_ID) @@ -1627,6 +1628,7 @@ describe('chatStore history mapping', () => { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'kimi-k2.6', + effortLevel: 'high', }) expect(sendMock.mock.calls.slice(0, 2)).toEqual([ [ @@ -1635,6 +1637,7 @@ describe('chatStore history mapping', () => { type: 'set_runtime_config', providerId: 'provider-1', modelId: 'kimi-k2.6', + effortLevel: 'high', }, ], [TEST_SESSION_ID, { type: 'prewarm_session' }], @@ -1693,12 +1696,14 @@ describe('chatStore history mapping', () => { useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, { providerId: null, modelId: 'claude-opus-4-7', + effortLevel: 'max', }) expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'set_runtime_config', providerId: null, modelId: 'claude-opus-4-7', + effortLevel: 'max', }) }) diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 585ce6f9..e0f52833 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -60,6 +60,7 @@ function resolveRuntimeRefreshSelection( modelId: modelIds.has(currentSelection.modelId) ? currentSelection.modelId : provider.models.main, + ...(currentSelection.effortLevel ? { effortLevel: currentSelection.effortLevel } : {}), } } diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index e7edf80c..a18bf908 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -136,7 +136,7 @@ const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, - effortLevel: 'medium', + effortLevel: 'max', thinkingEnabled: true, availableModels: [], activeProviderName: null, diff --git a/desktop/src/types/runtime.ts b/desktop/src/types/runtime.ts index 1c0fcd00..c47f815e 100644 --- a/desktop/src/types/runtime.ts +++ b/desktop/src/types/runtime.ts @@ -1,4 +1,7 @@ +import type { EffortLevel } from './settings' + export type RuntimeSelection = { providerId: string | null modelId: string + effortLevel?: EffortLevel } diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 501ff292..3d55857a 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -2930,6 +2930,7 @@ describe('WebSocket Chat Integration', () => { type: 'set_runtime_config', providerId: providerA.id, modelId: 'model-a-sonnet', + effortLevel: 'medium', })) ws.send(JSON.stringify({ type: 'user_message', content: 'first turn' })) phase = 'turn1' @@ -2950,6 +2951,7 @@ describe('WebSocket Chat Integration', () => { type: 'set_runtime_config', providerId: providerB.id, modelId: 'model-b-opus', + effortLevel: 'max', })) return } @@ -2989,6 +2991,7 @@ describe('WebSocket Chat Integration', () => { options: { providerId: providerA.id, model: 'model-a-sonnet', + effort: 'medium', }, }) expect(startCalls[1]).toMatchObject({ @@ -2996,6 +2999,7 @@ describe('WebSocket Chat Integration', () => { options: { providerId: providerB.id, model: 'model-b-opus', + effort: 'max', }, }) } finally { diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index d894aee1..6f0cfa72 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -419,9 +419,9 @@ describe('Business Flow: Models & Effort', () => { expect(status).toBe(400) }) - it('should default effort to medium', async () => { + it('should default effort to max', async () => { const { data } = await api('GET', '/api/effort') - expect(data.level).toBe('medium') + expect(data.level).toBe('max') expect(data.available).toEqual(['low', 'medium', 'high', 'max']) }) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 6240658e..4d3aade9 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -691,7 +691,7 @@ describe('Models API', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body.level).toBe('medium') + expect(body.level).toBe('max') expect(body.available).toEqual(['low', 'medium', 'high', 'max']) }) @@ -704,7 +704,7 @@ describe('Models API', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body.level).toBe('medium') + expect(body.level).toBe('max') expect(body.available).toEqual(['low', 'medium', 'high', 'max']) }) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index 570b6338..73755672 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -46,7 +46,7 @@ const DEFAULT_MODELS = [ const EFFORT_LEVELS = ['low', 'medium', 'high', 'max'] as const const DEFAULT_MODEL = 'claude-opus-4-7' -const DEFAULT_EFFORT = 'medium' +const DEFAULT_EFFORT = 'max' const settingsService = new SettingsService() const providerService = new ProviderService() diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 663045f3..eb611d9e 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -72,6 +72,9 @@ export type SessionLaunchInfo = { transcriptMessageCount: number customTitle: string | null permissionMode?: string + runtimeProviderId?: string | null + runtimeModelId?: string + effortLevel?: string } export type TrimSessionResult = { @@ -228,6 +231,9 @@ type SessionListSummary = { messageCount: number workDir: string | null permissionMode?: string + runtimeProviderId?: string | null + runtimeModelId?: string + effortLevel?: string repository?: PreparedSessionWorkspace['repository'] worktreeSession?: PersistedWorktreeSession | null } @@ -239,6 +245,7 @@ const VALID_SESSION_PERMISSION_MODES = new Set([ 'bypassPermissions', 'dontAsk', ]) +const VALID_SESSION_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) type ContentBlock = Record @@ -348,6 +355,9 @@ export class SessionService { let latestWorkDir: string | null = null let latestCwd: string | null = null let permissionMode: string | undefined + let runtimeProviderId: string | null | undefined + let runtimeModelId: string | undefined + let effortLevel: string | undefined let repository: PreparedSessionWorkspace['repository'] | undefined let worktreeSession: PersistedWorktreeSession | null | undefined @@ -393,6 +403,21 @@ export class SessionService { ) { permissionMode = entry.permissionMode } + if ( + (entry as Record).runtimeProviderId === null || + typeof (entry as Record).runtimeProviderId === 'string' + ) { + runtimeProviderId = (entry as Record).runtimeProviderId as string | null + } + if (typeof (entry as Record).runtimeModelId === 'string') { + runtimeModelId = (entry as Record).runtimeModelId as string + } + if ( + typeof (entry as Record).effortLevel === 'string' && + VALID_SESSION_EFFORT_LEVELS.has((entry as Record).effortLevel as string) + ) { + effortLevel = (entry as Record).effortLevel as string + } } if (typeof entry.cwd === 'string' && entry.cwd.trim()) { @@ -454,6 +479,9 @@ export class SessionService { messageCount, workDir: latestWorkDir || latestCwd || this.desanitizePath(projectDir), ...(permissionMode ? { permissionMode } : {}), + ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), + ...(runtimeModelId ? { runtimeModelId } : {}), + ...(effortLevel ? { effortLevel } : {}), ...(repository ? { repository } : {}), ...(worktreeSession !== undefined ? { worktreeSession } : {}), } @@ -1846,11 +1874,29 @@ export class SessionService { const worktreeSession = this.resolveWorktreeSessionFromEntries(entries) const permissionMode = this.resolvePermissionModeFromEntries(entries) let customTitle: string | null = null + let runtimeProviderId: string | null | undefined + let runtimeModelId: string | undefined + let effortLevel: string | undefined for (const entry of entries) { if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { customTitle = entry.customTitle } + if (entry.type === 'session-meta') { + const record = entry as Record + if (record.runtimeProviderId === null || typeof record.runtimeProviderId === 'string') { + runtimeProviderId = record.runtimeProviderId as string | null + } + if (typeof record.runtimeModelId === 'string') { + runtimeModelId = record.runtimeModelId + } + if ( + typeof record.effortLevel === 'string' && + VALID_SESSION_EFFORT_LEVELS.has(record.effortLevel) + ) { + effortLevel = record.effortLevel + } + } } const transcriptMessageCount = this.countTranscriptMessages(entries) @@ -1863,6 +1909,9 @@ export class SessionService { transcriptMessageCount, customTitle, permissionMode, + ...(runtimeProviderId !== undefined ? { runtimeProviderId } : {}), + ...(runtimeModelId ? { runtimeModelId } : {}), + ...(effortLevel ? { effortLevel } : {}), } } @@ -1928,6 +1977,9 @@ export class SessionService { customTitle?: string | null repository?: PreparedSessionWorkspace['repository'] permissionMode?: string + runtimeProviderId?: string | null + runtimeModelId?: string + effortLevel?: string } ): Promise { const matches = await this.findSessionFiles(sessionId) @@ -1957,6 +2009,13 @@ export class SessionService { ...(metadata.permissionMode && VALID_SESSION_PERMISSION_MODES.has(metadata.permissionMode) ? { permissionMode: metadata.permissionMode } : {}), + ...(metadata.runtimeProviderId !== undefined + ? { runtimeProviderId: metadata.runtimeProviderId } + : {}), + ...(metadata.runtimeModelId ? { runtimeModelId: metadata.runtimeModelId } : {}), + ...(metadata.effortLevel && VALID_SESSION_EFFORT_LEVELS.has(metadata.effortLevel) + ? { effortLevel: metadata.effortLevel } + : {}), timestamp: new Date().toISOString(), }) diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index ea9b6ea7..d729763d 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -24,7 +24,7 @@ export type ClientMessage = response: ComputerUsePermissionResponse } | { type: 'set_permission_mode'; mode: string } - | { type: 'set_runtime_config'; providerId: string | null; modelId: string } + | { type: 'set_runtime_config'; providerId: string | null; modelId: string; effortLevel?: string } | { type: 'stop_generation' } | { type: 'ping' } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 0f6a8400..37f1bc90 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -70,15 +70,19 @@ const sessionTitleState = new Map() const runtimeTransitionPromises = new Map>() const sessionStartupPromises = new Map>() +const runtimeOverrideVersions = new Map() +const sessionStartupRuntimeVersions = new Map() const lastResolvedStartupWorkDirs = new Map() const prewarmPendingSessions = new Set() const prewarmedSessions = new Set() const prewarmIdleTimers = new Map>() const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000 +const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']) async function sendRepositoryStartupStatus( ws: ServerWebSocket, @@ -549,44 +553,73 @@ async function handleSetRuntimeConfig( }) return } + const effortLevel = + typeof message.effortLevel === 'string' ? message.effortLevel.trim() : undefined + if (effortLevel !== undefined && !VALID_EFFORT_LEVELS.has(effortLevel)) { + sendMessage(ws, { + type: 'error', + message: 'Runtime effort selection is invalid.', + code: 'RUNTIME_CONFIG_INVALID', + }) + return + } const nextOverride = { providerId: message.providerId ?? null, modelId, + ...(effortLevel ? { effort: effortLevel } : {}), } const prevOverride = runtimeOverrides.get(sessionId) - runtimeOverrides.set(sessionId, nextOverride) - if ( prevOverride && prevOverride.providerId === nextOverride.providerId && - prevOverride.modelId === nextOverride.modelId + prevOverride.modelId === nextOverride.modelId && + prevOverride.effort === nextOverride.effort ) { return } - if (!conversationService.hasSession(sessionId)) { - const pendingStartup = sessionStartupPromises.get(sessionId) - if (pendingStartup) { - await enqueueRuntimeTransition(sessionId, async () => { - await pendingStartup.catch(() => undefined) - const currentOverride = runtimeOverrides.get(sessionId) - if ( - currentOverride?.providerId !== nextOverride.providerId || - currentOverride.modelId !== nextOverride.modelId || - !conversationService.hasSession(sessionId) - ) { - return - } - await restartSessionWithRuntimeConfig(ws, sessionId) - }) - } + runtimeOverrides.set(sessionId, nextOverride) + runtimeOverrideVersions.set( + sessionId, + (runtimeOverrideVersions.get(sessionId) ?? 0) + 1, + ) + + if (conversationService.hasSession(sessionId)) { + await enqueueRuntimeTransition(sessionId, async () => { + await persistSessionRuntimeConfig(sessionId, nextOverride) + await restartSessionWithRuntimeConfig(ws, sessionId) + }) return } - await enqueueRuntimeTransition(sessionId, () => - restartSessionWithRuntimeConfig(ws, sessionId), - ) + const pendingStartup = sessionStartupPromises.get(sessionId) + if (pendingStartup) { + const startupRuntimeVersion = sessionStartupRuntimeVersions.get(sessionId) ?? 0 + const currentRuntimeVersion = runtimeOverrideVersions.get(sessionId) ?? 0 + if (startupRuntimeVersion >= currentRuntimeVersion) { + await persistSessionRuntimeConfig(sessionId, nextOverride) + return + } + + await enqueueRuntimeTransition(sessionId, async () => { + await persistSessionRuntimeConfig(sessionId, nextOverride) + await pendingStartup.catch(() => undefined) + const currentOverride = runtimeOverrides.get(sessionId) + if ( + currentOverride?.providerId !== nextOverride.providerId || + currentOverride.modelId !== nextOverride.modelId || + currentOverride.effort !== nextOverride.effort || + !conversationService.hasSession(sessionId) + ) { + return + } + await restartSessionWithRuntimeConfig(ws, sessionId) + }) + return + } + + await persistSessionRuntimeConfig(sessionId, nextOverride) } async function restartSessionWithPermissionMode( @@ -648,6 +681,24 @@ async function persistSessionPermissionMode( }) } +async function persistSessionRuntimeConfig( + sessionId: string, + runtime: { providerId: string | null; modelId: string; effort?: string }, +): Promise { + const workDir = + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId).catch(() => null) + + if (!workDir) return + + await sessionService.appendSessionMetadata(sessionId, { + workDir, + runtimeProviderId: runtime.providerId, + runtimeModelId: runtime.modelId, + ...(runtime.effort ? { effortLevel: runtime.effort } : {}), + }) +} + async function restartSessionWithRuntimeConfig( ws: ServerWebSocket, sessionId: string, @@ -981,6 +1032,9 @@ async function ensureCliSessionStarted( if (conversationService.hasSession(sessionId)) return + const startupRuntimeVersion = runtimeOverrideVersions.get(sessionId) ?? 0 + sessionStartupRuntimeVersions.set(sessionId, startupRuntimeVersion) + const startup = (async () => { const workDir = await resolveSessionWorkDir(sessionId) lastResolvedStartupWorkDirs.set(sessionId, workDir) @@ -999,6 +1053,7 @@ async function ensureCliSessionStarted( } finally { if (sessionStartupPromises.get(sessionId) === startup) { sessionStartupPromises.delete(sessionId) + sessionStartupRuntimeVersions.delete(sessionId) } } } @@ -1859,10 +1914,23 @@ function isKnownRuntimeProviderId( } async function getRuntimeSettings(sessionId?: string): Promise { + const launchInfo = sessionId + ? await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) + : null const sessionPermissionMode = sessionId - ? await getSessionPermissionMode(sessionId) + ? launchInfo?.permissionMode ?? await getSessionPermissionMode(sessionId) + : undefined + const persistedRuntimeOverride = + launchInfo?.runtimeModelId + ? { + providerId: launchInfo.runtimeProviderId ?? null, + modelId: launchInfo.runtimeModelId, + ...(launchInfo.effortLevel ? { effort: launchInfo.effortLevel } : {}), + } + : undefined + const runtimeOverride = sessionId + ? runtimeOverrides.get(sessionId) ?? persistedRuntimeOverride : undefined - const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined if (runtimeOverride) { if (typeof runtimeOverride.providerId === 'string') { const { providers } = await providerService.listProviders() @@ -1881,16 +1949,12 @@ async function getRuntimeSettings(sessionId?: string): Promise } const userSettings = await settingsService.getUserSettings() - const effort = - typeof userSettings.effort === 'string' && userSettings.effort.trim() - ? userSettings.effort - : undefined const thinking = resolveDesktopThinkingMode(userSettings) return { permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined), model: runtimeOverride.modelId, - effort, + effort: runtimeOverride.effort, thinking, providerId: runtimeOverride.providerId, } @@ -1900,6 +1964,7 @@ async function getRuntimeSettings(sessionId?: string): Promise return { ...defaults, permissionMode: sessionPermissionMode ?? defaults.permissionMode, + effort: launchInfo?.effortLevel ?? defaults.effort, } } @@ -1995,6 +2060,7 @@ async function buildSessionStartupDiagnosticMessage( if (runtimeOverride) { lines.push(`- runtimeOverride.providerId: ${runtimeOverride.providerId ?? '(official)'}`) lines.push(`- runtimeOverride.modelId: ${runtimeOverride.modelId}`) + lines.push(`- runtimeOverride.effort: ${runtimeOverride.effort ?? '(auto)'}`) } else { lines.push('- runtimeOverride: (none)') } diff --git a/src/utils/__tests__/thinking.test.ts b/src/utils/__tests__/thinking.test.ts index 1e398570..c8d2679d 100644 --- a/src/utils/__tests__/thinking.test.ts +++ b/src/utils/__tests__/thinking.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { get3PModelCapabilityOverride } from '../model/modelSupportOverrides.js' import { resolveSideQueryThinkingConfig } from '../sideQuery.js' +import { modelSupportsEffort, modelSupportsMaxEffort } from '../effort.js' import { modelSupportsAdaptiveThinking, modelSupportsThinking, @@ -87,6 +88,8 @@ describe('provider-aware thinking support', () => { expect(modelSupportsThinking('deepseek-v4-pro')).toBe(true) expect(modelSupportsAdaptiveThinking('deepseek-v4-pro')).toBe(true) + expect(modelSupportsEffort('deepseek-v4-pro')).toBe(true) + expect(modelSupportsMaxEffort('deepseek-v4-pro')).toBe(true) expect(shouldSendExplicitDisabledThinking()).toBe(false) }) @@ -98,6 +101,17 @@ describe('provider-aware thinking support', () => { expect(modelSupportsThinking('MiniMax-M2.7')).toBe(true) expect(modelSupportsAdaptiveThinking('MiniMax-M2.7')).toBe(false) + expect(modelSupportsEffort('MiniMax-M2.7')).toBe(false) + expect(modelSupportsMaxEffort('MiniMax-M2.7')).toBe(false) + }) + + test('third-party base URLs do not default unknown model names to effort support', () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.moonshot.cn/anthropic' + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES + clearCapabilityCache() + + expect(modelSupportsEffort('kimi-k2.6')).toBe(false) + expect(modelSupportsMaxEffort('kimi-k2.6')).toBe(false) }) test('side queries inherit explicit disabled thinking for opted-in providers', () => { diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 2320e9c9..d38cfa5e 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -3,7 +3,7 @@ import { isUltrathinkEnabled } from './thinking.js' import { getInitialSettings } from './settings/settings.js' import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' -import { getAPIProvider } from './model/providers.js' +import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' import { isEnvTruthy } from './envUtils.js' import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js' @@ -45,7 +45,7 @@ export function modelSupportsEffort(model: string): boolean { // Default to true for unknown model strings on 1P. // Do not default to true for 3P as they have different formats for their // model strings (ex. anthropics/claude-code#30795) - return getAPIProvider() === 'firstParty' + return getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl() } // @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'max' effort.