diff --git a/README.md b/README.md index df50a62..543ee3a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ your app ──► /v1/chat/completions ──► cursor2api ──► api2.curs | Multi-turn agent loops (tool results replayed) | ✅ | | **Caller controls the tool set** — Cursor's native tools hidden | ✅ | | 200+ models, fetched live from your account | ✅ | -| Multi-account pool, round-robin + cooldown | ✅ | +| Multi-account pool, round-robin | ✅ | | Automatic token refresh | ✅ | | Client-supplied `system` prompt | ⚠️ best effort — [see below](#limitations) | @@ -89,8 +89,9 @@ if the box needs one to reach `cursor.com`. desktop install (`state.vscdb`). Handy on your own laptop. Both write mode-0600 files and print only metadata, never the tokens. For several -accounts, run either with `--merge --label some-name`; the pool round-robins over them -and cools an account down for 60s after a failure. +accounts, run either with `--merge --label some-name`; the pool round-robins over +them. (There is a cooldown path in the pool, but nothing calls it yet — a failing +account is retried on its next turn rather than being parked.) Access tokens last about two months and are refreshed automatically via the refresh token, so a one-time login is normally all you need. @@ -126,6 +127,11 @@ resp = client.chat.completions.create( resp.choices[0].message.tool_calls[0].function.arguments # '{"city":"Osaka"}' ``` +Errors carry a real status code rather than a blanket `502`: the upstream Connect code +is mapped to what an OpenAI client expects, so subscription quota exhaustion arrives as +`429` / `rate_limit_exceeded` and aggregators back off and retry instead of writing the +gateway off as dead. Anything else upstream stays a `502`. + Anything that speaks OpenAI works: Cline, Roo, Continue, LobeChat, one-api, your own scripts. If you aggregate several subscription providers behind [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI), see diff --git a/README.zh-CN.md b/README.zh-CN.md index 079e83b..279d8a3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,7 +27,7 @@ | 多轮 agent loop(工具结果回放) | ✅ | | **工具集由调用方控制** —— Cursor 自带工具全部隐藏 | ✅ | | 200+ 模型,按账号实时拉取 | ✅ | -| 多账号池,轮询 + 失败冷却 | ✅ | +| 多账号池,轮询 | ✅ | | token 自动刷新 | ✅ | | 客户端 `system` 提示词 | ⚠️ 尽力而为,[见下](#已知限制) | @@ -86,7 +86,8 @@ node dist/index.cjs `cursorAuth/accessToken`)。自己电脑上更省事。 两个脚本都写 0600 权限的文件,只打印元信息,不打印 token。多账号就带 -`--merge --label 名字` 各跑一次;池子会轮询,某个账号失败后冷却 60 秒。 +`--merge --label 名字` 各跑一次;池子会轮询。(池子里有冷却逻辑,但目前没有调用方—— +失败的账号不会被摘掉,下一轮照常轮到它。) access token 有效期约两个月,到期前会用 refresh token 自动续,所以正常只需登录一次。 @@ -121,6 +122,10 @@ resp = client.chat.completions.create( resp.choices[0].message.tool_calls[0].function.arguments # '{"city":"Osaka"}' ``` +错误会带真实状态码,不再一律 502:上游 Connect code 会映射成 OpenAI 客户端预期的状态, +订阅配额耗尽返回 `429` / `rate_limit_exceeded`,聚合层因此会退避重试,而不是把网关当成 +挂了。其余上游故障仍然是 `502`。 + 任何吃 OpenAI 格式的东西都能接:Cline、Roo、Continue、LobeChat、one-api,或者你自己的 脚本。如果你用 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 聚合多个 订阅型 provider,见 [docs/deploy-behind-cliproxyapi.md](docs/deploy-behind-cliproxyapi.md)。 diff --git a/src/cursor/session.ts b/src/cursor/session.ts index c7a9abf..a812fc3 100644 --- a/src/cursor/session.ts +++ b/src/cursor/session.ts @@ -37,7 +37,7 @@ export type TurnEvent = | { type: "thinking"; delta: string } | { type: "tool_call"; id: string; name: string; args: Record } | { type: "usage"; inputTokens?: number; outputTokens?: number } - | { type: "done"; reason: "stop" | "tool_calls" | "error"; error?: string }; + | { type: "done"; reason: "stop" | "tool_calls" | "error"; error?: string; code?: string }; export interface RunTurnOptions { token: string; @@ -51,6 +51,26 @@ export interface RunTurnOptions { signal?: AbortSignal; } +/** + * Upstream tool-call ids are not always safe to hand back to a caller: grok models + * return two ids joined by a literal newline (`call--0\nfc__0`). The + * caller round-trips this string as `tool_call_id`, and anything that re-encodes it + * under a stricter schema — Anthropic's `tool_use_id` pattern, for one — rejects the + * raw form, so fold everything outside the id alphabet into `_`. + */ +function safeToolCallId(raw: unknown): string { + const id = String(raw ?? "").replace(/[^A-Za-z0-9_-]/g, "_"); + return id || `call_${randomUUID()}`; +} + +/** Map an upstream HTTP status onto the Connect code the trailer would have carried. */ +function connectCodeForHttp(status: number): string | undefined { + if (status === 429) return "resource_exhausted"; + if (status === 401) return "unauthenticated"; + if (status === 403) return "permission_denied"; + return undefined; +} + const FLAG_COMPRESSED = 0x01; const FLAG_END_STREAM = 0x02; @@ -154,7 +174,14 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator req.on("response", (h) => { const status = Number(h[":status"] || 0); - if (status !== 200) end({ type: "done", reason: "error", error: `upstream HTTP ${status}` }); + if (status !== 200) { + end({ + type: "done", + reason: "error", + code: connectCodeForHttp(status), + error: `upstream HTTP ${status}`, + }); + } }); let buffer = Buffer.alloc(0); @@ -195,6 +222,7 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator return { type: "done", reason: "error", + code: parsed.error.code, error: `${parsed.error.code ?? "error"}: ${parsed.error.message ?? trailer}`, }; } @@ -256,7 +284,7 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator for (const [key, raw] of Object.entries(a.args ?? {})) args[key] = decodeArgValue(raw); push({ type: "tool_call", - id: String(a.toolCallId ?? randomUUID()), + id: safeToolCallId(a.toolCallId), name: String(a.name ?? a.toolName ?? "unknown"), args, }); @@ -283,7 +311,7 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator req.on("end", () => end({ type: "done", reason: "stop" })); const timer = setTimeout( - () => end({ type: "done", reason: "error", error: "upstream timeout" }), + () => end({ type: "done", reason: "error", code: "deadline_exceeded", error: "upstream timeout" }), config.requestTimeoutMs, ); const onAbort = (): void => end({ type: "done", reason: "error", error: "client aborted" }); diff --git a/src/server.ts b/src/server.ts index 0e0fd39..a309787 100644 --- a/src/server.ts +++ b/src/server.ts @@ -121,6 +121,7 @@ interface Collected { usage: { inputTokens?: number; outputTokens?: number }; reason: "stop" | "tool_calls" | "error"; error?: string; + code?: string; } async function collect(events: AsyncGenerator): Promise { @@ -135,6 +136,7 @@ async function collect(events: AsyncGenerator): Promise { } else if (ev.type === "done") { out.reason = ev.reason; out.error = ev.error; + out.code = ev.code; } } return out; @@ -150,7 +152,8 @@ async function bufferResponse( const result = await collect(events); if (result.reason === "error") { deps.log(`upstream error (${accountLabel}): ${result.error}`); - sendJson(res, 502, { error: { message: result.error ?? "upstream error", type: "upstream_error" } }); + const { status, body } = upstreamError(result.code, result.error); + sendJson(res, status, body); return; } const id = `chatcmpl-${randomUUID()}`; @@ -241,12 +244,13 @@ async function streamResponse( } else if (ev.type === "done") { if (ev.reason === "error") { deps.log(`upstream error (${accountLabel}): ${ev.error}`); + const { status, body } = upstreamError(ev.code, ev.error); if (!opened) { - sendJson(res, 502, { error: { message: ev.error ?? "upstream error", type: "upstream_error" } }); + sendJson(res, status, body); return; } // Mid-stream failures can only be reported inside the stream. - emit(res, { error: { message: ev.error ?? "upstream error", type: "upstream_error" } }); + emit(res, body); res.write("data: [DONE]\n\n"); res.end(); return; @@ -284,6 +288,36 @@ function chunk( }; } +/** + * Connect codes that a caller can act on differently, mapped to the HTTP status an + * OpenAI client expects. Quota exhaustion is the one that matters in practice: as a + * 502 it reads as a broken gateway, so clients and aggregators give up instead of + * backing off and retrying. Anything unrecognised stays a 502 rather than being + * dressed up as a client error. + */ +const STATUS_BY_CODE: Record = { + resource_exhausted: 429, + unauthenticated: 401, + permission_denied: 403, + invalid_argument: 400, + not_found: 404, + unimplemented: 501, + unavailable: 503, + deadline_exceeded: 504, +}; + +function upstreamError( + code: string | undefined, + message: string | undefined, +): { status: number; body: { error: { message: string; type: string; code?: string } } } { + const status = (code ? STATUS_BY_CODE[code] : undefined) ?? 502; + const type = status === 429 ? "rate_limit_exceeded" : "upstream_error"; + return { + status, + body: { error: { message: message ?? "upstream error", type, ...(code ? { code } : {}) } }, + }; +} + function usageBlock(usage: Collected["usage"]): Record { const prompt = usage.inputTokens ?? 0; const completion = usage.outputTokens ?? 0;