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/README.md +86 -39
- package/extensions/index.ts +35 -26
- package/package.json +5 -5
- package/src/catalog.ts +122 -13
- package/src/config.ts +11 -0
- package/src/media.ts +687 -136
- package/src/picker.ts +15 -3
- package/src/provider.ts +38 -59
package/src/media.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 [
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
headers:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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:
|
|
524
|
+
let response: Awaited<ReturnType<typeof openaiImageResponse>>;
|
|
198
525
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
215
|
-
await
|
|
216
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
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
|
|
786
|
+
? { model, prompt, ...output, n, images, mask, ...(stream ? { stream, partial_images } : {}) }
|
|
405
787
|
: {
|
|
406
|
-
contents: [{ role: "user", parts: [{ text:
|
|
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
|
-
|
|
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,
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
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(
|
|
554
|
-
|
|
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
|
});
|