pi-openai-toolkit 0.18.0 → 0.20.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 +156 -242
- package/README.zh.md +156 -242
- package/config.schema.json +15 -1
- package/docs/configuration.md +135 -7
- package/package.json +9 -6
- package/src/config/policy.ts +4 -2
- package/src/config/v2.ts +36 -3
- package/src/config.ts +1 -0
- package/src/context-management/codex-provider.ts +18 -8
- package/src/extension-runtime.ts +47 -26
- package/src/image-generation/artifacts.ts +28 -7
- package/src/image-generation/client.ts +5 -3
- package/src/image-generation/eligibility.ts +10 -5
- package/src/image-generation/extension.ts +6 -4
- package/src/image-generation/protocol.ts +175 -38
- package/src/image-generation/render.ts +13 -5
- package/src/image-generation/service.ts +35 -7
- package/src/image-generation/types.ts +23 -3
- package/src/payload-rewrite.ts +16 -1
- package/src/responses-headers.ts +6 -1
- package/src/responses-websocket/extension.ts +32 -0
- package/src/responses-websocket/transport.ts +577 -0
- package/src/runtime.ts +295 -28
- package/src/serializer.ts +214 -0
- package/src/supported-environment.ts +5 -0
- package/src/types.ts +6 -2
- package/src/web-search/extension.ts +4 -3
- package/src/web-search/types.ts +25 -3
- package/tsconfig.check.json +2 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_IMAGE_GENERATION_MODELS } from "../types";
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
IMAGE_GENERATION_MIME_TYPES,
|
|
4
4
|
IMAGE_GENERATION_QUALITIES,
|
|
5
5
|
IMAGE_GENERATION_SIZES,
|
|
6
6
|
MAX_GENERATED_IMAGE_BYTES,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
ImageGenerationError,
|
|
16
16
|
sanitizeImageDiagnostic,
|
|
17
17
|
type GenerateImageParams,
|
|
18
|
+
type ImageGenerationMimeType,
|
|
18
19
|
type NormalizedGenerateImageParams,
|
|
19
20
|
type ParsedGeneratedImage,
|
|
20
21
|
type PreparedReferenceImage,
|
|
@@ -269,35 +270,7 @@ function hasPngEndChunk(buffer: Buffer): boolean {
|
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
export function detectReferenceImageMimeType(bytes: Uint8Array): ReferenceImageMimeType | undefined {
|
|
272
|
-
|
|
273
|
-
if (readPngDimensions(buffer) && hasPngEndChunk(buffer)) return "image/png";
|
|
274
|
-
if (
|
|
275
|
-
buffer.length >= 4 &&
|
|
276
|
-
buffer[0] === 0xff &&
|
|
277
|
-
buffer[1] === 0xd8 &&
|
|
278
|
-
buffer[2] === 0xff &&
|
|
279
|
-
buffer.lastIndexOf(Buffer.from([0xff, 0xd9])) >= 3
|
|
280
|
-
) {
|
|
281
|
-
return "image/jpeg";
|
|
282
|
-
}
|
|
283
|
-
if (
|
|
284
|
-
buffer.length >= 20 &&
|
|
285
|
-
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
|
286
|
-
buffer.toString("ascii", 8, 12) === "WEBP"
|
|
287
|
-
) {
|
|
288
|
-
const declaredSize = buffer.readUInt32LE(4) + 8;
|
|
289
|
-
const chunkType = buffer.toString("ascii", 12, 16);
|
|
290
|
-
const chunkSize = buffer.readUInt32LE(16);
|
|
291
|
-
const paddedChunkSize = chunkSize + (chunkSize % 2);
|
|
292
|
-
if (
|
|
293
|
-
declaredSize === buffer.length &&
|
|
294
|
-
(chunkType === "VP8 " || chunkType === "VP8L" || chunkType === "VP8X") &&
|
|
295
|
-
20 + paddedChunkSize <= buffer.length
|
|
296
|
-
) {
|
|
297
|
-
return "image/webp";
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
return undefined;
|
|
273
|
+
return readImageMetadata(bytes)?.mimeType;
|
|
301
274
|
}
|
|
302
275
|
|
|
303
276
|
export function isValidPng(bytes: Uint8Array): boolean {
|
|
@@ -319,14 +292,145 @@ export function readPngDimensions(bytes: Uint8Array): { width: number; height: n
|
|
|
319
292
|
return { width, height };
|
|
320
293
|
}
|
|
321
294
|
|
|
295
|
+
function boundedDimensions(width: number, height: number): { width: number; height: number } | undefined {
|
|
296
|
+
if (
|
|
297
|
+
!Number.isInteger(width) ||
|
|
298
|
+
!Number.isInteger(height) ||
|
|
299
|
+
width <= 0 ||
|
|
300
|
+
height <= 0 ||
|
|
301
|
+
width > MAX_IMAGE_DIMENSION ||
|
|
302
|
+
height > MAX_IMAGE_DIMENSION
|
|
303
|
+
) {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
return { width, height };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const JPEG_DIMENSION_MARKERS = new Set([
|
|
310
|
+
0xc0,
|
|
311
|
+
0xc1,
|
|
312
|
+
0xc2,
|
|
313
|
+
0xc3,
|
|
314
|
+
0xc5,
|
|
315
|
+
0xc6,
|
|
316
|
+
0xc7,
|
|
317
|
+
0xc9,
|
|
318
|
+
0xca,
|
|
319
|
+
0xcb,
|
|
320
|
+
0xcd,
|
|
321
|
+
0xce,
|
|
322
|
+
0xcf,
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
function readJpegDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
326
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
327
|
+
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return undefined;
|
|
328
|
+
let offset = 2;
|
|
329
|
+
while (offset < buffer.length) {
|
|
330
|
+
if (buffer[offset] !== 0xff) {
|
|
331
|
+
offset += 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
while (offset < buffer.length && buffer[offset] === 0xff) offset += 1;
|
|
335
|
+
if (offset >= buffer.length) return undefined;
|
|
336
|
+
const marker = buffer[offset++];
|
|
337
|
+
if (marker === 0xd9 || marker === 0xda) return undefined;
|
|
338
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
339
|
+
if (offset + 2 > buffer.length) return undefined;
|
|
340
|
+
const segmentLength = buffer.readUInt16BE(offset);
|
|
341
|
+
if (segmentLength < 2 || offset + segmentLength > buffer.length) return undefined;
|
|
342
|
+
if (JPEG_DIMENSION_MARKERS.has(marker) && segmentLength >= 7) {
|
|
343
|
+
const height = buffer.readUInt16BE(offset + 3);
|
|
344
|
+
const width = buffer.readUInt16BE(offset + 5);
|
|
345
|
+
return boundedDimensions(width, height);
|
|
346
|
+
}
|
|
347
|
+
offset += segmentLength;
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hasJpegEndMarker(bytes: Uint8Array): boolean {
|
|
353
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
354
|
+
return buffer.length >= 2 && buffer[buffer.length - 2] === 0xff && buffer[buffer.length - 1] === 0xd9;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readWebpDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
358
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
359
|
+
if (
|
|
360
|
+
buffer.length < 20 ||
|
|
361
|
+
buffer.toString("ascii", 0, 4) !== "RIFF" ||
|
|
362
|
+
buffer.toString("ascii", 8, 12) !== "WEBP"
|
|
363
|
+
) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const declaredSize = buffer.readUInt32LE(4) + 8;
|
|
367
|
+
if (declaredSize !== buffer.length) return undefined;
|
|
368
|
+
let offset = 12;
|
|
369
|
+
while (offset + 8 <= buffer.length) {
|
|
370
|
+
const chunkType = buffer.toString("ascii", offset, offset + 4);
|
|
371
|
+
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
372
|
+
const dataStart = offset + 8;
|
|
373
|
+
const dataEnd = dataStart + chunkSize;
|
|
374
|
+
if (dataEnd > buffer.length) return undefined;
|
|
375
|
+
if (chunkType === "VP8X" && chunkSize >= 10) {
|
|
376
|
+
const width = 1 + buffer.readUIntLE(dataStart + 4, 3);
|
|
377
|
+
const height = 1 + buffer.readUIntLE(dataStart + 7, 3);
|
|
378
|
+
return boundedDimensions(width, height);
|
|
379
|
+
}
|
|
380
|
+
if (chunkType === "VP8 " && chunkSize >= 10) {
|
|
381
|
+
if (
|
|
382
|
+
buffer[dataStart + 3] === 0x9d &&
|
|
383
|
+
buffer[dataStart + 4] === 0x01 &&
|
|
384
|
+
buffer[dataStart + 5] === 0x2a
|
|
385
|
+
) {
|
|
386
|
+
const width = buffer.readUInt16LE(dataStart + 6) & 0x3fff;
|
|
387
|
+
const height = buffer.readUInt16LE(dataStart + 8) & 0x3fff;
|
|
388
|
+
return boundedDimensions(width, height);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (chunkType === "VP8L" && chunkSize >= 5 && buffer[dataStart] === 0x2f) {
|
|
392
|
+
const width = 1 + ((buffer[dataStart + 1] | (buffer[dataStart + 2] << 8)) & 0x3fff);
|
|
393
|
+
const height = 1 + ((buffer[dataStart + 2] >> 6) | (buffer[dataStart + 3] << 2) | (buffer[dataStart + 4] << 10));
|
|
394
|
+
return boundedDimensions(width, height);
|
|
395
|
+
}
|
|
396
|
+
offset = dataEnd + (chunkSize % 2);
|
|
397
|
+
}
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
322
401
|
function extractBase64(value: string): string {
|
|
323
402
|
const trimmed = value.trim();
|
|
324
403
|
const dataUrl = DATA_URL_PATTERN.exec(trimmed);
|
|
325
404
|
return (dataUrl?.[1] ?? trimmed).trim();
|
|
326
405
|
}
|
|
327
406
|
|
|
328
|
-
|
|
329
|
-
|
|
407
|
+
function normalizeImageMimeType(value: unknown): ImageGenerationMimeType | undefined {
|
|
408
|
+
if (typeof value !== "string") return undefined;
|
|
409
|
+
const normalized = value.trim().toLowerCase();
|
|
410
|
+
if (normalized === "image/jpg") return "image/jpeg";
|
|
411
|
+
return (IMAGE_GENERATION_MIME_TYPES as readonly string[]).includes(normalized)
|
|
412
|
+
? normalized as ImageGenerationMimeType
|
|
413
|
+
: undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function readImageMetadata(bytes: Uint8Array):
|
|
417
|
+
| { mimeType: ImageGenerationMimeType; width: number; height: number }
|
|
418
|
+
| undefined {
|
|
419
|
+
if (readPngDimensions(bytes) && hasPngEndChunk(Buffer.from(bytes))) {
|
|
420
|
+
const dimensions = readPngDimensions(bytes)!;
|
|
421
|
+
return { mimeType: "image/png", ...dimensions };
|
|
422
|
+
}
|
|
423
|
+
if (hasJpegEndMarker(bytes)) {
|
|
424
|
+
const dimensions = readJpegDimensions(bytes);
|
|
425
|
+
if (dimensions) return { mimeType: "image/jpeg", ...dimensions };
|
|
426
|
+
}
|
|
427
|
+
const dimensions = readWebpDimensions(bytes);
|
|
428
|
+
if (dimensions) return { mimeType: "image/webp", ...dimensions };
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function decodeGeneratedImage(value: string, hintedMimeType?: unknown):
|
|
433
|
+
| { ok: true; bytes: Buffer; mimeType: ImageGenerationMimeType; width: number; height: number }
|
|
330
434
|
| { ok: false; reason: "malformed-response" | "oversized-response"; errorMessage: string } {
|
|
331
435
|
const base64 = extractBase64(value);
|
|
332
436
|
const maxBase64Chars = Math.ceil(MAX_GENERATED_IMAGE_BYTES / 3) * 4;
|
|
@@ -358,16 +462,40 @@ export function decodeGeneratedPng(value: string):
|
|
|
358
462
|
errorMessage: "Image generation returned invalid or oversized image data.",
|
|
359
463
|
};
|
|
360
464
|
}
|
|
361
|
-
const
|
|
362
|
-
if (!
|
|
465
|
+
const metadata = readImageMetadata(bytes);
|
|
466
|
+
if (!metadata) {
|
|
363
467
|
bytes.fill(0);
|
|
364
468
|
return {
|
|
365
469
|
ok: false,
|
|
366
470
|
reason: "malformed-response",
|
|
367
|
-
errorMessage: "Image generation returned data that is not a
|
|
471
|
+
errorMessage: "Image generation returned data that is not a supported PNG, JPEG, or WebP image.",
|
|
368
472
|
};
|
|
369
473
|
}
|
|
370
|
-
|
|
474
|
+
if (typeof hintedMimeType === "string" && hintedMimeType.trim()) {
|
|
475
|
+
const hinted = normalizeImageMimeType(hintedMimeType);
|
|
476
|
+
if (!hinted || hinted !== metadata.mimeType) {
|
|
477
|
+
bytes.fill(0);
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
reason: "malformed-response",
|
|
481
|
+
errorMessage: "Image generation returned an image with inconsistent MIME metadata.",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return { ok: true, bytes, ...metadata };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function decodeGeneratedPng(value: string):
|
|
489
|
+
| { ok: true; bytes: Buffer; width: number; height: number }
|
|
490
|
+
| { ok: false; reason: "malformed-response" | "oversized-response"; errorMessage: string } {
|
|
491
|
+
const decoded = decodeGeneratedImage(value, "image/png");
|
|
492
|
+
if (!decoded.ok) return decoded;
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
bytes: decoded.bytes,
|
|
496
|
+
width: decoded.width,
|
|
497
|
+
height: decoded.height,
|
|
498
|
+
};
|
|
371
499
|
}
|
|
372
500
|
|
|
373
501
|
export function extractProviderErrorMessage(value: unknown, fallback: string): string {
|
|
@@ -419,13 +547,22 @@ export function parseImagesApiResponse(value: unknown): ParsedImageResponseResul
|
|
|
419
547
|
errorMessage: "Image generation response did not contain base64 image data.",
|
|
420
548
|
};
|
|
421
549
|
}
|
|
422
|
-
const
|
|
550
|
+
const mimeHint = item.mime_type ?? item.mimeType;
|
|
551
|
+
if (mimeHint !== undefined && typeof mimeHint !== "string") {
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
reason: "malformed-response",
|
|
555
|
+
errorMessage: "Image generation returned invalid MIME metadata.",
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const decoded = decodeGeneratedImage(item.b64_json, mimeHint);
|
|
423
559
|
if (!decoded.ok) return decoded;
|
|
424
560
|
|
|
425
561
|
return {
|
|
426
562
|
ok: true,
|
|
427
563
|
image: {
|
|
428
564
|
bytes: decoded.bytes,
|
|
565
|
+
mimeType: decoded.mimeType,
|
|
429
566
|
imageCallId:
|
|
430
567
|
normalizedOptionalString(item.id, MAX_IMAGE_IDENTIFIER_CHARS) ?? "image_generation_0",
|
|
431
568
|
responseId: normalizedOptionalString(value.id, MAX_IMAGE_IDENTIFIER_CHARS),
|
|
@@ -438,5 +575,5 @@ export function parseImagesApiResponse(value: unknown): ParsedImageResponseResul
|
|
|
438
575
|
|
|
439
576
|
export const _protocolTest = {
|
|
440
577
|
PNG_SIGNATURE,
|
|
441
|
-
|
|
578
|
+
IMAGE_GENERATION_MIME_TYPES,
|
|
442
579
|
};
|
|
@@ -15,12 +15,12 @@ import {
|
|
|
15
15
|
type Component,
|
|
16
16
|
} from "@earendil-works/pi-tui";
|
|
17
17
|
import { getGeneratedImagesRoot } from "./artifacts";
|
|
18
|
-
import {
|
|
18
|
+
import { readImageMetadata } from "./protocol";
|
|
19
19
|
import {
|
|
20
|
-
IMAGE_GENERATION_MIME_TYPE,
|
|
21
20
|
MAX_GENERATED_IMAGE_BYTES,
|
|
22
21
|
isImageGenerationDetails,
|
|
23
22
|
type GenerateImageParams,
|
|
23
|
+
type ImageGenerationMimeType,
|
|
24
24
|
type ImageGenerationToolDetails,
|
|
25
25
|
} from "./types";
|
|
26
26
|
|
|
@@ -66,6 +66,7 @@ class DiskBackedImageResult extends Container {
|
|
|
66
66
|
readonly artifactPath?: string;
|
|
67
67
|
readonly fileSize?: number;
|
|
68
68
|
readonly mtimeMs?: number;
|
|
69
|
+
readonly mimeType?: ImageGenerationMimeType;
|
|
69
70
|
readonly showImages: boolean;
|
|
70
71
|
readonly textValue: string;
|
|
71
72
|
|
|
@@ -74,6 +75,7 @@ class DiskBackedImageResult extends Container {
|
|
|
74
75
|
artifactPath?: string;
|
|
75
76
|
fileSize?: number;
|
|
76
77
|
mtimeMs?: number;
|
|
78
|
+
mimeType?: ImageGenerationMimeType;
|
|
77
79
|
showImages: boolean;
|
|
78
80
|
base64?: string;
|
|
79
81
|
theme: Theme;
|
|
@@ -83,6 +85,7 @@ class DiskBackedImageResult extends Container {
|
|
|
83
85
|
this.artifactPath = args.artifactPath;
|
|
84
86
|
this.fileSize = args.fileSize;
|
|
85
87
|
this.mtimeMs = args.mtimeMs;
|
|
88
|
+
this.mimeType = args.mimeType;
|
|
86
89
|
this.showImages = args.showImages;
|
|
87
90
|
this.textValue = `${args.text}\n${args.previewWarning ?? ""}`;
|
|
88
91
|
this.addChild(new Text(args.theme.fg("toolOutput", args.text), 0, 0));
|
|
@@ -94,7 +97,7 @@ class DiskBackedImageResult extends Container {
|
|
|
94
97
|
this.addChild(
|
|
95
98
|
new Image(
|
|
96
99
|
args.base64,
|
|
97
|
-
|
|
100
|
+
args.mimeType ?? "image/png",
|
|
98
101
|
{ fallbackColor: (text) => args.theme.fg("toolOutput", text) },
|
|
99
102
|
{ maxWidthCells: 60, filename: args.artifactPath },
|
|
100
103
|
),
|
|
@@ -107,6 +110,7 @@ class DiskBackedImageResult extends Container {
|
|
|
107
110
|
artifactPath?: string;
|
|
108
111
|
fileSize?: number;
|
|
109
112
|
mtimeMs?: number;
|
|
113
|
+
mimeType?: ImageGenerationMimeType;
|
|
110
114
|
showImages: boolean;
|
|
111
115
|
previewWarning?: string;
|
|
112
116
|
}): boolean {
|
|
@@ -115,6 +119,7 @@ class DiskBackedImageResult extends Container {
|
|
|
115
119
|
this.artifactPath === args.artifactPath &&
|
|
116
120
|
this.fileSize === args.fileSize &&
|
|
117
121
|
this.mtimeMs === args.mtimeMs &&
|
|
122
|
+
this.mimeType === args.mimeType &&
|
|
118
123
|
this.showImages === args.showImages
|
|
119
124
|
);
|
|
120
125
|
}
|
|
@@ -204,6 +209,7 @@ export function createImageGenerationResultRenderer(
|
|
|
204
209
|
artifactPath: realArtifact,
|
|
205
210
|
fileSize: info.size,
|
|
206
211
|
mtimeMs: info.mtimeMs,
|
|
212
|
+
mimeType: result.details.mimeType,
|
|
207
213
|
showImages: true,
|
|
208
214
|
})
|
|
209
215
|
) {
|
|
@@ -221,13 +227,14 @@ export function createImageGenerationResultRenderer(
|
|
|
221
227
|
previewWarning: "Image preview unavailable: the artifact could not be read.",
|
|
222
228
|
});
|
|
223
229
|
}
|
|
224
|
-
|
|
230
|
+
const metadata = readImageMetadata(bytes);
|
|
231
|
+
if (bytes.length !== info.size || !metadata || metadata.mimeType !== result.details.mimeType) {
|
|
225
232
|
bytes.fill(0);
|
|
226
233
|
return textOnly({
|
|
227
234
|
text,
|
|
228
235
|
theme,
|
|
229
236
|
context,
|
|
230
|
-
previewWarning: "Image preview unavailable: the artifact is not a valid
|
|
237
|
+
previewWarning: "Image preview unavailable: the artifact is not a valid supported image.",
|
|
231
238
|
});
|
|
232
239
|
}
|
|
233
240
|
const base64 = bytes.toString("base64");
|
|
@@ -237,6 +244,7 @@ export function createImageGenerationResultRenderer(
|
|
|
237
244
|
artifactPath: realArtifact,
|
|
238
245
|
fileSize: info.size,
|
|
239
246
|
mtimeMs: info.mtimeMs,
|
|
247
|
+
mimeType: metadata.mimeType,
|
|
240
248
|
showImages: true,
|
|
241
249
|
base64,
|
|
242
250
|
theme,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { assertConfigValid, loadToolkitConfig, resolveToolkitConfig } from "../config";
|
|
3
3
|
import { notifyConfigIssues } from "../config/notifications";
|
|
4
|
-
import { resolveResponsesEnvironment } from "../runtime";
|
|
4
|
+
import { resolveEffectiveModel, resolveResponsesEnvironment, VIRTUAL_MODEL_API } from "../runtime";
|
|
5
5
|
import {
|
|
6
6
|
copyImageToExplicitPath,
|
|
7
7
|
prepareExplicitOutputPath,
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
} from "./references";
|
|
22
22
|
import {
|
|
23
23
|
IMAGE_GENERATION_CAPABLE_APIS,
|
|
24
|
-
IMAGE_GENERATION_MIME_TYPE,
|
|
25
24
|
MAX_IMAGE_IDENTIFIER_CHARS,
|
|
26
25
|
ImageGenerationError,
|
|
27
26
|
sanitizeImageDiagnostic,
|
|
@@ -124,16 +123,28 @@ function asImageGenerationError(value: unknown): ImageGenerationError {
|
|
|
124
123
|
);
|
|
125
124
|
}
|
|
126
125
|
|
|
126
|
+
function formatImageMimeType(mimeType: ImageGenerationDetails["mimeType"]): string {
|
|
127
|
+
switch (mimeType) {
|
|
128
|
+
case "image/png":
|
|
129
|
+
return "PNG";
|
|
130
|
+
case "image/jpeg":
|
|
131
|
+
return "JPEG";
|
|
132
|
+
case "image/webp":
|
|
133
|
+
return "WebP";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
function formatResultText(args: {
|
|
128
138
|
artifactPath: string;
|
|
129
139
|
outputPath?: string;
|
|
130
140
|
warning?: string;
|
|
131
141
|
edited: boolean;
|
|
142
|
+
mimeType: ImageGenerationDetails["mimeType"];
|
|
132
143
|
width: number;
|
|
133
144
|
height: number;
|
|
134
145
|
}): string {
|
|
135
146
|
const lines = [
|
|
136
|
-
`${args.edited ? "Edited" : "Generated"}
|
|
147
|
+
`${args.edited ? "Edited" : "Generated"} ${formatImageMimeType(args.mimeType)} image (${args.width}x${args.height}).`,
|
|
137
148
|
`Artifact: ${args.artifactPath}`,
|
|
138
149
|
];
|
|
139
150
|
if (args.outputPath) lines.push(`Copied to: ${args.outputPath}`);
|
|
@@ -142,7 +153,7 @@ function formatResultText(args: {
|
|
|
142
153
|
}
|
|
143
154
|
|
|
144
155
|
function formatBatchResultText(details: ImageGenerationBatchDetails): string {
|
|
145
|
-
const lines = [`Generated ${details.succeededCount}/${details.requestedCount}
|
|
156
|
+
const lines = [`Generated ${details.succeededCount}/${details.requestedCount} images.`];
|
|
146
157
|
const failedIndexes = new Set((details.failures ?? []).map((failure) => failure.index));
|
|
147
158
|
let imageIndex = 0;
|
|
148
159
|
for (let batchIndex = 0; batchIndex < details.requestedCount; batchIndex += 1) {
|
|
@@ -183,13 +194,18 @@ async function persistGeneratedImage(args: {
|
|
|
183
194
|
agentDir: args.agentDir,
|
|
184
195
|
sessionId: args.sessionId,
|
|
185
196
|
imageCallId,
|
|
197
|
+
mimeType: args.generated.mimeType,
|
|
186
198
|
});
|
|
187
199
|
|
|
188
200
|
let outputPath: string | undefined;
|
|
189
201
|
let warning: string | undefined;
|
|
190
202
|
if (args.explicitOutput) {
|
|
191
203
|
try {
|
|
192
|
-
await args.deps.copyExplicit({
|
|
204
|
+
await args.deps.copyExplicit({
|
|
205
|
+
bytes: args.generated.bytes,
|
|
206
|
+
plan: args.explicitOutput,
|
|
207
|
+
mimeType: args.generated.mimeType,
|
|
208
|
+
});
|
|
193
209
|
outputPath = args.explicitOutput.path;
|
|
194
210
|
} catch (error) {
|
|
195
211
|
warning = sanitizeImageDiagnostic(
|
|
@@ -206,7 +222,7 @@ async function persistGeneratedImage(args: {
|
|
|
206
222
|
imageModel: args.imageModel,
|
|
207
223
|
imageCallId,
|
|
208
224
|
...(args.generated.responseId ? { responseId: args.generated.responseId } : {}),
|
|
209
|
-
mimeType:
|
|
225
|
+
mimeType: args.generated.mimeType,
|
|
210
226
|
byteCount: args.generated.bytes.length,
|
|
211
227
|
width: args.generated.width,
|
|
212
228
|
height: args.generated.height,
|
|
@@ -235,7 +251,18 @@ export function createImageGenerationExecutor(
|
|
|
235
251
|
notifyConfigIssues(args.ctx, resolved);
|
|
236
252
|
assertConfigValid(resolved, "imageGeneration", "compatibility");
|
|
237
253
|
const { config } = resolved;
|
|
238
|
-
|
|
254
|
+
let effectiveModel = args.ctx.model;
|
|
255
|
+
if (args.ctx.model?.api === VIRTUAL_MODEL_API) {
|
|
256
|
+
const effective = await resolveEffectiveModel(args.ctx);
|
|
257
|
+
if (!effective.ok) {
|
|
258
|
+
throw new ImageGenerationError(
|
|
259
|
+
"unsupported-model",
|
|
260
|
+
"Image generation is unavailable because the virtual model could not be resolved to a physical model.",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
effectiveModel = effective.model;
|
|
264
|
+
}
|
|
265
|
+
if (!isImageGenerationEnabledForModel(args.ctx.model, config.imageGeneration, effectiveModel)) {
|
|
239
266
|
throw new ImageGenerationError(
|
|
240
267
|
"unsupported-model",
|
|
241
268
|
"Image generation is not enabled for the current provider/model-id.",
|
|
@@ -332,6 +359,7 @@ export function createImageGenerationExecutor(
|
|
|
332
359
|
outputPath: details.outputPath,
|
|
333
360
|
warning: details.warning,
|
|
334
361
|
edited: details.edited,
|
|
362
|
+
mimeType: details.mimeType,
|
|
335
363
|
width: details.width,
|
|
336
364
|
height: details.height,
|
|
337
365
|
}),
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export const IMAGE_GENERATION_TOOL_NAME = "openai_generate_image";
|
|
2
|
+
/** Image formats accepted from independent Images API responses. */
|
|
3
|
+
export const IMAGE_GENERATION_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
|
4
|
+
/** Backward-compatible default format for callers that construct PNG-only fixtures. */
|
|
2
5
|
export const IMAGE_GENERATION_MIME_TYPE = "image/png" as const;
|
|
3
6
|
export const IMAGE_GENERATION_CAPABLE_APIS = [
|
|
4
7
|
"openai-responses",
|
|
@@ -32,7 +35,19 @@ export type ImageGenerationCapableApi = (typeof IMAGE_GENERATION_CAPABLE_APIS)[n
|
|
|
32
35
|
export type ImageGenerationSize = (typeof IMAGE_GENERATION_SIZES)[number];
|
|
33
36
|
export type ImageGenerationQuality = (typeof IMAGE_GENERATION_QUALITIES)[number];
|
|
34
37
|
export type ImageGenerationAction = "generate" | "edit";
|
|
35
|
-
export type
|
|
38
|
+
export type ImageGenerationMimeType = (typeof IMAGE_GENERATION_MIME_TYPES)[number];
|
|
39
|
+
export type ReferenceImageMimeType = ImageGenerationMimeType;
|
|
40
|
+
|
|
41
|
+
export function imageFileExtension(mimeType: ImageGenerationMimeType): "png" | "jpg" | "webp" {
|
|
42
|
+
switch (mimeType) {
|
|
43
|
+
case "image/png":
|
|
44
|
+
return "png";
|
|
45
|
+
case "image/jpeg":
|
|
46
|
+
return "jpg";
|
|
47
|
+
case "image/webp":
|
|
48
|
+
return "webp";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
36
51
|
|
|
37
52
|
export type ImageGenerationModel = {
|
|
38
53
|
api?: string;
|
|
@@ -72,6 +87,7 @@ export type PreparedReferenceImage = {
|
|
|
72
87
|
|
|
73
88
|
export type ParsedGeneratedImage = {
|
|
74
89
|
bytes: Buffer;
|
|
90
|
+
mimeType: ImageGenerationMimeType;
|
|
75
91
|
imageCallId: string;
|
|
76
92
|
responseId?: string;
|
|
77
93
|
revisedPrompt?: string;
|
|
@@ -86,7 +102,7 @@ export type ImageGenerationDetails = {
|
|
|
86
102
|
imageModel: string;
|
|
87
103
|
imageCallId: string;
|
|
88
104
|
responseId?: string;
|
|
89
|
-
mimeType:
|
|
105
|
+
mimeType: ImageGenerationMimeType;
|
|
90
106
|
byteCount: number;
|
|
91
107
|
width: number;
|
|
92
108
|
height: number;
|
|
@@ -188,6 +204,10 @@ function isBoundedString(value: unknown, maxChars: number): value is string {
|
|
|
188
204
|
return typeof value === "string" && value.trim().length > 0 && value.length <= maxChars;
|
|
189
205
|
}
|
|
190
206
|
|
|
207
|
+
function isImageGenerationMimeType(value: unknown): value is ImageGenerationMimeType {
|
|
208
|
+
return (IMAGE_GENERATION_MIME_TYPES as readonly string[]).includes(value as string);
|
|
209
|
+
}
|
|
210
|
+
|
|
191
211
|
export function isImageGenerationDetails(value: unknown): value is ImageGenerationDetails {
|
|
192
212
|
if (!isRecord(value)) return false;
|
|
193
213
|
return (
|
|
@@ -197,7 +217,7 @@ export function isImageGenerationDetails(value: unknown): value is ImageGenerati
|
|
|
197
217
|
isBoundedString(value.imageModel, MAX_IMAGE_MODEL_ID_CHARS) &&
|
|
198
218
|
isBoundedString(value.imageCallId, MAX_IMAGE_IDENTIFIER_CHARS) &&
|
|
199
219
|
(value.responseId === undefined || isBoundedString(value.responseId, MAX_IMAGE_IDENTIFIER_CHARS)) &&
|
|
200
|
-
value.mimeType
|
|
220
|
+
isImageGenerationMimeType(value.mimeType) &&
|
|
201
221
|
typeof value.byteCount === "number" &&
|
|
202
222
|
Number.isInteger(value.byteCount) &&
|
|
203
223
|
value.byteCount > 0 &&
|
package/src/payload-rewrite.ts
CHANGED
|
@@ -9,7 +9,13 @@ import type {
|
|
|
9
9
|
NativeCompactionEntry,
|
|
10
10
|
NativeCompactionInputProvenance,
|
|
11
11
|
} from "./types";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
serializeMessagesToResponsesInput,
|
|
14
|
+
serializeMessagesToResponsesInputWithLimits,
|
|
15
|
+
type ResponsesInputContentItem,
|
|
16
|
+
type ResponsesInputItem,
|
|
17
|
+
type ResponsesInputMessageItem,
|
|
18
|
+
} from "./serializer";
|
|
13
19
|
|
|
14
20
|
export type NativeReplaySegments = {
|
|
15
21
|
boundaryIndex: number;
|
|
@@ -369,6 +375,15 @@ export function serializeLiveTailToResponsesInput<TApi extends Api>(args: {
|
|
|
369
375
|
});
|
|
370
376
|
}
|
|
371
377
|
|
|
378
|
+
export async function serializeLiveTailToResponsesInputWithLimits<TApi extends Api>(args: {
|
|
379
|
+
model: Model<TApi>;
|
|
380
|
+
entries: readonly SessionEntry[];
|
|
381
|
+
}): Promise<ResponsesInputItem[]> {
|
|
382
|
+
return serializeMessagesToResponsesInputWithLimits(args.model, collectReplayMessages(args.entries), {
|
|
383
|
+
firstSystemMessageIsUpdate: true,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
372
387
|
function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
|
|
373
388
|
model: Model<TApi>;
|
|
374
389
|
payload: ResponsesCompatibleRequestPayload;
|
package/src/responses-headers.ts
CHANGED
|
@@ -127,7 +127,12 @@ export function buildResponsesRequestHeaders(
|
|
|
127
127
|
} else {
|
|
128
128
|
headers.set("content-type", options.contentType ?? "application/json");
|
|
129
129
|
}
|
|
130
|
-
if (
|
|
130
|
+
if (runtime.api === "azure-openai-responses") {
|
|
131
|
+
// Azure OpenAI authenticates Responses requests with `api-key`; do not
|
|
132
|
+
// forward an inherited bearer token from a model/provider header.
|
|
133
|
+
headers.delete("authorization");
|
|
134
|
+
headers.set("api-key", runtime.apiKey);
|
|
135
|
+
} else if (!headers.has("authorization")) {
|
|
131
136
|
headers.set("authorization", `Bearer ${runtime.apiKey}`);
|
|
132
137
|
}
|
|
133
138
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getApiProvider, registerApiProvider, unregisterApiProviders } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadToolkitConfig, resolveToolkitConfig } from "../config";
|
|
4
|
+
import { createResponsesWebSocketProvider } from "./transport";
|
|
5
|
+
|
|
6
|
+
const SOURCE_ID = "pi-openai-toolkit:openai-responses-websocket";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Add the transport branch at the API-adapter boundary instead of registering
|
|
10
|
+
* a provider. Existing provider ids, models, credentials, and base URLs remain
|
|
11
|
+
* untouched; only openai-responses models eligible for transport=auto/websocket
|
|
12
|
+
* are handled by the wrapper.
|
|
13
|
+
*/
|
|
14
|
+
export function installResponsesWebSocketTransport(
|
|
15
|
+
loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
|
|
16
|
+
): boolean {
|
|
17
|
+
// Re-installation can happen during Pi reloads. Remove only this extension's prior
|
|
18
|
+
// adapter so a config change cannot leave a stale enabled wrapper behind.
|
|
19
|
+
unregisterApiProviders(SOURCE_ID);
|
|
20
|
+
const resolved = resolveToolkitConfig(loadConfig());
|
|
21
|
+
if (!resolved.policy.responsesWebSocket.enabled || resolved.invalidFeatures.includes("responsesWebSocket")) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const original = getApiProvider("openai-responses");
|
|
25
|
+
if (!original) return false;
|
|
26
|
+
registerApiProvider(createResponsesWebSocketProvider(original, true), SOURCE_ID);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function responsesWebSocketExtension(_pi: ExtensionAPI): void {
|
|
31
|
+
installResponsesWebSocketTransport();
|
|
32
|
+
}
|