Skip to content

feat(model): per-model context limits and pricing, generated from Cursor's docs - #89

Merged
justin-carper merged 8 commits into
mainfrom
usage-metrics
Aug 3, 2026
Merged

feat(model): per-model context limits and pricing, generated from Cursor's docs#89
justin-carper merged 8 commits into
mainfrom
usage-metrics

Conversation

@justin-carper

@justin-carper justin-carper commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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_000 and cost: 0, so the session header rendered no context-window percentage and no dollar cost.

Verified against live opencode 1.18.11: /config/providers returned "limit":{"context":0,"output":0} for all 33 models. Two causes:

  1. buildModelV2Map() feeds the provider.models() plugin hook, whose output opencode discards for providers absent from models.dev (documented at src/model-discovery.ts:96-99). Cursor is not in models.dev — confirmed, 179 providers, no entry.
  2. The channel opencode actually reads, config.provider.cursor.models via toOpencodeModels(), emitted no limit or cost at all.

context: 0 is 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.tsOpencodeModelConfigEntry gains limit and cost; toOpencodeModels() emits them. Cost uses the flat snake_case cache_read/cache_write keys the config schema requires, not the ModelV2 nested cache shape. 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 emits src/model-limits.ts. npm run sync:model-limits regenerates, --check detects drift.
  • .github/workflows/ci.yml — weekly model-data-drift job. Scoped to schedule/workflow_dispatch so PR CI stays hermetic and the build matrix isn't dragged along.

Uses Default context, never Max context — Max Mode is undetectable from the plugin. Parses only the structured price columns, never the prose Notes cell; Cursor's agent.getUsage()chargedCents is the right source for promotions, discounts, the Cursor Token Fee, and Max Mode multipliers, not doc-prose scraping.

MODEL_OUTPUT_LIMITS stays 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_HOME redirect, isolation asserted before reading results):

model context output in/out cache r/w
claude-opus-4-8 300000 64000 5/25 0.5/6.25
gpt-5.5 272000 64000 5/30 0.5/0
grok-4.5 256000 32000 0/0 0/0
claude-sonnet-4-6 200000 32000 3/15 0.3/3.75
claude-fable-5 300000 64000 10/50 1/12.5

Baseline before this branch: context: 0 for 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. --check confirmed 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

  • Cursor Models pool models (Grok 4.5, Composer 2.5, Auto) are $0 — no per-token API charge, and they are absent from the "Other Models" pricing table entirely.
  • A user's own provider.cursor.models.<id> config override still wins at src/plugin/index.ts:211 and would keep context: 0 for that model. Pre-existing, out of scope.
  • Follow-up: MODEL_IDS coverage 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.

…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 justin-carper changed the title feat(model): per-model context limits and pricing for the usage display feat(model): per-model context limits and pricing, generated from Cursor's docs Aug 3, 2026
@justin-carper
justin-carper merged commit be7a36e into main Aug 3, 2026
8 checks passed
@justin-carper
justin-carper deleted the usage-metrics branch August 3, 2026 16:56
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant