fix(#1133): honor model_policy on the claude runtime (forward-port to next) (#1144)

Forward-port of the #1133 hotfix (commit 22f237a5, 1.4.5 hotfix line) onto
next. The hotfix was authored against src/core.cts (v1.4.4); on next the
resolver logic lives in src/model-resolver.cts (ADR-457 extraction, #888),
so the patch is re-applied there rather than cherry-picked.

resolveModelInternal step 2.5 now honors model_policy on the claude runtime:
the policy-resolved full model ID is mapped back to a Claude Code agent alias
via CLAUDE_POLICY_ID_TO_ALIAS (reverse of MODEL_ALIAS_MAP + claude-fable-5 ->
fable). Bare aliases (opus/sonnet/haiku/fable) pass through; an ID with no
Claude alias warns once to stderr (deduped by agentType::policyModel::tier)
and falls back to the configured tier alias. Non-claude runtimes return full
IDs verbatim (unchanged). resolveModelForTier is intentionally unchanged.

The warn-dedupe cache lives in model-resolver.cts; core.cts composes the
exported _resetRuntimeWarningCacheForTests to clear both that cache and the
config-loader warning cache (config-loader cannot import model-resolver --
circular dependency).

Ports the 6 #1133 tests (rewriting the old claude-no-op test that asserted
the bug) plus one added test covering the MODEL_ALIAS_MAP reverse-map path.

Forward-port of #1133

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-12 19:56:54 -04:00
committed by GitHub
parent 4ab5c7b3f2
commit 44024aa535
7 changed files with 136 additions and 27 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1133
---
**`model_policy` is now honored on the default `claude` runtime** — including the `anthropic-fable` Claude Fable 5 preset. Policy-resolved model IDs map to Claude Code agent aliases (e.g. `claude-fable-5` → `fable`), and IDs without a Claude alias warn and fall back to the configured tier. Forward-port of #1133 (originally shipped on the 1.4.5 hotfix line). (#1133)

View File

@@ -1294,7 +1294,7 @@ Choose a provider and budget level via the settings workflow; GSD writes the can
}
```
Known providers: `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`. Budget levels: `high`, `medium`, `low`. Use `anthropic` to keep the Opus 4.8-backed Claude preset, or `anthropic-fable` to opt into Claude Fable 5 for high-budget top-tier routing.
Known providers: `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`. Budget levels: `high`, `medium`, `low`. Use `anthropic` to keep the Opus 4.8-backed Claude preset, or `anthropic-fable` to opt into Claude Fable 5 for high-budget top-tier routing. On the default `claude` runtime, policy-resolved model IDs are mapped to Claude Code agent aliases (for example `claude-fable-5` → `fable`); an ID with no corresponding Claude alias emits a warning and falls back to the configured tier.
For advanced per-runtime control, `runtime_tiers` accepts explicit entries using the internal profile tier names (`opus`, `sonnet`, `haiku`):

View File

@@ -672,6 +672,8 @@ Canonical tier mappings by provider and budget:
Look up the selected (provider, budget) row and proceed to Step E to write those values.
> **claude runtime note:** On the default `claude` runtime, policy-resolved model IDs (e.g. `claude-fable-5`) are mapped to Claude Code agent aliases (`fable`, `opus`, `sonnet`, `haiku`); an ID with no corresponding alias emits a stderr warning and falls back to the configured tier alias.
**Step D — Generic-provider path:**
Prompt the user to enter each model ID as a free-text input. An empty input means "keep

View File

@@ -91,6 +91,7 @@ const {
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,
_resetModelPolicyWarningCacheForTests,
} = modelResolverModule;
// ─── Path helpers ────────────────────────────────────────────────────────────
@@ -416,7 +417,10 @@ export = {
resolveTierEntry,
resolveModelPolicy,
KNOWN_PROVIDERS,
_resetRuntimeWarningCacheForTests,
_resetRuntimeWarningCacheForTests: (): void => {
_resetRuntimeWarningCacheForTests();
_resetModelPolicyWarningCacheForTests();
},
pathExistsInternal,
gitWorktreeInfoInternal,
generateSlugInternal,

View File

@@ -76,6 +76,36 @@ function _resolveRuntimeTier(config: Record<string, unknown>, tier: string): Tie
});
}
// Reverse of the Claude tier-default IDs, plus the Fable alias which Claude
// Code's Agent tool accepts but which is not a GSD model-profile tier (#1133).
const CLAUDE_POLICY_ID_TO_ALIAS: Record<string, string> = {
...Object.fromEntries(
Object.entries(MODEL_ALIAS_MAP)
.filter((e): e is [string, string] => typeof e[1] === 'string')
.map(([aliasName, id]) => [id, aliasName]),
),
'claude-fable-5': 'fable',
};
const CLAUDE_AGENT_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']);
// Dedupe stderr warnings so repeated agent resolutions don't spam (#1133).
const _modelPolicyUnmappableWarned = new Set<string>();
function warnModelPolicyUnmappable(agentType: string, policyModel: string, tier: string): void {
const key = `${agentType}::${policyModel}::${tier}`;
if (_modelPolicyUnmappableWarned.has(key)) return;
_modelPolicyUnmappableWarned.add(key);
// MUST go to stderr — resolve-model's JSON result is parsed from stdout.
process.stderr.write(
`gsd: warning — model_policy resolved "${policyModel}" for ${agentType}, ` +
`but it has no Claude agent alias; using "${tier}" instead.\n`,
);
}
// Test-only: reset the model_policy warn-dedupe cache between cases (#1133).
function _resetModelPolicyWarningCacheForTests(): void {
_modelPolicyUnmappableWarned.clear();
}
/**
* #49 — Provider-neutral model policy preset resolution.
*/
@@ -153,14 +183,29 @@ function resolveModelInternal(cwd: string, agentType: string): string {
? 'inherit'
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
// 2.5. model_policy preset (#49)
// 2.5. model_policy preset (#49, #1133)
const configRuntime = config['runtime'] as string | null | undefined;
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
if (tier && tier !== 'inherit') {
const onClaude = !configRuntime || configRuntime === 'claude';
const effectiveRuntime = configRuntime || 'claude';
const mergedPolicy = config['model_policy']
? { ...(config['model_policy'] as Record<string, unknown>), runtime: configRuntime }
? { ...(config['model_policy'] as Record<string, unknown>), runtime: effectiveRuntime }
: null;
const policyModel = resolveModelPolicy(mergedPolicy, tier);
if (policyModel) return policyModel;
if (policyModel) {
// Non-Claude runtimes take full model IDs verbatim (unchanged behavior).
if (!onClaude) return policyModel;
// Claude Code's Agent tool takes tier aliases (opus/sonnet/haiku/fable),
// not full model IDs — map the policy-resolved ID back to an alias (#1133).
const aliasForId = CLAUDE_POLICY_ID_TO_ALIAS[policyModel];
if (aliasForId) return aliasForId;
// The policy value may already be a bare Claude agent alias (e.g. "fable").
if (CLAUDE_AGENT_ALIASES.has(policyModel)) return policyModel;
// No Claude alias for this ID (e.g. a pinned minor version like
// claude-opus-4-5). Warn once and fall through to the tier alias rather
// than returning an ID Claude Code cannot spawn.
warnModelPolicyUnmappable(agentType, policyModel, tier);
}
}
// 3. Runtime-aware resolution (#2517)
@@ -463,6 +508,7 @@ export = {
resolveTierEntry,
resolveModelPolicy,
resolveModelInternal,
_resetModelPolicyWarningCacheForTests,
VALID_GRANULARITIES,
resolveGranularityInternal,
assertValidGranularityOverride,

View File

@@ -376,34 +376,72 @@ describe('#49 resolveModelInternal: model_policy in the resolution chain', () =>
'runtime_tiers must not fire when config.runtime is absent');
});
test('model_policy is skipped when runtime:"claude" (no-op gate)', () => {
// runtime:"claude" is the implicit default and is treated as a no-op
// for model_policy resolution (same as the no-op gate in the existing
// runtime-aware resolution step). model_policy provider preset
// for anthropic may still fire — but runtime_tiers for claude is a no-op
// because claude-native resolution already handles that path.
test('model_policy provider preset resolves to a Claude alias on runtime:"claude" (#1133)', () => {
writeConfig(projectDir, {
runtime: 'claude',
model_profile: 'quality',
model_profile: 'balanced',
model_policy: { provider: 'anthropic-fable', budget: 'high' },
});
// gsd-planner -> opus tier; anthropic-fable opus/high = claude-fable-5 -> alias "fable"
assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'fable');
});
test('model_policy works with implicit claude runtime (no runtime key) (#1133)', () => {
writeConfig(projectDir, {
model_profile: 'balanced',
model_policy: { provider: 'anthropic-fable', budget: 'high' },
});
// gsd-executor -> sonnet tier; anthropic-fable sonnet/high = claude-fable-5 -> "fable"
assert.strictEqual(resolveModelInternal(projectDir, 'gsd-executor'), 'fable');
});
test('unmappable model_policy ID warns and falls back to the tier alias on claude (#1133)', () => {
_resetRuntimeWarningCacheForTests();
writeConfig(projectDir, {
runtime: 'claude',
model_profile: 'balanced',
model_policy: { provider: 'anthropic-fable', budget: 'low' },
});
// gsd-planner -> opus tier; anthropic-fable opus/low = claude-opus-4-5 (no alias) -> fall back to "opus"
assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'opus');
});
test('model_policy.runtime_tiers applies on runtime:"claude", mapped to alias (#1133)', () => {
writeConfig(projectDir, {
runtime: 'claude',
model_profile: 'balanced',
model_policy: {
provider: 'anthropic',
budget: 'high',
runtime_tiers: {
claude: {
opus: { model: 'claude-runtime-tiers-should-not-appear' },
},
},
runtime_tiers: { claude: { opus: { model: 'claude-fable-5' } } },
},
});
let result;
assert.doesNotThrow(() => {
result = resolveModelInternal(projectDir, 'gsd-planner');
// gsd-planner -> opus tier; runtime_tiers.claude.opus = claude-fable-5 -> "fable" (was a no-op pre-#1133)
assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'fable');
});
test('model_policy maps a built-in catalog model ID to its Claude alias via MODEL_ALIAS_MAP (#1133)', () => {
writeConfig(projectDir, {
runtime: 'claude',
model_profile: 'balanced',
model_policy: {
provider: 'anthropic',
budget: 'high',
runtime_tiers: { claude: { opus: { model: 'claude-opus-4-8' } } },
},
});
assert.ok(typeof result === 'string', 'must return a string');
// The claude runtime_tiers entry must not appear — model_policy runtime_tiers
// is a no-op for runtime:"claude"
assert.notStrictEqual(result, 'claude-runtime-tiers-should-not-appear',
'model_policy.runtime_tiers must be a no-op when runtime is "claude"');
// gsd-planner -> opus tier; runtime_tiers.claude.opus = claude-opus-4-8 ->
// reverse of MODEL_ALIAS_MAP -> "opus" (exercises the non-fable reverse-map path)
assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'opus');
});
test('model_policy still returns full IDs on non-claude runtimes (#1133 regression)', () => {
writeConfig(projectDir, {
runtime: 'opencode',
model_profile: 'balanced',
model_policy: { provider: 'anthropic-fable', budget: 'high' },
});
assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'claude-fable-5');
});
test('model_policy is skipped when tier:"inherit"', () => {
@@ -817,4 +855,18 @@ describe('#49 resolveModelForTier: model_policy beats dynamic_routing', () => {
});
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-executor', 0), 'my-sonnet');
});
test('model_policy value that is already a bare Claude alias is returned as-is on claude (#1133)', () => {
writeConfig(tmpDir, {
runtime: 'claude',
model_profile: 'balanced',
model_policy: {
provider: 'anthropic',
budget: 'high',
runtime_tiers: { claude: { opus: { model: 'fable' } } },
},
});
// gsd-planner → opus tier; runtime_tiers.claude.opus = "fable" is already a valid alias → "fable"
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'fable');
});
});

View File

@@ -66,7 +66,7 @@
"scan.md": 7688,
"secure-phase.md": 12187,
"session-report.md": 4044,
"settings-advanced.md": 39330,
"settings-advanced.md": 39621,
"settings-integrations.md": 15801,
"settings.md": 32133,
"ship.md": 20896,