diff --git a/README.md b/README.md index 1003269..0191637 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,21 @@ This opens Cursor OAuth in the browser. Tokens are stored in Start OpenCode and select any Cursor model. The plugin starts a local OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API. +## Models + +Grok 4.6 is supported. The plugin registers it as `grok-4.6` in the fallback +model catalog, and live discovery returns whatever models Cursor serves. + +- Default context: 256K tokens (Cursor-facing default) +- Reasoning: supported — Cursor lists Grok 4.6 with Agent + Thinking +- Short-context pricing: $2 input / $6 output per million tokens + +The plugin proxies Cursor's Connect API, so model metadata mirrors Cursor's +published surface. The xAI direct API advertises a 500K window and accepts +image input; neither applies to this plugin, which is text-in / text-out for +every model. Image and vision input are never forwarded, so Grok 4.6 is +text-only here. + ## How it works 1. OAuth — browser-based login to Cursor via PKCE. diff --git a/src/index.ts b/src/index.ts index 368500b..f87b098 100644 --- a/src/index.ts +++ b/src/index.ts @@ -399,6 +399,7 @@ const MODEL_COST_TABLE: Record = { "gpt-5.4-nano": { input: 0.2, output: 1.25, cache: { read: 0.02, write: 0 } }, // xAI + "grok-4.6": { input: 2, output: 6, cache: { read: 0.5, write: 0 } }, "grok-4.20": { input: 2, output: 6, cache: { read: 0.2, write: 0 } }, // Moonshot diff --git a/src/models.ts b/src/models.ts index 6926693..7b779c0 100644 --- a/src/models.ts +++ b/src/models.ts @@ -59,9 +59,20 @@ const FALLBACK_MODELS: CursorModel[] = [ { id: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark", reasoning: true, contextWindow: 128_000, maxTokens: 128_000 }, // Other models { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", reasoning: true, contextWindow: 1_000_000, maxTokens: 64_000 }, + { id: "grok-4.6", name: "Grok 4.6", reasoning: true, contextWindow: 256_000, maxTokens: 64_000 }, { id: "grok-code-fast-1", name: "Grok Code Fast 1", reasoning: false, contextWindow: 128_000, maxTokens: 64_000 }, ]; +/** + * Known catalog metadata keyed by id. The GetUsableModels protobuf carries + * no limit fields, so live-discovered models reuse these context/max-token + * (and, when discovery omits them, display/reasoning) values while keeping + * discovery fields such as displayName and thinkingDetails authoritative. + */ +const FALLBACK_MODEL_BY_ID = new Map( + FALLBACK_MODELS.map((model) => [model.id, model] as const), +); + /** * Pseudo-model for Cursor's server-side Auto routing. Always exposed * alongside discovered models; the proxy maps it to Run modelId "default". @@ -184,12 +195,19 @@ function normalizeSingleModel(model: unknown): CursorModel | null { const id = details.modelId.trim(); if (!id) return null; + const known = FALLBACK_MODEL_BY_ID.get(id); + const hasThinkingDetails = details.thinkingDetails !== undefined; + return { id, - name: pickDisplayName(details, id), - reasoning: Boolean(details.thinkingDetails), - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, + name: pickDisplayName(details, known?.name ?? id), + // Discovery fields win when present; known fallback metadata fills the + // gaps left by the limit-less GetUsableModels protobuf. + reasoning: hasThinkingDetails + ? Boolean(details.thinkingDetails) + : (known?.reasoning ?? false), + contextWindow: known?.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: known?.maxTokens ?? DEFAULT_MAX_TOKENS, }; } diff --git a/test/fixtures/fake-cursor-server.ts b/test/fixtures/fake-cursor-server.ts index 962aa68..d6fdcc5 100644 --- a/test/fixtures/fake-cursor-server.ts +++ b/test/fixtures/fake-cursor-server.ts @@ -1,7 +1,8 @@ import http2 from "node:http2"; import type { AddressInfo } from "node:net"; -import { create, toBinary } from "@bufbuild/protobuf"; +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { + AgentClientMessageSchema, AgentServerMessageSchema, ConversationStateStructureSchema, ExecServerMessageSchema, @@ -74,6 +75,8 @@ export interface FakeCursorServer { setScenario: (scenario: CursorScenario) => void; assertClean: () => void; close: () => Promise; + /** Model ids observed on every Run request, in arrival order. */ + getRunRequestModelIds: () => string[]; /** Test-only resource accounting hooks for proving cleanup assertions fire. */ trackChild: (pid: number) => void; untrackChild: (pid: number) => void; @@ -176,6 +179,43 @@ function isScenario(value: string): value is CursorScenario { return (CURSOR_SCENARIOS as readonly string[]).includes(value); } +/** + * Extract the model id from every Run request frame in a Connect-framed + * client payload. The proxy sends each Run request as a framed + * AgentClientMessage; heartbeats and other client messages are skipped. + */ +function decodeRunRequestModelIds(bytes: Buffer): string[] { + const ids: string[] = []; + let offset = 0; + while (offset + 5 <= bytes.length) { + const flags = bytes[offset]!; + const view = new DataView( + bytes.buffer, + bytes.byteOffset + offset, + bytes.byteLength - offset, + ); + const messageLength = view.getUint32(1, false); + const frameEnd = offset + 5 + messageLength; + if (frameEnd > bytes.length) break; + if ((flags & 0b0000_0010) === 0) { + const payload = bytes.subarray(offset + 5, frameEnd); + if (payload.length > 0) { + try { + const decoded = fromBinary(AgentClientMessageSchema, payload); + if (decoded.message.case === "runRequest") { + const modelId = + decoded.message.value.requestedModel?.modelId ?? + decoded.message.value.modelDetails?.modelId; + if (modelId) ids.push(modelId); + } + } catch {} + } + } + offset = frameEnd; + } + return ids; +} + /** * A local Connect-over-H2 server with bounded failure sequences. The request header * x-fake-cursor-scenario selects a scenario; setScenario supplies the default. @@ -187,6 +227,7 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = const streams = new Set(); const childPids = new Set(); const bridgeEntries = new Set(); + const runRequestModelIds: string[] = []; let defaultScenario: CursorScenario = "clean-end-stream"; let toolPauseCount = 0; @@ -207,6 +248,17 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = return; } + // Record which model id the proxy routed to Cursor on every Run request. + const requestChunks: Buffer[] = []; + stream.on("data", (chunk) => requestChunks.push(Buffer.from(chunk))); + stream.once("close", () => { + if (requestChunks.length > 0) { + runRequestModelIds.push( + ...decodeRunRequestModelIds(Buffer.concat(requestChunks)), + ); + } + }); + const requested = String(headers["x-fake-cursor-scenario"] ?? defaultScenario); const scenario = isScenario(requested) ? requested : defaultScenario; let completed = false; @@ -379,6 +431,7 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } = return { apiUrl: `http://127.0.0.1:${port}`, setScenario(scenario) { defaultScenario = scenario; }, + getRunRequestModelIds() { return [...runRequestModelIds]; }, trackChild(pid) { childPids.add(pid); }, untrackChild(pid) { childPids.delete(pid); }, trackBridgeEntry(key) { bridgeEntries.add(key); }, diff --git a/test/smoke.ts b/test/smoke.ts index 48afb39..68658fa 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -14,6 +14,7 @@ import { KvServerMessageSchema, ModelDetailsSchema, TextDeltaUpdateSchema, + ThinkingDetailsSchema, TurnEndedUpdateSchema, } from "../src/proto/agent_pb"; import { @@ -188,6 +189,12 @@ async function createTestCursorBackend(): Promise { displayName: model.name, displayNameShort: model.name, aliases: [], + // The protobuf only expresses thinking support by + // presence, so a truthy reasoning flag emits a + // thinkingDetails object; false/absent leaves it out. + ...(model.reasoning + ? { thinkingDetails: create(ThinkingDetailsSchema, {}) } + : {}), }), ), }), @@ -1207,6 +1214,178 @@ async function testDiscoveryFallbackAndSuccess( console.log("[test] Discovery fallback and success OK"); } +async function testGrok46CatalogAndRouting( + modules: TestModules, + backend: TestCursorBackend, +) { + console.log("[test] Testing Grok 4.6 catalog exposure, cost, and routing..."); + + // Fallback discovery exposes grok-4.6 with the Cursor-facing metadata and + // leaves grok-code-fast-1 untouched. + modules.clearModelCache(); + backend.setDiscoveryMode("empty"); + const fallback = await modules.getCursorModels("test-token"); + const grok46 = fallback.find((model) => model.id === "grok-4.6"); + assert(grok46, "Fallback discovery must expose grok-4.6"); + assertEqual(grok46.name, "Grok 4.6", "grok-4.6 fallback display name"); + assertEqual(grok46.reasoning, true, "grok-4.6 fallback must be reasoning-enabled"); + assertEqual(grok46.contextWindow, 256_000, "grok-4.6 fallback must use the Cursor-facing 256K context"); + assertEqual(grok46.maxTokens, 64_000, "grok-4.6 fallback max tokens"); + const grokCodeFast = fallback.find((model) => model.id === "grok-code-fast-1"); + assert(grokCodeFast, "Fallback discovery must keep grok-code-fast-1"); + assertEqual(grokCodeFast.reasoning, false, "grok-code-fast-1 must stay non-reasoning"); + assertEqual(grokCodeFast.contextWindow, 128_000, "grok-code-fast-1 must keep its 128K context"); + + // Live discovery normalizes known models to their fallback limits: the + // GetUsableModels protobuf carries no limit fields, so a live grok-4.6 must + // still report the Cursor-facing 256K/64K with reasoning true, while the + // discovery display name stays authoritative. Unknown models keep generic + // defaults. + modules.clearModelCache(); + backend.setDiscoveryMode("success"); + backend.setDiscoveredModels([ + { id: "grok-4.6", name: "Grok 4.6" }, + { id: "unknown-live-model", name: "Unknown Live Model" }, + ]); + const live = await modules.getCursorModels("test-token"); + const liveGrok46 = live.find((model) => model.id === "grok-4.6"); + assert(liveGrok46, "Live discovery must expose grok-4.6"); + assertEqual(liveGrok46.name, "Grok 4.6", "Live grok-4.6 must use the discovery display name"); + assertEqual(liveGrok46.reasoning, true, "Live grok-4.6 must reuse fallback reasoning when discovery omits thinking details"); + assertEqual(liveGrok46.contextWindow, 256_000, "Live grok-4.6 must normalize to the Cursor-facing 256K context"); + assertEqual(liveGrok46.maxTokens, 64_000, "Live grok-4.6 must normalize to 64K max tokens"); + const unknownLive = live.find((model) => model.id === "unknown-live-model"); + assert(unknownLive, "Live discovery must expose the unknown model"); + assertEqual(unknownLive.name, "Unknown Live Model", "Unknown live model keeps its discovery display name"); + assertEqual(unknownLive.reasoning, false, "Unknown live model keeps the generic non-reasoning default"); + assertEqual(unknownLive.contextWindow, 200_000, "Unknown live model keeps the generic 200K context default"); + assertEqual(unknownLive.maxTokens, 64_000, "Unknown live model keeps the generic 64K max-tokens default"); + + // Discovery fields stay authoritative where present: thinkingDetails emitted + // by Cursor overrides the fallback reasoning flag, while known limits still + // normalize from fallback metadata. + modules.clearModelCache(); + backend.setDiscoveredModels([ + { id: "grok-code-fast-1", name: "Grok Code Fast 1", reasoning: true }, + ]); + const authoritative = await modules.getCursorModels("test-token"); + const authoritativeGcf = authoritative.find((model) => model.id === "grok-code-fast-1"); + assert(authoritativeGcf, "Live discovery must expose grok-code-fast-1"); + assertEqual(authoritativeGcf.reasoning, true, "Discovery thinkingDetails must override fallback reasoning"); + assertEqual(authoritativeGcf.contextWindow, 128_000, "Known limits must still normalize from fallback metadata"); + assertEqual(authoritativeGcf.maxTokens, 64_000, "Known max tokens must still normalize from fallback metadata"); + + // The v2 catalog hook (auth.loader -> provider.models) emits a complete + // Grok 4.6 entry: limits, reasoning flag, explicit cost, text-only caps. + const authState = { + type: "oauth" as const, + access: makeJwt(Math.floor(Date.now() / 1000) + 3600), + refresh: "valid-refresh", + expires: Date.now() + 3_600_000, + }; + const hooks = await modules.CursorAuthPlugin({ + client: { auth: { set: async () => {} } }, + } as any); + const provider = { models: {} } as any; + modules.clearModelCache(); + backend.setDiscoveryMode("empty"); + const catalogConfig = await hooks.auth!.loader(async () => authState, provider); + assert(catalogConfig.baseURL, "Catalog hook must start the proxy"); + const catalogEntry = (provider.models as Record)["grok-4.6"]; + assert(catalogEntry, "Catalog hook must emit a grok-4.6 entry"); + assertEqual(catalogEntry.name, "Grok 4.6", "Catalog entry display name"); + assertEqual(catalogEntry.capabilities.reasoning, true, "Catalog entry must advertise reasoning"); + assertEqual(catalogEntry.limit.context, 256_000, "Catalog entry context limit"); + assertEqual(catalogEntry.limit.output, 64_000, "Catalog entry output limit"); + assertEqual(catalogEntry.cost.input, 2, "grok-4.6 input cost must be $2/M, not the generic grok pattern"); + assertEqual(catalogEntry.cost.output, 6, "grok-4.6 output cost must be $6/M, not the generic grok pattern"); + assertEqual(catalogEntry.cost.cache.read, 0.5, "grok-4.6 cached-read cost must be $0.50/M"); + assertEqual(catalogEntry.cost.cache.write, 0, "grok-4.6 cache-write cost must be $0/M"); + assertEqual(catalogEntry.capabilities.attachment, false, "grok-4.6 must stay text-only for attachments"); + assertEqual(catalogEntry.capabilities.input.image, false, "grok-4.6 must stay text-only for images"); + + // The config hook (opencode >= 1.18 config schema) emits the same entry. + modules.clearModelCache(); + backend.setDiscoveryMode("empty"); + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + cursor: { + type: "oauth", + refresh: "valid-refresh", + access: makeJwt(Math.floor(Date.now() / 1000) + 3600), + expires: Date.now() + 3_600_000, + }, + }); + try { + const cfg: any = { provider: {} }; + await hooks.config!(cfg); + const configEntry = cfg.provider?.cursor?.models?.["grok-4.6"]; + assert(configEntry, "Config hook must emit a grok-4.6 entry"); + assertEqual(configEntry.name, "Grok 4.6", "Config entry display name"); + assertEqual(configEntry.reasoning, true, "Config entry must advertise reasoning"); + assertEqual(configEntry.limit.context, 256_000, "Config entry context limit"); + assertEqual(configEntry.limit.output, 64_000, "Config entry output limit"); + assertEqual(configEntry.cost.input, 2, "Config entry input cost must be $2/M"); + assertEqual(configEntry.cost.output, 6, "Config entry output cost must be $6/M"); + assertEqual(configEntry.cost.cache_read, 0.5, "Config entry cached-read cost must be $0.50/M"); + assertEqual(configEntry.cost.cache_write, 0, "Config entry cache-write cost must be $0/M"); + } finally { + delete process.env.OPENCODE_AUTH_CONTENT; + } + modules.stopProxy(); + + // Both request paths accept grok-4.6 and route the id to Cursor's Run API + // through the model-agnostic proxy, with no model-specific branch. The child + // process loads the proxy with the fixture as its Cursor endpoint. + const fixture = await createFakeCursorServer({ deadlineMs: 1_000 }); + try { + fixture.setScenario("clean-end-stream"); + const routing = await runScenarioSubprocess(fixture.apiUrl, ` + const { startProxy, stopProxy } = await import("./src/proxy.ts"); + const port = await startProxy(async () => "test-token"); + const streamRes = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-4.6", stream: true, messages: [{ role: "user", content: "route grok-4.6 through streaming" }] }), + }); + const streamBody = await streamRes.text(); + const nonStreamRes = await fetch("http://localhost:" + port + "/v1/chat/completions", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "grok-4.6", stream: false, messages: [{ role: "user", content: "route grok-4.6 through non-streaming" }] }), + }); + const nonStreamBody = await nonStreamRes.json(); + stopProxy(); + console.log(JSON.stringify({ + streamStatus: streamRes.status, + streamDone: streamBody.includes("data: [DONE]"), + streamEcho: streamBody.includes('"model":"grok-4.6"'), + nonStreamStatus: nonStreamRes.status, + nonStreamModel: nonStreamBody.model, + })); + process.exit(0); + `); + assertEqual(routing.streamStatus, 200, "Streaming path must accept grok-4.6"); + assertEqual(routing.streamDone, true, "Streaming path must complete for grok-4.6"); + assertEqual(routing.streamEcho, true, "Streaming chunks must echo the grok-4.6 model id"); + assertEqual(routing.nonStreamStatus, 200, "Non-streaming path must accept grok-4.6"); + assertEqual(routing.nonStreamModel, "grok-4.6", "Non-streaming response must echo the grok-4.6 model id"); + + const deadline = Date.now() + 500; + while ( + Date.now() < deadline && + fixture.getRunRequestModelIds().filter((id) => id === "grok-4.6").length < 2 + ) { + await Bun.sleep(10); + } + assertEqual( + fixture.getRunRequestModelIds().filter((id) => id === "grok-4.6").length, + 2, + "Both streaming and non-streaming requests must route grok-4.6 to Cursor's Run API", + ); + } finally { + await fixture.close(); + } + console.log("[test] Grok 4.6 catalog exposure, cost, and routing OK"); +} + async function main() { const backend = await createTestCursorBackend(); process.env.CURSOR_API_URL = backend.apiUrl; @@ -1235,6 +1414,7 @@ async function main() { await testLifecycleDiagnostics(modules); await testExpiredTokenRefreshBeforeDiscovery(modules, backend); await testDiscoveryFallbackAndSuccess(modules, backend); + await testGrok46CatalogAndRouting(modules, backend); await Bun.sleep(0); assert(!unhandledRejection, `Unhandled rejection escaped the smoke harness: ${String(unhandledRejection)}`); console.log("\nāœ“ All smoke tests passed");