@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/dist/api-client.d.ts +2 -2
- package/dist/fetch.d.ts +13 -5
- package/dist/images.d.ts +34 -0
- package/dist/index.d.ts +17 -16
- package/dist/index.js +316 -62
- 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 +166 -77
- package/src/images.ts +209 -0
- package/src/index.ts +1 -0
- package/src/mcp-sanitize.ts +12 -8
- package/src/model-output-truncation.ts +7 -1
- package/src/normalize.ts +26 -0
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
package/src/mcp-sanitize.ts
CHANGED
|
@@ -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.
|
|
173
|
-
*
|
|
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
|
|
189
|
-
|
|
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
|
-
|
|
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
|