feat(model): per-model context limits and pricing, generated from Cursor's docs - #89
Merged
Conversation
…ecking
The drift mechanism had three ways to report success without having
verified anything, and the function holding every hard contract had no
test coverage.
- Remove the fail-open CLI guard. `isInvokedDirectly()` compared
`process.argv[1]` against `import.meta.url`; on any invocation where
they differ, `main()` never ran and the process exited 0 having done
nothing. The symlink fix addressed one instance, not the class. The
generator module is now import-pure and `scripts/sync-model-limits-cli.mjs`
calls `main()` unconditionally. Verified through a symlink, an absolute
path from another cwd, and `npm exec`.
- The CI step now captures stdout and requires the run summary line, so
"exited 0 having done nothing" fails instead of passing for free.
- An empty price cell no longer becomes $0. `parseDocsTable` leaves an
absent cell unset (distinct from present-but-empty) and throws when a
requested column is missing from a row; `parsePrice("")` throws.
`"-"` still means $0, which is what Cursor documents it as.
- `generate()` takes injectable `modelIds`/`overrides`, so strict
overrides, ambiguity on both tables, docs-over-override precedence and
the deterministic sort are exercised against the fixtures rather than
resting on one-time manual probes. `normalizeForComparison` is covered
too: it is what keeps `--check` from failing daily.
- Matching consults the `Provider` column. `claude`/`gpt` are dropped from
both sides to survive word-order differences, which also discarded vendor
identity; an id naming a dropped vendor now matches only a row whose
Provider agrees. Counts unchanged: 29 exact / 0 ambiguous on context,
27 exact / 6 overridden on pricing.
- Fixtures gain the verbatim `Claude Opus 4.7 (fast mode)` row ($30/$150,
6x Opus's rate) and assert `claude-opus-4-7` refuses it.
- Write-mode I/O failure exits 2, not 1: a failed write does not establish
that the committed file is stale.
- `Synced:` relabelled `Data last changed:`. Write mode short-circuits when
only the date would move, so the committed date was never a verification
date and read as though the file were a year stale.
- `schedule`/`workflow_dispatch` are workflow-wide triggers, so `build` and
`integration` are now scoped to push/pull_request.
- `SOURCES.context` points at the URL that currently serves markdown. The
`.md` form began returning HTTP 404, which made every run exit 2 — the
scheduled job's exit-2-is-a-warning branch would have swallowed that
indefinitely. No emitted value changes.
Zero numeric values change in the regenerated `src/model-limits.ts`: key
sets, key order, `DEFAULT_*`, `MODEL_OUTPUT_LIMITS` and all three
`resolve*` functions are byte-identical. The only diff is the header.
Tests 416 -> 442. Each new test was mutation-checked against the
pre-change behaviour to confirm it can fail.
Two corrections to the drift check: - SOURCES.context pointed at the extensionless docs URL, justified by a comment claiming the .md form returned 404. Not reproducible: measured against both URL shapes, the .md form returns the markdown table under both a markdown-preferring and a wildcard Accept header, while the extensionless form returns a ~110KB HTML page under a wildcard Accept. The .md form does not depend on the Accept header staying markdown-preferring, so both sources now use it. Comment corrected to the measured behavior. - Exit 2 (docs unreachable/unparseable, or a model id matching nothing) annotated a warning and exited 0, so a permanently broken check would pass every week indefinitely. It now fails the job: unverified is not the same as verified-clean. Regenerated output changes one line, the source URL recorded in the generated header. No numeric value changed.
justin-carper
added a commit
that referenced
this pull request
Aug 3, 2026
The 0.7.1-next.0 section shipped three problems. - The skills bridge (#90) named no contributor. Wayne Simpson authored the original implementation commit; the release notes credited only the PR opener, and the Co-authored-by trailer in the squashed commit was the sole record. - The #89 entry said src/model-limits.ts is "regenerated on schedule". The weekly model-data-drift job runs --check and fails on staleness; it never writes. Regenerating is manual. It also named a "models generator CLI" that does not exist -- the script is npm run sync:model-limits -- and carried CI-internal detail (import-purity, stdout capture) that is invisible to users. - #88's dev-dependency bumps had no entry, breaking the convention every prior release follows.
justin-carper
added a commit
that referenced
this pull request
Aug 3, 2026
…91) opencode calls the model with `tools: {}` on a compaction/summary turn, and the bundled ai-sdk converts an empty tool record to `options.tools === undefined`. The Cursor agent runs its own tools regardless of what the host declared, and the provider forwarded that activity as provider-executed `tool-call` / `tool-input-start` parts. opencode's SessionProcessor rejects those on a summary turn: case "tool-input-start": case "tool-call": if (assistantMessage.summary) throw Error(`Tool call not allowed while generating summary: ${name}`) so the turn hard-errored and the session could not be compacted at all. A host that declared no tools cannot accept tool parts, so route those turns through the existing `"reasoning"` tool-display path: Cursor's tool activity is folded into reasoning text instead of crossing the tool-execution boundary. Turns that do declare tools are untouched and still render structured blocks. The empty-array case matters as well as `undefined`: ai-sdk's early return only covers a null tool set, so a non-empty `tools` filtered down by `activeTools` arrives as `[]`. Manual `/compact` has been affected all along. Auto-compaction became reachable only in 0.7.1-next.0, because #89 published real per-model context windows — before that opencode resolved `limit.context` to 0 for every Cursor model, and a zero context limit structurally disables the auto-compaction trigger.
justin-carper
added a commit
that referenced
this pull request
Aug 4, 2026
The Cursor agent runtime compacts its own conversation as it approaches
its context threshold (`preCompact` hook, `trigger: "auto"`), so a second
opencode-driven pass is redundant. It is also actively harmful, in two
ways.
The compaction turn asks the model to summarize with zero tools declared.
The Cursor agent runs its own tools regardless, and opencode rejects the
result outright:
Tool call not allowed while generating summary: <tool>
Worse, compaction rewrites the transcript, so the next turn no longer
matches what the Cursor agent saw. `classifyTurn` correctly reports a
divergence and a fresh Cursor agent is created — and every distinct
agentId permanently holds a guarded SQLite store.db/-wal/-shm triple.
`SDKAgent.close()` cannot release those; it only flushes analytics and
releases the executor lease, while the checkpoint store is cached in an
agentId-keyed map evicted solely by dispose()/deleteAgent(). That
descriptor growth fed an uncatchable EXC_GUARD kill of the whole opencode
process (guard cookie 0x08fd4dbfade2dead — Apple's SQLite guard).
Suppress the trigger with a large `limit.input`, which is the value
opencode uses as its compaction threshold:
Is(e) = limit.input ? limit.input - reserved
: limit.context - maxOutput
`limit.context` is left honest, so the TUI context gauge and cost
reporting keep working — the alternative lever, `limit.context: 0`, would
disable the trigger but blank the gauge and regress #89.
`limit.input` is honored by opencode's runtime but is not declared in the
published @opencode-ai/sdk config types, so it is excluded from
`_limitKeyGuard` (which still protects context/output) and gated instead
by a new assertion in the integration test: without it, opencode dropping
support would silently restore auto-compaction and the fd leak.
Verified against the opencode 1.18.11 binary by enumerating the call
sites of Is() rather than textual hits on limit.input, since consumers
reach it transitively. The only other consumer, Pd() (preserve-recent
tokens, also used by manual /compact), clamps to 8000 both before and
after. Confirmed end-to-end under an isolated HOME that the sentinel
survives config validation into Provider.list() with limit.context
intact.
Manual /compact is unaffected. Opt back out with
`provider.cursor.options.autoCompaction: true`.
Tradeoff, documented in the README: this suppresses the proactive
threshold only, and opencode has no reactive context-overflow recovery
wired up for this provider, so its transcript is no longer trimmed
automatically. Ordinary turns send just the new message, but a cold
replay resends everything; if that overflows, the turn fails and
/compact is the manual recovery.
justin-carper
added a commit
that referenced
this pull request
Aug 4, 2026
The Cursor agent runtime compacts its own conversation as it approaches
its context threshold (`preCompact` hook, `trigger: "auto"`), so a second
opencode-driven pass is redundant. It is also actively harmful, in two
ways.
The compaction turn asks the model to summarize with zero tools declared.
The Cursor agent runs its own tools regardless, and opencode rejects the
result outright:
Tool call not allowed while generating summary: <tool>
Worse, compaction rewrites the transcript, so the next turn no longer
matches what the Cursor agent saw. `classifyTurn` correctly reports a
divergence and a fresh Cursor agent is created — and every distinct
agentId permanently holds a guarded SQLite store.db/-wal/-shm triple.
`SDKAgent.close()` cannot release those; it only flushes analytics and
releases the executor lease, while the checkpoint store is cached in an
agentId-keyed map evicted solely by dispose()/deleteAgent(). That
descriptor growth fed an uncatchable EXC_GUARD kill of the whole opencode
process (guard cookie 0x08fd4dbfade2dead — Apple's SQLite guard).
Suppress the trigger with a large `limit.input`, which is the value
opencode uses as its compaction threshold:
Is(e) = limit.input ? limit.input - reserved
: limit.context - maxOutput
`limit.context` is left honest, so the TUI context gauge and cost
reporting keep working — the alternative lever, `limit.context: 0`, would
disable the trigger but blank the gauge and regress #89.
`limit.input` is honored by opencode's runtime but is not declared in the
published @opencode-ai/sdk config types, so it is excluded from
`_limitKeyGuard` (which still protects context/output) and gated instead
by a new assertion in the integration test: without it, opencode dropping
support would silently restore auto-compaction and the fd leak.
Verified against the opencode 1.18.11 binary by enumerating the call
sites of Is() rather than textual hits on limit.input, since consumers
reach it transitively. The only other consumer, Pd() (preserve-recent
tokens, also used by manual /compact), clamps to 8000 both before and
after. Confirmed end-to-end under an isolated HOME that the sentinel
survives config validation into Provider.list() with limit.context
intact.
Manual /compact is unaffected. Opt back out with
`provider.cursor.options.autoCompaction: true`.
Tradeoff, documented in the README: this suppresses the proactive
threshold only, and opencode has no reactive context-overflow recovery
wired up for this provider, so its transcript is no longer trimmed
automatically. Ordinary turns send just the new message, but a cold
replay resends everything; if that overflows, the turn fails and
/compact is the manual recovery.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Cursor models now carry per-model context window limits, per-token pricing, and output limits, so opencode's TUI session header shows accurate tokens / context window % / cost. The values are generated from Cursor's official docs rather than hand-written, with a scheduled CI job that fails when they drift.
Why
Every Cursor model previously reported
limit.context: 200_000andcost: 0, so the session header rendered no context-window percentage and no dollar cost.Verified against live opencode 1.18.11:
/config/providersreturned"limit":{"context":0,"output":0}for all 33 models. Two causes:buildModelV2Map()feeds theprovider.models()plugin hook, whose output opencode discards for providers absent from models.dev (documented atsrc/model-discovery.ts:96-99). Cursor is not in models.dev — confirmed, 179 providers, no entry.config.provider.cursor.modelsviatoOpencodeModels(), emitted nolimitorcostat all.context: 0is what made the TUI render no percentage.Changes
Data on the channel opencode reads
src/model-limits.ts— per-model context limits, pricing, and output limits behind longest-prefix resolvers. Imports nothing, so it can be shared without a circular import.src/model-discovery.ts—OpencodeModelConfigEntrygainslimitandcost;toOpencodeModels()emits them. Cost uses the flat snake_casecache_read/cache_writekeys the config schema requires, not theModelV2nestedcacheshape. Compile-time guards fail typecheck if the emitted keys drift from the accepted schema.src/plugin/model-v2.ts— imports the shared resolvers instead of duplicating them.Generated, not hand-written
scripts/sync-model-limits.mjs— fetches Cursor's models-and-pricing and request-based-legacy docs, matches the 33 catalog ids to doc rows by token-set equality (29 from docs + 4 overrides for context; 27 + 6 for cost; 0 ambiguous), and emitssrc/model-limits.ts.npm run sync:model-limitsregenerates,--checkdetects drift..github/workflows/ci.yml— weeklymodel-data-driftjob. Scoped toschedule/workflow_dispatchso PR CI stays hermetic and the build matrix isn't dragged along.Uses
Default context, neverMax context— Max Mode is undetectable from the plugin. Parses only the structured price columns, never the proseNotescell; Cursor'sagent.getUsage()→chargedCentsis the right source for promotions, discounts, the Cursor Token Fee, and Max Mode multipliers, not doc-prose scraping.MODEL_OUTPUT_LIMITSstays hand-maintained — the docs have no output-token column.Verified
End-to-end, against an isolated opencode 1.18.11 (published plugin excluded via
XDG_CONFIG_HOMEredirect, isolation asserted before reading results):Baseline before this branch:
context: 0for all 33.Also: typecheck clean, 442 tests passing, build clean. Switching to the generator changed no numeric value — verified key-by-key against the hand-written maps.
--checkconfirmed to exit 0 clean and 1 after a deliberate value edit; exit 2 (docs unreachable, unparseable, or an id matching nothing) fails the job rather than passing with a warning, so a permanently broken check cannot stay green.Notes
provider.cursor.models.<id>config override still wins atsrc/plugin/index.ts:211and would keepcontext: 0for that model. Pre-existing, out of scope.MODEL_IDScoverage is unverified. Cursor's docs list 44 model rows against our 33 ids, so a newly released model still falls back to 200K/$0 silently. The drift job catches changed values, not additions.