From 564f73a3f0ce9747854f5226540362ca08087368 Mon Sep 17 00:00:00 2001 From: Jacob Kim Date: Fri, 10 Jul 2026 11:35:48 +0900 Subject: [PATCH] fix(providers): scope Codex effort defaults and options to per-model capability GPT-5.6's per-model reasoning-effort scale (Sol/Terra up to ultra, Luna up to max, GPT-5.5 up to xhigh) wasn't reflected anywhere: the default effort fallback ignored Codex's own recommendation, and every effort picker (preset editor, chat input cycle, settings default, providers-section help) offered the full scale regardless of the selected model. Also fixes the preset-edit popover closing itself immediately via Radix's default focus-return, and merges the App Server's model/list catalog with the static GPT-5.6 catalog so newer models are always selectable even on older installed Codex binaries. - Add dynamic + static per-model supported/default reasoning-effort registries in model-catalog.ts, sourced from the verified codex-cli 0.144.1 catalog - Scope all Codex effort selectors and cycle order to the active model, clamping to the nearest supported value on model switch - Add missing max/ultra entries to the providers-section reasoning help list - Fix PresetBar edit popover auto-closing via onCloseAutoFocus --- docs/providers/provider-runtimes.md | 2 +- src/components/layout/PresetBar.tsx | 9 +- .../settings-dialog-providers-section.tsx | 37 ++- .../layout/settings-dialog-sections.tsx | 10 +- src/components/layout/task-preset-editor.tsx | 34 ++- src/components/session/ChatInput.tsx | 28 +- src/components/session/chat-input.runtime.ts | 12 +- src/lib/providers/model-catalog.ts | 249 ++++++++++++++++-- src/lib/providers/runtime-option-contract.ts | 16 ++ src/lib/providers/use-codex-model-catalog.ts | 51 +++- src/lib/task-presets.ts | 10 +- src/store/app.store.ts | 4 +- tests/chat-input.runtime.test.ts | 9 + tests/codex-model-catalog-merge.test.ts | 31 +++ tests/model-catalog.test.ts | 91 ++++++- 15 files changed, 548 insertions(+), 45 deletions(-) create mode 100644 tests/codex-model-catalog-merge.test.ts diff --git a/docs/providers/provider-runtimes.md b/docs/providers/provider-runtimes.md index 1f740d7..a21c036 100644 --- a/docs/providers/provider-runtimes.md +++ b/docs/providers/provider-runtimes.md @@ -274,7 +274,7 @@ If you want the user-facing setup workflow instead of the runtime internals, use - `Guided`: `workspace-write` + `untrusted` + network off + web search cached - `Auto`: `danger-full-access` + `never` + network on + web search live -Current Codex defaults follow the App Server-aligned baseline in Stave: `workspace-write` file access, `untrusted` approvals, `network access = off`, `web search = cached`, `reasoning effort = medium`, raw reasoning off, and reasoning summary auto-detection enabled. +Current Codex defaults follow the App Server-aligned baseline in Stave: `workspace-write` file access, `untrusted` approvals, `network access = off`, `web search = cached`, `reasoning effort = xhigh` (the codex-cli 0.144.1 server-catalog default for the GPT-5.6 family), raw reasoning off, and reasoning summary auto-detection enabled. Stave now forwards an explicit `show_raw_agent_reasoning: false` override when the Codex UI toggle is off, so local CLI defaults or config files do not leave raw reasoning enabled unexpectedly. diff --git a/src/components/layout/PresetBar.tsx b/src/components/layout/PresetBar.tsx index 00a8d81..9018980 100644 --- a/src/components/layout/PresetBar.tsx +++ b/src/components/layout/PresetBar.tsx @@ -219,7 +219,14 @@ function PresetChip(props: PresetChipProps) { - + event.preventDefault()} + > onRequestEdit()}> Edit… diff --git a/src/components/layout/settings-dialog-providers-section.tsx b/src/components/layout/settings-dialog-providers-section.tsx index 5f96dda..1a22c8a 100644 --- a/src/components/layout/settings-dialog-providers-section.tsx +++ b/src/components/layout/settings-dialog-providers-section.tsx @@ -48,6 +48,7 @@ import { DEFAULT_CLAUDE_OPUS_MODEL, DEFAULT_CLAUDE_SONNET_MODEL, getDefaultModelForProvider, + listCodexReasoningEffortsForModel, } from "@/lib/providers/model-catalog"; import { useCodexModelCatalog } from "@/lib/providers/use-codex-model-catalog"; import { UI_LAYER_CLASS } from "@/lib/ui-layers"; @@ -366,6 +367,22 @@ const CODEX_REASONING_EFFORT_HELP = [ example: "Reserve this for genuinely complex work where you want Codex to think much longer.", }, + { + value: "max", + label: "Max", + description: + "Highest deliberation on the GPT-5.6 effort scale, above `xhigh`.", + example: + "Reserve this for hard multi-step implementation or debugging work where accuracy matters more than speed.", + }, + { + value: "ultra", + label: "Ultra", + description: + "The deepest reasoning budget Codex offers and the highest latency cost. Not every model accepts this tier (e.g. GPT-5.6 Luna caps at `max`).", + example: + "Reserve this for the hardest frontier-model tasks where you want Codex to think as long as possible.", + }, ] as const satisfies readonly ExplainedSelectOption< NonNullable >[]; @@ -604,6 +621,7 @@ export function ProvidersSection() { codexNetworkAccess, codexApprovalPolicy, codexReasoningEffort, + modelCodex, codexWebSearch, codexShowRawReasoning, codexReasoningSummary, @@ -640,6 +658,7 @@ export function ProvidersSection() { state.settings.codexNetworkAccess, state.settings.codexApprovalPolicy, state.settings.codexReasoningEffort, + state.settings.modelCodex, state.settings.codexWebSearch, state.settings.codexShowRawReasoning, state.settings.codexReasoningSummary, @@ -683,6 +702,18 @@ export function ProvidersSection() { model: defaultClaudeAdvisorModel, fallback: DEFAULT_CLAUDE_SONNET_MODEL, }); + // Scoped to the default Codex model so, e.g., GPT-5.6 Luna never offers + // "Ultra" here — a value only Sol/Terra accept. "Minimal" is always kept + // available since it's a legacy value Stave still maps to "low" at + // runtime, not part of the current model-reported effort scale. + const codexReasoningEffortOptions = useMemo(() => { + const supported = listCodexReasoningEffortsForModel({ model: modelCodex }); + return CODEX_REASONING_EFFORT_HELP.filter( + (option) => + option.value === "minimal" || + (supported as readonly string[]).includes(option.value), + ); + }, [modelCodex]); const claudeAdvisorModelEnabled = claudeAdvisorModel.trim().length > 0; const activeClaudeAdvisorSourceModel = resolveClaudeAdvisorSourceModel({ model: claudeAdvisorModel.trim() || modelClaude, @@ -1237,15 +1268,15 @@ export function ProvidersSection() { } > updateSettings({ patch: { diff --git a/src/components/layout/settings-dialog-sections.tsx b/src/components/layout/settings-dialog-sections.tsx index b86b87f..bb07f12 100644 --- a/src/components/layout/settings-dialog-sections.tsx +++ b/src/components/layout/settings-dialog-sections.tsx @@ -104,7 +104,7 @@ import { useCodexModelCatalog } from "@/lib/providers/use-codex-model-catalog"; import { BOOLEAN_TOGGLE_OPTIONS, CLAUDE_EFFORT_OPTIONS, - CODEX_EFFORT_OPTIONS, + listCodexEffortOptionsForModel, } from "@/lib/providers/runtime-option-contract"; import { cn } from "@/lib/utils"; import { @@ -1789,6 +1789,12 @@ function ModelsSection() { () => modelOptions.filter((option) => option.providerId === "codex"), [modelOptions], ); + // Scoped to the default Codex model so, e.g., GPT-5.6 Luna never offers + // "Ultra" here — a value only Sol/Terra accept. + const codexEffortOptions = useMemo( + () => listCodexEffortOptionsForModel({ model: modelCodex }), + [modelCodex], + ); const updateEligibleModels = useCallback( (args: { providerId: "claude-code" | "codex"; models: string[] }) => { updateSettings({ @@ -1950,7 +1956,7 @@ function ModelsSection() { - {CODEX_EFFORT_OPTIONS.map((option) => ( + {codexEffortOptions.map((option) => ( {option.label} diff --git a/src/components/layout/task-preset-editor.tsx b/src/components/layout/task-preset-editor.tsx index ff85138..116b3db 100644 --- a/src/components/layout/task-preset-editor.tsx +++ b/src/components/layout/task-preset-editor.tsx @@ -59,9 +59,11 @@ export function TaskPresetEditor(props: TaskPresetEditorProps) { () => listModelsForPresetProvider(provider), [provider], ); + // Model-scoped so, e.g., GPT-5.6 Luna never offers "Ultra" — a value only + // Sol/Terra accept. const effortOptions = useMemo( - () => listEffortsForPresetProvider(provider), - [provider], + () => listEffortsForPresetProvider(provider, model), + [provider, model], ); const providerOptions = useMemo< { value: ProviderId; label: string }[] @@ -91,10 +93,30 @@ export function TaskPresetEditor(props: TaskPresetEditorProps) { : "claude-code"; setProvider(providerId); const nextModels = listModelsForPresetProvider(providerId); - if (!nextModels.includes(model)) { - setModel(getDefaultModelForProvider({ providerId })); + const nextModel = nextModels.includes(model) + ? model + : getDefaultModelForProvider({ providerId }); + if (nextModel !== model) { + setModel(nextModel); } - const nextEfforts = listEffortsForPresetProvider(providerId).map( + const nextEfforts = listEffortsForPresetProvider( + providerId, + nextModel, + ).map((option) => option.value); + if ( + effort !== DEFAULT_EFFORT_VALUE && + !nextEfforts.includes(effort as TaskPresetEffort) + ) { + setEffort(DEFAULT_EFFORT_VALUE); + } + } + + function handleModelChange(nextModel: string) { + setModel(nextModel); + // Some Codex models accept a narrower effort scale than others (e.g. + // GPT-5.6 Luna has no "Ultra"); drop back to the default when the + // currently selected effort isn't valid for the new model. + const nextEfforts = listEffortsForPresetProvider(provider, nextModel).map( (option) => option.value, ); if ( @@ -170,7 +192,7 @@ export function TaskPresetEditor(props: TaskPresetEditorProps) { Model - diff --git a/src/components/session/ChatInput.tsx b/src/components/session/ChatInput.tsx index e6085f9..72a38a6 100644 --- a/src/components/session/ChatInput.tsx +++ b/src/components/session/ChatInput.tsx @@ -44,6 +44,7 @@ import { type ProviderCommandCatalogState, } from "@/lib/providers/provider-command-catalog"; import { + clampCodexEffortToModel, getDefaultModelForProvider, getProviderLabel, listProviderIds, @@ -1385,12 +1386,21 @@ function BaseChatInput() { return () => updateSettings({ patch: { - codexReasoningEffort: cycleCodexEffortValue(codexReasoningEffort), + codexReasoningEffort: cycleCodexEffortValue( + codexReasoningEffort, + activeModel, + ), }, }); } return undefined; - }, [activeProvider, claudeEffort, codexReasoningEffort, updateSettings]); + }, [ + activeModel, + activeProvider, + claudeEffort, + codexReasoningEffort, + updateSettings, + ]); const goalStatus = useMemo( () => buildChatInputGoalStatus({ @@ -1879,6 +1889,20 @@ function BaseChatInput() { }); return; } + if (selection.providerId === "codex") { + // Some Codex models accept a narrower effort scale than others + // (e.g. GPT-5.6 Luna has no "Ultra") — clamp so switching models + // never leaves an unsupported effort selected. + updateSettings({ + patch: { + codexReasoningEffort: clampCodexEffortToModel({ + model: nextModel, + effort: codexReasoningEffort, + }), + }, + }); + return; + } }} fastMode={activeProvider === "codex" ? codexFastMode : undefined} onFastModeChange={ diff --git a/src/components/session/chat-input.runtime.ts b/src/components/session/chat-input.runtime.ts index 001c899..b3103a5 100644 --- a/src/components/session/chat-input.runtime.ts +++ b/src/components/session/chat-input.runtime.ts @@ -1,6 +1,7 @@ import type { PromptInputRuntimeStatusItem } from "@/components/ai-elements/prompt-input-runtime-bar"; import type { PromptInputGoalStatus } from "@/components/ai-elements/prompt-input-goal-status"; import { resolveEffectiveCodexFileAccessMode } from "@/lib/providers/codex-runtime-options"; +import { listCodexReasoningEffortsForModel } from "@/lib/providers/model-catalog"; import type { ProviderGoalSnapshot, ProviderId, @@ -99,12 +100,21 @@ export function cycleClaudeEffortValue(current: AppSettings["claudeEffort"]) { }); } +/** + * Cycles the Codex reasoning effort. When `model` is provided, the cycle is + * scoped to that model's supported efforts (e.g. GPT-5.6 Luna skips "ultra") + * so clicking through the toolbar never lands on a value the model rejects. + */ export function cycleCodexEffortValue( current: AppSettings["codexReasoningEffort"], + model?: string, ) { + const order = model + ? listCodexReasoningEffortsForModel({ model }) + : CODEX_EFFORT_CYCLE_ORDER; return cycleOptionValue({ current, - order: CODEX_EFFORT_CYCLE_ORDER, + order, }); } diff --git a/src/lib/providers/model-catalog.ts b/src/lib/providers/model-catalog.ts index a19a84e..aaaa5f2 100644 --- a/src/lib/providers/model-catalog.ts +++ b/src/lib/providers/model-catalog.ts @@ -43,6 +43,9 @@ export const CLAUDE_SDK_MODEL_OPTIONS = [ // Source: // - local `codex app-server` / CLI baseline support // - https://developers.openai.com/codex/models (GPT-5.6 family, 2026-07-09) +// - verified against codex-cli 0.144.1 `model/list` (2026-07-10): the server +// catalog now ships gpt-5.6-sol/terra/luna (default effort xhigh; sol/terra +// support up to "ultra", luna up to "max") alongside gpt-5.5/5.4/5.4-mini. // GPT-5.6 ships as Sol (flagship), Terra (balanced), and Luna (fast/cheap). // gpt-5.5 is kept as the previous-generation fallback; gpt-5.4 variants and // codex-spark moved to legacy display names only. @@ -221,8 +224,36 @@ export interface ModelCapability { defaultCodexReasoningEffort?: NonNullable< ProviderRuntimeOptions["codexReasoningEffort"] >; + /** + * Reasoning-effort values the model actually accepts, per the Codex + * `model/list` catalog. Omitted for models where every effort level is + * accepted (or the constraint is unknown) — callers should treat a missing + * value as "no restriction" rather than "nothing supported". + */ + supportedCodexReasoningEfforts?: readonly NonNullable< + ProviderRuntimeOptions["codexReasoningEffort"] + >[]; } +/** + * Full selectable Codex reasoning-effort scale, in low-to-high order. Kept + * local to this module (rather than imported from + * `runtime-option-contract.ts`) so model-catalog has no dependency on the UI + * option-label layer. "minimal" is intentionally excluded — it was dropped + * from the Codex CLI effort scale with GPT-5.6 (see + * `runtime-option-contract.ts`). + */ +export const ALL_CODEX_REASONING_EFFORTS = [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +] as const satisfies readonly NonNullable< + ProviderRuntimeOptions["codexReasoningEffort"] +>[]; + export const MODEL_TIER_ORDER = [ "light", "standard", @@ -280,33 +311,56 @@ export const MODEL_CAPABILITIES: Record = { taskTypes: ["quick_edit", "general"], defaultClaudeEffort: "medium", }, + // Codex default efforts and supported-effort scales mirror + // `defaultReasoningEffort` / `supportedReasoningEfforts` reported by the + // codex-cli 0.144.1 App Server `model/list` catalog. Notably Luna does not + // accept "ultra" and GPT-5.5 caps out at "xhigh" (no "max"/"ultra"). "gpt-5.6-sol": { providerId: "codex", model: "gpt-5.6-sol", tier: "frontier", taskTypes: ["plan", "implementation", "debug", "review", "safety"], - defaultCodexReasoningEffort: "high", + defaultCodexReasoningEffort: "xhigh", + supportedCodexReasoningEfforts: [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ], }, "gpt-5.6-terra": { providerId: "codex", model: "gpt-5.6-terra", tier: "heavy", taskTypes: ["plan", "implementation", "debug", "review", "safety"], - defaultCodexReasoningEffort: "medium", + defaultCodexReasoningEffort: "xhigh", + supportedCodexReasoningEfforts: [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ], }, "gpt-5.6-luna": { providerId: "codex", model: "gpt-5.6-luna", tier: "light", taskTypes: ["quick_edit", "general"], - defaultCodexReasoningEffort: "low", + defaultCodexReasoningEffort: "xhigh", + // Luna is the one GPT-5.6 variant that does not accept "ultra". + supportedCodexReasoningEfforts: ["low", "medium", "high", "xhigh", "max"], }, "gpt-5.5": { providerId: "codex", model: "gpt-5.5", tier: "frontier", taskTypes: ["plan", "implementation", "debug", "review", "safety"], - defaultCodexReasoningEffort: "high", + defaultCodexReasoningEffort: "xhigh", + supportedCodexReasoningEfforts: ["low", "medium", "high", "xhigh"], }, }; @@ -405,6 +459,18 @@ export function resolveDefaultClaudeEffortForModel(args: { export function resolveDefaultCodexEffortForModel(args: { model: string; }): NonNullable { + // 1. Codex's own recommendation for the model, whether it comes from the + // live App Server catalog (`model/list.defaultReasoningEffort`, registered + // via registerDynamicDefaultReasoningEffort) or our static, verified + // MODEL_CAPABILITIES entry. The dynamic value wins when present since it + // reflects the installed Codex binary's current recommendation. + const dynamicDefault = dynamicDefaultReasoningEfforts.get( + args.model.trim(), + ); + if (dynamicDefault) { + return dynamicDefault; + } + const capability = getModelCapability({ model: args.model }); if ( capability?.providerId === "codex" && @@ -413,17 +479,8 @@ export function resolveDefaultCodexEffortForModel(args: { return capability.defaultCodexReasoningEffort; } - const normalizedModel = args.model.trim().toLowerCase(); - if (normalizedModel.includes("mini") || normalizedModel.includes("luna")) { - return "low"; - } - if ( - normalizedModel.includes("spark") || - normalizedModel.includes("sol") || - normalizedModel.includes("5.5") - ) { - return "high"; - } + // 2. No known Codex recommendation (e.g. a legacy or unrecognized model + // id) — fall back to the same "medium" baseline Stave has always used. return "medium"; } @@ -467,6 +524,168 @@ export function getDynamicDisplayNames(): ReadonlyMap { return dynamicDisplayNames; } +/** + * Dynamic per-model default-reasoning-effort registry populated at runtime + * from the Codex model catalog (`model/list.defaultReasoningEffort`). Read by + * `resolveDefaultCodexEffortForModel` so Stave always prefers Codex's own + * recommendation over the static fallback once the App Server catalog has + * been fetched. + */ +const dynamicDefaultReasoningEfforts = new Map< + string, + NonNullable +>(); + +const VALID_CODEX_REASONING_EFFORTS = new Set< + NonNullable +>(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); + +function isValidCodexReasoningEffort( + value: string, +): value is NonNullable { + return VALID_CODEX_REASONING_EFFORTS.has( + value as NonNullable, + ); +} + +/** + * Merge server-provided default reasoning efforts into the runtime registry. + * Called from `useCodexModelCatalog` after a successful `model/list` fetch. + * Unrecognized effort strings are ignored so a malformed server response + * cannot poison the registry. + */ +export function registerDynamicDefaultReasoningEfforts( + defaults: Map, +) { + for (const [model, effort] of defaults) { + if (isValidCodexReasoningEffort(effort)) { + dynamicDefaultReasoningEfforts.set(model, effort); + } + } +} + +/** + * Read-only access to the current dynamic default-reasoning-effort registry. + * Useful for tests and diagnostics. + */ +export function getDynamicDefaultReasoningEfforts(): ReadonlyMap< + string, + NonNullable +> { + return dynamicDefaultReasoningEfforts; +} + +/** + * Dynamic per-model supported-reasoning-effort registry populated at runtime + * from the Codex model catalog (`model/list.supportedReasoningEfforts`). Read + * by `listCodexReasoningEffortsForModel` so effort pickers never offer a + * value the currently installed Codex binary would reject for that model + * (e.g. GPT-5.6 Luna does not accept "ultra"). + */ +const dynamicSupportedReasoningEfforts = new Map< + string, + NonNullable[] +>(); + +/** + * Merge server-provided supported-effort lists into the runtime registry. + * Called from `useCodexModelCatalog` after a successful `model/list` fetch. + * Unrecognized effort strings are dropped so a malformed server response + * cannot poison the registry; an entry with no recognizable values is + * ignored entirely (falls through to the static catalog / unrestricted). + */ +export function registerDynamicSupportedReasoningEfforts( + supported: Map, +) { + for (const [model, efforts] of supported) { + const valid = efforts.filter(isValidCodexReasoningEffort); + if (valid.length > 0) { + dynamicSupportedReasoningEfforts.set(model, valid); + } + } +} + +/** + * Read-only access to the current dynamic supported-reasoning-effort + * registry. Useful for tests and diagnostics. + */ +export function getDynamicSupportedReasoningEfforts(): ReadonlyMap< + string, + readonly NonNullable[] +> { + return dynamicSupportedReasoningEfforts; +} + +/** + * Reasoning-effort values selectable for a given Codex model, in + * low-to-high order. Prefers the live App Server catalog (registered via + * `registerDynamicSupportedReasoningEfforts`) since it reflects the + * installed Codex binary; falls back to the verified static catalog entry; + * and finally returns the full scale when nothing is known about the model + * (never blocks a legacy/unrecognized model from being used). + */ +export function listCodexReasoningEffortsForModel(args: { + model: string; +}): readonly NonNullable[] { + const trimmedModel = args.model.trim(); + const dynamic = dynamicSupportedReasoningEfforts.get(trimmedModel); + if (dynamic && dynamic.length > 0) { + return dynamic; + } + + const capability = getModelCapability({ model: trimmedModel }); + if ( + capability?.providerId === "codex" && + capability.supportedCodexReasoningEfforts && + capability.supportedCodexReasoningEfforts.length > 0 + ) { + return capability.supportedCodexReasoningEfforts; + } + + return ALL_CODEX_REASONING_EFFORTS; +} + +/** + * Clamps a reasoning-effort value to one the target model actually accepts. + * Used when switching models (or loading a persisted preset) so a value like + * "ultra" carried over from GPT-5.6 Sol doesn't get silently sent to Luna, + * which would reject it. Steps down to the nearest lower supported value + * first (so "ultra" -> "max" rather than jumping straight to the model's + * default), falling back to the model's default effort if the current value + * is below every supported level (should not happen in practice). + */ +export function clampCodexEffortToModel(args: { + model: string; + effort: NonNullable; +}): NonNullable { + const supported = listCodexReasoningEffortsForModel({ model: args.model }); + if ((supported as readonly string[]).includes(args.effort)) { + return args.effort; + } + + const requestedIndex = ALL_CODEX_REASONING_EFFORTS.indexOf( + args.effort as (typeof ALL_CODEX_REASONING_EFFORTS)[number], + ); + const nextLower = [...supported] + .filter( + (candidate) => + ALL_CODEX_REASONING_EFFORTS.indexOf( + candidate as (typeof ALL_CODEX_REASONING_EFFORTS)[number], + ) <= requestedIndex, + ) + .sort( + (left, right) => + ALL_CODEX_REASONING_EFFORTS.indexOf( + right as (typeof ALL_CODEX_REASONING_EFFORTS)[number], + ) - + ALL_CODEX_REASONING_EFFORTS.indexOf( + left as (typeof ALL_CODEX_REASONING_EFFORTS)[number], + ), + )[0]; + + return nextLower ?? resolveDefaultCodexEffortForModel({ model: args.model }); +} + export function toHumanModelName(args: { model: string }) { // 1. Check dynamic registry first (server-provided names) const dynamic = dynamicDisplayNames.get(args.model); diff --git a/src/lib/providers/runtime-option-contract.ts b/src/lib/providers/runtime-option-contract.ts index 2ede547..da06d66 100644 --- a/src/lib/providers/runtime-option-contract.ts +++ b/src/lib/providers/runtime-option-contract.ts @@ -1,3 +1,4 @@ +import { listCodexReasoningEffortsForModel } from "@/lib/providers/model-catalog"; import type { ClaudeSettingSource, NormalizedProviderEvent, ProviderRuntimeOptions } from "@/lib/providers/provider.types"; type SelectOption = { @@ -77,6 +78,21 @@ export const CODEX_EFFORT_OPTIONS = [ { value: "ultra", label: "Ultra" }, ] as const satisfies readonly SelectOption>[]; +/** + * `CODEX_EFFORT_OPTIONS` filtered to the values the given Codex model + * actually accepts (e.g. GPT-5.6 Luna has no "ultra" tier), preserving the + * canonical low-to-high order. Unknown models get the unrestricted list back + * so a legacy/unrecognized model id never loses effort options entirely. + */ +export function listCodexEffortOptionsForModel(args: { + model: string; +}): readonly SelectOption>[] { + const supported = listCodexReasoningEffortsForModel({ model: args.model }); + return CODEX_EFFORT_OPTIONS.filter((option) => + (supported as readonly string[]).includes(option.value), + ); +} + export const CODEX_WEB_SEARCH_OPTIONS = [ { value: "cached", label: "Cached" }, { value: "disabled", label: "Disabled" }, diff --git a/src/lib/providers/use-codex-model-catalog.ts b/src/lib/providers/use-codex-model-catalog.ts index 570cb7e..8e46cc8 100644 --- a/src/lib/providers/use-codex-model-catalog.ts +++ b/src/lib/providers/use-codex-model-catalog.ts @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState } from "react"; import { getSdkModelOptions, + registerDynamicDefaultReasoningEfforts, registerDynamicDisplayNames, + registerDynamicSupportedReasoningEfforts, } from "@/lib/providers/model-catalog"; import type { CodexModelCatalogEntry } from "@/lib/providers/provider.types"; @@ -10,6 +12,24 @@ const FALLBACK_CODEX_MODELS = [ ] as string[]; const CODEX_MODEL_CACHE_TTL_MS = 5 * 60 * 1000; +/** + * Union of the Stave model catalog and the App Server's dynamic `model/list` + * result. The static catalog comes first so newly adopted families (e.g. + * GPT-5.6) are always selectable even when the installed Codex binary still + * reports an older lineup; server-only models are appended after. + */ +export function mergeCodexModelsWithCatalog( + dynamicModels: readonly string[], +): string[] { + const merged = [...FALLBACK_CODEX_MODELS]; + for (const model of dynamicModels) { + if (!merged.includes(model)) { + merged.push(model); + } + } + return merged; +} + type CodexModelCatalogCacheEntry = { status: "ready" | "error"; models: string[]; @@ -85,23 +105,48 @@ async function loadCodexModelCatalog(args: { .map((model) => model.model.trim()) .filter(Boolean); - // Register dynamic display names so toHumanModelName() can use them + // Register dynamic display names so toHumanModelName() can use them, + // dynamic default reasoning efforts so + // resolveDefaultCodexEffortForModel() reflects Codex's own + // recommendation (`defaultReasoningEffort`) for every catalog model, + // and dynamic supported-effort lists so effort pickers never offer a + // value the model would reject (e.g. Luna has no "ultra"). if (models.length > 0) { const nameMap = new Map(); + const defaultEffortMap = new Map(); + const supportedEffortsMap = new Map(); for (const entry of visibleEntries) { const id = entry.model.trim(); - if (id && entry.displayName && entry.displayName !== id) { + if (!id) { + continue; + } + if (entry.displayName && entry.displayName !== id) { nameMap.set(id, entry.displayName); } + if (entry.defaultReasoningEffort) { + defaultEffortMap.set(id, entry.defaultReasoningEffort); + } + if (entry.supportedReasoningEfforts.length > 0) { + supportedEffortsMap.set(id, entry.supportedReasoningEfforts); + } } if (nameMap.size > 0) { registerDynamicDisplayNames(nameMap); } + if (defaultEffortMap.size > 0) { + registerDynamicDefaultReasoningEfforts(defaultEffortMap); + } + if (supportedEffortsMap.size > 0) { + registerDynamicSupportedReasoningEfforts(supportedEffortsMap); + } } const nextEntry: CodexModelCatalogCacheEntry = { status: result.ok ? "ready" : "error", - models: models.length > 0 ? models : FALLBACK_CODEX_MODELS, + models: + models.length > 0 + ? mergeCodexModelsWithCatalog(models) + : FALLBACK_CODEX_MODELS, entries: visibleEntries, detail: result.detail || diff --git a/src/lib/task-presets.ts b/src/lib/task-presets.ts index efa4c94..4b902d4 100644 --- a/src/lib/task-presets.ts +++ b/src/lib/task-presets.ts @@ -8,6 +8,7 @@ import type { ProviderId } from "@/lib/providers/provider.types"; import { CLAUDE_EFFORT_OPTIONS, CODEX_EFFORT_OPTIONS, + listCodexEffortOptionsForModel, } from "@/lib/providers/runtime-option-contract"; import type { CliSessionContextMode } from "@/lib/terminal/types"; @@ -52,13 +53,16 @@ export type TaskPresetEffort = /** * Effort options selectable for a `task` preset, scoped to the provider's - * supported reasoning-effort scale. + * supported reasoning-effort scale. For Codex, passing `model` further + * narrows the list to what that specific model accepts (e.g. GPT-5.6 Luna + * has no "ultra" tier) — omit `model` to get the unrestricted Codex scale. */ export function listEffortsForPresetProvider( providerId: ProviderId, + model?: string, ): readonly { value: TaskPresetEffort; label: string }[] { if (providerId === "codex") { - return CODEX_EFFORT_OPTIONS; + return model ? listCodexEffortOptionsForModel({ model }) : CODEX_EFFORT_OPTIONS; } return CLAUDE_EFFORT_OPTIONS; } @@ -163,7 +167,7 @@ export function normalizeTaskPreset(input: Partial): TaskPreset { ? candidateModel : getDefaultModelForProvider({ providerId: provider }); - const allowedEfforts = listEffortsForPresetProvider(provider).map( + const allowedEfforts = listEffortsForPresetProvider(provider, model).map( (option) => option.value, ); const effort = diff --git a/src/store/app.store.ts b/src/store/app.store.ts index 341b704..233bcd5 100644 --- a/src/store/app.store.ts +++ b/src/store/app.store.ts @@ -2671,7 +2671,9 @@ const defaultSettings: AppSettings = { codexNetworkAccess: true, codexApprovalPolicy: "never", codexBinaryPath: "", - codexReasoningEffort: "medium", + // Matches the codex-cli 0.144.1 server-catalog default effort (xhigh) for + // the default model family (GPT-5.6). + codexReasoningEffort: "xhigh", codexWebSearch: "live", codexShowRawReasoning: false, codexReasoningSummary: "auto", diff --git a/tests/chat-input.runtime.test.ts b/tests/chat-input.runtime.test.ts index 2b45392..820f520 100644 --- a/tests/chat-input.runtime.test.ts +++ b/tests/chat-input.runtime.test.ts @@ -57,6 +57,15 @@ describe("chat-input runtime helpers", () => { expect(cycleCodexEffortValue("minimal")).toBe("low"); }); + test("scopes the Codex effort cycle to the model when provided", () => { + // GPT-5.6 Luna has no "ultra" tier — the cycle should wrap from "max" + // back to "low" instead of landing on "ultra". + expect(cycleCodexEffortValue("max", "gpt-5.6-luna")).toBe("low"); + expect(cycleCodexEffortValue("xhigh", "gpt-5.6-luna")).toBe("max"); + // Sol/Terra still cycle through "ultra". + expect(cycleCodexEffortValue("max", "gpt-5.6-sol")).toBe("ultra"); + }); + test("surfaces Codex runtime status items including binary override", () => { const items = buildChatInputRuntimeStatusItems(baseArgs); diff --git a/tests/codex-model-catalog-merge.test.ts b/tests/codex-model-catalog-merge.test.ts new file mode 100644 index 0000000..a038a9c --- /dev/null +++ b/tests/codex-model-catalog-merge.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { CODEX_MODEL_OPTIONS } from "@/lib/providers/model-catalog"; +import { mergeCodexModelsWithCatalog } from "@/lib/providers/use-codex-model-catalog"; + +describe("mergeCodexModelsWithCatalog", () => { + test("keeps the full static catalog when the server reports an older lineup", () => { + const merged = mergeCodexModelsWithCatalog([ + "gpt-5.5", + "gpt-5.4", + "gpt-5-codex", + ]); + for (const model of CODEX_MODEL_OPTIONS) { + expect(merged).toContain(model); + } + // Server-only extras are appended after the static catalog. + expect(merged.indexOf("gpt-5.6-sol")).toBeLessThan( + merged.indexOf("gpt-5.4"), + ); + expect(merged).toContain("gpt-5-codex"); + }); + + test("does not duplicate models present in both lists", () => { + const merged = mergeCodexModelsWithCatalog([...CODEX_MODEL_OPTIONS]); + expect(merged).toEqual([...CODEX_MODEL_OPTIONS]); + }); + + test("preserves catalog order first, then dynamic extras in server order", () => { + const merged = mergeCodexModelsWithCatalog(["gpt-7", "gpt-6"]); + expect(merged).toEqual([...CODEX_MODEL_OPTIONS, "gpt-7", "gpt-6"]); + }); +}); diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index eb7d15e..da435af 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; import { + ALL_CODEX_REASONING_EFFORTS, CLAUDE_FABLE_MODEL, CLAUDE_SDK_MODEL_OPTIONS, + clampCodexEffortToModel, CODEX_MODEL_OPTIONS, DEFAULT_CLAUDE_OPUS_MODEL, getDynamicDisplayNames, getModelCapability, + listCodexReasoningEffortsForModel, listModelCapabilities, MODEL_CAPABILITIES, resolveClaudeEffortForModelSwitch, @@ -17,7 +20,9 @@ import { getProviderLabel, getProviderWaveToneClass, inferProviderIdFromModel, + registerDynamicDefaultReasoningEfforts, registerDynamicDisplayNames, + registerDynamicSupportedReasoningEfforts, toHumanModelName, upgradeSettingsScopedClaudeModel, } from "@/lib/providers/model-catalog"; @@ -118,29 +123,101 @@ describe("model catalog", () => { }); test("returns the default Codex effort from model capabilities", () => { + // Defaults mirror `defaultReasoningEffort` from the codex-cli 0.144.1 + // App Server `model/list` catalog (xhigh across GPT-5.6 and GPT-5.5). expect(resolveDefaultCodexEffortForModel({ model: "gpt-5.6-luna" })).toBe( - "low", + "xhigh", ); expect(resolveDefaultCodexEffortForModel({ model: "gpt-5.6-terra" })).toBe( - "medium", + "xhigh", ); expect(resolveDefaultCodexEffortForModel({ model: "gpt-5.6-sol" })).toBe( - "high", + "xhigh", ); expect(resolveDefaultCodexEffortForModel({ model: "gpt-5.5" })).toBe( - "high", + "xhigh", ); - // Legacy models removed from the picker still resolve via the heuristic - // fallback so historical settings keep sane defaults. + // Legacy models removed from the picker have no known Codex + // recommendation, so they fall back to the flat "medium" baseline. expect(resolveDefaultCodexEffortForModel({ model: "gpt-5.4-mini" })).toBe( - "low", + "medium", ); expect(resolveDefaultCodexEffortForModel({ model: "gpt-5.4" })).toBe( "medium", ); expect( resolveDefaultCodexEffortForModel({ model: "gpt-5.3-codex-spark" }), + ).toBe("medium"); + }); + + test("prefers a dynamically registered Codex default effort over the static fallback", () => { + registerDynamicDefaultReasoningEfforts( + new Map([["gpt-5.4-mini", "high"]]), + ); + expect(resolveDefaultCodexEffortForModel({ model: "gpt-5.4-mini" })).toBe( + "high", + ); + }); + + test("ignores unrecognized dynamically registered effort values", () => { + registerDynamicDefaultReasoningEfforts( + new Map([["gpt-unknown-model", "not-a-real-effort"]]), + ); + expect( + resolveDefaultCodexEffortForModel({ model: "gpt-unknown-model" }), + ).toBe("medium"); + }); + + test("scopes selectable Codex reasoning efforts per model, per the verified server catalog", () => { + // Sol/Terra accept the full scale including "ultra". + expect(listCodexReasoningEffortsForModel({ model: "gpt-5.6-sol" })).toEqual( + ["low", "medium", "high", "xhigh", "max", "ultra"], + ); + expect( + listCodexReasoningEffortsForModel({ model: "gpt-5.6-terra" }), + ).toEqual(["low", "medium", "high", "xhigh", "max", "ultra"]); + // Luna has no "ultra" tier. + expect( + listCodexReasoningEffortsForModel({ model: "gpt-5.6-luna" }), + ).toEqual(["low", "medium", "high", "xhigh", "max"]); + // GPT-5.5 caps out at "xhigh" (no "max"/"ultra"). + expect(listCodexReasoningEffortsForModel({ model: "gpt-5.5" })).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]); + // Unknown/legacy models are unrestricted. + expect( + listCodexReasoningEffortsForModel({ model: "gpt-5.4" }), + ).toEqual(ALL_CODEX_REASONING_EFFORTS); + }); + + test("prefers a dynamically registered supported-effort list over the static fallback", () => { + // Uses a model id not touched by other tests in this file so the + // module-level registry mutation can't leak into later assertions. + registerDynamicSupportedReasoningEfforts( + new Map([["gpt-5.4-mini", ["low", "medium"]]]), + ); + expect( + listCodexReasoningEffortsForModel({ model: "gpt-5.4-mini" }), + ).toEqual(["low", "medium"]); + }); + + test("clamps an unsupported Codex effort down to the model's nearest supported value", () => { + // "ultra" carried over from Sol isn't valid for Luna — step down to the + // nearest lower supported value ("max"), not straight to the default. + expect( + clampCodexEffortToModel({ model: "gpt-5.6-luna", effort: "ultra" }), + ).toBe("max"); + // Already-supported values pass through unchanged. + expect( + clampCodexEffortToModel({ model: "gpt-5.6-luna", effort: "high" }), ).toBe("high"); + // GPT-5.5 caps at "xhigh" — "max" clamps down to it. + expect( + clampCodexEffortToModel({ model: "gpt-5.5", effort: "max" }), + ).toBe("xhigh"); }); test("keeps model capability metadata aligned with catalog models", () => {