Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/model-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ModelListItem } from "@cursor/sdk";
import type { Config } from "@opencode-ai/plugin";
import { fingerprintApiKey, resolveCursorApiKey } from "./api-key.js";
import { resolveContextLimit, resolveCost, resolveOutputLimit } from "./model-limits.js";
import { readLatestModelCache, readModelCache, writeModelCache } from "./model-cache.js";
import { FALLBACK_MODELS } from "./fallback-models.js";
import { loadCursorSdk } from "./cursor-runtime.js";
Expand Down Expand Up @@ -105,8 +107,59 @@ export interface OpencodeModelConfigEntry {
* Cursor's server-side `fast` default. See {@link defaultModelParams}.
*/
options: { params?: Record<string, string> };
/**
* Per-model context/output window. opencode's config channel is the only
* one that reaches the model registry for providers absent from
* models.dev, so the TUI session header's context-window percentage
* depends on this being present. Both fields are required by the schema.
*/
limit: { context: number; output: number };
/**
* Per-model API pricing, USD per million tokens. Note the FLAT snake_case
* cache keys — the config schema (`ProviderConfig` in
* `@opencode-ai/sdk`) uses `cache_read`/`cache_write`, unlike the
* `ModelV2` shape's nested `cache: { read, write }`.
*/
cost: { input: number; output: number; cache_read: number; cache_write: number };
}

/**
* Compile-time guard: the entries we write into
* `config.provider.cursor.models` must satisfy the shape opencode's config
* schema accepts. If opencode changes the schema (or we drift, e.g. by
* using `cache: { read, write }` instead of `cache_read`/`cache_write`),
* `npm run typecheck` fails here rather than silently producing a config
* opencode discards.
*/
type AcceptedModelConfig = NonNullable<
NonNullable<NonNullable<Config["provider"]>[string]>["models"]
>[string];
const _entryShapeGuard: AcceptedModelConfig = {} as OpencodeModelConfigEntry;
void _entryShapeGuard;

/**
* Assignability alone is too weak for `cost`/`limit`. Excess-property checking
* only applies to fresh object literals, and the schema's cache keys are
* optional — so a drifted `cost: { input, output, cache: { read, write } }`
* assigns cleanly to the accepted shape (verified: it typechecks) while
* opencode would read `cache_read`/`cache_write` as absent. These guards
* assert every key we emit is a key the schema actually declares.
*
* `never` means "no excess keys"; anything else collapses `_KeysAccepted` to
* `never` and the `true` initializer below fails to compile.
*/
type _KeysAccepted<Ours, Accepted> = Exclude<keyof Ours, keyof Accepted> extends never ? true : never;
const _costKeyGuard: _KeysAccepted<
OpencodeModelConfigEntry["cost"],
NonNullable<AcceptedModelConfig["cost"]>
> = true;
void _costKeyGuard;
const _limitKeyGuard: _KeysAccepted<
OpencodeModelConfigEntry["limit"],
NonNullable<AcceptedModelConfig["limit"]>
> = true;
void _limitKeyGuard;

/**
* Map discovered Cursor models to opencode's provider config `models` map. The
* Cursor SDK runs an agent (it calls tools itself), so every model is marked
Expand All @@ -116,6 +169,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
const out: Record<string, OpencodeModelConfigEntry> = {};
for (const item of items) {
const params = defaultModelParams(item);
const cost = resolveCost(item.id);
out[item.id] = {
id: item.id,
name: item.displayName || item.id,
Expand All @@ -125,6 +179,16 @@ export function toOpencodeModels(items: ModelListItem[]): Record<string, Opencod
tool_call: true,
variants: buildModelVariants(item),
options: Object.keys(params).length > 0 ? { params } : {},
limit: {
context: resolveContextLimit(item.id),
output: resolveOutputLimit(item.id),
},
cost: {
input: cost.input,
output: cost.output,
cache_read: cost.cacheRead,
cache_write: cost.cacheWrite,
},
};
}
return out;
Expand Down
160 changes: 160 additions & 0 deletions src/model-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Per-model default context window limits (tokens), keyed by model id prefix.
* Values from cursor.com/docs/account/pricing/request-based-legacy. The
* "Max context" (1M for frontier models) requires Max Mode and is NOT used
* here — the plugin can't detect Max Mode, so the default window is the
* honest limit to display.
*
* Longest prefix wins: `claude-opus-4-8` (300K) beats `claude-opus-4` (200K).
*/
const MODEL_CONTEXT_LIMITS: Record<string, number> = {
"claude-sonnet-4": 200_000,
"claude-sonnet-4-5": 200_000,
"claude-sonnet-4-6": 200_000,
"claude-sonnet-5": 200_000,
"claude-opus-4-5": 200_000,
"claude-opus-4-6": 200_000,
"claude-opus-4-7": 300_000,
"claude-opus-4-8": 300_000,
"claude-opus-5": 300_000,
"claude-haiku-4-5": 200_000,
"claude-fable-5": 300_000,
"gpt-5": 272_000,
"gpt-5-mini": 272_000,
"gpt-5.1": 272_000,
"gpt-5.2": 272_000,
"gpt-5.3-codex": 272_000,
"gpt-5.4": 272_000,
"gpt-5.5": 272_000,
"gpt-5.6-luna": 272_000,
"gpt-5.6-sol": 272_000,
"gpt-5.6-terra": 272_000,
"gemini-2.5-flash": 200_000,
"gemini-3-flash": 200_000,
"gemini-3.1-pro": 200_000,
"gemini-3.5-flash": 200_000,
"gemini-3.6-flash": 200_000,
"grok-4.5": 256_000,
"glm-5.2": 200_000,
"composer-2": 200_000,
"composer-2.5": 200_000,
"auto-smart": 200_000,
};

const DEFAULT_CONTEXT_LIMIT = 200_000;

/**
* Resolve a model's context window by longest-prefix match against
* {@link MODEL_CONTEXT_LIMITS}. Falls back to 200K for unknown models.
*/
export function resolveContextLimit(modelId: string): number {
let best: number | undefined;
let bestLen = 0;
for (const [prefix, limit] of Object.entries(MODEL_CONTEXT_LIMITS)) {
if (modelId.startsWith(prefix) && prefix.length > bestLen) {
best = limit;
bestLen = prefix.length;
}
}
return best ?? DEFAULT_CONTEXT_LIMIT;
}

/**
* Per-model API pricing (USD per million tokens), keyed by model id prefix.
* Values from cursor.com/docs/models-and-pricing. Cursor Models pool models
* (Grok 4.5, Composer 2.5, Auto) have $0 — they draw from the Cursor Models
* pool, not the Other Models pool, so there is no per-token API charge.
*
* Longest prefix wins: `gpt-5.4-mini` (0.75) beats `gpt-5.4` (2.50).
*/
const MODEL_COST: Record<string, { input: number; output: number; cacheRead: number; cacheWrite: number }> = {
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-sonnet-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
"claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
"gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
"gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
"gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
"gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
"gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
"gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02, cacheWrite: 0 },
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
"gpt-5.6-luna": { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
"gpt-5.6-sol": { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
"gpt-5.6-terra": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2.5 },
"gemini-2.5-flash": { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 },
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
"gemini-3.1-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
"gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 },
"gemini-3.6-flash": { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 },
"grok-4.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
"composer-2": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"composer-2.5": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"auto-smart": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};

const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };

/**
* Resolve a model's per-token cost by longest-prefix match against
* {@link MODEL_COST}. Falls back to $0 for unknown models (treated as
* subscription/Cursor Models pool).
*/
export function resolveCost(modelId: string): {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
} {
let best: { input: number; output: number; cacheRead: number; cacheWrite: number } | undefined;
let bestLen = 0;
for (const [prefix, cost] of Object.entries(MODEL_COST)) {
if (modelId.startsWith(prefix) && prefix.length > bestLen) {
best = cost;
bestLen = prefix.length;
}
}
return best ?? DEFAULT_COST;
}

/**
* Per-model output token limits, keyed by model id prefix. The Cursor SDK
* doesn't expose output limits, so these are best-known values. 32K default
* (the previous hardcoded value); 64K for frontier models known to support
* higher output. Low priority — the TUI doesn't display output limit.
*/
const MODEL_OUTPUT_LIMITS: Record<string, number> = {
"claude-opus-4-7": 64_000,
"claude-opus-4-8": 64_000,
"claude-opus-5": 64_000,
"claude-fable-5": 64_000,
"gpt-5.5": 64_000,
"gpt-5.6-sol": 64_000,
};

const DEFAULT_OUTPUT_LIMIT = 32_000;

/**
* Resolve a model's output limit by longest-prefix match. Falls back to 32K.
*/
export function resolveOutputLimit(modelId: string): number {
let best: number | undefined;
let bestLen = 0;
for (const [prefix, limit] of Object.entries(MODEL_OUTPUT_LIMITS)) {
if (modelId.startsWith(prefix) && prefix.length > bestLen) {
best = limit;
bestLen = prefix.length;
}
}
return best ?? DEFAULT_OUTPUT_LIMIT;
}
14 changes: 9 additions & 5 deletions src/plugin/model-v2.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { ModelListItem } from "@cursor/sdk";
import { modelSupportsReasoning } from "../model-discovery.js";
import { resolveContextLimit, resolveCost, resolveOutputLimit } from "../model-limits.js";
import { buildModelVariants, defaultModelParams } from "../model-variants.js";

export const PROVIDER_ID = "cursor";
Expand All @@ -19,9 +20,9 @@ export function providerNpm(): string {

/**
* Build opencode's rich runtime `Model` objects from discovered Cursor models.
* Used by the auth-aware `provider.models()` hook. Fields opencode does not get
* from the Cursor catalog are filled with safe defaults (zero cost — Cursor
* bills separately; generous context limits).
* Used by the auth-aware `provider.models()` hook. Cost and context/output
* limits are resolved per model from the shared maps in `../model-limits.js`,
* falling back to $0 / 200K context / 32K output for models absent from them.
*/
export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2> {
const out: Record<string, ModelV2> = {};
Expand All @@ -41,8 +42,11 @@ export function buildModelV2Map(items: ModelListItem[]): Record<string, ModelV2>
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 200_000, output: 32_000 },
cost: (() => {
const c = resolveCost(item.id);
return { input: c.input, output: c.output, cache: { read: c.cacheRead, write: c.cacheWrite } };
})(),
limit: { context: resolveContextLimit(item.id), output: resolveOutputLimit(item.id) },
status: "active",
options: Object.keys(params).length > 0 ? { params } : {},
headers: {},
Expand Down
51 changes: 51 additions & 0 deletions test/model-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,57 @@ describe("toOpencodeModels", () => {
});
});

describe("toOpencodeModels config-channel limits and cost", () => {
it("emits per-model limit with both context and output", () => {
const out = toOpencodeModels([
{ id: "claude-opus-4-8", displayName: "Opus 4.8" },
{ id: "gpt-5.5", displayName: "GPT-5.5" },
{ id: "grok-4.5", displayName: "Grok 4.5" },
] satisfies ModelListItem[]);
expect(out["claude-opus-4-8"]!.limit).toEqual({ context: 300_000, output: 64_000 });
expect(out["gpt-5.5"]!.limit).toEqual({ context: 272_000, output: 64_000 });
expect(out["grok-4.5"]!.limit).toEqual({ context: 256_000, output: 32_000 });
});

it("emits cost with FLAT snake_case cache keys, not nested cache object", () => {
const out = toOpencodeModels([
{ id: "claude-sonnet-4-6", displayName: "Sonnet 4.6" },
] satisfies ModelListItem[]);
expect(out["claude-sonnet-4-6"]!.cost).toEqual({
input: 3,
output: 15,
cache_read: 0.3,
cache_write: 3.75,
});
expect(out["claude-sonnet-4-6"]!.cost).not.toHaveProperty("cache");
});

it("emits $0 cost for Cursor Models pool models", () => {
const out = toOpencodeModels([
{ id: "composer-2.5", displayName: "Composer 2.5" },
] satisfies ModelListItem[]);
expect(out["composer-2.5"]!.cost).toEqual({
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
});
});

it("falls back to 200K/32K and $0 for unknown models", () => {
const out = toOpencodeModels([
{ id: "brand-new-model", displayName: "New" },
] satisfies ModelListItem[]);
expect(out["brand-new-model"]!.limit).toEqual({ context: 200_000, output: 32_000 });
expect(out["brand-new-model"]!.cost).toEqual({
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
});
});
});

describe("discoverModels without a key", () => {
it("returns the fallback snapshot with a warning when no cache exists", async () => {
const prev = process.env.CURSOR_API_KEY;
Expand Down
Loading