privateer-agent 0.12.57 → 0.12.58
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.
|
@@ -491,11 +491,25 @@ const approveInAppMs = isSubagentChild() ? 0 : Number(process.env.PRIVATEER_APPR
|
|
|
491
491
|
// anything, and media.ts has no business in the TUI's startup graph.
|
|
492
492
|
async function quoteMediaCall(tool: string, input: unknown, signal?: AbortSignal): Promise<number | null> {
|
|
493
493
|
const { readMediaCapabilities, quoteMediaCallUsd } = await import("../src/tools/media.ts");
|
|
494
|
+
const { effectiveMediaModel } = await import("../src/config/mediaModels.ts");
|
|
494
495
|
const a = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
|
|
495
|
-
|
|
496
|
-
|
|
496
|
+
// Price the model the tool will actually SEND — its own `model`, else this machine's
|
|
497
|
+
// /image-model or /video-model choice — not the account default it would fall past.
|
|
498
|
+
let priced = a;
|
|
499
|
+
let query: { imageModel?: string; videoModel?: string; model?: string } = {};
|
|
500
|
+
if (tool === "generate_image") {
|
|
501
|
+
const imageModel = effectiveMediaModel("image", a.model);
|
|
502
|
+
priced = { ...a, model: imageModel };
|
|
503
|
+
query = { imageModel };
|
|
504
|
+
} else if (tool === "generate_video" || tool === "generate_sprite") {
|
|
505
|
+
const imageModel = tool === "generate_sprite" ? effectiveMediaModel("image", a.image_model) : undefined;
|
|
506
|
+
priced = tool === "generate_sprite" ? { ...a, image_model: imageModel } : a;
|
|
507
|
+
query = { videoModel: effectiveMediaModel("video", a.model), imageModel };
|
|
508
|
+
} else if (tool === "generate_model") {
|
|
509
|
+
query = { model: typeof a.model === "string" && a.model ? a.model : undefined };
|
|
510
|
+
}
|
|
497
511
|
const caps = await readMediaCapabilities(query, signal);
|
|
498
|
-
return caps.ok ? quoteMediaCallUsd(tool,
|
|
512
|
+
return caps.ok ? quoteMediaCallUsd(tool, priced, caps.data) : null;
|
|
499
513
|
}
|
|
500
514
|
|
|
501
515
|
const cliLedger = cliGrant ? new CliSpendLedger(cliGrant, quoteMediaCall) : null;
|
|
@@ -20,14 +20,20 @@
|
|
|
20
20
|
// already on disk — no account, no network, no spend — so it stays useful to a
|
|
21
21
|
// signed-out terminal, and to a child whose job is to cut together what its parent
|
|
22
22
|
// generated.
|
|
23
|
+
//
|
|
24
|
+
// /image-model and /video-model are registered unconditionally too: they only write a
|
|
25
|
+
// local preference, and a signed-out terminal is told to /login when it opens the
|
|
26
|
+
// picker rather than finding the command missing.
|
|
23
27
|
import { makeMediaTools } from "../src/tools/media.ts";
|
|
24
28
|
import { makeComposeTools } from "../src/tools/videoCompose.ts";
|
|
25
29
|
import { mediaEnabled } from "../src/config/hosted.ts";
|
|
26
30
|
import { childHoldsSpendGrant } from "../src/permissions/childSpend.ts";
|
|
27
31
|
import { isSubagentChild } from "../src/remote/subagentRelay.ts";
|
|
32
|
+
import { registerMediaModelCommands } from "../src/tools/mediaModelCommands.ts";
|
|
28
33
|
|
|
29
34
|
export default function privateerMedia(pi: any): void {
|
|
30
35
|
const canSpend = !isSubagentChild() || childHoldsSpendGrant();
|
|
31
36
|
if (mediaEnabled() && canSpend) makeMediaTools()(pi);
|
|
32
37
|
makeComposeTools()(pi);
|
|
38
|
+
registerMediaModelCommands(pi);
|
|
33
39
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.58",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which image and video models this machine's agent generates with, when the agent
|
|
3
|
+
* doesn't name one itself. Set by `/image-model` and `/video-model`
|
|
4
|
+
* (extensions/privateer-media.ts), stored as `media.imageModel` / `media.videoModel`
|
|
5
|
+
* in ~/.privateer/config.json.
|
|
6
|
+
*
|
|
7
|
+
* PER MACHINE, NOT PER ACCOUNT. The account already has a default for each (the app's
|
|
8
|
+
* own picker writes it, server-side), and that is what an unset value here falls through
|
|
9
|
+
* to. A terminal choice deliberately does not write back to it: picking a cheap video
|
|
10
|
+
* model for a batch of terminal drafts should not change what the phone renders with.
|
|
11
|
+
*
|
|
12
|
+
* PRECEDENCE, highest first: the tool call's own `model` → this file → the account
|
|
13
|
+
* default. The agent naming a model still wins, because it only does that when a job
|
|
14
|
+
* calls for a specific one (a 4K clip, a model that takes a last frame).
|
|
15
|
+
*
|
|
16
|
+
* Read at CALL time, like piiAllow.ts: a pick made in one terminal reaches a harbor or
|
|
17
|
+
* a second terminal on the same machine on its next generation, without a restart.
|
|
18
|
+
* Missing file, unreadable file and malformed JSON all mean "no choice" — the account
|
|
19
|
+
* default — never a crash in the middle of a tool call.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
import { configPath } from "./paths.ts";
|
|
24
|
+
|
|
25
|
+
export type MediaKind = "image" | "video";
|
|
26
|
+
|
|
27
|
+
const KEY: Record<MediaKind, "imageModel" | "videoModel"> = { image: "imageModel", video: "videoModel" };
|
|
28
|
+
|
|
29
|
+
function readConfig(): Record<string, unknown> {
|
|
30
|
+
try {
|
|
31
|
+
const cfg = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
32
|
+
return cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The chosen model id for `kind`, or undefined to use the account default. */
|
|
39
|
+
export function mediaModelPref(kind: MediaKind): string | undefined {
|
|
40
|
+
const media = readConfig().media as Record<string, unknown> | undefined;
|
|
41
|
+
const v = media?.[KEY[kind]];
|
|
42
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Set (or, with null, clear) the choice for `kind`. Every other key in config.json is
|
|
47
|
+
* preserved — it also holds webhooks, privacy and coauthor settings this must not drop.
|
|
48
|
+
*/
|
|
49
|
+
export function setMediaModelPref(kind: MediaKind, id: string | null): void {
|
|
50
|
+
const cfg = readConfig();
|
|
51
|
+
const media = { ...((cfg.media as Record<string, unknown>) ?? {}) };
|
|
52
|
+
const clean = id?.trim();
|
|
53
|
+
if (clean) media[KEY[kind]] = clean;
|
|
54
|
+
else delete media[KEY[kind]];
|
|
55
|
+
const next: Record<string, unknown> = { ...cfg, media };
|
|
56
|
+
if (Object.keys(media).length === 0) delete next.media;
|
|
57
|
+
mkdirSync(dirname(configPath()), { recursive: true });
|
|
58
|
+
writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The model a call should send: its own `model`, else this machine's choice, else none. */
|
|
62
|
+
export function effectiveMediaModel(kind: MediaKind, requested: unknown): string | undefined {
|
|
63
|
+
return typeof requested === "string" && requested.trim() ? requested.trim() : mediaModelPref(kind);
|
|
64
|
+
}
|
package/src/tools/media.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSyn
|
|
|
42
42
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
43
43
|
import { tmpdir } from "node:os";
|
|
44
44
|
import { apiRequest } from "../auth/privateer.ts";
|
|
45
|
+
import { effectiveMediaModel } from "../config/mediaModels.ts";
|
|
45
46
|
|
|
46
47
|
/** Tool names these definitions register, for allow-list construction. */
|
|
47
48
|
export const MEDIA_TOOL_NAMES = [
|
|
@@ -280,7 +281,7 @@ export const generateImageToolDefinition = {
|
|
|
280
281
|
count: Type.Optional(Type.Number({ description: "How many variations to produce, 1-4. Above 1, files are suffixed -1, -2, … Defaults to 1." })),
|
|
281
282
|
aspectRatio: Type.Optional(Type.String({ description: "Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to the model's own." })),
|
|
282
283
|
size: Type.Optional(Type.String({ description: "Explicit pixel size if the model supports one, e.g. '1024x1024'." })),
|
|
283
|
-
model: Type.Optional(Type.String({ description: "Override the
|
|
284
|
+
model: Type.Optional(Type.String({ description: "Override the image model (e.g. 'google/gemini-3.1-flash-image'). Leave unset to use the one the user chose with /image-model, or the account default." })),
|
|
284
285
|
}),
|
|
285
286
|
async execute(
|
|
286
287
|
_toolCallId: string,
|
|
@@ -302,6 +303,7 @@ export const generateImageToolDefinition = {
|
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
const count = Math.min(Math.max(1, Math.round(params.count ?? 1)), 4);
|
|
306
|
+
const model = effectiveMediaModel("image", params.model);
|
|
305
307
|
const r = await callAccount<ImageResponse>("/api/agent/media/images", {
|
|
306
308
|
method: "POST",
|
|
307
309
|
signal,
|
|
@@ -311,7 +313,7 @@ export const generateImageToolDefinition = {
|
|
|
311
313
|
...(inputs.length ? { images: inputs } : {}),
|
|
312
314
|
...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
|
|
313
315
|
...(params.size ? { imageSize: params.size } : {}),
|
|
314
|
-
...(
|
|
316
|
+
...(model ? { model } : {}),
|
|
315
317
|
},
|
|
316
318
|
});
|
|
317
319
|
if (!r.ok) return text(`Image generation failed: ${r.message}`);
|
|
@@ -381,7 +383,7 @@ export const generateVideoToolDefinition = {
|
|
|
381
383
|
aspectRatio: Type.Optional(Type.String({ description: "Aspect ratio, e.g. '16:9', '9:16'. Model-specific." })),
|
|
382
384
|
resolution: Type.Optional(Type.String({ description: "Resolution, e.g. '720p' or '1080p'." })),
|
|
383
385
|
audio: Type.Optional(Type.Boolean({ description: "Ask the model to generate a soundtrack too, where it supports one. Costs more. Defaults to false." })),
|
|
384
|
-
model: Type.Optional(Type.String({ description: "Override the
|
|
386
|
+
model: Type.Optional(Type.String({ description: "Override the video model (e.g. 'google/veo-3.1-lite'). Leave unset to use the one the user chose with /video-model, or the account default." })),
|
|
385
387
|
}),
|
|
386
388
|
async execute(
|
|
387
389
|
_toolCallId: string,
|
|
@@ -417,6 +419,7 @@ export const generateVideoToolDefinition = {
|
|
|
417
419
|
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
418
420
|
}
|
|
419
421
|
|
|
422
|
+
const model = effectiveMediaModel("video", params.model);
|
|
420
423
|
const submitted = await callAccount<VideoSubmitResponse>("/api/agent/media/videos", {
|
|
421
424
|
method: "POST",
|
|
422
425
|
signal,
|
|
@@ -426,7 +429,7 @@ export const generateVideoToolDefinition = {
|
|
|
426
429
|
...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
|
|
427
430
|
...(params.resolution ? { resolution: params.resolution } : {}),
|
|
428
431
|
...(params.audio ? { generateAudio: true } : {}),
|
|
429
|
-
...(
|
|
432
|
+
...(model ? { model } : {}),
|
|
430
433
|
...(firstFrame ? { firstFrame } : {}),
|
|
431
434
|
...(lastFrame ? { lastFrame } : {}),
|
|
432
435
|
},
|
|
@@ -1154,9 +1157,10 @@ export function quoteMediaCallUsd(tool: string, input: unknown, caps: Capabiliti
|
|
|
1154
1157
|
};
|
|
1155
1158
|
switch (tool) {
|
|
1156
1159
|
case "generate_image": {
|
|
1157
|
-
// The report prices the
|
|
1158
|
-
// be quoted at the wrong model's rate, so it isn't quoted at all.
|
|
1159
|
-
|
|
1160
|
+
// The report prices the image model it DESCRIBES; a call naming another one would
|
|
1161
|
+
// be quoted at the wrong model's rate, so it isn't quoted at all. (A server that
|
|
1162
|
+
// ignores `?imageModel=` describes the account default — a mismatch, so no quote.)
|
|
1163
|
+
if (typeof args.model === "string" && args.model && args.model !== caps.image?.model) return null;
|
|
1160
1164
|
const each = caps.image?.priceUsdEach;
|
|
1161
1165
|
if (typeof each !== "number") return null;
|
|
1162
1166
|
const n = Math.min(Math.max(Math.trunc(num(args.count) ?? 1), 1), caps.image?.maxPerCall ?? 4);
|
|
@@ -1176,7 +1180,7 @@ export function quoteMediaCallUsd(tool: string, input: unknown, caps: Capabiliti
|
|
|
1176
1180
|
if (!row || clipUsd === null) return null;
|
|
1177
1181
|
const stills = row.billedTurnStills ?? 0;
|
|
1178
1182
|
const each = caps.image?.priceUsdEach;
|
|
1179
|
-
if (stills > 0 && (typeof each !== "number" || (typeof args.image_model === "string" && args.image_model))) return null;
|
|
1183
|
+
if (stills > 0 && (typeof each !== "number" || (typeof args.image_model === "string" && args.image_model && args.image_model !== caps.image?.model))) return null;
|
|
1180
1184
|
return clipUsd * (row.billedClips ?? 1) + stills * (each ?? 0);
|
|
1181
1185
|
}
|
|
1182
1186
|
case "generate_sfx":
|
|
@@ -1189,12 +1193,13 @@ export function quoteMediaCallUsd(tool: string, input: unknown, caps: Capabiliti
|
|
|
1189
1193
|
}
|
|
1190
1194
|
}
|
|
1191
1195
|
|
|
1192
|
-
/** Read the capability report (optionally describing a specific video / 3D model). */
|
|
1196
|
+
/** Read the capability report (optionally describing a specific image / video / 3D model). */
|
|
1193
1197
|
export async function readMediaCapabilities(
|
|
1194
|
-
query: { videoModel?: string; model?: string } = {},
|
|
1198
|
+
query: { imageModel?: string; videoModel?: string; model?: string } = {},
|
|
1195
1199
|
signal?: AbortSignal,
|
|
1196
1200
|
): Promise<{ ok: true; data: CapabilitiesResponse } | { ok: false; message: string }> {
|
|
1197
1201
|
const q = new URLSearchParams();
|
|
1202
|
+
if (query.imageModel) q.set("imageModel", query.imageModel);
|
|
1198
1203
|
if (query.videoModel) q.set("videoModel", query.videoModel);
|
|
1199
1204
|
if (query.model) q.set("model", query.model);
|
|
1200
1205
|
const qs = q.toString();
|
|
@@ -1293,10 +1298,17 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1293
1298
|
"their legal values and the price — is per-model.",
|
|
1294
1299
|
}),
|
|
1295
1300
|
),
|
|
1301
|
+
imageModel: Type.Optional(
|
|
1302
|
+
Type.String({
|
|
1303
|
+
description:
|
|
1304
|
+
"An image model id to describe instead of the default (e.g. 'google/gemini-3.1-flash-image'), " +
|
|
1305
|
+
"so the price per image is the one generate_image will charge for it.",
|
|
1306
|
+
}),
|
|
1307
|
+
),
|
|
1296
1308
|
videoModel: Type.Optional(
|
|
1297
1309
|
Type.String({
|
|
1298
1310
|
description:
|
|
1299
|
-
"A video model id to describe instead of the
|
|
1311
|
+
"A video model id to describe instead of the default (e.g. 'bytedance/seedance-2.0'). " +
|
|
1300
1312
|
"Clip lengths, aspect ratios and the price per clip are all per-model — check the one you will " +
|
|
1301
1313
|
"pass as generate_video's `model` before you spend on it.",
|
|
1302
1314
|
}),
|
|
@@ -1310,10 +1322,20 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1310
1322
|
}),
|
|
1311
1323
|
),
|
|
1312
1324
|
}),
|
|
1313
|
-
async execute(
|
|
1325
|
+
async execute(
|
|
1326
|
+
_toolCallId: string,
|
|
1327
|
+
params: { model?: string; ttsModel?: string; imageModel?: string; videoModel?: string },
|
|
1328
|
+
signal?: AbortSignal,
|
|
1329
|
+
) {
|
|
1330
|
+
// Unset means "the model generate_* would use", which is this machine's
|
|
1331
|
+
// /image-model or /video-model choice before the account default — describing the
|
|
1332
|
+
// account default instead would quote one model's price and clip lengths for another.
|
|
1333
|
+
const imageModel = effectiveMediaModel("image", params?.imageModel);
|
|
1334
|
+
const videoModel = effectiveMediaModel("video", params?.videoModel);
|
|
1314
1335
|
const query = new URLSearchParams();
|
|
1315
1336
|
if (params?.model) query.set("model", params.model);
|
|
1316
|
-
if (
|
|
1337
|
+
if (imageModel) query.set("imageModel", imageModel);
|
|
1338
|
+
if (videoModel) query.set("videoModel", videoModel);
|
|
1317
1339
|
if (params?.ttsModel) query.set("ttsModel", params.ttsModel);
|
|
1318
1340
|
const qs = query.toString();
|
|
1319
1341
|
const r = await callAccount<CapabilitiesResponse>(
|
|
@@ -1324,6 +1346,11 @@ export const mediaCapabilitiesToolDefinition = {
|
|
|
1324
1346
|
|
|
1325
1347
|
const { image, video, model3d, sfx, speech, privacy } = r.data;
|
|
1326
1348
|
const lines = describeImageVideo(image, video);
|
|
1349
|
+
// An older server ignores `?imageModel=` and reports the account default; say so
|
|
1350
|
+
// rather than let the agent read that model's price as the chosen one's.
|
|
1351
|
+
if (imageModel && image?.model && image.model !== imageModel) {
|
|
1352
|
+
lines.push(` (generate_image will use ${imageModel}; this server described the account default instead)`);
|
|
1353
|
+
}
|
|
1327
1354
|
|
|
1328
1355
|
lines.push(...describeModel3d(model3d));
|
|
1329
1356
|
lines.push(...describeSfx(sfx));
|
|
@@ -1773,13 +1800,13 @@ export const generateSpriteToolDefinition = {
|
|
|
1773
1800
|
}),
|
|
1774
1801
|
),
|
|
1775
1802
|
model: Type.Optional(
|
|
1776
|
-
Type.String({ description: "Video model id to render the motion with. Omit for the account default." }),
|
|
1803
|
+
Type.String({ description: "Video model id to render the motion with. Omit for the one chosen with /video-model, or the account default." }),
|
|
1777
1804
|
),
|
|
1778
1805
|
image_model: Type.Optional(
|
|
1779
1806
|
Type.String({
|
|
1780
1807
|
description:
|
|
1781
1808
|
"Image model id used to TURN the picture to face each direction, e.g. " +
|
|
1782
|
-
"'google/gemini-3.1-flash-image'. Omit for the account default. Only used when " +
|
|
1809
|
+
"'google/gemini-3.1-flash-image'. Omit for the one chosen with /image-model, or the account default. Only used when " +
|
|
1783
1810
|
"`directions` is 'four' or 'eight' — 'one' renders no turns and never touches an image " +
|
|
1784
1811
|
"model. Worth setting when a run fails with SPRITE_IMAGE_MODEL_UNAVAILABLE: that is the " +
|
|
1785
1812
|
"image provider being down, not the request being wrong, and naming another model here " +
|
|
@@ -1859,8 +1886,8 @@ export const generateSpriteToolDefinition = {
|
|
|
1859
1886
|
const guessed = guessResPath(cwd, params.dir);
|
|
1860
1887
|
return guessed ? { res_path: guessed } : {};
|
|
1861
1888
|
})()),
|
|
1862
|
-
...(
|
|
1863
|
-
...(
|
|
1889
|
+
...((m) => (m ? { model: m } : {}))(effectiveMediaModel("video", params.model)),
|
|
1890
|
+
...((m) => (m ? { image_model: m } : {}))(effectiveMediaModel("image", params.image_model)),
|
|
1864
1891
|
},
|
|
1865
1892
|
});
|
|
1866
1893
|
if (!submitted.ok) return text(`Sprite generation failed: ${submitted.message}`);
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// `/image-model` and `/video-model` — choose which model the media tools generate with
|
|
2
|
+
// on this machine when the agent doesn't name one (see config/mediaModels.ts for where
|
|
3
|
+
// the choice lives and why it is per machine rather than written to the account).
|
|
4
|
+
//
|
|
5
|
+
// /image-model open a picker over the account's live image catalog
|
|
6
|
+
// /image-model <id> set it directly (checked against that catalog)
|
|
7
|
+
// /image-model default clear it — back to the account default
|
|
8
|
+
//
|
|
9
|
+
// The catalog is the same list the app's own picker reads
|
|
10
|
+
// (GET /api/models/openrouter?action=imageGen|videoGen), so the terminal can never
|
|
11
|
+
// offer a model the account can't reach. Each row carries its privacy posture, because
|
|
12
|
+
// a ZDR account choosing a non-ZDR model will have every generation refused — better
|
|
13
|
+
// to see that in the picker than as a failed call later.
|
|
14
|
+
import { apiRequest } from "../auth/privateer.ts";
|
|
15
|
+
import { mediaModelPref, setMediaModelPref, type MediaKind } from "../config/mediaModels.ts";
|
|
16
|
+
|
|
17
|
+
export interface CatalogModel {
|
|
18
|
+
id: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
isZdr?: boolean;
|
|
21
|
+
isTee?: boolean;
|
|
22
|
+
supportedDurations?: number[] | null;
|
|
23
|
+
generateAudio?: boolean | null;
|
|
24
|
+
supportsImageToVideo?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ACTION: Record<MediaKind, string> = { image: "imageGen", video: "videoGen" };
|
|
28
|
+
const RESET = new Set(["default", "reset", "clear", "none", "account"]);
|
|
29
|
+
|
|
30
|
+
export async function fetchMediaCatalog(
|
|
31
|
+
kind: MediaKind,
|
|
32
|
+
): Promise<{ ok: true; models: CatalogModel[] } | { ok: false; message: string }> {
|
|
33
|
+
let res: Response;
|
|
34
|
+
try {
|
|
35
|
+
res = await apiRequest(`/api/models/openrouter?action=${ACTION[kind]}`, { method: "GET" });
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return { ok: false, message: `could not reach Privateer: ${e instanceof Error ? e.message : String(e)}` };
|
|
38
|
+
}
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
if (res.status === 401) return { ok: false, message: "not signed in — run /login first" };
|
|
41
|
+
return { ok: false, message: `Privateer returned ${res.status}` };
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const body = (await res.json()) as { models?: CatalogModel[] };
|
|
45
|
+
// OpenRouter's auto routers ride along in the image-output list but pick a CHAT
|
|
46
|
+
// model per request — not something a generation can be pinned to.
|
|
47
|
+
const models = (body.models ?? []).filter(
|
|
48
|
+
(m) => m && typeof m.id === "string" && m.id && !m.id.startsWith("openrouter/"),
|
|
49
|
+
);
|
|
50
|
+
return { ok: true, models };
|
|
51
|
+
} catch {
|
|
52
|
+
return { ok: false, message: "Privateer returned a malformed model list" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "3-15s" for a contiguous run of whole seconds, "5/10s" otherwise. Pure. */
|
|
57
|
+
export function formatDurations(d: number[]): string {
|
|
58
|
+
const s = [...new Set(d)].sort((a, b) => a - b);
|
|
59
|
+
const contiguous = s.length > 2 && s.every((v, i) => i === 0 || v === s[i - 1] + 1);
|
|
60
|
+
return contiguous ? `${s[0]}-${s[s.length - 1]}s` : `${s.join("/")}s`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One picker row: name, id, posture, and for video what the model can do. Pure. */
|
|
64
|
+
export function describeCatalogRow(m: CatalogModel, kind: MediaKind, current?: string): string {
|
|
65
|
+
const posture = m.isTee ? "confidential" : m.isZdr ? "ZDR" : "non-ZDR";
|
|
66
|
+
const tags = [posture];
|
|
67
|
+
if (kind === "video") {
|
|
68
|
+
if (m.supportedDurations?.length) tags.push(formatDurations(m.supportedDurations));
|
|
69
|
+
if (m.supportsImageToVideo) tags.push("first frame");
|
|
70
|
+
if (m.generateAudio) tags.push("audio");
|
|
71
|
+
}
|
|
72
|
+
const name = m.name && m.name !== m.id ? `${m.name} ` : "";
|
|
73
|
+
return `${m.id === current ? "● " : " "}${name}${m.id} (${tags.join(", ")})`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const LABEL: Record<MediaKind, string> = { image: "Image", video: "Video" };
|
|
77
|
+
|
|
78
|
+
function nonZdrWarning(m: CatalogModel | undefined): string {
|
|
79
|
+
return m && !m.isZdr && !m.isTee
|
|
80
|
+
? " — non-ZDR: refused if this account requires Zero Data Retention for media (Settings → Privacy)"
|
|
81
|
+
: "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Ui {
|
|
85
|
+
notify?: (msg: string, level?: string) => void;
|
|
86
|
+
select?: (title: string, options: string[]) => Promise<string | undefined>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The whole command, minus registration — exported so tests can drive it with a fake
|
|
91
|
+
* ui and a stubbed catalog.
|
|
92
|
+
*/
|
|
93
|
+
export async function runMediaModelCommand(
|
|
94
|
+
kind: MediaKind,
|
|
95
|
+
args: string,
|
|
96
|
+
ui: Ui | undefined,
|
|
97
|
+
catalog: (kind: MediaKind) => ReturnType<typeof fetchMediaCatalog> = fetchMediaCatalog,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const say = (msg: string, level = "info") => ui?.notify?.(msg, level);
|
|
100
|
+
const label = LABEL[kind];
|
|
101
|
+
const current = mediaModelPref(kind);
|
|
102
|
+
const arg = String(args ?? "").trim();
|
|
103
|
+
|
|
104
|
+
if (RESET.has(arg.toLowerCase())) {
|
|
105
|
+
setMediaModelPref(kind, null);
|
|
106
|
+
say(`${label} model: account default${current ? ` (was ${current})` : ""}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const list = await catalog(kind);
|
|
111
|
+
|
|
112
|
+
if (arg) {
|
|
113
|
+
// A typed id is checked against the live catalog when we can read it — a typo
|
|
114
|
+
// saved here would fail every later generation. When the catalog is unreachable
|
|
115
|
+
// it is saved anyway, with that said: the server still refuses an id it can't serve.
|
|
116
|
+
if (list.ok) {
|
|
117
|
+
const hit = list.models.find((m) => m.id === arg) ?? list.models.find((m) => m.id.toLowerCase() === arg.toLowerCase());
|
|
118
|
+
if (!hit) {
|
|
119
|
+
const near = list.models.filter((m) => m.id.toLowerCase().includes(arg.toLowerCase())).slice(0, 5);
|
|
120
|
+
say(
|
|
121
|
+
`No ${kind} model "${arg}" on this account.` +
|
|
122
|
+
(near.length ? ` Did you mean: ${near.map((m) => m.id).join(", ")}?` : ` Run /${kind}-model to browse.`),
|
|
123
|
+
"warning",
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
setMediaModelPref(kind, hit.id);
|
|
128
|
+
say(`${label} model: ${hit.id}${nonZdrWarning(hit)}`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
setMediaModelPref(kind, arg);
|
|
132
|
+
say(`${label} model: ${arg} (couldn't check it against the catalog: ${list.message})`, "warning");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!list.ok) {
|
|
137
|
+
say(`Couldn't list ${kind} models: ${list.message}`, "error");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!list.models.length) {
|
|
141
|
+
say(`This account has no ${kind} models available.`, "warning");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!ui?.select) {
|
|
145
|
+
say(`${label} model: ${current ?? "account default"}. Set one with /${kind}-model <id>.`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const DEFAULT_ROW = `${current ? " " : "● "}Account default`;
|
|
150
|
+
const rows = list.models.map((m) => describeCatalogRow(m, kind, current));
|
|
151
|
+
const picked = await ui.select(`${label} model for generate_${kind} — current: ${current ?? "account default"}`, [
|
|
152
|
+
DEFAULT_ROW,
|
|
153
|
+
...rows,
|
|
154
|
+
]);
|
|
155
|
+
if (picked === undefined) return;
|
|
156
|
+
if (picked === DEFAULT_ROW) {
|
|
157
|
+
setMediaModelPref(kind, null);
|
|
158
|
+
say(`${label} model: account default`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const chosen = list.models[rows.indexOf(picked)];
|
|
162
|
+
if (!chosen) return;
|
|
163
|
+
setMediaModelPref(kind, chosen.id);
|
|
164
|
+
say(`${label} model: ${chosen.id}${nonZdrWarning(chosen)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function registerMediaModelCommands(pi: { registerCommand?: (name: string, opts: unknown) => void }): void {
|
|
168
|
+
for (const kind of ["image", "video"] as const) {
|
|
169
|
+
pi.registerCommand?.(`${kind}-model`, {
|
|
170
|
+
description: `Choose the model generate_${kind} uses on this machine (default = the account's)`,
|
|
171
|
+
argumentHint: "[model id | default]",
|
|
172
|
+
handler: (args: string, ctx: any) => runMediaModelCommand(kind, args, ctx?.ui),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|