ottoport 1.11.0 → 1.13.0
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/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -1
- package/cli/ottoport.mjs +59 -3
- package/mcp/server.mjs +202 -11
- package/package.json +1 -1
- package/skills/ottoport/SKILL.md +15 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ottoport",
|
|
3
3
|
"description": "One API for every model. Call chat, image, video, speech and music models through the OttoPort gateway — as MCP tools, slash commands, or the bundled CLI.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.13.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "LITBOX LLC",
|
|
7
7
|
"email": "support@ottoport.ai"
|
package/README.md
CHANGED
|
@@ -78,7 +78,10 @@ forwarded from the environment rather than written into any config file.
|
|
|
78
78
|
|
|
79
79
|
`ottoport_list_models`, `ottoport_chat`, `ottoport_generate_image`,
|
|
80
80
|
`ottoport_generate_video`, `ottoport_video_webhook`, `ottoport_generate_speech`,
|
|
81
|
-
`ottoport_generate_music
|
|
81
|
+
`ottoport_generate_music`, and for keeping results, `ottoport_list_collections`,
|
|
82
|
+
`ottoport_create_collection`, `ottoport_list_assets`, `ottoport_save_asset`,
|
|
83
|
+
`ottoport_upload_file` and `ottoport_delete_asset`. Every generate tool takes
|
|
84
|
+
`collection` (an id or name) to save what it makes; `OTTOPORT_COLLECTION` sets a default.
|
|
82
85
|
The server speaks stdio JSON-RPC and is launched by the host as a subprocess, so
|
|
83
86
|
it needs Node 20+ on the PATH of whatever starts that host.
|
|
84
87
|
|
package/cli/ottoport.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// ottoport video "prompt" [--model kling-3.0] [--duration 5] [--out clip.mp4]
|
|
11
11
|
// ottoport speech "text" [--model gpt-4o-mini-tts] [--voice alloy] [--out speech.mp3]
|
|
12
12
|
// ottoport music "prompt" [--model suno-v5] [--duration 15] [--out song.mp3]
|
|
13
|
+
// ottoport collections [list | create "name"]
|
|
13
14
|
// ottoport mcp
|
|
14
15
|
// ottoport install <codex|claude|hermes|cursor|windsurf|claude-desktop>
|
|
15
16
|
//
|
|
@@ -278,6 +279,7 @@ async function cmdImage(prompt, flags) {
|
|
|
278
279
|
...(last(flags.seed) ? { seed: Number(last(flags.seed)) } : {}),
|
|
279
280
|
...(last(flags.image) ? { image_url: flags.image } : {}),
|
|
280
281
|
...(references(flags) ? { reference_image_urls: references(flags) } : {}),
|
|
282
|
+
...collection(flags),
|
|
281
283
|
};
|
|
282
284
|
const res = await fetch(`${baseUrl}/api/v1/images/generations`, {
|
|
283
285
|
method: "POST",
|
|
@@ -289,7 +291,7 @@ async function cmdImage(prompt, flags) {
|
|
|
289
291
|
const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
|
|
290
292
|
// A layer stack names each line, so `headline` can be found among eight URLs.
|
|
291
293
|
(json.data ?? []).filter((d) => d.url).forEach((d) =>
|
|
292
|
-
console.log(d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}:
|
|
294
|
+
console.log(outputLine(d, d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}: ` : "")));
|
|
293
295
|
if (last(flags.out) && urls[0]) await download(urls[0], last(flags.out));
|
|
294
296
|
}
|
|
295
297
|
|
|
@@ -311,6 +313,7 @@ async function cmdVideo(prompt, flags) {
|
|
|
311
313
|
...(references(flags, "ref-video") ? { reference_video_urls: references(flags, "ref-video") } : {}),
|
|
312
314
|
...(references(flags, "ref-audio") ? { reference_audio_urls: references(flags, "ref-audio") } : {}),
|
|
313
315
|
...webhook(flags),
|
|
316
|
+
...collection(flags),
|
|
314
317
|
};
|
|
315
318
|
console.error("submitting video job (this can take a minute)…");
|
|
316
319
|
const res = await fetch(`${baseUrl}/api/v1/videos/generations`, {
|
|
@@ -334,7 +337,7 @@ async function cmdVideo(prompt, flags) {
|
|
|
334
337
|
if (job.status === "failed") die(job.error || "video generation failed", 2);
|
|
335
338
|
const url = job.data?.[0]?.url;
|
|
336
339
|
if (url) {
|
|
337
|
-
console.log(
|
|
340
|
+
console.log(outputLine(job.data[0]));
|
|
338
341
|
if (flags.out) await download(url, last(flags.out));
|
|
339
342
|
} else {
|
|
340
343
|
console.log(JSON.stringify(job, null, 2));
|
|
@@ -386,6 +389,7 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
386
389
|
...(last(flags.voice) ? { voice: last(flags.voice) } : {}),
|
|
387
390
|
...(last(flags.duration) ? { duration: Number(last(flags.duration)) } : {}),
|
|
388
391
|
...(last(flags.format) ? { format: last(flags.format) } : {}),
|
|
392
|
+
...collection(flags),
|
|
389
393
|
};
|
|
390
394
|
const path = kind === "speech" ? "audio/speech" : "audio/music";
|
|
391
395
|
const res = await fetch(`${baseUrl}/api/v1/${path}`, {
|
|
@@ -397,7 +401,7 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
397
401
|
const json = await res.json();
|
|
398
402
|
const item = json.data?.[0];
|
|
399
403
|
if (!item) die("no audio returned", 2);
|
|
400
|
-
if (item.url) console.log(item
|
|
404
|
+
if (item.url) console.log(outputLine(item));
|
|
401
405
|
if (flags.out) {
|
|
402
406
|
if (item.b64_json) {
|
|
403
407
|
const bytes = Buffer.from(item.b64_json, "base64");
|
|
@@ -409,6 +413,51 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
409
413
|
}
|
|
410
414
|
}
|
|
411
415
|
|
|
416
|
+
/** `--collection <id>` keeps the results for good instead of letting them expire. */
|
|
417
|
+
function collection(flags) {
|
|
418
|
+
return last(flags.collection) ? { collection: last(flags.collection) } : {};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The URL to use — the kept one when the result was saved — and what became of the save. */
|
|
422
|
+
function outputLine(item, label = "") {
|
|
423
|
+
const saved = item.asset_id ? ` (saved as asset ${item.asset_id})` : item.asset_error ? ` (not saved: ${item.asset_error})` : "";
|
|
424
|
+
return `${label}${item.asset_url || item.url}${saved}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function cmdCollections(action, name, flags) {
|
|
428
|
+
const { baseUrl, apiKey } = config(flags);
|
|
429
|
+
if (action === "create") {
|
|
430
|
+
if (!name) die('collections create needs a name: ottoport collections create "Spring campaign"');
|
|
431
|
+
const res = await fetch(`${baseUrl}/api/v1/collections`, {
|
|
432
|
+
method: "POST", headers: headers(apiKey),
|
|
433
|
+
body: JSON.stringify({ name, ...(last(flags.description) ? { description: last(flags.description) } : {}), ...(last(flags["external-id"]) ? { external_id: last(flags["external-id"]) } : {}) }),
|
|
434
|
+
});
|
|
435
|
+
if (!res.ok) die(await readError(res));
|
|
436
|
+
const created = await res.json();
|
|
437
|
+
if (flags.json) return void console.log(JSON.stringify(created, null, 2));
|
|
438
|
+
return void console.log(`${created.id} ${created.name}`);
|
|
439
|
+
}
|
|
440
|
+
if (action && action !== "list") die(`unknown collections action: ${action} (list | create)`);
|
|
441
|
+
const res = await fetch(`${baseUrl}/api/v1/collections?limit=200`, { headers: headers(apiKey, false) });
|
|
442
|
+
if (!res.ok) die(await readError(res));
|
|
443
|
+
const body = await res.json();
|
|
444
|
+
if (flags.json) return void console.log(JSON.stringify(body, null, 2));
|
|
445
|
+
const { access } = body;
|
|
446
|
+
if (!access.enabled) {
|
|
447
|
+
console.error(access.override === false
|
|
448
|
+
? "collections are disabled for this account"
|
|
449
|
+
: `collections are locked: top up $${(access.threshold_cents / 100).toFixed(2)} in total to unlock ($${(access.remaining_cents / 100).toFixed(2)} to go)`);
|
|
450
|
+
}
|
|
451
|
+
if (access.enabled) {
|
|
452
|
+
const gb = (bytes) => `${+(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
453
|
+
const tier = access.tier && access.quota_source !== "account" ? ` · ${access.tier.id} tier` : "";
|
|
454
|
+
const next = access.quota_source !== "account" && access.next_tier ? ` · $${(access.next_tier.remaining_cents / 100).toFixed(2)} more for ${gb(access.next_tier.quota_bytes)}` : "";
|
|
455
|
+
console.error(`storage: ${gb(access.used_bytes)} of ${gb(access.quota_bytes)}${tier}${next}`);
|
|
456
|
+
}
|
|
457
|
+
for (const c of body.data) console.log(`${c.id} ${c.name} (${c.asset_count ?? 0} asset${c.asset_count === 1 ? "" : "s"})`);
|
|
458
|
+
if (!body.data.length) console.error("no collections in this project yet");
|
|
459
|
+
}
|
|
460
|
+
|
|
412
461
|
const HELP = `OttoPort — one command for every model.
|
|
413
462
|
|
|
414
463
|
Usage:
|
|
@@ -434,6 +483,8 @@ Usage:
|
|
|
434
483
|
ottoport webhook retry <job-id> [--json] redeliver a finished job's webhook now
|
|
435
484
|
ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--format mp3] [--out speech.mp3]
|
|
436
485
|
ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--format mp3] [--out song.mp3]
|
|
486
|
+
ottoport collections [list] [--json] collections in the key's project
|
|
487
|
+
ottoport collections create "<name>" [--description "..."] [--external-id <id>]
|
|
437
488
|
ottoport mcp
|
|
438
489
|
ottoport install <host> wire the MCP server (and skill) into an agent
|
|
439
490
|
hosts: codex, claude, hermes, cursor, windsurf, claude-desktop
|
|
@@ -443,6 +494,8 @@ Global flags:
|
|
|
443
494
|
--url <base> override https://ottoport.ai (local/self-hosted development)
|
|
444
495
|
--key <key> OttoPort API key (env OTTOPORT_API_KEY)
|
|
445
496
|
--webhook-secret <s> signs webhook deliveries (env OTTOPORT_WEBHOOK_SECRET)
|
|
497
|
+
--collection <id> on image, video, speech, music: keep the results in
|
|
498
|
+
that collection instead of letting them expire
|
|
446
499
|
|
|
447
500
|
Examples:
|
|
448
501
|
export OTTOPORT_API_KEY=op-...
|
|
@@ -459,6 +512,8 @@ Examples:
|
|
|
459
512
|
ottoport image --model seedream-5.0-layers --image https://…/poster.png
|
|
460
513
|
ottoport speech "Welcome to OttoPort" --voice alloy --out welcome.mp3
|
|
461
514
|
ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
|
|
515
|
+
ottoport collections create "Spring campaign"
|
|
516
|
+
ottoport image "spring hero banner" --collection <collection-id>
|
|
462
517
|
ottoport mcp
|
|
463
518
|
ottoport install codex
|
|
464
519
|
`;
|
|
@@ -477,6 +532,7 @@ async function main() {
|
|
|
477
532
|
case "webhook": return await cmdWebhook(rest[0], rest[1], flags);
|
|
478
533
|
case "speech": return await cmdAudio("speech", prompt, flags);
|
|
479
534
|
case "music": return await cmdAudio("music", prompt, flags);
|
|
535
|
+
case "collections": return await cmdCollections(rest[0], rest.slice(1).join(" "), flags);
|
|
480
536
|
case "mcp": return await import(new URL("../mcp/server.mjs", import.meta.url));
|
|
481
537
|
case "install": {
|
|
482
538
|
const { cmdInstall } = await import(new URL("./install.mjs", import.meta.url));
|
package/mcp/server.mjs
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
// transport directly (newline-delimited messages).
|
|
7
7
|
//
|
|
8
8
|
// Config via env:
|
|
9
|
-
// OTTOPORT_API_KEY
|
|
10
|
-
// OTTOPORT_BASE_URL
|
|
9
|
+
// OTTOPORT_API_KEY your op-… key (or the dev OTTOPORT_KEY_SECRET)
|
|
10
|
+
// OTTOPORT_BASE_URL gateway base URL (default http://localhost:3014)
|
|
11
|
+
// OTTOPORT_COLLECTION optional collection (id or name) every generation is saved into
|
|
11
12
|
//
|
|
12
13
|
// Register with Claude Code:
|
|
13
14
|
// claude mcp add ottoport -- node /abs/path/to/mcp/server.mjs
|
|
14
15
|
|
|
15
16
|
import { createInterface } from "node:readline";
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
+
import { readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { basename, extname, resolve as resolvePath } from "node:path";
|
|
17
19
|
|
|
18
20
|
const PROTOCOL_VERSION = "2024-11-05";
|
|
19
21
|
// Read from the package rather than repeated here: a version that says 0.1.0
|
|
@@ -43,6 +45,118 @@ async function gwError(res) {
|
|
|
43
45
|
return `${res.status} ${res.statusText}`;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// ── collections ──────────────────────────────────────────────────────
|
|
49
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
50
|
+
|
|
51
|
+
async function api(path, init = {}) {
|
|
52
|
+
const res = await fetch(`${BASE}${path}`, { ...init, headers: { ...headers(Boolean(init.body)), ...(init.headers ?? {}) } });
|
|
53
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
54
|
+
return res.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The collection id for what the agent (or OTTOPORT_COLLECTION) named.
|
|
59
|
+
*
|
|
60
|
+
* The gateway takes ids only, because names repeat and get renamed. People
|
|
61
|
+
* say names, though — "save it to Spring campaign" — so a name is looked up
|
|
62
|
+
* here: one match is used, several or none is an error that lists what
|
|
63
|
+
* exists, so the agent asks rather than guesses.
|
|
64
|
+
*/
|
|
65
|
+
async function collectionId(ref) {
|
|
66
|
+
const wanted = typeof ref === "string" && ref.trim() ? ref.trim() : process.env.OTTOPORT_COLLECTION?.trim();
|
|
67
|
+
if (!wanted) return undefined;
|
|
68
|
+
if (UUID.test(wanted)) return wanted;
|
|
69
|
+
const { data } = await api("/api/v1/collections?limit=200");
|
|
70
|
+
const matches = data.filter((c) => c.name.toLowerCase() === wanted.toLowerCase());
|
|
71
|
+
if (matches.length === 1) return matches[0].id;
|
|
72
|
+
const known = data.map((c) => `"${c.name}" (${c.id})`).join(", ") || "none yet";
|
|
73
|
+
if (matches.length > 1) throw new Error(`Several collections are named "${wanted}": ${matches.map((c) => c.id).join(", ")}. Pass the id.`);
|
|
74
|
+
throw new Error(`No collection named "${wanted}". Existing collections: ${known}. Create one with ottoport_create_collection.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One output line: the kept URL when it was saved, and what became of the save. */
|
|
78
|
+
function outputLine(item, label = "") {
|
|
79
|
+
const url = item.asset_url || item.url;
|
|
80
|
+
const saved = item.asset_id ? ` (saved as asset ${item.asset_id})` : item.asset_error ? ` (not saved: ${item.asset_error})` : "";
|
|
81
|
+
return `${label}${url}${saved}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const withCollection = (id) => (id ? { collection: id } : {});
|
|
85
|
+
|
|
86
|
+
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
87
|
+
const megabytes = (bytes) =>
|
|
88
|
+
bytes >= 1024 ** 4 ? `${+(bytes / 1024 ** 4).toFixed(2)} TB` : bytes >= 1024 ** 3 ? `${+(bytes / 1024 ** 3).toFixed(2)} GB` : `${+(bytes / 1_048_576).toFixed(1)} MB`;
|
|
89
|
+
|
|
90
|
+
async function listCollections() {
|
|
91
|
+
const { data, access } = await api("/api/v1/collections?limit=200");
|
|
92
|
+
const tier = access.tier && access.quota_source !== "account" ? ` (${access.tier.id} tier)` : "";
|
|
93
|
+
const upgrade = access.quota_source !== "account" && access.next_tier
|
|
94
|
+
? ` Topping up ${dollars(access.next_tier.remaining_cents)} more raises the allowance to ${megabytes(access.next_tier.quota_bytes)}.`
|
|
95
|
+
: "";
|
|
96
|
+
const state = access.enabled
|
|
97
|
+
? `Collections are on: ${megabytes(access.used_bytes)} of ${megabytes(access.quota_bytes)} used${tier}.${upgrade}`
|
|
98
|
+
: access.override === false
|
|
99
|
+
? "Collections are disabled for this account; nothing can be saved."
|
|
100
|
+
: `Collections are locked: they unlock once the account has topped up ${dollars(access.threshold_cents)} in total (${dollars(access.remaining_cents)} to go, at ${BASE}/dashboard). Existing collections can still be read.`;
|
|
101
|
+
const rows = data.map((c) => `${c.name} — id ${c.id} · ${c.asset_count ?? 0} asset${c.asset_count === 1 ? "" : "s"}${c.external_id ? ` · external_id ${c.external_id}` : ""}${c.description ? ` — ${c.description}` : ""}`);
|
|
102
|
+
return [state, ...(rows.length ? rows : ["No collections in this project yet."])].join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function createCollection({ name, description, external_id }) {
|
|
106
|
+
if (!name) throw new Error("`name` is required");
|
|
107
|
+
const c = await api("/api/v1/collections", { method: "POST", body: JSON.stringify({ name, ...(description ? { description } : {}), ...(external_id ? { external_id } : {}) }) });
|
|
108
|
+
return `Created collection "${c.name}" — id ${c.id}. Pass it as \`collection\` to any generate tool to save the results there.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function listAssets({ collection, kind, limit = 20 }) {
|
|
112
|
+
const id = await collectionId(collection);
|
|
113
|
+
const qs = new URLSearchParams({ limit: String(limit), ...(id ? { collection_id: id } : {}), ...(kind ? { kind } : {}) });
|
|
114
|
+
const { data, total } = await api(`/api/v1/assets?${qs}`);
|
|
115
|
+
if (!data.length) return "No saved assets.";
|
|
116
|
+
const lines = data.map((a) => `${a.kind} ${a.id}: ${a.url}${a.model ? ` · ${a.model}` : ""}${a.prompt ? ` — "${a.prompt.slice(0, 80)}${a.prompt.length > 80 ? "…" : ""}"` : ""}`);
|
|
117
|
+
return `${lines.join("\n")}${total > data.length ? `\n(${total - data.length} more)` : ""}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function saveAsset({ url, collection, prompt }) {
|
|
121
|
+
if (!url) throw new Error("`url` is required");
|
|
122
|
+
const id = await collectionId(collection);
|
|
123
|
+
if (!id) throw new Error("`collection` is required (an id or a name), or set OTTOPORT_COLLECTION");
|
|
124
|
+
const a = await api("/api/v1/assets", { method: "POST", body: JSON.stringify({ url, collection: id, ...(prompt ? { prompt } : {}) }) });
|
|
125
|
+
return `Saved as asset ${a.id}: ${a.url}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function deleteAsset({ asset_id }) {
|
|
129
|
+
if (!asset_id) throw new Error("`asset_id` is required");
|
|
130
|
+
await api(`/api/v1/assets/${encodeURIComponent(asset_id)}`, { method: "DELETE" });
|
|
131
|
+
return `Deleted asset ${asset_id}.`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const UPLOAD_TYPES = {
|
|
135
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
|
|
136
|
+
".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
|
|
137
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Put a local file where a model can read it. Agents hold paths, and every
|
|
142
|
+
* model input is a URL, so without this a reference image on disk is out of
|
|
143
|
+
* reach. The upload itself is temporary; `collection` also keeps it.
|
|
144
|
+
*/
|
|
145
|
+
async function uploadFile({ path, collection }) {
|
|
146
|
+
if (!path) throw new Error("`path` is required");
|
|
147
|
+
const file = resolvePath(path);
|
|
148
|
+
const type = UPLOAD_TYPES[extname(file).toLowerCase()];
|
|
149
|
+
if (!type) throw new Error(`Cannot upload ${basename(file)}: use PNG, JPEG, WebP, GIF, MP4, MOV, WebM, MP3, WAV, OGG, M4A or FLAC.`);
|
|
150
|
+
const { size } = statSync(file);
|
|
151
|
+
const signed = await api("/api/v1/uploads", { method: "POST", body: JSON.stringify({ content_type: type, size }) });
|
|
152
|
+
const put = await fetch(signed.upload_url, { method: "PUT", headers: signed.headers, body: readFileSync(file) });
|
|
153
|
+
if (!put.ok) throw new Error(`Upload failed (HTTP ${put.status})`);
|
|
154
|
+
const id = await collectionId(collection);
|
|
155
|
+
if (!id) return `Uploaded ${basename(file)}: ${signed.url}\nUse this URL as a model input until ${signed.expires_at}. Pass \`collection\` to keep it.`;
|
|
156
|
+
const a = await api("/api/v1/assets", { method: "POST", body: JSON.stringify({ url: signed.url, collection: id, metadata: { filename: basename(file) } }) });
|
|
157
|
+
return `Uploaded ${basename(file)} and saved it as asset ${a.id}: ${a.url}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
46
160
|
// ── tool implementations ─────────────────────────────────────────────
|
|
47
161
|
/**
|
|
48
162
|
* What a model takes, in one clause.
|
|
@@ -179,7 +293,7 @@ async function waitForJob(statusPath, job, { timeoutMs, label }) {
|
|
|
179
293
|
const IMAGE_JOB_PATH = "/api/v1/images/generations/{id}";
|
|
180
294
|
const VIDEO_JOB_PATH = "/api/v1/videos/generations/{id}";
|
|
181
295
|
|
|
182
|
-
async function generateImage({ prompt, model, ...rest }) {
|
|
296
|
+
async function generateImage({ prompt, model, collection, ...rest }) {
|
|
183
297
|
// A layer-decomposition model splits `image_url` and takes the prompt as an
|
|
184
298
|
// optional caption; every other image model needs the prompt. The gateway
|
|
185
299
|
// knows which is which and says so.
|
|
@@ -197,7 +311,7 @@ async function generateImage({ prompt, model, ...rest }) {
|
|
|
197
311
|
// Queued as a job and polled: a 4K or layered image can take longer than
|
|
198
312
|
// a proxy in front of the gateway holds a request open, and the hold is
|
|
199
313
|
// then settled on what actually came back.
|
|
200
|
-
body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, async: true }),
|
|
314
|
+
body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), async: true }),
|
|
201
315
|
});
|
|
202
316
|
if (!res.ok) throw new Error(await gwError(res));
|
|
203
317
|
let json = await res.json();
|
|
@@ -211,12 +325,12 @@ async function generateImage({ prompt, model, ...rest }) {
|
|
|
211
325
|
// A layer stack prints one line per layer with what the model called it, so
|
|
212
326
|
// the agent can pick "headline" out of eight URLs without opening them.
|
|
213
327
|
const lines = (json.data ?? []).filter((d) => d.url).map((d) =>
|
|
214
|
-
d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}:
|
|
328
|
+
outputLine(d, d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}: ` : ""),
|
|
215
329
|
);
|
|
216
330
|
return lines.length ? lines.join("\n") : "(no image returned)";
|
|
217
331
|
}
|
|
218
332
|
|
|
219
|
-
async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...rest }) {
|
|
333
|
+
async function generateVideo({ prompt, model, webhook_url, webhook_secret, collection, ...rest }) {
|
|
220
334
|
// A depth model takes only `video_url`; the gateway says which models need a prompt.
|
|
221
335
|
// A draft completion takes everything from its draft, the prompt included.
|
|
222
336
|
if (!prompt && !rest.video_url && !rest.draft_id) throw new Error("`prompt` is required");
|
|
@@ -228,7 +342,7 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...re
|
|
|
228
342
|
method: "POST",
|
|
229
343
|
headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
|
|
230
344
|
// Passed through; see `generateImage` for why this layer names nothing.
|
|
231
|
-
body: JSON.stringify({ model: model || "kling-3.0", ...(prompt ? { prompt } : {}), ...rest, ...(webhook_url ? { webhook_url, webhook_secret: secret } : {}) }),
|
|
345
|
+
body: JSON.stringify({ model: model || "kling-3.0", ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), ...(webhook_url ? { webhook_url, webhook_secret: secret } : {}) }),
|
|
232
346
|
});
|
|
233
347
|
if (!res.ok) throw new Error(await gwError(res));
|
|
234
348
|
const submitted = await res.json();
|
|
@@ -241,7 +355,8 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...re
|
|
|
241
355
|
// job rather than returning a queued-job JSON blob to the agent.
|
|
242
356
|
const { job, timedOut, path } = await waitForJob(VIDEO_JOB_PATH, submitted, { timeoutMs: 10 * 60_000, label: "video" });
|
|
243
357
|
if (timedOut) return `Video job ${job.id} is still processing. Poll ${path} for its result.`;
|
|
244
|
-
const
|
|
358
|
+
const output = job.data?.[0];
|
|
359
|
+
const url = output?.url ? outputLine(output) : undefined;
|
|
245
360
|
// The draft's job id is what completes it, so it is worth saying.
|
|
246
361
|
if (url && rest.draft) return `${url}\nDraft ${job.id} — complete it at 1080p with draft_id "${job.id}" within seven days.`;
|
|
247
362
|
return url || JSON.stringify(job);
|
|
@@ -259,7 +374,7 @@ async function videoWebhook({ job_id, action = "status" }) {
|
|
|
259
374
|
return JSON.stringify({ id: body.id, status: body.status, webhook: body.webhook ?? null }, null, 2);
|
|
260
375
|
}
|
|
261
376
|
|
|
262
|
-
async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
377
|
+
async function generateAudio(kind, { prompt, model, voice, duration, format, collection }) {
|
|
263
378
|
if (!prompt) throw new Error("`prompt` is required");
|
|
264
379
|
const res = await fetch(`${BASE}/api/v1/audio/${kind === "tts" ? "speech" : "music"}`, {
|
|
265
380
|
method: "POST",
|
|
@@ -270,13 +385,15 @@ async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
|
270
385
|
...(voice ? { voice } : {}),
|
|
271
386
|
...(duration ? { duration } : {}),
|
|
272
387
|
...(format ? { format } : {}),
|
|
388
|
+
...withCollection(await collectionId(collection)),
|
|
273
389
|
}),
|
|
274
390
|
});
|
|
275
391
|
if (!res.ok) throw new Error(await gwError(res));
|
|
276
392
|
const json = await res.json();
|
|
277
393
|
const audio = json.data?.[0];
|
|
278
394
|
if (!audio) return "(no audio returned)";
|
|
279
|
-
|
|
395
|
+
if (audio.url || audio.asset_url) return outputLine(audio);
|
|
396
|
+
return audio.b64_json ? `data:audio;base64,${audio.b64_json}` : "(no audio returned)";
|
|
280
397
|
}
|
|
281
398
|
|
|
282
399
|
// ── tool registry ────────────────────────────────────────────────────
|
|
@@ -334,6 +451,7 @@ const TOOLS = [
|
|
|
334
451
|
horizontal_angle: { type: "number", description: "Camera models only (qwen-image-angles): orbit in degrees, 0–360. Needs image_url; prompt optional." },
|
|
335
452
|
vertical_angle: { type: "number", description: "Camera models only: elevation in degrees, -30 (below) to 90 (overhead)." },
|
|
336
453
|
zoom: { type: "number", description: "Camera models only: 0 (wide) to 10 (close)." },
|
|
454
|
+
collection: { type: "string", description: "Save the result for good into this collection — its id or exact name (see ottoport_list_collections). Without it the file expires after a few days. Needs collections unlocked on the account. Defaults to OTTOPORT_COLLECTION when set." },
|
|
337
455
|
},
|
|
338
456
|
},
|
|
339
457
|
handler: generateImage,
|
|
@@ -366,6 +484,7 @@ const TOOLS = [
|
|
|
366
484
|
draft_id: { type: "string", description: "Seedance 2.5 only: the job id of a finished draft, to render that same shot at 1080p. Send `model` and this alone — prompt, inputs and duration come from the draft. Within seven days of the draft." },
|
|
367
485
|
webhook_url: { type: "string", description: "Public HTTPS endpoint to POST the finished job to (events video.completed / video.failed, signed with HMAC-SHA256). With it the tool returns the job id at once instead of waiting." },
|
|
368
486
|
webhook_secret: { type: "string", description: "Signing secret for webhook_url. Omit to use the server's OTTOPORT_WEBHOOK_SECRET." },
|
|
487
|
+
collection: { type: "string", description: "Save the result for good into this collection — its id or exact name (see ottoport_list_collections). Without it the file expires after a few days. Needs collections unlocked on the account. Defaults to OTTOPORT_COLLECTION when set." },
|
|
369
488
|
},
|
|
370
489
|
required: [],
|
|
371
490
|
},
|
|
@@ -394,6 +513,7 @@ const TOOLS = [
|
|
|
394
513
|
model: { type: "string", description: "TTS model id. Default gpt-4o-mini-tts." },
|
|
395
514
|
voice: { type: "string", description: "Optional provider voice id." },
|
|
396
515
|
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
516
|
+
collection: { type: "string", description: "Save the result for good into this collection — its id or exact name (see ottoport_list_collections). Without it the file expires after a few days. Needs collections unlocked on the account. Defaults to OTTOPORT_COLLECTION when set." },
|
|
397
517
|
},
|
|
398
518
|
required: ["prompt"],
|
|
399
519
|
},
|
|
@@ -409,11 +529,82 @@ const TOOLS = [
|
|
|
409
529
|
model: { type: "string", description: "Music model id. Default suno-v5." },
|
|
410
530
|
duration: { type: "number", description: "Requested duration in seconds." },
|
|
411
531
|
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
532
|
+
collection: { type: "string", description: "Save the result for good into this collection — its id or exact name (see ottoport_list_collections). Without it the file expires after a few days. Needs collections unlocked on the account. Defaults to OTTOPORT_COLLECTION when set." },
|
|
412
533
|
},
|
|
413
534
|
required: ["prompt"],
|
|
414
535
|
},
|
|
415
536
|
handler: (args) => generateAudio("music", args),
|
|
416
537
|
},
|
|
538
|
+
{
|
|
539
|
+
name: "ottoport_list_collections",
|
|
540
|
+
description: "Collections in this API key's project — where generated files are kept for good instead of expiring after a few days — with each one's id and asset count, and whether this account may save (collections unlock once the account has topped up $10 in total), and its storage allowance, which grows with what the account has paid in. Read it before saving, to find the collection the user means.",
|
|
541
|
+
inputSchema: { type: "object", properties: {} },
|
|
542
|
+
handler: listCollections,
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: "ottoport_create_collection",
|
|
546
|
+
description: "Create a collection: the user's own project, campaign or character, whose generations should be kept together. Returns its id, to pass as `collection` to the generate tools. Needs collections unlocked.",
|
|
547
|
+
inputSchema: {
|
|
548
|
+
type: "object",
|
|
549
|
+
properties: {
|
|
550
|
+
name: { type: "string", description: "Up to 80 characters." },
|
|
551
|
+
description: { type: "string" },
|
|
552
|
+
external_id: { type: "string", description: "The caller's own id for what this collection holds, unique in the project." },
|
|
553
|
+
},
|
|
554
|
+
required: ["name"],
|
|
555
|
+
},
|
|
556
|
+
handler: createCollection,
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
name: "ottoport_list_assets",
|
|
560
|
+
description: "Saved assets, newest first: each one's id, kind, permanent URL, model and prompt. Filter to one collection (id or name) and one kind.",
|
|
561
|
+
inputSchema: {
|
|
562
|
+
type: "object",
|
|
563
|
+
properties: {
|
|
564
|
+
collection: { type: "string", description: "Collection id or exact name. Omit for every collection in the project." },
|
|
565
|
+
kind: { type: "string", enum: ["image", "video", "audio"] },
|
|
566
|
+
limit: { type: "number", description: "Default 20, at most 200." },
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
handler: listAssets,
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: "ottoport_save_asset",
|
|
573
|
+
description: "Keep a file that was already generated or uploaded — its OttoPort URL, before it expires — in a collection. Use when the user picks a result after the fact; to save while generating, pass `collection` to the generate tool instead.",
|
|
574
|
+
inputSchema: {
|
|
575
|
+
type: "object",
|
|
576
|
+
properties: {
|
|
577
|
+
url: { type: "string", description: "The OttoPort URL a generate tool or ottoport_upload_file returned." },
|
|
578
|
+
collection: { type: "string", description: "Collection id or exact name." },
|
|
579
|
+
prompt: { type: "string", description: "The prompt that made it, to keep alongside." },
|
|
580
|
+
},
|
|
581
|
+
required: ["url"],
|
|
582
|
+
},
|
|
583
|
+
handler: saveAsset,
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
name: "ottoport_upload_file",
|
|
587
|
+
description: "Upload a local image, video or audio file and get a URL any model accepts as input (a reference image, a first frame, a motion clip). The URL lasts a day; pass `collection` to also keep the file for good.",
|
|
588
|
+
inputSchema: {
|
|
589
|
+
type: "object",
|
|
590
|
+
properties: {
|
|
591
|
+
path: { type: "string", description: "Path of the file on this machine." },
|
|
592
|
+
collection: { type: "string", description: "Collection id or exact name to save it into." },
|
|
593
|
+
},
|
|
594
|
+
required: ["path"],
|
|
595
|
+
},
|
|
596
|
+
handler: uploadFile,
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: "ottoport_delete_asset",
|
|
600
|
+
description: "Delete a saved asset and its file for good. Ask the user first.",
|
|
601
|
+
inputSchema: {
|
|
602
|
+
type: "object",
|
|
603
|
+
properties: { asset_id: { type: "string" } },
|
|
604
|
+
required: ["asset_id"],
|
|
605
|
+
},
|
|
606
|
+
handler: deleteAsset,
|
|
607
|
+
},
|
|
417
608
|
];
|
|
418
609
|
|
|
419
610
|
const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ottoport",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Claude Code plugin, CLI and MCP server for OttoPort — one OpenAI-compatible API for every LLM, image, video, and speech model.",
|
|
5
5
|
"homepage": "https://ottoport.ai",
|
|
6
6
|
"repository": {
|
package/skills/ottoport/SKILL.md
CHANGED
|
@@ -21,6 +21,15 @@ and do not call the HTTP API, when a tool covers the job:
|
|
|
21
21
|
| `ottoport_video_webhook` | `job_id`, plus `action` `status` (every delivery attempt and the endpoint's response) or `retry` (redeliver a finished job's webhook now). |
|
|
22
22
|
| `ottoport_generate_speech` | `prompt` (the text), plus optional `model`, `voice`, `format`. |
|
|
23
23
|
| `ottoport_generate_music` | `prompt`, plus optional `model`, `duration`, `format`. |
|
|
24
|
+
| `ottoport_list_collections` | The project's collections (ids, asset counts) and whether this account may save. Read it to find the collection the user means. |
|
|
25
|
+
| `ottoport_create_collection` | `name`, plus optional `description`, `external_id`. |
|
|
26
|
+
| `ottoport_list_assets` | Saved files, optionally by `collection` and `kind`. |
|
|
27
|
+
| `ottoport_save_asset` | `url` (an OttoPort result or upload, before it expires) and `collection` — for keeping a result the user picks after the fact. |
|
|
28
|
+
| `ottoport_upload_file` | `path` of a local file → a URL any model takes as input; with `collection` it is kept too. |
|
|
29
|
+
| `ottoport_delete_asset` | `asset_id`. Permanent — confirm with the user first. |
|
|
30
|
+
|
|
31
|
+
Every generate tool also takes `collection` (an id or exact name) to keep its
|
|
32
|
+
results; the server's `OTTOPORT_COLLECTION` sets a default.
|
|
24
33
|
|
|
25
34
|
If those tools are not present, the server is not registered in this client —
|
|
26
35
|
say so rather than reaching for a shell. `ottoport install <host>` registers it
|
|
@@ -90,8 +99,12 @@ as given rather than converting yourself.
|
|
|
90
99
|
|
|
91
100
|
## What comes back
|
|
92
101
|
|
|
93
|
-
- Image, video and audio tools return **URLs**, not files.
|
|
94
|
-
|
|
102
|
+
- Image, video and audio tools return **URLs**, not files. They expire after a
|
|
103
|
+
few days. To keep one, pass `collection` (or save it with
|
|
104
|
+
`ottoport_save_asset` while it lasts) — the returned URL is then permanent.
|
|
105
|
+
Collections unlock once the account has topped up $10 in total; when
|
|
106
|
+
`ottoport_list_collections` says they are locked, tell the user that rather
|
|
107
|
+
than retrying.
|
|
95
108
|
- Video blocks until the job is terminal, often a minute or more. Say so before
|
|
96
109
|
starting, and never retry a slow call — a retry is a second generation, billed
|
|
97
110
|
again.
|