@bitkyc08/opencodex 2.25.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/bin/ocx.mjs +59 -7
  2. package/gui/dist/assets/index-RL6b1bTV.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/base.ts +18 -0
  6. package/src/adapters/client-fingerprint.ts +0 -2
  7. package/src/adapters/cursor/http1-bidi.ts +361 -0
  8. package/src/adapters/cursor/live-models.ts +117 -30
  9. package/src/adapters/cursor/live-transport.ts +137 -56
  10. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  11. package/src/adapters/cursor/native-exec-network.ts +1 -1
  12. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  13. package/src/adapters/cursor/protobuf-request.ts +89 -21
  14. package/src/adapters/cursor/tool-definitions.ts +18 -7
  15. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  16. package/src/adapters/cursor/transport.ts +7 -0
  17. package/src/adapters/cursor.ts +2 -0
  18. package/src/adapters/google-http.ts +38 -10
  19. package/src/adapters/google.ts +23 -3
  20. package/src/adapters/openai-chat.ts +38 -13
  21. package/src/adapters/openai-responses.ts +129 -9
  22. package/src/adapters/registry.ts +30 -1
  23. package/src/bridge.ts +50 -12
  24. package/src/chat/inbound.ts +1 -0
  25. package/src/claude/agents-inject.ts +3 -2
  26. package/src/cli/dispatch.ts +19 -6
  27. package/src/cli/help.ts +2 -1
  28. package/src/cli/integrations.ts +35 -0
  29. package/src/cli/minimax.ts +8 -2
  30. package/src/cli/registry.ts +13 -2
  31. package/src/clients/config-export.ts +120 -1
  32. package/src/codex/account-store.ts +17 -1
  33. package/src/codex/auth-api.ts +51 -17
  34. package/src/codex/catalog/effort.ts +8 -5
  35. package/src/codex/catalog/provider-fetch.ts +39 -27
  36. package/src/codex/catalog/sync.ts +30 -8
  37. package/src/codex/inject.ts +39 -13
  38. package/src/codex/main-account.ts +29 -1
  39. package/src/codex/plan-from-token.ts +140 -0
  40. package/src/codex/plan.ts +25 -0
  41. package/src/codex/prompt-journal.ts +6 -2
  42. package/src/codex/quota-rejection.ts +21 -7
  43. package/src/codex/refresh.ts +4 -2
  44. package/src/codex/sync.ts +106 -3
  45. package/src/codex/warmup.ts +187 -81
  46. package/src/config.ts +85 -46
  47. package/src/generated/compatibility-version.json +127 -83
  48. package/src/grok/inject.ts +1 -1
  49. package/src/images/loop.ts +1 -0
  50. package/src/integrations/registry.ts +7 -0
  51. package/src/lab/automation/config-persistence.ts +2 -2
  52. package/src/lab/automation/persistence.ts +2 -2
  53. package/src/lab/ledger/purge.ts +2 -2
  54. package/src/lab/subject/behavior-fingerprint.ts +2 -2
  55. package/src/lib/config-ownership.ts +5 -2
  56. package/src/lib/destination-policy.ts +18 -1
  57. package/src/lib/provider-outbound.ts +7 -0
  58. package/src/lib/redact.ts +11 -0
  59. package/src/lib/tool-argument-integers.ts +50 -6
  60. package/src/lib/upstream-http-version.ts +57 -0
  61. package/src/lib/upstream-retry.ts +2 -1
  62. package/src/lib/windows-atomic-replace.ts +155 -0
  63. package/src/lib/windows-service-wrappers.ts +72 -0
  64. package/src/oauth/google-antigravity.ts +2 -2
  65. package/src/oauth/index.ts +60 -8
  66. package/src/providers/antigravity-models.ts +58 -4
  67. package/src/providers/command-code-efforts.ts +36 -9
  68. package/src/providers/context-cap.ts +9 -0
  69. package/src/providers/derive.ts +4 -0
  70. package/src/providers/fastwire.ts +453 -0
  71. package/src/providers/registry.ts +21 -1
  72. package/src/providers/service-tier.ts +173 -89
  73. package/src/responses/parser.ts +25 -16
  74. package/src/responses/reasoning-replay-cache.ts +30 -0
  75. package/src/responses/thought-signature-replay.ts +74 -4
  76. package/src/router.ts +6 -0
  77. package/src/routing/compatibility/behavior.ts +27 -16
  78. package/src/server/index.ts +10 -1
  79. package/src/server/management/oauth-account-routes.ts +10 -2
  80. package/src/server/management/shared.ts +1 -1
  81. package/src/server/management/system-routes.ts +15 -0
  82. package/src/server/request-log.ts +57 -3
  83. package/src/server/responses/agent-task-recovery.ts +6 -1
  84. package/src/server/responses/core.ts +209 -40
  85. package/src/server/responses/fetch-helpers.ts +15 -36
  86. package/src/server/responses/responses-field-backfill.ts +173 -0
  87. package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
  88. package/src/service.ts +38 -36
  89. package/src/storage/cleanup.ts +2 -2
  90. package/src/tray/windows.ts +3 -2
  91. package/src/types.ts +101 -14
  92. package/src/update/job.ts +9 -18
  93. package/src/update/transactional-install.d.mts +22 -0
  94. package/src/update/transactional-install.mjs +259 -0
  95. package/src/usage/cost.ts +34 -5
  96. package/src/usage/log.ts +74 -4
  97. package/src/vision/anthropic-describe.ts +10 -6
  98. package/src/web-search/anthropic-executor.ts +8 -6
  99. package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DxJ7kXj9.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-RL6b1bTV.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DQsMZzI5.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -1,11 +1,18 @@
1
1
  import type { AdapterEvent, OcxParsedRequest } from "../types";
2
2
  import type { TranslatorBudget } from "../lib/translator-budget";
3
+ import type { AdapterTierMetadata } from "../providers/fastwire";
3
4
 
4
5
  /** Metadata about the caller's incoming request, for auth-forwarding adapters. */
5
6
  export interface IncomingMeta {
6
7
  headers: Headers;
7
8
  translatorBudget: TranslatorBudget;
8
9
  abortSignal?: AbortSignal;
10
+ /**
11
+ * Provider-scoped fetch prepared by the Responses router. Stateful transports that emit more
12
+ * than one physical HTTP request per logical turn must reuse it so every request participates in
13
+ * the same pacing queue and custom provider fetch seam.
14
+ */
15
+ providerFetch?: typeof globalThis.fetch;
9
16
  /**
10
17
  * Image-normalization ladder bias for upstream-413 tightened retries: every image
11
18
  * starts one tier lower (devlog/260714_image_normalization_pipeline/030). Only the
@@ -39,6 +46,9 @@ export interface ProviderAdapter {
39
46
  incoming: IncomingMeta,
40
47
  emit: (event: AdapterEvent) => void,
41
48
  ): Promise<void>;
49
+
50
+ /** Exact no-field observation for runTurn adapters, which expose no AdapterRequest object. */
51
+ tierLogForRunTurn?(parsed: OcxParsedRequest): AdapterTierMetadata | undefined;
42
52
  }
43
53
 
44
54
  export interface AdapterRequest {
@@ -67,6 +77,12 @@ export interface AdapterRequest {
67
77
  wireField: "reasoning_effort" | "reasoning.effort" | "thinking.type";
68
78
  wireValue: string;
69
79
  };
80
+ /**
81
+ * Exact tier outcome seeded after this adapter serialized the outbound request.
82
+ * This is a live shared observer: response-phase methods mutate `outcome`, so retain
83
+ * the reference rather than cloning or snapshotting it.
84
+ */
85
+ tierLog?: AdapterTierMetadata;
70
86
  usageLog?: {
71
87
  inputTokens?: number;
72
88
  estimated?: boolean;
@@ -82,4 +98,6 @@ export interface AdapterFetchContext {
82
98
  returnRawErrors?: boolean;
83
99
  /** Whether the upstream response will be consumed as a stream; adapters may select low-latency transport settings. */
84
100
  stream?: boolean;
101
+ /** Custom fetch executor to use for physical upstream network requests (defaults to globalThis.fetch). */
102
+ executor?: typeof globalThis.fetch;
85
103
  }
@@ -45,8 +45,6 @@ export function claudeCodeSessionId(token: string | undefined): string {
45
45
  export const ANTIGRAVITY_IDE_VERSION = "2.5.5";
46
46
  const ANTIGRAVITY_IDE_CLIENT_NAME = "aidev_client";
47
47
  const ANTIGRAVITY_IDE_PLATFORM = "windows/amd64";
48
- /** Secondary Google API client UA the Antigravity client library reports. */
49
- export const ANTIGRAVITY_GOOG_API_CLIENT_UA = "google-api-nodejs-client/10.3.0";
50
48
 
51
49
  /**
52
50
  * Real Antigravity IDE User-Agent format, decompiled from 2.5.5 Go LS (`setHeaders` @ `0x1018fbe00`):
@@ -0,0 +1,361 @@
1
+ import { create, toBinary } from "@bufbuild/protobuf";
2
+ import type { TranslatorBudget } from "../../lib/translator-budget";
3
+ import { CURSOR_MAX_EFFECTIVE_CONNECT_PAYLOAD_BYTES } from "../../lib/translator-budget";
4
+ import { readBoundedResponseBytes } from "../../lib/bounded-body";
5
+ import { withUpstreamHttpVersionValue } from "../../lib/upstream-http-version";
6
+ import { isPreConnectReachabilityError } from "../../lib/upstream-reachability";
7
+ import { BidiRequestIdSchema } from "./gen/agent_pb";
8
+ import { consumeConnectFrames, encodeConnectFrame } from "./framing";
9
+
10
+ const CURSOR_RUN_SSE_PATH = "/agent.v1.AgentService/RunSSE";
11
+ const CURSOR_BIDI_APPEND_PATH = "/aiserver.v1.BidiService/BidiAppend";
12
+ const CURSOR_HTTP1_APPEND_TIMEOUT_MS = 30_000;
13
+ const CURSOR_HTTP1_APPEND_RESPONSE_MAX_BYTES = 64 * 1024;
14
+ const HEX = "0123456789abcdef";
15
+
16
+ export interface CursorHttp1BidiCallbacks {
17
+ onCommitted(): void;
18
+ onData(chunk: Uint8Array): void;
19
+ onEnd(): void;
20
+ onError(error: Error): void;
21
+ }
22
+
23
+ export interface CursorHttp1BidiOptions {
24
+ baseUrl: string;
25
+ token: string;
26
+ clientVersion: string;
27
+ sessionId: string;
28
+ requestId: string;
29
+ translatorBudget: TranslatorBudget;
30
+ callbacks: CursorHttp1BidiCallbacks;
31
+ fetch?: typeof globalThis.fetch;
32
+ }
33
+
34
+ interface PendingAppend {
35
+ payload: Uint8Array;
36
+ seqno: bigint;
37
+ rawBytesCharged: boolean;
38
+ }
39
+
40
+ function varintLength(value: bigint): number {
41
+ let length = 1;
42
+ for (let current = value; current >= 0x80n; current >>= 7n) length += 1;
43
+ return length;
44
+ }
45
+
46
+ function writeVarint(target: Uint8Array, offset: number, value: bigint): number {
47
+ let current = value;
48
+ while (current >= 0x80n) {
49
+ target[offset++] = Number(current & 0x7fn) | 0x80;
50
+ current >>= 7n;
51
+ }
52
+ target[offset++] = Number(current);
53
+ return offset;
54
+ }
55
+
56
+ function requestIdMessageBytes(requestId: string): Uint8Array {
57
+ const value = new TextEncoder().encode(requestId);
58
+ const message = new Uint8Array(1 + varintLength(BigInt(value.byteLength)) + value.byteLength);
59
+ let offset = 0;
60
+ message[offset++] = 0x0a; // field 1, string request_id
61
+ offset = writeVarint(message, offset, BigInt(value.byteLength));
62
+ message.set(value, offset);
63
+ return message;
64
+ }
65
+
66
+ /** Exact encoded size of Cursor's aiserver.v1.BidiAppendRequest hex fallback shape. */
67
+ export function cursorBidiAppendRequestSize(payloadBytes: number, requestId: string, seqno: bigint): number {
68
+ const hexBytes = payloadBytes * 2;
69
+ const requestIdBytes = requestIdMessageBytes(requestId).byteLength;
70
+ return (hexBytes > 0 ? 1 + varintLength(BigInt(hexBytes)) + hexBytes : 0)
71
+ + 1 + varintLength(BigInt(requestIdBytes)) + requestIdBytes
72
+ + (seqno > 0n ? 1 + varintLength(seqno) : 0);
73
+ }
74
+
75
+ /**
76
+ * Encode the compatibility request used by Cursor when HTTP/2 is disabled.
77
+ * The public client defaults to field 1's hexadecimal data when its binary-append
78
+ * feature gate is unavailable, so OpenCodex uses that backwards-compatible shape.
79
+ */
80
+ export function encodeCursorBidiAppendRequest(
81
+ payload: Uint8Array,
82
+ requestId: string,
83
+ seqno: bigint,
84
+ ): Uint8Array {
85
+ const requestIdBytes = requestIdMessageBytes(requestId);
86
+ const output = new Uint8Array(cursorBidiAppendRequestSize(payload.byteLength, requestId, seqno));
87
+ let offset = 0;
88
+ if (payload.byteLength > 0) {
89
+ const hexBytes = payload.byteLength * 2;
90
+ output[offset++] = 0x0a; // field 1, string data
91
+ offset = writeVarint(output, offset, BigInt(hexBytes));
92
+ for (const byte of payload) {
93
+ output[offset++] = HEX.charCodeAt(byte >>> 4);
94
+ output[offset++] = HEX.charCodeAt(byte & 0x0f);
95
+ }
96
+ }
97
+ output[offset++] = 0x12; // field 2, BidiRequestId request_id
98
+ offset = writeVarint(output, offset, BigInt(requestIdBytes.byteLength));
99
+ output.set(requestIdBytes, offset);
100
+ offset += requestIdBytes.byteLength;
101
+ if (seqno > 0n) {
102
+ output[offset++] = 0x18; // field 3, int64 append_seqno
103
+ offset = writeVarint(output, offset, seqno);
104
+ }
105
+ return output;
106
+ }
107
+
108
+ function cursorHttp1Error(operation: "RunSSE" | "BidiAppend", status: number): Error {
109
+ return new Error(`Cursor HTTP/1.1 ${operation} failed with HTTP ${status || "unknown"}`);
110
+ }
111
+
112
+ function asError(error: unknown): Error {
113
+ return error instanceof Error ? error : new Error(String(error));
114
+ }
115
+
116
+ /** Bun accepts Uint8Array request bodies; TypeScript's DOM BodyInit omits that runtime shape. */
117
+ function bunFetchBody(bytes: Uint8Array): BodyInit {
118
+ return bytes as unknown as BodyInit;
119
+ }
120
+
121
+ /**
122
+ * Cursor's HTTP/1.1 compatibility bridge: a server-streaming RunSSE request carries
123
+ * output, while each client message is posted separately through BidiAppend.
124
+ */
125
+ export class CursorHttp1BidiConnection {
126
+ private readonly abortController = new AbortController();
127
+ private readonly fetchImpl: typeof globalThis.fetch;
128
+ private readonly runSseReady: Promise<void>;
129
+ private resolveRunSseReady!: () => void;
130
+ private rejectRunSseReady!: (reason?: unknown) => void;
131
+ private appendChain: Promise<void> = Promise.resolve();
132
+ private nextAppendSeqno = 0n;
133
+ private committed = false;
134
+ private paused = false;
135
+ private resumeWaiters: Array<() => void> = [];
136
+ private terminal = false;
137
+ private closed = false;
138
+ destroyed = false;
139
+
140
+ constructor(private readonly options: CursorHttp1BidiOptions) {
141
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
142
+ this.runSseReady = new Promise<void>((resolve, reject) => {
143
+ this.resolveRunSseReady = resolve;
144
+ this.rejectRunSseReady = reject;
145
+ });
146
+ // A RunSSE failure can happen before the first append reaches its await; observe it here so
147
+ // the readiness promise never becomes an unhandled rejection.
148
+ void this.runSseReady.catch(() => undefined);
149
+ }
150
+
151
+ start(): void {
152
+ void this.runSse().catch(error => {
153
+ this.rejectRunSseReady(error);
154
+ this.fail(asError(error));
155
+ });
156
+ }
157
+
158
+ write(frameBytes: Uint8Array): boolean {
159
+ if (this.closed || this.terminal) return false;
160
+ let frame;
161
+ try {
162
+ const decoded = consumeConnectFrames(frameBytes, CURSOR_MAX_EFFECTIVE_CONNECT_PAYLOAD_BYTES, 2);
163
+ if (decoded.frames.length !== 1 || decoded.consumedBytes !== frameBytes.byteLength) {
164
+ throw new Error("Cursor HTTP/1.1 append requires exactly one complete Connect frame");
165
+ }
166
+ frame = decoded.frames[0]!;
167
+ if (frame.flags !== 0) throw new Error(`Cursor HTTP/1.1 append does not support Connect flags ${frame.flags}`);
168
+ this.options.translatorBudget.chargeRetained(frame.payload.byteLength, { kind: "request_copies" });
169
+ } catch (error) {
170
+ this.fail(asError(error));
171
+ return false;
172
+ }
173
+
174
+ const pending: PendingAppend = {
175
+ payload: frame.payload,
176
+ seqno: this.nextAppendSeqno++,
177
+ rawBytesCharged: true,
178
+ };
179
+ this.appendChain = this.appendChain
180
+ .then(() => this.postAppend(pending))
181
+ .catch(error => this.fail(asError(error)))
182
+ .finally(() => {
183
+ if (!pending.rawBytesCharged) return;
184
+ pending.rawBytesCharged = false;
185
+ this.options.translatorBudget.releaseRetained(pending.payload.byteLength, { kind: "request_copies" });
186
+ });
187
+ return true;
188
+ }
189
+
190
+ pause(): void {
191
+ this.paused = true;
192
+ }
193
+
194
+ resume(): void {
195
+ if (!this.paused) return;
196
+ this.paused = false;
197
+ const waiters = this.resumeWaiters;
198
+ this.resumeWaiters = [];
199
+ for (const resume of waiters) resume();
200
+ }
201
+
202
+ close(): void {
203
+ if (this.closed) return;
204
+ this.closed = true;
205
+ this.destroyed = true;
206
+ this.resume();
207
+ const reason = new DOMException("Cursor HTTP/1.1 connection closed", "AbortError");
208
+ this.rejectRunSseReady(reason);
209
+ this.abortController.abort(reason);
210
+ queueMicrotask(() => this.finish());
211
+ }
212
+
213
+ destroy(): void {
214
+ this.close();
215
+ }
216
+
217
+ private commonHeaders(contentType: string): Headers {
218
+ return new Headers({
219
+ "content-type": contentType,
220
+ "connect-protocol-version": "1",
221
+ authorization: `Bearer ${this.options.token}`,
222
+ "x-ghost-mode": "true",
223
+ "x-cursor-client-version": this.options.clientVersion,
224
+ "x-cursor-client-type": "cli",
225
+ "x-request-id": this.options.requestId,
226
+ "x-session-id": this.options.sessionId,
227
+ });
228
+ }
229
+
230
+ private requestUrl(path: string): string {
231
+ const url = new URL(path, this.options.baseUrl);
232
+ if (url.protocol !== "https:") {
233
+ throw new Error("Cursor HTTP/1.1 transport requires an HTTPS base URL");
234
+ }
235
+ return url.toString();
236
+ }
237
+
238
+ private http1Init(url: string, init: RequestInit): RequestInit {
239
+ return withUpstreamHttpVersionValue(url, init, "http1.1") ?? init;
240
+ }
241
+
242
+ private markCommitted(): void {
243
+ if (this.committed) return;
244
+ this.committed = true;
245
+ this.options.callbacks.onCommitted();
246
+ }
247
+
248
+ private async runSse(): Promise<void> {
249
+ const url = this.requestUrl(CURSOR_RUN_SSE_PATH);
250
+ const requestId = toBinary(BidiRequestIdSchema, create(BidiRequestIdSchema, {
251
+ requestId: this.options.requestId,
252
+ }));
253
+ const response = await this.fetchImpl(url, this.http1Init(url, {
254
+ method: "POST",
255
+ headers: this.commonHeaders("application/connect+proto"),
256
+ body: bunFetchBody(encodeConnectFrame(requestId)),
257
+ redirect: "manual",
258
+ signal: this.abortController.signal,
259
+ }));
260
+ if (response.status !== 200) {
261
+ void response.body?.cancel().catch(() => undefined);
262
+ throw cursorHttp1Error("RunSSE", response.status);
263
+ }
264
+ if (!response.body) throw new Error("Cursor HTTP/1.1 RunSSE returned no response body");
265
+ // Cursor has accepted the request id once RunSSE returns successful headers. Do not race the
266
+ // first BidiAppend ahead of that registration.
267
+ this.resolveRunSseReady();
268
+ const reader = response.body.getReader();
269
+ try {
270
+ while (!this.closed && !this.terminal) {
271
+ await this.waitWhilePaused();
272
+ if (this.closed || this.terminal) break;
273
+ const { value, done } = await reader.read();
274
+ if (done) break;
275
+ if (value && value.byteLength > 0) this.options.callbacks.onData(value);
276
+ }
277
+ this.finish();
278
+ } finally {
279
+ try { reader.releaseLock(); } catch { /* a pending abort may still own it */ }
280
+ }
281
+ }
282
+
283
+ private async postAppend(pending: PendingAppend): Promise<void> {
284
+ if (this.closed || this.terminal) return;
285
+ await this.runSseReady;
286
+ if (this.closed || this.terminal) return;
287
+ const encodedBytes = cursorBidiAppendRequestSize(
288
+ pending.payload.byteLength,
289
+ this.options.requestId,
290
+ pending.seqno,
291
+ );
292
+ const reservation = this.options.translatorBudget.reserveTransient(encodedBytes, { kind: "request_copies" });
293
+ let body: Uint8Array;
294
+ try {
295
+ body = encodeCursorBidiAppendRequest(pending.payload, this.options.requestId, pending.seqno);
296
+ } catch (error) {
297
+ reservation.release();
298
+ throw error;
299
+ }
300
+ if (pending.rawBytesCharged) {
301
+ pending.rawBytesCharged = false;
302
+ this.options.translatorBudget.releaseRetained(pending.payload.byteLength, { kind: "request_copies" });
303
+ }
304
+ try {
305
+ const url = this.requestUrl(CURSOR_BIDI_APPEND_PATH);
306
+ const timeout = AbortSignal.timeout(CURSOR_HTTP1_APPEND_TIMEOUT_MS);
307
+ const signal = AbortSignal.any([this.abortController.signal, timeout]);
308
+ let response: Response;
309
+ try {
310
+ response = await this.fetchImpl(url, this.http1Init(url, {
311
+ method: "POST",
312
+ headers: this.commonHeaders("application/proto"),
313
+ body: bunFetchBody(body),
314
+ redirect: "manual",
315
+ signal,
316
+ }));
317
+ } catch (error) {
318
+ // A proven DNS/TCP pre-connect failure means no append bytes reached Cursor and replay is
319
+ // safe. Every ambiguous rejection stays conservative: the server may have accepted the
320
+ // append before the response path failed, so suppress replay exactly as the h2 path does.
321
+ if (!isPreConnectReachabilityError(error)) this.markCommitted();
322
+ throw error;
323
+ }
324
+ this.markCommitted();
325
+ if (response.status !== 200) {
326
+ void response.body?.cancel().catch(() => undefined);
327
+ throw cursorHttp1Error("BidiAppend", response.status);
328
+ }
329
+ const result = await readBoundedResponseBytes(response, {
330
+ maxBytes: CURSOR_HTTP1_APPEND_RESPONSE_MAX_BYTES,
331
+ signal,
332
+ });
333
+ if (result.oversized) throw new Error("Cursor HTTP/1.1 BidiAppend response exceeds 64 KiB");
334
+ } finally {
335
+ reservation.release();
336
+ }
337
+ }
338
+
339
+ private waitWhilePaused(): Promise<void> {
340
+ if (!this.paused) return Promise.resolve();
341
+ return new Promise(resolve => this.resumeWaiters.push(resolve));
342
+ }
343
+
344
+ private fail(error: Error): void {
345
+ if (this.terminal) return;
346
+ this.terminal = true;
347
+ this.destroyed = true;
348
+ this.resume();
349
+ this.rejectRunSseReady(error);
350
+ if (!this.abortController.signal.aborted) this.abortController.abort(error);
351
+ this.options.callbacks.onError(error);
352
+ }
353
+
354
+ private finish(): void {
355
+ if (this.terminal) return;
356
+ this.terminal = true;
357
+ this.destroyed = true;
358
+ this.resume();
359
+ this.options.callbacks.onEnd();
360
+ }
361
+ }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Live Cursor model discovery via the `GetUsableModels` RPC (HTTP/2 + Connect-unary protobuf).
2
+ * Live Cursor model discovery via the `GetUsableModels` Connect-unary protobuf RPC.
3
+ * HTTP/2 remains the default; an explicit HTTP/1.1 provider pin uses Bun fetch instead.
3
4
  *
4
5
  * Returns the account's actually-usable model ids (the full effort-suffixed variants Cursor offers
5
6
  * for THIS plan), so the routed catalog reflects reality instead of a static superset. Failures are
@@ -14,6 +15,14 @@
14
15
  */
15
16
  import http2 from "node:http2";
16
17
  import { fromBinary } from "@bufbuild/protobuf";
18
+ import type { UpstreamHttpVersion } from "../../types";
19
+ import { readBoundedResponseBytes } from "../../lib/bounded-body";
20
+ import {
21
+ isPinnedHttp1,
22
+ UpstreamHttpVersionTargetError,
23
+ withUpstreamHttpVersionValue,
24
+ } from "../../lib/upstream-http-version";
25
+ import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits";
17
26
  import { GetUsableModelsResponseSchema } from "./gen/agent_pb";
18
27
 
19
28
  const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
@@ -27,11 +36,14 @@ export interface CursorUsableModelsOptions {
27
36
  baseUrl?: string;
28
37
  clientVersion?: string;
29
38
  timeoutMs?: number;
39
+ upstreamHttpVersion?: UpstreamHttpVersion;
40
+ /** Test/embedding seam. Production discovery uses Bun's global fetch in HTTP/1.1 mode. */
41
+ fetch?: typeof globalThis.fetch;
30
42
  }
31
43
 
32
44
  export type CursorUsableModelsResult =
33
45
  | { ok: true; models: string[] }
34
- | { ok: false; error: "auth" | "http" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string };
46
+ | { ok: false; error: "auth" | "http" | "policy" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string };
35
47
 
36
48
  /** Test-only seam for management connectivity probes; production callers retain the HTTP/2 path. */
37
49
  export function setFetchCursorUsableModelsForTests(next: CursorUsableModelsFetcher | null): void {
@@ -40,10 +52,11 @@ export function setFetchCursorUsableModelsForTests(next: CursorUsableModelsFetch
40
52
 
41
53
  const RETRYABLE_DISCOVERY_ERRORS = new Set(["timeout", "transport"]);
42
54
  const DISCOVERY_RETRY_TIMEOUT_MS = 3_000;
55
+ const CURSOR_MAX_DISCOVERED_MODELS = 500;
43
56
 
44
57
  /**
45
- * Live discovery with ONE bounded retry for transient pre-response failures (fresh HTTP/2
46
- * session each attempt). Completed non-2xx responses ("http"), auth, decode, and empty are
58
+ * Live discovery with ONE bounded retry for transient pre-response failures (a fresh transport
59
+ * attempt each time). Completed non-2xx responses ("http"), auth, decode, and empty are
47
60
  * deterministic and never retried. The retry attempt's deadline is capped so a cache-miss
48
61
  * catalog poll cannot stall much past the primary timeout (~11.5s worst case, accepted in
49
62
  * devlog 260723_cursor_context_continuity/030).
@@ -83,6 +96,104 @@ function resolveCursorDiscoveryBaseUrl(raw: string): { ok: true; baseUrl: string
83
96
  }
84
97
 
85
98
  async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
99
+ if (isPinnedHttp1(opts.upstreamHttpVersion)) return fetchCursorUsableModelsHttp1Once(opts);
100
+ return fetchCursorUsableModelsHttp2Once(opts);
101
+ }
102
+
103
+ function cursorDiscoveryHeaders(opts: CursorUsableModelsOptions): Record<string, string> {
104
+ return {
105
+ "content-type": "application/proto",
106
+ "connect-protocol-version": "1",
107
+ authorization: `Bearer ${opts.apiKey}`,
108
+ "x-ghost-mode": "true",
109
+ "x-cursor-client-version": opts.clientVersion ?? CURSOR_DISCOVERY_CLIENT_VERSION,
110
+ "x-cursor-client-type": "cli",
111
+ "x-session-id": crypto.randomUUID(),
112
+ };
113
+ }
114
+
115
+ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult {
116
+ try {
117
+ const response = fromBinary(GetUsableModelsResponseSchema, bytes);
118
+ // Account filtering uses wire `model_id` values only. Aliases like `composer-2-5` must not
119
+ // make stale configured ids such as `composer-2` look activated.
120
+ const ids: string[] = [];
121
+ const seenIds = new Set<string>();
122
+ for (const model of response.models ?? []) {
123
+ const rawId = (model as { modelId?: string }).modelId;
124
+ if (typeof rawId !== "string") continue;
125
+ const id = rawId.trim();
126
+ if (!isValidModelDiscoveryModelId(id) || seenIds.has(id)) continue;
127
+ seenIds.add(id);
128
+ ids.push(id);
129
+ if (ids.length >= CURSOR_MAX_DISCOVERED_MODELS) break;
130
+ }
131
+ return ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" };
132
+ } catch {
133
+ return { ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" };
134
+ }
135
+ }
136
+
137
+ async function fetchCursorUsableModelsHttp1Once(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
138
+ let requestUrl: string;
139
+ try {
140
+ const parsed = new URL(CURSOR_GET_USABLE_MODELS_PATH, opts.baseUrl ?? "https://api2.cursor.sh");
141
+ if (parsed.protocol !== "https:") {
142
+ return { ok: false, error: "policy", detail: "Cursor HTTP/1.1 discovery requires HTTPS" };
143
+ }
144
+ requestUrl = parsed.toString();
145
+ } catch {
146
+ return { ok: false, error: "transport", detail: "Cursor HTTP/1.1 discovery base URL is invalid" };
147
+ }
148
+ const timeoutMs = opts.timeoutMs ?? 8000;
149
+ const controller = new AbortController();
150
+ let timedOut = false;
151
+ const timer = setTimeout(() => {
152
+ timedOut = true;
153
+ controller.abort(new DOMException(`No response within ${timeoutMs}ms`, "TimeoutError"));
154
+ }, timeoutMs);
155
+ try {
156
+ const init = withUpstreamHttpVersionValue(requestUrl, {
157
+ method: "POST",
158
+ headers: cursorDiscoveryHeaders(opts),
159
+ redirect: "manual",
160
+ signal: controller.signal,
161
+ }, opts.upstreamHttpVersion);
162
+ const response = await (opts.fetch ?? globalThis.fetch)(requestUrl, init);
163
+ if (response.status === 401 || response.status === 403) {
164
+ void response.body?.cancel().catch(() => undefined);
165
+ return { ok: false, error: "auth", detail: `HTTP ${response.status}` };
166
+ }
167
+ if (response.status !== 200) {
168
+ void response.body?.cancel().catch(() => undefined);
169
+ return { ok: false, error: "http", detail: `HTTP ${response.status || "unknown"}` };
170
+ }
171
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
172
+ if (Number.isFinite(contentLength) && contentLength > CURSOR_MODEL_DISCOVERY_MAX_BYTES) {
173
+ void response.body?.cancel().catch(() => undefined);
174
+ return { ok: false, error: "too_large", detail: "GetUsableModels response exceeds 4 MiB" };
175
+ }
176
+ const body = await readBoundedResponseBytes(response, {
177
+ maxBytes: CURSOR_MODEL_DISCOVERY_MAX_BYTES,
178
+ signal: controller.signal,
179
+ });
180
+ if (body.oversized) {
181
+ return { ok: false, error: "too_large", detail: "GetUsableModels response exceeds 4 MiB" };
182
+ }
183
+ return decodeCursorUsableModels(body.bytes);
184
+ } catch (error) {
185
+ if (error instanceof UpstreamHttpVersionTargetError) {
186
+ return { ok: false, error: "policy", detail: error.message };
187
+ }
188
+ return timedOut
189
+ ? { ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` }
190
+ : { ok: false, error: "transport", detail: "HTTP/1.1 request failed" };
191
+ } finally {
192
+ clearTimeout(timer);
193
+ }
194
+ }
195
+
196
+ async function fetchCursorUsableModelsHttp2Once(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
86
197
  const baseUrl = (opts.baseUrl ?? "https://api2.cursor.sh").replace(/\/+$/, "");
87
198
  const timeoutMs = opts.timeoutMs ?? 8000;
88
199
 
@@ -118,13 +229,7 @@ async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Pro
118
229
  req = client.request({
119
230
  ":method": "POST",
120
231
  ":path": CURSOR_GET_USABLE_MODELS_PATH,
121
- "content-type": "application/proto",
122
- "connect-protocol-version": "1",
123
- authorization: `Bearer ${opts.apiKey}`,
124
- "x-ghost-mode": "true",
125
- "x-cursor-client-version": opts.clientVersion ?? CURSOR_DISCOVERY_CLIENT_VERSION,
126
- "x-cursor-client-type": "cli",
127
- "x-session-id": crypto.randomUUID(),
232
+ ...cursorDiscoveryHeaders(opts),
128
233
  });
129
234
  } catch {
130
235
  return close({ ok: false, error: "transport", detail: "HTTP/2 request setup failed" });
@@ -161,25 +266,7 @@ async function fetchCursorUsableModelsOnce(opts: CursorUsableModelsOptions): Pro
161
266
  return close({ ok: false, error: "auth", detail: `HTTP ${status}` });
162
267
  }
163
268
  if (status !== 200) return close({ ok: false, error: "http", detail: `HTTP ${status || "unknown"}` });
164
- try {
165
- const response = fromBinary(GetUsableModelsResponseSchema, new Uint8Array(Buffer.concat(chunks)));
166
- // Account filtering uses wire `model_id` values only. Aliases like `composer-2-5` must not
167
- // make stale configured ids such as `composer-2` look activated.
168
- const ids: string[] = [];
169
- const seenIds = new Set<string>();
170
- for (const model of response.models ?? []) {
171
- const rawId = (model as { modelId?: string }).modelId;
172
- if (typeof rawId !== "string") continue;
173
- const id = rawId.trim();
174
- if (id.length === 0 || /[\x00-\x1f]/.test(id) || seenIds.has(id)) continue;
175
- seenIds.add(id);
176
- ids.push(id);
177
- if (ids.length === 500) break;
178
- }
179
- close(ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" });
180
- } catch {
181
- close({ ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" });
182
- }
269
+ close(decodeCursorUsableModels(new Uint8Array(Buffer.concat(chunks))));
183
270
  });
184
271
 
185
272
  req.end(); // CRITICAL: no body argument (empty Buffer breaks Bun's HTTP/2 framing).