@@ -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 = {
6764export 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
103107const 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 */
111116export 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 */
199200export 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 */
376395export 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
509534const 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
604665async 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