@oai404iao/pi-codex-imagegen 0.4.0 → 2.0.0
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 +21 -19
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/package.json +16 -15
- package/src/background-image-generation.ts +44 -302
- package/src/index.ts +0 -1
- package/src/tools/image-generation.ts +68 -171
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
See the package guide below for release status; source edits do not publish artifacts.
|
|
4
4
|
|
|
5
|
-
Peer floor and development target: Pi
|
|
5
|
+
Peer floor and development target: Pi 1.2.0.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
Image generation uses standalone Images endpoints and is directly callable,
|
|
8
|
+
including through codemode while active. This does not enable codemode itself.
|
|
9
|
+
Hosted Responses image generation and `directImageApiFallback` have been removed.
|
|
10
|
+
Legacy hosted profiles are disabled with a diagnostic rather than silently routed
|
|
11
|
+
to a different implementation. Historical image outputs still replay and display.
|
|
12
12
|
|
|
13
13
|
Codemode scripts receive `{ path, latestPath?, image: { type, data, mimeType } }`.
|
|
14
14
|
The image data is base64; use `image(result.image)` to forward it to the model.
|
|
@@ -24,17 +24,19 @@ new image writes. Already-started server generation or disk writes cannot be
|
|
|
24
24
|
rolled back; cancellation is not a guarantee of avoiding provider charges.
|
|
25
25
|
|
|
26
26
|
Installs `image_generation`, background image commands, image persistence and
|
|
27
|
-
presentation.
|
|
27
|
+
presentation. Its only workspace dependency is `pi-codex-runtime`, not core,
|
|
28
|
+
web-search or the bundle. Photon supplies image decoding and PNG encoding.
|
|
28
29
|
When global `config.json.imageGeneration` is `false`, none of those generation
|
|
29
|
-
surfaces are registered. The gate also blocks late/manual standalone
|
|
30
|
-
|
|
30
|
+
surfaces are registered. The gate also blocks late/manual standalone execution
|
|
31
|
+
before authentication or network I/O.
|
|
31
32
|
Catalog-supported standalone profiles call Images generation/edit endpoints with
|
|
32
|
-
Pi authentication.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
Pi authentication. `transparent_background:true` requests transparency;
|
|
34
|
+
omitted or false requests an opaque background. References are sniffed by content,
|
|
35
|
+
including extensionless PNG/JPEG/WebP; GIF/BMP normalize to PNG without resizing.
|
|
36
|
+
All image paths, including background jobs, use
|
|
35
37
|
the selected provider's Pi-resolved credentials, headers and base URL. The
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
client never reads a separate `OPENAI_API_KEY` or forces the public OpenAI endpoint.
|
|
39
|
+
Authentication failure never switches accounts or providers.
|
|
38
40
|
`endpoint_config.imageGeneration` limits allowed modes at the authenticated
|
|
39
41
|
endpoint. Rejected/denied standalone jobs do not fall through to hosted jobs.
|
|
40
42
|
Explicit protocol rejection temporarily disables that mode for the session,
|
|
@@ -53,13 +55,13 @@ providers. Reserved endpoints remain unsupported compatibility surfaces.
|
|
|
53
55
|
See [the package guide](../../docs/codex-packages.md). From repository root:
|
|
54
56
|
|
|
55
57
|
```bash
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
pnpm --filter @oai404iao/pi-codex-imagegen run check
|
|
59
|
+
pnpm run test:codex-composition
|
|
60
|
+
pnpm run test:codex-packages
|
|
59
61
|
```
|
|
60
62
|
|
|
61
|
-
Client/capture/display/background-job regressions are in `tests/`;
|
|
62
|
-
|
|
63
|
+
Client/capture/display/background-job regressions are in `tests/`; cross-package
|
|
64
|
+
integration is in root `tests/codex/`. See the
|
|
63
65
|
[shared configuration guide](../pi-codex-runtime/reference/configuration.md) and
|
|
64
66
|
[image-generation example](assets/image-generation.gif). The example is a
|
|
65
67
|
repository asset, not part of the runtime tarball.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -11,8 +11,19 @@ apply-patch compatibility evidence remains pinned to
|
|
|
11
11
|
The unmodified provenance record preserves upstream source identifiers, not
|
|
12
12
|
claims that every referenced source file is shipped in this package.
|
|
13
13
|
|
|
14
|
+
Image request behavior and the runtime-owned namespace declaration now follow
|
|
15
|
+
Codex `5a3140176e668a2f72f3c098490eb7f7052d9d85`, including transparent-background
|
|
16
|
+
requests. The runtime package ships the exact source and canonical-JSON hashes
|
|
17
|
+
in `provenance/openai-codex-5a314017-image-generation.json`.
|
|
18
|
+
The earlier provenance record remains historical evidence.
|
|
19
|
+
|
|
14
20
|
Endpoint clients and presentation code are project implementations. Protocol
|
|
15
21
|
shapes are consumed from pi-codex-runtime rather than copied here.
|
|
16
22
|
|
|
23
|
+
Reference image normalization uses the separately distributed
|
|
24
|
+
`@silvia-odwyer/photon-node` 0.3.4 dependency (Apache-2.0), retaining original
|
|
25
|
+
dimensions and transparency when encoding GIF/BMP input as PNG. Its package
|
|
26
|
+
includes its own license; no Photon source or WASM binary is vendored here.
|
|
27
|
+
|
|
17
28
|
See `pi-codex-runtime/reference/source-map.md` in the repository for the pinned
|
|
18
29
|
evidence map and `reference/configuration.md` there for image configuration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oai404iao/pi-codex-imagegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Unpublished pi-codex-imagegen workspace for the staged Codex split",
|
|
6
6
|
"type": "module",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
]
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@earendil-works/pi-ai": ">=
|
|
32
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
33
|
-
"@earendil-works/pi-tui": ">=
|
|
31
|
+
"@earendil-works/pi-ai": ">=1.2.0",
|
|
32
|
+
"@earendil-works/pi-coding-agent": ">=1.2.0",
|
|
33
|
+
"@earendil-works/pi-tui": ">=1.2.0",
|
|
34
34
|
"typebox": "*"
|
|
35
35
|
},
|
|
36
36
|
"peerDependenciesMeta": {
|
|
@@ -45,22 +45,18 @@
|
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@oai404iao/pi-codex-runtime": "0.
|
|
48
|
+
"@oai404iao/pi-codex-runtime": "2.0.0",
|
|
49
|
+
"@silvia-odwyer/photon-node": "0.3.4"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-ai": "
|
|
52
|
-
"@earendil-works/pi-coding-agent": "
|
|
53
|
-
"@earendil-works/pi-tui": "
|
|
52
|
+
"@earendil-works/pi-ai": "1.2.0",
|
|
53
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
54
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
54
55
|
"@types/node": "^26.2.0",
|
|
55
56
|
"tsx": "^4.20.6",
|
|
56
57
|
"typebox": "^1.1.24",
|
|
57
58
|
"typescript": "^7.0.2"
|
|
58
59
|
},
|
|
59
|
-
"scripts": {
|
|
60
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
61
|
-
"test": "tsx --test tests/**/*.test.ts",
|
|
62
|
-
"check": "npm run typecheck && npm test"
|
|
63
|
-
},
|
|
64
60
|
"publishConfig": {
|
|
65
61
|
"access": "public",
|
|
66
62
|
"registry": "https://registry.npmjs.org/"
|
|
@@ -77,5 +73,10 @@
|
|
|
77
73
|
"engines": {
|
|
78
74
|
"node": ">=22.19.0"
|
|
79
75
|
},
|
|
80
|
-
"gitHead": "
|
|
81
|
-
|
|
76
|
+
"gitHead": "cf3a22fc7855afd0d31e21999b3868da8cf67d5f",
|
|
77
|
+
"scripts": {
|
|
78
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
79
|
+
"test": "tsx --test tests/**/*.test.ts",
|
|
80
|
+
"check": "pnpm run typecheck && pnpm run test"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -1,31 +1,17 @@
|
|
|
1
|
-
import type { Api, Model
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
4
|
-
import {
|
|
5
|
-
import { extname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { relative } from "node:path";
|
|
6
5
|
import { supportsImageInput, type ModelLike } from "@oai404iao/pi-codex-runtime/internal/capabilities";
|
|
7
|
-
import {
|
|
8
|
-
buildCodexJsonHeaders,
|
|
9
|
-
hasCodexRequestAuth,
|
|
10
|
-
resolveResponsesUrl,
|
|
11
|
-
type ResponsesProtocol,
|
|
12
|
-
withResolvedAuthBaseUrl,
|
|
13
|
-
} from "@oai404iao/pi-codex-runtime/internal/codex-http";
|
|
14
6
|
import { createBackgroundImageJobs, panelBranch } from "./background-image-jobs.js";
|
|
15
7
|
import { listResolvedModelProfiles } from "@oai404iao/pi-codex-runtime/internal/model-catalog/catalog";
|
|
16
8
|
import { loadModelSettings } from "@oai404iao/pi-codex-runtime/internal/model-catalog/runtime";
|
|
17
|
-
import { setProviderGeneratedHeader } from "@oai404iao/pi-codex-runtime/internal/provider-headers";
|
|
18
9
|
import { loadSettings } from "@oai404iao/pi-codex-runtime/internal/settings";
|
|
19
10
|
import { standaloneImageGeneration } from "./tools/image-generation.js";
|
|
20
|
-
import {
|
|
21
|
-
buildGeneratedImageDisplayText,
|
|
22
|
-
saveOpenAICodexGeneratedImage,
|
|
23
|
-
} from "./tools/image-generation/storage.js";
|
|
11
|
+
import { buildGeneratedImageDisplayText } from "./tools/image-generation/storage.js";
|
|
24
12
|
import { IMAGE_SAVE_DISPLAY_MESSAGE_TYPE, type SavedGeneratedImage } from "./tools/image-generation/types.js";
|
|
25
13
|
import { projectRoot } from "./utils/images.js";
|
|
26
|
-
import { checkEndpointResponse, rememberResolvedEndpoint, reportEndpointFailure, requireEndpointCapability } from "@oai404iao/pi-codex-runtime/internal/endpoint-state";
|
|
27
14
|
|
|
28
|
-
const BACKGROUND_IMAGE_INSTRUCTIONS = "Generate or edit images with the hosted image_generation tool. Use the user's prompt and any provided reference images. Return the image_generation_call result.";
|
|
29
15
|
const IMAGE_GEN_ERROR_MESSAGE_TYPE = "codex-image-generation-error";
|
|
30
16
|
|
|
31
17
|
export interface ParsedImageGenCommand {
|
|
@@ -33,20 +19,6 @@ export interface ParsedImageGenCommand {
|
|
|
33
19
|
imagePaths: string[];
|
|
34
20
|
}
|
|
35
21
|
|
|
36
|
-
interface ReferenceImage {
|
|
37
|
-
path: string;
|
|
38
|
-
mimeType: string;
|
|
39
|
-
base64: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface CodexImageResult {
|
|
43
|
-
id: string;
|
|
44
|
-
result: string;
|
|
45
|
-
outputFormat?: string;
|
|
46
|
-
revisedPrompt?: string;
|
|
47
|
-
imageModel?: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
22
|
interface ImageGenerationErrorDetails {
|
|
51
23
|
message: string;
|
|
52
24
|
prompt?: string;
|
|
@@ -60,12 +32,10 @@ interface ModelRegistryLike {
|
|
|
60
32
|
find?: (provider: string, id: string) => unknown;
|
|
61
33
|
}
|
|
62
34
|
|
|
63
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
64
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
35
|
function isModelLike(value: unknown): value is ModelLike {
|
|
68
|
-
|
|
36
|
+
if (!value || typeof value !== "object") return false;
|
|
37
|
+
const record = value as Record<string, unknown>;
|
|
38
|
+
return typeof record.provider === "string" && (typeof record.id === "string" || typeof record.name === "string");
|
|
69
39
|
}
|
|
70
40
|
|
|
71
41
|
function registryModels(registry: ModelRegistryLike | undefined): ModelLike[] {
|
|
@@ -85,21 +55,21 @@ function registryModels(registry: ModelRegistryLike | undefined): ModelLike[] {
|
|
|
85
55
|
function isConfiguredImageModel(model: ModelLike | undefined): boolean {
|
|
86
56
|
if (!model || !supportsImageInput(model)) return false;
|
|
87
57
|
const settings = loadModelSettings({ ...model, baseUrl: undefined });
|
|
88
|
-
return Boolean(settings.modelProfile?.effective.enabled && settings.imageGenerationImplementation);
|
|
58
|
+
return Boolean(settings.modelProfile?.effective.enabled && settings.imageGenerationImplementation === "standalone");
|
|
89
59
|
}
|
|
90
60
|
|
|
91
61
|
export function selectCodexImageModel(currentModel: ModelLike | undefined, registry: ModelRegistryLike | undefined): ModelLike | undefined {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
62
|
+
const profile = currentModel && loadModelSettings({ ...currentModel, baseUrl: undefined }).modelProfile;
|
|
63
|
+
if (profile?.removedHostedImageGeneration) {
|
|
64
|
+
throw new Error(`Hosted image generation was removed for ${profile.id}. Change tools.imageGeneration to standalone or false in models.json; no alternate model was selected.`);
|
|
65
|
+
}
|
|
66
|
+
if (isConfiguredImageModel(currentModel)) return currentModel;
|
|
94
67
|
const discovered = registryModels(registry).find(isConfiguredImageModel);
|
|
95
68
|
if (discovered) return discovered;
|
|
96
69
|
for (const profile of listResolvedModelProfiles()) {
|
|
97
|
-
if (!profile.effective.enabled ||
|
|
70
|
+
if (!profile.effective.enabled || profile.effective.tools.imageGeneration !== "standalone") continue;
|
|
98
71
|
const slash = profile.id.indexOf("/");
|
|
99
|
-
const candidate = registry?.find?.(
|
|
100
|
-
profile.id.slice(0, slash),
|
|
101
|
-
profile.id.slice(slash + 1),
|
|
102
|
-
);
|
|
72
|
+
const candidate = registry?.find?.(profile.id.slice(0, slash), profile.id.slice(slash + 1));
|
|
103
73
|
if (isModelLike(candidate) && isConfiguredImageModel(candidate)) return candidate;
|
|
104
74
|
}
|
|
105
75
|
return undefined;
|
|
@@ -143,7 +113,7 @@ function tokenizeArgs(input: string): string[] {
|
|
|
143
113
|
function isSupportedImagePathToken(token: string): boolean {
|
|
144
114
|
const normalized = token.replace(/^file:\/\//, "").replace(/[),.;:]+$/, "");
|
|
145
115
|
if (/^https?:\/\//i.test(normalized) || normalized.startsWith("data:")) return false;
|
|
146
|
-
return /\.(?:png|jpe?g|webp)$/i.test(normalized);
|
|
116
|
+
return /\.(?:png|jpe?g|webp|gif|bmp)$/i.test(normalized);
|
|
147
117
|
}
|
|
148
118
|
|
|
149
119
|
export function parseImageGenCommandArgs(input: string): ParsedImageGenCommand {
|
|
@@ -157,140 +127,6 @@ export function parseImageGenCommandArgs(input: string): ParsedImageGenCommand {
|
|
|
157
127
|
return { prompt: promptParts.join(" ").trim(), imagePaths };
|
|
158
128
|
}
|
|
159
129
|
|
|
160
|
-
function mimeTypeForPath(path: string): string {
|
|
161
|
-
const ext = extname(path).toLowerCase();
|
|
162
|
-
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
163
|
-
if (ext === ".webp") return "image/webp";
|
|
164
|
-
if (ext === ".png") return "image/png";
|
|
165
|
-
throw new Error(`Unsupported reference image type: ${path}. Use PNG, JPEG, or WebP.`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function loadReferenceImage(cwd: string, rawPath: string): Promise<ReferenceImage> {
|
|
169
|
-
const path = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
170
|
-
const buffer = await readFile(path);
|
|
171
|
-
return { path, mimeType: mimeTypeForPath(path), base64: buffer.toString("base64") };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function buildHeaders(
|
|
175
|
-
model: Model<Api>,
|
|
176
|
-
auth: { apiKey?: string; headers?: ProviderHeaders },
|
|
177
|
-
endpoint: ResponsesProtocol,
|
|
178
|
-
codexRequestExtensions: boolean,
|
|
179
|
-
): Headers {
|
|
180
|
-
const headers = buildCodexJsonHeaders({
|
|
181
|
-
modelHeaders: model.headers,
|
|
182
|
-
auth,
|
|
183
|
-
endpoint,
|
|
184
|
-
codexRequestExtensions,
|
|
185
|
-
});
|
|
186
|
-
if (codexRequestExtensions || endpoint === "codex") setProviderGeneratedHeader(headers, "OpenAI-Beta", "responses=experimental");
|
|
187
|
-
setProviderGeneratedHeader(headers, "accept", "text/event-stream");
|
|
188
|
-
return headers;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function buildBackgroundImageRequest(options: {
|
|
192
|
-
prompt: string;
|
|
193
|
-
referenceImages: ReferenceImage[];
|
|
194
|
-
responsesModel: string;
|
|
195
|
-
imageModel: string;
|
|
196
|
-
}): Record<string, unknown> {
|
|
197
|
-
const content: Array<Record<string, unknown>> = [
|
|
198
|
-
{ type: "input_text", text: options.referenceImages.length > 0 ? `Edit the provided image(s): ${options.prompt}` : options.prompt },
|
|
199
|
-
...options.referenceImages.map((image) => ({
|
|
200
|
-
type: "input_image",
|
|
201
|
-
detail: "auto",
|
|
202
|
-
image_url: `data:${image.mimeType};base64,${image.base64}`,
|
|
203
|
-
})),
|
|
204
|
-
];
|
|
205
|
-
return {
|
|
206
|
-
model: options.responsesModel,
|
|
207
|
-
store: false,
|
|
208
|
-
stream: true,
|
|
209
|
-
instructions: BACKGROUND_IMAGE_INSTRUCTIONS,
|
|
210
|
-
input: [{ role: "user", content }],
|
|
211
|
-
tools: [{
|
|
212
|
-
type: "image_generation",
|
|
213
|
-
model: options.imageModel,
|
|
214
|
-
output_format: "png",
|
|
215
|
-
action: options.referenceImages.length > 0 ? "edit" : "generate",
|
|
216
|
-
}],
|
|
217
|
-
tool_choice: { type: "image_generation" },
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function* parseSseEvents(response: Response, signal: AbortSignal): AsyncIterable<Record<string, unknown>> {
|
|
222
|
-
if (!response.body) return;
|
|
223
|
-
const reader = response.body.getReader();
|
|
224
|
-
const decoder = new TextDecoder();
|
|
225
|
-
let buffer = "";
|
|
226
|
-
const cancel = () => { void reader.cancel(signal.reason).catch(() => {}); };
|
|
227
|
-
signal.addEventListener("abort", cancel, { once: true });
|
|
228
|
-
if (signal.aborted) cancel();
|
|
229
|
-
try {
|
|
230
|
-
while (true) {
|
|
231
|
-
signal.throwIfAborted();
|
|
232
|
-
const { done, value } = await reader.read();
|
|
233
|
-
signal.throwIfAborted();
|
|
234
|
-
if (done) break;
|
|
235
|
-
buffer += decoder.decode(value, { stream: true });
|
|
236
|
-
let boundary: number;
|
|
237
|
-
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
|
|
238
|
-
const raw = buffer.slice(0, boundary);
|
|
239
|
-
buffer = buffer.slice(boundary + 2);
|
|
240
|
-
for (const line of raw.split(/\r?\n/)) {
|
|
241
|
-
if (!line.startsWith("data:")) continue;
|
|
242
|
-
const data = line.slice(5).trim();
|
|
243
|
-
if (!data || data === "[DONE]") continue;
|
|
244
|
-
yield JSON.parse(data) as Record<string, unknown>;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
} finally {
|
|
249
|
-
signal.removeEventListener("abort", cancel);
|
|
250
|
-
reader.releaseLock();
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function collectImageResult(results: CodexImageResult[], item: unknown, fallbackImageModel: string): void {
|
|
255
|
-
if (!item || typeof item !== "object") return;
|
|
256
|
-
const candidate = item as Record<string, unknown>;
|
|
257
|
-
if (candidate.type !== "image_generation_call" || typeof candidate.result !== "string") return;
|
|
258
|
-
const id = typeof candidate.id === "string" ? candidate.id : `ig_${results.length}`;
|
|
259
|
-
if (results.some((result) => result.id === id)) return;
|
|
260
|
-
results.push({
|
|
261
|
-
id,
|
|
262
|
-
result: candidate.result,
|
|
263
|
-
outputFormat: typeof candidate.output_format === "string" ? candidate.output_format : "png",
|
|
264
|
-
revisedPrompt: typeof candidate.revised_prompt === "string" ? candidate.revised_prompt : undefined,
|
|
265
|
-
imageModel: typeof candidate.model === "string" ? candidate.model : fallbackImageModel,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function collectResponseText(value: unknown, out: string[]): void {
|
|
270
|
-
if (!value || typeof value !== "object") return;
|
|
271
|
-
const item = value as Record<string, unknown>;
|
|
272
|
-
for (const key of ["text", "refusal", "summary_text", "message"] as const) {
|
|
273
|
-
const text = item[key];
|
|
274
|
-
if (typeof text === "string" && text.trim()) out.push(text.trim());
|
|
275
|
-
}
|
|
276
|
-
for (const key of ["content", "output", "summary"] as const) {
|
|
277
|
-
const child = item[key];
|
|
278
|
-
if (Array.isArray(child)) for (const part of child) collectResponseText(part, out);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export function summarizeNonImageResponse(response: Record<string, unknown> | undefined): string {
|
|
283
|
-
if (!response) return "No image was returned by Codex.";
|
|
284
|
-
const status = typeof response.status === "string" ? response.status : undefined;
|
|
285
|
-
const error = response.error && typeof response.error === "object" ? response.error as Record<string, unknown> : undefined;
|
|
286
|
-
const errorMessage = typeof error?.message === "string" ? error.message : undefined;
|
|
287
|
-
const texts: string[] = [];
|
|
288
|
-
collectResponseText(response, texts);
|
|
289
|
-
const text = [...new Set(texts)].join(" ").replace(/\s+/g, " ").trim();
|
|
290
|
-
const details = [status && status !== "completed" ? `status ${status}` : undefined, errorMessage, text].filter(Boolean).join(" · ");
|
|
291
|
-
return details ? `No image was returned by Codex: ${details}` : "No image was returned by Codex.";
|
|
292
|
-
}
|
|
293
|
-
|
|
294
130
|
function renderImageGenError(details: ImageGenerationErrorDetails, theme: Theme): Component {
|
|
295
131
|
return {
|
|
296
132
|
invalidate() {},
|
|
@@ -313,117 +149,32 @@ function renderImageGenError(details: ImageGenerationErrorDetails, theme: Theme)
|
|
|
313
149
|
async function runBackgroundImageGeneration(pi: ExtensionAPI, ctx: ExtensionCommandContext, parsed: ParsedImageGenCommand, signal: AbortSignal): Promise<void> {
|
|
314
150
|
signal.throwIfAborted();
|
|
315
151
|
const model = selectCodexImageModel(ctx.model as ModelLike | undefined, ctx.modelRegistry as ModelRegistryLike | undefined) as Model<Api> | undefined;
|
|
316
|
-
if (!model) throw new Error("No image-capable model with an enabled model catalog profile is available.");
|
|
152
|
+
if (!model) throw new Error("No image-capable model with an enabled standalone model catalog profile is available.");
|
|
317
153
|
const settings = loadModelSettings({ ...model, baseUrl: undefined }, ctx.cwd);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
referenced_image_paths: parsed.imagePaths,
|
|
324
|
-
}, {
|
|
325
|
-
cwd: ctx.cwd,
|
|
326
|
-
model,
|
|
327
|
-
modelRegistry: ctx.modelRegistry,
|
|
328
|
-
sessionManager: ctx.sessionManager,
|
|
329
|
-
}, settings, signal, { callId: "standalone" });
|
|
330
|
-
signal.throwIfAborted();
|
|
331
|
-
const saved = result.details.saved;
|
|
332
|
-
const workspaceRoot = projectRoot(ctx.cwd);
|
|
333
|
-
const relativePath = relative(workspaceRoot, saved.path);
|
|
334
|
-
const latestAbsolutePath = saved.latestPath ?? saved.path;
|
|
335
|
-
const latestRelativePath = relative(workspaceRoot, latestAbsolutePath);
|
|
336
|
-
const savedImage: SavedGeneratedImage = {
|
|
337
|
-
absolutePath: saved.path,
|
|
338
|
-
relativePath: relativePath && !relativePath.startsWith("..") ? relativePath : saved.path,
|
|
339
|
-
latestAbsolutePath,
|
|
340
|
-
latestRelativePath: latestRelativePath && !latestRelativePath.startsWith("..")
|
|
341
|
-
? latestRelativePath
|
|
342
|
-
: latestAbsolutePath,
|
|
343
|
-
responseId: undefined,
|
|
344
|
-
callId: "standalone",
|
|
345
|
-
outputFormat: saved.format,
|
|
346
|
-
imageModel: settings.imageModel,
|
|
347
|
-
revisedPrompt: parsed.prompt,
|
|
348
|
-
};
|
|
349
|
-
pi.sendMessage({
|
|
350
|
-
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
351
|
-
content: [{ type: "text", text: buildGeneratedImageDisplayText(savedImage, { expanded: false }) }],
|
|
352
|
-
display: true,
|
|
353
|
-
details: { savedImages: [savedImage] },
|
|
354
|
-
}, { triggerTurn: false });
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
358
|
-
signal.throwIfAborted();
|
|
359
|
-
if (!auth.ok) throw new Error(auth.error);
|
|
360
|
-
const requestModel = withResolvedAuthBaseUrl(model, auth);
|
|
361
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
362
|
-
rememberResolvedEndpoint(model, requestModel, sessionId);
|
|
363
|
-
requireEndpointCapability(settings.endpoint_config, requestModel, sessionId, "imageGeneration.hosted");
|
|
364
|
-
if (!hasCodexRequestAuth({
|
|
365
|
-
modelHeaders: model.headers,
|
|
366
|
-
auth: { apiKey: auth.apiKey, headers: auth.headers },
|
|
367
|
-
})) {
|
|
368
|
-
throw new Error(`No request authentication is configured for ${model.provider}.`);
|
|
369
|
-
}
|
|
370
|
-
const referenceImages = await Promise.all(parsed.imagePaths.map((path) => loadReferenceImage(ctx.cwd, path)));
|
|
371
|
-
signal.throwIfAborted();
|
|
372
|
-
const body = buildBackgroundImageRequest({
|
|
373
|
-
prompt: parsed.prompt,
|
|
374
|
-
referenceImages,
|
|
375
|
-
responsesModel: model.id,
|
|
376
|
-
imageModel: settings.imageModel,
|
|
377
|
-
});
|
|
378
|
-
const response = await fetch(resolveResponsesUrl(auth.baseUrl ?? model.baseUrl, settings.responsesEndpoint), {
|
|
379
|
-
signal,
|
|
380
|
-
method: "POST",
|
|
381
|
-
headers: buildHeaders(model, {
|
|
382
|
-
apiKey: auth.apiKey,
|
|
383
|
-
headers: auth.headers,
|
|
384
|
-
}, settings.responsesEndpoint, settings.codexRequestExtensions),
|
|
385
|
-
body: JSON.stringify(body),
|
|
386
|
-
});
|
|
387
|
-
signal.throwIfAborted();
|
|
388
|
-
await checkEndpointResponse(response, requestModel, sessionId, ["imageGeneration.hosted"], signal);
|
|
389
|
-
const results: CodexImageResult[] = [];
|
|
390
|
-
let responseId: string | undefined;
|
|
391
|
-
let lastResponse: Record<string, unknown> | undefined;
|
|
392
|
-
for await (const event of parseSseEvents(response, signal)) {
|
|
393
|
-
if (event.type === "error" || event.type === "response.failed") {
|
|
394
|
-
reportEndpointFailure(requestModel, sessionId, ["imageGeneration.hosted"], event.error ?? (event.response as { error?: unknown } | undefined)?.error);
|
|
395
|
-
}
|
|
396
|
-
if (event.type === "response.created" && event.response && typeof event.response === "object") {
|
|
397
|
-
lastResponse = event.response as Record<string, unknown>;
|
|
398
|
-
const id = (event.response as { id?: unknown }).id;
|
|
399
|
-
if (typeof id === "string") responseId = id;
|
|
400
|
-
}
|
|
401
|
-
if (event.type === "response.output_item.done") collectImageResult(results, event.item, settings.imageModel);
|
|
402
|
-
if ((event.type === "response.completed" || event.type === "response.done") && event.response && typeof event.response === "object") {
|
|
403
|
-
lastResponse = event.response as Record<string, unknown>;
|
|
404
|
-
const responseOutput = (event.response as { output?: unknown }).output;
|
|
405
|
-
if (Array.isArray(responseOutput)) for (const item of responseOutput) collectImageResult(results, item, settings.imageModel);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
if (results.length === 0) throw new Error(summarizeNonImageResponse(lastResponse));
|
|
409
|
-
const savedImages = [];
|
|
410
|
-
for (const result of results) {
|
|
411
|
-
signal.throwIfAborted();
|
|
412
|
-
savedImages.push(await saveOpenAICodexGeneratedImage(ctx.cwd, {
|
|
413
|
-
responseId,
|
|
414
|
-
callId: result.id,
|
|
415
|
-
result: result.result,
|
|
416
|
-
outputFormat: result.outputFormat,
|
|
417
|
-
imageModel: result.imageModel,
|
|
418
|
-
revisedPrompt: result.revisedPrompt ?? parsed.prompt,
|
|
419
|
-
}));
|
|
420
|
-
}
|
|
154
|
+
const result = await standaloneImageGeneration({
|
|
155
|
+
prompt: parsed.prompt, referenced_image_paths: parsed.imagePaths,
|
|
156
|
+
}, {
|
|
157
|
+
cwd: ctx.cwd, model, modelRegistry: ctx.modelRegistry, sessionManager: ctx.sessionManager,
|
|
158
|
+
}, settings, signal, { callId: "standalone" });
|
|
421
159
|
signal.throwIfAborted();
|
|
160
|
+
const saved = result.details.saved;
|
|
161
|
+
const workspaceRoot = projectRoot(ctx.cwd);
|
|
162
|
+
const relativePath = relative(workspaceRoot, saved.path);
|
|
163
|
+
const latestAbsolutePath = saved.latestPath ?? saved.path;
|
|
164
|
+
const latestRelativePath = relative(workspaceRoot, latestAbsolutePath);
|
|
165
|
+
const savedImage: SavedGeneratedImage = {
|
|
166
|
+
absolutePath: saved.path,
|
|
167
|
+
relativePath: relativePath && !relativePath.startsWith("..") ? relativePath : saved.path,
|
|
168
|
+
latestAbsolutePath,
|
|
169
|
+
latestRelativePath: latestRelativePath && !latestRelativePath.startsWith("..") ? latestRelativePath : latestAbsolutePath,
|
|
170
|
+
responseId: result.details.requestId,
|
|
171
|
+
callId: "standalone", outputFormat: saved.format, imageModel: settings.imageModel,
|
|
172
|
+
revisedPrompt: parsed.prompt,
|
|
173
|
+
};
|
|
422
174
|
pi.sendMessage({
|
|
423
175
|
customType: IMAGE_SAVE_DISPLAY_MESSAGE_TYPE,
|
|
424
|
-
content: [{ type: "text", text: buildGeneratedImageDisplayText(
|
|
425
|
-
display: true,
|
|
426
|
-
details: { savedImages },
|
|
176
|
+
content: [{ type: "text", text: buildGeneratedImageDisplayText(savedImage, { expanded: false }) }],
|
|
177
|
+
display: true, details: { savedImages: [savedImage] },
|
|
427
178
|
}, { triggerTurn: false });
|
|
428
179
|
}
|
|
429
180
|
|
|
@@ -432,23 +183,15 @@ export function registerBackgroundImageGenerationCommand(pi: ExtensionAPI): void
|
|
|
432
183
|
pi.on("session_start", (_event, ctx) => jobs.reset(ctx));
|
|
433
184
|
pi.on("session_shutdown", (_event, ctx) => jobs.reset(ctx));
|
|
434
185
|
pi.registerMessageRenderer<ImageGenerationErrorDetails>(IMAGE_GEN_ERROR_MESSAGE_TYPE, (message, _options, theme) => {
|
|
435
|
-
const rawContent = typeof message.content === "string"
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
.filter((item) => item.type === "text")
|
|
439
|
-
.map((item) => item.text)
|
|
440
|
-
.join("\n");
|
|
441
|
-
const details: ImageGenerationErrorDetails = {
|
|
186
|
+
const rawContent = typeof message.content === "string" ? message.content
|
|
187
|
+
: message.content.filter((item) => item.type === "text").map((item) => item.text).join("\n");
|
|
188
|
+
return renderImageGenError({
|
|
442
189
|
message: message.details?.message ?? rawContent.replace(/^Image generation failed:\s*/i, "") ?? "Image generation failed.",
|
|
443
|
-
prompt: message.details?.prompt,
|
|
444
|
-
|
|
445
|
-
referenceCount: message.details?.referenceCount,
|
|
446
|
-
};
|
|
447
|
-
return renderImageGenError(details, theme);
|
|
190
|
+
prompt: message.details?.prompt, imageModel: message.details?.imageModel, referenceCount: message.details?.referenceCount,
|
|
191
|
+
}, theme);
|
|
448
192
|
});
|
|
449
|
-
|
|
450
193
|
pi.registerCommand("image-gen", {
|
|
451
|
-
description: "Generate or edit an image in the background with the
|
|
194
|
+
description: "Generate or edit an image in the background with the standalone Images API. Usage: /image-gen prompt text [@reference.png]",
|
|
452
195
|
handler: async (args, ctx) => {
|
|
453
196
|
const parsed = parseImageGenCommandArgs(args);
|
|
454
197
|
if (!parsed.prompt) {
|
|
@@ -464,8 +207,7 @@ export function registerBackgroundImageGenerationCommand(pi: ExtensionAPI): void
|
|
|
464
207
|
const message = error instanceof Error ? error.message : String(error);
|
|
465
208
|
pi.sendMessage({
|
|
466
209
|
customType: IMAGE_GEN_ERROR_MESSAGE_TYPE,
|
|
467
|
-
content: `Image generation failed: ${message}`,
|
|
468
|
-
display: true,
|
|
210
|
+
content: `Image generation failed: ${message}`, display: true,
|
|
469
211
|
details: { message, prompt: parsed.prompt, imageModel: settings.imageModel, referenceCount: parsed.imagePaths.length } satisfies ImageGenerationErrorDetails,
|
|
470
212
|
}, { triggerTurn: false });
|
|
471
213
|
})
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,6 @@ export default function codexImagegen(pi: ExtensionAPI): void {
|
|
|
27
27
|
const definition = createImageGenerationToolDefinition({
|
|
28
28
|
loadSettings: (cwd, model) => loadModelSettings(model, cwd),
|
|
29
29
|
getCurrentTurnId: sessionId => currentCodexTurn(sessionId)?.turnId,
|
|
30
|
-
hasProviderRuntime: () => broker.coreEnabled,
|
|
31
30
|
});
|
|
32
31
|
const parameters = { ...definition.parameters };
|
|
33
32
|
broker.ownedTools.set("image_generation", { parameters, description: definition.description });
|
|
@@ -1,28 +1,21 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
-
import { buildSessionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { buildSessionContext, detectSupportedImageMimeTypeFromFile, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import { saveBase64Image } from "../utils/images.js";
|
|
6
5
|
import { imageGenerationOutput, imageGenerationOutputSchema } from "./image-generation/output.js";
|
|
7
6
|
import type { CodexMinimalToolsSettings } from "@oai404iao/pi-codex-runtime/internal/settings";
|
|
8
7
|
import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
|
|
9
|
-
import {
|
|
10
|
-
buildCodexJsonHeaders,
|
|
11
|
-
hasCodexRequestAuth,
|
|
12
|
-
resolveCodexApiEndpoint,
|
|
13
|
-
withResolvedAuthBaseUrl,
|
|
14
|
-
} from "@oai404iao/pi-codex-runtime/internal/codex-http";
|
|
8
|
+
import { buildCodexJsonHeaders, hasCodexRequestAuth, resolveCodexApiEndpoint, withResolvedAuthBaseUrl } from "@oai404iao/pi-codex-runtime/internal/codex-http";
|
|
15
9
|
import { loadModelSettings, type ResolvedCodexModelSettings } from "@oai404iao/pi-codex-runtime/internal/model-catalog/runtime";
|
|
16
10
|
import { checkEndpointResponse, rememberResolvedEndpoint, requireEndpointCapability } from "@oai404iao/pi-codex-runtime/internal/endpoint-state";
|
|
11
|
+
import { currentCodexTurn, uuidV7 } from "@oai404iao/pi-codex-runtime/internal/codex-wire-identity";
|
|
12
|
+
import { fetchCodexJson } from "@oai404iao/pi-codex-runtime/internal/json-request";
|
|
17
13
|
|
|
18
14
|
export interface ImageGenerationInput {
|
|
19
15
|
prompt?: string;
|
|
20
|
-
referenced_image_paths?: string[];
|
|
21
|
-
num_last_images_to_include?: number;
|
|
22
|
-
|
|
23
|
-
quality?: "low" | "medium" | "high" | "auto";
|
|
24
|
-
background?: "transparent" | "opaque" | "auto";
|
|
25
|
-
output_format?: "png" | "webp" | "jpeg";
|
|
16
|
+
referenced_image_paths?: string[] | null;
|
|
17
|
+
num_last_images_to_include?: number | null;
|
|
18
|
+
transparent_background?: boolean;
|
|
26
19
|
}
|
|
27
20
|
|
|
28
21
|
export const imageGenerationToolSchema = {
|
|
@@ -31,74 +24,22 @@ export const imageGenerationToolSchema = {
|
|
|
31
24
|
properties: {
|
|
32
25
|
prompt: { type: "string", description: "Image generation or editing prompt." },
|
|
33
26
|
referenced_image_paths: {
|
|
34
|
-
type: "array",
|
|
35
|
-
maxItems: 5,
|
|
27
|
+
type: ["array", "null"], maxItems: 5,
|
|
36
28
|
items: { type: "string", minLength: 1 },
|
|
37
|
-
description: "Local PNG, JPEG,
|
|
29
|
+
description: "Local image paths to edit. PNG, JPEG, WebP, GIF and BMP are supported.",
|
|
38
30
|
},
|
|
39
31
|
num_last_images_to_include: {
|
|
40
|
-
type: "integer",
|
|
41
|
-
minimum: 1,
|
|
42
|
-
maximum: 5,
|
|
32
|
+
type: ["integer", "null"], minimum: 1, maximum: 5,
|
|
43
33
|
description: "Use the newest conversation images when one or more targets have no local path.",
|
|
44
34
|
},
|
|
35
|
+
transparent_background: {
|
|
36
|
+
type: "boolean",
|
|
37
|
+
description: "Whether the output should have a transparent background. Defaults to false.",
|
|
38
|
+
},
|
|
45
39
|
},
|
|
46
40
|
required: ["prompt"],
|
|
47
41
|
};
|
|
48
42
|
|
|
49
|
-
async function urlToBase64(url: string, signal?: AbortSignal): Promise<string> {
|
|
50
|
-
const response = await fetch(url, { signal });
|
|
51
|
-
if (!response.ok) throw new Error(`Failed to download generated image: ${response.status} ${await response.text()}`);
|
|
52
|
-
const buffer = Buffer.from(await response.arrayBuffer());
|
|
53
|
-
return buffer.toString("base64");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function directImageGeneration(input: ImageGenerationInput, ctx: ImageGenerationToolContext, settings: CodexMinimalToolsSettings, signal?: AbortSignal) {
|
|
57
|
-
if (!settings.imageGeneration) throw new Error("Image generation is disabled by the global imageGeneration setting.");
|
|
58
|
-
if (!settings.directImageApiFallback) throw new Error("Direct Images API fallback is disabled. Use the configured image implementation, or enable directImageApiFallback.");
|
|
59
|
-
if (!input.prompt?.trim()) throw new Error("A prompt is required for direct image_generation fallback.");
|
|
60
|
-
signal?.throwIfAborted();
|
|
61
|
-
const model = ctx.model;
|
|
62
|
-
if (!model || !ctx.modelRegistry) throw new Error("No active model is available for direct image generation.");
|
|
63
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
64
|
-
signal?.throwIfAborted();
|
|
65
|
-
if (!auth.ok) throw new Error(auth.error);
|
|
66
|
-
const requestModel = withResolvedAuthBaseUrl(model, auth);
|
|
67
|
-
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
68
|
-
rememberResolvedEndpoint(model, requestModel, sessionId);
|
|
69
|
-
requireEndpointCapability(settings.endpoint_config, requestModel, sessionId, "imageGeneration.standalone");
|
|
70
|
-
if (!hasCodexRequestAuth({ modelHeaders: model.headers, auth })) {
|
|
71
|
-
throw new Error(`No request authentication for provider: ${model.provider}`);
|
|
72
|
-
}
|
|
73
|
-
const endpoint = loadModelSettings(model, ctx.cwd, settings).responsesEndpoint;
|
|
74
|
-
const body: Record<string, unknown> = {
|
|
75
|
-
model: settings.imageModel,
|
|
76
|
-
prompt: input.prompt,
|
|
77
|
-
};
|
|
78
|
-
if (input.size && input.size !== "auto") body.size = input.size;
|
|
79
|
-
if (input.quality && input.quality !== "auto") body.quality = input.quality;
|
|
80
|
-
if (input.background && input.background !== "auto") body.background = input.background;
|
|
81
|
-
if (input.output_format) body.output_format = input.output_format;
|
|
82
|
-
const response = await fetch(resolveCodexApiEndpoint(auth.baseUrl ?? model.baseUrl, endpoint, "images/generations"), {
|
|
83
|
-
method: "POST",
|
|
84
|
-
headers: buildCodexJsonHeaders({ modelHeaders: model.headers, auth, endpoint, codexRequestExtensions: settings.codexRequestExtensions }),
|
|
85
|
-
body: JSON.stringify(body),
|
|
86
|
-
signal,
|
|
87
|
-
});
|
|
88
|
-
await checkEndpointResponse(response, requestModel, sessionId, ["imageGeneration.standalone"], signal);
|
|
89
|
-
const json = await response.json() as { data?: Array<{ b64_json?: string; url?: string; revised_prompt?: string }> };
|
|
90
|
-
const first = json.data?.[0];
|
|
91
|
-
const base64 = first?.b64_json ?? (first?.url ? await urlToBase64(first.url, signal) : undefined);
|
|
92
|
-
if (!base64) throw new Error("OpenAI Images API returned no image data.");
|
|
93
|
-
signal?.throwIfAborted();
|
|
94
|
-
const saved = await saveBase64Image({ base64, callId: "direct", cwd: ctx.cwd, format: input.output_format, responseId: settings.imageModel, settings });
|
|
95
|
-
return {
|
|
96
|
-
content: [{ type: "text", text: `Generated image with ${settings.imageModel}; saved to ${saved.path}${saved.latestPath ? ` (latest: ${saved.latestPath})` : ""}.` }],
|
|
97
|
-
details: { saved, revisedPrompt: first?.revised_prompt, mode: "direct-images-api" },
|
|
98
|
-
structuredContent: imageGenerationOutput(saved, base64),
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
43
|
interface ImageGenerationToolContext {
|
|
103
44
|
cwd: string;
|
|
104
45
|
model?: Model<Api>;
|
|
@@ -124,26 +65,27 @@ async function referencedImageUrls(cwd: string, paths: readonly string[]): Promi
|
|
|
124
65
|
return Promise.all(paths.map(async (rawPath) => {
|
|
125
66
|
const normalized = rawPath.replace(/^@/, "");
|
|
126
67
|
const path = isAbsolute(normalized) ? normalized : resolve(cwd, normalized);
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
? "image/png"
|
|
130
|
-
: extension === ".jpg" || extension === ".jpeg"
|
|
131
|
-
? "image/jpeg"
|
|
132
|
-
: extension === ".webp"
|
|
133
|
-
? "image/webp"
|
|
134
|
-
: undefined;
|
|
135
|
-
if (!mimeType) throw new Error(`Unsupported reference image type: ${rawPath}. Use PNG, JPEG, or WebP.`);
|
|
68
|
+
const mimeType = await detectSupportedImageMimeTypeFromFile(path);
|
|
69
|
+
if (!mimeType) throw new Error(`Unsupported reference image type: ${rawPath}. Use PNG, JPEG, WebP, GIF, or BMP.`);
|
|
136
70
|
const data = await readFile(path);
|
|
71
|
+
if (mimeType === "image/gif" || mimeType === "image/bmp") {
|
|
72
|
+
// Codex Original mode normalizes unsupported source formats to PNG without resizing.
|
|
73
|
+
const { PhotonImage } = await import("@silvia-odwyer/photon-node");
|
|
74
|
+
const decoded = PhotonImage.new_from_byteslice(data);
|
|
75
|
+
try {
|
|
76
|
+
return { image_url: `data:image/png;base64,${Buffer.from(decoded.get_bytes()).toString("base64")}` };
|
|
77
|
+
} finally {
|
|
78
|
+
decoded.free();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
137
81
|
return { image_url: `data:${mimeType};base64,${data.toString("base64")}` };
|
|
138
82
|
}));
|
|
139
83
|
}
|
|
140
84
|
|
|
141
|
-
function recentConversationImageUrls(
|
|
142
|
-
ctx
|
|
143
|
-
count
|
|
144
|
-
|
|
145
|
-
if (!ctx.sessionManager) {
|
|
146
|
-
throw new Error("Conversation images are unavailable in this tool context; use referenced_image_paths.");
|
|
85
|
+
function recentConversationImageUrls(ctx: ImageGenerationToolContext, count: number): Array<{ image_url: string }> {
|
|
86
|
+
if (!ctx.sessionManager) throw new Error("Conversation images are unavailable in this tool context; use referenced_image_paths.");
|
|
87
|
+
if (!Number.isSafeInteger(count) || count < 1 || count > 5) {
|
|
88
|
+
throw new Error("num_last_images_to_include must be an integer between 1 and 5.");
|
|
147
89
|
}
|
|
148
90
|
const messages = buildSessionContext(ctx.sessionManager.getBranch()).messages;
|
|
149
91
|
const images: string[] = [];
|
|
@@ -151,31 +93,20 @@ function recentConversationImageUrls(
|
|
|
151
93
|
if (message.role === "user" || message.role === "toolResult") {
|
|
152
94
|
if (!Array.isArray(message.content)) continue;
|
|
153
95
|
for (const item of message.content) {
|
|
154
|
-
if (item.type === "image") {
|
|
155
|
-
images.push(`data:${item.mimeType};base64,${item.data}`);
|
|
156
|
-
}
|
|
96
|
+
if (item.type === "image") images.push(`data:${item.mimeType};base64,${item.data}`);
|
|
157
97
|
}
|
|
158
98
|
continue;
|
|
159
99
|
}
|
|
160
100
|
if (message.role !== "assistant") continue;
|
|
161
101
|
for (const block of message.content as unknown[]) {
|
|
162
102
|
if (!block || typeof block !== "object") continue;
|
|
163
|
-
const candidate = block as {
|
|
164
|
-
|
|
165
|
-
item?: { result?: unknown };
|
|
166
|
-
};
|
|
167
|
-
if (
|
|
168
|
-
candidate.type === "image_generation_call"
|
|
169
|
-
&& typeof candidate.item?.result === "string"
|
|
170
|
-
&& candidate.item.result
|
|
171
|
-
) {
|
|
103
|
+
const candidate = block as { type?: unknown; item?: { result?: unknown } };
|
|
104
|
+
if (candidate.type === "image_generation_call" && typeof candidate.item?.result === "string" && candidate.item.result) {
|
|
172
105
|
images.push(`data:image/png;base64,${candidate.item.result}`);
|
|
173
106
|
}
|
|
174
107
|
}
|
|
175
108
|
}
|
|
176
|
-
if (images.length < count) {
|
|
177
|
-
throw new Error(`Requested the last ${count} conversation images, but only ${images.length} were available.`);
|
|
178
|
-
}
|
|
109
|
+
if (images.length < count) throw new Error(`Requested the last ${count} conversation images, but only ${images.length} were available.`);
|
|
179
110
|
return images.slice(-count).map((image_url) => ({ image_url }));
|
|
180
111
|
}
|
|
181
112
|
|
|
@@ -188,84 +119,69 @@ export async function standaloneImageGeneration(
|
|
|
188
119
|
) {
|
|
189
120
|
signal?.throwIfAborted();
|
|
190
121
|
const callId = invocation.callId ?? "standalone";
|
|
191
|
-
const
|
|
122
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
123
|
+
const turnId = invocation.turnId ?? currentCodexTurn(sessionId)?.turnId ?? uuidV7();
|
|
192
124
|
const model = ctx.model;
|
|
193
125
|
if (!model || !ctx.modelRegistry) throw new Error("No active model is available for standalone image generation.");
|
|
194
126
|
if (!settings.enabled) throw new Error("pi-codex-minimal-tools is disabled.");
|
|
195
127
|
if (!settings.imageGeneration) throw new Error("Image generation is disabled by the global setting or current model profile.");
|
|
128
|
+
if (settings.imageGenerationImplementation !== "standalone") throw new Error("Image generation requires a standalone model profile.");
|
|
196
129
|
if (!input.prompt?.trim()) throw new Error("A prompt is required for standalone image generation.");
|
|
197
|
-
if (input.
|
|
130
|
+
if (input.transparent_background !== undefined && typeof input.transparent_background !== "boolean") {
|
|
131
|
+
throw new Error("transparent_background must be a boolean.");
|
|
132
|
+
}
|
|
133
|
+
if (input.num_last_images_to_include != null && input.referenced_image_paths?.length) {
|
|
198
134
|
throw new Error("Provide only one of referenced_image_paths or num_last_images_to_include.");
|
|
199
135
|
}
|
|
136
|
+
// Capture the invocation's images and turn before auth can yield to another turn.
|
|
137
|
+
const images = input.num_last_images_to_include != null
|
|
138
|
+
? recentConversationImageUrls(ctx, input.num_last_images_to_include)
|
|
139
|
+
: await referencedImageUrls(ctx.cwd, input.referenced_image_paths ?? []);
|
|
200
140
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
201
141
|
signal?.throwIfAborted();
|
|
202
142
|
if (!auth.ok) throw new Error(auth.error);
|
|
203
143
|
const requestModel = withResolvedAuthBaseUrl(model, auth);
|
|
204
|
-
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
205
144
|
rememberResolvedEndpoint(model, requestModel, sessionId);
|
|
206
145
|
requireEndpointCapability(settings.endpoint_config, requestModel, sessionId, "imageGeneration.standalone");
|
|
207
|
-
if (!hasCodexRequestAuth({
|
|
208
|
-
modelHeaders: model.headers,
|
|
209
|
-
auth: { apiKey: auth.apiKey, headers: auth.headers },
|
|
210
|
-
})) {
|
|
146
|
+
if (!hasCodexRequestAuth({ modelHeaders: model.headers, auth })) {
|
|
211
147
|
throw new Error(`No request authentication for provider: ${model.provider}`);
|
|
212
148
|
}
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
: await referencedImageUrls(ctx.cwd, input.referenced_image_paths ?? []);
|
|
216
|
-
const edit = images.length > 0;
|
|
217
|
-
signal?.throwIfAborted();
|
|
218
|
-
const response = await fetch(
|
|
219
|
-
resolveCodexApiEndpoint(
|
|
220
|
-
auth.baseUrl ?? model.baseUrl,
|
|
221
|
-
settings.responsesEndpoint,
|
|
222
|
-
edit ? "images/edits" : "images/generations",
|
|
223
|
-
),
|
|
149
|
+
const response = await fetchCodexJson(
|
|
150
|
+
resolveCodexApiEndpoint(auth.baseUrl ?? model.baseUrl, settings.responsesEndpoint, images.length ? "images/edits" : "images/generations"),
|
|
224
151
|
{
|
|
225
|
-
method: "POST",
|
|
226
152
|
headers: buildCodexJsonHeaders({
|
|
227
153
|
codexRequestExtensions: settings.codexRequestExtensions,
|
|
228
|
-
modelHeaders: model.headers,
|
|
229
|
-
|
|
230
|
-
endpoint: settings.responsesEndpoint,
|
|
231
|
-
extraHeaders: settings.codexRequestExtensions ? {
|
|
232
|
-
"x-codex-image-turn-id": turnId,
|
|
233
|
-
} : undefined,
|
|
154
|
+
modelHeaders: model.headers, auth, endpoint: settings.responsesEndpoint,
|
|
155
|
+
extraHeaders: settings.codexRequestExtensions ? { "x-codex-image-turn-id": turnId } : undefined,
|
|
234
156
|
}),
|
|
235
157
|
body: JSON.stringify({
|
|
236
|
-
model: settings.imageModel,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
quality: "auto",
|
|
241
|
-
size: "auto",
|
|
158
|
+
model: settings.imageModel, prompt: input.prompt,
|
|
159
|
+
...(images.length ? { images } : {}),
|
|
160
|
+
background: input.transparent_background === true ? "transparent" : "opaque",
|
|
161
|
+
quality: "auto", size: "auto",
|
|
242
162
|
}),
|
|
243
163
|
signal,
|
|
244
164
|
},
|
|
165
|
+
["imageGeneration.standalone"],
|
|
245
166
|
);
|
|
246
167
|
signal?.throwIfAborted();
|
|
247
168
|
await checkEndpointResponse(response, requestModel, sessionId, ["imageGeneration.standalone"], signal);
|
|
248
|
-
const result = await response.json() as {
|
|
249
|
-
data?: Array<{ b64_json?: string }>;
|
|
250
|
-
};
|
|
169
|
+
const result = await response.json() as { data?: Array<{ b64_json?: string; generation_id?: string }> };
|
|
251
170
|
signal?.throwIfAborted();
|
|
252
171
|
const base64 = result.data?.[0]?.b64_json;
|
|
253
172
|
if (!base64) throw new Error("Standalone image generation returned no image data.");
|
|
254
|
-
const saved = await saveBase64Image({
|
|
255
|
-
base64,
|
|
256
|
-
callId,
|
|
257
|
-
cwd: ctx.cwd,
|
|
258
|
-
format: "png",
|
|
259
|
-
responseId: settings.imageModel,
|
|
260
|
-
settings,
|
|
261
|
-
});
|
|
173
|
+
const saved = await saveBase64Image({ base64, callId, cwd: ctx.cwd, format: "png", responseId: settings.imageModel, settings });
|
|
262
174
|
signal?.throwIfAborted();
|
|
263
175
|
return {
|
|
264
176
|
content: [
|
|
265
177
|
{ type: "image", data: base64, mimeType: "image/png" },
|
|
266
178
|
{ type: "text", text: `Generated image with ${settings.imageModel}; saved to ${saved.path}${saved.latestPath ? ` (latest: ${saved.latestPath})` : ""}.` },
|
|
267
179
|
],
|
|
268
|
-
details: {
|
|
180
|
+
details: {
|
|
181
|
+
saved, mode: "standalone-images-api",
|
|
182
|
+
requestId: response.headers.get("x-codex-imagegen-request-id") ?? undefined,
|
|
183
|
+
generationId: result.data?.[0]?.generation_id,
|
|
184
|
+
},
|
|
269
185
|
structuredContent: imageGenerationOutput(saved, base64),
|
|
270
186
|
};
|
|
271
187
|
}
|
|
@@ -273,12 +189,11 @@ export async function standaloneImageGeneration(
|
|
|
273
189
|
export function createImageGenerationToolDefinition(options: {
|
|
274
190
|
loadSettings?: (cwd: string, model?: Model<Api>) => CodexMinimalToolsSettings | ResolvedCodexModelSettings;
|
|
275
191
|
getCurrentTurnId?: (sessionId: string | undefined) => string | undefined;
|
|
276
|
-
hasProviderRuntime?: () => boolean;
|
|
277
192
|
} = {}) {
|
|
278
193
|
return {
|
|
279
194
|
name: "image_generation",
|
|
280
195
|
label: "Image Generation",
|
|
281
|
-
description: "Generate or edit images using the
|
|
196
|
+
description: "Generate or edit images using the standalone Images API selected by the current model profile. Results are saved under imageOutputDir and mirrored to latest.png.",
|
|
282
197
|
promptSnippet: "Generate or edit images with the implementation selected by the current model profile.",
|
|
283
198
|
parameters: imageGenerationToolSchema,
|
|
284
199
|
outputSchema: imageGenerationOutputSchema,
|
|
@@ -286,28 +201,10 @@ export function createImageGenerationToolDefinition(options: {
|
|
|
286
201
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
287
202
|
const profileModel = ctx.model ? { ...ctx.model, baseUrl: "" } : undefined;
|
|
288
203
|
const settings = options.loadSettings?.(cwd, profileModel) ?? loadModelSettings(profileModel, cwd);
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
:
|
|
292
|
-
|
|
293
|
-
throw new Error("Image generation is disabled by the global setting or current model profile.");
|
|
294
|
-
}
|
|
295
|
-
if (resolvedSettings.imageGenerationImplementation === "standalone") {
|
|
296
|
-
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
297
|
-
return standaloneImageGeneration(params, ctx, resolvedSettings, signal, {
|
|
298
|
-
callId: toolCallId,
|
|
299
|
-
turnId: options.getCurrentTurnId?.(sessionId),
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
if (settings?.directImageApiFallback) return directImageGeneration(params, { ...ctx, cwd }, settings, signal);
|
|
303
|
-
if (options.hasProviderRuntime?.() === false) {
|
|
304
|
-
throw new Error("Hosted image_generation requires pi-codex-core. Use a catalog-supported standalone profile or explicitly enable directImageApiFallback.");
|
|
305
|
-
}
|
|
306
|
-
return {
|
|
307
|
-
isError: true,
|
|
308
|
-
content: [{ type: "text", text: "image_generation should be handled by the current model profile. If no hosted or standalone implementation is configured, explicitly enable directImageApiFallback using the current provider's Pi authentication." }],
|
|
309
|
-
details: { phase: "native-provider", nativeTool: "image_generation" },
|
|
310
|
-
};
|
|
204
|
+
const resolved = "modelProfile" in settings ? settings as ResolvedCodexModelSettings : loadModelSettings(profileModel, cwd, settings);
|
|
205
|
+
return standaloneImageGeneration(params, { ...ctx, cwd }, resolved, signal, {
|
|
206
|
+
callId: toolCallId, turnId: options.getCurrentTurnId?.(ctx.sessionManager?.getSessionId?.()),
|
|
207
|
+
});
|
|
311
208
|
},
|
|
312
209
|
};
|
|
313
210
|
}
|