pi-codex-image-gen 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/CONTRIBUTING.md +10 -2
- package/README.md +21 -13
- package/SECURITY.md +4 -2
- package/extensions/index.ts +46 -46
- package/package.json +1 -1
- package/skills/imagegen/SKILL.md +2 -0
- package/src/codex-response.ts +1 -1
- package/src/image-oauth.ts +223 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.15] - 2026-10-02
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Use `gpt-6-astra` as the default Codex image-routing model instead of `gpt-5.5`. Preserve explicit tool/configuration overrides and backend-selected image rendering.
|
|
14
|
+
|
|
15
|
+
## [0.1.14] - 2026-10-02
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Add package-owned ChatGPT image OAuth through `/login codex-images`, with Pi-managed credential storage and refresh. Keep chat on any provider, including `openai`, without installing Codex or importing Pi's legacy OAuth helpers.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Keep image authentication separate from the unsupported `openai` plan-sharing image route. Preserve available legacy Pi `openai-codex` credentials as a fallback when package-owned image credentials are absent.
|
|
24
|
+
- Keep OAuth refresh errors private and avoid switching accounts after a selected OAuth failure.
|
|
25
|
+
- Keep manual-login guidance visible when the local callback port is occupied.
|
|
26
|
+
|
|
9
27
|
## [0.1.13] - 2026-09-11
|
|
10
28
|
|
|
11
29
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -22,7 +22,7 @@ pi install -l /path/to/pi-mono/packages/pi-codex-image-gen
|
|
|
22
22
|
pi
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
With Pi 0.85.1 or later, run `/login codex-images`, complete the ChatGPT browser login, and ask Pi to generate an image. Chat can stay on `openai` OAuth. Neither the Codex app nor Pi's legacy OAuth helpers are required.
|
|
26
26
|
|
|
27
27
|
For a one-off run without changing settings:
|
|
28
28
|
|
|
@@ -44,6 +44,14 @@ The vendored CLI changes for Images 2.5 cover documented quality and flexible si
|
|
|
44
44
|
|
|
45
45
|
Optional live smoke test (requires explicit approval to use image quota): load this checkout with `pi -e`, generate one PNG, and edit it using `referencedImagePaths`. Verify inline display and saved bytes. Confirm that progress and the final summary do not invent an image model, and that `backendImageModel` is `"unknown"` unless the backend explicitly reports one. Verify `reportedImage` size/quality/background against the response, then inspect actual pixels for dimensions and alpha. A successful image does not prove which backend model ran. Never include credentials or raw image payloads in test reports.
|
|
46
46
|
|
|
47
|
+
For auth compatibility, run the smoke test with `/login codex-images` while chat uses `openai` OAuth. Verify the provider appears in `/login`, not `/model`, and that Pi persists the image credential under its own ID. Check `details.provider: "codex-images"` and `details.transport: "codex-responses"`. If legacy Pi `openai-codex` is available, test it separately without owned image credentials; it should report `openai-codex` / `codex-responses`. With both logins, owned image OAuth wins. A selected auth failure must not switch accounts. With only chat OAuth or an API key, the tool must ask for `/login codex-images` before any image request.
|
|
48
|
+
|
|
49
|
+
Test `/logout codex-images` without changing the chat login. On a remote/headless machine, test the full-redirect-URL fallback and callback-port conflicts. Never paste a callback into chat. Automated tests exercise actual provider registration, Pi login/storage/logout, PKCE/state, loopback/manual callbacks, cancellation, concurrent refresh, and image tool wiring using mocked token/image responses. They do not prove that a fresh live browser grant or a particular account supports the image endpoint.
|
|
50
|
+
|
|
51
|
+
On October 2, 2026, the maintainer reported that the package-owned flow worked in a live smoke test. This is a user-confirmed result, not automated verification of every account or of the served image model.
|
|
52
|
+
|
|
53
|
+
On October 2, 2026, live diagnostics with new `openai` plan-sharing OAuth rejected Responses image tools with `subscription_sharing_unsupported_capability`, and direct Images calls with `hardened_oauth_rule_missing`. OpenAI's [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations) list image generation as unsupported. The same chat grant could call a local image function; separate image-capable Codex OAuth produced verified PNGs through both native Images and Codex Responses endpoints. These probes established the grant distinction, not a live test of this package-owned browser login.
|
|
54
|
+
|
|
47
55
|
## Subscription capability check
|
|
48
56
|
|
|
49
57
|
Checked on September 11, 2026, with a ChatGPT subscription token and no API-key billing. These observations are account- and date-specific, not a public API guarantee.
|
|
@@ -83,7 +91,7 @@ Before opening a pull request:
|
|
|
83
91
|
- Treat save-mode names, config file keys, and auth flow as public interface; changes to defaults or precedence are breaking changes.
|
|
84
92
|
- Do not modify `skills/imagegen/scripts/image_gen.py` without a documented reason; it is a vendored fallback.
|
|
85
93
|
- The Codex Responses SSE contract is private and may change. If generation breaks, inspect sanitized event types, status codes, and allowlisted output metadata. Never log raw response bodies or auth headers.
|
|
86
|
-
-
|
|
94
|
+
- Keep the Pi tool subscription-only: prefer package-owned `codex-images` OAuth, preserve available legacy Pi `openai-codex` support, and never use the `openai` chat token, Codex app credentials, or `OPENAI_API_KEY` for tool requests.
|
|
87
95
|
|
|
88
96
|
## Code of conduct
|
|
89
97
|
|
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Create and edit images without leaving [Pi](https://pi.dev).
|
|
4
4
|
|
|
5
|
-
`pi-codex-image-gen` turns natural-language requests and reference images into PNG, JPEG, or WebP assets through **
|
|
5
|
+
`pi-codex-image-gen` turns natural-language requests and reference images into PNG, JPEG, or WebP assets through **ChatGPT subscription image generation**. Sign in from Pi; no Codex app or separate API key is required.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- **Generate images in conversation** — describe the asset you need and let Pi create it.
|
|
10
10
|
- **Edit from references** — transform up to five local or recent conversation images.
|
|
11
11
|
- **Save where work happens** — return images inline or organize them by project, session, or custom directory.
|
|
12
|
-
- **
|
|
12
|
+
- **Separate image login** — keep Pi chat on `openai` OAuth while the extension handles image authentication.
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -49,7 +49,7 @@ The tool reports generation stages and the backend's returned size, quality, bac
|
|
|
49
49
|
|
|
50
50
|
### Subscription reliability and limits
|
|
51
51
|
|
|
52
|
-
- Requests identify this package with a Pi User-Agent.
|
|
52
|
+
- Requests identify this package with a Pi User-Agent. The image login uses the Codex-compatible public OAuth client; it does not launch the Codex app, read its credentials, import browser cookies, or fall back to paid API calls.
|
|
53
53
|
- One five-minute network deadline covers the connection, retries, and stream. Escape cancels network work; the remote generation may still finish.
|
|
54
54
|
- Prompts: 32,000 characters. References: five regular PNG/JPEG/WebP files or conversation images, at most 20 MiB each and 50 MiB combined.
|
|
55
55
|
- Responses: 100 MiB total, with at most one 32 MiB decoded output image. Base64 and format signatures are checked; this is not a full image decoder. Backend text and revised prompts are limited to 4,000 characters; HTTP error bodies are read only up to 16 KiB and are not displayed.
|
|
@@ -69,13 +69,21 @@ In subscription tests on September 11, 2026, the direct endpoint accepted Flare,
|
|
|
69
69
|
|
|
70
70
|
## Authentication
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
The extension owns an image-capable ChatGPT OAuth flow, registered as **Codex Images** (`codex-images`). Pi stores its credentials and handles refresh. Your chat provider can remain `openai`; image authentication is independent.
|
|
73
73
|
|
|
74
74
|
```
|
|
75
|
-
> /login
|
|
75
|
+
> /login codex-images
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Complete the browser login with your ChatGPT account. You can also run `/login` and select **Codex Images (ChatGPT subscription)**. The browser redirects to `http://localhost:1455/auth/callback`. If the callback cannot reach Pi, paste the **full redirect URL** into Pi's login prompt, not into chat. Login expires after ten minutes.
|
|
79
|
+
|
|
80
|
+
Use Pi 0.85.1 or later. Pi stores the `codex-images` credential in its agent auth store (normally `~/.pi/agent/auth.json`). `/logout codex-images` removes that credential without changing your chat login. No credential files are created by the extension itself.
|
|
81
|
+
|
|
82
|
+
The package implements the Codex-compatible OAuth protocol itself. It neither imports Pi's `openai-codex` OAuth helpers nor requires the Codex app or its credential store. It still depends on OpenAI continuing to accept that public OAuth client and private image endpoint; this is not a new OAuth application registered with OpenAI.
|
|
83
|
+
|
|
84
|
+
Existing Pi `openai-codex` credentials remain a compatibility fallback when `codex-images` credentials are absent and the legacy provider is available. With both image logins, `codex-images` wins. A selected login that fails to refresh or generate does not switch to another account.
|
|
85
|
+
|
|
86
|
+
Both image logins use `https://chatgpt.com/backend-api/codex/responses`. Pi's new `openai` plan-sharing OAuth grant is different and [does not support image generation](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations). The extension never sends that chat token to the Codex backend. API keys do **not** enable this tool or API-key billing.
|
|
79
87
|
|
|
80
88
|
## Configuration
|
|
81
89
|
|
|
@@ -92,7 +100,7 @@ Project config overrides global config only when project trust is active. If pro
|
|
|
92
100
|
{
|
|
93
101
|
"save": "global",
|
|
94
102
|
"saveDir": "~/Pictures/generated",
|
|
95
|
-
"model": "gpt-
|
|
103
|
+
"model": "gpt-6-astra"
|
|
96
104
|
}
|
|
97
105
|
```
|
|
98
106
|
|
|
@@ -102,7 +110,7 @@ Project config overrides global config only when project trust is active. If pro
|
|
|
102
110
|
| --------- | ------ | ---------- | ---------------------------------------- |
|
|
103
111
|
| `save` | string | `"global"` | Default save mode (see below). |
|
|
104
112
|
| `saveDir` | string | — | Directory used when `save=custom`. |
|
|
105
|
-
| `model` | string | `"gpt-
|
|
113
|
+
| `model` | string | `"gpt-6-astra"`| Codex routing model, not the backend image model. |
|
|
106
114
|
|
|
107
115
|
### Environment variables
|
|
108
116
|
|
|
@@ -127,7 +135,7 @@ Project config overrides global config only when project trust is active. If pro
|
|
|
127
135
|
| Parameter | Type | Required | Description |
|
|
128
136
|
| -------------- | ------ | -------- | ------------------------------------------------------------------ |
|
|
129
137
|
| `prompt` | string | ✅ | The image generation prompt. |
|
|
130
|
-
| `model` | string | — | Override the Codex model. Defaults to config or `gpt-
|
|
138
|
+
| `model` | string | — | Override the Codex model. Defaults to config or `gpt-6-astra`. |
|
|
131
139
|
| `outputFormat` | string | — | `png` (default), `jpeg`, or `webp`. |
|
|
132
140
|
| `save` | string | — | Override save mode for this call. |
|
|
133
141
|
| `saveDir` | string | — | Directory when `save=custom`. Relative paths resolve under CWD. |
|
|
@@ -136,8 +144,8 @@ Project config overrides global config only when project trust is active. If pro
|
|
|
136
144
|
|
|
137
145
|
## How it works
|
|
138
146
|
|
|
139
|
-
1. Resolves
|
|
140
|
-
2. Sends a Codex Responses
|
|
147
|
+
1. Resolves package-owned `codex-images` OAuth via Pi, or falls back to available legacy Pi `openai-codex` OAuth.
|
|
148
|
+
2. Sends a request to the Codex Responses endpoint and routing model (default `gpt-6-astra`) with the `image_generation` tool enabled.
|
|
141
149
|
3. For edits, attaches the selected local or conversation images to the request.
|
|
142
150
|
4. The backend selects an image model to generate or edit the image.
|
|
143
151
|
5. Parses the SSE stream and strictly validates the returned base64 and image format.
|
|
@@ -148,8 +156,8 @@ Project config overrides global config only when project trust is active. If pro
|
|
|
148
156
|
|
|
149
157
|
| Symptom | Cause | Fix |
|
|
150
158
|
| --- | --- | --- |
|
|
151
|
-
| "Missing
|
|
152
|
-
| 401 / 403 response |
|
|
159
|
+
| "Missing image OAuth credentials" | No image-capable subscription login; chat OAuth and API keys are not sufficient | Run `/login codex-images` |
|
|
160
|
+
| 401 / 403 response | Image login rejected or token expired | Re-run `/login codex-images` |
|
|
153
161
|
| 429 response | Rate limited | Wait and retry; the extension retries automatically with backoff |
|
|
154
162
|
| "Codex did not return an image" | Backend refused the prompt | Rephrase the prompt and try again |
|
|
155
163
|
| "save=custom requires saveDir" | Missing config | Set `saveDir` in config or `PI_CODEX_IMAGE_SAVE_DIR` env var |
|
package/SECURITY.md
CHANGED
|
@@ -21,9 +21,11 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
|
|
|
21
21
|
|
|
22
22
|
`pi-codex-image-gen` is a Pi package. Pi extensions execute with the same permissions as the local user running Pi. Users should review installed Pi packages and only install packages from sources they trust.
|
|
23
23
|
|
|
24
|
-
The extension
|
|
24
|
+
The extension implements image-capable ChatGPT OAuth under the provider ID `codex-images`, using the Codex-compatible public OAuth client ID (not a client secret). Pi stores access/refresh tokens in its agent credential store, normally `~/.pi/agent/auth.json`, and coordinates refresh. This is local credential persistence, not encrypted secret storage; protect the agent directory and do not commit it. The extension does not write its own credential files or access the Codex app's credential store. `/logout codex-images` removes only the image login. Existing legacy Pi `openai-codex` OAuth remains a fallback when package-owned credentials are absent; refresh or request failure does not switch accounts.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Browser login uses random OAuth state and S256 PKCE. Its callback listener binds only to `127.0.0.1:1455`; the registered redirect URI is `http://localhost:1455/auth/callback`. Loopback HTTP is limited to this local callback. Automatic and pasted callbacks must have the expected origin, path, and state. Pasted input must be the full redirect URL, never a bare authorization code. Invalid callbacks cannot finish login. Listener cleanup runs on success, failure, and cancellation. Login has a ten-minute limit; token requests have a 30-second limit and 64 KiB response bound, with no retries or redirects. Do not paste login URLs into chat or log them.
|
|
27
|
+
|
|
28
|
+
Authorization/token exchange uses fixed `auth.openai.com` HTTPS endpoints with normal TLS validation. Image requests use only `https://chatgpt.com/backend-api/codex/responses`, an honest package User-Agent, and disabled redirects. JWT account claims supply a validated routing header only; OpenAI authenticates the token. The extension does not import browser cookies, use the `openai` plan-sharing chat grant, or switch to API-key billing. Only backend-reported image metadata and allowlisted numeric usage counters are retained; token response bodies, HTTP error bodies, and raw auth-resolution errors are not shown. Known image request credentials are redacted from backend text.
|
|
27
29
|
|
|
28
30
|
Network work has a five-minute deadline and a 100 MiB response bound. Output images are limited to 32 MiB; input images must be regular files and are limited to 20 MiB each and 50 MiB in total. Image checks validate base64 and format signatures, not all image internals. Treat images as untrusted content when opening them in other software.
|
|
29
31
|
|
package/extensions/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Project-local Codex image generation extension.
|
|
3
3
|
*
|
|
4
|
-
* Registers `codex_generate_image`, a tool that uses
|
|
5
|
-
*
|
|
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
6
|
* native `image_generation` tool. The backend selects the image model.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -12,16 +12,16 @@ import { mkdir, open, writeFile } from "node:fs/promises";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { isAbsolute, join, resolve } from "node:path";
|
|
14
14
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
15
|
-
import { CONFIG_DIR_NAME, 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";
|
|
16
16
|
import { type Static, Type } from "typebox";
|
|
17
17
|
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
18
|
+
import { extractImageAccountId, imageAuthProvider, IMAGE_AUTH_PROVIDER } from "../src/image-oauth.js";
|
|
18
19
|
import { abortable, httpFailure, MAX_IMAGE_BYTES, parseCodexSse, withRequestDeadline, type ParsedCodexResponse } from "../src/codex-response.js";
|
|
19
20
|
|
|
20
21
|
const PACKAGE_NAME = "pi-codex-image-gen";
|
|
21
|
-
const
|
|
22
|
-
const DEFAULT_MODEL = "gpt-
|
|
22
|
+
const LEGACY_PROVIDER = "openai-codex";
|
|
23
|
+
const DEFAULT_MODEL = "gpt-6-astra";
|
|
23
24
|
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
24
|
-
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
|
25
25
|
const DEFAULT_SAVE_MODE = "global";
|
|
26
26
|
const OPENAI_BETA_HEADER = "responses=experimental";
|
|
27
27
|
const MAX_RETRIES = 3;
|
|
@@ -135,31 +135,34 @@ interface InputImage {
|
|
|
135
135
|
mimeType: string;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// ---
|
|
138
|
+
// --- Image credentials (independent of the active chat provider) ---
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
try {
|
|
146
|
-
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
|
147
|
-
} catch {
|
|
148
|
-
throw new Error("Failed to decode OpenAI Codex auth token. Run /login for openai-codex again.");
|
|
149
|
-
}
|
|
140
|
+
interface ImageAuth {
|
|
141
|
+
provider: typeof IMAGE_AUTH_PROVIDER | typeof LEGACY_PROVIDER;
|
|
142
|
+
token: string;
|
|
143
|
+
accountId: string;
|
|
150
144
|
}
|
|
151
145
|
|
|
152
|
-
function
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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) };
|
|
161
164
|
}
|
|
162
|
-
|
|
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.`);
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
// --- #10: try/catch readConfigFile replaces racy existsSync + readFileSync ---
|
|
@@ -407,8 +410,7 @@ export function buildRequestBody(
|
|
|
407
410
|
|
|
408
411
|
async function requestImage(
|
|
409
412
|
params: ToolParams,
|
|
410
|
-
|
|
411
|
-
accountId: string,
|
|
413
|
+
auth: ImageAuth,
|
|
412
414
|
model: string,
|
|
413
415
|
outputFormat: OutputFormat,
|
|
414
416
|
sessionId: string,
|
|
@@ -418,8 +420,8 @@ async function requestImage(
|
|
|
418
420
|
): Promise<ParsedCodexResponse> {
|
|
419
421
|
const body = JSON.stringify(buildRequestBody(params, model, outputFormat, sessionId, inputImages));
|
|
420
422
|
const headers: Record<string, string> = {
|
|
421
|
-
Authorization: `Bearer ${token}`,
|
|
422
|
-
"chatgpt-account-id": accountId,
|
|
423
|
+
Authorization: `Bearer ${auth.token}`,
|
|
424
|
+
"chatgpt-account-id": auth.accountId,
|
|
423
425
|
originator: "pi",
|
|
424
426
|
"User-Agent": PACKAGE_NAME,
|
|
425
427
|
"OpenAI-Beta": OPENAI_BETA_HEADER,
|
|
@@ -450,7 +452,7 @@ async function requestImage(
|
|
|
450
452
|
throw new Error(failure.message);
|
|
451
453
|
}
|
|
452
454
|
|
|
453
|
-
return parseCodexSse(response, signal, [token, accountId], onProgress);
|
|
455
|
+
return parseCodexSse(response, signal, [auth.token, auth.accountId], onProgress);
|
|
454
456
|
}
|
|
455
457
|
throw new Error("Codex image generation request failed after all retries.");
|
|
456
458
|
});
|
|
@@ -460,12 +462,13 @@ async function requestImage(
|
|
|
460
462
|
|
|
461
463
|
export default function codexImageGen(pi: ExtensionAPI) {
|
|
462
464
|
reportInstallTelemetry();
|
|
465
|
+
pi.registerProvider(imageAuthProvider);
|
|
463
466
|
|
|
464
467
|
pi.registerTool({
|
|
465
468
|
name: "codex_generate_image",
|
|
466
469
|
label: "Codex Image",
|
|
467
470
|
description:
|
|
468
|
-
"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).
|
|
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.",
|
|
469
472
|
promptSnippet: "Generate or edit bitmap images via the OpenAI Codex ChatGPT backend image_generation tool.",
|
|
470
473
|
promptGuidelines: [
|
|
471
474
|
"Use codex_generate_image when the user asks to generate or edit a raster image with OpenAI/Codex image generation.",
|
|
@@ -491,14 +494,11 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
491
494
|
if (requestedModel.startsWith("gpt-image-")) {
|
|
492
495
|
throw new Error("The model parameter selects a Codex routing model, not an image model. Subscription image-model selection is not verified.");
|
|
493
496
|
}
|
|
494
|
-
const model = ctx.modelRegistry.find(PROVIDER, requestedModel)?.id || requestedModel; // #6: removed dead FALLBACK_MODEL
|
|
495
497
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
496
498
|
const saveConfig = resolveSaveConfig(params, ctx.cwd, sessionId, config);
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
const accountId = extractChatGptAccountId(token);
|
|
499
|
+
const auth = await resolveImageAuth(ctx.modelRegistry);
|
|
500
|
+
const provider = auth.provider;
|
|
501
|
+
const model = ctx.modelRegistry.find(provider, requestedModel)?.id || requestedModel;
|
|
502
502
|
const messages: unknown[] = [];
|
|
503
503
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
504
504
|
if (entry.type === "message") messages.push(entry.message);
|
|
@@ -507,15 +507,15 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
507
507
|
const inputImages = await resolveInputImages(params, ctx.cwd, messages);
|
|
508
508
|
|
|
509
509
|
onUpdate?.({
|
|
510
|
-
content: [{ type: "text", text: `Requesting image ${inputImages.length > 0 ? "edit" : "generation"} through ${
|
|
511
|
-
details: { provider
|
|
510
|
+
content: [{ type: "text", text: `Requesting image ${inputImages.length > 0 ? "edit" : "generation"} through ${provider}/${model}...` }],
|
|
511
|
+
details: { provider, model, outputFormat, inputImageCount: inputImages.length },
|
|
512
512
|
});
|
|
513
513
|
|
|
514
514
|
const started = Date.now();
|
|
515
|
-
const parsed = await requestImage(params,
|
|
515
|
+
const parsed = await requestImage(params, auth, model, outputFormat, sessionId, inputImages, signal, (stage) => {
|
|
516
516
|
onUpdate?.({
|
|
517
517
|
content: [{ type: "text", text: `Codex image stage: ${stage}.` }],
|
|
518
|
-
details: { provider
|
|
518
|
+
details: { provider, model, stage },
|
|
519
519
|
});
|
|
520
520
|
});
|
|
521
521
|
if (!parsed.image) {
|
|
@@ -534,7 +534,7 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
534
534
|
savedPath = await saveImage(imageBytes, outputFormat, saveConfig.outputDir, parsed.image.id || toolCallId);
|
|
535
535
|
onUpdate?.({
|
|
536
536
|
content: [{ type: "text", text: `Image saved to ${savedPath}.` }],
|
|
537
|
-
details: { provider
|
|
537
|
+
details: { provider, model, savedPath, byteCount: imageBytes.length },
|
|
538
538
|
});
|
|
539
539
|
} catch (error) {
|
|
540
540
|
saveWarning = `Image generation succeeded, but the image could not be saved to disk: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -542,7 +542,7 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
542
542
|
}
|
|
543
543
|
|
|
544
544
|
const summary = [
|
|
545
|
-
`Generated image via ${
|
|
545
|
+
`Generated image via ${provider}/${model} using the backend-selected image model.`,
|
|
546
546
|
`Status: ${parsed.image.status}.`,
|
|
547
547
|
reportedImage.size ? `Backend-reported size: ${reportedImage.size}.` : undefined,
|
|
548
548
|
reportedImage.quality ? `Backend-reported quality: ${reportedImage.quality}.` : undefined,
|
|
@@ -560,7 +560,7 @@ export default function codexImageGen(pi: ExtensionAPI) {
|
|
|
560
560
|
{ type: "image", data: parsed.image.result, mimeType: mimeForFormat(outputFormat) },
|
|
561
561
|
],
|
|
562
562
|
details: {
|
|
563
|
-
provider
|
|
563
|
+
provider,
|
|
564
564
|
model,
|
|
565
565
|
backendImageModel: reportedImage.model ?? "unknown",
|
|
566
566
|
reportedImage,
|
package/package.json
CHANGED
package/skills/imagegen/SKILL.md
CHANGED
|
@@ -26,7 +26,9 @@ Within CLI fallback, the CLI exposes three subcommands:
|
|
|
26
26
|
|
|
27
27
|
Rules:
|
|
28
28
|
- Use the Pi `codex_generate_image` tool by default for new image generation requests.
|
|
29
|
+
- Image login is separate from chat: use `/login codex-images` and complete the ChatGPT OAuth flow in Pi. The Codex app is not required. Existing legacy Pi `openai-codex` OAuth can be a fallback; new `openai` plan-sharing chat OAuth cannot generate images. Never ask the user to paste login URLs or tokens into chat.
|
|
29
30
|
- The tool's `model` parameter selects a Codex routing model, not Flare or Sunburst. Do not claim a specific served image model unless the response reports it. Read `details.reportedImage` for backend-reported output settings, then inspect the actual image; prompt requests for quality, dimensions, or transparency are not guarantees.
|
|
31
|
+
- The default routing model is `gpt-6-astra`. Explicit tool parameters and configuration values override this default.
|
|
30
32
|
- Do not automatically repeat quota, connection, timeout, or incomplete-stream failures. The remote generation may already have consumed quota.
|
|
31
33
|
- Use `referencedImagePaths` for edits when every target has a local path. Use `numLastImagesToInclude` only when a target is available solely in recent conversation history. Never provide both selectors. Masks and advanced CLI-only controls still require confirmed CLI fallback.
|
|
32
34
|
- Do not switch to CLI fallback for ordinary generation quality, size, or output file-path control.
|
package/src/codex-response.ts
CHANGED
|
@@ -152,7 +152,7 @@ export async function httpFailure(response: Response, signal: AbortSignal): Prom
|
|
|
152
152
|
const terminal = isQuota(error)
|
|
153
153
|
|| error.code === "moderation_blocked" || error.type === "image_generation_user_error";
|
|
154
154
|
const hint = response.status === 401
|
|
155
|
-
? "
|
|
155
|
+
? "Image login was rejected. Run /login codex-images again."
|
|
156
156
|
: response.status === 403
|
|
157
157
|
? "Codex access was denied. This can be a connection or account restriction; it does not identify the image model."
|
|
158
158
|
: errorHint(error);
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import type { OAuthAuth, OAuthCredential, Provider, ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
|
4
|
+
import { abortable } from "./codex-response.js";
|
|
5
|
+
|
|
6
|
+
export const IMAGE_AUTH_PROVIDER = "codex-images";
|
|
7
|
+
// Public OAuth client used by the image-capable ChatGPT/Codex flow.
|
|
8
|
+
// This is a client identifier, not a secret. No Codex app or Pi legacy helper is used.
|
|
9
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
10
|
+
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
11
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
12
|
+
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
13
|
+
const AUTH_CLAIM = "https://api.openai.com/auth";
|
|
14
|
+
const LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
15
|
+
const TOKEN_TIMEOUT_MS = 30_000;
|
|
16
|
+
const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
17
|
+
const LOGIN_HINT = `Run /login ${IMAGE_AUTH_PROVIDER} again.`;
|
|
18
|
+
|
|
19
|
+
class ImageOAuthDeniedError extends Error {
|
|
20
|
+
constructor() { super(`Image OAuth authorization was denied. ${LOGIN_HINT}`); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractImageAccountId(token: string): string {
|
|
24
|
+
try {
|
|
25
|
+
if (typeof token !== "string" || token.length > MAX_TOKEN_RESPONSE_BYTES
|
|
26
|
+
|| /[^a-zA-Z0-9_.-]/.test(token)) throw new Error();
|
|
27
|
+
const parts = token.split(".");
|
|
28
|
+
if (parts.length !== 3 || parts.some(part => !part)) throw new Error();
|
|
29
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
30
|
+
const accountId = payload?.[AUTH_CLAIM]?.chatgpt_account_id;
|
|
31
|
+
if (typeof accountId !== "string" || accountId.length === 0 || accountId.length > 128
|
|
32
|
+
|| /[^a-zA-Z0-9_-]/.test(accountId)) throw new Error();
|
|
33
|
+
// Routing hint only: OpenAI, not this client, authenticates the JWT.
|
|
34
|
+
return accountId;
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`Image OAuth token has no valid ChatGPT account ID. ${LOGIN_HINT}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseImageOAuthCallback(input: string, state: string): string {
|
|
41
|
+
try {
|
|
42
|
+
if (input.length > 8192) throw new Error();
|
|
43
|
+
const url = new URL(input.trim());
|
|
44
|
+
const expected = new URL(REDIRECT_URI);
|
|
45
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname || url.username || url.password || url.hash
|
|
46
|
+
|| url.searchParams.getAll("state").length !== 1 || url.searchParams.get("state") !== state) throw new Error();
|
|
47
|
+
if (url.searchParams.has("error")) throw new ImageOAuthDeniedError();
|
|
48
|
+
const codes = url.searchParams.getAll("code");
|
|
49
|
+
if (codes.length !== 1 || !codes[0] || codes[0].length > 4096) throw new Error();
|
|
50
|
+
return codes[0];
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof ImageOAuthDeniedError) throw error;
|
|
53
|
+
throw new Error("Invalid image OAuth callback. Paste the full redirect URL from this login attempt.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function startCallback(state: string): Promise<{ server: Server; code: Promise<string> }> {
|
|
58
|
+
let settle!: (code: string) => void;
|
|
59
|
+
let reject!: (error: Error) => void;
|
|
60
|
+
const code = new Promise<string>((resolve, rejectCode) => { settle = resolve; reject = rejectCode; });
|
|
61
|
+
void code.catch(() => undefined);
|
|
62
|
+
let consumed = false;
|
|
63
|
+
const server = createServer((request, response) => {
|
|
64
|
+
if (request.method !== "GET" || consumed) {
|
|
65
|
+
response.writeHead(400).end("Invalid OAuth callback.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const value = parseImageOAuthCallback(new URL(request.url ?? "/", REDIRECT_URI).href, state);
|
|
70
|
+
consumed = true;
|
|
71
|
+
response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" })
|
|
72
|
+
.end("Image login completed. You can close this window.");
|
|
73
|
+
settle(value);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// Do not reflect a code, state, or arbitrary query text into the page.
|
|
76
|
+
response.writeHead(400).end("Invalid OAuth callback. Return to Pi and retry.");
|
|
77
|
+
if (error instanceof ImageOAuthDeniedError) {
|
|
78
|
+
consumed = true;
|
|
79
|
+
reject(error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
server.headersTimeout = 15_000;
|
|
84
|
+
server.requestTimeout = 15_000;
|
|
85
|
+
try {
|
|
86
|
+
await new Promise<void>((resolve, reject) => {
|
|
87
|
+
server.once("error", reject);
|
|
88
|
+
server.listen(1455, "127.0.0.1", () => {
|
|
89
|
+
server.removeListener("error", reject);
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
server.on("error", () => reject(new Error(`Image OAuth callback failed. ${LOGIN_HINT}`)));
|
|
94
|
+
return { server, code };
|
|
95
|
+
} catch {
|
|
96
|
+
server.close();
|
|
97
|
+
throw new Error("Image OAuth callback listener is unavailable.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function requestTokens(body: URLSearchParams, signal: AbortSignal): Promise<OAuthCredential> {
|
|
102
|
+
const deadline = AbortSignal.any([signal, AbortSignal.timeout(TOKEN_TIMEOUT_MS)]);
|
|
103
|
+
try {
|
|
104
|
+
const response = await abortable(fetch(TOKEN_URL, {
|
|
105
|
+
method: "POST", redirect: "error", signal: deadline,
|
|
106
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
107
|
+
body,
|
|
108
|
+
}), deadline);
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
void response.body?.cancel().catch(() => undefined);
|
|
111
|
+
throw new Error();
|
|
112
|
+
}
|
|
113
|
+
if (Number(response.headers.get("content-length")) > MAX_TOKEN_RESPONSE_BYTES || !response.body) {
|
|
114
|
+
void response.body?.cancel().catch(() => undefined);
|
|
115
|
+
throw new Error();
|
|
116
|
+
}
|
|
117
|
+
const reader = response.body.getReader();
|
|
118
|
+
const blocks: Uint8Array[] = [];
|
|
119
|
+
let length = 0;
|
|
120
|
+
try {
|
|
121
|
+
while (true) {
|
|
122
|
+
const { done, value } = await abortable(reader.read(), deadline);
|
|
123
|
+
if (done) break;
|
|
124
|
+
length += value.byteLength;
|
|
125
|
+
if (length > MAX_TOKEN_RESPONSE_BYTES) throw new Error();
|
|
126
|
+
blocks.push(value);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
void reader.cancel().catch(() => undefined);
|
|
130
|
+
reader.releaseLock();
|
|
131
|
+
}
|
|
132
|
+
const data = JSON.parse(Buffer.concat(blocks).toString("utf8"));
|
|
133
|
+
if (typeof data?.access_token !== "string" || !data.access_token
|
|
134
|
+
|| typeof data.refresh_token !== "string" || !data.refresh_token
|
|
135
|
+
|| typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0) throw new Error();
|
|
136
|
+
const expires = Date.now() + data.expires_in * 1000;
|
|
137
|
+
if (!Number.isSafeInteger(expires)) throw new Error();
|
|
138
|
+
const accountId = extractImageAccountId(data.access_token);
|
|
139
|
+
return { type: "oauth", access: data.access_token, refresh: data.refresh_token, expires, accountId };
|
|
140
|
+
} catch {
|
|
141
|
+
// Never expose token responses, refresh tokens, codes, or fetch exceptions.
|
|
142
|
+
if (signal.aborted) throw new Error("Image OAuth was cancelled.");
|
|
143
|
+
throw new Error(`Image OAuth token exchange failed. ${LOGIN_HINT}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function login(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
|
148
|
+
const deadline = AbortSignal.any([interaction.signal, AbortSignal.timeout(LOGIN_TIMEOUT_MS)]);
|
|
149
|
+
const manualAbort = new AbortController();
|
|
150
|
+
let callback: Awaited<ReturnType<typeof startCallback>> | undefined;
|
|
151
|
+
try {
|
|
152
|
+
deadline.throwIfAborted();
|
|
153
|
+
const state = randomBytes(32).toString("base64url");
|
|
154
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
155
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
156
|
+
try {
|
|
157
|
+
callback = await startCallback(state);
|
|
158
|
+
} catch {
|
|
159
|
+
interaction.notify({ type: "info", message: "The local callback port is unavailable. Paste the full redirect URL to finish image login." });
|
|
160
|
+
}
|
|
161
|
+
const url = new URL(AUTHORIZE_URL);
|
|
162
|
+
url.search = new URLSearchParams({
|
|
163
|
+
response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
|
|
164
|
+
scope: "openid profile email offline_access", state,
|
|
165
|
+
code_challenge: challenge, code_challenge_method: "S256",
|
|
166
|
+
id_token_add_organizations: "true", codex_cli_simplified_flow: "true",
|
|
167
|
+
originator: "pi",
|
|
168
|
+
}).toString();
|
|
169
|
+
interaction.notify({
|
|
170
|
+
type: "auth_url", url: url.href,
|
|
171
|
+
instructions: "Sign in with ChatGPT for image generation. This is separate from Pi's OpenAI chat login."
|
|
172
|
+
+ (callback ? "" : " The local callback port is unavailable. Paste the full redirect URL into Pi to finish login."),
|
|
173
|
+
});
|
|
174
|
+
const manual = interaction.prompt({
|
|
175
|
+
type: "manual_code",
|
|
176
|
+
message: callback ? "Complete image login in your browser, or paste the full redirect URL:"
|
|
177
|
+
: "The local callback port is unavailable. Paste the full redirect URL:",
|
|
178
|
+
placeholder: REDIRECT_URI, signal: AbortSignal.any([deadline, manualAbort.signal]),
|
|
179
|
+
}).then(input => parseImageOAuthCallback(input, state));
|
|
180
|
+
const code = await abortable(callback ? Promise.race([callback.code, manual]) : manual, deadline);
|
|
181
|
+
manualAbort.abort();
|
|
182
|
+
return await requestTokens(new URLSearchParams({
|
|
183
|
+
grant_type: "authorization_code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
|
|
184
|
+
code, code_verifier: verifier,
|
|
185
|
+
}), deadline);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (deadline.aborted) throw new Error("Image OAuth login was cancelled or timed out.");
|
|
188
|
+
throw error;
|
|
189
|
+
} finally {
|
|
190
|
+
manualAbort.abort();
|
|
191
|
+
callback?.server.close();
|
|
192
|
+
callback?.server.closeAllConnections();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const imageOAuth: OAuthAuth = {
|
|
197
|
+
name: "Codex Images (ChatGPT subscription)",
|
|
198
|
+
isSubscription: true,
|
|
199
|
+
loginLabel: "Sign in with ChatGPT for images",
|
|
200
|
+
login,
|
|
201
|
+
async refresh(credential, signal) {
|
|
202
|
+
if (typeof credential.refresh !== "string" || !credential.refresh || credential.refresh.length > MAX_TOKEN_RESPONSE_BYTES) {
|
|
203
|
+
throw new Error(`Missing image OAuth refresh token. ${LOGIN_HINT}`);
|
|
204
|
+
}
|
|
205
|
+
return requestTokens(new URLSearchParams({
|
|
206
|
+
grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: credential.refresh,
|
|
207
|
+
}), signal);
|
|
208
|
+
},
|
|
209
|
+
async toAuth(credential) {
|
|
210
|
+
extractImageAccountId(credential.access);
|
|
211
|
+
return { apiKey: credential.access };
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Authentication-only provider: no chat models or model-picker entries.
|
|
216
|
+
export const imageAuthProvider: Provider = {
|
|
217
|
+
id: IMAGE_AUTH_PROVIDER,
|
|
218
|
+
name: imageOAuth.name,
|
|
219
|
+
auth: { oauth: imageOAuth },
|
|
220
|
+
getModels: () => [],
|
|
221
|
+
stream() { throw new Error("Codex Images is an authentication-only provider. Use codex_generate_image."); },
|
|
222
|
+
streamSimple() { throw new Error("Codex Images is an authentication-only provider. Use codex_generate_image."); },
|
|
223
|
+
};
|