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.
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_IMAGE_GENERATION_MODELS } from "../types";
2
2
  import {
3
- IMAGE_GENERATION_MIME_TYPE,
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
- const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
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
- export function decodeGeneratedPng(value: string):
329
- | { ok: true; bytes: Buffer; width: number; height: number }
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 dimensions = readPngDimensions(bytes);
362
- if (!dimensions) {
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 valid PNG image.",
471
+ errorMessage: "Image generation returned data that is not a supported PNG, JPEG, or WebP image.",
368
472
  };
369
473
  }
370
- return { ok: true, bytes, ...dimensions };
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 decoded = decodeGeneratedPng(item.b64_json);
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
- IMAGE_GENERATION_MIME_TYPE,
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 { isValidPng, readPngDimensions } from "./protocol";
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
- IMAGE_GENERATION_MIME_TYPE,
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
- if (bytes.length !== info.size || !isValidPng(bytes) || !readPngDimensions(bytes)) {
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 PNG.",
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"} PNG image (${args.width}x${args.height}).`,
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} PNG images.`];
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({ bytes: args.generated.bytes, plan: args.explicitOutput });
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: IMAGE_GENERATION_MIME_TYPE,
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
- if (!isImageGenerationEnabledForModel(args.ctx.model, config.imageGeneration)) {
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 ReferenceImageMimeType = "image/png" | "image/jpeg" | "image/webp";
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: typeof IMAGE_GENERATION_MIME_TYPE;
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 === IMAGE_GENERATION_MIME_TYPE &&
220
+ isImageGenerationMimeType(value.mimeType) &&
201
221
  typeof value.byteCount === "number" &&
202
222
  Number.isInteger(value.byteCount) &&
203
223
  value.byteCount > 0 &&
@@ -9,7 +9,13 @@ import type {
9
9
  NativeCompactionEntry,
10
10
  NativeCompactionInputProvenance,
11
11
  } from "./types";
12
- import { serializeMessagesToResponsesInput, type ResponsesInputContentItem, type ResponsesInputItem, type ResponsesInputMessageItem } from "./serializer";
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;
@@ -127,7 +127,12 @@ export function buildResponsesRequestHeaders(
127
127
  } else {
128
128
  headers.set("content-type", options.contentType ?? "application/json");
129
129
  }
130
- if (!headers.has("authorization")) {
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
+ }