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