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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ const MODEL_COST_TABLE: Record<string, ModelCost> = {
"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
Expand Down
26 changes: 22 additions & 4 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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,
};
}

Expand Down
55 changes: 54 additions & 1 deletion test/fixtures/fake-cursor-server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -74,6 +75,8 @@ export interface FakeCursorServer {
setScenario: (scenario: CursorScenario) => void;
assertClean: () => void;
close: () => Promise<void>;
/** 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;
Expand Down Expand Up @@ -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.
Expand All @@ -187,6 +227,7 @@ export async function createFakeCursorServer(options: { deadlineMs?: number } =
const streams = new Set<http2.ServerHttp2Stream>();
const childPids = new Set<number>();
const bridgeEntries = new Set<string>();
const runRequestModelIds: string[] = [];
let defaultScenario: CursorScenario = "clean-end-stream";
let toolPauseCount = 0;

Expand All @@ -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;
Expand Down Expand Up @@ -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); },
Expand Down
180 changes: 180 additions & 0 deletions test/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
KvServerMessageSchema,
ModelDetailsSchema,
TextDeltaUpdateSchema,
ThinkingDetailsSchema,
TurnEndedUpdateSchema,
} from "../src/proto/agent_pb";
import {
Expand Down Expand Up @@ -188,6 +189,12 @@ async function createTestCursorBackend(): Promise<TestCursorBackend> {
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, {}) }
: {}),
}),
),
}),
Expand Down Expand Up @@ -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<string, any>)["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;
Expand Down Expand Up @@ -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");
Expand Down