@opengeni/codex 0.2.11 → 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/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";
@@ -169,8 +169,10 @@ function sanitizeToolsInRpcMessage(
169
169
  * leaving the original content untouched.
170
170
  *
171
171
  * No-op when there is no `structuredContent` — so a tools/list response (or any
172
- * result without it) passes through unchanged. Runs alongside (after) the empty
173
- * outputSchema drop, which is what stops the MCP SDK -32602ing this same result.
172
+ * result without it) passes through unchanged. Valid object payloads stay intact
173
+ * for protocol-aware consumers. Invalid optional values (such as null) are removed
174
+ * after any useful value is copied into `content`, because the MCP client validates
175
+ * this field as an object before the tool can return. Runs after the outputSchema drop.
174
176
  */
175
177
  function inlineStructuredContentInRpcMessage(message: unknown): void {
176
178
  if (!message || typeof message !== "object") {
@@ -185,13 +187,15 @@ function inlineStructuredContentInRpcMessage(message: unknown): void {
185
187
  return;
186
188
  }
187
189
  const structured = record.structuredContent;
188
- if (structured === undefined || structured === null) {
189
- return;
190
+ if (structured !== undefined && structured !== null) {
191
+ const text = typeof structured === "string" ? structured : JSON.stringify(structured);
192
+ const content = Array.isArray(record.content) ? [...record.content] : [];
193
+ content.push({ type: "text", text });
194
+ record.content = content;
195
+ }
196
+ if (typeof structured !== "object" || structured === null || Array.isArray(structured)) {
197
+ delete record.structuredContent;
190
198
  }
191
- const text = typeof structured === "string" ? structured : JSON.stringify(structured);
192
- const content = Array.isArray(record.content) ? [...record.content] : [];
193
- content.push({ type: "text", text });
194
- record.content = content;
195
199
  }
196
200
 
197
201
  /** Sanitize a single JSON body (application/json MCP response). */
@@ -167,7 +167,13 @@ export function boundModelToolOutputItems<T extends ModelHistoryItem>(
167
167
  items: readonly T[],
168
168
  policyTokens = DEFAULT_MODEL_TOOL_OUTPUT_TRUNCATION_TOKENS,
169
169
  ): T[] {
170
- return items.map((item) => boundModelToolOutputItem(item, policyTokens));
170
+ let bounded: T[] | null = null;
171
+ for (const [index, item] of items.entries()) {
172
+ const next = boundModelToolOutputItem(item, policyTokens);
173
+ if (next !== item && bounded === null) bounded = items.slice(0, index);
174
+ bounded?.push(next);
175
+ }
176
+ return bounded ?? (items as T[]);
171
177
  }
172
178
 
173
179
  function boundToolOutputValue(output: unknown, budgetTokens: number): unknown {
package/src/normalize.ts CHANGED
@@ -115,6 +115,32 @@ export function normalizeCodexRequestBody(
115
115
  return body;
116
116
  }
117
117
 
118
+ /**
119
+ * Copy-on-write form for model clients that may retain converted input items.
120
+ * Only records the mutable normalizer can touch are copied; large content,
121
+ * tools, and unchanged protocol items remain shared immutable values.
122
+ */
123
+ export function normalizedCodexRequestBody(
124
+ body: Readonly<Record<string, unknown>>,
125
+ resolveModel: (slug: string) => string,
126
+ ): Record<string, unknown> {
127
+ const projected: Record<string, unknown> = { ...body };
128
+ if (body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning)) {
129
+ projected.reasoning = { ...(body.reasoning as Record<string, unknown>) };
130
+ }
131
+ if (Array.isArray(body.input)) {
132
+ projected.input = body.input.map((item) => {
133
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
134
+ const record = item as Record<string, unknown>;
135
+ return "id" in record ||
136
+ (record.type === "tool_search_call" && typeof record.arguments === "string")
137
+ ? { ...record }
138
+ : item;
139
+ });
140
+ }
141
+ return normalizeCodexRequestBody(projected, resolveModel);
142
+ }
143
+
118
144
  /**
119
145
  * Build a longest-prefix model resolver. Catalog slugs come from GET /models
120
146
  * (api-client.ts). One leading `namespace/` segment is stripped first; an