fix(gsd): enable dynamic routing without models section (#2851)

* test(integration): suppress npm pack buffer overflows

* fix(gsd): enable dynamic routing without models section
This commit is contained in:
mastertyko 2026-03-27 16:53:51 +01:00 committed by GitHub
parent c5907c3677
commit 905ee092ce
2 changed files with 160 additions and 1 deletions

View file

@ -18,6 +18,26 @@ export interface ModelSelectionResult {
routing: { tier: string; modelDowngraded: boolean } | null;
}
export function resolvePreferredModelConfig(
unitType: string,
autoModeStartModel: { provider: string; id: string } | null,
) {
const explicitConfig = resolveModelWithFallbacksForUnit(unitType);
if (explicitConfig) return explicitConfig;
const routingConfig = resolveDynamicRoutingConfig();
if (!routingConfig.enabled || !routingConfig.tier_models) return undefined;
const ceilingModel = routingConfig.tier_models.heavy
?? (autoModeStartModel ? `${autoModeStartModel.provider}/${autoModeStartModel.id}` : undefined);
if (!ceilingModel) return undefined;
return {
primary: ceilingModel,
fallbacks: [],
};
}
/**
* Select and apply the appropriate model for a unit dispatch.
* Handles: per-unit-type model preferences, dynamic complexity routing,
@ -36,7 +56,7 @@ export async function selectAndApplyModel(
autoModeStartModel: { provider: string; id: string } | null,
retryContext?: { isRetry: boolean; previousTier?: string },
): Promise<ModelSelectionResult> {
const modelConfig = resolveModelWithFallbacksForUnit(unitType);
const modelConfig = resolvePreferredModelConfig(unitType, autoModeStartModel);
let routing: { tier: string; modelDowngraded: boolean } | null = null;
if (modelConfig) {

View file

@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolvePreferredModelConfig } from "../auto-model-selection.js";
function makeTempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
test("resolvePreferredModelConfig synthesizes heavy routing ceiling when models section is absent", () => {
const originalCwd = process.cwd();
const originalGsdHome = process.env.GSD_HOME;
const tempProject = makeTempDir("gsd-routing-project-");
const tempGsdHome = makeTempDir("gsd-routing-home-");
try {
mkdirSync(join(tempProject, ".gsd"), { recursive: true });
writeFileSync(
join(tempProject, ".gsd", "PREFERENCES.md"),
[
"---",
"dynamic_routing:",
" enabled: true",
" tier_models:",
" light: claude-haiku-4-5",
" standard: claude-sonnet-4-6",
" heavy: claude-opus-4-6",
"---",
].join("\n"),
"utf-8",
);
process.env.GSD_HOME = tempGsdHome;
process.chdir(tempProject);
const config = resolvePreferredModelConfig("plan-slice", {
provider: "anthropic",
id: "claude-sonnet-4-6",
});
assert.deepEqual(config, {
primary: "claude-opus-4-6",
fallbacks: [],
});
} finally {
process.chdir(originalCwd);
if (originalGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = originalGsdHome;
rmSync(tempProject, { recursive: true, force: true });
rmSync(tempGsdHome, { recursive: true, force: true });
}
});
test("resolvePreferredModelConfig falls back to auto start model when heavy tier is absent", () => {
const originalCwd = process.cwd();
const originalGsdHome = process.env.GSD_HOME;
const tempProject = makeTempDir("gsd-routing-project-");
const tempGsdHome = makeTempDir("gsd-routing-home-");
try {
mkdirSync(join(tempProject, ".gsd"), { recursive: true });
writeFileSync(
join(tempProject, ".gsd", "PREFERENCES.md"),
[
"---",
"dynamic_routing:",
" enabled: true",
" tier_models:",
" light: claude-haiku-4-5",
" standard: claude-sonnet-4-6",
"---",
].join("\n"),
"utf-8",
);
process.env.GSD_HOME = tempGsdHome;
process.chdir(tempProject);
const config = resolvePreferredModelConfig("execute-task", {
provider: "openai",
id: "gpt-5.4",
});
assert.deepEqual(config, {
primary: "openai/gpt-5.4",
fallbacks: [],
});
} finally {
process.chdir(originalCwd);
if (originalGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = originalGsdHome;
rmSync(tempProject, { recursive: true, force: true });
rmSync(tempGsdHome, { recursive: true, force: true });
}
});
test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", () => {
const originalCwd = process.cwd();
const originalGsdHome = process.env.GSD_HOME;
const tempProject = makeTempDir("gsd-routing-project-");
const tempGsdHome = makeTempDir("gsd-routing-home-");
try {
mkdirSync(join(tempProject, ".gsd"), { recursive: true });
writeFileSync(
join(tempProject, ".gsd", "PREFERENCES.md"),
[
"---",
"models:",
" planning: claude-sonnet-4-6",
"dynamic_routing:",
" enabled: true",
" tier_models:",
" heavy: claude-opus-4-6",
"---",
].join("\n"),
"utf-8",
);
process.env.GSD_HOME = tempGsdHome;
process.chdir(tempProject);
const config = resolvePreferredModelConfig("plan-slice", {
provider: "anthropic",
id: "claude-opus-4-6",
});
assert.deepEqual(config, {
primary: "claude-sonnet-4-6",
fallbacks: [],
});
} finally {
process.chdir(originalCwd);
if (originalGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = originalGsdHome;
rmSync(tempProject, { recursive: true, force: true });
rmSync(tempGsdHome, { recursive: true, force: true });
}
});