@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.
- package/bin/ocx.mjs +59 -7
- package/gui/dist/assets/index-RL6b1bTV.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/base.ts +18 -0
- package/src/adapters/client-fingerprint.ts +0 -2
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/live-models.ts +117 -30
- package/src/adapters/cursor/live-transport.ts +137 -56
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +0 -1
- package/src/adapters/cursor/protobuf-request.ts +89 -21
- package/src/adapters/cursor/tool-definitions.ts +18 -7
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport.ts +7 -0
- package/src/adapters/cursor.ts +2 -0
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google.ts +23 -3
- package/src/adapters/openai-chat.ts +38 -13
- package/src/adapters/openai-responses.ts +129 -9
- package/src/adapters/registry.ts +30 -1
- package/src/bridge.ts +50 -12
- package/src/chat/inbound.ts +1 -0
- package/src/claude/agents-inject.ts +3 -2
- package/src/cli/dispatch.ts +19 -6
- package/src/cli/help.ts +2 -1
- package/src/cli/integrations.ts +35 -0
- package/src/cli/minimax.ts +8 -2
- package/src/cli/registry.ts +13 -2
- package/src/clients/config-export.ts +120 -1
- package/src/codex/account-store.ts +17 -1
- package/src/codex/auth-api.ts +51 -17
- package/src/codex/catalog/effort.ts +8 -5
- package/src/codex/catalog/provider-fetch.ts +39 -27
- package/src/codex/catalog/sync.ts +30 -8
- package/src/codex/inject.ts +39 -13
- package/src/codex/main-account.ts +29 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +25 -0
- package/src/codex/prompt-journal.ts +6 -2
- package/src/codex/quota-rejection.ts +21 -7
- package/src/codex/refresh.ts +4 -2
- package/src/codex/sync.ts +106 -3
- package/src/codex/warmup.ts +187 -81
- package/src/config.ts +85 -46
- package/src/generated/compatibility-version.json +127 -83
- package/src/grok/inject.ts +1 -1
- package/src/images/loop.ts +1 -0
- package/src/integrations/registry.ts +7 -0
- package/src/lab/automation/config-persistence.ts +2 -2
- package/src/lab/automation/persistence.ts +2 -2
- package/src/lab/ledger/purge.ts +2 -2
- package/src/lab/subject/behavior-fingerprint.ts +2 -2
- package/src/lib/config-ownership.ts +5 -2
- package/src/lib/destination-policy.ts +18 -1
- package/src/lib/provider-outbound.ts +7 -0
- package/src/lib/redact.ts +11 -0
- package/src/lib/tool-argument-integers.ts +50 -6
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-retry.ts +2 -1
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/oauth/google-antigravity.ts +2 -2
- package/src/oauth/index.ts +60 -8
- package/src/providers/antigravity-models.ts +58 -4
- package/src/providers/command-code-efforts.ts +36 -9
- package/src/providers/context-cap.ts +9 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/fastwire.ts +453 -0
- package/src/providers/registry.ts +21 -1
- package/src/providers/service-tier.ts +173 -89
- package/src/responses/parser.ts +25 -16
- package/src/responses/reasoning-replay-cache.ts +30 -0
- package/src/responses/thought-signature-replay.ts +74 -4
- package/src/router.ts +6 -0
- package/src/routing/compatibility/behavior.ts +27 -16
- package/src/server/index.ts +10 -1
- package/src/server/management/oauth-account-routes.ts +10 -2
- package/src/server/management/shared.ts +1 -1
- package/src/server/management/system-routes.ts +15 -0
- package/src/server/request-log.ts +57 -3
- package/src/server/responses/agent-task-recovery.ts +6 -1
- package/src/server/responses/core.ts +209 -40
- package/src/server/responses/fetch-helpers.ts +15 -36
- package/src/server/responses/responses-field-backfill.ts +173 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
- package/src/service.ts +38 -36
- package/src/storage/cleanup.ts +2 -2
- package/src/tray/windows.ts +3 -2
- package/src/types.ts +101 -14
- package/src/update/job.ts +9 -18
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/usage/cost.ts +34 -5
- package/src/usage/log.ts +74 -4
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/web-search/anthropic-executor.ts +8 -6
- package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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
package/src/adapters/base.ts
CHANGED
|
@@ -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`
|
|
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
|
|
46
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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).
|