From 52337a8861f4c36a3c6ccb7ff2658a4dda9f23d4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 10 May 2026 17:40:11 -0400 Subject: [PATCH] fix(sdk): honor Codex model overrides in init progress --- .../fix-3358-sdk-init-progress-models.md | 5 ++ sdk/src/query/config-query.test.ts | 25 ++++++++ sdk/src/query/config-query.ts | 64 +++++++++++++++++-- sdk/src/query/init-complex.test.ts | 28 ++++++++ sdk/src/query/init-complex.ts | 2 +- 5 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-3358-sdk-init-progress-models.md diff --git a/.changeset/fix-3358-sdk-init-progress-models.md b/.changeset/fix-3358-sdk-init-progress-models.md new file mode 100644 index 000000000..77c4561e1 --- /dev/null +++ b/.changeset/fix-3358-sdk-init-progress-models.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3361 +--- +**SDK `resolve-model` and `init.progress` now report Codex runtime override models before applying `resolve_model_ids: "omit"`** — Codex projects using runtime-specific `model_profile_overrides` now see the resolved planner and executor model IDs in SDK query output instead of empty strings or the composed `sonnet` fallback. (#3358) diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts index 6a3095094..ea24b5c3b 100644 --- a/sdk/src/query/config-query.test.ts +++ b/sdk/src/query/config-query.test.ts @@ -187,6 +187,31 @@ describe('resolveModel', () => { expect(data).toHaveProperty('model', ''); }); + it('runtime codex model_profile_overrides beat resolve_model_ids omit (#3358)', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + runtime: 'codex', + resolve_model_ids: 'omit', + model_profile_overrides: { + codex: { + opus: { model: 'gpt-5.5', reasoning_effort: 'high' }, + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5.4-mini', + }, + }, + }), + ); + + const planner = (await resolveModel(['gsd-planner'], tmpDir)).data as Record; + const executor = (await resolveModel(['gsd-executor'], tmpDir)).data as Record; + + expect(planner).toMatchObject({ model: 'gpt-5.5', profile: 'balanced' }); + expect(executor).toMatchObject({ model: 'gpt-5.3-codex', profile: 'balanced' }); + }); + it('resolveModel uses workstream config when --ws is specified', async () => { const { resolveModel } = await import('./config-query.js'); // Root config: balanced profile → gsd-executor resolves to 'sonnet' diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index 1d5c112d0..7c6d7a871 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -24,7 +24,13 @@ import { planningPaths } from './helpers.js'; import { maskIfSecret } from './secrets.js'; import type { QueryHandler } from './utils.js'; export { MODEL_PROFILES, VALID_PROFILES, getAgentToModelMapForProfile } from '../model-catalog.js'; -import { MODEL_PROFILES, VALID_PROFILES, getAgentToModelMapForProfile } from '../model-catalog.js'; +import { + AGENT_TO_PHASE_TYPE, + MODEL_PROFILES, + VALID_PROFILES, + getAgentToModelMapForProfile, + resolveRuntimeTierDefault, +} from '../model-catalog.js'; // ─── configGet ────────────────────────────────────────────────────────────── @@ -114,6 +120,40 @@ export const configPath: QueryHandler = async (_args, projectDir, workstream) => // ─── resolveModel ─────────────────────────────────────────────────────────── +type RuntimeTierName = 'opus' | 'sonnet' | 'haiku'; + +interface RuntimeTierEntry { + model?: string; + reasoning_effort?: string; +} + +function isRuntimeTierName(value: string): value is RuntimeTierName { + return value === 'opus' || value === 'sonnet' || value === 'haiku'; +} + +function normalizeRuntimeTierEntry(entry: unknown): RuntimeTierEntry | null { + if (typeof entry === 'string') return { model: entry }; + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + return entry as RuntimeTierEntry; + } + return null; +} + +function resolveRuntimeTier(config: Record, tier: string): RuntimeTierEntry | null { + if (!isRuntimeTierName(tier)) return null; + + const runtime = typeof config.runtime === 'string' ? config.runtime : ''; + if (!runtime || runtime === 'claude') return null; + + const builtin = resolveRuntimeTierDefault(runtime, tier); + const profileOverrides = config.model_profile_overrides as Record | undefined; + const runtimeOverrides = profileOverrides?.[runtime] as Record | undefined; + const userEntry = normalizeRuntimeTierEntry(runtimeOverrides?.[tier]); + + if (!builtin && !userEntry) return null; + return { ...(builtin ?? {}), ...(userEntry ?? {}) }; +} + /** * Query handler for resolve-model command. * @@ -149,10 +189,11 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) = return { data: result }; } - // No project config (or explicit omit policy) -> return empty model id (CJS parity) + const agentModels = MODEL_PROFILES[agentType]; + + // No project config -> return empty model id (CJS parity) const resolveModelIds = (config as Record).resolve_model_ids; - if (!configExists || resolveModelIds === 'omit') { - const agentModels = MODEL_PROFILES[agentType]; + if (!configExists) { const result = agentModels ? { model: '', profile } : { model: '', profile, unknown_agent: true }; @@ -160,7 +201,6 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) = } // Fall back to profile lookup - const agentModels = MODEL_PROFILES[agentType]; if (!agentModels) { const semanticFallback = profile === 'quality' ? 'opus' @@ -175,5 +215,19 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) = } const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet'; + const phaseType = AGENT_TO_PHASE_TYPE[agentType]; + const phaseTier = phaseType && typeof (config as Record).models === 'object' + ? ((config as Record).models as Record)[phaseType] + : undefined; + const tier = typeof phaseTier === 'string' ? phaseTier : alias; + const runtimeTier = resolveRuntimeTier(config as Record, tier); + if (runtimeTier?.model) { + return { data: { model: runtimeTier.model, profile } }; + } + + if (resolveModelIds === 'omit') { + return { data: { model: '', profile } }; + } + return { data: { model: alias, profile } }; }; diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts index c5d2a854d..9aca816ba 100644 --- a/sdk/src/query/init-complex.test.ts +++ b/sdk/src/query/init-complex.test.ts @@ -173,6 +173,34 @@ describe('initProgress', () => { expect(typeof data.config_path).toBe('string'); }); + it('reports Codex runtime override models when resolve_model_ids is omit (#3358)', async () => { + await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + runtime: 'codex', + resolve_model_ids: 'omit', + model_profile_overrides: { + codex: { + opus: { model: 'gpt-5.5', reasoning_effort: 'high' }, + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5.4-mini', + }, + }, + commit_docs: false, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, + })); + + const result = await initProgress([], tmpDir); + const data = result.data as Record; + expect(data.planner_model).toBe('gpt-5.5'); + expect(data.executor_model).toBe('gpt-5.3-codex'); + }); + // ── #2646: ROADMAP checkbox fallback when no phases/ directory ───────── it('derives completed_count from ROADMAP [x] checkboxes when phases/ is absent', async () => { // Fresh fixture: NO phases/ directory at all, checkbox-driven ROADMAP. diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index bb0f13f84..2e29d4251 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -43,7 +43,7 @@ import type { QueryHandler } from './utils.js'; async function getModelAlias(agentType: string, projectDir: string): Promise { const result = await resolveModel([agentType], projectDir); const data = result.data as Record; - return (data.model as string) || 'sonnet'; + return typeof data.model === 'string' ? data.model : 'sonnet'; } /**