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 +44 -0
- package/package.json +1 -1
- package/resources/imagegen/SKILL.md +3 -3
- package/src/auth.ts +48 -15
- package/src/backend.ts +53 -20
- package/src/index.ts +36 -18
- package/src/settings.ts +42 -0
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
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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 (
|
|
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(
|
|
56
|
+
resolved = await registry.getProviderAuth(provider);
|
|
38
57
|
} catch {
|
|
39
|
-
throw new Error(
|
|
58
|
+
throw new Error(`Could not refresh or resolve the ${provider} login. Use /login ${provider}, then /reload.`);
|
|
40
59
|
}
|
|
41
|
-
const credential = readStoredCredential(
|
|
60
|
+
const credential = readStoredCredential(provider);
|
|
42
61
|
const accessToken = resolved?.auth.apiKey;
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
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(
|
|
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
|
|
131
|
+
// Never follow a redirect with either kind of credential.
|
|
86
132
|
redirect: "error",
|
|
87
133
|
signal,
|
|
88
|
-
headers
|
|
89
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
pi.on("resources_discover", () =>
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/settings.ts
ADDED
|
@@ -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
|
+
}
|