From 0a026c7af6230b4e173560c3b2799d67facb3c98 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 5 Feb 2026 11:06:20 +0700 Subject: [PATCH] feat(cursor): Add cursor Provider --- open-sse/config/providerModels.js | 12 +- open-sse/executors/cursor.js | 371 +++++----- open-sse/translator/formats.js | 3 +- open-sse/translator/index.js | 2 + .../translator/request/openai-to-cursor.js | 96 +++ .../translator/response/cursor-to-openai.js | 30 + .../response/openai-to-claude.new.js | 205 ------ open-sse/utils/cursorProtobuf.js | 695 +++++++++--------- package.json | 3 +- src/app/api/oauth/cursor/auto-import/route.js | 84 +++ src/app/api/oauth/kiro/auto-import/route.js | 85 +++ src/app/api/usage/request-logs/route.js | 13 + src/lib/usageDb.js | 54 +- src/shared/components/CursorAuthModal.js | 253 +++---- src/shared/components/KiroAuthModal.js | 142 ++-- src/shared/components/RequestLogger.js | 2 +- 16 files changed, 1113 insertions(+), 937 deletions(-) create mode 100644 open-sse/translator/request/openai-to-cursor.js create mode 100644 open-sse/translator/response/cursor-to-openai.js delete mode 100644 open-sse/translator/response/openai-to-claude.new.js create mode 100644 src/app/api/oauth/cursor/auto-import/route.js create mode 100644 src/app/api/oauth/kiro/auto-import/route.js create mode 100644 src/app/api/usage/request-logs/route.js diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index f7476cc..36ba4d9 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -75,16 +75,14 @@ export const PROVIDER_MODELS = { { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, ], cu: [ // Cursor IDE - { id: "default", name: "Default (Server Picks)" }, + { id: "default", name: "Auto (Server Picks)" }, { id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" }, { id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" }, { id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" }, - { id: "claude-4-sonnet", name: "Claude 4 Sonnet" }, - { id: "gpt-4o", name: "GPT-4o" }, - { id: "gpt-5.1-codex", name: "GPT 5.1 Codex" }, - { id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet" }, - { id: "gpt-4o-mini", name: "GPT-4o Mini" }, - { id: "cursor-small", name: "Cursor Small" }, + { id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" }, + { id: "claude-4.5-haiku", name: "Claude 4.5 Haiku" }, + { id: "claude-4.5-opus", name: "Claude 4.5 Opus" }, + { id: "gpt-5.2-codex", name: "GPT 5.2 Codex" }, ], // API Key Providers (alias = id) diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 4ad595b..945979c 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -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; } diff --git a/open-sse/translator/formats.js b/open-sse/translator/formats.js index 74aa9c1..c32835d 100644 --- a/open-sse/translator/formats.js +++ b/open-sse/translator/formats.js @@ -8,6 +8,7 @@ export const FORMATS = { GEMINI_CLI: "gemini-cli", CODEX: "codex", ANTIGRAVITY: "antigravity", - KIRO: "kiro" + KIRO: "kiro", + CURSOR: "cursor" }; diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 6bc9e97..b0082ec 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -34,6 +34,7 @@ function ensureInitialized() { require("./request/openai-to-gemini.js"); require("./request/openai-responses.js"); require("./request/openai-to-kiro.js"); + require("./request/openai-to-cursor.js"); // Response translators require("./response/claude-to-openai.js"); @@ -41,6 +42,7 @@ function ensureInitialized() { require("./response/gemini-to-openai.js"); require("./response/openai-responses.js"); require("./response/kiro-to-openai.js"); + require("./response/cursor-to-openai.js"); } // Translate request: source -> openai -> target diff --git a/open-sse/translator/request/openai-to-cursor.js b/open-sse/translator/request/openai-to-cursor.js new file mode 100644 index 0000000..ae34090 --- /dev/null +++ b/open-sse/translator/request/openai-to-cursor.js @@ -0,0 +1,96 @@ +/** + * OpenAI to Cursor Request Translator + * Converts OpenAI messages to Cursor simple format + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +/** + * Convert OpenAI messages to Cursor simple format + * - system → user with [System Instructions] prefix + * - tool → user with [Tool Result: name] prefix + * - assistant with tool_calls → append [Calling tool: name with args: {...}] to content + */ +function convertMessages(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 === "tool") { + let toolContent = ""; + if (typeof msg.content === "string") { + toolContent = msg.content; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "text") { + toolContent += part.text; + } + } + } + + const toolName = msg.name || "tool"; + result.push({ + role: "user", + content: `[Tool Result: ${toolName}]\n${toolContent}` + }); + 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 (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { + if (content) { + result.push({ role: "assistant", content }); + } + + const toolCallsText = msg.tool_calls.map(tc => { + const funcName = tc.function?.name || "unknown"; + const funcArgs = tc.function?.arguments || "{}"; + return `[Calling tool: ${funcName} with args: ${funcArgs}]`; + }).join("\n"); + + result.push({ + role: "assistant", + content: toolCallsText + }); + } else if (content) { + result.push({ role: msg.role, content }); + } + } + } + + return result; +} + +/** + * Transform OpenAI request to Cursor format + * Returns modified body with converted messages + */ +export function buildCursorRequest(model, body, stream, credentials) { + const messages = convertMessages(body.messages || []); + + return { + ...body, + messages + }; +} + +register(FORMATS.OPENAI, FORMATS.CURSOR, buildCursorRequest, null); diff --git a/open-sse/translator/response/cursor-to-openai.js b/open-sse/translator/response/cursor-to-openai.js new file mode 100644 index 0000000..b254691 --- /dev/null +++ b/open-sse/translator/response/cursor-to-openai.js @@ -0,0 +1,30 @@ +/** + * Cursor to OpenAI Response Translator + * CursorExecutor already emits OpenAI format - this is a passthrough + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +/** + * Convert Cursor response to OpenAI format + * Since CursorExecutor.transformProtobufToSSE/JSON already emits OpenAI chunks, + * this is a passthrough translator (similar to Kiro pattern) + */ +export function convertCursorToOpenAI(chunk, state) { + if (!chunk) return null; + + // If chunk is already in OpenAI format (from executor transform), return as-is + if (chunk.object === "chat.completion.chunk" && chunk.choices) { + return chunk; + } + + // If chunk is a completion object (non-streaming), return as-is + if (chunk.object === "chat.completion" && chunk.choices) { + return chunk; + } + + // Fallback: return chunk as-is (should not reach here) + return chunk; +} + +register(FORMATS.CURSOR, FORMATS.OPENAI, null, convertCursorToOpenAI); diff --git a/open-sse/translator/response/openai-to-claude.new.js b/open-sse/translator/response/openai-to-claude.new.js deleted file mode 100644 index 176554d..0000000 --- a/open-sse/translator/response/openai-to-claude.new.js +++ /dev/null @@ -1,205 +0,0 @@ -import { register } from "../index.js"; -import { FORMATS } from "../formats.js"; - -// Prefix for Claude OAuth tool names (must match request translator) -const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_"; - -// Helper: stop thinking block if started -function stopThinkingBlock(state, results) { - if (!state.thinkingBlockStarted) return; - results.push({ - type: "content_block_stop", - index: state.thinkingBlockIndex - }); - state.thinkingBlockStarted = false; -} - -// Helper: stop text block if started -function stopTextBlock(state, results) { - if (!state.textBlockStarted || state.textBlockClosed) return; - state.textBlockClosed = true; - results.push({ - type: "content_block_stop", - index: state.textBlockIndex - }); - state.textBlockStarted = false; -} - -// Convert OpenAI stream chunk to Claude format -function openaiToClaudeResponse(chunk, state) { - if (!chunk || !chunk.choices?.[0]) return null; - - const results = []; - const choice = chunk.choices[0]; - const delta = choice.delta; - - // First chunk - ALWAYS send message_start first - if (!state.messageStartSent) { - state.messageStartSent = true; - state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`; - if (!state.messageId || state.messageId === "chat" || state.messageId.length < 8) { - state.messageId = chunk.extend_fields?.requestId || - chunk.extend_fields?.traceId || - `msg_${Date.now()}`; - } - state.model = chunk.model || "unknown"; - state.nextBlockIndex = 0; - results.push({ - type: "message_start", - message: { - id: state.messageId, - type: "message", - role: "assistant", - model: state.model, - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0 } - } - }); - } - - // Handle reasoning_content (thinking) - GLM, DeepSeek, etc. - const reasoningContent = delta?.reasoning_content || delta?.reasoning; - if (reasoningContent) { - stopTextBlock(state, results); - - if (!state.thinkingBlockStarted) { - state.thinkingBlockIndex = state.nextBlockIndex++; - state.thinkingBlockStarted = true; - results.push({ - type: "content_block_start", - index: state.thinkingBlockIndex, - content_block: { type: "thinking", thinking: "" } - }); - } - - results.push({ - type: "content_block_delta", - index: state.thinkingBlockIndex, - delta: { type: "thinking_delta", thinking: reasoningContent } - }); - } - - // Handle regular content - if (delta?.content) { - stopThinkingBlock(state, results); - - if (!state.textBlockStarted) { - state.textBlockIndex = state.nextBlockIndex++; - state.textBlockStarted = true; - state.textBlockClosed = false; - results.push({ - type: "content_block_start", - index: state.textBlockIndex, - content_block: { type: "text", text: "" } - }); - } - - results.push({ - type: "content_block_delta", - index: state.textBlockIndex, - delta: { type: "text_delta", text: delta.content } - }); - } - - // Tool calls - accumulate arguments instead of emitting immediately - if (delta?.tool_calls) { - for (const tc of delta.tool_calls) { - const idx = tc.index ?? 0; - - if (tc.id) { - stopThinkingBlock(state, results); - stopTextBlock(state, results); - - const toolBlockIndex = state.nextBlockIndex++; - - // Strip prefix from tool name for response - let toolName = tc.function?.name || ""; - if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { - toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); - } - - // Initialize accumulator for this tool - state.toolCalls.set(idx, { - id: tc.id, - name: toolName, - blockIndex: toolBlockIndex, - arguments: "", // Accumulate arguments here - startEmitted: false // Track if content_block_start sent - }); - } - - // Accumulate arguments instead of emitting immediately - if (tc.function?.arguments) { - const toolInfo = state.toolCalls.get(idx); - if (toolInfo) { - toolInfo.arguments += tc.function.arguments; - } - } - } - } - - // Finish - emit all accumulated tools in correct order - if (choice.finish_reason) { - stopThinkingBlock(state, results); - stopTextBlock(state, results); - - // STEP 1: Emit all content_block_start for tools (like CLIProxyAPIPlus) - const sortedTools = Array.from(state.toolCalls.entries()).sort((a, b) => a[0] - b[0]); - for (const [, toolInfo] of sortedTools) { - if (!toolInfo.startEmitted) { - results.push({ - type: "content_block_start", - index: toolInfo.blockIndex, - content_block: { - type: "tool_use", - id: toolInfo.id, - name: toolInfo.name, - input: {} - } - }); - toolInfo.startEmitted = true; - } - } - - // STEP 2: Emit input_json_delta + content_block_stop for each tool - for (const [, toolInfo] of sortedTools) { - if (toolInfo.arguments) { - results.push({ - type: "content_block_delta", - index: toolInfo.blockIndex, - delta: { type: "input_json_delta", partial_json: toolInfo.arguments } - }); - } - - results.push({ - type: "content_block_stop", - index: toolInfo.blockIndex - }); - } - - results.push({ - type: "message_delta", - delta: { stop_reason: convertFinishReason(choice.finish_reason) }, - usage: { output_tokens: 0 } - }); - results.push({ type: "message_stop" }); - } - - return results.length > 0 ? results : null; -} - -// Convert OpenAI finish_reason to Claude stop_reason -function convertFinishReason(reason) { - switch (reason) { - case "stop": return "end_turn"; - case "length": return "max_tokens"; - case "tool_calls": return "tool_use"; - default: return "end_turn"; - } -} - -// Register -register(FORMATS.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse); - diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js index 57a55e1..3ef7f6f 100644 --- a/open-sse/utils/cursorProtobuf.js +++ b/open-sse/utils/cursorProtobuf.js @@ -1,26 +1,125 @@ /** - * Cursor Protobuf Encoding/Decoding Utility - * - * Implements protobuf wire format encoding for Cursor API requests - * and decoding for streaming responses. - * - * Wire format reference: - * - Wire type 0: Varint (int32, int64, uint32, uint64, bool, enum) - * - Wire type 2: Length-delimited (string, bytes, embedded messages) + * Cursor Protobuf Encoder/Decoder + * Implements ConnectRPC protobuf wire format for Cursor API */ import { v4 as uuidv4 } from "uuid"; import zlib from "zlib"; -// ============================================================================= -// Encoding Functions -// ============================================================================= +const DEBUG = true; +const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args); + +// ==================== SCHEMAS ==================== + +const WIRE_TYPE = { VARINT: 0, FIXED64: 1, LEN: 2, FIXED32: 5 }; + +const ROLE = { USER: 1, ASSISTANT: 2 }; + +const UNIFIED_MODE = { CHAT: 1, AGENT: 2 }; + +const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 }; + +const FIELD = { + // StreamUnifiedChatRequestWithTools (top level) + REQUEST: 1, + + // StreamUnifiedChatRequest + MESSAGES: 1, + UNKNOWN_2: 2, + INSTRUCTION: 3, + UNKNOWN_4: 4, + MODEL: 5, + WEB_TOOL: 8, + UNKNOWN_13: 13, + CURSOR_SETTING: 15, + UNKNOWN_19: 19, + CONVERSATION_ID: 23, + METADATA: 26, + IS_AGENTIC: 27, + SUPPORTED_TOOLS: 29, + MESSAGE_IDS: 30, + MCP_TOOLS: 34, + LARGE_CONTEXT: 35, + UNKNOWN_38: 38, + UNIFIED_MODE: 46, + UNKNOWN_47: 47, + SHOULD_DISABLE_TOOLS: 48, + THINKING_LEVEL: 49, + UNKNOWN_51: 51, + UNKNOWN_53: 53, + UNIFIED_MODE_NAME: 54, + + // ConversationMessage + MSG_CONTENT: 1, + MSG_ROLE: 2, + MSG_ID: 13, + MSG_IS_AGENTIC: 29, + MSG_UNIFIED_MODE: 47, + MSG_SUPPORTED_TOOLS: 51, + + // Model + MODEL_NAME: 1, + MODEL_EMPTY: 4, + + // Instruction + INSTRUCTION_TEXT: 1, + + // CursorSetting + SETTING_PATH: 1, + SETTING_UNKNOWN_3: 3, + SETTING_UNKNOWN_6: 6, + SETTING_UNKNOWN_8: 8, + SETTING_UNKNOWN_9: 9, + + // CursorSetting.Unknown6 + SETTING6_FIELD_1: 1, + SETTING6_FIELD_2: 2, + + // Metadata + META_PLATFORM: 1, + META_ARCH: 2, + META_VERSION: 3, + META_CWD: 4, + META_TIMESTAMP: 5, + + // MessageId + MSGID_ID: 1, + MSGID_SUMMARY: 2, + MSGID_ROLE: 3, + + // MCPTool + MCP_TOOL_NAME: 1, + MCP_TOOL_DESC: 2, + MCP_TOOL_PARAMS: 3, + MCP_TOOL_SERVER: 4, + + // StreamUnifiedChatResponseWithTools (response) + TOOL_CALL: 1, + RESPONSE: 2, + + // ClientSideToolV2Call + TOOL_ID: 3, + TOOL_NAME: 9, + TOOL_RAW_ARGS: 10, + TOOL_MCP_PARAMS: 27, + + // MCPParams + MCP_TOOLS_LIST: 1, + + // MCPParams.Tool (nested) + MCP_NESTED_NAME: 1, + MCP_NESTED_PARAMS: 3, + + // StreamUnifiedChatResponse + RESPONSE_TEXT: 1, + THINKING: 25, + + // Thinking + THINKING_TEXT: 1 +}; + +// ==================== PRIMITIVE ENCODING ==================== -/** - * Encode an integer as a varint - * @param {number} value - Integer to encode - * @returns {Uint8Array} - Encoded bytes - */ export function encodeVarint(value) { const bytes = []; while (value >= 0x80) { @@ -31,53 +130,29 @@ export function encodeVarint(value) { return new Uint8Array(bytes); } -/** - * Encode a protobuf field - * @param {number} fieldNum - Field number - * @param {number} wireType - Wire type (0=varint, 2=length-delimited) - * @param {*} value - Value to encode - * @returns {Uint8Array} - Encoded bytes - */ export function encodeField(fieldNum, wireType, value) { const tag = (fieldNum << 3) | wireType; const tagBytes = encodeVarint(tag); - if (wireType === 0) { - // Varint + if (wireType === WIRE_TYPE.VARINT) { const valueBytes = encodeVarint(value); - const result = new Uint8Array(tagBytes.length + valueBytes.length); - result.set(tagBytes); - result.set(valueBytes, tagBytes.length); - return result; - } else if (wireType === 2) { - // Length-delimited (string, bytes, nested message) - let dataBytes; - if (typeof value === "string") { - dataBytes = new TextEncoder().encode(value); - } else if (value instanceof Uint8Array) { - dataBytes = value; - } else if (Buffer.isBuffer(value)) { - dataBytes = new Uint8Array(value); - } else { - dataBytes = new Uint8Array(0); - } + return concatArrays(tagBytes, valueBytes); + } + if (wireType === WIRE_TYPE.LEN) { + const dataBytes = typeof value === "string" + ? new TextEncoder().encode(value) + : value instanceof Uint8Array ? value + : Buffer.isBuffer(value) ? new Uint8Array(value) + : new Uint8Array(0); + const lengthBytes = encodeVarint(dataBytes.length); - const result = new Uint8Array(tagBytes.length + lengthBytes.length + dataBytes.length); - result.set(tagBytes); - result.set(lengthBytes, tagBytes.length); - result.set(dataBytes, tagBytes.length + lengthBytes.length); - return result; + return concatArrays(tagBytes, lengthBytes, dataBytes); } return new Uint8Array(0); } -/** - * Concatenate multiple Uint8Arrays - * @param {...Uint8Array} arrays - Arrays to concatenate - * @returns {Uint8Array} - Concatenated array - */ function concatArrays(...arrays) { const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); const result = new Uint8Array(totalLength); @@ -89,219 +164,159 @@ function concatArrays(...arrays) { return result; } -/** - * Encode a Message (conversation message) - * - * Schema: - * string content = 1; - * int32 role = 2; - * string messageId = 13; - * int32 chatModeEnum = 47; (only for user) - */ -export function encodeMessage(content, role, messageId, chatModeEnum = null) { - const parts = []; +// ==================== MESSAGE ENCODING ==================== - // Field 1: content (string) - parts.push(encodeField(1, 2, content)); - - // Field 2: role (int32) - 1=user, 2=assistant - parts.push(encodeField(2, 0, role)); - - // Field 13: messageId (string) - parts.push(encodeField(13, 2, messageId)); - - // Field 47: chatModeEnum (only for user messages) - if (chatModeEnum !== null) { - parts.push(encodeField(47, 0, chatModeEnum)); - } - - return concatArrays(...parts); +export function encodeMessage(content, role, messageId, chatModeEnum = null, isLast = false, hasTools = false) { + return concatArrays( + encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content), + encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role), + encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId), + encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0), + encodeField(FIELD.MSG_UNIFIED_MODE, WIRE_TYPE.VARINT, hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT), + ...(isLast && hasTools ? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : []) + ); } -/** - * Encode Instruction message - * Schema: string instruction = 1; - */ -export function encodeInstruction(instructionText) { - if (!instructionText) return new Uint8Array(0); - return encodeField(1, 2, instructionText); +export function encodeInstruction(text) { + return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0); } -/** - * Encode Model message - * Schema: - * string name = 1; - * bytes empty = 4; - */ export function encodeModel(modelName) { return concatArrays( - encodeField(1, 2, modelName), - encodeField(4, 2, new Uint8Array(0)) + encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName), + encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0)) ); } -/** - * Encode CursorSetting message - */ export function encodeCursorSetting() { - // Unknown6 nested message const unknown6 = concatArrays( - encodeField(1, 2, new Uint8Array(0)), - encodeField(2, 2, new Uint8Array(0)) + encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)), + encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0)) ); return concatArrays( - encodeField(1, 2, "cursor\\aisettings"), - encodeField(3, 2, new Uint8Array(0)), - encodeField(6, 2, unknown6), - encodeField(8, 0, 1), - encodeField(9, 0, 1) + encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, "cursor\\aisettings"), + encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)), + encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6), + encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1) ); } -/** - * Encode Metadata message - */ export function encodeMetadata() { return concatArrays( - encodeField(1, 2, process.platform || "linux"), - encodeField(2, 2, process.arch || "x64"), - encodeField(3, 2, process.version || "v20.0.0"), - encodeField(4, 2, process.cwd?.() || "/"), - encodeField(5, 2, new Date().toISOString()) + encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || "linux"), + encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || "x64"), + encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || "v20.0.0"), + encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd?.() || "/"), + encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString()) ); } -/** - * Encode MessageId message - */ export function encodeMessageId(messageId, role, summaryId = null) { - const parts = [ - encodeField(1, 2, messageId), - ]; - - if (summaryId) { - parts.push(encodeField(2, 2, summaryId)); - } - - parts.push(encodeField(3, 0, role)); - - return concatArrays(...parts); + return concatArrays( + encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId), + ...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []), + encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role) + ); } -/** - * Encode the Request message (inner request) - */ -export function encodeRequest(messages, modelName) { - const parts = []; +export function encodeMcpTool(tool) { + const toolName = tool.function?.name || tool.name || ""; + const toolDesc = tool.function?.description || tool.description || ""; + const inputSchema = tool.function?.parameters || tool.input_schema || {}; + + return concatArrays( + ...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []), + ...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []), + ...(Object.keys(inputSchema).length > 0 ? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))] : []), + encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, "custom") + ); +} + +// ==================== REQUEST BUILDING ==================== + +export function encodeRequest(messages, modelName, tools = [], reasoningEffort = null) { + const hasTools = tools?.length > 0; + const isAgentic = hasTools; const formattedMessages = []; const messageIds = []; - // Format messages - for (const msg of messages) { - const role = msg.role === "user" ? 1 : 2; + // Prepare messages + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT; const msgId = uuidv4(); + const isLast = i === messages.length - 1; formattedMessages.push({ content: msg.content, role, messageId: msgId, - chatModeEnum: role === 1 ? 1 : null // Only for user messages + isLast, + hasTools }); messageIds.push({ messageId: msgId, role }); } - // Field 1: repeated Message messages - for (const fm of formattedMessages) { - const messageBytes = encodeMessage(fm.content, fm.role, fm.messageId, fm.chatModeEnum); - parts.push(encodeField(1, 2, messageBytes)); - } + // Map reasoning effort to thinking level + let thinkingLevel = THINKING_LEVEL.UNSPECIFIED; + if (reasoningEffort === "medium") thinkingLevel = THINKING_LEVEL.MEDIUM; + else if (reasoningEffort === "high") thinkingLevel = THINKING_LEVEL.HIGH; - // Field 2: unknown2 = 1 - parts.push(encodeField(2, 0, 1)); + // Build request + return concatArrays( + // Messages + ...formattedMessages.map(fm => + encodeField(FIELD.MESSAGES, WIRE_TYPE.LEN, + encodeMessage(fm.content, fm.role, fm.messageId, null, fm.isLast, fm.hasTools) + ) + ), + + // Static fields + encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction("")), + encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)), + encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ""), + encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()), + encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, uuidv4()), + encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()), - // Field 3: Instruction - parts.push(encodeField(3, 2, encodeInstruction(""))); + // Tool-related fields + encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0), + ...(isAgentic ? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : []), + + // Message IDs + ...messageIds.map(mid => + encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role)) + ), - // Field 4: unknown4 = 1 - parts.push(encodeField(4, 0, 1)); + // MCP Tools + ...(tools?.length > 0 ? tools.map(tool => + encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)) + ) : []), - // Field 5: Model - always send, even for "default" - if (modelName) { - parts.push(encodeField(5, 2, encodeModel(modelName))); - } - - // Field 8: webTool = "" - parts.push(encodeField(8, 2, "")); - - // Field 13: unknown13 = 1 - parts.push(encodeField(13, 0, 1)); - - // Field 15: CursorSetting - parts.push(encodeField(15, 2, encodeCursorSetting())); - - // Field 19: unknown19 = 1 - parts.push(encodeField(19, 0, 1)); - - // Field 23: conversationId - parts.push(encodeField(23, 2, uuidv4())); - - // Field 26: Metadata - parts.push(encodeField(26, 2, encodeMetadata())); - - // Field 27: unknown27 = 0 - parts.push(encodeField(27, 0, 0)); - - // Field 30: repeated MessageId - for (const mid of messageIds) { - parts.push(encodeField(30, 2, encodeMessageId(mid.messageId, mid.role))); - } - - // Field 35: largeContext = 0 - parts.push(encodeField(35, 0, 0)); - - // Field 38: unknown38 = 0 - parts.push(encodeField(38, 0, 0)); - - // Field 46: chatModeEnum = 1 - parts.push(encodeField(46, 0, 1)); - - // Field 47: unknown47 = "" - parts.push(encodeField(47, 2, "")); - - // Field 48-51, 53 - parts.push(encodeField(48, 0, 0)); - parts.push(encodeField(49, 0, 0)); - parts.push(encodeField(51, 0, 0)); - parts.push(encodeField(53, 0, 1)); - - // Field 54: chatMode = "Ask" - parts.push(encodeField(54, 2, "Ask")); - - return concatArrays(...parts); + // Mode fields + encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.UNIFIED_MODE, WIRE_TYPE.VARINT, isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT), + encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ""), + encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1), + encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel), + encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? "Agent" : "Ask") + ); } -/** - * Build the full StreamUnifiedChatWithToolsRequest - */ -export function buildChatRequest(messages, modelName) { - // Field 1: Request request - const requestBytes = encodeRequest(messages, modelName); - return encodeField(1, 2, requestBytes); +export function buildChatRequest(messages, modelName, tools = [], reasoningEffort = null) { + return encodeField(FIELD.REQUEST, WIRE_TYPE.LEN, encodeRequest(messages, modelName, tools, reasoningEffort)); } -/** - * Wrap payload with ConnectRPC frame header - * - * Frame format: [flags:1][length:4][payload] - * - flags: 0x00 = uncompressed, 0x01 = gzip compressed - * - length: big-endian 32-bit length - * - * @param {Uint8Array} payload - Protobuf payload - * @param {boolean} compress - Whether to gzip compress (for messages >= 3) - * @returns {Uint8Array} - Framed data - */ export function wrapConnectRPCFrame(payload, compress = false) { let finalPayload = payload; let flags = 0x00; @@ -311,45 +326,29 @@ export function wrapConnectRPCFrame(payload, compress = false) { flags = 0x01; } - // Create frame: [flags:1][length:4][payload] const frame = new Uint8Array(5 + finalPayload.length); frame[0] = flags; - - // Big-endian length - const length = finalPayload.length; - frame[1] = (length >> 24) & 0xFF; - frame[2] = (length >> 16) & 0xFF; - frame[3] = (length >> 8) & 0xFF; - frame[4] = length & 0xFF; - + frame[1] = (finalPayload.length >> 24) & 0xFF; + frame[2] = (finalPayload.length >> 16) & 0xFF; + frame[3] = (finalPayload.length >> 8) & 0xFF; + frame[4] = finalPayload.length & 0xFF; frame.set(finalPayload, 5); + return frame; } -/** - * Generate complete Cursor request body - * @param {Array} messages - Array of {role, content} messages - * @param {string} modelName - Model name - * @returns {Uint8Array} - Complete request body - */ -export function generateCursorBody(messages, modelName) { - const protobuf = buildChatRequest(messages, modelName); - - // Compress if >= 3 messages - const shouldCompress = messages.length >= 3; - return wrapConnectRPCFrame(protobuf, shouldCompress); +export function generateCursorBody(messages, modelName, tools = [], reasoningEffort = null) { + log("BODY", `Generating: ${messages.length} msgs, model=${modelName}, tools=${tools.length}, reasoning=${reasoningEffort || "none"}`); + + const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort); + const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests + + log("BODY", `Protobuf=${protobuf.length}B, Framed=${framed.length}B`); + return framed; } -// ============================================================================= -// Decoding Functions -// ============================================================================= +// ==================== PRIMITIVE DECODING ==================== -/** - * Decode a varint from buffer - * @param {Uint8Array} buffer - Input buffer - * @param {number} offset - Start offset - * @returns {[number, number]} - [value, newOffset] - */ export function decodeVarint(buffer, offset) { let result = 0; let shift = 0; @@ -366,16 +365,8 @@ export function decodeVarint(buffer, offset) { return [result, pos]; } -/** - * Decode a single protobuf field - * @param {Uint8Array} buffer - Input buffer - * @param {number} offset - Start offset - * @returns {[number, number, any, number]} - [fieldNum, wireType, value, newOffset] - */ export function decodeField(buffer, offset) { - if (offset >= buffer.length) { - return [null, null, null, offset]; - } + if (offset >= buffer.length) return [null, null, null, offset]; const [tag, pos1] = decodeVarint(buffer, offset); const fieldNum = tag >> 3; @@ -384,20 +375,16 @@ export function decodeField(buffer, offset) { let value; let pos = pos1; - if (wireType === 0) { - // Varint + if (wireType === WIRE_TYPE.VARINT) { [value, pos] = decodeVarint(buffer, pos); - } else if (wireType === 2) { - // Length-delimited + } else if (wireType === WIRE_TYPE.LEN) { const [length, pos2] = decodeVarint(buffer, pos); value = buffer.slice(pos2, pos2 + length); pos = pos2 + length; - } else if (wireType === 1) { - // Fixed64 + } else if (wireType === WIRE_TYPE.FIXED64) { value = buffer.slice(pos, pos + 8); pos += 8; - } else if (wireType === 5) { - // Fixed32 + } else if (wireType === WIRE_TYPE.FIXED32) { value = buffer.slice(pos, pos + 4); pos += 4; } else { @@ -407,11 +394,6 @@ export function decodeField(buffer, offset) { return [fieldNum, wireType, value, pos]; } -/** - * Decode all fields from a protobuf message - * @param {Uint8Array} data - Protobuf data - * @returns {Map} - Map of fieldNum -> [{wireType, value}] - */ export function decodeMessage(data) { const fields = new Map(); let pos = 0; @@ -420,9 +402,7 @@ export function decodeMessage(data) { const [fieldNum, wireType, value, newPos] = decodeField(data, pos); if (fieldNum === null) break; - if (!fields.has(fieldNum)) { - fields.set(fieldNum, []); - } + if (!fields.has(fieldNum)) fields.set(fieldNum, []); fields.get(fieldNum).push({ wireType, value }); pos = newPos; } @@ -430,11 +410,8 @@ export function decodeMessage(data) { return fields; } -/** - * Parse ConnectRPC frame - * @param {Uint8Array} buffer - Input buffer - * @returns {{flags: number, length: number, payload: Uint8Array, consumed: number} | null} - */ +// ==================== RESPONSE PARSING ==================== + export function parseConnectRPCFrame(buffer) { if (buffer.length < 5) return null; @@ -445,92 +422,138 @@ export function parseConnectRPCFrame(buffer) { let payload = buffer.slice(5, 5 + length); - // Decompress if gzip flag is set + // Decompress if gzip if (flags === 0x01) { try { payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload))); - } catch { - // Decompression failed, return raw + } catch (err) { + log("PARSE", `Decompression failed: ${err.message}`); } } - return { - flags, - length, - payload, - consumed: 5 + length - }; + return { flags, length, payload, consumed: 5 + length }; +} + +function extractToolCall(toolCallData) { + const toolCall = decodeMessage(toolCallData); + let toolCallId = ""; + let toolName = ""; + let rawArgs = ""; + + // Extract tool call ID + if (toolCall.has(FIELD.TOOL_ID)) { + const fullId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value); + toolCallId = fullId.split("\n")[0]; // Cursor returns multi-line ID, take first line + } + + // Extract tool name + if (toolCall.has(FIELD.TOOL_NAME)) { + toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value); + } + + // Extract MCP params - nested real tool info + if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) { + try { + const mcpParams = decodeMessage(toolCall.get(FIELD.TOOL_MCP_PARAMS)[0].value); + + if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) { + const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value); + + if (tool.has(FIELD.MCP_NESTED_NAME)) { + toolName = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value); + } + + if (tool.has(FIELD.MCP_NESTED_PARAMS)) { + rawArgs = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value); + } + } + } catch (err) { + log("EXTRACT", `MCP parse error: ${err.message}`); + } + } + + // Fallback to raw_args + if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) { + rawArgs = new TextDecoder().decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value); + } + + if (toolCallId && toolName) { + return { + id: toolCallId, + type: "function", + function: { + name: toolName, + arguments: rawArgs || "{}" + } + }; + } + + return null; +} + +function extractTextAndThinking(responseData) { + const nested = decodeMessage(responseData); + let text = null; + let thinking = null; + + // Extract text + if (nested.has(FIELD.RESPONSE_TEXT)) { + text = new TextDecoder().decode(nested.get(FIELD.RESPONSE_TEXT)[0].value); + } + + // Extract thinking + if (nested.has(FIELD.THINKING)) { + try { + const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value); + if (thinkingMsg.has(FIELD.THINKING_TEXT)) { + thinking = new TextDecoder().decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value); + } + } catch (err) { + log("EXTRACT", `Thinking parse error: ${err.message}`); + } + } + + return { text, thinking }; } -/** - * Extract text content or error from response protobuf - * - * Response structure (from cursor-grpc/server_full.proto): - * - * message StreamUnifiedChatResponseWithTools { - * oneof response { - * ClientSideToolV2Call client_side_tool_v2_call = 1; - * StreamUnifiedChatResponse stream_unified_chat_response = 2; - * } - * } - * - * message StreamUnifiedChatResponse { - * string text = 1; // <-- THE TEXT WE NEED - * } - * - * @param {Uint8Array} payload - Decoded protobuf payload - * @returns {{text: string|null, error: string|null}} - Extracted content - */ export function extractTextFromResponse(payload) { try { const fields = decodeMessage(payload); - // Field 2 = StreamUnifiedChatResponse (contains the text) - if (fields.has(2)) { - for (const { wireType, value } of fields.get(2)) { - if (wireType === 2) { - // Decode nested StreamUnifiedChatResponse - try { - const nested = decodeMessage(value); - - // Field 1 = text (string) - if (nested.has(1)) { - for (const { wireType: nwt, value: nv } of nested.get(1)) { - if (nwt === 2) { - try { - const text = new TextDecoder().decode(nv); - // Return any non-empty text - if (text && text.length > 0) { - return { text, error: null }; - } - } catch {} - } - } - } - } catch {} - } + // Field 1: ClientSideToolV2Call + if (fields.has(FIELD.TOOL_CALL)) { + const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value); + if (toolCall) { + log("EXTRACT", `Tool call: ${toolCall.function.name}`); + return { text: null, error: null, toolCall, thinking: null }; } } - // Field 1 could be ClientSideToolV2Call (skip for now) - // Field 3 could be ConversationSummary (skip for now) + // Field 2: StreamUnifiedChatResponse + if (fields.has(FIELD.RESPONSE)) { + const { text, thinking } = extractTextAndThinking(fields.get(FIELD.RESPONSE)[0].value); + + if (text || thinking) { + return { text, error: null, toolCall: null, thinking }; + } + } - return { text: null, error: null }; - } catch { - return { text: null, error: null }; + return { text: null, error: null, toolCall: null, thinking: null }; + } catch (err) { + log("EXTRACT", `Error: ${err.message}`); + return { text: null, error: null, toolCall: null, thinking: null }; } } +// ==================== EXPORTS ==================== + export default { - // Encoding encodeVarint, encodeField, encodeMessage, buildChatRequest, wrapConnectRPCFrame, generateCursorBody, - - // Decoding decodeVarint, decodeField, decodeMessage, diff --git a/package.json b/package.json index 7e023da..6eea2c5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.6.2", "express": "^5.2.1", "fs": "^0.0.1-security", "http-proxy-middleware": "^3.0.5", @@ -35,4 +36,4 @@ "eslint-config-next": "16.1.6", "tailwindcss": "^4" } -} \ No newline at end of file +} diff --git a/src/app/api/oauth/cursor/auto-import/route.js b/src/app/api/oauth/cursor/auto-import/route.js new file mode 100644 index 0000000..2a9eec8 --- /dev/null +++ b/src/app/api/oauth/cursor/auto-import/route.js @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { homedir } from "os"; +import { join } from "path"; +import Database from "better-sqlite3"; + +/** + * GET /api/oauth/cursor/auto-import + * Auto-detect and extract Cursor tokens from local SQLite database + */ +export async function GET() { + try { + const platform = process.platform; + let dbPath; + + // Determine database path based on platform + if (platform === "darwin") { + dbPath = join(homedir(), "Library/Application Support/Cursor/User/globalStorage/state.vscdb"); + } else if (platform === "linux") { + dbPath = join(homedir(), ".config/Cursor/User/globalStorage/state.vscdb"); + } else if (platform === "win32") { + dbPath = join(process.env.APPDATA || "", "Cursor/User/globalStorage/state.vscdb"); + } else { + return NextResponse.json( + { error: "Unsupported platform", found: false }, + { status: 400 } + ); + } + + // Try to open database + let db; + try { + db = new Database(dbPath, { readonly: true, fileMustExist: true }); + } catch (error) { + return NextResponse.json({ + found: false, + error: "Cursor database not found. Make sure Cursor IDE is installed and you are logged in.", + }); + } + + try { + // Extract tokens from database + const rows = db.prepare( + "SELECT key, value FROM itemTable WHERE key IN (?, ?)" + ).all("cursorAuth/accessToken", "storage.serviceMachineId"); + + const tokens = {}; + for (const row of rows) { + if (row.key === "cursorAuth/accessToken") { + tokens.accessToken = row.value; + } else if (row.key === "storage.serviceMachineId") { + tokens.machineId = row.value; + } + } + + db.close(); + + // Validate tokens exist + if (!tokens.accessToken || !tokens.machineId) { + return NextResponse.json({ + found: false, + error: "Tokens not found in database. Please login to Cursor IDE first.", + }); + } + + return NextResponse.json({ + found: true, + accessToken: tokens.accessToken, + machineId: tokens.machineId, + }); + } catch (error) { + db?.close(); + return NextResponse.json({ + found: false, + error: `Failed to read database: ${error.message}`, + }); + } + } catch (error) { + console.log("Cursor auto-import error:", error); + return NextResponse.json( + { found: false, error: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/oauth/kiro/auto-import/route.js b/src/app/api/oauth/kiro/auto-import/route.js new file mode 100644 index 0000000..e2a6182 --- /dev/null +++ b/src/app/api/oauth/kiro/auto-import/route.js @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import { readFile, readdir } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; + +/** + * GET /api/oauth/kiro/auto-import + * Auto-detect and extract Kiro refresh token from AWS SSO cache + */ +export async function GET() { + try { + const cachePath = join(homedir(), ".aws/sso/cache"); + + // Try to read cache directory + let files; + try { + files = await readdir(cachePath); + } catch (error) { + return NextResponse.json({ + found: false, + error: "AWS SSO cache not found. Please login to Kiro IDE first.", + }); + } + + // Look for kiro-auth-token.json or any .json file with refreshToken + let refreshToken = null; + let foundFile = null; + + // First try kiro-auth-token.json + const kiroTokenFile = "kiro-auth-token.json"; + if (files.includes(kiroTokenFile)) { + try { + const content = await readFile(join(cachePath, kiroTokenFile), "utf-8"); + const data = JSON.parse(content); + if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { + refreshToken = data.refreshToken; + foundFile = kiroTokenFile; + } + } catch (error) { + // Continue to search other files + } + } + + // If not found, search all .json files + if (!refreshToken) { + for (const file of files) { + if (!file.endsWith(".json")) continue; + + try { + const content = await readFile(join(cachePath, file), "utf-8"); + const data = JSON.parse(content); + + // Look for Kiro refresh token (starts with aorAAAAAG) + if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { + refreshToken = data.refreshToken; + foundFile = file; + break; + } + } catch (error) { + // Skip invalid JSON files + continue; + } + } + } + + if (!refreshToken) { + return NextResponse.json({ + found: false, + error: "Kiro token not found in AWS SSO cache. Please login to Kiro IDE first.", + }); + } + + return NextResponse.json({ + found: true, + refreshToken, + source: foundFile, + }); + } catch (error) { + console.log("Kiro auto-import error:", error); + return NextResponse.json( + { found: false, error: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/usage/request-logs/route.js b/src/app/api/usage/request-logs/route.js new file mode 100644 index 0000000..0ae5e96 --- /dev/null +++ b/src/app/api/usage/request-logs/route.js @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getRecentLogs } from "@/lib/usageDb"; + +export async function GET() { + try { + const logs = await getRecentLogs(200); + return NextResponse.json(logs); + } catch (error) { + console.error("[API ERROR] /api/usage/logs failed:", error); + console.error("[API ERROR] Stack:", error?.stack); + return NextResponse.json({ error: "Failed to fetch logs" }, { status: 500 }); + } +} diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 40123c1..b658001 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -26,15 +26,21 @@ function getAppName() { function getUserDataDir() { if (isCloud) return "/tmp"; // Fallback for Workers - const platform = process.platform; - const homeDir = os.homedir(); - const appName = getAppName(); + try { + const platform = process.platform; + const homeDir = os.homedir(); + const appName = getAppName(); - if (platform === "win32") { - return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName); - } else { - // macOS & Linux: ~/.{appName} - return path.join(homeDir, `.${appName}`); + if (platform === "win32") { + return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName); + } else { + // macOS & Linux: ~/.{appName} + return path.join(homeDir, `.${appName}`); + } + } catch (error) { + console.error("[usageDb] Failed to get user data directory:", error.message); + // Fallback to cwd if homedir fails + return path.join(process.cwd(), ".9router"); } } @@ -44,8 +50,15 @@ const DB_FILE = isCloud ? null : path.join(DATA_DIR, "usage.json"); const LOG_FILE = isCloud ? null : path.join(DATA_DIR, "log.txt"); // Ensure data directory exists -if (!isCloud && !fs.existsSync(DATA_DIR)) { - fs.mkdirSync(DATA_DIR, { recursive: true }); +if (!isCloud && fs && typeof fs.existsSync === "function") { + try { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + console.log(`[usageDb] Created data directory: ${DATA_DIR}`); + } + } catch (error) { + console.error("[usageDb] Failed to create data directory:", error.message); + } } // Default data structure @@ -245,13 +258,30 @@ export async function appendRequestLog({ model, provider, connectionId, tokens, */ export async function getRecentLogs(limit = 200) { if (isCloud) return []; // Skip in Workers - if (!fs.existsSync(LOG_FILE)) return []; + + // Runtime check: ensure fs module is available + if (!fs || typeof fs.existsSync !== "function") { + console.error("[usageDb] fs module not available in this environment"); + return []; + } + + if (!LOG_FILE) { + console.error("[usageDb] LOG_FILE path not defined"); + return []; + } + + if (!fs.existsSync(LOG_FILE)) { + console.log(`[usageDb] Log file does not exist: ${LOG_FILE}`); + return []; + } + try { const content = fs.readFileSync(LOG_FILE, "utf-8"); const lines = content.trim().split("\n"); return lines.slice(-limit).reverse(); } catch (error) { - console.error("Failed to read log.txt:", error.message); + console.error("[usageDb] Failed to read log.txt:", error.message); + console.error("[usageDb] LOG_FILE path:", LOG_FILE); return []; } } diff --git a/src/shared/components/CursorAuthModal.js b/src/shared/components/CursorAuthModal.js index e70d217..04da8f7 100644 --- a/src/shared/components/CursorAuthModal.js +++ b/src/shared/components/CursorAuthModal.js @@ -1,29 +1,50 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; -import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; /** * Cursor Auth Modal - * Import token from Cursor IDE's local SQLite database - * - * Token Location: - * - Linux: ~/.config/Cursor/User/globalStorage/state.vscdb - * - macOS: /Users//Library/Application Support/Cursor/User/globalStorage/state.vscdb - * - Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb - * - * Database Keys: - * - cursorAuth/accessToken: The access token - * - storage.serviceMachineId: Machine ID for checksum + * Auto-detect and import token from Cursor IDE's local SQLite database */ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { const [accessToken, setAccessToken] = useState(""); const [machineId, setMachineId] = useState(""); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); - const { copied, copy } = useCopyToClipboard(); + const [autoDetecting, setAutoDetecting] = useState(false); + const [autoDetected, setAutoDetected] = useState(false); + + // Auto-detect tokens when modal opens + useEffect(() => { + if (!isOpen) return; + + const autoDetect = async () => { + setAutoDetecting(true); + setError(null); + setAutoDetected(false); + + try { + const res = await fetch("/api/oauth/cursor/auto-import"); + const data = await res.json(); + + if (data.found) { + setAccessToken(data.accessToken); + setMachineId(data.machineId); + setAutoDetected(true); + } else { + setError(data.error || "Could not auto-detect tokens"); + } + } catch (err) { + setError("Failed to auto-detect tokens"); + } finally { + setAutoDetecting(false); + } + }; + + autoDetect(); + }, [isOpen]); const handleImportToken = async () => { if (!accessToken.trim()) { @@ -65,130 +86,100 @@ export default function CursorAuthModal({ isOpen, onSuccess, onClose }) { } }; - const linuxCommand = `sqlite3 ~/.config/Cursor/User/globalStorage/state.vscdb "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`; - const macCommand = `sqlite3 "/Users/$USER/Library/Application Support/Cursor/User/globalStorage/state.vscdb" "SELECT key, value FROM itemTable WHERE key IN ('cursorAuth/accessToken', 'storage.serviceMachineId')"`; - return ( - +
- {/* Info Box */} -
-
- info -
-

- Prerequisites -

-

- Make sure you are logged in to Cursor IDE first. Tokens are stored in the local SQLite database. -

+ {/* Auto-detecting state */} + {autoDetecting && ( +
+
+ + progress_activity +
-
-
- - {/* Instructions */} -
-

How to get your tokens:

- -
-

Linux:

-
- - {linuxCommand} - - -
-
- -
-

macOS:

-
- - {macCommand} - - -
-
- -
-

Database locations:

-
    -
  • Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
  • -
  • macOS: /Users/<user>/Library/Application Support/Cursor/User/globalStorage/state.vscdb
  • -
  • Windows: %APPDATA%\Cursor\User\globalStorage\state.vscdb
  • -
-
-
- - {/* Access Token Input */} -
- -