diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index fe06d80d..5023aa45 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -1,8 +1,11 @@ import { BaseExecutor } from "./base.js"; -import { PROVIDERS } from "../config/providers.js"; +import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { generateCursorBody, + encodeField, + wrapConnectRPCFrame, + decodeMessage, parseConnectRPCFrame, extractTextFromResponse } from "../utils/cursorProtobuf.js"; @@ -13,6 +16,7 @@ import { chatChunkSse } from "../utils/sse.js"; import { FORMATS } from "../translator/formats.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import zlib from "zlib"; +import crypto from "crypto"; // Detect cloud environment const isCloudEnv = () => { @@ -38,6 +42,130 @@ const COMPRESS_FLAG = { GZIP_TRAILER: 0x03 }; +const AGENT_RUN_PATH = "/agent.v1.AgentService/Run"; +const PROTOBUF_LEN = 2; +const PROTOBUF_VARINT = 0; + +function concatBuffers(...parts) { + const length = parts.reduce((total, part) => total + part.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +const agentString = (field, value) => encodeField(field, PROTOBUF_LEN, value); +const agentMessage = (field, value) => encodeField(field, PROTOBUF_LEN, value); +const agentBool = (field, value) => encodeField(field, PROTOBUF_VARINT, value ? 1 : 0); + +function textFromContent(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n"); +} + +function isAgentTextRequest(body) { + // Many compatible clients always attach their built-in tool schemas, even + // for a normal text turn. Cursor's retired ChatService rejects those + // requests; AgentService can still answer the text turn, so ignore schemas + // here. A real tool-call/result conversation is kept on the legacy path + // until its AgentService tool protocol is implemented. + return Array.isArray(body?.messages) && body.messages.every((message) => { + if (message?.tool_calls?.length || message?.role === "tool") return false; + return typeof message?.content === "string" + || Array.isArray(message?.content) && message.content.every((part) => part?.type === "text"); + }); +} + +function encodeHistoryMessage(message) { + const content = textFromContent(message?.content); + if (!content) return null; + + // ConversationHistoryMessage.user / .assistant -> repeated content -> text. + const text = agentString(1, content); + if (message.role === "assistant") { + return agentMessage(2, agentMessage(1, agentMessage(1, text))); + } + return agentMessage(1, agentMessage(1, agentMessage(1, text))); +} + +function buildAgentRunFrame(messages, model) { + const system = messages + .filter((message) => message?.role === "system") + .map((message) => textFromContent(message.content)) + .filter(Boolean) + .join("\n\n"); + const chatMessages = messages.filter((message) => message?.role !== "system"); + const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user"); + const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1); + const history = chatMessages + .slice(0, currentIndex >= 0 ? currentIndex : -1) + .map(encodeHistoryMessage) + .filter(Boolean); + const userText = textFromContent(current?.content) || "Continue."; + + // agent.v1.UserMessageAction.user_message and its optional history. + const userMessage = concatBuffers( + agentString(1, userText), + agentString(2, crypto.randomUUID()), + ); + const conversationHistory = history.length + ? concatBuffers(...history.map((entry) => agentMessage(1, entry))) + : null; + const userAction = concatBuffers( + agentMessage(1, userMessage), + ...(conversationHistory ? [agentMessage(7, conversationHistory)] : []), + ); + const conversationAction = agentMessage(1, userAction); + const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true)); + const runRequest = concatBuffers( + // An empty ConversationStateStructure starts a fresh local agent session. + agentMessage(1, new Uint8Array()), + agentMessage(2, conversationAction), + ...(system ? [agentString(8, system)] : []), + agentMessage(9, requestedModel), + ); + + // agent.v1.AgentClientMessage.run_request. + return wrapConnectRPCFrame(agentMessage(1, runRequest)); +} + +function extractAgentString(message, field) { + const value = message?.get(field)?.[0]?.value; + return value ? Buffer.from(value).toString("utf8") : ""; +} + +function decodeAgentFrames(buffer, onFrame) { + let pending = Buffer.from(buffer || []); + while (pending.length >= 5) { + const flags = pending[0]; + const length = pending.readUInt32BE(1); + if (pending.length < 5 + length) break; + let payload = pending.subarray(5, 5 + length); + pending = pending.subarray(5 + length); + if (flags & COMPRESS_FLAG.GZIP) { + payload = zlib.gunzipSync(payload); + } + if (!(flags & COMPRESS_FLAG.TRAILER)) onFrame(payload); + } + return pending; +} + +function createRequestContextResponse() { + // AgentService asks every run for client context. 9router has no IDE file + // context, so acknowledge with an empty RequestContext. + const requestContextSuccess = agentMessage(1, new Uint8Array()); + const requestContextResult = agentMessage(1, requestContextSuccess); + const execClientMessage = agentMessage(10, requestContextResult); + return wrapConnectRPCFrame(agentMessage(2, execClientMessage)); +} + const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; const debugLog = (...args) => { if (CURSOR_STREAM_DEBUG) console.log(...args); @@ -253,7 +381,293 @@ export class CursorExecutor extends BaseExecutor { }); } + /** + * AgentService (agent.api5.cursor.sh) is HTTP/2-only. Node's fetch/undici speaks + * HTTP/1.1 and fails with HTTPParserError on the h2 preface — use http2 duplex. + */ + openAgentHttp2Stream(url, headers, signal) { + if (!http2) { + throw new Error("HTTP/2 is required for Cursor AgentService (endpoint is h2-only)"); + } + + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunkQueue = []; + let waiting = null; + let ended = false; + let streamError = null; + let req = null; + + const wake = (result) => { + if (!waiting) return; + const resolve = waiting; + waiting = null; + resolve(result); + }; + + const fail = (error) => { + if (streamError) return; + streamError = error; + ended = true; + wake(null); + }; + + const close = () => { + try { req?.destroy(); } catch {} + try { client.close(); } catch {} + }; + + client.on("error", fail); + + req = client.request({ + ":method": "POST", + ":path": urlObj.pathname, + ":authority": urlObj.host, + ":scheme": "https", + ...headers, + }); + + req.on("error", fail); + req.on("data", (chunk) => { + if (waiting) wake({ value: chunk, done: false }); + else chunkQueue.push(chunk); + }); + req.on("end", () => { + ended = true; + wake({ value: undefined, done: true }); + }); + + if (signal) { + const onAbort = () => { + fail(new Error("Request aborted")); + close(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + const responseHeaders = new Promise((resolve, reject) => { + const onEarlyError = (error) => reject(error); + client.once("error", onEarlyError); + req.once("error", onEarlyError); + req.once("response", (hdrs) => { + client.off("error", onEarlyError); + req.off("error", onEarlyError); + resolve(hdrs); + }); + }); + + return { + responseHeaders, + write(frame) { + if (req && !req.destroyed) req.write(Buffer.from(frame)); + }, + end() { + try { if (req && !req.destroyed) req.end(); } catch {} + }, + close, + async read() { + if (chunkQueue.length) return { value: chunkQueue.shift(), done: false }; + if (ended) { + if (streamError) throw streamError; + return { value: undefined, done: true }; + } + const result = await new Promise((resolve) => { waiting = resolve; }); + if (streamError) throw streamError; + return result || { value: undefined, done: true }; + }, + }; + } + + async executeAgent({ model, body, stream, credentials, signal }) { + const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint; + if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured"); + + const url = `${agentEndpoint}${AGENT_RUN_PATH}`; + const headers = this.buildHeaders(credentials); + const requestController = new AbortController(); + if (signal?.addEventListener) { + signal.addEventListener("abort", () => requestController.abort(signal.reason), { once: true }); + } + + let session; + try { + session = this.openAgentHttp2Stream(url, headers, requestController.signal); + session.write(buildAgentRunFrame(body.messages || [], model)); + } catch (error) { + throw new Error(`Cursor AgentService request failed: ${error.message}`); + } + + let responseHeaders; + try { + responseHeaders = await session.responseHeaders; + } catch (error) { + session.close(); + throw new Error(`Cursor AgentService request failed: ${error.message}`); + } + + const status = Number(responseHeaders[":status"] || 0); + if (status !== 200) { + let errorText = ""; + try { + while (true) { + const { done, value } = await session.read(); + if (done) break; + errorText += Buffer.from(value).toString("utf8"); + } + } catch {} + session.close(); + return { + response: new Response(JSON.stringify({ + error: { message: `Cursor AgentService ${status}: ${errorText || "request failed"}`, type: "api_error" }, + }), { status: status || HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + + // The Claude SSE translator derives Anthropic's message ID by stripping + // `chatcmpl-`. Keep the remaining ID in Anthropic's required `msg_` form + // so strict clients such as Claude Code accept the completed stream. + const responseId = `chatcmpl-msg_${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let pending = Buffer.alloc(0); + let finished = false; + + const consume = async (onEvent) => { + try { + while (!finished) { + const { done, value } = await session.read(); + if (done) break; + pending = Buffer.concat([pending, Buffer.from(value)]); + pending = decodeAgentFrames(pending, (payload) => { + const serverMessage = decodeMessage(payload); + + // agent.v1.AgentServerMessage.interaction_update + if (serverMessage.has(1)) { + const update = decodeMessage(serverMessage.get(1)[0].value); + if (update.has(1)) { + const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1); + if (textDelta) onEvent({ type: "text", value: textDelta }); + } + // Cursor's AgentService emits internal reasoning without the + // cryptographic signature required by Anthropic thinking blocks. + // Forwarding it makes strict Anthropic clients (Claude Code) + // discard or wait on an otherwise complete response. Keep the + // reasoning upstream-only and emit the normal answer text. + if (update.has(14)) { + finished = true; + onEvent({ type: "done" }); + } + } + + // AgentService requests IDE context before producing a response. + // Return an empty context; 9router is not coupled to an editor. + if (serverMessage.has(2)) { + const execRequest = decodeMessage(serverMessage.get(2)[0].value); + if (execRequest.has(10)) { + session.write(createRequestContextResponse()); + } else { + finished = true; + onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" }); + onEvent({ type: "done" }); + } + } + }); + } + } finally { + try { session.end(); } catch {} + try { session.close(); } catch {} + if (!finished) onEvent({ type: "done" }); + } + }; + + if (stream === false) { + let content = ""; + let reasoning = ""; + let agentError = null; + await consume((event) => { + if (event.type === "text") content += event.value; + else if (event.type === "thinking") reasoning += event.value; + else if (event.type === "error") agentError = event.value; + }); + if (agentError) { + return { + response: new Response(JSON.stringify({ error: { message: agentError, type: "api_error" } }), { + status: HTTP_STATUS.BAD_REQUEST, + headers: { "Content-Type": "application/json" }, + }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + return { + response: new Response(JSON.stringify({ + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }], + usage: estimateUsage(body, content.length, FORMATS.OPENAI), + }), { headers: { "Content-Type": "application/json" } }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + + const encoder = new TextEncoder(); + const responseStream = new ReadableStream({ + start(controller) { + consume((event) => { + if (event.type === "text") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } }))); + } else if (event.type === "thinking") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } }))); + } else if (event.type === "error") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } }))); + } else if (event.type === "done") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" }))); + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); + } + }).catch((error) => controller.error(error)); + }, + cancel() { + requestController.abort(); + }, + }); + + return { + response: new Response(responseStream, { headers: SSE_HEADERS }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + if (isAgentTextRequest(body)) { + try { + return await this.executeAgent({ model, body, stream, credentials, signal }); + } catch (error) { + return { + response: new Response(JSON.stringify({ + error: { message: error.message, type: "connection_error", code: "" }, + }), { status: HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }), + url: `${PROVIDER_OAUTH.cursor?.agentEndpoint || ""}${AGENT_RUN_PATH}`, + headers: {}, + transformedBody: body, + }; + } + } + const url = this.buildUrl(); const headers = this.buildHeaders(credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 47190acf..4e3475f4 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -291,12 +291,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Execute request let providerResponse, providerUrl, providerHeaders, finalBody; + // Most executors return their registry format. Cursor AgentService is an + // exception: it is decoded by the executor into OpenAI-compatible output. + let providerResponseFormat = targetFormat; try { const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); providerResponse = result.response; providerUrl = result.url; providerHeaders = result.headers; finalBody = result.transformedBody; + providerResponseFormat = result.responseFormat || targetFormat; reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { trackPendingRequest(model, provider, connectionId, false, true); @@ -335,7 +339,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } try { const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); - if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; } + if (retryResult.response.ok) { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerResponseFormat = retryResult.responseFormat || targetFormat; + } } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); } } else { log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); @@ -382,14 +390,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // True non-streaming response if (!stream) { - const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog }); + const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, trackDone, appendLog }); streamController.handleComplete(); return result; } // Streaming response const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); - return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); } export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { diff --git a/open-sse/providers/registry/cursor.js b/open-sse/providers/registry/cursor.js index ca0ecdb1..b8529373 100644 --- a/open-sse/providers/registry/cursor.js +++ b/open-sse/providers/registry/cursor.js @@ -23,7 +23,7 @@ export default { "Content-Type": "application/connect+proto", "User-Agent": "connect-es/1.6.1", }, - clientVersion: "3.1.0", + clientVersion: "3.12.17", }, models: [ { id: "default", name: "Auto (Server Picks)" }, @@ -44,11 +44,11 @@ export default { oauth: { apiEndpoint: "https://api2.cursor.sh", chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", - modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData", + modelsEndpoint: "/agent.v1.AgentService/GetUsableModels", api3Endpoint: "https://api3.cursor.sh", agentEndpoint: "https://agent.api5.cursor.sh", agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", - clientVersion: "3.1.0", + clientVersion: "3.12.17", clientType: "ide", dbKeys: { accessToken: "cursorAuth/accessToken", diff --git a/open-sse/services/cursorModels.js b/open-sse/services/cursorModels.js new file mode 100644 index 00000000..93e4330b --- /dev/null +++ b/open-sse/services/cursorModels.js @@ -0,0 +1,187 @@ +/** + * Cursor live model catalog fetcher. + * + * Cursor exposes the account-specific model picker through the AgentService + * `GetUsableModels` Connect RPC. Unlike the static provider registry, this + * includes models newly enabled for the account and omits unavailable ones. + */ + +import crypto from "crypto"; +import http2 from "http2"; +import { PROVIDER_OAUTH } from "../providers/index.js"; +import { buildCursorHeaders } from "../utils/cursorChecksum.js"; +import { decodeMessage } from "../utils/cursorProtobuf.js"; + +const FETCH_TIMEOUT_MS = 10_000; +const CACHE_TTL_MS = 5 * 60 * 1000; + +// agent.v1.ModelDetails protobuf field numbers. +const MODEL_ID_FIELD = 1; +const DISPLAY_MODEL_ID_FIELD = 3; +const DISPLAY_NAME_FIELD = 4; +const DISPLAY_NAME_SHORT_FIELD = 5; +const RESPONSE_MODELS_FIELD = 1; + +/** @type {Map} */ +const catalogCache = new Map(); + +function getCursorModelsUrl() { + const config = PROVIDER_OAUTH.cursor; + if (!config?.agentEndpoint || !config?.modelsEndpoint) return null; + return `${config.agentEndpoint.replace(/\/$/, "")}${config.modelsEndpoint}`; +} + +function cacheKey(credentials) { + const seed = [ + credentials?.providerSpecificData?.machineId, + credentials?.accessToken, + ].filter(Boolean).join(":"); + if (!seed) return "cursor-anonymous"; + return crypto.createHash("sha256").update(`cursor:${seed}`).digest("hex"); +} + +function firstString(fields, fieldNumber) { + const value = fields.get(fieldNumber)?.[0]?.value; + if (!value || typeof value === "number") return ""; + return Buffer.from(value).toString("utf8"); +} + +/** + * Decode Cursor's `agent.v1.GetUsableModelsResponse` protobuf payload. + * The response contains repeated `agent.v1.ModelDetails` messages in field 1. + */ +export function parseCursorUsableModels(payload) { + const response = decodeMessage(payload); + const seen = new Set(); + const models = []; + + for (const entry of response.get(RESPONSE_MODELS_FIELD) || []) { + if (!entry?.value || typeof entry.value === "number") continue; + const detail = decodeMessage(entry.value); + const id = firstString(detail, MODEL_ID_FIELD).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + const name = ( + firstString(detail, DISPLAY_NAME_FIELD) + || firstString(detail, DISPLAY_NAME_SHORT_FIELD) + || firstString(detail, DISPLAY_MODEL_ID_FIELD) + || id + ).trim(); + models.push({ id, name }); + } + + return models; +} + +/** + * agent.api5.cursor.sh is HTTP/2-only; Node fetch/undici cannot speak h2. + * Unary GetUsableModels uses an unframed protobuf body (application/proto). + */ +function http2PostProto(url, headers, body, signal, timeoutMs) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunks = []; + let responseHeaders = {}; + let settled = false; + + const finish = (fn) => (...args) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + try { client.close(); } catch {} + fn(...args); + }; + + const timeoutId = setTimeout(finish(() => { + reject(new Error("Cursor GetUsableModels timed out")); + }), timeoutMs); + + client.on("error", finish(reject)); + + const req = client.request({ + ":method": "POST", + ":path": urlObj.pathname, + ":authority": urlObj.host, + ":scheme": "https", + ...headers, + }); + + req.on("response", (hdrs) => { responseHeaders = hdrs; }); + req.on("data", (chunk) => { chunks.push(chunk); }); + req.on("end", finish(() => { + resolve({ + status: Number(responseHeaders[":status"] || 0), + body: Buffer.concat(chunks), + }); + })); + req.on("error", finish(reject)); + + if (signal) { + const onAbort = finish(() => reject(new Error("Request aborted"))); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + req.end(body && body.length ? Buffer.from(body) : undefined); + }); +} + +async function fetchCursorCatalog(credentials, signal) { + const accessToken = credentials?.accessToken; + const machineId = credentials?.providerSpecificData?.machineId; + const url = getCursorModelsUrl(); + if (!accessToken || !machineId || !url) return null; + + const headers = { + ...buildCursorHeaders(accessToken, machineId, credentials?.providerSpecificData?.ghostMode !== false), + // Connect unary calls use an unframed protobuf body, unlike Cursor chat's + // streaming `application/connect+proto` endpoint. + accept: "application/proto", + "content-type": "application/proto", + }; + delete headers["connect-accept-encoding"]; + delete headers["connect-protocol-version"]; + + const response = await http2PostProto(url, headers, new Uint8Array(), signal, FETCH_TIMEOUT_MS); + if (response.status !== 200) { + const error = new Error(`Cursor GetUsableModels returned ${response.status}`); + error.status = response.status; + throw error; + } + + return parseCursorUsableModels(new Uint8Array(response.body)); +} + +/** + * Resolve the live Cursor catalog for the authenticated account. + * Returns null on any failure so callers can fall back to static models. + */ +export async function resolveCursorModels(credentials, options = {}) { + if (!credentials?.accessToken || !credentials?.providerSpecificData?.machineId) { + options.log?.debug?.("CURSOR_MODELS", "No Cursor access token or machine ID; skipping live fetch"); + return null; + } + + const key = cacheKey(credentials); + const now = Date.now(); + if (!options.forceRefresh) { + const cached = catalogCache.get(key); + if (cached?.expiresAt > now) return { models: cached.models }; + } + + try { + const models = await fetchCursorCatalog(credentials, options.signal); + if (!models?.length) return null; + catalogCache.set(key, { expiresAt: now + CACHE_TTL_MS, models }); + return { models }; + } catch (error) { + options.log?.warn?.("CURSOR_MODELS", `Live model fetch failed: ${error?.message || error}`); + return null; + } +} + +export function clearCursorModelCache() { + catalogCache.clear(); +} diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.js index 961df7a1..81bac255 100644 --- a/open-sse/utils/cursorChecksum.js +++ b/open-sse/utils/cursorChecksum.js @@ -128,7 +128,8 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": clientKey, "x-cursor-checksum": checksum, - "x-cursor-client-version": "3.1.0", + "x-cursor-client-version": "3.12.17", + "x-cursor-client-commit": "0fb762053c34788bb7760d5673f8a6d4c8589d50", "x-cursor-client-type": "ide", "x-cursor-client-os": os, "x-cursor-client-arch": arch, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 310d7ff4..72c1423a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -67,6 +67,7 @@ export default function ProviderDetailPage() { const [thinkingMode, setThinkingMode] = useState("auto"); const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [suggestedModels, setSuggestedModels] = useState([]); + const [liveModels, setLiveModels] = useState([]); const [kiloFreeModels, setKiloFreeModels] = useState([]); const [disabledModelIds, setDisabledModelIds] = useState([]); const [confirmState, setConfirmState] = useState(null); @@ -142,7 +143,10 @@ export default function ProviderDetailPage() { const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth"); const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey"); const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; - const models = getModelsByProviderId(providerId); + const staticModels = getModelsByProviderId(providerId); + const models = providerId === "cursor" && liveModels.length > 0 + ? liveModels + : staticModels; const providerAlias = getProviderAlias(providerId); const isOpenAICompatible = isOpenAICompatibleProvider(providerId); @@ -453,6 +457,34 @@ export default function ProviderDetailPage() { fetchDisabledModels(); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); + // Cursor's model availability is account-specific and changes frequently. + // Load the active account's live catalog for the dashboard; the static + // registry remains the fallback while the request is pending or unavailable. + useEffect(() => { + if (providerId !== "cursor") { + setLiveModels([]); + return; + } + + const connection = connections.find((item) => item.isActive !== false); + if (!connection?.id) { + setLiveModels([]); + return; + } + + let cancelled = false; + fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }) + .then(async (res) => ({ ok: res.ok, data: await res.json() })) + .then(({ ok, data }) => { + if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) { + setLiveModels(data.models); + } + }) + .catch(() => {}); + + return () => { cancelled = true; }; + }, [providerId, connections]); + // Fetch suggested models from provider's public API (if configured) useEffect(() => { const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher; diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 106130f2..592cd704 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -10,6 +10,7 @@ import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; +import { resolveCursorModels } from "open-sse/services/cursorModels.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -292,6 +293,19 @@ const PROVIDER_MODELS_CONFIG = { }; } }, + cursor: { + customResolver: async (connection) => { + const result = await resolveCursorModels({ + accessToken: connection.accessToken, + providerSpecificData: connection.providerSpecificData || {}, + }, { forceRefresh: true, log: console }); + if (result?.models?.length) return { models: result.models }; + return { + models: getStaticProviderModels("cursor"), + warning: "Cursor returned no live models; falling back to static catalog.", + }; + }, + }, // Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows) kiro: { diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 6a05da37..e07745e3 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -13,6 +13,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; +import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; @@ -97,6 +98,13 @@ const LIVE_MODEL_RESOLVERS = { }); return result?.models?.length ? { models: result.models } : null; }, + cursor: async (conn) => { + const result = await resolveCursorModels({ + accessToken: conn.accessToken, + providerSpecificData: conn.providerSpecificData || {}, + }, { log: console }); + return result?.models?.length ? { models: result.models } : null; + } }; const parseOpenAIStyleModels = (data) => { diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 5708807b..3419140b 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -48,6 +48,48 @@ export default function ModelSelectModal({ const [providerNodes, setProviderNodes] = useState([]); const [customModels, setCustomModels] = useState([]); const [disabledModels, setDisabledModels] = useState({}); + const [cursorModels, setCursorModels] = useState([]); + + // Cursor exposes the usable catalog per account. Keep the static catalog only + // as a fallback, since it quickly becomes stale and different accounts can + // have different model entitlements. + const cursorConnectionIds = useMemo( + () => activeProviders + .filter((provider) => provider.provider === "cursor" && provider.id) + .map((provider) => provider.id), + [activeProviders], + ); + + useEffect(() => { + if (!isOpen || cursorConnectionIds.length === 0) { + setCursorModels([]); + return undefined; + } + + let cancelled = false; + Promise.all(cursorConnectionIds.map(async (connectionId) => { + const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data.models) ? data.models : []; + })) + .then((modelLists) => { + if (cancelled) return; + const seen = new Set(); + setCursorModels(modelLists.flat().filter((model) => { + if (!model?.id || seen.has(model.id)) return false; + seen.add(model.id); + return true; + })); + }) + .catch((error) => { + // Do not hide the static fallback when the account catalog is unavailable. + console.warn("Unable to load Cursor models for selector:", error); + if (!cancelled) setCursorModels([]); + }); + + return () => { cancelled = true; }; + }, [isOpen, cursorConnectionIds]); const fetchCombos = async () => { try { @@ -280,7 +322,9 @@ export default function ModelSelectModal({ hasModels: mergedModels.length > 0, }; } else { - const hardcodedModels = getModelsByProviderId(providerId); + const hardcodedModels = providerId === "cursor" && cursorModels.length > 0 + ? cursorModels + : getModelsByProviderId(providerId); const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); // Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider @@ -349,7 +393,7 @@ export default function ModelSelectModal({ }); return groups; - }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]); + }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]); // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) const filteredCombos = useMemo(() => { diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 0c11eda0..232c5d59 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -1,6 +1,6 @@ { "alicode-intl": { - "baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", + "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", "headers": {}, "quirks": { "preserveCacheControl": true @@ -231,7 +231,7 @@ "Content-Type": "application/connect+proto", "User-Agent": "connect-es/1.6.1" }, - "clientVersion": "3.1.0" + "clientVersion": "3.12.17" }, "deepgram": { "baseUrl": "https://api.deepgram.com/v1/listen", diff --git a/tests/unit/cursor-agent-proto.test.js b/tests/unit/cursor-agent-proto.test.js new file mode 100644 index 00000000..2d571aba --- /dev/null +++ b/tests/unit/cursor-agent-proto.test.js @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { + decodeMessage, + encodeField, + encodeAgentValue, + decodeAgentValue, + encodeMcpToolDefinition, + encodeMcpTools, + decodeMcpArgs, + encodeMcpResultSuccess, + encodeMcpResultError, + encodeMcpResultToolNotFound, +} from "../../open-sse/utils/cursorProtobuf.js"; +import { + isAgentCapableRequest, + buildAgentRunFrame, +} from "../../open-sse/executors/cursor.js"; + +// AgentService (agent.v1) codec tests — validate the production implementation +// in cursorProtobuf.js + the executor's frame builders. Pure round-trip, no network. +// Field numbers verified against Cursor's agent.proto (extracted via @oh-my-pi). + +const LEN = 2; +// McpArgs.args map entry { field1: key, field2: Value } +const entry = (k, v) => Buffer.concat([ + Buffer.from(encodeField(2, LEN, + Buffer.concat([Buffer.from(encodeField(1, LEN, k)), Buffer.from(encodeField(2, LEN, encodeAgentValue(v)))]) + )), +]); + +describe("Cursor AgentService codec (cursorProtobuf.js)", () => { + describe("google.protobuf.Value round-trip", () => { + const cases = [ + ["null", null], + ["bool true", true], + ["bool false", false], + ["string", "hello"], + ["integer", 42], + ["float", 3.14], + ["empty object", {}], + ["flat object", { a: 1, b: "x", c: true }], + ["nested object", { outer: { inner: [1, 2, "three"] } }], + ["array of mixed", [1, "two", false, null]], + ["deeply nested", { a: { b: { c: { d: 1 } } } }], + ]; + for (const [label, value] of cases) { + it(`encodes/decodes ${label}`, () => { + expect(decodeAgentValue(encodeAgentValue(value))).toEqual(value); + }); + } + }); + + describe("McpToolDefinition", () => { + it("encodes name, description, input_schema (Value), provider, tool_name", () => { + const schema = { type: "object", properties: { city: { type: "string" } }, required: ["city"] }; + const def = encodeMcpToolDefinition({ function: { name: "get_weather", description: "Get weather", parameters: schema } }); + const msg = decodeMessage(def); + expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("get_weather"); + expect(Buffer.from(msg.get(2)[0].value).toString("utf8")).toBe("Get weather"); + expect(Buffer.from(msg.get(4)[0].value).toString("utf8")).toBe("9router"); + expect(Buffer.from(msg.get(5)[0].value).toString("utf8")).toBe("get_weather"); + expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema); + }); + + it("preserves nested JSON-schema types", () => { + const schema = { + type: "object", + properties: { + query: { type: "string", description: "search query" }, + opts: { type: "array", items: { type: "string" } }, + }, + required: ["query"], + }; + const def = encodeMcpToolDefinition({ function: { name: "search", parameters: schema } }); + const msg = decodeMessage(def); + expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema); + }); + + it("accepts flat tool shape (no .function wrapper)", () => { + const def = encodeMcpToolDefinition({ name: "noop", description: "d", inputSchema: { type: "object" } }); + const msg = decodeMessage(def); + expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("noop"); + }); + }); + + describe("encodeMcpTools", () => { + it("produces empty bytes for no tools", () => { + expect(encodeMcpTools([]).length).toBe(0); + expect(encodeMcpTools().length).toBe(0); + }); + + it("wraps multiple tool defs as repeated field 1", () => { + const tools = [ + { function: { name: "get_weather", parameters: { type: "object" } } }, + { function: { name: "calculate", parameters: { type: "object" } } }, + ]; + const mcpTools = encodeMcpTools(tools); + const inner = decodeMessage(mcpTools); + expect(inner.get(1).length).toBe(2); + }); + }); + + describe("McpArgs decode", () => { + it("decodes name, toolName, toolCallId, and typed args map", () => { + const argsBytes = Buffer.concat([ + entry("city", "Hanoi"), + entry("count", 5), + entry("flag", true), + entry("nested", { a: [1, 2] }), + ]); + const mcpArgs = Buffer.concat([ + Buffer.from(encodeField(1, LEN, "get_weather")), + argsBytes, + Buffer.from(encodeField(3, LEN, "call_abc")), + Buffer.from(encodeField(5, LEN, "get_weather")), + ]); + const decoded = decodeMcpArgs(mcpArgs); + expect(decoded.name).toBe("get_weather"); + expect(decoded.toolName).toBe("get_weather"); + expect(decoded.toolCallId).toBe("call_abc"); + expect(decoded.args).toEqual({ city: "Hanoi", count: 5, flag: true, nested: { a: [1, 2] } }); + }); + + it("handles empty args map", () => { + const mcpArgs = Buffer.concat([ + Buffer.from(encodeField(1, LEN, "noop")), + Buffer.from(encodeField(5, LEN, "noop")), + ]); + expect(decodeMcpArgs(mcpArgs).args).toEqual({}); + }); + }); + + describe("McpResult success", () => { + it("builds success with single text content", () => { + const bytes = encodeMcpResultSuccess({ textItems: ['{"temp":32}'], isError: false }); + const msg = decodeMessage(bytes); // McpResult level + expect(msg.has(1)).toBe(true); // success variant + const success = decodeMessage(msg.get(1)[0].value); + expect(success.get(1).length).toBe(1); + expect(success.get(2)[0].value).toBe(0); // is_error=false + const item = decodeMessage(success.get(1)[0].value); + const textContent = decodeMessage(item.get(1)[0].value); + expect(Buffer.from(textContent.get(1)[0].value).toString("utf8")).toBe('{"temp":32}'); + }); + + it("builds success with multiple text items", () => { + const bytes = encodeMcpResultSuccess({ textItems: ["line1", "line2"] }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + expect(success.get(1).length).toBe(2); + }); + + it("marks is_error=true", () => { + const bytes = encodeMcpResultSuccess({ textItems: ["fail"], isError: true }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + expect(success.get(2)[0].value).toBe(1); + }); + }); + + describe("McpResult image content", () => { + it("builds image item with raw bytes + mime type", () => { + const imgBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const bytes = encodeMcpResultSuccess({ imageItems: [{ data: imgBytes, mimeType: "image/png" }] }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + const item = decodeMessage(success.get(1)[0].value); + expect(item.has(2)).toBe(true); // image variant + const img = decodeMessage(item.get(2)[0].value); + expect(Buffer.from(img.get(1)[0].value)).toEqual(Buffer.from(imgBytes)); + expect(Buffer.from(img.get(2)[0].value).toString("utf8")).toBe("image/png"); + }); + + it("builds mixed text + image content", () => { + const imgBytes = new Uint8Array([1, 2, 3]); + const bytes = encodeMcpResultSuccess({ textItems: ["see image"], imageItems: [{ data: imgBytes, mimeType: "image/jpeg" }] }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + expect(success.get(1).length).toBe(2); + expect(decodeMessage(success.get(1)[0].value).has(1)).toBe(true); // text + expect(decodeMessage(success.get(1)[1].value).has(2)).toBe(true); // image + }); + }); + + describe("McpResult error / toolNotFound", () => { + it("builds error result (field 2)", () => { + const bytes = encodeMcpResultError("tool crashed"); + const msg = decodeMessage(bytes); + expect(msg.has(2)).toBe(true); + const err = decodeMessage(msg.get(2)[0].value); + expect(Buffer.from(err.get(1)[0].value).toString("utf8")).toBe("tool crashed"); + }); + + it("builds toolNotFound result (field 5)", () => { + const bytes = encodeMcpResultToolNotFound("missing_tool"); + const msg = decodeMessage(bytes); + expect(msg.has(5)).toBe(true); + const tnf = decodeMessage(msg.get(5)[0].value); + expect(Buffer.from(tnf.get(1)[0].value).toString("utf8")).toBe("missing_tool"); + }); + }); +}); + +describe("Cursor AgentService executor helpers (cursor.js)", () => { + describe("isAgentCapableRequest", () => { + it("accepts plain text content", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }] })).toBe(true); + }); + + it("accepts array text content", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] })).toBe(true); + }); + + it("accepts request with tools declared", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }], tools: [{ function: { name: "t" } }] })).toBe(true); + }); + + it("accepts history with assistant tool_calls + tool results", () => { + expect(isAgentCapableRequest({ + messages: [ + { role: "user", content: "weather?" }, + { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "c1", content: "sunny" }, + { role: "user", content: "thanks" }, + ], + })).toBe(true); + }); + + it("rejects non-text (image) content", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "image_url" }] }] })).toBe(false); + }); + + it("rejects missing messages", () => { + expect(isAgentCapableRequest({})).toBe(false); + expect(isAgentCapableRequest(null)).toBe(false); + }); + }); + + describe("buildAgentRunFrame", () => { + // buildAgentRunFrame returns a wrapped Connect-RPC frame (5-byte header + AgentClientMessage). + const unwrap = (frame) => frame.subarray(5); + + it("encodes a text-only run request with system + model", () => { + const frame = unwrap(buildAgentRunFrame( + [{ role: "system", content: "be brief" }, { role: "user", content: "hi" }], + "gpt-5.2", + )); + const clientMsg = decodeMessage(frame); + expect(clientMsg.has(1)).toBe(true); // run_request + const run = decodeMessage(clientMsg.get(1)[0].value); + expect(run.has(2)).toBe(true); // action + expect(run.has(9)).toBe(true); // requested_model + }); + + it("encodes mcp_tools (field 4) when tools are provided", () => { + const tools = [{ function: { name: "get_weather", description: "weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }]; + const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "weather?" }], "gpt-5.2", tools)); + const run = decodeMessage(decodeMessage(frame).get(1)[0].value); + expect(run.has(4)).toBe(true); // mcp_tools + const mcpTools = decodeMessage(run.get(4)[0].value); + expect(mcpTools.get(1).length).toBe(1); + }); + + it("omits mcp_tools when no tools provided", () => { + const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "hi" }], "gpt-5.2", [])); + const run = decodeMessage(decodeMessage(frame).get(1)[0].value); + expect(run.has(4)).toBe(false); + }); + + it("encodes conversation_history from prior turns including tool calls/results", () => { + const messages = [ + { role: "user", content: "weather in Tokyo?" }, + { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }] }, + { role: "tool", tool_call_id: "c1", content: "18C cloudy" }, + { role: "user", content: "thanks" }, + ]; + const frame = unwrap(buildAgentRunFrame(messages, "gpt-5.2", [])); + const run = decodeMessage(decodeMessage(frame).get(1)[0].value); + const action = decodeMessage(run.get(2)[0].value); + const userAction = decodeMessage(action.get(1)[0].value); + expect(userAction.has(7)).toBe(true); // conversation_history (field 7) + const history = decodeMessage(userAction.get(7)[0].value); + expect(history.get(1).length).toBeGreaterThanOrEqual(2); // prior turns + }); + }); +}); diff --git a/tests/unit/cursor-models.test.js b/tests/unit/cursor-models.test.js new file mode 100644 index 00000000..23de871e --- /dev/null +++ b/tests/unit/cursor-models.test.js @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCursorModelCache, + parseCursorUsableModels, + resolveCursorModels, +} from "../../open-sse/services/cursorModels.js"; + +const originalFetch = global.fetch; + +function varint(value) { + const bytes = []; + while (value >= 0x80) { + bytes.push((value & 0x7f) | 0x80); + value >>>= 7; + } + bytes.push(value); + return Uint8Array.from(bytes); +} + +function field(fieldNumber, value) { + return Uint8Array.from([(fieldNumber << 3) | 2, ...varint(value.length), ...value]); +} + +function text(value) { + return new TextEncoder().encode(value); +} + +function concat(...parts) { + const size = parts.reduce((sum, part) => sum + part.length, 0); + const result = new Uint8Array(size); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function model(id, name) { + return field(1, concat(field(1, text(id)), field(4, text(name)))); +} + +describe("Cursor live model catalog", () => { + beforeEach(() => { + clearCursorModelCache(); + }); + + afterEach(() => { + global.fetch = originalFetch; + clearCursorModelCache(); + }); + + it("decodes the GetUsableModels protobuf response", () => { + const payload = concat( + model("default", "Auto"), + model("gpt-5.3-codex", "GPT 5.3 Codex"), + model("gpt-5.3-codex", "Duplicate"), + ); + + expect(parseCursorUsableModels(payload)).toEqual([ + { id: "default", name: "Auto" }, + { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, + ]); + }); + + it("fetches the account-specific catalog and caches it", async () => { + const payload = concat(model("claude-4.6-opus", "Claude 4.6 Opus")); + global.fetch = vi.fn().mockResolvedValue(new Response(payload, { status: 200 })); + const credentials = { + accessToken: "cursor-token", + providerSpecificData: { machineId: "machine-id" }, + }; + + await expect(resolveCursorModels(credentials)).resolves.toEqual({ + models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }], + }); + await expect(resolveCursorModels(credentials)).resolves.toEqual({ + models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }], + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + "https://agent.api5.cursor.sh/agent.v1.AgentService/GetUsableModels", + expect.objectContaining({ + method: "POST", + body: expect.any(Uint8Array), + headers: expect.objectContaining({ + "content-type": "application/proto", + accept: "application/proto", + }), + }), + ); + }); + + it("fails open when the Cursor catalog request fails", async () => { + global.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 403 })); + + await expect(resolveCursorModels({ + accessToken: "cursor-token", + providerSpecificData: { machineId: "machine-id" }, + })).resolves.toBeNull(); + }); +});