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.
- package/CHANGELOG.md +22 -2
- package/CONTRIBUTING.md +32 -3
- package/README.md +35 -4
- package/SECURITY.md +8 -0
- package/extensions/index.ts +130 -196
- package/package.json +10 -7
- package/skills/imagegen/SKILL.md +12 -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/install-telemetry.ts +19 -46
|
@@ -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
|
|
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
|
-
-
|
|
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
|
-
|
|
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.
|
|
233
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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-
|
|
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-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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`.
|
|
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,
|
|
5
|
-
|
|
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
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
"
|
|
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
|
|
148
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
+
}
|
package/src/install-telemetry.ts
CHANGED
|
@@ -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
|
|
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
|
|
55
|
-
if (isTruthyEnvFlag(
|
|
56
|
-
|
|
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(
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
75
|
+
// Best-effort telemetry: ignore local policy and filesystem failures.
|
|
99
76
|
}
|
|
100
77
|
}
|
|
101
|
-
|
|
102
|
-
export function reportInstallTelemetry(): void {
|
|
103
|
-
void reportInstallTelemetryAsync();
|
|
104
|
-
}
|