mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat(cursor): Add cursor Provider
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export const FORMATS = {
|
||||
GEMINI_CLI: "gemini-cli",
|
||||
CODEX: "codex",
|
||||
ANTIGRAVITY: "antigravity",
|
||||
KIRO: "kiro"
|
||||
KIRO: "kiro",
|
||||
CURSOR: "cursor"
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
96
open-sse/translator/request/openai-to-cursor.js
Normal file
96
open-sse/translator/request/openai-to-cursor.js
Normal file
@@ -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);
|
||||
30
open-sse/translator/response/cursor-to-openai.js
Normal file
30
open-sse/translator/response/cursor-to-openai.js
Normal file
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number, Array>} - 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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
84
src/app/api/oauth/cursor/auto-import/route.js
Normal file
84
src/app/api/oauth/cursor/auto-import/route.js
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
85
src/app/api/oauth/kiro/auto-import/route.js
Normal file
85
src/app/api/oauth/kiro/auto-import/route.js
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/app/api/usage/request-logs/route.js
Normal file
13
src/app/api/usage/request-logs/route.js
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<user>/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 (
|
||||
<Modal isOpen={isOpen} title="Connect Cursor IDE" onClose={onClose} size="lg">
|
||||
<Modal isOpen={isOpen} title="Connect Cursor IDE" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-2">
|
||||
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">info</span>
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
Prerequisites
|
||||
</p>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
Make sure you are logged in to Cursor IDE first. Tokens are stored in the local SQLite database.
|
||||
</p>
|
||||
{/* Auto-detecting state */}
|
||||
{autoDetecting && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">How to get your tokens:</p>
|
||||
|
||||
<div className="bg-sidebar/50 p-3 rounded-lg space-y-2">
|
||||
<p className="text-xs text-text-muted">Linux:</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="text-xs bg-sidebar px-2 py-1 rounded flex-1 overflow-x-auto whitespace-pre">
|
||||
{linuxCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(linuxCommand, "linux-cmd")}
|
||||
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary flex-shrink-0"
|
||||
title="Copy command"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === "linux-cmd" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-sidebar/50 p-3 rounded-lg space-y-2">
|
||||
<p className="text-xs text-text-muted">macOS:</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="text-xs bg-sidebar px-2 py-1 rounded flex-1 overflow-x-auto whitespace-pre">
|
||||
{macCommand}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copy(macCommand, "mac-cmd")}
|
||||
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary flex-shrink-0"
|
||||
title="Copy command"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === "mac-cmd" ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-text-muted">
|
||||
<p className="mb-1">Database locations:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
<li>Linux: <code className="bg-sidebar px-1 rounded">~/.config/Cursor/User/globalStorage/state.vscdb</code></li>
|
||||
<li>macOS: <code className="bg-sidebar px-1 rounded">/Users/<user>/Library/Application Support/Cursor/User/globalStorage/state.vscdb</code></li>
|
||||
<li>Windows: <code className="bg-sidebar px-1 rounded">%APPDATA%\Cursor\User\globalStorage\state.vscdb</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Token Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Access Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
placeholder="Paste your access token here..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
From key: <code className="bg-sidebar px-1 rounded">cursorAuth/accessToken</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Machine ID Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Machine ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={machineId}
|
||||
onChange={(e) => setMachineId(e.target.value)}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
From key: <code className="bg-sidebar px-1 rounded">storage.serviceMachineId</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
<h3 className="text-lg font-semibold mb-2">Auto-detecting tokens...</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Reading from Cursor IDE database
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleImportToken}
|
||||
fullWidth
|
||||
disabled={importing || !accessToken.trim() || !machineId.trim()}
|
||||
>
|
||||
{importing ? "Importing..." : "Import Token"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{/* Form (shown after auto-detect completes) */}
|
||||
{!autoDetecting && (
|
||||
<>
|
||||
{/* Success message if auto-detected */}
|
||||
{autoDetected && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex gap-2">
|
||||
<span className="material-symbols-outlined text-green-600 dark:text-green-400">check_circle</span>
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Tokens auto-detected from Cursor IDE successfully!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info message if not auto-detected */}
|
||||
{!autoDetected && !error && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-2">
|
||||
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">info</span>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Cursor IDE not detected. Please paste your tokens manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Access Token Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Access Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={accessToken}
|
||||
onChange={(e) => setAccessToken(e.target.value)}
|
||||
placeholder="Access token will be auto-filled..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Machine ID Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Machine ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={machineId}
|
||||
onChange={(e) => setMachineId(e.target.value)}
|
||||
placeholder="Machine ID will be auto-filled..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleImportToken}
|
||||
fullWidth
|
||||
disabled={importing || !accessToken.trim() || !machineId.trim()}
|
||||
>
|
||||
{importing ? "Importing..." : "Import Token"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
"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";
|
||||
|
||||
/**
|
||||
* Kiro Auth Method Selection Modal
|
||||
* Allows user to choose between multiple Kiro authentication methods:
|
||||
* 1. AWS Builder ID (Device Code)
|
||||
* 2. AWS IAM Identity Center/IDC (Device Code with custom startUrl/region)
|
||||
* 3. Google Social Login (Manual callback)
|
||||
* 4. GitHub Social Login (Manual callback)
|
||||
* 5. Import Token (Paste refresh token)
|
||||
* Auto-detects token from AWS SSO cache or allows manual import
|
||||
*/
|
||||
export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
|
||||
const [selectedMethod, setSelectedMethod] = useState(null);
|
||||
@@ -21,7 +15,37 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
|
||||
const [refreshToken, setRefreshToken] = 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 token when import method is selected
|
||||
useEffect(() => {
|
||||
if (selectedMethod !== "import" || !isOpen) return;
|
||||
|
||||
const autoDetect = async () => {
|
||||
setAutoDetecting(true);
|
||||
setError(null);
|
||||
setAutoDetected(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/oauth/kiro/auto-import");
|
||||
const data = await res.json();
|
||||
|
||||
if (data.found) {
|
||||
setRefreshToken(data.refreshToken);
|
||||
setAutoDetected(true);
|
||||
} else {
|
||||
setError(data.error || "Could not auto-detect token");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to auto-detect token");
|
||||
} finally {
|
||||
setAutoDetecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
autoDetect();
|
||||
}, [selectedMethod, isOpen]);
|
||||
|
||||
const handleMethodSelect = (method) => {
|
||||
setSelectedMethod(method);
|
||||
@@ -275,42 +299,76 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
|
||||
{/* Import Token */}
|
||||
{selectedMethod === "import" && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800 mb-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 Please login to Kiro IDE first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Refresh Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={refreshToken}
|
||||
onChange={(e) => setRefreshToken(e.target.value)}
|
||||
placeholder="aorAAAAAG..."
|
||||
className="font-mono text-sm"
|
||||
type="password"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Find it in Kiro IDE at: <code className="bg-sidebar px-1 rounded">~/.aws/sso/cache/kiro-auth-token.json</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
{/* Auto-detecting state */}
|
||||
{autoDetecting && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Auto-detecting token...</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Reading from AWS SSO cache
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleImportToken} fullWidth disabled={importing}>
|
||||
{importing ? "Importing..." : "Import Token"}
|
||||
</Button>
|
||||
<Button onClick={handleBack} variant="ghost" fullWidth>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
{/* Form (shown after auto-detect completes) */}
|
||||
{!autoDetecting && (
|
||||
<>
|
||||
{/* Success message if auto-detected */}
|
||||
{autoDetected && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex gap-2">
|
||||
<span className="material-symbols-outlined text-green-600 dark:text-green-400">check_circle</span>
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Token auto-detected from Kiro IDE successfully!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info message if not auto-detected */}
|
||||
{!autoDetected && !error && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-2">
|
||||
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">info</span>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Kiro IDE not detected. Please paste your refresh token manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Refresh Token <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={refreshToken}
|
||||
onChange={(e) => setRefreshToken(e.target.value)}
|
||||
placeholder="Token will be auto-filled..."
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleImportToken} fullWidth disabled={importing || !refreshToken.trim()}>
|
||||
{importing ? "Importing..." : "Import Token"}
|
||||
</Button>
|
||||
<Button onClick={handleBack} variant="ghost" fullWidth>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function RequestLogger() {
|
||||
const fetchLogs = async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/usage/logs");
|
||||
const res = await fetch("/api/usage/request-logs");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setLogs(data);
|
||||
|
||||
Reference in New Issue
Block a user