@opengeni/codex 2.0.1 → 2.0.2-canary.38789554071001
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/dist/index.d.ts +1 -0
- package/dist/index.js +319 -0
- package/dist/index.js.map +1 -1
- package/dist/responses-websocket.d.ts +33 -0
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/responses-websocket.ts +428 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FetchLike } from "./fetch.js";
|
|
2
|
+
export declare const CODEX_RESPONSES_WEBSOCKET_BETA = "responses_websockets=2026-02-06";
|
|
3
|
+
/** The subset of the WHATWG/Bun WebSocket this transport uses. */
|
|
4
|
+
export type CodexWebSocketLike = {
|
|
5
|
+
readonly readyState: number;
|
|
6
|
+
send(data: string): void;
|
|
7
|
+
close(code?: number, reason?: string): void;
|
|
8
|
+
onopen: ((event: unknown) => void) | null;
|
|
9
|
+
onmessage: ((event: {
|
|
10
|
+
data: unknown;
|
|
11
|
+
}) => void) | null;
|
|
12
|
+
onerror: ((event: unknown) => void) | null;
|
|
13
|
+
onclose: ((event: {
|
|
14
|
+
code?: number;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}) => void) | null;
|
|
17
|
+
};
|
|
18
|
+
export type CodexWebSocketFactory = (url: string, headers: Record<string, string>) => CodexWebSocketLike;
|
|
19
|
+
export type CodexResponsesTransportOptions = {
|
|
20
|
+
/** Opens a WebSocket with handshake headers; absent means HTTP only. */
|
|
21
|
+
createSocket?: CodexWebSocketFactory | null;
|
|
22
|
+
/** Close a connection that has carried no request for this long. */
|
|
23
|
+
idleMs?: number;
|
|
24
|
+
/** Never start a request on a connection older than this. */
|
|
25
|
+
maxAgeMs?: number;
|
|
26
|
+
connectTimeoutMs?: number;
|
|
27
|
+
/** After a failed handshake, use HTTP for this conversation for this long. */
|
|
28
|
+
handshakeBackoffMs?: number;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
};
|
|
31
|
+
/** Translate the WebSocket rate-limit event into the SSE endpoint's headers. */
|
|
32
|
+
export declare function codexRateLimitHeaders(event: Record<string, unknown>): Array<[string, string]>;
|
|
33
|
+
export declare function codexResponsesTransport(http: FetchLike, options?: CodexResponsesTransportOptions): FetchLike;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/codex",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2-canary.38789554071001",
|
|
4
4
|
"description": "ChatGPT/Codex subscription auth + transport: device-code login, token refresh, and the Responses-backend fetch. Pure HTTP + transforms; no database dependency.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@opengeni/contracts": "2.0.
|
|
45
|
-
"@opengeni/network": "2.0.
|
|
44
|
+
"@opengeni/contracts": "2.0.2-canary.38789554071001",
|
|
45
|
+
"@opengeni/network": "2.0.2-canary.38789554071001",
|
|
46
46
|
"zod": "^4.2.1"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./api-client";
|
|
|
10
10
|
export * from "./request-context";
|
|
11
11
|
export * from "./response-timeout";
|
|
12
12
|
export * from "./fetch";
|
|
13
|
+
export * from "./responses-websocket";
|
|
13
14
|
export * from "./mcp-sanitize";
|
|
14
15
|
export * from "./model-output-truncation";
|
|
15
16
|
export * from "./opaque-artifact";
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// Responses WebSocket transport for the Codex subscription backend.
|
|
2
|
+
//
|
|
3
|
+
// The backend accepts the same Responses request over a persistent WebSocket
|
|
4
|
+
// (`response.create` messages) and streams the same events back. Keeping one
|
|
5
|
+
// warm connection per conversation removes the per-request HTTP setup from
|
|
6
|
+
// every agent step (about 0.6 s of time-to-first-event measured against the
|
|
7
|
+
// SSE endpoint). This module is a drop-in FetchLike: it turns a streaming POST
|
|
8
|
+
// to /codex/responses into a `response.create` message and synthesizes the
|
|
9
|
+
// SSE Response the rest of the Codex transport already validates, audits and
|
|
10
|
+
// times out. Anything it cannot carry (busy connection, failed handshake,
|
|
11
|
+
// non-streaming or unexpected requests, runtimes without header-capable
|
|
12
|
+
// WebSockets) goes over HTTP unchanged.
|
|
13
|
+
|
|
14
|
+
import type { FetchLike } from "./fetch";
|
|
15
|
+
|
|
16
|
+
export const CODEX_RESPONSES_WEBSOCKET_BETA = "responses_websockets=2026-02-06";
|
|
17
|
+
|
|
18
|
+
/** The subset of the WHATWG/Bun WebSocket this transport uses. */
|
|
19
|
+
export type CodexWebSocketLike = {
|
|
20
|
+
readonly readyState: number;
|
|
21
|
+
send(data: string): void;
|
|
22
|
+
close(code?: number, reason?: string): void;
|
|
23
|
+
onopen: ((event: unknown) => void) | null;
|
|
24
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
25
|
+
onerror: ((event: unknown) => void) | null;
|
|
26
|
+
onclose: ((event: { code?: number; reason?: string }) => void) | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type CodexWebSocketFactory = (
|
|
30
|
+
url: string,
|
|
31
|
+
headers: Record<string, string>,
|
|
32
|
+
) => CodexWebSocketLike;
|
|
33
|
+
|
|
34
|
+
export type CodexResponsesTransportOptions = {
|
|
35
|
+
/** Opens a WebSocket with handshake headers; absent means HTTP only. */
|
|
36
|
+
createSocket?: CodexWebSocketFactory | null;
|
|
37
|
+
/** Close a connection that has carried no request for this long. */
|
|
38
|
+
idleMs?: number;
|
|
39
|
+
/** Never start a request on a connection older than this. */
|
|
40
|
+
maxAgeMs?: number;
|
|
41
|
+
connectTimeoutMs?: number;
|
|
42
|
+
/** After a failed handshake, use HTTP for this conversation for this long. */
|
|
43
|
+
handshakeBackoffMs?: number;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const DEFAULT_IDLE_MS = 5 * 60_000;
|
|
48
|
+
// The backend retires connections after about an hour; never start a request
|
|
49
|
+
// on one that could be closed underneath it.
|
|
50
|
+
const DEFAULT_MAX_AGE_MS = 45 * 60_000;
|
|
51
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
52
|
+
const DEFAULT_HANDSHAKE_BACKOFF_MS = 5 * 60_000;
|
|
53
|
+
|
|
54
|
+
// Per-request headers stay off the shared handshake: they would split every
|
|
55
|
+
// request onto its own connection, and the WebSocket protocol has no
|
|
56
|
+
// per-message equivalent the backend reads.
|
|
57
|
+
const PER_REQUEST_HEADERS = new Set([
|
|
58
|
+
"accept",
|
|
59
|
+
"content-type",
|
|
60
|
+
"content-length",
|
|
61
|
+
"idempotency-key",
|
|
62
|
+
"openai-beta",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const TERMINAL_EVENTS = new Set([
|
|
66
|
+
"response.completed",
|
|
67
|
+
"response.done",
|
|
68
|
+
"response.failed",
|
|
69
|
+
"response.incomplete",
|
|
70
|
+
"response.error",
|
|
71
|
+
"error",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// Connection-level events that the SSE endpoint conveys as response headers.
|
|
75
|
+
const RATE_LIMITS_EVENT = "codex.rate_limits";
|
|
76
|
+
const METADATA_EVENT = "codex.response.metadata";
|
|
77
|
+
const TIMING_EVENT = "responsesapi.websocket_timing";
|
|
78
|
+
|
|
79
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
80
|
+
|
|
81
|
+
type Connection = {
|
|
82
|
+
key: string;
|
|
83
|
+
socket: CodexWebSocketLike;
|
|
84
|
+
openedAt: number;
|
|
85
|
+
busy: boolean;
|
|
86
|
+
closed: boolean;
|
|
87
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
88
|
+
/** Receives every message while a request is in flight. */
|
|
89
|
+
onEvent: ((event: Record<string, unknown>) => void) | null;
|
|
90
|
+
/** Receives the close of the socket while a request is in flight. */
|
|
91
|
+
onClose: ((reason: Error) => void) | null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function bunWebSocketFactory(): CodexWebSocketFactory | null {
|
|
95
|
+
const runtime = globalThis as { Bun?: unknown; WebSocket?: unknown };
|
|
96
|
+
// Only Bun's WebSocket accepts handshake headers, which carry the bearer.
|
|
97
|
+
if (!runtime.Bun || typeof runtime.WebSocket !== "function") return null;
|
|
98
|
+
const Socket = runtime.WebSocket as new (
|
|
99
|
+
url: string,
|
|
100
|
+
options: { headers: Record<string, string> },
|
|
101
|
+
) => CodexWebSocketLike;
|
|
102
|
+
return (url, headers) => new Socket(url, { headers });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function eventData(data: unknown): string | null {
|
|
106
|
+
if (typeof data === "string") return data;
|
|
107
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
108
|
+
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Translate the WebSocket rate-limit event into the SSE endpoint's headers. */
|
|
113
|
+
export function codexRateLimitHeaders(event: Record<string, unknown>): Array<[string, string]> {
|
|
114
|
+
const limits = event.rate_limits;
|
|
115
|
+
if (!limits || typeof limits !== "object") return [];
|
|
116
|
+
const headers: Array<[string, string]> = [];
|
|
117
|
+
for (const slot of ["primary", "secondary"] as const) {
|
|
118
|
+
const window = (limits as Record<string, unknown>)[slot];
|
|
119
|
+
if (!window || typeof window !== "object") continue;
|
|
120
|
+
const value = window as Record<string, unknown>;
|
|
121
|
+
const number = (field: string) =>
|
|
122
|
+
typeof value[field] === "number" && Number.isFinite(value[field])
|
|
123
|
+
? Math.round(value[field] as number)
|
|
124
|
+
: null;
|
|
125
|
+
const used = number("used_percent");
|
|
126
|
+
const minutes = number("window_minutes");
|
|
127
|
+
const resetAfter = number("reset_after_seconds");
|
|
128
|
+
const resetAt = number("reset_at");
|
|
129
|
+
if (used !== null) headers.push([`x-codex-${slot}-used-percent`, String(used)]);
|
|
130
|
+
if (minutes !== null) {
|
|
131
|
+
headers.push([`x-codex-${slot}-window-minutes`, String(minutes)]);
|
|
132
|
+
headers.push([`x-codex-${slot}-limit-window-seconds`, String(minutes * 60)]);
|
|
133
|
+
}
|
|
134
|
+
if (resetAfter !== null)
|
|
135
|
+
headers.push([`x-codex-${slot}-reset-after-seconds`, String(resetAfter)]);
|
|
136
|
+
if (resetAt !== null) headers.push([`x-codex-${slot}-reset-at`, String(resetAt)]);
|
|
137
|
+
}
|
|
138
|
+
return headers;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function metadataHeaders(event: Record<string, unknown>): Array<[string, string]> {
|
|
142
|
+
const headers = event.headers;
|
|
143
|
+
if (!headers || typeof headers !== "object") return [];
|
|
144
|
+
return Object.entries(headers as Record<string, unknown>).flatMap(([name, value]) =>
|
|
145
|
+
HEADER_NAME.test(name) && typeof value === "string" && !/[\r\n]/.test(value)
|
|
146
|
+
? [[name.toLowerCase(), value] as [string, string]]
|
|
147
|
+
: [],
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sseBlock(event: Record<string, unknown>): Uint8Array {
|
|
152
|
+
const type = typeof event.type === "string" ? event.type : "message";
|
|
153
|
+
return new TextEncoder().encode(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function requestUrl(input: string | URL | Request): string | null {
|
|
157
|
+
if (typeof input === "string") return input;
|
|
158
|
+
if (input instanceof URL) return input.toString();
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function codexResponsesTransport(
|
|
163
|
+
http: FetchLike,
|
|
164
|
+
options: CodexResponsesTransportOptions = {},
|
|
165
|
+
): FetchLike {
|
|
166
|
+
const createSocket =
|
|
167
|
+
options.createSocket === undefined ? bunWebSocketFactory() : options.createSocket;
|
|
168
|
+
if (!createSocket) return http;
|
|
169
|
+
const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
|
|
170
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
171
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
172
|
+
const handshakeBackoffMs = options.handshakeBackoffMs ?? DEFAULT_HANDSHAKE_BACKOFF_MS;
|
|
173
|
+
const now = options.now ?? Date.now;
|
|
174
|
+
const pool = new Map<string, Connection>();
|
|
175
|
+
const backoffUntil = new Map<string, number>();
|
|
176
|
+
|
|
177
|
+
const retire = (connection: Connection) => {
|
|
178
|
+
if (connection.idleTimer) clearTimeout(connection.idleTimer);
|
|
179
|
+
connection.idleTimer = null;
|
|
180
|
+
if (pool.get(connection.key) === connection) pool.delete(connection.key);
|
|
181
|
+
if (!connection.closed) {
|
|
182
|
+
connection.closed = true;
|
|
183
|
+
try {
|
|
184
|
+
connection.socket.close(1000, "done");
|
|
185
|
+
} catch {
|
|
186
|
+
// Already closing.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const release = (connection: Connection) => {
|
|
192
|
+
connection.busy = false;
|
|
193
|
+
connection.onEvent = null;
|
|
194
|
+
connection.onClose = null;
|
|
195
|
+
if (connection.closed) return;
|
|
196
|
+
if (connection.idleTimer) clearTimeout(connection.idleTimer);
|
|
197
|
+
connection.idleTimer = setTimeout(() => retire(connection), idleMs);
|
|
198
|
+
(connection.idleTimer as { unref?: () => void }).unref?.();
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const connect = (url: string, key: string, headers: Record<string, string>) =>
|
|
202
|
+
new Promise<Connection>((resolve, reject) => {
|
|
203
|
+
let socket: CodexWebSocketLike;
|
|
204
|
+
try {
|
|
205
|
+
socket = createSocket(url, headers);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
reject(error);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const connection: Connection = {
|
|
211
|
+
key,
|
|
212
|
+
socket,
|
|
213
|
+
openedAt: now(),
|
|
214
|
+
busy: false,
|
|
215
|
+
closed: false,
|
|
216
|
+
idleTimer: null,
|
|
217
|
+
onEvent: null,
|
|
218
|
+
onClose: null,
|
|
219
|
+
};
|
|
220
|
+
let opened = false;
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
if (opened) return;
|
|
223
|
+
connection.closed = true;
|
|
224
|
+
try {
|
|
225
|
+
socket.close();
|
|
226
|
+
} catch {
|
|
227
|
+
// Never opened.
|
|
228
|
+
}
|
|
229
|
+
reject(new Error("Codex response WebSocket handshake timed out"));
|
|
230
|
+
}, connectTimeoutMs);
|
|
231
|
+
socket.onopen = () => {
|
|
232
|
+
opened = true;
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
resolve(connection);
|
|
235
|
+
};
|
|
236
|
+
socket.onerror = () => {
|
|
237
|
+
// A close event always follows and settles the state.
|
|
238
|
+
};
|
|
239
|
+
socket.onclose = (event) => {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
connection.closed = true;
|
|
242
|
+
if (pool.get(key) === connection) pool.delete(key);
|
|
243
|
+
if (connection.idleTimer) clearTimeout(connection.idleTimer);
|
|
244
|
+
if (!opened) {
|
|
245
|
+
reject(
|
|
246
|
+
new Error(`Codex response WebSocket handshake failed (${event?.code ?? "closed"})`),
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
connection.onClose?.(
|
|
251
|
+
new Error(
|
|
252
|
+
`Codex response WebSocket closed before the response finished (${event?.code ?? "closed"})`,
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
socket.onmessage = (message) => {
|
|
257
|
+
const text = eventData(message.data);
|
|
258
|
+
if (text === null) return;
|
|
259
|
+
let parsed: unknown;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(text);
|
|
262
|
+
} catch {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
266
|
+
connection.onEvent?.(parsed as Record<string, unknown>);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return async (input, init) => {
|
|
272
|
+
const url = requestUrl(input);
|
|
273
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
274
|
+
const body = init?.body;
|
|
275
|
+
if (
|
|
276
|
+
!url ||
|
|
277
|
+
method !== "POST" ||
|
|
278
|
+
typeof body !== "string" ||
|
|
279
|
+
!/^https:\/\/[^/]+\/.*\/codex\/responses(?:\?|$)/.test(url)
|
|
280
|
+
) {
|
|
281
|
+
return await http(input, init);
|
|
282
|
+
}
|
|
283
|
+
let payload: Record<string, unknown>;
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(body) as unknown;
|
|
286
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
287
|
+
payload = parsed as Record<string, unknown>;
|
|
288
|
+
} catch {
|
|
289
|
+
return await http(input, init);
|
|
290
|
+
}
|
|
291
|
+
if (payload.stream !== true) return await http(input, init);
|
|
292
|
+
|
|
293
|
+
const handshake: Record<string, string> = {};
|
|
294
|
+
for (const [name, value] of new Headers(init?.headers).entries()) {
|
|
295
|
+
if (!PER_REQUEST_HEADERS.has(name)) handshake[name] = value;
|
|
296
|
+
}
|
|
297
|
+
handshake["openai-beta"] = CODEX_RESPONSES_WEBSOCKET_BETA;
|
|
298
|
+
const wsUrl = url.replace(/^https:/, "wss:");
|
|
299
|
+
const key = JSON.stringify([wsUrl, Object.entries(handshake).sort()]);
|
|
300
|
+
if ((backoffUntil.get(key) ?? 0) > now()) return await http(input, init);
|
|
301
|
+
|
|
302
|
+
const signal = init?.signal ?? null;
|
|
303
|
+
signal?.throwIfAborted();
|
|
304
|
+
let connection = pool.get(key);
|
|
305
|
+
if (connection && (connection.closed || now() - connection.openedAt > maxAgeMs)) {
|
|
306
|
+
if (!connection.busy) retire(connection);
|
|
307
|
+
connection = undefined;
|
|
308
|
+
}
|
|
309
|
+
if (connection?.busy) return await http(input, init);
|
|
310
|
+
if (!connection) {
|
|
311
|
+
try {
|
|
312
|
+
connection = await connect(wsUrl, key, handshake);
|
|
313
|
+
} catch {
|
|
314
|
+
backoffUntil.set(key, now() + handshakeBackoffMs);
|
|
315
|
+
return await http(input, init);
|
|
316
|
+
}
|
|
317
|
+
if (pool.has(key)) {
|
|
318
|
+
// A concurrent request of the same conversation opened one first.
|
|
319
|
+
const existing = pool.get(key)!;
|
|
320
|
+
if (!existing.busy && !existing.closed) {
|
|
321
|
+
retire(connection);
|
|
322
|
+
connection = existing;
|
|
323
|
+
} else {
|
|
324
|
+
pool.set(key, connection);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
pool.set(key, connection);
|
|
328
|
+
}
|
|
329
|
+
if (connection.busy) return await http(input, init);
|
|
330
|
+
}
|
|
331
|
+
const active = connection;
|
|
332
|
+
active.busy = true;
|
|
333
|
+
if (active.idleTimer) clearTimeout(active.idleTimer);
|
|
334
|
+
active.idleTimer = null;
|
|
335
|
+
|
|
336
|
+
return await new Promise<Response>((resolve, reject) => {
|
|
337
|
+
const headers = new Headers({ "content-type": "text/event-stream" });
|
|
338
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
339
|
+
let started = false;
|
|
340
|
+
let finished = false;
|
|
341
|
+
const pending: Uint8Array[] = [];
|
|
342
|
+
|
|
343
|
+
const finish = (error?: unknown) => {
|
|
344
|
+
if (finished) return;
|
|
345
|
+
finished = true;
|
|
346
|
+
signal?.removeEventListener("abort", onAbort);
|
|
347
|
+
if (error !== undefined) {
|
|
348
|
+
// The provider may still be working on a request we can no longer
|
|
349
|
+
// follow; that socket is never reused.
|
|
350
|
+
retire(active);
|
|
351
|
+
active.busy = false;
|
|
352
|
+
if (started) controller?.error(error);
|
|
353
|
+
else reject(error);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
release(active);
|
|
357
|
+
try {
|
|
358
|
+
controller?.close();
|
|
359
|
+
} catch {
|
|
360
|
+
// The reader already cancelled.
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const onAbort = () => finish(signal?.reason ?? new Error("Model request aborted"));
|
|
364
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
365
|
+
|
|
366
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
367
|
+
start(streamController) {
|
|
368
|
+
controller = streamController;
|
|
369
|
+
for (const block of pending.splice(0)) streamController.enqueue(block);
|
|
370
|
+
if (finished) streamController.close();
|
|
371
|
+
},
|
|
372
|
+
cancel() {
|
|
373
|
+
if (!finished) finish(new Error("Model response stream cancelled"));
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const begin = (status = 200, errorBody?: string) => {
|
|
378
|
+
started = true;
|
|
379
|
+
if (errorBody !== undefined) {
|
|
380
|
+
headers.set("content-type", "application/json");
|
|
381
|
+
resolve(new Response(errorBody, { status, headers }));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
resolve(new Response(stream, { status, headers }));
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
active.onClose = (error) => finish(error);
|
|
388
|
+
active.onEvent = (event) => {
|
|
389
|
+
if (finished) return;
|
|
390
|
+
const type = typeof event.type === "string" ? event.type : "";
|
|
391
|
+
if (type === RATE_LIMITS_EVENT) {
|
|
392
|
+
if (!started)
|
|
393
|
+
for (const [name, value] of codexRateLimitHeaders(event)) headers.set(name, value);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (type === METADATA_EVENT) {
|
|
397
|
+
if (!started)
|
|
398
|
+
for (const [name, value] of metadataHeaders(event)) headers.set(name, value);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (type === TIMING_EVENT) return;
|
|
402
|
+
if (!started && type === "error" && typeof event.status === "number") {
|
|
403
|
+
// The SSE endpoint answers a refused request with an HTTP error and a
|
|
404
|
+
// JSON body; keep that shape so limits and auth stay classified.
|
|
405
|
+
for (const [name, value] of metadataHeaders(event)) headers.set(name, value);
|
|
406
|
+
const status = event.status >= 400 && event.status < 600 ? event.status : 500;
|
|
407
|
+
begin(status, JSON.stringify({ error: event.error ?? null }));
|
|
408
|
+
// The connection stays usable after a refused request.
|
|
409
|
+
finished = true;
|
|
410
|
+
signal?.removeEventListener("abort", onAbort);
|
|
411
|
+
release(active);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!started) begin();
|
|
415
|
+
const block = sseBlock(event);
|
|
416
|
+
if (controller) controller.enqueue(block);
|
|
417
|
+
else pending.push(block);
|
|
418
|
+
if (TERMINAL_EVENTS.has(type)) finish();
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
active.socket.send(JSON.stringify({ ...payload, type: "response.create" }));
|
|
423
|
+
} catch (error) {
|
|
424
|
+
finish(error);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
}
|