From 1c53517d9e7d19a02e596c632d8e80f4a45a957e Mon Sep 17 00:00:00 2001 From: Beta-Devin AI <248786709+beta-devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:46:09 +0000 Subject: [PATCH] Keep the Cursor stream open across tool round trips Co-Authored-By: Test --- .env.example | 17 +++ CHANGELOG.md | 36 +++++++ Dockerfile | 12 +++ README.md | 56 +++++++--- README.zh-CN.md | 34 ++++-- cursor2api/openai_api.py | 61 +++++++++++ cursor2api/server.py | 222 +++++++++++++++++++++++++++++++++++---- docker-compose.yml | 15 +++ pyproject.toml | 2 +- tests/test_units.py | 107 +++++++++++++++++++ 10 files changed, 511 insertions(+), 51 deletions(-) create mode 100644 .env.example create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 tests/test_units.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b339926 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Cursor credential (a crsr_ key from cursor.com/dashboard). Skip it to log in +# with the browser instead: python -m cursor2api login +CURSOR_API_KEY= + +# Key(s) local clients must send as x-api-key or Bearer, comma separated. +# Empty means the port needs no key. +API_KEY= + +BIND=127.0.0.1 +PORT=8787 +DEFAULT_MODEL=claude-sonnet-5 + +# Prepended to every turn's system prompt. +CURSOR2API_SYSTEM_PROMPT= + +# Seconds an unfinished tool round trip may keep its upstream stream open. +CURSOR2API_RESUME_TTL=900 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c29bdfc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +## v0.1.2 + +- Tool calls now continue the Cursor turn that asked for them: the upstream stream + stays open until the caller returns the `tool_result`, instead of replaying the + whole history as a new request. Claude Code agent loops that used to stall (the + model would re-verify earlier steps, or object that the transcript looked forged) + finish in one pass, each round trip costs one response instead of a full + re-read of Cursor's ~25k token harness. +- When a stream is no longer available, the replayed history is attributed to the + relay rather than pasted in as `Human:`/`Assistant:` lines. + +## v0.1.1 + +- OpenAI `response_format`: `json_object` and `json_schema` are emulated with an + instruction, and markdown code fences are stripped from the answer, streaming + included. +- `API_KEY` accepts several comma-separated client keys (`AUTH_TOKEN` also works). +- A `.env` next to the server is read at startup. +- `Dockerfile` and `docker-compose.yml`. +- Plain conversations no longer talk about a workspace or reach for Cursor's + built-in tools. +- A turn now ends on `turn_ended` or when the stream closes instead of on a short + silence, so long file writes in Claude Code are no longer cut off mid-task. +- SSE keepalives while the upstream is quiet, a first-event timeout, and Cursor + error codes mapped onto Anthropic error types (`403` for a model the account + has not enabled). + +## v0.1.0 + +- Anthropic `POST /v1/messages` and OpenAI `POST /v1/chat/completions`, streaming + and buffered, over Cursor's `agent.v1.AgentService/Run` protocol. +- `GET /v1/models` from the account's own catalog. +- Tools, images, PDFs, thinking, usage, Cursor web search, Claude Code support. +- Cursor API key or browser (PKCE) login. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9fae4a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY cursor2api ./cursor2api +COPY pyproject.toml README.md LICENSE ./ + +ENV BIND=0.0.0.0 PORT=8787 CURSOR2API_USE_CLI_AUTH=0 CURSOR2API_AUTO_LOGIN=0 +EXPOSE 8787 +CMD ["python", "-m", "cursor2api", "serve"] diff --git a/README.md b/README.md index 833f766..2255f78 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,30 @@ -# cursor2api +# cursor2api — Cursor to OpenAI / Anthropic compatible API proxy -[中文说明](README.zh-CN.md) +[中文说明](README.zh-CN.md) · [Changelog](CHANGELOG.md) -Use the models of your Cursor account through the Anthropic Messages API and the -OpenAI Chat Completions API. +Turns your Cursor account into an OpenAI- and Anthropic-compatible endpoint, so +Claude Code, Cherry Studio, LobeChat, the OpenAI SDK and the Anthropic SDK can use +Cursor's models directly. Pure protocol client: no browser automation, no Cursor IDE +running in the background. -- `POST /v1/messages` and `POST /v1/chat/completions`, streaming or buffered +- `POST /v1/messages` (Anthropic Messages) and `POST /v1/chat/completions` (OpenAI + Chat Completions), streaming (SSE) or buffered - every model the signed-in account can use, listed by `GET /v1/models` -- tools, images, PDFs, thinking/reasoning, usage -- authorise with a Cursor API key or a browser login +- function calling / tools, images, PDFs, thinking (reasoning), usage, + `response_format` JSON mode +- works with Claude Code out of the box +- authorise with a Cursor API key or a browser (OAuth2 PKCE) login +- one file per concern, standard library only apart from `h2` ## Install - git clone + git clone https://github.com/Yuki13929/cursor2api cd cursor2api pip install -r requirements.txt -Python 3.9+ and the `h2` package. +Python 3.9+ and the `h2` package. Docker works too: + + CURSOR_API_KEY=crsr_... docker compose up -d ## Authorise @@ -61,8 +69,11 @@ Routes: `POST /v1/messages`, `POST /v1/messages/count_tokens`, `POST /v1/chat/completions`, `GET /v1/models`, `GET /v1/models/{id}`, `GET /login`, `GET /health`. -Common settings: `PORT`, `BIND`, `API_KEY` (require an `x-api-key` from local clients), -`DEFAULT_MODEL`. +Common settings, from the environment or a `.env` next to the server +(see [.env.example](.env.example)): `PORT`, `BIND`, `API_KEY` (one or more +comma-separated keys local clients must send as `x-api-key` or `Bearer`), +`DEFAULT_MODEL`, `CURSOR2API_SYSTEM_PROMPT` (prepended to every turn), +`CURSOR2API_RESUME_TTL` (how long an unfinished tool round trip may stay open). ## Models @@ -81,10 +92,15 @@ Unknown ids fall back to `DEFAULT_MODEL`. ## Notes -- `temperature`, `top_p`, `top_k`, `cache_control`, `n`, `seed` and `response_format` - have no equivalent upstream and are ignored; `stop_sequences`, `max_tokens` and - `tool_choice` are approximated locally. +- `temperature`, `top_p`, `top_k`, `cache_control`, `n` and `seed` have no equivalent + upstream and are ignored; `stop_sequences`, `max_tokens`, `tool_choice` and + `response_format` are approximated locally. JSON mode is an instruction plus fence + stripping, not a grammar, so a schema is requested rather than enforced. - Thinking text is Cursor's summary and its Anthropic `signature` is always `""`. +- A tool round trip continues the Cursor turn it came from: the upstream stream is + kept open until the caller sends the `tool_result` back, which keeps agent loops + fast and coherent. Reconnecting instead replays the history as an attributed + transcript, which costs a full re-read of Cursor's harness. - Web search is Cursor's own server-side tool, reported as `server_tool_use` + `web_search_tool_result` with titles and urls only. - Cursor's agent system prompt is always present and inflates `input_tokens`. @@ -118,6 +134,12 @@ MIT. ## Repository topics - cursor cursor-ai cursor-api anthropic-api claude openai-api openai-compatible - anthropic-compatible llm-proxy api-proxy reverse-engineering protobuf connect-rpc - python sse streaming function-calling claude-code + cursor cursor-ai cursor-api cursor2api anthropic-api claude claude-code + openai-api openai-compatible anthropic-compatible llm-proxy api-proxy ai-gateway + reverse-engineering protobuf connect-rpc python sse streaming function-calling + gpt free-api + +Suggested repository description: + + Cursor to OpenAI/Anthropic compatible API proxy: use your Cursor account's models + from Claude Code and any OpenAI or Anthropic client. Tools, images, PDFs, streaming. diff --git a/README.zh-CN.md b/README.zh-CN.md index e07a322..00fa188 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,21 +1,26 @@ -# cursor2api +# cursor2api — Cursor 转 OpenAI / Anthropic 兼容 API 代理 -[English](README.md) +[English](README.md) · [更新日志](CHANGELOG.md) -用 Anthropic Messages API 和 OpenAI Chat Completions API 调用你 Cursor 账号里的模型。 +把你的 Cursor 账号变成 OpenAI 和 Anthropic 兼容的接口,Claude Code、Cherry Studio、 +LobeChat、OpenAI SDK、Anthropic SDK 都能直接用 Cursor 的模型。纯协议实现,不需要浏览器 +自动化,也不需要后台开着 Cursor IDE。 -- `POST /v1/messages`、`POST /v1/chat/completions`,支持流式和非流式 +- `POST /v1/messages`、`POST /v1/chat/completions`,支持流式(SSE)和非流式 - 账号能用的所有模型,`GET /v1/models` 列出 -- 工具调用、图片、PDF、thinking/reasoning、用量统计 -- 用 Cursor API key 或浏览器授权登录 +- 工具调用、图片、PDF、thinking/reasoning、用量统计、`response_format` JSON 模式 +- 开箱支持 Claude Code +- 用 Cursor API key 或浏览器授权(OAuth2 PKCE)登录 ## 安装 - git clone <你的仓库> + git clone https://github.com/Yuki13929/cursor2api cd cursor2api pip install -r requirements.txt -需要 Python 3.9+ 和 `h2`。 +需要 Python 3.9+ 和 `h2`。也可以用 Docker: + + CURSOR_API_KEY=crsr_... docker compose up -d ## 授权 @@ -59,7 +64,10 @@ OpenAI 客户端,同一个端口: `POST /v1/chat/completions`、`GET /v1/models`、`GET /v1/models/{id}`、`GET /login`、 `GET /health`。 -常用配置:`PORT`、`BIND`、`API_KEY`(要求本地客户端带 `x-api-key`)、`DEFAULT_MODEL`。 +常用配置可以写在环境变量或服务同目录的 `.env` 里(见 [.env.example](.env.example)): +`PORT`、`BIND`、`API_KEY`(可逗号分隔多个,本地客户端用 `x-api-key` 或 `Bearer` 带上)、 +`DEFAULT_MODEL`、`CURSOR2API_SYSTEM_PROMPT`(附加到每一轮的系统提示)、 +`CURSOR2API_RESUME_TTL`(未完成的工具回合最多保留多久)。 ## 模型 @@ -77,9 +85,13 @@ OpenAI 客户端,同一个端口: ## 说明 -- `temperature`、`top_p`、`top_k`、`cache_control`、`n`、`seed`、`response_format` - 在上游没有对应项,直接忽略;`stop_sequences`、`max_tokens`、`tool_choice` 是本地近似实现。 +- `temperature`、`top_p`、`top_k`、`cache_control`、`n`、`seed` 在上游没有对应项,直接 + 忽略;`stop_sequences`、`max_tokens`、`tool_choice`、`response_format` 是本地近似实现, + JSON 模式靠指令加去掉代码围栏,不是语法约束,schema 只是要求而非强制。 - thinking 内容是 Cursor 给的摘要,Anthropic 的 `signature` 恒为 `""`。 +- 工具回合会接着原来的 Cursor 回合继续:上游那条流一直保持到调用方把 `tool_result` + 发回来,所以 agent 循环又快又连贯。只有在这条流已经不在时才会退回重放历史(以明确 + 署名的对话记录形式),代价是 Cursor 那套系统提示要重新读一遍。 - 联网搜索用的是 Cursor 自己的服务端工具,转成 `server_tool_use` + `web_search_tool_result`,只有标题和链接。 - Cursor 的 agent 系统提示始终存在,会抬高 `input_tokens`。 diff --git a/cursor2api/openai_api.py b/cursor2api/openai_api.py index 28e8bf1..798754a 100644 --- a/cursor2api/openai_api.py +++ b/cursor2api/openai_api.py @@ -108,6 +108,10 @@ def to_anthropic(body): out["stop_sequences"] = [stop] if isinstance(stop, str) else list(stop) if body.get("reasoning_effort") not in (None, "none") or body.get("thinking"): out["thinking"] = {"type": "enabled"} + instruction = json_instruction(body.get("response_format")) + if instruction: + out["system"] = ((out.get("system", "") + "\n\n") if out.get("system") else "") \ + + instruction tools = [] for t in body.get("tools") or []: @@ -129,6 +133,63 @@ def to_anthropic(body): return out +def json_instruction(response_format): + """System text that emulates response_format, which Cursor has no knob for.""" + kind = (response_format or {}).get("type") + if kind == "json_object": + return ("Reply with a single JSON object and nothing else: no prose, no " + "explanation and no markdown code fence.") + if kind == "json_schema": + schema = (response_format.get("json_schema") or {}).get("schema") or {} + return ("Reply with a single JSON value matching this JSON Schema and nothing " + "else: no prose, no explanation and no markdown code fence.\n" + + json.dumps(schema, ensure_ascii=False)) + return "" + + +class Unfence: + """Strips a markdown code fence from a stream of text. + + Models wrap JSON in ```json ... ``` even when told not to, which breaks callers + that pass the content straight to a JSON parser. Text is held back only while a + fence marker could still be forming, so streaming stays incremental. + """ + + def __init__(self, enabled): + self.enabled = enabled + self.buf = "" + self.started = False + + def feed(self, text): + if not self.enabled: + return text + self.buf += text + if not self.started: + stripped = self.buf.lstrip() + if stripped.startswith("```"): + nl = stripped.find("\n") + if nl < 0: # opening fence still incomplete + return "" + self.buf = stripped[nl + 1:] + elif "```".startswith(stripped[:3]) and len(stripped) < 3: + return "" + self.started = True + out, self.buf = self.buf, "" + tail = len(out) - len(out.rstrip("\n`")) # a closing fence may still be forming + if tail: + out, self.buf = out[:len(out) - tail], out[len(out) - tail:] + return out + + def close(self): + out, self.buf = self.buf, "" + return out.rstrip().rstrip("`").rstrip() if self.enabled else out + + +def unfence(text, enabled): + f = Unfence(enabled) + return f.feed(text) + f.close() + + FINISH = {"end_turn": "stop", "stop_sequence": "stop", "max_tokens": "length", "tool_use": "tool_calls"} diff --git a/cursor2api/server.py b/cursor2api/server.py index 3af885a..e1dddd3 100644 --- a/cursor2api/server.py +++ b/cursor2api/server.py @@ -26,7 +26,7 @@ Run: PORT=8787 API_KEY=sk-local python -m cursor2api serve Use: ANTHROPIC_BASE_URL=http://127.0.0.1:8787 ANTHROPIC_API_KEY=sk-local claude """ -import base64, json, os, struct, sys, threading, time, uuid +import base64, json, os, re, struct, sys, threading, time, uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import auth as auth_mod @@ -34,9 +34,31 @@ from .auth import AuthError from .session import HOST, Session + +def _load_env_file(): + """Reads KEY=VALUE lines from ./.env, without overriding the real environment.""" + path = os.environ.get("CURSOR2API_ENV", ".env") + try: + with open(path, encoding="utf-8") as fh: + lines = fh.readlines() + except OSError: + return + for line in lines: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip().strip("'\"")) + + +_load_env_file() + BIND = os.environ.get("BIND", "127.0.0.1") PORT = int(os.environ.get("PORT", "8787")) -API_KEY = os.environ.get("API_KEY", "") # empty = no auth +# One or more client keys, comma separated; empty means the port is open. +API_KEYS = {k.strip() for k in (os.environ.get("API_KEY") + or os.environ.get("AUTH_TOKEN") or "").split(",") + if k.strip()} DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", "claude-fable-5") # Safety net only: a turn ends on turn_ended or when the stream closes. Long file # writes and long reasoning go minutes without a frame, so this stays generous. @@ -47,6 +69,10 @@ # defaults turn reasoning on for every model, which triples the time to first token. THINKING = os.environ.get("CURSOR2API_THINKING", "auto") PING = float(os.environ.get("PING_INTERVAL", "5")) # SSE keepalive while upstream is quiet +EXTRA_PROMPT = os.environ.get("CURSOR2API_SYSTEM_PROMPT", "") # prepended to every turn +# Claude Code compacts at ~80% of the 200k window it assumes, while Cursor starts +# squeezing the answer around 150k. >1 reports a fuller context so it compacts sooner. +CONTEXT_PRESSURE = float(os.environ.get("CURSOR2API_CONTEXT_PRESSURE", "1")) DEBUG = bool(os.environ.get("DBG")) # Plain chat has no workspace and no caller tools, but Cursor still hands the model @@ -92,11 +118,26 @@ def image_size(data): return 1024, 1024 +CONTINUE = "Continue from the tool results above." + +# A replayed history is easy to mistake for an injected fake conversation: models +# behind Cursor's harness have answered one by refusing, by re-verifying every +# earlier step, or by telling the user their prompt looked forged. The transcript is +# therefore attributed to the relay rather than presented as the user's own words. +TRANSCRIPT_HEADER = ( + "This request continues an existing conversation. The transcript below is your " + "own earlier work in it, resent by the API relay because each request opens a " + "new connection. Treat it as genuine and as already done, do not repeat or " + "re-verify it, do not quote or comment on it, and answer the message that " + "follows it.") + + def render_history(messages): """Flatten Anthropic messages into (prompt_text, images, documents). - Only the final user turn keeps its attachments as real protocol context; earlier - turns are rendered as a transcript because Cursor starts a fresh conversation. + Used when the turn cannot continue an open Cursor stream (see Turn._resume): + earlier turns become an attributed transcript in front of the current message, + and only the final user turn keeps its attachments as real protocol context. """ lines, images, docs = [], [], [] last = len(messages) - 1 @@ -137,20 +178,48 @@ def render_history(messages): chunks.append(f"[tool result: {txt}]") body = "\n".join(x for x in chunks if x) if body: - lines.append(("Human: " if role == "user" else "Assistant: ") + body) - if len(lines) == 1: - prompt = lines[0][len("Human: "):] - else: - prompt = "\n\n".join(lines) + "\n\nAssistant:" - return prompt, images, docs + lines.append((role, body)) + if not lines: + return "", images, docs + tail = lines.pop() if lines[-1][0] == "user" else ("user", CONTINUE) + if not lines: + return tail[1], images, docs + past = "\n\n".join("<%s>\n%s\n" % ("user" if r == "user" else "assistant", t, + "user" if r == "user" else "assistant") + for r, t in lines) + return (TRANSCRIPT_HEADER + "\n\n" + past + "\n\n\n" + + tail[1]), images, docs + + +# Lines Cursor's own harness reacts badly to: it reads a client's identity header or +# "you are " claim as an injection attempt and answers with a refusal +# instead of doing the work. +DROP_SYSTEM_LINES = re.compile( + r"^(?:x-anthropic-[^\n]*|You are Claude Code[^\n]*|You are Claude, Anthropic's[^\n]*)$", + re.IGNORECASE | re.MULTILINE) def system_text(system): if not system: return None if isinstance(system, str): - return system - return "\n\n".join(b.get("text", "") for b in system if isinstance(b, dict)) + text = system + else: + text = "\n\n".join(b.get("text", "") for b in system if isinstance(b, dict)) + text = re.sub(r"\n{3,}", "\n\n", DROP_SYSTEM_LINES.sub("", text)).strip() + return text or None + + +def estimate_tokens(body): + """Rough input size, ~4 chars per token, tool schemas included. + + Callers such as Claude Code decide when to compact their history from this, so a + number that ignores the tool definitions makes them compact far too late. + """ + chars = len(json.dumps(body.get("messages", []), ensure_ascii=False)) + chars += len(system_text(body.get("system")) or "") + chars += len(json.dumps(body.get("tools") or [], ensure_ascii=False)) + return max(1, int(chars / 4 * max(1.0, CONTEXT_PRESSURE))) def tool_specs(tools): @@ -163,6 +232,69 @@ def tool_specs(tools): return out +# ----------------------------------------------------------- session resume +# Cursor keeps the conversation on its side, so a tool round trip is answered on +# the still-open Run stream instead of replaying the history. Replaying is both +# slow (Cursor re-reads its ~25k token harness every time) and unreliable: a +# transcript of someone else's tool calls pasted into a prompt reads as an +# injection attempt, and models answer it by refusing or by re-checking every +# earlier step instead of continuing the task. +RESUME_TTL = float(os.environ.get("CURSOR2API_RESUME_TTL", "900")) +_live = {} # tool_use id -> {session, exec, ts} +_live_lock = threading.Lock() + + +def _reap_live(now=None): + now = now or time.time() + with _live_lock: + stale = {id(e["session"]): e for e in _live.values() + if now - e["ts"] > RESUME_TTL} + for k in [k for k, e in _live.items() if id(e["session"]) in stale]: + del _live[k] + for e in stale.values(): + try: + e["session"].close() + except Exception: + pass + + +def remember_session(session, pending): + """Keeps the stream open so the caller's tool results can continue this turn.""" + _reap_live() + now = time.time() + with _live_lock: + for blk in pending: + _live[blk["id"]] = {"session": session, "exec": blk.get("exec"), "ts": now} + + +def take_session(ids): + """The open session that emitted these tool_use ids, if it is still around.""" + with _live_lock: + entries = [_live.get(i) for i in ids] + if not entries or any(e is None for e in entries): + return None, [] + session = entries[0]["session"] + if any(e["session"] is not session for e in entries): + return None, [] + for i in list(_live): + if _live[i]["session"] is session: + del _live[i] + return session, [e["exec"] for e in entries] + + +def tool_results(message): + """[(tool_use_id, text, is_error)] from a user message, or [] if it has none.""" + out = [] + for b in _blocks(message.get("content")): + if b.get("type") != "tool_result": + continue + c = b.get("content") + text = c if isinstance(c, str) else "\n".join( + x.get("text", "") for x in (c or []) if isinstance(x, dict)) + out.append((b.get("tool_use_id"), text, bool(b.get("is_error")))) + return out + + # --------------------------------------------------------------- live turns class Turn: """Runs one assistant turn and yields normalized deltas.""" @@ -179,7 +311,10 @@ def __init__(self, body): except (TypeError, ValueError): self.max_tokens = 0 self.chat = not self.tools + self.json_only = False # OpenAI response_format: strip markdown fences self.session = None + self.resumed = False # continued an open stream instead of replaying + self.keep_open = False # caller owes us tool results on this stream self.pending = [] # tool_use blocks emitted this turn self.usage = {"input_tokens": 0, "output_tokens": 0} self.stop_reason = None @@ -196,7 +331,36 @@ def _tune(self, images, docs): self.model_params = params self.chat = not self.tools and not images and not docs + def _resume(self): + """Answers a tool round trip on the stream that asked for it.""" + msgs = self.body.get("messages", []) + if not msgs or msgs[-1].get("role") != "user": + return False + results = tool_results(msgs[-1]) + if not results: + return False + session, execs = take_session([r[0] for r in results]) + if session is None: + return False + try: + session.send_tool_results([(ex[0], ex[1], text, is_error) + for (tid, text, is_error), ex + in zip(results, execs)]) + except Exception: + session.close() + return False + self.session = session + self.resumed = True + if DEBUG: + print("resumed stream for %d tool result(s)" % len(results), file=sys.stderr) + return True + def start(self): + if self._resume(): + return + if DEBUG: + print("fresh stream, %d message(s)" % len(self.body.get("messages") or []), + file=sys.stderr) prompt, images, docs = render_history(self.body.get("messages", [])) sysmsg = system_text(self.body.get("system")) choice = self.body.get("tool_choice") or {} @@ -218,6 +382,8 @@ def start(self): self._tune(images, docs) if self.chat: sysmsg = ((sysmsg + "\n\n") if sysmsg else "") + CHAT_PROMPT + if EXTRA_PROMPT: + sysmsg = EXTRA_PROMPT + (("\n\n" + sysmsg) if sysmsg else "") self.session = Session(model=self.model, system=sysmsg, tools=self.tools, web=WEB, model_params=self.model_params, debug=DEBUG, chat=self.chat) @@ -244,6 +410,10 @@ def stream(self): elif kind == "tool_use": self.pending.append(val) self.stop_reason = "tool_use" + # The stream stays open: the result comes back as the next request + # and continues this same Cursor turn. + remember_session(self.session, [val]) + self.keep_open = True yield "tool_use", val return # hand control back to the API caller elif kind == "turn_ended": @@ -275,7 +445,7 @@ def _cut(self, delta): return delta, None def close(self): - if self.session: + if self.session and not self.keep_open: self.session.close() @@ -355,11 +525,11 @@ def _err(self, code, kind, message): "error": {"type": kind, "message": message}}, headers) def _authed(self): - if not API_KEY: + if not API_KEYS: return True key = self.headers.get("x-api-key") or "" - auth = self.headers.get("authorization") or "" - return key == API_KEY or auth.replace("Bearer ", "") == API_KEY + auth = (self.headers.get("authorization") or "").replace("Bearer ", "").strip() + return key in API_KEYS or auth in API_KEYS def _body(self): n = int(self.headers.get("content-length") or 0) @@ -414,9 +584,7 @@ def do_POST(self): route = route[len("/openai"):] if route == "/v1/messages/count_tokens": - chars = len(json.dumps(body.get("messages", []), ensure_ascii=False)) - chars += len(system_text(body.get("system")) or "") - return self._json(200, {"input_tokens": max(1, chars // 4)}) + return self._json(200, {"input_tokens": estimate_tokens(body)}) openai = route in ("/v1/chat/completions", "/chat/completions") if route != "/v1/messages" and not openai: @@ -424,6 +592,8 @@ def do_POST(self): if openai: model_in = body.get("model", DEFAULT_MODEL) + json_only = (body.get("response_format") or {}).get("type") in ( + "json_object", "json_schema") body = openai_api.to_anthropic(body) if not body.get("messages"): return self._err(400, "invalid_request_error", "messages: required") @@ -432,6 +602,7 @@ def do_POST(self): try: if openai: turn.model_in = model_in + turn.json_only = json_only if body.get("stream"): self._stream_turn_openai(turn) else: @@ -593,7 +764,8 @@ def _buffer_turn_openai(self, turn): if err: return self._err(*upstream_error(err)) self._json(200, openai_api.completion( - turn.model_in, "".join(text), "".join(think), turn.pending, + turn.model_in, openai_api.unfence("".join(text), turn.json_only), + "".join(think), turn.pending, turn.stop_reason or "end_turn", usage_of(turn))) def _stream_turn_openai(self, turn): @@ -614,6 +786,7 @@ def send(obj): turn.start() calls = 0 last_ping = time.time() + unfence = openai_api.Unfence(turn.json_only) for kind, val in turn.stream(): if kind == "tick": if time.time() - last_ping > PING: @@ -624,7 +797,9 @@ def send(obj): continue last_ping = time.time() if kind == "text": - send(openai_api.chunk(cid, turn.model_in, {"content": val})) + val = unfence.feed(val) + if val: + send(openai_api.chunk(cid, turn.model_in, {"content": val})) elif kind == "thinking": send(openai_api.chunk(cid, turn.model_in, {"reasoning_content": val})) elif kind == "web": @@ -641,6 +816,9 @@ def send(obj): self.wfile.write(b"0\r\n\r\n") self.wfile.flush() return + rest = unfence.close() + if rest: + send(openai_api.chunk(cid, turn.model_in, {"content": rest})) send(openai_api.chunk(cid, turn.model_in, {}, openai_api.FINISH.get(turn.stop_reason or "end_turn", "stop"), usage_of(turn))) @@ -726,7 +904,7 @@ def main(): srv.daemon_threads = True print(f"listening on http://{BIND}:{PORT} " f"(/v1/messages, /v1/chat/completions, /v1/models; " - f"default model {DEFAULT_MODEL}, auth {'on' if API_KEY else 'off'})", flush=True) + f"default model {DEFAULT_MODEL}, auth {'on' if API_KEYS else 'off'})", flush=True) srv.serve_forever() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d37d676 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + cursor2api: + build: . + image: cursor2api + ports: + - "8787:8787" + environment: + # Cursor credential: a crsr_ key, or mount a credentials.json below + CURSOR_API_KEY: ${CURSOR_API_KEY:-} + # Key(s) local clients must send as x-api-key / Bearer, comma separated + API_KEY: ${API_KEY:-} + DEFAULT_MODEL: ${DEFAULT_MODEL:-claude-sonnet-5} + volumes: + - ./credentials:/root/.config/cursor2api + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index 33dca54..d0cd905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cursor2api" -version = "0.1.0" +version = "0.1.2" description = "Anthropic Messages and OpenAI Chat Completions APIs in front of Cursor's agent protocol" readme = "README.md" requires-python = ">=3.9" diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..958ecd5 --- /dev/null +++ b/tests/test_units.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Offline checks for the pure translation helpers. No server, no credentials.""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from cursor2api import openai_api, server # noqa: E402 + + +def check(name, got, want): + print("%-46s %s" % (name, "ok" if got == want else "FAILED %r != %r" % (got, want))) + return got == want + + +def unfence_stream(chunks, enabled=True): + f = openai_api.Unfence(enabled) + return "".join(f.feed(c) for c in chunks) + f.close() + + +def main(): + ok = [] + ok.append(check("unfence: plain json untouched", + openai_api.unfence('{"a": 1}', True), '{"a": 1}')) + ok.append(check("unfence: fenced json", + openai_api.unfence('```json\n{"a": 1}\n```', True), '{"a": 1}')) + ok.append(check("unfence: bare fence", + openai_api.unfence('```\n{"a": 1}\n```', True), '{"a": 1}')) + ok.append(check("unfence: disabled keeps fence", + openai_api.unfence('```json\n{"a": 1}\n```', False), + '```json\n{"a": 1}\n```')) + ok.append(check("unfence: streamed in pieces", + unfence_stream(["``", "`json\n", '{"a"', ': 1}', "\n``", "`"]), + '{"a": 1}')) + ok.append(check("unfence: backticks inside text survive", + unfence_stream(["use `x` here"]), "use `x` here")) + + ok.append(check("response_format json_object -> instruction", + "single JSON object" in openai_api.json_instruction( + {"type": "json_object"}), True)) + ok.append(check("response_format json_schema carries schema", + '"type": "object"' in openai_api.json_instruction( + {"type": "json_schema", + "json_schema": {"schema": {"type": "object"}}}), True)) + ok.append(check("response_format text -> nothing", + openai_api.json_instruction({"type": "text"}), "")) + ok.append(check("response_format reaches system", + "no markdown code fence" in openai_api.to_anthropic( + {"model": "m", "messages": [{"role": "user", "content": "hi"}], + "response_format": {"type": "json_object"}})["system"], True)) + ok.append(check("max_completion_tokens -> max_tokens", + openai_api.to_anthropic( + {"model": "m", "max_completion_tokens": 32, + "messages": [{"role": "user", "content": "hi"}]})["max_tokens"], 32)) + + ok.append(check("system: injection-triggering lines dropped", + server.system_text("You are Claude Code, Anthropic's CLI\n" + "x-anthropic-billing-header: on\nBe terse."), + "Be terse.")) + ok.append(check("system: blocks joined", + server.system_text([{"type": "text", "text": "a"}, + {"type": "text", "text": "b"}]), "a\n\nb")) + ok.append(check("count_tokens: tool schemas counted", + server.estimate_tokens( + {"messages": [{"role": "user", "content": "hi"}], + "tools": [{"name": "Read", "input_schema": {"type": "object"}}]}) + > server.estimate_tokens( + {"messages": [{"role": "user", "content": "hi"}]}), True)) + + ok.append(check("history: single turn stays a plain prompt", + server.render_history( + [{"role": "user", "content": "hi"}])[0], "hi")) + prompt = server.render_history( + [{"role": "user", "content": "task"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", + "name": "Read", "input": {"p": "m.py"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", + "content": "code"}]}])[0] + ok.append(check("history: replay is attributed and ends on the new turn", + prompt.startswith(server.TRANSCRIPT_HEADER) + and "" in prompt + and prompt.endswith("[tool result: code]"), True)) + + ok.append(check("tool_results: ids, text and error flag", + server.tool_results( + {"role": "user", + "content": [{"type": "tool_result", "tool_use_id": "t1", + "content": [{"type": "text", "text": "out"}], + "is_error": True}]}), + [("t1", "out", True)])) + + class FakeSession: + pass + + live = FakeSession() + server.remember_session(live, [{"id": "t1", "exec": (7, None)}]) + ok.append(check("resume: the emitting stream is found once", + server.take_session(["t1"]), (live, [(7, None)]))) + ok.append(check("resume: unknown tool id falls back to replay", + server.take_session(["t1"]), (None, []))) + + print("\n%d/%d ok" % (sum(ok), len(ok))) + return 0 if all(ok) else 1 + + +if __name__ == "__main__": + sys.exit(main())