models.ts
179 lines · scripts/models.ts at 3f79d81
1/**
2 * Model taxonomy — neutral module with no imports from hosts/ or resolvers/.
3 *
4 * Model families supported by model overlays in model-overlays/{family}.md.
5 * Host configs reference these as `defaultModel` strings (validated at
6 * generation time), but the model axis is independent of the host axis.
7 *
8 * IMPORTANT: host ≠ model. Claude Code can run any Claude model (Opus, Sonnet,
9 * Haiku, future). Codex CLI runs GPT/o-series models. Cursor and OpenCode can
10 * front multiple providers. The generator does NOT auto-detect the model from
11 * the host — users can pass --model explicitly, otherwise each host supplies
12 * its own generation default. Exception outside this module: ./setup detects
13 * the Codex model from ${CODEX_HOME:-~/.codex}/config.toml
14 * (scripts/resolve-codex-generation-model.ts) and passes it as an explicit
15 * --model.
16 */
18export const ALL_MODEL_NAMES = [
19 'claude',
20 'opus-4-7',
21 'fable-5',
22 'opus-4-8',
23 'sonnet-5',
24 'gpt',
25 'gpt-5.4',
26 'gpt-5.6-sol',
27 'gpt-6-astra',
28 'gemini',
29 'o-series',
30] as const;
32export type Model = (typeof ALL_MODEL_NAMES)[number];
34/**
35 * Resolve a model argument from CLI input to a known Model family.
36 *
37 * Input is trimmed and lowercased, and one trailing `[...]` context marker
38 * (for example `claude-sonnet-5[1m]`) is stripped before matching.
39 *
40 * Precedence rules:
41 * 1. Exact match against ALL_MODEL_NAMES → return as-is. This is the ONLY
42 * path that selects `gpt-5.6-sol` — Sol is intentionally exact-only.
43 * It is also how to force a model-pinned profile: `--model opus-4-7`,
44 * `--model sonnet-5`, and so on.
45 * 2. Family heuristics for common variants:
46 * - `gpt-5.4-mini`, `gpt-5.4-turbo`, `gpt-5.4-*` → `gpt-5.4`
47 * - `gpt-*` (anything else GPT, including other 5.6 variants) → `gpt`
48 * - `o3`, `o4`, `o4-mini`, `o1`, `o1-mini`, `o1-pro` → `o-series`
49 * - Model-pinned Claude families (Opus 4.7, Opus 4.8, Sonnet 5) use an
50 * allowlist: the bare ID (`claude-<family>-<major>`), the bare ID plus
51 * an 8-digit date snapshot (`-YYYYMMDD`), and the bare ID plus `-latest`
52 * keep the pinned profile. Any other suffix, including a point release
53 * with or without a date (`claude-opus-4-7-1`, `claude-sonnet-5-5`,
54 * `claude-sonnet-5-5-20261001`), falls to `claude`: a newer model should
55 * not read nudges written for an older one.
56 * - Fable is the exception: any `claude-fable-5-*` ID keeps `fable-5`
57 * while a Fable 5 point release is one of gstack's eval models (the
58 * judge, CLAUDE_JUDGE_EVAL_MODEL in lib/eval-model.ts). The exception
59 * ends when CLAUDE_JUDGE_EVAL_MODEL leaves Fable 5.
60 * - Any other `claude-*` (including `claude-opus-5-5`) → `claude`
61 * - `gemini-*` (2.5-pro, flash, etc.) → `gemini`
62 * 3. Unknown input → returns null (caller decides: error, or fall back).
63 *
64 * The resolver file in model-overlays/{model}.md applies further fallback
65 * (e.g., missing gpt-5.4.md falls back to gpt.md). This function only
66 * normalizes CLI input to a family name.
67 */
68export function resolveModel(input: string): Model | null {
69 const s = input.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim();
70 if (!s) return null;
72 // Exact match first
73 if ((ALL_MODEL_NAMES as readonly string[]).includes(s)) {
74 return s as Model;
75 }
77 // Family heuristics
78 // Sol never reaches here — the exact match above already returned it. Do
79 // not add a Sol family pattern: Terra, Luna, future 5.6 variants, and
80 // suffixed model IDs must NOT inherit Sol's behavioral profile; they fall
81 // through to the generic `gpt` family below.
82 if (/^gpt-6-astra(-|$)/.test(s)) return 'gpt-6-astra';
83 if (/^gpt-5\.4(-|$)/.test(s)) return 'gpt-5.4';
84 if (/^gpt(-|$)/.test(s)) return 'gpt';
85 if (/^o[0-9]+(-|$)/.test(s)) return 'o-series';
86 const pinned = /^claude-(opus-4-7|opus-4-8|sonnet-5)(-\d{8}|-latest)?$/.exec(s);
87 if (pinned) return pinned[1] as Model;
88 if (/^claude-fable-5(-|$)/.test(s)) return 'fable-5';
89 if (/^claude(-|$)/.test(s)) return 'claude';
90 if (/^gemini(-|$)/.test(s)) return 'gemini';
92 return null;
93}
95/**
96 * Validate a string against ALL_MODEL_NAMES. Used by host-config validators
97 * when a HostConfig declares `defaultModel`. Returns an error message or null
98 * if valid.
99 */
100export function validateModel(input: string): string | null {
101 if ((ALL_MODEL_NAMES as readonly string[]).includes(input)) return null;
102 return `'${input}' is not a known model. Use ${ALL_MODEL_NAMES.join(', ')}.`;
103}
105/**
106 * Overlays `./setup --claude-model <id>` may select for the Claude host. The
107 * list is the Claude subset of ALL_MODEL_NAMES; a new Claude overlay joins
108 * both lists.
109 */
110export const CLAUDE_OVERLAY_MODELS = ['claude', 'opus-4-7', 'opus-4-8', 'sonnet-5', 'fable-5'] as const;
112export type ClaudeOverlay = (typeof CLAUDE_OVERLAY_MODELS)[number];
114export interface ClaudeOverlayError {
115 kind: 'alias' | 'unknown' | 'non-claude';
116 problem: string;
117 cause: string;
118 fix: string;
119}
121export type ClaudeOverlayResult =
122 | { overlay: ClaudeOverlay; generic: boolean }
123 | { error: ClaudeOverlayError };
125const CLAUDE_CODE_ALIASES = ['opus', 'sonnet', 'haiku', 'default'];
127/**
128 * Map a `--claude-model` value to the overlay setup renders. `resolveModel`
129 * does the family matching (bracketed context suffixes, dated and `-latest`
130 * snapshots); this adds the Claude-only allowlist and the errors setup prints.
131 * `generic` is true when a specific model ID has no overlay of its own and
132 * uses the generic `claude` overlay (for example `claude-opus-5-5`).
133 */
134export function resolveClaudeOverlay(id: string): ClaudeOverlayResult {
135 const s = id.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim();
136 const problem = `${JSON.stringify(id)} is not a Claude model ID that gstack can map to a skill overlay.`;
137 if (CLAUDE_CODE_ALIASES.includes(s)) {
138 return { error: {
139 kind: 'alias',
140 problem,
141 cause: `"${s}" is a Claude Code alias whose model changes over time, so it names no fixed overlay.`,
142 fix: 'Pass a full model ID, for example ./setup --claude-model claude-opus-4-8, or ./setup --claude-model claude for the generic overlay.',
143 } };
144 }
145 const family = resolveModel(s);
146 if (!family) {
147 return { error: {
148 kind: 'unknown',
149 problem,
150 cause: 'It does not match any model family gstack knows.',
151 fix: 'Pass a Claude model ID such as claude-opus-4-8 or claude-sonnet-5, or claude for the generic overlay.',
152 } };
153 }
154 if (!(CLAUDE_OVERLAY_MODELS as readonly string[]).includes(family)) {
155 return { error: {
156 kind: 'non-claude',
157 problem,
158 cause: `It belongs to the ${family} family, and --claude-model selects Claude overlays only.`,
159 fix: `For Codex skills use ./setup --host codex --model ${id}. For Claude, pass a Claude model ID such as claude-opus-4-8.`,
160 } };
161 }
162 return { overlay: family as ClaudeOverlay, generic: family === 'claude' && s !== 'claude' };
163}
165// `bun scripts/models.ts claude-overlay <id>` — setup's bridge to the table
166// above. Prints "<overlay>\t<generic 0|1>" or the error lines on stderr (exit 1).
167if (import.meta.main) {
168 const [command, id] = process.argv.slice(2);
169 if (command !== 'claude-overlay' || id === undefined) {
170 console.error('Usage: bun scripts/models.ts claude-overlay <claude-model-id>');
171 process.exit(2);
172 }
173 const result = resolveClaudeOverlay(id);
174 if ('error' in result) {
175 console.error(`${result.error.problem}\n${result.error.cause}\n${result.error.fix}`);
176 process.exit(1);
177 }
178 console.log(`${result.overlay}\t${result.generic ? 1 : 0}`);
179}