Skip to content

Commit 08db0a4

Browse files
committed
fix(model): emit limits and cost on the config channel opencode reads
1 parent edbeb8e commit 08db0a4

4 files changed

Lines changed: 279 additions & 164 deletions

File tree

src/model-discovery.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ModelListItem } from "@cursor/sdk";
2+
import type { Config } from "@opencode-ai/plugin";
23
import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js";
4+
import { resolveContextLimit, resolveCost, resolveOutputLimit } from "./model-limits.js";
35
import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js";
46
import { FALLBACK_MODELS } from "./fallback-models.js";
57
import { loadCursorSdk } from "./cursor-runtime.js";
@@ -105,8 +107,59 @@ export interface OpencodeModelConfigEntry {
105107
* Cursor's server-side `fast` default. See {@link defaultModelParams}.
106108
*/
107109
options: { params?: Record<string, string> };
110+
/**
111+
* Per-model context/output window. opencode's config channel is the only
112+
* one that reaches the model registry for providers absent from
113+
* models.dev, so the TUI session header's context-window percentage
114+
* depends on this being present. Both fields are required by the schema.
115+
*/
116+
limit: { context: number; output: number };
117+
/**
118+
* Per-model API pricing, USD per million tokens. Note the FLAT snake_case
119+
* cache keys — the config schema (`ProviderConfig` in
120+
* `@opencode-ai/sdk`) uses `cache_read`/`cache_write`, unlike the
121+
* `ModelV2` shape's nested `cache: { read, write }`.
122+
*/
123+
cost: { input: number; output: number; cache_read: number; cache_write: number };
108124
}
109125

126+
/**
127+
* Compile-time guard: the entries we write into
128+
* `config.provider.cursor.models` must satisfy the shape opencode's config
129+
* schema accepts. If opencode changes the schema (or we drift, e.g. by
130+
* using `cache: { read, write }` instead of `cache_read`/`cache_write`),
131+
* `npm run typecheck` fails here rather than silently producing a config
132+
* opencode discards.
133+
*/
134+
type AcceptedModelConfig = NonNullable<
135+
NonNullable<NonNullable<Config["provider"]>[string]>["models"]
136+
>[string];
137+
const _entryShapeGuard: AcceptedModelConfig = {} as OpencodeModelConfigEntry;
138+
void _entryShapeGuard;
139+
140+
/**
141+
* Assignability alone is too weak for `cost`/`limit`. Excess-property checking
142+
* only applies to fresh object literals, and the schema's cache keys are
143+
* optional — so a drifted `cost: { input, output, cache: { read, write } }`
144+
* assigns cleanly to the accepted shape (verified: it typechecks) while
145+
* opencode would read `cache_read`/`cache_write` as absent. These guards
146+
* assert every key we emit is a key the schema actually declares.
147+
*
148+
* `never` means "no excess keys"; anything else collapses `_KeysAccepted` to
149+
* `never` and the `true` initializer below fails to compile.
150+
*/
151+
type _KeysAccepted<Ours, Accepted> = Exclude<keyof Ours, keyof Accepted> extends never ? true : never;
152+
const _costKeyGuard: _KeysAccepted<
153+
OpencodeModelConfigEntry["cost"],
154+
NonNullable<AcceptedModelConfig["cost"]>
155+
> = true;
156+
void _costKeyGuard;
157+
const _limitKeyGuard: _KeysAccepted<
158+
OpencodeModelConfigEntry["limit"],
159+
NonNullable<AcceptedModelConfig["limit"]>
160+
> = true;
161+
void _limitKeyGuard;
162+
110163
/**
111164
* Map discovered Cursor models to opencode's provider config `models` map. The
112165
* Cursor SDK runs an agent (it calls tools itself), so every model is marked
@@ -116,6 +169,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
116169
const out: Record<string, OpencodeModelConfigEntry> = {};
117170
for (const item of items) {
118171
const params = defaultModelParams(item);
172+
const cost = resolveCost(item.id);
119173
out[item.id] = {
120174
id: item.id,
121175
name: item.displayName || item.id,
@@ -125,6 +179,16 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
125179
tool_call: true,
126180
variants: buildModelVariants(item),
127181
options: Object.keys(params).length > 0 ? { params } : {},
182+
limit: {
183+
context: resolveContextLimit(item.id),
184+
output: resolveOutputLimit(item.id),
185+
},
186+
cost: {
187+
input: cost.input,
188+
output: cost.output,
189+
cache_read: cost.cacheRead,
190+
cache_write: cost.cacheWrite,
191+
},
128192
};
129193
}
130194
return out;

src/model-limits.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Per-model default context window limits (tokens), keyed by model id prefix.
3+
* Values from cursor.com/docs/account/pricing/request-based-legacy. The
4+
* "Max context" (1M for frontier models) requires Max Mode and is NOT used
5+
* here — the plugin can't detect Max Mode, so the default window is the
6+
* honest limit to display.
7+
*
8+
* Longest prefix wins: `claude-opus-4-8` (300K) beats `claude-opus-4` (200K).
9+
*/
10+
const MODEL_CONTEXT_LIMITS: Record<string, number> = {
11+
"claude-sonnet-4": 200_000,
12+
"claude-sonnet-4-5": 200_000,
13+
"claude-sonnet-4-6": 200_000,
14+
"claude-sonnet-5": 200_000,
15+
"claude-opus-4-5": 200_000,
16+
"claude-opus-4-6": 200_000,
17+
"claude-opus-4-7": 300_000,
18+
"claude-opus-4-8": 300_000,
19+
"claude-opus-5": 300_000,
20+
"claude-haiku-4-5": 200_000,
21+
"claude-fable-5": 300_000,
22+
"gpt-5": 272_000,
23+
"gpt-5-mini": 272_000,
24+
"gpt-5.1": 272_000,
25+
"gpt-5.2": 272_000,
26+
"gpt-5.3-codex": 272_000,
27+
"gpt-5.4": 272_000,
28+
"gpt-5.5": 272_000,
29+
"gpt-5.6-luna": 272_000,
30+
"gpt-5.6-sol": 272_000,
31+
"gpt-5.6-terra": 272_000,
32+
"gemini-2.5-flash": 200_000,
33+
"gemini-3-flash": 200_000,
34+
"gemini-3.1-pro": 200_000,
35+
"gemini-3.5-flash": 200_000,
36+
"gemini-3.6-flash": 200_000,
37+
"grok-4.5": 256_000,
38+
"glm-5.2": 200_000,
39+
"composer-2": 200_000,
40+
"composer-2.5": 200_000,
41+
"auto-smart": 200_000,
42+
};
43+
44+
const DEFAULT_CONTEXT_LIMIT = 200_000;
45+
46+
/**
47+
* Resolve a model's context window by longest-prefix match against
48+
* {@link MODEL_CONTEXT_LIMITS}. Falls back to 200K for unknown models.
49+
*/
50+
export function resolveContextLimit(modelId: string): number {
51+
let best: number | undefined;
52+
let bestLen = 0;
53+
for (const [prefix, limit] of Object.entries(MODEL_CONTEXT_LIMITS)) {
54+
if (modelId.startsWith(prefix) && prefix.length > bestLen) {
55+
best = limit;
56+
bestLen = prefix.length;
57+
}
58+
}
59+
return best ?? DEFAULT_CONTEXT_LIMIT;
60+
}
61+
62+
/**
63+
* Per-model API pricing (USD per million tokens), keyed by model id prefix.
64+
* Values from cursor.com/docs/models-and-pricing. Cursor Models pool models
65+
* (Grok 4.5, Composer 2.5, Auto) have $0 — they draw from the Cursor Models
66+
* pool, not the Other Models pool, so there is no per-token API charge.
67+
*
68+
* Longest prefix wins: `gpt-5.4-mini` (0.75) beats `gpt-5.4` (2.50).
69+
*/
70+
const MODEL_COST: Record<string, { input: number; output: number; cacheRead: number; cacheWrite: number }> = {
71+
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
72+
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
73+
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
74+
"claude-sonnet-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
75+
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
76+
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
77+
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
78+
"claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
79+
"claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
80+
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
81+
"claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
82+
"gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
83+
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
84+
"gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
85+
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
86+
"gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
87+
"gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
88+
"gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
89+
"gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 },
90+
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
91+
"gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
92+
"gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
93+
"gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
94+
"gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 },
95+
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
96+
"gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
97+
"gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 },
98+
"gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 },
99+
"grok-4.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
100+
"glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
101+
"composer-2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
102+
"composer-2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
103+
"auto-smart": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
104+
};
105+
106+
const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
107+
108+
/**
109+
* Resolve a model's per-token cost by longest-prefix match against
110+
* {@link MODEL_COST}. Falls back to $0 for unknown models (treated as
111+
* subscription/Cursor Models pool).
112+
*/
113+
export function resolveCost(modelId: string): {
114+
input: number;
115+
output: number;
116+
cacheRead: number;
117+
cacheWrite: number;
118+
} {
119+
let best: { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined;
120+
let bestLen = 0;
121+
for (const [prefix, cost] of Object.entries(MODEL_COST)) {
122+
if (modelId.startsWith(prefix) && prefix.length > bestLen) {
123+
best = cost;
124+
bestLen = prefix.length;
125+
}
126+
}
127+
return best ?? DEFAULT_COST;
128+
}
129+
130+
/**
131+
* Per-model output token limits, keyed by model id prefix. The Cursor SDK
132+
* doesn't expose output limits, so these are best-known values. 32K default
133+
* (the previous hardcoded value); 64K for frontier models known to support
134+
* higher output. Low priority — the TUI doesn't display output limit.
135+
*/
136+
const MODEL_OUTPUT_LIMITS: Record<string, number> = {
137+
"claude-opus-4-7": 64_000,
138+
"claude-opus-4-8": 64_000,
139+
"claude-opus-5": 64_000,
140+
"claude-fable-5": 64_000,
141+
"gpt-5.5": 64_000,
142+
"gpt-5.6-sol": 64_000,
143+
};
144+
145+
const DEFAULT_OUTPUT_LIMIT = 32_000;
146+
147+
/**
148+
* Resolve a model's output limit by longest-prefix match. Falls back to 32K.
149+
*/
150+
export function resolveOutputLimit(modelId: string): number {
151+
let best: number | undefined;
152+
let bestLen = 0;
153+
for (const [prefix, limit] of Object.entries(MODEL_OUTPUT_LIMITS)) {
154+
if (modelId.startsWith(prefix) && prefix.length > bestLen) {
155+
best = limit;
156+
bestLen = prefix.length;
157+
}
158+
}
159+
return best ?? DEFAULT_OUTPUT_LIMIT;
160+
}

0 commit comments

Comments
 (0)