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.
@@ -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.11.0",
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})` : ""}: ${d.url}` : d.url));
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(url);
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.url);
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 your op-… key (or the dev OTTOPORT_KEY_SECRET)
10
- // OTTOPORT_BASE_URL gateway base URL (default http://localhost:3014)
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})` : ""}: ${d.url}` : d.url,
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 url = job.data?.[0]?.url;
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
- return audio.url || (audio.b64_json ? `data:audio;base64,${audio.b64_json}` : "(no audio returned)");
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.11.0",
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": {
@@ -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. Provider URLs expire;
94
- download anything worth keeping.
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.