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.
@@ -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.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})` : ""}: ${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,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 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,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})` : ""}: ${d.url}` : d.url,
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 url = job.data?.[0]?.url;
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
- return audio.url || (audio.b64_json ? `data:audio;base64,${audio.b64_json}` : "(no audio returned)");
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.11.0",
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": {
@@ -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.