/** * eval-models — a repo's eval model rules, .gstack/eval-models.json, and the * lint gstack-eval-plan runs over every arm it prices or reserves (field * list 2, W9). The file states the rules as data, never prose: * * allow ids or globs an arm may use; required, and an id it does not * match fails closed (an uncatalogued or misspelled model is * never admitted by default) * deny ids or globs no arm may use, checked first * scopes per eval scope (smoke, full: the call-plan row scope; an arm * with no stated scope is full) an extra allow and/or deny list, * so "Fable in smoke only" is scopes.full.deny ["claude-fable-*"] * families optional ordered map, newest first: an arm must be the newest * id of its family, and with the map present an arm in no family * fails as unresolved instead of passing unchecked * * Literal allow/deny apply whatever lib/model-catalog.ts holds. MODEL_CATALOG * (one current model per provider and review tier) only checks the families * map's currency: a catalog model listed after another id in its family is a * stale map. The reviewed copy is read from origin/; the working tree's * copy only with --models-from head. Doc: docs/eval-models.md. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { MODEL_CATALOG } from './model-catalog'; import type { ResultCodeName } from './result-codes'; export const EVAL_MODELS_PATH = '.gstack/eval-models.json'; export const EVAL_SCOPES = ['smoke', 'full'] as const; export type EvalScope = typeof EVAL_SCOPES[number]; export interface ScopeRules { allow?: string[]; deny: string[] } export interface EvalModelRules { schema_version: 1; allow: string[]; deny: string[]; scopes: Partial>; families: Record } export interface ArmUse { model: string; scope: EvalScope; where: string } export type ModelVerdict = 'ok' | 'denied' | 'old-generation' | 'generation-unresolved'; export interface ModelFinding extends ArmUse { verdict: ModelVerdict; reason: string } export interface ModelRulesCheck { ok: boolean; source: string; lines: string[]; findings: ModelFinding[]; errors: Array<{ code: ResultCodeName; message: string }> } const PATTERN = /^[A-Za-z0-9._:/*?-]+$/; const TOP_KEYS = ['schema_version', 'allow', 'deny', 'scopes', 'families']; function patterns(v: unknown, at: string, errors: string[], o: { required?: boolean; literal?: boolean } = {}): string[] { if (v === undefined && !o.required) return []; if (!Array.isArray(v) || (o.required && v.length === 0)) { errors.push(`${at} must be ${o.required ? 'a non-empty' : 'an'} array of model ids${o.literal ? '' : ' or globs'}`); return []; } const out: string[] = []; v.forEach((p, i) => { if (typeof p !== 'string' || !PATTERN.test(p) || (o.literal && /[*?]/.test(p))) errors.push(`${at}[${i}] ${JSON.stringify(p)} is not a model id${o.literal ? ' (family entries are literal ids, newest first)' : ' or glob (letters, digits, . _ : / - and the wildcards * ?)'}`); else out.push(p); }); return out; } const isObject = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); /** Parse and validate the rules; any unknown key, scope or malformed entry fails the whole file. */ export function parseEvalModels(text: string): { rules: EvalModelRules } | { errors: string[] } { let doc: unknown; try { doc = JSON.parse(text); } catch (e: any) { return { errors: [`not valid JSON: ${e.message}`] }; } if (!isObject(doc)) return { errors: ['must be a JSON object'] }; const errors: string[] = []; for (const k of Object.keys(doc)) if (!TOP_KEYS.includes(k)) errors.push(`unknown key ${JSON.stringify(k)} (known: ${TOP_KEYS.join(', ')})`); if (doc.schema_version !== 1) errors.push('schema_version must be 1'); const rules: EvalModelRules = { schema_version: 1, allow: patterns(doc.allow, 'allow', errors, { required: true }), deny: patterns(doc.deny, 'deny', errors), scopes: {}, families: {} }; if (doc.scopes !== undefined && !isObject(doc.scopes)) errors.push('scopes must be an object keyed by eval scope'); for (const [scope, v] of Object.entries(isObject(doc.scopes) ? doc.scopes : {})) { if (!(EVAL_SCOPES as readonly string[]).includes(scope)) { errors.push(`scopes.${scope}: unknown eval scope (known: ${EVAL_SCOPES.join(', ')})`); continue; } if (!isObject(v)) { errors.push(`scopes.${scope} must be an object with allow and/or deny`); continue; } const extra = Object.keys(v).filter(k => k !== 'allow' && k !== 'deny'); if (extra.length) errors.push(`scopes.${scope}: unknown key(s) ${extra.join(', ')} (known: allow, deny)`); rules.scopes[scope as EvalScope] = { ...(v.allow !== undefined ? { allow: patterns(v.allow, `scopes.${scope}.allow`, errors, { required: true }) } : {}), deny: patterns(v.deny, `scopes.${scope}.deny`, errors) }; } if (doc.families !== undefined && !isObject(doc.families)) errors.push('families must be an object of family name to model ids, newest first'); const owner = new Map(); for (const [family, ids] of Object.entries(isObject(doc.families) ? doc.families : {})) { rules.families[family] = patterns(ids, `families.${family}`, errors, { required: true, literal: true }); for (const id of rules.families[family]!) { if (owner.has(id)) errors.push(`families: ${id} is listed in both ${owner.get(id)} and ${family}`); else owner.set(id, family); } } for (const entry of MODEL_CATALOG) { const family = owner.get(entry.model); const head = family ? rules.families[family]![0] : entry.model; if (head !== entry.model) errors.push(`families.${family} lists ${head} before ${entry.model}, gstack's current ${entry.provider} ${entry.tier} model (lib/model-catalog.ts, verified ${entry.verifiedAt}); list newest first`); } return errors.length ? { errors } : { rules }; } /** The id as given, without a provider prefix, and a Claude id without its date suffix: the forms a rule may name. */ function forms(model: string): string[] { const bare = model.replace(/^[a-z-]+[/:]/, ''); return [...new Set([model, bare, /^claude-/.test(bare) ? bare.replace(/-\d{8}$/, '') : bare])]; } const glob = (p: string) => new RegExp(`^${p.split('').map(c => (c === '*' ? '.*' : c === '?' ? '.' : c.replace(/[.+^${}()|[\]\\]/g, '\\$&'))).join('')}$`); const firstMatch = (list: string[] | undefined, model: string) => (list ?? []).find(p => forms(model).some(f => glob(p).test(f))); /** Deny, the scope's deny, allow, the scope's allow, then the newest-generation rule; the first that refuses decides. */ export function lintArmModels(rules: EvalModelRules, uses: ArmUse[]): ModelFinding[] { const families = Object.entries(rules.families); return uses.map((use): ModelFinding => { const scope = rules.scopes[use.scope]; const denied = firstMatch(rules.deny, use.model); if (denied) return { ...use, verdict: 'denied', reason: `deny ${denied}` }; const scopeDenied = firstMatch(scope?.deny, use.model); if (scopeDenied) return { ...use, verdict: 'denied', reason: `scopes.${use.scope}.deny ${scopeDenied}` }; const allowed = firstMatch(rules.allow, use.model); if (!allowed) return { ...use, verdict: 'denied', reason: 'matches no allow entry (unknown ids fail closed)' }; if (scope?.allow && !firstMatch(scope.allow, use.model)) return { ...use, verdict: 'denied', reason: `matches no scopes.${use.scope}.allow entry` }; if (!families.length) return { ...use, verdict: 'ok', reason: `allow ${allowed}` }; const family = families.find(([, ids]) => ids.some(id => forms(use.model).includes(id))); if (!family) return { ...use, verdict: 'generation-unresolved', reason: 'in no families entry, so the newest-generation rule cannot be checked' }; if (!forms(use.model).includes(family[1][0]!)) return { ...use, verdict: 'old-generation', reason: `families.${family[0]} newest is ${family[1][0]}` }; return { ...use, verdict: 'ok', reason: `allow ${allowed}; newest in families.${family[0]}` }; }); } const VERDICT_CODE: Record, ResultCodeName> = { denied: 'EVAL_MODEL_DENIED', 'old-generation': 'EVAL_MODEL_OLD_GENERATION', 'generation-unresolved': 'EVAL_MODEL_GENERATION_UNRESOLVED' }; function gitText(repoRoot: string, args: string[]): string | null { const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout : null; } export interface EvalModelsLoad { text: string | null; label: string; notes: string[]; error: string | null } /** The reviewed rules from origin/ (default) or the working tree's copy; text null when none are declared. */ export function loadEvalModels(repoRoot: string | null, o: { from: 'base' | 'head'; base: string }): EvalModelsLoad { if (!repoRoot) return { text: null, label: 'none (not a git repository)', notes: [], error: null }; const headFile = path.join(repoRoot, EVAL_MODELS_PATH); const head = fs.existsSync(headFile) ? fs.readFileSync(headFile, 'utf8') : null; if (o.from === 'head') return { text: head, label: head === null ? `none (no ${EVAL_MODELS_PATH} in the working tree)` : `head (unreviewed) ${EVAL_MODELS_PATH}`, notes: [], error: null }; const ref = `origin/${o.base.replace(/^origin\//, '')}`; const sha = gitText(repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])?.trim(); if (!sha) { if (head === null) return { text: null, label: `none (${ref} is not a resolvable commit)`, notes: [], error: null }; return { text: null, label: ref, notes: [], error: `${ref} is not a resolvable commit, so the reviewed ${EVAL_MODELS_PATH} cannot be read; fetch it, or pass --models-from head to lint with the working-tree copy` }; } const at = `${ref}@${sha.slice(0, 12)}`; const base = gitText(repoRoot, ['show', `${sha}:${EVAL_MODELS_PATH}`]); if (base === null) return { text: null, label: `none at ${at}`, notes: head === null ? [] : [`the working tree has an unreviewed ${EVAL_MODELS_PATH}; --models-from head lints with it`], error: null }; return { text: base, label: `${at}:${EVAL_MODELS_PATH}`, notes: head !== null && head !== base ? [`the working-tree ${EVAL_MODELS_PATH} differs from the reviewed copy; linting with ${ref}`] : [], error: null }; } /** Load, validate and lint: one EVAL_MODEL line per arm, then the EVAL_MODELS summary; a refusal or an invalid file is an error. */ export function checkModelRules(repoRoot: string | null, uses: ArmUse[], o: { from: 'base' | 'head'; base: string }): ModelRulesCheck { const load = loadEvalModels(repoRoot, o); const notes = load.notes.map(n => `EVAL_MODELS_NOTE: ${n}`); const invalid = (message: string): ModelRulesCheck => ({ ok: false, source: load.label, lines: [...notes, `EVAL_MODELS: invalid source=${load.label}`], findings: [], errors: [{ code: 'EVAL_MODELS_INVALID', message }] }); if (load.error) return invalid(load.error); if (load.text === null) return { ok: true, source: load.label, lines: [...notes, `EVAL_MODELS: ${load.label}`], findings: [], errors: [] }; const parsed = parseEvalModels(load.text); if ('errors' in parsed) return invalid(`${load.label}: ${parsed.errors.join('; ')}`); const findings = lintArmModels(parsed.rules, uses); const bad = findings.filter(f => f.verdict !== 'ok'); return { ok: bad.length === 0, source: load.label, findings, lines: [...notes, ...findings.map(f => `EVAL_MODEL: ${f.model} scope=${f.scope} ${f.verdict} (${f.reason}) where=${f.where}`), `EVAL_MODELS: ${bad.length ? `fail refused=${bad.length}/${findings.length}` : `ok n=${findings.length}`} source=${load.label}`], errors: bad.map(f => ({ code: VERDICT_CODE[f.verdict as Exclude], message: `${f.model} (scope ${f.scope}, ${f.where}): ${f.reason}` })), }; }