fix(sdk): honor Codex model overrides in init progress

This commit is contained in:
Tom Boucher
2026-05-10 17:40:11 -04:00
committed by GitHub
parent 19295f5ab6
commit 52337a8861
5 changed files with 118 additions and 6 deletions

View File

@@ -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)

View File

@@ -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<string, unknown>;
const executor = (await resolveModel(['gsd-executor'], tmpDir)).data as Record<string, unknown>;
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'

View File

@@ -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<string, unknown>, 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<string, unknown> | undefined;
const runtimeOverrides = profileOverrides?.[runtime] as Record<string, unknown> | 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<string, unknown>).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<string, unknown>).models === 'object'
? ((config as Record<string, unknown>).models as Record<string, unknown>)[phaseType]
: undefined;
const tier = typeof phaseTier === 'string' ? phaseTier : alias;
const runtimeTier = resolveRuntimeTier(config as Record<string, unknown>, tier);
if (runtimeTier?.model) {
return { data: { model: runtimeTier.model, profile } };
}
if (resolveModelIds === 'omit') {
return { data: { model: '', profile } };
}
return { data: { model: alias, profile } };
};

View File

@@ -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<string, unknown>;
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.

View File

@@ -43,7 +43,7 @@ import type { QueryHandler } from './utils.js';
async function getModelAlias(agentType: string, projectDir: string): Promise<string> {
const result = await resolveModel([agentType], projectDir);
const data = result.data as Record<string, unknown>;
return (data.model as string) || 'sonnet';
return typeof data.model === 'string' ? data.model : 'sonnet';
}
/**