diff --git a/src/main/codex-usage/codex-model-pricing.test.ts b/src/main/codex-usage/codex-model-pricing.test.ts new file mode 100644 index 00000000000..17d1cf0908b --- /dev/null +++ b/src/main/codex-usage/codex-model-pricing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { normalizeModelForPricing } from './codex-model-pricing' + +describe('normalizeModelForPricing', () => { + it('strips the max and ultra reasoning tiers Codex 0.147 introduced', () => { + expect(normalizeModelForPricing('gpt-5.6-sol-ultra')).toBe('gpt-5.6-sol') + expect(normalizeModelForPricing('gpt-5.6-sol(ultra)')).toBe('gpt-5.6-sol') + expect(normalizeModelForPricing('gpt-5.6-luna-max')).toBe('gpt-5.6-luna') + expect(normalizeModelForPricing('gpt-5.6-luna(max)')).toBe('gpt-5.6-luna') + }) + + it('keeps gpt-5.1-codex-max a family name rather than a stripped reasoning tier', () => { + expect(normalizeModelForPricing('gpt-5.1-codex-max')).toBe('gpt-5.1-codex-max') + expect(normalizeModelForPricing('gpt-5.1-codex-max-xhigh')).toBe('gpt-5.1-codex-max') + expect(normalizeModelForPricing('gpt-5.1-codex-max(high)')).toBe('gpt-5.1-codex-max') + expect(normalizeModelForPricing('gpt-5.1-codex-high')).toBe('gpt-5.1-codex') + }) + + it('still rejects a parenthesized suffix that is not a reasoning tier', () => { + expect(normalizeModelForPricing('gpt-5.5(preview)')).toBe(null) + }) +}) diff --git a/src/main/codex-usage/codex-model-pricing.ts b/src/main/codex-usage/codex-model-pricing.ts index 23e721e27db..4c691fd3828 100644 --- a/src/main/codex-usage/codex-model-pricing.ts +++ b/src/main/codex-usage/codex-model-pricing.ts @@ -80,7 +80,17 @@ export const MODEL_PRICING: Record = { } } -const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none'] +const REASONING_TIER_SUFFIXES = [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', + 'auto', + 'none' +] function stripParenthesizedReasoningTier(model: string): string | null { const match = model.match(/^(.*)\(([^()]*)\)$/) @@ -116,13 +126,17 @@ export function normalizeModelForPricing(model: string | null): string | null { return null } + // Why: `max` is both a reasoning tier and the tail of the `gpt-5.1-codex-max` family + // name, so this family must resolve before dash-suffix stripping eats it and reprices + // the rows as plain `gpt-5.1-codex`. + if (lower === 'gpt-5.1-codex-max' || lower.startsWith('gpt-5.1-codex-max-')) { + return 'gpt-5.1-codex-max' + } + const normalized = stripDashReasoningTiers(lower) if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') { return 'gpt-5' } - if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) { - return 'gpt-5.1-codex-max' - } if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) { return 'gpt-5.1-codex' } diff --git a/src/main/codex-usage/store-model-pricing.test.ts b/src/main/codex-usage/store-model-pricing.test.ts index 6c8ef234e8d..f8bbddc0b81 100644 --- a/src/main/codex-usage/store-model-pricing.test.ts +++ b/src/main/codex-usage/store-model-pricing.test.ts @@ -160,7 +160,12 @@ describe('CodexUsageStore', () => { it('normalizes GPT-5.6 reasoning suffixes before pricing', async () => { const store = createStoreWithState({ - dailyAggregates: ['gpt-5.6-terra-high', 'gpt-5.6-luna(medium)'].map((model) => ({ + dailyAggregates: [ + 'gpt-5.6-terra-high', + 'gpt-5.6-luna(medium)', + 'gpt-5.6-sol-ultra', + 'gpt-5.6-luna(max)' + ].map((model) => ({ day: '2026-04-09', model, projectKey: 'worktree:repo-1::/workspace/repo', @@ -185,6 +190,13 @@ describe('CodexUsageStore', () => { expect( breakdown.find((row) => row.key === 'gpt-5.6-luna(medium)')?.estimatedCostUsd ).toBeCloseTo(0.205) + // Codex 0.147 tiers: an unrecognized suffix drops the row's cost entirely. + expect(breakdown.find((row) => row.key === 'gpt-5.6-sol-ultra')?.estimatedCostUsd).toBeCloseTo( + 1.025 + ) + expect(breakdown.find((row) => row.key === 'gpt-5.6-luna(max)')?.estimatedCostUsd).toBeCloseTo( + 0.205 + ) }) it('prices the bare gpt-5.6 alias at Sol rates without shadowing the tier IDs', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts index fc33c89cdda..e4926e43aa3 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { + findCatalogModelByRequestedId, + getAgentSessionOptionCatalog +} from '../../../../shared/agent-session-option-catalog' import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertWorkerLaunchPreferencesCreateTerminal, @@ -75,17 +78,36 @@ describe('orchestration worker launch preferences', () => { rejected: ['max', 'ultra', 'future-effort'] }, { + // Why: an id no seed row explains means unknown host capability, so the menu stops at + // the last tier every supported Codex build accepts. Raising this fallback would hand + // `max`/`ultra` to a binary that predates them; per-model ceilings lift it instead. model: 'future-codex-model', accepted: ['minimal', 'low', 'medium', 'high', 'xhigh'], rejected: ['max', 'ultra', 'future-effort'] + }, + { + // Bare family alias, as accepted by `codex -m luna`. + model: 'luna', + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], + rejected: ['ultra', 'future-effort'] + }, + { + model: 'sol', + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], + rejected: ['future-effort'] + }, + { + // Dated variant published after this Orca build. + model: 'gpt-5.6-luna-2026-08-01', + accepted: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'], + rejected: ['ultra', 'future-effort'] } ])('enforces the Codex effort ceiling for $model', ({ model, accepted, rejected }) => { const catalog = getAgentSessionOptionCatalog('codex')! const effort = - catalog.models - .find((candidate) => candidate.id === model) - ?.options.find((option) => option.id === 'effort') ?? - catalog.unknownModelOptions?.find((option) => option.id === 'effort') + findCatalogModelByRequestedId(catalog, model)?.options.find( + (option) => option.id === 'effort' + ) ?? catalog.unknownModelOptions?.find((option) => option.id === 'effort') expect(effort?.kind.type).toBe('select') expect( diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts index f907571e2bc..64ddbcd39a9 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts @@ -1,6 +1,6 @@ import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' import { - findCatalogModel, + findCatalogModelByRequestedId, findCatalogOption, getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' @@ -75,7 +75,7 @@ export function resolveWorkerLaunchPreferences(args: { } if (args.effort) { - const model = findCatalogModel(catalog, args.model) + const model = findCatalogModelByRequestedId(catalog, args.model) const option = findCatalogOption(model, 'effort') ?? (!model diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index b7e9d0e6bed..d40322586aa 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -234,5 +234,34 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { // command and let its own picker apply the account-supported model. midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } }, - unknownModelOptions: [codexEffort('xhigh')] + // Why: an id absent from the seed means unknown host capability, so the menu stops + // at the last tier every supported Codex build accepts. The higher ceilings stay + // model-scoped because a Codex older than 0.147 cannot run gpt-5.6 at all, which is + // what keeps `max`/`ultra` from ever reaching a binary that would reject them. + unknownModelOptions: [codexEffort('xhigh')], + resolveModelId: resolveCodexCatalogModelId +} + +/** Why: Codex effort validity is keyed on exact seed ids, so a dated id + * (`gpt-5.6-luna-2026-08-01`) or the bare family alias (`luna`) would silently fall back + * to the `xhigh` ceiling and reject `max`/`ultra` that the host actually supports. + * Validation only — launch args must keep the id the caller asked for. */ +function resolveCodexCatalogModelId(modelId: string): string | undefined { + const ids = CODEX_SESSION_OPTION_CATALOG.models.map((model) => model.id) + if (ids.includes(modelId)) { + return modelId + } + // Longest prefix wins so `gpt-5.6-luna-2026-08-01` resolves to Luna, not a shorter family. + const variantOf = ids + .filter((id) => modelId.startsWith(`${id}-`)) + .reduce( + (best, id) => (!best || id.length > best.length ? id : best), + undefined + ) + if (variantOf) { + return variantOf + } + const aliasOf = ids.filter((id) => id.endsWith(`-${modelId}`)) + // An ambiguous alias must not guess a family; leaving it unresolved keeps the safe ceiling. + return aliasOf.length === 1 ? aliasOf[0] : undefined } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index ae2af3b450f..8ffbd885b5b 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -60,6 +60,10 @@ export type AgentSessionOptionCatalog = { supportsWorkerLaunchPreferences?: true /** Launch-safe options for opaque model ids that are absent from the static catalog. */ unknownModelOptions?: CatalogOption[] + /** Maps a requested id onto the seed row that describes its capabilities (dated ids, + * family aliases). Why: validation-only — resolving during launch would inject the + * seed's option defaults over the user's own agent config. */ + resolveModelId?: (modelId: string) => string | undefined composeModelValue?: (modelId: string, values: Record) => string /** Why: a seeded id the CLI has retired is a fatal launch, so a successful probe * must be able to drop it rather than only add. Membership only — option menus diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index e7aae4aaaaa..54ad9198ac5 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { getAgentSessionOptionCatalog, mergeCatalogModels } from './agent-session-option-catalog' +import { + findCatalogModel, + findCatalogModelByRequestedId, + getAgentSessionOptionCatalog, + mergeCatalogModels +} from './agent-session-option-catalog' import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' import { resolveNativeChatSessionOptionDefaults, @@ -19,6 +24,31 @@ describe('agent session option catalog', () => { expect(catalog?.models.find((model) => model.id === 'haiku')?.options).toEqual([]) }) + it('resolves Codex family aliases and dated variants onto their seed capabilities', () => { + const codex = getAgentSessionOptionCatalog('codex')! + expect(findCatalogModelByRequestedId(codex, 'luna')?.id).toBe('gpt-5.6-luna') + expect(findCatalogModelByRequestedId(codex, 'sol')?.id).toBe('gpt-5.6-sol') + expect(findCatalogModelByRequestedId(codex, 'gpt-5.6-luna-2026-08-01')?.id).toBe('gpt-5.6-luna') + expect(findCatalogModelByRequestedId(codex, 'gpt-5.6-sol')?.id).toBe('gpt-5.6-sol') + // An id that matches no family keeps the conservative unknown-model ceiling. + expect(findCatalogModelByRequestedId(codex, 'future-codex-model')).toBeUndefined() + // Exact matching must stay exact: it also gates launch-arg defaulting. + expect(findCatalogModel(codex, 'luna')).toBeUndefined() + }) + + it('never injects a catalog effort default for an aliased Codex model', () => { + // Why: the user's ~/.codex/config.toml owns model_reasoning_effort unless they picked one. + expect(resolveAgentSessionOptionLaunch('codex', { model: 'luna' }).args).toEqual(['-m', 'luna']) + expect(resolveAgentSessionOptionLaunch('codex', { model: 'luna', effort: 'max' })).toEqual({ + args: ['-m', 'luna', '-c', 'model_reasoning_effort=max'], + appliedValues: { model: 'luna', effort: 'max' } + }) + // Luna tops out at max, so ultra is still dropped rather than sent to the CLI. + expect( + resolveAgentSessionOptionLaunch('codex', { model: 'luna', effort: 'ultra' }).args + ).toEqual(['-m', 'luna']) + }) + it('merges discovered labels while preserving cataloged option shapes', () => { const seed = getAgentSessionOptionCatalog('cursor')!.models const merged = mergeCatalogModels(seed, [ diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index b242b6b27c9..1df06c3122a 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -47,6 +47,21 @@ export function findCatalogModel( return catalog.models.find((model) => model.id === modelId) } +/** Resolves aliases and dated variants onto their seed row for capability checks. + * Why: kept separate from `findCatalogModel` because that lookup also gates launch-arg + * injection — a fuzzy match there would override the user's own agent config. */ +export function findCatalogModelByRequestedId( + catalog: AgentSessionOptionCatalog, + modelId: string +): CatalogModel | undefined { + const exact = findCatalogModel(catalog, modelId) + if (exact) { + return exact + } + const resolved = catalog.resolveModelId?.(modelId) + return resolved ? findCatalogModel(catalog, resolved) : undefined +} + export function findCatalogOption( model: CatalogModel | undefined, optionId: string diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 53df967a838..1fa26a4251d 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -1,5 +1,9 @@ import type { AgentType } from './agent-status-types' -import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { + findCatalogModel, + findCatalogModelByRequestedId, + getAgentSessionOptionCatalog +} from './agent-session-option-catalog' import type { SessionOptionValue } from './native-chat-session-options' export type ResolvedSessionOptionLaunch = { @@ -43,7 +47,13 @@ export function resolveAgentSessionOptionLaunch( const model = findCatalogModel(catalog, modelId) const appliedValues: Record = {} const args: string[] = [] - const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] + // Why: the menu comes from the alias-resolved row so a dated or aliased id keeps its real + // ceiling, while `model` stays an exact match — an alias must not gain catalog defaults, + // which would override the effort the user set in their own agent config. + const modelOptions = + (model ?? findCatalogModelByRequestedId(catalog, modelId))?.options ?? + catalog.unknownModelOptions ?? + [] const modelValues = Object.fromEntries( modelOptions.flatMap((option) => { const explicitValue = values[option.id] diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index ce7052d43a9..4d73cfc9b50 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -168,6 +168,9 @@ describe('COMMIT_MESSAGE_AGENT_SPECS', () => { it('orders Codex models by version descending to match the official picker', () => { const ids = COMMIT_MESSAGE_AGENT_SPECS.codex?.models.map((m) => m.id) expect(ids).toEqual([ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index c65850ba08a..bed8cbdb7e3 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -99,6 +99,18 @@ const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'xhigh', label: 'Extra High' } ] +// Why: only the gpt-5.6 family accepts these; pre-5.6 Codex models reject them, and a +// Codex older than 0.147 cannot run gpt-5.6 at all, so the higher tiers stay model-scoped. +const OPENAI_MAX_THINKING_LEVELS: ThinkingLevel[] = [ + ...OPENAI_THINKING_LEVELS, + { id: 'max', label: 'Max' } +] + +const OPENAI_ULTRA_THINKING_LEVELS: ThinkingLevel[] = [ + ...OPENAI_MAX_THINKING_LEVELS, + { id: 'ultra', label: 'Ultra' } +] + const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, @@ -407,6 +419,24 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial