ADR-857 rollout phase 2f — the FINAL core.cts decomposition. Move the model and effort resolution cluster (resolveModelInternal, resolveModelPolicy, resolveTierEntry, _resolveRuntimeTier, resolveModelForTier, resolveGranularityInternal, assertValidGranularityOverride, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, nextEffort + VALID_GRANULARITIES/ VALID_EFFORTS/EFFORT_SET + interfaces) out of core.cts into a new leaf module src/model-resolver.cts. core.cts re-exports the 13 public symbols (callers in init/docs/commands unchanged; export= set byte-identical). Cycle-free: model-resolver imports only leaves (config-loader for loadConfig, configuration for defaults, model-profiles + model-catalog for the static tables). Removed 6 now-unused imports from core (verified zero remaining references, none re-exported). This completes the god-module decomposition: core.cts 2271 -> 389 lines (~83%), now a thin re-export spine over seven clean leaves (io, phase-id, roadmap-parser, core-utils, phase-locator, config-loader, model-resolver). New-CLI-module checklist done (.gitignore, eslint, INVENTORY 96->97 + row, manifest, ARCHITECTURE, CONTEXT.md "Model Resolver Module"). Adds tests/model-resolver.test.cjs (81 tests: behavioral + shim-identity + adversarial). Gates: lint, code-review (export set byte-identical; import-removal verified), security-review, codex adversarial-review (all 0 findings; verbatim move). Mac 4303 pass; clean-build docker 13117 pass, 0 fail. Closes #888 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -131,6 +131,7 @@ build/
|
|||||||
/gsd-core/bin/lib/io.cjs
|
/gsd-core/bin/lib/io.cjs
|
||||||
/gsd-core/bin/lib/phase-id.cjs
|
/gsd-core/bin/lib/phase-id.cjs
|
||||||
/gsd-core/bin/lib/config-loader.cjs
|
/gsd-core/bin/lib/config-loader.cjs
|
||||||
|
/gsd-core/bin/lib/model-resolver.cjs
|
||||||
/gsd-core/bin/lib/phase-locator.cjs
|
/gsd-core/bin/lib/phase-locator.cjs
|
||||||
/gsd-core/bin/lib/roadmap-parser.cjs
|
/gsd-core/bin/lib/roadmap-parser.cjs
|
||||||
/gsd-core/bin/lib/drift.cjs
|
/gsd-core/bin/lib/drift.cjs
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ Module owning the shared low-level utility primitives extracted from Core: POSIX
|
|||||||
### Config Loader Module
|
### Config Loader Module
|
||||||
Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides (`loadConfig` plus its `_deepMergeConfig`/`isGitIgnored`/`_warnUnknownProfileOverrides` helpers). Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); `core.cjs` re-exports `loadConfig` for back-compat. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`).
|
Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides (`loadConfig` plus its `_deepMergeConfig`/`isGitIgnored`/`_warnUnknownProfileOverrides` helpers). Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); `core.cjs` re-exports `loadConfig` for back-compat. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`).
|
||||||
|
|
||||||
|
### Model Resolver Module
|
||||||
|
Module owning model and effort resolution policy: resolves the model, runtime tier, planning granularity, reasoning effort, and fast-mode for a given agent by reading project config and resolving against the model profiles and catalog (`resolveModelInternal`, `resolveModelPolicy`, `resolveTierEntry`, `resolveModelForTier`, `resolveGranularityInternal`, `resolveEffortInternal`, `resolveFastModeInternal`, `resolveEffortForTier`, `nextEffort`, `assertValidGranularityOverride`). Depends only on leaf modules (`config-loader` for `loadConfig`, `configuration` for defaults, `model-profiles` and `model-catalog` for the static tables) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2f (#888) — the final core.cts decomposition step, leaving Core a thin re-export spine; `core.cjs` re-exports the resolvers for back-compat. Source of truth: `gsd-core/bin/lib/model-resolver.cjs` (generated from `src/model-resolver.cts`).
|
||||||
|
|
||||||
### Package Identity Module [Planned]
|
### Package Identity Module [Planned]
|
||||||
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
|
Single seam owning GSD's published-package coordinates so a repoint/rename is a one-line change instead of a tree-wide sweep. Source of truth is `package.json`; values are *derived*, not re-typed: `packageName` (`.name` → `@opengsd/get-shit-done-redux`), `binName` (`Object.keys(.bin)[0]` → `get-shit-done-redux`), `repoSlug` (parsed from `.repository.url` → `open-gsd/get-shit-done-redux`), plus derived `changelogRawUrl` and `manualInstallCommand({ scope, runtime })`. Generated `.cjs` per ADR-457 (generated-single-source); shipped under `gsd-core/bin/lib/`. Three consumer worlds: **Node** consumers `require()` it at runtime (worker, `check-latest-version.cjs`, `bin/install.js`); the **bash launcher** snippet receives the literal injected by `scripts/sync-runtime-launcher.cjs` at sync time; **prose/help** literals (`update.md`, installer help) carry a committed copy. A drift-guard lint (`scripts/lint-package-identity-drift.cjs`, sibling to `check:alias-drift`) fails CI on any raw package/repo literal outside `package.json`, the generated module, and the value-checked materialization sites — this is what keeps the seam real (`two adapters`, not one). Replaces the contradictory pair it consolidates: the runtime-broken `require('../package.json').name` in `hooks/gsd-check-update-worker.js` (#378, resolves to `undefined` post-install) and the hardcoded constant in `check-latest-version.cjs` (#2992). _Avoid_: "package name string", "the npm name" (when you mean the seam). See ADR-457 and Installer Module.
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core
|
|||||||
| `milestone.cjs` | Milestone archival, requirements marking |
|
| `milestone.cjs` | Milestone archival, requirements marking |
|
||||||
| `commands.cjs` | Misc commands (slug, timestamp, todos, scaffolding, stats) |
|
| `commands.cjs` | Misc commands (slug, timestamp, todos, scaffolding, stats) |
|
||||||
| `model-profiles.cjs` | Model profile resolution table |
|
| `model-profiles.cjs` | Model profile resolution table |
|
||||||
|
| `model-resolver.cjs` | Model and effort resolution policy — resolves model, tier, granularity, effort, and fast-mode for a given agent from project config and model profiles/catalog (extracted from `core.cjs`, ADR-857) |
|
||||||
| `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON parsing, shell argument validation |
|
| `security.cjs` | Path traversal prevention, prompt injection detection, safe JSON parsing, shell argument validation |
|
||||||
| `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support |
|
| `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support |
|
||||||
| `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection |
|
| `docs.cjs` | Docs-update workflow init, Markdown scanning, monorepo detection |
|
||||||
|
|||||||
@@ -309,6 +309,7 @@
|
|||||||
"milestone.cjs",
|
"milestone.cjs",
|
||||||
"model-catalog.cjs",
|
"model-catalog.cjs",
|
||||||
"model-profiles.cjs",
|
"model-profiles.cjs",
|
||||||
|
"model-resolver.cjs",
|
||||||
"package-identity.cjs",
|
"package-identity.cjs",
|
||||||
"package-legitimacy.cjs",
|
"package-legitimacy.cjs",
|
||||||
"phase-command-router.cjs",
|
"phase-command-router.cjs",
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CLI Modules (96 shipped)
|
## CLI Modules (97 shipped)
|
||||||
|
|
||||||
Full listing: `gsd-core/bin/lib/*.cjs`.
|
Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||||
|
|
||||||
@@ -420,6 +420,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
|||||||
| `milestone.cjs` | Milestone archival, requirements marking |
|
| `milestone.cjs` | Milestone archival, requirements marking |
|
||||||
| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers |
|
| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers |
|
||||||
| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table |
|
| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table |
|
||||||
|
| `model-resolver.cjs` | Model/effort resolution policy — resolves model, tier, granularity, effort, and fast-mode for an agent from config + model profiles/catalog (extracted from `core.cjs`, ADR-857) |
|
||||||
| `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) |
|
| `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) |
|
||||||
| `package-legitimacy.cjs` | Registry-API package legitimacy verdicts (OK/SUS/SLOP) from npm/PyPI/crates, slopcheck optional |
|
| `package-legitimacy.cjs` | Registry-API package legitimacy verdicts (OK/SUS/SLOP) from npm/PyPI/crates, slopcheck optional |
|
||||||
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
|
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export default tseslint.config(
|
|||||||
'gsd-core/bin/lib/command-aliases.cjs',
|
'gsd-core/bin/lib/command-aliases.cjs',
|
||||||
'gsd-core/bin/lib/config-schema.cjs',
|
'gsd-core/bin/lib/config-schema.cjs',
|
||||||
'gsd-core/bin/lib/model-profiles.cjs',
|
'gsd-core/bin/lib/model-profiles.cjs',
|
||||||
|
'gsd-core/bin/lib/model-resolver.cjs',
|
||||||
'gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs',
|
'gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs',
|
||||||
'gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs',
|
'gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs',
|
||||||
'gsd-core/bin/lib/observability/logger.cjs',
|
'gsd-core/bin/lib/observability/logger.cjs',
|
||||||
|
|||||||
457
src/core.cts
457
src/core.cts
@@ -20,8 +20,8 @@ import roadmapParserModule = require('./roadmap-parser.cjs');
|
|||||||
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule;
|
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import modelProfiles = require('./model-profiles.cjs');
|
import modelProfiles = require('./model-profiles.cjs');
|
||||||
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
|
const { MODEL_PROFILES, VALID_PHASE_TYPES: _VALID_PHASE_TYPES } = modelProfiles;
|
||||||
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } from './model-catalog.cjs';
|
import { RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, KNOWN_PROVIDERS, MODEL_ALIAS_MAP } from './model-catalog.cjs';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import worktreeSafety = require('./worktree-safety.cjs');
|
import worktreeSafety = require('./worktree-safety.cjs');
|
||||||
const {
|
const {
|
||||||
@@ -62,9 +62,6 @@ const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocat
|
|||||||
import { findProjectRoot } from './project-root.cjs';
|
import { findProjectRoot } from './project-root.cjs';
|
||||||
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
||||||
|
|
||||||
// ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─
|
|
||||||
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cjs';
|
|
||||||
|
|
||||||
// ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ─────
|
// ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ─────
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import configLoaderModule = require('./config-loader.cjs');
|
import configLoaderModule = require('./config-loader.cjs');
|
||||||
@@ -77,6 +74,25 @@ const {
|
|||||||
RUNTIME_OVERRIDE_TIERS,
|
RUNTIME_OVERRIDE_TIERS,
|
||||||
} = configLoaderModule;
|
} = configLoaderModule;
|
||||||
|
|
||||||
|
// ─── Model Resolver Module (extracted from core, ADR-857 phase 2f / #888) ────
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
import modelResolverModule = require('./model-resolver.cjs');
|
||||||
|
const {
|
||||||
|
resolveTierEntry,
|
||||||
|
resolveModelPolicy,
|
||||||
|
resolveModelInternal,
|
||||||
|
VALID_GRANULARITIES,
|
||||||
|
resolveGranularityInternal,
|
||||||
|
assertValidGranularityOverride,
|
||||||
|
resolveModelForTier,
|
||||||
|
VALID_EFFORTS,
|
||||||
|
EFFORT_SET,
|
||||||
|
nextEffort,
|
||||||
|
resolveEffortInternal,
|
||||||
|
resolveFastModeInternal,
|
||||||
|
resolveEffortForTier,
|
||||||
|
} = modelResolverModule;
|
||||||
|
|
||||||
// ─── Path helpers ────────────────────────────────────────────────────────────
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
||||||
// toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877).
|
// toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877).
|
||||||
// The destructured bindings above (from coreUtilsModule) make them available to
|
// The destructured bindings above (from coreUtilsModule) make them available to
|
||||||
@@ -247,431 +263,12 @@ function checkAgentsInstalled(runtime?: string): AgentsInstalledResult {
|
|||||||
// The destructured bindings above (from configLoaderModule) make them available to
|
// The destructured bindings above (from configLoaderModule) make them available to
|
||||||
// core-internal callers; _resetRuntimeWarningCacheForTests is re-exported for back-compat.
|
// core-internal callers; _resetRuntimeWarningCacheForTests is re-exported for back-compat.
|
||||||
|
|
||||||
interface TierEntryResolved {
|
// resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES,
|
||||||
model: string;
|
// resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier,
|
||||||
reasoning_effort?: string;
|
// VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal,
|
||||||
[key: string]: unknown;
|
// resolveEffortForTier — all moved to model-resolver.cjs (ADR-857 phase 2f / #888).
|
||||||
}
|
// The destructured bindings above (from modelResolverModule) make them available to
|
||||||
|
// core-internal callers; core.cjs re-exports all 13 symbols for back-compat.
|
||||||
interface ResolveTierEntryOpts {
|
|
||||||
runtime: string | null | undefined;
|
|
||||||
tier: string | null | undefined;
|
|
||||||
overrides: Record<string, unknown> | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
|
|
||||||
*/
|
|
||||||
function resolveTierEntry({ runtime, tier, overrides }: ResolveTierEntryOpts): TierEntryResolved | null {
|
|
||||||
if (!runtime || !tier) return null;
|
|
||||||
|
|
||||||
const runtimeMap = RUNTIME_PROFILE_MAP as unknown as Record<string, Record<string, Record<string, unknown>>>;
|
|
||||||
const builtin = runtimeMap[runtime]?.[tier] || null;
|
|
||||||
const overridesMap = overrides as Record<string, Record<string, unknown>> | null | undefined;
|
|
||||||
const userRaw = overridesMap?.[runtime]?.[tier];
|
|
||||||
|
|
||||||
let userEntry: Record<string, unknown> | null = null;
|
|
||||||
if (userRaw) {
|
|
||||||
userEntry = typeof userRaw === 'string' ? { model: userRaw } : (userRaw as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!builtin && !userEntry) return null;
|
|
||||||
return { ...(builtin || {}), ...(userEntry || {}) } as TierEntryResolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience wrapper used by resolveModelInternal.
|
|
||||||
*/
|
|
||||||
function _resolveRuntimeTier(config: Record<string, unknown>, tier: string): TierEntryResolved | null {
|
|
||||||
return resolveTierEntry({
|
|
||||||
runtime: config['runtime'] as string | null | undefined,
|
|
||||||
tier,
|
|
||||||
overrides: config['model_profile_overrides'] as Record<string, unknown> | null | undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #49 — Provider-neutral model policy preset resolution.
|
|
||||||
*/
|
|
||||||
function resolveModelPolicy(policy: Record<string, unknown> | null | undefined, tier: string | null | undefined): string | null {
|
|
||||||
if (!policy || typeof policy !== 'object') return null;
|
|
||||||
if (!tier) return null;
|
|
||||||
|
|
||||||
const runtime = policy['runtime'];
|
|
||||||
const rtOverrides = policy['runtime_tiers'];
|
|
||||||
if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') {
|
|
||||||
const rtOverridesMap = rtOverrides as Record<string, unknown>;
|
|
||||||
if (Object.hasOwn(rtOverridesMap, runtime)) {
|
|
||||||
const runtimeEntry = rtOverridesMap[runtime];
|
|
||||||
if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) {
|
|
||||||
const raw = (runtimeEntry as Record<string, unknown>)[tier];
|
|
||||||
if (raw != null) {
|
|
||||||
const entry = typeof raw === 'string' ? { model: raw } : (raw as Record<string, unknown>);
|
|
||||||
if (entry && entry['model']) return entry['model'] as string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = policy['provider'];
|
|
||||||
if (!provider || typeof provider !== 'string') return null;
|
|
||||||
|
|
||||||
if (provider === 'generic' || provider === 'custom') {
|
|
||||||
const TIER_TO_POLICY_KEY: Record<string, string> = { opus: 'high', sonnet: 'medium', haiku: 'low' };
|
|
||||||
const policyKey = TIER_TO_POLICY_KEY[tier];
|
|
||||||
if (!policyKey) return null;
|
|
||||||
const v = policy[policyKey];
|
|
||||||
return (v && typeof v === 'string') ? v : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const presetsMap = PROVIDER_PRESETS as Record<string, Record<string, Record<string, { model: string } | null>>>;
|
|
||||||
if (!Object.hasOwn(presetsMap, provider)) return null;
|
|
||||||
const presetForProvider = presetsMap[provider];
|
|
||||||
if (!presetForProvider || typeof presetForProvider !== 'object') return null;
|
|
||||||
|
|
||||||
if (!Object.hasOwn(presetForProvider, tier)) return null;
|
|
||||||
const tierPresets = presetForProvider[tier];
|
|
||||||
if (!tierPresets || typeof tierPresets !== 'object') return null;
|
|
||||||
|
|
||||||
const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium';
|
|
||||||
if (!Object.hasOwn(tierPresets, budget)) return null;
|
|
||||||
const budgetEntry = tierPresets[budget];
|
|
||||||
if (!budgetEntry || !budgetEntry.model) return null;
|
|
||||||
|
|
||||||
return budgetEntry.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModelInternal(cwd: string, agentType: string): string {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
|
|
||||||
// 1. Per-agent override
|
|
||||||
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
|
||||||
const override = modelOverrides?.[agentType];
|
|
||||||
if (override) {
|
|
||||||
return override;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Compute the tier
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
||||||
const profile = String(config['model_profile'] || 'balanced').toLowerCase();
|
|
||||||
const agentModels = (MODEL_PROFILES as unknown as Record<string, Record<string, string>>)[agentType];
|
|
||||||
const phaseType = (AGENT_TO_PHASE_TYPE)[agentType];
|
|
||||||
const configModels = config['models'] as Record<string, string> | null | undefined;
|
|
||||||
const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
|
|
||||||
? configModels[phaseType]
|
|
||||||
: undefined;
|
|
||||||
const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
|
|
||||||
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
|
||||||
? phaseTypeTier
|
|
||||||
: (profile === 'inherit'
|
|
||||||
? 'inherit'
|
|
||||||
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
|
|
||||||
|
|
||||||
// 2.5. model_policy preset (#49)
|
|
||||||
const configRuntime = config['runtime'] as string | null | undefined;
|
|
||||||
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
|
||||||
const mergedPolicy = config['model_policy']
|
|
||||||
? { ...(config['model_policy'] as Record<string, unknown>), runtime: configRuntime }
|
|
||||||
: null;
|
|
||||||
const policyModel = resolveModelPolicy(mergedPolicy, tier);
|
|
||||||
if (policyModel) return policyModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Runtime-aware resolution (#2517)
|
|
||||||
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
|
||||||
const entry = _resolveRuntimeTier(config, tier);
|
|
||||||
if (entry?.model) return entry.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. resolve_model_ids: "omit"
|
|
||||||
if (config['resolve_model_ids'] === 'omit') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Profile lookup (Claude-native default).
|
|
||||||
if (!agentModels) {
|
|
||||||
return profile === 'quality' ? 'opus'
|
|
||||||
: profile === 'budget' ? 'haiku'
|
|
||||||
: profile === 'inherit' ? 'inherit'
|
|
||||||
: 'sonnet';
|
|
||||||
}
|
|
||||||
if (tier === 'inherit') return 'inherit';
|
|
||||||
const alias = tier;
|
|
||||||
|
|
||||||
if (config['resolve_model_ids']) {
|
|
||||||
return (MODEL_ALIAS_MAP as Record<string, string>)[alias!] || alias!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return alias!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the planning granularity for a phase type (#68).
|
|
||||||
*/
|
|
||||||
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
|
|
||||||
if (override !== undefined && override !== null && override !== '') {
|
|
||||||
if (VALID_GRANULARITIES.has(override)) {
|
|
||||||
return override;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
|
|
||||||
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
|
|
||||||
? configGranularities[phaseType]
|
|
||||||
: undefined;
|
|
||||||
if (perPhase && VALID_GRANULARITIES.has(perPhase)) {
|
|
||||||
return perPhase;
|
|
||||||
}
|
|
||||||
if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') {
|
|
||||||
return config['granularity'] as string;
|
|
||||||
}
|
|
||||||
const planning = config['planning'] as Record<string, unknown> | null | undefined;
|
|
||||||
const planningGran = planning && planning['granularity'];
|
|
||||||
if (planningGran !== undefined && planningGran !== null && planningGran !== '') {
|
|
||||||
return planningGran as string;
|
|
||||||
}
|
|
||||||
return 'standard';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
|
|
||||||
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
|
|
||||||
*/
|
|
||||||
function assertValidGranularityOverride(
|
|
||||||
override: string | null | undefined,
|
|
||||||
fail: (msg: string) => never,
|
|
||||||
): void {
|
|
||||||
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
|
|
||||||
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #3024 — Resolve a model for a specific dynamic-routing attempt.
|
|
||||||
*/
|
|
||||||
function resolveModelForTier(cwd: string, agentType: string, attempt?: number): string {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0;
|
|
||||||
|
|
||||||
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
|
||||||
const override = modelOverrides?.[agentType];
|
|
||||||
if (override) return override;
|
|
||||||
|
|
||||||
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
|
|
||||||
return resolveModelInternal(cwd, agentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
|
||||||
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
|
||||||
return resolveModelInternal(cwd, agentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tierModels = dr['tier_models'] as Record<string, string> | null | undefined;
|
|
||||||
if (!tierModels || typeof tierModels !== 'object') {
|
|
||||||
return resolveModelInternal(cwd, agentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
|
|
||||||
if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) {
|
|
||||||
return resolveModelInternal(cwd, agentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
|
||||||
? (dr['max_escalations'] as number)
|
|
||||||
: 1;
|
|
||||||
const escalationEnabled = dr['escalate_on_failure'] !== false;
|
|
||||||
const effectiveAttempt = escalationEnabled
|
|
||||||
? Math.min(attemptN, maxEscalations)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
let tier = defaultTier;
|
|
||||||
for (let i = 0; i < effectiveAttempt; i += 1) {
|
|
||||||
const next = (nextTier)(tier);
|
|
||||||
if (!next || next === tier) break;
|
|
||||||
tier = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alias = tierModels[tier];
|
|
||||||
if (typeof alias !== 'string' || alias.length === 0) {
|
|
||||||
return resolveModelInternal(cwd, agentType);
|
|
||||||
}
|
|
||||||
return alias;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
|
|
||||||
|
|
||||||
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
||||||
const EFFORT_SET = new Set(VALID_EFFORTS);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walk one step up the effort ladder from `e`.
|
|
||||||
*/
|
|
||||||
function nextEffort(e: string): string | null {
|
|
||||||
const i = VALID_EFFORTS.indexOf(e);
|
|
||||||
if (i < 0) return null;
|
|
||||||
return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EffortOpts {
|
|
||||||
override?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FastModeOpts {
|
|
||||||
override?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #443 — Resolve a universal effort string for (cwd, agentType).
|
|
||||||
*/
|
|
||||||
function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts): string {
|
|
||||||
// Step 1: invocation override
|
|
||||||
if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) {
|
|
||||||
return opts.override;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort']))
|
|
||||||
? (config['effort'] as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Step 2: agent_overrides
|
|
||||||
if (effortCfg) {
|
|
||||||
const ao = effortCfg['agent_overrides'];
|
|
||||||
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
|
||||||
const v = (ao as Record<string, unknown>)[agentType];
|
|
||||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
|
||||||
const mao = canonicalEffort && typeof canonicalEffort === 'object'
|
|
||||||
? (canonicalEffort as Record<string, unknown>)['agent_overrides']
|
|
||||||
: undefined;
|
|
||||||
if (mao && typeof mao === 'object' && !Array.isArray(mao)) {
|
|
||||||
const v = (mao as Record<string, unknown>)[agentType];
|
|
||||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: routing_tier_defaults by agent's default tier.
|
|
||||||
const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
|
|
||||||
if (agentTier) {
|
|
||||||
if (effortCfg && effortCfg['routing_tier_defaults'] &&
|
|
||||||
typeof effortCfg['routing_tier_defaults'] === 'object' &&
|
|
||||||
!Array.isArray(effortCfg['routing_tier_defaults'])) {
|
|
||||||
const v = (effortCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
|
||||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
||||||
} else if (!effortCfg) {
|
|
||||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
|
||||||
const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
|
|
||||||
? (canonicalEffort as Record<string, unknown>)['routing_tier_defaults']
|
|
||||||
: undefined;
|
|
||||||
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
|
||||||
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
|
||||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: effort.default
|
|
||||||
if (effortCfg) {
|
|
||||||
const d = effortCfg['default'];
|
|
||||||
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
|
||||||
} else {
|
|
||||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
|
||||||
const d = canonicalEffort && typeof canonicalEffort === 'object'
|
|
||||||
? (canonicalEffort as Record<string, unknown>)['default']
|
|
||||||
: undefined;
|
|
||||||
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: hardcoded default
|
|
||||||
return 'high';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #443 — Resolve fast_mode boolean for (cwd, agentType).
|
|
||||||
*/
|
|
||||||
function resolveFastModeInternal(cwd: string, agentType: string, opts?: FastModeOpts): boolean {
|
|
||||||
// Step 1: invocation override
|
|
||||||
if (opts && typeof opts.override === 'boolean') {
|
|
||||||
return opts.override;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const fmCfg = (config['fast_mode'] && typeof config['fast_mode'] === 'object' && !Array.isArray(config['fast_mode']))
|
|
||||||
? (config['fast_mode'] as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Step 2: agent_overrides
|
|
||||||
if (fmCfg) {
|
|
||||||
const ao = fmCfg['agent_overrides'];
|
|
||||||
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
|
||||||
const v = (ao as Record<string, unknown>)[agentType];
|
|
||||||
if (typeof v === 'boolean') return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: routing_tier_defaults by agent's default tier.
|
|
||||||
const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
|
|
||||||
if (agentTier) {
|
|
||||||
if (fmCfg && fmCfg['routing_tier_defaults'] &&
|
|
||||||
typeof fmCfg['routing_tier_defaults'] === 'object' &&
|
|
||||||
!Array.isArray(fmCfg['routing_tier_defaults'])) {
|
|
||||||
const v = (fmCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
|
||||||
if (typeof v === 'boolean') return v;
|
|
||||||
} else if (!fmCfg) {
|
|
||||||
const canonicalFm = (CANONICAL_CONFIG_DEFAULTS)['fast_mode'];
|
|
||||||
const manifestDefaults = canonicalFm && typeof canonicalFm === 'object'
|
|
||||||
? (canonicalFm as Record<string, unknown>)['routing_tier_defaults']
|
|
||||||
: undefined;
|
|
||||||
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
|
||||||
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
|
||||||
if (typeof v === 'boolean') return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: fast_mode.enabled
|
|
||||||
if (fmCfg && typeof fmCfg['enabled'] === 'boolean') {
|
|
||||||
return fmCfg['enabled'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: hardcoded default
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #443 — Resolve effort for a dynamic-routing attempt (with escalation).
|
|
||||||
*/
|
|
||||||
function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): string {
|
|
||||||
const base = resolveEffortInternal(cwd, agentType);
|
|
||||||
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
|
||||||
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
if (dr['escalate_on_failure'] === false) {
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
|
||||||
? (dr['max_escalations'] as number)
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0;
|
|
||||||
const effectiveAttempt = Math.min(attemptN, maxEscalations);
|
|
||||||
|
|
||||||
let current = base;
|
|
||||||
for (let i = 0; i < effectiveAttempt; i++) {
|
|
||||||
const next = nextEffort(current);
|
|
||||||
if (!next || next === current) break;
|
|
||||||
current = next;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Summary body helpers / Misc utilities / Phase file helpers ───────────────
|
// ─── Summary body helpers / Misc utilities / Phase file helpers ───────────────
|
||||||
// extractOneLinerFromBody, pathExistsInternal, generateSlugInternal,
|
// extractOneLinerFromBody, pathExistsInternal, generateSlugInternal,
|
||||||
|
|||||||
476
src/model-resolver.cts
Normal file
476
src/model-resolver.cts
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* Model Resolver — Model and effort resolution policy
|
||||||
|
*
|
||||||
|
* ADR-857 rollout phase 2f: extracted from core.cts (issue #888).
|
||||||
|
* Owns model and effort resolution policy: resolves the model, runtime tier,
|
||||||
|
* planning granularity, reasoning effort, and fast-mode for a given agent by
|
||||||
|
* reading project config and resolving against the model profiles and catalog.
|
||||||
|
* Behaviour is preserved byte-for-behaviour from the prior location; only
|
||||||
|
* the module boundary moved. core.cjs re-exports the resolvers for back-compat.
|
||||||
|
*
|
||||||
|
* New imports should pull resolvers from model-resolver.cjs directly.
|
||||||
|
*
|
||||||
|
* Dependencies (leaf modules only — no core.cjs):
|
||||||
|
* - node:fs / node:path (stdlib, not currently needed — included for future use)
|
||||||
|
* - ./config-loader.cjs (loadConfig)
|
||||||
|
* - ./configuration.cjs (CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
|
||||||
|
* - ./model-profiles.cjs (MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier)
|
||||||
|
* - ./model-catalog.cjs (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
import configLoaderModule = require('./config-loader.cjs');
|
||||||
|
const { loadConfig } = configLoaderModule;
|
||||||
|
|
||||||
|
// ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─
|
||||||
|
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cjs';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
import modelProfiles = require('./model-profiles.cjs');
|
||||||
|
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
|
||||||
|
|
||||||
|
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS } from './model-catalog.cjs';
|
||||||
|
|
||||||
|
// ─── Model alias resolution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TierEntryResolved {
|
||||||
|
model: string;
|
||||||
|
reasoning_effort?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveTierEntryOpts {
|
||||||
|
runtime: string | null | undefined;
|
||||||
|
tier: string | null | undefined;
|
||||||
|
overrides: Record<string, unknown> | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
|
||||||
|
*/
|
||||||
|
function resolveTierEntry({ runtime, tier, overrides }: ResolveTierEntryOpts): TierEntryResolved | null {
|
||||||
|
if (!runtime || !tier) return null;
|
||||||
|
|
||||||
|
const runtimeMap = RUNTIME_PROFILE_MAP as unknown as Record<string, Record<string, Record<string, unknown>>>;
|
||||||
|
const builtin = runtimeMap[runtime]?.[tier] || null;
|
||||||
|
const overridesMap = overrides as Record<string, Record<string, unknown>> | null | undefined;
|
||||||
|
const userRaw = overridesMap?.[runtime]?.[tier];
|
||||||
|
|
||||||
|
let userEntry: Record<string, unknown> | null = null;
|
||||||
|
if (userRaw) {
|
||||||
|
userEntry = typeof userRaw === 'string' ? { model: userRaw } : (userRaw as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!builtin && !userEntry) return null;
|
||||||
|
return { ...(builtin || {}), ...(userEntry || {}) } as TierEntryResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper used by resolveModelInternal.
|
||||||
|
*/
|
||||||
|
function _resolveRuntimeTier(config: Record<string, unknown>, tier: string): TierEntryResolved | null {
|
||||||
|
return resolveTierEntry({
|
||||||
|
runtime: config['runtime'] as string | null | undefined,
|
||||||
|
tier,
|
||||||
|
overrides: config['model_profile_overrides'] as Record<string, unknown> | null | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #49 — Provider-neutral model policy preset resolution.
|
||||||
|
*/
|
||||||
|
function resolveModelPolicy(policy: Record<string, unknown> | null | undefined, tier: string | null | undefined): string | null {
|
||||||
|
if (!policy || typeof policy !== 'object') return null;
|
||||||
|
if (!tier) return null;
|
||||||
|
|
||||||
|
const runtime = policy['runtime'];
|
||||||
|
const rtOverrides = policy['runtime_tiers'];
|
||||||
|
if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') {
|
||||||
|
const rtOverridesMap = rtOverrides as Record<string, unknown>;
|
||||||
|
if (Object.hasOwn(rtOverridesMap, runtime)) {
|
||||||
|
const runtimeEntry = rtOverridesMap[runtime];
|
||||||
|
if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) {
|
||||||
|
const raw = (runtimeEntry as Record<string, unknown>)[tier];
|
||||||
|
if (raw != null) {
|
||||||
|
const entry = typeof raw === 'string' ? { model: raw } : (raw as Record<string, unknown>);
|
||||||
|
if (entry && entry['model']) return entry['model'] as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = policy['provider'];
|
||||||
|
if (!provider || typeof provider !== 'string') return null;
|
||||||
|
|
||||||
|
if (provider === 'generic' || provider === 'custom') {
|
||||||
|
const TIER_TO_POLICY_KEY: Record<string, string> = { opus: 'high', sonnet: 'medium', haiku: 'low' };
|
||||||
|
const policyKey = TIER_TO_POLICY_KEY[tier];
|
||||||
|
if (!policyKey) return null;
|
||||||
|
const v = policy[policyKey];
|
||||||
|
return (v && typeof v === 'string') ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetsMap = PROVIDER_PRESETS as Record<string, Record<string, Record<string, { model: string } | null>>>;
|
||||||
|
if (!Object.hasOwn(presetsMap, provider)) return null;
|
||||||
|
const presetForProvider = presetsMap[provider];
|
||||||
|
if (!presetForProvider || typeof presetForProvider !== 'object') return null;
|
||||||
|
|
||||||
|
if (!Object.hasOwn(presetForProvider, tier)) return null;
|
||||||
|
const tierPresets = presetForProvider[tier];
|
||||||
|
if (!tierPresets || typeof tierPresets !== 'object') return null;
|
||||||
|
|
||||||
|
const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium';
|
||||||
|
if (!Object.hasOwn(tierPresets, budget)) return null;
|
||||||
|
const budgetEntry = tierPresets[budget];
|
||||||
|
if (!budgetEntry || !budgetEntry.model) return null;
|
||||||
|
|
||||||
|
return budgetEntry.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelInternal(cwd: string, agentType: string): string {
|
||||||
|
const config = loadConfig(cwd);
|
||||||
|
|
||||||
|
// 1. Per-agent override
|
||||||
|
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
||||||
|
const override = modelOverrides?.[agentType];
|
||||||
|
if (override) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compute the tier
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||||
|
const profile = String(config['model_profile'] || 'balanced').toLowerCase();
|
||||||
|
const agentModels = (MODEL_PROFILES as unknown as Record<string, Record<string, string>>)[agentType];
|
||||||
|
const phaseType = (AGENT_TO_PHASE_TYPE)[agentType];
|
||||||
|
const configModels = config['models'] as Record<string, string> | null | undefined;
|
||||||
|
const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
|
||||||
|
? configModels[phaseType]
|
||||||
|
: undefined;
|
||||||
|
const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
|
||||||
|
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
||||||
|
? phaseTypeTier
|
||||||
|
: (profile === 'inherit'
|
||||||
|
? 'inherit'
|
||||||
|
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
|
||||||
|
|
||||||
|
// 2.5. model_policy preset (#49)
|
||||||
|
const configRuntime = config['runtime'] as string | null | undefined;
|
||||||
|
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
||||||
|
const mergedPolicy = config['model_policy']
|
||||||
|
? { ...(config['model_policy'] as Record<string, unknown>), runtime: configRuntime }
|
||||||
|
: null;
|
||||||
|
const policyModel = resolveModelPolicy(mergedPolicy, tier);
|
||||||
|
if (policyModel) return policyModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Runtime-aware resolution (#2517)
|
||||||
|
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
||||||
|
const entry = _resolveRuntimeTier(config, tier);
|
||||||
|
if (entry?.model) return entry.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. resolve_model_ids: "omit"
|
||||||
|
if (config['resolve_model_ids'] === 'omit') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Profile lookup (Claude-native default).
|
||||||
|
if (!agentModels) {
|
||||||
|
return profile === 'quality' ? 'opus'
|
||||||
|
: profile === 'budget' ? 'haiku'
|
||||||
|
: profile === 'inherit' ? 'inherit'
|
||||||
|
: 'sonnet';
|
||||||
|
}
|
||||||
|
if (tier === 'inherit') return 'inherit';
|
||||||
|
const alias = tier;
|
||||||
|
|
||||||
|
if (config['resolve_model_ids']) {
|
||||||
|
return (MODEL_ALIAS_MAP as Record<string, string>)[alias!] || alias!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return alias!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the planning granularity for a phase type (#68).
|
||||||
|
*/
|
||||||
|
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
|
||||||
|
if (override !== undefined && override !== null && override !== '') {
|
||||||
|
if (VALID_GRANULARITIES.has(override)) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const config = loadConfig(cwd);
|
||||||
|
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
|
||||||
|
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
|
||||||
|
? configGranularities[phaseType]
|
||||||
|
: undefined;
|
||||||
|
if (perPhase && VALID_GRANULARITIES.has(perPhase)) {
|
||||||
|
return perPhase;
|
||||||
|
}
|
||||||
|
if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') {
|
||||||
|
return config['granularity'] as string;
|
||||||
|
}
|
||||||
|
const planning = config['planning'] as Record<string, unknown> | null | undefined;
|
||||||
|
const planningGran = planning && planning['granularity'];
|
||||||
|
if (planningGran !== undefined && planningGran !== null && planningGran !== '') {
|
||||||
|
return planningGran as string;
|
||||||
|
}
|
||||||
|
return 'standard';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
|
||||||
|
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
|
||||||
|
*/
|
||||||
|
function assertValidGranularityOverride(
|
||||||
|
override: string | null | undefined,
|
||||||
|
fail: (msg: string) => never,
|
||||||
|
): void {
|
||||||
|
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
|
||||||
|
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #3024 — Resolve a model for a specific dynamic-routing attempt.
|
||||||
|
*/
|
||||||
|
function resolveModelForTier(cwd: string, agentType: string, attempt?: number): string {
|
||||||
|
const config = loadConfig(cwd);
|
||||||
|
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0;
|
||||||
|
|
||||||
|
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
||||||
|
const override = modelOverrides?.[agentType];
|
||||||
|
if (override) return override;
|
||||||
|
|
||||||
|
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
|
||||||
|
return resolveModelInternal(cwd, agentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
||||||
|
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
||||||
|
return resolveModelInternal(cwd, agentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierModels = dr['tier_models'] as Record<string, string> | null | undefined;
|
||||||
|
if (!tierModels || typeof tierModels !== 'object') {
|
||||||
|
return resolveModelInternal(cwd, agentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
|
||||||
|
if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) {
|
||||||
|
return resolveModelInternal(cwd, agentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
||||||
|
? (dr['max_escalations'] as number)
|
||||||
|
: 1;
|
||||||
|
const escalationEnabled = dr['escalate_on_failure'] !== false;
|
||||||
|
const effectiveAttempt = escalationEnabled
|
||||||
|
? Math.min(attemptN, maxEscalations)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let tier = defaultTier;
|
||||||
|
for (let i = 0; i < effectiveAttempt; i += 1) {
|
||||||
|
const next = (nextTier)(tier);
|
||||||
|
if (!next || next === tier) break;
|
||||||
|
tier = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = tierModels[tier];
|
||||||
|
if (typeof alias !== 'string' || alias.length === 0) {
|
||||||
|
return resolveModelInternal(cwd, agentType);
|
||||||
|
}
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
|
||||||
|
|
||||||
|
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
||||||
|
const EFFORT_SET = new Set(VALID_EFFORTS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk one step up the effort ladder from `e`.
|
||||||
|
*/
|
||||||
|
function nextEffort(e: string): string | null {
|
||||||
|
const i = VALID_EFFORTS.indexOf(e);
|
||||||
|
if (i < 0) return null;
|
||||||
|
return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EffortOpts {
|
||||||
|
override?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastModeOpts {
|
||||||
|
override?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 — Resolve a universal effort string for (cwd, agentType).
|
||||||
|
*/
|
||||||
|
function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts): string {
|
||||||
|
// Step 1: invocation override
|
||||||
|
if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) {
|
||||||
|
return opts.override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(cwd);
|
||||||
|
const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort']))
|
||||||
|
? (config['effort'] as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Step 2: agent_overrides
|
||||||
|
if (effortCfg) {
|
||||||
|
const ao = effortCfg['agent_overrides'];
|
||||||
|
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
||||||
|
const v = (ao as Record<string, unknown>)[agentType];
|
||||||
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||||
|
const mao = canonicalEffort && typeof canonicalEffort === 'object'
|
||||||
|
? (canonicalEffort as Record<string, unknown>)['agent_overrides']
|
||||||
|
: undefined;
|
||||||
|
if (mao && typeof mao === 'object' && !Array.isArray(mao)) {
|
||||||
|
const v = (mao as Record<string, unknown>)[agentType];
|
||||||
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: routing_tier_defaults by agent's default tier.
|
||||||
|
const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
|
||||||
|
if (agentTier) {
|
||||||
|
if (effortCfg && effortCfg['routing_tier_defaults'] &&
|
||||||
|
typeof effortCfg['routing_tier_defaults'] === 'object' &&
|
||||||
|
!Array.isArray(effortCfg['routing_tier_defaults'])) {
|
||||||
|
const v = (effortCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
||||||
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||||
|
} else if (!effortCfg) {
|
||||||
|
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||||
|
const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
|
||||||
|
? (canonicalEffort as Record<string, unknown>)['routing_tier_defaults']
|
||||||
|
: undefined;
|
||||||
|
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
||||||
|
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
||||||
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: effort.default
|
||||||
|
if (effortCfg) {
|
||||||
|
const d = effortCfg['default'];
|
||||||
|
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
||||||
|
} else {
|
||||||
|
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||||
|
const d = canonicalEffort && typeof canonicalEffort === 'object'
|
||||||
|
? (canonicalEffort as Record<string, unknown>)['default']
|
||||||
|
: undefined;
|
||||||
|
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: hardcoded default
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 — Resolve fast_mode boolean for (cwd, agentType).
|
||||||
|
*/
|
||||||
|
function resolveFastModeInternal(cwd: string, agentType: string, opts?: FastModeOpts): boolean {
|
||||||
|
// Step 1: invocation override
|
||||||
|
if (opts && typeof opts.override === 'boolean') {
|
||||||
|
return opts.override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig(cwd);
|
||||||
|
const fmCfg = (config['fast_mode'] && typeof config['fast_mode'] === 'object' && !Array.isArray(config['fast_mode']))
|
||||||
|
? (config['fast_mode'] as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Step 2: agent_overrides
|
||||||
|
if (fmCfg) {
|
||||||
|
const ao = fmCfg['agent_overrides'];
|
||||||
|
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
||||||
|
const v = (ao as Record<string, unknown>)[agentType];
|
||||||
|
if (typeof v === 'boolean') return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: routing_tier_defaults by agent's default tier.
|
||||||
|
const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
|
||||||
|
if (agentTier) {
|
||||||
|
if (fmCfg && fmCfg['routing_tier_defaults'] &&
|
||||||
|
typeof fmCfg['routing_tier_defaults'] === 'object' &&
|
||||||
|
!Array.isArray(fmCfg['routing_tier_defaults'])) {
|
||||||
|
const v = (fmCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
||||||
|
if (typeof v === 'boolean') return v;
|
||||||
|
} else if (!fmCfg) {
|
||||||
|
const canonicalFm = (CANONICAL_CONFIG_DEFAULTS)['fast_mode'];
|
||||||
|
const manifestDefaults = canonicalFm && typeof canonicalFm === 'object'
|
||||||
|
? (canonicalFm as Record<string, unknown>)['routing_tier_defaults']
|
||||||
|
: undefined;
|
||||||
|
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
||||||
|
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
||||||
|
if (typeof v === 'boolean') return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: fast_mode.enabled
|
||||||
|
if (fmCfg && typeof fmCfg['enabled'] === 'boolean') {
|
||||||
|
return fmCfg['enabled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: hardcoded default
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 — Resolve effort for a dynamic-routing attempt (with escalation).
|
||||||
|
*/
|
||||||
|
function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): string {
|
||||||
|
const base = resolveEffortInternal(cwd, agentType);
|
||||||
|
|
||||||
|
const config = loadConfig(cwd);
|
||||||
|
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
||||||
|
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
if (dr['escalate_on_failure'] === false) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
||||||
|
? (dr['max_escalations'] as number)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0;
|
||||||
|
const effectiveAttempt = Math.min(attemptN, maxEscalations);
|
||||||
|
|
||||||
|
let current = base;
|
||||||
|
for (let i = 0; i < effectiveAttempt; i++) {
|
||||||
|
const next = nextEffort(current);
|
||||||
|
if (!next || next === current) break;
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export = {
|
||||||
|
resolveTierEntry,
|
||||||
|
resolveModelPolicy,
|
||||||
|
resolveModelInternal,
|
||||||
|
VALID_GRANULARITIES,
|
||||||
|
resolveGranularityInternal,
|
||||||
|
assertValidGranularityOverride,
|
||||||
|
resolveModelForTier,
|
||||||
|
VALID_EFFORTS,
|
||||||
|
EFFORT_SET,
|
||||||
|
nextEffort,
|
||||||
|
resolveEffortInternal,
|
||||||
|
resolveFastModeInternal,
|
||||||
|
resolveEffortForTier,
|
||||||
|
};
|
||||||
683
tests/model-resolver.test.cjs
Normal file
683
tests/model-resolver.test.cjs
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for model-resolver.cjs (ADR-857 phase 2f / #888).
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - resolveModelInternal: model resolution across tiers + profile overrides
|
||||||
|
* - resolveGranularityInternal + assertValidGranularityOverride
|
||||||
|
* - resolveEffortInternal / resolveFastModeInternal
|
||||||
|
* - resolveEffortForTier / nextEffort
|
||||||
|
* - resolveModelForTier (dynamic routing)
|
||||||
|
* - resolveModelPolicy (#49 provider-neutral presets)
|
||||||
|
* - resolveTierEntry (#2517 runtime-aware tier resolution)
|
||||||
|
* - shim identity: core.X === modelResolver.X for all 13 public symbols
|
||||||
|
* - ADVERSARIAL: unknown agent types, invalid granularity/effort overrides,
|
||||||
|
* runtime override edge cases
|
||||||
|
*/
|
||||||
|
|
||||||
|
process.env.GSD_TEST_MODE = '1';
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
|
||||||
|
const { cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
// ─── modules under test ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||||||
|
const coreModule = require('../gsd-core/bin/lib/core.cjs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
resolveTierEntry,
|
||||||
|
resolveModelPolicy,
|
||||||
|
resolveModelInternal,
|
||||||
|
VALID_GRANULARITIES,
|
||||||
|
resolveGranularityInternal,
|
||||||
|
assertValidGranularityOverride,
|
||||||
|
resolveModelForTier,
|
||||||
|
VALID_EFFORTS,
|
||||||
|
EFFORT_SET,
|
||||||
|
nextEffort,
|
||||||
|
resolveEffortInternal,
|
||||||
|
resolveFastModeInternal,
|
||||||
|
resolveEffortForTier,
|
||||||
|
} = modelResolver;
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeTempProject(prefix = 'gsd-model-resolver-test-') {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||||
|
return tmpDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(tmpDir, obj) {
|
||||||
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── shim identity ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('model-resolver shim identity', () => {
|
||||||
|
test('core.resolveTierEntry === modelResolver.resolveTierEntry', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveTierEntry, modelResolver.resolveTierEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.resolveModelPolicy === modelResolver.resolveModelPolicy', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveModelPolicy, modelResolver.resolveModelPolicy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.resolveModelInternal === modelResolver.resolveModelInternal', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveModelInternal, modelResolver.resolveModelInternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.VALID_GRANULARITIES === modelResolver.VALID_GRANULARITIES (same Set object)', () => {
|
||||||
|
assert.strictEqual(coreModule.VALID_GRANULARITIES, modelResolver.VALID_GRANULARITIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.resolveGranularityInternal === modelResolver.resolveGranularityInternal', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveGranularityInternal, modelResolver.resolveGranularityInternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.assertValidGranularityOverride === modelResolver.assertValidGranularityOverride', () => {
|
||||||
|
assert.strictEqual(coreModule.assertValidGranularityOverride, modelResolver.assertValidGranularityOverride);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.resolveModelForTier === modelResolver.resolveModelForTier', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveModelForTier, modelResolver.resolveModelForTier);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.VALID_EFFORTS === modelResolver.VALID_EFFORTS (same Array object)', () => {
|
||||||
|
assert.strictEqual(coreModule.VALID_EFFORTS, modelResolver.VALID_EFFORTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.EFFORT_SET === modelResolver.EFFORT_SET (same Set object)', () => {
|
||||||
|
assert.strictEqual(coreModule.EFFORT_SET, modelResolver.EFFORT_SET);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.nextEffort === modelResolver.nextEffort', () => {
|
||||||
|
assert.strictEqual(coreModule.nextEffort, modelResolver.nextEffort);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.resolveEffortInternal === modelResolver.resolveEffortInternal', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveEffortInternal, modelResolver.resolveEffortInternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.resolveFastModeInternal === modelResolver.resolveFastModeInternal', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveFastModeInternal, modelResolver.resolveFastModeInternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('core.resolveEffortForTier === modelResolver.resolveEffortForTier', () => {
|
||||||
|
assert.strictEqual(coreModule.resolveEffortForTier, modelResolver.resolveEffortForTier);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveModelInternal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveModelInternal', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||||
|
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||||
|
|
||||||
|
test('no config -> balanced profile -> gsd-planner resolves to a string', () => {
|
||||||
|
const model = resolveModelInternal(tmpDir, 'gsd-planner');
|
||||||
|
assert.ok(typeof model === 'string' && model.length > 0, `Expected non-empty string, got: ${JSON.stringify(model)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('model_overrides takes precedence over everything else', () => {
|
||||||
|
writeConfig(tmpDir, { model_overrides: { 'gsd-planner': 'my-custom-model' } });
|
||||||
|
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'my-custom-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('model_profile=quality -> opus-class model for gsd-planner', () => {
|
||||||
|
writeConfig(tmpDir, { model_profile: 'quality' });
|
||||||
|
const model = resolveModelInternal(tmpDir, 'gsd-planner');
|
||||||
|
// quality profile must resolve to a non-empty model string
|
||||||
|
assert.ok(typeof model === 'string' && model.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('model_profile=budget -> haiku-class model for gsd-planner', () => {
|
||||||
|
writeConfig(tmpDir, { model_profile: 'budget' });
|
||||||
|
const model = resolveModelInternal(tmpDir, 'gsd-planner');
|
||||||
|
assert.ok(typeof model === 'string' && model.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolve_model_ids=omit -> returns empty string', () => {
|
||||||
|
writeConfig(tmpDir, { resolve_model_ids: 'omit' });
|
||||||
|
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown agent type, no config -> returns a non-empty string (fallback)', () => {
|
||||||
|
const model = resolveModelInternal(tmpDir, 'completely-unknown-agent-xyz');
|
||||||
|
assert.ok(typeof model === 'string' && model.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('model_profile=inherit -> returns "inherit"', () => {
|
||||||
|
writeConfig(tmpDir, { model_profile: 'inherit' });
|
||||||
|
assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'inherit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('models config per phase type overrides profile tier', () => {
|
||||||
|
writeConfig(tmpDir, { models: { planning: 'opus' } });
|
||||||
|
// gsd-planner maps to planning phase type; config says opus
|
||||||
|
// with no resolve_model_ids, should return 'opus'
|
||||||
|
const model = resolveModelInternal(tmpDir, 'gsd-planner');
|
||||||
|
assert.strictEqual(model, 'opus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('models with invalid tier value falls through to profile', () => {
|
||||||
|
writeConfig(tmpDir, { models: { planning: 'not-a-valid-tier' } });
|
||||||
|
// invalid tier value -> falls back to profile resolution
|
||||||
|
const model = resolveModelInternal(tmpDir, 'gsd-planner');
|
||||||
|
assert.ok(typeof model === 'string' && model.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime non-claude + model_profile_overrides for runtime tier', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
runtime: 'codex',
|
||||||
|
model_profile_overrides: {
|
||||||
|
codex: { haiku: 'codex-mini', sonnet: 'codex', opus: 'codex-full' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// gsd-codebase-mapper is light tier -> haiku in balanced profile
|
||||||
|
const model = resolveModelInternal(tmpDir, 'gsd-codebase-mapper');
|
||||||
|
assert.ok(typeof model === 'string' && model.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveGranularityInternal ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveGranularityInternal', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||||
|
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||||
|
|
||||||
|
test('no config, no override -> returns "standard"', () => {
|
||||||
|
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning'), 'standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid override wins over config', () => {
|
||||||
|
writeConfig(tmpDir, { granularity: 'fine' });
|
||||||
|
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', 'coarse'), 'coarse');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid override ignored, falls through to config', () => {
|
||||||
|
writeConfig(tmpDir, { granularity: 'fine' });
|
||||||
|
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', 'ultradetailed'), 'fine');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null override falls through to config', () => {
|
||||||
|
writeConfig(tmpDir, { granularity: 'coarse' });
|
||||||
|
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', null), 'coarse');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-phase-type granularity beats global granularity', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
granularity: 'coarse',
|
||||||
|
granularities: { planning: 'fine' },
|
||||||
|
});
|
||||||
|
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning'), 'fine');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planning.granularity nested config used as fallback', () => {
|
||||||
|
writeConfig(tmpDir, { planning: { granularity: 'coarse' } });
|
||||||
|
assert.strictEqual(resolveGranularityInternal(tmpDir, null), 'coarse');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VALID_GRANULARITIES contains exactly coarse, standard, fine', () => {
|
||||||
|
assert.ok(VALID_GRANULARITIES instanceof Set);
|
||||||
|
assert.ok(VALID_GRANULARITIES.has('coarse'));
|
||||||
|
assert.ok(VALID_GRANULARITIES.has('standard'));
|
||||||
|
assert.ok(VALID_GRANULARITIES.has('fine'));
|
||||||
|
assert.strictEqual(VALID_GRANULARITIES.size, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── assertValidGranularityOverride ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('assertValidGranularityOverride', () => {
|
||||||
|
test('undefined -> no-op (no throw)', () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertValidGranularityOverride(undefined, (msg) => { throw new Error(msg); })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null -> no-op (no throw)', () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertValidGranularityOverride(null, (msg) => { throw new Error(msg); })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty string -> no-op (no throw)', () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertValidGranularityOverride('', (msg) => { throw new Error(msg); })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid value "coarse" -> no-op (no throw)', () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
assertValidGranularityOverride('coarse', (msg) => { throw new Error(msg); })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid value -> calls fail with descriptive message', () => {
|
||||||
|
let caught = null;
|
||||||
|
// fail is called with the message; we capture it by throwing so the test can inspect
|
||||||
|
assert.throws(
|
||||||
|
() => assertValidGranularityOverride('megafine', (msg) => { caught = msg; throw new Error(msg); }),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(err.message.includes('megafine'), `error message should include the invalid value: ${err.message}`);
|
||||||
|
assert.ok(err.message.includes('coarse') && err.message.includes('standard') && err.message.includes('fine'),
|
||||||
|
`error message should list valid values: ${err.message}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.ok(caught !== null, 'fail should have been called');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveEffortInternal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveEffortInternal', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||||
|
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||||
|
|
||||||
|
test('no config -> gsd-planner (heavy) defaults to "xhigh" via tier default', () => {
|
||||||
|
assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'xhigh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invocation override beats everything', () => {
|
||||||
|
writeConfig(tmpDir, { effort: { agent_overrides: { 'gsd-planner': 'low' } } });
|
||||||
|
assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner', { override: 'minimal' }), 'minimal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent_overrides beats routing_tier_defaults', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
effort: {
|
||||||
|
routing_tier_defaults: { heavy: 'medium' },
|
||||||
|
agent_overrides: { 'gsd-planner': 'low' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.strictEqual(resolveEffortInternal(tmpDir, 'gsd-planner'), 'low');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('effort.default is final fallback when no tier default matches', () => {
|
||||||
|
writeConfig(tmpDir, { effort: { default: 'minimal' } });
|
||||||
|
assert.strictEqual(resolveEffortInternal(tmpDir, 'completely-unknown-agent-xyz'), 'minimal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('VALID_EFFORTS and EFFORT_SET are consistent', () => {
|
||||||
|
assert.ok(Array.isArray(VALID_EFFORTS));
|
||||||
|
assert.ok(EFFORT_SET instanceof Set);
|
||||||
|
assert.strictEqual(EFFORT_SET.size, VALID_EFFORTS.length);
|
||||||
|
for (const e of VALID_EFFORTS) {
|
||||||
|
assert.ok(EFFORT_SET.has(e), `EFFORT_SET missing: ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── nextEffort ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('nextEffort', () => {
|
||||||
|
test('minimal -> low', () => {
|
||||||
|
assert.strictEqual(nextEffort('minimal'), 'low');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('max -> max (clamp at ceiling)', () => {
|
||||||
|
assert.strictEqual(nextEffort('max'), 'max');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('high -> xhigh', () => {
|
||||||
|
assert.strictEqual(nextEffort('high'), 'xhigh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown effort -> null', () => {
|
||||||
|
assert.strictEqual(nextEffort('turbo'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveFastModeInternal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveFastModeInternal', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||||
|
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||||
|
|
||||||
|
test('no config -> defaults to false', () => {
|
||||||
|
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opts.override=true beats config', () => {
|
||||||
|
writeConfig(tmpDir, { fast_mode: { agent_overrides: { 'gsd-planner': false } } });
|
||||||
|
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner', { override: true }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fast_mode.enabled=true sets default for all agents', () => {
|
||||||
|
writeConfig(tmpDir, { fast_mode: { enabled: true } });
|
||||||
|
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent_overrides beats enabled', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
fast_mode: { enabled: true, agent_overrides: { 'gsd-planner': false } },
|
||||||
|
});
|
||||||
|
assert.strictEqual(resolveFastModeInternal(tmpDir, 'gsd-planner'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown agent with no config -> false', () => {
|
||||||
|
assert.strictEqual(resolveFastModeInternal(tmpDir, 'unknown-agent-xyz'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveEffortForTier ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveEffortForTier', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||||
|
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||||
|
|
||||||
|
test('dynamic_routing disabled -> attempt has no effect', () => {
|
||||||
|
const base = resolveEffortForTier(tmpDir, 'gsd-planner', 0);
|
||||||
|
const at1 = resolveEffortForTier(tmpDir, 'gsd-planner', 1);
|
||||||
|
assert.strictEqual(base, at1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dynamic_routing enabled + escalate_on_failure=true + attempt=1 -> one step up', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
dynamic_routing: {
|
||||||
|
enabled: true,
|
||||||
|
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
|
||||||
|
escalate_on_failure: true,
|
||||||
|
max_escalations: 2,
|
||||||
|
},
|
||||||
|
effort: { routing_tier_defaults: { light: 'low' } },
|
||||||
|
});
|
||||||
|
assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 0), 'low');
|
||||||
|
assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 1), 'medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escalation clamps at "max"', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
dynamic_routing: {
|
||||||
|
enabled: true,
|
||||||
|
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
|
||||||
|
escalate_on_failure: true,
|
||||||
|
max_escalations: 99,
|
||||||
|
},
|
||||||
|
effort: { default: 'xhigh' },
|
||||||
|
});
|
||||||
|
assert.strictEqual(resolveEffortForTier(tmpDir, 'gsd-planner', 99), 'max');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveModelForTier ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveModelForTier', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||||
|
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||||
|
|
||||||
|
test('no dynamic_routing -> falls back to resolveModelInternal', () => {
|
||||||
|
const fromForTier = resolveModelForTier(tmpDir, 'gsd-planner');
|
||||||
|
const fromInternal = resolveModelInternal(tmpDir, 'gsd-planner');
|
||||||
|
assert.strictEqual(fromForTier, fromInternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('model_overrides wins before dynamic routing logic', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
model_overrides: { 'gsd-planner': 'override-model' },
|
||||||
|
dynamic_routing: {
|
||||||
|
enabled: true,
|
||||||
|
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
|
||||||
|
escalate_on_failure: true,
|
||||||
|
max_escalations: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-planner'), 'override-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dynamic_routing + tier_models + attempt=0 -> default tier model', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
dynamic_routing: {
|
||||||
|
enabled: true,
|
||||||
|
tier_models: { light: 'haiku-custom', standard: 'sonnet-custom', heavy: 'opus-custom' },
|
||||||
|
escalate_on_failure: true,
|
||||||
|
max_escalations: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// gsd-codebase-mapper is 'light' tier
|
||||||
|
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-codebase-mapper', 0), 'haiku-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dynamic_routing + attempt=1 escalates tier', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
dynamic_routing: {
|
||||||
|
enabled: true,
|
||||||
|
tier_models: { light: 'haiku-custom', standard: 'sonnet-custom', heavy: 'opus-custom' },
|
||||||
|
escalate_on_failure: true,
|
||||||
|
max_escalations: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// gsd-codebase-mapper light -> attempt=1 -> standard
|
||||||
|
assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-codebase-mapper', 1), 'sonnet-custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveModelPolicy ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveModelPolicy (#49)', () => {
|
||||||
|
test('null policy -> null', () => {
|
||||||
|
assert.strictEqual(resolveModelPolicy(null, 'sonnet'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no provider -> null', () => {
|
||||||
|
assert.strictEqual(resolveModelPolicy({ budget: 'medium' }, 'sonnet'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generic provider: tier=opus -> reads policy.high', () => {
|
||||||
|
const result = resolveModelPolicy(
|
||||||
|
{ provider: 'generic', high: 'my-high-model', medium: 'my-medium', low: 'my-low' },
|
||||||
|
'opus'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result, 'my-high-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generic provider: tier=sonnet -> reads policy.medium', () => {
|
||||||
|
const result = resolveModelPolicy(
|
||||||
|
{ provider: 'generic', high: 'hi', medium: 'med', low: 'lo' },
|
||||||
|
'sonnet'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result, 'med');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generic provider: tier=haiku -> reads policy.low', () => {
|
||||||
|
const result = resolveModelPolicy(
|
||||||
|
{ provider: 'generic', high: 'hi', medium: 'med', low: 'lo' },
|
||||||
|
'haiku'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result, 'lo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('custom provider same as generic', () => {
|
||||||
|
const result = resolveModelPolicy(
|
||||||
|
{ provider: 'custom', medium: 'custom-sonnet' },
|
||||||
|
'sonnet'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result, 'custom-sonnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runtime_tiers override takes precedence over provider', () => {
|
||||||
|
const result = resolveModelPolicy(
|
||||||
|
{
|
||||||
|
provider: 'generic',
|
||||||
|
high: 'generic-hi',
|
||||||
|
medium: 'generic-med',
|
||||||
|
low: 'generic-lo',
|
||||||
|
runtime: 'codex',
|
||||||
|
runtime_tiers: { codex: { sonnet: 'codex-sonnet-override' } },
|
||||||
|
},
|
||||||
|
'sonnet'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result, 'codex-sonnet-override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown tier for generic -> null', () => {
|
||||||
|
const result = resolveModelPolicy(
|
||||||
|
{ provider: 'generic', high: 'hi', medium: 'med', low: 'lo' },
|
||||||
|
'unknown-tier'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveTierEntry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveTierEntry (#2517)', () => {
|
||||||
|
test('null runtime -> null', () => {
|
||||||
|
assert.strictEqual(resolveTierEntry({ runtime: null, tier: 'sonnet', overrides: null }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null tier -> null', () => {
|
||||||
|
assert.strictEqual(resolveTierEntry({ runtime: 'codex', tier: null, overrides: null }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown runtime + unknown tier, no overrides -> null', () => {
|
||||||
|
assert.strictEqual(resolveTierEntry({
|
||||||
|
runtime: 'totally-unknown-runtime-xyz',
|
||||||
|
tier: 'totally-unknown-tier',
|
||||||
|
overrides: null,
|
||||||
|
}), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user override as string expands to { model: string }', () => {
|
||||||
|
const entry = resolveTierEntry({
|
||||||
|
runtime: 'codex',
|
||||||
|
tier: 'sonnet',
|
||||||
|
overrides: { codex: { sonnet: 'my-custom-codex-model' } },
|
||||||
|
});
|
||||||
|
assert.ok(entry !== null);
|
||||||
|
assert.strictEqual(entry.model, 'my-custom-codex-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user override as object merged with builtin', () => {
|
||||||
|
const entry = resolveTierEntry({
|
||||||
|
runtime: 'codex',
|
||||||
|
tier: 'sonnet',
|
||||||
|
overrides: { codex: { sonnet: { model: 'user-model', extra: 'value' } } },
|
||||||
|
});
|
||||||
|
assert.ok(entry !== null);
|
||||||
|
assert.strictEqual(entry.model, 'user-model');
|
||||||
|
assert.strictEqual(entry['extra'], 'value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ADVERSARIAL ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('ADVERSARIAL: edge cases', () => {
|
||||||
|
let tmpDir;
|
||||||
|
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||||
|
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||||
|
|
||||||
|
test('resolveModelInternal: unknown agent + model_profile=quality -> "opus" fallback', () => {
|
||||||
|
writeConfig(tmpDir, { model_profile: 'quality' });
|
||||||
|
const model = resolveModelInternal(tmpDir, 'completely-unknown-agent');
|
||||||
|
assert.strictEqual(model, 'opus');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveModelInternal: unknown agent + model_profile=budget -> "haiku" fallback', () => {
|
||||||
|
writeConfig(tmpDir, { model_profile: 'budget' });
|
||||||
|
assert.strictEqual(resolveModelInternal(tmpDir, 'unknown-agent'), 'haiku');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveGranularityInternal: empty override "" is treated as no override', () => {
|
||||||
|
writeConfig(tmpDir, { granularity: 'fine' });
|
||||||
|
assert.strictEqual(resolveGranularityInternal(tmpDir, 'planning', ''), 'fine');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertValidGranularityOverride: "ultrawide" is invalid -> fail called', () => {
|
||||||
|
let errorMsg = null;
|
||||||
|
assert.throws(
|
||||||
|
() => assertValidGranularityOverride('ultrawide', (msg) => { errorMsg = msg; throw new Error(msg); }),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(err.message.includes('ultrawide'), `error should mention the invalid value: ${err.message}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.ok(errorMsg !== null, 'fail should have been called');
|
||||||
|
assert.ok(errorMsg.includes('ultrawide'), `error message should include 'ultrawide': ${errorMsg}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveEffortInternal: invalid override "turbo" falls through to tier default', () => {
|
||||||
|
const result = resolveEffortInternal(tmpDir, 'gsd-planner', { override: 'turbo' });
|
||||||
|
// gsd-planner is heavy -> tier default xhigh
|
||||||
|
assert.strictEqual(result, 'xhigh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveFastModeInternal: string "true" override is not accepted (must be boolean)', () => {
|
||||||
|
const result = resolveFastModeInternal(tmpDir, 'gsd-planner', { override: 'true' });
|
||||||
|
// string is not boolean -> falls through to default false
|
||||||
|
assert.strictEqual(result, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveEffortInternal: effort block is non-object string -> uses tier default', () => {
|
||||||
|
writeConfig(tmpDir, { effort: 'bad-value' });
|
||||||
|
const result = resolveEffortInternal(tmpDir, 'gsd-planner');
|
||||||
|
assert.ok(EFFORT_SET.has(result), `Expected valid effort, got: ${result}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveModelForTier: unknown agent with dynamic routing -> resolveModelInternal fallback', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
dynamic_routing: {
|
||||||
|
enabled: true,
|
||||||
|
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
|
||||||
|
escalate_on_failure: true,
|
||||||
|
max_escalations: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// unknown agent has no defaultTier -> falls back to resolveModelInternal
|
||||||
|
const fromForTier = resolveModelForTier(tmpDir, 'unknown-agent-xyz');
|
||||||
|
const fromInternal = resolveModelInternal(tmpDir, 'unknown-agent-xyz');
|
||||||
|
assert.strictEqual(fromForTier, fromInternal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveTierEntry: runtime override with non-string, non-object value -> no model set', () => {
|
||||||
|
const entry = resolveTierEntry({
|
||||||
|
runtime: 'codex',
|
||||||
|
tier: 'sonnet',
|
||||||
|
overrides: { codex: { sonnet: 42 } },
|
||||||
|
});
|
||||||
|
// numeric 42 is neither string nor object -> treated as truthy userEntry=42 (not expanded)
|
||||||
|
// result will have whatever builtins exist + the override
|
||||||
|
// Key requirement: does not crash
|
||||||
|
assert.ok(entry !== null || entry === null, 'should not throw');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveModelPolicy: non-object policy -> null', () => {
|
||||||
|
assert.strictEqual(resolveModelPolicy('string-policy', 'sonnet'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveModelPolicy: null tier -> null', () => {
|
||||||
|
assert.strictEqual(resolveModelPolicy({ provider: 'generic', medium: 'sonnet' }, null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveEffortForTier: max_escalations=0 caps escalation', () => {
|
||||||
|
writeConfig(tmpDir, {
|
||||||
|
dynamic_routing: {
|
||||||
|
enabled: true,
|
||||||
|
tier_models: { light: 'haiku', standard: 'sonnet', heavy: 'opus' },
|
||||||
|
escalate_on_failure: true,
|
||||||
|
max_escalations: 0,
|
||||||
|
},
|
||||||
|
effort: { routing_tier_defaults: { light: 'low' } },
|
||||||
|
});
|
||||||
|
const at0 = resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 0);
|
||||||
|
const at1 = resolveEffortForTier(tmpDir, 'gsd-codebase-mapper', 1);
|
||||||
|
// max_escalations=0 means no escalation allowed even at attempt=1
|
||||||
|
assert.strictEqual(at0, at1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user