fix(cursor): HTTP/2 AgentService support + version bump to 3.12.17

Real Cursor IDE now uses AgentService at agent.api5.cursor.sh (HTTP/2-only)
while 9router still spoke the retired ChatService at api2.cursor.sh with
outdated headers, producing HTTP 429 "Update Required". Add an executeAgent
path that builds an agent.v1.RunRequest Connect RPC over a raw http2 stream
and fetches the account-specific usable model catalog via GetUsableModels.

Also implement MCP tool calling over AgentService: encode OpenAI tools as
AgentRunRequest.mcp_tools (McpToolDefinition with google.protobuf.Value
input_schema), decode McpArgs tool calls, and forward them to the client as
OpenAI tool_calls so the client runs the tool and resumes in the next turn.
Reply to request_context_args with a non-empty RequestContext, to server
heartbeats with client_heartbeat, and to KV blob get/set with empty results,
so action queries no longer stall the stream. Fold the client system prompt
into the user message (custom_system_prompt makes the server return an empty
turn). Bump clientVersion to 3.12.17 and add the x-cursor-client-commit
header so the gateway identifies as a current Cursor IDE release.
This commit is contained in:
long2ice
2026-07-20 15:39:17 +07:00
committed by decolua
parent 4f48ab8c7f
commit 6994cd1f70
12 changed files with 1106 additions and 13 deletions

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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<string, { expiresAt: number, models: { id: string, name: string }[] }>} */
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();
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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) => {

View File

@@ -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(() => {

View File

@@ -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",

View File

@@ -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
});
});
});

View File

@@ -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();
});
});