pi-codex-image-gen 0.1.12 → 0.1.14
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/CHANGELOG.md +34 -2
- package/CONTRIBUTING.md +42 -5
- package/README.md +50 -11
- package/SECURITY.md +11 -1
- package/extensions/index.ts +164 -230
- package/package.json +10 -7
- package/skills/imagegen/SKILL.md +13 -10
- package/skills/imagegen/references/cli.md +7 -5
- package/skills/imagegen/references/image-api.md +18 -8
- package/skills/imagegen/references/prompting.md +2 -2
- package/skills/imagegen/references/sample-prompts.md +2 -2
- package/skills/imagegen/scripts/image_gen.py +29 -20
- package/src/codex-response.ts +286 -0
- package/src/image-oauth.ts +223 -0
- package/src/install-telemetry.ts +19 -46
package/extensions/index.ts
CHANGED
|
@@ -1,31 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project-local Codex image generation extension.
|
|
3
3
|
*
|
|
4
|
-
* Registers `codex_generate_image`, a tool that uses
|
|
5
|
-
*
|
|
6
|
-
* native `image_generation` tool. The backend
|
|
4
|
+
* Registers `codex_generate_image`, a tool that uses package-owned
|
|
5
|
+
* ChatGPT image OAuth or legacy Pi auth to call the Codex Responses backend with the
|
|
6
|
+
* native `image_generation` tool. The backend selects the image model.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
|
-
import {
|
|
10
|
+
import { constants } from "node:fs";
|
|
11
|
+
import { mkdir, open, writeFile } from "node:fs/promises";
|
|
11
12
|
import { homedir } from "node:os";
|
|
12
13
|
import { isAbsolute, join, resolve } from "node:path";
|
|
13
14
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
14
|
-
import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
15
16
|
import { type Static, Type } from "typebox";
|
|
16
17
|
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
18
|
+
import { extractImageAccountId, imageAuthProvider, IMAGE_AUTH_PROVIDER } from "../src/image-oauth.js";
|
|
19
|
+
import { abortable, httpFailure, MAX_IMAGE_BYTES, parseCodexSse, withRequestDeadline, type ParsedCodexResponse } from "../src/codex-response.js";
|
|
17
20
|
|
|
18
21
|
const PACKAGE_NAME = "pi-codex-image-gen";
|
|
19
|
-
const
|
|
22
|
+
const LEGACY_PROVIDER = "openai-codex";
|
|
20
23
|
const DEFAULT_MODEL = "gpt-5.5";
|
|
21
24
|
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
22
|
-
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
23
25
|
const DEFAULT_SAVE_MODE = "global";
|
|
24
26
|
const OPENAI_BETA_HEADER = "responses=experimental";
|
|
25
27
|
const MAX_RETRIES = 3;
|
|
26
28
|
const BASE_DELAY_MS = 1000;
|
|
27
29
|
const MAX_RETRY_DELAY_MS = 30_000;
|
|
28
30
|
const MAX_EDIT_IMAGES = 5;
|
|
31
|
+
const MAX_INPUT_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
32
|
+
const MAX_TOTAL_INPUT_BYTES = 50 * 1024 * 1024;
|
|
33
|
+
const MAX_PROMPT_CHARS = 32_000;
|
|
29
34
|
|
|
30
35
|
const SAVE_MODES = ["none", "project", "global", "custom"] as const;
|
|
31
36
|
type SaveMode = (typeof SAVE_MODES)[number];
|
|
@@ -35,11 +40,6 @@ type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
|
|
35
40
|
|
|
36
41
|
// --- #1: Retry helpers with exponential backoff + jitter ---
|
|
37
42
|
|
|
38
|
-
function isRetryableStatus(status: number, errorText: string): boolean {
|
|
39
|
-
if ([429, 500, 502, 503, 504].includes(status)) return true;
|
|
40
|
-
return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(errorText);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
43
|
export function parseRetryAfter(value: string | null, nowMs = Date.now()): number | undefined {
|
|
44
44
|
if (!value) return undefined;
|
|
45
45
|
const trimmed = value.trim();
|
|
@@ -89,9 +89,9 @@ export function abortableDelay(milliseconds: number, signal?: AbortSignal): Prom
|
|
|
89
89
|
// --- Tool parameter schema ---
|
|
90
90
|
|
|
91
91
|
const TOOL_PARAMS = Type.Object({
|
|
92
|
-
prompt: Type.String({ description: "The image prompt. Be specific about subject, composition, style, text, and constraints." }),
|
|
92
|
+
prompt: Type.String({ minLength: 1, maxLength: MAX_PROMPT_CHARS, description: "The image prompt. Be specific about subject, composition, style, text, and constraints." }),
|
|
93
93
|
model: Type.Optional(
|
|
94
|
-
Type.String({ description: `Codex model
|
|
94
|
+
Type.String({ minLength: 1, maxLength: 200, description: `Codex routing model, not an image model selector. Defaults to ${DEFAULT_MODEL}.` }),
|
|
95
95
|
),
|
|
96
96
|
outputFormat: Type.Optional(StringEnum(OUTPUT_FORMATS)),
|
|
97
97
|
save: Type.Optional(StringEnum(SAVE_MODES)),
|
|
@@ -130,69 +130,39 @@ interface SaveConfig {
|
|
|
130
130
|
outputDir?: string;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
interface GeneratedImage {
|
|
134
|
-
id: string;
|
|
135
|
-
status: string;
|
|
136
|
-
result: string;
|
|
137
|
-
revisedPrompt?: string;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
interface ParsedCodexResponse {
|
|
141
|
-
image?: GeneratedImage;
|
|
142
|
-
text: string[];
|
|
143
|
-
responseId?: string;
|
|
144
|
-
usage?: unknown;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
133
|
interface InputImage {
|
|
148
134
|
data: string;
|
|
149
135
|
mimeType: string;
|
|
150
136
|
}
|
|
151
137
|
|
|
152
|
-
// ---
|
|
153
|
-
|
|
154
|
-
type CodexSseEvent =
|
|
155
|
-
| { type: "error"; message?: string; code?: string }
|
|
156
|
-
| { type: "response.failed"; response?: { error?: { message?: string } } }
|
|
157
|
-
| { type: "response.created"; response?: { id?: string } }
|
|
158
|
-
| { type: "response.output_text.delta"; delta?: string }
|
|
159
|
-
| {
|
|
160
|
-
type: "response.output_item.done";
|
|
161
|
-
item?: {
|
|
162
|
-
type?: string;
|
|
163
|
-
id?: string | number;
|
|
164
|
-
status?: string;
|
|
165
|
-
result?: string;
|
|
166
|
-
revised_prompt?: string;
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
| { type: "response.completed"; response?: { id?: string; usage?: unknown } };
|
|
170
|
-
|
|
171
|
-
// --- JWT helpers ---
|
|
138
|
+
// --- Image credentials (independent of the active chat provider) ---
|
|
172
139
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
try {
|
|
179
|
-
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
|
180
|
-
} catch (error) {
|
|
181
|
-
throw new Error(`Failed to decode OpenAI Codex auth token: ${error instanceof Error ? error.message : String(error)}`);
|
|
182
|
-
}
|
|
140
|
+
interface ImageAuth {
|
|
141
|
+
provider: typeof IMAGE_AUTH_PROVIDER | typeof LEGACY_PROVIDER;
|
|
142
|
+
token: string;
|
|
143
|
+
accountId: string;
|
|
183
144
|
}
|
|
184
145
|
|
|
185
|
-
function
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
146
|
+
async function resolveImageAuth(registry: ExtensionContext["modelRegistry"]): Promise<ImageAuth> {
|
|
147
|
+
for (const provider of [IMAGE_AUTH_PROVIDER, LEGACY_PROVIDER] as const) {
|
|
148
|
+
let token: string | undefined;
|
|
149
|
+
try {
|
|
150
|
+
if (typeof registry.getProviderAuth === "function") {
|
|
151
|
+
const resolved = await registry.getProviderAuth(provider);
|
|
152
|
+
if (resolved && resolved.source !== "OAuth") {
|
|
153
|
+
throw new Error("Image credentials must use OAuth.");
|
|
154
|
+
}
|
|
155
|
+
token = resolved?.auth.apiKey;
|
|
156
|
+
if (resolved && !token) throw new Error("Missing image access token.");
|
|
157
|
+
} else {
|
|
158
|
+
token = await registry.getApiKeyForProvider(provider);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
throw new Error(`Image authentication failed for ${provider}. Run /login ${IMAGE_AUTH_PROVIDER} again. No alternate account was used.`);
|
|
162
|
+
}
|
|
163
|
+
if (token) return { provider, token, accountId: extractImageAccountId(token) };
|
|
194
164
|
}
|
|
195
|
-
|
|
165
|
+
throw new Error(`Missing image OAuth credentials. Run /login ${IMAGE_AUTH_PROVIDER}. Pi's openai plan-sharing OAuth does not support images; API keys and Codex app credentials are not used.`);
|
|
196
166
|
}
|
|
197
167
|
|
|
198
168
|
// --- #10: try/catch readConfigFile replaces racy existsSync + readFileSync ---
|
|
@@ -208,7 +178,7 @@ function readConfigFile(path: string): ExtensionConfig {
|
|
|
208
178
|
export function loadConfig(cwd: string, projectTrusted: boolean, agentDir = getAgentDir()): ExtensionConfig {
|
|
209
179
|
const globalConfig = readConfigFile(join(agentDir, "extensions", "codex-image-gen.json"));
|
|
210
180
|
if (!projectTrusted) return globalConfig;
|
|
211
|
-
const projectConfig = readConfigFile(join(cwd,
|
|
181
|
+
const projectConfig = readConfigFile(join(cwd, CONFIG_DIR_NAME, "extensions", "codex-image-gen.json"));
|
|
212
182
|
return { ...globalConfig, ...projectConfig };
|
|
213
183
|
}
|
|
214
184
|
|
|
@@ -221,7 +191,7 @@ export function resolveUnderCwd(cwd: string, path: string, homeDir = homedir()):
|
|
|
221
191
|
}
|
|
222
192
|
|
|
223
193
|
function sanitizePathPart(value: string, fallback: string): string {
|
|
224
|
-
const sanitized = value
|
|
194
|
+
const sanitized = value.slice(0, 128)
|
|
225
195
|
.split("")
|
|
226
196
|
.map((ch) => (/[a-zA-Z0-9_-]/.test(ch) ? ch : "_"))
|
|
227
197
|
.join("")
|
|
@@ -239,7 +209,7 @@ function resolveSaveConfig(params: ToolParams, cwd: string, sessionId: string, c
|
|
|
239
209
|
throw new Error(`Invalid save mode: ${mode}. Expected one of ${SAVE_MODES.join(", ")}.`);
|
|
240
210
|
}
|
|
241
211
|
if (mode === "project") {
|
|
242
|
-
return { mode, outputDir: join(cwd,
|
|
212
|
+
return { mode, outputDir: join(cwd, CONFIG_DIR_NAME, "generated-images", safeSessionId) };
|
|
243
213
|
}
|
|
244
214
|
if (mode === "global") {
|
|
245
215
|
return { mode, outputDir: join(getAgentDir(), "generated-images", safeSessionId) };
|
|
@@ -270,12 +240,13 @@ function imagePath(outputFormat: OutputFormat, outputDir: string, imageCallId: s
|
|
|
270
240
|
}
|
|
271
241
|
|
|
272
242
|
export function decodeImageData(base64Data: string, outputFormat: OutputFormat): Buffer {
|
|
243
|
+
if (base64Data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4) throw new Error("Codex image exceeded the 32 MiB size limit.");
|
|
273
244
|
const value = base64Data.trim();
|
|
274
|
-
if (!value || value.length % 4 !== 0 ||
|
|
245
|
+
if (!value || value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value)) {
|
|
275
246
|
throw new Error("Codex returned invalid base64 image data.");
|
|
276
247
|
}
|
|
277
248
|
const bytes = Buffer.from(value, "base64");
|
|
278
|
-
if (bytes.length === 0 || bytes.toString("base64") !== value) {
|
|
249
|
+
if (bytes.length === 0 || bytes.length > MAX_IMAGE_BYTES || bytes.toString("base64") !== value) {
|
|
279
250
|
throw new Error("Codex returned invalid base64 image data.");
|
|
280
251
|
}
|
|
281
252
|
const validSignature =
|
|
@@ -294,8 +265,8 @@ async function saveImage(
|
|
|
294
265
|
): Promise<string> {
|
|
295
266
|
const filePath = imagePath(outputFormat, outputDir, imageCallId);
|
|
296
267
|
await withFileMutationQueue(filePath, async () => {
|
|
297
|
-
await mkdir(outputDir, { recursive: true });
|
|
298
|
-
await writeFile(filePath, bytes);
|
|
268
|
+
await mkdir(outputDir, { recursive: true, mode: 0o700 });
|
|
269
|
+
await writeFile(filePath, bytes, { flag: "wx", mode: 0o600 });
|
|
299
270
|
});
|
|
300
271
|
return filePath;
|
|
301
272
|
}
|
|
@@ -322,6 +293,29 @@ function mimeFromBytes(bytes: Buffer, path: string): string {
|
|
|
322
293
|
throw new Error(`Referenced image is unavailable or unsupported: ${path}`);
|
|
323
294
|
}
|
|
324
295
|
|
|
296
|
+
async function readInputImage(path: string): Promise<Buffer> {
|
|
297
|
+
// O_NONBLOCK prevents named pipes from blocking before the regular-file check.
|
|
298
|
+
const file = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
299
|
+
try {
|
|
300
|
+
const info = await file.stat();
|
|
301
|
+
if (!info.isFile()) throw new Error("Referenced images must be regular files.");
|
|
302
|
+
if (info.size > MAX_INPUT_IMAGE_BYTES) throw new Error("Referenced image exceeds 20 MiB.");
|
|
303
|
+
const blocks: Buffer[] = [];
|
|
304
|
+
let total = 0;
|
|
305
|
+
while (true) {
|
|
306
|
+
const block = Buffer.alloc(Math.min(64 * 1024, MAX_INPUT_IMAGE_BYTES + 1 - total));
|
|
307
|
+
const { bytesRead } = await file.read(block, 0, block.length, null);
|
|
308
|
+
if (!bytesRead) break;
|
|
309
|
+
total += bytesRead;
|
|
310
|
+
if (total > MAX_INPUT_IMAGE_BYTES) throw new Error("Referenced image exceeds 20 MiB.");
|
|
311
|
+
blocks.push(block.subarray(0, bytesRead));
|
|
312
|
+
}
|
|
313
|
+
return Buffer.concat(blocks, total);
|
|
314
|
+
} finally {
|
|
315
|
+
await file.close();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
325
319
|
export async function resolveInputImages(
|
|
326
320
|
params: ToolParams,
|
|
327
321
|
cwd: string,
|
|
@@ -333,19 +327,22 @@ export async function resolveInputImages(
|
|
|
333
327
|
}
|
|
334
328
|
if (paths.length > MAX_EDIT_IMAGES) throw new Error(`referencedImagePaths accepts at most ${MAX_EDIT_IMAGES} paths.`);
|
|
335
329
|
if (paths.length > 0) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
330
|
+
const images: InputImage[] = [];
|
|
331
|
+
let total = 0;
|
|
332
|
+
for (const path of paths) {
|
|
333
|
+
const normalized = path.startsWith("@") ? path.slice(1) : path;
|
|
334
|
+
const absolutePath = resolveUnderCwd(cwd, normalized);
|
|
335
|
+
let bytes: Buffer;
|
|
336
|
+
try {
|
|
337
|
+
bytes = await readInputImage(absolutePath);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
throw new Error(`Unable to read referenced image at ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
340
|
+
}
|
|
341
|
+
total += bytes.length;
|
|
342
|
+
if (total > MAX_TOTAL_INPUT_BYTES) throw new Error("Referenced images exceed 50 MiB in total.");
|
|
343
|
+
images.push({ data: bytes.toString("base64"), mimeType: mimeFromBytes(bytes, absolutePath) });
|
|
344
|
+
}
|
|
345
|
+
return images;
|
|
349
346
|
}
|
|
350
347
|
if (params.numLastImagesToInclude !== undefined) {
|
|
351
348
|
const count = params.numLastImagesToInclude;
|
|
@@ -356,6 +353,16 @@ export async function resolveInputImages(
|
|
|
356
353
|
if (images.length !== count) {
|
|
357
354
|
throw new Error(`Requested the last ${count} conversation images, but only ${images.length} were available.`);
|
|
358
355
|
}
|
|
356
|
+
let total = 0;
|
|
357
|
+
for (const image of images) {
|
|
358
|
+
if (image.data.length > Math.ceil(MAX_INPUT_IMAGE_BYTES / 3) * 4) throw new Error("Conversation image exceeds 20 MiB.");
|
|
359
|
+
const format = OUTPUT_FORMATS.find(format => mimeForFormat(format) === image.mimeType);
|
|
360
|
+
if (!format) throw new Error("Conversation image has an unsupported format.");
|
|
361
|
+
const bytes = decodeImageData(image.data, format);
|
|
362
|
+
if (bytes.length > MAX_INPUT_IMAGE_BYTES) throw new Error("Conversation image exceeds 20 MiB.");
|
|
363
|
+
total += bytes.length;
|
|
364
|
+
if (total > MAX_TOTAL_INPUT_BYTES) throw new Error("Conversation images exceed 50 MiB in total.");
|
|
365
|
+
}
|
|
359
366
|
return images;
|
|
360
367
|
}
|
|
361
368
|
return [];
|
|
@@ -399,185 +406,99 @@ export function buildRequestBody(
|
|
|
399
406
|
};
|
|
400
407
|
}
|
|
401
408
|
|
|
402
|
-
// --- SSE parsing ---
|
|
403
|
-
|
|
404
|
-
function parseSseDataLines(chunk: string): string | undefined {
|
|
405
|
-
const data = chunk
|
|
406
|
-
.split("\n")
|
|
407
|
-
.filter((line) => line.startsWith("data:"))
|
|
408
|
-
.map((line) => line.slice(5).trim())
|
|
409
|
-
.join("\n")
|
|
410
|
-
.trim();
|
|
411
|
-
return data && data !== "[DONE]" ? data : undefined;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
async function parseCodexSse(response: Response, signal?: AbortSignal): Promise<ParsedCodexResponse> {
|
|
415
|
-
if (!response.body) throw new Error("Codex response did not include a stream body.");
|
|
416
|
-
const reader = response.body.getReader();
|
|
417
|
-
const decoder = new TextDecoder();
|
|
418
|
-
let buffer = "";
|
|
419
|
-
const parsed: ParsedCodexResponse = { text: [] };
|
|
420
|
-
|
|
421
|
-
try {
|
|
422
|
-
while (true) {
|
|
423
|
-
if (signal?.aborted) throw new Error("Image generation was aborted.");
|
|
424
|
-
const { done, value } = await reader.read();
|
|
425
|
-
if (done) break;
|
|
426
|
-
buffer += decoder.decode(value, { stream: true });
|
|
427
|
-
|
|
428
|
-
let separator = buffer.indexOf("\n\n");
|
|
429
|
-
while (separator !== -1) {
|
|
430
|
-
const chunk = buffer.slice(0, separator);
|
|
431
|
-
buffer = buffer.slice(separator + 2);
|
|
432
|
-
const data = parseSseDataLines(chunk);
|
|
433
|
-
if (data) handleCodexEvent(JSON.parse(data) as CodexSseEvent, parsed);
|
|
434
|
-
separator = buffer.indexOf("\n\n");
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
const remaining = parseSseDataLines(buffer);
|
|
438
|
-
if (remaining) handleCodexEvent(JSON.parse(remaining) as CodexSseEvent, parsed);
|
|
439
|
-
} finally {
|
|
440
|
-
try {
|
|
441
|
-
await reader.cancel();
|
|
442
|
-
} catch {
|
|
443
|
-
// ignored: stream may already be closed
|
|
444
|
-
}
|
|
445
|
-
reader.releaseLock();
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return parsed;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// --- #11: Typed event handler via discriminated union ---
|
|
452
|
-
|
|
453
|
-
function handleCodexEvent(event: CodexSseEvent, parsed: ParsedCodexResponse): void {
|
|
454
|
-
if (!event || typeof event !== "object") return;
|
|
455
|
-
|
|
456
|
-
switch (event.type) {
|
|
457
|
-
case "error": {
|
|
458
|
-
const e = event as Extract<CodexSseEvent, { type: "error" }>;
|
|
459
|
-
throw new Error(`Codex error: ${e.message || e.code || JSON.stringify(event)}`);
|
|
460
|
-
}
|
|
461
|
-
case "response.failed": {
|
|
462
|
-
const e = event as Extract<CodexSseEvent, { type: "response.failed" }>;
|
|
463
|
-
throw new Error(e.response?.error?.message || "Codex response failed.");
|
|
464
|
-
}
|
|
465
|
-
case "response.created": {
|
|
466
|
-
const e = event as Extract<CodexSseEvent, { type: "response.created" }>;
|
|
467
|
-
if (typeof e.response?.id === "string") {
|
|
468
|
-
parsed.responseId = e.response.id;
|
|
469
|
-
}
|
|
470
|
-
break;
|
|
471
|
-
}
|
|
472
|
-
case "response.output_text.delta": {
|
|
473
|
-
const e = event as Extract<CodexSseEvent, { type: "response.output_text.delta" }>;
|
|
474
|
-
if (typeof e.delta === "string") {
|
|
475
|
-
parsed.text.push(e.delta);
|
|
476
|
-
}
|
|
477
|
-
break;
|
|
478
|
-
}
|
|
479
|
-
case "response.output_item.done": {
|
|
480
|
-
const e = event as Extract<CodexSseEvent, { type: "response.output_item.done" }>;
|
|
481
|
-
const item = e.item;
|
|
482
|
-
if (item?.type === "image_generation_call") {
|
|
483
|
-
if (typeof item.result !== "string" || item.result.length === 0) {
|
|
484
|
-
throw new Error("Codex image_generation_call did not contain image data.");
|
|
485
|
-
}
|
|
486
|
-
parsed.image = {
|
|
487
|
-
id: String(item.id || "image_generation"),
|
|
488
|
-
status: String(item.status || "completed"),
|
|
489
|
-
result: item.result,
|
|
490
|
-
revisedPrompt: typeof item.revised_prompt === "string" ? item.revised_prompt : undefined,
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
break;
|
|
494
|
-
}
|
|
495
|
-
case "response.completed": {
|
|
496
|
-
const e = event as Extract<CodexSseEvent, { type: "response.completed" }>;
|
|
497
|
-
if (typeof e.response?.id === "string") parsed.responseId = e.response.id;
|
|
498
|
-
if (e.response?.usage) parsed.usage = e.response.usage;
|
|
499
|
-
break;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
409
|
// --- #1: requestImage with retry + backoff + jitter ---
|
|
505
410
|
|
|
506
411
|
async function requestImage(
|
|
507
412
|
params: ToolParams,
|
|
508
|
-
|
|
509
|
-
accountId: string,
|
|
413
|
+
auth: ImageAuth,
|
|
510
414
|
model: string,
|
|
511
415
|
outputFormat: OutputFormat,
|
|
512
416
|
sessionId: string,
|
|
513
417
|
inputImages: InputImage[],
|
|
514
418
|
signal?: AbortSignal,
|
|
419
|
+
onProgress?: (stage: string) => void,
|
|
515
420
|
): Promise<ParsedCodexResponse> {
|
|
516
421
|
const body = JSON.stringify(buildRequestBody(params, model, outputFormat, sessionId, inputImages));
|
|
517
422
|
const headers: Record<string, string> = {
|
|
518
|
-
Authorization: `Bearer ${token}`,
|
|
519
|
-
"chatgpt-account-id": accountId,
|
|
423
|
+
Authorization: `Bearer ${auth.token}`,
|
|
424
|
+
"chatgpt-account-id": auth.accountId,
|
|
520
425
|
originator: "pi",
|
|
426
|
+
"User-Agent": PACKAGE_NAME,
|
|
521
427
|
"OpenAI-Beta": OPENAI_BETA_HEADER,
|
|
522
428
|
accept: "text/event-stream",
|
|
523
429
|
"content-type": "application/json",
|
|
524
430
|
};
|
|
525
431
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const errorText = await response.text();
|
|
538
|
-
if (attempt <= MAX_RETRIES && isRetryableStatus(response.status, errorText)) {
|
|
539
|
-
const delay = retryDelayMs(attempt, response.headers.get("retry-after"));
|
|
540
|
-
await abortableDelay(delay, signal);
|
|
541
|
-
continue;
|
|
432
|
+
return withRequestDeadline(signal, async (signal) => {
|
|
433
|
+
for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
|
|
434
|
+
signal.throwIfAborted();
|
|
435
|
+
let response: Response;
|
|
436
|
+
try {
|
|
437
|
+
response = await abortable(fetch(CODEX_RESPONSES_URL, {
|
|
438
|
+
method: "POST", headers, body, signal, redirect: "error",
|
|
439
|
+
}), signal);
|
|
440
|
+
} catch {
|
|
441
|
+
signal.throwIfAborted();
|
|
442
|
+
throw new Error("Codex connection failed. No automatic retry was made; check connectivity before trying again.");
|
|
542
443
|
}
|
|
543
|
-
throw new Error(`Codex image generation request failed (${response.status}): ${errorText}`);
|
|
544
|
-
}
|
|
545
444
|
|
|
546
|
-
|
|
547
|
-
|
|
445
|
+
if (!response.ok) {
|
|
446
|
+
const failure = await httpFailure(response, signal);
|
|
447
|
+
if (attempt <= MAX_RETRIES && failure.retry) {
|
|
448
|
+
const delay = retryDelayMs(attempt, response.headers.get("retry-after"));
|
|
449
|
+
await abortableDelay(delay, signal);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
throw new Error(failure.message);
|
|
453
|
+
}
|
|
548
454
|
|
|
549
|
-
|
|
455
|
+
return parseCodexSse(response, signal, [auth.token, auth.accountId], onProgress);
|
|
456
|
+
}
|
|
457
|
+
throw new Error("Codex image generation request failed after all retries.");
|
|
458
|
+
});
|
|
550
459
|
}
|
|
551
460
|
|
|
552
461
|
// --- Extension entry point ---
|
|
553
462
|
|
|
554
463
|
export default function codexImageGen(pi: ExtensionAPI) {
|
|
555
464
|
reportInstallTelemetry();
|
|
465
|
+
pi.registerProvider(imageAuthProvider);
|
|
556
466
|
|
|
557
467
|
pi.registerTool({
|
|
558
468
|
name: "codex_generate_image",
|
|
559
469
|
label: "Codex Image",
|
|
560
470
|
description:
|
|
561
|
-
"Generate or edit an image with the OpenAI Codex ChatGPT backend built-in image_generation tool
|
|
562
|
-
promptSnippet: "Generate or edit bitmap images via the OpenAI Codex ChatGPT backend
|
|
471
|
+
"Generate or edit an image with the OpenAI Codex ChatGPT backend built-in image_generation tool. The backend selects the image model. Accepts up to five local or recent conversation images (20 MiB each, 50 MiB total). Requires /login codex-images or existing legacy Pi OAuth credentials; works with openai OAuth chat and does not require the Codex app or an API key. Network deadline: 5 minutes; output image limit: 32 MiB; backend text is limited to 4,000 characters.",
|
|
472
|
+
promptSnippet: "Generate or edit bitmap images via the OpenAI Codex ChatGPT backend image_generation tool.",
|
|
563
473
|
promptGuidelines: [
|
|
564
474
|
"Use codex_generate_image when the user asks to generate or edit a raster image with OpenAI/Codex image generation.",
|
|
565
475
|
"Do not use codex_generate_image without a clear image-generation request, because it consumes the user's Codex image quota.",
|
|
476
|
+
"The model parameter selects a Codex routing model, not an image model. Do not pass gpt-image-* IDs.",
|
|
477
|
+
"Output metadata is backend-reported, not independently verified. Check pixels for dimensions and transparency; do not infer a served model from appearance or a successful request.",
|
|
478
|
+
"Do not automatically repeat quota, connection, deadline, or incomplete-stream failures. The backend may already have consumed image quota.",
|
|
566
479
|
],
|
|
567
480
|
parameters: TOOL_PARAMS,
|
|
568
481
|
executionMode: "parallel", // #4: safe to run concurrently — no shared state, saves serialized per-path
|
|
569
482
|
async execute(toolCallId, params: ToolParams, signal, onUpdate, ctx) {
|
|
483
|
+
if (typeof params.prompt !== "string" || !params.prompt.trim() || params.prompt.length > MAX_PROMPT_CHARS) {
|
|
484
|
+
throw new Error("Image prompt must contain 1 to 32,000 characters.");
|
|
485
|
+
}
|
|
570
486
|
const outputFormat = params.outputFormat || "png";
|
|
487
|
+
if (!OUTPUT_FORMATS.includes(outputFormat)) throw new Error("Unsupported image output format.");
|
|
571
488
|
const projectTrusted = typeof ctx.isProjectTrusted === "function" && ctx.isProjectTrusted();
|
|
572
489
|
const config = loadConfig(ctx.cwd, projectTrusted); // #5: load once, pass to resolveSaveConfig
|
|
573
490
|
const requestedModel = params.model || config.model || DEFAULT_MODEL;
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
491
|
+
if (typeof requestedModel !== "string" || !requestedModel.trim() || requestedModel.length > 200) {
|
|
492
|
+
throw new Error("Codex routing model must contain 1 to 200 characters.");
|
|
493
|
+
}
|
|
494
|
+
if (requestedModel.startsWith("gpt-image-")) {
|
|
495
|
+
throw new Error("The model parameter selects a Codex routing model, not an image model. Subscription image-model selection is not verified.");
|
|
578
496
|
}
|
|
579
|
-
const accountId = extractChatGptAccountId(token);
|
|
580
497
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
498
|
+
const saveConfig = resolveSaveConfig(params, ctx.cwd, sessionId, config);
|
|
499
|
+
const auth = await resolveImageAuth(ctx.modelRegistry);
|
|
500
|
+
const provider = auth.provider;
|
|
501
|
+
const model = ctx.modelRegistry.find(provider, requestedModel)?.id || requestedModel;
|
|
581
502
|
const messages: unknown[] = [];
|
|
582
503
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
583
504
|
if (entry.type === "message") messages.push(entry.message);
|
|
@@ -586,18 +507,24 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
586
507
|
const inputImages = await resolveInputImages(params, ctx.cwd, messages);
|
|
587
508
|
|
|
588
509
|
onUpdate?.({
|
|
589
|
-
content: [{ type: "text", text: `Requesting
|
|
590
|
-
details: { provider
|
|
510
|
+
content: [{ type: "text", text: `Requesting image ${inputImages.length > 0 ? "edit" : "generation"} through ${provider}/${model}...` }],
|
|
511
|
+
details: { provider, model, outputFormat, inputImageCount: inputImages.length },
|
|
591
512
|
});
|
|
592
513
|
|
|
593
|
-
const
|
|
514
|
+
const started = Date.now();
|
|
515
|
+
const parsed = await requestImage(params, auth, model, outputFormat, sessionId, inputImages, signal, (stage) => {
|
|
516
|
+
onUpdate?.({
|
|
517
|
+
content: [{ type: "text", text: `Codex image stage: ${stage}.` }],
|
|
518
|
+
details: { provider, model, stage },
|
|
519
|
+
});
|
|
520
|
+
});
|
|
594
521
|
if (!parsed.image) {
|
|
595
522
|
const text = parsed.text.join("").trim();
|
|
596
523
|
throw new Error(text ? `Codex did not return an image. Response text: ${text}` : "Codex did not return an image.");
|
|
597
524
|
}
|
|
598
525
|
|
|
599
526
|
const imageBytes = decodeImageData(parsed.image.result, outputFormat);
|
|
600
|
-
const
|
|
527
|
+
const reportedImage = parsed.image.reported;
|
|
601
528
|
let savedPath: string | undefined;
|
|
602
529
|
let attemptedPath: string | undefined;
|
|
603
530
|
let saveWarning: string | undefined;
|
|
@@ -607,7 +534,7 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
607
534
|
savedPath = await saveImage(imageBytes, outputFormat, saveConfig.outputDir, parsed.image.id || toolCallId);
|
|
608
535
|
onUpdate?.({
|
|
609
536
|
content: [{ type: "text", text: `Image saved to ${savedPath}.` }],
|
|
610
|
-
details: { provider
|
|
537
|
+
details: { provider, model, savedPath, byteCount: imageBytes.length },
|
|
611
538
|
});
|
|
612
539
|
} catch (error) {
|
|
613
540
|
saveWarning = `Image generation succeeded, but the image could not be saved to disk: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -615,8 +542,11 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
615
542
|
}
|
|
616
543
|
|
|
617
544
|
const summary = [
|
|
618
|
-
`Generated image via ${
|
|
545
|
+
`Generated image via ${provider}/${model} using the backend-selected image model.`,
|
|
619
546
|
`Status: ${parsed.image.status}.`,
|
|
547
|
+
reportedImage.size ? `Backend-reported size: ${reportedImage.size}.` : undefined,
|
|
548
|
+
reportedImage.quality ? `Backend-reported quality: ${reportedImage.quality}.` : undefined,
|
|
549
|
+
reportedImage.background ? `Backend-reported background: ${reportedImage.background}.` : undefined,
|
|
620
550
|
parsed.image.revisedPrompt ? `Revised prompt: ${parsed.image.revisedPrompt}` : undefined,
|
|
621
551
|
savedPath ? `Saved image to: ${savedPath}` : "Image was not saved to disk.",
|
|
622
552
|
saveWarning ? `Warning: ${saveWarning}` : undefined,
|
|
@@ -630,9 +560,13 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
630
560
|
{ type: "image", data: parsed.image.result, mimeType: mimeForFormat(outputFormat) },
|
|
631
561
|
],
|
|
632
562
|
details: {
|
|
633
|
-
provider
|
|
563
|
+
provider,
|
|
634
564
|
model,
|
|
635
|
-
backendImageModel: "
|
|
565
|
+
backendImageModel: reportedImage.model ?? "unknown",
|
|
566
|
+
reportedImage,
|
|
567
|
+
transport: "codex-responses",
|
|
568
|
+
generationDurationMs: Date.now() - started,
|
|
569
|
+
byteCount: imageBytes.length,
|
|
636
570
|
outputFormat,
|
|
637
571
|
saveMode: saveConfig.mode,
|
|
638
572
|
savedPath,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-image-gen",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Image generation for Pi using
|
|
3
|
+
"version": "0.1.14",
|
|
4
|
+
"description": "Image generation and editing for Pi using your ChatGPT Codex login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"author": "Jose Mocito",
|
|
@@ -58,16 +58,19 @@
|
|
|
58
58
|
"typebox": "*"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
|
-
"@earendil-works/pi-ai": "^0.
|
|
62
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"typescript": "^
|
|
61
|
+
"@earendil-works/pi-ai": "^0.85.1",
|
|
62
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
63
|
+
"@types/node": "^26.2.0",
|
|
64
|
+
"typebox": "^1.3.10",
|
|
65
|
+
"typescript": "^7.0.2"
|
|
66
66
|
},
|
|
67
67
|
"publishConfig": {
|
|
68
68
|
"access": "public"
|
|
69
69
|
},
|
|
70
70
|
"engines": {
|
|
71
71
|
"node": ">=20.6.0"
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"@mocito/install-telemetry": "0.1.1"
|
|
72
75
|
}
|
|
73
76
|
}
|