oira666_pi-image-generation 0.0.1 → 0.0.2

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/README.md CHANGED
@@ -1 +1,45 @@
1
+ # pi-image-generation
2
+
3
+ ```sh
1
4
  pi install npm:oira666_pi-image-generation
5
+ ```
6
+
7
+ Generate and edit PNG images with the `image_gen` tool. Without settings, the extension uses the stored `openai-codex` subscription login, as before.
8
+
9
+ ## Credentials
10
+
11
+ Create `~/.pi/agent/pi-image-generation-settings.json` to choose a different OpenAI provider registered in pi:
12
+
13
+ ```json
14
+ {
15
+ "provider": "openai-dmitry"
16
+ }
17
+ ```
18
+
19
+ Pi resolves the selected provider's credentials and manages OAuth refresh. A Codex subscription alias uses the ChatGPT image backend. A provider resolving an OpenAI API key uses the OpenAI Images API.
20
+
21
+ Alternatively, supply a literal OpenAI API key (billed to your OpenAI API account):
22
+
23
+ ```json
24
+ {
25
+ "apiKey": "sk-your-openai-api-key"
26
+ }
27
+ ```
28
+
29
+ Use **exactly one** of `provider` or `apiKey`. Empty, malformed, unreadable, or ambiguous settings disable image generation with a warning; they never fall back to another account. API keys are literal strings, not shell commands or environment-variable expressions.
30
+
31
+ ### Project overrides
32
+
33
+ In a trusted project, settings are checked in this order (first existing file wins):
34
+
35
+ 1. `.pi/agent/pi-image-generation-settings.json`
36
+ 2. `.pi/pi-image-generation-settings.json` (standard pi project config directory)
37
+ 3. `~/.pi/agent/pi-image-generation-settings.json`
38
+
39
+ Project settings replace the whole global file, so a project provider does not inherit a global API key. Untrusted projects use only global settings. `PI_CODING_AGENT_DIR` overrides the global agent directory.
40
+
41
+ Run `/reload` after changing settings or logging in to update tool and skill availability. Credentials are also checked again on each tool call. The active conversation model can use any provider.
42
+
43
+ Only OpenAI credentials are supported: selected subscription providers must have a `https://chatgpt.com` base URL, and API-key providers a `https://api.openai.com` base URL. Other or unknown origins are rejected to avoid sending another service's credentials to OpenAI. Image requests use fixed OpenAI endpoints, not custom provider paths or headers. There is no automatic fallback between accounts or services.
44
+
45
+ Keep API-key settings private (`chmod 600 <settings-file>`) and do not commit them to source control. Image prompts and supplied references are uploaded to the selected image service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-image-generation",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Image generation and reference-image editing for pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,7 +5,7 @@ description: Generate and edit raster images with image_gen, including photos, i
5
5
 
6
6
  # Image generation
7
7
 
8
- Use `image_gen` for new raster artwork, image edits, and variations. The tool is available to the current pi model regardless of its provider. It uses the configured Codex subscription for the image request; do not launch another Codex agent, request an API key, or fall back to a paid image API.
8
+ Use `image_gen` for new raster artwork, image edits, and variations. The tool is available to the current pi model regardless of its provider. It uses the OpenAI provider or API key selected in `pi-image-generation-settings.json`, defaulting to the Codex subscription. Do not launch another Codex agent, request secrets in chat, or switch accounts or services as a fallback.
9
9
 
10
10
  ## Choose the right operation
11
11
 
@@ -110,6 +110,6 @@ Recent-image edit:
110
110
 
111
111
  ## Authentication and failures
112
112
 
113
- The tool and this skill load only when pi has an `openai-codex` OAuth subscription credential in its `auth.json`. Pi manages token refresh. After adding or removing that login, use `/reload` to update extension and skill registration. The active conversation model does not need to be OpenAI.
113
+ By default, the tool and this skill load when pi has an `openai-codex` OAuth subscription login. `pi-image-generation-settings.json` in the global agent directory or trusted project `.pi` (also `.pi/agent`) directory can select another provider with `provider` or supply an OpenAI `apiKey`. Project settings override global settings. API-key requests use the paid OpenAI Images API, not the subscription backend. Pi manages provider credential resolution and OAuth refresh. After changing credentials or settings, use `/reload` to update registration. The active conversation model does not need to be OpenAI.
114
114
 
115
- If authentication fails, tell the user to log in with `/login openai-codex`. For rate limits, wait rather than changing providers. After a timeout or connection failure, the image request may already have consumed allowance. Do not claim a file exists unless the tool returned a saved path.
115
+ If authentication fails, ask the user to check their configured provider login or API-key settings; never ask them to paste secrets into chat. For rate limits, wait rather than changing providers. After a timeout or connection failure, the image request may already have consumed allowance or incurred API charges. Do not claim a file exists unless the tool returned a saved path.
package/src/auth.ts CHANGED
@@ -1,20 +1,34 @@
1
1
  import { readStoredCredential, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ImageSettings } from "./settings.ts";
2
3
 
3
4
  export const PROVIDER = "openai-codex";
4
5
  export const OFF_WARNING = "image generation is off, no openai-codex provider configured.";
5
6
 
6
- export function hasSubscription(): boolean {
7
- const credential = readStoredCredential(PROVIDER);
7
+ export function hasSubscription(provider = PROVIDER): boolean {
8
+ const credential = readStoredCredential(provider);
8
9
  return credential?.type === "oauth" && typeof credential.access === "string" &&
9
10
  credential.access.length > 0 && typeof credential.refresh === "string" &&
10
11
  credential.refresh.length > 0;
11
12
  }
12
13
 
13
- export interface ImageAuth {
14
- accessToken: string;
15
- accountId: string;
14
+ export function hasImageAuth(
15
+ settings: ImageSettings | undefined,
16
+ registry: Pick<ExtensionContext["modelRegistry"], "getProviderAuthStatus">,
17
+ ): boolean {
18
+ if (!settings) return hasSubscription();
19
+ if ("apiKey" in settings) return true;
20
+ if (readStoredCredential(settings.provider)?.type === "oauth") return hasSubscription(settings.provider);
21
+ return registry.getProviderAuthStatus(settings.provider).configured;
16
22
  }
17
23
 
24
+ export function offWarning(settings?: ImageSettings): string {
25
+ return settings && "provider" in settings
26
+ ? `image generation is off, no ${settings.provider} provider configured.`
27
+ : OFF_WARNING;
28
+ }
29
+
30
+ export type ImageAuth = { accessToken: string; accountId: string } | { apiKey: string };
31
+
18
32
  // Only extracts routing metadata. The backend, not this decoder, verifies the JWT.
19
33
  export function accountIdFromToken(token: string): string | undefined {
20
34
  try {
@@ -27,25 +41,44 @@ export function accountIdFromToken(token: string): string | undefined {
27
41
  }
28
42
 
29
43
  export async function resolveImageAuth(
30
- registry: Pick<ExtensionContext["modelRegistry"], "getProviderAuth">,
44
+ registry: Pick<ExtensionContext["modelRegistry"], "getProviderAuth"> &
45
+ Partial<Pick<ExtensionContext["modelRegistry"], "getProvider">>,
46
+ settings?: ImageSettings,
31
47
  ): Promise<ImageAuth> {
32
- if (!hasSubscription()) throw new Error(OFF_WARNING + " Use /login openai-codex, then /reload.");
48
+ if (settings && "apiKey" in settings) return { apiKey: settings.apiKey };
49
+ const provider = settings?.provider ?? PROVIDER;
50
+ // With no settings, preserve the subscription-only default (no paid API fallback).
51
+ if (!settings && !hasSubscription()) throw new Error(OFF_WARNING + " Use /login openai-codex, then /reload.");
33
52
 
34
53
  // Pi owns token refresh, file locking and persistence. Never refresh or write auth.json here.
35
54
  let resolved;
36
55
  try {
37
- resolved = await registry.getProviderAuth(PROVIDER);
56
+ resolved = await registry.getProviderAuth(provider);
38
57
  } catch {
39
- throw new Error("Could not refresh the openai-codex login. Use /login openai-codex, then /reload.");
58
+ throw new Error(`Could not refresh or resolve the ${provider} login. Use /login ${provider}, then /reload.`);
40
59
  }
41
- const credential = readStoredCredential(PROVIDER);
60
+ const credential = readStoredCredential(provider);
42
61
  const accessToken = resolved?.auth.apiKey;
43
- // Reject environment/API-key fallbacks and provider overrides that changed the credential.
44
- if (credential?.type !== "oauth" || !accessToken || accessToken !== credential.access) {
45
- throw new Error("Image generation requires the stored openai-codex subscription login, not an API key or override.");
62
+ if (!accessToken) throw new Error(offWarning(settings) + ` Use /login ${provider}, then /reload.`);
63
+ if (settings) {
64
+ // A provider name is not proof that its secret belongs to OpenAI. Never send another
65
+ // service's credentials to our fixed endpoints, even if the user selects it by mistake.
66
+ const baseUrl = resolved?.auth.baseUrl ?? registry.getProvider?.(provider)?.baseUrl;
67
+ let origin: string | undefined;
68
+ try { origin = baseUrl ? new URL(baseUrl).origin : undefined; } catch { /* reject below */ }
69
+ const expectedOrigin = credential?.type === "oauth" ? "https://chatgpt.com" : "https://api.openai.com";
70
+ if (origin !== expectedOrigin) {
71
+ throw new Error(`The ${provider} provider is not configured for a supported OpenAI endpoint. Select an OpenAI provider or set apiKey explicitly.`);
72
+ }
73
+ if (credential?.type !== "oauth") return { apiKey: accessToken };
74
+ }
75
+
76
+ // Reject environment/API-key overrides of a subscription credential, including named aliases.
77
+ if (credential?.type !== "oauth" || accessToken !== credential.access) {
78
+ throw new Error(`Image generation requires the stored ${provider} subscription login, not an API key or override.`);
46
79
  }
47
80
  const accountId = accountIdFromToken(accessToken) ??
48
- (typeof credential.accountId === "string" ? credential.accountId : undefined);
49
- if (!accountId) throw new Error("The openai-codex login has no account ID. Please log in again.");
81
+ (typeof credential.accountId === "string" && credential.accountId.length > 0 ? credential.accountId : undefined);
82
+ if (!accountId) throw new Error(`The ${provider} login has no account ID. Please log in again.`);
50
83
  return { accessToken, accountId };
51
84
  }
package/src/backend.ts CHANGED
@@ -4,6 +4,7 @@ import type { ImageAuth } from "./auth.ts";
4
4
 
5
5
  export const IMAGE_MODEL = "gpt-image-2";
6
6
  const BASE_URL = "https://chatgpt.com/backend-api/codex";
7
+ const API_BASE_URL = "https://api.openai.com/v1";
7
8
  export const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
8
9
  const MAX_RESPONSE_BYTES = 48 * 1024 * 1024;
9
10
 
@@ -70,6 +71,23 @@ export function decodePng(base64: unknown): Buffer {
70
71
  return bytes;
71
72
  }
72
73
 
74
+ function editForm(fields: Record<string, string>, images: string[]): FormData {
75
+ const form = new FormData();
76
+ for (const [name, value] of Object.entries(fields)) form.append(name, value);
77
+ for (const [index, image] of images.entries()) {
78
+ // loadReferences supplies bounded data URLs; never fetch a reference URL with credentials.
79
+ const match = /^data:(image\/(png|jpeg|webp));base64,([A-Za-z0-9+/]+={0,2})$/.exec(image);
80
+ if (!match || match[3].length % 4 !== 0 || match[3].length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4) {
81
+ throw new Error("Reference images must be bounded PNG, JPEG, or WebP data URLs.");
82
+ }
83
+ const bytes = Buffer.from(match[3], "base64");
84
+ if (bytes.length > MAX_IMAGE_BYTES) throw new Error("Reference image exceeds the size limit.");
85
+ const extension = match[2] === "jpeg" ? "jpg" : match[2];
86
+ form.append("image[]", new Blob([new Uint8Array(bytes)], { type: match[1] }), `reference-${index + 1}.${extension}`);
87
+ }
88
+ return form;
89
+ }
90
+
73
91
  export async function requestImage(
74
92
  auth: ImageAuth,
75
93
  request: ImageRequest,
@@ -78,28 +96,43 @@ export async function requestImage(
78
96
  ): Promise<GeneratedImage> {
79
97
  signal.throwIfAborted();
80
98
  const editing = !!request.images?.length;
99
+ const apiKeyAuth = "apiKey" in auth;
100
+ const fields = {
101
+ model: IMAGE_MODEL,
102
+ prompt: request.prompt,
103
+ background: "auto",
104
+ quality: "auto",
105
+ size: "auto",
106
+ ...(apiKeyAuth ? { output_format: "png" } : {}),
107
+ };
108
+ const headers: Record<string, string> = apiKeyAuth ? {
109
+ Authorization: `Bearer ${auth.apiKey}`,
110
+ } : {
111
+ Authorization: `Bearer ${auth.accessToken}`,
112
+ "ChatGPT-Account-ID": auth.accountId,
113
+ originator: "pi-image-generation",
114
+ "x-codex-image-turn-id": randomUUID(),
115
+ };
116
+ let requestBody: string | FormData;
117
+ if (apiKeyAuth && editing) {
118
+ requestBody = editForm(fields, request.images!);
119
+ // fetch supplies the multipart boundary; do not set Content-Type here.
120
+ } else {
121
+ headers["Content-Type"] = "application/json";
122
+ requestBody = JSON.stringify({
123
+ ...fields,
124
+ ...(editing ? { images: request.images!.map(image_url => ({ image_url })) } : {}),
125
+ });
126
+ }
81
127
  let response: Response;
82
128
  try {
83
- response = await fetcher(`${BASE_URL}/images/${editing ? "edits" : "generations"}`, {
129
+ response = await fetcher(`${apiKeyAuth ? API_BASE_URL : BASE_URL}/images/${editing ? "edits" : "generations"}`, {
84
130
  method: "POST",
85
- // Never follow a redirect with a subscription credential.
131
+ // Never follow a redirect with either kind of credential.
86
132
  redirect: "error",
87
133
  signal,
88
- headers: {
89
- Authorization: `Bearer ${auth.accessToken}`,
90
- "ChatGPT-Account-ID": auth.accountId,
91
- "Content-Type": "application/json",
92
- originator: "pi-image-generation",
93
- "x-codex-image-turn-id": randomUUID(),
94
- },
95
- body: JSON.stringify({
96
- model: IMAGE_MODEL,
97
- prompt: request.prompt,
98
- background: "auto",
99
- quality: "auto",
100
- size: "auto",
101
- ...(editing ? { images: request.images!.map(image_url => ({ image_url })) } : {}),
102
- }),
134
+ headers,
135
+ body: requestBody,
103
136
  });
104
137
  } catch {
105
138
  signal.throwIfAborted();
@@ -108,12 +141,12 @@ export async function requestImage(
108
141
 
109
142
  if (!response.ok) {
110
143
  // Do not echo upstream bodies, prompts, account IDs or auth headers into the session.
111
- await response.body?.cancel();
144
+ await response.body?.cancel().catch(() => {});
112
145
  const hints: Record<number, string> = {
113
146
  400: "The backend rejected the image request. Check the prompt and reference images.",
114
- 401: "The subscription login was rejected. Use /login openai-codex, then /reload.",
147
+ 401: "The image credential was rejected. Check your provider login or API-key settings, then /reload.",
115
148
  403: "Image generation is not permitted for this account or request.",
116
- 404: "The subscription image endpoint is unavailable.",
149
+ 404: "The image endpoint is unavailable.",
117
150
  429: "The image allowance or rate limit was reached. Wait before trying again.",
118
151
  };
119
152
  throw new Error(`Image generation failed (HTTP ${response.status}). ${hints[response.status] ?? "No automatic retry was made; try again later."}`);
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { Type } from "typebox";
3
- import { resizeImage, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { hasSubscription, OFF_WARNING, resolveImageAuth } from "./auth.ts";
3
+ import { resizeImage, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { hasImageAuth, offWarning, resolveImageAuth } from "./auth.ts";
5
+ import { loadImageSettings } from "./settings.ts";
5
6
  import { IMAGE_MODEL, requestImage } from "./backend.ts";
6
7
  import { loadReferences, prepareOutput, saveImage } from "./images.ts";
7
8
 
@@ -34,23 +35,39 @@ async function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T
34
35
  }
35
36
 
36
37
  export default function imageGeneration(pi: ExtensionAPI): void {
37
- if (!hasSubscription()) {
38
- pi.on("session_start", (_event, ctx) => {
39
- if (ctx.hasUI) ctx.ui.notify(OFF_WARNING, "warning");
40
- else console.error(OFF_WARNING);
41
- });
42
- return;
43
- }
38
+ const settingsFor = (ctx: ExtensionContext) => loadImageSettings(ctx.cwd, ctx.isProjectTrusted());
39
+ let registered = false;
40
+ // Wait for the session cwd, trust decision and custom provider registrations.
41
+ pi.on("session_start", async (_event, ctx) => {
42
+ try {
43
+ const settings = await settingsFor(ctx);
44
+ if (!hasImageAuth(settings, ctx.modelRegistry)) throw new Error(offWarning(settings));
45
+ if (!registered) {
46
+ pi.registerTool(tool);
47
+ registered = true;
48
+ }
49
+ } catch (error) {
50
+ const message = (error as Error).message;
51
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
52
+ else console.error(message);
53
+ }
54
+ });
44
55
 
45
- // Not listed in the package's static skill manifest, so it cannot leak into unauthenticated sessions.
46
- pi.on("resources_discover", () => ({
47
- skillPaths: hasSubscription() ? [fileURLToPath(new URL("../resources/imagegen/SKILL.md", import.meta.url))] : [],
48
- }));
56
+ // Not listed in the static manifest, so it cannot leak into unauthenticated sessions.
57
+ pi.on("resources_discover", async (_event, ctx) => {
58
+ try {
59
+ const settings = await settingsFor(ctx);
60
+ return { skillPaths: registered && hasImageAuth(settings, ctx.modelRegistry)
61
+ ? [fileURLToPath(new URL("../resources/imagegen/SKILL.md", import.meta.url))] : [] };
62
+ } catch {
63
+ return { skillPaths: [] };
64
+ }
65
+ });
49
66
 
50
- pi.registerTool({
67
+ const tool: ToolDefinition<typeof parameters> = {
51
68
  name: "image_gen",
52
69
  label: "Generate image",
53
- description: "Generate or edit one raster image using the configured Codex subscription. Accepts up to five local or recent-conversation reference images for consistent edits and variants. Saves a new PNG and returns a preview when possible. No API-key fallback. Reference limits: 32 MiB each, 50 MiB combined; 4-minute request timeout. Load the imagegen skill first.",
70
+ description: "Generate or edit one raster image using the configured OpenAI provider or API key. Accepts up to five local or recent-conversation reference images for consistent edits and variants. Saves a new PNG and returns a preview when possible. Uses openai-codex by default; credentials can be overridden in pi-image-generation-settings.json. Reference limits: 32 MiB each, 50 MiB combined; 4-minute request timeout. Load the imagegen skill first.",
54
71
  promptSnippet: "Generate images or edit them using local or recent image references",
55
72
  promptGuidelines: [
56
73
  "Before using image_gen, read the imagegen skill. Reuse reference images and state what must remain unchanged for consistent edits.",
@@ -62,11 +79,12 @@ export default function imageGeneration(pi: ExtensionAPI): void {
62
79
  const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
63
80
  requestSignal.throwIfAborted();
64
81
  if (!args.prompt.trim()) throw new Error("The image prompt must not be blank.");
65
- if (!hasSubscription()) throw new Error(OFF_WARNING + " Use /login openai-codex, then /reload.");
82
+ const settings = await settingsFor(ctx);
83
+ if (!hasImageAuth(settings, ctx.modelRegistry)) throw new Error(offWarning(settings) + " Check your image generation settings and login, then /reload.");
66
84
  const path = await prepareOutput(ctx.cwd, args.output_path);
67
85
  const images = await loadReferences(args.referenced_image_paths, args.num_last_images_to_include,
68
86
  ctx.sessionManager.getBranch(), ctx.cwd, requestSignal);
69
- const auth = await abortable(resolveImageAuth(ctx.modelRegistry), requestSignal);
87
+ const auth = await abortable(resolveImageAuth(ctx.modelRegistry, settings), requestSignal);
70
88
  onUpdate?.({ content: [{ type: "text", text: images.length ? "Editing image…" : "Generating image…" }], details: {} });
71
89
 
72
90
  let result;
@@ -109,5 +127,5 @@ export default function imageGeneration(pi: ExtensionAPI): void {
109
127
  },
110
128
  };
111
129
  },
112
- });
130
+ };
113
131
  }
@@ -0,0 +1,42 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export const SETTINGS_FILE = "pi-image-generation-settings.json";
6
+ export type ImageSettings = { provider: string } | { apiKey: string };
7
+
8
+ // Select a whole file, not a field merge: a local provider must not inherit a global API key.
9
+ export async function loadImageSettings(cwd: string, projectTrusted: boolean): Promise<ImageSettings | undefined> {
10
+ const paths = [
11
+ ...(projectTrusted ? [join(cwd, CONFIG_DIR_NAME, "agent", SETTINGS_FILE), join(cwd, CONFIG_DIR_NAME, SETTINGS_FILE)] : []),
12
+ join(getAgentDir(), SETTINGS_FILE),
13
+ ];
14
+ for (const path of paths) {
15
+ let text: string;
16
+ try {
17
+ text = await readFile(path, "utf8");
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
20
+ throw new Error(`Cannot read image generation settings: ${path}`);
21
+ }
22
+ let value: unknown;
23
+ try {
24
+ value = JSON.parse(text);
25
+ } catch {
26
+ // JSON parser errors can contain the API key. Never include them in diagnostics.
27
+ throw new Error(`Invalid JSON in image generation settings: ${path}`);
28
+ }
29
+ if (value && typeof value === "object" && !Array.isArray(value)) {
30
+ const entries = Object.entries(value);
31
+ if (entries.length === 1) {
32
+ const [key, raw] = entries[0];
33
+ if (typeof raw === "string" && raw.trim()) {
34
+ if (key === "provider") return { provider: raw.trim() };
35
+ if (key === "apiKey") return { apiKey: raw.trim() };
36
+ }
37
+ }
38
+ }
39
+ throw new Error(`Image generation settings must contain exactly one non-empty string: provider or apiKey (${path}).`);
40
+ }
41
+ return undefined;
42
+ }