feat(cursor): Add cursor Provider
This commit is contained in:
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* CursorExecutor - Executor for Cursor AI IDE
|
||||
* Uses ConnectRPC/protobuf protocol with HTTP/2 for streaming chat
|
||||
*/
|
||||
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/constants.js";
|
||||
import {
|
||||
@@ -12,29 +7,75 @@ import {
|
||||
} from "../utils/cursorProtobuf.js";
|
||||
import crypto from "crypto";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import http2 from "http2";
|
||||
import zlib from "zlib";
|
||||
|
||||
// Detect cloud environment
|
||||
const isCloudEnv = () => {
|
||||
if (typeof caches !== "undefined" && typeof caches === "object") return true;
|
||||
if (typeof EdgeRuntime !== "undefined") return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Lazy import http2 (only in Node.js environment)
|
||||
let http2 = null;
|
||||
if (!isCloudEnv()) {
|
||||
try {
|
||||
http2 = await import("http2");
|
||||
} catch {
|
||||
// http2 not available
|
||||
}
|
||||
}
|
||||
|
||||
const COMPRESS_FLAG = {
|
||||
NONE: 0x00,
|
||||
GZIP: 0x01,
|
||||
GZIP_ALT: 0x02,
|
||||
GZIP_BOTH: 0x03
|
||||
};
|
||||
|
||||
function decompressPayload(payload, flags) {
|
||||
if (flags === COMPRESS_FLAG.GZIP || flags === COMPRESS_FLAG.GZIP_ALT || flags === COMPRESS_FLAG.GZIP_BOTH) {
|
||||
try {
|
||||
return zlib.gunzipSync(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createErrorResponse(jsonError) {
|
||||
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|
||||
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|
||||
|| jsonError?.error?.message
|
||||
|| "API Error";
|
||||
|
||||
const isRateLimit = jsonError?.error?.code === "resource_exhausted";
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: isRateLimit ? "rate_limit_error" : "api_error",
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
|
||||
}
|
||||
}), {
|
||||
status: isRateLimit ? 429 : 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
export class CursorExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("cursor", PROVIDERS.cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for Cursor API
|
||||
*/
|
||||
buildUrl() {
|
||||
return `${this.config.baseUrl}${this.config.chatPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Cursor checksum (jyh cipher) - timestamp integer version
|
||||
* This is the format that works with Cursor API
|
||||
*/
|
||||
// Jyh cipher checksum for Cursor API authentication
|
||||
generateChecksum(machineId) {
|
||||
// Use timestamp / 1e6 format (same as Python demo that works)
|
||||
const timestamp = Math.floor(Date.now() / 1000000);
|
||||
|
||||
// Create 6-byte big-endian array
|
||||
const byteArray = new Uint8Array([
|
||||
(timestamp >> 40) & 0xFF,
|
||||
(timestamp >> 32) & 0xFF,
|
||||
@@ -44,14 +85,12 @@ export class CursorExecutor extends BaseExecutor {
|
||||
timestamp & 0xFF
|
||||
]);
|
||||
|
||||
// Jyh cipher obfuscation
|
||||
let t = 165;
|
||||
for (let i = 0; i < byteArray.length; i++) {
|
||||
byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF;
|
||||
t = byteArray[i];
|
||||
}
|
||||
|
||||
// URL-safe base64 encode (without padding)
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let encoded = "";
|
||||
|
||||
@@ -74,23 +113,6 @@ export class CursorExecutor extends BaseExecutor {
|
||||
return `${encoded}${machineId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate client key from token
|
||||
*/
|
||||
generateClientKey(token) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate session ID
|
||||
*/
|
||||
generateSessionId(token) {
|
||||
return uuidv5(token, uuidv5.DNS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers with Cursor checksum authentication
|
||||
*/
|
||||
buildHeaders(credentials) {
|
||||
const accessToken = credentials.accessToken;
|
||||
const machineId = credentials.providerSpecificData?.machineId;
|
||||
@@ -100,9 +122,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
throw new Error("Machine ID is required for Cursor API");
|
||||
}
|
||||
|
||||
const cleanToken = accessToken.includes("::")
|
||||
? accessToken.split("::")[1]
|
||||
: accessToken;
|
||||
const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken;
|
||||
|
||||
return {
|
||||
"authorization": `Bearer ${cleanToken}`,
|
||||
@@ -111,7 +131,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
"content-type": "application/connect+proto",
|
||||
"user-agent": "connect-es/1.6.1",
|
||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||
"x-client-key": this.generateClientKey(cleanToken),
|
||||
"x-client-key": crypto.createHash("sha256").update(cleanToken).digest("hex"),
|
||||
"x-cursor-checksum": this.generateChecksum(machineId),
|
||||
"x-cursor-client-version": "2.3.41",
|
||||
"x-cursor-client-type": "ide",
|
||||
@@ -122,62 +142,44 @@ export class CursorExecutor extends BaseExecutor {
|
||||
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
||||
"x-request-id": crypto.randomUUID(),
|
||||
"x-session-id": this.generateSessionId(cleanToken),
|
||||
"x-session-id": uuidv5(cleanToken, uuidv5.DNS),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI-format messages to Cursor format
|
||||
*/
|
||||
convertMessages(body) {
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
const messages = body.messages || [];
|
||||
const result = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") {
|
||||
result.push({
|
||||
role: "user",
|
||||
content: `[System Instructions]\n${msg.content}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user" || msg.role === "assistant") {
|
||||
let content = "";
|
||||
|
||||
if (typeof msg.content === "string") {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") {
|
||||
content += part.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
result.push({ role: msg.role, content });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
const tools = body.tools || [];
|
||||
const reasoningEffort = body.reasoning_effort || null;
|
||||
return generateCursorBody(messages, model, tools, reasoningEffort);
|
||||
}
|
||||
|
||||
async makeFetchRequest(url, headers, body, signal) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal
|
||||
});
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: Buffer.from(await response.arrayBuffer())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP/2 request to Cursor API
|
||||
*/
|
||||
makeHttp2Request(url, headers, body, signal) {
|
||||
if (!http2) {
|
||||
throw new Error("http2 module not available");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const client = http2.connect(`https://${urlObj.host}`);
|
||||
|
||||
const chunks = [];
|
||||
let responseHeaders = {};
|
||||
|
||||
client.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
client.on("error", reject);
|
||||
|
||||
const req = client.request({
|
||||
":method": "POST",
|
||||
@@ -187,24 +189,16 @@ export class CursorExecutor extends BaseExecutor {
|
||||
...headers
|
||||
});
|
||||
|
||||
req.on("response", (hdrs) => {
|
||||
responseHeaders = hdrs;
|
||||
});
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("response", (hdrs) => { responseHeaders = hdrs; });
|
||||
req.on("data", (chunk) => { chunks.push(chunk); });
|
||||
req.on("end", () => {
|
||||
client.close();
|
||||
const data = Buffer.concat(chunks);
|
||||
resolve({
|
||||
status: responseHeaders[":status"],
|
||||
headers: responseHeaders,
|
||||
body: data
|
||||
body: Buffer.concat(chunks)
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
client.close();
|
||||
reject(err);
|
||||
@@ -223,28 +217,21 @@ export class CursorExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom execute for Cursor - handles protobuf binary protocol with HTTP/2
|
||||
*/
|
||||
async execute({ model, body, stream, credentials, signal, log }) {
|
||||
const url = this.buildUrl();
|
||||
const headers = this.buildHeaders(credentials);
|
||||
|
||||
// Convert messages and build protobuf body
|
||||
const messages = this.convertMessages(body);
|
||||
const cursorBody = generateCursorBody(messages, model);
|
||||
|
||||
log?.debug?.("CURSOR", `Sending ${messages.length} messages to ${model}, stream=${stream}`);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
try {
|
||||
// Use HTTP/2 for Cursor API (required)
|
||||
const response = await this.makeHttp2Request(url, headers, cursorBody, signal);
|
||||
const response = http2
|
||||
? await this.makeHttp2Request(url, headers, transformedBody, signal)
|
||||
: await this.makeFetchRequest(url, headers, transformedBody, signal);
|
||||
|
||||
if (response.status !== 200) {
|
||||
// Create error response
|
||||
const errorText = response.body?.toString() || "Unknown error";
|
||||
const errorResponse = new Response(JSON.stringify({
|
||||
error: {
|
||||
message: `[${response.status}]: ${response.body.toString() || "Unknown error"}`,
|
||||
message: `[${response.status}]: ${errorText}`,
|
||||
type: "invalid_request_error",
|
||||
code: ""
|
||||
}
|
||||
@@ -255,14 +242,12 @@ export class CursorExecutor extends BaseExecutor {
|
||||
return { response: errorResponse, url, headers, transformedBody: body };
|
||||
}
|
||||
|
||||
// Transform based on stream parameter
|
||||
const transformedResponse = stream !== false
|
||||
? this.transformProtobufToSSE(response.body, model)
|
||||
: this.transformProtobufToJSON(response.body, model);
|
||||
|
||||
return { response: transformedResponse, url, headers, transformedBody: body };
|
||||
} catch (error) {
|
||||
log?.error?.("CURSOR", `Request failed: ${error.message}`);
|
||||
const errorResponse = new Response(JSON.stringify({
|
||||
error: {
|
||||
message: error.message,
|
||||
@@ -277,16 +262,13 @@ export class CursorExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ConnectRPC protobuf buffer to JSON Response (non-streaming)
|
||||
*/
|
||||
transformProtobufToJSON(buffer, model) {
|
||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Parse all frames and collect content
|
||||
let offset = 0;
|
||||
let totalContent = "";
|
||||
const toolCalls = [];
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) break;
|
||||
@@ -299,43 +281,19 @@ export class CursorExecutor extends BaseExecutor {
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
// Decompress if gzip (flags 0x01 or 0x03)
|
||||
if (flags === 0x01 || flags === 0x03) {
|
||||
try {
|
||||
const zlib = require("zlib");
|
||||
payload = zlib.gunzipSync(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
payload = decompressPayload(payload, flags);
|
||||
if (!payload) continue;
|
||||
|
||||
// Check if payload is JSON error (ConnectRPC error format)
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
const jsonError = JSON.parse(text);
|
||||
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|
||||
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|
||||
|| jsonError?.error?.message
|
||||
|| "API Error";
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
|
||||
}
|
||||
}), {
|
||||
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Extract text or error from protobuf
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
|
||||
if (result.error) {
|
||||
// Return error response
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
@@ -348,14 +306,18 @@ export class CursorExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
totalContent += result.text;
|
||||
}
|
||||
if (result.toolCall) toolCalls.push(result.toolCall);
|
||||
if (result.text) totalContent += result.text;
|
||||
}
|
||||
|
||||
// Build non-streaming response
|
||||
const estimatedPromptTokens = 10;
|
||||
const estimatedCompletionTokens = Math.max(1, Math.floor(totalContent.length / 4));
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: totalContent || null
|
||||
};
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
const completion = {
|
||||
id: responseId,
|
||||
@@ -364,16 +326,13 @@ export class CursorExecutor extends BaseExecutor {
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: totalContent
|
||||
},
|
||||
finish_reason: "stop"
|
||||
message,
|
||||
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: estimatedPromptTokens,
|
||||
completion_tokens: estimatedCompletionTokens,
|
||||
total_tokens: estimatedPromptTokens + estimatedCompletionTokens
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: Math.max(1, Math.floor(totalContent.length / 4)),
|
||||
total_tokens: 10 + Math.max(1, Math.floor(totalContent.length / 4))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -383,17 +342,14 @@ export class CursorExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ConnectRPC protobuf buffer to SSE Response
|
||||
*/
|
||||
transformProtobufToSSE(buffer, model) {
|
||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Parse all frames from buffer
|
||||
const chunks = [];
|
||||
let offset = 0;
|
||||
let totalContent = "";
|
||||
const toolCalls = [];
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) break;
|
||||
@@ -406,43 +362,19 @@ export class CursorExecutor extends BaseExecutor {
|
||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||
offset += 5 + length;
|
||||
|
||||
// Decompress if gzip (flags 0x01 or 0x03)
|
||||
if (flags === 0x01 || flags === 0x03) {
|
||||
try {
|
||||
const zlib = require("zlib");
|
||||
payload = zlib.gunzipSync(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
payload = decompressPayload(payload, flags);
|
||||
if (!payload) continue;
|
||||
|
||||
// Check if payload is JSON error (ConnectRPC error format)
|
||||
try {
|
||||
const text = payload.toString("utf-8");
|
||||
if (text.startsWith("{") && text.includes('"error"')) {
|
||||
const jsonError = JSON.parse(text);
|
||||
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|
||||
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
|
||||
|| jsonError?.error?.message
|
||||
|| "API Error";
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: jsonError?.error?.code === "resource_exhausted" ? "rate_limit_error" : "api_error",
|
||||
code: jsonError?.error?.details?.[0]?.debug?.error || "unknown"
|
||||
}
|
||||
}), {
|
||||
status: jsonError?.error?.code === "resource_exhausted" ? 429 : 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
return createErrorResponse(JSON.parse(text));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Extract text or error from protobuf
|
||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||
|
||||
if (result.error) {
|
||||
// Return error response
|
||||
return new Response(JSON.stringify({
|
||||
error: {
|
||||
message: result.error,
|
||||
@@ -455,28 +387,69 @@ export class CursorExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
totalContent += result.text;
|
||||
const chunk = {
|
||||
if (result.toolCall) {
|
||||
toolCalls.push(result.toolCall);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
chunks.push(`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content: "" },
|
||||
finish_reason: null
|
||||
}]
|
||||
})}\n\n`);
|
||||
}
|
||||
|
||||
chunks.push(`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: chunks.length === 0
|
||||
delta: { tool_calls: [{ index: toolCalls.length - 1, ...result.toolCall }] },
|
||||
finish_reason: null
|
||||
}]
|
||||
})}\n\n`);
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
totalContent += result.text;
|
||||
chunks.push(`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: chunks.length === 0 && toolCalls.length === 0
|
||||
? { role: "assistant", content: result.text }
|
||||
: { content: result.text },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
chunks.push(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
})}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add finish chunk
|
||||
const estimatedTokens = Math.max(1, Math.floor(totalContent.length / 4));
|
||||
const finishChunk = {
|
||||
if (chunks.length === 0 && toolCalls.length === 0) {
|
||||
chunks.push(`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content: "" },
|
||||
finish_reason: null
|
||||
}]
|
||||
})}\n\n`);
|
||||
}
|
||||
|
||||
chunks.push(`data: ${JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
@@ -484,15 +457,14 @@ export class CursorExecutor extends BaseExecutor {
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "stop"
|
||||
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: estimatedTokens,
|
||||
total_tokens: estimatedTokens
|
||||
completion_tokens: Math.max(1, Math.floor(totalContent.length / 4)),
|
||||
total_tokens: Math.max(1, Math.floor(totalContent.length / 4))
|
||||
}
|
||||
};
|
||||
chunks.push(`data: ${JSON.stringify(finishChunk)}\n\n`);
|
||||
})}\n\n`);
|
||||
chunks.push("data: [DONE]\n\n");
|
||||
|
||||
return new Response(chunks.join(""), {
|
||||
@@ -505,9 +477,6 @@ export class CursorExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor doesn't support standard OAuth refresh
|
||||
*/
|
||||
async refreshCredentials() {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user