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.**
|
**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.**
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="./public/providers/openclaw.png" alt="OpenClaw" width="80"/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/9router)
|
[](https://www.npmjs.com/package/9router)
|
||||||
[](https://www.npmjs.com/package/9router)
|
[](https://www.npmjs.com/package/9router)
|
||||||
@@ -1122,21 +1118,6 @@ Notes:
|
|||||||
**Dashboard opens on wrong port**
|
**Dashboard opens on wrong port**
|
||||||
- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
|
- 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**
|
**First login not working**
|
||||||
- Check `INITIAL_PASSWORD` in `.env`
|
- Check `INITIAL_PASSWORD` in `.env`
|
||||||
- If unset, fallback password is `123456`
|
- If unset, fallback password is `123456`
|
||||||
@@ -1184,80 +1165,6 @@ Authorization: Bearer your-api-key
|
|||||||
→ Returns all models + combos in OpenAI format
|
→ 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
|
## 📧 Support
|
||||||
|
|
||||||
- **Website**: [9router.com](https://9router.com)
|
- **Website**: [9router.com](https://9router.com)
|
||||||
@@ -1278,17 +1185,7 @@ Thanks to all contributors who helped make 9Router better!
|
|||||||
|
|
||||||
[](https://starchart.cc/decolua/9router)
|
[](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
|
## 🔀 Forks
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 740 KiB |
@@ -5,12 +5,10 @@ import {
|
|||||||
parseConnectRPCFrame,
|
parseConnectRPCFrame,
|
||||||
extractTextFromResponse
|
extractTextFromResponse
|
||||||
} from "../utils/cursorProtobuf.js";
|
} from "../utils/cursorProtobuf.js";
|
||||||
|
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
||||||
import { estimateUsage } from "../utils/usageTracking.js";
|
import { estimateUsage } from "../utils/usageTracking.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { buildCursorRequest } from "../translator/request/openai-to-cursor.js";
|
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import crypto from "crypto";
|
|
||||||
import { v5 as uuidv5 } from "uuid";
|
|
||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
|
|
||||||
// Detect cloud environment
|
// Detect cloud environment
|
||||||
@@ -37,18 +35,50 @@ const COMPRESS_FLAG = {
|
|||||||
GZIP_TRAILER: 0x03
|
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) {
|
function decompressPayload(payload, flags) {
|
||||||
// ConnectRPC trailer frame (flags & 0x02) - contains status JSON, not compressed data
|
// Check if payload is JSON error (starts with {"error")
|
||||||
if (flags & COMPRESS_FLAG.TRAILER) {
|
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
|
||||||
return payload;
|
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 {
|
try {
|
||||||
return zlib.gunzipSync(payload);
|
return zlib.gunzipSync(payload);
|
||||||
} catch (err) {
|
} catch (gzipErr) {
|
||||||
console.log(`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, error=${err.message}`);
|
// Fallback: TRAILER and GZIP_TRAILER frames sometimes use raw zlib deflate format
|
||||||
return payload;
|
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;
|
return payload;
|
||||||
@@ -83,46 +113,6 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
return `${this.config.baseUrl}${this.config.chatPath}`;
|
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) {
|
buildHeaders(credentials) {
|
||||||
const accessToken = credentials.accessToken;
|
const accessToken = credentials.accessToken;
|
||||||
const machineId = credentials.providerSpecificData?.machineId;
|
const machineId = credentials.providerSpecificData?.machineId;
|
||||||
@@ -132,34 +122,14 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
throw new Error("Machine ID is required for Cursor API");
|
throw new Error("Machine ID is required for Cursor API");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken;
|
return buildCursorHeaders(accessToken, machineId, ghostMode);
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
const translatedBody = buildCursorRequest(model, body, stream, credentials);
|
// Messages are already translated by chatCore (claude→openai→cursor)
|
||||||
const messages = translatedBody.messages || [];
|
// Do NOT call buildCursorRequest again — double-translation drops tool_results
|
||||||
const tools = translatedBody.tools || body.tools || [];
|
const messages = body.messages || [];
|
||||||
|
const tools = body.tools || [];
|
||||||
const reasoningEffort = body.reasoning_effort || null;
|
const reasoningEffort = body.reasoning_effort || null;
|
||||||
return generateCursorBody(messages, model, tools, reasoningEffort);
|
return generateCursorBody(messages, model, tools, reasoningEffort);
|
||||||
}
|
}
|
||||||
@@ -282,48 +252,82 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
let totalContent = "";
|
let totalContent = "";
|
||||||
const toolCalls = [];
|
const toolCalls = [];
|
||||||
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||||
|
const finalizedIds = new Set();
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
|
||||||
|
debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
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 flags = buffer[offset];
|
||||||
const length = buffer.readUInt32BE(offset + 1);
|
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);
|
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||||
offset += 5 + length;
|
offset += 5 + length;
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
// Stop at ConnectRPC trailer frame (end of response, anything after is a separate response)
|
payload = decompressPayload(payload, flags);
|
||||||
if (flags & COMPRESS_FLAG.TRAILER) {
|
if (!payload) {
|
||||||
break;
|
debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = decompressPayload(payload, flags);
|
// Check for JSON error frames (byte guard: skip toString on non-JSON frames)
|
||||||
if (!payload) continue;
|
if (payload.length > 0 && payload[0] === 0x7b) {
|
||||||
|
try {
|
||||||
try {
|
const text = payload.toString("utf-8");
|
||||||
const text = payload.toString("utf-8");
|
if (text.includes('"error"')) {
|
||||||
if (text.startsWith("{") && text.includes('"error"')) {
|
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||||
return createErrorResponse(JSON.parse(text));
|
debugLog(
|
||||||
}
|
`[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||||
} catch {}
|
);
|
||||||
|
if (hasContent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return createErrorResponse(JSON.parse(text));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||||
|
debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return new Response(JSON.stringify({
|
const hasContent = totalContent || toolCallsMap.size > 0;
|
||||||
error: {
|
debugLog(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`);
|
||||||
message: result.error,
|
if (hasContent) {
|
||||||
type: "rate_limit_error",
|
break;
|
||||||
code: "rate_limited"
|
}
|
||||||
|
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) {
|
if (result.toolCall) {
|
||||||
@@ -342,6 +346,7 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
// Push to final array when isLast is true
|
// Push to final array when isLast is true
|
||||||
if (tc.isLast) {
|
if (tc.isLast) {
|
||||||
const finalToolCall = toolCallsMap.get(tc.id);
|
const finalToolCall = toolCallsMap.get(tc.id);
|
||||||
|
finalizedIds.add(tc.id);
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
id: finalToolCall.id,
|
id: finalToolCall.id,
|
||||||
type: finalToolCall.type,
|
type: finalToolCall.type,
|
||||||
@@ -356,10 +361,15 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
if (result.text) totalContent += result.text;
|
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)
|
// Finalize all remaining tool calls in map (in case stream ended without isLast=true)
|
||||||
for (const [id, tc] of toolCallsMap.entries()) {
|
for (const [id, tc] of toolCallsMap.entries()) {
|
||||||
// Check if already in final array
|
// 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({
|
toolCalls.push({
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
type: tc.type,
|
type: tc.type,
|
||||||
@@ -371,6 +381,8 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`);
|
||||||
|
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -411,65 +423,104 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
let totalContent = "";
|
let totalContent = "";
|
||||||
const toolCalls = [];
|
const toolCalls = [];
|
||||||
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||||
|
const finalizedIds = new Set();
|
||||||
|
const emittedToolCallIds = new Set();
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
|
||||||
|
debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
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 flags = buffer[offset];
|
||||||
const length = buffer.readUInt32BE(offset + 1);
|
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);
|
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||||
offset += 5 + length;
|
offset += 5 + length;
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
// Stop at ConnectRPC trailer frame (end of response, anything after is a separate response)
|
payload = decompressPayload(payload, flags);
|
||||||
if (flags & COMPRESS_FLAG.TRAILER) {
|
if (!payload) {
|
||||||
break;
|
debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = decompressPayload(payload, flags);
|
// Check for JSON error frames (byte-guard: only decode if starts with '{')
|
||||||
if (!payload) continue;
|
if (payload[0] === 0x7b) {
|
||||||
|
try {
|
||||||
try {
|
const text = payload.toString("utf-8");
|
||||||
const text = payload.toString("utf-8");
|
if (text.includes('"error"')) {
|
||||||
if (text.startsWith("{") && text.includes('"error"')) {
|
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||||
return createErrorResponse(JSON.parse(text));
|
debugLog(
|
||||||
}
|
`[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}`
|
||||||
} catch {}
|
);
|
||||||
|
if (hasContent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return createErrorResponse(JSON.parse(text));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||||
|
debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return new Response(JSON.stringify({
|
const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0;
|
||||||
error: {
|
debugLog(`[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}`);
|
||||||
message: result.error,
|
if (hasContent) {
|
||||||
type: "rate_limit_error",
|
break;
|
||||||
code: "rate_limited"
|
}
|
||||||
|
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) {
|
if (result.toolCall) {
|
||||||
const tc = result.toolCall;
|
const tc = result.toolCall;
|
||||||
|
|
||||||
if (chunks.length === 0) {
|
if (chunks.length === 0) {
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(
|
||||||
id: responseId,
|
`data: ${JSON.stringify({
|
||||||
object: "chat.completion.chunk",
|
id: responseId,
|
||||||
created,
|
object: "chat.completion.chunk",
|
||||||
model,
|
created,
|
||||||
choices: [{
|
model,
|
||||||
index: 0,
|
choices: [
|
||||||
delta: { role: "assistant", content: "" },
|
{
|
||||||
finish_reason: null
|
index: 0,
|
||||||
}]
|
delta: { role: "assistant", content: "" },
|
||||||
})}\n\n`);
|
finish_reason: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolCallsMap.has(tc.id)) {
|
if (toolCallsMap.has(tc.id)) {
|
||||||
@@ -481,106 +532,185 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
// Stream the delta arguments
|
// Stream the delta arguments
|
||||||
if (tc.function.arguments) {
|
if (tc.function.arguments) {
|
||||||
chunks.push(`data: ${JSON.stringify({
|
emittedToolCallIds.add(tc.id);
|
||||||
id: responseId,
|
chunks.push(
|
||||||
object: "chat.completion.chunk",
|
`data: ${JSON.stringify({
|
||||||
created,
|
id: responseId,
|
||||||
model,
|
object: "chat.completion.chunk",
|
||||||
choices: [{
|
created,
|
||||||
index: 0,
|
model,
|
||||||
delta: {
|
choices: [
|
||||||
tool_calls: [{
|
{
|
||||||
index: existing.index,
|
index: 0,
|
||||||
id: tc.id,
|
delta: {
|
||||||
type: "function",
|
tool_calls: [
|
||||||
function: {
|
{
|
||||||
name: tc.function.name,
|
index: existing.index,
|
||||||
arguments: tc.function.arguments
|
id: tc.id,
|
||||||
}
|
type: "function",
|
||||||
}]
|
function: {
|
||||||
},
|
name: tc.function.name,
|
||||||
finish_reason: null
|
arguments: tc.function.arguments
|
||||||
}]
|
}
|
||||||
})}\n\n`);
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
finish_reason: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New tool call - assign index and add to map
|
// New tool call - assign index and add to map
|
||||||
const toolCallIndex = toolCalls.length;
|
const toolCallIndex = toolCalls.length;
|
||||||
|
finalizedIds.add(tc.id);
|
||||||
toolCalls.push({ ...tc, index: toolCallIndex });
|
toolCalls.push({ ...tc, index: toolCallIndex });
|
||||||
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
|
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
|
||||||
|
|
||||||
// Stream initial tool call with name
|
// Stream initial tool call with name
|
||||||
chunks.push(`data: ${JSON.stringify({
|
emittedToolCallIds.add(tc.id);
|
||||||
id: responseId,
|
chunks.push(
|
||||||
object: "chat.completion.chunk",
|
`data: ${JSON.stringify({
|
||||||
created,
|
id: responseId,
|
||||||
model,
|
object: "chat.completion.chunk",
|
||||||
choices: [{
|
created,
|
||||||
index: 0,
|
model,
|
||||||
delta: {
|
choices: [
|
||||||
tool_calls: [{
|
{
|
||||||
index: toolCallIndex,
|
index: 0,
|
||||||
id: tc.id,
|
delta: {
|
||||||
type: "function",
|
tool_calls: [
|
||||||
function: {
|
{
|
||||||
name: tc.function.name,
|
index: toolCallIndex,
|
||||||
arguments: tc.function.arguments
|
id: tc.id,
|
||||||
}
|
type: "function",
|
||||||
}]
|
function: {
|
||||||
},
|
name: tc.function.name,
|
||||||
finish_reason: null
|
arguments: tc.function.arguments
|
||||||
}]
|
}
|
||||||
})}\n\n`);
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
finish_reason: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.text) {
|
if (result.text) {
|
||||||
totalContent += 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,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
created,
|
created,
|
||||||
model,
|
model,
|
||||||
choices: [{
|
choices: [
|
||||||
index: 0,
|
{
|
||||||
delta: chunks.length === 0 && toolCalls.length === 0
|
index: 0,
|
||||||
? { role: "assistant", content: result.text }
|
delta: { role: "assistant", content: "" },
|
||||||
: { content: result.text },
|
finish_reason: null
|
||||||
finish_reason: null
|
}
|
||||||
}]
|
]
|
||||||
})}\n\n`);
|
})}\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`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
|
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
|
||||||
|
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(
|
||||||
id: responseId,
|
`data: ${JSON.stringify({
|
||||||
object: "chat.completion.chunk",
|
id: responseId,
|
||||||
created,
|
object: "chat.completion.chunk",
|
||||||
model,
|
created,
|
||||||
choices: [{
|
model,
|
||||||
index: 0,
|
choices: [
|
||||||
delta: {},
|
{
|
||||||
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
index: 0,
|
||||||
}],
|
delta: {},
|
||||||
usage
|
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
||||||
})}\n\n`);
|
}
|
||||||
|
],
|
||||||
|
usage
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
chunks.push("data: [DONE]\n\n");
|
chunks.push("data: [DONE]\n\n");
|
||||||
|
|
||||||
return new Response(chunks.join(""), {
|
return new Response(chunks.join(""), {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* OpenAI to Cursor Request Translator
|
* OpenAI to Cursor Request Translator
|
||||||
* - assistant tool_calls → kept as-is (Cursor generates tool calls)
|
* Converts OpenAI messages to Cursor ask/agent format.
|
||||||
* - Claude tool_use blocks → converted to OpenAI tool_calls format
|
*
|
||||||
* - tool results → converted to user message string
|
* 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 { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
@@ -10,96 +12,154 @@ import { FORMATS } from "../formats.js";
|
|||||||
function extractContent(content) {
|
function extractContent(content) {
|
||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") return content;
|
||||||
if (Array.isArray(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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a map of tool_use_id → tool_name from the previous assistant message
|
function sanitizeToolResultText(text) {
|
||||||
function getToolNameMap(prevMsg) {
|
// Strip non-printable control chars that can produce backend request errors
|
||||||
const map = {};
|
return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
|
||||||
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;
|
function escapeXml(text) {
|
||||||
}
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
return map;
|
}
|
||||||
|
|
||||||
|
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) {
|
function convertMessages(messages) {
|
||||||
const result = [];
|
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++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
|
|
||||||
if (msg.role === "system") {
|
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({
|
result.push({
|
||||||
role: "user",
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.role === "assistant") {
|
if (msg.role === "tool") {
|
||||||
let content = extractContent(msg.content) || "";
|
const toolContent = extractContent(msg.content);
|
||||||
let tool_calls = null;
|
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) {
|
if (msg.role === "user" || msg.role === "assistant") {
|
||||||
// OpenAI format: strip `index` field
|
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||||
tool_calls = msg.tool_calls.map(({ index, ...tc }) => tc);
|
const parts = [];
|
||||||
} else if (Array.isArray(msg.content)) {
|
for (const block of msg.content) {
|
||||||
// Claude format: extract tool_use blocks from content array
|
if (!block || typeof block !== "object") continue;
|
||||||
const extracted = msg.content
|
if (block.type === "text") {
|
||||||
.filter(b => b.type === "tool_use")
|
if (typeof block.text === "string") {
|
||||||
.map(b => ({
|
parts.push(block.text || "");
|
||||||
id: b.id,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: b.name,
|
|
||||||
arguments: JSON.stringify(b.input || {})
|
|
||||||
}
|
}
|
||||||
}));
|
continue;
|
||||||
if (extracted.length > 0) tool_calls = extracted;
|
}
|
||||||
|
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) {
|
const content = extractContent(msg.content);
|
||||||
result.push({ role: "assistant", content, tool_calls });
|
|
||||||
} else if (content) {
|
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
|
||||||
result.push({ role: "assistant", content });
|
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 clientKey = generateHashed64Hex(cleanToken);
|
||||||
const checksum = generateCursorChecksum(effectiveMachineId);
|
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 {
|
return {
|
||||||
"Authorization": `Bearer ${cleanToken}`,
|
"authorization": `Bearer ${cleanToken}`,
|
||||||
"connect-accept-encoding": "gzip",
|
"connect-accept-encoding": "gzip",
|
||||||
"connect-protocol-version": "1",
|
"connect-protocol-version": "1",
|
||||||
"Content-Type": "application/connect+proto",
|
"content-type": "application/connect+proto",
|
||||||
"User-Agent": "connect-es/1.6.1",
|
"user-agent": "connect-es/1.6.1",
|
||||||
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
"x-amzn-trace-id": `Root=${crypto.randomUUID()}`,
|
||||||
"x-client-key": clientKey,
|
"x-client-key": clientKey,
|
||||||
"x-cursor-checksum": checksum,
|
"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-config-version": crypto.randomUUID(),
|
||||||
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
"x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
"x-ghost-mode": ghostMode ? "true" : "false",
|
"x-ghost-mode": ghostMode ? "true" : "false",
|
||||||
"x-request-id": crypto.randomUUID(),
|
"x-request-id": crypto.randomUUID(),
|
||||||
"x-session-id": sessionId,
|
"x-session-id": sessionId
|
||||||
"Host": "api2.cursor.sh"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import zlib from "zlib";
|
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 log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args);
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
const PROTOBUF_SCHEMA_VERSION = "1.1.3";
|
||||||
|
|
||||||
// ==================== SCHEMAS ====================
|
// ==================== SCHEMAS ====================
|
||||||
|
|
||||||
@@ -18,6 +21,8 @@ const ROLE = { USER: 1, ASSISTANT: 2 };
|
|||||||
const UNIFIED_MODE = { CHAT: 1, AGENT: 2 };
|
const UNIFIED_MODE = { CHAT: 1, AGENT: 2 };
|
||||||
|
|
||||||
const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 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 = {
|
const FIELD = {
|
||||||
// StreamUnifiedChatRequestWithTools (top level)
|
// StreamUnifiedChatRequestWithTools (top level)
|
||||||
@@ -67,28 +72,25 @@ const FIELD = {
|
|||||||
TOOL_RESULT_TOOL_CALL: 11,
|
TOOL_RESULT_TOOL_CALL: 11,
|
||||||
TOOL_RESULT_MODEL_CALL_ID: 12,
|
TOOL_RESULT_MODEL_CALL_ID: 12,
|
||||||
|
|
||||||
// ClientSideToolV2Result
|
// ClientSideToolV2Result (nested inside ToolResult.result)
|
||||||
CV2R_TOOL: 1,
|
CLIENT_RESULT_TOOL: 1,
|
||||||
CV2R_MCP_RESULT: 28,
|
CLIENT_RESULT_MCP_RESULT: 28,
|
||||||
CV2R_CALL_ID: 35,
|
CLIENT_RESULT_TOOL_CALL_ID: 35,
|
||||||
CV2R_MODEL_CALL_ID: 48,
|
CLIENT_RESULT_MODEL_CALL_ID: 48,
|
||||||
CV2R_TOOL_INDEX: 49,
|
CLIENT_RESULT_TOOL_INDEX: 49,
|
||||||
|
|
||||||
// MCPResult
|
// MCPResult (nested inside ClientSideToolV2Result.mcp_result)
|
||||||
MCPR_SELECTED_TOOL: 1,
|
MCP_RESULT_SELECTED_TOOL: 1,
|
||||||
MCPR_RESULT: 2,
|
MCP_RESULT_RESULT: 2,
|
||||||
|
|
||||||
// ClientSideToolV2Call
|
// ClientSideToolV2Call (nested inside ToolResult.tool_call)
|
||||||
CV2C_TOOL: 1,
|
CLIENT_CALL_TOOL: 1,
|
||||||
CV2C_MCP_PARAMS: 27,
|
CLIENT_CALL_MCP_PARAMS: 27,
|
||||||
CV2C_CALL_ID: 3,
|
CLIENT_CALL_TOOL_CALL_ID: 3,
|
||||||
CV2C_NAME: 9,
|
CLIENT_CALL_NAME: 9,
|
||||||
CV2C_RAW_ARGS: 10,
|
CLIENT_CALL_RAW_ARGS: 10,
|
||||||
CV2C_TOOL_INDEX: 48,
|
CLIENT_CALL_TOOL_INDEX: 48,
|
||||||
CV2C_MODEL_CALL_ID: 49,
|
CLIENT_CALL_MODEL_CALL_ID: 49,
|
||||||
|
|
||||||
// ConversationMessage extra fields
|
|
||||||
MSG_SERVER_BUBBLE_ID: 32,
|
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
MODEL_NAME: 1,
|
MODEL_NAME: 1,
|
||||||
@@ -135,6 +137,7 @@ const FIELD = {
|
|||||||
TOOL_NAME: 9,
|
TOOL_NAME: 9,
|
||||||
TOOL_RAW_ARGS: 10,
|
TOOL_RAW_ARGS: 10,
|
||||||
TOOL_IS_LAST: 11,
|
TOOL_IS_LAST: 11,
|
||||||
|
TOOL_IS_LAST_ALT: 15,
|
||||||
TOOL_MCP_PARAMS: 27,
|
TOOL_MCP_PARAMS: 27,
|
||||||
|
|
||||||
// MCPParams
|
// MCPParams
|
||||||
@@ -152,6 +155,19 @@ const FIELD = {
|
|||||||
THINKING_TEXT: 1
|
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 ====================
|
// ==================== PRIMITIVE ENCODING ====================
|
||||||
|
|
||||||
export function encodeVarint(value) {
|
export function encodeVarint(value) {
|
||||||
@@ -200,15 +216,46 @@ function concatArrays(...arrays) {
|
|||||||
|
|
||||||
// ==================== MESSAGE ENCODING ====================
|
// ==================== MESSAGE ENCODING ====================
|
||||||
|
|
||||||
// ClientSideToolV2 enum: MCP = 19
|
|
||||||
const CLIENT_SIDE_TOOL_V2_MCP = 19;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format tool name: "toolName" → "mcp_custom_toolName"
|
* Format tool name: "toolName" → "mcp_custom_toolName"
|
||||||
|
* Also handles: "mcp__server__tool" → "mcp_server_tool"
|
||||||
*/
|
*/
|
||||||
function formatToolName(name) {
|
function formatToolName(name) {
|
||||||
if (name.startsWith("mcp_")) return name;
|
const base = typeof name === "string" && name.length > 0 ? name : "tool";
|
||||||
return `mcp_custom_${name}`;
|
|
||||||
|
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(
|
return concatArrays(
|
||||||
encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
|
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_MCP_RESULT, WIRE_TYPE.LEN, encodeMcpResult(selectedTool, resultContent)),
|
||||||
encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||||
...(modelCallId ? [encodeField(FIELD.CV2R_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []),
|
...(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(
|
return concatArrays(
|
||||||
encodeField(FIELD.CV2C_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP),
|
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_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||||
encodeField(FIELD.CV2C_NAME, WIRE_TYPE.LEN, toolName),
|
encodeField(FIELD.CV2C_NAME, WIRE_TYPE.LEN, toolName),
|
||||||
encodeField(FIELD.CV2C_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
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)] : [])
|
...(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 originalName = toolResult.tool_name || toolResult.name || "";
|
||||||
const toolName = formatToolName(originalName);
|
const toolName = formatToolName(originalName);
|
||||||
const rawArgs = toolResult.raw_args || "{}";
|
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 { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || "");
|
||||||
|
const toolIndex = toolResult.tool_index || toolResult.index || 1;
|
||||||
|
|
||||||
// Derive mcpToolName: strip "mcp_" prefix → "custom_toolName"
|
// Parse tool name to extract server and selected tool
|
||||||
const mcpToolName = toolName.startsWith("mcp_") ? toolName.slice(4) : originalName;
|
const { serverName, selectedTool } = parseToolName(toolName);
|
||||||
|
|
||||||
return concatArrays(
|
return concatArrays(
|
||||||
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||||
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
|
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)] : []),
|
...(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_RAW_ARGS, WIRE_TYPE.LEN, rawArgs),
|
||||||
encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN,
|
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,
|
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 isAgentic = hasTools;
|
||||||
const formattedMessages = [];
|
const formattedMessages = [];
|
||||||
const messageIds = [];
|
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++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[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 role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT;
|
||||||
const msgId = uuidv4();
|
const msgId = uuidv4();
|
||||||
const isLast = i === messages.length - 1;
|
const isLast = i === normalizedMessages.length - 1;
|
||||||
|
|
||||||
formattedMessages.push({
|
formattedMessages.push({
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
@@ -719,6 +827,16 @@ export function extractTextFromResponse(payload) {
|
|||||||
try {
|
try {
|
||||||
const fields = decodeMessage(payload);
|
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
|
// Field 1: ClientSideToolV2Call
|
||||||
if (fields.has(FIELD.TOOL_CALL)) {
|
if (fields.has(FIELD.TOOL_CALL)) {
|
||||||
const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value);
|
const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value);
|
||||||
@@ -739,8 +857,15 @@ export function extractTextFromResponse(payload) {
|
|||||||
|
|
||||||
return { text: null, error: null, toolCall: null, thinking: null };
|
return { text: null, error: null, toolCall: null, thinking: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log("EXTRACT", `Error: ${err.message}`);
|
log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`);
|
||||||
return { text: null, error: null, toolCall: null, thinking: null };
|
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",
|
"name": "9router-app",
|
||||||
"version": "0.3.35",
|
"version": "0.3.41",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -139,17 +139,32 @@ export default function ModelSelectModal({
|
|||||||
hasModels: nodeModels.length > 0,
|
hasModels: nodeModels.length > 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const models = getModelsByProviderId(providerId);
|
const hardcodedModels = getModelsByProviderId(providerId);
|
||||||
if (models.length > 0) {
|
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] = {
|
groups[providerId] = {
|
||||||
name: providerInfo.name,
|
name: providerInfo.name,
|
||||||
alias: alias,
|
alias: alias,
|
||||||
color: providerInfo.color,
|
color: providerInfo.color,
|
||||||
models: models.map((m) => ({
|
models: allModels,
|
||||||
id: m.id,
|
|
||||||
name: m.name,
|
|
||||||
value: `${alias}/${m.id}`,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,6 +314,11 @@ export default function ModelSelectModal({
|
|||||||
<span className="material-symbols-outlined text-[11px]">edit</span>
|
<span className="material-symbols-outlined text-[11px]">edit</span>
|
||||||
{model.name}
|
{model.name}
|
||||||
</span>
|
</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}
|
) : model.name}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user