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.
@@ -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 Pi's existing
5
- * openai-codex ChatGPT/Codex auth to call the Codex Responses backend with the
6
- * native `image_generation` tool. The backend maps that tool to gpt-image-2.
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 { mkdir, readFile, writeFile } from "node:fs/promises";
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 PROVIDER = "openai-codex";
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 that should invoke image generation. Defaults to ${DEFAULT_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
- // --- #11: Typed SSE event discriminated union ---
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
- function decodeJwtPayload(token: string): Record<string, unknown> {
174
- const parts = token.split(".");
175
- if (parts.length !== 3 || !parts[1]) {
176
- throw new Error("OpenAI Codex auth token is not a JWT. Run /login for openai-codex again.");
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 extractChatGptAccountId(token: string): string {
186
- const payload = decodeJwtPayload(token);
187
- const authClaims = payload[JWT_CLAIM_PATH];
188
- if (!authClaims || typeof authClaims !== "object") {
189
- throw new Error("OpenAI Codex auth token does not contain ChatGPT auth claims. Run /login for openai-codex again.");
190
- }
191
- const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
192
- if (typeof accountId !== "string" || accountId.length === 0) {
193
- throw new Error("OpenAI Codex auth token does not contain chatgpt_account_id. Run /login for openai-codex again.");
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
- return accountId;
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, ".pi", "extensions", "codex-image-gen.json"));
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, ".pi", "generated-images", safeSessionId) };
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 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
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
- return Promise.all(
337
- paths.map(async (path) => {
338
- const normalized = path.startsWith("@") ? path.slice(1) : path;
339
- const absolutePath = resolveUnderCwd(cwd, normalized);
340
- let bytes: Buffer;
341
- try {
342
- bytes = await readFile(absolutePath);
343
- } catch (error) {
344
- throw new Error(`Unable to read referenced image at ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`);
345
- }
346
- return { data: bytes.toString("base64"), mimeType: mimeFromBytes(bytes, absolutePath) };
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
- token: string,
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
- for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
527
- if (signal?.aborted) throw new Error("Image generation was aborted.");
528
-
529
- const response = await fetch(CODEX_RESPONSES_URL, {
530
- method: "POST",
531
- headers,
532
- body,
533
- signal,
534
- });
535
-
536
- if (!response.ok) {
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
- return parseCodexSse(response, signal);
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
- throw new Error("Codex image generation request failed after all retries.");
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 (gpt-image-2). Accepts up to five local or recent conversation images. Uses the existing openai-codex login; does not require OPENAI_API_KEY.",
562
- promptSnippet: "Generate or edit bitmap images via the OpenAI Codex ChatGPT backend gpt-image-2 image_generation tool.",
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
- const model = ctx.modelRegistry.find(PROVIDER, requestedModel)?.id || requestedModel; // #6: removed dead FALLBACK_MODEL
575
- const token = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
576
- if (!token) {
577
- throw new Error(`Missing ${PROVIDER} credentials. Run /login and select ChatGPT Plus/Pro (Codex).`);
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 gpt-image-2 ${inputImages.length > 0 ? "edit" : "generation"} through ${PROVIDER}/${model}...` }],
590
- details: { provider: PROVIDER, model, outputFormat, inputImageCount: inputImages.length },
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 parsed = await requestImage(params, token, accountId, model, outputFormat, sessionId, inputImages, signal);
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 saveConfig = resolveSaveConfig(params, ctx.cwd, sessionId, config);
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: PROVIDER, model, savedPath, byteCount: imageBytes.length },
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 ${PROVIDER}/${model} using backend gpt-image-2.`,
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: PROVIDER,
563
+ provider,
634
564
  model,
635
- backendImageModel: "gpt-image-2",
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.12",
4
- "description": "Image generation for Pi using the ChatGPT Images 2.0 model.",
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.80.0",
62
- "@earendil-works/pi-coding-agent": "^0.80.0",
63
- "typebox": "^1.3.4",
64
- "@types/node": "^26.1.0",
65
- "typescript": "^6.0.3"
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
  }