pi-codex-image-gen 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.14] - 2026-10-02
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+
15
+ ### Fixed
16
+
17
+ - 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.
18
+ - Keep OAuth refresh errors private and avoid switching accounts after a selected OAuth failure.
19
+ - Keep manual-login guidance visible when the local callback port is occupied.
20
+
9
21
  ## [0.1.13] - 2026-09-11
10
22
 
11
23
  ### 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
- Then run `/login` for `openai-codex` and ask Pi to generate an image.
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
- - Avoid requiring `OPENAI_API_KEY` for the normal Pi tool path; auth piggybacks on Pi's `openai-codex` login.
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 **Codex image generation**, using your existing ChatGPT Codex login instead of a separate API key.
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
- - **No separate API setup** — reuse your existing ChatGPT Plus/Pro Codex authentication.
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. There is no Codex impersonation, browser-cookie import, or paid API fallback.
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
- Uses your existing **openai-codex** login — no `OPENAI_API_KEY` required. If you haven't logged in yet:
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
- Select **ChatGPT Plus/Pro (Codex)** and complete the OAuth flow.
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
 
@@ -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 auth via Pi's `openai-codex` provider (ChatGPT session token).
140
- 2. Sends a Codex Responses API request to the routing model (default `gpt-5.5`) with the `image_generation` tool enabled.
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-5.5`) 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 openai-codex credentials" | Not logged in | Run `/login` and select **ChatGPT Plus/Pro (Codex)** |
152
- | 401 / 403 response | Token expired | Re-run `/login` for openai-codex |
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 uses Pi's existing `openai-codex` login to obtain a short-lived JWT. The token is used only for requests to the Codex Responses API and is never written to disk or logged. Do not commit API keys, tokens, or decoded JWT payloads.
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
- Subscription requests use the fixed HTTPS `chatgpt.com/backend-api/codex/responses` endpoint with normal TLS validation, an honest package User-Agent, and redirects disabled. The extension does not import browser cookies, change authentication, or switch to API-key billing. Only backend-reported image metadata and allowlisted numeric usage counters are retained; raw HTTP error bodies are not shown. Known request credentials are redacted from backend text.
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
 
@@ -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 Pi's existing
5
- * openai-codex ChatGPT/Codex auth to call the Codex Responses backend with the
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 PROVIDER = "openai-codex";
22
+ const LEGACY_PROVIDER = "openai-codex";
22
23
  const DEFAULT_MODEL = "gpt-5.5";
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
- // --- JWT helpers ---
138
+ // --- Image credentials (independent of the active chat provider) ---
139
139
 
140
- function decodeJwtPayload(token: string): Record<string, unknown> {
141
- const parts = token.split(".");
142
- if (parts.length !== 3 || !parts[1]) {
143
- throw new Error("OpenAI Codex auth token is not a JWT. Run /login for openai-codex again.");
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 extractChatGptAccountId(token: string): string {
153
- const payload = decodeJwtPayload(token);
154
- const authClaims = payload[JWT_CLAIM_PATH];
155
- if (!authClaims || typeof authClaims !== "object") {
156
- throw new Error("OpenAI Codex auth token does not contain ChatGPT auth claims. Run /login for openai-codex again.");
157
- }
158
- const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
159
- if (typeof accountId !== "string" || accountId.length === 0) {
160
- throw new Error("OpenAI Codex auth token does not contain chatgpt_account_id. Run /login for openai-codex again.");
146
+ async function resolveImageAuth(registry: ExtensionContext["modelRegistry"]): Promise<ImageAuth> {
147
+ for (const provider of [IMAGE_AUTH_PROVIDER, LEGACY_PROVIDER] as const) {
148
+ let token: string | undefined;
149
+ try {
150
+ if (typeof registry.getProviderAuth === "function") {
151
+ const resolved = await registry.getProviderAuth(provider);
152
+ if (resolved && resolved.source !== "OAuth") {
153
+ throw new Error("Image credentials must use OAuth.");
154
+ }
155
+ token = resolved?.auth.apiKey;
156
+ if (resolved && !token) throw new Error("Missing image access token.");
157
+ } else {
158
+ token = await registry.getApiKeyForProvider(provider);
159
+ }
160
+ } catch {
161
+ throw new Error(`Image authentication failed for ${provider}. Run /login ${IMAGE_AUTH_PROVIDER} again. No alternate account was used.`);
162
+ }
163
+ if (token) return { provider, token, accountId: extractImageAccountId(token) };
161
164
  }
162
- return accountId;
165
+ throw new Error(`Missing image OAuth credentials. Run /login ${IMAGE_AUTH_PROVIDER}. Pi's openai plan-sharing OAuth does not support images; API keys and Codex app credentials are not used.`);
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
- token: string,
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). Uses the existing openai-codex login; does not require OPENAI_API_KEY. Network deadline: 5 minutes; output image limit: 32 MiB; backend text is limited to 4,000 characters.",
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 token = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
498
- if (!token) {
499
- throw new Error(`Missing ${PROVIDER} credentials. Run /login and select ChatGPT Plus/Pro (Codex).`);
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 ${PROVIDER}/${model}...` }],
511
- details: { provider: PROVIDER, model, outputFormat, inputImageCount: inputImages.length },
510
+ content: [{ type: "text", text: `Requesting image ${inputImages.length > 0 ? "edit" : "generation"} through ${provider}/${model}...` }],
511
+ details: { provider, model, outputFormat, inputImageCount: inputImages.length },
512
512
  });
513
513
 
514
514
  const started = Date.now();
515
- const parsed = await requestImage(params, token, accountId, model, outputFormat, sessionId, inputImages, signal, (stage) => {
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: PROVIDER, model, stage },
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: PROVIDER, model, savedPath, byteCount: imageBytes.length },
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 ${PROVIDER}/${model} using the backend-selected image model.`,
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: PROVIDER,
563
+ provider,
564
564
  model,
565
565
  backendImageModel: reportedImage.model ?? "unknown",
566
566
  reportedImage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-image-gen",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Image generation and editing for Pi using your ChatGPT Codex login.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,6 +26,7 @@ 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.
30
31
  - Do not automatically repeat quota, connection, timeout, or incomplete-stream failures. The remote generation may already have consumed quota.
31
32
  - 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.
@@ -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
- ? "Codex login was rejected. Run /login for openai-codex again."
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
+ };