Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
| 多轮 agent loop(工具结果回放) | ✅ |
| **工具集由调用方控制** —— Cursor 自带工具全部隐藏 | ✅ |
| 200+ 模型,按账号实时拉取 | ✅ |
| 多账号池,轮询 + 失败冷却 | ✅ |
| 多账号池,轮询 | ✅ |
| token 自动刷新 | ✅ |
| 客户端 `system` 提示词 | ⚠️ 尽力而为,[见下](#已知限制) |

Expand Down Expand Up @@ -86,7 +86,8 @@ node dist/index.cjs
`cursorAuth/accessToken`)。自己电脑上更省事。

两个脚本都写 0600 权限的文件,只打印元信息,不打印 token。多账号就带
`--merge --label 名字` 各跑一次;池子会轮询,某个账号失败后冷却 60 秒。
`--merge --label 名字` 各跑一次;池子会轮询。(池子里有冷却逻辑,但目前没有调用方——
失败的账号不会被摘掉,下一轮照常轮到它。)

access token 有效期约两个月,到期前会用 refresh token 自动续,所以正常只需登录一次。

Expand Down Expand Up @@ -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)。
Expand Down
36 changes: 32 additions & 4 deletions src/cursor/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type TurnEvent =
| { type: "thinking"; delta: string }
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
| { 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;
Expand All @@ -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-<uuid>-0\nfc_<uuid>_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;

Expand Down Expand Up @@ -154,7 +174,14 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator<TurnEvent>

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);
Expand Down Expand Up @@ -195,6 +222,7 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator<TurnEvent>
return {
type: "done",
reason: "error",
code: parsed.error.code,
error: `${parsed.error.code ?? "error"}: ${parsed.error.message ?? trailer}`,
};
}
Expand Down Expand Up @@ -256,7 +284,7 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator<TurnEvent>
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,
});
Expand All @@ -283,7 +311,7 @@ export async function* runTurn(opts: RunTurnOptions): AsyncGenerator<TurnEvent>
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" });
Expand Down
40 changes: 37 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TurnEvent>): Promise<Collected> {
Expand All @@ -135,6 +136,7 @@ async function collect(events: AsyncGenerator<TurnEvent>): Promise<Collected> {
} else if (ev.type === "done") {
out.reason = ev.reason;
out.error = ev.error;
out.code = ev.code;
}
}
return out;
Expand All @@ -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()}`;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, number> = {
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<string, number> {
const prompt = usage.inputTokens ?? 0;
const completion = usage.outputTokens ?? 0;
Expand Down
Loading