@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.
- package/dist/api-client.d.ts +2 -2
- package/dist/fetch.d.ts +9 -1
- package/dist/images.d.ts +34 -0
- package/dist/index.d.ts +17 -16
- package/dist/index.js +292 -39
- package/dist/index.js.map +1 -1
- package/dist/mcp-sanitize.d.ts +1 -1
- package/dist/model-output-truncation.d.ts +1 -1
- package/dist/normalize.d.ts +6 -0
- package/dist/realtime.d.ts +4 -4
- package/dist/refresh.d.ts +1 -1
- package/dist/response-timeout.d.ts +1 -1
- package/dist/usage-normalize.d.ts +1 -1
- package/package.json +2 -1
- package/src/fetch.ts +146 -54
- package/src/images.ts +209 -0
- package/src/index.ts +1 -0
- package/src/model-output-truncation.ts +7 -1
- package/src/normalize.ts +26 -0
package/dist/mcp-sanitize.d.ts
CHANGED
|
@@ -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;
|
package/dist/normalize.d.ts
CHANGED
|
@@ -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
|
package/dist/realtime.d.ts
CHANGED
|
@@ -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 { 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.
|
|
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
|
|
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
|
|
490
|
-
//
|
|
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
|
|
495
|
-
|
|
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
|
-
|
|
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
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
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 ?
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
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
|
|
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
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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