ottoport 1.13.2 → 1.15.1

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
- "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.13.2",
3
+ "description": "One API for every model. Call chat, image, video, speech and music models through the OttoPort gateway \u2014 as MCP tools, slash commands, or the bundled CLI.",
4
+ "version": "1.15.1",
5
5
  "author": {
6
6
  "name": "LITBOX LLC",
7
7
  "email": "support@ottoport.ai"
package/README.md CHANGED
@@ -30,6 +30,7 @@ ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p
30
30
  ottoport webhook status|retry <job-id> # delivery attempts / redeliver now
31
31
  ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--out speech.mp3]
32
32
  ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--out song.mp3]
33
+ ottoport music --model mirelo-sfx-2.0 --video <url> [--duration 8] # soundtrack for a video
33
34
 
34
35
  ottoport install <host> # wire the MCP server into an agent
35
36
  ottoport mcp # run the MCP server on stdio
@@ -94,16 +95,29 @@ the character performs the clip's movement). Images take one reference (`image_u
94
95
  (`reference_image_urls`). Both take a `resolution`: `480p`…`4k` for video,
95
96
  `1k`/`2k`/`4k` for images, and a tier above a model's base rate costs more.
96
97
 
97
- The GPT Image family and `ideogram-4.5` read a `quality` on top of that — `low`, `medium` (the
98
- default every catalog rate is measured at), `high`, and on `gpt-image-2.5` and
99
- `gpt-image-2.5-flare` also `xhigh` and `max`. It is how much compute the model
100
- spends, and the ladder is steep: `max` bills sixteen times `medium`.
98
+ The GPT Image family, `ideogram-4.5` and `ideogram-vector-1` read a `quality` on top of that — `low`, `medium` (the
99
+ default every catalog rate is measured at), `high`, and on `gpt-image-2.6`,
100
+ `gpt-image-2.6-flare`, `gpt-image-2.5` and `gpt-image-2.5-flare` also `xhigh`
101
+ and `max`. It is how much compute the model spends, and the ladder is steep:
102
+ `max` bills sixteen times `medium` on 2.5 and about five times on 2.6.
101
103
 
102
104
  Two image models take a picture apart instead: `qwen-image-layered` and
103
105
  `seedream-5.0-layers` split `image_url` into RGBA layers and return one URL per
104
106
  layer, bottom first (the prompt is an optional caption; `num_layers`, 2–10, is
105
107
  honoured by Qwen only). Each layer returned bills separately.
106
108
 
109
+ Two return SVG: `ideogram-vector-1` draws editable vectors from a prompt
110
+ (shaped by `aspect_ratio`, not `size` or `resolution`), and
111
+ `ideogram-vector-1-vectorize` traces `image_url` into one, with no prompt
112
+ (`vector_mode`: `default` or `simple`).
113
+
114
+ `gpt-image-2.6` and `gpt-image-2.6-flare` can also return a file other than a
115
+ raster, from a prompt alone: `output_format: "gif"` is an animation (`frame_count`
116
+ 2–16, default 8; above 8 bills double; `frame_duration_ms` 20–2000), and
117
+ `output_format: "svg"` is a vector file at a small surcharge. One file per
118
+ request. A GIF picks its own frame size, so `size`, `resolution` and
119
+ `aspect_ratio` do not apply to it.
120
+
107
121
  Which modes and tiers a model accepts differs per model, so
108
122
  `ottoport_list_models` — and `ottoport models` in the CLI — prints each one's
109
123
  menu beside its price. For one model in full, including every parameter it
@@ -149,3 +163,20 @@ https://ottoport.ai/docs#webhooks.
149
163
  Docs: <https://ottoport.ai/docs> · Support: support@ottoport.ai
150
164
 
151
165
  MIT © LITBOX LLC
166
+
167
+
168
+ ## Asynchronous media jobs (1.15.1)
169
+
170
+ Image, video, speech and music generation return an accepted job immediately by default. Save its `job_id`; do not resubmit to check progress. Chat streaming is unchanged.
171
+
172
+ ```sh
173
+ ottoport image "a mountain lake" --model midjourney-v8.2
174
+ ottoport job image <job-id>
175
+ ottoport job image <job-id> --wait --out lake.png
176
+ # Explicit waiting is also available on submission:
177
+ ottoport image "a mountain lake" --wait --out lake.png
178
+ ```
179
+
180
+ Use job kinds `image`, `video`, `speech`, or `music`. `--out` downloads only when a result is available; otherwise query the returned job with `--wait --out`. Pending and failed job responses retain the task ID and status route.
181
+
182
+ MCP generation tools return `job_id`, `status`, `status_path`, and the next tool call. Query with `ottoport_get_job({kind, job_id})`. Optional `wait: true` waits up to 45 seconds per MCP call, returning the same job if still pending. Querying a job does not submit another generation. Existing scripts that expected immediate URLs should add `--wait`, or adopt job polling.
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 music --model mirelo-sfx-2.0 --video <url> [--duration 8]
13
14
  // ottoport collections [list | create "name"]
14
15
  // ottoport mcp
15
16
  // ottoport install <codex|claude|hermes|cursor|windsurf|claude-desktop>
@@ -25,7 +26,7 @@ const BASE = "https://ottoport.ai";
25
26
 
26
27
  // ── arg parsing ──────────────────────────────────────────────────────
27
28
  // Switches that never take a value, so `--draft "a boat"` keeps its prompt.
28
- const BOOLEAN_FLAGS = new Set(["draft"]);
29
+ const BOOLEAN_FLAGS = new Set(["draft", "wait"]);
29
30
 
30
31
  function parseArgs(argv) {
31
32
  const positional = [];
@@ -266,17 +267,50 @@ function references(flags, name = "ref") {
266
267
  return list.length ? list : undefined;
267
268
  }
268
269
 
270
+ const JOB_PATHS = { image: "images/generations", video: "videos/generations", speech: "audio/speech", music: "audio/music" };
271
+ async function resolveJob(kind, job, flags) {
272
+ if (!job.id || !job.status) return job;
273
+ const { baseUrl, apiKey } = config(flags);
274
+ // Always expose the ID before a caller opts into waiting.
275
+ if (flags.wait === true) console.error(`job ${job.id} accepted`);
276
+ while (flags.wait === true && ["queued", "processing"].includes(job.status)) {
277
+ await new Promise((resolve) => setTimeout(resolve, 3000));
278
+ const res = await fetch(`${baseUrl}/api/v1/${JOB_PATHS[kind]}/${encodeURIComponent(job.id)}`, { headers: headers(apiKey, false) });
279
+ if (!res.ok) die(await readError(res));
280
+ job = await res.json();
281
+ }
282
+ return job;
283
+ }
284
+ function printJob(kind, job) {
285
+ console.log(JSON.stringify({ ...job, job_id: job.id, kind, status_path: `/api/v1/${JOB_PATHS[kind]}/${encodeURIComponent(job.id)}`, next: `ottoport job ${kind} ${job.id}` }, null, 2));
286
+ }
287
+ async function cmdJob(kind, id, flags) {
288
+ if (!Object.hasOwn(JOB_PATHS, kind) || !id) die("usage: ottoport job image|video|speech|music <id> [--wait] [--out file]");
289
+ const { baseUrl, apiKey } = config(flags);
290
+ const res = await fetch(`${baseUrl}/api/v1/${JOB_PATHS[kind]}/${encodeURIComponent(id)}`, { headers: headers(apiKey, false) });
291
+ if (!res.ok) die(await readError(res));
292
+ const job = await resolveJob(kind, await res.json(), flags);
293
+ printJob(kind, job);
294
+ if (job.status === "failed") die(job.error || "generation failed", 2);
295
+ const item = job.data?.[0];
296
+ if (flags.out && (item?.asset_url || item?.url)) await download(item.asset_url || item.url, last(flags.out));
297
+ }
298
+
269
299
  async function cmdImage(prompt, flags) {
270
300
  // A layer-decomposition model needs only --image; the prompt is a caption.
271
301
  if (!prompt && !last(flags.image)) die("image requires a prompt: ottoport image \"a red fox\" (or --image <url> on a layer model)");
272
302
  const { baseUrl, apiKey } = config(flags);
273
303
  const body = {
304
+ async: true,
274
305
  model: last(flags.model) || "gpt-image-2",
275
306
  ...(prompt ? { prompt } : {}),
276
307
  ...(last(flags.layers) ? { num_layers: Number(last(flags.layers)) } : {}),
277
308
  ...(last(flags.size) ? { size: last(flags.size) } : {}),
278
309
  ...(last(flags.resolution) ? { resolution: last(flags.resolution) } : {}),
279
310
  ...(last(flags.quality) ? { quality: last(flags.quality) } : {}),
311
+ ...(last(flags.format) ? { output_format: last(flags.format) } : {}),
312
+ ...(last(flags.frames) ? { frame_count: Number(last(flags.frames)) } : {}),
313
+ ...(last(flags["frame-ms"]) ? { frame_duration_ms: Number(last(flags["frame-ms"])) } : {}),
280
314
  ...(last(flags["aspect-ratio"]) ? { aspect_ratio: last(flags["aspect-ratio"]) } : {}),
281
315
  ...(last(flags.n) ? { n: Number(last(flags.n)) } : {}),
282
316
  ...(last(flags.seed) ? { seed: Number(last(flags.seed)) } : {}),
@@ -290,7 +324,11 @@ async function cmdImage(prompt, flags) {
290
324
  body: JSON.stringify(body),
291
325
  });
292
326
  if (!res.ok) die(await readError(res));
293
- const json = await res.json();
327
+ const json = await resolveJob("image", await res.json(), flags);
328
+ if (json.id) {
329
+ printJob("image", json);
330
+ if (json.status === "failed") die(json.error || "image generation failed", 2);
331
+ }
294
332
  const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
295
333
  // A layer stack names each line, so `headline` can be found among eight URLs.
296
334
  (json.data ?? []).filter((d) => d.url).forEach((d) =>
@@ -324,7 +362,7 @@ async function cmdVideo(prompt, flags) {
324
362
  ...webhook(flags),
325
363
  ...collection(flags),
326
364
  };
327
- console.error("submitting video job (this can take a minute)…");
365
+ console.error("submitting video job…");
328
366
  const res = await fetch(`${baseUrl}/api/v1/videos/generations`, {
329
367
  method: "POST",
330
368
  headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
@@ -334,7 +372,7 @@ async function cmdVideo(prompt, flags) {
334
372
  let job = await res.json();
335
373
  if (job.status === "failed") die(job.error || "video generation failed", 2);
336
374
  if (body.webhook_url) process.stderr.write(`webhook: ${body.webhook_url} will be notified when the job finishes\n`);
337
- if (flags.wait !== false) {
375
+ if (flags.wait === true) {
338
376
  process.stderr.write(`job ${job.id} accepted; waiting for completion…\n`);
339
377
  while (job.status === "queued" || job.status === "processing") {
340
378
  await new Promise((resolve) => setTimeout(resolve, 3_000));
@@ -343,6 +381,7 @@ async function cmdVideo(prompt, flags) {
343
381
  job = await status.json();
344
382
  }
345
383
  }
384
+ printJob("video", job);
346
385
  if (job.status === "failed") die(job.error || "video generation failed", 2);
347
386
  const url = job.data?.[0]?.url;
348
387
  if (url) {
@@ -352,8 +391,6 @@ async function cmdVideo(prompt, flags) {
352
391
  process.stderr.write(`draft ${job.id} — render it in 1080p within seven days: ottoport video --model ${model} --draft-id ${job.id}\n`);
353
392
  }
354
393
  if (flags.out) await download(url, last(flags.out));
355
- } else {
356
- console.log(JSON.stringify(job, null, 2));
357
394
  }
358
395
  }
359
396
 
@@ -394,11 +431,15 @@ async function cmdWebhook(action, jobId, flags) {
394
431
  }
395
432
 
396
433
  async function cmdAudio(kind, prompt, flags) {
397
- if (!prompt) die(`${kind} requires a prompt: ottoport ${kind} "hello"`);
434
+ // A sound-effects model scores a video, so the prompt is optional with one.
435
+ const video = kind === "music" ? last(flags.video) : undefined;
436
+ if (!prompt && !video) die(`${kind} requires a prompt: ottoport ${kind} "hello"`);
398
437
  const { baseUrl, apiKey } = config(flags);
399
438
  const body = {
439
+ async: true,
400
440
  model: last(flags.model) || (kind === "speech" ? "gpt-4o-mini-tts" : "suno-v5"),
401
- prompt,
441
+ ...(prompt ? { prompt } : {}),
442
+ ...(video ? { video_url: video } : {}),
402
443
  ...(last(flags.voice) ? { voice: last(flags.voice) } : {}),
403
444
  ...(last(flags.duration) ? { duration: Number(last(flags.duration)) } : {}),
404
445
  ...(last(flags.format) ? { format: last(flags.format) } : {}),
@@ -407,11 +448,16 @@ async function cmdAudio(kind, prompt, flags) {
407
448
  const path = kind === "speech" ? "audio/speech" : "audio/music";
408
449
  const res = await fetch(`${baseUrl}/api/v1/${path}`, {
409
450
  method: "POST",
410
- headers: headers(apiKey),
451
+ headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
411
452
  body: JSON.stringify(body),
412
453
  });
413
454
  if (!res.ok) die(await readError(res));
414
- const json = await res.json();
455
+ const json = await resolveJob(kind, await res.json(), flags);
456
+ if (json.id) {
457
+ printJob(kind, json);
458
+ if (json.status === "failed") die(json.error || "audio generation failed", 2);
459
+ if (["queued", "processing"].includes(json.status)) return;
460
+ }
415
461
  const item = json.data?.[0];
416
462
  if (!item) die("no audio returned", 2);
417
463
  if (item.url) console.log(outputLine(item));
@@ -482,6 +528,7 @@ Usage:
482
528
  ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k] [--quality high]
483
529
  [--aspect-ratio 16:9] [--n 1] [--seed 7]
484
530
  [--image <url>] [--ref <url>[,<url>…]] [--out file.png]
531
+ ottoport image "<prompt>" --model gpt-image-2.6 --format gif|svg [--frames 8] [--frame-ms 200]
485
532
  ottoport image --model qwen-image-layered --image <url> [--layers 4]
486
533
  split one image into RGBA layers (prompt optional)
487
534
  ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p]
@@ -499,6 +546,9 @@ Usage:
499
546
  ottoport webhook retry <job-id> [--json] redeliver a finished job's webhook now
500
547
  ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--format mp3] [--out speech.mp3]
501
548
  ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--format mp3] [--out song.mp3]
549
+ ottoport music ["<prompt>"] --model mirelo-sfx-2.0 --video <url> [--duration 8] [--out sfx.wav]
550
+ a soundtrack synced to the video, dialogue kept;
551
+ set --duration to the clip's length (it bills)
502
552
  ottoport collections [list] [--json] collections in the key's project
503
553
  ottoport collections create "<name>" [--description "..."] [--external-id <id>]
504
554
  ottoport mcp
@@ -528,6 +578,10 @@ Examples:
528
578
  ottoport image --model seedream-5.0-layers --image https://…/poster.png
529
579
  ottoport speech "Welcome to OttoPort" --voice alloy --out welcome.mp3
530
580
  ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
581
+ ottoport music "rain on a tin roof" --model mirelo-sfx-2.0 --duration 10 --format mp3 --out rain.mp3
582
+ ottoport music --model mirelo-sfx-2.0 --video https://example.com/chase.mp4 --duration 8 --out chase.wav
583
+ Media commands return jobs immediately. Add --wait to wait for results.
584
+ ottoport job image|video|speech|music <id> [--wait] [--out file]
531
585
  ottoport collections create "Spring campaign"
532
586
  ottoport image "spring hero banner" --collection <collection-id>
533
587
  ottoport mcp
@@ -545,6 +599,7 @@ async function main() {
545
599
  case "chat": return await cmdChat(prompt, flags);
546
600
  case "image": return await cmdImage(prompt, flags);
547
601
  case "video": return await cmdVideo(prompt, flags);
602
+ case "job": return await cmdJob(rest[0], rest[1], flags);
548
603
  case "webhook": return await cmdWebhook(rest[0], rest[1], flags);
549
604
  case "speech": return await cmdAudio("speech", prompt, flags);
550
605
  case "music": return await cmdAudio("music", prompt, flags);
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Turn text into speech, or a prompt into music, through the OttoPort gateway
2
+ description: Turn text into speech, or a prompt into music or sound effects, through the OttoPort gateway
3
3
  argument-hint: "<text> [--voice alloy] [--music]"
4
4
  allowed-tools: mcp__ottoport__ottoport_generate_speech, mcp__ottoport__ottoport_generate_music, Bash(curl:*)
5
5
  ---
@@ -7,7 +7,9 @@ allowed-tools: mcp__ottoport__ottoport_generate_speech, mcp__ottoport__ottoport_
7
7
  Request: $ARGUMENTS
8
8
 
9
9
  Use `ottoport_generate_music` when the request is for music, a song, or a
10
- backing track; otherwise use `ottoport_generate_speech` to read the text aloud.
10
+ backing track — or for sound effects, ambience or a soundtrack for a video, with
11
+ `model` `mirelo-sfx-2.0` (pass the video as `video_url`, and `duration` as its
12
+ length); otherwise use `ottoport_generate_speech` to read the text aloud.
11
13
  Pass `voice` and `format` only when named — speech defaults to `gpt-4o-mini-tts`
12
14
  with the `alloy` voice, music to `suno-v5`.
13
15
 
package/mcp/server.mjs CHANGED
@@ -282,9 +282,13 @@ async function waitForJob(statusPath, job, { timeoutMs, label }) {
282
282
  while (job.status === "queued" || job.status === "processing") {
283
283
  if (Date.now() >= deadline) return { job, timedOut: true, path };
284
284
  await new Promise((resolve) => setTimeout(resolve, 3_000));
285
- const status = await fetch(`${BASE}${path}`, { headers: headers(false) });
286
- if (!status.ok) throw new Error(await gwError(status));
287
- job = await status.json();
285
+ try {
286
+ const status = await fetch(`${BASE}${path}`, { headers: headers(false), signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) });
287
+ if (!status.ok) throw new Error(await gwError(status));
288
+ job = await status.json();
289
+ } catch (error) {
290
+ return { job: { ...job, poll_error: error.message }, timedOut: true, path };
291
+ }
288
292
  }
289
293
  if (job.status === "failed") throw new Error(job.error || `${label} generation failed`);
290
294
  return { job, timedOut: false, path };
@@ -293,7 +297,21 @@ async function waitForJob(statusPath, job, { timeoutMs, label }) {
293
297
  const IMAGE_JOB_PATH = "/api/v1/images/generations/{id}";
294
298
  const VIDEO_JOB_PATH = "/api/v1/videos/generations/{id}";
295
299
 
296
- async function generateImage({ prompt, model, collection, ...rest }) {
300
+ const JOB_PATHS = { image: IMAGE_JOB_PATH, video: VIDEO_JOB_PATH, speech: "/api/v1/audio/speech/{id}", music: "/api/v1/audio/music/{id}" };
301
+ function describeJob(kind, job) {
302
+ return JSON.stringify({ ...job, job_id: job.id, kind, status_path: JOB_PATHS[kind].replace("{id}", encodeURIComponent(job.id)), next: { tool: "ottoport_get_job", arguments: { kind, job_id: job.id } } }, null, 2);
303
+ }
304
+ async function getJob({ kind, job_id, wait = false }) {
305
+ if (!Object.hasOwn(JOB_PATHS, kind) || !job_id) throw new Error("Valid kind and job_id are required");
306
+ const path = JOB_PATHS[kind].replace("{id}", encodeURIComponent(job_id));
307
+ const res = await fetch(`${BASE}${path}`, { headers: headers(false) });
308
+ if (!res.ok) throw new Error(await gwError(res));
309
+ let job = await res.json();
310
+ if (wait) ({ job } = await waitForJob(JOB_PATHS[kind], job, { timeoutMs: 45_000, label: kind }));
311
+ return describeJob(kind, job);
312
+ }
313
+
314
+ async function generateImage({ prompt, model, collection, wait = false, ...rest }) {
297
315
  // A layer-decomposition model splits `image_url` and takes the prompt as an
298
316
  // optional caption; every other image model needs the prompt. The gateway
299
317
  // knows which is which and says so.
@@ -318,8 +336,9 @@ async function generateImage({ prompt, model, collection, ...rest }) {
318
336
  // A 202 is a job. A 200 with `data` is the image itself — the dev key, or a
319
337
  // gateway that predates image jobs — and is read exactly as before.
320
338
  if (res.status === 202) {
321
- const { job, timedOut, path } = await waitForJob(IMAGE_JOB_PATH, json, { timeoutMs: 5 * 60_000, label: "image" });
322
- if (timedOut) return `Image job ${job.id} is still processing. Poll ${path} for its result.`;
339
+ if (!wait) return describeJob("image", json);
340
+ const { job, timedOut, path } = await waitForJob(IMAGE_JOB_PATH, json, { timeoutMs: 45_000, label: "image" });
341
+ if (timedOut) return describeJob("image", job);
323
342
  json = job;
324
343
  }
325
344
  // A layer stack prints one line per layer with what the model called it, so
@@ -330,7 +349,7 @@ async function generateImage({ prompt, model, collection, ...rest }) {
330
349
  return lines.length ? lines.join("\n") : "(no image returned)";
331
350
  }
332
351
 
333
- async function generateVideo({ prompt, model, webhook_url, webhook_secret, collection, ...rest }) {
352
+ async function generateVideo({ prompt, model, webhook_url, webhook_secret, collection, wait = false, ...rest }) {
334
353
  // A depth model takes only `video_url`; the gateway says which models need a prompt.
335
354
  // A draft completion takes everything from its draft, the prompt included.
336
355
  if (!prompt && !rest.video_url && !rest.draft_id) throw new Error("`prompt` is required");
@@ -347,14 +366,10 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, colle
347
366
  if (!res.ok) throw new Error(await gwError(res));
348
367
  const submitted = await res.json();
349
368
  if (submitted.status === "failed") throw new Error(submitted.error || "video generation failed");
350
- // With a webhook the caller has said where the result should go; holding the
351
- // tool call open for minutes as well would only block the agent.
352
- if (webhook_url) return `Video job ${submitted.id} is ${submitted.status}. ${webhook_url} will receive video.completed or video.failed when it finishes; check deliveries with ottoport_video_webhook.`;
353
- // The public video endpoint is deliberately asynchronous. MCP tools should
354
- // still fulfil their promise of returning usable output, so wait for the
355
- // job rather than returning a queued-job JSON blob to the agent.
356
- const { job, timedOut, path } = await waitForJob(VIDEO_JOB_PATH, submitted, { timeoutMs: 10 * 60_000, label: "video" });
357
- if (timedOut) return `Video job ${job.id} is still processing. Poll ${path} for its result.`;
369
+ // Return the accepted job before the MCP client timeout; waiting is opt-in.
370
+ if (!wait || webhook_url) return describeJob("video", submitted);
371
+ const { job, timedOut, path } = await waitForJob(VIDEO_JOB_PATH, submitted, { timeoutMs: 45_000, label: "video" });
372
+ if (timedOut) return describeJob("video", job);
358
373
  const output = job.data?.[0];
359
374
  const url = output?.url ? outputLine(output) : undefined;
360
375
  // The draft's job id is what completes it, so it is worth saying.
@@ -375,22 +390,31 @@ async function videoWebhook({ job_id, action = "status" }) {
375
390
  return JSON.stringify({ id: body.id, status: body.status, webhook: body.webhook ?? null }, null, 2);
376
391
  }
377
392
 
378
- async function generateAudio(kind, { prompt, model, voice, duration, format, collection }) {
379
- if (!prompt) throw new Error("`prompt` is required");
393
+ async function generateAudio(kind, { prompt, model, voice, duration, format, video_url, collection, wait = false }) {
394
+ if (!prompt && !video_url) throw new Error("`prompt` is required");
380
395
  const res = await fetch(`${BASE}/api/v1/audio/${kind === "tts" ? "speech" : "music"}`, {
381
396
  method: "POST",
382
- headers: headers(),
397
+ headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
383
398
  body: JSON.stringify({
399
+ async: true,
384
400
  model: model || (kind === "tts" ? "gpt-4o-mini-tts" : "suno-v5"),
385
- prompt,
401
+ ...(prompt ? { prompt } : {}),
386
402
  ...(voice ? { voice } : {}),
403
+ ...(video_url ? { video_url } : {}),
387
404
  ...(duration ? { duration } : {}),
388
405
  ...(format ? { format } : {}),
389
406
  ...withCollection(await collectionId(collection)),
390
407
  }),
391
408
  });
392
409
  if (!res.ok) throw new Error(await gwError(res));
393
- const json = await res.json();
410
+ let json = await res.json();
411
+ if (res.status === 202) {
412
+ const jobKind = kind === "tts" ? "speech" : "music";
413
+ if (!wait) return describeJob(jobKind, json);
414
+ const result = await waitForJob(JOB_PATHS[jobKind], json, { timeoutMs: 45_000, label: jobKind });
415
+ if (result.timedOut) return describeJob(jobKind, result.job);
416
+ json = result.job;
417
+ }
394
418
  const audio = json.data?.[0];
395
419
  if (!audio) return "(no audio returned)";
396
420
  if (audio.url || audio.asset_url) return outputLine(audio);
@@ -399,6 +423,12 @@ async function generateAudio(kind, { prompt, model, voice, duration, format, col
399
423
 
400
424
  // ── tool registry ────────────────────────────────────────────────────
401
425
  const TOOLS = [
426
+ {
427
+ name: "ottoport_get_job",
428
+ description: "Read an existing image, video, speech or music job, including progress, errors and result URLs. Never resubmits or bills a new generation.",
429
+ inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["image", "video", "speech", "music"] }, job_id: { type: "string" }, wait: { type: "boolean", description: "Optionally wait up to 45 seconds." } }, required: ["kind", "job_id"] },
430
+ handler: getJob,
431
+ },
402
432
  {
403
433
  name: "ottoport_list_models",
404
434
  description: "The OttoPort catalog: modality, provider, price, and what each model accepts — input modes, reference-image count, resolutions. Read it before naming a model or a mode. Filter with `modality`, or pass `model` for ONE model in full: its resolutions with their price multipliers and every request parameter it reads.",
@@ -429,10 +459,11 @@ const TOOLS = [
429
459
  },
430
460
  {
431
461
  name: "ottoport_generate_image",
432
- description: "Generate an image through an OttoPort image model (GPT Image, Nano Banana, …). Text-to-image, editing from one reference, or several references at once. Also layer decomposition: qwen-image-layered and seedream-5.0-layers take `image_url` alone and return the image split into RGBA layers, one URL per layer, billed per layer. Which a model accepts is in its capabilities — call ottoport_list_models. Returns image URL(s).",
462
+ description: "Generate an image through an OttoPort image model (GPT Image, Nano Banana, …). Text-to-image, editing from one reference, or several references at once. Also layer decomposition: qwen-image-layered and seedream-5.0-layers take `image_url` alone and return the image split into RGBA layers, one URL per layer, billed per layer. Vectors: ideogram-vector-1 returns editable SVG from a prompt (shape it with `aspect_ratio`, not `size`), and ideogram-vector-1-vectorize takes `image_url` alone and returns it traced as SVG. Which a model accepts is in its capabilities — call ottoport_list_models. Defaults to asynchronous submission: returns job_id and polling instructions. Use ottoport_get_job for results.",
433
463
  inputSchema: {
434
464
  type: "object",
435
465
  properties: {
466
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
436
467
  prompt: { type: "string", description: "What to generate. On a layer-decomposition model, an optional caption of the input image." },
437
468
  model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2.1, nano-banana-pro. Default gpt-image-2." },
438
469
  size: { type: "string", description: 'Exact pixels, e.g. "1024x1024". Wins over `resolution`.' },
@@ -443,7 +474,7 @@ const TOOLS = [
443
474
  // at best. The model's own menu is in `ottoport_list_models`.
444
475
  resolution: { type: "string", description: 'Output detail — "1k", "2k" or "4k", but only the tiers this model lists in ottoport_list_models. Priced accordingly. Pair with `aspect_ratio` when you know the shape but not the pixel vocabulary; an explicit `size` wins.' },
445
476
  aspect_ratio: { type: "string", description: 'e.g. "16:9". Read only when `size` is absent.' },
446
- quality: { type: "string", description: 'How much compute the model spends, on the GPT Image family and ideogram-4.5 — "low", "medium" (the default), "high", and on gpt-image-2.5 "xhigh" and "max". Priced by tier, and the ladder is steep: `max` costs sixteen times `medium`. Check the model\'s `qualities` in ottoport_list_models.' },
477
+ quality: { type: "string", description: 'How much compute the model spends, on the GPT Image family, ideogram-4.5 and ideogram-vector-1 — "low", "medium" (the default), "high", and on gpt-image-2.5 "xhigh" and "max". Priced by tier, and the ladder is steep: `max` costs sixteen times `medium`. Check the model\'s `qualities` in ottoport_list_models.' },
447
478
  n: { type: "number" },
448
479
  seed: { type: "number" },
449
480
  image_url: { type: "string", description: "One reference image, for editing and image-to-image — or, on a layer-decomposition model, the image to split." },
@@ -452,6 +483,10 @@ const TOOLS = [
452
483
  horizontal_angle: { type: "number", description: "Camera models only (qwen-image-angles): orbit in degrees, 0–360. Needs image_url; prompt optional." },
453
484
  vertical_angle: { type: "number", description: "Camera models only: elevation in degrees, -30 (below) to 90 (overhead)." },
454
485
  zoom: { type: "number", description: "Camera models only: 0 (wide) to 10 (close)." },
486
+ output_format: { type: "string", enum: ["gif", "svg"], description: "gpt-image-2.6 and gpt-image-2.6-flare, text-to-image only: `gif` returns an animation, `svg` a vector file. One file per request. GIF picks its own frame size, so `size`, `resolution` and `aspect_ratio` do not apply; describe the motion and the loop in the prompt. SVG costs extra — see the model's rate in ottoport_list_models." },
487
+ frame_count: { type: "number", description: "With output_format gif: frames, 2–16, default 8. Above 8 bills double." },
488
+ frame_duration_ms: { type: "number", description: "With output_format gif: milliseconds per frame, 20–2000." },
489
+ vector_mode: { type: "string", enum: ["default", "simple"], description: "ideogram-vector-1-vectorize only: `default` keeps finer detail, `simple` traces fewer, simpler shapes. Same price." },
455
490
  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." },
456
491
  },
457
492
  },
@@ -459,10 +494,11 @@ const TOOLS = [
459
494
  },
460
495
  {
461
496
  name: "ottoport_generate_video",
462
- description: "Generate a video through an OttoPort video model (Veo, Kling, Seedance, …). Modes: text-to-video, image-to-video (`image_url`), first-and-last-frame (plus `last_frame_url`), reference-to-video (`reference_image_urls`, plus `reference_video_urls` / `reference_audio_urls` on models whose capabilities list `referenceMedia`), and on some models video edit/extend (`video_url`). Seedance 2.5 also drafts: `draft: true` (or the model `seedance-2.5-draft`, where every request is a draft) renders a cheap 480p preview, and `draft_id` set to that job's id renders the same shot at 1080p. video-depth-anything instead turns `video_url` into a grayscale depth video and takes no prompt. Kling motion control (`kling-2.6-motion-control`, `kling-3.0-motion-control` and their `-pro` variants) makes the character in `image_url` perform the movement in `video_url`; the prompt is optional and the output runs as long as the clip. Which a model accepts is in its capabilities — call ottoport_list_models. Blocks on the queue and returns a video URL, unless `webhook_url` is given.",
497
+ description: "Generate a video through an OttoPort video model (Veo, Kling, Seedance, …). Modes: text-to-video, image-to-video (`image_url`), first-and-last-frame (plus `last_frame_url`), reference-to-video (`reference_image_urls`, plus `reference_video_urls` / `reference_audio_urls` on models whose capabilities list `referenceMedia`), and on some models video edit/extend (`video_url`). Seedance 2.5 also drafts: `draft: true` (or the model `seedance-2.5-draft`, where every request is a draft) renders a cheap 480p preview, and `draft_id` set to that job's id renders the same shot at 1080p. video-depth-anything instead turns `video_url` into a grayscale depth video and takes no prompt. Kling motion control (`kling-2.6-motion-control`, `kling-3.0-motion-control` and their `-pro` variants) makes the character in `image_url` perform the movement in `video_url`; the prompt is optional and the output runs as long as the clip. Which a model accepts is in its capabilities — call ottoport_list_models. Defaults to asynchronous submission. Use ottoport_get_job for results.",
463
498
  inputSchema: {
464
499
  type: "object",
465
500
  properties: {
501
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
466
502
  prompt: { type: "string", description: "Required, except on video-depth-anything, which takes none, and the motion-control models, where it is optional." },
467
503
  model: { type: "string", description: "Model id, e.g. veo-3.1, kling-3.0, seedance-2.0-fast, kling-3.0-motion-control, video-depth-anything. Default kling-3.0." },
468
504
  duration: { type: "number", description: "Seconds. The main lever on what this costs." },
@@ -506,10 +542,11 @@ const TOOLS = [
506
542
  },
507
543
  {
508
544
  name: "ottoport_generate_speech",
509
- description: "Convert text to speech through an OttoPort TTS model. Returns an audio URL or data URL.",
545
+ description: "Convert text to speech through an OttoPort TTS model. Defaults to asynchronous submission; returns job_id. Query ottoport_get_job for audio results.",
510
546
  inputSchema: {
511
547
  type: "object",
512
548
  properties: {
549
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
513
550
  prompt: { type: "string", description: "Text to synthesize." },
514
551
  model: { type: "string", description: "TTS model id. Default gpt-4o-mini-tts." },
515
552
  voice: { type: "string", description: "Optional provider voice id." },
@@ -522,17 +559,18 @@ const TOOLS = [
522
559
  },
523
560
  {
524
561
  name: "ottoport_generate_music",
525
- description: "Generate music from a prompt through an OttoPort music model. Returns an audio URL or data URL.",
562
+ description: "Generate music or sound effects from a prompt through an OttoPort music model — or, with mirelo-sfx-2.0 and a video_url, a soundtrack synced to that video. Defaults to asynchronous submission; returns job_id. Query ottoport_get_job for audio results.",
526
563
  inputSchema: {
527
564
  type: "object",
528
565
  properties: {
529
- prompt: { type: "string", description: "Music-generation prompt." },
530
- model: { type: "string", description: "Music model id. Default suno-v5." },
566
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
567
+ prompt: { type: "string", description: "Music-generation prompt, or the sounds to make. Optional with video_url." },
568
+ model: { type: "string", description: "Music model id. Default suno-v5; mirelo-sfx-2.0 for sound effects." },
531
569
  duration: { type: "number", description: "Requested duration in seconds." },
570
+ video_url: { type: "string", description: "mirelo-sfx-2.0 only: an http(s) video to score. Set duration to its length — duration is what bills." },
532
571
  format: { type: "string", description: 'e.g. "mp3" or "wav".' },
533
572
  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." },
534
573
  },
535
- required: ["prompt"],
536
574
  },
537
575
  handler: (args) => generateAudio("music", args),
538
576
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ottoport",
3
- "version": "1.13.2",
3
+ "version": "1.15.1",
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": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ottoport
3
- description: Generate text, images, video, speech, and music through OttoPort — one gateway for every model (GPT, Claude, Gemini, GPT Image, Nano Banana, Veo, Kling, Seedance, Suno). Use when the user wants to call an LLM or generate an image, video, or audio and has OttoPort configured, or asks to "use OttoPort".
3
+ description: Generate text, images, video, speech, and music through OttoPort — one gateway for every model (GPT, Claude, Gemini, GPT Image, Nano Banana, Veo, Kling, Seedance, Suno, Mirelo). Use when the user wants to call an LLM or generate an image, video, audio or sound effects and has OttoPort configured, or asks to "use OttoPort".
4
4
  ---
5
5
 
6
6
  # OttoPort
@@ -20,7 +20,7 @@ and do not call the HTTP API, when a tool covers the job:
20
20
  | `ottoport_generate_video` | `prompt`, plus optional `model`, `duration`, `resolution`, `aspect_ratio`, `seed`, `image_url` (first frame), `last_frame_url`, `reference_image_urls`, `reference_video_urls` / `reference_audio_urls` (models listing `referenceMedia`), `video_url` (edit / extend, or the movement for motion control), `character_orientation` / `keep_original_sound` (motion control), `draft` / `draft_id` (Seedance 2.5: a 480p preview, then the same shot at 1080p from the preview's job id; the model `seedance-2.5-draft` drafts on every request without `draft`), `webhook_url` (returns the job id at once and POSTs the result there; the secret comes from `webhook_secret` or the server's `OTTOPORT_WEBHOOK_SECRET`). |
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
- | `ottoport_generate_music` | `prompt`, plus optional `model`, `duration`, `format`. |
23
+ | `ottoport_generate_music` | `prompt`, plus optional `model`, `duration`, `format`. Sound effects too: `model` `mirelo-sfx-2.0` makes them from the prompt, or with `video_url` a soundtrack synced to that video, dialogue kept (prompt then optional). It bills per requested second, so set `duration` to the clip's length. |
24
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
25
  | `ottoport_create_collection` | `name`, plus optional `description`, `external_id`. |
26
26
  | `ottoport_list_assets` | Saved files, optionally by `collection` and `kind`. |
@@ -65,11 +65,12 @@ video and `"1k"`/`"2k"`/`"4k"` for images, but **no model offers all of it**:
65
65
  `gpt-image-1.5` only 1k. Same for how many references a model takes —
66
66
  `nano-banana-lite` accepts 3, `gpt-image-2` accepts 16.
67
67
 
68
- `quality` is a second priced dial, on the GPT Image family and `ideogram-4.5`:
68
+ `quality` is a second priced dial, on the GPT Image family, `ideogram-4.5` and
69
+ `ideogram-vector-1`:
69
70
  `low`, `medium` (the default, and the tier every catalog rate was measured at),
70
- `high`, and `xhigh`/`max` on `gpt-image-2.5` and `gpt-image-2.5-flare`. It buys
71
- compute, not pixels, and the ladder is steep — `max` bills sixteen times
72
- `medium`. A model's tiers are its `qualities` in `ottoport_list_models`.
71
+ `high`, and `xhigh`/`max` on `gpt-image-2.6`, `gpt-image-2.5` and their Flare
72
+ tiers. It buys compute, not pixels, and the ladder is steep — `max` bills
73
+ sixteen times `medium` on 2.5 and about five times on 2.6. A model's tiers are its `qualities` in `ottoport_list_models`.
73
74
 
74
75
  So read the model's own menu before naming a mode, a resolution or a second
75
76
  reference image: `ottoport_list_models` with `model` set to the one you are
@@ -90,7 +91,7 @@ sensible default.
90
91
  | image | `gpt-image-2` | `nano-banana-2.1` (fast generation and edits), `nano-banana-pro` (high fidelity) |
91
92
  | video | `kling-3.0` | `seedance-2.0-fast` (cheap), `veo-3.1` (with audio) |
92
93
  | speech | `gpt-4o-mini-tts` | `eleven-v3` |
93
- | music | `suno-v5` | `lyria-2` |
94
+ | music | `suno-v5` | `lyria-2`, `mirelo-sfx-2.0` (sound effects, or a soundtrack for a video) |
94
95
 
95
96
  Never invent a model id. Call `ottoport_list_models` when unsure — the catalog
96
97
  changes, and a wrong id is a failed billable call. Rates come back in credits (the unit the ledger
@@ -143,3 +144,20 @@ music live under `/api/v1/images/generations`, `/videos/generations`,
143
144
  - The CLI always targets `https://ottoport.ai`; pass `--url` explicitly only
144
145
  for local development or a self-hosted gateway.
145
146
  - `OTTOPORT_BASE_URL` configures the MCP server only.
147
+
148
+
149
+ ## Asynchronous media jobs (1.15.1)
150
+
151
+ Image, video, speech and music generation return an accepted job immediately by default. Save its `job_id`; do not resubmit to check progress. Chat streaming is unchanged.
152
+
153
+ ```sh
154
+ ottoport image "a mountain lake" --model midjourney-v8.2
155
+ ottoport job image <job-id>
156
+ ottoport job image <job-id> --wait --out lake.png
157
+ # Explicit waiting is also available on submission:
158
+ ottoport image "a mountain lake" --wait --out lake.png
159
+ ```
160
+
161
+ Use job kinds `image`, `video`, `speech`, or `music`. `--out` downloads only when a result is available; otherwise query the returned job with `--wait --out`. Pending and failed job responses retain the task ID and status route.
162
+
163
+ MCP generation tools return `job_id`, `status`, `status_path`, and the next tool call. Query with `ottoport_get_job({kind, job_id})`. Optional `wait: true` waits up to 45 seconds per MCP call, returning the same job if still pending. Querying a job does not submit another generation. Existing scripts that expected immediate URLs should add `--wait`, or adopt job polling.