diff --git a/src/model-discovery.ts b/src/model-discovery.ts index f741c3c..ebb0b0e 100644 --- a/src/model-discovery.ts +++ b/src/model-discovery.ts @@ -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"; @@ -105,8 +107,59 @@ export interface OpencodeModelConfigEntry { * Cursor's server-side `fast` default. See {@link defaultModelParams}. */ options: { params?: Record }; + /** + * 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[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 = Exclude extends never ? true : never; +const _costKeyGuard: _KeysAccepted< + OpencodeModelConfigEntry["cost"], + NonNullable +> = true; +void _costKeyGuard; +const _limitKeyGuard: _KeysAccepted< + OpencodeModelConfigEntry["limit"], + NonNullable +> = 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 @@ -116,6 +169,7 @@ export function toOpencodeModels(items: ModelListItem[]): Record = {}; 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, @@ -125,6 +179,16 @@ export function toOpencodeModels(items: ModelListItem[]): Record 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; diff --git a/src/model-limits.ts b/src/model-limits.ts new file mode 100644 index 0000000..3d4f6ba --- /dev/null +++ b/src/model-limits.ts @@ -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 = { + "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 = { + "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 = { + "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; +} diff --git a/src/plugin/model-v2.ts b/src/plugin/model-v2.ts index 4d15fed..753937e 100644 --- a/src/plugin/model-v2.ts +++ b/src/plugin/model-v2.ts @@ -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"; @@ -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 { const out: Record = {}; @@ -41,8 +42,11 @@ export function buildModelV2Map(items: ModelListItem[]): Record 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: {}, diff --git a/test/model-discovery.test.ts b/test/model-discovery.test.ts index 12b0081..b8669bf 100644 --- a/test/model-discovery.test.ts +++ b/test/model-discovery.test.ts @@ -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; diff --git a/test/model-v2.test.ts b/test/model-v2.test.ts index 16f01ad..d1db3f6 100644 --- a/test/model-v2.test.ts +++ b/test/model-v2.test.ts @@ -19,4 +19,117 @@ describe("buildModelV2Map", () => { const map = buildModelV2Map([{ id: "plain", displayName: "Plain" }]); expect(map["plain"]!.options).toEqual({}); }); + + it("sets context limit from per-model map for known models", () => { + const map = buildModelV2Map([ + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + { id: "claude-opus-4-8", displayName: "Claude Opus 4.8" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "grok-4.5", displayName: "Grok 4.5" }, + ]); + expect(map["claude-sonnet-4-6"]!.limit.context).toBe(200_000); + expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000); + expect(map["gpt-5.5"]!.limit.context).toBe(272_000); + expect(map["grok-4.5"]!.limit.context).toBe(256_000); + }); + + it("falls back to 200K context for unknown models", () => { + const map = buildModelV2Map([{ id: "some-unknown-model", displayName: "Unknown" }]); + expect(map["some-unknown-model"]!.limit.context).toBe(200_000); + }); + + it("uses longest prefix match for context limit", () => { + const map = buildModelV2Map([ + { id: "claude-opus-4-5", displayName: "Opus 4.5" }, + { id: "claude-opus-4-8", displayName: "Opus 4.8" }, + ]); + expect(map["claude-opus-4-5"]!.limit.context).toBe(200_000); + expect(map["claude-opus-4-8"]!.limit.context).toBe(300_000); + }); + + it("sets cost from per-model map for known models", () => { + const map = buildModelV2Map([ + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + { id: "claude-opus-4-8", displayName: "Claude Opus 4.8" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "claude-fable-5", displayName: "Claude Fable 5" }, + ]); + expect(map["claude-sonnet-4-6"]!.cost).toEqual({ + input: 3, + output: 15, + cache: { read: 0.3, write: 3.75 }, + }); + expect(map["claude-opus-4-8"]!.cost).toEqual({ + input: 5, + output: 25, + cache: { read: 0.5, write: 6.25 }, + }); + expect(map["gpt-5.5"]!.cost).toEqual({ + input: 5, + output: 30, + cache: { read: 0.5, write: 0 }, + }); + expect(map["claude-fable-5"]!.cost).toEqual({ + input: 10, + output: 50, + cache: { read: 1, write: 12.5 }, + }); + }); + + it("sets output limit from per-model map for known models", () => { + const map = buildModelV2Map([ + { id: "claude-opus-5", displayName: "Claude Opus 5" }, + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + { id: "gpt-5.5", displayName: "GPT-5.5" }, + { id: "composer-2.5", displayName: "Composer 2.5" }, + ]); + expect(map["claude-opus-5"]!.limit.output).toBe(64_000); + expect(map["claude-sonnet-4-6"]!.limit.output).toBe(32_000); + expect(map["gpt-5.5"]!.limit.output).toBe(64_000); + expect(map["composer-2.5"]!.limit.output).toBe(32_000); + }); + + it("uses $0 cost for Cursor Models pool models", () => { + const map = buildModelV2Map([ + { id: "composer-2.5", displayName: "Composer 2.5" }, + { id: "grok-4.5", displayName: "Grok 4.5" }, + { id: "auto-smart", displayName: "Auto Smart" }, + ]); + expect(map["composer-2.5"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + expect(map["grok-4.5"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + expect(map["auto-smart"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + }); + + it("falls back to $0 cost for unknown models", () => { + const map = buildModelV2Map([{ id: "some-unknown-model", displayName: "Unknown" }]); + expect(map["some-unknown-model"]!.cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }); + }); + + it("uses longest prefix match for cost", () => { + // gpt-5.4 (2.50) vs gpt-5.4-mini (0.75) must pick the longer prefix + const map = buildModelV2Map([ + { id: "gpt-5.4", displayName: "GPT-5.4" }, + { id: "gpt-5.4-mini", displayName: "GPT-5.4 Mini" }, + { id: "gpt-5.4-nano", displayName: "GPT-5.4 Nano" }, + ]); + expect(map["gpt-5.4"]!.cost.input).toBe(2.5); + expect(map["gpt-5.4-mini"]!.cost.input).toBe(0.75); + expect(map["gpt-5.4-nano"]!.cost.input).toBe(0.2); + }); });