ottoport 1.14.0 → 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.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.1",
5
5
  "author": {
6
6
  "name": "LITBOX LLC",
7
7
  "email": "support@ottoport.ai"
package/README.md CHANGED
@@ -95,16 +95,29 @@ 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
+
108
121
  Which modes and tiers a model accepts differs per model, so
109
122
  `ottoport_list_models` — and `ottoport models` in the CLI — prints each one's
110
123
  menu beside its price. For one model in full, including every parameter it
@@ -150,3 +163,20 @@ https://ottoport.ai/docs#webhooks.
150
163
  Docs: <https://ottoport.ai/docs> · Support: support@ottoport.ai
151
164
 
152
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
@@ -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 = [];
@@ -267,17 +267,50 @@ function references(flags, name = "ref") {
267
267
  return list.length ? list : undefined;
268
268
  }
269
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
+
270
299
  async function cmdImage(prompt, flags) {
271
300
  // A layer-decomposition model needs only --image; the prompt is a caption.
272
301
  if (!prompt && !last(flags.image)) die("image requires a prompt: ottoport image \"a red fox\" (or --image <url> on a layer model)");
273
302
  const { baseUrl, apiKey } = config(flags);
274
303
  const body = {
304
+ async: true,
275
305
  model: last(flags.model) || "gpt-image-2",
276
306
  ...(prompt ? { prompt } : {}),
277
307
  ...(last(flags.layers) ? { num_layers: Number(last(flags.layers)) } : {}),
278
308
  ...(last(flags.size) ? { size: last(flags.size) } : {}),
279
309
  ...(last(flags.resolution) ? { resolution: last(flags.resolution) } : {}),
280
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"])) } : {}),
281
314
  ...(last(flags["aspect-ratio"]) ? { aspect_ratio: last(flags["aspect-ratio"]) } : {}),
282
315
  ...(last(flags.n) ? { n: Number(last(flags.n)) } : {}),
283
316
  ...(last(flags.seed) ? { seed: Number(last(flags.seed)) } : {}),
@@ -291,7 +324,11 @@ async function cmdImage(prompt, flags) {
291
324
  body: JSON.stringify(body),
292
325
  });
293
326
  if (!res.ok) die(await readError(res));
294
- 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
+ }
295
332
  const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
296
333
  // A layer stack names each line, so `headline` can be found among eight URLs.
297
334
  (json.data ?? []).filter((d) => d.url).forEach((d) =>
@@ -325,7 +362,7 @@ async function cmdVideo(prompt, flags) {
325
362
  ...webhook(flags),
326
363
  ...collection(flags),
327
364
  };
328
- console.error("submitting video job (this can take a minute)…");
365
+ console.error("submitting video job…");
329
366
  const res = await fetch(`${baseUrl}/api/v1/videos/generations`, {
330
367
  method: "POST",
331
368
  headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
@@ -335,7 +372,7 @@ async function cmdVideo(prompt, flags) {
335
372
  let job = await res.json();
336
373
  if (job.status === "failed") die(job.error || "video generation failed", 2);
337
374
  if (body.webhook_url) process.stderr.write(`webhook: ${body.webhook_url} will be notified when the job finishes\n`);
338
- if (flags.wait !== false) {
375
+ if (flags.wait === true) {
339
376
  process.stderr.write(`job ${job.id} accepted; waiting for completion…\n`);
340
377
  while (job.status === "queued" || job.status === "processing") {
341
378
  await new Promise((resolve) => setTimeout(resolve, 3_000));
@@ -344,6 +381,7 @@ async function cmdVideo(prompt, flags) {
344
381
  job = await status.json();
345
382
  }
346
383
  }
384
+ printJob("video", job);
347
385
  if (job.status === "failed") die(job.error || "video generation failed", 2);
348
386
  const url = job.data?.[0]?.url;
349
387
  if (url) {
@@ -353,8 +391,6 @@ async function cmdVideo(prompt, flags) {
353
391
  process.stderr.write(`draft ${job.id} — render it in 1080p within seven days: ottoport video --model ${model} --draft-id ${job.id}\n`);
354
392
  }
355
393
  if (flags.out) await download(url, last(flags.out));
356
- } else {
357
- console.log(JSON.stringify(job, null, 2));
358
394
  }
359
395
  }
360
396
 
@@ -400,6 +436,7 @@ async function cmdAudio(kind, prompt, flags) {
400
436
  if (!prompt && !video) die(`${kind} requires a prompt: ottoport ${kind} "hello"`);
401
437
  const { baseUrl, apiKey } = config(flags);
402
438
  const body = {
439
+ async: true,
403
440
  model: last(flags.model) || (kind === "speech" ? "gpt-4o-mini-tts" : "suno-v5"),
404
441
  ...(prompt ? { prompt } : {}),
405
442
  ...(video ? { video_url: video } : {}),
@@ -411,11 +448,16 @@ async function cmdAudio(kind, prompt, flags) {
411
448
  const path = kind === "speech" ? "audio/speech" : "audio/music";
412
449
  const res = await fetch(`${baseUrl}/api/v1/${path}`, {
413
450
  method: "POST",
414
- headers: headers(apiKey),
451
+ headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
415
452
  body: JSON.stringify(body),
416
453
  });
417
454
  if (!res.ok) die(await readError(res));
418
- 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
+ }
419
461
  const item = json.data?.[0];
420
462
  if (!item) die("no audio returned", 2);
421
463
  if (item.url) console.log(outputLine(item));
@@ -486,6 +528,7 @@ Usage:
486
528
  ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k] [--quality high]
487
529
  [--aspect-ratio 16:9] [--n 1] [--seed 7]
488
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]
489
532
  ottoport image --model qwen-image-layered --image <url> [--layers 4]
490
533
  split one image into RGBA layers (prompt optional)
491
534
  ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p]
@@ -537,6 +580,8 @@ Examples:
537
580
  ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
538
581
  ottoport music "rain on a tin roof" --model mirelo-sfx-2.0 --duration 10 --format mp3 --out rain.mp3
539
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]
540
585
  ottoport collections create "Spring campaign"
541
586
  ottoport image "spring hero banner" --collection <collection-id>
542
587
  ottoport mcp
@@ -554,6 +599,7 @@ async function main() {
554
599
  case "chat": return await cmdChat(prompt, flags);
555
600
  case "image": return await cmdImage(prompt, flags);
556
601
  case "video": return await cmdVideo(prompt, flags);
602
+ case "job": return await cmdJob(rest[0], rest[1], flags);
557
603
  case "webhook": return await cmdWebhook(rest[0], rest[1], flags);
558
604
  case "speech": return await cmdAudio("speech", prompt, flags);
559
605
  case "music": return await cmdAudio("music", prompt, flags);
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,12 +390,13 @@ 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, video_url, collection }) {
393
+ async function generateAudio(kind, { prompt, model, voice, duration, format, video_url, collection, wait = false }) {
379
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
401
  ...(prompt ? { prompt } : {}),
386
402
  ...(voice ? { voice } : {}),
@@ -391,7 +407,14 @@ async function generateAudio(kind, { prompt, model, voice, duration, format, vid
391
407
  }),
392
408
  });
393
409
  if (!res.ok) throw new Error(await gwError(res));
394
- 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
+ }
395
418
  const audio = json.data?.[0];
396
419
  if (!audio) return "(no audio returned)";
397
420
  if (audio.url || audio.asset_url) return outputLine(audio);
@@ -400,6 +423,12 @@ async function generateAudio(kind, { prompt, model, voice, duration, format, vid
400
423
 
401
424
  // ── tool registry ────────────────────────────────────────────────────
402
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
+ },
403
432
  {
404
433
  name: "ottoport_list_models",
405
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.",
@@ -430,10 +459,11 @@ const TOOLS = [
430
459
  },
431
460
  {
432
461
  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).",
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.",
434
463
  inputSchema: {
435
464
  type: "object",
436
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." },
437
467
  prompt: { type: "string", description: "What to generate. On a layer-decomposition model, an optional caption of the input image." },
438
468
  model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2.1, nano-banana-pro. Default gpt-image-2." },
439
469
  size: { type: "string", description: 'Exact pixels, e.g. "1024x1024". Wins over `resolution`.' },
@@ -444,7 +474,7 @@ const TOOLS = [
444
474
  // at best. The model's own menu is in `ottoport_list_models`.
445
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.' },
446
476
  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.' },
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.' },
448
478
  n: { type: "number" },
449
479
  seed: { type: "number" },
450
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." },
@@ -453,6 +483,10 @@ const TOOLS = [
453
483
  horizontal_angle: { type: "number", description: "Camera models only (qwen-image-angles): orbit in degrees, 0–360. Needs image_url; prompt optional." },
454
484
  vertical_angle: { type: "number", description: "Camera models only: elevation in degrees, -30 (below) to 90 (overhead)." },
455
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." },
456
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." },
457
491
  },
458
492
  },
@@ -460,10 +494,11 @@ const TOOLS = [
460
494
  },
461
495
  {
462
496
  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.",
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.",
464
498
  inputSchema: {
465
499
  type: "object",
466
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." },
467
502
  prompt: { type: "string", description: "Required, except on video-depth-anything, which takes none, and the motion-control models, where it is optional." },
468
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." },
469
504
  duration: { type: "number", description: "Seconds. The main lever on what this costs." },
@@ -507,10 +542,11 @@ const TOOLS = [
507
542
  },
508
543
  {
509
544
  name: "ottoport_generate_speech",
510
- 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.",
511
546
  inputSchema: {
512
547
  type: "object",
513
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." },
514
550
  prompt: { type: "string", description: "Text to synthesize." },
515
551
  model: { type: "string", description: "TTS model id. Default gpt-4o-mini-tts." },
516
552
  voice: { type: "string", description: "Optional provider voice id." },
@@ -523,10 +559,11 @@ const TOOLS = [
523
559
  },
524
560
  {
525
561
  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.",
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.",
527
563
  inputSchema: {
528
564
  type: "object",
529
565
  properties: {
566
+ wait: { type: "boolean", description: "Default false: return job immediately. True waits up to 45 seconds, then returns the same job for polling." },
530
567
  prompt: { type: "string", description: "Music-generation prompt, or the sounds to make. Optional with video_url." },
531
568
  model: { type: "string", description: "Music model id. Default suno-v5; mirelo-sfx-2.0 for sound effects." },
532
569
  duration: { type: "number", description: "Requested duration in seconds." },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ottoport",
3
- "version": "1.14.0",
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": {
@@ -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
@@ -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.