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.**
-
-
-
-
+ **Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.**
[](https://www.npmjs.com/package/9router)
[](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!
[](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}
);