diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5d06d0..9ef2ae3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,15 @@ name: CI +# `schedule` and `workflow_dispatch` exist for the model-data-drift job only. +# Triggers are workflow-wide in Actions, so every job below carries an `if:` +# that scopes it to the events it is actually for. on: push: branches: ["**"] pull_request: + schedule: + - cron: "0 9 * * 1" + workflow_dispatch: permissions: contents: read @@ -11,6 +17,9 @@ permissions: jobs: build: name: typecheck · test · build (node ${{ matrix.node-version }}) + # Code CI only. The weekly cron and manual dispatch exist for + # model-data-drift; re-running the matrix on them buys nothing. + if: github.event_name == 'push' || github.event_name == 'pull_request' runs-on: ubuntu-latest strategy: fail-fast: false @@ -45,8 +54,56 @@ jobs: - name: Build run: npm run build + model-data-drift: + name: model limits · drift vs Cursor docs + # Deliberately not on pull_request: this job reaches cursor.com, and PR CI + # stays hermetic. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: "24.x" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Check committed model limits against Cursor docs + run: | + log="$RUNNER_TEMP/drift.log" + set +e + npm run sync:model-limits -- --check 2>&1 | tee "$log" + code=${PIPESTATUS[0]} + set -e + if [ "$code" = "1" ]; then + echo "::error::src/model-limits.ts is stale. Run 'npm run sync:model-limits' and commit." + exit 1 + fi + if [ "$code" = "2" ]; then + echo "::error::Could not verify model data (docs unreachable, unparseable, or a model id matched nothing). The drift check did not run — treat this as unverified, not as passing." + exit 1 + fi + if [ "$code" != "0" ]; then + echo "::error::Unexpected exit code $code from the drift check." + exit "$code" + fi + # Exit 0 is only trustworthy if the run also reported its summary. A + # bare 0 is what "did nothing at all" looks like, and that has + # happened: an entry-point guard once skipped main() entirely and the + # job passed for free. Demand the evidence, not just the code. + if ! grep -qE 'sync-model-limits: src/model-limits\.ts is up to date \(context: [0-9]+ from docs, [0-9]+ overridden \| cost: [0-9]+ from docs, [0-9]+ overridden \| [0-9]+ model ids\)' "$log"; then + echo "::error::Drift check exited 0 without printing a run summary — it did not actually verify anything." + exit 1 + fi + integration: name: e2e · opencode loads plugin & lists models + # Code CI only, same reason as `build`. + if: github.event_name == 'push' || github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 diff --git a/package.json b/package.json index 2be948d..dcbf498 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "vitest run --config vitest.e2e.config.ts --passWithNoTests", + "sync:model-limits": "node scripts/sync-model-limits-cli.mjs", "prepublishOnly": "npm run typecheck && npm test && npm run build" }, "dependencies": { diff --git a/scripts/sync-model-limits-cli.mjs b/scripts/sync-model-limits-cli.mjs new file mode 100644 index 0000000..2357d79 --- /dev/null +++ b/scripts/sync-model-limits-cli.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** + * CLI entry point for `scripts/sync-model-limits.mjs`. + * + * This file exists so the generator module stays import-pure (the tests import + * it) without needing an "am I the entry point?" guard inside it. Such a guard + * — comparing `process.argv[1]` against `import.meta.url` — is fail-open: on + * any invocation where the two differ (a symlinked path, a wrapper, an exec + * shim) `main()` never runs and the process exits 0 having done nothing, which + * makes the scheduled drift check permanently green and permanently useless. + * That already happened once, via a symlinked `/tmp` path on macOS. + * + * There is no guard here. Running this file always runs `main()`. + */ +import { main } from "./sync-model-limits.mjs"; + +process.exitCode = await main(process.argv.slice(2)); diff --git a/scripts/sync-model-limits.d.mts b/scripts/sync-model-limits.d.mts new file mode 100644 index 0000000..80e8f1e --- /dev/null +++ b/scripts/sync-model-limits.d.mts @@ -0,0 +1,56 @@ +/** + * Types for `sync-model-limits.mjs`. The script is plain ESM JavaScript (it + * runs via `node` with no build step), but `tsconfig.json` includes `test`, so + * `test/sync-model-limits.test.ts` needs a declaration to import it. + * + * This mirror is hand-maintained. `allowJs: true` would remove the need for it, + * but it does not work in this repo: it pulls `src/sidecar/agent-host.mjs` into + * the program, and that file assigns to `console.log`, which strips `log`, + * `debug`, `info`, `warn`, and `error` off the global `Console` type and breaks + * 30 checks in existing `.ts` files. Measured, not assumed — see + * `.superpowers/sdd/task-6-report.md`. Two things keep this file honest in the + * meantime: `test/sync-model-limits.test.ts` asserts the module's runtime + * export names match the list declared here, and the tests call every declared + * signature, so a parameter that is declared but missing (or vice versa) fails + * `npm run typecheck`. + */ + +export type DocsRow = Record; + +export type ModelCost = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +}; + +export type MatchResult = + | { row: DocsRow; ambiguous?: never } + | { row?: never; ambiguous: string[] }; + +export declare const SOURCES: { context: string; pricing: string }; +export declare const MODEL_IDS: string[]; +export declare const OVERRIDES: Record< + string, + { context?: number; cost?: ModelCost; why: string } +>; + +export declare function parseDocsTable(md: string, columnNames: string[]): DocsRow[]; +export declare function parseTokens(text: string): number | undefined; +export declare function parsePrice(text: string): number; +export declare function matchModelId(id: string, docRows: DocsRow[]): MatchResult | undefined; +export declare function normalizeForComparison(text: string): string; +export declare function generate(input: { + contextMd: string; + pricingMd: string; + modelIds?: readonly string[]; + overrides?: Record; + date?: string; +}): { + text: string; + stats: { + context: { matched: number; overridden: number }; + cost: { matched: number; overridden: number }; + }; +}; +export declare function main(argv: string[]): Promise; diff --git a/scripts/sync-model-limits.mjs b/scripts/sync-model-limits.mjs new file mode 100644 index 0000000..2cc9eb2 --- /dev/null +++ b/scripts/sync-model-limits.mjs @@ -0,0 +1,679 @@ +/** + * Generate `src/model-limits.ts` from Cursor's published docs. + * + * opencode computes cost as `tokens x model.cost`, so a static rate card is + * structurally required — no provider channel can inject a dollar amount. This + * script keeps that rate card from being hand-written: it reads Cursor's own + * markdown docs, matches their display names against our supported model ids, + * and emits the two generated maps. + * + * This module is import-pure: it never runs `main()` as a side effect. The CLI + * lives in `scripts/sync-model-limits-cli.mjs`, which calls `main()` + * unconditionally. That split is deliberate — a "am I the entry point?" guard + * comparing `process.argv[1]` against `import.meta.url` is fail-open: any + * invocation where the two differ makes `main()` never run and the process exit + * 0 having done nothing, which is a permanently green, permanently useless + * drift job. That already happened once (a symlinked path). + * + * Modes: + * (default) fetch docs, write `src/model-limits.ts` + * --check fetch docs, regenerate in memory, compare against the committed + * file without writing + * + * Exit codes (CI depends on these): + * 0 no drift + * 1 drift detected (committed file differs from generated) + * 2 network, parse, or write failure — docs unreachable, table/columns + * missing, a row missing a requested column, an unparseable cell, a + * MODEL_IDS entry unmatched with no override, an ambiguous match, or the + * output file could not be written + * + * Node 22 built-ins only. No dependencies. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUTPUT_PATH = join(HERE, "..", "src", "model-limits.ts"); + +/** + * The two docs pages, both in their `.md` form. + * + * Cursor's docs site content-negotiates. Measured against both URL shapes: + * with the markdown-preferring Accept header `fetchDoc` sends, the `.md` and + * extensionless forms both return the same markdown. With a default wildcard + * Accept header, only the `.md` form does — the extensionless form returns the + * ~110KB HTML page instead, which carries no pipe table. + * + * The `.md` form is therefore the more robust choice: it does not depend on the + * Accept header staying markdown-preferring. Keep both URLs on `.md`. + * + * A non-2xx response, or a page that stops carrying the expected columns, + * exits 2 — so if Cursor moves either page it surfaces rather than going quiet. + */ +export const SOURCES = { + context: "https://cursor.com/docs/account/pricing/request-based-legacy.md", + pricing: "https://cursor.com/docs/models-and-pricing.md", +}; + +/** + * The Cursor model ids we support — the single source of truth for coverage. + * Taken from Cursor's live model catalog. An id here that the docs do not list + * must have an OVERRIDES entry; otherwise this script exits 2 rather than + * emitting a silent default. + */ +export const MODEL_IDS = [ + "auto-smart", + "claude-fable-5", + "claude-haiku-4-5", + "claude-opus-4-5", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-sonnet-4", + "claude-sonnet-4-5", + "claude-sonnet-4-6", + "claude-sonnet-5", + "composer-2", + "composer-2.5", + "default", + "gemini-2.5-flash", + "gemini-3-flash", + "gemini-3.1-pro", + "gemini-3.5-flash", + "gemini-3.6-flash", + "glm-5.2", + "gpt-5-mini", + "gpt-5.1", + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "grok-4.5", +]; + +const POOL_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +/** + * Ids the docs cannot supply, with the reason. `context` and `cost` are + * independent: a model can appear in the context table but not the pricing + * one, because the pricing doc is the "Other Models" table and Cursor Models + * pool models are priced by pool rather than per token. + */ +export const OVERRIDES = { + "auto-smart": { + context: 200_000, + cost: POOL_COST, + why: 'docs row is "Auto Cost", which lists "-" for context; Cursor Models pool, so no per-token charge', + }, + default: { + context: 200_000, + cost: POOL_COST, + why: 'the "Auto" catalog id; same docs row as auto-smart, same pool pricing', + }, + "composer-2": { + context: 200_000, + cost: POOL_COST, + why: 'docs list "Composer 1" and "Composer 2.5", never "Composer 2"; Cursor Models pool', + }, + "gpt-5.1": { + context: 272_000, + cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + why: "docs list only GPT-5.1 Codex / Codex Max / Codex Mini, never bare 5.1; values follow GPT-5.1 Codex", + }, + "grok-4.5": { + cost: POOL_COST, + why: 'absent from models-and-pricing.md ("Other Models" table); Cursor Models pool, so no per-token charge', + }, + "composer-2.5": { + cost: POOL_COST, + why: 'absent from models-and-pricing.md ("Other Models" table); Cursor Models pool, so no per-token charge', + }, +}; + +/** Column that holds the model display name in both docs tables. */ +const NAME_COLUMN = "Model"; + +/** Column that holds the vendor in both docs tables. */ +const PROVIDER_COLUMN = "Provider"; + +/** Vendor/pricing words that appear on one side of a match but not the other. */ +const NOISE_TOKENS = new Set(["claude", "gpt", "cursor", "cost"]); + +/** + * Vendor identity for the vendor words {@link NOISE_TOKENS} drops, mapped to + * the `Provider` cell that must accompany them. + * + * Dropping `claude`/`gpt` is what lets `claude-sonnet-4-6` match "Claude 4.6 + * Sonnet", but it also discards vendor identity: a surviving row from a + * different vendor whose remaining tokens coincide would be a wrong-but- + * confident match (e.g. the id `gpt-5.5` against a hypothetical Anthropic row + * "Claude 5.5", both reducing to `{5.5}`). Re-checking the `Provider` column + * puts the discarded identity back. + * + * Only the dropped words need an entry. `gemini`, `grok`, `glm`, `composer`, + * and `kimi` are not noise tokens, so their vendor identity already survives + * inside the compared token set. + */ +const PROVIDER_BY_VENDOR_TOKEN = new Map([ + ["claude", "anthropic"], + ["gpt", "openai"], +]); + +/** A cell that is nothing but a markdown link, e.g. `[Claude Opus 4.8](url)`. */ +const WHOLE_CELL_LINK = /^\[([^\]]+)\]\([^)]*\)$/; + +function splitRow(line) { + const trimmed = line.trim(); + return trimmed + .slice(1, trimmed.endsWith("|") ? -1 : undefined) + .split("|") + .map((cell) => { + const value = cell.trim(); + // Unwrap link cells (the Model column is usually a link) but leave cells + // that merely contain a link — notably Notes — untouched. + const link = WHOLE_CELL_LINK.exec(value); + return link ? link[1].trim() : value; + }); +} + +const SEPARATOR_ROW = /^\|[\s:|-]+\|$/; + +/** + * Parse the first GFM table in `md` that carries every column in + * `columnNames`. Selecting by column rather than by position matters: the + * pricing doc ships an unrelated Plan/Price table alongside the model table. + * + * @param {string} md + * @param {string[]} columnNames + * @returns {Array>} + */ +export function parseDocsTable(md, columnNames) { + const lines = md.split("\n"); + const seenHeaders = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!SEPARATOR_ROW.test(line)) continue; + const previous = lines[i - 1].trim(); + if (!previous.startsWith("|")) continue; + const header = splitRow(previous); + seenHeaders.push(header.join(" | ")); + if (!columnNames.every((name) => header.includes(name))) continue; + + const rows = []; + for (let j = i + 1; j < lines.length; j++) { + const raw = lines[j].trim(); + if (!raw.startsWith("|")) break; + const cells = splitRow(raw); + const row = {}; + // An absent cell (row shorter than the header) is left unset rather than + // defaulted to "". Conflating the two is how a missing price column + // becomes a silent $0: `parsePrice("")` used to answer 0. + for (const [index, name] of header.entries()) { + if (index < cells.length) row[name] = cells[index]; + } + for (const name of columnNames) { + if (row[name] === undefined) { + throw new Error( + `row ${JSON.stringify(row[NAME_COLUMN] ?? raw.slice(0, 60))} is missing the requested ` + + `column "${name}" (${cells.length} cells for ${header.length} headers)`, + ); + } + } + rows.push(row); + } + if (rows.length === 0) { + throw new Error(`table with columns [${columnNames.join(", ")}] has no data rows`); + } + return rows; + } + throw new Error( + `no table carries every column [${columnNames.join(", ")}]. Tables found: ${ + seenHeaders.length ? seenHeaders.map((h) => `<${h}>`).join("; ") : "none" + }`, + ); +} + +/** + * `"200k"` -> `200000`, `"1M"` -> `1000000`, `"-"` -> `undefined`. + * Throws on anything else so an unexpected docs format exits 2. + * + * @param {string} text + * @returns {number | undefined} + */ +export function parseTokens(text) { + const value = String(text ?? "").trim(); + if (value === "" || value === "-") return undefined; + const match = /^([\d,]+(?:\.\d+)?)\s*([kKmM])?$/.exec(value); + if (!match) throw new Error(`cannot parse a token count from ${JSON.stringify(text)}`); + const amount = Number(match[1].replace(/,/g, "")); + const scale = match[2]?.toLowerCase() === "k" ? 1_000 : match[2]?.toLowerCase() === "m" ? 1_000_000 : 1; + return amount * scale; +} + +/** + * `"$3"` -> `3`, `"$0.30"` -> `0.3`, `"-"` -> `0`. + * Throws on anything else so an unexpected docs format exits 2. + * + * `"-"` is Cursor's documented "not applicable" and is a real $0. An empty cell + * is not: it carries no statement about price, and answering 0 for it would + * emit a silently wrong rate card entry for a model that matched. So it throws. + * + * @param {string} text + * @returns {number} + */ +export function parsePrice(text) { + const value = String(text ?? "").trim(); + if (value === "-") return 0; + if (value === "") { + throw new Error(`empty price cell: expected a dollar amount, or "-" for not applicable`); + } + const match = /^\$?([\d,]+(?:\.\d+)?)$/.exec(value); + if (!match) throw new Error(`cannot parse a price from ${JSON.stringify(text)}`); + return Number(match[1].replace(/,/g, "")); +} + +/** + * Reduce a model id or a docs display name to a comparable token set. + * Cursor's ids and its docs names disagree on word order and on vendor + * prefixes (`claude-sonnet-4-6` vs "Claude 4.6 Sonnet"; "Claude Opus 4.8" + * flips it back), so compare as sets. Adjacent bare numbers are joined so the + * id's `4-6` lines up with the docs' `4.6`. + * + * @param {string} text + * @returns {Set} + */ +function tokenSet(text) { + const parts = String(text).toLowerCase().split(/[^a-z0-9.]+/).filter(Boolean); + const merged = []; + for (const part of parts) { + const previous = merged[merged.length - 1]; + if (/^\d+$/.test(part) && previous !== undefined && /^[\d.]+$/.test(previous)) { + merged[merged.length - 1] = `${previous}.${part}`; + } else { + merged.push(part); + } + } + return new Set(merged.filter((token) => !NOISE_TOKENS.has(token))); +} + +function sameTokens(a, b) { + if (a.size !== b.size) return false; + for (const token of a) if (!b.has(token)) return false; + return true; +} + +/** + * The `Provider` cell a row must carry for `id`, or `undefined` when `id` + * names no vendor whose identity {@link tokenSet} discards. + * + * @param {string} id + * @returns {string | undefined} + */ +function requiredProvider(id) { + for (const part of String(id).toLowerCase().split(/[^a-z0-9.]+/)) { + const provider = PROVIDER_BY_VENDOR_TOKEN.get(part); + if (provider !== undefined) return provider; + } + return undefined; +} + +/** + * Find the single docs row whose display name describes `id`. Exact set + * equality only — a near miss like "Composer 2.5" must not satisfy + * "composer-2" — plus a `Provider` check for vendors the token set drops. + * + * The provider check is strict, not best-effort: an id naming a dropped vendor + * matches only a row whose `Provider` cell says so. A row with a blank or + * absent `Provider` therefore does not match such an id, which is the honest + * outcome — the alternative is matching on the same evidence that was just + * found insufficient. + * + * @param {string} id + * @param {Array>} docRows + * @returns {{ row: Record, ambiguous?: never } | { row?: never, ambiguous: string[] } | undefined} + */ +export function matchModelId(id, docRows) { + const wanted = tokenSet(id); + const provider = requiredProvider(id); + const hits = docRows.filter((row) => { + if (!sameTokens(wanted, tokenSet(row[NAME_COLUMN] ?? ""))) return false; + if (provider === undefined) return true; + return (row[PROVIDER_COLUMN] ?? "").trim().toLowerCase() === provider; + }); + if (hits.length === 1) return { row: hits[0] }; + if (hits.length > 1) return { ambiguous: hits.map((row) => row[NAME_COLUMN] ?? "") }; + return undefined; +} + +function formatTokens(value) { + return value.toLocaleString("en-US").replace(/,/g, "_"); +} + +function formatCost(cost) { + return `{ input: ${cost.input}, output: ${cost.output}, cacheRead: ${cost.cacheRead}, cacheWrite: ${cost.cacheWrite} }`; +} + +/** Placeholder the generated date line is normalized to before any comparison. */ +const SYNC_DATE_LINE = /^ \* Data last changed: .*$/m; +const SYNC_DATE_PLACEHOLDER = " * Data last changed: "; + +/** + * Strip the date line so `--check` reports data drift, not the passage of + * time. Write mode uses the same normalization to leave the committed date + * alone when nothing else moved. + * + * @param {string} text + */ +export function normalizeForComparison(text) { + return text.replace(SYNC_DATE_LINE, SYNC_DATE_PLACEHOLDER); +} + +/** + * Resolve every id in `modelIds` against the two docs tables and emit the full + * text of `src/model-limits.ts`. + * + * `modelIds` and `overrides` are injectable so the contracts this function + * holds — strict overrides, ambiguity, docs-over-override precedence, sorted + * output — can be exercised against small fixtures instead of only against the + * live 33-id catalog. + * + * @param {{ + * contextMd: string, + * pricingMd: string, + * modelIds?: readonly string[], + * overrides?: Record, + * date?: string, + * }} input + * @returns {{ text: string, stats: { context: { matched: number, overridden: number }, cost: { matched: number, overridden: number } } }} + */ +export function generate({ + contextMd, + pricingMd, + modelIds = MODEL_IDS, + overrides = OVERRIDES, + date = new Date().toISOString().slice(0, 10), +}) { + const contextRows = parseDocsTable(contextMd, [NAME_COLUMN, "Default context"]); + const priceRows = parseDocsTable(pricingMd, [NAME_COLUMN, "Input", "Cache write", "Cache read", "Output"]); + + const stats = { + context: { matched: 0, overridden: 0 }, + cost: { matched: 0, overridden: 0 }, + }; + const contextLimits = []; + const costs = []; + + for (const id of [...modelIds].sort()) { + const override = overrides[id]; + + const contextHit = matchModelId(id, contextRows); + if (contextHit && "ambiguous" in contextHit) { + throw new Error(`${id}: ambiguous context match against [${contextHit.ambiguous.join(", ")}]`); + } + // "Default context", never "Max context": Max context requires Max Mode, + // which the plugin cannot detect. + const docContext = contextHit?.row ? parseTokens(contextHit.row["Default context"]) : undefined; + let context; + if (docContext !== undefined) { + context = docContext; + stats.context.matched += 1; + } else if (override?.context !== undefined) { + context = override.context; + stats.context.overridden += 1; + } else { + throw new Error( + `${id}: no "Default context" in ${SOURCES.context} and no OVERRIDES entry. ` + + `Add an override with a reason, or drop the id from MODEL_IDS.`, + ); + } + contextLimits.push(` ${JSON.stringify(id)}: ${formatTokens(context)},`); + + const priceHit = matchModelId(id, priceRows); + if (priceHit && "ambiguous" in priceHit) { + throw new Error(`${id}: ambiguous pricing match against [${priceHit.ambiguous.join(", ")}]`); + } + let cost; + if (priceHit?.row) { + // Structured columns only. The Notes cell is never parsed — see the + // generated file's header for why. + cost = { + input: parsePrice(priceHit.row["Input"]), + output: parsePrice(priceHit.row["Output"]), + cacheRead: parsePrice(priceHit.row["Cache read"]), + cacheWrite: parsePrice(priceHit.row["Cache write"]), + }; + stats.cost.matched += 1; + } else if (override?.cost !== undefined) { + cost = override.cost; + stats.cost.overridden += 1; + } else { + throw new Error( + `${id}: no pricing row in ${SOURCES.pricing} and no OVERRIDES entry. ` + + `Add an override with a reason, or drop the id from MODEL_IDS.`, + ); + } + costs.push(` ${JSON.stringify(id)}: ${formatCost(cost)},`); + } + + const text = `/** + * GENERATED FILE — do not edit by hand. + * + * Generated by \`scripts/sync-model-limits.mjs\` from Cursor's published docs: + * context windows ${SOURCES.context} + * pricing ${SOURCES.pricing} + * + * Data last changed: ${date} + * (a sync that finds no data change leaves this date alone, so it dates the + * last change to the generated maps — NOT the last time they were verified. + * Verification runs on a schedule in CI; see the model-data-drift job.) + * Regenerate: \`npm run sync:model-limits\` + * + * Only MODEL_CONTEXT_LIMITS and MODEL_COST are derived from the docs. + * MODEL_OUTPUT_LIMITS further down is hand-maintained, because Cursor's docs + * publish no output-token column — but it is still emitted from this file's + * template, so edit it in \`scripts/sync-model-limits.mjs\`, not here. An edit + * made here is reverted by the next sync. + * + * Pricing is read from the structured Input / Cache write / Cache read / + * Output columns only. The \`Notes\` cell is deliberately NOT parsed, even + * though promotions are announced there in prose (Claude Sonnet 5's row + * advertises "$2/M input and $10/M output through August 31, 2026" while its + * price columns still read $3 / $15). Extracting money from free text is + * confidently wrong by construction, promo windows expire, and Cursor's own + * \`agent.getUsage()\` -> \`chargedCents\` is the authoritative source for + * promotions, discounts, the Cursor Token Fee, and Max Mode multipliers. This + * map is only the rate card opencode multiplies token counts by. + */ + +/** + * Per-model default context window limits (tokens), keyed by model id prefix. + * The "Max context" column (1M for frontier models) requires Max Mode and is + * NOT used here — the plugin can't detect Max Mode, so the default window is + * the honest limit to display. + * + * Longest prefix wins: \`claude-opus-4-8\` (300K) beats \`claude-opus-4\` (200K). + */ +const MODEL_CONTEXT_LIMITS: Record = { +${contextLimits.join("\n")} +}; + +const DEFAULT_CONTEXT_LIMIT = 200_000; + +/** + * Resolve a model's context window by longest-prefix match against + * {@link MODEL_CONTEXT_LIMITS}. Falls back to 200K for unknown models. + */ +export function resolveContextLimit(modelId: string): number { + let best: number | undefined; + let bestLen = 0; + for (const [prefix, limit] of Object.entries(MODEL_CONTEXT_LIMITS)) { + if (modelId.startsWith(prefix) && prefix.length > bestLen) { + best = limit; + bestLen = prefix.length; + } + } + return best ?? DEFAULT_CONTEXT_LIMIT; +} + +/** + * Per-model API pricing (USD per million tokens), keyed by model id prefix. + * Cursor Models pool models (Grok 4.5, Composer, Auto) have $0 — they draw + * from the Cursor Models pool, not the Other Models pool, so there is no + * per-token API charge and they are absent from the pricing docs entirely. + * + * Longest prefix wins: \`gpt-5.4-mini\` (0.75) beats \`gpt-5.4\` (2.50). + */ +const MODEL_COST: Record = { +${costs.join("\n")} +}; + +const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +/** + * Resolve a model's per-token cost by longest-prefix match against + * {@link MODEL_COST}. Falls back to $0 for unknown models (treated as + * subscription/Cursor Models pool). + */ +export function resolveCost(modelId: string): { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} { + let best: { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined; + let bestLen = 0; + for (const [prefix, cost] of Object.entries(MODEL_COST)) { + if (modelId.startsWith(prefix) && prefix.length > bestLen) { + best = cost; + bestLen = prefix.length; + } + } + return best ?? DEFAULT_COST; +} + +/** + * NOT DERIVED FROM THE DOCS — hand-maintained in the template inside + * \`scripts/sync-model-limits.mjs\`. Cursor's docs publish no output-token + * column, so there is nothing to generate these from. Editing this map here + * has no lasting effect; the next sync reverts it. + * + * Per-model output token limits, keyed by model id prefix. The Cursor SDK + * doesn't expose output limits, so these are best-known values. 32K default + * (the previous hardcoded value); 64K for frontier models known to support + * higher output. Low priority — the TUI doesn't display output limit. + */ +const MODEL_OUTPUT_LIMITS: Record = { + "claude-opus-4-7": 64_000, + "claude-opus-4-8": 64_000, + "claude-opus-5": 64_000, + "claude-fable-5": 64_000, + "gpt-5.5": 64_000, + "gpt-5.6-sol": 64_000, +}; + +const DEFAULT_OUTPUT_LIMIT = 32_000; + +/** + * Resolve a model's output limit by longest-prefix match. Falls back to 32K. + */ +export function resolveOutputLimit(modelId: string): number { + let best: number | undefined; + let bestLen = 0; + for (const [prefix, limit] of Object.entries(MODEL_OUTPUT_LIMITS)) { + if (modelId.startsWith(prefix) && prefix.length > bestLen) { + best = limit; + bestLen = prefix.length; + } + } + return best ?? DEFAULT_OUTPUT_LIMIT; +} +`; + + return { text, stats }; +} + +async function fetchDoc(url) { + const response = await fetch(url, { headers: { accept: "text/plain,text/markdown,*/*" } }); + if (!response.ok) throw new Error(`GET ${url} -> HTTP ${response.status}`); + return await response.text(); +} + +/** + * Run the CLI. Returns the process exit code rather than calling + * `process.exit`, so the caller owns the exit. Invoked unconditionally by + * `scripts/sync-model-limits-cli.mjs`. + * + * @param {string[]} argv + * @returns {Promise} + */ +export async function main(argv) { + const check = argv.includes("--check"); + + let generated; + try { + const [contextMd, pricingMd] = await Promise.all([fetchDoc(SOURCES.context), fetchDoc(SOURCES.pricing)]); + generated = generate({ contextMd, pricingMd }); + } catch (error) { + process.stderr.write(`sync-model-limits: ${error instanceof Error ? error.message : String(error)}\n`); + return 2; + } + + const { text, stats } = generated; + let committed; + try { + committed = readFileSync(OUTPUT_PATH, "utf8"); + } catch { + committed = undefined; + } + const unchanged = committed !== undefined && normalizeForComparison(committed) === normalizeForComparison(text); + + const summary = + `context: ${stats.context.matched} from docs, ${stats.context.overridden} overridden | ` + + `cost: ${stats.cost.matched} from docs, ${stats.cost.overridden} overridden | ` + + `${MODEL_IDS.length} model ids`; + + if (check) { + if (committed === undefined) { + process.stderr.write(`sync-model-limits: ${OUTPUT_PATH} does not exist\n`); + return 1; + } + if (unchanged) { + process.stdout.write(`sync-model-limits: src/model-limits.ts is up to date (${summary})\n`); + return 0; + } + process.stderr.write( + `sync-model-limits: src/model-limits.ts differs from Cursor's docs. ` + + `Run \`npm run sync:model-limits\` and commit the result. (${summary})\n`, + ); + return 1; + } + + if (unchanged) { + // Only the sync date would move; leave the file alone so a no-op sync does + // not produce a diff. + process.stdout.write(`sync-model-limits: src/model-limits.ts already up to date (${summary})\n`); + return 0; + } + + try { + writeFileSync(OUTPUT_PATH, text); + } catch (error) { + // Exit 2, not 1: an I/O failure is an environment problem, and 1 means + // "the committed file is stale", which this does not establish. + process.stderr.write( + `sync-model-limits: cannot write ${OUTPUT_PATH}: ${error instanceof Error ? error.message : String(error)}\n`, + ); + return 2; + } + process.stdout.write(`sync-model-limits: wrote src/model-limits.ts (${summary})\n`); + return 0; +} diff --git a/src/model-discovery.ts b/src/model-discovery.ts index f741c3c..ebb0b0e 100644 --- a/src/model-discovery.ts +++ b/src/model-discovery.ts @@ -1,5 +1,7 @@ import type { ModelListItem } from "@cursor/sdk"; +import type { Config } from "@opencode-ai/plugin"; import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js"; +import { resolveContextLimit, resolveCost, resolveOutputLimit } from "./model-limits.js"; import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js"; import { FALLBACK_MODELS } from "./fallback-models.js"; import { loadCursorSdk } from "./cursor-runtime.js"; @@ -105,8 +107,59 @@ export interface OpencodeModelConfigEntry { * Cursor's server-side `fast` default. See {@link defaultModelParams}. */ options: { params?: Record }; + /** + * Per-model context/output window. opencode's config channel is the only + * one that reaches the model registry for providers absent from + * models.dev, so the TUI session header's context-window percentage + * depends on this being present. Both fields are required by the schema. + */ + limit: { context: number; output: number }; + /** + * Per-model API pricing, USD per million tokens. Note the FLAT snake_case + * cache keys — the config schema (`ProviderConfig` in + * `@opencode-ai/sdk`) uses `cache_read`/`cache_write`, unlike the + * `ModelV2` shape's nested `cache: { read, write }`. + */ + cost: { input: number; output: number; cache_read: number; cache_write: number }; } +/** + * Compile-time guard: the entries we write into + * `config.provider.cursor.models` must satisfy the shape opencode's config + * schema accepts. If opencode changes the schema (or we drift, e.g. by + * using `cache: { read, write }` instead of `cache_read`/`cache_write`), + * `npm run typecheck` fails here rather than silently producing a config + * opencode discards. + */ +type AcceptedModelConfig = NonNullable< + NonNullable[string]>["models"] +>[string]; +const _entryShapeGuard: AcceptedModelConfig = {} as OpencodeModelConfigEntry; +void _entryShapeGuard; + +/** + * Assignability alone is too weak for `cost`/`limit`. Excess-property checking + * only applies to fresh object literals, and the schema's cache keys are + * optional — so a drifted `cost: { input, output, cache: { read, write } }` + * assigns cleanly to the accepted shape (verified: it typechecks) while + * opencode would read `cache_read`/`cache_write` as absent. These guards + * assert every key we emit is a key the schema actually declares. + * + * `never` means "no excess keys"; anything else collapses `_KeysAccepted` to + * `never` and the `true` initializer below fails to compile. + */ +type _KeysAccepted = Exclude extends never ? true : never; +const _costKeyGuard: _KeysAccepted< + OpencodeModelConfigEntry["cost"], + NonNullable +> = true; +void _costKeyGuard; +const _limitKeyGuard: _KeysAccepted< + OpencodeModelConfigEntry["limit"], + NonNullable +> = true; +void _limitKeyGuard; + /** * Map discovered Cursor models to opencode's provider config `models` map. The * Cursor SDK runs an agent (it calls tools itself), so every model is marked @@ -116,6 +169,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record = {}; for (const item of items) { const params = defaultModelParams(item); + const cost = resolveCost(item.id); out[item.id] = { id: item.id, name: item.displayName || item.id, @@ -125,6 +179,16 @@ export function toOpencodeModels(items: ModelListItem[]): Record 0 ? { params } : {}, + limit: { + context: resolveContextLimit(item.id), + output: resolveOutputLimit(item.id), + }, + cost: { + input: cost.input, + output: cost.output, + cache_read: cost.cacheRead, + cache_write: cost.cacheWrite, + }, }; } return out; diff --git a/src/model-limits.ts b/src/model-limits.ts new file mode 100644 index 0000000..3b3e021 --- /dev/null +++ b/src/model-limits.ts @@ -0,0 +1,196 @@ +/** + * GENERATED FILE — do not edit by hand. + * + * Generated by `scripts/sync-model-limits.mjs` from Cursor's published docs: + * context windows https://cursor.com/docs/account/pricing/request-based-legacy.md + * pricing https://cursor.com/docs/models-and-pricing.md + * + * Data last changed: 2026-08-03 + * (a sync that finds no data change leaves this date alone, so it dates the + * last change to the generated maps — NOT the last time they were verified. + * Verification runs on a schedule in CI; see the model-data-drift job.) + * Regenerate: `npm run sync:model-limits` + * + * Only MODEL_CONTEXT_LIMITS and MODEL_COST are derived from the docs. + * MODEL_OUTPUT_LIMITS further down is hand-maintained, because Cursor's docs + * publish no output-token column — but it is still emitted from this file's + * template, so edit it in `scripts/sync-model-limits.mjs`, not here. An edit + * made here is reverted by the next sync. + * + * Pricing is read from the structured Input / Cache write / Cache read / + * Output columns only. The `Notes` cell is deliberately NOT parsed, even + * though promotions are announced there in prose (Claude Sonnet 5's row + * advertises "$2/M input and $10/M output through August 31, 2026" while its + * price columns still read $3 / $15). Extracting money from free text is + * confidently wrong by construction, promo windows expire, and Cursor's own + * `agent.getUsage()` -> `chargedCents` is the authoritative source for + * promotions, discounts, the Cursor Token Fee, and Max Mode multipliers. This + * map is only the rate card opencode multiplies token counts by. + */ + +/** + * Per-model default context window limits (tokens), keyed by model id prefix. + * The "Max context" column (1M for frontier models) requires Max Mode and is + * NOT used here — the plugin can't detect Max Mode, so the default window is + * the honest limit to display. + * + * Longest prefix wins: `claude-opus-4-8` (300K) beats `claude-opus-4` (200K). + */ +const MODEL_CONTEXT_LIMITS: Record = { + "auto-smart": 200_000, + "claude-fable-5": 300_000, + "claude-haiku-4-5": 200_000, + "claude-opus-4-5": 200_000, + "claude-opus-4-6": 200_000, + "claude-opus-4-7": 300_000, + "claude-opus-4-8": 300_000, + "claude-opus-5": 300_000, + "claude-sonnet-4": 200_000, + "claude-sonnet-4-5": 200_000, + "claude-sonnet-4-6": 200_000, + "claude-sonnet-5": 200_000, + "composer-2": 200_000, + "composer-2.5": 200_000, + "default": 200_000, + "gemini-2.5-flash": 200_000, + "gemini-3-flash": 200_000, + "gemini-3.1-pro": 200_000, + "gemini-3.5-flash": 200_000, + "gemini-3.6-flash": 200_000, + "glm-5.2": 200_000, + "gpt-5-mini": 272_000, + "gpt-5.1": 272_000, + "gpt-5.2": 272_000, + "gpt-5.3-codex": 272_000, + "gpt-5.4": 272_000, + "gpt-5.4-mini": 272_000, + "gpt-5.4-nano": 272_000, + "gpt-5.5": 272_000, + "gpt-5.6-luna": 272_000, + "gpt-5.6-sol": 272_000, + "gpt-5.6-terra": 272_000, + "grok-4.5": 256_000, +}; + +const DEFAULT_CONTEXT_LIMIT = 200_000; + +/** + * Resolve a model's context window by longest-prefix match against + * {@link MODEL_CONTEXT_LIMITS}. Falls back to 200K for unknown models. + */ +export function resolveContextLimit(modelId: string): number { + let best: number | undefined; + let bestLen = 0; + for (const [prefix, limit] of Object.entries(MODEL_CONTEXT_LIMITS)) { + if (modelId.startsWith(prefix) && prefix.length > bestLen) { + best = limit; + bestLen = prefix.length; + } + } + return best ?? DEFAULT_CONTEXT_LIMIT; +} + +/** + * Per-model API pricing (USD per million tokens), keyed by model id prefix. + * Cursor Models pool models (Grok 4.5, Composer, Auto) have $0 — they draw + * from the Cursor Models pool, not the Other Models pool, so there is no + * per-token API charge and they are absent from the pricing docs entirely. + * + * Longest prefix wins: `gpt-5.4-mini` (0.75) beats `gpt-5.4` (2.50). + */ +const MODEL_COST: Record = { + "auto-smart": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + "claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-sonnet-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "composer-2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "composer-2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "default": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + "gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, + "gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, + "gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, + "gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, + "gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 }, + "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, + "gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, + "gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, + "gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, + "gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, + "gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 }, + "gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 }, + "gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, + "gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 }, + "gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + "gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 }, + "grok-4.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +}; + +const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +/** + * Resolve a model's per-token cost by longest-prefix match against + * {@link MODEL_COST}. Falls back to $0 for unknown models (treated as + * subscription/Cursor Models pool). + */ +export function resolveCost(modelId: string): { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} { + let best: { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined; + let bestLen = 0; + for (const [prefix, cost] of Object.entries(MODEL_COST)) { + if (modelId.startsWith(prefix) && prefix.length > bestLen) { + best = cost; + bestLen = prefix.length; + } + } + return best ?? DEFAULT_COST; +} + +/** + * NOT DERIVED FROM THE DOCS — hand-maintained in the template inside + * `scripts/sync-model-limits.mjs`. Cursor's docs publish no output-token + * column, so there is nothing to generate these from. Editing this map here + * has no lasting effect; the next sync reverts it. + * + * Per-model output token limits, keyed by model id prefix. The Cursor SDK + * doesn't expose output limits, so these are best-known values. 32K default + * (the previous hardcoded value); 64K for frontier models known to support + * higher output. Low priority — the TUI doesn't display output limit. + */ +const MODEL_OUTPUT_LIMITS: Record = { + "claude-opus-4-7": 64_000, + "claude-opus-4-8": 64_000, + "claude-opus-5": 64_000, + "claude-fable-5": 64_000, + "gpt-5.5": 64_000, + "gpt-5.6-sol": 64_000, +}; + +const DEFAULT_OUTPUT_LIMIT = 32_000; + +/** + * Resolve a model's output limit by longest-prefix match. Falls back to 32K. + */ +export function resolveOutputLimit(modelId: string): number { + let best: number | undefined; + let bestLen = 0; + for (const [prefix, limit] of Object.entries(MODEL_OUTPUT_LIMITS)) { + if (modelId.startsWith(prefix) && prefix.length > bestLen) { + best = limit; + bestLen = prefix.length; + } + } + return best ?? DEFAULT_OUTPUT_LIMIT; +} diff --git a/src/plugin/model-v2.ts b/src/plugin/model-v2.ts index 4d15fed..753937e 100644 --- a/src/plugin/model-v2.ts +++ b/src/plugin/model-v2.ts @@ -1,6 +1,7 @@ import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import type { ModelListItem } from "@cursor/sdk"; import { modelSupportsReasoning } from "../model-discovery.js"; +import { resolveContextLimit, resolveCost, resolveOutputLimit } from "../model-limits.js"; import { buildModelVariants, defaultModelParams } from "../model-variants.js"; export const PROVIDER_ID = "cursor"; @@ -19,9 +20,9 @@ export function providerNpm(): string { /** * Build opencode's rich runtime `Model` objects from discovered Cursor models. - * Used by the auth-aware `provider.models()` hook. Fields opencode does not get - * from the Cursor catalog are filled with safe defaults (zero cost — Cursor - * bills separately; generous context limits). + * Used by the auth-aware `provider.models()` hook. Cost and context/output + * limits are resolved per model from the shared maps in `../model-limits.js`, + * falling back to $0 / 200K context / 32K output for models absent from them. */ export function buildModelV2Map(items: ModelListItem[]): Record { const out: Record = {}; @@ -41,8 +42,11 @@ export function buildModelV2Map(items: ModelListItem[]): Record output: { text: true, audio: false, image: false, video: false, pdf: false }, interleaved: false, }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 200_000, output: 32_000 }, + cost: (() => { + const c = resolveCost(item.id); + return { input: c.input, output: c.output, cache: { read: c.cacheRead, write: c.cacheWrite } }; + })(), + limit: { context: resolveContextLimit(item.id), output: resolveOutputLimit(item.id) }, status: "active", options: Object.keys(params).length > 0 ? { params } : {}, headers: {}, diff --git a/test/fixtures/cursor-legacy-docs.md b/test/fixtures/cursor-legacy-docs.md new file mode 100644 index 0000000..6174e70 --- /dev/null +++ b/test/fixtures/cursor-legacy-docs.md @@ -0,0 +1,19 @@ + + +## Models + +| Model | Provider | Default context | Max context | Capabilities | Requests | Notes | +| --------------------------------------------------------------------------------------------- | --------- | --------------- | ----------- | ----------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Auto Cost | Cursor | - | - | Agent | - | Hidden by default | +| [Claude 4.5 Haiku](https://www.anthropic.com/claude/haiku) | Anthropic | 200k | - | Thinking, Images | 1 | Hidden by default; Bedrock/Vertex: regional endpoints +10% surcharge; Cache: writes 1.25x, reads 0.1x | +| [Claude 4.6 Sonnet](https://www.anthropic.com/claude/sonnet) | Anthropic | 200k | 1M | Agent, Thinking, Images | - | Hidden by default; Requires Max Mode on legacy request-based plans; Up to 1M tokens with extended context at the same per-token rates (no long-context surcharge) | +| [Claude Opus 4.7 (fast mode)](https://www.anthropic.com/claude/opus) | Anthropic | 200k | 1M | Agent, Thinking, Images | - | Hidden by default; Requires Max Mode on legacy request-based plans; Limited research preview; Up to 1M tokens with extended context at the same per-token rates as shorter context | +| [Claude Opus 4.8](https://www.anthropic.com/claude/opus) | Anthropic | 300k | 1M | Agent, Thinking, Images | - | Hidden by default; Requires Max Mode on legacy request-based plans; Fast mode (\`claude-opus-4-8-fast\`) requires Max Mode on legacy request-based plans; Fast mode is 3x lower per-token pricing than Opus 4.7 fast mode; Up to 1M tokens with extended context at the same per-token rates (no long-context surcharge) | +| [Composer 2.5](https://cursor.com/blog/composer-2-5) | Cursor | 200k | - | Agent, Thinking, Images | 2 | - | +| [GPT-5.1 Codex Max](https://platform.openai.com/docs/models/gpt-5-codex) | OpenAI | 272k | - | Agent, Thinking, Images | 1 | Hidden by default | +| [GPT-5.5](https://developers.openai.com/api/docs/models/gpt-5.5) | OpenAI | 272k | 1M | Agent, Thinking, Images | - | Hidden by default; Requires Max Mode on legacy request-based plans; Agentic and reasoning capabilities; More token-efficient than GPT-5.4 on comparable tasks; Improved persistence on long-running tasks; Fast mode is available at higher rates; Long context supports up to 1M tokens with 2x input pricing | +| Grok 4.5 | Cursor | 256k | - | Agent, Thinking | - | Jointly trained by Cursor and SpaceXAI | diff --git a/test/fixtures/cursor-pricing-docs.md b/test/fixtures/cursor-pricing-docs.md new file mode 100644 index 0000000..7256656 --- /dev/null +++ b/test/fixtures/cursor-pricing-docs.md @@ -0,0 +1,30 @@ + + +## Other Models pricing + +| Model | Provider | Input | Cache write | Cache read | Output | Notes | +| --------------------------------------------------------------------------------------------- | --------- | ----- | ----------- | ---------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Claude 4.5 Haiku](https://www.anthropic.com/claude/haiku) | Anthropic | $1 | $1.25 | $0.1 | $5 | Hidden by default; Bedrock/Vertex: regional endpoints +10% surcharge; Cache: writes 1.25x, reads 0.1x | +| [Claude 4.6 Sonnet](https://www.anthropic.com/claude/sonnet) | Anthropic | $3 | $3.75 | $0.3 | $15 | Hidden by default; Requires Max Mode on legacy request-based plans; Up to 1M tokens with extended context at the same per-token rates (no long-context surcharge) | +| [Claude Opus 4.7 (fast mode)](https://www.anthropic.com/claude/opus) | Anthropic | $30 | $37.5 | $3 | $150 | Hidden by default; Requires Max Mode on legacy request-based plans; Limited research preview; Up to 1M tokens with extended context at the same per-token rates as shorter context | +| [Claude Opus 4.8](https://www.anthropic.com/claude/opus) | Anthropic | $5 | $6.25 | $0.5 | $25 | Hidden by default; Requires Max Mode on legacy request-based plans; Fast mode (\`claude-opus-4-8-fast\`) requires Max Mode on legacy request-based plans; Fast mode is 3x lower per-token pricing than Opus 4.7 fast mode; Up to 1M tokens with extended context at the same per-token rates (no long-context surcharge) | +| [Claude Sonnet 5](https://www.anthropic.com/claude/sonnet) | Anthropic | $3 | $3.75 | $0.3 | $15 | Launch promotion: $2/M input and $10/M output through August 31, 2026; Requires Max Mode on legacy request-based plans; Up to 1M tokens with extended context at the same per-token rates (no long-context surcharge); Uses an updated tokenizer, so the same input can map to more tokens | +| [Gemini 3.6 Flash](https://ai.google.dev/gemini-api/docs) | Google | $1.5 | - | $0.15 | $7.5 | - | +| [GPT-5.4 Mini](https://developers.openai.com/api/docs/models/gpt-5.4-mini) | OpenAI | $0.75 | - | $0.075 | $4.5 | Hidden by default; Smaller, faster variant of GPT-5.4; 90% discount on cached input tokens | +| [GPT-5.5](https://developers.openai.com/api/docs/models/gpt-5.5) | OpenAI | $5 | - | $0.5 | $30 | Hidden by default; Requires Max Mode on legacy request-based plans; Agentic and reasoning capabilities; More token-efficient than GPT-5.4 on comparable tasks; Improved persistence on long-running tasks; Fast mode is available at higher rates; Long context supports up to 1M tokens with 2x input pricing | +| Kimi K2.7 Code | Moonshot | $0.95 | - | $0.19 | $4 | Hidden by default | + +## Plans + +| Plan | Price | Other Models usage included | Cursor Models | +| :--------------------- | :--------------------- | :-------------------------- | :---------------------- | +| **Start** (India only) | ₹649/mo, tax inclusive | $0 | Generous included usage | +| **Pro** | $20/mo | $20 | Generous included usage | +| **Pro Plus** | $60/mo | $70 | Generous included usage | +| **Ultra** | $200/mo | $400 | Generous included usage | diff --git a/test/model-discovery.test.ts b/test/model-discovery.test.ts index 12b0081..b8669bf 100644 --- a/test/model-discovery.test.ts +++ b/test/model-discovery.test.ts @@ -81,6 +81,57 @@ describe("toOpencodeModels", () => { }); }); +describe("toOpencodeModels config-channel limits and cost", () => { + it("emits per-model limit with both context and output", () => { + const out = toOpencodeModels([ + { id: "claude-opus-4-8", displayName: "Opus 4.8" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "grok-4.5", displayName: "Grok 4.5" }, + ] satisfies ModelListItem[]); + expect(out["claude-opus-4-8"]!.limit).toEqual({ context: 300_000, output: 64_000 }); + expect(out["gpt-5.5"]!.limit).toEqual({ context: 272_000, output: 64_000 }); + expect(out["grok-4.5"]!.limit).toEqual({ context: 256_000, output: 32_000 }); + }); + + it("emits cost with FLAT snake_case cache keys, not nested cache object", () => { + const out = toOpencodeModels([ + { id: "claude-sonnet-4-6", displayName: "Sonnet 4.6" }, + ] satisfies ModelListItem[]); + expect(out["claude-sonnet-4-6"]!.cost).toEqual({ + input: 3, + output: 15, + cache_read: 0.3, + cache_write: 3.75, + }); + expect(out["claude-sonnet-4-6"]!.cost).not.toHaveProperty("cache"); + }); + + it("emits $0 cost for Cursor Models pool models", () => { + const out = toOpencodeModels([ + { id: "composer-2.5", displayName: "Composer 2.5" }, + ] satisfies ModelListItem[]); + expect(out["composer-2.5"]!.cost).toEqual({ + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }); + }); + + it("falls back to 200K/32K and $0 for unknown models", () => { + const out = toOpencodeModels([ + { id: "brand-new-model", displayName: "New" }, + ] satisfies ModelListItem[]); + expect(out["brand-new-model"]!.limit).toEqual({ context: 200_000, output: 32_000 }); + expect(out["brand-new-model"]!.cost).toEqual({ + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + }); + }); +}); + describe("discoverModels without a key", () => { it("returns the fallback snapshot with a warning when no cache exists", async () => { const prev = process.env.CURSOR_API_KEY; diff --git a/test/model-v2.test.ts b/test/model-v2.test.ts index 16f01ad..d1db3f6 100644 --- a/test/model-v2.test.ts +++ b/test/model-v2.test.ts @@ -19,4 +19,117 @@ describe("buildModelV2Map", () => { const map = buildModelV2Map([{ id: "plain", displayName: "Plain" }]); expect(map["plain"]!.options).toEqual({}); }); + + it("sets context limit from per-model map for known models", () => { + const map = buildModelV2Map([ + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + { id: "claude-opus-4-8", displayName: "Claude Opus 4.8" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "grok-4.5", displayName: "Grok 4.5" }, + ]); + expect(map["claude-sonnet-4-6"]!.limit.context).toBe(200_000); + expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000); + expect(map["gpt-5.5"]!.limit.context).toBe(272_000); + expect(map["grok-4.5"]!.limit.context).toBe(256_000); + }); + + it("falls back to 200K context for unknown models", () => { + const map = buildModelV2Map([{ id: "some-unknown-model", displayName: "Unknown" }]); + expect(map["some-unknown-model"]!.limit.context).toBe(200_000); + }); + + it("uses longest prefix match for context limit", () => { + const map = buildModelV2Map([ + { id: "claude-opus-4-5", displayName: "Opus 4.5" }, + { id: "claude-opus-4-8", displayName: "Opus 4.8" }, + ]); + expect(map["claude-opus-4-5"]!.limit.context).toBe(200_000); + expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000); + }); + + it("sets cost from per-model map for known models", () => { + const map = buildModelV2Map([ + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + { id: "claude-opus-4-8", displayName: "Claude Opus 4.8" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "claude-fable-5", displayName: "Claude Fable 5" }, + ]); + expect(map["claude-sonnet-4-6"]!.cost).toEqual({ + input: 3, + output: 15, + cache: { read: 0.3, write: 3.75 }, + }); + expect(map["claude-opus-4-8"]!.cost).toEqual({ + input: 5, + output: 25, + cache: { read: 0.5, write: 6.25 }, + }); + expect(map["gpt-5.5"]!.cost).toEqual({ + input: 5, + output: 30, + cache: { read: 0.5, write: 0 }, + }); + expect(map["claude-fable-5"]!.cost).toEqual({ + input: 10, + output: 50, + cache: { read: 1, write: 12.5 }, + }); + }); + + it("sets output limit from per-model map for known models", () => { + const map = buildModelV2Map([ + { id: "claude-opus-5", displayName: "Claude Opus 5" }, + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "composer-2.5", displayName: "Composer 2.5" }, + ]); + expect(map["claude-opus-5"]!.limit.output).toBe(64_000); + expect(map["claude-sonnet-4-6"]!.limit.output).toBe(32_000); + expect(map["gpt-5.5"]!.limit.output).toBe(64_000); + expect(map["composer-2.5"]!.limit.output).toBe(32_000); + }); + + it("uses $0 cost for Cursor Models pool models", () => { + const map = buildModelV2Map([ + { id: "composer-2.5", displayName: "Composer 2.5" }, + { id: "grok-4.5", displayName: "Grok 4.5" }, + { id: "auto-smart", displayName: "Auto Smart" }, + ]); + expect(map["composer-2.5"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + expect(map["grok-4.5"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + expect(map["auto-smart"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + }); + + it("falls back to $0 cost for unknown models", () => { + const map = buildModelV2Map([{ id: "some-unknown-model", displayName: "Unknown" }]); + expect(map["some-unknown-model"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + }); + + it("uses longest prefix match for cost", () => { + // gpt-5.4 (2.50) vs gpt-5.4-mini (0.75) must pick the longer prefix + const map = buildModelV2Map([ + { id: "gpt-5.4", displayName: "GPT-5.4" }, + { id: "gpt-5.4-mini", displayName: "GPT-5.4 Mini" }, + { id: "gpt-5.4-nano", displayName: "GPT-5.4 Nano" }, + ]); + expect(map["gpt-5.4"]!.cost.input).toBe(2.5); + expect(map["gpt-5.4-mini"]!.cost.input).toBe(0.75); + expect(map["gpt-5.4-nano"]!.cost.input).toBe(0.2); + }); }); diff --git a/test/sync-model-limits.test.ts b/test/sync-model-limits.test.ts new file mode 100644 index 0000000..8f00ec4 --- /dev/null +++ b/test/sync-model-limits.test.ts @@ -0,0 +1,438 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import * as sync from "../scripts/sync-model-limits.mjs"; +import { + parseDocsTable, + matchModelId, + parseTokens, + parsePrice, + normalizeForComparison, + generate, +} from "../scripts/sync-model-limits.mjs"; + +const legacy = readFileSync(join(__dirname, "fixtures/cursor-legacy-docs.md"), "utf8"); +const pricing = readFileSync(join(__dirname, "fixtures/cursor-pricing-docs.md"), "utf8"); + +/** Build a minimal GFM table so a single parse/match rule can be isolated. */ +function table(header: string[], rows: string[][]): string { + return [ + `| ${header.join(" | ")} |`, + `| ${header.map(() => "---").join(" | ")} |`, + ...rows.map((cells) => `| ${cells.join(" | ")} |`), + ].join("\n"); +} + +/** The model ids emitted into one generated map, in emission order. */ +function emittedKeys(text: string, marker: string): string[] { + const start = text.indexOf(marker); + expect(start).toBeGreaterThanOrEqual(0); + const end = text.indexOf("};", start); + return [...text.slice(start, end).matchAll(/^ {2}"([^"]+)":/gm)].map((m) => m[1] ?? ""); +} + +describe("parseTokens", () => { + it("parses k and M suffixes and treats a dash as absent", () => { + expect(parseTokens("200k")).toBe(200_000); + expect(parseTokens("300k")).toBe(300_000); + expect(parseTokens("1M")).toBe(1_000_000); + expect(parseTokens("-")).toBeUndefined(); + }); + + it("throws on anything it cannot parse, so a docs format change exits 2", () => { + // Every one of these would otherwise become a plausible-looking number. + expect(() => parseTokens("200 thousand")).toThrow(/cannot parse a token count/); + expect(() => parseTokens("~200k")).toThrow(/cannot parse a token count/); + expect(() => parseTokens("200k (Max Mode)")).toThrow(/cannot parse a token count/); + expect(() => parseTokens("n/a")).toThrow(/cannot parse a token count/); + }); +}); + +describe("parsePrice", () => { + it("parses dollar amounts and treats a dash as zero", () => { + expect(parsePrice("$3")).toBe(3); + expect(parsePrice("$0.30")).toBe(0.3); + expect(parsePrice("$12.5")).toBe(12.5); + // "-" is Cursor's documented "not applicable" — a real $0. + expect(parsePrice("-")).toBe(0); + }); + + it("throws on anything it cannot parse, so a docs format change exits 2", () => { + expect(() => parsePrice("$3/M")).toThrow(/cannot parse a price/); + expect(() => parsePrice("free")).toThrow(/cannot parse a price/); + expect(() => parsePrice("$2 (promo)")).toThrow(/cannot parse a price/); + }); + + it("throws on an empty cell rather than answering $0", () => { + // An empty cell states nothing about price. Answering 0 would put a + // silently wrong rate-card entry in front of users, which is the exact + // failure the strict-override rule exists to prevent. Realistic trigger: + // Cursor writing `| |` instead of `| - |`. + expect(() => parsePrice("")).toThrow(/empty price cell/); + expect(() => parsePrice(" ")).toThrow(/empty price cell/); + expect(() => parsePrice(undefined as unknown as string)).toThrow(/empty price cell/); + }); +}); + +describe("normalizeForComparison", () => { + // This is what keeps `--check` from failing every day on the date alone. If + // it stopped blanking the line, the drift job would cry wolf until someone + // silenced it; if it blanked too much, real drift would go unreported. + const file = (dateLine: string, body: string) => `/**\n${dateLine}\n */\nconst x = ${body};\n`; + + it("ignores the date line so the passage of time is not drift", () => { + const a = file(" * Data last changed: 2026-01-01", "1"); + const b = file(" * Data last changed: 2027-12-31", "1"); + expect(a).not.toBe(b); + expect(normalizeForComparison(a)).toBe(normalizeForComparison(b)); + }); + + it("still reports a difference anywhere else, including on the same date", () => { + const a = file(" * Data last changed: 2026-01-01", "1"); + const b = file(" * Data last changed: 2026-01-01", "2"); + expect(normalizeForComparison(a)).not.toBe(normalizeForComparison(b)); + }); + + it("blanks only the date line, not the lines around it", () => { + const text = file(" * Data last changed: 2026-01-01", "1"); + const normalized = normalizeForComparison(text); + expect(normalized).toContain(""); + expect(normalized).not.toContain("2026-01-01"); + expect(normalized).toContain("const x = 1;"); + }); + + it("normalizes the real generated file's date line", () => { + // Guards the coupling between the emitted label and the regex: rename one + // without the other and `--check` silently starts failing on the date. + const { text } = generate({ contextMd: legacy, pricingMd: pricing, modelIds: ["gpt-5.5"], overrides: {} }); + expect(text).toMatch(/^ \* Data last changed: \d{4}-\d{2}-\d{2}$/m); + expect(normalizeForComparison(text)).toContain(" * Data last changed: "); + }); +}); + +describe("parseDocsTable", () => { + it("extracts the model name out of a markdown link cell", () => { + const rows = parseDocsTable(legacy, ["Model", "Default context"]); + const names = rows.map((r) => r["Model"]); + expect(names).toContain("Claude 4.6 Sonnet"); + expect(names).toContain("Auto Cost"); + }); + + it("selects the table that has the requested columns, not the first table", () => { + // The pricing doc ships two tables: the model table and an unrelated + // Plan/Price table. Selecting by column names is what keeps them apart. + const rows = parseDocsTable(pricing, ["Model", "Input", "Output"]); + expect(rows.map((r) => r["Model"])).toContain("GPT-5.5"); + expect(rows.map((r) => r["Model"])).not.toContain("**Pro**"); + const plans = parseDocsTable(pricing, ["Plan", "Price"]); + expect(plans.map((r) => r["Plan"])).toContain("**Pro**"); + expect(plans.map((r) => r["Plan"])).not.toContain("GPT-5.5"); + }); + + it("throws when no table carries every requested column", () => { + expect(() => parseDocsTable(legacy, ["Model", "Nope"])).toThrow(/Nope/); + }); + + it("throws when a data row is shorter than the header and drops a requested column", () => { + // A short row used to yield "" for the missing cells, which parsePrice + // turned into $0 — a wrong rate card with no signal at all. + const md = table( + ["Model", "Provider", "Input", "Output"], + [["Claude Sonnet 5", "Anthropic", "$3", "$15"], ["Claude Opus 5", "Anthropic"]], + ); + expect(() => parseDocsTable(md, ["Model", "Input", "Output"])).toThrow( + /Claude Opus 5.*missing the requested column "Input"/, + ); + }); + + it("does not throw for a column it was not asked for", () => { + // Narrow on purpose: only the columns the caller depends on are contracts. + const md = table( + ["Model", "Provider", "Input", "Notes"], + [["Claude Sonnet 5", "Anthropic", "$3"]], + ); + const rows = parseDocsTable(md, ["Model", "Input"]); + expect(rows).toHaveLength(1); + expect(rows[0]?.["Input"]).toBe("$3"); + expect(rows[0]?.["Notes"]).toBeUndefined(); + }); + + it("keeps an empty-but-present cell distinct from an absent one", () => { + const md = table(["Model", "Provider", "Input"], [["Claude Sonnet 5", "Anthropic", ""]]); + const rows = parseDocsTable(md, ["Model", "Input"]); + expect(rows[0]?.["Input"]).toBe(""); + // Present-but-empty parses no further: it is not a $0. + expect(() => parsePrice(rows[0]?.["Input"] ?? "")).toThrow(/empty price cell/); + }); + + it("reads price from the structured columns and ignores promo prose in Notes", () => { + // Claude Sonnet 5's row advertises a $2/$10 launch promotion in its Notes + // cell while the price columns still read $3/$15. We parse columns only. + const rows = parseDocsTable(pricing, ["Model", "Input", "Output", "Notes"]); + const sonnet5 = rows.find((r) => r["Model"] === "Claude Sonnet 5"); + expect(sonnet5?.["Notes"]).toMatch(/\$2\/M input and \$10\/M output/); + expect(parsePrice(sonnet5?.["Input"] ?? "")).toBe(3); + expect(parsePrice(sonnet5?.["Output"] ?? "")).toBe(15); + }); +}); + +describe("matchModelId", () => { + const rows = parseDocsTable(legacy, ["Model", "Default context"]); + + it("matches despite flipped word order and vendor prefixes", () => { + expect(matchModelId("claude-sonnet-4-6", rows)?.row?.["Model"]).toBe("Claude 4.6 Sonnet"); + expect(matchModelId("claude-opus-4-8", rows)?.row?.["Model"]).toBe("Claude Opus 4.8"); + expect(matchModelId("gpt-5.5", rows)?.row?.["Model"]).toBe("GPT-5.5"); + expect(matchModelId("grok-4.5", rows)?.row?.["Model"]).toBe("Grok 4.5"); + }); + + it("returns undefined for ids the docs do not list", () => { + expect(matchModelId("auto-smart", rows)?.row).toBeUndefined(); + expect(matchModelId("default", rows)?.row).toBeUndefined(); + // Docs list only GPT-5.1 Codex / Codex Max / Codex Mini, never bare 5.1. + expect(matchModelId("gpt-5.1", rows)?.row).toBeUndefined(); + }); + + it("does not fall back to a sibling variant row", () => { + // "Composer 2.5" must not satisfy the id "composer-2". + expect(matchModelId("composer-2", rows)?.row).toBeUndefined(); + expect(matchModelId("composer-2.5", rows)?.row?.["Model"]).toBe("Composer 2.5"); + }); + + it("does not match a model the pricing table omits", () => { + // The pricing doc is the "Other Models" table; Cursor Models pool models + // are priced by pool and never appear there. + const priceRows = parseDocsTable(pricing, ["Model", "Input", "Output"]); + expect(matchModelId("grok-4.5", priceRows)?.row).toBeUndefined(); + }); + + it("reports ambiguity instead of silently picking one row", () => { + const md = table( + ["Model", "Provider", "Default context"], + [ + ["Claude Sonnet 5", "Anthropic", "200k"], + ["Claude 5 Sonnet", "Anthropic", "300k"], + ], + ); + const dupes = parseDocsTable(md, ["Model", "Default context"]); + const result = matchModelId("claude-sonnet-5", dupes); + expect(result?.row).toBeUndefined(); + expect(result?.ambiguous).toEqual(["Claude Sonnet 5", "Claude 5 Sonnet"]); + }); + + it("refuses the (fast mode) variant row", () => { + // The 6x-wrong-cost hazard, asserted rather than described: the context + // table lists "Claude Opus 4.7 (fast mode)" at 200k and the pricing table + // prices it at $30/$150 against Opus's normal $5/$25. A subset-based + // matcher would hand that row to `claude-opus-4-7`. + const priceRows = parseDocsTable(pricing, ["Model", "Input", "Output"]); + const fast = priceRows.find((r) => r["Model"] === "Claude Opus 4.7 (fast mode)"); + expect(fast?.["Input"]).toBe("$30"); + expect(fast?.["Output"]).toBe("$150"); + + expect(rows.map((r) => r["Model"])).toContain("Claude Opus 4.7 (fast mode)"); + // Neither table offers a plain Opus 4.7 row in these fixtures, so the only + // candidate is the fast-mode row — and it is refused outright, not + // preferred-but-available. + expect(matchModelId("claude-opus-4-7", rows)?.row).toBeUndefined(); + expect(matchModelId("claude-opus-4-7", rows)?.ambiguous).toBeUndefined(); + expect(matchModelId("claude-opus-4-7", priceRows)?.row).toBeUndefined(); + }); + + it("requires the Provider cell to agree when the id names a stripped vendor", () => { + // `claude` and `gpt` are dropped from both sides so word order can differ, + // which also discards vendor identity: `{5.5}` describes both "GPT-5.5" + // and a hypothetical "Claude 5.5". The Provider column decides. + const crossVendor = parseDocsTable( + table(["Model", "Provider", "Default context"], [["Claude 5.5", "Anthropic", "999k"]]), + ["Model", "Default context"], + ); + expect(matchModelId("gpt-5.5", crossVendor)?.row).toBeUndefined(); + expect(matchModelId("claude-5.5", crossVendor)?.row?.["Model"]).toBe("Claude 5.5"); + }); + + it("picks the right row when two vendors ship the same remaining tokens", () => { + const bothVendors = parseDocsTable( + table( + ["Model", "Provider", "Default context"], + [ + ["Claude 5.5", "Anthropic", "300k"], + ["GPT-5.5", "OpenAI", "272k"], + ], + ), + ["Model", "Default context"], + ); + expect(matchModelId("gpt-5.5", bothVendors)?.row?.["Default context"]).toBe("272k"); + expect(matchModelId("claude-5.5", bothVendors)?.row?.["Default context"]).toBe("300k"); + }); + + it("does not match a vendor-bearing id against a row with no Provider cell", () => { + // Strict on purpose: a blank Provider is not evidence of the right vendor, + // and matching anyway would be deciding on the evidence just found absent. + const noProvider = parseDocsTable( + table(["Model", "Default context"], [["Claude Opus 4.8", "300k"]]), + ["Model", "Default context"], + ); + expect(matchModelId("claude-opus-4-8", noProvider)?.row).toBeUndefined(); + }); +}); + +describe("generate", () => { + // `modelIds` and `overrides` are injected so these contracts run against the + // 8-row fixtures. Without injection the function closes over the live 33-id + // catalog and throws on ~25 unrelated ids before reaching the assertion. + const base = { contextMd: legacy, pricingMd: pricing }; + + it("throws when an id has no docs row and no override", () => { + // The strict-override contract: never a silent 200K, never a silent $0. + expect(() => generate({ ...base, modelIds: ["totally-made-up-model"], overrides: {} })).toThrow( + /totally-made-up-model: no "Default context" .* and no OVERRIDES entry/, + ); + }); + + it("throws when an id has a context row but no pricing row and no override", () => { + // Grok 4.5 is in the context table and absent from the pricing table, so + // context and cost must be satisfiable independently — and an unsatisfied + // cost must still stop the run. + expect(() => generate({ ...base, modelIds: ["grok-4.5"], overrides: {} })).toThrow( + /grok-4\.5: no pricing row .* and no OVERRIDES entry/, + ); + }); + + it("throws on an ambiguous context match", () => { + const contextMd = table( + ["Model", "Provider", "Default context"], + [ + ["Claude Sonnet 5", "Anthropic", "200k"], + ["Claude 5 Sonnet", "Anthropic", "300k"], + ], + ); + expect(() => generate({ ...base, contextMd, modelIds: ["claude-sonnet-5"], overrides: {} })).toThrow( + /claude-sonnet-5: ambiguous context match against \[Claude Sonnet 5, Claude 5 Sonnet\]/, + ); + }); + + it("throws on an ambiguous pricing match", () => { + const pricingMd = table( + ["Model", "Provider", "Input", "Cache write", "Cache read", "Output"], + [ + ["GPT-5.5", "OpenAI", "$5", "-", "$0.5", "$30"], + ["GPT 5.5", "OpenAI", "$50", "-", "$5", "$300"], + ], + ); + expect(() => generate({ ...base, pricingMd, modelIds: ["gpt-5.5"], overrides: {} })).toThrow( + /gpt-5\.5: ambiguous pricing match against \[GPT-5\.5, GPT 5\.5\]/, + ); + }); + + it("prefers the docs row over an override that also covers the id", () => { + // Precedence matters because an override is invisible to `--check`: it can + // never drift. A docs row that loses to a stale override is a value that + // stops being verified without anyone noticing. + const { text, stats } = generate({ + ...base, + modelIds: ["claude-opus-4-8"], + overrides: { + "claude-opus-4-8": { + context: 111_000, + cost: { input: 99, output: 99, cacheRead: 99, cacheWrite: 99 }, + why: "deliberately wrong, to prove the docs win", + }, + }, + }); + expect(text).toContain(`"claude-opus-4-8": 300_000,`); + expect(text).not.toContain("111_000"); + expect(text).toContain(`"claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },`); + expect(text).not.toContain("99"); + expect(stats).toEqual({ + context: { matched: 1, overridden: 0 }, + cost: { matched: 1, overridden: 0 }, + }); + }); + + it("falls back to the override only where the docs are silent", () => { + const { text, stats } = generate({ + ...base, + modelIds: ["grok-4.5"], + overrides: { "grok-4.5": { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, why: "pool" } }, + }); + expect(text).toContain(`"grok-4.5": 256_000,`); + expect(text).toContain(`"grok-4.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },`); + expect(stats).toEqual({ + context: { matched: 1, overridden: 0 }, + cost: { matched: 0, overridden: 1 }, + }); + }); + + it("emits keys sorted by model id whatever order they arrive in", () => { + // Unsorted output would make every `--check` diff unreadable and every + // reordering look like drift. + const { text } = generate({ + ...base, + modelIds: ["gpt-5.5", "claude-opus-4-8", "claude-haiku-4-5"], + overrides: {}, + }); + const expected = ["claude-haiku-4-5", "claude-opus-4-8", "gpt-5.5"]; + expect(emittedKeys(text, "const MODEL_CONTEXT_LIMITS")).toEqual(expected); + expect(emittedKeys(text, "const MODEL_COST")).toEqual(expected); + }); + + it("keeps the hand-maintained parts of the template out of the docs-derived maps", () => { + const { text } = generate({ ...base, modelIds: ["gpt-5.5"], overrides: {} }); + // MODEL_OUTPUT_LIMITS has no docs column behind it and is emitted verbatim. + expect(emittedKeys(text, "const MODEL_OUTPUT_LIMITS")).toEqual([ + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-fable-5", + "gpt-5.5", + "gpt-5.6-sol", + ]); + expect(text).toContain("const DEFAULT_CONTEXT_LIMIT = 200_000;"); + expect(text).toContain("const DEFAULT_OUTPUT_LIMIT = 32_000;"); + expect(text).toContain("const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };"); + }); +}); + +describe("CLI entry", () => { + const moduleSrc = readFileSync(join(__dirname, "../scripts/sync-model-limits.mjs"), "utf8"); + const cliSrc = readFileSync(join(__dirname, "../scripts/sync-model-limits-cli.mjs"), "utf8"); + + it("runs main unconditionally, with no entry-point guard", () => { + // The guard class this replaces was fail-open: when `argv[1]` did not equal + // the module URL, `main()` never ran and the process exited 0 having done + // nothing — a permanently green drift job. + expect(cliSrc).toMatch(/^process\.exitCode = await main\(process\.argv\.slice\(2\)\);$/m); + expect(cliSrc).not.toMatch(/if\s*\(/); + }); + + it("keeps the generator module import-pure", () => { + expect(moduleSrc).not.toMatch(/import\.meta\.url ===/); + expect(moduleSrc).not.toMatch(/process\.exitCode/); + expect(moduleSrc).toMatch(/^export async function main\(argv\) \{$/m); + }); + + it("is the file the npm script runs", () => { + const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { + scripts: Record; + }; + expect(pkg.scripts["sync:model-limits"]).toBe("node scripts/sync-model-limits-cli.mjs"); + }); + + it("exports exactly the names the hand-maintained declaration file lists", () => { + // `scripts/sync-model-limits.d.mts` is a hand-written mirror; this is what + // stops it drifting out of existence-agreement with the module. + expect(Object.keys(sync).sort()).toEqual([ + "MODEL_IDS", + "OVERRIDES", + "SOURCES", + "generate", + "main", + "matchModelId", + "normalizeForComparison", + "parseDocsTable", + "parsePrice", + "parseTokens", + ]); + }); +});