ottoport 1.10.0 → 1.12.0
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 +1 -1
- package/README.md +9 -5
- package/cli/ottoport.mjs +53 -3
- package/mcp/server.mjs +206 -18
- package/package.json +1 -1
- package/skills/ottoport/SKILL.md +22 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ottoport",
|
|
3
3
|
"description": "One API for every model. Call chat, image, video, speech and music models through the OttoPort gateway — as MCP tools, slash commands, or the bundled CLI.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.12.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "LITBOX LLC",
|
|
7
7
|
"email": "support@ottoport.ai"
|
package/README.md
CHANGED
|
@@ -78,19 +78,23 @@ forwarded from the environment rather than written into any config file.
|
|
|
78
78
|
|
|
79
79
|
`ottoport_list_models`, `ottoport_chat`, `ottoport_generate_image`,
|
|
80
80
|
`ottoport_generate_video`, `ottoport_video_webhook`, `ottoport_generate_speech`,
|
|
81
|
-
`ottoport_generate_music
|
|
81
|
+
`ottoport_generate_music`, and for keeping results, `ottoport_list_collections`,
|
|
82
|
+
`ottoport_create_collection`, `ottoport_list_assets`, `ottoport_save_asset`,
|
|
83
|
+
`ottoport_upload_file` and `ottoport_delete_asset`. Every generate tool takes
|
|
84
|
+
`collection` (an id or name) to save what it makes; `OTTOPORT_COLLECTION` sets a default.
|
|
82
85
|
The server speaks stdio JSON-RPC and is launched by the host as a subprocess, so
|
|
83
86
|
it needs Node 20+ on the PATH of whatever starts that host.
|
|
84
87
|
|
|
85
88
|
### Modes and resolution
|
|
86
89
|
|
|
87
|
-
Video takes
|
|
88
|
-
first-and-last-frame (`image_url` + `last_frame_url`),
|
|
89
|
-
(`reference_image_urls`)
|
|
90
|
+
Video takes five modes — text-to-video, image-to-video (`image_url`),
|
|
91
|
+
first-and-last-frame (`image_url` + `last_frame_url`), reference-to-video
|
|
92
|
+
(`reference_image_urls`), and Kling motion control (`image_url` + `video_url`:
|
|
93
|
+
the character performs the clip's movement). Images take one reference (`image_url`) or several
|
|
90
94
|
(`reference_image_urls`). Both take a `resolution`: `480p`…`4k` for video,
|
|
91
95
|
`1k`/`2k`/`4k` for images, and a tier above a model's base rate costs more.
|
|
92
96
|
|
|
93
|
-
The GPT Image family
|
|
97
|
+
The GPT Image family and `ideogram-4.5` read a `quality` on top of that — `low`, `medium` (the
|
|
94
98
|
default every catalog rate is measured at), `high`, and on `gpt-image-2.5` and
|
|
95
99
|
`gpt-image-2.5-flare` also `xhigh` and `max`. It is how much compute the model
|
|
96
100
|
spends, and the ladder is steep: `max` bills sixteen times `medium`.
|
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 collections [list | create "name"]
|
|
13
14
|
// ottoport mcp
|
|
14
15
|
// ottoport install <codex|claude|hermes|cursor|windsurf|claude-desktop>
|
|
15
16
|
//
|
|
@@ -278,6 +279,7 @@ async function cmdImage(prompt, flags) {
|
|
|
278
279
|
...(last(flags.seed) ? { seed: Number(last(flags.seed)) } : {}),
|
|
279
280
|
...(last(flags.image) ? { image_url: flags.image } : {}),
|
|
280
281
|
...(references(flags) ? { reference_image_urls: references(flags) } : {}),
|
|
282
|
+
...collection(flags),
|
|
281
283
|
};
|
|
282
284
|
const res = await fetch(`${baseUrl}/api/v1/images/generations`, {
|
|
283
285
|
method: "POST",
|
|
@@ -289,7 +291,7 @@ async function cmdImage(prompt, flags) {
|
|
|
289
291
|
const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
|
|
290
292
|
// A layer stack names each line, so `headline` can be found among eight URLs.
|
|
291
293
|
(json.data ?? []).filter((d) => d.url).forEach((d) =>
|
|
292
|
-
console.log(d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}:
|
|
294
|
+
console.log(outputLine(d, d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}: ` : "")));
|
|
293
295
|
if (last(flags.out) && urls[0]) await download(urls[0], last(flags.out));
|
|
294
296
|
}
|
|
295
297
|
|
|
@@ -311,6 +313,7 @@ async function cmdVideo(prompt, flags) {
|
|
|
311
313
|
...(references(flags, "ref-video") ? { reference_video_urls: references(flags, "ref-video") } : {}),
|
|
312
314
|
...(references(flags, "ref-audio") ? { reference_audio_urls: references(flags, "ref-audio") } : {}),
|
|
313
315
|
...webhook(flags),
|
|
316
|
+
...collection(flags),
|
|
314
317
|
};
|
|
315
318
|
console.error("submitting video job (this can take a minute)…");
|
|
316
319
|
const res = await fetch(`${baseUrl}/api/v1/videos/generations`, {
|
|
@@ -334,7 +337,7 @@ async function cmdVideo(prompt, flags) {
|
|
|
334
337
|
if (job.status === "failed") die(job.error || "video generation failed", 2);
|
|
335
338
|
const url = job.data?.[0]?.url;
|
|
336
339
|
if (url) {
|
|
337
|
-
console.log(
|
|
340
|
+
console.log(outputLine(job.data[0]));
|
|
338
341
|
if (flags.out) await download(url, last(flags.out));
|
|
339
342
|
} else {
|
|
340
343
|
console.log(JSON.stringify(job, null, 2));
|
|
@@ -386,6 +389,7 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
386
389
|
...(last(flags.voice) ? { voice: last(flags.voice) } : {}),
|
|
387
390
|
...(last(flags.duration) ? { duration: Number(last(flags.duration)) } : {}),
|
|
388
391
|
...(last(flags.format) ? { format: last(flags.format) } : {}),
|
|
392
|
+
...collection(flags),
|
|
389
393
|
};
|
|
390
394
|
const path = kind === "speech" ? "audio/speech" : "audio/music";
|
|
391
395
|
const res = await fetch(`${baseUrl}/api/v1/${path}`, {
|
|
@@ -397,7 +401,7 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
397
401
|
const json = await res.json();
|
|
398
402
|
const item = json.data?.[0];
|
|
399
403
|
if (!item) die("no audio returned", 2);
|
|
400
|
-
if (item.url) console.log(item
|
|
404
|
+
if (item.url) console.log(outputLine(item));
|
|
401
405
|
if (flags.out) {
|
|
402
406
|
if (item.b64_json) {
|
|
403
407
|
const bytes = Buffer.from(item.b64_json, "base64");
|
|
@@ -409,6 +413,45 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
409
413
|
}
|
|
410
414
|
}
|
|
411
415
|
|
|
416
|
+
/** `--collection <id>` keeps the results for good instead of letting them expire. */
|
|
417
|
+
function collection(flags) {
|
|
418
|
+
return last(flags.collection) ? { collection: last(flags.collection) } : {};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The URL to use — the kept one when the result was saved — and what became of the save. */
|
|
422
|
+
function outputLine(item, label = "") {
|
|
423
|
+
const saved = item.asset_id ? ` (saved as asset ${item.asset_id})` : item.asset_error ? ` (not saved: ${item.asset_error})` : "";
|
|
424
|
+
return `${label}${item.asset_url || item.url}${saved}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function cmdCollections(action, name, flags) {
|
|
428
|
+
const { baseUrl, apiKey } = config(flags);
|
|
429
|
+
if (action === "create") {
|
|
430
|
+
if (!name) die('collections create needs a name: ottoport collections create "Spring campaign"');
|
|
431
|
+
const res = await fetch(`${baseUrl}/api/v1/collections`, {
|
|
432
|
+
method: "POST", headers: headers(apiKey),
|
|
433
|
+
body: JSON.stringify({ name, ...(last(flags.description) ? { description: last(flags.description) } : {}), ...(last(flags["external-id"]) ? { external_id: last(flags["external-id"]) } : {}) }),
|
|
434
|
+
});
|
|
435
|
+
if (!res.ok) die(await readError(res));
|
|
436
|
+
const created = await res.json();
|
|
437
|
+
if (flags.json) return void console.log(JSON.stringify(created, null, 2));
|
|
438
|
+
return void console.log(`${created.id} ${created.name}`);
|
|
439
|
+
}
|
|
440
|
+
if (action && action !== "list") die(`unknown collections action: ${action} (list | create)`);
|
|
441
|
+
const res = await fetch(`${baseUrl}/api/v1/collections?limit=200`, { headers: headers(apiKey, false) });
|
|
442
|
+
if (!res.ok) die(await readError(res));
|
|
443
|
+
const body = await res.json();
|
|
444
|
+
if (flags.json) return void console.log(JSON.stringify(body, null, 2));
|
|
445
|
+
const { access } = body;
|
|
446
|
+
if (!access.enabled) {
|
|
447
|
+
console.error(access.override === false
|
|
448
|
+
? "collections are disabled for this account"
|
|
449
|
+
: `collections are locked: top up $${(access.threshold_cents / 100).toFixed(2)} in total to unlock ($${(access.remaining_cents / 100).toFixed(2)} to go)`);
|
|
450
|
+
}
|
|
451
|
+
for (const c of body.data) console.log(`${c.id} ${c.name} (${c.asset_count ?? 0} asset${c.asset_count === 1 ? "" : "s"})`);
|
|
452
|
+
if (!body.data.length) console.error("no collections in this project yet");
|
|
453
|
+
}
|
|
454
|
+
|
|
412
455
|
const HELP = `OttoPort — one command for every model.
|
|
413
456
|
|
|
414
457
|
Usage:
|
|
@@ -434,6 +477,8 @@ Usage:
|
|
|
434
477
|
ottoport webhook retry <job-id> [--json] redeliver a finished job's webhook now
|
|
435
478
|
ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--format mp3] [--out speech.mp3]
|
|
436
479
|
ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--format mp3] [--out song.mp3]
|
|
480
|
+
ottoport collections [list] [--json] collections in the key's project
|
|
481
|
+
ottoport collections create "<name>" [--description "..."] [--external-id <id>]
|
|
437
482
|
ottoport mcp
|
|
438
483
|
ottoport install <host> wire the MCP server (and skill) into an agent
|
|
439
484
|
hosts: codex, claude, hermes, cursor, windsurf, claude-desktop
|
|
@@ -443,6 +488,8 @@ Global flags:
|
|
|
443
488
|
--url <base> override https://ottoport.ai (local/self-hosted development)
|
|
444
489
|
--key <key> OttoPort API key (env OTTOPORT_API_KEY)
|
|
445
490
|
--webhook-secret <s> signs webhook deliveries (env OTTOPORT_WEBHOOK_SECRET)
|
|
491
|
+
--collection <id> on image, video, speech, music: keep the results in
|
|
492
|
+
that collection instead of letting them expire
|
|
446
493
|
|
|
447
494
|
Examples:
|
|
448
495
|
export OTTOPORT_API_KEY=op-...
|
|
@@ -459,6 +506,8 @@ Examples:
|
|
|
459
506
|
ottoport image --model seedream-5.0-layers --image https://…/poster.png
|
|
460
507
|
ottoport speech "Welcome to OttoPort" --voice alloy --out welcome.mp3
|
|
461
508
|
ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
|
|
509
|
+
ottoport collections create "Spring campaign"
|
|
510
|
+
ottoport image "spring hero banner" --collection <collection-id>
|
|
462
511
|
ottoport mcp
|
|
463
512
|
ottoport install codex
|
|
464
513
|
`;
|
|
@@ -477,6 +526,7 @@ async function main() {
|
|
|
477
526
|
case "webhook": return await cmdWebhook(rest[0], rest[1], flags);
|
|
478
527
|
case "speech": return await cmdAudio("speech", prompt, flags);
|
|
479
528
|
case "music": return await cmdAudio("music", prompt, flags);
|
|
529
|
+
case "collections": return await cmdCollections(rest[0], rest.slice(1).join(" "), flags);
|
|
480
530
|
case "mcp": return await import(new URL("../mcp/server.mjs", import.meta.url));
|
|
481
531
|
case "install": {
|
|
482
532
|
const { cmdInstall } = await import(new URL("./install.mjs", import.meta.url));
|
package/mcp/server.mjs
CHANGED
|
@@ -6,14 +6,16 @@
|
|
|
6
6
|
// transport directly (newline-delimited messages).
|
|
7
7
|
//
|
|
8
8
|
// Config via env:
|
|
9
|
-
// OTTOPORT_API_KEY
|
|
10
|
-
// OTTOPORT_BASE_URL
|
|
9
|
+
// OTTOPORT_API_KEY your op-… key (or the dev OTTOPORT_KEY_SECRET)
|
|
10
|
+
// OTTOPORT_BASE_URL gateway base URL (default http://localhost:3014)
|
|
11
|
+
// OTTOPORT_COLLECTION optional collection (id or name) every generation is saved into
|
|
11
12
|
//
|
|
12
13
|
// Register with Claude Code:
|
|
13
14
|
// claude mcp add ottoport -- node /abs/path/to/mcp/server.mjs
|
|
14
15
|
|
|
15
16
|
import { createInterface } from "node:readline";
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
+
import { readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { basename, extname, resolve as resolvePath } from "node:path";
|
|
17
19
|
|
|
18
20
|
const PROTOCOL_VERSION = "2024-11-05";
|
|
19
21
|
// Read from the package rather than repeated here: a version that says 0.1.0
|
|
@@ -43,6 +45,113 @@ async function gwError(res) {
|
|
|
43
45
|
return `${res.status} ${res.statusText}`;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// ── collections ──────────────────────────────────────────────────────
|
|
49
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
50
|
+
|
|
51
|
+
async function api(path, init = {}) {
|
|
52
|
+
const res = await fetch(`${BASE}${path}`, { ...init, headers: { ...headers(Boolean(init.body)), ...(init.headers ?? {}) } });
|
|
53
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
54
|
+
return res.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The collection id for what the agent (or OTTOPORT_COLLECTION) named.
|
|
59
|
+
*
|
|
60
|
+
* The gateway takes ids only, because names repeat and get renamed. People
|
|
61
|
+
* say names, though — "save it to Spring campaign" — so a name is looked up
|
|
62
|
+
* here: one match is used, several or none is an error that lists what
|
|
63
|
+
* exists, so the agent asks rather than guesses.
|
|
64
|
+
*/
|
|
65
|
+
async function collectionId(ref) {
|
|
66
|
+
const wanted = typeof ref === "string" && ref.trim() ? ref.trim() : process.env.OTTOPORT_COLLECTION?.trim();
|
|
67
|
+
if (!wanted) return undefined;
|
|
68
|
+
if (UUID.test(wanted)) return wanted;
|
|
69
|
+
const { data } = await api("/api/v1/collections?limit=200");
|
|
70
|
+
const matches = data.filter((c) => c.name.toLowerCase() === wanted.toLowerCase());
|
|
71
|
+
if (matches.length === 1) return matches[0].id;
|
|
72
|
+
const known = data.map((c) => `"${c.name}" (${c.id})`).join(", ") || "none yet";
|
|
73
|
+
if (matches.length > 1) throw new Error(`Several collections are named "${wanted}": ${matches.map((c) => c.id).join(", ")}. Pass the id.`);
|
|
74
|
+
throw new Error(`No collection named "${wanted}". Existing collections: ${known}. Create one with ottoport_create_collection.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One output line: the kept URL when it was saved, and what became of the save. */
|
|
78
|
+
function outputLine(item, label = "") {
|
|
79
|
+
const url = item.asset_url || item.url;
|
|
80
|
+
const saved = item.asset_id ? ` (saved as asset ${item.asset_id})` : item.asset_error ? ` (not saved: ${item.asset_error})` : "";
|
|
81
|
+
return `${label}${url}${saved}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const withCollection = (id) => (id ? { collection: id } : {});
|
|
85
|
+
|
|
86
|
+
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
87
|
+
const megabytes = (bytes) => `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
88
|
+
|
|
89
|
+
async function listCollections() {
|
|
90
|
+
const { data, access } = await api("/api/v1/collections?limit=200");
|
|
91
|
+
const state = access.enabled
|
|
92
|
+
? `Collections are on: ${megabytes(access.used_bytes)} of ${megabytes(access.quota_bytes)} used.`
|
|
93
|
+
: access.override === false
|
|
94
|
+
? "Collections are disabled for this account; nothing can be saved."
|
|
95
|
+
: `Collections are locked: they unlock once the account has topped up ${dollars(access.threshold_cents)} in total (${dollars(access.remaining_cents)} to go, at ${BASE}/dashboard). Existing collections can still be read.`;
|
|
96
|
+
const rows = data.map((c) => `${c.name} — id ${c.id} · ${c.asset_count ?? 0} asset${c.asset_count === 1 ? "" : "s"}${c.external_id ? ` · external_id ${c.external_id}` : ""}${c.description ? ` — ${c.description}` : ""}`);
|
|
97
|
+
return [state, ...(rows.length ? rows : ["No collections in this project yet."])].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function createCollection({ name, description, external_id }) {
|
|
101
|
+
if (!name) throw new Error("`name` is required");
|
|
102
|
+
const c = await api("/api/v1/collections", { method: "POST", body: JSON.stringify({ name, ...(description ? { description } : {}), ...(external_id ? { external_id } : {}) }) });
|
|
103
|
+
return `Created collection "${c.name}" — id ${c.id}. Pass it as \`collection\` to any generate tool to save the results there.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function listAssets({ collection, kind, limit = 20 }) {
|
|
107
|
+
const id = await collectionId(collection);
|
|
108
|
+
const qs = new URLSearchParams({ limit: String(limit), ...(id ? { collection_id: id } : {}), ...(kind ? { kind } : {}) });
|
|
109
|
+
const { data, total } = await api(`/api/v1/assets?${qs}`);
|
|
110
|
+
if (!data.length) return "No saved assets.";
|
|
111
|
+
const lines = data.map((a) => `${a.kind} ${a.id}: ${a.url}${a.model ? ` · ${a.model}` : ""}${a.prompt ? ` — "${a.prompt.slice(0, 80)}${a.prompt.length > 80 ? "…" : ""}"` : ""}`);
|
|
112
|
+
return `${lines.join("\n")}${total > data.length ? `\n(${total - data.length} more)` : ""}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function saveAsset({ url, collection, prompt }) {
|
|
116
|
+
if (!url) throw new Error("`url` is required");
|
|
117
|
+
const id = await collectionId(collection);
|
|
118
|
+
if (!id) throw new Error("`collection` is required (an id or a name), or set OTTOPORT_COLLECTION");
|
|
119
|
+
const a = await api("/api/v1/assets", { method: "POST", body: JSON.stringify({ url, collection: id, ...(prompt ? { prompt } : {}) }) });
|
|
120
|
+
return `Saved as asset ${a.id}: ${a.url}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function deleteAsset({ asset_id }) {
|
|
124
|
+
if (!asset_id) throw new Error("`asset_id` is required");
|
|
125
|
+
await api(`/api/v1/assets/${encodeURIComponent(asset_id)}`, { method: "DELETE" });
|
|
126
|
+
return `Deleted asset ${asset_id}.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const UPLOAD_TYPES = {
|
|
130
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif",
|
|
131
|
+
".mp4": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
|
|
132
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Put a local file where a model can read it. Agents hold paths, and every
|
|
137
|
+
* model input is a URL, so without this a reference image on disk is out of
|
|
138
|
+
* reach. The upload itself is temporary; `collection` also keeps it.
|
|
139
|
+
*/
|
|
140
|
+
async function uploadFile({ path, collection }) {
|
|
141
|
+
if (!path) throw new Error("`path` is required");
|
|
142
|
+
const file = resolvePath(path);
|
|
143
|
+
const type = UPLOAD_TYPES[extname(file).toLowerCase()];
|
|
144
|
+
if (!type) throw new Error(`Cannot upload ${basename(file)}: use PNG, JPEG, WebP, GIF, MP4, MOV, WebM, MP3, WAV, OGG, M4A or FLAC.`);
|
|
145
|
+
const { size } = statSync(file);
|
|
146
|
+
const signed = await api("/api/v1/uploads", { method: "POST", body: JSON.stringify({ content_type: type, size }) });
|
|
147
|
+
const put = await fetch(signed.upload_url, { method: "PUT", headers: signed.headers, body: readFileSync(file) });
|
|
148
|
+
if (!put.ok) throw new Error(`Upload failed (HTTP ${put.status})`);
|
|
149
|
+
const id = await collectionId(collection);
|
|
150
|
+
if (!id) return `Uploaded ${basename(file)}: ${signed.url}\nUse this URL as a model input until ${signed.expires_at}. Pass \`collection\` to keep it.`;
|
|
151
|
+
const a = await api("/api/v1/assets", { method: "POST", body: JSON.stringify({ url: signed.url, collection: id, metadata: { filename: basename(file) } }) });
|
|
152
|
+
return `Uploaded ${basename(file)} and saved it as asset ${a.id}: ${a.url}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
46
155
|
// ── tool implementations ─────────────────────────────────────────────
|
|
47
156
|
/**
|
|
48
157
|
* What a model takes, in one clause.
|
|
@@ -179,7 +288,7 @@ async function waitForJob(statusPath, job, { timeoutMs, label }) {
|
|
|
179
288
|
const IMAGE_JOB_PATH = "/api/v1/images/generations/{id}";
|
|
180
289
|
const VIDEO_JOB_PATH = "/api/v1/videos/generations/{id}";
|
|
181
290
|
|
|
182
|
-
async function generateImage({ prompt, model, ...rest }) {
|
|
291
|
+
async function generateImage({ prompt, model, collection, ...rest }) {
|
|
183
292
|
// A layer-decomposition model splits `image_url` and takes the prompt as an
|
|
184
293
|
// optional caption; every other image model needs the prompt. The gateway
|
|
185
294
|
// knows which is which and says so.
|
|
@@ -197,7 +306,7 @@ async function generateImage({ prompt, model, ...rest }) {
|
|
|
197
306
|
// Queued as a job and polled: a 4K or layered image can take longer than
|
|
198
307
|
// a proxy in front of the gateway holds a request open, and the hold is
|
|
199
308
|
// then settled on what actually came back.
|
|
200
|
-
body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, async: true }),
|
|
309
|
+
body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), async: true }),
|
|
201
310
|
});
|
|
202
311
|
if (!res.ok) throw new Error(await gwError(res));
|
|
203
312
|
let json = await res.json();
|
|
@@ -211,12 +320,12 @@ async function generateImage({ prompt, model, ...rest }) {
|
|
|
211
320
|
// A layer stack prints one line per layer with what the model called it, so
|
|
212
321
|
// the agent can pick "headline" out of eight URLs without opening them.
|
|
213
322
|
const lines = (json.data ?? []).filter((d) => d.url).map((d) =>
|
|
214
|
-
d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}:
|
|
323
|
+
outputLine(d, d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}: ` : ""),
|
|
215
324
|
);
|
|
216
325
|
return lines.length ? lines.join("\n") : "(no image returned)";
|
|
217
326
|
}
|
|
218
327
|
|
|
219
|
-
async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...rest }) {
|
|
328
|
+
async function generateVideo({ prompt, model, webhook_url, webhook_secret, collection, ...rest }) {
|
|
220
329
|
// A depth model takes only `video_url`; the gateway says which models need a prompt.
|
|
221
330
|
// A draft completion takes everything from its draft, the prompt included.
|
|
222
331
|
if (!prompt && !rest.video_url && !rest.draft_id) throw new Error("`prompt` is required");
|
|
@@ -228,7 +337,7 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...re
|
|
|
228
337
|
method: "POST",
|
|
229
338
|
headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
|
|
230
339
|
// Passed through; see `generateImage` for why this layer names nothing.
|
|
231
|
-
body: JSON.stringify({ model: model || "kling-3.0", ...(prompt ? { prompt } : {}), ...rest, ...(webhook_url ? { webhook_url, webhook_secret: secret } : {}) }),
|
|
340
|
+
body: JSON.stringify({ model: model || "kling-3.0", ...(prompt ? { prompt } : {}), ...rest, ...withCollection(await collectionId(collection)), ...(webhook_url ? { webhook_url, webhook_secret: secret } : {}) }),
|
|
232
341
|
});
|
|
233
342
|
if (!res.ok) throw new Error(await gwError(res));
|
|
234
343
|
const submitted = await res.json();
|
|
@@ -241,7 +350,8 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...re
|
|
|
241
350
|
// job rather than returning a queued-job JSON blob to the agent.
|
|
242
351
|
const { job, timedOut, path } = await waitForJob(VIDEO_JOB_PATH, submitted, { timeoutMs: 10 * 60_000, label: "video" });
|
|
243
352
|
if (timedOut) return `Video job ${job.id} is still processing. Poll ${path} for its result.`;
|
|
244
|
-
const
|
|
353
|
+
const output = job.data?.[0];
|
|
354
|
+
const url = output?.url ? outputLine(output) : undefined;
|
|
245
355
|
// The draft's job id is what completes it, so it is worth saying.
|
|
246
356
|
if (url && rest.draft) return `${url}\nDraft ${job.id} — complete it at 1080p with draft_id "${job.id}" within seven days.`;
|
|
247
357
|
return url || JSON.stringify(job);
|
|
@@ -259,7 +369,7 @@ async function videoWebhook({ job_id, action = "status" }) {
|
|
|
259
369
|
return JSON.stringify({ id: body.id, status: body.status, webhook: body.webhook ?? null }, null, 2);
|
|
260
370
|
}
|
|
261
371
|
|
|
262
|
-
async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
372
|
+
async function generateAudio(kind, { prompt, model, voice, duration, format, collection }) {
|
|
263
373
|
if (!prompt) throw new Error("`prompt` is required");
|
|
264
374
|
const res = await fetch(`${BASE}/api/v1/audio/${kind === "tts" ? "speech" : "music"}`, {
|
|
265
375
|
method: "POST",
|
|
@@ -270,13 +380,15 @@ async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
|
270
380
|
...(voice ? { voice } : {}),
|
|
271
381
|
...(duration ? { duration } : {}),
|
|
272
382
|
...(format ? { format } : {}),
|
|
383
|
+
...withCollection(await collectionId(collection)),
|
|
273
384
|
}),
|
|
274
385
|
});
|
|
275
386
|
if (!res.ok) throw new Error(await gwError(res));
|
|
276
387
|
const json = await res.json();
|
|
277
388
|
const audio = json.data?.[0];
|
|
278
389
|
if (!audio) return "(no audio returned)";
|
|
279
|
-
|
|
390
|
+
if (audio.url || audio.asset_url) return outputLine(audio);
|
|
391
|
+
return audio.b64_json ? `data:audio;base64,${audio.b64_json}` : "(no audio returned)";
|
|
280
392
|
}
|
|
281
393
|
|
|
282
394
|
// ── tool registry ────────────────────────────────────────────────────
|
|
@@ -316,7 +428,7 @@ const TOOLS = [
|
|
|
316
428
|
type: "object",
|
|
317
429
|
properties: {
|
|
318
430
|
prompt: { type: "string", description: "What to generate. On a layer-decomposition model, an optional caption of the input image." },
|
|
319
|
-
model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2, nano-banana-pro. Default gpt-image-2." },
|
|
431
|
+
model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2.1, nano-banana-pro. Default gpt-image-2." },
|
|
320
432
|
size: { type: "string", description: 'Exact pixels, e.g. "1024x1024". Wins over `resolution`.' },
|
|
321
433
|
// No `enum`: the vocabulary is "1k"/"2k"/"4k", but which of them a given
|
|
322
434
|
// model actually offers differs per model, and an enum here reads as a
|
|
@@ -325,7 +437,7 @@ const TOOLS = [
|
|
|
325
437
|
// at best. The model's own menu is in `ottoport_list_models`.
|
|
326
438
|
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.' },
|
|
327
439
|
aspect_ratio: { type: "string", description: 'e.g. "16:9". Read only when `size` is absent.' },
|
|
328
|
-
quality: { type: "string", description: 'How much compute the model spends, on the GPT Image family
|
|
440
|
+
quality: { type: "string", description: 'How much compute the model spends, on the GPT Image family and ideogram-4.5 — "low", "medium" (the default), "high", and on gpt-image-2.5 "xhigh" and "max". Priced by tier, and the ladder is steep: `max` costs sixteen times `medium`. Check the model\'s `qualities` in ottoport_list_models.' },
|
|
329
441
|
n: { type: "number" },
|
|
330
442
|
seed: { type: "number" },
|
|
331
443
|
image_url: { type: "string", description: "One reference image, for editing and image-to-image — or, on a layer-decomposition model, the image to split." },
|
|
@@ -334,36 +446,40 @@ const TOOLS = [
|
|
|
334
446
|
horizontal_angle: { type: "number", description: "Camera models only (qwen-image-angles): orbit in degrees, 0–360. Needs image_url; prompt optional." },
|
|
335
447
|
vertical_angle: { type: "number", description: "Camera models only: elevation in degrees, -30 (below) to 90 (overhead)." },
|
|
336
448
|
zoom: { type: "number", description: "Camera models only: 0 (wide) to 10 (close)." },
|
|
449
|
+
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." },
|
|
337
450
|
},
|
|
338
451
|
},
|
|
339
452
|
handler: generateImage,
|
|
340
453
|
},
|
|
341
454
|
{
|
|
342
455
|
name: "ottoport_generate_video",
|
|
343
|
-
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` 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. Which a model accepts is in its capabilities — call ottoport_list_models. Blocks on the queue and returns a video URL, unless `webhook_url` is given.",
|
|
456
|
+
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` renders a cheap 480p preview, and `draft_id` set to that job's id renders the same shot at 1080p. video-depth-anything instead turns `video_url` into a grayscale depth video and takes no prompt. Kling motion control (`kling-2.6-motion-control`, `kling-3.0-motion-control` and their `-pro` variants) makes the character in `image_url` perform the movement in `video_url`; the prompt is optional and the output runs as long as the clip. Which a model accepts is in its capabilities — call ottoport_list_models. Blocks on the queue and returns a video URL, unless `webhook_url` is given.",
|
|
344
457
|
inputSchema: {
|
|
345
458
|
type: "object",
|
|
346
459
|
properties: {
|
|
347
|
-
prompt: { type: "string", description: "Required, except on video-depth-anything, which takes none." },
|
|
348
|
-
model: { type: "string", description: "Model id, e.g. veo-3.1, kling-3.0, seedance-2.0-fast, video-depth-anything. Default kling-3.0." },
|
|
460
|
+
prompt: { type: "string", description: "Required, except on video-depth-anything, which takes none, and the motion-control models, where it is optional." },
|
|
461
|
+
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." },
|
|
349
462
|
duration: { type: "number", description: "Seconds. The main lever on what this costs." },
|
|
350
463
|
// Per-model, not universal — see the note on the image tool. kling-3.0
|
|
351
464
|
// has no 480p and no 4k; gemini-omni-flash has only 720p.
|
|
352
465
|
resolution: { type: "string", description: 'Output detail — "480p", "720p", "1080p", "2k" or "4k", but only the tiers this model lists in ottoport_list_models. Priced accordingly, so it is worth reading before spending.' },
|
|
353
466
|
aspect_ratio: { type: "string", description: 'e.g. "16:9".' },
|
|
354
467
|
seed: { type: "number" },
|
|
355
|
-
image_url: { type: "string", description: "First frame, for image-to-video." },
|
|
468
|
+
image_url: { type: "string", description: "First frame, for image-to-video — or, on a motion-control model, the character who performs the movement." },
|
|
356
469
|
last_frame_url: { type: "string", description: "Final frame. With `image_url`, this is first-and-last-frame generation." },
|
|
357
470
|
reference_image_urls: { type: "array", items: { type: "string" }, description: "Subjects the shot carries through — a character, a product, a style — without being a frame of it." },
|
|
358
471
|
reference_video_urls: { type: "array", items: { type: "string" }, description: "Reference videos — motion, a performance, a scene — on models listing `referenceMedia` (Seedance 2.x, MiniMax H3, Wan 3.0). Billed per second of reference video where the upstream charges for it. To change one clip rather than draw on it, send it as `video_url` on a model listing video-edit." },
|
|
359
472
|
reference_audio_urls: { type: "array", items: { type: "string" }, description: "Reference audio — a voice, a beat — beside reference images or videos, on the same models. Not billed." },
|
|
360
473
|
keyframes: { type: "array", items: { type: "object", properties: { image_url: { type: "string" }, time: { type: "number", description: "Seconds from the start; omit to spread evenly." } }, required: ["image_url"] }, description: "Images pinned in time, for keyframes-to-video." },
|
|
361
|
-
video_url: { type: "string", description: "A video to edit or extend, on models listing video-edit / video-extend — or the only input to video-depth-anything. Edits and
|
|
474
|
+
video_url: { type: "string", description: "A video to edit or extend, on models listing video-edit / video-extend — or the only input to video-depth-anything, or the movement a motion-control model transfers (3-30s; 3-10s with `character_orientation` \"image\"). Edits, depth and motion control bill per second of this video (edits and depth at most 60s; motion control at least 3s)." },
|
|
475
|
+
character_orientation: { type: "string", enum: ["video", "image"], description: "Motion-control models only: whether the character faces the way the person in `video_url` does (\"video\", the default) or keeps the pose of `image_url` (\"image\")." },
|
|
476
|
+
keep_original_sound: { type: "boolean", description: "Motion-control models only: keep the audio of `video_url` in the result." },
|
|
362
477
|
video_task: { type: "string", enum: ["edit", "extend"], description: "edit (default) restyles video_url; extend continues it for `duration` more seconds." },
|
|
363
478
|
draft: { type: "boolean", description: "Seedance 2.5 only: render a 480p preview at about a quarter of the 1080p price, to check the shot before paying for it. Text, image and reference-image requests; no `resolution`." },
|
|
364
479
|
draft_id: { type: "string", description: "Seedance 2.5 only: the job id of a finished draft, to render that same shot at 1080p. Send `model` and this alone — prompt, inputs and duration come from the draft. Within seven days of the draft." },
|
|
365
480
|
webhook_url: { type: "string", description: "Public HTTPS endpoint to POST the finished job to (events video.completed / video.failed, signed with HMAC-SHA256). With it the tool returns the job id at once instead of waiting." },
|
|
366
481
|
webhook_secret: { type: "string", description: "Signing secret for webhook_url. Omit to use the server's OTTOPORT_WEBHOOK_SECRET." },
|
|
482
|
+
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." },
|
|
367
483
|
},
|
|
368
484
|
required: [],
|
|
369
485
|
},
|
|
@@ -392,6 +508,7 @@ const TOOLS = [
|
|
|
392
508
|
model: { type: "string", description: "TTS model id. Default gpt-4o-mini-tts." },
|
|
393
509
|
voice: { type: "string", description: "Optional provider voice id." },
|
|
394
510
|
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
511
|
+
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." },
|
|
395
512
|
},
|
|
396
513
|
required: ["prompt"],
|
|
397
514
|
},
|
|
@@ -407,11 +524,82 @@ const TOOLS = [
|
|
|
407
524
|
model: { type: "string", description: "Music model id. Default suno-v5." },
|
|
408
525
|
duration: { type: "number", description: "Requested duration in seconds." },
|
|
409
526
|
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
527
|
+
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." },
|
|
410
528
|
},
|
|
411
529
|
required: ["prompt"],
|
|
412
530
|
},
|
|
413
531
|
handler: (args) => generateAudio("music", args),
|
|
414
532
|
},
|
|
533
|
+
{
|
|
534
|
+
name: "ottoport_list_collections",
|
|
535
|
+
description: "Collections in this API key's project — where generated files are kept for good instead of expiring after a few days — with each one's id and asset count, and whether this account may save (collections unlock once the account has topped up $10 in total). Read it before saving, to find the collection the user means.",
|
|
536
|
+
inputSchema: { type: "object", properties: {} },
|
|
537
|
+
handler: listCollections,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: "ottoport_create_collection",
|
|
541
|
+
description: "Create a collection: the user's own project, campaign or character, whose generations should be kept together. Returns its id, to pass as `collection` to the generate tools. Needs collections unlocked.",
|
|
542
|
+
inputSchema: {
|
|
543
|
+
type: "object",
|
|
544
|
+
properties: {
|
|
545
|
+
name: { type: "string", description: "Up to 80 characters." },
|
|
546
|
+
description: { type: "string" },
|
|
547
|
+
external_id: { type: "string", description: "The caller's own id for what this collection holds, unique in the project." },
|
|
548
|
+
},
|
|
549
|
+
required: ["name"],
|
|
550
|
+
},
|
|
551
|
+
handler: createCollection,
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
name: "ottoport_list_assets",
|
|
555
|
+
description: "Saved assets, newest first: each one's id, kind, permanent URL, model and prompt. Filter to one collection (id or name) and one kind.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
type: "object",
|
|
558
|
+
properties: {
|
|
559
|
+
collection: { type: "string", description: "Collection id or exact name. Omit for every collection in the project." },
|
|
560
|
+
kind: { type: "string", enum: ["image", "video", "audio"] },
|
|
561
|
+
limit: { type: "number", description: "Default 20, at most 200." },
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
handler: listAssets,
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "ottoport_save_asset",
|
|
568
|
+
description: "Keep a file that was already generated or uploaded — its OttoPort URL, before it expires — in a collection. Use when the user picks a result after the fact; to save while generating, pass `collection` to the generate tool instead.",
|
|
569
|
+
inputSchema: {
|
|
570
|
+
type: "object",
|
|
571
|
+
properties: {
|
|
572
|
+
url: { type: "string", description: "The OttoPort URL a generate tool or ottoport_upload_file returned." },
|
|
573
|
+
collection: { type: "string", description: "Collection id or exact name." },
|
|
574
|
+
prompt: { type: "string", description: "The prompt that made it, to keep alongside." },
|
|
575
|
+
},
|
|
576
|
+
required: ["url"],
|
|
577
|
+
},
|
|
578
|
+
handler: saveAsset,
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
name: "ottoport_upload_file",
|
|
582
|
+
description: "Upload a local image, video or audio file and get a URL any model accepts as input (a reference image, a first frame, a motion clip). The URL lasts a day; pass `collection` to also keep the file for good.",
|
|
583
|
+
inputSchema: {
|
|
584
|
+
type: "object",
|
|
585
|
+
properties: {
|
|
586
|
+
path: { type: "string", description: "Path of the file on this machine." },
|
|
587
|
+
collection: { type: "string", description: "Collection id or exact name to save it into." },
|
|
588
|
+
},
|
|
589
|
+
required: ["path"],
|
|
590
|
+
},
|
|
591
|
+
handler: uploadFile,
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "ottoport_delete_asset",
|
|
595
|
+
description: "Delete a saved asset and its file for good. Ask the user first.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
type: "object",
|
|
598
|
+
properties: { asset_id: { type: "string" } },
|
|
599
|
+
required: ["asset_id"],
|
|
600
|
+
},
|
|
601
|
+
handler: deleteAsset,
|
|
602
|
+
},
|
|
415
603
|
];
|
|
416
604
|
|
|
417
605
|
const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ottoport",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
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
|
@@ -16,11 +16,20 @@ and do not call the HTTP API, when a tool covers the job:
|
|
|
16
16
|
| --- | --- |
|
|
17
17
|
| `ottoport_list_models` | The catalog: what each model is for, and its rate in credits and USD. Filter with `modality`, or pass `model` for ONE model in full — its modes, its resolution tiers with prices, and every parameter it reads. |
|
|
18
18
|
| `ottoport_chat` | `prompt`, plus optional `model`, `system`, `temperature`, `max_tokens`. |
|
|
19
|
-
| `ottoport_generate_image` | `prompt`, plus optional `model`, `size` or `resolution`, `quality` (GPT Image
|
|
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), `draft` / `draft_id` (Seedance 2.5: a 480p preview, then the same shot at 1080p from the preview's job id), `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`). |
|
|
19
|
+
| `ottoport_generate_image` | `prompt`, plus optional `model`, `size` or `resolution`, `quality` (GPT Image and Ideogram), `aspect_ratio`, `n`, `seed`, `image_url` (one reference), `reference_image_urls` (several). Layer decomposition too: `model` `qwen-image-layered` or `seedream-5.0-layers` with `image_url` alone (prompt optional, `num_layers` on Qwen) returns one URL per RGBA layer. |
|
|
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), `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
23
|
| `ottoport_generate_music` | `prompt`, plus optional `model`, `duration`, `format`. |
|
|
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
|
+
| `ottoport_create_collection` | `name`, plus optional `description`, `external_id`. |
|
|
26
|
+
| `ottoport_list_assets` | Saved files, optionally by `collection` and `kind`. |
|
|
27
|
+
| `ottoport_save_asset` | `url` (an OttoPort result or upload, before it expires) and `collection` — for keeping a result the user picks after the fact. |
|
|
28
|
+
| `ottoport_upload_file` | `path` of a local file → a URL any model takes as input; with `collection` it is kept too. |
|
|
29
|
+
| `ottoport_delete_asset` | `asset_id`. Permanent — confirm with the user first. |
|
|
30
|
+
|
|
31
|
+
Every generate tool also takes `collection` (an id or exact name) to keep its
|
|
32
|
+
results; the server's `OTTOPORT_COLLECTION` sets a default.
|
|
24
33
|
|
|
25
34
|
If those tools are not present, the server is not registered in this client —
|
|
26
35
|
say so rather than reaching for a shell. `ottoport install <host>` registers it
|
|
@@ -28,7 +37,7 @@ say so rather than reaching for a shell. `ottoport install <host>` registers it
|
|
|
28
37
|
|
|
29
38
|
## Modes
|
|
30
39
|
|
|
31
|
-
Video takes
|
|
40
|
+
Video takes five, and which ones a model accepts is per-model — `ottoport_list_models`
|
|
32
41
|
prints each one's menu beside its price:
|
|
33
42
|
|
|
34
43
|
| Mode | Send |
|
|
@@ -37,6 +46,7 @@ prints each one's menu beside its price:
|
|
|
37
46
|
| image-to-video | `image_url` — the first frame |
|
|
38
47
|
| first-and-last-frame | `image_url` **and** `last_frame_url` |
|
|
39
48
|
| reference-to-video | `reference_image_urls` — a character, a product, a style the shot carries through without either being a frame of it; plus `reference_video_urls` and `reference_audio_urls` (motion, a performance, a voice) on models whose capabilities list `referenceMedia` — Seedance 2.x, MiniMax H3, Wan 3.0 |
|
|
49
|
+
| motion-control | `image_url` (the character) **and** `video_url` (a 3-30s clip of the movement), on `kling-2.6-motion-control`, `kling-3.0-motion-control` and their `-pro` variants. Prompt optional; `character_orientation` `"video"` (default) or `"image"` (clip then capped at 10s), `keep_original_sound`. Bills per second of the clip, 3s at least |
|
|
40
50
|
|
|
41
51
|
Images are the same idea with one axis: `image_url` for a single edit, or
|
|
42
52
|
`reference_image_urls` for several at once, up to that model's limit.
|
|
@@ -55,8 +65,8 @@ video and `"1k"`/`"2k"`/`"4k"` for images, but **no model offers all of it**:
|
|
|
55
65
|
`gpt-image-1.5` only 1k. Same for how many references a model takes —
|
|
56
66
|
`nano-banana-lite` accepts 3, `gpt-image-2` accepts 16.
|
|
57
67
|
|
|
58
|
-
`quality` is a second priced dial, on the GPT Image family
|
|
59
|
-
`medium` (the default, and the tier every catalog rate was measured at),
|
|
68
|
+
`quality` is a second priced dial, on the GPT Image family and `ideogram-4.5`:
|
|
69
|
+
`low`, `medium` (the default, and the tier every catalog rate was measured at),
|
|
60
70
|
`high`, and `xhigh`/`max` on `gpt-image-2.5` and `gpt-image-2.5-flare`. It buys
|
|
61
71
|
compute, not pixels, and the ladder is steep — `max` bills sixteen times
|
|
62
72
|
`medium`. A model's tiers are its `qualities` in `ottoport_list_models`.
|
|
@@ -77,7 +87,7 @@ sensible default.
|
|
|
77
87
|
| Modality | Default | Also available |
|
|
78
88
|
| --- | --- | --- |
|
|
79
89
|
| chat | `claude-sonnet-5` | `claude-haiku-4.5` (cheap), `gpt-5.5`, `gemini-3.5-flash` |
|
|
80
|
-
| image | `gpt-image-2` | `nano-banana-
|
|
90
|
+
| image | `gpt-image-2` | `nano-banana-2.1` (fast generation and edits), `nano-banana-pro` (high fidelity) |
|
|
81
91
|
| video | `kling-3.0` | `seedance-2.0-fast` (cheap), `veo-3.1` (with audio) |
|
|
82
92
|
| speech | `gpt-4o-mini-tts` | `eleven-v3` |
|
|
83
93
|
| music | `suno-v5` | `lyria-2` |
|
|
@@ -89,8 +99,12 @@ as given rather than converting yourself.
|
|
|
89
99
|
|
|
90
100
|
## What comes back
|
|
91
101
|
|
|
92
|
-
- Image, video and audio tools return **URLs**, not files.
|
|
93
|
-
|
|
102
|
+
- Image, video and audio tools return **URLs**, not files. They expire after a
|
|
103
|
+
few days. To keep one, pass `collection` (or save it with
|
|
104
|
+
`ottoport_save_asset` while it lasts) — the returned URL is then permanent.
|
|
105
|
+
Collections unlock once the account has topped up $10 in total; when
|
|
106
|
+
`ottoport_list_collections` says they are locked, tell the user that rather
|
|
107
|
+
than retrying.
|
|
94
108
|
- Video blocks until the job is terminal, often a minute or more. Say so before
|
|
95
109
|
starting, and never retry a slow call — a retry is a second generation, billed
|
|
96
110
|
again.
|