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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,23 @@ const client = new OpenAI({
| POST | `/v1/responses` | Responses text + `function_call`; supports semantic SSE streaming |
| POST | `/v1/messages` | Anthropic Messages + `tool_use`; supports `stream: true` |

### Reasoning effort

OpenAI clients can select a Cursor reasoning variant without hard-coding its
full Cursor model ID:

- Chat Completions: `reasoning_effort`
- Responses: `reasoning.effort`

For example, model `gpt-5.6-sol` with effort `high` resolves to
`gpt-5.6-sol-high` when that ID is present in the live `agent --list-models`
catalog. Explicit variants can be retargeted, and `-fast` is preserved
(`gpt-5.6-sol-high-fast` plus `low` resolves to
`gpt-5.6-sol-low-fast`). Supported spellings are `none`/`off`, `minimal`,
`low`, `medium`, `high`, `xhigh`/`extra-high`, and `max`. The API returns
`400 unsupported_reasoning_effort` instead of silently choosing another model
when the requested family does not offer that level.

Usage and token fields: responses may include `usage` token fields (`prompt_tokens`/`completion_tokens` for Chat Completions, `input_tokens`/`output_tokens` for Responses). These are heuristic estimates (character count ÷ 4), not Cursor billing meters. Do not use them for invoicing.

## Environment variables
Expand Down
29 changes: 23 additions & 6 deletions src/lib/handlers/chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import {
import type { ToolTurnEvent, ToolTurnResult } from "../acp-tool-session.js";
import { createStreamParser } from "../cli-stream-parser.js";
import { json, writeSseHeaders } from "../http.js";
import { resolveModelForExecution } from "../model-map.js";
import {
resolveModelForExecution,
UnsupportedReasoningEffortError,
} from "../model-map.js";
import {
buildPromptFromMessages,
normalizeModelId,
Expand Down Expand Up @@ -263,11 +266,25 @@ export async function handleChatCompletions(
const requested = normalizeModelId(body.model);
const model = resolveModel(requested, lastRequestedModelRef, config);
const models = await getCachedCursorModels(config, modelCacheRef);
const decision = resolveModelForExecution({
requested: model,
defaultModel: config.defaultModel,
availableCursorIds: models.map((m) => m.id),
});
let decision;
try {
decision = resolveModelForExecution({
requested: model,
defaultModel: config.defaultModel,
availableCursorIds: models.map((m) => m.id),
reasoningEffort: body.reasoning_effort,
});
} catch (error) {
if (!(error instanceof UnsupportedReasoningEffortError)) throw error;
json(res, 400, {
error: {
message: error.message,
code: error.code,
type: "invalid_request_error",
},
});
return;
}
const cursorModel = decision.final;
rememberResolvedModel(cursorModel, lastRequestedModelRef);
logModelResolution(config.verbose, decision);
Expand Down
32 changes: 26 additions & 6 deletions src/lib/handlers/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
} from "../agent-runner.js";
import type { ToolTurnEvent, ToolTurnResult } from "../acp-tool-session.js";
import { createStreamParser } from "../cli-stream-parser.js";
import { resolveModelForExecution } from "../model-map.js";
import {
resolveModelForExecution,
UnsupportedReasoningEffortError,
} from "../model-map.js";
import {
buildPromptFromMessages,
normalizeModelId,
Expand Down Expand Up @@ -510,11 +513,28 @@ export async function handleResponses(
const requested = normalizeModelId(body.model);
const model = resolveModel(requested, lastRequestedModelRef, config);
const models = await getCachedCursorModels(config, modelCacheRef);
const decision = resolveModelForExecution({
requested: model,
defaultModel: config.defaultModel,
availableCursorIds: models.map((m) => m.id),
});
let decision;
try {
decision = resolveModelForExecution({
requested: model,
defaultModel: config.defaultModel,
availableCursorIds: models.map((m) => m.id),
reasoningEffort:
typeof body.reasoning?.effort === "string"
? body.reasoning.effort
: undefined,
});
} catch (error) {
if (!(error instanceof UnsupportedReasoningEffortError)) throw error;
json(res, 400, {
error: {
message: error.message,
code: error.code,
type: "invalid_request_error",
},
});
return;
}
const cursorModel = decision.final;
rememberResolvedModel(cursorModel, lastRequestedModelRef);
logModelResolution(config.verbose, decision);
Expand Down
75 changes: 74 additions & 1 deletion src/lib/model-map.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";

import { resolveModelForExecution, resolveToCursorModel } from "./model-map.js";
import {
resolveModelForExecution,
resolveToCursorModel,
UnsupportedReasoningEffortError,
} from "./model-map.js";

describe("resolveToCursorModel", () => {
it("maps dated sonnet id to cursor sonnet-4.5", () => {
Expand Down Expand Up @@ -48,4 +52,73 @@ describe("resolveModelForExecution", () => {
expect(decision.final).toBe("default");
expect(decision.requestedWasDefault).toBe(true);
});

it("maps a logical model and reasoning effort to a Cursor variant", () => {
const decision = resolveModelForExecution({
requested: "gpt-5.6-sol",
reasoningEffort: "high",
defaultModel: "auto",
availableCursorIds: ["auto", "gpt-5.6-sol-low", "gpt-5.6-sol-high"],
});
expect(decision.final).toBe("gpt-5.6-sol-high");
expect(decision.reasoningEffort).toBe("high");
expect(decision.fallbackUsed).toBe(false);
});

it("replaces an explicit effort while preserving the fast variant", () => {
const decision = resolveModelForExecution({
requested: "gpt-5.6-sol-high-fast",
reasoningEffort: "low",
defaultModel: "auto",
availableCursorIds: [
"auto",
"gpt-5.6-sol-high-fast",
"gpt-5.6-sol-low-fast",
],
});
expect(decision.final).toBe("gpt-5.6-sol-low-fast");
});

it("maps off to Cursor's none suffix", () => {
const decision = resolveModelForExecution({
requested: "gpt-5.6-sol",
reasoningEffort: "off",
defaultModel: "auto",
availableCursorIds: ["auto", "gpt-5.6-sol-none"],
});
expect(decision.final).toBe("gpt-5.6-sol-none");
expect(decision.reasoningEffort).toBe("none");
});

it("accepts extra-high when the catalog spells it xhigh", () => {
const decision = resolveModelForExecution({
requested: "gpt-5.6-sol",
reasoningEffort: "extra-high",
defaultModel: "auto",
availableCursorIds: ["auto", "gpt-5.6-sol-xhigh"],
});
expect(decision.final).toBe("gpt-5.6-sol-xhigh");
});

it("rejects a reasoning effort unavailable for the requested family", () => {
expect(() =>
resolveModelForExecution({
requested: "composer-2.5",
reasoningEffort: "high",
defaultModel: "auto",
availableCursorIds: ["auto", "composer-2.5"],
}),
).toThrow(UnsupportedReasoningEffortError);
});

it("rejects an unknown reasoning effort", () => {
expect(() =>
resolveModelForExecution({
requested: "gpt-5.6-sol",
reasoningEffort: "ultra",
defaultModel: "auto",
availableCursorIds: ["auto", "gpt-5.6-sol-high"],
}),
).toThrow(/ultra/);
});
});
118 changes: 118 additions & 0 deletions src/lib/model-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,34 @@ export type ModelResolutionDecision = {
requested?: string;
mapped?: string;
final: string;
reasoningEffort?: CursorReasoningEffort;
requestedWasDefault: boolean;
validated: boolean;
fallbackUsed: boolean;
fallbackReason?: string;
};

export type CursorReasoningEffort =
| "none"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "max";

export class UnsupportedReasoningEffortError extends Error {
readonly code = "unsupported_reasoning_effort";

constructor(
readonly model: string,
readonly effort: string,
) {
super(`Cursor model "${model}" does not offer reasoning effort "${effort}"`);
this.name = "UnsupportedReasoningEffortError";
}
}

/** Anthropic-style model name (any case) -> Cursor CLI model id */
const ANTHROPIC_TO_CURSOR: Record<string, string> = {
// Claude 4.6
Expand Down Expand Up @@ -93,16 +115,112 @@ function matchAvailableModel(
return byLower.get(candidate.toLowerCase());
}

const EFFORT_ALIASES: Record<string, CursorReasoningEffort> = {
off: "none",
none: "none",
minimal: "minimal",
low: "low",
medium: "medium",
high: "high",
xhigh: "xhigh",
"extra-high": "xhigh",
extra_high: "xhigh",
max: "max",
};

const EFFORT_SUFFIXES = [
"extra-high",
"minimal",
"medium",
"xhigh",
"high",
"none",
"low",
"max",
] as const;

function normalizeReasoningEffort(
effort: string | undefined,
): CursorReasoningEffort | undefined {
if (!effort?.trim()) return undefined;
return EFFORT_ALIASES[effort.trim().toLowerCase()];
}

function splitFastSuffix(model: string): { base: string; fast: boolean } {
return model.toLowerCase().endsWith("-fast")
? { base: model.slice(0, -5), fast: true }
: { base: model, fast: false };
}

function stripEffortSuffix(model: string): string {
const lower = model.toLowerCase();
const suffix = EFFORT_SUFFIXES.find((value) =>
lower.endsWith(`-${value}`),
);
return suffix ? model.slice(0, -(suffix.length + 1)) : model;
}

function effortSuffixCandidates(effort: CursorReasoningEffort): string[] {
if (effort === "xhigh") return ["xhigh", "extra-high"];
return [effort];
}

function resolveReasoningModel(args: {
model: string;
effort: CursorReasoningEffort;
availableCursorIds: string[];
}): string | undefined {
if (args.model === "default" || args.model === "auto") return undefined;

const { base: withoutFast, fast } = splitFastSuffix(args.model);
const base = stripEffortSuffix(withoutFast);
for (const suffix of effortSuffixCandidates(args.effort)) {
const candidate = `${base}-${suffix}${fast ? "-fast" : ""}`;
const matched = matchAvailableModel(candidate, args.availableCursorIds);
if (matched) return matched;
}
return undefined;
}

export function resolveModelForExecution(args: {
requested: string | undefined;
defaultModel: string;
availableCursorIds: string[];
reasoningEffort?: string;
}): ModelResolutionDecision {
const requested = args.requested?.trim();
const requestedWasDefault = requested === "default";
const mapped = requestedWasDefault
? "default"
: resolveToCursorModel(requested) ?? args.defaultModel;
const reasoningEffort = normalizeReasoningEffort(args.reasoningEffort);

if (args.reasoningEffort && !reasoningEffort) {
throw new UnsupportedReasoningEffortError(
mapped,
args.reasoningEffort,
);
}

if (reasoningEffort) {
const reasoningModel = resolveReasoningModel({
model: mapped,
effort: reasoningEffort,
availableCursorIds: args.availableCursorIds,
});
if (!reasoningModel) {
throw new UnsupportedReasoningEffortError(mapped, reasoningEffort);
}
return {
requested,
mapped,
final: reasoningModel,
reasoningEffort,
requestedWasDefault,
validated: true,
fallbackUsed: false,
};
}

if (mapped === "default") {
return {
Expand Down
1 change: 1 addition & 0 deletions src/lib/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type OpenAiChatCompletionRequest = {
parallel_tool_calls?: boolean;
functions?: any[];
function_call?: any;
reasoning_effort?: string;
};

export type OpenAiResponsesRequest = {
Expand Down
Loading