Skip to content

Commit b466a22

Browse files
committed
fix(ci): repair the weekly model-limits drift check and refresh model data
The scheduled `model limits · drift vs Cursor docs` job failed on every run. cursor.com answers 404 to the script's `accept: text/plain,text/markdown,*/*` header and 200 to default fetch headers, so the check exited 2 before reading any data. Drop the custom header. With the fetch working, the generator also needed updating: - the pricing doc now has two tables with identical columns (Cursor Models pool, then Other Models); read every matching table, and still report a row present in both as ambiguous - MODEL_IDS now matches the live 40-id catalog (adds claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5, gemini-3.7-flash, gemini-3.8-flash, grok-4.6, grok-4.7, muse-spark-1.3; drops glm-5.2) - grok-4.5 and composer-2.5 now have docs price rows, so their $0 pool overrides are removed; auto-smart/default/composer-2 keep $0 as placeholder rates with corrected reasons - the NO_AUTO_COMPACTION_INPUT_LIMIT block, hand-inserted into the generated file in 27d11bb, moves into the generator template so a regen keeps it Also add a root vitest.config.ts excluding `.cursor/**`: the plugin mirrors skills into <cwd>/.cursor/skills/, and default discovery then collects their test files (94 load failures when running `npm test` in a dogfooded checkout). Tests: add a two-table generate test; drop two tests that only echoed generated cost numbers and assert the invariant instead.
1 parent 98993c1 commit b466a22

7 files changed

Lines changed: 232 additions & 82 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
- **Fix: the scheduled model-data drift job failed on every run (HTTP 404).**
8+
`fetchDoc` sent a markdown-preferring `Accept` header
9+
(`text/plain,text/markdown,*/*`), which cursor.com began answering with
10+
404, so the weekly drift check never verified anything. The fetch now uses
11+
node's default headers, which get the `.md` pages. The pricing doc also
12+
moved Cursor Models pool rates (Grok, Composer) into a second model table
13+
ahead of the "Other Models" table; the sync script now reads every
14+
matching table, and the `NO_AUTO_COMPACTION_INPUT_LIMIT` sentinel block
15+
is now emitted from the generator template (it was hand-inserted into
16+
the generated file in #92, so the next sync would have deleted it).
17+
18+
- **Model limits and costs regenerated from the 2026-09-29 Cursor docs.**
19+
Added claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5, gemini-3.7-flash,
20+
gemini-3.8-flash, grok-4.6, grok-4.7, and muse-spark-1.3; dropped glm-5.2
21+
(absent from the live catalog). Cursor now publishes per-token rates for
22+
Cursor Models pool models, so the obsolete $0 overrides for grok-4.5 and
23+
composer-2.5 are gone: grok-4.5 is now $2/$6 (was $0) and composer-2.5
24+
$0.5/$2.5 (was $0). Grok 4.7 resolves to its plain base row ($2/$6) — the
25+
Fast and 500k variants are priced higher and left to per-request params.
26+
Also picked up docs price changes: claude-sonnet-5 $3/$15 → $2/$10,
27+
gpt-5.6-sol $5/$30 → $4/$20 (promo columns). auto-smart/default/composer-2
28+
keep $0 as placeholder rates (no docs row; Auto bills at the list price of
29+
whichever model it routes to, which is unknowable per request).
30+
31+
- **`npm test` no longer collects `.cursor/`.** The plugin mirrors skills
32+
into `<cwd>/.cursor/skills/` while dogfooding this repo, and default
33+
vitest discovery then collected the mirrored skills' own test files —
34+
94 load failures for anyone running `npm test` locally. A root
35+
`vitest.config.ts` now excludes `.cursor/**` from test discovery. The
36+
e2e config is untouched.
37+
738
## [0.10.0-next.1] — 2026-09-29 (pre-release)
839

940
Adds opencode v2 support alongside v1 (#127). Not on `latest`; install with

‎scripts/sync-model-limits.mjs‎

Lines changed: 104 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,11 @@ const OUTPUT_PATH = join(HERE, "..", "src", "model-limits.ts");
4141
/**
4242
* The two docs pages, both in their `.md` form.
4343
*
44-
* Cursor's docs site content-negotiates. Measured against both URL shapes:
45-
* with the markdown-preferring Accept header `fetchDoc` sends, the `.md` and
46-
* extensionless forms both return the same markdown. With a default wildcard
47-
* Accept header, only the `.md` form does — the extensionless form returns the
48-
* ~110KB HTML page instead, which carries no pipe table.
49-
*
50-
* The `.md` form is therefore the more robust choice: it does not depend on the
51-
* Accept header staying markdown-preferring. Keep both URLs on `.md`.
44+
* Cursor's docs site content-negotiates. With node's default fetch headers the
45+
* `.md` form returns the markdown; a custom markdown-preferring Accept header
46+
* of the form "text/plain,text/markdown,*-slash-*" is answered with HTTP 404,
47+
* `fetchDoc` sends no Accept header. The extensionless form returns the ~110KB
48+
* HTML page under a wildcard Accept header, so keep both URLs on `.md`.
5249
*
5350
* A non-2xx response, or a page that stops carrying the expected columns,
5451
* exits 2 — so if Cursor moves either page it surfaces rather than going quiet.
@@ -67,16 +64,19 @@ export const SOURCES = {
6764
export const MODEL_IDS = [
6865
"auto-smart",
6966
"claude-fable-5",
67+
"claude-fable-5-1",
7068
"claude-haiku-4-5",
7169
"claude-opus-4-5",
7270
"claude-opus-4-6",
7371
"claude-opus-4-7",
7472
"claude-opus-4-8",
7573
"claude-opus-5",
74+
"claude-opus-5-5",
7675
"claude-sonnet-4",
7776
"claude-sonnet-4-5",
7877
"claude-sonnet-4-6",
7978
"claude-sonnet-5",
79+
"claude-sonnet-5-5",
8080
"composer-2",
8181
"composer-2.5",
8282
"default",
@@ -85,7 +85,8 @@ export const MODEL_IDS = [
8585
"gemini-3.1-pro",
8686
"gemini-3.5-flash",
8787
"gemini-3.6-flash",
88-
"glm-5.2",
88+
"gemini-3.7-flash",
89+
"gemini-3.8-flash",
8990
"gpt-5-mini",
9091
"gpt-5.1",
9192
"gpt-5.2",
@@ -98,45 +99,41 @@ export const MODEL_IDS = [
9899
"gpt-5.6-sol",
99100
"gpt-5.6-terra",
100101
"grok-4.5",
102+
"grok-4.6",
103+
"grok-4.7",
104+
"muse-spark-1.3",
101105
];
102106

103107
const POOL_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
104108

105109
/**
106110
* Ids the docs cannot supply, with the reason. `context` and `cost` are
107-
* independent: a model can appear in the context table but not the pricing
108-
* one, because the pricing doc is the "Other Models" table and Cursor Models
109-
* pool models are priced by pool rather than per token.
111+
* independent: a model can appear in one docs table but not the other. The
112+
* pricing doc lists Cursor Models pool rates (Grok, Composer) alongside the
113+
* "Other Models" table, so those ids no longer need overrides — the remaining
114+
* entries are ids neither table lists.
110115
*/
111116
export const OVERRIDES = {
112117
"auto-smart": {
113118
context: 200_000,
114119
cost: POOL_COST,
115-
why: 'docs row is "Auto Cost", which lists "-" for context; Cursor Models pool, so no per-token charge',
120+
why: 'no docs row; Auto bills at the list price of whichever model it routes to, unknowable per request, so $0 is a placeholder rate',
116121
},
117122
default: {
118123
context: 200_000,
119124
cost: POOL_COST,
120-
why: 'the "Auto" catalog id; same docs row as auto-smart, same pool pricing',
125+
why: 'the "Auto" catalog id; no docs row, same placeholder $0 rate as auto-smart',
121126
},
122127
"composer-2": {
123128
context: 200_000,
124129
cost: POOL_COST,
125-
why: 'docs list "Composer 1" and "Composer 2.5", never "Composer 2"; Cursor Models pool',
130+
why: 'docs list "Composer 1" and "Composer 2.5", never "Composer 2"; no published rate, $0 placeholder',
126131
},
127132
"gpt-5.1": {
128133
context: 272_000,
129134
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
130135
why: "docs list only GPT-5.1 Codex / Codex Max / Codex Mini, never bare 5.1; values follow GPT-5.1 Codex",
131136
},
132-
"grok-4.5": {
133-
cost: POOL_COST,
134-
why: 'absent from models-and-pricing.md ("Other Models" table); Cursor Models pool, so no per-token charge',
135-
},
136-
"composer-2.5": {
137-
cost: POOL_COST,
138-
why: 'absent from models-and-pricing.md ("Other Models" table); Cursor Models pool, so no per-token charge',
139-
},
140137
};
141138

142139
/** Column that holds the model display name in both docs tables. */
@@ -190,15 +187,34 @@ const SEPARATOR_ROW = /^\|[\s:|-]+\|$/;
190187
/**
191188
* Parse the first GFM table in `md` that carries every column in
192189
* `columnNames`. Selecting by column rather than by position matters: the
193-
* pricing doc ships an unrelated Plan/Price table alongside the model table.
190+
* pricing doc ships an unrelated Plan/Price table alongside the model tables.
191+
*
192+
* The pricing doc now ships TWO model tables with the same columns — the
193+
* Cursor Models pool rates (Grok, Composer) before the "Other Models" table —
194+
* so `generate` reads every matching table via `parseAllDocsTables` instead.
194195
*
195196
* @param {string} md
196197
* @param {string[]} columnNames
197198
* @returns {Array<Record<string, string>>}
198199
*/
199200
export function parseDocsTable(md, columnNames) {
201+
return parseAllDocsTables(md, columnNames)[0];
202+
}
203+
204+
/**
205+
* Parse every GFM table in `md` that carries every column in `columnNames`.
206+
* The pricing doc's two model tables (Cursor Models pool rates, then Other
207+
* Models) both carry the pricing columns; returning only the first would leave
208+
* every third-party id unmatched.
209+
*
210+
* @param {string} md
211+
* @param {string[]} columnNames
212+
* @returns {Array<Array<Record<string, string>>>}
213+
*/
214+
function parseAllDocsTables(md, columnNames) {
200215
const lines = md.split("\n");
201216
const seenHeaders = [];
217+
const tables = [];
202218
for (let i = 1; i < lines.length; i++) {
203219
const line = lines[i].trim();
204220
if (!SEPARATOR_ROW.test(line)) continue;
@@ -233,13 +249,16 @@ export function parseDocsTable(md, columnNames) {
233249
if (rows.length === 0) {
234250
throw new Error(`table with columns [${columnNames.join(", ")}] has no data rows`);
235251
}
236-
return rows;
252+
tables.push(rows);
253+
}
254+
if (tables.length === 0) {
255+
throw new Error(
256+
`no table carries every column [${columnNames.join(", ")}]. Tables found: ${
257+
seenHeaders.length ? seenHeaders.map((h) => `<${h}>`).join("; ") : "none"
258+
}`,
259+
);
237260
}
238-
throw new Error(
239-
`no table carries every column [${columnNames.join(", ")}]. Tables found: ${
240-
seenHeaders.length ? seenHeaders.map((h) => `<${h}>`).join("; ") : "none"
241-
}`,
242-
);
261+
return tables;
243262
}
244263

245264
/**
@@ -368,10 +387,10 @@ const SYNC_DATE_PLACEHOLDER = " * Data last changed: <ignored for comparison>";
368387

369388
/**
370389
* Strip the date line so `--check` reports data drift, not the passage of
371-
* time. Write mode uses the same normalization to leave the committed date
372-
* alone when nothing else moved.
390+
* time.
373391
*
374392
* @param {string} text
393+
* @returns {string}
375394
*/
376395
export function normalizeForComparison(text) {
377396
return text.replace(SYNC_DATE_LINE, SYNC_DATE_PLACEHOLDER);
@@ -384,7 +403,7 @@ export function normalizeForComparison(text) {
384403
* `modelIds` and `overrides` are injectable so the contracts this function
385404
* holds — strict overrides, ambiguity, docs-over-override precedence, sorted
386405
* output — can be exercised against small fixtures instead of only against the
387-
* live 33-id catalog.
406+
* live 40-id catalog.
388407
*
389408
* @param {{
390409
* contextMd: string,
@@ -402,8 +421,14 @@ export function generate({
402421
overrides = OVERRIDES,
403422
date = new Date().toISOString().slice(0, 10),
404423
}) {
405-
const contextRows = parseDocsTable(contextMd, [NAME_COLUMN, "Default context"]);
406-
const priceRows = parseDocsTable(pricingMd, [NAME_COLUMN, "Input", "Cache write", "Cache read", "Output"]);
424+
const contextRows = parseAllDocsTables(contextMd, [NAME_COLUMN, "Default context"]).flat();
425+
const priceRows = parseAllDocsTables(pricingMd, [
426+
NAME_COLUMN,
427+
"Input",
428+
"Cache write",
429+
"Cache read",
430+
"Output",
431+
]).flat();
407432

408433
const stats = {
409434
context: { matched: 0, overridden: 0 },
@@ -486,8 +511,8 @@ export function generate({
486511
* Pricing is read from the structured Input / Cache write / Cache read /
487512
* Output columns only. The \`Notes\` cell is deliberately NOT parsed, even
488513
* though promotions are announced there in prose (Claude Sonnet 5's row
489-
* advertises "$2/M input and $10/M output through August 31, 2026" while its
490-
* price columns still read $3 / $15). Extracting money from free text is
514+
* advertised "$2/M input and $10/M output through August 31, 2026" while its
515+
* price columns read $3 / $15). Extracting money from free text is
491516
* confidently wrong by construction, promo windows expire, and Cursor's own
492517
* \`agent.getUsage()\` -> \`chargedCents\` is the authoritative source for
493518
* promotions, discounts, the Cursor Token Fee, and Max Mode multipliers. This
@@ -508,6 +533,41 @@ ${contextLimits.join("\n")}
508533
509534
const DEFAULT_CONTEXT_LIMIT = 200_000;
510535
536+
/**
537+
* Sentinel \`limit.input\` that pushes opencode's auto-compaction threshold out
538+
* of reach, so auto-compaction never fires. opencode computes the threshold as
539+
* \`limit.input ? limit.input - reserved : limit.context - maxOutput\`, so a huge
540+
* \`input\` makes it unreachable while \`limit.context\` stays honest — the TUI
541+
* context gauge keeps working.
542+
*
543+
* Why suppress it: the Cursor agent runtime self-compacts on its own context
544+
* threshold (\`@cursor/sdk\` \`dist/esm/357.js\`, \`preCompact\` hook with
545+
* \`trigger: "auto"\`), so opencode-driven compaction is redundant. It is also
546+
* harmful — each opencode compaction rewrites the transcript, which classifies
547+
* as \`divergence\` and mints a fresh Cursor agentId, and every distinct agentId
548+
* permanently adds a guarded SQLite \`store.db\`/\`-wal\`/\`-shm\` triple that
549+
* \`agent.close()\` cannot release.
550+
*
551+
* This is NOT a real model capability. Verified against the opencode 1.18.11
552+
* binary by enumerating the call sites of \`Is()\` (the threshold function) rather
553+
* than textual hits on \`limit.input\`, since consumers reach it transitively:
554+
* - \`vl()\` — the proactive auto-compaction trigger. Suppressed here.
555+
* - \`Pd()\` — preserve-recent-tokens budget, also used by manual
556+
* \`/compact\`. Inert: it is \`min(8000, max(2000,
557+
* floor(Is*0.25)))\`, which saturates at 8000 for any
558+
* \`Is >= 32000\` — true both before and after the sentinel.
559+
* Everything else that touches \`limit.input\` is catalog merge/serialization.
560+
*
561+
* Also verified end-to-end (isolated HOME, \`opencode models cursor --verbose\`)
562+
* that a config-channel \`limit.input\` survives validation and reaches
563+
* \`Provider.list()\` with \`limit.context\` intact.
564+
*
565+
* Caveat: \`Is()\` is \`max(0, input - reserved)\`, so a user setting
566+
* \`compaction.reserved >= this value\` would drive the threshold to 0 and make
567+
* compaction fire every turn. Absurd but user-settable.
568+
*/
569+
export const NO_AUTO_COMPACTION_INPUT_LIMIT = 1_000_000_000;
570+
511571
/**
512572
* Resolve a model's context window by longest-prefix match against
513573
* {@link MODEL_CONTEXT_LIMITS}. Falls back to 200K for unknown models.
@@ -526,9 +586,10 @@ export function resolveContextLimit(modelId: string): number {
526586
527587
/**
528588
* Per-model API pricing (USD per million tokens), keyed by model id prefix.
529-
* Cursor Models pool models (Grok 4.5, Composer, Auto) have $0 — they draw
530-
* from the Cursor Models pool, not the Other Models pool, so there is no
531-
* per-token API charge and they are absent from the pricing docs entirely.
589+
* Read from the pricing doc's two rate tables — Cursor Models (Grok,
590+
* Composer) and Other Models (third-party). Auto/default and Composer 2 have
591+
* no docs row; their $0 is a placeholder rate, not a free model (Auto bills
592+
* at the routed model's list price — see the script's OVERRIDES).
532593
*
533594
* Longest prefix wins: \`gpt-5.4-mini\` (0.75) beats \`gpt-5.4\` (2.50).
534595
*/
@@ -602,7 +663,9 @@ export function resolveOutputLimit(modelId: string): number {
602663
}
603664

604665
async function fetchDoc(url) {
605-
const response = await fetch(url, { headers: { accept: "text/plain,text/markdown,*/*" } });
666+
// No custom Accept header — cursor.com 404s "text/plain,text/markdown,*/*";
667+
// fetch defaults get the .md.
668+
const response = await fetch(url);
606669
if (!response.ok) throw new Error(`GET ${url} -> HTTP ${response.status}`);
607670
return await response.text();
608671
}

0 commit comments

Comments
 (0)