fix(codex): support dynamic reasoning efforts (#1012)

* fix(codex): support model-reported reasoning efforts

* fix(web): prevent service worker edge caching

* ci: retrigger stuck Actions run

* fix(codex): accept dynamic reasoning effort values

* fix(web): restore reasoning effort on model switch failure
This commit is contained in:
ejj.cc
2026-07-12 18:38:56 +08:00
committed by GitHub
parent 4c76668a6c
commit d160203bb2
21 changed files with 372 additions and 58 deletions
+3 -1
View File
@@ -153,7 +153,9 @@ export type SandboxPolicy =
excludeSlashTmp?: boolean;
};
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
// The app server reports supported effort identifiers per model. Keep this
// open so newly introduced server values can flow through without a CLI update.
export type ReasoningEffort = string;
export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed';
export type CollaborationMode = {
+17
View File
@@ -111,6 +111,7 @@ vi.mock('./utils/codexCliOverrides', () => ({
}))
import { runCodex as runCodexImpl } from './runCodex'
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
describe('runCodex', () => {
beforeEach(() => {
@@ -242,4 +243,20 @@ describe('runCodex', () => {
replayTranscriptHistoryOnStart: true
}))
})
it('accepts and normalizes model-reported reasoning efforts from session config', async () => {
await runCodexImpl({ workingDirectory: '/tmp/project' })
const registration = harness.session.rpcHandlerManager.registerHandler.mock.calls.find(
([method]) => method === RPC_METHODS.SetSessionConfig
)
const handler = registration?.[1] as ((payload: unknown) => Promise<unknown>) | undefined
expect(handler).toBeTypeOf('function')
await handler?.({ modelReasoningEffort: 'max' })
await handler?.({ modelReasoningEffort: ' EXTREME ' })
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(2, 'max')
expect(mockCodexSession.setModelReasoningEffort).toHaveBeenNthCalledWith(3, 'extreme')
})
})
+2 -13
View File
@@ -19,11 +19,10 @@ import type { ReasoningEffort } from './appServerTypes';
import { parseCodexSpecialCommand } from './codexSpecialCommands';
import { listSlashCommands } from '@/modules/common/slashCommands';
import { resolveCodexSlashCommand } from './utils/slashCommands';
import { parseReasoningEffortValue } from './utils/reasoningEffort';
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
export async function runCodex(opts: {
startedBy?: 'runner' | 'terminal';
codexArgs?: string[];
@@ -304,16 +303,6 @@ export async function runCodex(opts: {
return parsed.data;
};
const resolveModelReasoningEffort = (value: unknown): ReasoningEffort | undefined => {
if (value === null) {
return undefined;
}
if (typeof value !== 'string' || !REASONING_EFFORTS.has(value as ReasoningEffort)) {
throw new Error('Invalid model reasoning effort');
}
return value as ReasoningEffort;
};
const resolveModel = (value: unknown): string => {
if (typeof value !== 'string') {
throw new Error('Invalid model');
@@ -363,7 +352,7 @@ export async function runCodex(opts: {
}
if (config.modelReasoningEffort !== undefined) {
currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort);
currentModelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort);
}
if (config.collaborationMode !== undefined) {
+2 -2
View File
@@ -123,7 +123,7 @@ describe('appServerConfig', () => {
it('passes model reasoning effort via thread config', () => {
const params = buildThreadStartParams({
cwd: '/workspace/project',
mode: { permissionMode: 'default', modelReasoningEffort: 'xhigh', collaborationMode: 'default' },
mode: { permissionMode: 'default', modelReasoningEffort: 'ultra', collaborationMode: 'default' },
mcpServers
});
@@ -133,7 +133,7 @@ describe('appServerConfig', () => {
args: ['mcp']
},
developer_instructions: codexSystemPrompt,
model_reasoning_effort: 'xhigh'
model_reasoning_effort: 'ultra'
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { parseReasoningEffortValue } from './reasoningEffort';
describe('parseReasoningEffortValue', () => {
it('normalizes non-empty model-reported values', () => {
expect(parseReasoningEffortValue(' EXTREME ')).toBe('extreme');
});
it('maps null to the default effort', () => {
expect(parseReasoningEffortValue(null)).toBeUndefined();
});
it('rejects empty and non-string values', () => {
expect(() => parseReasoningEffortValue(' ')).toThrow('Invalid model reasoning effort');
expect(() => parseReasoningEffortValue(42)).toThrow('Invalid model reasoning effort');
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { ReasoningEffort } from '../appServerTypes';
export function parseReasoningEffortValue(value: unknown): ReasoningEffort | undefined {
if (value === null) {
return undefined;
}
if (typeof value !== 'string') {
throw new Error('Invalid model reasoning effort');
}
const effort = value.trim().toLowerCase();
if (!effort) {
throw new Error('Invalid model reasoning effort');
}
return effort;
}
@@ -41,6 +41,12 @@ describe('resolveCodexSlashCommand', () => {
expect(resolveCodexSlashCommand('/reasoning low', state)).toMatchObject({
updates: { modelReasoningEffort: 'low' }
});
expect(resolveCodexSlashCommand('/reasoning max', state)).toMatchObject({
updates: { modelReasoningEffort: 'max' }
});
expect(resolveCodexSlashCommand('/reasoning EXTREME', state)).toMatchObject({
updates: { modelReasoningEffort: 'extreme' }
});
expect(resolveCodexSlashCommand('/permissions yolo', state)).toMatchObject({
updates: { permissionMode: 'yolo' }
});
+5 -10
View File
@@ -3,8 +3,8 @@ import type { CodexPermissionMode } from '@hapi/protocol/types';
import type { ReasoningEffort } from '../appServerTypes';
import type { EnhancedMode } from '../loop';
import type { SlashCommand } from '@/modules/common/slashCommands';
import { parseReasoningEffortValue } from './reasoningEffort';
const REASONING_EFFORTS = new Set<ReasoningEffort>(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000;
const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([
@@ -186,16 +186,11 @@ export function resolveCodexSlashCommand(
updates: { modelReasoningEffort: null }
};
}
if (!REASONING_EFFORTS.has(rest as ReasoningEffort)) {
const effort = parseReasoningEffortValue(rest);
return {
kind: 'handled',
message: `Unknown Codex reasoning effort: ${rest}`
};
}
return {
kind: 'handled',
message: `Codex reasoning effort set to ${rest}`,
updates: { modelReasoningEffort: rest as ReasoningEffort }
message: `Codex reasoning effort set to ${effort}`,
updates: { modelReasoningEffort: effort }
};
}
@@ -259,7 +254,7 @@ export function resolveCodexSlashCommand(
'- `/compact` — compact current Codex thread context',
'- `/status` — show current Codex session config',
'- `/model [name|auto]` — show or set model',
'- `/reasoning [low|medium|high|xhigh|default]` — show or set reasoning effort',
'- `/reasoning [level|default]` — show or set reasoning effort',
'- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)',
'- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode',
'',
+14
View File
@@ -121,6 +121,20 @@ describe('codexCommand', () => {
}
})
it('accepts and normalizes a dynamic model reasoning effort', async () => {
await codexCommand.run(createCommandContext([
'--started-by',
'runner',
'--model-reasoning-effort',
' EXTREME '
]))
expect(runCodexMock).toHaveBeenCalledWith({
startedBy: 'runner',
modelReasoningEffort: 'extreme'
})
})
it('prints the upgrade error and exits when the local version check fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
+2 -15
View File
@@ -7,20 +7,7 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'
import type { CodexPermissionMode } from '@hapi/protocol/types'
import type { ReasoningEffort } from '@/codex/appServerTypes'
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
function parseReasoningEffort(value: string): ReasoningEffort {
switch (value) {
case 'none':
case 'minimal':
case 'low':
case 'medium':
case 'high':
case 'xhigh':
return value
default:
throw new Error('Invalid --model-reasoning-effort value')
}
}
import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort'
// Mirror the web /service-tier endpoint's enum so the internal resume spawn
// path can never seed/persist an unsupported tier string.
@@ -86,7 +73,7 @@ export const codexCommand: CommandDefinition = {
if (!effort) {
throw new Error('Missing --model-reasoning-effort value')
}
options.modelReasoningEffort = parseReasoningEffort(effort)
options.modelReasoningEffort = parseReasoningEffortValue(effort)
} else if (arg === '--service-tier') {
const tier = commandArgs[++i]
if (!tier) {
+10 -2
View File
@@ -191,10 +191,18 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
}
function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response {
return new Response(Bun.file(asset.sourcePath), {
headers: {
const headers: Record<string, string> = {
'Content-Type': asset.mimeType
}
if (asset.path === '/sw.js') {
headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
headers['CDN-Cache-Control'] = 'no-store'
headers['Cloudflare-CDN-Cache-Control'] = 'no-store'
}
return new Response(Bun.file(asset.sourcePath), {
headers
})
}
@@ -471,7 +471,7 @@ export function HappyComposer(props: {
? getCodexComposerReasoningEffortOptions(
modelReasoningEffort,
agentFlavor,
agentFlavor === 'opencode' ? availableModelReasoningEffortOptions : undefined
availableModelReasoningEffortOptions
)
: [],
[agentFlavor, modelReasoningEffort, availableModelReasoningEffortOptions]
@@ -23,6 +23,39 @@ describe('getCodexComposerReasoningEffortOptions', () => {
])
})
it('uses arbitrary model-reported efforts for Codex', () => {
expect(getCodexComposerReasoningEffortOptions('extreme', 'codex', [
{ value: 'low' },
{ value: 'medium' },
{ value: 'high' },
{ value: 'xhigh' },
{ value: 'max' },
{ value: 'ultra' },
{ value: 'extreme' }
])).toEqual([
{ value: null, label: 'Default' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' },
{ value: 'max', label: 'Max' },
{ value: 'ultra', label: 'Ultra' },
{ value: 'extreme', label: 'Extreme' }
])
})
it('keeps an unsupported current Codex effort visible', () => {
expect(getCodexComposerReasoningEffortOptions('ultra', 'codex', [
{ value: 'low' },
{ value: 'max' }
])).toEqual([
{ value: null, label: 'Default' },
{ value: 'ultra', label: 'Ultra' },
{ value: 'low', label: 'Low' },
{ value: 'max', label: 'Max' }
])
})
it('returns no options for OpenCode until dynamic options are available', () => {
expect(getCodexComposerReasoningEffortOptions(null, 'opencode')).toEqual([])
expect(getCodexComposerReasoningEffortOptions(null, 'opencode', [])).toEqual([])
@@ -14,7 +14,8 @@ const CODEX_REASONING_EFFORT_LABELS: Record<string, string> = {
medium: 'Medium',
high: 'High',
xhigh: 'XHigh',
max: 'Max'
max: 'Max',
ultra: 'Ultra'
}
function normalizeCodexComposerReasoningEffort(effort?: string | null): string | null {
@@ -31,7 +32,7 @@ function formatCodexReasoningEffortLabel(effort: string): string {
?? `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`
}
function buildOpencodeComposerReasoningEffortOptions(
function buildDynamicReasoningEffortOptions(
currentEffort: string | null,
dynamicOptions: ComposerReasoningEffortSourceOption[]
): CodexComposerReasoningEffortOption[] {
@@ -66,7 +67,11 @@ export function getCodexComposerReasoningEffortOptions(
if (!dynamicOptions || dynamicOptions.length === 0) {
return []
}
return buildOpencodeComposerReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions)
return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions)
}
if (dynamicOptions && dynamicOptions.length > 0) {
return buildDynamicReasoningEffortOptions(normalizedCurrentEffort, dynamicOptions)
}
const options: CodexComposerReasoningEffortOption[] = [
@@ -1,10 +1,12 @@
import type { AgentType, CodexReasoningEffort } from './types'
import { CODEX_REASONING_EFFORT_OPTIONS } from './types'
import { useTranslation } from '@/lib/use-translation'
import { getCodexComposerReasoningEffortOptions } from '@/components/AssistantChat/codexReasoningEffortOptions'
export function ReasoningEffortSelector(props: {
agent: AgentType
value: CodexReasoningEffort
availableOptions?: Array<{ value: string; name?: string }>
isDisabled: boolean
onChange: (value: CodexReasoningEffort) => void
}) {
@@ -14,6 +16,15 @@ export function ReasoningEffortSelector(props: {
return null
}
const options = props.agent === 'codex' && props.availableOptions?.length
? getCodexComposerReasoningEffortOptions(null, props.agent, props.availableOptions).map((option) => ({
value: option.value ?? 'default',
label: option.label
}))
: CODEX_REASONING_EFFORT_OPTIONS.filter(
(option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max'
)
return (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
@@ -26,7 +37,7 @@ export function ReasoningEffortSelector(props: {
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{CODEX_REASONING_EFFORT_OPTIONS.filter((option) => props.agent === 'opencode' ? option.value !== 'xhigh' : option.value !== 'max').map((option) => (
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
+23 -1
View File
@@ -12,6 +12,7 @@ import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggesti
import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions'
import { useRecentPaths } from '@/hooks/useRecentPaths'
import { useTranslation } from '@/lib/use-translation'
import { getCodexModelReasoningEfforts } from '@/lib/codexModelCapabilities'
import {
buildNewSessionCursorPickerState,
isCursorEffortWireAllowed,
@@ -199,6 +200,26 @@ export function NewSession(props: {
}
return options
}, [codexModelsState.models, model])
const codexSupportedReasoningEfforts = useMemo(
() => getCodexModelReasoningEfforts(codexModelsState.models, model),
[codexModelsState.models, model]
)
const codexReasoningEffortOptions = useMemo(
() => codexSupportedReasoningEfforts?.map((value) => ({ value })),
[codexSupportedReasoningEfforts]
)
useEffect(() => {
if (
agent !== 'codex'
|| modelReasoningEffort === 'default'
|| !codexSupportedReasoningEfforts
|| codexSupportedReasoningEfforts.includes(modelReasoningEffort)
) {
return
}
setModelReasoningEffort('default')
}, [agent, codexSupportedReasoningEfforts, modelReasoningEffort])
const cursorModelsState = useCursorModelsForMachine({
api: props.api,
machineId,
@@ -719,7 +740,8 @@ export function NewSession(props: {
<ReasoningEffortSelector
agent={agent}
value={modelReasoningEffort}
isDisabled={isFormDisabled}
availableOptions={agent === 'codex' ? codexReasoningEffortOptions : undefined}
isDisabled={isFormDisabled || (agent === 'codex' && codexModelsState.isLoading)}
onChange={setModelReasoningEffort}
/>
<YoloToggle
+2 -1
View File
@@ -10,7 +10,8 @@ import type { AgentFlavor, ClaudeEffortLevel } from '@hapi/protocol'
export type AgentType = AgentFlavor
export type SessionType = 'simple' | 'worktree'
export type CodexReasoningEffort = 'default' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
// Codex reports supported efforts dynamically; keep this open for new server values.
export type CodexReasoningEffort = string
export type ClaudeEffort = 'auto' | ClaudeEffortLevel
function modelPresetOptions<TModel extends string>(
+38 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
applyModelChangeWithReasoningRollback,
buildGoalStateMessages,
isScratchlistHotkeyBlockedTarget,
isScratchlistToggleHotkey,
@@ -9,6 +10,42 @@ import {
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import type { AttachmentMetadata, DecryptedMessage } from '@/types/api'
describe('applyModelChangeWithReasoningRollback', () => {
it('restores the previous effort when the model switch fails after clearing it', async () => {
const modelError = new Error('model switch failed')
const setModel = vi.fn(async () => { throw modelError })
const setModelReasoningEffort = vi.fn(async () => {})
await expect(applyModelChangeWithReasoningRollback({
model: 'gpt-next',
previousModelReasoningEffort: 'extreme',
shouldClearReasoningEffort: true,
setModel,
setModelReasoningEffort
})).rejects.toBe(modelError)
expect(setModelReasoningEffort.mock.calls).toEqual([[null], ['extreme']])
expect(setModel).toHaveBeenCalledWith('gpt-next')
})
it('keeps the cleared effort when the model switch succeeds', async () => {
const setModel = vi.fn(async () => {})
const setModelReasoningEffort = vi.fn(async () => {})
await applyModelChangeWithReasoningRollback({
model: 'gpt-next',
previousModelReasoningEffort: 'extreme',
shouldClearReasoningEffort: true,
setModel,
setModelReasoningEffort
})
expect(setModelReasoningEffort).toHaveBeenCalledOnce()
expect(setModelReasoningEffort).toHaveBeenCalledWith(null)
expect(setModel).toHaveBeenCalledWith('gpt-next')
})
})
function userMessage(props: {
id: string
createdAt: number
+70 -4
View File
@@ -21,6 +21,10 @@ import { buildConversationOutline } from '@/chat/outline'
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
import { isQueuedForInvocation, mergeMessages } from '@/lib/messages'
import { inactiveSessionCanResume } from '@/lib/sessionResume'
import {
getCodexModelReasoningEfforts,
supportsCodexReasoningEffort
} from '@/lib/codexModelCapabilities'
import { HappyComposer, type ComposerSendError } from '@/components/AssistantChat/HappyComposer'
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
@@ -64,6 +68,33 @@ import { useVoiceOptional } from '@/lib/voice-context'
import { VoiceBackendSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
type SessionModelSelection = { provider: string; modelId: string } | string | null
export async function applyModelChangeWithReasoningRollback(args: {
model: SessionModelSelection
previousModelReasoningEffort: string | null
shouldClearReasoningEffort: boolean
setModel: (model: SessionModelSelection) => Promise<void>
setModelReasoningEffort: (effort: string | null) => Promise<void>
}): Promise<void> {
let clearedReasoningEffort = false
try {
if (args.shouldClearReasoningEffort) {
await args.setModelReasoningEffort(null)
clearedReasoningEffort = true
}
await args.setModel(args.model)
} catch (error) {
if (clearedReasoningEffort && args.previousModelReasoningEffort) {
await args.setModelReasoningEffort(args.previousModelReasoningEffort).catch((restoreError) => {
console.error('Failed to restore model reasoning effort:', restoreError)
})
}
throw error
}
}
/**
* Returns whether a PendingSchedule should trigger an auto-clear timer.
*
@@ -516,6 +547,16 @@ function SessionChatInner(props: SessionChatProps) {
}
return options
}, [agentFlavor, codexModelsState.models])
const codexSupportedReasoningEfforts = useMemo(
() => agentFlavor === 'codex'
? getCodexModelReasoningEfforts(codexModelsState.models, props.session.model)
: undefined,
[agentFlavor, codexModelsState.models, props.session.model]
)
const codexReasoningEffortOptions = useMemo(
() => codexSupportedReasoningEfforts?.map((value) => ({ value })),
[codexSupportedReasoningEfforts]
)
const opencodeModelsState = useOpencodeModels({
api: props.api,
sessionId: props.session.id,
@@ -909,16 +950,39 @@ function SessionChatInner(props: SessionChatProps) {
}, [setCollaborationMode, props.onRefresh, haptic])
// Model mode change handler
const handleModelChange = useCallback(async (model: { provider: string; modelId: string } | string | null) => {
const handleModelChange = useCallback(async (model: SessionModelSelection) => {
const previousModelReasoningEffort = props.session.modelReasoningEffort
const shouldClearReasoningEffort = agentFlavor === 'codex'
&& Boolean(previousModelReasoningEffort)
&& supportsCodexReasoningEffort(
codexModelsState.models,
model,
previousModelReasoningEffort
) === false
try {
await setModel(model)
await applyModelChangeWithReasoningRollback({
model,
previousModelReasoningEffort,
shouldClearReasoningEffort,
setModel,
setModelReasoningEffort
})
haptic.notification('success')
props.onRefresh()
} catch (e) {
haptic.notification('error')
console.error('Failed to set model:', e)
}
}, [setModel, props.onRefresh, haptic])
}, [
agentFlavor,
codexModelsState.models,
props.session.modelReasoningEffort,
setModelReasoningEffort,
setModel,
props.onRefresh,
haptic
])
const handleCursorBaseModelChange = useCallback(async (baseKey: string | null) => {
if (!cursorPicker) {
@@ -1243,7 +1307,9 @@ function SessionChatInner(props: SessionChatProps) {
piModels={agentFlavor === 'pi' ? (piModelsState.availableModels.length > 0 ? piModelsState.availableModels : piCachedModels) : undefined}
piSelectedModel={agentFlavor === 'pi' ? piSelectedModel : undefined}
availableModelReasoningEffortOptions={
agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0
agentFlavor === 'codex'
? codexReasoningEffortOptions
: agentFlavor === 'opencode' && opencodeReasoningEffortState.options.length > 0
? opencodeReasoningEffortState.options
: undefined
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { CodexModelSummary } from '@/types/api'
import {
getCodexModelReasoningEfforts,
resolveCodexModel,
supportsCodexReasoningEffort
} from './codexModelCapabilities'
const models: CodexModelSummary[] = [
{
id: 'gpt-5.6-sol',
displayName: 'GPT-5.6-Sol',
isDefault: true,
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']
},
{
id: 'gpt-5.6-luna',
displayName: 'GPT-5.6-Luna',
isDefault: false,
supportedReasoningEfforts: ['LOW', 'medium', 'max', 'max', ' ']
}
]
describe('Codex model capabilities', () => {
it('resolves auto and missing model ids to the reported default model', () => {
expect(resolveCodexModel(models, 'auto')?.id).toBe('gpt-5.6-sol')
expect(resolveCodexModel(models, null)?.id).toBe('gpt-5.6-sol')
expect(resolveCodexModel(models, { modelId: 'gpt-5.6-luna' })?.id).toBe('gpt-5.6-luna')
})
it('returns normalized, de-duplicated efforts for the selected model', () => {
expect(getCodexModelReasoningEfforts(models, 'gpt-5.6-luna')).toEqual([
'low',
'medium',
'max'
])
})
it('distinguishes unsupported efforts from unavailable capability data', () => {
expect(supportsCodexReasoningEffort(models, 'gpt-5.6-sol', 'ultra')).toBe(true)
expect(supportsCodexReasoningEffort(models, 'gpt-5.6-luna', 'ultra')).toBe(false)
expect(supportsCodexReasoningEffort(models, 'unknown', 'ultra')).toBeUndefined()
})
})
+44
View File
@@ -0,0 +1,44 @@
import type { CodexModelSummary } from '@/types/api'
type ModelSelection = string | { modelId: string } | null | undefined
function normalizeEfforts(efforts: readonly string[] | undefined): string[] | undefined {
if (!efforts) return undefined
const normalized = [...new Set(
efforts
.map((effort) => effort.trim().toLowerCase())
.filter(Boolean)
)]
return normalized.length > 0 ? normalized : undefined
}
export function resolveCodexModel(
models: readonly CodexModelSummary[],
model: ModelSelection
): CodexModelSummary | null {
const normalizedModelId = (typeof model === 'string' ? model : model?.modelId)?.trim()
if (!normalizedModelId || normalizedModelId === 'auto') {
return models.find((model) => model.isDefault) ?? models[0] ?? null
}
return models.find((model) => model.id === normalizedModelId) ?? null
}
export function getCodexModelReasoningEfforts(
models: readonly CodexModelSummary[],
model: ModelSelection
): string[] | undefined {
return normalizeEfforts(resolveCodexModel(models, model)?.supportedReasoningEfforts)
}
export function supportsCodexReasoningEffort(
models: readonly CodexModelSummary[],
model: ModelSelection,
effort: string | null | undefined
): boolean | undefined {
const supportedEfforts = getCodexModelReasoningEfforts(models, model)
if (!supportedEfforts) return undefined
if (!effort) return true
return supportedEfforts.includes(effort.trim().toLowerCase())
}