pi-codex-image-gen 0.1.12 → 0.1.13

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,6 +1,6 @@
1
1
  # CLI reference (`scripts/image_gen.py`)
2
2
 
3
- This file is for the fallback CLI mode only. Read it when the user explicitly asks to use `scripts/image_gen.py` / CLI / API / model controls, or after the user explicitly confirms that a transparent-output request should use the `gpt-image-1.5` true-transparency fallback path.
3
+ This file is for the fallback CLI mode only. Read it when the user requests CLI/API controls or confirms a native transparency API fallback.
4
4
 
5
5
  `generate-batch` is a CLI subcommand in this fallback path. It is not a top-level mode of the skill.
6
6
  The word `batch` in a user request is not CLI opt-in by itself.
@@ -73,12 +73,14 @@ python "$IMAGE_GEN" edit \
73
73
 
74
74
  `gpt-image-2` is the default model for new CLI fallback work.
75
75
 
76
+ For explicit Images 2.5 requests, use `--model gpt-image-2.5-flare` or `--model gpt-image-2.5-sunburst`. Their `2026-09-08` snapshots are also supported. Both accept `--quality xhigh` and `--quality max`, and the flexible size constraints below, including `1536x864`. Sizes above `2560x1440` are experimental. Leave 2.5 `--input-fidelity` unset; support is not verified. These options do not apply to the Pi tool.
77
+
76
78
  - Use `--quality low` for fast drafts, thumbnails, and quick iterations.
77
79
  - Use `--quality medium`, `--quality high`, or `--quality auto` for final assets, dense text, diagrams, identity-sensitive edits, and high-resolution outputs.
78
80
  - Square images are typically fastest. Use `--size 1024x1024` for quick square drafts.
79
81
  - If the user asks for 4K-style output, use `--size 3840x2160` for landscape or `--size 2160x3840` for portrait.
80
82
  - Do not pass `--input-fidelity` with `gpt-image-2`; this model always uses high fidelity for image inputs.
81
- - Do not use `--background transparent` with `gpt-image-2`; the default transparent-image workflow uses Pi `codex_generate_image` on a flat chroma-key background plus local removal. Use `gpt-image-1.5` only after the user explicitly confirms the true-transparent CLI fallback, unless they already requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback.
83
+ - `gpt-image-2` supports `--background transparent` in preview with PNG or WebP. Confirm the separately billed API fallback before switching from Pi's chroma-key path.
82
84
 
83
85
  Popular `gpt-image-2` sizes:
84
86
  - `1024x1024`
@@ -140,7 +142,7 @@ python "$IMAGE_GEN" generate \
140
142
  --out output/imagegen/product-cutout.png
141
143
  ```
142
144
 
143
- When using this path, explain briefly that Pi `codex_generate_image` plus chroma-key removal is the default transparent-image path, but this request needs true model-native transparency. `gpt-image-2` does not support `background=transparent`, so `gpt-image-1.5` is required for this confirmed fallback.
145
+ The older-model example above remains available when explicitly requested. Prefer `--model gpt-image-2` for native transparency preview. Explain that this API fallback requires an API key and separate billing; Pi still uses chroma-key removal.
144
146
 
145
147
  ## Quality, input fidelity, and masks (CLI fallback only)
146
148
  These are explicit CLI controls. They are not Pi `codex_generate_image` tool arguments.
@@ -229,8 +231,8 @@ Notes:
229
231
  - For many requested deliverable assets, provide one prompt/job per distinct asset and use semantic filenames when possible.
230
232
 
231
233
  ## CLI notes
232
- - Supported sizes depend on the model. `gpt-image-2` supports flexible constrained sizes; older GPT Image models support `1024x1024`, `1536x1024`, `1024x1536`, or `auto`.
233
- - True transparent CLI outputs require `output_format` to be `png` or `webp` and are not supported by `gpt-image-2`.
234
+ - Supported sizes depend on the model. GPT Image 2 and 2.5 (including the documented snapshots) support flexible constrained sizes; older GPT Image models support `1024x1024`, `1536x1024`, `1024x1536`, or `auto`.
235
+ - Native transparent CLI outputs require `output_format` to be `png` or `webp`. GPT Image 2 support is in preview.
234
236
  - `--prompt-file`, `--output-compression`, `--moderation`, `--max-attempts`, `--fail-fast`, `--force`, and `--no-augment` are supported.
235
237
  - This CLI is intended for GPT Image models. Do not assume older non-GPT image-model behavior applies here.
236
238
 
@@ -1,31 +1,39 @@
1
1
  # Image API quick reference
2
2
 
3
- This file is for the fallback CLI mode only. Use it when the user explicitly asks to use `scripts/image_gen.py` / CLI / API / model controls, or after the user explicitly confirms that a transparent-output request should use the `gpt-image-1.5` true-transparency fallback path.
3
+ This file is for the fallback CLI mode only. Use it when the user explicitly requests CLI/API controls or confirms a native transparency API fallback.
4
4
 
5
5
  These parameters describe the Image API and bundled CLI fallback surface. Do not assume they are normal arguments on the Pi `codex_generate_image` tool.
6
6
 
7
7
  ## Scope
8
- - This fallback CLI is intended for GPT Image models (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, and `gpt-image-1-mini`).
8
+ - This fallback CLI supports GPT Image models, including `gpt-image-2.5-flare` and `gpt-image-2.5-sunburst`.
9
9
  - The Pi `codex_generate_image` tool and the fallback CLI do not expose the same controls.
10
10
 
11
11
  ## Model summary
12
12
 
13
13
  | Model | Quality | Input fidelity | Resolutions | Recommended use |
14
14
  | --- | --- | --- | --- | --- |
15
+ | `gpt-image-2.5-flare` | `low`, `medium`, `high`, `xhigh`, `max`, `auto` | Leave unset; not verified | `auto` or flexible sizes below | Optional fast generation |
16
+ | `gpt-image-2.5-sunburst` | `low`, `medium`, `high`, `xhigh`, `max`, `auto` | Leave unset; not verified | `auto` or flexible sizes below | Optional precision editing |
15
17
  | `gpt-image-2` | `low`, `medium`, `high`, `auto` | Always high fidelity for image inputs; do not set `input_fidelity` | `auto` or flexible sizes that satisfy the constraints below | Default for new CLI/API workflows: high-quality generation and editing, text-heavy images, photorealism, compositing, identity-sensitive edits, and workflows where fewer retries matter |
16
18
  | `gpt-image-1.5` | `low`, `medium`, `high`, `auto` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | True transparent-background fallback and backward-compatible workflows |
17
19
  | `gpt-image-1` | `low`, `medium`, `high`, `auto` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | Legacy compatibility |
18
20
  | `gpt-image-1-mini` | `low`, `medium`, `high`, `auto` | `low`, `high` | `1024x1024`, `1024x1536`, `1536x1024`, `auto` | Cost-sensitive draft batches and lower-stakes previews |
19
21
 
20
- ## gpt-image-2 sizes
22
+ The CLI also recognizes the `2026-09-08` snapshots of both 2.5 models for extended quality settings. Defaults remain unchanged.
21
23
 
22
- `gpt-image-2` accepts `auto` or any `WIDTHxHEIGHT` size that satisfies all constraints:
24
+ Sources: [Flare](https://developers.openai.com/api/docs/models/gpt-image-2.5-flare), [Sunburst](https://developers.openai.com/api/docs/models/gpt-image-2.5-sunburst), [image generation guide](https://developers.openai.com/api/docs/guides/image-generation.md), and [image tool options](https://developers.openai.com/api/docs/guides/tools-image-generation).
25
+
26
+ ## Flexible sizes: GPT Image 2 and 2.5
27
+
28
+ GPT Image 2 and both 2.5 models accept `auto` or any `WIDTHxHEIGHT` size that satisfies all constraints. The CLI also recognizes their documented dated snapshots.
23
29
 
24
30
  - Maximum edge length must be less than or equal to `3840px`.
25
31
  - Both edges must be multiples of `16px`.
26
32
  - Long edge to short edge ratio must not exceed `3:1`.
27
33
  - Total pixels must be at least `655,360` and no more than `8,294,400`.
28
34
 
35
+ For 2.5, resolutions above `2560x1440` are experimental.
36
+
29
37
  Popular sizes:
30
38
 
31
39
  | Label | Size | Notes |
@@ -49,8 +57,8 @@ Square images are typically fastest to generate. For 4K-style output, use `3840x
49
57
  - `prompt`: text prompt
50
58
  - `model`: image model
51
59
  - `n`: number of images (1-10)
52
- - `size`: `auto` by default for `gpt-image-2`; flexible `WIDTHxHEIGHT` sizes are allowed only for `gpt-image-2`; older GPT Image models use `1024x1024`, `1536x1024`, `1024x1536`, or `auto`
53
- - `quality`: `low`, `medium`, `high`, or `auto`
60
+ - `size`: `auto` by default; GPT Image 2 and 2.5 accept flexible `WIDTHxHEIGHT` sizes under the constraints above; older models use `1024x1024`, `1536x1024`, `1024x1536`, or `auto`
61
+ - `quality`: `low`, `medium`, `high`, or `auto`; the documented 2.5 models also accept `xhigh` and `max`
54
62
  - `background`: output transparency behavior (`transparent`, `opaque`, or `auto`) for generated output; this is not the same thing as the prompt's visual scene/backdrop
55
63
  - `output_format`: `png` (default), `jpeg`, `webp`
56
64
  - `output_compression`: 0-100 (jpeg/webp only)
@@ -68,9 +76,11 @@ Model-specific note for `input_fidelity`:
68
76
 
69
77
  ## Transparent backgrounds
70
78
 
71
- `gpt-image-2` does not currently support the Image API `background=transparent` parameter. The skill's default transparent-image path is Pi `codex_generate_image` with a flat chroma-key background, followed by local alpha extraction with `python "scripts/remove_chroma_key.py"`.
79
+ `gpt-image-2` supports `background=transparent` in preview with PNG or WebP. The Pi tool has no background parameter, so its default path remains chroma-key generation followed by local alpha extraction.
80
+
81
+ Both 2.5 API models also document native transparency with PNG or WebP. This public API capability does not establish that subscription parameters are honored.
72
82
 
73
- Use CLI `gpt-image-1.5` with `background=transparent` and a transparent-capable output format such as `png` or `webp` only after the user explicitly confirms that fallback, unless they already requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. If the user asks for true/native transparency, the subject is too complex for clean chroma-key removal, or local background removal fails validation, explain the tradeoff and ask before switching.
83
+ Use CLI `gpt-image-2 --background transparent --output-format png` (or `webp`) only after the user chooses the API path. Explain that it requires an API key and separate billing. Do not silently switch to an older model if preview transparency fails.
74
84
 
75
85
  ## Output
76
86
  - `data[]` list with `b64_json` per image
@@ -79,7 +79,7 @@ Do not add:
79
79
  - Ask for crisp edges, generous padding, and no use of the key color inside the subject.
80
80
  - After generation, remove the background locally with `python "scripts/remove_chroma_key.py" --input <source> --out <final.png> --auto-key border --soft-matte --transparent-threshold 12 --opaque-threshold 220 --despill` and validate the alpha result before shipping it.
81
81
  - Use soft matte and despill for antialiased edges; hard tolerance-only removal is mainly for flat pixel-art or exact-color fixtures.
82
- - Use CLI `gpt-image-1.5 --background transparent --output-format png` only after the user explicitly confirms the fallback, or when the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. Ask first for true/native transparency requests, failed chroma-key validation, or complex transparent subjects such as hair, fur, glass, smoke, liquids, translucent materials, reflective objects, or soft shadows.
82
+ - Use CLI `gpt-image-2 --background transparent --output-format png` (preview) only after the user chooses the separately billed API fallback. Offer it for native transparency requests, failed chroma-key validation, or complex subjects such as hair, glass, smoke, or soft shadows.
83
83
 
84
84
  ## Fallback-only execution controls
85
85
  - `quality`, `input_fidelity`, explicit masks, output format, and output paths are fallback-only execution controls.
@@ -87,7 +87,7 @@ Do not add:
87
87
  - If the user explicitly chooses CLI fallback, see `references/cli.md` and `references/image-api.md` for those controls.
88
88
  - In CLI fallback mode, `gpt-image-2` is the default. It supports `quality=low|medium|high|auto`; use `low` for fast drafts and thumbnails, and move to `medium`, `high`, or `auto` for final assets.
89
89
  - `gpt-image-2` always uses high fidelity for image inputs, so do not set `input_fidelity` with that model.
90
- - If a transparent request needs true CLI transparency, ask before using `gpt-image-1.5` unless the user already explicitly chose it. Explain that Pi-tool chroma-key removal is the default path, but `gpt-image-2` does not support `background=transparent`.
90
+ - For native transparency, offer the GPT Image 2 API preview with PNG or WebP. Ask before switching from Pi's chroma-key path to API billing.
91
91
  - If the user asks for 4K-style output with `gpt-image-2`, use `3840x2160` for landscape or `2160x3840` for portrait.
92
92
 
93
93
  ## Use-case tips
@@ -19,7 +19,7 @@ CLI model notes:
19
19
  - `gpt-image-2` is the fallback CLI default for new workflows.
20
20
  - `gpt-image-2` supports `quality` values `low`, `medium`, `high`, and `auto`.
21
21
  - For 4K-style `gpt-image-2` output, use `3840x2160` or `2160x3840`.
22
- - If transparent output needs true CLI fallback, ask before using `gpt-image-1.5` unless the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback. Explain that Pi-tool chroma-key removal is the default path, but `gpt-image-2` does not support `background=transparent`.
22
+ - For native transparency, offer CLI `gpt-image-2 --background transparent --output-format png` (preview). Ask before switching from Pi's chroma-key path to separate API billing.
23
23
  - Do not set `input_fidelity` with `gpt-image-2`; image inputs already use high fidelity.
24
24
 
25
25
  For prompting principles (structure, specificity, invariants, iteration), see `references/prompting.md`.
@@ -395,7 +395,7 @@ Scene/backdrop: perfectly flat solid #00ff00 chroma-key background for local bac
395
395
  Constraints: background must be one uniform color with no shadows, gradients, texture, reflections, floor plane, or lighting variation; crisp silhouette; generous padding; no halos or fringing; preserve label text exactly; no restyling; do not use #00ff00 anywhere in the subject
396
396
  ```
397
397
 
398
- Post-process note: after Pi tool generation, run `python "scripts/remove_chroma_key.py" --input <source> --out <final.png> --auto-key border --soft-matte --transparent-threshold 12 --opaque-threshold 220 --despill`. Ask before using CLI `gpt-image-1.5 --background transparent --output-format png` for true/native transparency, failed chroma-key validation, or complex subjects such as hair, fur, glass, smoke, liquids, translucent materials, reflections, or soft shadows, unless the user already explicitly requested `gpt-image-1.5`, `scripts/image_gen.py`, or CLI fallback.
398
+ Post-process note: after Pi tool generation, run `python "scripts/remove_chroma_key.py" --input <source> --out <final.png> --auto-key border --soft-matte --transparent-threshold 12 --opaque-threshold 220 --despill`. For native transparency, failed validation, or complex subjects, offer CLI `gpt-image-2 --background transparent --output-format png` (preview). Ask before switching to separate API billing.
399
399
 
400
400
  ### style-transfer
401
401
  ```
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  """Fallback CLI for explicit image generation or editing with GPT Image models.
3
3
 
4
- Used only when the user explicitly opts into CLI fallback mode, or when explicit
5
- transparent output requires the `gpt-image-1.5` fallback path.
4
+ Used only when the user explicitly opts into CLI fallback mode, including
5
+ confirmed native transparency requests with separate API billing.
6
6
 
7
7
  Defaults to gpt-image-2 and a structured prompt augmentation workflow.
8
8
  """
@@ -118,7 +118,7 @@ def _parse_size(size: str) -> Optional[Tuple[int, int]]:
118
118
  return int(match.group(1)), int(match.group(2))
119
119
 
120
120
 
121
- def _validate_gpt_image_2_size(size: str) -> None:
121
+ def _validate_flexible_size(size: str, model: str) -> None:
122
122
  if size == "auto":
123
123
  return
124
124
 
@@ -132,20 +132,20 @@ def _validate_gpt_image_2_size(size: str) -> None:
132
132
  total_pixels = width * height
133
133
 
134
134
  if max_edge > GPT_IMAGE_2_MAX_EDGE:
135
- _die("gpt-image-2 size maximum edge length must be less than or equal to 3840px.")
135
+ _die(f"{model} size maximum edge length must be less than or equal to 3840px.")
136
136
  if width % 16 != 0 or height % 16 != 0:
137
- _die("gpt-image-2 size width and height must be multiples of 16px.")
137
+ _die(f"{model} size width and height must be multiples of 16px.")
138
138
  if max_edge / min_edge > GPT_IMAGE_2_MAX_RATIO:
139
- _die("gpt-image-2 size long edge to short edge ratio must not exceed 3:1.")
139
+ _die(f"{model} size long edge to short edge ratio must not exceed 3:1.")
140
140
  if total_pixels < GPT_IMAGE_2_MIN_PIXELS or total_pixels > GPT_IMAGE_2_MAX_PIXELS:
141
141
  _die(
142
- "gpt-image-2 size total pixels must be at least 655,360 and no more than 8,294,400."
142
+ f"{model} size total pixels must be at least 655,360 and no more than 8,294,400."
143
143
  )
144
144
 
145
145
 
146
146
  def _validate_size(size: str, model: str) -> None:
147
- if model == GPT_IMAGE_2_MODEL:
148
- _validate_gpt_image_2_size(size)
147
+ if _is_gpt_image_2(model) or _is_gpt_image_2_5(model):
148
+ _validate_flexible_size(size, model)
149
149
  return
150
150
 
151
151
  if size not in ALLOWED_LEGACY_SIZES:
@@ -154,9 +154,23 @@ def _validate_size(size: str, model: str) -> None:
154
154
  )
155
155
 
156
156
 
157
- def _validate_quality(quality: str) -> None:
158
- if quality not in ALLOWED_QUALITIES:
159
- _die("quality must be one of low, medium, high, or auto.")
157
+ def _is_gpt_image_2(model: str) -> bool:
158
+ return model in {GPT_IMAGE_2_MODEL, "gpt-image-2-2026-04-21"}
159
+
160
+
161
+ def _is_gpt_image_2_5(model: str) -> bool:
162
+ return model in {
163
+ "gpt-image-2.5-flare",
164
+ "gpt-image-2.5-flare-2026-09-08",
165
+ "gpt-image-2.5-sunburst",
166
+ "gpt-image-2.5-sunburst-2026-09-08",
167
+ }
168
+
169
+
170
+ def _validate_quality(quality: str, model: str) -> None:
171
+ allowed = ALLOWED_QUALITIES | {"xhigh", "max"} if _is_gpt_image_2_5(model) else ALLOWED_QUALITIES
172
+ if quality not in allowed:
173
+ _die(f"quality for {model} must be one of {', '.join(sorted(allowed))}.")
160
174
 
161
175
 
162
176
  def _validate_background(background: Optional[str]) -> None:
@@ -187,13 +201,8 @@ def _validate_model_specific_options(
187
201
  background: Optional[str],
188
202
  input_fidelity: Optional[str] = None,
189
203
  ) -> None:
190
- if model != GPT_IMAGE_2_MODEL:
204
+ if not _is_gpt_image_2(model):
191
205
  return
192
- if background == "transparent":
193
- _die(
194
- "transparent backgrounds are not supported in gpt-image-2, the latest model. "
195
- "Use --model gpt-image-1.5 --background transparent --output-format png instead."
196
- )
197
206
  if input_fidelity is not None:
198
207
  _die(
199
208
  "input_fidelity is not supported in gpt-image-2 because image inputs always use high fidelity for this model."
@@ -210,7 +219,7 @@ def _validate_generate_payload(payload: Dict[str, Any]) -> None:
210
219
  quality = str(payload.get("quality", DEFAULT_QUALITY))
211
220
  background = payload.get("background")
212
221
  _validate_size(size, model)
213
- _validate_quality(quality)
222
+ _validate_quality(quality, model)
214
223
  _validate_background(background)
215
224
  _validate_model_specific_options(model=model, background=background)
216
225
  oc = payload.get("output_compression")
@@ -978,7 +987,7 @@ def main() -> int:
978
987
 
979
988
  _validate_model(args.model)
980
989
  _validate_size(args.size, args.model)
981
- _validate_quality(args.quality)
990
+ _validate_quality(args.quality, args.model)
982
991
  _validate_background(args.background)
983
992
  _validate_model_specific_options(
984
993
  model=args.model,
@@ -0,0 +1,286 @@
1
+ // The subscription Responses contract is private. Keep its parser and safety
2
+ // boundaries separate from Pi tool registration and file handling.
3
+ export const REQUEST_TIMEOUT_MS = 5 * 60_000;
4
+ export const MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
5
+ export const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
6
+ const MAX_ERROR_BYTES = 16 * 1024;
7
+ const MAX_TEXT_CHARS = 4000;
8
+
9
+ export interface ReportedImage {
10
+ model?: string;
11
+ size?: string;
12
+ quality?: string;
13
+ background?: string;
14
+ outputFormat?: string;
15
+ }
16
+
17
+ export interface ParsedCodexResponse {
18
+ image?: {
19
+ id: string;
20
+ status: "completed";
21
+ result: string;
22
+ revisedPrompt?: string;
23
+ reported: ReportedImage;
24
+ };
25
+ text: string[];
26
+ responseId?: string;
27
+ usage?: unknown;
28
+ }
29
+
30
+ function object(value: unknown): Record<string, unknown> {
31
+ return value !== null && typeof value === "object" && !Array.isArray(value)
32
+ ? value as Record<string, unknown> : {};
33
+ }
34
+
35
+ function text(value: unknown, secrets: string[] = []): string {
36
+ if (typeof value !== "string") return "";
37
+ let result = value;
38
+ for (const secret of secrets) {
39
+ if (secret) result = result.split(secret).join("[redacted]");
40
+ }
41
+ // Also hide a JWT cut short by the text bound; exact-token matching alone
42
+ // cannot redact a credential that arrives across the truncation boundary.
43
+ return result.replace(/\beyJ[A-Za-z0-9_.-]*/g, "[redacted]")
44
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, MAX_TEXT_CHARS);
45
+ }
46
+
47
+ function identifier(value: unknown, secrets: string[]): string | undefined {
48
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value)
49
+ && !secrets.some(secret => secret && value.includes(secret)) ? value : undefined;
50
+ }
51
+
52
+ export function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
53
+ return new Promise<T>((resolve, reject) => {
54
+ const abort = () => reject(signal.reason);
55
+ if (signal.aborted) abort();
56
+ else signal.addEventListener("abort", abort, { once: true });
57
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
58
+ });
59
+ }
60
+
61
+ export async function withRequestDeadline<T>(
62
+ signal: AbortSignal | undefined,
63
+ run: (signal: AbortSignal) => Promise<T>,
64
+ ): Promise<T> {
65
+ const controller = new AbortController();
66
+ const abort = () => controller.abort(new Error("Image generation was aborted."));
67
+ if (signal?.aborted) abort();
68
+ else signal?.addEventListener("abort", abort, { once: true });
69
+ const timer = setTimeout(() => controller.abort(new Error(
70
+ "Image generation timed out after 5 minutes. The backend may still finish; no automatic retry was made.",
71
+ )), REQUEST_TIMEOUT_MS);
72
+ try {
73
+ controller.signal.throwIfAborted();
74
+ return await run(controller.signal);
75
+ } catch (error) {
76
+ if (controller.signal.aborted) throw controller.signal.reason;
77
+ throw error;
78
+ } finally {
79
+ clearTimeout(timer);
80
+ signal?.removeEventListener("abort", abort);
81
+ }
82
+ }
83
+
84
+ async function* chunks(response: Response, limit: number, signal: AbortSignal): AsyncGenerator<Uint8Array> {
85
+ if (!response.body) throw new Error("Codex response did not include a stream body.");
86
+ const reader = response.body.getReader();
87
+ let bytes = 0;
88
+ try {
89
+ const declared = Number(response.headers.get("content-length"));
90
+ if (declared > limit) throw new Error("Codex response exceeded the size limit.");
91
+ while (true) {
92
+ signal.throwIfAborted();
93
+ let part: ReadableStreamReadResult<Uint8Array>;
94
+ try {
95
+ part = await abortable(reader.read(), signal);
96
+ } catch {
97
+ signal.throwIfAborted();
98
+ throw new Error("Codex response stream was interrupted. The backend may still finish; no automatic retry was made.");
99
+ }
100
+ const { done, value } = part;
101
+ if (done) break;
102
+ bytes += value.byteLength;
103
+ if (bytes > limit) throw new Error("Codex response exceeded the size limit.");
104
+ yield value;
105
+ }
106
+ } finally {
107
+ // Do not let a stalled stream cancellation defeat the request deadline.
108
+ void reader.cancel().catch(() => undefined);
109
+ reader.releaseLock();
110
+ }
111
+ }
112
+
113
+ const QUOTA_CODES = new Set([
114
+ "insufficient_quota", "quota_exceeded", "usage_limit_reached", "usage_limit_exceeded",
115
+ "billing_hard_limit_reached", "billing_not_active", "organization_usage_limit_exceeded",
116
+ "workspace_member_usage_limit_reached",
117
+ ]);
118
+
119
+ function isQuota(error: Record<string, unknown>): boolean {
120
+ return [error.code, error.type].some(value => typeof value === "string" && QUOTA_CODES.has(value));
121
+ }
122
+
123
+ function errorHint(error: unknown): string {
124
+ const { code, type } = object(error);
125
+ if (isQuota({ code, type })) {
126
+ return "Codex subscription quota is unavailable or exhausted. Check your plan or wait for its reset.";
127
+ }
128
+ if (code === "moderation_blocked" || type === "image_generation_user_error") {
129
+ return "Codex could not generate this image. Review the prompt and input images before trying again.";
130
+ }
131
+ return "Codex could not complete the image request.";
132
+ }
133
+
134
+ export async function httpFailure(response: Response, signal: AbortSignal): Promise<{ message: string; retry: boolean }> {
135
+ if (response.headers.get("cf-mitigated") === "challenge") {
136
+ void response.body?.cancel().catch(() => undefined);
137
+ return { message: "Codex connection was challenged by Cloudflare. This does not establish model or subscription availability.", retry: false };
138
+ }
139
+ let body = "";
140
+ try {
141
+ const decoder = new TextDecoder();
142
+ for await (const chunk of chunks(response, MAX_ERROR_BYTES, signal)) body += decoder.decode(chunk, { stream: true });
143
+ body += decoder.decode();
144
+ } catch {
145
+ signal.throwIfAborted();
146
+ // A large or unreadable error body is deliberately not exposed.
147
+ }
148
+ let error: Record<string, unknown> = {};
149
+ try {
150
+ error = object(object(JSON.parse(body)).error);
151
+ } catch { /* HTML and other non-JSON error bodies are not diagnostics. */ }
152
+ const terminal = isQuota(error)
153
+ || error.code === "moderation_blocked" || error.type === "image_generation_user_error";
154
+ const hint = response.status === 401
155
+ ? "Codex login was rejected. Run /login for openai-codex again."
156
+ : response.status === 403
157
+ ? "Codex access was denied. This can be a connection or account restriction; it does not identify the image model."
158
+ : errorHint(error);
159
+ return {
160
+ message: `Codex image request failed (HTTP ${response.status}). ${hint}`,
161
+ retry: !terminal && [429, 500, 502, 503, 504].includes(response.status),
162
+ };
163
+ }
164
+
165
+ function reportedImage(item: Record<string, unknown>, secrets: string[]): ReportedImage {
166
+ const reported: ReportedImage = {};
167
+ const model = item.model;
168
+ if (typeof model === "string" && /^gpt-image-[a-z0-9.-]{1,80}$/.test(model)
169
+ && !secrets.some(secret => secret && model.includes(secret))) reported.model = model;
170
+ if (typeof item.size === "string" && /^[1-9]\d{0,4}x[1-9]\d{0,4}$/.test(item.size)) reported.size = item.size;
171
+ if (typeof item.quality === "string" && ["low", "medium", "high", "xhigh", "max", "auto"].includes(item.quality)) reported.quality = item.quality;
172
+ if (typeof item.background === "string" && ["transparent", "opaque", "auto"].includes(item.background)) reported.background = item.background;
173
+ if (typeof item.output_format === "string" && ["png", "jpeg", "webp"].includes(item.output_format)) reported.outputFormat = item.output_format;
174
+ return reported;
175
+ }
176
+
177
+ export async function parseCodexSse(
178
+ response: Response,
179
+ signal: AbortSignal,
180
+ secrets: string[],
181
+ onProgress?: (stage: string) => void,
182
+ ): Promise<ParsedCodexResponse> {
183
+ const parsed: ParsedCodexResponse = { text: [] };
184
+ let completed = false;
185
+ let textChars = 0;
186
+ let lastStage: string | undefined;
187
+ const image = (value: unknown) => {
188
+ const item = object(value);
189
+ if (item.type !== "image_generation_call") return;
190
+ if (parsed.image) throw new Error("Codex returned more than one image. No automatic retry was made.");
191
+ if (item.status !== "completed") throw new Error("Codex image generation did not complete.");
192
+ if (typeof item.result !== "string" || !item.result) throw new Error("Codex image_generation_call did not contain image data.");
193
+ if (item.result.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4) throw new Error("Codex image exceeded the 32 MiB size limit.");
194
+ parsed.image = {
195
+ id: identifier(item.id, secrets) ?? "image_generation",
196
+ status: "completed",
197
+ result: item.result,
198
+ revisedPrompt: text(item.revised_prompt, secrets) || undefined,
199
+ reported: reportedImage(item, secrets),
200
+ };
201
+ };
202
+ const handle = (frame: string) => {
203
+ const data = frame.split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join("\n");
204
+ if (!data || data === "[DONE]") return;
205
+ let event: Record<string, unknown>;
206
+ try { event = object(JSON.parse(data)); }
207
+ catch { throw new Error("Codex returned an invalid stream event. No automatic retry was made."); }
208
+ switch (event.type) {
209
+ case "error":
210
+ case "response.failed":
211
+ throw new Error(errorHint(object(event.response).error ?? event.error ?? event));
212
+ case "response.incomplete":
213
+ throw new Error("Codex response was incomplete. No automatic retry was made.");
214
+ case "response.created":
215
+ parsed.responseId = identifier(object(event.response).id, secrets);
216
+ break;
217
+ case "response.output_text.delta":
218
+ if (typeof event.delta === "string" && textChars < MAX_TEXT_CHARS) {
219
+ const delta = event.delta.slice(0, MAX_TEXT_CHARS - textChars);
220
+ parsed.text.push(delta);
221
+ textChars += delta.length;
222
+ }
223
+ break;
224
+ case "response.output_item.done":
225
+ image(event.item);
226
+ break;
227
+ case "response.completed": {
228
+ const final = object(event.response);
229
+ parsed.responseId = identifier(final.id, secrets) ?? parsed.responseId;
230
+ // Usage contains numeric counters only; never persist arbitrary response objects.
231
+ const usage = object(final.usage);
232
+ const counters: Record<string, unknown> = Object.fromEntries(
233
+ ["input_tokens", "output_tokens", "total_tokens"].flatMap(key =>
234
+ typeof usage[key] === "number" && Number.isFinite(usage[key]) && usage[key] >= 0
235
+ ? [[key, usage[key]]] : []),
236
+ );
237
+ for (const [key, fields] of [
238
+ ["input_tokens_details", ["cached_tokens"]],
239
+ ["output_tokens_details", ["reasoning_tokens"]],
240
+ ] as const) {
241
+ const values = object(usage[key]);
242
+ const details = Object.fromEntries(fields.flatMap(field =>
243
+ typeof values[field] === "number" && Number.isFinite(values[field]) && values[field] >= 0
244
+ ? [[field, values[field]]] : []));
245
+ if (Object.keys(details).length) counters[key] = details;
246
+ }
247
+ if (Object.keys(counters).length) parsed.usage = counters;
248
+ if (!parsed.image && Array.isArray(final.output)) final.output.forEach(image);
249
+ completed = true;
250
+ break;
251
+ }
252
+ case "response.image_generation_call.in_progress":
253
+ case "response.image_generation_call.generating":
254
+ case "response.image_generation_call.completed":
255
+ if (event.type !== lastStage) {
256
+ lastStage = event.type;
257
+ onProgress?.(event.type.split(".").at(-1)!);
258
+ }
259
+ break;
260
+ }
261
+ };
262
+ const decoder = new TextDecoder();
263
+ let buffer = "";
264
+ let scanFrom = 0;
265
+ for await (const chunk of chunks(response, MAX_RESPONSE_BYTES, signal)) {
266
+ buffer += decoder.decode(chunk, { stream: true });
267
+ const separator = /\r?\n\r?\n/g;
268
+ separator.lastIndex = scanFrom;
269
+ let match: RegExpExecArray | null;
270
+ while ((match = separator.exec(buffer))) {
271
+ handle(buffer.slice(0, match.index));
272
+ buffer = buffer.slice(match.index + match[0].length);
273
+ if (completed) break;
274
+ separator.lastIndex = 0;
275
+ }
276
+ if (completed) break;
277
+ scanFrom = Math.max(0, buffer.length - 3);
278
+ }
279
+ if (!completed) {
280
+ buffer += decoder.decode();
281
+ if (buffer.trim()) handle(buffer);
282
+ }
283
+ if (!completed) throw new Error("Codex stream ended before completion. The backend may still finish; no automatic retry was made.");
284
+ parsed.text = [text(parsed.text.join(""), secrets)];
285
+ return parsed;
286
+ }
@@ -1,12 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  const PACKAGE_NAME = "pi-codex-image-gen";
8
- const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
9
- const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
8
+ const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
10
9
  const CI_ENVIRONMENT_VARIABLES = [
11
10
  "APPVEYOR",
12
11
  "BITBUCKET_BUILD_NUMBER",
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
24
23
  "VERCEL",
25
24
  ];
26
25
 
27
- interface InstallTelemetryState {
28
- lastReportedVersion?: string;
29
- }
30
-
31
26
  interface PiSettingsDocument {
32
27
  enableInstallTelemetry?: unknown;
33
28
  }
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
51
46
  return normalized !== "0" && normalized !== "false" && normalized !== "no";
52
47
  }
53
48
 
54
- function isCiEnvironment(): boolean {
55
- if (isTruthyEnvFlag(process.env.CI)) return true;
56
- return CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]));
57
- }
58
-
59
- function isInstallTelemetryEnabled(): boolean {
60
- if (isCiEnvironment()) return false;
61
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
62
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
49
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
50
+ if (isTruthyEnvFlag(env.CI)) return false;
51
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
52
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
63
53
 
64
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
65
- return settings.enableInstallTelemetry !== false;
54
+ const settings = readJsonFile(settingsPath) as PiSettingsDocument;
55
+ if (settings.enableInstallTelemetry === false) return false;
56
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
57
+ return true;
66
58
  }
67
59
 
68
60
  function getPackageVersion(): string {
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
70
62
  return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
71
63
  }
72
64
 
73
- function getInstallTelemetryUserAgent(version: string): string {
74
- const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
75
- const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
76
- return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
77
- }
78
-
79
- async function reportInstallTelemetryAsync(): Promise<void> {
65
+ export function reportInstallTelemetry(): void {
80
66
  try {
81
- if (!isInstallTelemetryEnabled()) return;
82
-
83
- const version = getPackageVersion();
84
- const extensionsDir = join(getAgentDir(), "extensions");
85
- const statePath = join(extensionsDir, "pi-codex-image-gen-install.json");
86
- const state = readJsonFile(statePath) as InstallTelemetryState;
87
- if (state.lastReportedVersion === version) return;
88
-
89
- await mkdir(extensionsDir, { recursive: true });
90
- await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
91
-
92
- const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
93
- await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
94
- headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
95
- signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
96
- });
67
+ void report({
68
+ endpoint: INSTALL_TELEMETRY_ENDPOINT,
69
+ tool: PACKAGE_NAME,
70
+ version: getPackageVersion(),
71
+ statePath: join(getAgentDir(), "extensions", "pi-codex-image-gen-install.json"),
72
+ enabled: isInstallTelemetryEnabled(),
73
+ }).catch(() => undefined);
97
74
  } catch {
98
- // Best-effort telemetry: ignore settings, filesystem, and network failures.
75
+ // Best-effort telemetry: ignore local policy and filesystem failures.
99
76
  }
100
77
  }
101
-
102
- export function reportInstallTelemetry(): void {
103
- void reportInstallTelemetryAsync();
104
- }