pi-cliproxyapi-native 0.3.1 → 0.3.3
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 +46 -23
- package/extensions/index.ts +35 -26
- package/package.json +1 -1
- package/src/catalog.ts +124 -21
- package/src/config.ts +11 -0
- package/src/media.ts +475 -120
- package/src/picker.ts +2 -3
- package/src/provider.ts +38 -59
package/src/media.ts
CHANGED
|
@@ -4,12 +4,22 @@ import * as http from "node:http";
|
|
|
4
4
|
import * as https from "node:https";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { addAbortSignal, Readable } from "node:stream";
|
|
7
|
-
import {
|
|
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";
|
|
8
19
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { mapMediaCatalog, mediaCapability, mediaControls } from "./catalog.ts";
|
|
10
|
-
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";
|
|
11
22
|
import { type DefaultsContext, effectiveMediaDefaults, resolveMediaModel } from "./media-defaults.ts";
|
|
12
|
-
import { deadline, fetchCatalog, validateKey } from "./provider.ts";
|
|
13
23
|
|
|
14
24
|
type MediaContext = Pick<ExtensionContext, "cwd" | "model"> &
|
|
15
25
|
DefaultsContext & {
|
|
@@ -17,6 +27,7 @@ type MediaContext = Pick<ExtensionContext, "cwd" | "model"> &
|
|
|
17
27
|
};
|
|
18
28
|
|
|
19
29
|
const MAX_INLINE_IMAGE_BASE64_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
const MAX_IMAGE_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
20
31
|
|
|
21
32
|
const generationParameters = Type.Object({
|
|
22
33
|
model: Type.Optional(
|
|
@@ -79,6 +90,14 @@ const imageParameters = Type.Object({
|
|
|
79
90
|
"OpenAI image count. Values above 1 require an explicit api/ route; see controls.n. Default 1.",
|
|
80
91
|
}),
|
|
81
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
|
+
),
|
|
82
101
|
images: Type.Optional(
|
|
83
102
|
Type.Array(Type.String({ minLength: 1 }), {
|
|
84
103
|
minItems: 1,
|
|
@@ -129,6 +148,7 @@ function mediaOutputOptions(model: string, params: Partial<Static<typeof imagePa
|
|
|
129
148
|
moderation: params.moderation,
|
|
130
149
|
n: params.n,
|
|
131
150
|
input_fidelity: params.input_fidelity,
|
|
151
|
+
partial_images: params.partial_images,
|
|
132
152
|
};
|
|
133
153
|
const controls = mediaControls(model);
|
|
134
154
|
for (const field of [
|
|
@@ -171,6 +191,17 @@ function mediaOutputOptions(model: string, params: Partial<Static<typeof imagePa
|
|
|
171
191
|
(!controls.n || !Number.isInteger(options.n) || options.n < controls.n.min || options.n > controls.n.max)
|
|
172
192
|
)
|
|
173
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
|
+
);
|
|
174
205
|
if (
|
|
175
206
|
options.output_compression !== undefined &&
|
|
176
207
|
(!controls.output_compression ||
|
|
@@ -296,64 +327,180 @@ export async function mediaKey(ctx: Pick<MediaContext, "modelRegistry">, signal:
|
|
|
296
327
|
}
|
|
297
328
|
}
|
|
298
329
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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,
|
|
309
379
|
timeout: 0,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
headers:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
);
|
|
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;
|
|
357
504
|
}
|
|
358
505
|
|
|
359
506
|
async function mediaRequest(
|
|
@@ -361,39 +508,65 @@ async function mediaRequest(
|
|
|
361
508
|
key: string,
|
|
362
509
|
path: string,
|
|
363
510
|
signal: AbortSignal,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
},
|
|
367
523
|
) {
|
|
368
|
-
let response:
|
|
524
|
+
let response: Awaited<ReturnType<typeof openaiImageResponse>>;
|
|
369
525
|
try {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
+
}
|
|
380
544
|
} catch {
|
|
381
545
|
throw new Error(
|
|
382
546
|
"CLIProxyAPI media request failed: connection, cancellation, timeout, or redirect error. Generation was not retried.",
|
|
383
547
|
);
|
|
384
548
|
}
|
|
385
|
-
|
|
386
|
-
await
|
|
387
|
-
|
|
549
|
+
try {
|
|
550
|
+
await transport.onResponse?.({ status: response.status, headers: response.headers });
|
|
551
|
+
} catch (error) {
|
|
552
|
+
await response.body?.cancel().catch(() => {});
|
|
553
|
+
throw error;
|
|
388
554
|
}
|
|
389
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
|
+
}
|
|
390
562
|
if (!reader) throw new Error("CLIProxyAPI media returned an empty response.");
|
|
391
563
|
try {
|
|
564
|
+
if (onPartial) return await readEvents(reader, maxBytes, onPartial);
|
|
392
565
|
const chunks: Uint8Array[] = [];
|
|
393
566
|
let size = 0;
|
|
394
567
|
while (true) {
|
|
395
568
|
const chunk = await reader.read();
|
|
396
|
-
if (chunk.done) break;
|
|
569
|
+
if (chunk.done || !chunk.value) break;
|
|
397
570
|
size += chunk.value.byteLength;
|
|
398
571
|
if (size > maxBytes) {
|
|
399
572
|
await reader.cancel();
|
|
@@ -404,6 +577,7 @@ async function mediaRequest(
|
|
|
404
577
|
const result: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
405
578
|
return result;
|
|
406
579
|
} catch {
|
|
580
|
+
void reader.cancel().catch(() => {});
|
|
407
581
|
throw new Error(
|
|
408
582
|
"CLIProxyAPI media response is invalid, incomplete, cancelled, or exceeds the response size limit.",
|
|
409
583
|
);
|
|
@@ -553,12 +727,199 @@ function parseGoogleImages(value: unknown) {
|
|
|
553
727
|
return images;
|
|
554
728
|
}
|
|
555
729
|
|
|
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(
|
|
757
|
+
config: Config,
|
|
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 } = {},
|
|
769
|
+
) {
|
|
770
|
+
const route = mediaCapability(model)?.route;
|
|
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 =
|
|
776
|
+
route === "xai"
|
|
777
|
+
? {
|
|
778
|
+
model,
|
|
779
|
+
prompt,
|
|
780
|
+
n: 1,
|
|
781
|
+
response_format: "b64_json",
|
|
782
|
+
resolution: output.resolution,
|
|
783
|
+
aspect_ratio: output.aspect_ratio,
|
|
784
|
+
}
|
|
785
|
+
: route === "openai"
|
|
786
|
+
? { model, prompt, ...output, n, images, mask, ...(stream ? { stream, partial_images } : {}) }
|
|
787
|
+
: {
|
|
788
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
789
|
+
generationConfig: {
|
|
790
|
+
responseModalities: ["TEXT", "IMAGE"],
|
|
791
|
+
candidateCount: 1,
|
|
792
|
+
...(output.resolution !== undefined || output.aspect_ratio !== undefined
|
|
793
|
+
? { imageConfig: { imageSize: output.resolution, aspectRatio: output.aspect_ratio } }
|
|
794
|
+
: {}),
|
|
795
|
+
},
|
|
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
|
+
|
|
556
916
|
export async function generateImage(
|
|
557
917
|
config: Config,
|
|
558
918
|
params: Static<typeof imageParameters>,
|
|
559
919
|
ctx: MediaContext,
|
|
560
920
|
signal?: AbortSignal,
|
|
561
921
|
onProgress?: (text: string) => void,
|
|
922
|
+
onPartial?: (image: ImageContent, index: number) => void,
|
|
562
923
|
) {
|
|
563
924
|
const model = resolveMediaModel(config, ctx, "image", params.model);
|
|
564
925
|
const route = mediaCapability(model)?.route;
|
|
@@ -577,40 +938,14 @@ export async function generateImage(
|
|
|
577
938
|
bounded.throwIfAborted();
|
|
578
939
|
onProgress?.(inputs.images ? "Editing image..." : "Generating image...");
|
|
579
940
|
bounded.throwIfAborted();
|
|
580
|
-
const
|
|
941
|
+
const { images, usage } = await requestImages(
|
|
581
942
|
config,
|
|
582
943
|
key,
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
: inputs.images
|
|
586
|
-
? "v1/images/edits"
|
|
587
|
-
: "v1/images/generations",
|
|
944
|
+
model,
|
|
945
|
+
{ prompt: params.prompt, output, ...inputs },
|
|
588
946
|
bounded,
|
|
589
|
-
|
|
590
|
-
route === "xai"
|
|
591
|
-
? {
|
|
592
|
-
model,
|
|
593
|
-
prompt: params.prompt,
|
|
594
|
-
n: 1,
|
|
595
|
-
response_format: "b64_json",
|
|
596
|
-
resolution: output.resolution,
|
|
597
|
-
aspect_ratio: output.aspect_ratio,
|
|
598
|
-
}
|
|
599
|
-
: route === "openai"
|
|
600
|
-
? { model, prompt: params.prompt, ...output, n: output.n ?? 1, ...inputs }
|
|
601
|
-
: {
|
|
602
|
-
contents: [{ role: "user", parts: [{ text: params.prompt }] }],
|
|
603
|
-
generationConfig: {
|
|
604
|
-
responseModalities: ["TEXT", "IMAGE"],
|
|
605
|
-
candidateCount: 1,
|
|
606
|
-
...(output.resolution !== undefined || output.aspect_ratio !== undefined
|
|
607
|
-
? { imageConfig: { imageSize: output.resolution, aspectRatio: output.aspect_ratio } }
|
|
608
|
-
: {}),
|
|
609
|
-
},
|
|
610
|
-
},
|
|
611
|
-
route === "openai",
|
|
947
|
+
onPartial,
|
|
612
948
|
);
|
|
613
|
-
const images = route === "gemini" ? parseGoogleImages(result) : parseImages(result, output.n ?? 1);
|
|
614
949
|
bounded.throwIfAborted();
|
|
615
950
|
onProgress?.("Saving image...");
|
|
616
951
|
bounded.throwIfAborted();
|
|
@@ -647,7 +982,7 @@ export async function generateImage(
|
|
|
647
982
|
previewBudget -= image.data.length;
|
|
648
983
|
}
|
|
649
984
|
}
|
|
650
|
-
return { content, details: { model, files } };
|
|
985
|
+
return { content, details: { model, files }, usage };
|
|
651
986
|
}
|
|
652
987
|
|
|
653
988
|
function requestId(value: unknown) {
|
|
@@ -675,12 +1010,15 @@ export async function generateVideo(
|
|
|
675
1010
|
const output = mediaOutputOptions(model, params);
|
|
676
1011
|
const bounded = deadline(signal, 60000);
|
|
677
1012
|
const key = await generationKey(config, { ...params, model }, ctx, bounded);
|
|
678
|
-
const result = await mediaRequest(config, key, "v1/videos", bounded,
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
+
},
|
|
684
1022
|
});
|
|
685
1023
|
if (!isRecord(result)) throw new Error("CLIProxyAPI returned an invalid video submission.");
|
|
686
1024
|
return { model, request_id: requestId(result.request_id), status: "pending" };
|
|
@@ -690,7 +1028,7 @@ export async function videoStatus(config: Config, id: string, ctx: MediaContext,
|
|
|
690
1028
|
const request_id = requestId(id);
|
|
691
1029
|
const bounded = deadline(signal, 15000);
|
|
692
1030
|
const key = await mediaKey(ctx, bounded);
|
|
693
|
-
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 });
|
|
694
1032
|
if (!isRecord(result)) throw new Error("CLIProxyAPI returned an invalid video status.");
|
|
695
1033
|
if (result.status === "pending") return { request_id, status: "pending" };
|
|
696
1034
|
if (result.status === "failed" || result.status === "expired") {
|
|
@@ -739,15 +1077,32 @@ export function registerMediaTools(pi: ExtensionAPI, config: Config) {
|
|
|
739
1077
|
name: "cliproxyapi_generate_image",
|
|
740
1078
|
label: "CLIProxyAPI image",
|
|
741
1079
|
description:
|
|
742
|
-
"Generate images, or edit local images with OpenAI models by supplying images and an optional mask. Supports OpenAI size, quality, background, format/compression
|
|
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.",
|
|
743
1081
|
promptSnippet: "Generate requested raster images through CLIProxyAPI using the effective image default.",
|
|
744
1082
|
promptGuidelines: [
|
|
745
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.",
|
|
746
1084
|
],
|
|
747
1085
|
parameters: imageParameters,
|
|
748
1086
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
749
|
-
return generateImage(
|
|
750
|
-
|
|
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
|
+
},
|
|
751
1106
|
);
|
|
752
1107
|
},
|
|
753
1108
|
});
|