diff --git a/src/model-resolver.cts b/src/model-resolver.cts index 2fea5d5e1..2820b3510 100644 --- a/src/model-resolver.cts +++ b/src/model-resolver.cts @@ -105,6 +105,51 @@ function _resetModelPolicyWarningCacheForTests(): void { _modelPolicyUnmappableWarned.clear(); } +// Dedupe stderr warnings for unmappable model_overrides Claude IDs (#2041). +const _modelOverrideUnmappableWarned = new Set(); +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 | 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 | 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 | 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, diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 3538a26a5..234bc8f99 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -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 };