mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Fix : Add custom to model selector
This commit is contained in:
105
README.md
105
README.md
@@ -5,11 +5,7 @@
|
||||
|
||||
**Never stop coding. Auto-route to FREE & cheap AI models with smart fallback.**
|
||||
|
||||
**Free AI Provider for OpenClaw.**
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/providers/openclaw.png" alt="OpenClaw" width="80"/>
|
||||
</p>
|
||||
**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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 740 KiB |
@@ -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(""), {
|
||||
|
||||
@@ -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, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildToolResultBlock(toolName, toolCallId, resultText) {
|
||||
const cleanResult = sanitizeToolResultText(resultText || "");
|
||||
return [
|
||||
"<tool_result>",
|
||||
`<tool_name>${escapeXml(toolName || "tool")}</tool_name>`,
|
||||
`<tool_call_id>${escapeXml(toolCallId || "")}</tool_call_id>`,
|
||||
`<result>${escapeXml(cleanResult)}</result>`,
|
||||
"</tool_result>"
|
||||
].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(`<tool_result>\n<tool_name>${toolName}</tool_name>\n<tool_call_id>${toolCallId}</tool_call_id>\n<result>${toolResultText}</result>\n</tool_result>`);
|
||||
}
|
||||
}
|
||||
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(/<system-reminder>[\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: `<tool_result>\n<tool_name>${toolName}</tool_name>\n<tool_call_id>${toolCallId}</tool_call_id>\n<result>${toolContent}</result>\n</tool_result>`
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.3.35",
|
||||
"version": "0.3.41",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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({
|
||||
<span className="material-symbols-outlined text-[11px]">edit</span>
|
||||
{model.name}
|
||||
</span>
|
||||
) : model.isCustom ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{model.name}
|
||||
<span className="text-[9px] opacity-60 font-normal">custom</span>
|
||||
</span>
|
||||
) : model.name}
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user