diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 36ba4d92..72e45781 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -43,16 +43,16 @@ export const PROVIDER_MODELS = { { id: "glm-4.7", name: "GLM 4.7" }, ], ag: [ // Antigravity - special case: models call different backends - { id: "gemini-3-pro-low", name: "Gemini 3 Pro Low" }, + { id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5" }, + { id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, { id: "gemini-3-pro-high", name: "Gemini 3 Pro High" }, + { id: "gemini-3-pro-low", name: "Gemini 3 Pro Low" }, { id: "gemini-3-flash", name: "Gemini 3 Flash" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, - { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 " }, - { id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking" }, - { id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5" }, ], - gh: [ // GitHub Copilot - { id: "gpt-5", name: "GPT-5" }, + gh: [ // GitHub Copilot - always uses OpenAI format (Copilot API is OpenAI-compatible) { id: "gpt-5-mini", name: "GPT-5 Mini" }, // { id: "gpt-5.1", name: "GPT-5.1" }, // { id: "gpt-5.2", name: "GPT-5.2" }, @@ -147,9 +147,12 @@ export function findModelName(aliasOrId, modelId) { export function getModelTargetFormat(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; - if (!models) return null; - const found = models.find(m => m.id === modelId); - return found?.targetFormat || null; + if (models) { + const found = models.find(m => m.id === modelId); + if (found?.targetFormat) return found.targetFormat; + } + + return null; } // Provider ID to alias mapping diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 945979c6..190429db 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -5,6 +5,9 @@ import { parseConnectRPCFrame, extractTextFromResponse } from "../utils/cursorProtobuf.js"; +import { estimateUsage } from "../utils/usageTracking.js"; +import { FORMATS } from "../translator/formats.js"; +import { buildCursorRequest } from "../translator/request/openai-to-cursor.js"; import crypto from "crypto"; import { v5 as uuidv5 } from "uuid"; import zlib from "zlib"; @@ -34,11 +37,26 @@ const COMPRESS_FLAG = { }; function decompressPayload(payload, flags) { + // Check if payload is JSON error (starts with {"error") + if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { + try { + const text = payload.toString('utf-8'); + if (text.startsWith('{"error"')) { + console.log(`[DECOMPRESS] Detected JSON error, skipping decompression`); + return payload; + } + } catch {} + } + if (flags === COMPRESS_FLAG.GZIP || flags === COMPRESS_FLAG.GZIP_ALT || flags === COMPRESS_FLAG.GZIP_BOTH) { try { return zlib.gunzipSync(payload); - } catch { - return null; + } catch (err) { + console.log(`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, error=${err.message}`); + console.log(`[DECOMPRESS ERROR] First 50 bytes (hex):`, payload.slice(0, 50).toString('hex')); + console.log(`[DECOMPRESS ERROR] First 50 bytes (utf8):`, payload.slice(0, 50).toString('utf8').replace(/[^\x20-\x7E]/g, '.')); + // Try to use payload as-is if decompression fails + return payload; } } return payload; @@ -147,8 +165,10 @@ export class CursorExecutor extends BaseExecutor { } transformRequest(model, body, stream, credentials) { - const messages = body.messages || []; - const tools = body.tools || []; + // Call translator to convert OpenAI format to Cursor format + const translatedBody = buildCursorRequest(model, body, stream, credentials); + const messages = translatedBody.messages || []; + const tools = translatedBody.tools || body.tools || []; const reasoningEffort = body.reasoning_effort || null; return generateCursorBody(messages, model, tools, reasoningEffort); } @@ -243,8 +263,8 @@ export class CursorExecutor extends BaseExecutor { } const transformedResponse = stream !== false - ? this.transformProtobufToSSE(response.body, model) - : this.transformProtobufToJSON(response.body, model); + ? this.transformProtobufToSSE(response.body, model, body) + : this.transformProtobufToJSON(response.body, model, body); return { response: transformedResponse, url, headers, transformedBody: body }; } catch (error) { @@ -262,108 +282,43 @@ export class CursorExecutor extends BaseExecutor { } } - transformProtobufToJSON(buffer, model) { + transformProtobufToJSON(buffer, model, body) { const responseId = `chatcmpl-cursor-${Date.now()}`; const created = Math.floor(Date.now() / 1000); let offset = 0; let totalContent = ""; const toolCalls = []; + const toolCallsMap = new Map(); // Track streaming tool calls by ID + let frameCount = 0; + + console.log(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); while (offset < buffer.length) { - if (offset + 5 > buffer.length) break; + if (offset + 5 > buffer.length) { + console.log(`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}`); + break; + } const flags = buffer[offset]; const length = buffer.readUInt32BE(offset + 1); - if (offset + 5 + length > buffer.length) break; + console.log(`[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, '0')}, length=${length}`); + + if (offset + 5 + length > buffer.length) { + console.log(`[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`); + break; + } let payload = buffer.slice(offset + 5, offset + 5 + length); offset += 5 + length; + frameCount++; payload = decompressPayload(payload, flags); - if (!payload) continue; - - try { - const text = payload.toString("utf-8"); - if (text.startsWith("{") && text.includes('"error"')) { - return createErrorResponse(JSON.parse(text)); - } - } catch {} - - const result = extractTextFromResponse(new Uint8Array(payload)); - - if (result.error) { - return new Response(JSON.stringify({ - error: { - message: result.error, - type: "rate_limit_error", - code: "rate_limited" - } - }), { - status: 429, - headers: { "Content-Type": "application/json" } - }); - } - - if (result.toolCall) toolCalls.push(result.toolCall); - if (result.text) totalContent += result.text; - } - - const message = { - role: "assistant", - content: totalContent || null - }; - - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; - } - - const completion = { - id: responseId, - object: "chat.completion", - created, - model, - choices: [{ - index: 0, - message, - finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop" - }], - usage: { - prompt_tokens: 10, - completion_tokens: Math.max(1, Math.floor(totalContent.length / 4)), - total_tokens: 10 + Math.max(1, Math.floor(totalContent.length / 4)) - } - }; - - return new Response(JSON.stringify(completion), { - status: 200, - headers: { "Content-Type": "application/json" } - }); - } - - transformProtobufToSSE(buffer, model) { - const responseId = `chatcmpl-cursor-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - - const chunks = []; - let offset = 0; - let totalContent = ""; - const toolCalls = []; - - while (offset < buffer.length) { - if (offset + 5 > buffer.length) break; - - const flags = buffer[offset]; - const length = buffer.readUInt32BE(offset + 1); - - if (offset + 5 + length > buffer.length) break; - - let payload = buffer.slice(offset + 5, offset + 5 + length); - offset += 5 + length; - - payload = decompressPayload(payload, flags); - if (!payload) continue; + if (!payload) { + console.log(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`); + continue; + } try { const text = payload.toString("utf-8"); @@ -373,6 +328,7 @@ export class CursorExecutor extends BaseExecutor { } catch {} const result = extractTextFromResponse(new Uint8Array(payload)); + console.log(`[CURSOR DECODED] Frame ${frameCount}:`, result); if (result.error) { return new Response(JSON.stringify({ @@ -388,7 +344,149 @@ export class CursorExecutor extends BaseExecutor { } if (result.toolCall) { - toolCalls.push(result.toolCall); + const tc = result.toolCall; + + if (toolCallsMap.has(tc.id)) { + // Accumulate arguments for existing tool call + const existing = toolCallsMap.get(tc.id); + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + } else { + // New tool call + toolCallsMap.set(tc.id, { ...tc }); + } + + // Push to final array when isLast is true + if (tc.isLast) { + const finalToolCall = toolCallsMap.get(tc.id); + toolCalls.push({ + id: finalToolCall.id, + type: finalToolCall.type, + function: { + name: finalToolCall.function.name, + arguments: finalToolCall.function.arguments + } + }); + } + } + + if (result.text) totalContent += result.text; + } + + console.log(`[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}`); + + // Finalize all remaining tool calls in map (in case stream ended without isLast=true) + for (const [id, tc] of toolCallsMap.entries()) { + // Check if already in final array + if (!toolCalls.find(t => t.id === id)) { + console.log(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); + toolCalls.push({ + id: tc.id, + type: tc.type, + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + }); + } + } + + console.log(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`); + + const message = { + role: "assistant", + content: totalContent || null + }; + + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + } + + const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); + + const completion = { + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ + index: 0, + message, + finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop" + }], + usage + }; + + return new Response(JSON.stringify(completion), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + transformProtobufToSSE(buffer, model, body) { + const responseId = `chatcmpl-cursor-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const chunks = []; + let offset = 0; + let totalContent = ""; + const toolCalls = []; + const toolCallsMap = new Map(); // Track streaming tool calls by ID + let frameCount = 0; + + console.log(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); + + while (offset < buffer.length) { + if (offset + 5 > buffer.length) { + console.log(`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}`); + break; + } + + const flags = buffer[offset]; + const length = buffer.readUInt32BE(offset + 1); + + console.log(`[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, '0')}, length=${length}`); + + if (offset + 5 + length > buffer.length) { + console.log(`[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`); + break; + } + + let payload = buffer.slice(offset + 5, offset + 5 + length); + offset += 5 + length; + frameCount++; + + payload = decompressPayload(payload, flags); + if (!payload) { + console.log(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`); + continue; + } + + try { + const text = payload.toString("utf-8"); + if (text.startsWith("{") && text.includes('"error"')) { + return createErrorResponse(JSON.parse(text)); + } + } catch {} + + const result = extractTextFromResponse(new Uint8Array(payload)); + console.log(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result); + + if (result.error) { + return new Response(JSON.stringify({ + error: { + message: result.error, + type: "rate_limit_error", + code: "rate_limited" + } + }), { + status: 429, + headers: { "Content-Type": "application/json" } + }); + } + + if (result.toolCall) { + const tc = result.toolCall; if (chunks.length === 0) { chunks.push(`data: ${JSON.stringify({ @@ -404,17 +502,66 @@ export class CursorExecutor extends BaseExecutor { })}\n\n`); } - chunks.push(`data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { tool_calls: [{ index: toolCalls.length - 1, ...result.toolCall }] }, - finish_reason: null - }] - })}\n\n`); + if (toolCallsMap.has(tc.id)) { + // Accumulate arguments for existing tool call + const existing = toolCallsMap.get(tc.id); + const oldArgsLen = existing.function.arguments.length; + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + + // Stream the delta arguments + if (tc.function.arguments) { + chunks.push(`data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: existing.index, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + }] + }, + finish_reason: null + }] + })}\n\n`); + } + } else { + // New tool call - assign index and add to map + const toolCallIndex = toolCalls.length; + toolCalls.push({ ...tc, index: toolCallIndex }); + toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex }); + + // Stream initial tool call with name + chunks.push(`data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: toolCallIndex, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + }] + }, + finish_reason: null + }] + })}\n\n`); + } } if (result.text) { @@ -435,6 +582,8 @@ export class CursorExecutor extends BaseExecutor { } } + console.log(`[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}`); + if (chunks.length === 0 && toolCalls.length === 0) { chunks.push(`data: ${JSON.stringify({ id: responseId, @@ -449,6 +598,8 @@ export class CursorExecutor extends BaseExecutor { })}\n\n`); } + const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); + chunks.push(`data: ${JSON.stringify({ id: responseId, object: "chat.completion.chunk", @@ -459,11 +610,7 @@ export class CursorExecutor extends BaseExecutor { delta: {}, finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop" }], - usage: { - prompt_tokens: 0, - completion_tokens: Math.max(1, Math.floor(totalContent.length / 4)), - total_tokens: Math.max(1, Math.floor(totalContent.length / 4)) - } + usage })}\n\n`); chunks.push("data: [DONE]\n\n"); diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index c8238b62..f2e95041 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -31,7 +31,13 @@ export class GithubExecutor extends BaseExecutor { async refreshCopilotToken(githubAccessToken, log) { try { const response = await fetch("https://api.github.com/copilot_internal/v2/token", { - headers: { "Authorization": `Bearer ${githubAccessToken}`, "User-Agent": "GitHub-Copilot/1.0", "Accept": "*/*" } + headers: { + "Authorization": `token ${githubAccessToken}`, + "User-Agent": "GithubCopilot/1.0", + "Editor-Version": "vscode/1.100.0", + "Editor-Plugin-Version": "copilot/1.300.0", + "Accept": "application/json" + } }); if (!response.ok) return null; const data = await response.json(); @@ -87,8 +93,18 @@ export class GithubExecutor extends BaseExecutor { } needsRefresh(credentials) { + // Always refresh if no copilotToken + if (!credentials.copilotToken) return true; + if (credentials.copilotTokenExpiresAt) { - if (new Date(credentials.copilotTokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000) return true; + // Handle both Unix timestamp (seconds) and ISO string + let expiresAtMs = credentials.copilotTokenExpiresAt; + if (typeof expiresAtMs === "number" && expiresAtMs < 1e12) { + expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms + } else if (typeof expiresAtMs === "string") { + expiresAtMs = new Date(expiresAtMs).getTime(); + } + if (expiresAtMs - Date.now() < 5 * 60 * 1000) return true; } return super.needsRefresh(credentials); } diff --git a/open-sse/index.js b/open-sse/index.js index 60eb3ef3..7e6fb334 100644 --- a/open-sse/index.js +++ b/open-sse/index.js @@ -61,6 +61,9 @@ export { export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js"; export { createStreamController, pipeWithDisconnect, createDisconnectAwareStream } from "./utils/streamHandler.js"; +// Executors +export { getExecutor, hasSpecializedExecutor } from "./executors/index.js"; + // Utils export { errorResponse, formatProviderError } from "./utils/error.js"; export { diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 16e6f2b0..edcec2b7 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -424,9 +424,11 @@ export async function refreshCopilotToken(githubAccessToken, log) { try { const response = await fetch("https://api.github.com/copilot_internal/v2/token", { headers: { - "Authorization": `Bearer ${githubAccessToken}`, - "User-Agent": "GitHub-Copilot/1.0", - "Accept": "*/*" + "Authorization": `token ${githubAccessToken}`, + "User-Agent": "GithubCopilot/1.0", + "Editor-Version": "vscode/1.100.0", + "Editor-Plugin-Version": "copilot/1.300.0", + "Accept": "application/json" } }); diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 301d993b..7d5f8e11 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -61,15 +61,23 @@ export async function getUsageForProvider(connection) { /** * GitHub Copilot Usage + * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API */ async function getGitHubUsage(accessToken, providerSpecificData) { try { + if (!accessToken) { + throw new Error("No GitHub access token available. Please re-authorize the connection."); + } + + // copilot_internal/user API requires GitHub OAuth token, not copilotToken const response = await fetch("https://api.github.com/copilot_internal/user", { headers: { - "Authorization": `Bearer ${accessToken}`, + "Authorization": `token ${accessToken}`, "Accept": "application/json", "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, "User-Agent": GITHUB_CONFIG.userAgent, + "Editor-Version": "vscode/1.100.0", + "Editor-Plugin-Version": "copilot-chat/0.26.7", }, }); @@ -191,20 +199,48 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { const data = await response.json(); const quotas = {}; - // Parse model quotas + // Parse model quotas (inspired by vscode-antigravity-cockpit) if (data.models) { - for (const [name, info] of Object.entries(data.models)) { - // Only include gemini and claude models - if (!name.includes("gemini") && !name.includes("claude")) continue; - - if (info.quotaInfo) { - const percentage = (info.quotaInfo.remainingFraction || 0) * 100; - quotas[name] = { - remaining: percentage, - resetTime: info.quotaInfo.resetTime || "", - unlimited: false, - }; + // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) + const importantModels = [ + 'claude-opus-4-5-thinking', + 'claude-opus-4-5', + 'claude-sonnet-4-5-thinking', + 'claude-sonnet-4-5', + 'gemini-3-pro-high', + 'gemini-3-pro-low', + 'gemini-3-flash', + 'gemini-2.5-flash', + ]; + + for (const [modelKey, info] of Object.entries(data.models)) { + // Skip models without quota info + if (!info.quotaInfo) { + continue; } + + // Skip internal models and non-important models + if (info.isInternal || !importantModels.includes(modelKey)) { + continue; + } + + const remainingFraction = info.quotaInfo.remainingFraction || 0; + const remainingPercentage = remainingFraction * 100; + + // Convert percentage to used/total for UI compatibility + const total = 1000; // Normalized base + const remaining = Math.round(total * remainingFraction); + const used = total - remaining; + + // Use modelKey as key (matches PROVIDER_MODELS id) + quotas[modelKey] = { + used, + total, + resetAt: info.quotaInfo.resetTime || null, + remainingPercentage, + unlimited: false, + displayName: info.displayName || modelKey, + }; } } diff --git a/open-sse/translator/helpers/openaiHelper.js b/open-sse/translator/helpers/openaiHelper.js index b86e0f9b..90879b93 100644 --- a/open-sse/translator/helpers/openaiHelper.js +++ b/open-sse/translator/helpers/openaiHelper.js @@ -75,6 +75,53 @@ export function filterToOpenAIFormat(body) { delete body.tools; } + // Normalize tools to OpenAI format (from Claude, Gemini, etc.) + if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { + body.tools = body.tools.map(tool => { + // Already OpenAI format + if (tool.type === "function" && tool.function) return tool; + + // Claude format: {name, description, input_schema} + if (tool.name && (tool.input_schema || tool.description)) { + return { + type: "function", + function: { + name: tool.name, + description: tool.description || "", + parameters: tool.input_schema || { type: "object", properties: {} } + } + }; + } + + // Gemini format: {functionDeclarations: [{name, description, parameters}]} + if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) { + return tool.functionDeclarations.map(fn => ({ + type: "function", + function: { + name: fn.name, + description: fn.description || "", + parameters: fn.parameters || { type: "object", properties: {} } + } + })); + } + + return tool; + }).flat(); + } + + // Normalize tool_choice to OpenAI format + if (body.tool_choice && typeof body.tool_choice === "object") { + const choice = body.tool_choice; + // Claude format: {type: "auto|any|tool", name?: "..."} + if (choice.type === "auto") { + body.tool_choice = "auto"; + } else if (choice.type === "any") { + body.tool_choice = "required"; + } else if (choice.type === "tool" && choice.name) { + body.tool_choice = { type: "function", function: { name: choice.name } }; + } + } + return body; } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index b0082ec6..326746c0 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -71,11 +71,6 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream } } - // Step 1.5: Filter to clean OpenAI format (only when target is OpenAI) - if (targetFormat === FORMATS.OPENAI) { - result = filterToOpenAIFormat(result); - } - // Step 2: openai -> target (if target is not openai) if (targetFormat !== FORMATS.OPENAI) { const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`); @@ -85,6 +80,12 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream } } + // Always normalize to clean OpenAI format when target is OpenAI + // This handles hybrid requests (e.g., OpenAI messages + Claude tools) + if (targetFormat === FORMATS.OPENAI) { + result = filterToOpenAIFormat(result); + } + // Final step: prepare request for Claude format endpoints if (targetFormat === FORMATS.CLAUDE) { result = prepareClaudeRequest(result, provider); diff --git a/open-sse/translator/request/openai-to-cursor.js b/open-sse/translator/request/openai-to-cursor.js index ae340902..ae5b30d1 100644 --- a/open-sse/translator/request/openai-to-cursor.js +++ b/open-sse/translator/request/openai-to-cursor.js @@ -6,15 +6,18 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; /** - * Convert OpenAI messages to Cursor simple format + * Convert OpenAI messages to Cursor format with native tool_results support * - 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 + * - tool → accumulate into tool_results array for next user/assistant message + * - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively) */ function convertMessages(messages) { const result = []; + let pendingToolResults = []; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; - for (const msg of messages) { if (msg.role === "system") { result.push({ role: "user", @@ -36,9 +39,14 @@ function convertMessages(messages) { } const toolName = msg.name || "tool"; - result.push({ - role: "user", - content: `[Tool Result: ${toolName}]\n${toolContent}` + const toolCallId = msg.tool_call_id || ""; + + // Accumulate tool result + pendingToolResults.push({ + tool_call_id: toolCallId, + name: toolName, + index: pendingToolResults.length, + raw_args: toolContent }); continue; } @@ -56,23 +64,34 @@ function convertMessages(messages) { } } + // Keep tool_calls structure for assistant messages if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { + const assistantMsg = { role: "assistant" }; if (content) { - result.push({ role: "assistant", content }); + assistantMsg.content = content; + } + assistantMsg.tool_calls = msg.tool_calls; + + // Attach pending tool results to assistant message with tool_calls + if (pendingToolResults.length > 0) { + assistantMsg.tool_results = pendingToolResults; + pendingToolResults = []; } - 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(assistantMsg); + } else if (content || pendingToolResults.length > 0) { + const msgObj = { + role: msg.role, + content: content || "" + }; - result.push({ - role: "assistant", - content: toolCallsText - }); - } else if (content) { - result.push({ role: msg.role, content }); + // Attach pending tool results to this message + if (pendingToolResults.length > 0) { + msgObj.tool_results = pendingToolResults; + pendingToolResults = []; + } + + result.push(msgObj); } } } diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js index 3ef7f6f4..0ee28b50 100644 --- a/open-sse/utils/cursorProtobuf.js +++ b/open-sse/utils/cursorProtobuf.js @@ -53,10 +53,18 @@ const FIELD = { MSG_CONTENT: 1, MSG_ROLE: 2, MSG_ID: 13, + MSG_TOOL_RESULTS: 18, MSG_IS_AGENTIC: 29, MSG_UNIFIED_MODE: 47, MSG_SUPPORTED_TOOLS: 51, + // ConversationMessage.ToolResult + TOOL_RESULT_CALL_ID: 1, + TOOL_RESULT_NAME: 2, + TOOL_RESULT_INDEX: 3, + TOOL_RESULT_RAW_ARGS: 5, + TOOL_RESULT_RESULT: 8, + // Model MODEL_NAME: 1, MODEL_EMPTY: 4, @@ -101,6 +109,7 @@ const FIELD = { TOOL_ID: 3, TOOL_NAME: 9, TOOL_RAW_ARGS: 10, + TOOL_IS_LAST: 11, TOOL_MCP_PARAMS: 27, // MCPParams @@ -166,11 +175,28 @@ function concatArrays(...arrays) { // ==================== MESSAGE ENCODING ==================== -export function encodeMessage(content, role, messageId, chatModeEnum = null, isLast = false, hasTools = false) { +export function encodeToolResult(toolResult) { + const toolCallId = toolResult.tool_call_id || ""; + const toolName = toolResult.name || ""; + const toolIndex = toolResult.index || 0; + const rawArgs = toolResult.raw_args || "{}"; + + return concatArrays( + encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId), + encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName), + encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex), + encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs) + ); +} + +export function encodeMessage(content, role, messageId, chatModeEnum = null, isLast = false, hasTools = false, toolResults = []) { 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), + ...(toolResults.length > 0 ? toolResults.map(tr => + encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr)) + ) : []), 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))] : []) @@ -254,7 +280,8 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort = role, messageId: msgId, isLast, - hasTools + hasTools, + toolResults: msg.tool_results || [] }); messageIds.push({ messageId: msgId, role }); @@ -270,7 +297,7 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort = // Messages ...formattedMessages.map(fm => encodeField(FIELD.MESSAGES, WIRE_TYPE.LEN, - encodeMessage(fm.content, fm.role, fm.messageId, null, fm.isLast, fm.hasTools) + encodeMessage(fm.content, fm.role, fm.messageId, null, fm.isLast, fm.hasTools, fm.toolResults) ) ), @@ -439,6 +466,7 @@ function extractToolCall(toolCallData) { let toolCallId = ""; let toolName = ""; let rawArgs = ""; + let isLast = false; // Extract tool call ID if (toolCall.has(FIELD.TOOL_ID)) { @@ -451,6 +479,11 @@ function extractToolCall(toolCallData) { toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value); } + // Extract is_last flag + if (toolCall.has(FIELD.TOOL_IS_LAST)) { + isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0; + } + // Extract MCP params - nested real tool info if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) { try { @@ -484,7 +517,8 @@ function extractToolCall(toolCallData) { function: { name: toolName, arguments: rawArgs || "{}" - } + }, + isLast }; } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 402b3733..76f4e7a2 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -2,70 +2,13 @@ import { translateResponse, initState } from "../translator/index.js"; import { FORMATS } from "../translator/formats.js"; import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; +import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; -// Re-export COLORS for backward compatibility -export { COLORS }; +export { COLORS, formatSSE }; -// Singleton TextEncoder/Decoder for performance (reuse across all streams) const sharedDecoder = new TextDecoder(); const sharedEncoder = new TextEncoder(); -// Parse SSE data line (optimized - reduce string operations) -function parseSSELine(line) { - if (!line || line.charCodeAt(0) !== 100) return null; // 'd' = 100 - - const data = line.slice(5).trim(); - if (data === "[DONE]") return { done: true }; - - try { - return JSON.parse(data); - } catch (error) { - // Log parse errors for debugging incomplete chunks - if (data.length > 0 && data.length < 1000) { - console.log(`[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 100)}...`); - } - return null; - } -} - -/** - * Format output as SSE - * @param {object} data - Data to format - * @param {string} sourceFormat - Target format for client - * @returns {string} SSE formatted string - */ -export function formatSSE(data, sourceFormat) { - // Handle null/undefined - if (data === null || data === undefined) { - return "data: null\n\n"; - } - - if (data && data.done) return "data: [DONE]\n\n"; - - // OpenAI Responses API format: has event field - if (data && data.event && data.data) { - return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`; - } - - // Claude format: include event prefix - if (sourceFormat === FORMATS.CLAUDE && data && data.type) { - // If perf_metrics is null, remove it to avoid serialization issues - if (data.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) { - const { perf_metrics, ...usageWithoutPerf } = data.usage; - data = { ...data, usage: usageWithoutPerf }; - } - return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`; - } - - // If perf_metrics is null, remove it to avoid serialization issues - if (data?.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) { - const { perf_metrics, ...usageWithoutPerf } = data.usage; - data = { ...data, usage: usageWithoutPerf }; - } - - return `data: ${JSON.stringify(data)}\n\n`; -} - /** * Stream modes */ @@ -129,37 +72,42 @@ export function createSSEStream(options = {}) { try { const parsed = JSON.parse(trimmed.slice(5).trim()); - // Track content length for estimation - const content = parsed.choices?.[0]?.delta?.content || parsed.choices?.[0]?.delta?.reasoning_content; + const idFixed = fixInvalidId(parsed); + + if (!hasValuableContent(parsed, FORMATS.OPENAI)) { + continue; + } + + const delta = parsed.choices?.[0]?.delta; + const content = delta?.content || delta?.reasoning_content; if (content && typeof content === "string") { totalContentLength += content.length; } - // Extract usage from chunk const extracted = extractUsage(parsed); if (extracted) { - usage = extracted; // Keep original usage for logging + usage = extracted; } - // Inject estimated usage into final chunk (has finish_reason but no valid usage) const isFinishChunk = parsed.choices?.[0]?.finish_reason; if (isFinishChunk && !hasValidUsage(parsed.usage)) { const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); - parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); // Filter + already has buffer + parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); output = `data: ${JSON.stringify(parsed)}\n`; usage = estimated; injectedUsage = true; } else if (isFinishChunk && usage) { - // Add buffer and filter usage for client (but keep original for logging) const buffered = addBufferToUsage(usage); parsed.usage = filterUsageForFormat(buffered, FORMATS.OPENAI); output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; + } else if (idFixed) { + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; } } catch { } } - // Normalize if not already injected if (!injectedUsage) { if (line.startsWith("data:") && !line.startsWith("data: ")) { output = "data: " + line.slice(5) + "\n"; @@ -231,6 +179,11 @@ export function createSSEStream(options = {}) { if (translated?.length > 0) { for (const item of translated) { + // Filter empty chunks + if (!hasValuableContent(item, sourceFormat)) { + continue; // Skip this empty chunk + } + // Inject estimated usage if finish chunk has no valid usage const isFinishChunk = item.type === "message_delta" || item.choices?.[0]?.finish_reason; if (state.finishReason && isFinishChunk && !hasValidUsage(item.usage) && totalContentLength > 0) { diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.js new file mode 100644 index 00000000..ed154f74 --- /dev/null +++ b/open-sse/utils/streamHelpers.js @@ -0,0 +1,85 @@ +import { FORMATS } from "../translator/formats.js"; + +// Parse SSE data line +export function parseSSELine(line) { + if (!line || line.charCodeAt(0) !== 100) return null; // 'd' = 100 + + const data = line.slice(5).trim(); + if (data === "[DONE]") return { done: true }; + + try { + return JSON.parse(data); + } catch (error) { + if (data.length > 0 && data.length < 1000) { + console.log(`[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 100)}...`); + } + return null; + } +} + +// Check if chunk has valuable content (not empty) +export function hasValuableContent(chunk, format) { + // OpenAI format + if (format === FORMATS.OPENAI && chunk.choices?.[0]?.delta) { + const delta = chunk.choices[0].delta; + return delta.content && delta.content !== "" || + delta.reasoning_content && delta.reasoning_content !== "" || + delta.tool_calls && delta.tool_calls.length > 0 || + chunk.choices[0].finish_reason || + delta.role; + } + + // Claude format + if (format === FORMATS.CLAUDE) { + const isContentBlockDelta = chunk.type === "content_block_delta"; + const hasText = chunk.delta?.text && chunk.delta.text !== ""; + const hasThinking = chunk.delta?.thinking && chunk.delta.thinking !== ""; + + if (isContentBlockDelta && !hasText && !hasThinking) { + return false; + } + return true; + } + + return true; // Other formats: keep all chunks +} + +// Fix invalid id (generic or too short) +export function fixInvalidId(parsed) { + if (parsed.id && (parsed.id === "chat" || parsed.id === "completion" || parsed.id.length < 8)) { + const fallbackId = parsed.extend_fields?.requestId || + parsed.extend_fields?.traceId || + Date.now().toString(36); + parsed.id = `chatcmpl-${fallbackId}`; + return true; + } + return false; +} + +// Format output as SSE +export function formatSSE(data, sourceFormat) { + if (data === null || data === undefined) return "data: null\n\n"; + if (data && data.done) return "data: [DONE]\n\n"; + + // OpenAI Responses API format + if (data && data.event && data.data) { + return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`; + } + + // Claude format + if (sourceFormat === FORMATS.CLAUDE && data && data.type) { + if (data.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) { + const { perf_metrics, ...usageWithoutPerf } = data.usage; + data = { ...data, usage: usageWithoutPerf }; + } + return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`; + } + + // Remove null perf_metrics + if (data?.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) { + const { perf_metrics, ...usageWithoutPerf } = data.usage; + data = { ...data, usage: usageWithoutPerf }; + } + + return `data: ${JSON.stringify(data)}\n\n`; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js index 31b3a5e9..6c6176ba 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js @@ -155,7 +155,10 @@ export default function ProviderLimitCard({ {!loading && !error && !message && quotas?.length > 0 && (
|
+
+ {colors.emoji}
+ {quota.name}
+
+ |
+
+ {/* Limit (Progress + Numbers) */}
+
+
+ {/* Progress bar - always show with border for visibility */}
+
+
+
+
+
+ {/* Numbers */}
+
+
+ {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
+
+
+ {remaining}%
+
+
+ |
+
+ {/* Reset Time */}
+
+
+ {countdown !== "-" && (
+
+
+ in {countdown}
+
+ )}
+ {resetDisplay && (
+
+ {resetDisplay}
+
+ )}
+ |
+
{conn.name}
+ )} +{error}
+{quota.message}
+