fix(#2041): map model_overrides full claude IDs to agent-tool aliases

model_overrides values that are full Claude model IDs (claude-sonnet-5,
claude-opus-4-8, claude-haiku-4-5, claude-fable-5) were returned verbatim on
the claude runtime and handed to the Claude Agent tool, whose typed model
parameter documents only tier aliases (opus/sonnet/haiku/fable). The
model_policy path already mapped full IDs -> aliases via
CLAUDE_POLICY_ID_TO_ALIAS (#1144); model_overrides skipped that mapping, so
the two resolver paths produced different shapes for the same underlying
Claude model. The fix mirrors #1144 on the override path via a shared
mapClaudeOverrideForRuntime helper used by both resolveModelInternal and
resolveModelForTier. Bare aliases pass through verbatim; non-Claude runtimes
and non-Claude custom/vendor values keep full IDs verbatim (parity). An
unmappable Claude ID (e.g. claude-opus-4-5) warns once to stderr and falls
through to tier resolution, exactly as the model_policy path already does.
Alias mapping is also the documented best practice (prevents staleness when
new model versions ship).
This commit is contained in:
Tom Boucher
2026-07-06 20:06:30 -04:00
parent 9ea5519bc0
commit f214f1320d
2 changed files with 59 additions and 3 deletions

View File

@@ -105,6 +105,51 @@ function _resetModelPolicyWarningCacheForTests(): void {
_modelPolicyUnmappableWarned.clear();
}
// Dedupe stderr warnings for unmappable model_overrides Claude IDs (#2041).
const _modelOverrideUnmappableWarned = new Set<string>();
function warnModelOverrideUnmappable(agentType: string, overrideValue: string): void {
const key = `${agentType}::${overrideValue}`;
if (_modelOverrideUnmappableWarned.has(key)) return;
_modelOverrideUnmappableWarned.add(key);
// MUST go to stderr — resolve-model's JSON result is parsed from stdout.
process.stderr.write(
`gsd: warning — model_overrides value "${overrideValue}" for ${agentType} ` +
`has no Claude agent alias; falling through to tier resolution.\n`,
);
}
// Test-only: reset the model_overrides warn-dedupe cache between cases (#2041).
function _resetModelOverrideWarningCacheForTests(): void {
_modelOverrideUnmappableWarned.clear();
}
/**
* #2041 — Map a `model_overrides` value to its Claude Agent-tool alias on the
* claude runtime, mirroring the `model_policy` path (#1144). Claude Code's
* Agent tool `model` parameter documents only tier aliases (opus/sonnet/haiku/
* fable); a full Claude model ID returned verbatim is silently dropped by the
* spawner. Returns the value to return verbatim, or null to signal "fall
* through to normal tier/dynamic-routing resolution" (used when a Claude full
* ID has no alias — matches model_policy's warn-and-fall-through). Non-Claude
* runtimes and non-Claude values always pass through verbatim.
*/
function mapClaudeOverrideForRuntime(
override: string,
configRuntime: string | null | undefined,
agentType: string,
): string | null {
const onClaude = !configRuntime || configRuntime === 'claude';
if (!onClaude) return override;
const alias = CLAUDE_POLICY_ID_TO_ALIAS[override];
if (alias) return alias;
if (CLAUDE_AGENT_ALIASES.has(override)) return override;
if (override.startsWith('claude-')) {
warnModelOverrideUnmappable(agentType, override);
return null;
}
return override;
}
/**
* #49 — Provider-neutral model policy preset resolution.
*/
@@ -159,11 +204,15 @@ function resolveModelPolicy(policy: Record<string, unknown> | null | undefined,
function resolveModelInternal(cwd: string, agentType: string): string {
const config = loadConfig(cwd);
// 1. Per-agent override
// 1. Per-agent override (#2041: map Claude full IDs → Agent-tool aliases on
// the claude runtime, mirroring the model_policy path #1144; non-Claude
// runtimes and non-Claude values pass through verbatim).
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
const override = modelOverrides?.[agentType];
if (override) {
return override;
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
if (mapped !== null) return mapped;
// Unmappable Claude ID — fall through to tier resolution (matches model_policy).
}
// 2. Compute the tier
@@ -287,7 +336,11 @@ function resolveModelForTier(cwd: string, agentType: string, attempt?: number):
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
const override = modelOverrides?.[agentType];
if (override) return override;
if (override) {
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
if (mapped !== null) return mapped;
// Unmappable Claude ID — fall through to dynamic_routing / model_policy resolution.
}
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
return resolveModelInternal(cwd, agentType);
@@ -508,6 +561,8 @@ export = {
resolveModelPolicy,
resolveModelInternal,
_resetModelPolicyWarningCacheForTests,
_resetModelOverrideWarningCacheForTests,
mapClaudeOverrideForRuntime,
VALID_GRANULARITIES,
resolveGranularityInternal,
assertValidGranularityOverride,

View File

@@ -423,6 +423,7 @@ function resetRuntimeWarningCaches() {
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
configLoader._resetRuntimeWarningCacheForTests();
modelResolver._resetModelPolicyWarningCacheForTests();
modelResolver._resetModelOverrideWarningCacheForTests();
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, TOOLS_PATH };