@opengeni/codex 0.2.13 → 0.2.15

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.
@@ -1,4 +1,4 @@
1
- import type { FetchLike } from "./fetch";
1
+ import type { FetchLike } from "./fetch.js";
2
2
  /**
3
3
  * Maps connector tool names to a Responses-API-legal charset and back. One
4
4
  * instance per codex_apps transport (i.e. per turn): tools/list populates it,
@@ -18,7 +18,7 @@
18
18
  * runtime request seam and the database history boundary call the same pure
19
19
  * function, so replayed conversation truth is identical to live model input.
20
20
  */
21
- export { MODEL_TOOL_OUTPUT_OVERSIZED_IMAGE_CARD_DATA_URL } from "./oversized-image-card";
21
+ export { MODEL_TOOL_OUTPUT_OVERSIZED_IMAGE_CARD_DATA_URL } from "./oversized-image-card.js";
22
22
  export type ModelHistoryItem = Record<string, unknown>;
23
23
  export declare const CODEX_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS = 10000;
24
24
  export declare const CODEX_TOOL_OUTPUT_SERIALIZATION_ALLOWANCE = 1.2;
@@ -1,5 +1,11 @@
1
1
  /** Mutates a parsed Responses request body in place and returns it. Pure + synchronous + unit-testable. */
2
2
  export declare function normalizeCodexRequestBody(body: Record<string, unknown>, resolveModel: (slug: string) => string): Record<string, unknown>;
3
+ /**
4
+ * Copy-on-write form for model clients that may retain converted input items.
5
+ * Only records the mutable normalizer can touch are copied; large content,
6
+ * tools, and unchanged protocol items remain shared immutable values.
7
+ */
8
+ export declare function normalizedCodexRequestBody(body: Readonly<Record<string, unknown>>, resolveModel: (slug: string) => string): Record<string, unknown>;
3
9
  /**
4
10
  * Build a longest-prefix model resolver. Catalog slugs come from GET /models
5
11
  * (api-client.ts). One leading `namespace/` segment is stripped first; an
@@ -1,7 +1,7 @@
1
- import { type CodexAuthHeaders } from "./api-client";
2
- import { CODEX_REALTIME_MODEL, CODEX_REALTIME_VERSION } from "./constants";
3
- import type { CodexFetch } from "./device-code";
4
- import { type CodexRealtimeInitialItem } from "./realtime-v3";
1
+ import { type CodexAuthHeaders } from "./api-client.js";
2
+ import { CODEX_REALTIME_MODEL, CODEX_REALTIME_VERSION } from "./constants.js";
3
+ import type { CodexFetch } from "./device-code.js";
4
+ import { type CodexRealtimeInitialItem } from "./realtime-v3.js";
5
5
  export declare const CODEX_REALTIME_VOICES: readonly ["juniper", "maple", "spruce", "ember", "vale", "breeze", "arbor", "sol", "cove"];
6
6
  export type CodexRealtimeVoice = (typeof CODEX_REALTIME_VOICES)[number];
7
7
  export type CodexRealtimeCallInput = {
package/dist/refresh.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CodexFetch } from "./device-code";
1
+ import type { CodexFetch } from "./device-code.js";
2
2
  /** Permanent — the workspace must reconnect (status => needs_relogin). */
3
3
  export declare class CodexReloginRequired extends Error {
4
4
  constructor(message: string);
@@ -1,4 +1,4 @@
1
- import type { CodexResponseTimeoutClass, CodexResponseTimeoutPolicy } from "./request-context";
1
+ import type { CodexResponseTimeoutClass, CodexResponseTimeoutPolicy } from "./request-context.js";
2
2
  export declare const CODEX_RESPONSE_TIMEOUT_ERROR_TYPE = "opengeni_codex_response_timeout";
3
3
  export declare const DEFAULT_CODEX_RESPONSE_TIMEOUT_POLICY: CodexResponseTimeoutPolicy;
4
4
  export declare function resolveCodexResponseTimeoutPolicy(override: Partial<CodexResponseTimeoutPolicy> | undefined): CodexResponseTimeoutPolicy;
@@ -1,4 +1,4 @@
1
- import { type CodexRateLimitResetCreditsSummary } from "./reset-credits";
1
+ import { type CodexRateLimitResetCreditsSummary } from "./reset-credits.js";
2
2
  /** The 5-hour (primary) window's `limit_window_seconds`. */
3
3
  export declare const CODEX_FIVE_HOUR_WINDOW_SECONDS = 18000;
4
4
  /** The weekly (secondary) window's `limit_window_seconds`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/codex",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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,6 +41,7 @@
41
41
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
42
42
  },
43
43
  "dependencies": {
44
+ "@opengeni/network": "^0.2.2",
44
45
  "zod": "^4.2.1"
45
46
  },
46
47
  "engines": {
package/src/fetch.ts CHANGED
@@ -39,6 +39,20 @@ export type FetchLike = (input: string | URL | Request, init?: RequestInit) => P
39
39
  * error that happened during the same Codex turn.
40
40
  */
41
41
  export const CODEX_TRANSPORT_ERROR_HEADER = "x-opengeni-codex-transport-error";
42
+ /** Internal transport handoff; always removed before network I/O. */
43
+ export const CODEX_REQUEST_BODY_NORMALIZED_HEADER = "x-opengeni-request-body-normalized";
44
+ const REPLAYABLE_REQUEST_BODY_FACTORY = Symbol.for("opengeni.replayable-request-body-factory");
45
+
46
+ type ReplayableRequestInit = RequestInit & {
47
+ [REPLAYABLE_REQUEST_BODY_FACTORY]?: () => ReadableStream<Uint8Array>;
48
+ };
49
+ /** Internal resolved-model handoff; always removed before network I/O. */
50
+ export const CODEX_REQUEST_MODEL_HEADER = "x-opengeni-request-model";
51
+ /** Internal durable request-identity handoff; always removed before network I/O. */
52
+ export const CODEX_REQUEST_ID_HEADER = "x-opengeni-request-id";
53
+ /** Internal original response-mode handoff; always removed before network I/O. */
54
+ export const CODEX_REQUEST_CALLER_STREAM_HEADER = "x-opengeni-request-caller-stream";
55
+ const MAX_CODEX_ERROR_BODY_BYTES = 64 * 1024;
42
56
 
43
57
  function headersCarryCodexTransportMarker(headers: unknown): boolean {
44
58
  if (!headers || typeof headers !== "object") return false;
@@ -357,16 +371,22 @@ async function observedResponse(
357
371
  controller.close();
358
372
  return;
359
373
  }
360
- armIdle();
361
374
  if (!firstByte) {
362
375
  firstByte = true;
376
+ // Deliver the provider byte before durable audit I/O. Audit latency
377
+ // is not provider silence and must not manufacture an idle timeout.
378
+ if (idleTimer) clearTimeout(idleTimer);
379
+ controller.enqueue(chunk.value);
363
380
  await emitRequestEvent(audit, {
364
381
  phase: "first_byte",
365
382
  responseObserved: true,
366
383
  status: res.status,
367
384
  ...(requestId ? { providerRequestId: requestId } : {}),
368
385
  });
386
+ if (!terminal) armIdle();
387
+ return;
369
388
  }
389
+ armIdle();
370
390
  controller.enqueue(chunk.value);
371
391
  } catch (error) {
372
392
  if (terminal) return;
@@ -446,7 +466,8 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
446
466
  const rewritten = rawUrl.replace(/(?<!\/codex)\/responses(\b|$)/, "/codex/responses$1");
447
467
 
448
468
  const policy = resolveCodexResponseTimeoutPolicy(ctx.responseTimeoutPolicy);
449
- const requestId = ctx.nextRequestId?.() ?? randomUUID();
469
+ const handedRequestId = new Headers(init?.headers).get(CODEX_REQUEST_ID_HEADER);
470
+ const requestId = handedRequestId ?? ctx.nextRequestId?.() ?? randomUUID();
450
471
  const logicalStartedAt = Date.now();
451
472
  let transportAttempt = 0;
452
473
 
@@ -455,6 +476,13 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
455
476
  authenticationAttempt: number,
456
477
  ): Promise<Response> => {
457
478
  const headers = new Headers(init?.headers);
479
+ const bodyAlreadyNormalized = headers.get(CODEX_REQUEST_BODY_NORMALIZED_HEADER) === "1";
480
+ const normalizedModel = headers.get(CODEX_REQUEST_MODEL_HEADER) ?? undefined;
481
+ const normalizedCallerStream = headers.get(CODEX_REQUEST_CALLER_STREAM_HEADER);
482
+ headers.delete(CODEX_REQUEST_BODY_NORMALIZED_HEADER);
483
+ headers.delete(CODEX_REQUEST_MODEL_HEADER);
484
+ headers.delete(CODEX_REQUEST_ID_HEADER);
485
+ headers.delete(CODEX_REQUEST_CALLER_STREAM_HEADER);
458
486
  headers.set("Authorization", `Bearer ${auth.accessToken}`);
459
487
  if (auth.chatgptAccountId) {
460
488
  headers.set("ChatGPT-Account-ID", auth.chatgptAccountId);
@@ -486,13 +514,20 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
486
514
  }
487
515
 
488
516
  // The backend is streaming-only; force stream=true on the wire but remember
489
- // the caller's intent so a non-streaming caller (e.g. the compaction
490
- // summarizer) still gets a single JSON Response back.
491
- let callerWantsStream = true;
492
- let model: string | undefined;
517
+ // the caller's intent for legacy/unowned non-streaming consumers. The owned
518
+ // compaction path consumes the same streaming model boundary as normal turns.
519
+ let callerWantsStream = bodyAlreadyNormalized ? normalizedCallerStream !== "0" : true;
520
+ let model: string | undefined = normalizedModel;
493
521
  let requestOpaqueArtifacts: string[] = [];
494
- const nextInit: RequestInit = { ...init, headers };
495
- if (typeof init?.body === "string") {
522
+ const replayableBodyFactory = (init as ReplayableRequestInit | undefined)?.[
523
+ REPLAYABLE_REQUEST_BODY_FACTORY
524
+ ];
525
+ const nextInit: RequestInit = {
526
+ ...init,
527
+ headers,
528
+ ...(replayableBodyFactory ? { body: replayableBodyFactory() } : {}),
529
+ };
530
+ if (!bodyAlreadyNormalized && typeof init?.body === "string") {
496
531
  try {
497
532
  const parsed = JSON.parse(init.body) as Record<string, unknown>;
498
533
  callerWantsStream = parsed.stream === true;
@@ -501,10 +536,16 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
501
536
  nextInit.body = JSON.stringify(normalized);
502
537
  requestOpaqueArtifacts = opaqueProviderArtifactFingerprints(normalized.input);
503
538
  } catch {
504
- /* leave unparseable bodies untouched (already copied from init) */
539
+ // This is the final request-policy boundary for the strict Responses
540
+ // endpoint. Never let malformed bytes bypass the reviewed policy.
541
+ throw new Error("Model request could not be prepared");
505
542
  }
543
+ } else if (!bodyAlreadyNormalized) {
544
+ throw new Error("Model request could not be prepared");
545
+ }
546
+ if (!bodyAlreadyNormalized) {
547
+ ctx.onRequestOpaqueArtifacts?.({ requestId, fingerprints: requestOpaqueArtifacts });
506
548
  }
507
- ctx.onRequestOpaqueArtifacts?.({ requestId, fingerprints: requestOpaqueArtifacts });
508
549
  headers.set(
509
550
  "Idempotency-Key",
510
551
  authenticationAttempt === 0 ? requestId : `${requestId}:auth-${authenticationAttempt}`,
@@ -590,11 +631,10 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
590
631
  status: res.status,
591
632
  });
592
633
  }
593
- // The codex backend leaves the terminal event's response.output empty and
594
- // delivers the assistant items via output_item.done events instead. The
595
- // @openai/agents parser (streaming AND non-streaming) reads response.output,
596
- // so we must reconstruct it: collapse to one JSON Response for a non-streaming
597
- // caller, or repair the live stream's terminal event for a streaming caller.
634
+ // The backend leaves terminal response.output empty and delivers assistant
635
+ // items through output_item.done. The typed model reducer reconstructs normal
636
+ // streaming calls; only the legacy non-streaming transport fallback collapses
637
+ // SSE into one JSON response here.
598
638
  if (!res.ok) {
599
639
  // Buffer the error body once and re-emit it as a concrete JSON Response.
600
640
  // A streaming responses request whose error body is left as the raw
@@ -607,7 +647,7 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
607
647
  // so the SDK does not burn its retry budget on a limit that won't lift.
608
648
  return await bufferCodexErrorResponse(res);
609
649
  }
610
- return callerWantsStream ? repairCodexStream(res) : await sseToJsonResponse(res);
650
+ return callerWantsStream ? validateCodexStream(res) : await sseToJsonResponse(res);
611
651
  };
612
652
 
613
653
  try {
@@ -681,29 +721,88 @@ export function classifyCodexUsageLimitError(error: unknown): CodexUsageLimitInf
681
721
  * Reading the body here also drains the socket of a discarded 401 (no leak).
682
722
  */
683
723
  async function bufferCodexErrorResponse(res: Response): Promise<Response> {
684
- const bodyText = await res.text().catch(() => "");
724
+ const { text: bodyText, truncated } = await readBoundedResponseText(
725
+ res,
726
+ MAX_CODEX_ERROR_BODY_BYTES,
727
+ );
685
728
  const headers = new Headers(res.headers);
686
729
  headers.set("content-type", "application/json");
687
730
  headers.set(CODEX_TRANSPORT_ERROR_HEADER, "1");
688
731
  headers.delete("content-length"); // body re-serialized
689
732
  headers.delete("content-encoding"); // text() already decoded any gzip
690
733
  let errorType: string | undefined;
734
+ let responseBody = bodyText;
691
735
  try {
692
736
  const parsed = JSON.parse(bodyText) as { error?: { type?: unknown } };
693
737
  errorType = typeof parsed.error?.type === "string" ? parsed.error.type : undefined;
694
738
  } catch {
695
739
  /* non-JSON error body — leave as-is, no retry-header override */
696
740
  }
741
+ if (truncated) {
742
+ responseBody = JSON.stringify({
743
+ error: {
744
+ type: "provider_error_body_too_large",
745
+ code: "provider_error_body_too_large",
746
+ message: `The provider returned an error body larger than ${MAX_CODEX_ERROR_BODY_BYTES} bytes`,
747
+ },
748
+ });
749
+ headers.set("x-opengeni-provider-error-truncated", "1");
750
+ }
697
751
  if (errorType === CODEX_USAGE_LIMIT_ERROR_TYPE) {
698
752
  headers.set("x-should-retry", "false");
699
753
  }
700
- return new Response(bodyText, {
754
+ return new Response(responseBody, {
701
755
  status: res.status,
702
756
  statusText: res.statusText,
703
757
  headers,
704
758
  });
705
759
  }
706
760
 
761
+ async function readBoundedResponseText(
762
+ response: Response,
763
+ maxBytes: number,
764
+ ): Promise<{ text: string; truncated: boolean }> {
765
+ if (!response.body) return { text: "", truncated: false };
766
+ const reader = response.body.getReader();
767
+ const decoder = new TextDecoder();
768
+ const parts: string[] = [];
769
+ let bytes = 0;
770
+ let truncated = false;
771
+ try {
772
+ while (bytes < maxBytes) {
773
+ const next = await reader.read();
774
+ if (next.done) {
775
+ parts.push(decoder.decode());
776
+ return { text: parts.join(""), truncated };
777
+ }
778
+ const remaining = maxBytes - bytes;
779
+ const accepted =
780
+ next.value.byteLength > remaining ? next.value.subarray(0, remaining) : next.value;
781
+ bytes += accepted.byteLength;
782
+ parts.push(decoder.decode(accepted, { stream: true }));
783
+ if (accepted.byteLength !== next.value.byteLength) {
784
+ truncated = true;
785
+ break;
786
+ }
787
+ if (bytes >= maxBytes) {
788
+ // Reaching the hard cap is sufficient to classify the body as
789
+ // oversized. Probing for one more chunk can wait forever when an
790
+ // upstream producer stops emitting without closing its stream.
791
+ truncated = true;
792
+ break;
793
+ }
794
+ }
795
+ } catch {
796
+ truncated = true;
797
+ } finally {
798
+ // Cancellation is advisory cleanup. Some Fetch/Streams implementations do
799
+ // not settle cancel() until the producer exits; never let an oversized
800
+ // provider error hold the request open behind that implementation detail.
801
+ if (truncated) void reader.cancel().catch(() => undefined);
802
+ }
803
+ return { text: parts.join(""), truncated };
804
+ }
805
+
707
806
  /**
708
807
  * Collapse a Responses SSE stream into the single JSON Response object a
709
808
  * non-streaming `responses.create` caller expects: the terminal response.*
@@ -1074,11 +1173,12 @@ function codexSseFailureError(
1074
1173
  }
1075
1174
 
1076
1175
  /**
1077
- * Repair a live Responses SSE stream for the @openai/agents streaming parser: pass
1078
- * every event through unchanged, collect the output_item.done items, and inject
1079
- * them into the terminal event's empty `output` so the parser sees the message.
1176
+ * Preserve a live Responses SSE stream byte-for-byte while translating only
1177
+ * provider-specific terminal failures into typed transport errors. Successful
1178
+ * output reconstruction belongs to the model reducer, so this layer retains no
1179
+ * duplicate output-item graph.
1080
1180
  */
1081
- function repairCodexStream(res: Response): Response {
1181
+ function validateCodexStream(res: Response): Response {
1082
1182
  if (!res.body) {
1083
1183
  const error = codexSseFailureError(
1084
1184
  res,
@@ -1099,7 +1199,6 @@ function repairCodexStream(res: Response): Response {
1099
1199
  headers,
1100
1200
  });
1101
1201
  }
1102
- const items: unknown[] = [];
1103
1202
  const decoder = new TextDecoder();
1104
1203
  const encoder = new TextEncoder();
1105
1204
  let buffer = "";
@@ -1113,9 +1212,8 @@ function repairCodexStream(res: Response): Response {
1113
1212
  const block = buffer.slice(0, boundary.start);
1114
1213
  const separator = buffer.slice(boundary.start, boundary.end);
1115
1214
  buffer = buffer.slice(boundary.end);
1116
- const patched = patchSseBlock(block, items, res);
1117
- successfulTerminalSeen ||= patched.successfulTerminal;
1118
- controller.enqueue(encoder.encode(`${patched.block}${separator}`));
1215
+ successfulTerminalSeen ||= inspectCodexSseBlock(block, res);
1216
+ controller.enqueue(encoder.encode(`${block}${separator}`));
1119
1217
  boundary = findSseBlockBoundary(buffer, final);
1120
1218
  }
1121
1219
  };
@@ -1128,9 +1226,8 @@ function repairCodexStream(res: Response): Response {
1128
1226
  buffer += decoder.decode();
1129
1227
  emitCompleteBlocks(controller, true);
1130
1228
  if (buffer.length > 0) {
1131
- const patched = patchSseBlock(buffer, items, res);
1132
- successfulTerminalSeen ||= patched.successfulTerminal;
1133
- controller.enqueue(encoder.encode(patched.block));
1229
+ successfulTerminalSeen ||= inspectCodexSseBlock(buffer, res);
1230
+ controller.enqueue(encoder.encode(buffer));
1134
1231
  buffer = "";
1135
1232
  }
1136
1233
  if (!successfulTerminalSeen) {
@@ -1182,21 +1279,31 @@ function sseLineEndingEnd(value: string, index: number, final: boolean): number
1182
1279
  return final ? index + 1 : null;
1183
1280
  }
1184
1281
 
1185
- type PatchedSseBlock = { block: string; successfulTerminal: boolean };
1282
+ const CODEX_TERMINAL_TYPE_HINTS = [
1283
+ '"response.completed"',
1284
+ '"response.done"',
1285
+ '"response.failed"',
1286
+ '"response.incomplete"',
1287
+ '"response.error"',
1288
+ '"error"',
1289
+ ] as const;
1186
1290
 
1187
1291
  /**
1188
- * Collect output_item.done items and rewrite only a successful terminal event.
1189
- * Failed/error/incomplete terminals throw before their provider message can be
1190
- * exposed to Agents as an ordinary response_done event.
1292
+ * Parse only blocks that can be terminal. Ordinary deltas and output items pass
1293
+ * without object allocation; failed/error/incomplete terminals throw before the
1294
+ * model can mistake them for an ordinary response_done event.
1191
1295
  */
1192
- function patchSseBlock(block: string, items: unknown[], source: Response): PatchedSseBlock {
1296
+ function inspectCodexSseBlock(block: string, source: Response): boolean {
1193
1297
  const lines = block.split(/\r\n|\r|\n/);
1194
1298
  const dataStr = lines
1195
1299
  .filter((l) => l.startsWith("data:"))
1196
1300
  .map((l) => l.slice(5).trim())
1197
1301
  .join("\n");
1198
1302
  if (!dataStr || dataStr === "[DONE]") {
1199
- return { block, successfulTerminal: false };
1303
+ return false;
1304
+ }
1305
+ if (!CODEX_TERMINAL_TYPE_HINTS.some((terminalType) => dataStr.includes(terminalType))) {
1306
+ return false;
1200
1307
  }
1201
1308
  let ev: {
1202
1309
  type?: string;
@@ -1210,11 +1317,7 @@ function patchSseBlock(block: string, items: unknown[], source: Response): Patch
1210
1317
  try {
1211
1318
  ev = JSON.parse(dataStr);
1212
1319
  } catch {
1213
- return { block, successfulTerminal: false };
1214
- }
1215
- if (ev.type === "response.output_item.done" && ev.item !== undefined) {
1216
- items.push(ev.item);
1217
- return { block, successfulTerminal: false };
1320
+ return false;
1218
1321
  }
1219
1322
  if (ev.type === "response.failed") {
1220
1323
  throw codexSseFailureError(
@@ -1268,8 +1371,7 @@ function patchSseBlock(block: string, items: unknown[], source: Response): Patch
1268
1371
  }
1269
1372
  if ((ev.type === "response.completed" || ev.type === "response.done") && ev.response) {
1270
1373
  if (
1271
- ev.response.status === "failed" ||
1272
- ev.response.status === "incomplete" ||
1374
+ (ev.response.status !== undefined && ev.response.status !== "completed") ||
1273
1375
  (ev.response.error !== null && ev.response.error !== undefined)
1274
1376
  ) {
1275
1377
  throw codexSseFailureError(
@@ -1286,17 +1388,7 @@ function patchSseBlock(block: string, items: unknown[], source: Response): Patch
1286
1388
  },
1287
1389
  );
1288
1390
  }
1289
- const out = ev.response.output;
1290
- if ((!Array.isArray(out) || out.length === 0) && items.length > 0) {
1291
- ev.response = { ...ev.response, output: items };
1292
- const nonData = lines.filter((l) => !l.startsWith("data:"));
1293
- const lineEnding = block.match(/\r\n|\r|\n/)?.[0] ?? "\n";
1294
- return {
1295
- block: [...nonData, `data: ${JSON.stringify(ev)}`].join(lineEnding),
1296
- successfulTerminal: true,
1297
- };
1298
- }
1299
- return { block, successfulTerminal: true };
1391
+ return true;
1300
1392
  }
1301
- return { block, successfulTerminal: false };
1393
+ return false;
1302
1394
  }
package/src/images.ts ADDED
@@ -0,0 +1,209 @@
1
+ import { CODEX_CLIENT_VERSION, CODEX_ORIGINATOR, CODEX_RESPONSES_BASE } from "./constants";
2
+ import type { CodexRequestContext, CodexTokenSnapshot } from "./request-context";
3
+ import type { FetchLike } from "./fetch";
4
+ import { pinnedFetch, readJsonBase64Field, readResponseTextBounded } from "@opengeni/network";
5
+
6
+ const CODEX_IMAGE_MODEL = "gpt-image-2";
7
+ const CODEX_IMAGE_RESPONSE_MAX_BYTES = 90 * 1024 * 1024;
8
+ const CODEX_IMAGE_ERROR_MAX_BYTES = 64 * 1024;
9
+ const CODEX_IMAGE_MAX_BYTES = 64 * 1024 * 1024;
10
+ const CODEX_IMAGE_REQUEST_TIMEOUT_MS = 5 * 60_000;
11
+ const CODEX_IMAGE_MAX_REFERENCES = 5;
12
+
13
+ const codexImageFetch: FetchLike = async (input, init) =>
14
+ await pinnedFetch(
15
+ input,
16
+ init,
17
+ {
18
+ environment: "production",
19
+ integrationsAllowPrivateNetworkTargets: false,
20
+ },
21
+ {
22
+ label: "Codex image generation",
23
+ requireHttpsOutsideLocalTest: true,
24
+ },
25
+ );
26
+
27
+ export type CodexGeneratedImage = {
28
+ bytes: Uint8Array;
29
+ declaredMediaType: "image/png";
30
+ };
31
+
32
+ export type CodexImageReferenceInput = Readonly<{
33
+ mediaType: "image/png" | "image/jpeg" | "image/webp";
34
+ bytes: Uint8Array;
35
+ }>;
36
+
37
+ export class CodexImageApiError extends Error {
38
+ constructor(
39
+ readonly status: number,
40
+ message: string,
41
+ ) {
42
+ super(message);
43
+ this.name = "CodexImageApiError";
44
+ }
45
+ }
46
+
47
+ export class CodexImageRequestTimeoutError extends Error {
48
+ constructor(readonly timeoutMs: number) {
49
+ super(`Codex image generation timed out after ${Math.ceil(timeoutMs / 1_000)} seconds`);
50
+ this.name = "CodexImageRequestTimeoutError";
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Execute Codex's standalone, client-side image tool against the same
56
+ * ChatGPT/Codex account as the owning model turn. Only a definitive 401 is
57
+ * retried, after refreshing auth; ambiguous transport/5xx outcomes are never
58
+ * replayed because an image request may already have incurred work or cost.
59
+ */
60
+ export async function generateCodexSubscriptionImage(input: {
61
+ prompt: string;
62
+ references?: readonly CodexImageReferenceInput[];
63
+ turnId: string;
64
+ context: Pick<CodexRequestContext, "clientVersion" | "getToken" | "refresh">;
65
+ abortSignal?: AbortSignal;
66
+ fetch?: FetchLike;
67
+ /** Internal test/host override; one absolute budget covers auth retry and body streaming. */
68
+ requestTimeoutMs?: number;
69
+ }): Promise<CodexGeneratedImage> {
70
+ const fetchImpl = input.fetch ?? codexImageFetch;
71
+ const timeoutMs = input.requestTimeoutMs ?? CODEX_IMAGE_REQUEST_TIMEOUT_MS;
72
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
73
+ throw new RangeError("Codex image request timeout must be a positive safe integer");
74
+ }
75
+ const references = input.references ?? [];
76
+ if (references.length > CODEX_IMAGE_MAX_REFERENCES) {
77
+ throw new RangeError(
78
+ `Codex image editing accepts at most ${CODEX_IMAGE_MAX_REFERENCES} images`,
79
+ );
80
+ }
81
+ for (const reference of references) {
82
+ if (reference.bytes.byteLength === 0) throw new Error("Codex image reference is empty");
83
+ }
84
+ const deadline = new AbortController();
85
+ const timer = setTimeout(
86
+ () => deadline.abort(new CodexImageRequestTimeoutError(timeoutMs)),
87
+ timeoutMs,
88
+ );
89
+ const signal = input.abortSignal
90
+ ? AbortSignal.any([input.abortSignal, deadline.signal])
91
+ : deadline.signal;
92
+ const request = async (auth: CodexTokenSnapshot): Promise<Response> => {
93
+ const headers = codexImageHeaders(auth, input.context.clientVersion, input.turnId);
94
+ return await fetchImpl(
95
+ `${CODEX_RESPONSES_BASE}/${references.length > 0 ? "images/edits" : "images/generations"}`,
96
+ {
97
+ method: "POST",
98
+ redirect: "error",
99
+ headers,
100
+ body: JSON.stringify(
101
+ references.length > 0
102
+ ? {
103
+ images: references.map((reference) => ({
104
+ image_url: `data:${reference.mediaType};base64,${Buffer.from(reference.bytes).toString("base64")}`,
105
+ })),
106
+ prompt: input.prompt,
107
+ background: "auto",
108
+ model: CODEX_IMAGE_MODEL,
109
+ quality: "auto",
110
+ size: "auto",
111
+ }
112
+ : {
113
+ prompt: input.prompt,
114
+ background: "auto",
115
+ model: CODEX_IMAGE_MODEL,
116
+ quality: "auto",
117
+ size: "auto",
118
+ },
119
+ ),
120
+ signal,
121
+ },
122
+ );
123
+ };
124
+
125
+ const operation = (async (): Promise<CodexGeneratedImage> => {
126
+ let response = await request(await input.context.getToken());
127
+ if (response.status === 401) {
128
+ await response.body?.cancel().catch(() => undefined);
129
+ response = await request(await input.context.refresh());
130
+ }
131
+ if (!response.ok) {
132
+ const detail = await readResponseTextBounded(
133
+ response,
134
+ CODEX_IMAGE_ERROR_MAX_BYTES,
135
+ "Codex image error",
136
+ { signal },
137
+ ).catch(() => "");
138
+ throw new CodexImageApiError(
139
+ response.status,
140
+ detail
141
+ ? `Codex image generation failed (${response.status}): ${boundedErrorMessage(detail)}`
142
+ : `Codex image generation failed (${response.status})`,
143
+ );
144
+ }
145
+
146
+ const bytes = await readJsonBase64Field(response, {
147
+ fieldName: "b64_json",
148
+ shape: "string",
149
+ maxResponseBytes: CODEX_IMAGE_RESPONSE_MAX_BYTES,
150
+ maxDecodedBytes: CODEX_IMAGE_MAX_BYTES,
151
+ label: "Codex image generation",
152
+ signal,
153
+ });
154
+ return { bytes, declaredMediaType: "image/png" };
155
+ })();
156
+ let removeAbortListener = (): void => undefined;
157
+ const aborted = new Promise<never>((_resolve, reject) => {
158
+ const onAbort = () => reject(signal.reason);
159
+ if (signal.aborted) {
160
+ onAbort();
161
+ return;
162
+ }
163
+ signal.addEventListener("abort", onAbort, { once: true });
164
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort);
165
+ });
166
+ try {
167
+ // The race is the backstop for credential resolvers and injected transports
168
+ // that do not observe AbortSignal. Promise.race attaches a rejection handler
169
+ // to the losing operation, so it cannot become an unhandled rejection.
170
+ return await Promise.race([operation, aborted]);
171
+ } finally {
172
+ removeAbortListener();
173
+ clearTimeout(timer);
174
+ }
175
+ }
176
+
177
+ function codexImageHeaders(
178
+ auth: CodexTokenSnapshot,
179
+ clientVersion: string,
180
+ turnId: string,
181
+ ): Headers {
182
+ const headers = new Headers({
183
+ Authorization: `Bearer ${auth.accessToken}`,
184
+ accept: "application/json",
185
+ "content-type": "application/json",
186
+ originator: CODEX_ORIGINATOR,
187
+ "User-Agent": `${CODEX_ORIGINATOR}/${clientVersion || CODEX_CLIENT_VERSION}`,
188
+ version: clientVersion || CODEX_CLIENT_VERSION,
189
+ "x-codex-image-turn-id": turnId,
190
+ });
191
+ if (auth.chatgptAccountId) headers.set("ChatGPT-Account-ID", auth.chatgptAccountId);
192
+ if (auth.isFedramp) headers.set("X-OpenAI-Fedramp", "true");
193
+ return headers;
194
+ }
195
+
196
+ function boundedErrorMessage(body: string): string {
197
+ let message = body;
198
+ try {
199
+ const value = JSON.parse(body) as {
200
+ error?: { message?: unknown };
201
+ message?: unknown;
202
+ };
203
+ const candidate = value.error?.message ?? value.message;
204
+ if (typeof candidate === "string") message = candidate;
205
+ } catch {
206
+ // Preserve a bounded non-JSON provider diagnostic.
207
+ }
208
+ return message.replace(/\s+/g, " ").trim().slice(0, 1_000);
209
+ }
package/src/index.ts CHANGED
@@ -12,5 +12,6 @@ export * from "./fetch";
12
12
  export * from "./mcp-sanitize";
13
13
  export * from "./model-output-truncation";
14
14
  export * from "./opaque-artifact";
15
+ export * from "./images";
15
16
  export * from "./realtime";
16
17
  export * from "./realtime-v3";