Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ streams the response back **untranslated**.
- Replays conversation state through content-addressed blobs, maps server tool calls back to Codex,
discovers live Cursor models through the protobuf `GetUsableModels` RPC, and retries only before a
run request is committed to the wire.
- Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`,
`cursor/auto-balance`, and `cursor/auto-intelligence` entries. Explicit levels are encoded in
`requested_model.parameters` while the legacy `cursor/auto` entry retains the account/team default.
- Cursor-native local filesystem/shell/network execution is denied by default. Explicit `mcpServers`
and `desktopExecutor` integrations have separate opt-ins; `unsafeAllowNativeLocalExec` enables the
broader built-in executor and bypasses Codex approval/sandbox semantics.
Expand Down
14 changes: 14 additions & 0 deletions docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,20 @@ Responses providers so passthrough stays byte-for-byte identical to upstream.
The Cursor bridge is experimental. After `ocx login cursor`, add or edit the `cursor` entry under
`providers` in `~/.opencodex/config.json` (Windows: `%USERPROFILE%\.opencodex\config.json`).

Cursor Router's complete optimization ladder is exposed as separate Codex model ids because Codex's
model picker cannot render Cursor-specific model parameters:

| Codex model | Cursor Router mode |
| --- | --- |
| `cursor/auto` | Team/account default (backwards compatible) |
| `cursor/auto-cost` | Cost |
| `cursor/auto-balance` | Balance |
| `cursor/auto-intelligence` | Intelligence |

The explicit variants all send Cursor's `default` model with its `optimization` model parameter, so
the selection is preserved on every request. They remain available when live model discovery omits
`default`, just like the original `cursor/auto` entry.

By default, Cursor's server-driven native local tools stay **disabled**. Codex keeps using its own
tools (`apply_patch`, `exec_command`, and so on) with approval and sandbox policy. Set
`unsafeAllowNativeLocalExec` only for trusted local experiments where you accept that Cursor may
Expand Down
43 changes: 39 additions & 4 deletions src/adapters/cursor/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,48 @@ export function isCursorModelAvailableForAccount(modelId: string, liveIds: reado
/** Codex-facing id for Cursor's auto-router. Always kept in the catalog even when live discovery omits it. */
export const CURSOR_AUTO_MODEL_ID = "auto";

/** Cursor Router's public optimization modes (cost -> intelligence Pareto frontier). */
export const CURSOR_ROUTING_LEVELS = ["cost", "balance", "intelligence"] as const;
export type CursorRoutingLevel = typeof CURSOR_ROUTING_LEVELS[number];

/**
* Codex cannot render Cursor's model-specific parameter control, so expose each optimization mode
* as a first-class routed model next to the backwards-compatible `cursor/auto` entry.
*/
export const CURSOR_ROUTER_MODEL_IDS = [
CURSOR_AUTO_MODEL_ID,
...CURSOR_ROUTING_LEVELS.map(level => `${CURSOR_AUTO_MODEL_ID}-${level}`),
] as const;

/** Wire id Cursor Connect expects for the auto-router (GetUsableModels returns `default`, not `auto`). */
export const CURSOR_AUTO_WIRE_MODEL_ID = "default";

export interface CursorWireModelSelection {
modelId: string;
routingLevel?: CursorRoutingLevel;
}

/** Resolve a Codex-facing model id into Cursor's wire model plus optional router parameter. */
export function cursorWireModelSelection(modelId: string): CursorWireModelSelection {
const normalized = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : modelId;
if (normalized === CURSOR_AUTO_MODEL_ID) return { modelId: CURSOR_AUTO_WIRE_MODEL_ID };
const prefix = `${CURSOR_AUTO_MODEL_ID}-`;
if (normalized.startsWith(prefix)) {
const level = normalized.slice(prefix.length);
if ((CURSOR_ROUTING_LEVELS as readonly string[]).includes(level)) {
return { modelId: CURSOR_AUTO_WIRE_MODEL_ID, routingLevel: level as CursorRoutingLevel };
}
}
return { modelId: normalized };
}

/** Map a Codex-facing Cursor model id to the upstream wire id. */
export function cursorCodexToWireModelId(modelId: string): string {
const normalized = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : modelId;
return normalized === CURSOR_AUTO_MODEL_ID ? CURSOR_AUTO_WIRE_MODEL_ID : normalized;
return cursorWireModelSelection(modelId).modelId;
}

export function isCursorRouterModelId(modelId: string): boolean {
return (CURSOR_ROUTER_MODEL_IDS as readonly string[]).includes(modelId);
}

/** Filter the static Cursor seed to models this account can use. */
Expand All @@ -95,7 +130,7 @@ export function filterCursorConfiguredModelsByLiveDiscovery<T extends { id: stri
liveIds: readonly string[],
): T[] {
return configured.filter(model =>
model.id === CURSOR_AUTO_MODEL_ID || isCursorModelAvailableForAccount(model.id, liveIds),
isCursorRouterModelId(model.id) || isCursorModelAvailableForAccount(model.id, liveIds),
);
}

Expand All @@ -108,7 +143,7 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
// advertise effort so Codex exposes the tier picker. `supportsReasoningEffort` tracks whether the
// model has *selectable effort tiers* (CURSOR_MODEL_EFFORT_TIERS), NOT merely whether it reasons:
// gemini/grok/kimi/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker).
{ id: "auto", contextWindow: CONTEXT_200K, supportsReasoningEffort: false },
...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })),

{ id: "claude-sonnet-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
{ id: "claude-4-sonnet", contextWindow: CONTEXT_200K },
Expand Down
15 changes: 15 additions & 0 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
McpToolResultSchema,
McpToolsSchema,
ModelDetailsSchema,
RequestedModelSchema,
RequestedModel_ModelParameterbytesSchema,
ResumeActionSchema,
RequestContextSchema,
RequestContextEnvSchema,
Expand All @@ -43,6 +45,9 @@ import {

const encoder = new TextEncoder();

/** Parameter id advertised by Cursor's `default` model for its Cost/Balance/Intelligence control. */
export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";

/** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */
function runtimeTimeZone(): string {
try {
Expand Down Expand Up @@ -364,6 +369,16 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
displayNameShort: request.modelId,
aliases: [],
}),
...(request.routingLevel ? {
requestedModel: create(RequestedModelSchema, {
modelId: request.modelId,
maxMode: false,
parameters: [create(RequestedModel_ModelParameterbytesSchema, {
id: CURSOR_ROUTING_LEVEL_PARAMETER_ID,
value: request.routingLevel,
})],
}),
} : {}),
// Mirror the client (Responses) tool definitions into the top-level AgentRunRequest.mcp_tools
// channel. Advertising them ONLY via native-exec `requestContextArgs` (RequestContext.tools) is
// insufficient: cursor models report those tools as unavailable and fall back to native tools.
Expand Down
13 changes: 8 additions & 5 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} from "../../types";
import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
import type { CursorRequestMessage, CursorRunRequest } from "./types";
import { cursorCodexToWireModelId } from "./discovery";
import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery";
import { cursorEffortSuffix } from "./effort-map";
import {
cursorMcpToolEncodedSize,
Expand Down Expand Up @@ -97,10 +97,11 @@ function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[])
* `undefined` for non-reasoning models like `composer-2.5`. A fully-qualified id (one that isn't a
* known effort base) passes through unchanged.
*/
function normalizeCursorModelId(modelId: string, reasoning?: string): string {
const id = cursorCodexToWireModelId(modelId);
function normalizeCursorModelId(modelId: string, reasoning?: string): { modelId: string; routingLevel?: CursorRoutingLevel } {
const selection = cursorWireModelSelection(modelId);
const id = selection.modelId;
const suffix = cursorEffortSuffix(id, reasoning);
return suffix ? `${id}-${suffix}` : id;
return { ...selection, modelId: suffix ? `${id}-${suffix}` : id };
}

function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined {
Expand Down Expand Up @@ -166,8 +167,10 @@ export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest
const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice);
const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
const limitNote = catalogLimitNote(budget.tools, budget.omitted);
const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning);
return {
modelId: normalizeCursorModelId(parsed.modelId, parsed.options.reasoning),
modelId: model.modelId,
...(model.routingLevel ? { routingLevel: model.routingLevel } : {}),
// The Cursor conversation id comes ONLY from remembered state (_cursorConversationId). Do NOT fall
// back to the OpenAI Responses previous_response_id (resp_*): that is a Responses-chain id in a
// different namespace and would start an unrelated Cursor conversation, breaking tool-result
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/cursor/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { OcxUsage } from "../../types";
import type { OcxMessage, OcxRequestOptions, OcxTool } from "../../types";
import type { CursorRoutingLevel } from "./discovery";

export interface CursorRunRequest {
modelId: string;
/** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */
routingLevel?: CursorRoutingLevel;
conversationId: string;
system: string[];
messages: CursorRequestMessage[];
Expand Down
10 changes: 10 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ and synthesizing explicit "no tool result was recorded" answers only when no rea
These compatibility guards are covered by focused tests and should stay close to the adapters that
need them.

## Cursor Router optimization levels

Cursor Router's parameterized `default` model is represented in Codex by four catalog rows:
`cursor/auto` preserves Cursor's team/account default, while `cursor/auto-cost`,
`cursor/auto-balance`, and `cursor/auto-intelligence` make each optimization level explicit.
All four route to the `default` Cursor wire model. Explicit variants additionally populate
`AgentRunRequest.requested_model.parameters` with the `optimization` parameter; this is the same
parameterized-model channel used by current Cursor clients. Router rows are static capabilities and
must survive a live `GetUsableModels` response that omits `default`.

## Cursor active-context usage

Cursor's `conversationCheckpointUpdate.tokenDetails.usedTokens` is treated as the authoritative
Expand Down
25 changes: 23 additions & 2 deletions tests/codex-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,12 @@ describe("Codex catalog routed normalization", () => {
});

expect(fetchCalls).toBe(0);
const cursorRouterIds = ["auto", "auto-cost", "auto-balance", "auto-intelligence"];
const routedIds = models
.filter(model => model.provider === "cursor" && cursorRouterIds.includes(model.id))
.map(model => model.id);
expect(routedIds).toHaveLength(cursorRouterIds.length);
expect(routedIds).toEqual(expect.arrayContaining(cursorRouterIds));
const auto = models.find(model => model.provider === "cursor" && model.id === "auto");
expect(auto).toMatchObject({
provider: "cursor",
Expand All @@ -1046,16 +1052,31 @@ describe("Codex catalog routed normalization", () => {
expect(entry?.input_modalities).toEqual(["text", "image"]);
expect(entry?.supported_reasoning_levels).toEqual([]);
expect(entry).not.toHaveProperty("default_reasoning_level");
for (const id of cursorRouterIds.slice(1)) {
const routedEntry = entries.find(item => item.slug === `cursor/${id}`);
expect(routedEntry?.context_window).toBe(200_000);
expect(routedEntry?.supported_reasoning_levels).toEqual([]);
}
} finally {
globalThis.fetch = originalFetch;
clearModelCache("cursor");
}
});

test("Cursor live discovery keeps auto even when GetUsableModels omits it", () => {
const configured = [{ id: "auto" }, { id: "gpt-5.4" }, { id: "claude-fable-5" }];
test("Cursor live discovery keeps all router levels when GetUsableModels omits them", () => {
const configured = [
{ id: "auto" },
{ id: "auto-cost" },
{ id: "auto-balance" },
{ id: "auto-intelligence" },
{ id: "gpt-5.4" },
{ id: "claude-fable-5" },
];
expect(filterCursorConfiguredModelsByLiveDiscovery(configured, ["gpt-5.4-high"]).map(model => model.id)).toEqual([
"auto",
"auto-cost",
"auto-balance",
"auto-intelligence",
"gpt-5.4",
]);
});
Expand Down
22 changes: 22 additions & 0 deletions tests/cursor-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe("Cursor adapter live transport", () => {
);

expect(requests[0]?.modelId).toBe("default");
expect(requests[0]?.routingLevel).toBeUndefined();
expect(writes).toEqual([]);
expect(events).toEqual([
{ type: "thinking_delta", thinking: "검토 중" },
Expand All @@ -82,6 +83,27 @@ describe("Cursor adapter live transport", () => {
]);
});

test("runTurn preserves explicit Cursor Router optimization levels", async () => {
const requests: CursorRunRequest[] = [];
const adapter = createCursorAdapter(provider, {
createTransport: () => ({
async *run(request) {
requests.push(request);
yield { type: "done" } satisfies CursorServerMessage;
},
writeClient() {},
}),
});

await adapter.runTurn?.(
{ ...parsed, modelId: "cursor/auto-intelligence" },
{ headers: new Headers() },
() => {},
);

expect(requests[0]).toMatchObject({ modelId: "default", routingLevel: "intelligence" });
});

test("runTurn sanitizes unexpected transport errors", async () => {
const adapter = createCursorAdapter(provider, {
createTransport: () => ({
Expand Down
21 changes: 20 additions & 1 deletion tests/cursor-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { create, fromBinary } from "@bufbuild/protobuf";
import { handleCursorNativeKv, storeCursorBlob } from "../src/adapters/cursor/native-exec";
import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request";
import { CURSOR_ROUTING_LEVEL_PARAMETER_ID, encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request";
import {
AgentClientMessageSchema,
ConversationStepSchema,
Expand Down Expand Up @@ -81,6 +81,25 @@ describe("Cursor blob handshake", () => {
expect(run?.mcpTools?.mcpTools[0]?.toolName).toBe("mcp__fs__read_file");
});

test("encodes Cursor Router levels through requested_model parameters", () => {
const bytes = encodeCursorRunRequest({
modelId: "default",
routingLevel: "cost",
conversationId: "c1",
system: [],
messages: [{ role: "user", content: "hi" }],
});
const msg = fromBinary(AgentClientMessageSchema, bytes);
const run = msg.message.case === "runRequest" ? msg.message.value : undefined;

expect(run?.modelDetails?.modelId).toBe("default");
expect(run?.requestedModel).toMatchObject({
modelId: "default",
maxMode: false,
parameters: [{ id: CURSOR_ROUTING_LEVEL_PARAMETER_ID, value: "cost" }],
});
});

test("adds Cursor exact-tool guidance to system prompt blobs when tools are advertised", () => {
const bytes = encodeCursorRunRequest({
modelId: "claude-4.6-sonnet",
Expand Down
24 changes: 18 additions & 6 deletions tests/cursor-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
import {
CURSOR_AUTO_WIRE_MODEL_ID,
CURSOR_DEFAULT_CONTEXT_WINDOW,
CURSOR_ROUTER_MODEL_IDS,
CURSOR_ROUTING_LEVELS,
CURSOR_STATIC_MODELS,
cursorCodexToWireModelId,
filterCursorConfiguredModelsByLiveDiscovery,
Expand All @@ -10,6 +12,7 @@ import {
cursorModelIds,
cursorModelInputModalities,
cursorModelReasoningEfforts,
cursorWireModelSelection,
inferCursorContextWindow,
normalizeCursorModels,
} from "../src/adapters/cursor/discovery";
Expand All @@ -19,7 +22,7 @@ describe("Cursor discovery metadata", () => {
const ids = cursorModelIds(CURSOR_STATIC_MODELS);

expect(ids.length).toBeGreaterThanOrEqual(38);
expect(ids).toContain("auto");
expect(ids).toEqual(expect.arrayContaining([...CURSOR_ROUTER_MODEL_IDS]));
expect(ids).toContain("claude-sonnet-5");
expect(ids).toContain("composer-2.5");
expect(ids).toContain("composer-2.5-fast");
Expand All @@ -40,7 +43,9 @@ describe("Cursor discovery metadata", () => {
expect(ids).toContain("gpt-5.5-extra");
expect(ids).not.toContain("composer-2");
// `auto` mirrors the jawcode SOT `default` entry (200k), not the generic fallback window.
expect(cursorModelContextWindows(CURSOR_STATIC_MODELS).auto).toBe(200_000);
for (const id of CURSOR_ROUTER_MODEL_IDS) {
expect(cursorModelContextWindows(CURSOR_STATIC_MODELS)[id]).toBe(200_000);
}
expect(cursorModelContextWindows(CURSOR_STATIC_MODELS)["composer-2.5-fast"]).toBe(200_000);
});

Expand Down Expand Up @@ -68,17 +73,24 @@ describe("Cursor discovery metadata", () => {
expect(filtered.map(model => model.id)).toEqual(["gpt-5.4"]);
});

test("live discovery filter always keeps auto even when GetUsableModels omits it", () => {
test("live discovery filter always keeps all router levels when GetUsableModels omits them", () => {
const filtered = filterCursorConfiguredModelsByLiveDiscovery(
[{ id: "auto" }, { id: "gpt-5.4" }, { id: "claude-fable-5" }],
[...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id })), { id: "gpt-5.4" }, { id: "claude-fable-5" }],
["gpt-5.4-high"],
);
expect(filtered.map(model => model.id)).toEqual(["auto", "gpt-5.4"]);
expect(filtered.map(model => model.id)).toEqual([...CURSOR_ROUTER_MODEL_IDS, "gpt-5.4"]);
});

test("auto maps to default on the Cursor wire", () => {
test("auto and every explicit routing level map to default on the Cursor wire", () => {
expect(cursorCodexToWireModelId("auto")).toBe(CURSOR_AUTO_WIRE_MODEL_ID);
expect(cursorCodexToWireModelId("cursor/auto")).toBe(CURSOR_AUTO_WIRE_MODEL_ID);
for (const level of CURSOR_ROUTING_LEVELS) {
expect(cursorWireModelSelection(`auto-${level}`)).toEqual({
modelId: CURSOR_AUTO_WIRE_MODEL_ID,
routingLevel: level,
});
expect(cursorCodexToWireModelId(`cursor/auto-${level}`)).toBe(CURSOR_AUTO_WIRE_MODEL_ID);
}
expect(cursorCodexToWireModelId("gpt-5.4")).toBe("gpt-5.4");
});

Expand Down