diff --git a/README.md b/README.md index 27760226..fa08180e 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,7 @@ **Never stop coding. Auto-route to FREE & cheap AI models with smart fallback.** - **Free AI Provider for OpenClaw.** - -

- OpenClaw -

+ **Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.** [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) @@ -1122,21 +1118,6 @@ Notes: **Dashboard opens on wrong port** - Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` -**Cloud sync errors** -- Verify `BASE_URL` points to your running instance (example: `http://localhost:20128`) -- Verify `CLOUD_URL` points to your expected cloud endpoint (example: `https://9router.com`) -- Keep `NEXT_PUBLIC_*` values aligned with server-side values when possible. - -**Cloud endpoint `stream=false` returns 500 (`Unexpected token 'd'...`)** -- Symptom usually appears on public cloud endpoint (`https://9router.com/v1`) for non-streaming calls. -- Root cause: upstream returns SSE payload (`data: ...`) while client expects JSON. -- Workaround: use `stream=true` for cloud direct calls. -- Local 9Router runtime includes SSE→JSON fallback for non-streaming calls when upstream returns `text/event-stream`. - -**Cloud says connected, but request still fails with `Invalid API key`** -- Create a fresh key from local dashboard (`/api/keys`) and run cloud sync (`Enable Cloud` then `Sync Now`). -- Old/non-synced keys can still return `401` on cloud even if local endpoint works. - **First login not working** - Check `INITIAL_PASSWORD` in `.env` - If unset, fallback password is `123456` @@ -1184,80 +1165,6 @@ Authorization: Bearer your-api-key → Returns all models + combos in OpenAI format ``` -### Compatibility Endpoints - -- `POST /v1/chat/completions` -- `POST /v1/messages` -- `POST /v1/responses` -- `GET /v1/models` -- `POST /v1/messages/count_tokens` -- `GET /v1beta/models` -- `POST /v1beta/models/{...path}` (Gemini-style `generateContent`) -- `POST /v1/api/chat` (Ollama-style transform path) - -### Cloud Validation Scripts - -Added test scripts under `tester/security/`: - -- `tester/security/test-docker-hardening.sh` - - Builds Docker image and validates hardening checks (`/api/cloud/auth` auth guard, `REQUIRE_API_KEY`, secure auth cookie behavior). -- `tester/security/test-cloud-openai-compatible.sh` - - Sends a direct OpenAI-compatible request to cloud endpoint (`https://9router.com/v1/chat/completions`) with provided model/key. -- `tester/security/test-cloud-sync-and-call.sh` - - End-to-end flow: create local key -> enable/sync cloud -> call cloud endpoint with retry. - - Includes fallback check with `stream=true` to distinguish auth errors from non-streaming parse issues. - -Security note for cloud test scripts: - -- Never hardcode real API keys in scripts/commits. -- Provide keys only via environment variables: - - `API_KEY`, `CLOUD_API_KEY`, or `OPENAI_API_KEY` (supported by `test-cloud-openai-compatible.sh`) -- Example: - -```bash -OPENAI_API_KEY="your-cloud-key" bash tester/security/test-cloud-openai-compatible.sh -``` - -Expected behavior from recent validation: - -- Local runtime (`http://127.0.0.1:20128/v1/chat/completions`): works with `stream=false` and `stream=true`. -- Docker runtime (same API path exposed by container): hardening checks pass, cloud auth guard works, strict API key mode works when enabled. -- Public cloud endpoint (`https://9router.com/v1/chat/completions`): - - `stream=true`: expected to succeed (SSE chunks returned). - - `stream=false`: may fail with `500` + parse error (`Unexpected token 'd'`) when upstream returns SSE content to a non-streaming client path. - -### Dashboard and Management API - -- Auth/settings: `/api/auth/login`, `/api/auth/logout`, `/api/settings`, `/api/settings/require-login` -- Provider management: `/api/providers`, `/api/providers/[id]`, `/api/providers/[id]/test`, `/api/providers/[id]/models`, `/api/providers/validate`, `/api/provider-nodes*` -- OAuth flows: `/api/oauth/[provider]/[action]` (+ provider-specific imports like Cursor/Kiro) -- Routing config: `/api/models/alias`, `/api/combos*`, `/api/keys*`, `/api/pricing` -- Usage/logs: `/api/usage/history`, `/api/usage/logs`, `/api/usage/request-logs`, `/api/usage/[connectionId]` -- Cloud sync: `/api/sync/cloud`, `/api/sync/initialize`, `/api/cloud/*` -- CLI helpers: `/api/cli-tools/claude-settings`, `/api/cli-tools/codex-settings`, `/api/cli-tools/droid-settings`, `/api/cli-tools/openclaw-settings` - -### Authentication Behavior - -- Dashboard routes (`/dashboard/*`) use `auth_token` cookie protection. -- Login uses saved password hash when present; otherwise it falls back to `INITIAL_PASSWORD`. -- `requireLogin` can be toggled via `/api/settings/require-login`. - -### Request Processing (High Level) - -1. Client sends request to `/v1/*`. -2. Route handler calls `handleChat` (`src/sse/handlers/chat.js`). -3. Model is resolved (direct provider/model or alias/combo resolution). -4. Credentials are selected from local DB with account availability filtering. -5. `handleChatCore` (`open-sse/handlers/chatCore.js`) detects format and translates request. -6. Provider executor sends upstream request. -7. Stream is translated back to client format when needed. -8. Usage/logging is recorded (`src/lib/usageDb.js`). -9. Fallback applies on provider/account/model errors according to combo rules. - -Full architecture reference: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) - ---- - ## 📧 Support - **Website**: [9router.com](https://9router.com) @@ -1278,17 +1185,7 @@ Thanks to all contributors who helped make 9Router better! [![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) -### How to Contribute -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines. - ---- ## 🔀 Forks diff --git a/images/9router.png b/images/9router.png index 47bac22f..283210a2 100644 Binary files a/images/9router.png and b/images/9router.png differ diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 6dfa9cca..abe85a45 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -5,12 +5,10 @@ import { parseConnectRPCFrame, extractTextFromResponse } from "../utils/cursorProtobuf.js"; +import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { estimateUsage } from "../utils/usageTracking.js"; import { FORMATS } from "../translator/formats.js"; -import { buildCursorRequest } from "../translator/request/openai-to-cursor.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import crypto from "crypto"; -import { v5 as uuidv5 } from "uuid"; import zlib from "zlib"; // Detect cloud environment @@ -37,18 +35,50 @@ const COMPRESS_FLAG = { GZIP_TRAILER: 0x03 }; +const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; +const debugLog = (...args) => { + if (CURSOR_STREAM_DEBUG) console.log(...args); +}; + function decompressPayload(payload, flags) { - // ConnectRPC trailer frame (flags & 0x02) - contains status JSON, not compressed data - if (flags & COMPRESS_FLAG.TRAILER) { - return payload; + // 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"')) { + debugLog(`[DECOMPRESS] Detected JSON error, skipping decompression`); + return payload; + } + } catch {} } - if (flags === COMPRESS_FLAG.GZIP) { + if ( + flags === COMPRESS_FLAG.GZIP || + flags === COMPRESS_FLAG.TRAILER || + flags === COMPRESS_FLAG.GZIP_TRAILER + ) { + // Primary: try gzip decompression (standard gzip header 0x1f 0x8b) try { return zlib.gunzipSync(payload); - } catch (err) { - console.log(`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, error=${err.message}`); - return payload; + } catch (gzipErr) { + // Fallback: TRAILER and GZIP_TRAILER frames sometimes use raw zlib deflate format + try { + return zlib.inflateSync(payload); + } catch (deflateErr) { + // Last resort: try raw deflate (no zlib header) + try { + return zlib.inflateRawSync(payload); + } catch (rawErr) { + debugLog( + `[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${gzipErr.message}, deflate=${deflateErr.message}, raw=${rawErr.message}` + ); + debugLog( + `[DECOMPRESS ERROR] First 50 bytes (hex):`, + payload.slice(0, 50).toString("hex") + ); + return payload; + } + } } } return payload; @@ -83,46 +113,6 @@ export class CursorExecutor extends BaseExecutor { return `${this.config.baseUrl}${this.config.chatPath}`; } - // Jyh cipher checksum for Cursor API authentication - generateChecksum(machineId) { - const timestamp = Math.floor(Date.now() / 1000000); - const byteArray = new Uint8Array([ - (timestamp >> 40) & 0xFF, - (timestamp >> 32) & 0xFF, - (timestamp >> 24) & 0xFF, - (timestamp >> 16) & 0xFF, - (timestamp >> 8) & 0xFF, - timestamp & 0xFF - ]); - - let t = 165; - for (let i = 0; i < byteArray.length; i++) { - byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF; - t = byteArray[i]; - } - - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let encoded = ""; - - for (let i = 0; i < byteArray.length; i += 3) { - const a = byteArray[i]; - const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0; - const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0; - - encoded += alphabet[a >> 2]; - encoded += alphabet[((a & 3) << 4) | (b >> 4)]; - - if (i + 1 < byteArray.length) { - encoded += alphabet[((b & 15) << 2) | (c >> 6)]; - } - if (i + 2 < byteArray.length) { - encoded += alphabet[c & 63]; - } - } - - return `${encoded}${machineId}`; - } - buildHeaders(credentials) { const accessToken = credentials.accessToken; const machineId = credentials.providerSpecificData?.machineId; @@ -132,34 +122,14 @@ export class CursorExecutor extends BaseExecutor { throw new Error("Machine ID is required for Cursor API"); } - const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken; - - return { - "authorization": `Bearer ${cleanToken}`, - "connect-accept-encoding": "gzip", - "connect-protocol-version": "1", - "content-type": "application/connect+proto", - "user-agent": "connect-es/1.6.1", - "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, - "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", - "x-cursor-client-os": process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux", - "x-cursor-client-arch": process.arch === "arm64" ? "aarch64" : "x64", - "x-cursor-client-device-type": "desktop", - "x-cursor-config-version": crypto.randomUUID(), - "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", - "x-ghost-mode": ghostMode ? "true" : "false", - "x-request-id": crypto.randomUUID(), - "x-session-id": uuidv5(cleanToken, uuidv5.DNS), - }; + return buildCursorHeaders(accessToken, machineId, ghostMode); } transformRequest(model, body, stream, credentials) { - const translatedBody = buildCursorRequest(model, body, stream, credentials); - const messages = translatedBody.messages || []; - const tools = translatedBody.tools || body.tools || []; + // Messages are already translated by chatCore (claude→openai→cursor) + // Do NOT call buildCursorRequest again — double-translation drops tool_results + const messages = body.messages || []; + const tools = body.tools || []; const reasoningEffort = body.reasoning_effort || null; return generateCursorBody(messages, model, tools, reasoningEffort); } @@ -282,53 +252,87 @@ export class CursorExecutor extends BaseExecutor { let totalContent = ""; const toolCalls = []; const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); let frameCount = 0; + debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); + while (offset < buffer.length) { - if (offset + 5 > buffer.length) break; + if (offset + 5 > buffer.length) { + debugLog( + `[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; + debugLog( + `[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}` + ); + + if (offset + 5 + length > buffer.length) { + debugLog( + `[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++; - // Stop at ConnectRPC trailer frame (end of response, anything after is a separate response) - if (flags & COMPRESS_FLAG.TRAILER) { - break; + payload = decompressPayload(payload, flags); + if (!payload) { + debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`); + continue; } - 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 {} + // Check for JSON error frames (byte guard: skip toString on non-JSON frames) + if (payload.length > 0 && payload[0] === 0x7b) { + try { + const text = payload.toString("utf-8"); + if (text.includes('"error"')) { + const hasContent = totalContent || toolCallsMap.size > 0; + debugLog( + `[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + if (hasContent) { + break; + } + return createErrorResponse(JSON.parse(text)); + } + } catch {} + } const result = extractTextFromResponse(new Uint8Array(payload)); + debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result); if (result.error) { - return new Response(JSON.stringify({ - error: { - message: result.error, - type: "rate_limit_error", - code: "rate_limited" + const hasContent = totalContent || toolCallsMap.size > 0; + debugLog(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`); + if (hasContent) { + break; + } + return new Response( + JSON.stringify({ + error: { + message: result.error, + type: "rate_limit_error", + code: "rate_limited" + } + }), + { + status: HTTP_STATUS.RATE_LIMITED, + headers: { "Content-Type": "application/json" } } - }), { - status: HTTP_STATUS.RATE_LIMITED, - headers: { "Content-Type": "application/json" } - }); + ); } if (result.toolCall) { const tc = result.toolCall; - + if (toolCallsMap.has(tc.id)) { // Accumulate arguments for existing tool call const existing = toolCallsMap.get(tc.id); @@ -338,10 +342,11 @@ export class CursorExecutor extends BaseExecutor { // 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); + finalizedIds.add(tc.id); toolCalls.push({ id: finalToolCall.id, type: finalToolCall.type, @@ -352,14 +357,19 @@ export class CursorExecutor extends BaseExecutor { }); } } - + if (result.text) totalContent += result.text; } + debugLog( + `[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)) { + if (!finalizedIds.has(id)) { + debugLog(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); toolCalls.push({ id: tc.id, type: tc.type, @@ -371,6 +381,8 @@ export class CursorExecutor extends BaseExecutor { } } + debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`); + const message = { role: "assistant", @@ -411,176 +423,294 @@ export class CursorExecutor extends BaseExecutor { let totalContent = ""; const toolCalls = []; const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); + const emittedToolCallIds = new Set(); let frameCount = 0; + debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); + while (offset < buffer.length) { - if (offset + 5 > buffer.length) break; + if (offset + 5 > buffer.length) { + debugLog( + `[CURSOR BUFFER SSE] 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; + debugLog( + `[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}` + ); + + if (offset + 5 + length > buffer.length) { + debugLog( + `[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++; - // Stop at ConnectRPC trailer frame (end of response, anything after is a separate response) - if (flags & COMPRESS_FLAG.TRAILER) { - break; + payload = decompressPayload(payload, flags); + if (!payload) { + debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`); + continue; } - 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 {} + // Check for JSON error frames (byte-guard: only decode if starts with '{') + if (payload[0] === 0x7b) { + try { + const text = payload.toString("utf-8"); + if (text.includes('"error"')) { + const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; + debugLog( + `[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + if (hasContent) { + break; + } + return createErrorResponse(JSON.parse(text)); + } + } catch {} + } const result = extractTextFromResponse(new Uint8Array(payload)); + debugLog(`[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" + const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; + debugLog(`[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}`); + if (hasContent) { + break; + } + return new Response( + JSON.stringify({ + error: { + message: result.error, + type: "rate_limit_error", + code: "rate_limited" + } + }), + { + status: HTTP_STATUS.RATE_LIMITED, + headers: { "Content-Type": "application/json" } } - }), { - status: HTTP_STATUS.RATE_LIMITED, - headers: { "Content-Type": "application/json" } - }); + ); } if (result.toolCall) { const tc = 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: { role: "assistant", content: "" }, + 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`); + emittedToolCallIds.add(tc.id); + 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; + finalizedIds.add(tc.id); 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`); + emittedToolCallIds.add(tc.id); + 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) { totalContent += result.text; - chunks.push(`data: ${JSON.stringify({ + 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 + } + ] + })}\n\n` + ); + } + } + + debugLog( + `[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}` + ); + + // Finalize all remaining tool calls in map (stream may have ended without isLast=true) + for (const [id, tc] of toolCallsMap.entries()) { + if (!finalizedIds.has(id)) { + debugLog(`[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); + const toolCallIndex = toolCalls.length; + toolCalls.push({ + id: tc.id, + type: tc.type, + index: toolCallIndex, + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + }); + + // Emit SSE chunk for the finalized tool call if not already emitted + if (!emittedToolCallIds.has(tc.id)) { + 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 (chunks.length === 0 && toolCalls.length === 0) { + 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 - }] - })}\n\n`); - } - } - - - 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`); + choices: [ + { + index: 0, + delta: { role: "assistant", content: "" }, + finish_reason: null + } + ] + })}\n\n` + ); } const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); - chunks.push(`data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop" - }], - usage - })}\n\n`); + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop" + } + ], + usage + })}\n\n` + ); chunks.push("data: [DONE]\n\n"); return new Response(chunks.join(""), { diff --git a/open-sse/translator/request/openai-to-cursor.js b/open-sse/translator/request/openai-to-cursor.js index d2b78d88..a36f25cb 100644 --- a/open-sse/translator/request/openai-to-cursor.js +++ b/open-sse/translator/request/openai-to-cursor.js @@ -1,8 +1,10 @@ /** * OpenAI to Cursor Request Translator - * - assistant tool_calls → kept as-is (Cursor generates tool calls) - * - Claude tool_use blocks → converted to OpenAI tool_calls format - * - tool results → converted to user message string + * Converts OpenAI messages to Cursor ask/agent format. + * + * Important: Cursor can loop when tool outputs are sent via protobuf tool_results + * with partial schema mismatches. For stability, tool outputs are represented as + * structured text blocks in user messages. */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; @@ -10,96 +12,154 @@ import { FORMATS } from "../formats.js"; function extractContent(content) { if (typeof content === "string") return content; if (Array.isArray(content)) { - return content.filter(p => p.type === "text").map(p => p.text).join(""); + return content + .filter(part => { + if (!part || typeof part !== "object") return false; + return part.type === "text" && typeof part.text === "string"; + }) + .map(part => part.text || "") + .join(""); } return ""; } -// Build a map of tool_use_id → tool_name from the previous assistant message -function getToolNameMap(prevMsg) { - const map = {}; - if (!prevMsg?.tool_calls) return map; - for (const tc of prevMsg.tool_calls) { - if (tc.id && tc.function?.name) map[tc.id] = tc.function.name; - } - return map; +function sanitizeToolResultText(text) { + // Strip non-printable control chars that can produce backend request errors + return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ""); +} + +function escapeXml(text) { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +function buildToolResultBlock(toolName, toolCallId, resultText) { + const cleanResult = sanitizeToolResultText(resultText || ""); + return [ + "", + `${escapeXml(toolName || "tool")}`, + `${escapeXml(toolCallId || "")}`, + `${escapeXml(cleanResult)}`, + "" + ].join("\n"); +} + +function normalizeToolCallId(id) { + return typeof id === "string" ? id.split("\n")[0] : ""; } function convertMessages(messages) { const result = []; + + // Build a map of tool_call_id -> tool name from assistant tool calls + const toolCallMetaMap = new Map(); + const rememberToolMeta = (toolCallId, toolName) => { + if (!toolCallId) return; + const name = toolName || "tool"; + toolCallMetaMap.set(toolCallId, { name }); + const normalized = normalizeToolCallId(toolCallId); + if (normalized && normalized !== toolCallId) { + toolCallMetaMap.set(normalized, { name }); + } + }; + + for (const msg of messages) { + if (msg.role === "assistant" && msg.tool_calls) { + for (const tc of msg.tool_calls) { + rememberToolMeta(tc.id || "", tc.function?.name || "tool"); + } + } + if (msg.role === "assistant" && Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part?.type !== "tool_use") continue; + rememberToolMeta(part.id || "", part.name || "tool"); + } + } + } for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (msg.role === "system") { - result.push({ role: "user", content: `[System Instructions]\n${msg.content}` }); - continue; - } - - if (msg.role === "user") { - if (Array.isArray(msg.content)) { - const parts = []; - const prevMsg = result[result.length - 1]; - const nameMap = getToolNameMap(prevMsg); - for (const block of msg.content) { - if (block.type === "text") { - parts.push(block.text); - } else if (block.type === "tool_result") { - // Claude format: user message with tool_result blocks - const toolResultText = extractContent(block.content) || ""; - const toolCallId = block.tool_use_id || ""; - const toolName = nameMap[toolCallId] || ""; - parts.push(`\n${toolName}\n${toolCallId}\n${toolResultText}\n`); - } - } - result.push({ role: "user", content: parts.join("\n") || "" }); - } else { - result.push({ role: "user", content: extractContent(msg.content) || "" }); - } - continue; - } - - if (msg.role === "tool") { - // Strip system-reminder tags injected by Claude Code - const raw = extractContent(msg.content) || ""; - const toolContent = raw.replace(/[\s\S]*?<\/system-reminder>/g, "").trim(); - const prevMsg = result[result.length - 1]; - const nameMap = getToolNameMap(prevMsg); - const toolCallId = msg.tool_call_id || ""; - const toolName = nameMap[toolCallId] || ""; result.push({ role: "user", - content: `\n${toolName}\n${toolCallId}\n${toolContent}\n` + content: `[System Instructions]\n${extractContent(msg.content)}` }); continue; } - if (msg.role === "assistant") { - let content = extractContent(msg.content) || ""; - let tool_calls = null; + if (msg.role === "tool") { + const toolContent = extractContent(msg.content); + const toolCallId = msg.tool_call_id || ""; + const toolMeta = toolCallMetaMap.get(toolCallId) || {}; + const toolName = msg.name || toolMeta.name || "tool"; + result.push({ + role: "user", + content: buildToolResultBlock(toolName, toolCallId, toolContent) + }); + continue; + } - if (msg.tool_calls && msg.tool_calls.length > 0) { - // OpenAI format: strip `index` field - tool_calls = msg.tool_calls.map(({ index, ...tc }) => tc); - } else if (Array.isArray(msg.content)) { - // Claude format: extract tool_use blocks from content array - const extracted = msg.content - .filter(b => b.type === "tool_use") - .map(b => ({ - id: b.id, - type: "function", - function: { - name: b.name, - arguments: JSON.stringify(b.input || {}) + if (msg.role === "user" || msg.role === "assistant") { + if (msg.role === "user" && Array.isArray(msg.content)) { + const parts = []; + for (const block of msg.content) { + if (!block || typeof block !== "object") continue; + if (block.type === "text") { + if (typeof block.text === "string") { + parts.push(block.text || ""); } - })); - if (extracted.length > 0) tool_calls = extracted; + continue; + } + if (block.type === "tool_result") { + const toolCallId = block.tool_use_id || ""; + const toolMeta = + toolCallMetaMap.get(toolCallId) || + toolCallMetaMap.get(normalizeToolCallId(toolCallId)); + const toolName = toolMeta?.name || "tool"; + const toolContent = extractContent(block.content); + parts.push(buildToolResultBlock(toolName, toolCallId, toolContent)); + } + } + const joined = parts.filter(Boolean).join("\n"); + if (joined) result.push({ role: "user", content: joined }); + continue; } - if (tool_calls) { - result.push({ role: "assistant", content, tool_calls }); - } else if (content) { - result.push({ role: "assistant", content }); + const content = extractContent(msg.content); + + if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { + const assistantMsg = { role: "assistant", content: content || "" }; + assistantMsg.tool_calls = msg.tool_calls.map(tc => { + const { index, ...rest } = tc || {}; + return rest; + }); + result.push(assistantMsg); + } else if (msg.role === "assistant" && Array.isArray(msg.content)) { + const extractedToolCalls = msg.content + .filter(b => b?.type === "tool_use") + .map(b => ({ + id: b.id || "", + type: "function", + function: { + name: b.name || "tool", + arguments: JSON.stringify(b.input || {}) + } + })) + .filter(tc => tc.id); + + if (extractedToolCalls.length > 0) { + result.push({ + role: "assistant", + content: content || "", + tool_calls: extractedToolCalls + }); + } else if (content) { + result.push({ role: "assistant", content }); + } + } else { + if (content) { + result.push({ role: msg.role, content }); + } } } } diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.js index e9585f98..c41d5b7a 100644 --- a/open-sse/utils/cursorChecksum.js +++ b/open-sse/utils/cursorChecksum.js @@ -106,22 +106,38 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr const clientKey = generateHashed64Hex(cleanToken); const checksum = generateCursorChecksum(effectiveMachineId); + // Detect OS + let os = "linux"; + if (typeof process !== "undefined") { + if (process.platform === "win32") os = "windows"; + else if (process.platform === "darwin") os = "macos"; + } + + // Detect architecture + let arch = "x64"; + if (typeof process !== "undefined") { + if (process.arch === "arm64") arch = "aarch64"; + } + return { - "Authorization": `Bearer ${cleanToken}`, + "authorization": `Bearer ${cleanToken}`, "connect-accept-encoding": "gzip", "connect-protocol-version": "1", - "Content-Type": "application/connect+proto", - "User-Agent": "connect-es/1.6.1", + "content-type": "application/connect+proto", + "user-agent": "connect-es/1.6.1", "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": clientKey, "x-cursor-checksum": checksum, - "x-cursor-client-version": "1.1.3", + "x-cursor-client-version": "2.3.41", + "x-cursor-client-type": "ide", + "x-cursor-client-os": os, + "x-cursor-client-arch": arch, + "x-cursor-client-device-type": "desktop", "x-cursor-config-version": crypto.randomUUID(), "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", "x-ghost-mode": ghostMode ? "true" : "false", "x-request-id": crypto.randomUUID(), - "x-session-id": sessionId, - "Host": "api2.cursor.sh" + "x-session-id": sessionId }; } diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js index 35c43134..9d94b1e8 100644 --- a/open-sse/utils/cursorProtobuf.js +++ b/open-sse/utils/cursorProtobuf.js @@ -6,8 +6,11 @@ import { v4 as uuidv4 } from "uuid"; import zlib from "zlib"; -const DEBUG = true; +const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1"; const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args); +const textDecoder = new TextDecoder(); + +const PROTOBUF_SCHEMA_VERSION = "1.1.3"; // ==================== SCHEMAS ==================== @@ -18,6 +21,8 @@ const ROLE = { USER: 1, ASSISTANT: 2 }; const UNIFIED_MODE = { CHAT: 1, AGENT: 2 }; const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 }; +const CLIENT_SIDE_TOOL_V2 = { MCP: 19 }; +const CLIENT_SIDE_TOOL_V2_MCP = 19; const FIELD = { // StreamUnifiedChatRequestWithTools (top level) @@ -67,28 +72,25 @@ const FIELD = { TOOL_RESULT_TOOL_CALL: 11, TOOL_RESULT_MODEL_CALL_ID: 12, - // ClientSideToolV2Result - CV2R_TOOL: 1, - CV2R_MCP_RESULT: 28, - CV2R_CALL_ID: 35, - CV2R_MODEL_CALL_ID: 48, - CV2R_TOOL_INDEX: 49, + // ClientSideToolV2Result (nested inside ToolResult.result) + CLIENT_RESULT_TOOL: 1, + CLIENT_RESULT_MCP_RESULT: 28, + CLIENT_RESULT_TOOL_CALL_ID: 35, + CLIENT_RESULT_MODEL_CALL_ID: 48, + CLIENT_RESULT_TOOL_INDEX: 49, - // MCPResult - MCPR_SELECTED_TOOL: 1, - MCPR_RESULT: 2, + // MCPResult (nested inside ClientSideToolV2Result.mcp_result) + MCP_RESULT_SELECTED_TOOL: 1, + MCP_RESULT_RESULT: 2, - // ClientSideToolV2Call - CV2C_TOOL: 1, - CV2C_MCP_PARAMS: 27, - CV2C_CALL_ID: 3, - CV2C_NAME: 9, - CV2C_RAW_ARGS: 10, - CV2C_TOOL_INDEX: 48, - CV2C_MODEL_CALL_ID: 49, - - // ConversationMessage extra fields - MSG_SERVER_BUBBLE_ID: 32, + // ClientSideToolV2Call (nested inside ToolResult.tool_call) + CLIENT_CALL_TOOL: 1, + CLIENT_CALL_MCP_PARAMS: 27, + CLIENT_CALL_TOOL_CALL_ID: 3, + CLIENT_CALL_NAME: 9, + CLIENT_CALL_RAW_ARGS: 10, + CLIENT_CALL_TOOL_INDEX: 48, + CLIENT_CALL_MODEL_CALL_ID: 49, // Model MODEL_NAME: 1, @@ -135,6 +137,7 @@ const FIELD = { TOOL_NAME: 9, TOOL_RAW_ARGS: 10, TOOL_IS_LAST: 11, + TOOL_IS_LAST_ALT: 15, TOOL_MCP_PARAMS: 27, // MCPParams @@ -152,6 +155,19 @@ const FIELD = { THINKING_TEXT: 1 }; +// Known response field numbers — used to detect unknown fields from protocol updates +const KNOWN_RESPONSE_FIELDS = new Set([ + FIELD.TOOL_CALL, + FIELD.RESPONSE, + FIELD.TOOL_ID, + FIELD.TOOL_NAME, + FIELD.TOOL_RAW_ARGS, + FIELD.TOOL_IS_LAST, + FIELD.TOOL_MCP_PARAMS, + FIELD.RESPONSE_TEXT, + FIELD.THINKING +]); + // ==================== PRIMITIVE ENCODING ==================== export function encodeVarint(value) { @@ -200,15 +216,46 @@ function concatArrays(...arrays) { // ==================== MESSAGE ENCODING ==================== -// ClientSideToolV2 enum: MCP = 19 -const CLIENT_SIDE_TOOL_V2_MCP = 19; - /** * Format tool name: "toolName" → "mcp_custom_toolName" + * Also handles: "mcp__server__tool" → "mcp_server_tool" */ function formatToolName(name) { - if (name.startsWith("mcp_")) return name; - return `mcp_custom_${name}`; + const base = typeof name === "string" && name.length > 0 ? name : "tool"; + + if (base.startsWith("mcp__")) { + const rest = base.slice("mcp__".length); + const splitIdx = rest.indexOf("__"); + if (splitIdx >= 0) { + const server = rest.slice(0, splitIdx) || "custom"; + const toolName = rest.slice(splitIdx + 2) || "tool"; + return `mcp_${server}_${toolName}`; + } + return `mcp_custom_${rest || "tool"}`; + } + + if (base.startsWith("mcp_")) return base; + return `mcp_custom_${base}`; +} + +/** + * Parse formatted tool name: "mcp_server_tool" → { serverName, selectedTool } + */ +function parseToolName(formattedName) { + if (typeof formattedName !== "string" || !formattedName.startsWith("mcp_")) { + return { serverName: "custom", selectedTool: formattedName || "tool" }; + } + + const tail = formattedName.slice("mcp_".length); + const splitIdx = tail.indexOf("_"); + if (splitIdx < 0) { + return { serverName: "custom", selectedTool: tail || "tool" }; + } + + return { + serverName: tail.slice(0, splitIdx) || "custom", + selectedTool: tail.slice(splitIdx + 1) || "tool" + }; } /** @@ -235,15 +282,16 @@ function encodeMcpResult(selectedTool, resultContent) { } /** - * Encode ClientSideToolV2Result proto + * Encode ClientSideToolV2Result proto: { tool, mcp_result, call_id, model_call_id, tool_index } + * Represents the result of executing a tool */ -function encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent) { +function encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex = 1) { return concatArrays( encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP), encodeField(FIELD.CV2R_MCP_RESULT, WIRE_TYPE.LEN, encodeMcpResult(selectedTool, resultContent)), encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId), ...(modelCallId ? [encodeField(FIELD.CV2R_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []), - encodeField(FIELD.CV2R_TOOL_INDEX, WIRE_TYPE.VARINT, 1) + encodeField(FIELD.CV2R_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1) ); } @@ -260,16 +308,17 @@ function encodeMcpParamsForCall(toolName, rawArgs, serverName) { } /** - * Encode ClientSideToolV2Call proto + * Encode ClientSideToolV2Call proto: { tool, mcp_params, call_id, name, raw_args, tool_index, model_call_id } + * Represents a tool call definition */ -function encodeClientSideToolV2Call(toolCallId, toolName, mcpToolName, rawArgs, modelCallId) { +function encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex = 1) { return concatArrays( encodeField(FIELD.CV2C_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP), - encodeField(FIELD.CV2C_MCP_PARAMS, WIRE_TYPE.LEN, encodeMcpParamsForCall(mcpToolName, rawArgs, "custom")), + encodeField(FIELD.CV2C_MCP_PARAMS, WIRE_TYPE.LEN, encodeMcpParamsForCall(selectedTool, rawArgs, serverName)), encodeField(FIELD.CV2C_CALL_ID, WIRE_TYPE.LEN, toolCallId), encodeField(FIELD.CV2C_NAME, WIRE_TYPE.LEN, toolName), encodeField(FIELD.CV2C_RAW_ARGS, WIRE_TYPE.LEN, rawArgs), - encodeField(FIELD.CV2C_TOOL_INDEX, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.CV2C_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1), ...(modelCallId ? [encodeField(FIELD.CV2C_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []) ); } @@ -282,23 +331,24 @@ export function encodeToolResult(toolResult) { const originalName = toolResult.tool_name || toolResult.name || ""; const toolName = formatToolName(originalName); const rawArgs = toolResult.raw_args || "{}"; - const resultContent = toolResult.result_content || ""; + const resultContent = toolResult.result_content || toolResult.result || ""; const { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || ""); + const toolIndex = toolResult.tool_index || toolResult.index || 1; - // Derive mcpToolName: strip "mcp_" prefix → "custom_toolName" - const mcpToolName = toolName.startsWith("mcp_") ? toolName.slice(4) : originalName; + // Parse tool name to extract server and selected tool + const { serverName, selectedTool } = parseToolName(toolName); 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, toolResult.tool_index || 1), + encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1), ...(modelCallId ? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []), encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs), encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN, - encodeClientSideToolV2Result(toolCallId, modelCallId, mcpToolName, resultContent) + encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex) ), encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN, - encodeClientSideToolV2Call(toolCallId, toolName, mcpToolName, rawArgs, modelCallId) + encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex) ) ); } @@ -384,13 +434,71 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort = const isAgentic = hasTools; const formattedMessages = []; const messageIds = []; + const normalizedMessages = []; - // Prepare messages + // Guardrail: split mixed assistant payload into separate assistant messages + // This prevents protobuf encoding errors when tool calls and results are in same message for (let i = 0; i < messages.length; i++) { const msg = messages[i]; + const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; + const hasToolResults = Array.isArray(msg?.tool_results) && msg.tool_results.length > 0; + + if (msg?.role === "assistant" && hasToolCalls && hasToolResults) { + log( + "ENCODE", + `normalizing mixed assistant tool payload at msg[${i}] (calls=${msg.tool_calls.length}, results=${msg.tool_results.length})` + ); + + // Keep assistant tool call message without embedded results + normalizedMessages.push({ + ...msg, + tool_results: [] + }); + + // Avoid inserting duplicate assistant tool-result message if next one already matches + const nextMsg = messages[i + 1]; + const nextHasToolResults = + nextMsg?.role === "assistant" && + Array.isArray(nextMsg?.tool_results) && + nextMsg.tool_results.length > 0; + const currentIds = new Set( + msg.tool_results.map(tr => tr?.tool_call_id).filter(id => typeof id === "string") + ); + const nextIds = new Set( + (nextMsg?.tool_results || []) + .map(tr => tr?.tool_call_id) + .filter(id => typeof id === "string") + ); + let sameIds = currentIds.size > 0 && currentIds.size === nextIds.size; + if (sameIds) { + for (const id of currentIds) { + if (!nextIds.has(id)) { + sameIds = false; + break; + } + } + } + + if (!(nextHasToolResults && sameIds)) { + normalizedMessages.push({ + role: "assistant", + content: "", + tool_results: msg.tool_results + }); + } + + continue; + } + + normalizedMessages.push(msg); + } + + // Prepare messages + for (let i = 0; i < normalizedMessages.length; i++) { + const msg = normalizedMessages[i]; const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT; const msgId = uuidv4(); - const isLast = i === messages.length - 1; + const isLast = i === normalizedMessages.length - 1; formattedMessages.push({ content: msg.content, @@ -719,6 +827,16 @@ export function extractTextFromResponse(payload) { try { const fields = decodeMessage(payload); + // Warn about unknown field numbers — may indicate a Cursor protocol update + for (const fieldNum of fields.keys()) { + if (!KNOWN_RESPONSE_FIELDS.has(fieldNum)) { + log( + "SCHEMA", + `Unknown response field #${fieldNum} detected. Schema v${PROTOBUF_SCHEMA_VERSION} may be outdated.` + ); + } + } + // Field 1: ClientSideToolV2Call if (fields.has(FIELD.TOOL_CALL)) { const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value); @@ -731,7 +849,7 @@ export function extractTextFromResponse(payload) { // 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 }; } @@ -739,8 +857,15 @@ export function extractTextFromResponse(payload) { 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 }; + log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`); + return { + text: null, + error: null, + toolCall: null, + thinking: null, + raw: Buffer.from(payload).toString("base64"), + decodeError: err.message + }; } } diff --git a/package.json b/package.json index 954972c0..f9d5bf7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.35", + "version": "0.3.41", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index 36401094..9e5218e0 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -139,17 +139,32 @@ export default function ModelSelectModal({ hasModels: nodeModels.length > 0, }; } else { - const models = getModelsByProviderId(providerId); - if (models.length > 0) { + const hardcodedModels = getModelsByProviderId(providerId); + const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); + + // Custom models user added via "Add Model" button (alias === modelId pattern) + const customModels = Object.entries(modelAliases) + .filter(([aliasName, fullModel]) => + fullModel.startsWith(`${alias}/`) && + aliasName === fullModel.replace(`${alias}/`, "") && + !hardcodedIds.has(fullModel.replace(`${alias}/`, "")) + ) + .map(([, fullModel]) => { + const modelId = fullModel.replace(`${alias}/`, ""); + return { id: modelId, name: modelId, value: fullModel, isCustom: true }; + }); + + const allModels = [ + ...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}` })), + ...customModels, + ]; + + if (allModels.length > 0) { groups[providerId] = { name: providerInfo.name, alias: alias, color: providerInfo.color, - models: models.map((m) => ({ - id: m.id, - name: m.name, - value: `${alias}/${m.id}`, - })), + models: allModels, }; } } @@ -299,6 +314,11 @@ export default function ModelSelectModal({ edit {model.name} + ) : model.isCustom ? ( + + {model.name} + custom + ) : model.name} );