From 7a6379da7e8a505e4914dc16e5713a8c86f483c8 Mon Sep 17 00:00:00 2001 From: lilinxiong Date: Mon, 17 Aug 2026 15:04:53 +0800 Subject: [PATCH 1/7] feat(cursor): add HTTP/1.1 compatibility transport --- .../src/content/docs/guides/providers.md | 5 +- .../src/content/docs/reference/adapters.md | 7 +- .../docs/reference/configuration/providers.md | 8 +- .../content/docs/zh-cn/guides/providers.md | 5 +- .../content/docs/zh-cn/reference/adapters.md | 8 +- .../reference/configuration/providers.md | 5 + src/adapters/cursor/http1-bidi.ts | 324 ++++++++++++++++++ src/adapters/cursor/live-models.ts | 128 +++++-- src/adapters/cursor/live-transport.ts | 170 ++++++--- src/codex/catalog/provider-fetch.ts | 8 +- src/lib/upstream-http-version.ts | 45 +++ src/server/responses/fetch-helpers.ts | 36 +- src/types.ts | 5 +- structure/04_transports-and-sidecars.md | 2 +- tests/cursor-hardening.test.ts | 58 ++++ tests/cursor-http1-transport.test.ts | 174 ++++++++++ 16 files changed, 862 insertions(+), 126 deletions(-) create mode 100644 src/adapters/cursor/http1-bidi.ts create mode 100644 src/lib/upstream-http-version.ts create mode 100644 tests/cursor-http1-transport.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7af39e32b3..266c42a784 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -116,7 +116,7 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | -| `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | +| `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | After a terminal Nous refresh failure, run `ocx login nous` to reauthenticate. @@ -511,7 +511,8 @@ provider-wide adapter. To opt a model without a built-in default (for example Cursor is tracked separately as an experimental adapter. `adapter: "cursor"` appears in `ocx init` and the dashboard Add Provider picker as an experimental local config entry with Cursor's static fallback model catalog metadata. When a Cursor access token is configured, opencodex uses Cursor's -live HTTP/2 transport. Its bundled fallback seed includes `gpt-5.6-sol` / `terra` / `luna` (1M context), +live HTTP/2 transport. Set `upstreamHttpVersion: "http1.1"` when a proxy requires Cursor's HTTP/1.1 +compatibility path; the setting covers both inference and live model discovery. Its bundled fallback seed includes `gpt-5.6-sol` / `terra` / `luna` (1M context), regular/Fast rows for Grok 4.5 and 4.6 (500K), and `kimi-k3` (262K); live discovery decides which remain visible for the account. Grok 4.6 exposes `low` / `medium` / `high` / `xhigh` in both forms, while 4.5 stops at `high`. Fast requests send the matching base Grok model with separate `effort` diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 93a1c42f63..8a9bd05c45 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -186,7 +186,10 @@ advertised effort control on those models as proof of upstream-native reasoning ## `cursor` -**Targets:** Cursor's `agent.v1.AgentService/Run` over HTTP/2 Connect streaming at `api2.cursor.sh`. +**Targets:** Cursor's `agent.v1.AgentService/Run` over HTTP/2 Connect streaming at `api2.cursor.sh` +by default. With `upstreamHttpVersion: "http1.1"` (or `"h1"`), uses Cursor's HTTP/1.1 +compatibility pair: `agent.v1.AgentService/RunSSE` for server output and +`aiserver.v1.BidiService/BidiAppend` for client messages. **Auth:** Cursor OAuth/access token from `provider.apiKey` or the forwarded authorization header. - Uses `runTurn` rather than the ordinary fetch/parse path. Requests, server events, tool arguments, @@ -195,6 +198,8 @@ advertised effort control on those models as proof of upstream-native reasoning - Replays conversation state through content-addressed blobs, maps server tool calls back to Codex, discovers live Cursor models through the protobuf `GetUsableModels` RPC, and retries only before a run request is committed to the wire. +- Honors `upstreamHttpVersion` for both live model discovery and inference. `auto`, `http2`, and `h2` + preserve the existing HTTP/2 transport; only `http1.1` and `h1` select compatibility mode. - Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`, `cursor/auto-balance`, and `cursor/auto-intelligence` entries. Explicit levels are encoded in `requested_model.parameters` while the legacy `cursor/auto` entry retains the account/team default. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2c1d2e3eef..0f8959975a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -67,7 +67,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | | `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | -| `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | +| `upstreamHttpVersion?` | `"auto" \| "http1.1" \| "h1" \| "http2" \| "h2"` | Pin the HTTP version used for upstream requests to this provider. Defaults to `auto`, which lets Bun negotiate. Set `http1.1` when a provider's HTTP/2 SSE stream stalls instead of delivering events — the symptom is a long-running streaming request that produces nothing and eventually times out. For Cursor, `http1.1`/`h1` selects its `RunSSE` + `BidiAppend` compatibility transport for inference and also pins live model discovery. Management `POST`/`PATCH` accept `null` to clear it back to `auto`. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `supportsServiceTier?` | `boolean` | Tri-state canonical Fast capability fallback. `true` publishes Fast in the catalog, satisfies service-tier routing requirements, contributes a supported fingerprint, and lets fast mode inject the provider's canonical wire value on a compatible final adapter. `false` strips the field and never injects, and exact model declarations cannot reopen it. Absent leaves the provider unclassified: fast mode does not inject or normalize a canonical caller value, and caller values obey the final wire's forwarding permission (`chatServiceTier` on Chat; passthrough on Responses). The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | @@ -297,6 +297,11 @@ The Cursor bridge is experimental. After `ocx login cursor`, add or edit `provid Cursor Router's optimization ladder is exposed as separate Codex ids because the picker cannot render Cursor-specific model parameters: +If a proxy cannot carry Cursor's default HTTP/2 stream, set `upstreamHttpVersion` to `"http1.1"`. +This switches inference to Cursor's `RunSSE` + `BidiAppend` compatibility transport and uses +HTTP/1.1 for `GetUsableModels` discovery as well. Leave it unset or use `"auto"` for the existing +HTTP/2 behavior. + | Codex model | Cursor Router mode | | --- | --- | | `cursor/auto` | Team/account default | @@ -324,6 +329,7 @@ Cursor server-driven local tools are disabled by default. Codex continues using "baseUrl": "https://api2.cursor.sh", "authMode": "oauth", "defaultModel": "auto", + "upstreamHttpVersion": "http1.1", "nativeLocalExec": "off" } } diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index b65ab443f3..1858644f49 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -101,7 +101,7 @@ ocx logout | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | -| `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | +| `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | Nous refresh 发生终止性失败后,请运行 `ocx login nous` 重新认证。 @@ -365,7 +365,8 @@ adapter。若要将没有内置默认值的模型(例如 `gpt-5.4-nano`)接 Cursor 作为单独的实验性 adapter 进行跟踪。`adapter: "cursor"` 会作为实验性本地配置出现在 `ocx init` 和 dashboard Add Provider picker 中,并保存 Cursor 的静态回退模型目录 metadata。配置 -Cursor access token 后,opencodex 会使用 Cursor live HTTP/2 transport。内置回退列表包含上下文为 +Cursor access token 后,opencodex 会使用 Cursor live HTTP/2 transport。代理要求 Cursor 的 +HTTP/1.1 兼容路径时,可设置 `upstreamHttpVersion: "http1.1"`;该设置同时覆盖推理与实时模型发现。内置回退列表包含上下文为 1M 的 `gpt-5.6-sol` / `terra` / `luna`、上下文为 500K 的 Grok 4.5/4.6 普通与 Fast 条目,以及上下文为 262K 的 `kimi-k3`;最终显示哪些模型由账号的实时发现结果决定。Grok 4.6 的两种形式均提供 `low` / `medium` / `high` / `xhigh`,而 4.5 最高为 `high`。Fast 请求会发送对应的 Grok 基础模型, diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index dc39d307b7..07dbfcf441 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -123,8 +123,10 @@ Kiro 的 assistant 文本本身没有可靠的回合结束标记,但终止的 ## `cursor` -**目标:** `api2.cursor.sh` 上采用 HTTP/2 Connect streaming 的 -`agent.v1.AgentService/Run`。 +**目标:** 默认使用 `api2.cursor.sh` 上采用 HTTP/2 Connect streaming 的 +`agent.v1.AgentService/Run`。配置 `upstreamHttpVersion: "http1.1"`(或 `"h1"`)后,改用 +Cursor 的 HTTP/1.1 兼容传输:通过 `agent.v1.AgentService/RunSSE` 接收 server output,并通过 +`aiserver.v1.BidiService/BidiAppend` 发送 client message。 **认证:** `provider.apiKey` 或转发 authorization header 中的 Cursor OAuth/access token。 - 使用 `runTurn`,而不是常规 fetch/parse 路径。请求、server event、工具参数、usage checkpoint @@ -132,6 +134,8 @@ Kiro 的 assistant 文本本身没有可靠的回合结束标记,但终止的 Connect message。 - 经 content-addressed blob 重放对话状态,把 server tool call 映射回 Codex,用 protobuf `GetUsableModels` RPC 发现实时 Cursor 模型,并且只在 run request 尚未 commit 到 wire 前重试。 +- 模型实时发现和推理都会遵守 `upstreamHttpVersion`。`auto`、`http2` 与 `h2` 保持原有 HTTP/2 + transport;只有 `http1.1` 与 `h1` 会选择兼容模式。 - 保留 `cursor/grok-4.5-fast` 作为可选模型,但向 Cursor 发送规范的 `grok-4.5` 模型,并将独立的 `effort` 和 `fast=true` 值放入 `requested_model.parameters`。 - Cursor 原生本地 filesystem/shell/network 执行默认被拒绝。显式 `mcpServers` 与 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 0393dcc569..08a828957c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -222,6 +222,10 @@ affinity。这些策略不能规避 provider enforcement。 Cursor 桥接是实验性的。执行 `ocx login cursor` 之后,添加或编辑 `providers.cursor`。Cursor Router 的优化层级会作为独立的 Codex id 暴露,因为选择器无法渲染 Cursor 特定的模型参数: +如果代理无法承载 Cursor 默认的 HTTP/2 stream,请将 `upstreamHttpVersion` 设置为 +`"http1.1"`。推理会切换到 Cursor 的 `RunSSE` + `BidiAppend` 兼容传输,`GetUsableModels` +实时发现也会使用 HTTP/1.1。保持未设置或使用 `"auto"`,则继续使用现有 HTTP/2 行为。 + | Codex model | Cursor Router mode | | --- | --- | | `cursor/auto` | Team/account default | @@ -245,6 +249,7 @@ Cursor 由服务端驱动的本地工具默认是禁用的。Codex 继续使用 "baseUrl": "https://api2.cursor.sh", "authMode": "oauth", "defaultModel": "auto", + "upstreamHttpVersion": "http1.1", "nativeLocalExec": "off" } } diff --git a/src/adapters/cursor/http1-bidi.ts b/src/adapters/cursor/http1-bidi.ts new file mode 100644 index 0000000000..59bf202c79 --- /dev/null +++ b/src/adapters/cursor/http1-bidi.ts @@ -0,0 +1,324 @@ +import { create, toBinary } from "@bufbuild/protobuf"; +import type { TranslatorBudget } from "../../lib/translator-budget"; +import { CURSOR_MAX_EFFECTIVE_CONNECT_PAYLOAD_BYTES } from "../../lib/translator-budget"; +import { readBoundedResponseBytes } from "../../lib/bounded-body"; +import { withUpstreamHttpVersionValue } from "../../lib/upstream-http-version"; +import { BidiRequestIdSchema } from "./gen/agent_pb"; +import { consumeConnectFrames, encodeConnectFrame } from "./framing"; + +const CURSOR_RUN_SSE_PATH = "/agent.v1.AgentService/RunSSE"; +const CURSOR_BIDI_APPEND_PATH = "/aiserver.v1.BidiService/BidiAppend"; +const CURSOR_HTTP1_APPEND_TIMEOUT_MS = 30_000; +const CURSOR_HTTP1_APPEND_RESPONSE_MAX_BYTES = 64 * 1024; +const HEX = "0123456789abcdef"; + +export interface CursorHttp1BidiCallbacks { + onCommitted(): void; + onData(chunk: Uint8Array): void; + onEnd(): void; + onError(error: Error): void; +} + +export interface CursorHttp1BidiOptions { + baseUrl: string; + token: string; + clientVersion: string; + sessionId: string; + requestId: string; + translatorBudget: TranslatorBudget; + callbacks: CursorHttp1BidiCallbacks; + fetch?: typeof globalThis.fetch; +} + +interface PendingAppend { + payload: Uint8Array; + seqno: bigint; + rawBytesCharged: boolean; +} + +function varintLength(value: bigint): number { + let length = 1; + for (let current = value; current >= 0x80n; current >>= 7n) length += 1; + return length; +} + +function writeVarint(target: Uint8Array, offset: number, value: bigint): number { + let current = value; + while (current >= 0x80n) { + target[offset++] = Number(current & 0x7fn) | 0x80; + current >>= 7n; + } + target[offset++] = Number(current); + return offset; +} + +function requestIdMessageBytes(requestId: string): Uint8Array { + const value = new TextEncoder().encode(requestId); + const message = new Uint8Array(1 + varintLength(BigInt(value.byteLength)) + value.byteLength); + let offset = 0; + message[offset++] = 0x0a; // field 1, string request_id + offset = writeVarint(message, offset, BigInt(value.byteLength)); + message.set(value, offset); + return message; +} + +/** Exact encoded size of Cursor's aiserver.v1.BidiAppendRequest hex fallback shape. */ +export function cursorBidiAppendRequestSize(payloadBytes: number, requestId: string, seqno: bigint): number { + const hexBytes = payloadBytes * 2; + const requestIdBytes = requestIdMessageBytes(requestId).byteLength; + return (hexBytes > 0 ? 1 + varintLength(BigInt(hexBytes)) + hexBytes : 0) + + 1 + varintLength(BigInt(requestIdBytes)) + requestIdBytes + + (seqno > 0n ? 1 + varintLength(seqno) : 0); +} + +/** + * Encode the compatibility request used by Cursor when HTTP/2 is disabled. + * The public client defaults to field 1's hexadecimal data when its binary-append + * feature gate is unavailable, so OpenCodex uses that backwards-compatible shape. + */ +export function encodeCursorBidiAppendRequest( + payload: Uint8Array, + requestId: string, + seqno: bigint, +): Uint8Array { + const requestIdBytes = requestIdMessageBytes(requestId); + const output = new Uint8Array(cursorBidiAppendRequestSize(payload.byteLength, requestId, seqno)); + let offset = 0; + if (payload.byteLength > 0) { + const hexBytes = payload.byteLength * 2; + output[offset++] = 0x0a; // field 1, string data + offset = writeVarint(output, offset, BigInt(hexBytes)); + for (const byte of payload) { + output[offset++] = HEX.charCodeAt(byte >>> 4); + output[offset++] = HEX.charCodeAt(byte & 0x0f); + } + } + output[offset++] = 0x12; // field 2, BidiRequestId request_id + offset = writeVarint(output, offset, BigInt(requestIdBytes.byteLength)); + output.set(requestIdBytes, offset); + offset += requestIdBytes.byteLength; + if (seqno > 0n) { + output[offset++] = 0x18; // field 3, int64 append_seqno + offset = writeVarint(output, offset, seqno); + } + return output; +} + +function cursorHttp1Error(operation: "RunSSE" | "BidiAppend", status: number): Error { + return new Error(`Cursor HTTP/1.1 ${operation} failed with HTTP ${status || "unknown"}`); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +/** Bun accepts Uint8Array request bodies; TypeScript's DOM BodyInit omits that runtime shape. */ +function bunFetchBody(bytes: Uint8Array): BodyInit { + return bytes as unknown as BodyInit; +} + +/** + * Cursor's HTTP/1.1 compatibility bridge: a server-streaming RunSSE request carries + * output, while each client message is posted separately through BidiAppend. + */ +export class CursorHttp1BidiConnection { + private readonly abortController = new AbortController(); + private readonly fetchImpl: typeof globalThis.fetch; + private appendChain: Promise = Promise.resolve(); + private nextAppendSeqno = 0n; + private committed = false; + private paused = false; + private resumeWaiters: Array<() => void> = []; + private terminal = false; + private closed = false; + destroyed = false; + + constructor(private readonly options: CursorHttp1BidiOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch; + } + + start(): void { + void this.runSse().catch(error => this.fail(asError(error))); + } + + write(frameBytes: Uint8Array): boolean { + if (this.closed || this.terminal) return false; + let frame; + try { + const decoded = consumeConnectFrames(frameBytes, CURSOR_MAX_EFFECTIVE_CONNECT_PAYLOAD_BYTES, 2); + if (decoded.frames.length !== 1 || decoded.consumedBytes !== frameBytes.byteLength) { + throw new Error("Cursor HTTP/1.1 append requires exactly one complete Connect frame"); + } + frame = decoded.frames[0]!; + if (frame.flags !== 0) throw new Error(`Cursor HTTP/1.1 append does not support Connect flags ${frame.flags}`); + this.options.translatorBudget.chargeRetained(frame.payload.byteLength, { kind: "request_copies" }); + } catch (error) { + this.fail(asError(error)); + return false; + } + + const pending: PendingAppend = { + payload: frame.payload, + seqno: this.nextAppendSeqno++, + rawBytesCharged: true, + }; + this.appendChain = this.appendChain + .then(() => this.postAppend(pending)) + .catch(error => this.fail(asError(error))) + .finally(() => { + if (!pending.rawBytesCharged) return; + pending.rawBytesCharged = false; + this.options.translatorBudget.releaseRetained(pending.payload.byteLength, { kind: "request_copies" }); + }); + return true; + } + + pause(): void { + this.paused = true; + } + + resume(): void { + if (!this.paused) return; + this.paused = false; + const waiters = this.resumeWaiters; + this.resumeWaiters = []; + for (const resume of waiters) resume(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.destroyed = true; + this.resume(); + this.abortController.abort(new DOMException("Cursor HTTP/1.1 connection closed", "AbortError")); + queueMicrotask(() => this.finish()); + } + + destroy(): void { + this.close(); + } + + private commonHeaders(contentType: string): Headers { + return new Headers({ + "content-type": contentType, + "connect-protocol-version": "1", + authorization: `Bearer ${this.options.token}`, + "x-ghost-mode": "true", + "x-cursor-client-version": this.options.clientVersion, + "x-cursor-client-type": "cli", + "x-request-id": this.options.requestId, + "x-session-id": this.options.sessionId, + }); + } + + private requestUrl(path: string): string { + return new URL(path, this.options.baseUrl).toString(); + } + + private http1Init(url: string, init: RequestInit): RequestInit { + return withUpstreamHttpVersionValue(url, init, "http1.1") ?? init; + } + + private async runSse(): Promise { + const url = this.requestUrl(CURSOR_RUN_SSE_PATH); + const requestId = toBinary(BidiRequestIdSchema, create(BidiRequestIdSchema, { + requestId: this.options.requestId, + })); + const response = await this.fetchImpl(url, this.http1Init(url, { + method: "POST", + headers: this.commonHeaders("application/connect+proto"), + body: bunFetchBody(encodeConnectFrame(requestId)), + redirect: "manual", + signal: this.abortController.signal, + })); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw cursorHttp1Error("RunSSE", response.status); + } + if (!response.body) throw new Error("Cursor HTTP/1.1 RunSSE returned no response body"); + const reader = response.body.getReader(); + try { + while (!this.closed && !this.terminal) { + await this.waitWhilePaused(); + if (this.closed || this.terminal) break; + const { value, done } = await reader.read(); + if (done) break; + if (value && value.byteLength > 0) this.options.callbacks.onData(value); + } + this.finish(); + } finally { + try { reader.releaseLock(); } catch { /* a pending abort may still own it */ } + } + } + + private async postAppend(pending: PendingAppend): Promise { + if (this.closed || this.terminal) return; + const encodedBytes = cursorBidiAppendRequestSize( + pending.payload.byteLength, + this.options.requestId, + pending.seqno, + ); + const reservation = this.options.translatorBudget.reserveTransient(encodedBytes, { kind: "request_copies" }); + let body: Uint8Array; + try { + body = encodeCursorBidiAppendRequest(pending.payload, this.options.requestId, pending.seqno); + } catch (error) { + reservation.release(); + throw error; + } + if (pending.rawBytesCharged) { + pending.rawBytesCharged = false; + this.options.translatorBudget.releaseRetained(pending.payload.byteLength, { kind: "request_copies" }); + } + if (!this.committed) { + this.committed = true; + this.options.callbacks.onCommitted(); + } + + const url = this.requestUrl(CURSOR_BIDI_APPEND_PATH); + const timeout = AbortSignal.timeout(CURSOR_HTTP1_APPEND_TIMEOUT_MS); + const signal = AbortSignal.any([this.abortController.signal, timeout]); + try { + const response = await this.fetchImpl(url, this.http1Init(url, { + method: "POST", + headers: this.commonHeaders("application/proto"), + body: bunFetchBody(body), + redirect: "manual", + signal, + })); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw cursorHttp1Error("BidiAppend", response.status); + } + const result = await readBoundedResponseBytes(response, { + maxBytes: CURSOR_HTTP1_APPEND_RESPONSE_MAX_BYTES, + signal, + }); + if (result.oversized) throw new Error("Cursor HTTP/1.1 BidiAppend response exceeds 64 KiB"); + } finally { + reservation.release(); + } + } + + private waitWhilePaused(): Promise { + if (!this.paused) return Promise.resolve(); + return new Promise(resolve => this.resumeWaiters.push(resolve)); + } + + private fail(error: Error): void { + if (this.terminal) return; + this.terminal = true; + this.destroyed = true; + this.resume(); + if (!this.abortController.signal.aborted) this.abortController.abort(error); + this.options.callbacks.onError(error); + } + + private finish(): void { + if (this.terminal) return; + this.terminal = true; + this.destroyed = true; + this.resume(); + this.options.callbacks.onEnd(); + } +} diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index 00831f84c2..b6d0d261a2 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -1,5 +1,6 @@ /** - * Live Cursor model discovery via the `GetUsableModels` RPC (HTTP/2 + Connect-unary protobuf). + * Live Cursor model discovery via the `GetUsableModels` Connect-unary protobuf RPC. + * HTTP/2 remains the default; an explicit HTTP/1.1 provider pin uses Bun fetch instead. * * Returns the account's actually-usable model ids (the full effort-suffixed variants Cursor offers * for THIS plan), so the routed catalog reflects reality instead of a static superset. Failures are @@ -14,6 +15,9 @@ */ import http2 from "node:http2"; import { fromBinary } from "@bufbuild/protobuf"; +import type { UpstreamHttpVersion } from "../../types"; +import { readBoundedResponseBytes } from "../../lib/bounded-body"; +import { isPinnedHttp1, withUpstreamHttpVersionValue } from "../../lib/upstream-http-version"; import { GetUsableModelsResponseSchema } from "./gen/agent_pb"; const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels"; @@ -27,6 +31,9 @@ export interface CursorUsableModelsOptions { baseUrl?: string; clientVersion?: string; timeoutMs?: number; + upstreamHttpVersion?: UpstreamHttpVersion; + /** Test/embedding seam. Production discovery uses Bun's global fetch in HTTP/1.1 mode. */ + fetch?: typeof globalThis.fetch; } export type CursorUsableModelsResult = @@ -42,8 +49,8 @@ const RETRYABLE_DISCOVERY_ERRORS = new Set(["timeout", "transport"]); const DISCOVERY_RETRY_TIMEOUT_MS = 3_000; /** - * Live discovery with ONE bounded retry for transient pre-response failures (fresh HTTP/2 - * session each attempt). Completed non-2xx responses ("http"), auth, decode, and empty are + * Live discovery with ONE bounded retry for transient pre-response failures (a fresh transport + * attempt each time). Completed non-2xx responses ("http"), auth, decode, and empty are * deterministic and never retried. The retry attempt's deadline is capped so a cache-miss * catalog poll cannot stall much past the primary timeout (~11.5s worst case, accepted in * devlog 260723_cursor_context_continuity/030). @@ -83,6 +90,93 @@ function resolveCursorDiscoveryBaseUrl(raw: string): { ok: true; baseUrl: string } async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Promise { + if (isPinnedHttp1(opts.upstreamHttpVersion)) return fetchCursorUsableModelsHttp1Once(opts); + return fetchCursorUsableModelsHttp2Once(opts); +} + +function cursorDiscoveryHeaders(opts: CursorUsableModelsOptions): Record { + return { + "content-type": "application/proto", + "connect-protocol-version": "1", + authorization: `Bearer ${opts.apiKey}`, + "x-ghost-mode": "true", + "x-cursor-client-version": opts.clientVersion ?? CURSOR_DISCOVERY_CLIENT_VERSION, + "x-cursor-client-type": "cli", + "x-session-id": crypto.randomUUID(), + }; +} + +function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult { + try { + const response = fromBinary(GetUsableModelsResponseSchema, bytes); + // Account filtering uses wire `model_id` values only. Aliases like `composer-2-5` must not + // make stale configured ids such as `composer-2` look activated. + const ids: string[] = []; + const seenIds = new Set(); + for (const model of response.models ?? []) { + const rawId = (model as { modelId?: string }).modelId; + if (typeof rawId !== "string") continue; + const id = rawId.trim(); + if (id.length === 0 || /[\x00-\x1f]/.test(id) || seenIds.has(id)) continue; + seenIds.add(id); + ids.push(id); + if (ids.length === 500) break; + } + return ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" }; + } catch { + return { ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" }; + } +} + +async function fetchCursorUsableModelsHttp1Once(opts: CursorUsableModelsOptions): Promise { + const baseUrl = (opts.baseUrl ?? "https://api2.cursor.sh").replace(/\/+$/, ""); + const requestUrl = `${baseUrl}${CURSOR_GET_USABLE_MODELS_PATH}`; + const timeoutMs = opts.timeoutMs ?? 8000; + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(new DOMException(`No response within ${timeoutMs}ms`, "TimeoutError")); + }, timeoutMs); + try { + const init = withUpstreamHttpVersionValue(requestUrl, { + method: "POST", + headers: cursorDiscoveryHeaders(opts), + redirect: "manual", + signal: controller.signal, + }, opts.upstreamHttpVersion); + const response = await (opts.fetch ?? globalThis.fetch)(requestUrl, init); + if (response.status === 401 || response.status === 403) { + void response.body?.cancel().catch(() => undefined); + return { ok: false, error: "auth", detail: `HTTP ${response.status}` }; + } + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + return { ok: false, error: "http", detail: `HTTP ${response.status || "unknown"}` }; + } + const contentLength = Number(response.headers.get("content-length") ?? 0); + if (Number.isFinite(contentLength) && contentLength > CURSOR_MODEL_DISCOVERY_MAX_BYTES) { + void response.body?.cancel().catch(() => undefined); + return { ok: false, error: "too_large", detail: "GetUsableModels response exceeds 4 MiB" }; + } + const body = await readBoundedResponseBytes(response, { + maxBytes: CURSOR_MODEL_DISCOVERY_MAX_BYTES, + signal: controller.signal, + }); + if (body.oversized) { + return { ok: false, error: "too_large", detail: "GetUsableModels response exceeds 4 MiB" }; + } + return decodeCursorUsableModels(body.bytes); + } catch { + return timedOut + ? { ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` } + : { ok: false, error: "transport", detail: "HTTP/1.1 request failed" }; + } finally { + clearTimeout(timer); + } +} + +async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions): Promise { const baseUrl = (opts.baseUrl ?? "https://api2.cursor.sh").replace(/\/+$/, ""); const timeoutMs = opts.timeoutMs ?? 8000; @@ -118,13 +212,7 @@ async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Pro req = client.request({ ":method": "POST", ":path": CURSOR_GET_USABLE_MODELS_PATH, - "content-type": "application/proto", - "connect-protocol-version": "1", - authorization: `Bearer ${opts.apiKey}`, - "x-ghost-mode": "true", - "x-cursor-client-version": opts.clientVersion ?? CURSOR_DISCOVERY_CLIENT_VERSION, - "x-cursor-client-type": "cli", - "x-session-id": crypto.randomUUID(), + ...cursorDiscoveryHeaders(opts), }); } catch { return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" }); @@ -161,25 +249,7 @@ async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Pro return close({ ok: false, error: "auth", detail: `HTTP ${status}` }); } if (status !== 200) return close({ ok: false, error: "http", detail: `HTTP ${status || "unknown"}` }); - try { - const response = fromBinary(GetUsableModelsResponseSchema, new Uint8Array(Buffer.concat(chunks))); - // Account filtering uses wire `model_id` values only. Aliases like `composer-2-5` must not - // make stale configured ids such as `composer-2` look activated. - const ids: string[] = []; - const seenIds = new Set(); - for (const model of response.models ?? []) { - const rawId = (model as { modelId?: string }).modelId; - if (typeof rawId !== "string") continue; - const id = rawId.trim(); - if (id.length === 0 || /[\x00-\x1f]/.test(id) || seenIds.has(id)) continue; - seenIds.add(id); - ids.push(id); - if (ids.length === 500) break; - } - close(ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" }); - } catch { - close({ ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" }); - } + close(decodeCursorUsableModels(new Uint8Array(Buffer.concat(chunks)))); }); req.end(); // CRITICAL: no body argument (empty Buffer breaks Bun's HTTP/2 framing). diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 0d32b94b5b..7b450fba0a 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -81,6 +81,8 @@ import { } from "./native-exec-shell"; import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "./types"; import type { CursorTransport, CursorTransportFactoryInput } from "./transport"; +import { CursorHttp1BidiConnection } from "./http1-bidi"; +import { isPinnedHttp1 } from "../../lib/upstream-http-version"; const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; @@ -406,6 +408,7 @@ export function clientToolFinalizeGraceMsForRequest(request: CursorRunRequest, b class LiveCursorTransport implements CursorTransport { private session?: http2.ClientHttp2Session; private stream?: http2.ClientHttp2Stream; + private http1Connection?: CursorHttp1BidiConnection; private heartbeat?: ReturnType; private firstFrameTimer?: ReturnType; private committed = false; @@ -683,12 +686,24 @@ class LiveCursorTransport implements CursorTransport { || this.pendingTransportFrames >= CURSOR_MAX_PENDING_FRAMES ) { this.stream?.pause(); + this.http1Connection?.pause(); return; } if ( this.transportBufferedBytes <= CURSOR_TRANSPORT_RESUME_BYTES && this.pendingTransportFrames <= CURSOR_PENDING_FRAMES_RESUME - ) this.stream?.resume(); + ) { + this.stream?.resume(); + this.http1Connection?.resume(); + } + } + + private writeConnectFrame(frame: Uint8Array): void { + if (this.http1Connection) { + this.http1Connection.write(frame); + return; + } + this.stream?.write(frame); } requestCommitted(): boolean { @@ -712,6 +727,7 @@ class LiveCursorTransport implements CursorTransport { this.clearFirstFrameTimer(); this.stream?.close(); this.session?.close(); + this.http1Connection?.close(); this.releaseBlobRequestScope(); this.releaseMcpObservation?.(); this.releaseMcpObservation = undefined; @@ -724,12 +740,16 @@ class LiveCursorTransport implements CursorTransport { this.clearPendingFinalize(); if (this.heartbeat) clearInterval(this.heartbeat); this.clearFirstFrameTimer(); - try { - this.stream?.close(http2.constants.NGHTTP2_CANCEL); - } catch { - this.stream?.destroy(); + if (this.http1Connection) { + this.http1Connection.close(); + } else { + try { + this.stream?.close(http2.constants.NGHTTP2_CANCEL); + } catch { + this.stream?.destroy(); + } + this.session?.close(); } - this.session?.close(); this.releaseBlobRequestScope(); this.releaseMcpObservation?.(); this.releaseMcpObservation = undefined; @@ -800,29 +820,42 @@ class LiveCursorTransport implements CursorTransport { this.emittedTerminal = false; this.firstFrameAt = undefined; this.firstFrameLogged = false; - const dialHost = cursorHostLabel(this.input.provider.baseUrl || "https://api2.cursor.sh"); - debugProviderDiagnostic("cursor", "dial", { host: dialHost }); - this.session = http2.connect(this.input.provider.baseUrl || "https://api2.cursor.sh"); - // The run request is buffered until the HTTP/2 session connects. Failures before `connect` - // (DNS, ECONNREFUSED, TLS, connect timeout) mean the server never received the request, so they - // are safe to retry. Once connected, bytes flush to the server and the turn must not be replayed. - this.session.on("connect", () => { - this.committed = true; - debugProviderDiagnostic("cursor", "connected", { connectMs: Date.now() - this.turnStartedAt }); - }); - this.stream = this.session.request({ - ":method": "POST", - ":path": CURSOR_RUN_PATH, - "content-type": "application/connect+proto", - "connect-protocol-version": "1", - te: "trailers", - authorization: `Bearer ${this.token}`, - "x-ghost-mode": "true", - "x-cursor-client-version": CURSOR_CLIENT_VERSION, - "x-cursor-client-type": "cli", - "x-request-id": crypto.randomUUID(), - "x-session-id": this.sessionId, - }); + const baseUrl = this.input.provider.baseUrl || "https://api2.cursor.sh"; + const useHttp1 = isPinnedHttp1(this.input.provider.upstreamHttpVersion); + const requestId = crypto.randomUUID(); + const dialHost = cursorHostLabel(baseUrl); + debugProviderDiagnostic("cursor", "dial", { host: dialHost, transport: useHttp1 ? "http1.1" : "http2" }); + + let session: http2.ClientHttp2Session | undefined; + let stream: http2.ClientHttp2Stream | undefined; + if (!useHttp1) { + session = http2.connect(baseUrl); + this.session = session; + // The run request is buffered until the HTTP/2 session connects. Failures before `connect` + // (DNS, ECONNREFUSED, TLS, connect timeout) mean the server never received the request, so they + // are safe to retry. Once connected, bytes flush to the server and the turn must not be replayed. + session.on("connect", () => { + this.committed = true; + debugProviderDiagnostic("cursor", "connected", { + transport: "http2", + connectMs: Date.now() - this.turnStartedAt, + }); + }); + stream = session.request({ + ":method": "POST", + ":path": CURSOR_RUN_PATH, + "content-type": "application/connect+proto", + "connect-protocol-version": "1", + te: "trailers", + authorization: `Bearer ${this.token}`, + "x-ghost-mode": "true", + "x-cursor-client-version": CURSOR_CLIENT_VERSION, + "x-cursor-client-type": "cli", + "x-request-id": requestId, + "x-session-id": this.sessionId, + }); + this.stream = stream; + } // Single-shot terminal owner for this turn (createTerminalSettler): stream error, session // error, trailers, end, abort, and the first-frame timeout all race into it, and only the @@ -848,11 +881,9 @@ class LiveCursorTransport implements CursorTransport { } settler.settleFail(error); }; - const session = this.session; - const stream = this.stream; // Session-level errors (TLS/socket/GOAWAY) do not always propagate to the stream listener; // without this handler they could bypass orderly failure reporting entirely. - session.on("error", err => { + const onSessionError = (err: unknown) => { const realErr = err instanceof Error ? err : new Error(String(err)); debugProviderDiagnostic("cursor", "session-error", { code: String((realErr as { code?: unknown }).code ?? ""), @@ -860,15 +891,18 @@ class LiveCursorTransport implements CursorTransport { elapsedMs: Date.now() - this.turnStartedAt, }); failAndClear(realErr); - }); + }; this.firstFrameTimer = setTimeout(() => { this.firstFrameTimer = undefined; debugProviderDiagnostic("cursor", "first-frame-timeout", { timeoutMs: this.input.firstFrameTimeoutMs ?? CURSOR_FIRST_FRAME_TIMEOUT_MS }); - try { stream.close(); } catch { /* already closing */ } - try { session.close(); } catch { /* already closing */ } - // close() waits for in-flight frames; a dead socket can ignore it — force-destroy shortly - // after so a stalled TLS session cannot linger past the timeout. - armTimeoutDestroyFallback(stream, session, this.input.timeoutDestroyGraceMs ?? CURSOR_TIMEOUT_DESTROY_GRACE_MS); + try { stream?.close(); } catch { /* already closing */ } + try { session?.close(); } catch { /* already closing */ } + this.http1Connection?.close(); + if (stream && session) { + // close() waits for in-flight frames; a dead socket can ignore it — force-destroy shortly + // after so a stalled TLS session cannot linger past the timeout. + armTimeoutDestroyFallback(stream, session, this.input.timeoutDestroyGraceMs ?? CURSOR_TIMEOUT_DESTROY_GRACE_MS); + } releaseBacklogLease(); settler.settleFail(new Error("Cursor transport timed out before first response")); }, this.input.firstFrameTimeoutMs ?? CURSOR_FIRST_FRAME_TIMEOUT_MS); @@ -966,7 +1000,7 @@ class LiveCursorTransport implements CursorTransport { }); } }; - this.stream.on("data", chunk => { + const onData = (chunk: string | Uint8Array) => { this.clearFirstFrameTimer(); // Once the turn has settled, late network bytes must never be charged — // the backlog lease is already released and nobody would own these. @@ -995,13 +1029,13 @@ class LiveCursorTransport implements CursorTransport { if (charged && !appended) this.releaseTransportBytes(bytes.byteLength); failAndClear(err instanceof Error ? err : new Error(String(err))); } - }); - this.stream.on("trailers", trailers => { + }; + const onTrailers = (trailers: http2.IncomingHttpHeaders) => { const status = trailers["grpc-status"]; if (status !== undefined) debugProviderDiagnostic("cursor", "trailers", { grpcStatus: String(status) }); if (status && status !== "0") failAndClear(new Error(`Cursor gRPC error ${status}`)); - }); - this.stream.on("error", err => { + }; + const onStreamError = (err: unknown) => { const realErr = err instanceof Error ? err : new Error(String(err)); if (this.expectedClose) { failAndClear(realErr); @@ -1019,8 +1053,8 @@ class LiveCursorTransport implements CursorTransport { elapsedMs: Date.now() - this.turnStartedAt, }); failAndClear(realErr); - }); - this.stream.on("end", () => { + }; + const onStreamEnd = () => { this.clearFirstFrameTimer(); debugProviderDiagnostic("cursor", "stream-end", { committed: this.committed, @@ -1081,16 +1115,48 @@ class LiveCursorTransport implements CursorTransport { }, (err) => { failAndClear(err instanceof Error ? err : new Error(String(err))); }); - }); + }; + + if (useHttp1) { + const providerFetch = (this.input.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch; + this.http1Connection = new CursorHttp1BidiConnection({ + baseUrl, + token: this.token, + clientVersion: CURSOR_CLIENT_VERSION, + sessionId: this.sessionId, + requestId, + translatorBudget: this.translatorBudget, + callbacks: { + onCommitted: () => { + this.committed = true; + debugProviderDiagnostic("cursor", "connected", { + transport: "http1.1", + connectMs: Date.now() - this.turnStartedAt, + }); + }, + onData, + onEnd: onStreamEnd, + onError: onStreamError, + }, + ...(providerFetch ? { fetch: providerFetch } : {}), + }); + this.http1Connection.start(); + } else { + session!.on("error", onSessionError); + stream!.on("data", onData); + stream!.on("trailers", onTrailers); + stream!.on("error", onStreamError); + stream!.on("end", onStreamEnd); + } signal?.addEventListener("abort", () => { this.close(); failAndClear(new Error("Cursor request was aborted")); }, { once: true }); - this.stream.write(encodeConnectFrame(encodedRequest)); + this.writeConnectFrame(encodeConnectFrame(encodedRequest)); this.heartbeat = setInterval(() => { - this.stream?.write(encodeClientMessage({ + this.writeConnectFrame(encodeClientMessage({ message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) }, })); }, HEARTBEAT_MS); @@ -1101,10 +1167,10 @@ class LiveCursorTransport implements CursorTransport { state: ReturnType, push: (message: CursorServerMessage) => void, ): Promise { - if (!this.stream) return; + if (!this.stream && !this.http1Connection) return; debugProviderDiagnostic("cursor", "frame", describeCursorServerFrame(message)); if (message.message.case === "kvServerMessage") { - this.stream.write(encodeConnectFrame(handleCursorNativeKv(message.message.value, this.blobRequestScope))); + this.writeConnectFrame(encodeConnectFrame(handleCursorNativeKv(message.message.value, this.blobRequestScope))); return; } if (message.message.case === "execServerMessage") { @@ -1125,7 +1191,7 @@ class LiveCursorTransport implements CursorTransport { // run the same local action twice. push({ type: "local_side_effect" }); const replies = await handleCursorNativeExec(message.message.value, this.execContext); - for (const reply of replies) this.stream.write(encodeConnectFrame(reply)); + for (const reply of replies) this.writeConnectFrame(encodeConnectFrame(reply)); return; } if (message.message.case === "interactionQuery") { @@ -1135,7 +1201,7 @@ class LiveCursorTransport implements CursorTransport { const query = message.message.value; const plan = planInteractionQueryReply(query); debugProviderDiagnostic("cursor", "interaction-query", { id: query.id, queryCase: query.query.case ?? "unknown", reply: plan.replyCase }); - this.stream.write(encodeClientMessage({ message: { case: "interactionResponse", value: plan.response } })); + this.writeConnectFrame(encodeClientMessage({ message: { case: "interactionResponse", value: plan.response } })); if (!state.terminated) { if (plan.planText) { this.sawAssistantText = true; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 69203c8940..93d36ac8ae 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1214,7 +1214,13 @@ async function fetchProviderModelsWithAuth( "degraded", ); } - const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl }); + const cursorFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch; + const liveResult = await fetchCursorUsableModels({ + apiKey, + baseUrl: prov.baseUrl, + upstreamHttpVersion: prov.upstreamHttpVersion, + ...(cursorFetch ? { fetch: cursorFetch } : {}), + }); if (liveResult.ok) { const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); const result = available.length > 0 ? available : configured; diff --git a/src/lib/upstream-http-version.ts b/src/lib/upstream-http-version.ts new file mode 100644 index 0000000000..363579a2f1 --- /dev/null +++ b/src/lib/upstream-http-version.ts @@ -0,0 +1,45 @@ +import type { OcxProviderConfig, UpstreamHttpVersion } from "../types"; + +/** True when a provider explicitly selected the HTTP/1.1 compatibility path. */ +export function isPinnedHttp1(version: UpstreamHttpVersion | undefined): boolean { + return version === "http1.1" || version === "h1"; +} + +/** + * Bun's fetch accepts a non-standard `protocol` init to pin the HTTP version + * (BunFetchRequestInit.protocol). The DOM lib types do not include it, so the + * value is represented through a RequestInit cast at this shared boundary. + */ +const UPSTREAM_HTTP_VERSION_PROTOCOL: Record, string> = { + "http1.1": "http1.1", + h1: "h1", + http2: "http2", + h2: "h2", +}; + +/** Attach Bun's `protocol` pin for one explicit version value. */ +export function withUpstreamHttpVersionValue( + input: Parameters[0], + init: RequestInit | undefined, + version: UpstreamHttpVersion | undefined, +): RequestInit | undefined { + if (!version || version === "auto") return init; + // Bun's protocol pin requires an https: target; local/plaintext upstreams keep + // their existing transport untouched. + const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + try { + if (new URL(target).protocol !== "https:") return init; + } catch { + return init; + } + return { ...(init ?? {}), protocol: UPSTREAM_HTTP_VERSION_PROTOCOL[version] } as RequestInit; +} + +/** Attach Bun's `protocol` pin when the provider opted into a fixed HTTP version. */ +export function withUpstreamHttpVersion( + input: Parameters[0], + init: RequestInit | undefined, + provider: Pick, +): RequestInit | undefined { + return withUpstreamHttpVersionValue(input, init, provider.upstreamHttpVersion); +} diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 21652a5345..120f14d4dc 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -33,7 +33,7 @@ import { import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { modelInList, namespacedToolName } from "../../types"; -import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage, UpstreamHttpVersion } from "../../types"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types"; import { forceRefreshOAuthAccessSnapshot, getOAuthCredentialApiBaseUrl, @@ -103,7 +103,9 @@ import { import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair"; import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; import { waitForProviderRequestSlot } from "../../providers/request-pacing"; +import { withUpstreamHttpVersion } from "../../lib/upstream-http-version"; +export { withUpstreamHttpVersion } from "../../lib/upstream-http-version"; export function disableResponsesRequestTimeout(req: Request, server: Pick, "timeout"> | undefined): boolean { if (!server) return false; @@ -149,38 +151,6 @@ export interface ProviderFetchOptions { modelId?: string; } -/** - * Bun's fetch accepts a non-standard `protocol` init to pin the HTTP version - * (BunFetchRequestInit.protocol). The DOM lib types do not include it, so the - * value is carried on an intersection and stripped before non-Bun callers. - * The accepted values are the shared UPSTREAM_HTTP_VERSION_VALUES enum from types. - */ -const UPSTREAM_HTTP_VERSION_PROTOCOL: Record, string> = { - "http1.1": "http1.1", - h1: "h1", - http2: "http2", - h2: "h2", -}; - -/** Attach Bun's `protocol` pin when the provider opted into a fixed HTTP version. */ -export function withUpstreamHttpVersion( - input: Parameters[0], - init: RequestInit | undefined, - provider: OcxProviderConfig, -): RequestInit | undefined { - const version = provider.upstreamHttpVersion; - if (!version || version === "auto") return init; - // Bun's protocol pin requires an https: target; local/plaintext upstreams keep - // their existing transport untouched. - const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - try { - if (new URL(target).protocol !== "https:") return init; - } catch { - return init; - } - return { ...(init ?? {}), protocol: UPSTREAM_HTTP_VERSION_PROTOCOL[version] } as RequestInit; -} - export function providerFetch( provider: OcxProviderConfig, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), diff --git a/src/types.ts b/src/types.ts index ecf375f265..e1ccb7ba47 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1450,8 +1450,9 @@ export interface OcxProviderConfig { * HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on * HTTP/2 streaming responses (issue #1668). "http1.1" / "h1" forces HTTP/1.1, * "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation - * (current behavior unchanged). Only meaningful for https: base URLs. - */ + * (current behavior unchanged). Only meaningful for https: base URLs. Cursor additionally maps + * an HTTP/1.1 pin onto its RunSSE + BidiAppend compatibility transport. + */ upstreamHttpVersion?: UpstreamHttpVersion; /** * Google only. When `false`, the AI Studio (direct) path sends Gemini Flash ids diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 717edc2a01..9b16bacbeb 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -859,7 +859,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | -| Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts` | Thread continuity is the point: a retry must not start a new Cursor thread. | +| Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts` | Thread continuity is the point: a retry must not start a new Cursor thread. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | | Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/` | Inbound translation onto the same routing pipeline. | | Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 438ac2bc04..a6d2130f46 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -98,6 +98,64 @@ describe("Cursor live-model discovery hardening", () => { }); }); + test("HTTP/1.1 discovery uses fetch with Bun's protocol pin", async () => { + const body = toBinary(GetUsableModelsResponseSchema, create(GetUsableModelsResponseSchema, { + models: [create(ModelDetailsSchema, { modelId: "claude-opus-5" })], + })); + let seenUrl = ""; + let seenInit: RequestInit | undefined; + const fetchImpl = (async (input: Parameters[0], init?: RequestInit) => { + seenUrl = String(input); + seenInit = init; + return new Response(body, { status: 200, headers: { "content-type": "application/proto" } }); + }) as typeof fetch; + + const result = await fetchCursorUsableModels({ + apiKey: "test-token", + baseUrl: "https://api2.cursor.sh", + upstreamHttpVersion: "http1.1", + fetch: fetchImpl, + }); + + expect(result).toEqual({ ok: true, models: ["claude-opus-5"] }); + expect(seenUrl).toBe("https://api2.cursor.sh/agent.v1.AgentService/GetUsableModels"); + expect(seenInit?.method).toBe("POST"); + expect((seenInit as RequestInit & { protocol?: string }).protocol).toBe("http1.1"); + expect(new Headers(seenInit?.headers).get("authorization")).toBe("Bearer test-token"); + }); + + test("Cursor catalog propagates the provider HTTP/1.1 pin to discovery", async () => { + const providerName = "cursor-http1-discovery"; + const body = toBinary(GetUsableModelsResponseSchema, create(GetUsableModelsResponseSchema, { + models: [create(ModelDetailsSchema, { modelId: "claude-opus-5" })], + })); + let seenProtocol: string | undefined; + const fetchImpl = (async (_input: Parameters[0], init?: RequestInit) => { + seenProtocol = (init as RequestInit & { protocol?: string } | undefined)?.protocol; + return new Response(body, { status: 200, headers: { "content-type": "application/proto" } }); + }) as typeof fetch; + + try { + const models = await gatherRoutedModels({ + providers: { + [providerName]: { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + apiKey: "test-token", + upstreamHttpVersion: "http1.1", + models: ["claude-opus-5"], + fetch: fetchImpl, + } as Parameters[0]["providers"][string] & { fetch: typeof fetch }, + }, + }); + + expect(models.map(model => `${model.provider}/${model.id}`)).toContain(`${providerName}/claude-opus-5`); + expect(seenProtocol).toBe("http1.1"); + } finally { + clearModelCache(providerName); + } + }); + test("classifies authentication failures", async () => { const result = await withDiscoveryServer(respond(401), baseUrl => fetchCursorUsableModels({ apiKey: "bad-token", baseUrl })); diff --git a/tests/cursor-http1-transport.test.ts b/tests/cursor-http1-transport.test.ts new file mode 100644 index 0000000000..d62de34427 --- /dev/null +++ b/tests/cursor-http1-transport.test.ts @@ -0,0 +1,174 @@ +import { fromBinary } from "@bufbuild/protobuf"; +import { describe, expect, test } from "bun:test"; +import { + AgentClientMessageSchema, + BidiRequestIdSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import { decodeConnectFrame, encodeConnectFrame } from "../src/adapters/cursor/framing"; +import { + cursorBidiAppendRequestSize, + encodeCursorBidiAppendRequest, +} from "../src/adapters/cursor/http1-bidi"; +import { createLiveCursorTransport } from "../src/adapters/cursor/live-transport"; +import type { OcxProviderConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +interface DecodedAppend { + data: string; + requestId: string; + appendSeqno: bigint; +} + +function readVarint(bytes: Uint8Array, offset: number): { value: bigint; offset: number } { + let value = 0n; + let shift = 0n; + while (offset < bytes.byteLength) { + const byte = bytes[offset++]!; + value |= BigInt(byte & 0x7f) << shift; + if ((byte & 0x80) === 0) return { value, offset }; + shift += 7n; + if (shift > 63n) throw new Error("fixture varint is too large"); + } + throw new Error("fixture varint is incomplete"); +} + +function decodeAppend(bytes: Uint8Array): DecodedAppend { + let offset = 0; + let data = ""; + let requestId = ""; + let appendSeqno = 0n; + while (offset < bytes.byteLength) { + const tag = readVarint(bytes, offset); + offset = tag.offset; + const field = Number(tag.value >> 3n); + const wireType = Number(tag.value & 7n); + if (wireType === 2) { + const length = readVarint(bytes, offset); + offset = length.offset; + const end = offset + Number(length.value); + const value = bytes.subarray(offset, end); + offset = end; + if (field === 1) data = new TextDecoder().decode(value); + if (field === 2) requestId = fromBinary(BidiRequestIdSchema, value).requestId; + continue; + } + if (wireType === 0) { + const value = readVarint(bytes, offset); + offset = value.offset; + if (field === 3) appendSeqno = value.value; + continue; + } + throw new Error(`unsupported fixture wire type ${wireType}`); + } + return { data, requestId, appendSeqno }; +} + +function bodyBytes(body: BodyInit | null | undefined): Uint8Array { + if (body instanceof Uint8Array) return body; + if (body instanceof ArrayBuffer) return new Uint8Array(body); + throw new Error(`unexpected request body ${Object.prototype.toString.call(body)}`); +} + +function hexBytes(value: string): Uint8Array { + if (value.length % 2 !== 0) throw new Error("fixture hex payload has an odd length"); + return Uint8Array.from({ length: value.length / 2 }, (_, index) => + Number.parseInt(value.slice(index * 2, index * 2 + 2), 16)); +} + +describe("Cursor HTTP/1.1 compatibility transport", () => { + test("encodes the Cursor BidiAppend hex fallback wire shape", () => { + const payload = Uint8Array.of(0xde, 0xad, 0xbe, 0xef); + const encoded = encodeCursorBidiAppendRequest(payload, "request-123", 300n); + + expect(encoded.byteLength).toBe(cursorBidiAppendRequestSize(payload.byteLength, "request-123", 300n)); + expect(decodeAppend(encoded)).toEqual({ + data: "deadbeef", + requestId: "request-123", + appendSeqno: 300n, + }); + }); + + test("uses RunSSE for output and BidiAppend for the initial client message", async () => { + let runController!: ReadableStreamDefaultController; + let runRequestId = ""; + const appends: DecodedAppend[] = []; + const paths: string[] = []; + const protocols: Array = []; + + const fetchImpl = (async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(String(input)); + paths.push(url.pathname); + protocols.push((init as RequestInit & { protocol?: string } | undefined)?.protocol); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer test-token"); + + if (url.pathname === "/agent.v1.AgentService/RunSSE") { + const frame = decodeConnectFrame(bodyBytes(init?.body)).frame; + runRequestId = fromBinary(BidiRequestIdSchema, frame.payload).requestId; + const body = new ReadableStream({ + start(controller) { runController = controller; }, + }); + return new Response(body, { + status: 200, + headers: { "content-type": "application/connect+proto" }, + }); + } + + if (url.pathname === "/aiserver.v1.BidiService/BidiAppend") { + const append = decodeAppend(bodyBytes(init?.body)); + appends.push(append); + queueMicrotask(() => { + runController.enqueue(encodeConnectFrame(new TextEncoder().encode("{}"), { endStream: true })); + runController.close(); + }); + return new Response(new Uint8Array(), { status: 200 }); + } + + throw new Error(`unexpected Cursor compatibility endpoint ${url.pathname}`); + }) as typeof fetch; + + const provider = { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + apiKey: "test-token", + upstreamHttpVersion: "http1.1", + fetch: fetchImpl, + } as OcxProviderConfig & { fetch: typeof fetch }; + const transport = createLiveCursorTransport({ + provider, + translatorBudget: createTestTranslatorBudget(), + firstFrameTimeoutMs: 2_000, + }); + + const output = []; + try { + for await (const message of transport.run({ + modelId: "claude-opus-5", + conversationId: "cursor_http1_test", + system: [], + messages: [{ role: "user", content: "hello" }], + })) output.push(message); + } finally { + await transport.close?.(); + } + + expect(output).toEqual([]); + expect(paths).toEqual([ + "/agent.v1.AgentService/RunSSE", + "/aiserver.v1.BidiService/BidiAppend", + ]); + expect(protocols).toEqual(["http1.1", "http1.1"]); + expect(runRequestId).not.toBe(""); + expect(appends).toHaveLength(1); + expect(appends[0]?.requestId).toBe(runRequestId); + expect(appends[0]?.appendSeqno).toBe(0n); + expect(appends[0]?.data).not.toBe(""); + expect(fromBinary(AgentClientMessageSchema, hexBytes(appends[0]!.data)).message.case).toBe("runRequest"); + expect(transport.requestCommitted?.()).toBe(true); + }); + + test("preserves the proto3 zero and later append sequence values", () => { + const payload = Uint8Array.of(1, 2, 3); + expect(decodeAppend(encodeCursorBidiAppendRequest(payload, "req", 0n)).appendSeqno).toBe(0n); + expect(decodeAppend(encodeCursorBidiAppendRequest(payload, "req", 1n)).appendSeqno).toBe(1n); + }); +}); From af692a1683237343fa0a1457fbf848bf73fbf34a Mon Sep 17 00:00:00 2001 From: lilinxiong Date: Mon, 17 Aug 2026 15:20:06 +0800 Subject: [PATCH 2/7] feat(gui): expose Cursor HTTP transport setting --- .../src/content/docs/guides/providers.md | 3 +- .../docs/reference/configuration/providers.md | 6 +- .../content/docs/zh-cn/guides/providers.md | 3 +- .../reference/configuration/providers.md | 3 +- .../provider-workspace/ProviderSettings.tsx | 33 +++- .../components/provider-workspace/types.ts | 1 + gui/src/hooks/useJsonConfigEditor.ts | 2 +- gui/src/hooks/useProviderAccountPools.ts | 2 +- gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + gui/src/pages/providers-shared.ts | 1 + gui/src/provider-workspace/catalog.ts | 2 + ...rovider-settings-cursor-transport.test.tsx | 152 ++++++++++++++++++ 20 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 gui/tests/provider-settings-cursor-transport.test.tsx diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 266c42a784..8d6814db2c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -512,7 +512,8 @@ Cursor is tracked separately as an experimental adapter. `adapter: "cursor"` app and the dashboard Add Provider picker as an experimental local config entry with Cursor's static fallback model catalog metadata. When a Cursor access token is configured, opencodex uses Cursor's live HTTP/2 transport. Set `upstreamHttpVersion: "http1.1"` when a proxy requires Cursor's HTTP/1.1 -compatibility path; the setting covers both inference and live model discovery. Its bundled fallback seed includes `gpt-5.6-sol` / `terra` / `luna` (1M context), +compatibility path; the setting covers both inference and live model discovery and is exposed at +**Providers → Cursor → Settings → Cursor transport**. Its bundled fallback seed includes `gpt-5.6-sol` / `terra` / `luna` (1M context), regular/Fast rows for Grok 4.5 and 4.6 (500K), and `kimi-k3` (262K); live discovery decides which remain visible for the account. Grok 4.6 exposes `low` / `medium` / `high` / `xhigh` in both forms, while 4.5 stops at `high`. Fast requests send the matching base Grok model with separate `effort` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 0f8959975a..6ac7d5446c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -300,7 +300,7 @@ Cursor-specific model parameters: If a proxy cannot carry Cursor's default HTTP/2 stream, set `upstreamHttpVersion` to `"http1.1"`. This switches inference to Cursor's `RunSSE` + `BidiAppend` compatibility transport and uses HTTP/1.1 for `GetUsableModels` discovery as well. Leave it unset or use `"auto"` for the existing -HTTP/2 behavior. +HTTP/2 behavior. In the dashboard choose **Providers → Cursor → Settings → Cursor transport**. | Codex model | Cursor Router mode | | --- | --- | @@ -336,8 +336,8 @@ Cursor server-driven local tools are disabled by default. Codex continues using } ``` -Set the field on `providers.cursor`, not at the top level. In the dashboard use **Providers → Cursor -→ Edit JSON**, save, then restart. Legacy `unsafeAllowNativeLocalExec: true` equals +Set `nativeLocalExec` on `providers.cursor`, not at the top level. In the dashboard use **Providers +→ Cursor → Edit JSON**, save, then restart. Legacy `unsafeAllowNativeLocalExec: true` equals `nativeLocalExec: "on"` only when `nativeLocalExec` is unset. MCP, screen recording, and computer use are controlled separately by `mcpServers` and `desktopExecutor`. diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 1858644f49..4e924458ee 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -366,7 +366,8 @@ adapter。若要将没有内置默认值的模型(例如 `gpt-5.4-nano`)接 Cursor 作为单独的实验性 adapter 进行跟踪。`adapter: "cursor"` 会作为实验性本地配置出现在 `ocx init` 和 dashboard Add Provider picker 中,并保存 Cursor 的静态回退模型目录 metadata。配置 Cursor access token 后,opencodex 会使用 Cursor live HTTP/2 transport。代理要求 Cursor 的 -HTTP/1.1 兼容路径时,可设置 `upstreamHttpVersion: "http1.1"`;该设置同时覆盖推理与实时模型发现。内置回退列表包含上下文为 +HTTP/1.1 兼容路径时,可设置 `upstreamHttpVersion: "http1.1"`;该设置同时覆盖推理与实时模型发现, +并可在 **Providers → Cursor → 设置 → Cursor 传输协议** 中选择。内置回退列表包含上下文为 1M 的 `gpt-5.6-sol` / `terra` / `luna`、上下文为 500K 的 Grok 4.5/4.6 普通与 Fast 条目,以及上下文为 262K 的 `kimi-k3`;最终显示哪些模型由账号的实时发现结果决定。Grok 4.6 的两种形式均提供 `low` / `medium` / `high` / `xhigh`,而 4.5 最高为 `high`。Fast 请求会发送对应的 Grok 基础模型, diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 08a828957c..15e824c7f8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -225,6 +225,7 @@ Cursor 桥接是实验性的。执行 `ocx login cursor` 之后,添加或编 如果代理无法承载 Cursor 默认的 HTTP/2 stream,请将 `upstreamHttpVersion` 设置为 `"http1.1"`。推理会切换到 Cursor 的 `RunSSE` + `BidiAppend` 兼容传输,`GetUsableModels` 实时发现也会使用 HTTP/1.1。保持未设置或使用 `"auto"`,则继续使用现有 HTTP/2 行为。 +在仪表板中,可通过 **Providers → Cursor → 设置 → Cursor 传输协议** 进行选择。 | Codex model | Cursor Router mode | | --- | --- | @@ -256,7 +257,7 @@ Cursor 由服务端驱动的本地工具默认是禁用的。Codex 继续使用 } ``` -请将该字段设置在 `providers.cursor` 上,而不是顶层。在仪表板中,使用 **Providers → Cursor → Edit JSON**,保存,然后重启。旧的 `unsafeAllowNativeLocalExec: true` 仅在未设置 `nativeLocalExec` 时,才等同于 `nativeLocalExec: "on"`。MCP、屏幕录制和 computer use 由 `mcpServers` 和 `desktopExecutor` 单独控制。 +请将 `nativeLocalExec` 设置在 `providers.cursor` 上,而不是顶层。在仪表板中,使用 **Providers → Cursor → Edit JSON**,保存,然后重启。旧的 `unsafeAllowNativeLocalExec: true` 仅在未设置 `nativeLocalExec` 时,才等同于 `nativeLocalExec: "on"`。MCP、屏幕录制和 computer use 由 `mcpServers` 和 `desktopExecutor` 单独控制。 每个 `mcpServers.` 都可以接受 `command`(stdio)或 `url`(Streamable HTTP)。stdio 还接受 `args`、`env` 和 `cwd`;HTTP 接受 `headers`。两者都支持 `enabled`(默认 true)和 `toolPrefix`。`desktopExecutor` 接受 `computerUseCommand`、`recordScreenCommand`、`cwd`、`env` 和 `timeoutMs`(默认 `30000`)。命令通过 `sh -c` 执行,从 stdin 读取一个 JSON 请求,并且必须向 stdout 写入一个 JSON 结果。 diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index fe9725e6a5..1509e13a14 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -27,6 +27,11 @@ const EMPTY_MODELS: string[] = []; type ChoicesStatus = "idle" | "loading" | "ready" | "error"; type PacingRule = { requestsPerMinute?: number; minIntervalMs?: number }; type PacingStatus = { enabled: boolean; queued: number; nextSlotInMs: number; lastStartedAt?: number; lastModelId?: string }; +type CursorHttpVersion = "http2" | "http1.1"; + +function effectiveCursorHttpVersion(value: WorkspaceItem["upstreamHttpVersion"]): CursorHttpVersion { + return value === "http1.1" || value === "h1" ? "http1.1" : "http2"; +} function numberDraft(value: number | undefined): string { return value === undefined ? "" : String(value); } function positiveRpm(value: string): number | undefined { @@ -67,6 +72,7 @@ export default function ProviderSettings({ const initialAuth = String(item.authMode ?? (item.keyOptional ? "local" : "key")); const liveModelDiscoverySupported = providerSupportsLiveModelDiscovery(item.name, item); const savedLiveModels = liveModelDiscoverySupported ? item.liveModels !== false : false; + const savedCursorHttpVersion = effectiveCursorHttpVersion(item.upstreamHttpVersion); const [adapter, setAdapter] = useState(item.adapter); const [baseUrl, setBaseUrl] = useState(item.baseUrl); const [defaultModel, setDefaultModel] = useState(item.defaultModel ?? ""); @@ -75,6 +81,7 @@ export default function ProviderSettings({ const [note, setNote] = useState(item.note ?? ""); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false); const [liveModels, setLiveModels] = useState(savedLiveModels); + const [cursorHttpVersion, setCursorHttpVersion] = useState(savedCursorHttpVersion); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [accountMode, setAccountMode] = useState<"pool" | "direct">(item.codexAccountMode ?? "pool"); @@ -102,6 +109,7 @@ export default function ProviderSettings({ setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); + setCursorHttpVersion(savedCursorHttpVersion); setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); @@ -109,7 +117,7 @@ export default function ProviderSettings({ setMsg(null); setModeMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, item.requestPacing, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ // Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress @@ -185,7 +193,8 @@ export default function ProviderSettings({ || (adapter.trim() === "anthropic" && authMode === "key" && apiKeyTransport !== (item.apiKeyTransport ?? "x-api-key")) || note.trim() !== (item.note ?? "") || allowPrivateNetwork !== (item.allowPrivateNetwork ?? false) - || liveModels !== savedLiveModels; + || liveModels !== savedLiveModels + || (adapter.trim() === "cursor" && cursorHttpVersion !== savedCursorHttpVersion); const pacingDirty = pacingSignature(pacingDraft) !== pacingSignature(item.requestPacing); const formDirty = dirty || pacingDirty; @@ -242,6 +251,9 @@ export default function ProviderSettings({ // Keep omitted legacy values omitted unless the user actually changes this toggle. // Otherwise an unrelated settings save manufactures `liveModels: true` provenance. if (liveModelDiscoverySupported && liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; + if (adapter.trim() === "cursor" && cursorHttpVersion !== savedCursorHttpVersion) { + patch.upstreamHttpVersion = cursorHttpVersion === "http1.1" ? "http1.1" : null; + } if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport; else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = ""; } @@ -287,7 +299,8 @@ export default function ProviderSettings({ setAdapter(item.adapter); setBaseUrl(item.baseUrl); setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); - setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); setMsg(null); + setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); + setCursorHttpVersion(savedCursorHttpVersion); setMsg(null); setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); setPacingModels({ ...(item.requestPacing?.models ?? {}) }); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); @@ -356,6 +369,20 @@ export default function ProviderSettings({ setBaseUrl(e.target.value)} readOnly={plainBaseUrlLocked} disabled={plainBaseUrlLocked} /> )} + {adapter.trim() === "cursor" && ( + + )}