Files
msd-core/src/model-catalog.cts
Tom Boucher 09b535ac00 feat(#2481): add a negotiated effortSurface axis and wire invocation-time effort
ADR-1239 gains a ninth negotiated axis, effortSurface (argv | none), declaring how
a host accepts reasoning effort. ADR-443 is amended in the same change because its
recorded deferral is what the axis resolves: its Unblock condition offered paths
(a) and (b) and stated the choice was 'a maintainer call this file records but does
not make'. Path (a) is selected and satisfied here.

Before this, effort reached a runtime only through install-time channels
(EFFORT_RENDERING's frontmatter/api), so reviewer CLIs spawned as subprocesses
silently inherited whatever effort sat in the user's own global CLI config. The
review lane now resolves one universal effort through the ADR-443 cascade and
renders it per host through the negotiated descriptor.

Every per-host value is documentation-sourced, never inferred:
- claude   argv  -- verified via 'claude --help' (--effort <level>)
- opencode argv  -- verified via 'opencode run --help' (--variant)
- codex    argv  -- codex-rs/exec/src/cli.rs: model_reasoning_effort is NOT a CLI
                    flag (config.toml key only), so the global -c override is the
                    only argv route
- 15 hosts undocumented -- their docs state no reasoning setting; the sentinel
                    fails closed rather than inheriting a profile baseline

No config-file vocabulary member: the only host that ever had one (Gemini CLI's
thinkingConfig) was removed as a sunset runtime by 8f2ebbe9b (#1928, PR #1996),
and neither Antigravity CLI nor ZCode documents a reasoning setting.

Closes #2481

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:10:22 -04:00

318 lines
13 KiB
TypeScript

/**
* Model catalog — typed access to model-catalog.json.
*
* ADR-457 build-at-publish: the hand-written bin/lib/model-catalog.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
*/
import path from 'node:path';
// In .cts (CommonJS output) files, `require` is available as a global;
// we use it directly to load JSON candidates.
const _require: NodeRequire = require;
// Resolve model-catalog.json via a prioritised candidate list so the module
// works in every layout:
//
// 1. Co-located install path — gsd-core/bin/shared/model-catalog.json
// 2. GSD_MODEL_CATALOG env override
//
// A third candidate — `sdk/shared/model-catalog.json`, three levels up — used to
// sit between them. It was the legacy source-repo path kept as a fallback by the
// #3288 fix, whose contract was "check the co-located path FIRST, before the
// legacy source-repo path". ADR-0174 then retired the `@opengsd/gsd-sdk` package
// boundary and deleted the `sdk/` tree outright, so that candidate can no longer
// resolve in any layout: in a source repo there is no `sdk/`, and in an install
// layout it points at `~/.claude/sdk/shared/`, which the installer never writes
// (the original #3288 bug). It is removed rather than left as dead weight that
// implies a package boundary this repo no longer has.
const _catalogCandidates: string[] = [
path.resolve(__dirname, '..', 'shared', 'model-catalog.json'),
...(process.env['GSD_MODEL_CATALOG'] ? [path.resolve(process.env['GSD_MODEL_CATALOG'])] : []),
];
/** Typed tier entry from model-catalog.json (model + optional reasoning_effort). */
export interface TierEntry {
model: string;
reasoning_effort?: string;
}
/** Per-agent model mapping in the catalog. */
export interface AgentMeta {
golden: string;
balanced: string;
budget: string;
phaseType: string;
routingTier: string;
}
/** The shape of model-catalog.json. */
export interface ModelCatalog {
profiles: string[];
phaseTypes: string[];
adaptiveTierMap: Record<string, string>;
runtimeTierDefaults: Record<string, Record<string, TierEntry | null>>;
providerPresets: Record<string, Record<string, Record<string, TierEntry | null>>>;
agents: Record<string, AgentMeta>;
}
let catalog: ModelCatalog | null = null;
let _catalogLastErr: Error | null = null;
for (const _p of _catalogCandidates) {
try {
catalog = _require(_p) as ModelCatalog;
break;
} catch (e) {
const isMissingCandidate =
(e && (e as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND' && String((e as Error).message || '').includes(_p)) ||
(e && (e as NodeJS.ErrnoException).code === 'ENOENT');
if (!isMissingCandidate) throw e;
_catalogLastErr = e as Error;
}
}
if (!catalog) {
throw new Error(
`model-catalog.json not found. Tried:\n${_catalogCandidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${_catalogLastErr?.message}`
);
}
// After the throw guard above, catalog is guaranteed non-null.
const _catalog = catalog;
export { _catalog as catalog };
export const VALID_PROFILES: string[] = [..._catalog.profiles];
export const VALID_PHASE_TYPES: Set<string> = new Set(_catalog.phaseTypes);
export const VALID_AGENT_TIERS: Set<string> = new Set(Object.keys(_catalog.adaptiveTierMap));
// Catalog-derived so this can never drift from the resolver's tier gate:
// Object.values(adaptiveTierMap) === ['opus', 'sonnet', 'haiku'] today, plus 'inherit'.
export const VALID_TIERS: Set<string> = new Set([...Object.values(_catalog.adaptiveTierMap), 'inherit']);
// Same catalog-derived tier values as VALID_TIERS but WITHOUT 'inherit' — used
// by config-loader's runtime-override validation (model_profile_overrides /
// model_policy.runtime_tiers), which does not accept 'inherit' as a tier.
export const ADAPTIVE_TIER_VALUES: Set<string> = new Set(Object.values(_catalog.adaptiveTierMap));
/** Per-profile model slots for each agent. */
export interface AgentModelProfiles {
quality: string;
balanced: string;
budget: string;
adaptive: string;
}
export const MODEL_PROFILES: Record<string, AgentModelProfiles> = Object.fromEntries(
Object.entries(_catalog.agents).map(([agent, meta]) => [agent, {
quality: meta.golden,
balanced: meta.balanced,
budget: meta.budget,
adaptive: _catalog.adaptiveTierMap[meta.routingTier],
}])
);
export const AGENT_TO_PHASE_TYPE: Record<string, string> = Object.fromEntries(
Object.entries(_catalog.agents).map(([agent, meta]) => [agent, meta.phaseType])
);
export const AGENT_DEFAULT_TIERS: Record<string, string> = Object.fromEntries(
Object.entries(_catalog.agents).map(([agent, meta]) => [agent, meta.routingTier])
);
export const MODEL_ALIAS_MAP: Record<string, string | undefined> = Object.fromEntries(
Object.entries(_catalog.runtimeTierDefaults['claude'] ?? {}).map(([tier, entry]) => [tier, entry?.model])
);
export const RUNTIME_PROFILE_MAP: Record<string, Record<string, TierEntry>> = (() => {
const result: Record<string, Record<string, TierEntry>> = {};
for (const [runtime, tiers] of Object.entries(_catalog.runtimeTierDefaults)) {
const filtered: Record<string, TierEntry> = {};
for (const [tier, entry] of Object.entries(tiers)) {
if (entry) filtered[tier] = entry;
}
if (Object.keys(filtered).length > 0) result[runtime] = filtered;
}
return result;
})();
export const KNOWN_RUNTIMES: Set<string> = new Set(Object.keys(_catalog.runtimeTierDefaults));
export const RUNTIMES_WITH_REASONING_EFFORT: Set<string> = new Set(
Object.entries(_catalog.runtimeTierDefaults)
.filter(([, tiers]) => Object.values(tiers).some((entry) => entry && entry.reasoning_effort))
.map(([runtime]) => runtime)
);
export const PROVIDER_PRESETS: Record<string, Record<string, Record<string, TierEntry | null>>> =
_catalog.providerPresets ?? {};
// KNOWN_PROVIDERS excludes 'generic' — it is a sentinel (all null entries) that
// forces users to supply model IDs via model_profile_overrides. It is not a
// real catalog-backed provider (#49).
export const KNOWN_PROVIDERS: Set<string> = new Set(
Object.entries(PROVIDER_PRESETS)
.filter(([, tiers]) =>
Object.values(tiers).some((budgets) =>
budgets && Object.values(budgets).some((entry) => entry && entry.model)
)
)
.map(([name]) => name)
);
export function nextTier(currentTier: string): string | null {
const order = ['light', 'standard', 'heavy'];
const idx = order.indexOf(String(currentTier));
if (idx === -1) return null;
return order[Math.min(idx + 1, order.length - 1)];
}
export function formatAgentToModelMapAsTable(agentToModelMap: Record<string, string>): string {
const agentWidth = Math.max('Agent'.length, ...Object.keys(agentToModelMap).map((a) => a.length));
const modelWidth = Math.max('Model'.length, ...Object.values(agentToModelMap).map((m) => m.length));
const sep = '─'.repeat(agentWidth + 2) + '┼' + '─'.repeat(modelWidth + 2);
const header = ` ${'Agent'.padEnd(agentWidth)} │ ${'Model'.padEnd(modelWidth)}`;
let out = `${header}\n${sep}\n`;
for (const [agent, model] of Object.entries(agentToModelMap)) {
out += ` ${agent.padEnd(agentWidth)} │ ${model.padEnd(modelWidth)}\n`;
}
return out;
}
export function getAgentToModelMapForProfile(normalizedProfile: string): Record<string, string> {
const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced';
const out: Record<string, string> = {};
for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) {
const profilesRec = profiles as unknown as Record<string, string>;
out[agent] = profile === 'inherit' ? 'inherit' : (profilesRec[profile] ?? profiles.balanced);
}
return out;
}
// ─── Effort rendering ────────────────────────────────────────────────────────
export interface EffortSpec {
param: string;
channel: string;
supported: Set<string>;
clamp(level: string): string;
}
export const EFFORT_RENDERING: Record<string, EffortSpec> = {
claude: {
param: 'output_config.effort',
channel: 'frontmatter',
supported: new Set(['low', 'medium', 'high', 'xhigh', 'max']),
clamp(level: string): string {
if (level === 'minimal') return 'low';
return level;
},
},
codex: {
param: 'model_reasoning_effort',
channel: 'api',
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh']),
clamp(level: string): string {
if (level === 'max') return 'xhigh';
return level;
},
},
};
export interface RenderedEffort {
value: string;
param: string | null;
channel: string | null;
}
// ─── Invocation-time (argv) effort rendering ─────────────────────────────────
//
// ADR-1239 amendment (#2481) / ADR-443 path (a). EFFORT_RENDERING above covers the
// two INSTALL-TIME channels (`frontmatter`, `api`) — effort baked into a generated
// artifact. This table covers the INVOCATION-TIME channel: the argument appended to
// a host CLI spawned as a subprocess.
//
// WHETHER to emit is not decided here — it is the negotiated `effortSurface` axis on
// the host's descriptor (`argv` | `none`). This table only knows the
// syntax for hosts whose surface is `argv`. A host absent from this table renders
// null, so an undeclared or undocumented host silently gets nothing rather than a
// guessed flag.
export interface EffortArgvSpec {
/** Render the argv fragment for an already-clamped effort level. */
render(level: string): string[];
/** Levels this CLI accepts; anything else clamps via `clamp` first. */
supported: Set<string>;
clamp(level: string): string;
}
export const EFFORT_ARGV: Record<string, EffortArgvSpec> = {
// Verified against `claude --help`: `--effort <level>`.
claude: {
render: (level: string): string[] => ['--effort', level],
supported: new Set(['low', 'medium', 'high', 'xhigh', 'max']),
clamp: (level: string): string => (level === 'minimal' ? 'low' : level),
},
// Verified against `opencode run --help`: `--variant` — "model variant
// (provider-specific reasoning effort, e.g., high, max, minimal)".
opencode: {
render: (level: string): string[] => ['--variant', level],
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
clamp: (level: string): string => level,
},
// First-party Codex docs: `model_reasoning_effort` is a config-only key with no
// dedicated flag, so the generic `-c key=value` override is the only argv route.
codex: {
render: (level: string): string[] => ['-c', `model_reasoning_effort=${level}`],
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh']),
clamp: (level: string): string => (level === 'max' ? 'xhigh' : level),
},
};
export interface RenderedEffortArgv {
argv: string[];
value: string | null;
host: string;
}
/**
* Render the invocation-time effort argument for a host.
*
* `effortSurface` is the host's negotiated axis value. Only `argv` produces an
* argument; `none`, `undocumented`, and anything unrecognised produce nothing.
* Never throws.
*/
export function renderEffortArgv(
host: string,
universalEffort: string,
effortSurface: string | null | undefined,
): RenderedEffortArgv {
const empty: RenderedEffortArgv = { argv: [], value: null, host };
if (effortSurface !== 'argv') return empty;
// Own-property lookup only. A plain `EFFORT_ARGV[host]` resolves `__proto__`
// (and `constructor`/`toString`) to inherited members, which are truthy but
// carry no `clamp`/`render` — a hostile host id would throw instead of
// degrading. The host id reaches here from a descriptor, i.e. untrusted JSON.
if (typeof host !== 'string' || !Object.prototype.hasOwnProperty.call(EFFORT_ARGV, host)) return empty;
const spec = EFFORT_ARGV[host];
if (!spec || typeof spec.clamp !== 'function' || typeof spec.render !== 'function') return empty;
if (typeof universalEffort !== 'string' || universalEffort.length === 0) return empty;
const clamped = spec.clamp(universalEffort);
if (!spec.supported.has(clamped)) return empty;
return { argv: spec.render(clamped), value: clamped, host };
}
/**
* Render a universal effort string for a specific runtime.
*/
export function renderEffortForRuntime(runtime: string, universalEffort: string): RenderedEffort {
const spec = EFFORT_RENDERING[runtime];
if (!spec) {
return { value: universalEffort, param: null, channel: null };
}
return {
value: spec.clamp(universalEffort),
param: spec.param,
channel: spec.channel,
};
}
// ─── Fast mode propagation ───────────────────────────────────────────────────
export const RUNTIMES_WITH_FAST_MODE: Set<string> = new Set(['api']);