diff --git a/src/App.vue b/src/App.vue index c39b2cd5b..9a84f1ec7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -944,7 +944,8 @@ :cwd="composerCwd" :collaboration-modes="availableCollaborationModes" :selected-collaboration-mode="selectedCollaborationMode" - :models="availableModelIds" :selected-model="composerSelectedModelId" + :models="availableModelIds" :model-reasoning-efforts="availableModelReasoningEfforts" + :selected-model="composerSelectedModelId" :selected-reasoning-effort="selectedReasoningEffort" :selected-speed-mode="selectedSpeedMode" :is-updating-speed-mode="isUpdatingSpeedMode" @@ -1027,6 +1028,7 @@ :collaboration-modes="availableCollaborationModes" :selected-collaboration-mode="selectedCollaborationMode" :models="availableModelIds" + :model-reasoning-efforts="availableModelReasoningEfforts" :selected-model="composerSelectedModelId" :selected-reasoning-effort="selectedReasoningEffort" :selected-speed-mode="selectedSpeedMode" @@ -1418,6 +1420,7 @@ const { selectedThreadId, availableCollaborationModes, availableModelIds, + availableModelReasoningEfforts, selectedCollaborationMode, selectedModelId, selectedReasoningEffort, diff --git a/src/api/codexGateway.test.ts b/src/api/codexGateway.test.ts index 7bb4980f9..1e10ef13c 100644 --- a/src/api/codexGateway.test.ts +++ b/src/api/codexGateway.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getAvailableModelIds, getThreadDetail, listDirectoryComposioConnectors, resumeThread, startThreadTurn } from './codexGateway' +import { getAvailableModelIds, getAvailableModels, getCurrentModelConfig, getThreadDetail, listDirectoryComposioConnectors, resumeThread, startThreadTurn } from './codexGateway' function mockRpcFetch(): { requests: Array<{ method: string, params: Record }> } { const requests: Array<{ method: string, params: Record }> = [] @@ -59,6 +59,48 @@ describe('startThreadTurn collaboration mode payloads', () => { }, }) }) + + it('passes GPT-5.6 ultra reasoning through to Codex', async () => { + const { requests } = mockRpcFetch() + + await startThreadTurn('thread-1', 'solve it', [], 'gpt-5.6-sol', 'ultra', undefined, [], 'default') + + expect(requests[0].params.effort).toBe('ultra') + expect(requests[0].params.collaborationMode).toEqual({ + mode: 'default', + settings: { + model: 'gpt-5.6-sol', + reasoning_effort: 'ultra', + developer_instructions: null, + }, + }) + }) +}) + +describe('getCurrentModelConfig', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each(['max', 'ultra'] as const)('keeps the GPT-5.6 %s reasoning level', async (reasoningEffort) => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + result: { + config: { + model: 'gpt-5.6-sol', + model_provider: 'openai', + model_reasoning_effort: reasoningEffort, + }, + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }))) + + await expect(getCurrentModelConfig()).resolves.toMatchObject({ + model: 'gpt-5.6-sol', + reasoningEffort, + }) + }) }) describe('listDirectoryComposioConnectors', () => { @@ -173,6 +215,54 @@ describe('getAvailableModelIds', () => { })).resolves.toEqual(['gpt-5.5', 'gpt-5.4-mini']) expect(requests).toEqual(['/codex-api/provider-models', '/codex-api/rpc']) }) + + it('preserves model-specific reasoning metadata from model/list', async () => { + vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = typeof init?.body === 'string' + ? JSON.parse(init.body) as { method: string } + : { method: '' } + expect(body.method).toBe('model/list') + return new Response(JSON.stringify({ + result: { + data: [ + { + id: 'gpt-5.6-sol', + supportedReasoningEfforts: [ + { reasoningEffort: 'low' }, + { reasoningEffort: 'max' }, + { reasoningEffort: 'ultra' }, + ], + defaultReasoningEffort: 'low', + }, + { + id: 'gpt-5.5', + supportedReasoningEfforts: [ + { reasoningEffort: 'low' }, + { reasoningEffort: 'xhigh' }, + ], + defaultReasoningEffort: 'low', + }, + ], + }, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + })) + + await expect(getAvailableModels({ includeProviderModels: false })).resolves.toEqual([ + { + id: 'gpt-5.6-sol', + supportedReasoningEfforts: ['low', 'max', 'ultra'], + defaultReasoningEffort: 'low', + }, + { + id: 'gpt-5.5', + supportedReasoningEfforts: ['low', 'xhigh'], + defaultReasoningEffort: 'low', + }, + ]) + }) }) describe('getThreadDetail', () => { diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index e66e46366..3124e77b4 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -12,7 +12,6 @@ import type { ConfigReadResponse, GetAccountRateLimitsResponse, ModelListResponse, - ReasoningEffort, ThreadForkResponse, ThreadListResponse, ThreadReadResponse, @@ -20,6 +19,7 @@ import type { ThreadStartResponse, Turn, } from './appServerDtos' +import { isReasoningEffort } from '../types/codex' import { extractErrorMessage, normalizeCodexApiError } from './codexErrors' import { readActiveTurnIdFromResponse, @@ -53,6 +53,7 @@ import type { UiReviewWorkspaceView, UiRateLimitSnapshot, UiRateLimitWindow, + ReasoningEffort, UiThreadAutomation, UiThreadAutomationStatus, } from '../types/codex' @@ -252,6 +253,12 @@ type ProviderModelsResponse = { exclusive?: unknown } +export type AvailableModel = { + id: string + supportedReasoningEfforts: ReasoningEffort[] | null + defaultReasoningEffort: ReasoningEffort | null +} + const PROVIDER_MODELS_FETCH_TIMEOUT_MS = 5_000 type ResolvedCollaborationModeSettings = { @@ -700,10 +707,7 @@ async function enrichThreadMessagesWithFallback(threadId: string, messages: UiMe } function normalizeReasoningEffort(value: unknown): ReasoningEffort | '' { - const allowed: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] - return typeof value === 'string' && allowed.includes(value as ReasoningEffort) - ? (value as ReasoningEffort) - : '' + return isReasoningEffort(value) ? value : '' } function normalizeSpeedMode(value: unknown): SpeedMode { @@ -2036,28 +2040,65 @@ async function fetchProviderModelIds(providerId?: string): Promise<{ ids: string return null } -export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean; providerId?: string } = {}): Promise { +function normalizeAvailableModel(value: unknown): AvailableModel | null { + const record = asRecord(value) + if (!record) return null + const id = readString(record.id ?? record.model)?.trim() ?? '' + if (!id) return null + + const rawEfforts = record.supportedReasoningEfforts ?? record.supported_reasoning_efforts + const supportedReasoningEfforts = Array.isArray(rawEfforts) + ? rawEfforts.flatMap((candidate) => { + const option = asRecord(candidate) + const effort = option + ? option.reasoningEffort ?? option.reasoning_effort ?? option.effort + : candidate + return isReasoningEffort(effort) ? [effort] : [] + }).filter((effort, index, efforts) => efforts.indexOf(effort) === index) + : null + const rawDefault = record.defaultReasoningEffort ?? record.default_reasoning_effort + + return { + id, + supportedReasoningEfforts, + defaultReasoningEffort: isReasoningEffort(rawDefault) ? rawDefault : null, + } +} + +function providerAvailableModel(id: string): AvailableModel { + return { + id, + supportedReasoningEfforts: null, + defaultReasoningEffort: null, + } +} + +export async function getAvailableModels(options: { includeProviderModels?: boolean; requireProviderModels?: boolean; providerId?: string } = {}): Promise { const shouldIncludeProviderModels = options.includeProviderModels !== false const providerModels = shouldIncludeProviderModels ? await fetchProviderModelIds(options.providerId) : null if (providerModels?.exclusive || options.requireProviderModels) { - return providerModels?.ids ?? [] + return (providerModels?.ids ?? []).map(providerAvailableModel) } const payload = await callRpc('model/list', {}) - const ids: string[] = [] + const models: AvailableModel[] = [] for (const row of payload.data) { - const candidate = row.id || row.model - if (!candidate || ids.includes(candidate)) continue - ids.push(candidate) + const model = normalizeAvailableModel(row) + if (!model || models.some((candidate) => candidate.id === model.id)) continue + models.push(model) } - if (!shouldIncludeProviderModels || !providerModels) return ids + if (!shouldIncludeProviderModels || !providerModels) return models - for (const candidate of providerModels.ids) { - if (!ids.includes(candidate)) ids.push(candidate) + for (const id of providerModels.ids) { + if (!models.some((candidate) => candidate.id === id)) models.push(providerAvailableModel(id)) } - return ids + return models +} + +export async function getAvailableModelIds(options: { includeProviderModels?: boolean; requireProviderModels?: boolean; providerId?: string } = {}): Promise { + return (await getAvailableModels(options)).map((model) => model.id) } export async function getCurrentModelConfig(): Promise { diff --git a/src/components/content/ThreadComposer.vue b/src/components/content/ThreadComposer.vue index 9be5a3561..acc82f267 100644 --- a/src/components/content/ThreadComposer.vue +++ b/src/components/content/ThreadComposer.vue @@ -287,12 +287,12 @@ /> @@ -438,6 +438,7 @@ const props = defineProps<{ collaborationModes?: CollaborationModeOption[] selectedCollaborationMode: CollaborationModeKind models: string[] + modelReasoningEfforts?: Record selectedModel: string selectedReasoningEffort: ReasoningEffort | '' selectedSpeedMode: SpeedMode @@ -585,14 +586,22 @@ const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator. const DRAFT_STORAGE_PREFIX = 'codex-web-local.thread-draft.v1.' let lastActiveThreadId = '' -const reasoningOptions: Array<{ value: ReasoningEffort; label: string }> = [ +const reasoningOptionCatalog: Array<{ value: ReasoningEffort; label: string }> = [ { value: 'none', label: 'None' }, { value: 'minimal', label: 'Minimal' }, { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' }, { value: 'xhigh', label: 'Extra high' }, + { value: 'max', label: 'Max' }, + { value: 'ultra', label: 'Ultra' }, ] +const reasoningOptions = computed(() => { + const supportedEfforts = props.modelReasoningEfforts?.[props.selectedModel] + if (supportedEfforts === undefined) return reasoningOptionCatalog + const supportedSet = new Set(supportedEfforts) + return reasoningOptionCatalog.filter((option) => supportedSet.has(option.value)) +}) function formatModelLabel(modelId: string): string { return modelId.trim().replace(/^gpt/i, 'GPT') } @@ -2214,6 +2223,9 @@ watch( @apply truncate; } +.thread-composer-thinking-control :deep(.composer-dropdown-options) { + @apply max-h-64; +} .thread-composer-actions { @apply ml-auto flex min-w-0 items-center gap-2; diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index b17d1f2b2..b2c364bc6 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -9,14 +9,14 @@ import { useDesktopState, } from './useDesktopState' import type { UiProjectGroup } from '../types/codex' -import type { WorkspaceRootsState } from '../api/codexGateway' +import type { AvailableModel, WorkspaceRootsState } from '../api/codexGateway' const gatewayMocks = vi.hoisted(() => ({ archiveThread: vi.fn(), forkThread: vi.fn(), getAccountRateLimits: vi.fn(), getAvailableCollaborationModes: vi.fn(), - getAvailableModelIds: vi.fn(), + getAvailableModels: vi.fn(), getCurrentModelConfig: vi.fn(), getPendingServerRequests: vi.fn(), getSkillsList: vi.fn(), @@ -62,6 +62,14 @@ function thread(id: string, cwd: string, options: { hasWorktree?: boolean } = {} } } +function modelsWithoutReasoning(...ids: string[]): AvailableModel[] { + return ids.map((id) => ({ + id, + supportedReasoningEfforts: null, + defaultReasoningEffort: null, + })) +} + function installTestWindow(initialStorage: Record = {}) { const store = new Map(Object.entries(initialStorage)) vi.stubGlobal('window', { @@ -530,7 +538,7 @@ describe('startup request deduplication', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue(['gpt-5.5']) + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning('gpt-5.5')) try { const state = useDesktopState() @@ -561,7 +569,7 @@ describe('startup request deduplication', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue(['gpt-5.5']) + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning('gpt-5.5')) try { const state = useDesktopState() @@ -758,16 +766,16 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue([ + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning( 'big-pickle', 'deepseek-v4-flash-free', 'ring-2.6-1t-free', - ]) + )) const state = useDesktopState() await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) - expect(gatewayMocks.getAvailableModelIds).toHaveBeenCalledWith({ + expect(gatewayMocks.getAvailableModels).toHaveBeenCalledWith({ includeProviderModels: true, requireProviderModels: true, providerId: 'opencode-zen', @@ -801,11 +809,11 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue([ + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning( 'big-pickle', 'deepseek-v4-flash-free', 'ring-2.6-1t-free', - ]) + )) const state = useDesktopState() await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) @@ -838,10 +846,10 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue([ + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning( 'gpt-5.5', 'gpt-5.4-mini', - ]) + )) const state = useDesktopState() await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) @@ -870,10 +878,10 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue([ + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning( 'gpt-5.5', 'gpt-5.4-mini', - ]) + )) const state = useDesktopState() await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) @@ -890,6 +898,45 @@ describe('provider model selection', () => { }) }) + it('uses model-specific reasoning levels and clamps incompatible selections to the model default', async () => { + installTestWindow() + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ groups: [], nextCursor: null }) + gatewayMocks.getAvailableCollaborationModes.mockResolvedValue([{ value: 'default', label: 'Default' }]) + gatewayMocks.getSkillsList.mockResolvedValue([]) + gatewayMocks.getAccountRateLimits.mockResolvedValue(null) + gatewayMocks.getCurrentModelConfig.mockResolvedValue({ + model: 'gpt-5.6-sol', + providerId: '', + reasoningEffort: 'ultra', + speedMode: 'standard', + }) + gatewayMocks.getAvailableModels.mockResolvedValue([ + { + id: 'gpt-5.6-sol', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + defaultReasoningEffort: 'low', + }, + { + id: 'gpt-5.5', + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + }, + ]) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) + + expect(state.selectedModelId.value).toBe('gpt-5.6-sol') + expect(state.selectedReasoningEffort.value).toBe('ultra') + expect(state.availableModelReasoningEfforts.value['gpt-5.5']).toEqual(['low', 'medium', 'high', 'xhigh']) + + state.setSelectedModelIdForThread('__new-thread__', 'gpt-5.5') + expect(state.selectedReasoningEffort.value).toBe('medium') + + state.setSelectedReasoningEffort('ultra') + expect(state.selectedReasoningEffort.value).toBe('medium') + }) + it('keeps an existing OpenCode Zen thread locked to Zen models after Codex auth becomes active', async () => { installTestWindow() gatewayMocks.getThreadGroupsPage.mockResolvedValue({ @@ -905,11 +952,11 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockImplementation(async (options?: { providerId?: string }) => { + gatewayMocks.getAvailableModels.mockImplementation(async (options?: { providerId?: string }) => { if (options?.providerId === 'opencode-zen') { - return ['big-pickle', 'ring-2.6-1t-free'] + return modelsWithoutReasoning('big-pickle', 'ring-2.6-1t-free') } - return ['gpt-5.5', 'gpt-5.4-mini'] + return modelsWithoutReasoning('gpt-5.5', 'gpt-5.4-mini') }) gatewayMocks.resumeThread.mockResolvedValue({ model: 'gpt-5.4-mini', @@ -926,7 +973,7 @@ describe('provider model selection', () => { await state.loadMessages('legacy-zen-thread') await state.refreshAll({ includeSelectedThreadMessages: false, awaitAncillaryRefreshes: true }) - expect(gatewayMocks.getAvailableModelIds).toHaveBeenLastCalledWith({ + expect(gatewayMocks.getAvailableModels).toHaveBeenLastCalledWith({ includeProviderModels: true, requireProviderModels: true, providerId: 'opencode-zen', @@ -961,11 +1008,11 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockImplementation(async (options?: { providerId?: string }) => { + gatewayMocks.getAvailableModels.mockImplementation(async (options?: { providerId?: string }) => { if (options?.providerId === 'opencode-zen') { - return ['big-pickle', 'ring-2.6-1t-free'] + return modelsWithoutReasoning('big-pickle', 'ring-2.6-1t-free') } - return ['gpt-5.5', 'gpt-5.4-mini'] + return modelsWithoutReasoning('gpt-5.5', 'gpt-5.4-mini') }) gatewayMocks.resumeThread.mockResolvedValue({ model: 'gpt-5.4-mini', @@ -983,7 +1030,7 @@ describe('provider model selection', () => { await state.refreshAll({ includeSelectedThreadMessages: false }) await new Promise((resolve) => globalThis.setTimeout(resolve, 0)) - expect(gatewayMocks.getAvailableModelIds).toHaveBeenLastCalledWith({ + expect(gatewayMocks.getAvailableModels).toHaveBeenLastCalledWith({ includeProviderModels: true, requireProviderModels: true, providerId: 'opencode-zen', @@ -1004,7 +1051,7 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue(['gpt-5.5', 'gpt-5.4-mini']) + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning('gpt-5.5', 'gpt-5.4-mini')) gatewayMocks.startThread.mockResolvedValue({ threadId: 'codex-thread', model: 'gpt-5.5', @@ -1051,10 +1098,10 @@ describe('provider model selection', () => { ))).toBe(true) const modelConfigCallsBeforeLoad = gatewayMocks.getCurrentModelConfig.mock.calls.length - const availableModelCallsBeforeLoad = gatewayMocks.getAvailableModelIds.mock.calls.length + const availableModelCallsBeforeLoad = gatewayMocks.getAvailableModels.mock.calls.length await state.loadMessages('codex-thread') expect(gatewayMocks.getCurrentModelConfig).toHaveBeenCalledTimes(modelConfigCallsBeforeLoad) - expect(gatewayMocks.getAvailableModelIds).toHaveBeenCalledTimes(availableModelCallsBeforeLoad) + expect(gatewayMocks.getAvailableModels).toHaveBeenCalledTimes(availableModelCallsBeforeLoad) expect(state.messages.value.map((message) => `${message.role}:${message.text}`)).toEqual([ 'user:hi', 'assistant:Hi.', @@ -1084,7 +1131,7 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue(['gpt-5.5', 'gpt-5.4-mini']) + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning('gpt-5.5', 'gpt-5.4-mini')) gatewayMocks.startThread.mockResolvedValue({ threadId: 'mini-thread', model: 'gpt-5.4-mini', @@ -1151,7 +1198,7 @@ describe('provider model selection', () => { reasoningEffort: 'medium', speedMode: 'standard', }) - gatewayMocks.getAvailableModelIds.mockResolvedValue(['gpt-5.5', 'gpt-5.4-mini']) + gatewayMocks.getAvailableModels.mockResolvedValue(modelsWithoutReasoning('gpt-5.5', 'gpt-5.4-mini')) gatewayMocks.resumeThread.mockRejectedValue(new Error('thread not found')) const state = useDesktopState() diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index 50244a778..0ac7fa8df 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -6,7 +6,7 @@ import { getAvailableCollaborationModes, getAccountRateLimits, renameThread, - getAvailableModelIds, + getAvailableModels, getCurrentModelConfig, getPendingServerRequests, getSkillsList, @@ -33,12 +33,14 @@ import { subscribeCodexNotifications, startThreadTurn, type RpcNotification, + type AvailableModel, type SkillInfo, type ThreadQueueState, type WorkspaceRootsState, } from '../api/codexGateway' import { CodexApiError } from '../api/codexErrors' import { normalizeFileChangeStatus, toUiFileChanges } from '../api/normalizers/v2' +import { REASONING_EFFORTS } from '../types/codex' import type { CollaborationModeKind, CollaborationModeOption, @@ -91,7 +93,7 @@ const TURN_START_FOLLOW_UP_SYNC_DELAY_MS = 3000 const RECENT_THREAD_MESSAGE_LOAD_REUSE_MS = 2000 const RECENT_THREAD_LIST_LOAD_REUSE_MS = 2000 const RECENT_SKILLS_LOAD_REUSE_MS = 2000 -const REASONING_EFFORT_OPTIONS: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] +const REASONING_EFFORT_OPTIONS: readonly ReasoningEffort[] = REASONING_EFFORTS const GLOBAL_SERVER_REQUEST_SCOPE = '__global__' const MODEL_FALLBACK_ID = 'gpt-5.4-mini' const OPENCODE_ZEN_DEFAULT_MODEL = 'big-pickle' @@ -1423,6 +1425,8 @@ export function useDesktopState() { let hasLoadedPersistedQueueState = false const eventUnreadByThreadId = ref>({}) const availableModelIds = ref([]) + const availableModelReasoningEfforts = ref>({}) + const availableModelDefaultReasoningEfforts = ref>({}) const availableCollaborationModes = ref([ { value: 'default', label: 'Default' }, { value: 'plan', label: 'Plan' }, @@ -1654,6 +1658,42 @@ export function useDesktopState() { return normalizeProviderContextId(threadModelProviderByThreadId.value[normalizedThreadId] ?? activeProviderId.value) } + function readSupportedReasoningEffortsForModel(modelId: string): readonly ReasoningEffort[] { + return availableModelReasoningEfforts.value[modelId.trim()] ?? REASONING_EFFORT_OPTIONS + } + + function pickReasoningEffortForModel( + modelId: string, + preferredEffort: ReasoningEffort | '' = selectedReasoningEffort.value, + ): ReasoningEffort | '' { + const normalizedModelId = modelId.trim() + const supportedEfforts = readSupportedReasoningEffortsForModel(normalizedModelId) + if (preferredEffort && supportedEfforts.includes(preferredEffort)) return preferredEffort + + const defaultEffort = availableModelDefaultReasoningEfforts.value[normalizedModelId] + if (defaultEffort && supportedEfforts.includes(defaultEffort)) return defaultEffort + return supportedEfforts[0] ?? '' + } + + function ensureReasoningEffortSupportedForModel(modelId: string): void { + selectedReasoningEffort.value = pickReasoningEffortForModel(modelId) + } + + function setAvailableModelMetadata(models: AvailableModel[]): void { + const reasoningEfforts: Record = {} + const defaultReasoningEfforts: Record = {} + for (const model of models) { + if (model.supportedReasoningEfforts !== null) { + reasoningEfforts[model.id] = [...model.supportedReasoningEfforts] + } + if (model.defaultReasoningEffort) { + defaultReasoningEfforts[model.id] = model.defaultReasoningEffort + } + } + availableModelReasoningEfforts.value = reasoningEfforts + availableModelDefaultReasoningEfforts.value = defaultReasoningEfforts + } + function ensureAvailableModelIds(...modelIds: string[]): void { const nextModelIds = [...availableModelIds.value] for (const modelId of modelIds) { @@ -1681,6 +1721,7 @@ export function useDesktopState() { saveSelectedThreadId(nextThreadId) } selectedModelId.value = readProviderCompatibleSelectedModel(readModelIdForThread(nextThreadId)) + ensureReasoningEffortSupportedForModel(selectedModelId.value) selectedCollaborationMode.value = readSelectedCollaborationMode( selectedCollaborationModeByContext.value, nextThreadId, @@ -1712,9 +1753,10 @@ export function useDesktopState() { } selectedModelIdByContext.value = nextModelMap } - if (threadId.trim() === selectedThreadId.value) { + if (contextId === toThreadContextId(selectedThreadId.value)) { selectedModelId.value = readModelIdForThread(selectedThreadId.value) ensureAvailableModelIds(selectedModelId.value) + ensureReasoningEffortSupportedForModel(selectedModelId.value) } else { ensureAvailableModelIds(normalizedModelId) } @@ -1740,6 +1782,7 @@ export function useDesktopState() { ensureAvailableModelIds(normalizedModelId) if (selectedThreadId.value === normalizedThreadId) { selectedModelId.value = readModelIdForThread(selectedThreadId.value) + ensureReasoningEffortSupportedForModel(selectedModelId.value) } saveSelectedModelMap(selectedModelIdByContext.value) } @@ -1926,7 +1969,7 @@ export function useDesktopState() { } function setSelectedReasoningEffort(effort: ReasoningEffort | ''): void { - if (effort && !REASONING_EFFORT_OPTIONS.includes(effort)) { + if (effort && !readSupportedReasoningEffortsForModel(selectedModelId.value).includes(effort)) { return } selectedReasoningEffort.value = effort @@ -1987,11 +2030,13 @@ export function useDesktopState() { const targetProviderId = readProviderIdForThread(selectedThreadId.value) const isProviderBacked = targetProviderId !== 'codex' const normalizedSelectedModelId = readModelIdForThread(selectedThreadId.value) - const modelIds = await getAvailableModelIds({ + const models = await getAvailableModels({ includeProviderModels: isProviderBacked || options?.includeProviderModels !== false, requireProviderModels: isProviderBacked, providerId: isProviderBacked ? targetProviderId : undefined, }) + const modelIds = models.map((model) => model.id) + setAvailableModelMetadata(models) const providerModelContextId = toProviderModelContextId(targetProviderId) const providerScopedModelId = providerModelContextId ? normalizeStoredModelId(selectedModelIdByContext.value[providerModelContextId]) @@ -2043,12 +2088,10 @@ export function useDesktopState() { saveSelectedModelMap(selectedModelIdByContext.value) } - if ( - currentConfig.reasoningEffort && - REASONING_EFFORT_OPTIONS.includes(currentConfig.reasoningEffort) - ) { - selectedReasoningEffort.value = currentConfig.reasoningEffort - } + selectedReasoningEffort.value = pickReasoningEffortForModel( + selectedModelId.value, + currentConfig.reasoningEffort, + ) selectedSpeedMode.value = currentConfig.speedMode } catch (unknownError) { if (isCodexCliMissingError(unknownError)) { @@ -5674,6 +5717,7 @@ export function useDesktopState() { selectedThreadId, availableCollaborationModes, availableModelIds, + availableModelReasoningEfforts, selectedCollaborationMode, selectedModelId, selectedReasoningEffort, diff --git a/src/server/codexAppServerBridge.ts b/src/server/codexAppServerBridge.ts index da20b374d..8eb2431c3 100644 --- a/src/server/codexAppServerBridge.ts +++ b/src/server/codexAppServerBridge.ts @@ -46,7 +46,7 @@ import { resolveCodexCommand, resolveRipgrepCommand, } from '../commandResolution.js' -import type { CollaborationModeKind, ReasoningEffort } from '../types/codex.js' +import { isReasoningEffort, type CollaborationModeKind, type ReasoningEffort } from '../types/codex.js' import { isAbsoluteLikePath } from '../pathUtils.js' type JsonRpcCall = { @@ -5674,10 +5674,7 @@ async function appendThreadQueuedMessage(threadId: string, message: StoredQueued } function normalizeReasoningEffort(value: unknown): ReasoningEffort | '' { - const allowed: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] - return typeof value === 'string' && allowed.includes(value as ReasoningEffort) - ? (value as ReasoningEffort) - : '' + return isReasoningEffort(value) ? value : '' } function normalizeCollaborationModeReasoningEffort(value: ReasoningEffort | '' | null | undefined): ReasoningEffort | null { diff --git a/src/types/codex.ts b/src/types/codex.ts index a80fe5d5a..5eb29bccb 100644 --- a/src/types/codex.ts +++ b/src/types/codex.ts @@ -2,7 +2,22 @@ export type RpcEnvelope = { result: T } -export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' +export const REASONING_EFFORTS = [ + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', +] as const + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number] + +export function isReasoningEffort(value: unknown): value is ReasoningEffort { + return typeof value === 'string' && REASONING_EFFORTS.some((effort) => effort === value) +} export type SpeedMode = 'standard' | 'fast' export type CollaborationModeKind = 'default' | 'plan' diff --git a/tests/providers-models/gpt-5-6-max-and-ultra-thinking-levels.md b/tests/providers-models/gpt-5-6-max-and-ultra-thinking-levels.md new file mode 100644 index 000000000..8ea76ac4c --- /dev/null +++ b/tests/providers-models/gpt-5-6-max-and-ultra-thinking-levels.md @@ -0,0 +1,33 @@ +### GPT-5.6 Max and Ultra thinking levels + +#### Feature/Change Name +GPT-5.6 reasoning-level selection supports the new `max` and `ultra` values. + +#### Prerequisites/Setup +1. Install a Codex CLI version whose model catalog includes GPT-5.6 and its new reasoning levels. +2. Sign in with an account that can use a GPT-5.6 model. +3. Build and start the app. + +#### Steps +1. Start a new chat and select `GPT-5.6-Sol` or `GPT-5.6-Terra`. +2. Open the Thinking selector in light theme and confirm `Max` and `Ultra` are present. +3. Select `Max`, send a prompt, and confirm the turn starts without an invalid reasoning-effort error. +4. Select `Ultra`, send a second prompt, and confirm the turn starts without an invalid reasoning-effort error. +5. Select `GPT-5.6-Luna` and confirm `Max` is present but `Ultra` is absent. +6. Select `GPT-5.5` and confirm both `Max` and `Ultra` are absent. +7. Switch from a GPT-5.6 model with `Ultra` selected to GPT-5.5 and confirm Thinking changes to GPT-5.5's default effort. +8. Switch to dark theme and repeat the selector visibility checks. +9. Reload the page while `Ultra` is configured for GPT-5.6 Sol or Terra and confirm the selector still displays `Ultra`. + +#### Expected Results +- The Thinking selector follows each model's `supportedReasoningEfforts` metadata. +- GPT-5.6 Sol and Terra include `Max` and `Ultra`; GPT-5.6 Luna includes `Max` only; GPT-5.5 includes neither. +- Selecting either value passes the exact lowercase `max` or `ultra` value to Codex. +- Switching to a model that does not support the current effort selects that model's default effort. +- A configured `max` or `ultra` value survives config normalization and appears selected after refresh. +- The options remain readable in light and dark themes. + +#### Rollback/Cleanup +- Restore the preferred model and thinking level. + +--- diff --git a/tests/providers-models/index.md b/tests/providers-models/index.md index 6031ed8ed..d49764033 100644 --- a/tests/providers-models/index.md +++ b/tests/providers-models/index.md @@ -34,3 +34,4 @@ Return to the [manual test index](../../tests.md). | [Thread-locked providers across Zen, Codex, and OpenRouter](thread-locked-providers-across-zen-codex-and-openrouter.md) | | [Selected thread loads do not refetch provider models](selected-thread-loads-do-not-refetch-provider-models.md) | | [Provider-backed scheduled refreshes keep model menus populated](provider-backed-scheduled-refreshes-keep-model-menus-populated.md) | +| [GPT-5.6 Max and Ultra thinking levels](gpt-5-6-max-and-ultra-thinking-levels.md) |