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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +35 -4
- package/cli/ottoport.mjs +65 -10
- package/commands/speech.md +4 -2
- package/mcp/server.mjs +66 -28
- package/package.json +1 -1
- package/skills/ottoport/SKILL.md +25 -7
|
@@ -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
|
|
4
|
-
"version": "1.
|
|
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
|
|
98
|
-
default every catalog rate is measured at), `high`, and on `gpt-image-2.
|
|
99
|
-
`gpt-image-2.
|
|
100
|
-
spends, and the ladder is steep:
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
package/commands/speech.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
530
|
-
|
|
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.
|
|
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": {
|
package/skills/ottoport/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
71
|
-
compute, not pixels, and the ladder is steep — `max` bills
|
|
72
|
-
`medium
|
|
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.
|