fix codexplan gateway defaults after model resolution

This commit is contained in:
jatmn
2026-08-01 10:46:30 -07:00
parent 25f476773d
commit 2b5ae791f8
15 changed files with 286 additions and 23 deletions
+1 -2
View File
@@ -4610,8 +4610,7 @@ async function handleInitializeRequest(
mainThreadAgent.model &&
mainThreadAgent.model !== 'inherit'
) {
const agentModel = parseUserSpecifiedModel(mainThreadAgent.model)
setMainLoopModelOverride(agentModel)
setMainLoopModelOverride(mainThreadAgent.model)
}
// SDK-defined agents arrive via init, so main.tsx's lookup missed them.
+1 -1
View File
@@ -2070,7 +2070,7 @@ async function run(): Promise<CommanderCommand> {
// If user didn't specify a model but agent has one, use the agent's model
let effectiveModel = userSpecifiedModel;
if (!effectiveModel && mainThreadAgentDefinition?.model && mainThreadAgentDefinition.model !== 'inherit') {
effectiveModel = parseUserSpecifiedModel(mainThreadAgentDefinition.model);
effectiveModel = mainThreadAgentDefinition.model;
}
setMainLoopModelOverride(effectiveModel);
+4
View File
@@ -97,6 +97,7 @@ import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
import {
getDefaultMainLoopModelSetting,
getProviderRequestModel,
getRuntimeMainLoopModel,
parseUserSpecifiedModel,
renderModelName,
@@ -1389,6 +1390,9 @@ async function* queryLoop(
return appState.toolPermissionContext
},
model: currentModel,
requestModel: pinnedTurnRoute?.routed
? currentModel
: getProviderRequestModel(appStateMainLoopModel, currentModel),
...(config.gates.fastModeEnabled && {
fastMode: appState.fastMode,
}),
+14
View File
@@ -26,6 +26,20 @@ describe('getActiveSessionAgentModelSelection', () => {
expect(selection.mainLoopModelForSession).toBe('agent-specific-model')
})
test('retains codexplan for provider-boundary routing', () => {
const selection = getActiveSessionAgentModelSelection({
agent: createAgent('codexplan'),
baseMainLoopModel: 'sonnet',
hasExplicitModelOverride: false,
hasAgentManagedModel: false,
})
expect(selection).toEqual({
shouldUpdateModel: true,
mainLoopModelForSession: 'codexplan',
})
})
test('preserves an explicit model override when selecting an agent with a model', () => {
const selection = getActiveSessionAgentModelSelection({
agent: createAgent('agent-specific-model'),
+3 -2
View File
@@ -2,7 +2,6 @@ import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import {
getDefaultMainLoopModelSetting,
type ModelSetting,
parseUserSpecifiedModel,
} from '../utils/model/model.js'
type ActiveSessionAgentModelSelection =
@@ -33,7 +32,9 @@ export function getActiveSessionAgentModelSelection({
if (agent.model && agent.model !== 'inherit') {
return {
shouldUpdateModel: true,
mainLoopModelForSession: parseUserSpecifiedModel(agent.model),
// Query resolves this to the concrete runtime model, while retaining the
// selection at the provider boundary for custom-gateway defaults.
mainLoopModelForSession: agent.model,
}
}
+4 -2
View File
@@ -232,7 +232,7 @@ describe('model-only routes', () => {
subagentType: 'verification',
settings: modelOnlySettings,
})
expect(result).toEqual({ mainLoopModel: 'gpt-5-mini' })
expect(result).toEqual({ mainLoopModel: 'gpt-5-mini', routed: true })
expect('providerOverride' in result).toBe(false)
})
@@ -316,6 +316,7 @@ describe('resolveAgentRunModelRouting', () => {
expect(result).toEqual({
mainLoopModel: 'deepseek-chat',
routed: true,
providerOverride: {
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com/v1',
@@ -440,7 +441,7 @@ describe('resolveAgentRunModelRouting', () => {
subagentType: 'verification',
settings,
})
expect(result).toEqual({ mainLoopModel: 'gpt-5-mini' })
expect(result).toEqual({ mainLoopModel: 'gpt-5-mini', routed: true })
})
test('permissionMode is threaded into alias resolution, not dropped', () => {
@@ -783,6 +784,7 @@ describe('resolveAgentRunModelRouting: in-process teammate route identity', () =
})
expect(result).toEqual({
mainLoopModel: 'deepseek-chat',
routed: true,
providerOverride: {
model: 'deepseek-chat',
baseURL: 'https://api.deepseek.com/v1',
+6 -2
View File
@@ -30,6 +30,8 @@ export function isProviderOverride(route: AgentRoute): route is ProviderOverride
export interface AgentRunModelRouting {
mainLoopModel: string
providerOverride?: ProviderOverride
/** True when an explicit agent route selected this model. */
routed?: boolean
}
type AgentModelConfig = NonNullable<SettingsJson['agentModels']>[string]
@@ -199,10 +201,11 @@ export function resolveAgentRunModelRouting({
const route = resolveAgentModelProvider(toolRequestedModel, settings)
if (!route) return { mainLoopModel: resolvedAgentModel }
if (isProviderOverride(route)) {
return { mainLoopModel: route.model, providerOverride: route }
return { mainLoopModel: route.model, providerOverride: route, routed: true }
}
return {
mainLoopModel: resolveModelOnlyModel(route.model, parentModel, permissionMode),
routed: true,
}
}
@@ -211,10 +214,11 @@ export function resolveAgentRunModelRouting({
resolveAgentModelProvider(agentDefinitionModel, settings)
if (!route) return { mainLoopModel: resolvedAgentModel }
if (isProviderOverride(route)) {
return { mainLoopModel: route.model, providerOverride: route }
return { mainLoopModel: route.model, providerOverride: route, routed: true }
}
return {
mainLoopModel: resolveModelOnlyModel(route.model, parentModel, permissionMode),
routed: true,
}
}
+63
View File
@@ -141,6 +141,27 @@ function makeOpenAIStreamChunk(
})}\n\n`
}
function makeOpenAIStreamingResponse(): Response {
const encoder = new TextEncoder()
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encoder.encode(
makeOpenAIStreamChunk({ role: 'assistant', content: 'ok' }),
),
)
controller.enqueue(
encoder.encode(makeOpenAIStreamChunk({}, 'stop')),
)
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
},
}),
{ headers: { 'content-type': 'text/event-stream' } },
)
}
function makeStallingOpenAIStreamResponse(
onCancel?: (reason: unknown) => void,
): Response {
@@ -337,6 +358,48 @@ afterEach(() => {
})
describe('Claude API lifecycle tracking', () => {
test('uses the original codexplan selection for custom-gateway defaults', async () => {
setClientTestEnv()
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://gateway.example/v1'
process.env.OPENAI_API_KEY = 'test-key'
process.env.OPENCLAUDE_MAX_RETRIES = '0'
const queryLifecycle = new QueryLifecycleOperationTracker()
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = parseRequestBody(init)
return makeOpenAIStreamingResponse()
}) as typeof fetch
const generator = queryModelWithStreaming({
messages: [
{
type: 'user',
uuid: '00000000-0000-0000-0000-000000000007',
timestamp: '2026-06-17T00:00:00.000Z',
message: { role: 'user', content: 'hello' },
} as Message,
],
systemPrompt: asSystemPrompt([]),
thinkingConfig: { type: 'disabled' },
tools: [],
signal: new AbortController().signal,
options: {
...makeOptions(queryLifecycle),
model: 'gpt-5.6-sol',
requestModel: 'codexplan',
},
})
for await (const _message of generator) {
// Draining the stream makes the request complete before assertions.
}
expect(requestBody?.model).toBe('gpt-5.6-sol')
expect(requestBody?.reasoning_effort).toBe('high')
})
test('ends a failed streaming dispatch before retry backoff is reported', async () => {
setClientTestEnv()
process.env.OPENCLAUDE_MAX_RETRIES = '1'
+9 -6
View File
@@ -706,6 +706,8 @@ export function assistantMessageToMessageParam(
export type Options = {
getToolPermissionContext: () => Promise<ToolPermissionContext>
model: string
/** Original selection used only for provider-side alias routing. */
requestModel?: string
toolChoice?: BetaToolChoiceTool | BetaToolChoiceAuto | undefined
isNonInteractiveSession: boolean
extraToolSchemas?: BetaToolUnion[]
@@ -1147,6 +1149,7 @@ async function* queryModel(
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
void
> {
const providerRequestModel = options.requestModel ?? options.model
// Check cheap conditions first — the off-switch await blocks on GrowthBook
// init (~10ms). For non-Opus models (haiku, sonnet) this skips the await
// entirely. Subscribers don't hit this path at all.
@@ -1873,7 +1876,7 @@ async function* queryModel(
lastRequestBetas = betasParams
return {
model: normalizeModelStringForAPI(options.model),
model: normalizeModelStringForAPI(providerRequestModel),
// IMPORTANT: `system` must appear before `messages` in the object literal.
// JSON.stringify preserves insertion order. The native Bun attestation
// (Attestation.zig) overwrites the FIRST `cch=00000` sentinel in the
@@ -1964,7 +1967,7 @@ async function* queryModel(
() =>
getAnthropicClient({
maxRetries: 0, // Disabled auto-retry in favor of manual implementation
model: options.model,
model: providerRequestModel,
fetchOverride: options.fetchOverride,
source: options.querySource,
providerOverride: options.providerOverride,
@@ -2858,9 +2861,9 @@ async function* queryModel(
})
endActiveApiCall()
const result = yield* executeNonStreamingRequest(
{ model: options.model, source: options.querySource, providerOverride: options.providerOverride, effortValue: effort },
{ model: providerRequestModel, source: options.querySource, providerOverride: options.providerOverride, effortValue: effort },
{
model: options.model,
model: providerRequestModel,
fallbackModel: options.fallbackModel,
thinkingConfig,
...(isFastModeEnabled() && { fastMode: isFastMode }),
@@ -2975,13 +2978,13 @@ async function* queryModel(
endActiveApiCall()
const result = yield* executeNonStreamingRequest(
{
model: options.model,
model: providerRequestModel,
source: options.querySource,
providerOverride: options.providerOverride,
effortValue: effort,
},
{
model: options.model,
model: providerRequestModel,
fallbackModel: options.fallbackModel,
thinkingConfig,
...(isFastModeEnabled() && { fastMode: isFastMode }),
@@ -114,6 +114,100 @@ describe('runAgent provider routing', () => {
expect(parentContext.getAppState().mainLoopModel).toBe('parent-model')
})
test('retains an explicit codexplan selection in child state', async () => {
const parentContext = createToolUseContext('parent-model')
const stop = new Error('stop after cache-safe params')
let capturedContext: ToolUseContext | undefined
const runAgent = await importRunAgent()
const generator = runAgent({
agentDefinition: { ...createAgentDefinition(), model: 'codexplan' },
promptMessages: [createUserMessage({ content: 'inspect this' })],
toolUseContext: parentContext,
canUseTool: async () => ({ behavior: 'allow' }),
isAsync: false,
querySource: 'agent:builtin:general-purpose',
availableTools: [],
onCacheSafeParams: params => {
capturedContext = params.toolUseContext
throw stop
},
})
await expect(generator.next()).rejects.toBe(stop)
expect(capturedContext?.options.mainLoopModel).toBe('gpt-5.6-sol')
expect(capturedContext?.getAppState().mainLoopModel).toBe('codexplan')
expect(capturedContext?.getAppState().mainLoopModelForSession).toBe(
'codexplan',
)
})
test('does not retain codexplan when an explicit agent route selects Sol', async () => {
settingsForTest = {
agentModels: { sol: { model: 'gpt-5.6-sol' } },
agentRouting: { 'general-purpose': 'sol' },
}
const stop = new Error('stop after cache-safe params')
let capturedContext: ToolUseContext | undefined
const runAgent = await importRunAgent()
const generator = runAgent({
agentDefinition: { ...createAgentDefinition(), model: 'codexplan' },
promptMessages: [createUserMessage({ content: 'inspect this' })],
toolUseContext: createToolUseContext('parent-model'),
canUseTool: async () => ({ behavior: 'allow' }),
isAsync: false,
querySource: 'agent:builtin:general-purpose',
availableTools: [],
onCacheSafeParams: params => {
capturedContext = params.toolUseContext
throw stop
},
})
await expect(generator.next()).rejects.toBe(stop)
expect(capturedContext?.options.mainLoopModel).toBe('gpt-5.6-sol')
expect(capturedContext?.getAppState().mainLoopModel).toBe('gpt-5.6-sol')
})
test('an explicit agent route wins over CLAUDE_CODE_SUBAGENT_MODEL', async () => {
settingsForTest = {
agentModels: { sol: { model: 'gpt-5.6-sol' } },
agentRouting: { 'general-purpose': 'sol' },
}
const previous = process.env.CLAUDE_CODE_SUBAGENT_MODEL
process.env.CLAUDE_CODE_SUBAGENT_MODEL = 'codexplan'
try {
const stop = new Error('stop after cache-safe params')
let capturedContext: ToolUseContext | undefined
const runAgent = await importRunAgent()
const generator = runAgent({
agentDefinition: createAgentDefinition(),
promptMessages: [createUserMessage({ content: 'inspect this' })],
toolUseContext: createToolUseContext('parent-model'),
canUseTool: async () => ({ behavior: 'allow' }),
isAsync: false,
querySource: 'agent:builtin:general-purpose',
availableTools: [],
onCacheSafeParams: params => {
capturedContext = params.toolUseContext
throw stop
},
})
await expect(generator.next()).rejects.toBe(stop)
expect(capturedContext?.getAppState().mainLoopModel).toBe('gpt-5.6-sol')
} finally {
if (previous === undefined) {
delete process.env.CLAUDE_CODE_SUBAGENT_MODEL
} else {
process.env.CLAUDE_CODE_SUBAGENT_MODEL = previous
}
}
})
test('rejects disallowed routed models before building child context', async () => {
settingsForTest = {
...routedSettings,
+32 -5
View File
@@ -58,6 +58,7 @@ import { clearSessionHooks } from '../../utils/hooks/sessionHooks.js'
import { executeSubagentStartHooks } from '../../utils/hooks.js'
import { createUserMessage } from '../../utils/messages.js'
import { getAgentModel } from '../../utils/model/agent.js'
import { getProviderRequestModel } from '../../utils/model/model.js'
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
import { resolveAgentRunModelRouting, shouldEnforceModelAllowlist } from '../../services/api/agentRouting.js'
import { getInitialSettings } from '../../utils/settings/settings.js'
@@ -356,7 +357,11 @@ export async function* runAgent({
// Resolve per-agent provider routing from settings
const settings = getInitialSettings()
const { mainLoopModel: effectiveModel, providerOverride } =
const {
mainLoopModel: effectiveModel,
providerOverride,
routed: isExplicitlyRouted,
} =
resolveAgentRunModelRouting({
resolvedAgentModel,
parentModel: toolUseContext.options.mainLoopModel,
@@ -368,6 +373,28 @@ export async function* runAgent({
permissionMode,
})
// Keep the concrete model in the tool context, but preserve a codexplan
// selection in agent state when it still resolves to that concrete model.
// Query uses the state value only at the provider boundary, where custom
// gateways distinguish the legacy alias from an explicit GPT-5.6 Sol model.
const requestedToolModel = model?.trim()
const requestedSubagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL?.trim()
const rawAgentSelection =
isExplicitlyRouted
? effectiveModel
: requestedSubagentModel ||
(requestedToolModel && requestedToolModel.toLowerCase() !== 'inherit'
? requestedToolModel
: agentDefinition.model && agentDefinition.model !== 'inherit'
? agentDefinition.model
: appState.mainLoopModelForSession ??
appState.mainLoopModel ??
effectiveModel)
const agentProviderRequestModel = getProviderRequestModel(
rawAgentSelection,
effectiveModel,
)
if (
shouldEnforceModelAllowlist(
resolvedAgentModel,
@@ -518,8 +545,8 @@ export async function* runAgent({
: state.effortValue
const modelStateChanged =
state.mainLoopModel !== effectiveModel ||
state.mainLoopModelForSession !== effectiveModel
state.mainLoopModel !== agentProviderRequestModel ||
state.mainLoopModelForSession !== agentProviderRequestModel
if (
toolPermissionContext === state.toolPermissionContext &&
@@ -530,8 +557,8 @@ export async function* runAgent({
}
return {
...state,
mainLoopModel: effectiveModel,
mainLoopModelForSession: effectiveModel,
mainLoopModel: agentProviderRequestModel,
mainLoopModelForSession: agentProviderRequestModel,
toolPermissionContext,
effortValue,
}
+14
View File
@@ -896,6 +896,20 @@ export function parseUserSpecifiedModel(
return modelInputTrimmed
}
// Keep the concrete runtime model for context, display, and capability lookup,
// but retain codexplan at the provider boundary: custom gateways deliberately
// give that legacy alias a different default effort than explicit GPT-5.6 Sol.
export function getProviderRequestModel(
selectedModel: string,
runtimeModel: string,
): string {
const selected = selectedModel.trim()
const base = selected.replace(/\[1m]$/i, '').split('?', 1)[0]?.toLowerCase()
return base === 'codexplan' && parseUserSpecifiedModel(selected) === runtimeModel
? selected
: runtimeModel
}
/**
* Resolves a skill's `model:` frontmatter against the current model, carrying
* the `[1m]` suffix over when the target family supports it.
@@ -1,6 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { has1mContext } from '../context.js'
import { parseUserSpecifiedModel, renderModelSetting } from './model.js'
import {
getProviderRequestModel,
parseUserSpecifiedModel,
renderModelSetting,
} from './model.js'
// Regression: the Codex aliases (codexplan/codexspark) dropped the `[1m]`
// (1M-context) tag while every Claude alias (opus/sonnet/haiku/best) preserved
@@ -48,6 +52,18 @@ describe('parseUserSpecifiedModel — codex alias 1M tag', () => {
test('codexplan display identifies the Sol model', () => {
expect(renderModelSetting('codexplan')).toBe('codexplan (gpt-5.6-sol)')
})
test('keeps codexplan as the provider request selection after runtime resolution', () => {
expect(getProviderRequestModel('codexplan', 'gpt-5.6-sol')).toBe(
'codexplan',
)
expect(getProviderRequestModel('gpt-5.6-sol', 'gpt-5.6-sol')).toBe(
'gpt-5.6-sol',
)
expect(getProviderRequestModel('codexplan', 'gpt-5.6-terra')).toBe(
'gpt-5.6-terra',
)
})
})
// Bare gpt-5.6 resolves to the flagship tier (Sol) at parse time — not just in
+23
View File
@@ -5,9 +5,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
getMainLoopModelOverride,
getSessionId,
getSessionProjectDir,
isSessionPersistenceDisabled,
setMainLoopModelOverride,
setSessionPersistenceDisabled,
switchSession,
} from '../bootstrap/state.js'
@@ -29,6 +31,7 @@ import {
} from './sessionStorage.ts'
import {
processResumedConversation,
restoreAgentFromSession,
type ProcessedResume,
} from './sessionRestore.js'
import type { ContentReplacementRecord } from './toolResultStorage.js'
@@ -45,6 +48,7 @@ let originalSkipPromptHistory: string | undefined
let originalSessionId: string
let originalSessionProjectDir: string | null
let originalPersistenceDisabled: boolean
let originalMainLoopModelOverride: ReturnType<typeof getMainLoopModelOverride>
function id(n: number): UUID {
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` as UUID
@@ -251,6 +255,7 @@ beforeEach(async () => {
originalSessionId = getSessionId()
originalSessionProjectDir = getSessionProjectDir()
originalPersistenceDisabled = isSessionPersistenceDisabled()
originalMainLoopModelOverride = getMainLoopModelOverride()
process.env.NODE_ENV = 'test'
process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true'
@@ -289,6 +294,7 @@ afterEach(async () => {
originalSkipPromptHistory
}
setSessionPersistenceDisabled(originalPersistenceDisabled)
setMainLoopModelOverride(originalMainLoopModelOverride)
switchSession(originalSessionId as never, originalSessionProjectDir)
resetProjectForTesting()
releaseSharedMutationLock()
@@ -422,3 +428,20 @@ describe('forked session resume', () => {
expect(forkInfoMessage(result.messages)).toBeUndefined()
})
})
describe('restoreAgentFromSession', () => {
test('retains codexplan as the provider-boundary selection', () => {
setMainLoopModelOverride(undefined)
const agent = {
agentType: 'planner',
model: 'codexplan',
} as unknown as import('../tools/AgentTool/loadAgentsDir.js').AgentDefinition
restoreAgentFromSession('planner', undefined, {
activeAgents: [agent],
allAgents: [agent],
})
expect(getMainLoopModelOverride()).toBe('codexplan')
})
})
+1 -2
View File
@@ -41,7 +41,6 @@ import { logForDebugging } from './debug.js'
import type { FileHistorySnapshot } from './fileHistory.js'
import { fileHistoryRestoreStateFromLog } from './fileHistory.js'
import { createSystemMessage } from './messages.js'
import { parseUserSpecifiedModel } from './model/model.js'
import { getPlansDirectory } from './plans.js'
import { setCwd } from './Shell.js'
import {
@@ -244,7 +243,7 @@ export function restoreAgentFromSession(
resumedAgent.model &&
resumedAgent.model !== 'inherit'
) {
setMainLoopModelOverride(parseUserSpecifiedModel(resumedAgent.model))
setMainLoopModelOverride(resumedAgent.model)
}
return { agentDefinition: resumedAgent, agentType: resumedAgent.agentType }