/** * install-model-override-resolver — install-time per-agent model-override * resolution (#2256 / #2794), extracted from the package-root `bin/install.js` * (#2875 Part 2 / J8). * * bin/install.js's inline agent-staging loop duplicated this EXACT precedence * chain across two runtime branches (OpenCode ~24 lines, Kilo ~24 lines) — * `model_overrides[agent]` > `model_profile_overrides..` > omit. * Extracted here — a `src/*.cts` module compiled into the shipped * `gsd-core/bin/lib/` tree, mirroring `install-effort-resolver.cts`'s existing * precedent (#2071) — so the descriptor-driven agents pipeline * (`convertedAgentsKind` / `stageAgentsForRuntimeWithConverter`) and * bin/install.js's own callers resolve through the SAME code. A single source * of truth makes the two paths diverge only if this module changes, not * silently across two hand-maintained copies (the Generative Fix Divergence * class CLAUDE.md's "Known Defects" section warns about). * * `readGsdGlobalModelOverrides` / `readGsdEffectiveModelOverrides` / * `readGsdRuntimeProfileResolver` are impure (config-file reads); * `resolveAgentModelOverride` is pure given their pre-resolved outputs. * * #2875 defect fix: this module is on the `installRuntimeArtifacts` call tree * (reached from `runtime-artifact-layout.cts`'s agents-kind `stage()` for the * opencode/kilo converters) — every fs touch below routes through * `installFs()` (install-fs-adapter.cts), matching `retired-artifact-cleanup.cts` * / `user-artifact-staging.cts`'s existing precedent, instead of calling * `node:fs` directly. A raw `fs` call here silently bypassed a fake adapter * injected via `withInstallFs`/`installRuntimeArtifacts(..., { fs })`, * exactly the class of bug those two modules were fixed for. */ import path from 'node:path'; import os from 'node:os'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-fs-adapter.cjs is an export= CommonJS module import installFsAdapter = require('./install-fs-adapter.cjs'); const { installFs } = installFsAdapter; // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-effort-resolver.cjs is an export= CommonJS module import installEffortResolver = require('./install-effort-resolver.cjs'); const { _readGsdConfigFile, _findAncestorGsdConfigPath } = installEffortResolver as { _readGsdConfigFile: (absPath: string, label: string) => Record | null; _findAncestorGsdConfigPath: (targetDir: string) => string | null; }; // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module import modelCatalog = require('./model-catalog.cjs'); const { MODEL_PROFILES: GSD_MODEL_PROFILES } = modelCatalog as unknown as { MODEL_PROFILES: Record> }; // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module import modelResolverModule = require('./model-resolver.cjs'); const { resolveTierEntry: gsdResolveTierEntry } = modelResolverModule as { resolveTierEntry: (opts: { runtime: string; tier: string; overrides: unknown }) => { model?: string } | null; }; interface ReadOptions { homedir?: () => string; } interface RuntimeProfileResolver { runtime: string; resolve: (agentName: string) => { model?: string; reasoning_effort?: string } | null; } /** * Read `model_overrides` from `~/.gsd/defaults.json` at install time. * Returns an object mapping agent names to model IDs, or null if the file * doesn't exist or has no `model_overrides` entry. * * Mirrors bin/install.js's prior local `readGsdGlobalModelOverrides` exactly * (silent try/catch — no stderr warning on malformed JSON, unlike * `_readGsdConfigFile`'s effort-config sibling — preserved for byte-parity). */ function readGsdGlobalModelOverrides(options: ReadOptions = {}): Record | null { try { const home = options.homedir ? options.homedir() : os.homedir(); const defaultsPath = path.join(home, '.gsd', 'defaults.json'); if (!installFs().existsSync(defaultsPath)) return null; const raw = installFs().readFileSync(defaultsPath, 'utf-8'); const parsed = JSON.parse(raw) as { model_overrides?: unknown }; const overrides = parsed.model_overrides; if (!overrides || typeof overrides !== 'object') return null; return overrides as Record; } catch { return null; } } /** * Effective per-agent `model_overrides` for the install path. * * Merges `~/.gsd/defaults.json` (global) with per-project * `/.planning/config.json`. Per-project keys win on conflict; keys * present in only one source are preserved from that source (#2256). * * `targetDir` is the consuming runtime's install root (walks up to find * `.planning/`). When `targetDir` is null/undefined only the global file is * consulted. * * Returns a plain `{ agentName: modelId }` object, or `null` when neither * source defines `model_overrides`. */ function readGsdEffectiveModelOverrides(targetDir: string | null = null, options: ReadOptions = {}): Record | null { const global = readGsdGlobalModelOverrides(options); let projectOverrides: Record | null = null; if (targetDir) { // #2875 defect fix (Generative Fix Divergence): the 8-deep upward walk to // `.planning/config.json` is single-sourced in install-effort-resolver.cts // (`_findAncestorGsdConfigPath`) — this module was itself extracted FROM // that one to stop duplicating shared install-time config logic, so a // second hand-rolled copy of the walk here defeated the point. const candidate = _findAncestorGsdConfigPath(targetDir); if (candidate) { try { const parsed = JSON.parse(installFs().readFileSync(candidate, 'utf-8')) as { model_overrides?: unknown }; if (parsed && typeof parsed === 'object' && parsed.model_overrides && typeof parsed.model_overrides === 'object') { projectOverrides = parsed.model_overrides as Record; } } catch { // Malformed config.json — fall back to global; readGsdRuntimeProfileResolver // surfaces a parse warning via _readGsdConfigFile already. } } } if (!global && !projectOverrides) return null; // Per-project wins on conflict; preserve non-conflicting global keys. return { ...(global || {}), ...(projectOverrides || {}) }; } interface RuntimeProfileMergedConfig { runtime: string | null; model_profile: string; model_profile_overrides: unknown; } /** * Build a runtime-aware tier resolver for the install path (#2517). * * Probes BOTH per-project `/.planning/config.json` AND * `~/.gsd/defaults.json`, with per-project keys winning over global. * * Returns null if no `runtime` is configured, if `model_profile` is * `inherit`, or if no project config is reachable AND `~/.gsd/defaults.json` * declares no `model_profile` (#3543 — an unverifiable profile is never * baked, letting the runtime's own default/session model govern). */ function readGsdRuntimeProfileResolver(targetDir: string | null = null): RuntimeProfileResolver | null { const homeDefaults = _readGsdConfigFile( path.join(os.homedir(), '.gsd', 'defaults.json'), '~/.gsd/defaults.json', ); // #2875 defect fix (Generative Fix Divergence): same shared walk as // readGsdEffectiveModelOverrides above — see that call site's comment. let projectConfig: Record | null = null; if (targetDir) { const candidate = _findAncestorGsdConfigPath(targetDir); if (candidate) projectConfig = _readGsdConfigFile(candidate, '.planning/config.json'); } const merged: RuntimeProfileMergedConfig = { runtime: (projectConfig && (projectConfig.runtime as string | undefined)) || (homeDefaults && (homeDefaults.runtime as string | undefined)) || null, model_profile: (projectConfig && (projectConfig.model_profile as string | undefined)) || (homeDefaults && (homeDefaults.model_profile as string | undefined)) || 'balanced', model_profile_overrides: (projectConfig && projectConfig.model_profile_overrides) || (homeDefaults && homeDefaults.model_profile_overrides) || null, }; if (!merged.runtime) return null; if (!projectConfig && !(homeDefaults && homeDefaults.model_profile)) { return null; } const profile = String(merged.model_profile).toLowerCase(); if (profile === 'inherit') return null; const runtime = merged.runtime; return { runtime, resolve(agentName: string) { const agentModels = GSD_MODEL_PROFILES[agentName]; if (!agentModels) return null; const tier = agentModels[profile] || agentModels.balanced; if (!tier) return null; return gsdResolveTierEntry({ runtime, tier, overrides: merged.model_profile_overrides, }); }, }; } /** * Resolve the effective model override for a single agent, given a * pre-resolved `modelOverrides` map and `runtimeResolver` (both from the * functions above). Pure — no filesystem access. * * Precedence (J8 — identical for kilo and opencode, resolved through this ONE * shared function so the two runtimes can never diverge): * 1. modelOverrides[agentName] (#2256 — explicit per-agent override) * 2. runtimeResolver.resolve(agentName)?.model * (#2794 — tier-based model_profile_overrides..) * 3. null (omit — J7: the frontmatter key must not appear, not `null`/`""`) */ function resolveAgentModelOverride( agentName: string, modelOverrides: Record | null | undefined, runtimeResolver: RuntimeProfileResolver | null | undefined, ): string | null { const explicit = modelOverrides ? modelOverrides[agentName] : undefined; if (explicit) return explicit; if (runtimeResolver) { const entry = runtimeResolver.resolve(agentName); if (entry && entry.model) return entry.model; } return null; } export = { readGsdGlobalModelOverrides, readGsdEffectiveModelOverrides, readGsdRuntimeProfileResolver, resolveAgentModelOverride, };