ottoport 1.14.0 → 1.15.2

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.14.0",
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.2",
5
5
  "author": {
6
6
  "name": "LITBOX LLC",
7
7
  "email": "support@ottoport.ai"
package/README.md CHANGED
@@ -29,7 +29,7 @@ ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p
29
29
  [--webhook-url https://…] [--no-wait]
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
- ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--out song.mp3]
32
+ ottoport music "<prompt>" [--model suno-v6] [--duration 15] [--out song.mp3]
33
33
  ottoport music --model mirelo-sfx-2.0 --video <url> [--duration 8] # soundtrack for a video
34
34
 
35
35
  ottoport install <host> # wire the MCP server into an agent
@@ -95,16 +95,35 @@ the character performs the clip's movement). Images take one reference (`image_u
95
95
  (`reference_image_urls`). Both take a `resolution`: `480p`…`4k` for video,
96
96
  `1k`/`2k`/`4k` for images, and a tier above a model's base rate costs more.
97
97
 
98
- The GPT Image family and `ideogram-4.5` read a `quality` on top of that — `low`, `medium` (the
99
- default every catalog rate is measured at), `high`, and on `gpt-image-2.5` and
100
- `gpt-image-2.5-flare` also `xhigh` and `max`. It is how much compute the model
101
- 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.
102
103
 
103
104
  Two image models take a picture apart instead: `qwen-image-layered` and
104
105
  `seedream-5.0-layers` split `image_url` into RGBA layers and return one URL per
105
106
  layer, bottom first (the prompt is an optional caption; `num_layers`, 2–10, is
106
107
  honoured by Qwen only). Each layer returned bills separately.
107
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
+
121
+ ```sh
122
+ ottoport image "a paper boat bobbing on water, seamless loop" --format gif --wait --out boat.gif
123
+ ```
124
+
125
+ Asking for either format with no model named uses `gpt-image-2.6`.
126
+
108
127
  Which modes and tiers a model accepts differs per model, so
109
128
  `ottoport_list_models` — and `ottoport models` in the CLI — prints each one's
110
129
  menu beside its price. For one model in full, including every parameter it
@@ -150,3 +169,20 @@ https://ottoport.ai/docs#webhooks.
150
169
  Docs: <https://ottoport.ai/docs> · Support: support@ottoport.ai
151
170
 
152
171
  MIT © LITBOX LLC
172
+
173
+
174
+ ## Asynchronous media jobs (1.15.1)
175
+
176
+ 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.
177
+
178
+ ```sh
179
+ ottoport image "a mountain lake" --model midjourney-v8.2
180
+ ottoport job image <job-id>
181
+ ottoport job image <job-id> --wait --out lake.png
182
+ # Explicit waiting is also available on submission:
183
+ ottoport image "a mountain lake" --wait --out lake.png
184
+ ```
185
+
186
+ 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.
187
+
188
+ 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
@@ -9,7 +9,7 @@
9
9
  // ottoport image "prompt" [--model gpt-image-2] [--resolution 2k] [--ref a.png,b.png] [--out img.png]
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
- // ottoport music "prompt" [--model suno-v5] [--duration 15] [--out song.mp3]
12
+ // ottoport music "prompt" [--model suno-v6] [--duration 15] [--out song.mp3]
13
13
  // ottoport music --model mirelo-sfx-2.0 --video <url> [--duration 8]
14
14
  // ottoport collections [list | create "name"]
15
15
  // ottoport mcp
@@ -26,7 +26,7 @@ const BASE = "https://ottoport.ai";
26
26
 
27
27
  // ── arg parsing ──────────────────────────────────────────────────────
28
28
  // Switches that never take a value, so `--draft "a boat"` keeps its prompt.
29
- const BOOLEAN_FLAGS = new Set(["draft"]);
29
+ const BOOLEAN_FLAGS = new Set(["draft", "wait"]);
30
30
 
31
31
  function parseArgs(argv) {
32
32
  const positional = [];
@@ -116,6 +116,7 @@ async function cmdModel(id, flags) {
116
116
  : caps.resolutions;
117
117
  console.log(`resolution: ${tiers.join(", ")}${caps.base_resolution ? ` (base ${caps.base_resolution})` : ""}`);
118
118
  }
119
+ if (caps?.outputFormats?.length) console.log(`outputs: ${caps.outputFormats.join(", ")}, besides images (--format)`);
119
120
  console.log("parameters:");
120
121
  for (const p of m.parameters ?? []) {
121
122
  console.log(` ${String(p.name).padEnd(21)}${p.type}${p.required ? ", required" : ""} — ${p.note}`);
@@ -150,7 +151,8 @@ function capabilityLine(caps) {
150
151
  return `accepts: ${caps.modes.join(", ")}${refs} · ${caps.resolutions.join("/")}`;
151
152
  }
152
153
  const inputs = caps.maxInputImages ? `up to ${caps.maxInputImages} input images` : "text only";
153
- return `accepts: ${inputs} · ${caps.resolutions.join("/")}`;
154
+ const formats = caps.outputFormats?.length ? ` · also outputs ${caps.outputFormats.join("/")}` : "";
155
+ return `accepts: ${inputs} · ${caps.resolutions.join("/")}${formats}`;
154
156
  }
155
157
 
156
158
  async function cmdChat(prompt, flags) {
@@ -267,17 +269,58 @@ function references(flags, name = "ref") {
267
269
  return list.length ? list : undefined;
268
270
  }
269
271
 
272
+ const JOB_PATHS = { image: "images/generations", video: "videos/generations", speech: "audio/speech", music: "audio/music" };
273
+ async function resolveJob(kind, job, flags) {
274
+ if (!job.id || !job.status) return job;
275
+ const { baseUrl, apiKey } = config(flags);
276
+ // Always expose the ID before a caller opts into waiting.
277
+ if (flags.wait === true) console.error(`job ${job.id} accepted`);
278
+ while (flags.wait === true && ["queued", "processing"].includes(job.status)) {
279
+ await new Promise((resolve) => setTimeout(resolve, 3000));
280
+ const res = await fetch(`${baseUrl}/api/v1/${JOB_PATHS[kind]}/${encodeURIComponent(job.id)}`, { headers: headers(apiKey, false) });
281
+ if (!res.ok) die(await readError(res));
282
+ job = await res.json();
283
+ }
284
+ return job;
285
+ }
286
+ function printJob(kind, job) {
287
+ 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));
288
+ }
289
+ async function cmdJob(kind, id, flags) {
290
+ if (!Object.hasOwn(JOB_PATHS, kind) || !id) die("usage: ottoport job image|video|speech|music <id> [--wait] [--out file]");
291
+ const { baseUrl, apiKey } = config(flags);
292
+ const res = await fetch(`${baseUrl}/api/v1/${JOB_PATHS[kind]}/${encodeURIComponent(id)}`, { headers: headers(apiKey, false) });
293
+ if (!res.ok) die(await readError(res));
294
+ const job = await resolveJob(kind, await res.json(), flags);
295
+ printJob(kind, job);
296
+ if (job.status === "failed") die(job.error || "generation failed", 2);
297
+ const item = job.data?.[0];
298
+ if (flags.out && (item?.asset_url || item?.url)) await download(item.asset_url || item.url, last(flags.out));
299
+ }
300
+
270
301
  async function cmdImage(prompt, flags) {
271
302
  // A layer-decomposition model needs only --image; the prompt is a caption.
272
303
  if (!prompt && !last(flags.image)) die("image requires a prompt: ottoport image \"a red fox\" (or --image <url> on a layer model)");
273
304
  const { baseUrl, apiKey } = config(flags);
305
+ const format = last(flags.format);
306
+ const wantsFile = format === "gif" || format === "svg";
307
+ // Checked before anything is spent: the file would hold one format under another's name.
308
+ const outExtension = String(last(flags.out) ?? "").match(/\.(png|jpe?g|webp|gif|svg)$/i)?.[1].toLowerCase();
309
+ if (wantsFile && outExtension && outExtension !== format) {
310
+ die(`--format ${format} returns a .${format} file, but --out names a .${outExtension} one. Change one of them.`);
311
+ }
274
312
  const body = {
275
- model: last(flags.model) || "gpt-image-2",
313
+ async: true,
314
+ // The default model has no GIF or SVG output, so asking for one picks a model that does.
315
+ model: last(flags.model) || (wantsFile ? "gpt-image-2.6" : "gpt-image-2"),
276
316
  ...(prompt ? { prompt } : {}),
277
317
  ...(last(flags.layers) ? { num_layers: Number(last(flags.layers)) } : {}),
278
318
  ...(last(flags.size) ? { size: last(flags.size) } : {}),
279
319
  ...(last(flags.resolution) ? { resolution: last(flags.resolution) } : {}),
280
320
  ...(last(flags.quality) ? { quality: last(flags.quality) } : {}),
321
+ ...(last(flags.format) ? { output_format: last(flags.format) } : {}),
322
+ ...(last(flags.frames) ? { frame_count: Number(last(flags.frames)) } : {}),
323
+ ...(last(flags["frame-ms"]) ? { frame_duration_ms: Number(last(flags["frame-ms"])) } : {}),
281
324
  ...(last(flags["aspect-ratio"]) ? { aspect_ratio: last(flags["aspect-ratio"]) } : {}),
282
325
  ...(last(flags.n) ? { n: Number(last(flags.n)) } : {}),
283
326
  ...(last(flags.seed) ? { seed: Number(last(flags.seed)) } : {}),
@@ -291,7 +334,11 @@ async function cmdImage(prompt, flags) {
291
334
  body: JSON.stringify(body),
292
335
  });
293
336
  if (!res.ok) die(await readError(res));
294
- const json = await res.json();
337
+ const json = await resolveJob("image", await res.json(), flags);
338
+ if (json.id) {
339
+ printJob("image", json);
340
+ if (json.status === "failed") die(json.error || "image generation failed", 2);
341
+ }
295
342
  const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
296
343
  // A layer stack names each line, so `headline` can be found among eight URLs.
297
344
  (json.data ?? []).filter((d) => d.url).forEach((d) =>
@@ -325,7 +372,7 @@ async function cmdVideo(prompt, flags) {
325
372
  ...webhook(flags),
326
373
  ...collection(flags),
327
374
  };
328
- console.error("submitting video job (this can take a minute)…");
375
+ console.error("submitting video job…");
329
376
  const res = await fetch(`${baseUrl}/api/v1/videos/generations`, {
330
377
  method: "POST",
331
378
  headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
@@ -335,7 +382,7 @@ async function cmdVideo(prompt, flags) {
335
382
  let job = await res.json();
336
383
  if (job.status === "failed") die(job.error || "video generation failed", 2);
337
384
  if (body.webhook_url) process.stderr.write(`webhook: ${body.webhook_url} will be notified when the job finishes\n`);
338
- if (flags.wait !== false) {
385
+ if (flags.wait === true) {
339
386
  process.stderr.write(`job ${job.id} accepted; waiting for completion…\n`);
340
387
  while (job.status === "queued" || job.status === "processing") {
341
388
  await new Promise((resolve) => setTimeout(resolve, 3_000));
@@ -344,6 +391,7 @@ async function cmdVideo(prompt, flags) {
344
391
  job = await status.json();
345
392
  }
346
393
  }
394
+ printJob("video", job);
347
395
  if (job.status === "failed") die(job.error || "video generation failed", 2);
348
396
  const url = job.data?.[0]?.url;
349
397
  if (url) {
@@ -353,8 +401,6 @@ async function cmdVideo(prompt, flags) {
353
401
  process.stderr.write(`draft ${job.id} — render it in 1080p within seven days: ottoport video --model ${model} --draft-id ${job.id}\n`);
354
402
  }
355
403
  if (flags.out) await download(url, last(flags.out));
356
- } else {
357
- console.log(JSON.stringify(job, null, 2));
358
404
  }
359
405
  }
360
406
 
@@ -400,7 +446,8 @@ async function cmdAudio(kind, prompt, flags) {
400
446
  if (!prompt && !video) die(`${kind} requires a prompt: ottoport ${kind} "hello"`);
401
447
  const { baseUrl, apiKey } = config(flags);
402
448
  const body = {
403
- model: last(flags.model) || (kind === "speech" ? "gpt-4o-mini-tts" : "suno-v5"),
449
+ async: true,
450
+ model: last(flags.model) || (kind === "speech" ? "gpt-4o-mini-tts" : "suno-v6"),
404
451
  ...(prompt ? { prompt } : {}),
405
452
  ...(video ? { video_url: video } : {}),
406
453
  ...(last(flags.voice) ? { voice: last(flags.voice) } : {}),
@@ -411,11 +458,16 @@ async function cmdAudio(kind, prompt, flags) {
411
458
  const path = kind === "speech" ? "audio/speech" : "audio/music";
412
459
  const res = await fetch(`${baseUrl}/api/v1/${path}`, {
413
460
  method: "POST",
414
- headers: headers(apiKey),
461
+ headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
415
462
  body: JSON.stringify(body),
416
463
  });
417
464
  if (!res.ok) die(await readError(res));
418
- const json = await res.json();
465
+ const json = await resolveJob(kind, await res.json(), flags);
466
+ if (json.id) {
467
+ printJob(kind, json);
468
+ if (json.status === "failed") die(json.error || "audio generation failed", 2);
469
+ if (["queued", "processing"].includes(json.status)) return;
470
+ }
419
471
  const item = json.data?.[0];
420
472
  if (!item) die("no audio returned", 2);
421
473
  if (item.url) console.log(outputLine(item));
@@ -486,6 +538,7 @@ Usage:
486
538
  ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k] [--quality high]
487
539
  [--aspect-ratio 16:9] [--n 1] [--seed 7]
488
540
  [--image <url>] [--ref <url>[,<url>…]] [--out file.png]
541
+ ottoport image "<prompt>" --format gif|svg [--model gpt-image-2.6] [--frames 8] [--frame-ms 200] [--wait --out loop.gif]
489
542
  ottoport image --model qwen-image-layered --image <url> [--layers 4]
490
543
  split one image into RGBA layers (prompt optional)
491
544
  ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p]
@@ -502,7 +555,7 @@ Usage:
502
555
  ottoport webhook status <job-id> [--json] delivery attempts and responses
503
556
  ottoport webhook retry <job-id> [--json] redeliver a finished job's webhook now
504
557
  ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--format mp3] [--out speech.mp3]
505
- ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--format mp3] [--out song.mp3]
558
+ ottoport music "<prompt>" [--model suno-v6] [--duration 15] [--format mp3] [--out song.mp3]
506
559
  ottoport music ["<prompt>"] --model mirelo-sfx-2.0 --video <url> [--duration 8] [--out sfx.wav]
507
560
  a soundtrack synced to the video, dialogue kept;
508
561
  set --duration to the clip's length (it bills)
@@ -537,6 +590,8 @@ Examples:
537
590
  ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
538
591
  ottoport music "rain on a tin roof" --model mirelo-sfx-2.0 --duration 10 --format mp3 --out rain.mp3
539
592
  ottoport music --model mirelo-sfx-2.0 --video https://example.com/chase.mp4 --duration 8 --out chase.wav
593
+ Media commands return jobs immediately. Add --wait to wait for results.
594
+ ottoport job image|video|speech|music <id> [--wait] [--out file]
540
595
  ottoport collections create "Spring campaign"
541
596
  ottoport image "spring hero banner" --collection <collection-id>
542
597
  ottoport mcp
@@ -554,6 +609,7 @@ async function main() {
554
609
  case "chat": return await cmdChat(prompt, flags);
555
610
  case "image": return await cmdImage(prompt, flags);
556
611
  case "video": return await cmdVideo(prompt, flags);
612
+ case "job": return await cmdJob(rest[0], rest[1], flags);
557
613
  case "webhook": return await cmdWebhook(rest[0], rest[1], flags);
558
614
  case "speech": return await cmdAudio("speech", prompt, flags);
559
615
  case "music": return await cmdAudio("music", prompt, flags);
package/commands/image.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Generate an image through the OttoPort gateway
3
- argument-hint: "<prompt> [--model <id>] [--resolution 2k] [--quality high] [--ref <url>…]"
3
+ argument-hint: "<prompt> [--model <id>] [--resolution 2k] [--quality high] [--ref <url>…] [--format gif|svg]"
4
4
  allowed-tools: mcp__ottoport__ottoport_generate_image, mcp__ottoport__ottoport_list_models, Bash(curl:*)
5
5
  ---
6
6
 
@@ -9,6 +9,12 @@ Generate an image with the `ottoport_generate_image` MCP tool.
9
9
  Request: $ARGUMENTS
10
10
 
11
11
  - Pass `model` only if the user named one; the tool defaults to `gpt-image-2`.
12
+ - An animated GIF or an SVG file comes from this tool, not from a video model:
13
+ set `output_format` to `gif` or `svg`, and the tool picks `gpt-image-2.6`. Only
14
+ that model and `gpt-image-2.6-flare` offer them, from a prompt alone, one file
15
+ per request. Put the motion and the loop in a GIF's prompt, and send no `size`,
16
+ `resolution` or `aspect_ratio` with it. `frame_count` is 2–16 (default 8; above
17
+ 8 bills double) and `frame_duration_ms` is 20–2000.
12
18
  - An edit of an existing image goes through `image_url`, not the prompt. Several
13
19
  references at once — a character plus the scene, a product plus a style — go
14
20
  through `reference_image_urls`.
@@ -16,7 +22,7 @@ Request: $ARGUMENTS
16
22
  `size` instead when the user named exact pixels. A higher tier costs more, so
17
23
  send one only when the request asks for it.
18
24
  - On the GPT Image models, `quality` buys compute rather than pixels — `low`,
19
- `medium` (the default), `high`, and `xhigh`/`max` on `gpt-image-2.5`. It is
25
+ `medium` (the default), `high`, and `xhigh`/`max` on `gpt-image-2.6` and `gpt-image-2.5`. It is
20
26
  billed steeply, so send it only when the user asked for a draft or a finish.
21
27
  - Models differ in how many references and which resolutions they take. If a
22
28
  request needs more than one reference, or 4k, check `ottoport_list_models`
@@ -11,7 +11,7 @@ backing track — or for sound effects, ambience or a soundtrack for a video, wi
11
11
  `model` `mirelo-sfx-2.0` (pass the video as `video_url`, and `duration` as its
12
12
  length); otherwise use `ottoport_generate_speech` to read the text aloud.
13
13
  Pass `voice` and `format` only when named — speech defaults to `gpt-4o-mini-tts`
14
- with the `alloy` voice, music to `suno-v5`.
14
+ with the `alloy` voice, music to `suno-v6`.
15
15
 
16
16
  Print the returned audio URL, and save it to disk with `curl -sSL` only if the
17
17
  user asked for a file.
package/mcp/server.mjs CHANGED
@@ -185,6 +185,9 @@ function accepts(capabilities) {
185
185
  if (Array.isArray(capabilities.resolutions) && capabilities.resolutions.length) {
186
186
  parts.push(capabilities.resolutions.join("/"));
187
187
  }
188
+ if (Array.isArray(capabilities.outputFormats) && capabilities.outputFormats.length) {
189
+ parts.push(`output_format ${capabilities.outputFormats.join("/")}`);
190
+ }
188
191
  return parts.length ? ` · accepts ${parts.join(", ")}` : "";
189
192
  }
190
193
 
@@ -217,6 +220,7 @@ async function describeModel(id) {
217
220
  : caps.resolutions;
218
221
  lines.push(`Resolutions: ${tiers.join(", ")}${caps.base_resolution ? ` (base ${caps.base_resolution})` : ""}`);
219
222
  }
223
+ if (caps?.outputFormats?.length) lines.push(`Output formats: ${caps.outputFormats.join(", ")}, besides images, via output_format`);
220
224
  lines.push("Parameters:");
221
225
  for (const p of m.parameters ?? []) {
222
226
  lines.push(` ${p.name} (${p.type}${p.required ? ", required" : ""}) — ${p.note}`);
@@ -282,9 +286,13 @@ async function waitForJob(statusPath, job, { timeoutMs, label }) {
282
286
  while (job.status === "queued" || job.status === "processing") {
283
287
  if (Date.now() >= deadline) return { job, timedOut: true, path };
284
288
  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();
289
+ try {
290
+ const status = await fetch(`${BASE}${path}`, { headers: headers(false), signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) });
291
+ if (!status.ok) throw new Error(await gwError(status));
292
+ job = await status.json();
293
+ } catch (error) {
294
+ return { job: { ...job, poll_error: error.message }, timedOut: true, path };
295
+ }
288
296
  }
289
297
  if (job.status === "failed") throw new Error(job.error || `${label} generation failed`);
290
298
  return { job, timedOut: false, path };
@@ -293,7 +301,21 @@ async function waitForJob(statusPath, job, { timeoutMs, label }) {
293
301
  const IMAGE_JOB_PATH = "/api/v1/images/generations/{id}";
294
302
  const VIDEO_JOB_PATH = "/api/v1/videos/generations/{id}";
295
303
 
296
- async function generateImage({ prompt, model, collection, ...rest }) {
304
+ const JOB_PATHS = { image: IMAGE_JOB_PATH, video: VIDEO_JOB_PATH, speech: "/api/v1/audio/speech/{id}", music: "/api/v1/audio/music/{id}" };
305
+ function describeJob(kind, job) {
306
+ 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);
307
+ }
308
+ async function getJob({ kind, job_id, wait = false }) {
309
+ if (!Object.hasOwn(JOB_PATHS, kind) || !job_id) throw new Error("Valid kind and job_id are required");
310
+ const path = JOB_PATHS[kind].replace("{id}", encodeURIComponent(job_id));
311
+ const res = await fetch(`${BASE}${path}`, { headers: headers(false) });
312
+ if (!res.ok) throw new Error(await gwError(res));
313
+ let job = await res.json();
314
+ if (wait) ({ job } = await waitForJob(JOB_PATHS[kind], job, { timeoutMs: 45_000, label: kind }));
315
+ return describeJob(kind, job);
316
+ }
317
+
318
+ async function generateImage({ prompt, model, collection, wait = false, ...rest }) {
297
319
  // A layer-decomposition model splits `image_url` and takes the prompt as an
298
320
  // optional caption; every other image model needs the prompt. The gateway
299
321
  // knows which is which and says so.
@@ -311,15 +333,17 @@ async function generateImage({ prompt, model, collection, ...rest }) {
311
333
  // Queued as a job and polled: a 4K or layered image can take longer than
312
334
  // a proxy in front of the gateway holds a request open, and the hold is
313
335
  // then settled on what actually came back.
314
- body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), async: true }),
336
+ // The default model has no GIF or SVG output, so asking for one picks a model that does.
337
+ body: JSON.stringify({ model: model || (rest.output_format === "gif" || rest.output_format === "svg" ? "gpt-image-2.6" : "gpt-image-2"), ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), async: true }),
315
338
  });
316
339
  if (!res.ok) throw new Error(await gwError(res));
317
340
  let json = await res.json();
318
341
  // A 202 is a job. A 200 with `data` is the image itself — the dev key, or a
319
342
  // gateway that predates image jobs — and is read exactly as before.
320
343
  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.`;
344
+ if (!wait) return describeJob("image", json);
345
+ const { job, timedOut, path } = await waitForJob(IMAGE_JOB_PATH, json, { timeoutMs: 45_000, label: "image" });
346
+ if (timedOut) return describeJob("image", job);
323
347
  json = job;
324
348
  }
325
349
  // A layer stack prints one line per layer with what the model called it, so
@@ -330,7 +354,7 @@ async function generateImage({ prompt, model, collection, ...rest }) {
330
354
  return lines.length ? lines.join("\n") : "(no image returned)";
331
355
  }
332
356
 
333
- async function generateVideo({ prompt, model, webhook_url, webhook_secret, collection, ...rest }) {
357
+ async function generateVideo({ prompt, model, webhook_url, webhook_secret, collection, wait = false, ...rest }) {
334
358
  // A depth model takes only `video_url`; the gateway says which models need a prompt.
335
359
  // A draft completion takes everything from its draft, the prompt included.
336
360
  if (!prompt && !rest.video_url && !rest.draft_id) throw new Error("`prompt` is required");
@@ -347,14 +371,10 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, colle
347
371
  if (!res.ok) throw new Error(await gwError(res));
348
372
  const submitted = await res.json();
349
373
  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.`;
374
+ // Return the accepted job before the MCP client timeout; waiting is opt-in.
375
+ if (!wait || webhook_url) return describeJob("video", submitted);
376
+ const { job, timedOut, path } = await waitForJob(VIDEO_JOB_PATH, submitted, { timeoutMs: 45_000, label: "video" });
377
+ if (timedOut) return describeJob("video", job);
358
378
  const output = job.data?.[0];
359
379
  const url = output?.url ? outputLine(output) : undefined;
360
380
  // The draft's job id is what completes it, so it is worth saying.
@@ -375,13 +395,14 @@ async function videoWebhook({ job_id, action = "status" }) {
375
395
  return JSON.stringify({ id: body.id, status: body.status, webhook: body.webhook ?? null }, null, 2);
376
396
  }
377
397
 
378
- async function generateAudio(kind, { prompt, model, voice, duration, format, video_url, collection }) {
398
+ async function generateAudio(kind, { prompt, model, voice, duration, format, video_url, collection, wait = false }) {
379
399
  if (!prompt && !video_url) throw new Error("`prompt` is required");
380
400
  const res = await fetch(`${BASE}/api/v1/audio/${kind === "tts" ? "speech" : "music"}`, {
381
401
  method: "POST",
382
- headers: headers(),
402
+ headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
383
403
  body: JSON.stringify({
384
- model: model || (kind === "tts" ? "gpt-4o-mini-tts" : "suno-v5"),
404
+ async: true,
405
+ model: model || (kind === "tts" ? "gpt-4o-mini-tts" : "suno-v6"),
385
406
  ...(prompt ? { prompt } : {}),
386
407
  ...(voice ? { voice } : {}),
387
408
  ...(video_url ? { video_url } : {}),
@@ -391,7 +412,14 @@ async function generateAudio(kind, { prompt, model, voice, duration, format, vid
391
412
  }),
392
413
  });
393
414
  if (!res.ok) throw new Error(await gwError(res));
394
- const json = await res.json();
415
+ let json = await res.json();
416
+ if (res.status === 202) {
417
+ const jobKind = kind === "tts" ? "speech" : "music";
418
+ if (!wait) return describeJob(jobKind, json);
419
+ const result = await waitForJob(JOB_PATHS[jobKind], json, { timeoutMs: 45_000, label: jobKind });
420
+ if (result.timedOut) return describeJob(jobKind, result.job);
421
+ json = result.job;
422
+ }
395
423
  const audio = json.data?.[0];
396
424
  if (!audio) return "(no audio returned)";
397
425
  if (audio.url || audio.asset_url) return outputLine(audio);
@@ -400,6 +428,12 @@ async function generateAudio(kind, { prompt, model, voice, duration, format, vid
400
428
 
401
429
  // ── tool registry ────────────────────────────────────────────────────
402
430
  const TOOLS = [
431
+ {
432
+ name: "ottoport_get_job",
433
+ description: "Read an existing image, video, speech or music job, including progress, errors and result URLs. Never resubmits or bills a new generation.",
434
+ 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"] },
435
+ handler: getJob,
436
+ },
403
437
  {
404
438
  name: "ottoport_list_models",
405
439
  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.",
@@ -430,12 +464,13 @@ const TOOLS = [
430
464
  },
431
465
  {
432
466
  name: "ottoport_generate_image",
433
- 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).",
467
+ 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. Animated GIFs and SVG files from a prompt: set output_format on gpt-image-2.6 or its Flare tier, rather than generating a video. 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.",
434
468
  inputSchema: {
435
469
  type: "object",
436
470
  properties: {
471
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
437
472
  prompt: { type: "string", description: "What to generate. On a layer-decomposition model, an optional caption of the input image." },
438
- model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2.1, nano-banana-pro. Default gpt-image-2." },
473
+ model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2.1, nano-banana-pro. Default gpt-image-2, or gpt-image-2.6 when output_format is gif or svg." },
439
474
  size: { type: "string", description: 'Exact pixels, e.g. "1024x1024". Wins over `resolution`.' },
440
475
  // No `enum`: the vocabulary is "1k"/"2k"/"4k", but which of them a given
441
476
  // model actually offers differs per model, and an enum here reads as a
@@ -444,7 +479,7 @@ const TOOLS = [
444
479
  // at best. The model's own menu is in `ottoport_list_models`.
445
480
  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.' },
446
481
  aspect_ratio: { type: "string", description: 'e.g. "16:9". Read only when `size` is absent.' },
447
- 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.' },
482
+ 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.' },
448
483
  n: { type: "number" },
449
484
  seed: { type: "number" },
450
485
  image_url: { type: "string", description: "One reference image, for editing and image-to-image — or, on a layer-decomposition model, the image to split." },
@@ -453,6 +488,10 @@ const TOOLS = [
453
488
  horizontal_angle: { type: "number", description: "Camera models only (qwen-image-angles): orbit in degrees, 0–360. Needs image_url; prompt optional." },
454
489
  vertical_angle: { type: "number", description: "Camera models only: elevation in degrees, -30 (below) to 90 (overhead)." },
455
490
  zoom: { type: "number", description: "Camera models only: 0 (wide) to 10 (close)." },
491
+ 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." },
492
+ frame_count: { type: "number", description: "With output_format gif: frames, 2–16, default 8. Above 8 bills double." },
493
+ frame_duration_ms: { type: "number", description: "With output_format gif: milliseconds per frame, 20–2000." },
494
+ vector_mode: { type: "string", enum: ["default", "simple"], description: "ideogram-vector-1-vectorize only: `default` keeps finer detail, `simple` traces fewer, simpler shapes. Same price." },
456
495
  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." },
457
496
  },
458
497
  },
@@ -460,10 +499,11 @@ const TOOLS = [
460
499
  },
461
500
  {
462
501
  name: "ottoport_generate_video",
463
- 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.",
502
+ 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.",
464
503
  inputSchema: {
465
504
  type: "object",
466
505
  properties: {
506
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
467
507
  prompt: { type: "string", description: "Required, except on video-depth-anything, which takes none, and the motion-control models, where it is optional." },
468
508
  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." },
469
509
  duration: { type: "number", description: "Seconds. The main lever on what this costs." },
@@ -507,10 +547,11 @@ const TOOLS = [
507
547
  },
508
548
  {
509
549
  name: "ottoport_generate_speech",
510
- description: "Convert text to speech through an OttoPort TTS model. Returns an audio URL or data URL.",
550
+ description: "Convert text to speech through an OttoPort TTS model. Defaults to asynchronous submission; returns job_id. Query ottoport_get_job for audio results.",
511
551
  inputSchema: {
512
552
  type: "object",
513
553
  properties: {
554
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
514
555
  prompt: { type: "string", description: "Text to synthesize." },
515
556
  model: { type: "string", description: "TTS model id. Default gpt-4o-mini-tts." },
516
557
  voice: { type: "string", description: "Optional provider voice id." },
@@ -523,12 +564,13 @@ const TOOLS = [
523
564
  },
524
565
  {
525
566
  name: "ottoport_generate_music",
526
- 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. Returns an audio URL or data URL.",
567
+ 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.",
527
568
  inputSchema: {
528
569
  type: "object",
529
570
  properties: {
571
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
530
572
  prompt: { type: "string", description: "Music-generation prompt, or the sounds to make. Optional with video_url." },
531
- model: { type: "string", description: "Music model id. Default suno-v5; mirelo-sfx-2.0 for sound effects." },
573
+ model: { type: "string", description: "Music model id. Default suno-v6; mirelo-sfx-2.0 for sound effects." },
532
574
  duration: { type: "number", description: "Requested duration in seconds." },
533
575
  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." },
534
576
  format: { type: "string", description: 'e.g. "mp3" or "wav".' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ottoport",
3
- "version": "1.14.0",
3
+ "version": "1.15.2",
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": {
@@ -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
@@ -88,9 +89,10 @@ sensible default.
88
89
  | --- | --- | --- |
89
90
  | chat | `claude-sonnet-5` | `claude-haiku-4.5` (cheap), `gpt-5.5`, `gemini-3.5-flash` |
90
91
  | image | `gpt-image-2` | `nano-banana-2.1` (fast generation and edits), `nano-banana-pro` (high fidelity) |
92
+ | GIF or SVG file | `gpt-image-2.6`, with `output_format` on the image tool | `gpt-image-2.6-flare` (faster) |
91
93
  | video | `kling-3.0` | `seedance-2.0-fast` (cheap), `veo-3.1` (with audio) |
92
94
  | speech | `gpt-4o-mini-tts` | `eleven-v3` |
93
- | music | `suno-v5` | `lyria-2`, `mirelo-sfx-2.0` (sound effects, or a soundtrack for a video) |
95
+ | music | `suno-v6` | `lyria-2`, `mirelo-sfx-2.0` (sound effects, or a soundtrack for a video) |
94
96
 
95
97
  Never invent a model id. Call `ottoport_list_models` when unsure — the catalog
96
98
  changes, and a wrong id is a failed billable call. Rates come back in credits (the unit the ledger
@@ -143,3 +145,20 @@ music live under `/api/v1/images/generations`, `/videos/generations`,
143
145
  - The CLI always targets `https://ottoport.ai`; pass `--url` explicitly only
144
146
  for local development or a self-hosted gateway.
145
147
  - `OTTOPORT_BASE_URL` configures the MCP server only.
148
+
149
+
150
+ ## Asynchronous media jobs (1.15.1)
151
+
152
+ 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.
153
+
154
+ ```sh
155
+ ottoport image "a mountain lake" --model midjourney-v8.2
156
+ ottoport job image <job-id>
157
+ ottoport job image <job-id> --wait --out lake.png
158
+ # Explicit waiting is also available on submission:
159
+ ottoport image "a mountain lake" --wait --out lake.png
160
+ ```
161
+
162
+ 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.
163
+
164
+ 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.