chore: merge release v1.4.5 to main (#1142)
* chore: bump version to 1.4.5 for hotfix * fix(#1133): honor model_policy on the claude runtime via alias mapping model_policy (including the anthropic-fable Claude Fable 5 preset) was silently ignored on the default `claude` runtime: resolveModelInternal gated the policy branch on `runtime !== 'claude'`, so agents kept resolving from model_profile and no warning was emitted. Wire model_policy into the claude path: map the policy-resolved model ID back to a Claude Code agent alias (claude-fable-5 -> fable, claude-opus-4-8 -> opus, etc.). A value that is already a bare alias is returned as-is; an ID with no Claude alias warns once to stderr and falls back to the configured tier alias rather than emitting an unspawnable ID. Non-claude runtimes return full IDs verbatim (unchanged) and resolveModelForTier precedence is unchanged. Authored against the v1.4.4 base (src/core.cts) for the 1.4.5 hotfix line — NOT next, where this logic lives in src/model-resolver.cts; the forward-port to next is a separate follow-up. Closes #1133 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: promote CHANGELOG for v1.4.5 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"displayName": "GSD Core",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"author": {
|
||||
"name": "open-gsd",
|
||||
|
||||
@@ -6,6 +6,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.5] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`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. Previously the entire `model_policy` block was silently ignored on `claude`. (#1133) (#1133)
|
||||
|
||||
## [1.4.4] - 2026-06-11
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1284,7 +1284,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`):
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.",
|
||||
"contextFileName": "GEMINI.md"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@opengsd/gsd-core",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@opengsd/gsd-core",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.84",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opengsd/gsd-core",
|
||||
"version": "1.4.4",
|
||||
"version": "1.4.5",
|
||||
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"bin": {
|
||||
"gsd-core": "bin/install.js",
|
||||
|
||||
49
src/core.cts
49
src/core.cts
@@ -1426,6 +1426,7 @@ function _warnUnknownProfileOverrides(parsed: Record<string, unknown>, configLab
|
||||
// between cases that intentionally exercise the warning path repeatedly.
|
||||
function _resetRuntimeWarningCacheForTests(): void {
|
||||
_warnedConfigKeys.clear();
|
||||
_modelPolicyUnmappableWarned.clear();
|
||||
}
|
||||
|
||||
interface TierEntryResolved {
|
||||
@@ -1471,6 +1472,31 @@ 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`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #49 — Provider-neutral model policy preset resolution.
|
||||
*/
|
||||
@@ -1548,14 +1574,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)
|
||||
|
||||
@@ -376,34 +376,57 @@ 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 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.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"');
|
||||
assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'claude-fable-5');
|
||||
});
|
||||
|
||||
test('model_policy is skipped when tier:"inherit"', () => {
|
||||
@@ -817,4 +840,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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user