ottoport 1.11.0 → 1.12.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 +53 -3
- package/mcp/server.mjs +197 -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.12.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,45 @@ 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
|
+
for (const c of body.data) console.log(`${c.id} ${c.name} (${c.asset_count ?? 0} asset${c.asset_count === 1 ? "" : "s"})`);
|
|
452
|
+
if (!body.data.length) console.error("no collections in this project yet");
|
|
453
|
+
}
|
|
454
|
+
|
|
412
455
|
const HELP = `OttoPort — one command for every model.
|
|
413
456
|
|
|
414
457
|
Usage:
|
|
@@ -434,6 +477,8 @@ Usage:
|
|
|
434
477
|
ottoport webhook retry <job-id> [--json] redeliver a finished job's webhook now
|
|
435
478
|
ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--format mp3] [--out speech.mp3]
|
|
436
479
|
ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--format mp3] [--out song.mp3]
|
|
480
|
+
ottoport collections [list] [--json] collections in the key's project
|
|
481
|
+
ottoport collections create "<name>" [--description "..."] [--external-id <id>]
|
|
437
482
|
ottoport mcp
|
|
438
483
|
ottoport install <host> wire the MCP server (and skill) into an agent
|
|
439
484
|
hosts: codex, claude, hermes, cursor, windsurf, claude-desktop
|
|
@@ -443,6 +488,8 @@ Global flags:
|
|
|
443
488
|
--url <base> override https://ottoport.ai (local/self-hosted development)
|
|
444
489
|
--key <key> OttoPort API key (env OTTOPORT_API_KEY)
|
|
445
490
|
--webhook-secret <s> signs webhook deliveries (env OTTOPORT_WEBHOOK_SECRET)
|
|
491
|
+
--collection <id> on image, video, speech, music: keep the results in
|
|
492
|
+
that collection instead of letting them expire
|
|
446
493
|
|
|
447
494
|
Examples:
|
|
448
495
|
export OTTOPORT_API_KEY=op-...
|
|
@@ -459,6 +506,8 @@ Examples:
|
|
|
459
506
|
ottoport image --model seedream-5.0-layers --image https://…/poster.png
|
|
460
507
|
ottoport speech "Welcome to OttoPort" --voice alloy --out welcome.mp3
|
|
461
508
|
ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
|
|
509
|
+
ottoport collections create "Spring campaign"
|
|
510
|
+
ottoport image "spring hero banner" --collection <collection-id>
|
|
462
511
|
ottoport mcp
|
|
463
512
|
ottoport install codex
|
|
464
513
|
`;
|
|
@@ -477,6 +526,7 @@ async function main() {
|
|
|
477
526
|
case "webhook": return await cmdWebhook(rest[0], rest[1], flags);
|
|
478
527
|
case "speech": return await cmdAudio("speech", prompt, flags);
|
|
479
528
|
case "music": return await cmdAudio("music", prompt, flags);
|
|
529
|
+
case "collections": return await cmdCollections(rest[0], rest.slice(1).join(" "), flags);
|
|
480
530
|
case "mcp": return await import(new URL("../mcp/server.mjs", import.meta.url));
|
|
481
531
|
case "install": {
|
|
482
532
|
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,113 @@ 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) => `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
88
|
+
|
|
89
|
+
async function listCollections() {
|
|
90
|
+
const { data, access } = await api("/api/v1/collections?limit=200");
|
|
91
|
+
const state = access.enabled
|
|
92
|
+
? `Collections are on: ${megabytes(access.used_bytes)} of ${megabytes(access.quota_bytes)} used.`
|
|
93
|
+
: access.override === false
|
|
94
|
+
? "Collections are disabled for this account; nothing can be saved."
|
|
95
|
+
: `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.`;
|
|
96
|
+
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}` : ""}`);
|
|
97
|
+
return [state, ...(rows.length ? rows : ["No collections in this project yet."])].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function createCollection({ name, description, external_id }) {
|
|
101
|
+
if (!name) throw new Error("`name` is required");
|
|
102
|
+
const c = await api("/api/v1/collections", { method: "POST", body: JSON.stringify({ name, ...(description ? { description } : {}), ...(external_id ? { external_id } : {}) }) });
|
|
103
|
+
return `Created collection "${c.name}" — id ${c.id}. Pass it as \`collection\` to any generate tool to save the results there.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function listAssets({ collection, kind, limit = 20 }) {
|
|
107
|
+
const id = await collectionId(collection);
|
|
108
|
+
const qs = new URLSearchParams({ limit: String(limit), ...(id ? { collection_id: id } : {}), ...(kind ? { kind } : {}) });
|
|
109
|
+
const { data, total } = await api(`/api/v1/assets?${qs}`);
|
|
110
|
+
if (!data.length) return "No saved assets.";
|
|
111
|
+
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 ? "…" : ""}"` : ""}`);
|
|
112
|
+
return `${lines.join("\n")}${total > data.length ? `\n(${total - data.length} more)` : ""}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function saveAsset({ url, collection, prompt }) {
|
|
116
|
+
if (!url) throw new Error("`url` is required");
|
|
117
|
+
const id = await collectionId(collection);
|
|
118
|
+
if (!id) throw new Error("`collection` is required (an id or a name), or set OTTOPORT_COLLECTION");
|
|
119
|
+
const a = await api("/api/v1/assets", { method: "POST", body: JSON.stringify({ url, collection: id, ...(prompt ? { prompt } : {}) }) });
|
|
120
|
+
return `Saved as asset ${a.id}: ${a.url}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function deleteAsset({ asset_id }) {
|
|
124
|
+
if (!asset_id) throw new Error("`asset_id` is required");
|
|
125
|
+
await api(`/api/v1/assets/${encodeURIComponent(asset_id)}`, { method: "DELETE" });
|
|
126
|
+
return `Deleted asset ${asset_id}.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const UPLOAD_TYPES = {
|
|
130
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
|
|
131
|
+
".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
|
|
132
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Put a local file where a model can read it. Agents hold paths, and every
|
|
137
|
+
* model input is a URL, so without this a reference image on disk is out of
|
|
138
|
+
* reach. The upload itself is temporary; `collection` also keeps it.
|
|
139
|
+
*/
|
|
140
|
+
async function uploadFile({ path, collection }) {
|
|
141
|
+
if (!path) throw new Error("`path` is required");
|
|
142
|
+
const file = resolvePath(path);
|
|
143
|
+
const type = UPLOAD_TYPES[extname(file).toLowerCase()];
|
|
144
|
+
if (!type) throw new Error(`Cannot upload ${basename(file)}: use PNG, JPEG, WebP, GIF, MP4, MOV, WebM, MP3, WAV, OGG, M4A or FLAC.`);
|
|
145
|
+
const { size } = statSync(file);
|
|
146
|
+
const signed = await api("/api/v1/uploads", { method: "POST", body: JSON.stringify({ content_type: type, size }) });
|
|
147
|
+
const put = await fetch(signed.upload_url, { method: "PUT", headers: signed.headers, body: readFileSync(file) });
|
|
148
|
+
if (!put.ok) throw new Error(`Upload failed (HTTP ${put.status})`);
|
|
149
|
+
const id = await collectionId(collection);
|
|
150
|
+
if (!id) return `Uploaded ${basename(file)}: ${signed.url}\nUse this URL as a model input until ${signed.expires_at}. Pass \`collection\` to keep it.`;
|
|
151
|
+
const a = await api("/api/v1/assets", { method: "POST", body: JSON.stringify({ url: signed.url, collection: id, metadata: { filename: basename(file) } }) });
|
|
152
|
+
return `Uploaded ${basename(file)} and saved it as asset ${a.id}: ${a.url}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
46
155
|
// ── tool implementations ─────────────────────────────────────────────
|
|
47
156
|
/**
|
|
48
157
|
* What a model takes, in one clause.
|
|
@@ -179,7 +288,7 @@ async function waitForJob(statusPath, job, { timeoutMs, label }) {
|
|
|
179
288
|
const IMAGE_JOB_PATH = "/api/v1/images/generations/{id}";
|
|
180
289
|
const VIDEO_JOB_PATH = "/api/v1/videos/generations/{id}";
|
|
181
290
|
|
|
182
|
-
async function generateImage({ prompt, model, ...rest }) {
|
|
291
|
+
async function generateImage({ prompt, model, collection, ...rest }) {
|
|
183
292
|
// A layer-decomposition model splits `image_url` and takes the prompt as an
|
|
184
293
|
// optional caption; every other image model needs the prompt. The gateway
|
|
185
294
|
// knows which is which and says so.
|
|
@@ -197,7 +306,7 @@ async function generateImage({ prompt, model, ...rest }) {
|
|
|
197
306
|
// Queued as a job and polled: a 4K or layered image can take longer than
|
|
198
307
|
// a proxy in front of the gateway holds a request open, and the hold is
|
|
199
308
|
// then settled on what actually came back.
|
|
200
|
-
body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, async: true }),
|
|
309
|
+
body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), async: true }),
|
|
201
310
|
});
|
|
202
311
|
if (!res.ok) throw new Error(await gwError(res));
|
|
203
312
|
let json = await res.json();
|
|
@@ -211,12 +320,12 @@ async function generateImage({ prompt, model, ...rest }) {
|
|
|
211
320
|
// A layer stack prints one line per layer with what the model called it, so
|
|
212
321
|
// the agent can pick "headline" out of eight URLs without opening them.
|
|
213
322
|
const lines = (json.data ?? []).filter((d) => d.url).map((d) =>
|
|
214
|
-
d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}:
|
|
323
|
+
outputLine(d, d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}: ` : ""),
|
|
215
324
|
);
|
|
216
325
|
return lines.length ? lines.join("\n") : "(no image returned)";
|
|
217
326
|
}
|
|
218
327
|
|
|
219
|
-
async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...rest }) {
|
|
328
|
+
async function generateVideo({ prompt, model, webhook_url, webhook_secret, collection, ...rest }) {
|
|
220
329
|
// A depth model takes only `video_url`; the gateway says which models need a prompt.
|
|
221
330
|
// A draft completion takes everything from its draft, the prompt included.
|
|
222
331
|
if (!prompt && !rest.video_url && !rest.draft_id) throw new Error("`prompt` is required");
|
|
@@ -228,7 +337,7 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...re
|
|
|
228
337
|
method: "POST",
|
|
229
338
|
headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
|
|
230
339
|
// 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 } : {}) }),
|
|
340
|
+
body: JSON.stringify({ model: model || "kling-3.0", ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), ...(webhook_url ? { webhook_url, webhook_secret: secret } : {}) }),
|
|
232
341
|
});
|
|
233
342
|
if (!res.ok) throw new Error(await gwError(res));
|
|
234
343
|
const submitted = await res.json();
|
|
@@ -241,7 +350,8 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...re
|
|
|
241
350
|
// job rather than returning a queued-job JSON blob to the agent.
|
|
242
351
|
const { job, timedOut, path } = await waitForJob(VIDEO_JOB_PATH, submitted, { timeoutMs: 10 * 60_000, label: "video" });
|
|
243
352
|
if (timedOut) return `Video job ${job.id} is still processing. Poll ${path} for its result.`;
|
|
244
|
-
const
|
|
353
|
+
const output = job.data?.[0];
|
|
354
|
+
const url = output?.url ? outputLine(output) : undefined;
|
|
245
355
|
// The draft's job id is what completes it, so it is worth saying.
|
|
246
356
|
if (url && rest.draft) return `${url}\nDraft ${job.id} — complete it at 1080p with draft_id "${job.id}" within seven days.`;
|
|
247
357
|
return url || JSON.stringify(job);
|
|
@@ -259,7 +369,7 @@ async function videoWebhook({ job_id, action = "status" }) {
|
|
|
259
369
|
return JSON.stringify({ id: body.id, status: body.status, webhook: body.webhook ?? null }, null, 2);
|
|
260
370
|
}
|
|
261
371
|
|
|
262
|
-
async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
372
|
+
async function generateAudio(kind, { prompt, model, voice, duration, format, collection }) {
|
|
263
373
|
if (!prompt) throw new Error("`prompt` is required");
|
|
264
374
|
const res = await fetch(`${BASE}/api/v1/audio/${kind === "tts" ? "speech" : "music"}`, {
|
|
265
375
|
method: "POST",
|
|
@@ -270,13 +380,15 @@ async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
|
270
380
|
...(voice ? { voice } : {}),
|
|
271
381
|
...(duration ? { duration } : {}),
|
|
272
382
|
...(format ? { format } : {}),
|
|
383
|
+
...withCollection(await collectionId(collection)),
|
|
273
384
|
}),
|
|
274
385
|
});
|
|
275
386
|
if (!res.ok) throw new Error(await gwError(res));
|
|
276
387
|
const json = await res.json();
|
|
277
388
|
const audio = json.data?.[0];
|
|
278
389
|
if (!audio) return "(no audio returned)";
|
|
279
|
-
|
|
390
|
+
if (audio.url || audio.asset_url) return outputLine(audio);
|
|
391
|
+
return audio.b64_json ? `data:audio;base64,${audio.b64_json}` : "(no audio returned)";
|
|
280
392
|
}
|
|
281
393
|
|
|
282
394
|
// ── tool registry ────────────────────────────────────────────────────
|
|
@@ -334,6 +446,7 @@ const TOOLS = [
|
|
|
334
446
|
horizontal_angle: { type: "number", description: "Camera models only (qwen-image-angles): orbit in degrees, 0–360. Needs image_url; prompt optional." },
|
|
335
447
|
vertical_angle: { type: "number", description: "Camera models only: elevation in degrees, -30 (below) to 90 (overhead)." },
|
|
336
448
|
zoom: { type: "number", description: "Camera models only: 0 (wide) to 10 (close)." },
|
|
449
|
+
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
450
|
},
|
|
338
451
|
},
|
|
339
452
|
handler: generateImage,
|
|
@@ -366,6 +479,7 @@ const TOOLS = [
|
|
|
366
479
|
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
480
|
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
481
|
webhook_secret: { type: "string", description: "Signing secret for webhook_url. Omit to use the server's OTTOPORT_WEBHOOK_SECRET." },
|
|
482
|
+
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
483
|
},
|
|
370
484
|
required: [],
|
|
371
485
|
},
|
|
@@ -394,6 +508,7 @@ const TOOLS = [
|
|
|
394
508
|
model: { type: "string", description: "TTS model id. Default gpt-4o-mini-tts." },
|
|
395
509
|
voice: { type: "string", description: "Optional provider voice id." },
|
|
396
510
|
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
511
|
+
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
512
|
},
|
|
398
513
|
required: ["prompt"],
|
|
399
514
|
},
|
|
@@ -409,11 +524,82 @@ const TOOLS = [
|
|
|
409
524
|
model: { type: "string", description: "Music model id. Default suno-v5." },
|
|
410
525
|
duration: { type: "number", description: "Requested duration in seconds." },
|
|
411
526
|
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
527
|
+
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
528
|
},
|
|
413
529
|
required: ["prompt"],
|
|
414
530
|
},
|
|
415
531
|
handler: (args) => generateAudio("music", args),
|
|
416
532
|
},
|
|
533
|
+
{
|
|
534
|
+
name: "ottoport_list_collections",
|
|
535
|
+
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). Read it before saving, to find the collection the user means.",
|
|
536
|
+
inputSchema: { type: "object", properties: {} },
|
|
537
|
+
handler: listCollections,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: "ottoport_create_collection",
|
|
541
|
+
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.",
|
|
542
|
+
inputSchema: {
|
|
543
|
+
type: "object",
|
|
544
|
+
properties: {
|
|
545
|
+
name: { type: "string", description: "Up to 80 characters." },
|
|
546
|
+
description: { type: "string" },
|
|
547
|
+
external_id: { type: "string", description: "The caller's own id for what this collection holds, unique in the project." },
|
|
548
|
+
},
|
|
549
|
+
required: ["name"],
|
|
550
|
+
},
|
|
551
|
+
handler: createCollection,
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
name: "ottoport_list_assets",
|
|
555
|
+
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.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
type: "object",
|
|
558
|
+
properties: {
|
|
559
|
+
collection: { type: "string", description: "Collection id or exact name. Omit for every collection in the project." },
|
|
560
|
+
kind: { type: "string", enum: ["image", "video", "audio"] },
|
|
561
|
+
limit: { type: "number", description: "Default 20, at most 200." },
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
handler: listAssets,
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "ottoport_save_asset",
|
|
568
|
+
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.",
|
|
569
|
+
inputSchema: {
|
|
570
|
+
type: "object",
|
|
571
|
+
properties: {
|
|
572
|
+
url: { type: "string", description: "The OttoPort URL a generate tool or ottoport_upload_file returned." },
|
|
573
|
+
collection: { type: "string", description: "Collection id or exact name." },
|
|
574
|
+
prompt: { type: "string", description: "The prompt that made it, to keep alongside." },
|
|
575
|
+
},
|
|
576
|
+
required: ["url"],
|
|
577
|
+
},
|
|
578
|
+
handler: saveAsset,
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: "ottoport_upload_file",
|
|
582
|
+
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.",
|
|
583
|
+
inputSchema: {
|
|
584
|
+
type: "object",
|
|
585
|
+
properties: {
|
|
586
|
+
path: { type: "string", description: "Path of the file on this machine." },
|
|
587
|
+
collection: { type: "string", description: "Collection id or exact name to save it into." },
|
|
588
|
+
},
|
|
589
|
+
required: ["path"],
|
|
590
|
+
},
|
|
591
|
+
handler: uploadFile,
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "ottoport_delete_asset",
|
|
595
|
+
description: "Delete a saved asset and its file for good. Ask the user first.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
type: "object",
|
|
598
|
+
properties: { asset_id: { type: "string" } },
|
|
599
|
+
required: ["asset_id"],
|
|
600
|
+
},
|
|
601
|
+
handler: deleteAsset,
|
|
602
|
+
},
|
|
417
603
|
];
|
|
418
604
|
|
|
419
605
|
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.12.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.
|