pi-cliproxyapi-native 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/media.ts CHANGED
@@ -1,14 +1,25 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
1
+ import { constants } from "node:fs";
2
+ import { mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
2
3
  import * as http from "node:http";
3
4
  import * as https from "node:https";
4
- import { join } from "node:path";
5
+ import { join, resolve } from "node:path";
5
6
  import { addAbortSignal, Readable } from "node:stream";
6
- import { type ImageContent, type Static, type TextContent, Type } from "@earendil-works/pi-ai";
7
+ import {
8
+ type AssistantImages,
9
+ type ImageApi,
10
+ type ImageContent,
11
+ type ImageModel,
12
+ type ImagesContext,
13
+ type ImagesOptions,
14
+ type Static,
15
+ type TextContent,
16
+ Type,
17
+ type Usage,
18
+ } from "@earendil-works/pi-ai";
7
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { mapMediaCatalog, mediaCapability, mediaControls } from "./catalog.ts";
9
- import { type Config, isRecord, PROVIDER_ID } from "./config.ts";
20
+ import { fetchCatalog, mapMediaCatalog, mediaCapability, mediaControls } from "./catalog.ts";
21
+ import { type Config, deadline, isRecord, PROVIDER_ID, validateKey } from "./config.ts";
10
22
  import { type DefaultsContext, effectiveMediaDefaults, resolveMediaModel } from "./media-defaults.ts";
11
- import { deadline, fetchCatalog, validateKey } from "./provider.ts";
12
23
 
13
24
  type MediaContext = Pick<ExtensionContext, "cwd" | "model"> &
14
25
  DefaultsContext & {
@@ -16,6 +27,7 @@ type MediaContext = Pick<ExtensionContext, "cwd" | "model"> &
16
27
  };
17
28
 
18
29
  const MAX_INLINE_IMAGE_BASE64_BYTES = 4 * 1024 * 1024;
30
+ const MAX_IMAGE_RESPONSE_BYTES = 32 * 1024 * 1024;
19
31
 
20
32
  const generationParameters = Type.Object({
21
33
  model: Type.Optional(
@@ -49,6 +61,63 @@ const outputParameters = {
49
61
  const imageParameters = Type.Object({
50
62
  ...generationParameters.properties,
51
63
  ...outputParameters,
64
+ quality: Type.Optional(
65
+ Type.String({
66
+ description: "OpenAI quality from controls.quality; Sunburst and Flare also support xhigh and max.",
67
+ }),
68
+ ),
69
+ background: Type.Optional(
70
+ Type.String({
71
+ description: "OpenAI background: auto, opaque, or transparent. Transparency requires PNG or WebP.",
72
+ }),
73
+ ),
74
+ output_format: Type.Optional(
75
+ Type.String({ description: "OpenAI output format: png, jpeg, or webp. Omit for the backend default." }),
76
+ ),
77
+ output_compression: Type.Optional(
78
+ Type.Integer({
79
+ minimum: 0,
80
+ maximum: 100,
81
+ description: "OpenAI JPEG/WebP compression, 0–100. Requires an explicit jpeg or webp output_format.",
82
+ }),
83
+ ),
84
+ moderation: Type.Optional(Type.String({ description: "OpenAI moderation: auto or low." })),
85
+ n: Type.Optional(
86
+ Type.Integer({
87
+ minimum: 1,
88
+ maximum: 10,
89
+ description:
90
+ "OpenAI image count. Values above 1 require an explicit api/ route; see controls.n. Default 1.",
91
+ }),
92
+ ),
93
+ partial_images: Type.Optional(
94
+ Type.Integer({
95
+ minimum: 0,
96
+ maximum: 3,
97
+ description:
98
+ "OpenAI only: stream up to 3 partial previews while generating. Requires n of 1; each preview adds about 100 output tokens. Omit or 0 for none.",
99
+ }),
100
+ ),
101
+ images: Type.Optional(
102
+ Type.Array(Type.String({ minLength: 1 }), {
103
+ minItems: 1,
104
+ maxItems: 16,
105
+ description:
106
+ "OpenAI editing: local PNG/JPEG/WebP paths, relative to the working directory or absolute. Uploads these files; no URLs. Max 14 MiB each and 20 MiB total including mask. Omit to generate a new image.",
107
+ }),
108
+ ),
109
+ mask: Type.Optional(
110
+ Type.String({
111
+ minLength: 1,
112
+ description:
113
+ "Optional local PNG mask for images[0]. The first image must also be PNG with matching dimensions; mask must have an alpha channel. Transparent regions are edited.",
114
+ }),
115
+ ),
116
+ input_fidelity: Type.Optional(
117
+ Type.String({
118
+ description: "Editing fidelity for GPT Image 1.5 only: low or high. Omit for other models.",
119
+ }),
120
+ ),
52
121
  size: Type.Optional(
53
122
  Type.String({
54
123
  minLength: 1,
@@ -67,13 +136,31 @@ const videoParameters = Type.Object({
67
136
  ),
68
137
  });
69
138
 
70
- function mediaOutputOptions(
71
- model: string,
72
- params: Pick<Static<typeof imageParameters>, "size" | "resolution" | "aspect_ratio">,
73
- ) {
74
- const options = { size: params.size, resolution: params.resolution, aspect_ratio: params.aspect_ratio };
139
+ function mediaOutputOptions(model: string, params: Partial<Static<typeof imageParameters>>) {
140
+ const options = {
141
+ size: params.size,
142
+ resolution: params.resolution,
143
+ aspect_ratio: params.aspect_ratio,
144
+ quality: params.quality,
145
+ background: params.background,
146
+ output_format: params.output_format,
147
+ output_compression: params.output_compression,
148
+ moderation: params.moderation,
149
+ n: params.n,
150
+ input_fidelity: params.input_fidelity,
151
+ partial_images: params.partial_images,
152
+ };
75
153
  const controls = mediaControls(model);
76
- for (const field of ["size", "resolution", "aspect_ratio"] as const) {
154
+ for (const field of [
155
+ "size",
156
+ "resolution",
157
+ "aspect_ratio",
158
+ "quality",
159
+ "background",
160
+ "output_format",
161
+ "moderation",
162
+ "input_fidelity",
163
+ ] as const) {
77
164
  const value = options[field];
78
165
  if (value === undefined) continue;
79
166
  if (typeof value === "string" && controls[field].includes(value)) continue;
@@ -99,9 +186,124 @@ function mediaOutputOptions(
99
186
  `CLIProxyAPI ${field} is unsupported for ${model}. See cliproxyapi_media_models for supported controls.`,
100
187
  );
101
188
  }
189
+ if (
190
+ options.n !== undefined &&
191
+ (!controls.n || !Number.isInteger(options.n) || options.n < controls.n.min || options.n > controls.n.max)
192
+ )
193
+ throw new Error("CLIProxyAPI n is unsupported for this route. Use an api/ image model for 2–10 images.");
194
+ if (
195
+ options.partial_images !== undefined &&
196
+ (!controls.partial_images ||
197
+ !Number.isInteger(options.partial_images) ||
198
+ options.partial_images < controls.partial_images.min ||
199
+ options.partial_images > controls.partial_images.max ||
200
+ (options.partial_images > 0 && (options.n ?? 1) !== 1))
201
+ )
202
+ throw new Error(
203
+ "CLIProxyAPI partial_images requires an OpenAI image model, n of 1, and an integer from 0 to 3.",
204
+ );
205
+ if (
206
+ options.output_compression !== undefined &&
207
+ (!controls.output_compression ||
208
+ !Number.isInteger(options.output_compression) ||
209
+ options.output_compression < 0 ||
210
+ options.output_compression > 100 ||
211
+ !controls.output_compression.formats.includes(options.output_format ?? ""))
212
+ )
213
+ throw new Error(
214
+ "CLIProxyAPI output_compression requires jpeg or webp output_format and an integer from 0 to 100.",
215
+ );
216
+ if (options.background === "transparent" && options.output_format === "jpeg")
217
+ throw new Error("CLIProxyAPI transparent backgrounds require png or webp output_format.");
218
+ if (options.input_fidelity !== undefined && params.images === undefined)
219
+ throw new Error("CLIProxyAPI input_fidelity requires editing images.");
102
220
  return options;
103
221
  }
104
222
 
223
+ async function imageInputs(
224
+ model: string,
225
+ params: Static<typeof imageParameters>,
226
+ cwd: string,
227
+ signal: AbortSignal,
228
+ ) {
229
+ if (params.images === undefined) {
230
+ if (params.mask !== undefined) throw new Error("CLIProxyAPI mask requires editing images.");
231
+ return {};
232
+ }
233
+ const limits = mediaControls(model).editing;
234
+ if (
235
+ !limits ||
236
+ !Array.isArray(params.images) ||
237
+ params.images.length < 1 ||
238
+ params.images.length > limits.max_images
239
+ )
240
+ throw new Error("CLIProxyAPI editing requires 1–16 local images and a supported OpenAI image model.");
241
+ const images: { image_url: string }[] = [];
242
+ let mask: { image_url: string } | undefined;
243
+ let firstPngDimensions: Buffer | undefined;
244
+ let total = 0;
245
+ for (const [index, path] of [
246
+ ...params.images,
247
+ ...(params.mask === undefined ? [] : [params.mask]),
248
+ ].entries()) {
249
+ signal.throwIfAborted();
250
+ if (
251
+ typeof path !== "string" ||
252
+ !path.trim() ||
253
+ path.includes("\0") ||
254
+ /^[a-z][a-z\d+.-]*:\/\//i.test(path)
255
+ )
256
+ throw new Error("CLIProxyAPI editing accepts local image paths, not URLs.");
257
+ // Nonblocking open prevents FIFOs from hanging before the regular-file check.
258
+ const file = await open(resolve(cwd, path), constants.O_RDONLY | constants.O_NONBLOCK);
259
+ let bytes: Buffer;
260
+ try {
261
+ const info = await file.stat();
262
+ if (!info.isFile() || info.size > limits.max_file_bytes || total + info.size > limits.max_total_bytes)
263
+ throw new Error(
264
+ "CLIProxyAPI editing requires regular files within the 14 MiB/file and 20 MiB total limits.",
265
+ );
266
+ const chunks: Buffer[] = [];
267
+ let size = 0;
268
+ for await (const chunk of file.createReadStream({
269
+ signal,
270
+ highWaterMark: 64 * 1024,
271
+ autoClose: false,
272
+ })) {
273
+ size += chunk.length;
274
+ total += chunk.length;
275
+ if (size > limits.max_file_bytes || total > limits.max_total_bytes)
276
+ throw new Error("CLIProxyAPI editing input exceeds the upload size limit.");
277
+ chunks.push(chunk);
278
+ }
279
+ bytes = Buffer.concat(chunks, size);
280
+ } finally {
281
+ await file.close();
282
+ }
283
+ let mimeType: string;
284
+ try {
285
+ ({ mimeType } = imageType(bytes));
286
+ } catch {
287
+ throw new Error(`CLIProxyAPI editing input "${path}" is not a supported PNG, JPEG, or WebP image.`);
288
+ }
289
+ const isMask = index === params.images.length;
290
+ if (index === 0 && mimeType === "image/png") firstPngDimensions = bytes.subarray(16, 24);
291
+ if (
292
+ isMask &&
293
+ (mimeType !== "image/png" ||
294
+ !firstPngDimensions?.equals(bytes.subarray(16, 24)) ||
295
+ ![4, 6].includes(bytes[25]))
296
+ )
297
+ throw new Error(
298
+ "CLIProxyAPI mask and first image must be matching-size PNGs; the mask needs an alpha channel.",
299
+ );
300
+ const input = { image_url: `data:${mimeType};base64,${bytes.toString("base64")}` };
301
+ if (isMask) mask = input;
302
+ else images.push(input);
303
+ }
304
+ return { images, mask };
305
+ }
306
+
105
307
  export async function mediaKey(ctx: Pick<MediaContext, "modelRegistry">, signal: AbortSignal) {
106
308
  signal.throwIfAborted();
107
309
  const cancelled = Promise.withResolvers<never>();
@@ -125,64 +327,180 @@ export async function mediaKey(ctx: Pick<MediaContext, "modelRegistry">, signal:
125
327
  }
126
328
  }
127
329
 
128
- function openaiImageResponse(config: Config, key: string, body: object, signal: AbortSignal) {
129
- return new Promise<{ ok: boolean; status: number; body: ReturnType<typeof Readable.toWeb> | null }>(
130
- (resolve, reject) => {
131
- const url = new URL(`${config.baseUrl}/v1/images/generations`);
132
- const transport = url.protocol === "https:" ? https : http;
133
- const options = "options" in transport.globalAgent ? transport.globalAgent.options : undefined;
134
- // Preserve configured proxies without inheriting the global agent's socket timeout.
135
- const agent = new transport.Agent({
136
- ...(isRecord(options) ? options : {}),
137
- keepAlive: false,
330
+ // Pi's native image interface can supply transport options; the image tool uses none.
331
+ type TransportOptions = Pick<ImagesOptions, "fetch" | "headers" | "env"> & {
332
+ onResponse?: (response: { status: number; headers: Record<string, string> }) => unknown;
333
+ };
334
+
335
+ function requestHeaders(key: string, extra: TransportOptions["headers"] = {}) {
336
+ const headers: Record<string, string> = {};
337
+ for (const [name, value] of Object.entries(extra))
338
+ if (value !== null && !["authorization", "content-type", "accept-encoding"].includes(name.toLowerCase()))
339
+ headers[name] = value;
340
+ return { ...headers, Authorization: `Bearer ${key}`, "Content-Type": "application/json" };
341
+ }
342
+
343
+ function openaiImageResponse(
344
+ config: Config,
345
+ key: string,
346
+ path: string,
347
+ body: unknown,
348
+ signal: AbortSignal,
349
+ { headers, env }: TransportOptions,
350
+ ) {
351
+ return new Promise<{
352
+ ok: boolean;
353
+ status: number;
354
+ headers: Record<string, string>;
355
+ body: ResponseBody | null;
356
+ }>((resolve, reject) => {
357
+ const url = new URL(`${config.baseUrl}/${path}`);
358
+ const transport = url.protocol === "https:" ? https : http;
359
+ const options = "options" in transport.globalAgent ? transport.globalAgent.options : undefined;
360
+ // Pi exports its proxy setting to the environment. Node 22.21+ and 24.5+ apply proxyEnv,
361
+ // including NO_PROXY; older Node versions connect directly. The global socket timeout is dropped.
362
+ const agent = new transport.Agent({
363
+ ...(isRecord(options) ? options : {}),
364
+ proxyEnv: { ...process.env, ...env },
365
+ keepAlive: false,
366
+ timeout: 0,
367
+ });
368
+ const connect = agent.createConnection.bind(agent);
369
+ agent.createConnection = (connectionOptions, callback) => {
370
+ // Node assigns HTTPS proxy sockets to the request only after CONNECT completes.
371
+ const socket = connect(connectionOptions, callback);
372
+ return socket ? addAbortSignal(signal, socket) : socket;
373
+ };
374
+ const request = transport.request(
375
+ url,
376
+ {
377
+ method: "POST",
378
+ agent,
138
379
  timeout: 0,
139
- });
140
- const connect = agent.createConnection.bind(agent);
141
- agent.createConnection = (connectionOptions, callback) => {
142
- // Node assigns HTTPS proxy sockets to the request only after CONNECT completes.
143
- const socket = connect(connectionOptions, callback);
144
- return socket ? addAbortSignal(signal, socket) : socket;
145
- };
146
- const request = transport.request(
147
- url,
148
- {
149
- method: "POST",
150
- agent,
151
- timeout: 0,
152
- signal,
153
- headers: {
154
- Authorization: `Bearer ${key}`,
155
- "Content-Type": "application/json",
156
- "Accept-Encoding": "identity",
157
- },
158
- },
159
- (incoming) => {
160
- const status = incoming.statusCode ?? 500;
161
- if (status < 200 || status >= 300) {
162
- incoming.destroy();
163
- resolve({ ok: false, status, body: null });
164
- return;
165
- }
166
- if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
167
- incoming.destroy();
168
- reject(new Error("Unsupported image response encoding."));
169
- return;
170
- }
171
- resolve({
172
- ok: true,
173
- status,
174
- body: Readable.toWeb(addAbortSignal(signal, incoming), { strategy: { highWaterMark: 1 } }),
175
- });
176
- },
177
- );
178
- request.on("error", reject);
179
- request.on("upgrade", (incoming, socket) => {
180
- socket.destroy();
181
- resolve({ ok: false, status: incoming.statusCode ?? 101, body: null });
182
- });
183
- request.end(JSON.stringify(body));
184
- },
185
- );
380
+ signal,
381
+ headers: { ...requestHeaders(key, headers), "Accept-Encoding": "identity" },
382
+ },
383
+ (incoming) => {
384
+ const status = incoming.statusCode ?? 500;
385
+ const ok = status >= 200 && status < 300;
386
+ const received = Object.fromEntries(
387
+ Object.entries(incoming.headers).flatMap(([name, value]) =>
388
+ value === undefined ? [] : [[name, Array.isArray(value) ? value.join(", ") : value]],
389
+ ),
390
+ );
391
+ if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
392
+ incoming.destroy();
393
+ if (ok) reject(new Error("Unsupported image response encoding."));
394
+ else resolve({ ok, status, headers: received, body: null });
395
+ return;
396
+ }
397
+ resolve({
398
+ ok,
399
+ status,
400
+ headers: received,
401
+ body: Readable.toWeb(addAbortSignal(signal, incoming), { strategy: { highWaterMark: 1 } }),
402
+ });
403
+ },
404
+ );
405
+ request.on("error", reject);
406
+ request.on("upgrade", (incoming, socket) => {
407
+ socket.destroy();
408
+ resolve({ ok: false, status: incoming.statusCode ?? 101, headers: {}, body: null });
409
+ });
410
+ request.end(JSON.stringify(body));
411
+ });
412
+ }
413
+
414
+ type StreamReader = {
415
+ read(): Promise<{ done: boolean; value?: Uint8Array }>;
416
+ cancel(): Promise<void>;
417
+ };
418
+ // Fetch and Node web streams have incompatible DOM typings; both satisfy this shape.
419
+ type ResponseBody = { getReader(): StreamReader & { releaseLock(): void }; cancel(): Promise<void> };
420
+
421
+ // Upstream errors name the rejected option or model. Show a short JSON message without credentials.
422
+ async function errorDetail(reader: StreamReader, key: string) {
423
+ const chunks: Uint8Array[] = [];
424
+ let size = 0;
425
+ // A stalled error body must not hold the failure until the request deadline; cancel ends pending reads.
426
+ const stall = setTimeout(() => void reader.cancel().catch(() => {}), 5000);
427
+ try {
428
+ while (size <= 64 * 1024) {
429
+ const chunk = await reader.read();
430
+ if (chunk.done || !chunk.value) break;
431
+ chunks.push(chunk.value);
432
+ size += chunk.value.byteLength;
433
+ }
434
+ return errorText(JSON.parse(Buffer.concat(chunks).toString("utf8")), key);
435
+ } catch {
436
+ return "";
437
+ } finally {
438
+ clearTimeout(stall);
439
+ void reader.cancel().catch(() => {});
440
+ }
441
+ }
442
+
443
+ function errorText(value: unknown, key: string) {
444
+ const error = isRecord(value) && isRecord(value.error) ? value.error : value;
445
+ // OpenAI-style bodies nest { message }; xAI-style bodies use a string error.
446
+ const message = !isRecord(error)
447
+ ? undefined
448
+ : typeof error.message === "string"
449
+ ? error.message
450
+ : typeof error.error === "string"
451
+ ? error.error
452
+ : undefined;
453
+ if (message === undefined) return "";
454
+ // Covers the proxy key, bearer tokens, and OpenAI, Anthropic, xAI, and Google key formats.
455
+ return message
456
+ .split(key)
457
+ .join("[redacted]")
458
+ .replace(/\b(?:sk-|xai-|AIza|ya29\.|Bearer\s+)[\w.~+/=-]+/gi, "[redacted]")
459
+ .replace(/[\p{C}\s]+/gu, " ")
460
+ .trim()
461
+ .slice(0, 500);
462
+ }
463
+
464
+ // Server-sent events: partial previews go to the callback, other events are returned in order.
465
+ async function readEvents(
466
+ reader: StreamReader,
467
+ maxBytes: number,
468
+ onPartial: (event: Record<string, unknown>) => void,
469
+ ) {
470
+ const decoder = new TextDecoder();
471
+ const events: Record<string, unknown>[] = [];
472
+ let buffer = "";
473
+ // A CRLF pair can straddle two chunks; hold a trailing CR until the next chunk arrives.
474
+ let carry = "";
475
+ let size = 0;
476
+ const frame = (text: string) => {
477
+ const data = text
478
+ .split("\n")
479
+ .filter((line) => line.startsWith("data:"))
480
+ .map((line) => line.slice(5).replace(/^ /, "").replace(/\r$/, ""))
481
+ .join("\n");
482
+ if (!data || data === "[DONE]") return;
483
+ const event: unknown = JSON.parse(data);
484
+ if (!isRecord(event)) throw new Error("Invalid image event.");
485
+ if (typeof event.type === "string" && event.type.endsWith(".partial_image")) onPartial(event);
486
+ else events.push(event);
487
+ };
488
+ for (let searched = 0; ; ) {
489
+ const chunk = await reader.read();
490
+ if (chunk.done || !chunk.value) break;
491
+ size += chunk.value.byteLength;
492
+ if (size > maxBytes) throw new Error("Response too large.");
493
+ const text = carry + decoder.decode(chunk.value, { stream: true });
494
+ carry = text.endsWith("\r") ? "\r" : "";
495
+ buffer += (carry ? text.slice(0, -1) : text).replace(/\r\n/g, "\n");
496
+ for (let end = buffer.indexOf("\n\n", searched); end >= 0; end = buffer.indexOf("\n\n")) {
497
+ frame(buffer.slice(0, end));
498
+ buffer = buffer.slice(end + 2);
499
+ }
500
+ searched = Math.max(0, buffer.length - 1);
501
+ }
502
+ frame(buffer + (carry + decoder.decode()).replace(/\r\n/g, "\n"));
503
+ return events;
186
504
  }
187
505
 
188
506
  async function mediaRequest(
@@ -190,39 +508,65 @@ async function mediaRequest(
190
508
  key: string,
191
509
  path: string,
192
510
  signal: AbortSignal,
193
- maxBytes: number,
194
- body?: object,
195
- openaiImage = false,
511
+ {
512
+ maxBytes,
513
+ body,
514
+ openaiImage = false,
515
+ onPartial,
516
+ ...transport
517
+ }: TransportOptions & {
518
+ maxBytes: number;
519
+ body?: unknown;
520
+ openaiImage?: boolean;
521
+ onPartial?: (event: Record<string, unknown>) => void;
522
+ },
196
523
  ) {
197
- let response: Response | Awaited<ReturnType<typeof openaiImageResponse>>;
524
+ let response: Awaited<ReturnType<typeof openaiImageResponse>>;
198
525
  try {
199
- response =
200
- openaiImage && body
201
- ? await openaiImageResponse(config, key, body, signal)
202
- : await fetch(`${config.baseUrl}/${path}`, {
203
- method: body ? "POST" : "GET",
204
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
205
- body: body ? JSON.stringify(body) : undefined,
206
- redirect: "error",
207
- signal,
208
- });
526
+ // A caller-supplied fetch replaces the dedicated image connection and owns its timeouts.
527
+ if (openaiImage && body && !transport.fetch)
528
+ response = await openaiImageResponse(config, key, path, body, signal, transport);
529
+ else {
530
+ const result = await (transport.fetch ?? fetch)(`${config.baseUrl}/${path}`, {
531
+ method: body ? "POST" : "GET",
532
+ headers: requestHeaders(key, transport.headers),
533
+ body: body ? JSON.stringify(body) : undefined,
534
+ redirect: "error",
535
+ signal,
536
+ });
537
+ response = {
538
+ ok: result.ok,
539
+ status: result.status,
540
+ headers: Object.fromEntries(result.headers),
541
+ body: result.body,
542
+ };
543
+ }
209
544
  } catch {
210
545
  throw new Error(
211
546
  "CLIProxyAPI media request failed: connection, cancellation, timeout, or redirect error. Generation was not retried.",
212
547
  );
213
548
  }
214
- if (!response.ok) {
215
- await response.body?.cancel();
216
- throw new Error(`CLIProxyAPI media request failed (HTTP ${response.status}).`);
549
+ try {
550
+ await transport.onResponse?.({ status: response.status, headers: response.headers });
551
+ } catch (error) {
552
+ await response.body?.cancel().catch(() => {});
553
+ throw error;
217
554
  }
218
555
  const reader = response.body?.getReader();
556
+ if (!response.ok) {
557
+ const detail = reader ? await errorDetail(reader, key) : "";
558
+ throw new Error(
559
+ `CLIProxyAPI media request failed (HTTP ${response.status})${detail ? `: ${detail}` : "."}`,
560
+ );
561
+ }
219
562
  if (!reader) throw new Error("CLIProxyAPI media returned an empty response.");
220
563
  try {
564
+ if (onPartial) return await readEvents(reader, maxBytes, onPartial);
221
565
  const chunks: Uint8Array[] = [];
222
566
  let size = 0;
223
567
  while (true) {
224
568
  const chunk = await reader.read();
225
- if (chunk.done) break;
569
+ if (chunk.done || !chunk.value) break;
226
570
  size += chunk.value.byteLength;
227
571
  if (size > maxBytes) {
228
572
  await reader.cancel();
@@ -233,6 +577,7 @@ async function mediaRequest(
233
577
  const result: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
234
578
  return result;
235
579
  } catch {
580
+ void reader.cancel().catch(() => {});
236
581
  throw new Error(
237
582
  "CLIProxyAPI media response is invalid, incomplete, cancelled, or exceeds the response size limit.",
238
583
  );
@@ -264,19 +609,7 @@ async function generationKey(
264
609
  return key;
265
610
  }
266
611
 
267
- function parseImage(value: unknown) {
268
- if (!isRecord(value) || !Array.isArray(value.data) || value.data.length !== 1) {
269
- throw new Error("CLIProxyAPI image response must contain one image.");
270
- }
271
- const entry: unknown = value.data[0];
272
- if (!isRecord(entry) || typeof entry.b64_json !== "string") {
273
- throw new Error("CLIProxyAPI did not return b64_json image data. URL-only images are not downloaded.");
274
- }
275
- const encoded = entry.b64_json;
276
- const bytes = Buffer.from(encoded, "base64");
277
- if (!encoded || bytes.toString("base64") !== encoded) {
278
- throw new Error("CLIProxyAPI returned invalid base64 image data.");
279
- }
612
+ function imageType(bytes: Buffer) {
280
613
  let extension: string;
281
614
  let mimeType: string;
282
615
  if (
@@ -307,10 +640,34 @@ function parseImage(value: unknown) {
307
640
  } else {
308
641
  throw new Error("CLIProxyAPI returned an unsupported or malformed image; expected JPEG, PNG, or WebP.");
309
642
  }
643
+ return { extension, mimeType };
644
+ }
645
+
646
+ function parseImage(entry: unknown) {
647
+ if (!isRecord(entry) || typeof entry.b64_json !== "string") {
648
+ throw new Error("CLIProxyAPI did not return b64_json image data. URL-only images are not downloaded.");
649
+ }
650
+ const encoded = entry.b64_json;
651
+ const bytes = Buffer.from(encoded, "base64");
652
+ if (!encoded || bytes.toString("base64") !== encoded) {
653
+ throw new Error("CLIProxyAPI returned invalid base64 image data.");
654
+ }
655
+ const { extension, mimeType } = imageType(bytes);
310
656
  const image: ImageContent = { type: "image", data: encoded, mimeType };
311
657
  return { bytes, extension, image };
312
658
  }
313
659
 
660
+ function parseImages(value: unknown, count: number) {
661
+ if (
662
+ !isRecord(value) ||
663
+ value.error !== undefined ||
664
+ !Array.isArray(value.data) ||
665
+ value.data.length !== count
666
+ )
667
+ throw new Error(`CLIProxyAPI image response must contain ${count} image(s).`);
668
+ return value.data.map(parseImage);
669
+ }
670
+
314
671
  function parseGoogleImages(value: unknown) {
315
672
  const invalid = () =>
316
673
  new Error(
@@ -361,7 +718,7 @@ function parseGoogleImages(value: unknown) {
361
718
  if (part.thought === true) continue;
362
719
  if (part.inlineData !== undefined) {
363
720
  if (!isRecord(part.inlineData) || typeof part.inlineData.data !== "string") throw invalid();
364
- const parsed = parseImage({ data: [{ b64_json: part.inlineData.data }] });
721
+ const parsed = parseImage({ b64_json: part.inlineData.data });
365
722
  if (part.inlineData.mimeType !== parsed.image.mimeType) throw invalid();
366
723
  images.push(parsed);
367
724
  } else if (typeof part.text !== "string") throw invalid();
@@ -370,40 +727,65 @@ function parseGoogleImages(value: unknown) {
370
727
  return images;
371
728
  }
372
729
 
373
- export async function generateImage(
730
+ // OpenAI reports image token usage. Costs use list prices and stay zero for unpriced models.
731
+ function imageUsage(model: string, value: unknown) {
732
+ const tokens = (field: unknown) =>
733
+ typeof field === "number" && Number.isSafeInteger(field) && field >= 0 ? field : undefined;
734
+ if (!isRecord(value)) return undefined;
735
+ const input = tokens(value.input_tokens);
736
+ const output = tokens(value.output_tokens);
737
+ if (input === undefined || output === undefined) return undefined;
738
+ const imageInput = Math.min(
739
+ input,
740
+ tokens(isRecord(value.input_tokens_details) ? value.input_tokens_details.image_tokens : 0) ?? 0,
741
+ );
742
+ const [textPrice, imagePrice, outputPrice] = mediaCapability(model)?.price ?? [0, 0, 0];
743
+ const cost = {
744
+ input: ((input - imageInput) * textPrice + imageInput * imagePrice) / 1e6,
745
+ output: (output * outputPrice) / 1e6,
746
+ cacheRead: 0,
747
+ cacheWrite: 0,
748
+ total: 0,
749
+ };
750
+ cost.total = cost.input + cost.output;
751
+ const usage: Usage = { input, output, cacheRead: 0, cacheWrite: 0, totalTokens: input + output, cost };
752
+ return usage;
753
+ }
754
+
755
+ // Shared by the image tool and Pi's native image interface.
756
+ async function requestImages(
374
757
  config: Config,
375
- params: Static<typeof imageParameters>,
376
- ctx: MediaContext,
377
- signal?: AbortSignal,
378
- onProgress?: (text: string) => void,
758
+ key: string,
759
+ model: string,
760
+ request: {
761
+ prompt: string;
762
+ output: Partial<ReturnType<typeof mediaOutputOptions>>;
763
+ images?: { image_url: string }[];
764
+ mask?: { image_url: string };
765
+ },
766
+ signal: AbortSignal,
767
+ onPartial?: (image: ImageContent, index: number) => void,
768
+ { onPayload, ...transport }: TransportOptions & { onPayload?: (payload: unknown) => unknown } = {},
379
769
  ) {
380
- const model = resolveMediaModel(config, ctx, "image", params.model);
381
770
  const route = mediaCapability(model)?.route;
382
- const output = mediaOutputOptions(model, params);
383
- const bounded = deadline(signal, route === "openai" ? 600000 : 180000);
384
- const key = await generationKey(config, { ...params, model }, ctx, bounded);
385
- bounded.throwIfAborted();
386
- onProgress?.("Generating image...");
387
- bounded.throwIfAborted();
388
- const result = await mediaRequest(
389
- config,
390
- key,
391
- route === "gemini" ? `v1beta/models/${model}:generateContent` : "v1/images/generations",
392
- bounded,
393
- 32 * 1024 * 1024,
771
+ const { prompt, images, mask } = request;
772
+ const { partial_images = 0, ...output } = request.output;
773
+ const n = output.n ?? 1;
774
+ const stream = route === "openai" && partial_images > 0;
775
+ const body =
394
776
  route === "xai"
395
777
  ? {
396
778
  model,
397
- prompt: params.prompt,
779
+ prompt,
398
780
  n: 1,
399
781
  response_format: "b64_json",
400
782
  resolution: output.resolution,
401
783
  aspect_ratio: output.aspect_ratio,
402
784
  }
403
785
  : route === "openai"
404
- ? { model, prompt: params.prompt, n: 1, size: output.size }
786
+ ? { model, prompt, ...output, n, images, mask, ...(stream ? { stream, partial_images } : {}) }
405
787
  : {
406
- contents: [{ role: "user", parts: [{ text: params.prompt }] }],
788
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
407
789
  generationConfig: {
408
790
  responseModalities: ["TEXT", "IMAGE"],
409
791
  candidateCount: 1,
@@ -411,10 +793,159 @@ export async function generateImage(
411
793
  ? { imageConfig: { imageSize: output.resolution, aspectRatio: output.aspect_ratio } }
412
794
  : {}),
413
795
  },
414
- },
415
- route === "openai",
796
+ };
797
+ const payload = (await onPayload?.(body)) ?? body;
798
+ const path =
799
+ route === "gemini"
800
+ ? `v1beta/models/${model}:generateContent`
801
+ : images
802
+ ? "v1/images/edits"
803
+ : "v1/images/generations";
804
+ if (!stream) {
805
+ const result = await mediaRequest(config, key, path, signal, {
806
+ ...transport,
807
+ maxBytes: MAX_IMAGE_RESPONSE_BYTES,
808
+ body: payload,
809
+ openaiImage: route === "openai",
810
+ });
811
+ return {
812
+ images: route === "gemini" ? parseGoogleImages(result) : parseImages(result, n),
813
+ usage: route === "openai" && isRecord(result) ? imageUsage(model, result.usage) : undefined,
814
+ };
815
+ }
816
+ // Each frame carries one full image, so the stream may hold the final image plus every preview.
817
+ const events = await mediaRequest(config, key, path, signal, {
818
+ ...transport,
819
+ maxBytes: MAX_IMAGE_RESPONSE_BYTES * (1 + partial_images),
820
+ body: payload,
821
+ openaiImage: true,
822
+ onPartial: (event) => {
823
+ try {
824
+ const index = typeof event.partial_image_index === "number" ? event.partial_image_index : 0;
825
+ onPartial?.(parseImage(event).image, index);
826
+ } catch {
827
+ // A malformed preview does not affect the final image.
828
+ }
829
+ },
830
+ });
831
+ if (!Array.isArray(events)) throw new Error("CLIProxyAPI returned an invalid image stream.");
832
+ const failed = events.find(
833
+ (event) => isRecord(event) && (event.type === "error" || event.error !== undefined),
834
+ );
835
+ if (failed) {
836
+ const detail = errorText(failed, key);
837
+ throw new Error(`CLIProxyAPI image stream failed${detail ? `: ${detail}` : "."}`);
838
+ }
839
+ const completed = events.filter(
840
+ (event) => isRecord(event) && typeof event.type === "string" && event.type.endsWith(".completed"),
841
+ );
842
+ if (completed.length !== n) throw new Error(`CLIProxyAPI image stream must complete ${n} image(s).`);
843
+ return { images: completed.map(parseImage), usage: imageUsage(model, completed[0].usage) };
844
+ }
845
+
846
+ export async function generateNativeImages(
847
+ config: Config,
848
+ model: ImageModel<ImageApi>,
849
+ context: ImagesContext,
850
+ options?: ImagesOptions,
851
+ ) {
852
+ const result: AssistantImages = {
853
+ api: model.api,
854
+ provider: model.provider,
855
+ model: model.id,
856
+ output: [],
857
+ stopReason: "stop",
858
+ timestamp: Date.now(),
859
+ };
860
+ try {
861
+ const capability = mediaCapability(model.id);
862
+ if (capability?.purpose !== "image" || capability.disabledReason)
863
+ throw new Error(capability?.disabledReason ?? "Choose a supported CLIProxyAPI image model.");
864
+ const prompt = context.input.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n\n");
865
+ if (!prompt.trim() || (capability.route === "openai" && prompt.length > 32000))
866
+ throw new Error(
867
+ "CLIProxyAPI requires a non-empty prompt; OpenAI prompts are limited to 32000 characters.",
868
+ );
869
+ const limits = mediaControls(model.id).editing;
870
+ const blocks = context.input.flatMap((block) => (block.type === "image" ? [block] : []));
871
+ let total = 0;
872
+ const images = blocks.map(({ data }) => {
873
+ const bytes = Buffer.from(data, "base64");
874
+ total += bytes.length;
875
+ if (
876
+ !limits ||
877
+ blocks.length > limits.max_images ||
878
+ bytes.length > limits.max_file_bytes ||
879
+ total > limits.max_total_bytes
880
+ )
881
+ throw new Error(
882
+ "CLIProxyAPI image inputs need an OpenAI image model: at most 16 images, 14 MiB each, 20 MiB total.",
883
+ );
884
+ let mimeType: string;
885
+ try {
886
+ ({ mimeType } = imageType(bytes));
887
+ } catch {
888
+ throw new Error("CLIProxyAPI image inputs must be PNG, JPEG, or WebP.");
889
+ }
890
+ return { image_url: `data:${mimeType};base64,${bytes.toString("base64")}` };
891
+ });
892
+ const generated = await requestImages(
893
+ config,
894
+ validateKey(options?.apiKey ?? ""),
895
+ model.id,
896
+ { prompt, output: {}, ...(images.length ? { images } : {}) },
897
+ deadline(options?.signal, capability.route === "openai" ? 600000 : 180000),
898
+ undefined,
899
+ {
900
+ fetch: options?.fetch,
901
+ headers: options?.headers,
902
+ env: options?.env,
903
+ onPayload: options?.onPayload && ((payload) => options.onPayload?.(payload, model)),
904
+ onResponse: options?.onResponse && ((response) => options.onResponse?.(response, model)),
905
+ },
906
+ );
907
+ result.output = generated.images.map(({ image }) => image);
908
+ result.usage = generated.usage;
909
+ } catch (error) {
910
+ result.stopReason = options?.signal?.aborted ? "aborted" : "error";
911
+ result.errorMessage = error instanceof Error ? error.message : String(error);
912
+ }
913
+ return result;
914
+ }
915
+
916
+ export async function generateImage(
917
+ config: Config,
918
+ params: Static<typeof imageParameters>,
919
+ ctx: MediaContext,
920
+ signal?: AbortSignal,
921
+ onProgress?: (text: string) => void,
922
+ onPartial?: (image: ImageContent, index: number) => void,
923
+ ) {
924
+ const model = resolveMediaModel(config, ctx, "image", params.model);
925
+ const route = mediaCapability(model)?.route;
926
+ const output = mediaOutputOptions(model, params);
927
+ if (
928
+ typeof params.prompt !== "string" ||
929
+ !params.prompt.trim() ||
930
+ (route === "openai" && params.prompt.length > 32000)
931
+ )
932
+ throw new Error(
933
+ "CLIProxyAPI requires a non-empty prompt; OpenAI prompts are limited to 32000 characters.",
934
+ );
935
+ const bounded = deadline(signal, route === "openai" ? 600000 : 180000);
936
+ const inputs = await imageInputs(model, params, ctx.cwd, bounded);
937
+ const key = await generationKey(config, { ...params, model }, ctx, bounded);
938
+ bounded.throwIfAborted();
939
+ onProgress?.(inputs.images ? "Editing image..." : "Generating image...");
940
+ bounded.throwIfAborted();
941
+ const { images, usage } = await requestImages(
942
+ config,
943
+ key,
944
+ model,
945
+ { prompt: params.prompt, output, ...inputs },
946
+ bounded,
947
+ onPartial,
416
948
  );
417
- const images = route === "gemini" ? parseGoogleImages(result) : [parseImage(result)];
418
949
  bounded.throwIfAborted();
419
950
  onProgress?.("Saving image...");
420
951
  bounded.throwIfAborted();
@@ -451,7 +982,7 @@ export async function generateImage(
451
982
  previewBudget -= image.data.length;
452
983
  }
453
984
  }
454
- return { content, details: { model, files } };
985
+ return { content, details: { model, files }, usage };
455
986
  }
456
987
 
457
988
  function requestId(value: unknown) {
@@ -479,12 +1010,15 @@ export async function generateVideo(
479
1010
  const output = mediaOutputOptions(model, params);
480
1011
  const bounded = deadline(signal, 60000);
481
1012
  const key = await generationKey(config, { ...params, model }, ctx, bounded);
482
- const result = await mediaRequest(config, key, "v1/videos", bounded, 64 * 1024, {
483
- model,
484
- prompt: params.prompt,
485
- duration: params.duration,
486
- resolution: output.resolution,
487
- aspect_ratio: output.aspect_ratio,
1013
+ const result = await mediaRequest(config, key, "v1/videos", bounded, {
1014
+ maxBytes: 64 * 1024,
1015
+ body: {
1016
+ model,
1017
+ prompt: params.prompt,
1018
+ duration: params.duration,
1019
+ resolution: output.resolution,
1020
+ aspect_ratio: output.aspect_ratio,
1021
+ },
488
1022
  });
489
1023
  if (!isRecord(result)) throw new Error("CLIProxyAPI returned an invalid video submission.");
490
1024
  return { model, request_id: requestId(result.request_id), status: "pending" };
@@ -494,7 +1028,7 @@ export async function videoStatus(config: Config, id: string, ctx: MediaContext,
494
1028
  const request_id = requestId(id);
495
1029
  const bounded = deadline(signal, 15000);
496
1030
  const key = await mediaKey(ctx, bounded);
497
- const result = await mediaRequest(config, key, `v1/videos/${request_id}`, bounded, 64 * 1024);
1031
+ const result = await mediaRequest(config, key, `v1/videos/${request_id}`, bounded, { maxBytes: 64 * 1024 });
498
1032
  if (!isRecord(result)) throw new Error("CLIProxyAPI returned an invalid video status.");
499
1033
  if (result.status === "pending") return { request_id, status: "pending" };
500
1034
  if (result.status === "failed" || result.status === "expired") {
@@ -543,15 +1077,32 @@ export function registerMediaTools(pi: ExtensionAPI, config: Config) {
543
1077
  name: "cliproxyapi_generate_image",
544
1078
  label: "CLIProxyAPI image",
545
1079
  description:
546
- "Generate images using an explicit available image model, a selected session default, or gpt-image-2.5-sunburst automatically for OpenAI/GPT chats. Saves each final image under a new .pi/ artifact directory and returns paths and previews for vision-capable chat models. Maximum response: 32 MiB; inline image base64: 4 MiB. Larger images return paths without previews. No automatic retries.",
1080
+ "Generate images, or edit local images with OpenAI models by supplying images and an optional mask. Supports OpenAI size, quality, background, format/compression, image count and streamed partial previews from cliproxyapi_media_models. Uses the selected model or automatic OpenAI/GPT default without changing chat. Saves final images under a new .pi/ directory with optional previews. Maximum response: 32 MiB in total, plus 32 MiB per streamed preview; inline base64: 4 MiB. No automatic retries.",
547
1081
  promptSnippet: "Generate requested raster images through CLIProxyAPI using the effective image default.",
548
1082
  promptGuidelines: [
549
1083
  "Use cliproxyapi_generate_image for requested raster images. Omit model to use the effective default unless a specific image model is requested; keep the current chat model.",
550
1084
  ],
551
1085
  parameters: imageParameters,
552
1086
  async execute(_id, params, signal, onUpdate, ctx) {
553
- return generateImage(config, params, ctx, signal, (text) =>
554
- onUpdate?.({ content: [{ type: "text", text }], details: {} }),
1087
+ return generateImage(
1088
+ config,
1089
+ params,
1090
+ ctx,
1091
+ signal,
1092
+ (text) => onUpdate?.({ content: [{ type: "text", text }], details: {} }),
1093
+ (image, index) => {
1094
+ const large = image.data.length > MAX_INLINE_IMAGE_BASE64_BYTES;
1095
+ onUpdate?.({
1096
+ content: [
1097
+ {
1098
+ type: "text",
1099
+ text: `Partial image ${index + 1}${large ? " (preview over 4 MiB omitted)" : ""}`,
1100
+ },
1101
+ ...(large ? [] : [image]),
1102
+ ],
1103
+ details: {},
1104
+ });
1105
+ },
555
1106
  );
556
1107
  },
557
1108
  });