pi-cliproxyapi-native 0.1.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/LICENSE +21 -0
- package/README.md +246 -0
- package/extensions/index.ts +53 -0
- package/package.json +52 -0
- package/src/catalog.ts +248 -0
- package/src/config.ts +64 -0
- package/src/media-defaults.ts +79 -0
- package/src/media.ts +571 -0
- package/src/picker.ts +310 -0
- package/src/provider.ts +221 -0
package/src/media.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { addAbortSignal, Readable } from "node:stream";
|
|
6
|
+
import { type ImageContent, type Static, type TextContent, Type } from "@earendil-works/pi-ai";
|
|
7
|
+
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";
|
|
10
|
+
import { type DefaultsContext, effectiveMediaDefaults, resolveMediaModel } from "./media-defaults.ts";
|
|
11
|
+
import { fetchCatalog, validateKey } from "./provider.ts";
|
|
12
|
+
|
|
13
|
+
type MediaContext = Pick<ExtensionContext, "cwd" | "model"> &
|
|
14
|
+
DefaultsContext & {
|
|
15
|
+
modelRegistry: Pick<ExtensionContext["modelRegistry"], "getProviderAuth">;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const MAX_INLINE_IMAGE_BASE64_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const generationParameters = Type.Object({
|
|
21
|
+
model: Type.Optional(
|
|
22
|
+
Type.String({
|
|
23
|
+
description:
|
|
24
|
+
"Exact advertised ID, including any routing prefix, from cliproxyapi_media_models. Omit to use the effective default (selected with /cli:model, or automatic for OpenAI/GPT images); explicit ID wins.",
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
prompt: Type.String({ minLength: 1 }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const outputParameters = {
|
|
31
|
+
resolution: Type.Optional(
|
|
32
|
+
Type.String({
|
|
33
|
+
minLength: 1,
|
|
34
|
+
maxLength: 8,
|
|
35
|
+
description:
|
|
36
|
+
"Model-specific resolution tier, with exact spelling from cliproxyapi_media_models controls.resolution. Omit to preserve the backend default. OpenAI images use size instead.",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
aspect_ratio: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
minLength: 3,
|
|
42
|
+
maxLength: 8,
|
|
43
|
+
description:
|
|
44
|
+
"Output aspect ratio from cliproxyapi_media_models controls.aspect_ratio, such as 16:9. OpenAI images encode this in size instead. Omit to preserve the backend default.",
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const imageParameters = Type.Object({
|
|
50
|
+
...generationParameters.properties,
|
|
51
|
+
...outputParameters,
|
|
52
|
+
size: Type.Optional(
|
|
53
|
+
Type.String({
|
|
54
|
+
minLength: 1,
|
|
55
|
+
maxLength: 32,
|
|
56
|
+
description:
|
|
57
|
+
"OpenAI image size: auto or WIDTHxHEIGHT. Use controls.size and controls.size_limits from cliproxyapi_media_models. Omit to preserve the backend default.",
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const videoParameters = Type.Object({
|
|
63
|
+
...generationParameters.properties,
|
|
64
|
+
...outputParameters,
|
|
65
|
+
duration: Type.Optional(
|
|
66
|
+
Type.Integer({ minimum: 1, maximum: 15, description: "Video length in seconds (1–15)." }),
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
|
|
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 };
|
|
75
|
+
const controls = mediaControls(model);
|
|
76
|
+
for (const field of ["size", "resolution", "aspect_ratio"] as const) {
|
|
77
|
+
const value = options[field];
|
|
78
|
+
if (value === undefined) continue;
|
|
79
|
+
if (typeof value === "string" && controls[field].includes(value)) continue;
|
|
80
|
+
const limits = field === "size" ? controls.size_limits : undefined;
|
|
81
|
+
if (typeof value === "string" && limits) {
|
|
82
|
+
const match = /^([1-9]\d{0,3})x([1-9]\d{0,3})$/.exec(value);
|
|
83
|
+
if (match && match[0] === value) {
|
|
84
|
+
const width = Number(match[1]);
|
|
85
|
+
const height = Number(match[2]);
|
|
86
|
+
const pixels = width * height;
|
|
87
|
+
if (
|
|
88
|
+
width % limits.multiple_of === 0 &&
|
|
89
|
+
height % limits.multiple_of === 0 &&
|
|
90
|
+
Math.max(width, height) <= limits.max_edge &&
|
|
91
|
+
Math.max(width, height) / Math.min(width, height) <= limits.max_aspect_ratio &&
|
|
92
|
+
pixels >= limits.min_pixels &&
|
|
93
|
+
pixels <= limits.max_pixels
|
|
94
|
+
)
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(
|
|
99
|
+
`CLIProxyAPI ${field} is unsupported for ${model}. See cliproxyapi_media_models for supported controls.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return options;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function deadline(signal: AbortSignal | undefined, milliseconds: number) {
|
|
106
|
+
return AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(milliseconds)]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function mediaKey(ctx: Pick<MediaContext, "modelRegistry">, signal: AbortSignal) {
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
const cancelled = Promise.withResolvers<never>();
|
|
112
|
+
const onAbort = () => cancelled.reject(new Error("CLIProxyAPI media operation cancelled or timed out."));
|
|
113
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
114
|
+
try {
|
|
115
|
+
// Pi's provider-auth facade has no signal argument; bound the wait without reading its store.
|
|
116
|
+
const resolved = await Promise.race([ctx.modelRegistry.getProviderAuth(PROVIDER_ID), cancelled.promise]);
|
|
117
|
+
signal.throwIfAborted();
|
|
118
|
+
if (!resolved?.auth.apiKey) {
|
|
119
|
+
throw new Error("Use /login cliproxyapi or set CLIPROXYAPI_API_KEY before using media tools.");
|
|
120
|
+
}
|
|
121
|
+
return validateKey(resolved.auth.apiKey);
|
|
122
|
+
} catch {
|
|
123
|
+
if (signal.aborted) throw new Error("CLIProxyAPI media operation cancelled or timed out.");
|
|
124
|
+
throw new Error(
|
|
125
|
+
"CLIProxyAPI media authentication failed. Use /login cliproxyapi or check CLIPROXYAPI_API_KEY.",
|
|
126
|
+
);
|
|
127
|
+
} finally {
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function openaiImageResponse(config: Config, key: string, body: object, signal: AbortSignal) {
|
|
133
|
+
return new Promise<{ ok: boolean; status: number; body: ReturnType<typeof Readable.toWeb> | null }>(
|
|
134
|
+
(resolve, reject) => {
|
|
135
|
+
const url = new URL(`${config.baseUrl}/v1/images/generations`);
|
|
136
|
+
// A private agent and no socket timeout leave the overall signal in control of long image waits.
|
|
137
|
+
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
138
|
+
url,
|
|
139
|
+
{
|
|
140
|
+
method: "POST",
|
|
141
|
+
agent: false,
|
|
142
|
+
timeout: 0,
|
|
143
|
+
signal,
|
|
144
|
+
headers: {
|
|
145
|
+
Authorization: `Bearer ${key}`,
|
|
146
|
+
"Content-Type": "application/json",
|
|
147
|
+
"Accept-Encoding": "identity",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
(incoming) => {
|
|
151
|
+
const status = incoming.statusCode ?? 500;
|
|
152
|
+
if (status < 200 || status >= 300) {
|
|
153
|
+
incoming.destroy();
|
|
154
|
+
resolve({ ok: false, status, body: null });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (incoming.headers["content-encoding"] && incoming.headers["content-encoding"] !== "identity") {
|
|
158
|
+
incoming.destroy();
|
|
159
|
+
reject(new Error("Unsupported image response encoding."));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
resolve({
|
|
163
|
+
ok: true,
|
|
164
|
+
status,
|
|
165
|
+
body: Readable.toWeb(addAbortSignal(signal, incoming), { strategy: { highWaterMark: 1 } }),
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
request.on("error", reject);
|
|
170
|
+
request.on("upgrade", (incoming, socket) => {
|
|
171
|
+
socket.destroy();
|
|
172
|
+
resolve({ ok: false, status: incoming.statusCode ?? 101, body: null });
|
|
173
|
+
});
|
|
174
|
+
request.end(JSON.stringify(body));
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function mediaRequest(
|
|
180
|
+
config: Config,
|
|
181
|
+
key: string,
|
|
182
|
+
path: string,
|
|
183
|
+
signal: AbortSignal,
|
|
184
|
+
maxBytes: number,
|
|
185
|
+
body?: object,
|
|
186
|
+
openaiImage = false,
|
|
187
|
+
) {
|
|
188
|
+
let response: Response | Awaited<ReturnType<typeof openaiImageResponse>>;
|
|
189
|
+
try {
|
|
190
|
+
response =
|
|
191
|
+
openaiImage && body
|
|
192
|
+
? await openaiImageResponse(config, key, body, signal)
|
|
193
|
+
: await fetch(`${config.baseUrl}/${path}`, {
|
|
194
|
+
method: body ? "POST" : "GET",
|
|
195
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
196
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
197
|
+
redirect: "error",
|
|
198
|
+
signal,
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error(
|
|
202
|
+
"CLIProxyAPI media request failed: connection, cancellation, timeout, or redirect error. Generation was not retried.",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
await response.body?.cancel();
|
|
207
|
+
throw new Error(`CLIProxyAPI media request failed (HTTP ${response.status}).`);
|
|
208
|
+
}
|
|
209
|
+
const reader = response.body?.getReader();
|
|
210
|
+
if (!reader) throw new Error("CLIProxyAPI media returned an empty response.");
|
|
211
|
+
try {
|
|
212
|
+
const chunks: Uint8Array[] = [];
|
|
213
|
+
let size = 0;
|
|
214
|
+
while (true) {
|
|
215
|
+
const chunk = await reader.read();
|
|
216
|
+
if (chunk.done) break;
|
|
217
|
+
size += chunk.value.byteLength;
|
|
218
|
+
if (size > maxBytes) {
|
|
219
|
+
await reader.cancel();
|
|
220
|
+
throw new Error("Response too large.");
|
|
221
|
+
}
|
|
222
|
+
chunks.push(chunk.value);
|
|
223
|
+
}
|
|
224
|
+
const result: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
225
|
+
return result;
|
|
226
|
+
} catch {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"CLIProxyAPI media response is invalid, incomplete, cancelled, or exceeds the response size limit.",
|
|
229
|
+
);
|
|
230
|
+
} finally {
|
|
231
|
+
reader.releaseLock();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function listMediaModels(config: Config, ctx: MediaContext, signal?: AbortSignal) {
|
|
236
|
+
const bounded = deadline(signal, 15000);
|
|
237
|
+
const key = await mediaKey(ctx, bounded);
|
|
238
|
+
return mapMediaCatalog(await fetchCatalog(config, key, bounded));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function generationKey(
|
|
242
|
+
config: Config,
|
|
243
|
+
params: { model: string; prompt: string },
|
|
244
|
+
ctx: MediaContext,
|
|
245
|
+
signal: AbortSignal,
|
|
246
|
+
) {
|
|
247
|
+
if (typeof params.prompt !== "string" || !params.prompt.trim()) {
|
|
248
|
+
throw new Error("CLIProxyAPI media requires a non-empty prompt.");
|
|
249
|
+
}
|
|
250
|
+
const key = await mediaKey(ctx, signal);
|
|
251
|
+
const available = mapMediaCatalog(await fetchCatalog(config, key, signal));
|
|
252
|
+
if (!available.some(({ id }) => id === params.model)) {
|
|
253
|
+
throw new Error("The selected media model is not available in the live CLIProxyAPI catalog.");
|
|
254
|
+
}
|
|
255
|
+
return key;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseImage(value: unknown) {
|
|
259
|
+
if (!isRecord(value) || !Array.isArray(value.data) || value.data.length !== 1) {
|
|
260
|
+
throw new Error("CLIProxyAPI image response must contain one image.");
|
|
261
|
+
}
|
|
262
|
+
const entry: unknown = value.data[0];
|
|
263
|
+
if (!isRecord(entry) || typeof entry.b64_json !== "string") {
|
|
264
|
+
throw new Error("CLIProxyAPI did not return b64_json image data. URL-only images are not downloaded.");
|
|
265
|
+
}
|
|
266
|
+
const encoded = entry.b64_json;
|
|
267
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
268
|
+
if (!encoded || bytes.toString("base64") !== encoded) {
|
|
269
|
+
throw new Error("CLIProxyAPI returned invalid base64 image data.");
|
|
270
|
+
}
|
|
271
|
+
let extension: string;
|
|
272
|
+
let mimeType: string;
|
|
273
|
+
if (
|
|
274
|
+
bytes.length > 24 &&
|
|
275
|
+
bytes.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) &&
|
|
276
|
+
bytes.toString("ascii", 12, 16) === "IHDR"
|
|
277
|
+
) {
|
|
278
|
+
extension = "png";
|
|
279
|
+
mimeType = "image/png";
|
|
280
|
+
} else if (
|
|
281
|
+
bytes.length > 4 &&
|
|
282
|
+
bytes[0] === 0xff &&
|
|
283
|
+
bytes[1] === 0xd8 &&
|
|
284
|
+
bytes[2] === 0xff &&
|
|
285
|
+
bytes.at(-2) === 0xff &&
|
|
286
|
+
bytes.at(-1) === 0xd9
|
|
287
|
+
) {
|
|
288
|
+
extension = "jpg";
|
|
289
|
+
mimeType = "image/jpeg";
|
|
290
|
+
} else if (
|
|
291
|
+
bytes.length > 20 &&
|
|
292
|
+
bytes.toString("ascii", 0, 4) === "RIFF" &&
|
|
293
|
+
bytes.toString("ascii", 8, 12) === "WEBP" &&
|
|
294
|
+
bytes.readUInt32LE(4) + 8 === bytes.length
|
|
295
|
+
) {
|
|
296
|
+
extension = "webp";
|
|
297
|
+
mimeType = "image/webp";
|
|
298
|
+
} else {
|
|
299
|
+
throw new Error("CLIProxyAPI returned an unsupported or malformed image; expected JPEG, PNG, or WebP.");
|
|
300
|
+
}
|
|
301
|
+
const image: ImageContent = { type: "image", data: encoded, mimeType };
|
|
302
|
+
return { bytes, extension, image };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parseGoogleImages(value: unknown) {
|
|
306
|
+
const invalid = () =>
|
|
307
|
+
new Error(
|
|
308
|
+
"CLIProxyAPI Google image response is blocked, refused, incomplete, text-only, or malformed. No images saved.",
|
|
309
|
+
);
|
|
310
|
+
if (
|
|
311
|
+
!isRecord(value) ||
|
|
312
|
+
value.error !== undefined ||
|
|
313
|
+
value.refusal !== undefined ||
|
|
314
|
+
(value.promptFeedback !== undefined &&
|
|
315
|
+
(!isRecord(value.promptFeedback) || value.promptFeedback.blockReason !== undefined)) ||
|
|
316
|
+
!Array.isArray(value.candidates) ||
|
|
317
|
+
value.candidates.length !== 1
|
|
318
|
+
)
|
|
319
|
+
throw invalid();
|
|
320
|
+
const candidate: unknown = value.candidates[0];
|
|
321
|
+
if (
|
|
322
|
+
!isRecord(candidate) ||
|
|
323
|
+
candidate.finishReason !== "STOP" ||
|
|
324
|
+
candidate.refusal !== undefined ||
|
|
325
|
+
!isRecord(candidate.content) ||
|
|
326
|
+
!Array.isArray(candidate.content.parts) ||
|
|
327
|
+
candidate.content.parts.length > 256
|
|
328
|
+
)
|
|
329
|
+
throw invalid();
|
|
330
|
+
for (const ratings of [
|
|
331
|
+
isRecord(value.promptFeedback) ? value.promptFeedback.safetyRatings : undefined,
|
|
332
|
+
candidate.safetyRatings,
|
|
333
|
+
]) {
|
|
334
|
+
if (
|
|
335
|
+
ratings !== undefined &&
|
|
336
|
+
(!Array.isArray(ratings) ||
|
|
337
|
+
ratings.some(
|
|
338
|
+
(rating: unknown) =>
|
|
339
|
+
!isRecord(rating) || (rating.blocked !== undefined && rating.blocked !== false),
|
|
340
|
+
))
|
|
341
|
+
)
|
|
342
|
+
throw invalid();
|
|
343
|
+
}
|
|
344
|
+
const images: ReturnType<typeof parseImage>[] = [];
|
|
345
|
+
for (const part of candidate.content.parts) {
|
|
346
|
+
if (
|
|
347
|
+
!isRecord(part) ||
|
|
348
|
+
part.refusal !== undefined ||
|
|
349
|
+
(part.thought !== undefined && typeof part.thought !== "boolean")
|
|
350
|
+
)
|
|
351
|
+
throw invalid();
|
|
352
|
+
if (part.thought === true) continue;
|
|
353
|
+
if (part.inlineData !== undefined) {
|
|
354
|
+
if (!isRecord(part.inlineData) || typeof part.inlineData.data !== "string") throw invalid();
|
|
355
|
+
const parsed = parseImage({ data: [{ b64_json: part.inlineData.data }] });
|
|
356
|
+
if (part.inlineData.mimeType !== parsed.image.mimeType) throw invalid();
|
|
357
|
+
images.push(parsed);
|
|
358
|
+
} else if (typeof part.text !== "string") throw invalid();
|
|
359
|
+
}
|
|
360
|
+
if (images.length === 0 || images.length > 16) throw invalid();
|
|
361
|
+
return images;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function generateImage(
|
|
365
|
+
config: Config,
|
|
366
|
+
params: Static<typeof imageParameters>,
|
|
367
|
+
ctx: MediaContext,
|
|
368
|
+
signal?: AbortSignal,
|
|
369
|
+
onProgress?: (text: string) => void,
|
|
370
|
+
) {
|
|
371
|
+
const model = resolveMediaModel(config, ctx, "image", params.model);
|
|
372
|
+
const route = mediaCapability(model)?.route;
|
|
373
|
+
const output = mediaOutputOptions(model, params);
|
|
374
|
+
const bounded = deadline(signal, route === "openai" ? 600000 : 180000);
|
|
375
|
+
const key = await generationKey(config, { ...params, model }, ctx, bounded);
|
|
376
|
+
bounded.throwIfAborted();
|
|
377
|
+
onProgress?.("Generating image...");
|
|
378
|
+
bounded.throwIfAborted();
|
|
379
|
+
const result = await mediaRequest(
|
|
380
|
+
config,
|
|
381
|
+
key,
|
|
382
|
+
route === "gemini" ? `v1beta/models/${model}:generateContent` : "v1/images/generations",
|
|
383
|
+
bounded,
|
|
384
|
+
32 * 1024 * 1024,
|
|
385
|
+
route === "xai"
|
|
386
|
+
? {
|
|
387
|
+
model,
|
|
388
|
+
prompt: params.prompt,
|
|
389
|
+
n: 1,
|
|
390
|
+
response_format: "b64_json",
|
|
391
|
+
resolution: output.resolution,
|
|
392
|
+
aspect_ratio: output.aspect_ratio,
|
|
393
|
+
}
|
|
394
|
+
: route === "openai"
|
|
395
|
+
? { model, prompt: params.prompt, n: 1, size: output.size }
|
|
396
|
+
: {
|
|
397
|
+
contents: [{ role: "user", parts: [{ text: params.prompt }] }],
|
|
398
|
+
generationConfig: {
|
|
399
|
+
responseModalities: ["TEXT", "IMAGE"],
|
|
400
|
+
candidateCount: 1,
|
|
401
|
+
...(output.resolution !== undefined || output.aspect_ratio !== undefined
|
|
402
|
+
? { imageConfig: { imageSize: output.resolution, aspectRatio: output.aspect_ratio } }
|
|
403
|
+
: {}),
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
route === "openai",
|
|
407
|
+
);
|
|
408
|
+
const images = route === "gemini" ? parseGoogleImages(result) : [parseImage(result)];
|
|
409
|
+
bounded.throwIfAborted();
|
|
410
|
+
onProgress?.("Saving image...");
|
|
411
|
+
bounded.throwIfAborted();
|
|
412
|
+
const artifacts = join(ctx.cwd, ".pi");
|
|
413
|
+
await mkdir(artifacts, { recursive: true, mode: 0o700 });
|
|
414
|
+
const directory = await mkdtemp(join(artifacts, "cliproxyapi-image-"));
|
|
415
|
+
const files = images.map(({ extension, image }, index) => ({
|
|
416
|
+
path: join(directory, `${index === 0 ? "image" : `image-${index + 1}`}.${extension}`),
|
|
417
|
+
mimeType: image.mimeType,
|
|
418
|
+
}));
|
|
419
|
+
try {
|
|
420
|
+
for (const [index, { bytes }] of images.entries()) {
|
|
421
|
+
await writeFile(files[index].path, bytes, { flag: "wx", mode: 0o600, signal: bounded });
|
|
422
|
+
}
|
|
423
|
+
bounded.throwIfAborted();
|
|
424
|
+
} catch {
|
|
425
|
+
await rm(directory, { recursive: true, force: true });
|
|
426
|
+
throw new Error("CLIProxyAPI generated images but could not save the artifacts.");
|
|
427
|
+
}
|
|
428
|
+
let previewBudget = MAX_INLINE_IMAGE_BASE64_BYTES;
|
|
429
|
+
const content: (TextContent | ImageContent)[] = [];
|
|
430
|
+
for (const [index, { image }] of images.entries()) {
|
|
431
|
+
const previewOmitted = !ctx.model?.input.includes("image")
|
|
432
|
+
? "Preview omitted: the current chat model does not support images."
|
|
433
|
+
: image.data.length > previewBudget
|
|
434
|
+
? "Preview omitted: image base64 exceeds the 4 MiB inline budget. Use read on the saved file."
|
|
435
|
+
: undefined;
|
|
436
|
+
content.push({
|
|
437
|
+
type: "text",
|
|
438
|
+
text: `Saved image: ${files[index].path}${previewOmitted ? `\n${previewOmitted}` : ""}`,
|
|
439
|
+
});
|
|
440
|
+
if (!previewOmitted) {
|
|
441
|
+
content.push(image);
|
|
442
|
+
previewBudget -= image.data.length;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { content, details: { model, files } };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function requestId(value: unknown) {
|
|
449
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(value)) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
"CLIProxyAPI video requires a valid request_id (letters, digits, underscore, or hyphen).",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return value;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function generateVideo(
|
|
458
|
+
config: Config,
|
|
459
|
+
params: Static<typeof videoParameters>,
|
|
460
|
+
ctx: MediaContext,
|
|
461
|
+
signal?: AbortSignal,
|
|
462
|
+
) {
|
|
463
|
+
if (
|
|
464
|
+
params.duration !== undefined &&
|
|
465
|
+
(!Number.isInteger(params.duration) || params.duration < 1 || params.duration > 15)
|
|
466
|
+
) {
|
|
467
|
+
throw new Error("CLIProxyAPI video duration must be an integer from 1 to 15 seconds.");
|
|
468
|
+
}
|
|
469
|
+
const model = resolveMediaModel(config, ctx, "video", params.model);
|
|
470
|
+
const output = mediaOutputOptions(model, params);
|
|
471
|
+
const bounded = deadline(signal, 60000);
|
|
472
|
+
const key = await generationKey(config, { ...params, model }, ctx, bounded);
|
|
473
|
+
const result = await mediaRequest(config, key, "v1/videos", bounded, 64 * 1024, {
|
|
474
|
+
model,
|
|
475
|
+
prompt: params.prompt,
|
|
476
|
+
duration: params.duration,
|
|
477
|
+
resolution: output.resolution,
|
|
478
|
+
aspect_ratio: output.aspect_ratio,
|
|
479
|
+
});
|
|
480
|
+
if (!isRecord(result)) throw new Error("CLIProxyAPI returned an invalid video submission.");
|
|
481
|
+
return { model, request_id: requestId(result.request_id), status: "pending" };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function videoStatus(config: Config, id: string, ctx: MediaContext, signal?: AbortSignal) {
|
|
485
|
+
const request_id = requestId(id);
|
|
486
|
+
const bounded = deadline(signal, 15000);
|
|
487
|
+
const key = await mediaKey(ctx, bounded);
|
|
488
|
+
const result = await mediaRequest(config, key, `v1/videos/${request_id}`, bounded, 64 * 1024);
|
|
489
|
+
if (!isRecord(result)) throw new Error("CLIProxyAPI returned an invalid video status.");
|
|
490
|
+
if (result.status === "pending") return { request_id, status: "pending" };
|
|
491
|
+
if (result.status === "failed" || result.status === "expired") {
|
|
492
|
+
return { request_id, status: "failed", upstream_status: result.status };
|
|
493
|
+
}
|
|
494
|
+
if (result.status === "done" && isRecord(result.video) && result.video.respect_moderation === false) {
|
|
495
|
+
return { request_id, status: "failed", upstream_status: "done", respect_moderation: false };
|
|
496
|
+
}
|
|
497
|
+
if (
|
|
498
|
+
result.status !== "done" ||
|
|
499
|
+
!isRecord(result.video) ||
|
|
500
|
+
typeof result.video.url !== "string" ||
|
|
501
|
+
(result.video.respect_moderation !== undefined && typeof result.video.respect_moderation !== "boolean")
|
|
502
|
+
) {
|
|
503
|
+
throw new Error("CLIProxyAPI returned an unknown or malformed video status.");
|
|
504
|
+
}
|
|
505
|
+
let url: URL;
|
|
506
|
+
try {
|
|
507
|
+
url = new URL(result.video.url);
|
|
508
|
+
} catch {
|
|
509
|
+
throw new Error("CLIProxyAPI returned an invalid video URL.");
|
|
510
|
+
}
|
|
511
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.href.length > 8192) {
|
|
512
|
+
throw new Error("CLIProxyAPI returned an unsafe video URL.");
|
|
513
|
+
}
|
|
514
|
+
return { request_id, status: "completed", upstream_status: "done", url: url.href };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function registerMediaTools(pi: ExtensionAPI, config: Config) {
|
|
518
|
+
pi.registerTool({
|
|
519
|
+
name: "cliproxyapi_media_models",
|
|
520
|
+
label: "CLIProxyAPI media models",
|
|
521
|
+
description:
|
|
522
|
+
"List available CLIProxyAPI image/video models, their supported output controls, and effective defaults. Does not change the chat model.",
|
|
523
|
+
parameters: Type.Object({}),
|
|
524
|
+
async execute(_id, _params, signal, _update, ctx) {
|
|
525
|
+
const models = await listMediaModels(config, ctx, signal);
|
|
526
|
+
const { defaults, automatic } = effectiveMediaDefaults(config, ctx);
|
|
527
|
+
return {
|
|
528
|
+
content: [{ type: "text", text: JSON.stringify({ models, defaults, automatic }) }],
|
|
529
|
+
details: { models, defaults, automatic },
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
pi.registerTool({
|
|
534
|
+
name: "cliproxyapi_generate_image",
|
|
535
|
+
label: "CLIProxyAPI image",
|
|
536
|
+
description:
|
|
537
|
+
"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.",
|
|
538
|
+
promptSnippet: "Generate requested raster images through CLIProxyAPI using the effective image default.",
|
|
539
|
+
promptGuidelines: [
|
|
540
|
+
"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.",
|
|
541
|
+
],
|
|
542
|
+
parameters: imageParameters,
|
|
543
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
544
|
+
return generateImage(config, params, ctx, signal, (text) =>
|
|
545
|
+
onUpdate?.({ content: [{ type: "text", text }], details: {} }),
|
|
546
|
+
);
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
pi.registerTool({
|
|
550
|
+
name: "cliproxyapi_generate_video",
|
|
551
|
+
label: "CLIProxyAPI video",
|
|
552
|
+
description:
|
|
553
|
+
"Submit one video generation using an explicit available video model or the session video default selected with /cli:model and optional duration (1–15 seconds). Returns request_id; use cliproxyapi_video_status to check it. Do not submit again to poll. No automatic retries.",
|
|
554
|
+
parameters: videoParameters,
|
|
555
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
556
|
+
const details = await generateVideo(config, params, ctx, signal);
|
|
557
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
pi.registerTool({
|
|
561
|
+
name: "cliproxyapi_video_status",
|
|
562
|
+
label: "CLIProxyAPI video status",
|
|
563
|
+
description:
|
|
564
|
+
"Check one video request_id once. Returns pending, completed with a video URL, or failed. Does not download the video or submit generation.",
|
|
565
|
+
parameters: Type.Object({ request_id: Type.String({ minLength: 1 }) }),
|
|
566
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
567
|
+
const details = await videoStatus(config, params.request_id, ctx, signal);
|
|
568
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
}
|