mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(codex): accept max/ultra reasoning effort for aliased and dated model ids
Codex effort validity was keyed on exact seed ids, so `luna`, `gpt-5.6-luna-2026-08-01` and any post-release variant fell back to the global `xhigh` ceiling and rejected `max`/`ultra`. Resolve family aliases and dated variants onto their seed row for capability lookups only, so launch args keep the requested id and gain no catalog defaults. Also add `max`/`ultra` to the usage-cost tier stripper (an `(ultra)` row previously priced as null) while keeping `gpt-5.1-codex-max` a family name, and seed the gpt-5.6 rows in the Source Control AI Codex fallback. Old binaries stay safe: the higher ceilings are model-scoped, and a Codex below 0.147 cannot run gpt-5.6 at all.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -80,7 +80,17 @@ export const MODEL_PRICING: Record<string, CodexModelPricing> = {
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | undefined>(
|
||||
(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
|
||||
}
|
||||
|
||||
@@ -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, SessionOptionValue>) => 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
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, SessionOptionValue> = {}
|
||||
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]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Record<TuiAgent, CommitMessageA
|
||||
// Why: ordered to match the official `codex` model picker — descending
|
||||
// by version so the frontier model lands on top and legacy models trail.
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.6-sol',
|
||||
label: 'GPT-5.6 Sol',
|
||||
thinkingLevels: OPENAI_ULTRA_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-terra',
|
||||
label: 'GPT-5.6 Terra',
|
||||
thinkingLevels: OPENAI_ULTRA_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-luna',
|
||||
label: 'GPT-5.6 Luna',
|
||||
thinkingLevels: OPENAI_MAX_THINKING_LEVELS,
|
||||
defaultThinkingLevel: 'low'
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
label: 'GPT-5.5',
|
||||
|
||||
Reference in New Issue
Block a user