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.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +34 -4
- package/cli/ottoport.mjs +54 -8
- package/mcp/server.mjs +60 -23
- package/package.json +1 -1
- package/skills/ottoport/SKILL.md +22 -4
|
@@ -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
|
@@ -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
|
|
99
|
-
default every catalog rate is measured at), `high`, and on `gpt-image-2.
|
|
100
|
-
`gpt-image-2.
|
|
101
|
-
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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,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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -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
|
|
@@ -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.
|