@opengeni/codex 0.2.3 → 0.2.7

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/src/fetch.ts CHANGED
@@ -10,13 +10,24 @@
10
10
  // - retries once on 401 after a forced token refresh (spec §1.9)
11
11
  // Stream parsing is delegated to the SDK (SSE passthrough; spec §0(d)).
12
12
 
13
+ import { randomUUID } from "node:crypto";
13
14
  import { CODEX_ORIGINATOR } from "./constants";
14
15
  import { normalizeCodexRequestBody } from "./normalize";
15
16
  import {
16
17
  codexRequestStorage,
18
+ type CodexModelRequestEvent,
19
+ type CodexRequestContext,
20
+ type CodexResponseTimeoutPolicy,
17
21
  type CodexTokenSnapshot,
18
22
  type CodexUsageHeaderSnapshot,
19
23
  } from "./request-context";
24
+ import {
25
+ CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
26
+ CodexResponseTimeoutError,
27
+ classifyCodexResponseTimeoutError,
28
+ isPreHeadersTimeoutError,
29
+ resolveCodexResponseTimeoutPolicy,
30
+ } from "./response-timeout";
20
31
 
21
32
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
22
33
 
@@ -118,6 +129,252 @@ export function parseCodexUsageHeaders(headers: Headers): CodexUsageHeaderSnapsh
118
129
  };
119
130
  }
120
131
 
132
+ type RequestAudit = {
133
+ ctx: CodexRequestContext;
134
+ requestId: string;
135
+ transportAttempt: number;
136
+ model?: string;
137
+ logicalStartedAt: number;
138
+ attemptStartedAt: number;
139
+ policy: CodexResponseTimeoutPolicy;
140
+ };
141
+
142
+ async function emitRequestEvent(
143
+ audit: RequestAudit,
144
+ event: Omit<
145
+ CodexModelRequestEvent,
146
+ "requestId" | "transportAttempt" | "model" | "durationMs" | "timeoutPolicy"
147
+ >,
148
+ ): Promise<void> {
149
+ await audit.ctx.onModelRequestEvent?.({
150
+ requestId: audit.requestId,
151
+ transportAttempt: audit.transportAttempt,
152
+ ...(audit.model ? { model: audit.model } : {}),
153
+ durationMs: Math.max(0, Date.now() - audit.attemptStartedAt),
154
+ timeoutPolicy: audit.policy,
155
+ ...event,
156
+ });
157
+ }
158
+
159
+ function providerRequestId(headers: Headers): string | undefined {
160
+ return headers.get("x-request-id") ?? headers.get("request-id") ?? undefined;
161
+ }
162
+
163
+ async function fetchBeforeHeaders(
164
+ base: FetchLike,
165
+ input: string,
166
+ init: RequestInit,
167
+ audit: RequestAudit,
168
+ ): Promise<Response> {
169
+ const elapsed = Date.now() - audit.logicalStartedAt;
170
+ const wholeRemainingMs = audit.policy.wholeRequestTimeoutMs - elapsed;
171
+ const timeoutClass =
172
+ wholeRemainingMs <= audit.policy.headersTimeoutMs ? "whole_request" : "headers";
173
+ const deadlineMs = Math.max(1, Math.min(audit.policy.headersTimeoutMs, wholeRemainingMs));
174
+ if (wholeRemainingMs <= 0) {
175
+ throw new CodexResponseTimeoutError("whole_request", audit.requestId, false);
176
+ }
177
+
178
+ const externalSignal = init.signal;
179
+ if (externalSignal?.aborted) throw externalSignal.reason;
180
+ const controller = new AbortController();
181
+ const forwardAbort = () => controller.abort(externalSignal?.reason);
182
+ externalSignal?.addEventListener("abort", forwardAbort, { once: true });
183
+ const basePromise = base(input, { ...init, signal: controller.signal });
184
+ let deadlineError: CodexResponseTimeoutError | null = null;
185
+ let timer: ReturnType<typeof setTimeout> | undefined;
186
+ const deadline = new Promise<never>((_resolve, reject) => {
187
+ timer = setTimeout(() => {
188
+ deadlineError = new CodexResponseTimeoutError(timeoutClass, audit.requestId, false);
189
+ reject(deadlineError);
190
+ }, deadlineMs);
191
+ });
192
+ try {
193
+ return await Promise.race([basePromise, deadline]);
194
+ } catch (error) {
195
+ if (deadlineError) {
196
+ controller.abort(deadlineError);
197
+ void basePromise
198
+ .then((late) => late.body?.cancel(deadlineError ?? undefined))
199
+ .catch(() => undefined);
200
+ throw deadlineError;
201
+ }
202
+ throw error;
203
+ } finally {
204
+ if (timer) clearTimeout(timer);
205
+ externalSignal?.removeEventListener("abort", forwardAbort);
206
+ }
207
+ }
208
+
209
+ async function observedResponse(
210
+ res: Response,
211
+ audit: RequestAudit,
212
+ externalSignal: AbortSignal | null | undefined,
213
+ ): Promise<Response> {
214
+ const requestId = providerRequestId(res.headers);
215
+ if (!res.body) {
216
+ await emitRequestEvent(audit, {
217
+ phase: res.ok ? "completed" : "failed",
218
+ responseObserved: true,
219
+ status: res.status,
220
+ ...(requestId ? { providerRequestId: requestId } : {}),
221
+ });
222
+ return res;
223
+ }
224
+
225
+ const reader = res.body.getReader();
226
+ let terminal = false;
227
+ let firstByte = false;
228
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
229
+ let wholeTimer: ReturnType<typeof setTimeout> | undefined;
230
+ let armIdle: () => void = () => undefined;
231
+ let abortFromOutside: (() => void) | undefined;
232
+
233
+ const clearTimers = () => {
234
+ if (idleTimer) clearTimeout(idleTimer);
235
+ if (wholeTimer) clearTimeout(wholeTimer);
236
+ if (abortFromOutside) externalSignal?.removeEventListener("abort", abortFromOutside);
237
+ };
238
+
239
+ const body = new ReadableStream<Uint8Array>({
240
+ start(controller) {
241
+ const timeOut = (klass: "idle_stream" | "whole_request") => {
242
+ if (terminal) return;
243
+ terminal = true;
244
+ clearTimers();
245
+ const error = new CodexResponseTimeoutError(klass, audit.requestId, true);
246
+ void reader.cancel(error).catch(() => undefined);
247
+ void emitRequestEvent(audit, {
248
+ phase: "timed_out",
249
+ responseObserved: true,
250
+ timeoutClass: klass,
251
+ status: res.status,
252
+ ...(requestId ? { providerRequestId: requestId } : {}),
253
+ }).then(
254
+ () => controller.error(error),
255
+ () => controller.error(error),
256
+ );
257
+ };
258
+ armIdle = () => {
259
+ if (idleTimer) clearTimeout(idleTimer);
260
+ idleTimer = setTimeout(() => timeOut("idle_stream"), audit.policy.streamIdleTimeoutMs);
261
+ };
262
+ armIdle();
263
+ const wholeRemaining = Math.max(
264
+ 1,
265
+ audit.policy.wholeRequestTimeoutMs - (Date.now() - audit.logicalStartedAt),
266
+ );
267
+ wholeTimer = setTimeout(() => timeOut("whole_request"), wholeRemaining);
268
+ abortFromOutside = () => {
269
+ if (terminal) return;
270
+ terminal = true;
271
+ clearTimers();
272
+ const reason = externalSignal?.reason ?? new DOMException("Aborted", "AbortError");
273
+ void reader.cancel(reason).catch(() => undefined);
274
+ void emitRequestEvent(audit, {
275
+ phase: "failed",
276
+ responseObserved: true,
277
+ status: res.status,
278
+ ...(requestId ? { providerRequestId: requestId } : {}),
279
+ }).then(
280
+ () => controller.error(reason),
281
+ () => controller.error(reason),
282
+ );
283
+ };
284
+ if (externalSignal?.aborted) {
285
+ abortFromOutside();
286
+ } else {
287
+ externalSignal?.addEventListener("abort", abortFromOutside, { once: true });
288
+ }
289
+ },
290
+ async pull(controller) {
291
+ if (terminal) return;
292
+ try {
293
+ const chunk = await reader.read();
294
+ if (terminal) return;
295
+ if (chunk.done) {
296
+ terminal = true;
297
+ clearTimers();
298
+ await emitRequestEvent(audit, {
299
+ phase: res.ok ? "completed" : "failed",
300
+ responseObserved: true,
301
+ status: res.status,
302
+ ...(requestId ? { providerRequestId: requestId } : {}),
303
+ });
304
+ controller.close();
305
+ return;
306
+ }
307
+ armIdle();
308
+ if (!firstByte) {
309
+ firstByte = true;
310
+ await emitRequestEvent(audit, {
311
+ phase: "first_byte",
312
+ responseObserved: true,
313
+ status: res.status,
314
+ ...(requestId ? { providerRequestId: requestId } : {}),
315
+ });
316
+ }
317
+ controller.enqueue(chunk.value);
318
+ } catch (error) {
319
+ if (terminal) return;
320
+ terminal = true;
321
+ clearTimers();
322
+ await emitRequestEvent(audit, {
323
+ phase: "failed",
324
+ responseObserved: true,
325
+ status: res.status,
326
+ ...(requestId ? { providerRequestId: requestId } : {}),
327
+ });
328
+ controller.error(error);
329
+ }
330
+ },
331
+ async cancel(reason) {
332
+ if (!terminal) {
333
+ terminal = true;
334
+ clearTimers();
335
+ await emitRequestEvent(audit, {
336
+ phase: "failed",
337
+ responseObserved: true,
338
+ status: res.status,
339
+ ...(requestId ? { providerRequestId: requestId } : {}),
340
+ }).catch(() => undefined);
341
+ }
342
+ await reader.cancel(reason).catch(() => undefined);
343
+ },
344
+ });
345
+ const headers = new Headers(res.headers);
346
+ headers.delete("content-length");
347
+ return new Response(body, { status: res.status, statusText: res.statusText, headers });
348
+ }
349
+
350
+ function timeoutErrorResponse(info: {
351
+ timeoutClass: "connect" | "headers" | "idle_stream" | "whole_request";
352
+ requestId: string;
353
+ responseObserved: boolean;
354
+ message: string;
355
+ }): Response {
356
+ return new Response(
357
+ JSON.stringify({
358
+ error: {
359
+ type: CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
360
+ code: CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
361
+ message: info.message,
362
+ timeout_class: info.timeoutClass,
363
+ response_observed: info.responseObserved,
364
+ request_id: info.requestId,
365
+ },
366
+ }),
367
+ {
368
+ status: 504,
369
+ headers: {
370
+ "content-type": "application/json",
371
+ "x-should-retry": "false",
372
+ [CODEX_TRANSPORT_ERROR_HEADER]: "1",
373
+ },
374
+ },
375
+ );
376
+ }
377
+
121
378
  export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): FetchLike {
122
379
  return async (input, init) => {
123
380
  const ctx = codexRequestStorage.getStore();
@@ -131,7 +388,15 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
131
388
  // URLs whose base already includes /codex (avoids /codex/codex/responses).
132
389
  const rewritten = rawUrl.replace(/(?<!\/codex)\/responses(\b|$)/, "/codex/responses$1");
133
390
 
134
- const attempt = async (auth: CodexTokenSnapshot): Promise<Response> => {
391
+ const policy = resolveCodexResponseTimeoutPolicy(ctx.responseTimeoutPolicy);
392
+ const requestId = ctx.nextRequestId?.() ?? randomUUID();
393
+ const logicalStartedAt = Date.now();
394
+ let transportAttempt = 0;
395
+
396
+ const attempt = async (
397
+ auth: CodexTokenSnapshot,
398
+ authenticationAttempt: number,
399
+ ): Promise<Response> => {
135
400
  const headers = new Headers(init?.headers);
136
401
  headers.set("Authorization", `Bearer ${auth.accessToken}`);
137
402
  if (auth.chatgptAccountId) {
@@ -158,26 +423,90 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
158
423
  // the caller's intent so a non-streaming caller (e.g. the compaction
159
424
  // summarizer) still gets a single JSON Response back.
160
425
  let callerWantsStream = true;
426
+ let model: string | undefined;
161
427
  const nextInit: RequestInit = { ...init, headers };
162
428
  if (typeof init?.body === "string") {
163
429
  try {
164
430
  const parsed = JSON.parse(init.body) as Record<string, unknown>;
165
431
  callerWantsStream = parsed.stream === true;
166
- nextInit.body = JSON.stringify(normalizeCodexRequestBody(parsed, ctx.resolveModel));
432
+ const normalized = normalizeCodexRequestBody(parsed, ctx.resolveModel);
433
+ model = typeof normalized.model === "string" ? normalized.model : undefined;
434
+ nextInit.body = JSON.stringify(normalized);
167
435
  } catch {
168
436
  /* leave unparseable bodies untouched (already copied from init) */
169
437
  }
170
438
  }
439
+ headers.set(
440
+ "Idempotency-Key",
441
+ authenticationAttempt === 0 ? requestId : `${requestId}:auth-${authenticationAttempt}`,
442
+ );
171
443
  if (process.env.CODEX_DEBUG) {
172
- const keys =
173
- typeof nextInit.body === "string"
174
- ? Object.keys(JSON.parse(nextInit.body) as Record<string, unknown>)
175
- : [];
444
+ let keys: string[] = [];
445
+ if (typeof nextInit.body === "string") {
446
+ try {
447
+ keys = Object.keys(JSON.parse(nextInit.body) as Record<string, unknown>);
448
+ } catch {
449
+ /* an unparseable body is already passed through unchanged above */
450
+ }
451
+ }
176
452
  console.error(
177
453
  `[codex-debug] POST ${rewritten} stream=${callerWantsStream} bodyKeys=[${keys.join(",")}]`,
178
454
  );
179
455
  }
180
- const res = await base(rewritten, nextInit);
456
+ let res: Response;
457
+ transportAttempt += 1;
458
+ const audit: RequestAudit = {
459
+ ctx,
460
+ requestId,
461
+ transportAttempt,
462
+ ...(model ? { model } : {}),
463
+ logicalStartedAt,
464
+ attemptStartedAt: Date.now(),
465
+ policy,
466
+ };
467
+ await emitRequestEvent(audit, {
468
+ phase: "started",
469
+ responseObserved: false,
470
+ });
471
+ try {
472
+ res = await fetchBeforeHeaders(base, rewritten, nextInit, audit);
473
+ const upstreamRequestId = providerRequestId(res.headers);
474
+ await emitRequestEvent(audit, {
475
+ phase: "headers",
476
+ responseObserved: true,
477
+ status: res.status,
478
+ ...(upstreamRequestId ? { providerRequestId: upstreamRequestId } : {}),
479
+ });
480
+ const observed = await observedResponse(res, audit, nextInit.signal);
481
+ res = observed;
482
+ } catch (error) {
483
+ if (nextInit.signal?.aborted) {
484
+ await emitRequestEvent(audit, {
485
+ phase: "failed",
486
+ responseObserved: false,
487
+ }).catch(() => undefined);
488
+ throw error;
489
+ }
490
+ const klass = isPreHeadersTimeoutError(error);
491
+ if (!klass) {
492
+ await emitRequestEvent(audit, {
493
+ phase: "failed",
494
+ responseObserved: false,
495
+ });
496
+ throw error;
497
+ }
498
+ // An absent response does not prove that the provider never accepted
499
+ // this operation. Until a provider-specific receipt can prove
500
+ // non-acceptance or resume the same operation, never replay it.
501
+ // Audit persistence must not replace the typed transport timeout.
502
+ await emitRequestEvent(audit, {
503
+ phase: "timed_out",
504
+ responseObserved: false,
505
+ timeoutClass: klass,
506
+ willRetry: false,
507
+ }).catch(() => undefined);
508
+ throw new CodexResponseTimeoutError(klass, requestId, false);
509
+ }
181
510
  // Multi-account P4 (Part A): scrape the usage headers ONCE, before the
182
511
  // OK/!res.ok branch, so the same fire-and-forget read also covers the 429
183
512
  // hard-cap path (an exhausted serving account stamps its own fresh
@@ -216,11 +545,22 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
216
545
  return callerWantsStream ? repairCodexStream(res) : await sseToJsonResponse(res);
217
546
  };
218
547
 
219
- let res = await attempt(await ctx.getToken());
220
- if (res.status === 401) {
221
- res = await attempt(await ctx.refresh()); // single refresh-on-401 retry (spec §1.9)
548
+ try {
549
+ let res = await attempt(await ctx.getToken(), 0);
550
+ if (res.status === 401) {
551
+ res = await attempt(await ctx.refresh(), 1); // single refresh-on-401 retry (spec §1.9)
552
+ }
553
+ return res;
554
+ } catch (error) {
555
+ const timeout = classifyCodexResponseTimeoutError(error);
556
+ if (!timeout) throw error;
557
+ return timeoutErrorResponse({
558
+ timeoutClass: timeout.timeoutClass,
559
+ requestId: timeout.requestId ?? requestId,
560
+ responseObserved: timeout.responseObserved,
561
+ message: timeout.message,
562
+ });
222
563
  }
223
- return res;
224
564
  };
225
565
  }
226
566
 
@@ -292,7 +632,11 @@ async function bufferCodexErrorResponse(res: Response): Promise<Response> {
292
632
  if (errorType === CODEX_USAGE_LIMIT_ERROR_TYPE) {
293
633
  headers.set("x-should-retry", "false");
294
634
  }
295
- return new Response(bodyText, { status: res.status, statusText: res.statusText, headers });
635
+ return new Response(bodyText, {
636
+ status: res.status,
637
+ statusText: res.statusText,
638
+ headers,
639
+ });
296
640
  }
297
641
 
298
642
  /**
@@ -303,14 +647,10 @@ async function bufferCodexErrorResponse(res: Response): Promise<Response> {
303
647
  async function sseToJsonResponse(res: Response): Promise<Response> {
304
648
  const text = await res.text();
305
649
  let final: Record<string, unknown> | null = null;
650
+ let terminalError: Response | null = null;
306
651
  const items: unknown[] = []; // assembled from output_item.done (the codex backend
307
652
  // leaves response.completed.response.output empty and emits the items separately).
308
- for (const block of text.split("\n\n")) {
309
- const data = block
310
- .split("\n")
311
- .filter((l) => l.startsWith("data:"))
312
- .map((l) => l.slice(5).trim())
313
- .join("\n");
653
+ for (const data of sseDataPayloads(text)) {
314
654
  if (!data || data === "[DONE]") {
315
655
  continue;
316
656
  }
@@ -318,21 +658,79 @@ async function sseToJsonResponse(res: Response): Promise<Response> {
318
658
  const ev = JSON.parse(data) as {
319
659
  type?: string;
320
660
  response?: Record<string, unknown>;
661
+ error?: unknown;
662
+ code?: unknown;
663
+ message?: unknown;
664
+ param?: unknown;
321
665
  item?: unknown;
322
666
  };
323
667
  if (ev.type === "response.output_item.done" && ev.item !== undefined) {
324
668
  items.push(ev.item);
325
- } else if (
326
- ev.type === "response.completed" ||
327
- ev.type === "response.done" ||
328
- ev.type === "response.incomplete"
329
- ) {
669
+ } else if (ev.type === "response.failed") {
670
+ terminalError = codexSseFailureResponse(
671
+ res,
672
+ ev.response?.error,
673
+ "response_failed",
674
+ "The Codex response failed",
675
+ {
676
+ eventType: ev.type,
677
+ responseId: ev.response?.id,
678
+ responseStatus: ev.response?.status,
679
+ },
680
+ );
681
+ } else if (ev.type === "error" || ev.type === "response.error") {
682
+ terminalError = codexSseFailureResponse(
683
+ res,
684
+ ev.error ?? ev.response?.error ?? ev,
685
+ "response_error",
686
+ "The Codex response stream reported an error",
687
+ {
688
+ eventType: ev.type,
689
+ responseId: ev.response?.id,
690
+ responseStatus: ev.response?.status,
691
+ },
692
+ );
693
+ } else if (ev.type === "response.incomplete") {
694
+ const details = ev.response?.incomplete_details;
695
+ const reason =
696
+ details && typeof details === "object"
697
+ ? (details as Record<string, unknown>).reason
698
+ : undefined;
699
+ terminalError = codexSseFailureResponse(
700
+ res,
701
+ {
702
+ code: "response_incomplete",
703
+ message:
704
+ typeof reason === "string" && reason.length > 0
705
+ ? `The Codex response was incomplete (${reason})`
706
+ : "The Codex response was incomplete",
707
+ },
708
+ "response_incomplete",
709
+ "The Codex response was incomplete",
710
+ {
711
+ eventType: ev.type,
712
+ responseId: ev.response?.id,
713
+ responseStatus: ev.response?.status,
714
+ },
715
+ );
716
+ } else if (ev.type === "response.completed" || ev.type === "response.done") {
330
717
  final = ev.response ?? null;
331
718
  }
332
719
  } catch {
333
720
  /* ignore non-JSON keepalive lines */
334
721
  }
335
722
  }
723
+ if (terminalError) {
724
+ return terminalError;
725
+ }
726
+ if (!final) {
727
+ return codexSseFailureResponse(
728
+ res,
729
+ null,
730
+ "invalid_sse_terminal",
731
+ "The Codex response stream ended without a terminal response",
732
+ );
733
+ }
336
734
  if (final && items.length > 0) {
337
735
  final = { ...final, output: items }; // prefer the assembled items over an empty output array
338
736
  }
@@ -344,7 +742,269 @@ async function sseToJsonResponse(res: Response): Promise<Response> {
344
742
  const headers = new Headers(res.headers);
345
743
  headers.set("content-type", "application/json");
346
744
  headers.delete("content-length");
347
- return new Response(JSON.stringify(final ?? {}), { status: 200, headers });
745
+ return new Response(JSON.stringify(final), { status: 200, headers });
746
+ }
747
+
748
+ const NON_RETRYABLE_SSE_ERROR_CODES = new Set([
749
+ "bio_policy",
750
+ "context_length_exceeded",
751
+ "cyber_policy",
752
+ "insufficient_quota",
753
+ "invalid_prompt",
754
+ "usage_limit_reached",
755
+ ]);
756
+
757
+ /**
758
+ * Project the data payloads from a complete SSE body. EventSource accepts LF,
759
+ * CRLF, and bare CR line endings; splitting only on `\n\n` can therefore merge
760
+ * a standards-valid terminal failure into the preceding event and silently
761
+ * turn it into `{}`. Preserve the SSE rule that multiple data lines are joined
762
+ * with `\n`, and tolerate a final event without a trailing blank line as the
763
+ * previous transport parser did.
764
+ */
765
+ function sseDataPayloads(text: string): string[] {
766
+ const payloads: string[] = [];
767
+ let dataLines: string[] = [];
768
+ const dispatch = () => {
769
+ if (dataLines.length > 0) payloads.push(dataLines.join("\n"));
770
+ dataLines = [];
771
+ };
772
+
773
+ for (const line of text.split(/\r\n|\r|\n/)) {
774
+ if (line === "") {
775
+ dispatch();
776
+ continue;
777
+ }
778
+ if (line === "data") {
779
+ dataLines.push("");
780
+ continue;
781
+ }
782
+ if (!line.startsWith("data:")) continue;
783
+ const value = line.slice(5);
784
+ dataLines.push(value.startsWith(" ") ? value.slice(1) : value);
785
+ }
786
+ dispatch();
787
+ return payloads;
788
+ }
789
+
790
+ const CODEX_TERMINAL_ERROR_FIELD_MAX_BYTES = 256;
791
+ const CODEX_TERMINAL_ERROR_MESSAGE_MAX_BYTES = 4 * 1024;
792
+ const CODEX_TERMINAL_ERROR_TRUNCATION_MARKER = "… [truncated]";
793
+
794
+ function boundedTerminalErrorField(
795
+ value: unknown,
796
+ maxBytes: number,
797
+ ): { value?: string; truncated: boolean } {
798
+ if (typeof value !== "string") return { truncated: false };
799
+ const encoder = new TextEncoder();
800
+ const encoded = encoder.encode(value);
801
+ if (encoded.byteLength <= maxBytes) return { value, truncated: false };
802
+
803
+ const markerBytes = encoder.encode(CODEX_TERMINAL_ERROR_TRUNCATION_MARKER).byteLength;
804
+ let prefixEnd = Math.max(0, maxBytes - markerBytes);
805
+ while (prefixEnd > 0 && (encoded[prefixEnd]! & 0xc0) === 0x80) {
806
+ prefixEnd -= 1;
807
+ }
808
+ return {
809
+ value: `${new TextDecoder().decode(encoded.subarray(0, prefixEnd))}${CODEX_TERMINAL_ERROR_TRUNCATION_MARKER}`,
810
+ truncated: true,
811
+ };
812
+ }
813
+
814
+ /**
815
+ * Convert a terminal error carried inside an HTTP-200 SSE stream into the
816
+ * ordinary non-2xx JSON error contract expected by the OpenAI SDK. Codex CLI
817
+ * treats the same events as provider failures; returning a successful `{}`
818
+ * loses the actual cause and makes compaction look semantically empty.
819
+ */
820
+ function codexSseFailureResponse(
821
+ source: Response,
822
+ rawError: unknown,
823
+ fallbackCode: string,
824
+ fallbackMessage: string,
825
+ metadata: {
826
+ eventType?: unknown;
827
+ responseId?: unknown;
828
+ responseStatus?: unknown;
829
+ } = {},
830
+ ): Response {
831
+ const projection = codexSseFailureProjection(
832
+ source,
833
+ rawError,
834
+ fallbackCode,
835
+ fallbackMessage,
836
+ metadata,
837
+ );
838
+ return new Response(JSON.stringify({ error: projection.error }), {
839
+ status: projection.status,
840
+ headers: projection.headers,
841
+ });
842
+ }
843
+
844
+ export type CodexSseFailureProjection = {
845
+ status: number;
846
+ error: {
847
+ type: string;
848
+ code: string;
849
+ message: string;
850
+ param?: string;
851
+ event_type?: string;
852
+ response_id?: string;
853
+ response_status?: string;
854
+ diagnostic_truncated?: true;
855
+ };
856
+ headers: Headers;
857
+ };
858
+
859
+ function codexSseFailureProjection(
860
+ source: Response,
861
+ rawError: unknown,
862
+ fallbackCode: string,
863
+ fallbackMessage: string,
864
+ metadata: {
865
+ eventType?: unknown;
866
+ responseId?: unknown;
867
+ responseStatus?: unknown;
868
+ } = {},
869
+ ): CodexSseFailureProjection {
870
+ const record =
871
+ rawError && typeof rawError === "object" && !Array.isArray(rawError)
872
+ ? (rawError as Record<string, unknown>)
873
+ : {};
874
+ const typeField = boundedTerminalErrorField(record.type, CODEX_TERMINAL_ERROR_FIELD_MAX_BYTES);
875
+ const codeField = boundedTerminalErrorField(record.code, CODEX_TERMINAL_ERROR_FIELD_MAX_BYTES);
876
+ const messageField = boundedTerminalErrorField(
877
+ record.message ?? (typeof rawError === "string" ? rawError : undefined),
878
+ CODEX_TERMINAL_ERROR_MESSAGE_MAX_BYTES,
879
+ );
880
+ const paramField = boundedTerminalErrorField(record.param, CODEX_TERMINAL_ERROR_FIELD_MAX_BYTES);
881
+ const eventTypeField = boundedTerminalErrorField(
882
+ metadata.eventType,
883
+ CODEX_TERMINAL_ERROR_FIELD_MAX_BYTES,
884
+ );
885
+ const responseIdField = boundedTerminalErrorField(
886
+ metadata.responseId,
887
+ CODEX_TERMINAL_ERROR_FIELD_MAX_BYTES,
888
+ );
889
+ const responseStatusField = boundedTerminalErrorField(
890
+ metadata.responseStatus,
891
+ CODEX_TERMINAL_ERROR_FIELD_MAX_BYTES,
892
+ );
893
+ const providerType =
894
+ typeField.value === "error" ||
895
+ typeField.value === "response.error" ||
896
+ typeField.value === "response.failed"
897
+ ? undefined
898
+ : typeField.value;
899
+ const code =
900
+ (codeField.value?.length ? codeField.value : undefined) ??
901
+ (providerType?.length ? providerType : undefined) ??
902
+ fallbackCode;
903
+ const diagnosticTruncated =
904
+ typeField.truncated ||
905
+ codeField.truncated ||
906
+ messageField.truncated ||
907
+ paramField.truncated ||
908
+ eventTypeField.truncated ||
909
+ responseIdField.truncated ||
910
+ responseStatusField.truncated ||
911
+ Object.keys(record).some((key) => !["type", "code", "message", "param"].includes(key)) ||
912
+ (rawError !== null &&
913
+ rawError !== undefined &&
914
+ typeof rawError !== "string" &&
915
+ (typeof rawError !== "object" || Array.isArray(rawError)));
916
+ const error: CodexSseFailureProjection["error"] = {
917
+ type: providerType?.length ? providerType : code,
918
+ code,
919
+ message: messageField.value?.length ? messageField.value : fallbackMessage,
920
+ ...(paramField.value?.length ? { param: paramField.value } : {}),
921
+ ...(eventTypeField.value?.length ? { event_type: eventTypeField.value } : {}),
922
+ ...(responseIdField.value?.length ? { response_id: responseIdField.value } : {}),
923
+ ...(responseStatusField.value?.length ? { response_status: responseStatusField.value } : {}),
924
+ ...(diagnosticTruncated ? { diagnostic_truncated: true } : {}),
925
+ };
926
+ const status =
927
+ code === "rate_limit_exceeded" ||
928
+ code === "usage_limit_reached" ||
929
+ code === "insufficient_quota"
930
+ ? 429
931
+ : NON_RETRYABLE_SSE_ERROR_CODES.has(code)
932
+ ? 400
933
+ : 502;
934
+ const headers = new Headers(source.headers);
935
+ headers.set("content-type", "application/json");
936
+ headers.set(CODEX_TRANSPORT_ERROR_HEADER, "1");
937
+ // A terminal event means the provider already accepted and completed this
938
+ // request. Never let the OpenAI SDK replay it merely because we synthesized
939
+ // a non-2xx response to preserve the terminal failure.
940
+ headers.set("x-should-retry", "false");
941
+ headers.delete("content-length");
942
+ headers.delete("content-encoding");
943
+ return { status, error, headers };
944
+ }
945
+
946
+ /**
947
+ * A provider terminal carried inside an accepted HTTP-200 stream. The OpenAI
948
+ * SDK cannot turn that late terminal into a non-2xx APIError because headers
949
+ * have already been accepted, so the body transform throws this equivalent
950
+ * bounded shape. Provider-supplied message/param text is intentionally absent:
951
+ * the worker may persist Error.message, while identifiers/classifications are
952
+ * sufficient for retry, compaction, and incident diagnostics.
953
+ */
954
+ export class CodexStreamingTerminalError extends Error {
955
+ readonly status: number;
956
+ readonly code: string;
957
+ readonly type: string;
958
+ readonly eventType?: string;
959
+ readonly responseId?: string;
960
+ readonly responseStatus?: string;
961
+ readonly headers: Headers;
962
+ readonly error: Record<string, unknown>;
963
+
964
+ constructor(projection: CodexSseFailureProjection, publicMessage: string) {
965
+ super(publicMessage);
966
+ this.name = "CodexStreamingTerminalError";
967
+ this.status = projection.status;
968
+ this.code = projection.error.code;
969
+ this.type = projection.error.type;
970
+ if (projection.error.event_type !== undefined) {
971
+ this.eventType = projection.error.event_type;
972
+ }
973
+ if (projection.error.response_id !== undefined) {
974
+ this.responseId = projection.error.response_id;
975
+ }
976
+ if (projection.error.response_status !== undefined) {
977
+ this.responseStatus = projection.error.response_status;
978
+ }
979
+ this.headers = projection.headers;
980
+ this.error = {
981
+ type: projection.error.type,
982
+ code: projection.error.code,
983
+ ...(projection.error.event_type ? { event_type: projection.error.event_type } : {}),
984
+ ...(projection.error.response_id ? { response_id: projection.error.response_id } : {}),
985
+ ...(projection.error.response_status
986
+ ? { response_status: projection.error.response_status }
987
+ : {}),
988
+ ...(projection.error.diagnostic_truncated ? { diagnostic_truncated: true } : {}),
989
+ };
990
+ }
991
+ }
992
+
993
+ function codexSseFailureError(
994
+ source: Response,
995
+ rawError: unknown,
996
+ fallbackCode: string,
997
+ publicMessage: string,
998
+ metadata: {
999
+ eventType?: unknown;
1000
+ responseId?: unknown;
1001
+ responseStatus?: unknown;
1002
+ } = {},
1003
+ ): CodexStreamingTerminalError {
1004
+ return new CodexStreamingTerminalError(
1005
+ codexSseFailureProjection(source, rawError, fallbackCode, publicMessage, metadata),
1006
+ publicMessage,
1007
+ );
348
1008
  }
349
1009
 
350
1010
  /**
@@ -354,66 +1014,223 @@ async function sseToJsonResponse(res: Response): Promise<Response> {
354
1014
  */
355
1015
  function repairCodexStream(res: Response): Response {
356
1016
  if (!res.body) {
357
- return res;
1017
+ const error = codexSseFailureError(
1018
+ res,
1019
+ null,
1020
+ "invalid_sse_terminal",
1021
+ "The Codex response stream ended without a terminal response",
1022
+ );
1023
+ const body = new ReadableStream<Uint8Array>({
1024
+ start(controller) {
1025
+ controller.error(error);
1026
+ },
1027
+ });
1028
+ const headers = new Headers(res.headers);
1029
+ headers.delete("content-length");
1030
+ return new Response(body, {
1031
+ status: res.status,
1032
+ statusText: res.statusText,
1033
+ headers,
1034
+ });
358
1035
  }
359
1036
  const items: unknown[] = [];
360
1037
  const decoder = new TextDecoder();
361
1038
  const encoder = new TextEncoder();
362
1039
  let buffer = "";
1040
+ let successfulTerminalSeen = false;
1041
+ const emitCompleteBlocks = (
1042
+ controller: TransformStreamDefaultController<Uint8Array>,
1043
+ final: boolean,
1044
+ ) => {
1045
+ let boundary = findSseBlockBoundary(buffer, final);
1046
+ while (boundary) {
1047
+ const block = buffer.slice(0, boundary.start);
1048
+ const separator = buffer.slice(boundary.start, boundary.end);
1049
+ buffer = buffer.slice(boundary.end);
1050
+ const patched = patchSseBlock(block, items, res);
1051
+ successfulTerminalSeen ||= patched.successfulTerminal;
1052
+ controller.enqueue(encoder.encode(`${patched.block}${separator}`));
1053
+ boundary = findSseBlockBoundary(buffer, final);
1054
+ }
1055
+ };
363
1056
  const transform = new TransformStream<Uint8Array, Uint8Array>({
364
1057
  transform(chunk, controller) {
365
1058
  buffer += decoder.decode(chunk, { stream: true });
366
- let idx = buffer.indexOf("\n\n");
367
- while (idx !== -1) {
368
- const block = buffer.slice(0, idx);
369
- buffer = buffer.slice(idx + 2);
370
- controller.enqueue(encoder.encode(`${patchSseBlock(block, items)}\n\n`));
371
- idx = buffer.indexOf("\n\n");
372
- }
1059
+ emitCompleteBlocks(controller, false);
373
1060
  },
374
1061
  flush(controller) {
1062
+ buffer += decoder.decode();
1063
+ emitCompleteBlocks(controller, true);
375
1064
  if (buffer.length > 0) {
376
- controller.enqueue(encoder.encode(patchSseBlock(buffer, items)));
1065
+ const patched = patchSseBlock(buffer, items, res);
1066
+ successfulTerminalSeen ||= patched.successfulTerminal;
1067
+ controller.enqueue(encoder.encode(patched.block));
1068
+ buffer = "";
1069
+ }
1070
+ if (!successfulTerminalSeen) {
1071
+ throw codexSseFailureError(
1072
+ res,
1073
+ null,
1074
+ "invalid_sse_terminal",
1075
+ "The Codex response stream ended without a terminal response",
1076
+ );
377
1077
  }
378
1078
  },
379
1079
  });
380
1080
  const headers = new Headers(res.headers);
381
1081
  headers.delete("content-length");
382
- return new Response(res.body.pipeThrough(transform), { status: res.status, headers });
1082
+ return new Response(res.body.pipeThrough(transform), {
1083
+ status: res.status,
1084
+ headers,
1085
+ });
1086
+ }
1087
+
1088
+ type SseBlockBoundary = { start: number; end: number };
1089
+
1090
+ /**
1091
+ * Find two consecutive SSE line endings without misreading one CRLF as a bare
1092
+ * CR followed by a bare LF. A trailing CR is intentionally held until the next
1093
+ * chunk (or final flush), because only then can it be distinguished from the
1094
+ * first byte of CRLF.
1095
+ */
1096
+ function findSseBlockBoundary(value: string, final: boolean): SseBlockBoundary | null {
1097
+ for (let index = 0; index < value.length; index += 1) {
1098
+ const firstEnd = sseLineEndingEnd(value, index, final);
1099
+ if (firstEnd === null) continue;
1100
+ const secondEnd = sseLineEndingEnd(value, firstEnd, final);
1101
+ if (secondEnd !== null) {
1102
+ return { start: index, end: secondEnd };
1103
+ }
1104
+ index = firstEnd - 1;
1105
+ }
1106
+ return null;
383
1107
  }
384
1108
 
385
- /** Collect output_item.done items (mutating `items`); rewrite the terminal event's empty output. */
386
- function patchSseBlock(block: string, items: unknown[]): string {
387
- const lines = block.split("\n");
1109
+ function sseLineEndingEnd(value: string, index: number, final: boolean): number | null {
1110
+ const current = value[index];
1111
+ if (current === "\n") return index + 1;
1112
+ if (current !== "\r") return null;
1113
+ if (index + 1 < value.length) {
1114
+ return value[index + 1] === "\n" ? index + 2 : index + 1;
1115
+ }
1116
+ return final ? index + 1 : null;
1117
+ }
1118
+
1119
+ type PatchedSseBlock = { block: string; successfulTerminal: boolean };
1120
+
1121
+ /**
1122
+ * Collect output_item.done items and rewrite only a successful terminal event.
1123
+ * Failed/error/incomplete terminals throw before their provider message can be
1124
+ * exposed to Agents as an ordinary response_done event.
1125
+ */
1126
+ function patchSseBlock(block: string, items: unknown[], source: Response): PatchedSseBlock {
1127
+ const lines = block.split(/\r\n|\r|\n/);
388
1128
  const dataStr = lines
389
1129
  .filter((l) => l.startsWith("data:"))
390
1130
  .map((l) => l.slice(5).trim())
391
1131
  .join("\n");
392
1132
  if (!dataStr || dataStr === "[DONE]") {
393
- return block;
1133
+ return { block, successfulTerminal: false };
394
1134
  }
395
- let ev: { type?: string; item?: unknown; response?: Record<string, unknown> };
1135
+ let ev: {
1136
+ type?: string;
1137
+ item?: unknown;
1138
+ response?: Record<string, unknown>;
1139
+ error?: unknown;
1140
+ code?: unknown;
1141
+ message?: unknown;
1142
+ param?: unknown;
1143
+ };
396
1144
  try {
397
1145
  ev = JSON.parse(dataStr);
398
1146
  } catch {
399
- return block;
1147
+ return { block, successfulTerminal: false };
400
1148
  }
401
1149
  if (ev.type === "response.output_item.done" && ev.item !== undefined) {
402
1150
  items.push(ev.item);
403
- return block;
1151
+ return { block, successfulTerminal: false };
1152
+ }
1153
+ if (ev.type === "response.failed") {
1154
+ throw codexSseFailureError(
1155
+ source,
1156
+ ev.response?.error,
1157
+ "response_failed",
1158
+ "The Codex response failed",
1159
+ {
1160
+ eventType: ev.type,
1161
+ responseId: ev.response?.id,
1162
+ responseStatus: ev.response?.status,
1163
+ },
1164
+ );
1165
+ }
1166
+ if (ev.type === "error" || ev.type === "response.error") {
1167
+ throw codexSseFailureError(
1168
+ source,
1169
+ ev.error ?? ev.response?.error ?? ev,
1170
+ "response_error",
1171
+ "The Codex response stream reported an error",
1172
+ {
1173
+ eventType: ev.type,
1174
+ responseId: ev.response?.id,
1175
+ responseStatus: ev.response?.status,
1176
+ },
1177
+ );
1178
+ }
1179
+ if (ev.type === "response.incomplete") {
1180
+ const details = ev.response?.incomplete_details;
1181
+ const reason =
1182
+ details && typeof details === "object"
1183
+ ? (details as Record<string, unknown>).reason
1184
+ : undefined;
1185
+ throw codexSseFailureError(
1186
+ source,
1187
+ {
1188
+ code: "response_incomplete",
1189
+ message:
1190
+ typeof reason === "string" && reason.length > 0
1191
+ ? `The Codex response was incomplete (${reason})`
1192
+ : "The Codex response was incomplete",
1193
+ },
1194
+ "response_incomplete",
1195
+ "The Codex response was incomplete",
1196
+ {
1197
+ eventType: ev.type,
1198
+ responseId: ev.response?.id,
1199
+ responseStatus: ev.response?.status,
1200
+ },
1201
+ );
404
1202
  }
405
- if (
406
- (ev.type === "response.completed" ||
407
- ev.type === "response.done" ||
408
- ev.type === "response.incomplete") &&
409
- ev.response
410
- ) {
1203
+ if ((ev.type === "response.completed" || ev.type === "response.done") && ev.response) {
1204
+ if (
1205
+ ev.response.status === "failed" ||
1206
+ ev.response.status === "incomplete" ||
1207
+ (ev.response.error !== null && ev.response.error !== undefined)
1208
+ ) {
1209
+ throw codexSseFailureError(
1210
+ source,
1211
+ ev.response.error,
1212
+ ev.response.status === "incomplete" ? "response_incomplete" : "response_failed",
1213
+ ev.response.status === "incomplete"
1214
+ ? "The Codex response was incomplete"
1215
+ : "The Codex response failed",
1216
+ {
1217
+ eventType: ev.type,
1218
+ responseId: ev.response.id,
1219
+ responseStatus: ev.response.status,
1220
+ },
1221
+ );
1222
+ }
411
1223
  const out = ev.response.output;
412
1224
  if ((!Array.isArray(out) || out.length === 0) && items.length > 0) {
413
1225
  ev.response = { ...ev.response, output: items };
414
1226
  const nonData = lines.filter((l) => !l.startsWith("data:"));
415
- return [...nonData, `data: ${JSON.stringify(ev)}`].join("\n");
1227
+ const lineEnding = block.match(/\r\n|\r|\n/)?.[0] ?? "\n";
1228
+ return {
1229
+ block: [...nonData, `data: ${JSON.stringify(ev)}`].join(lineEnding),
1230
+ successfulTerminal: true,
1231
+ };
416
1232
  }
1233
+ return { block, successfulTerminal: true };
417
1234
  }
418
- return block;
1235
+ return { block, successfulTerminal: false };
419
1236
  }