diff --git a/README.md b/README.md index caeaafd..f06f2c8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/lib/handlers/chat-completions.ts b/src/lib/handlers/chat-completions.ts index 15e9128..7ee79d0 100644 --- a/src/lib/handlers/chat-completions.ts +++ b/src/lib/handlers/chat-completions.ts @@ -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, @@ -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); diff --git a/src/lib/handlers/responses.ts b/src/lib/handlers/responses.ts index c0411c6..209e3fb 100644 --- a/src/lib/handlers/responses.ts +++ b/src/lib/handlers/responses.ts @@ -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, @@ -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); diff --git a/src/lib/model-map.test.ts b/src/lib/model-map.test.ts index b89aede..45f9c6e 100644 --- a/src/lib/model-map.test.ts +++ b/src/lib/model-map.test.ts @@ -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", () => { @@ -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/); + }); }); diff --git a/src/lib/model-map.ts b/src/lib/model-map.ts index 93d9e2e..bbe210e 100644 --- a/src/lib/model-map.ts +++ b/src/lib/model-map.ts @@ -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 = { // Claude 4.6 @@ -93,16 +115,112 @@ function matchAvailableModel( return byLower.get(candidate.toLowerCase()); } +const EFFORT_ALIASES: Record = { + 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 { diff --git a/src/lib/openai.ts b/src/lib/openai.ts index 903b51b..94eeec9 100644 --- a/src/lib/openai.ts +++ b/src/lib/openai.ts @@ -9,6 +9,7 @@ export type OpenAiChatCompletionRequest = { parallel_tool_calls?: boolean; functions?: any[]; function_call?: any; + reasoning_effort?: string; }; export type OpenAiResponsesRequest = { diff --git a/src/lib/server.test.ts b/src/lib/server.test.ts index 1796db4..11f814f 100644 --- a/src/lib/server.test.ts +++ b/src/lib/server.test.ts @@ -11,6 +11,8 @@ vi.mock("./cursor-cli.js", () => ({ listCursorCliModels: vi.fn().mockResolvedValue([ { id: "claude-3-opus", name: "Claude 3 Opus" }, { id: "claude-3-sonnet", name: "Claude 3 Sonnet" }, + { id: "gpt-5.6-sol-low", name: "GPT-5.6 Sol Low" }, + { id: "gpt-5.6-sol-high", name: "GPT-5.6 Sol High" }, ]), })); @@ -160,7 +162,7 @@ describe("startBridgeServer", () => { expect(status).toBe(200); const data = JSON.parse(body); expect(data.object).toBe("list"); - expect(data.data).toHaveLength(2); + expect(data.data).toHaveLength(4); expect(data.data[0].id).toBe("claude-3-opus"); }); @@ -369,6 +371,79 @@ describe("startBridgeServer", () => { expect(data.usage.output_tokens).toBeGreaterThan(0); }); + it("maps Chat Completions reasoning_effort to a Cursor model variant", async () => { + const runMock = vi.mocked(run); + runMock.mockClear(); + servers = startBridgeServer({ + version: "1.0.0", + config: createTestConfig(), + }); + await new Promise((resolve) => + servers[0].on("listening", () => resolve()), + ); + + const { status } = await fetchServer(servers[0], "/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.6-sol", + reasoning_effort: "high", + messages: [{ role: "user", content: "Hi" }], + }), + }); + expect(status).toBe(200); + const [, args] = runMock.mock.calls[0]; + expect(args).toContain("gpt-5.6-sol-high"); + }); + + it("maps Responses reasoning.effort to a Cursor model variant", async () => { + const runMock = vi.mocked(run); + runMock.mockClear(); + servers = startBridgeServer({ + version: "1.0.0", + config: createTestConfig(), + }); + await new Promise((resolve) => + servers[0].on("listening", () => resolve()), + ); + + const { status } = await fetchServer(servers[0], "/v1/responses", { + method: "POST", + body: JSON.stringify({ + model: "gpt-5.6-sol", + reasoning: { effort: "low" }, + input: "Hi", + }), + }); + expect(status).toBe(200); + const [, args] = runMock.mock.calls[0]; + expect(args).toContain("gpt-5.6-sol-low"); + }); + + it("rejects reasoning effort unavailable for the model family", async () => { + servers = startBridgeServer({ + version: "1.0.0", + config: createTestConfig(), + }); + await new Promise((resolve) => + servers[0].on("listening", () => resolve()), + ); + + const { status, body } = await fetchServer( + servers[0], + "/v1/chat/completions", + { + method: "POST", + body: JSON.stringify({ + model: "claude-3-opus", + reasoning_effort: "max", + messages: [{ role: "user", content: "Hi" }], + }), + }, + ); + expect(status).toBe(400); + expect(JSON.parse(body).error.code).toBe("unsupported_reasoning_effort"); + }); + it("streams OpenAI Responses semantic SSE events", async () => { servers = startBridgeServer({ version: "1.0.0",