ottoport 1.3.2 → 1.4.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 +23 -4
- package/cli/ottoport.mjs +128 -35
- package/commands/image.md +10 -2
- package/commands/models.md +16 -8
- package/commands/video.md +14 -2
- package/mcp/server.mjs +115 -27
- package/package.json +1 -1
- package/skills/ottoport/SKILL.md +36 -4
|
@@ -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.4.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "LITBOX LLC",
|
|
7
7
|
"email": "support@ottoport.ai"
|
package/README.md
CHANGED
|
@@ -19,10 +19,12 @@ ottoport models [--modality chat|image|video|tts|music] [--json]
|
|
|
19
19
|
|
|
20
20
|
ottoport chat "<prompt>" [--model claude-sonnet-5] [--system "..."] [--no-stream]
|
|
21
21
|
[--temperature 0.7] [--max-tokens 512]
|
|
22
|
-
ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024
|
|
23
|
-
[--
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k]
|
|
23
|
+
[--aspect-ratio 16:9] [--n 1] [--seed 7]
|
|
24
|
+
[--image <url>] [--ref <url>[,<url>…]] [--out file.png]
|
|
25
|
+
ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p]
|
|
26
|
+
[--aspect-ratio 16:9] [--image <url>] [--last-frame <url>]
|
|
27
|
+
[--ref <url>[,<url>…]] [--out clip.mp4]
|
|
26
28
|
ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--out speech.mp3]
|
|
27
29
|
ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--out song.mp3]
|
|
28
30
|
|
|
@@ -76,6 +78,21 @@ forwarded from the environment rather than written into any config file.
|
|
|
76
78
|
The server speaks stdio JSON-RPC and is launched by the host as a subprocess, so
|
|
77
79
|
it needs Node 20+ on the PATH of whatever starts that host.
|
|
78
80
|
|
|
81
|
+
### Modes and resolution
|
|
82
|
+
|
|
83
|
+
Video takes four modes — text-to-video, image-to-video (`image_url`),
|
|
84
|
+
first-and-last-frame (`image_url` + `last_frame_url`), and reference-to-video
|
|
85
|
+
(`reference_image_urls`). Images take one reference (`image_url`) or several
|
|
86
|
+
(`reference_image_urls`). Both take a `resolution`: `480p`…`4k` for video,
|
|
87
|
+
`1k`/`2k`/`4k` for images, and a tier above a model's base rate costs more.
|
|
88
|
+
|
|
89
|
+
Which modes and tiers a model accepts differs per model, so
|
|
90
|
+
`ottoport_list_models` — and `ottoport models` in the CLI — prints each one's
|
|
91
|
+
menu beside its price. For one model in full, including every parameter it
|
|
92
|
+
reads, pass `model` to that tool or run `ottoport models <model-id>`. Asking
|
|
93
|
+
for something a model does not offer is refused with its actual list, before a
|
|
94
|
+
generation is spent.
|
|
95
|
+
|
|
79
96
|
## Configuration
|
|
80
97
|
|
|
81
98
|
- `OTTOPORT_API_KEY` — your `op-…` key. Export it from your shell profile: the
|
|
@@ -90,6 +107,8 @@ it needs Node 20+ on the PATH of whatever starts that host.
|
|
|
90
107
|
Download anything worth keeping.
|
|
91
108
|
- Video generation takes a minute or more and the call blocks until the job is
|
|
92
109
|
terminal. A retry is a second billable generation, not a resumption.
|
|
110
|
+
- `resolution` is a price multiplier, not a formatting hint. Leave it unset to
|
|
111
|
+
bill at the model's base tier.
|
|
93
112
|
- Requests are billed against the prepaid balance on your OttoPort account.
|
|
94
113
|
|
|
95
114
|
Docs: <https://ottoport.ai/docs> · Support: support@ottoport.ai
|
package/cli/ottoport.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// ottoport models [--modality chat|image|video]
|
|
7
7
|
// ottoport chat "prompt" [--model claude-sonnet-5] [--system "..."] [--no-stream]
|
|
8
|
-
// ottoport image "prompt" [--model gpt-image-2] [--
|
|
8
|
+
// ottoport image "prompt" [--model gpt-image-2] [--resolution 2k] [--ref a.png,b.png] [--out img.png]
|
|
9
9
|
// ottoport video "prompt" [--model kling-3.0] [--duration 5] [--out clip.mp4]
|
|
10
10
|
// ottoport speech "text" [--model gpt-4o-mini-tts] [--voice alloy] [--out speech.mp3]
|
|
11
11
|
// ottoport music "prompt" [--model suno-v5] [--duration 15] [--out song.mp3]
|
|
@@ -31,7 +31,11 @@ function parseArgs(argv) {
|
|
|
31
31
|
if (key.startsWith("no-")) {
|
|
32
32
|
flags[key.slice(3)] = false;
|
|
33
33
|
} else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
34
|
-
|
|
34
|
+
const value = argv[++i];
|
|
35
|
+
// Repeats accumulate rather than overwrite, so `--ref a --ref b` keeps
|
|
36
|
+
// both. Everything downstream reads the last value of a scalar flag,
|
|
37
|
+
// which an array's last element still is.
|
|
38
|
+
flags[key] = key in flags ? [].concat(flags[key], value) : value;
|
|
35
39
|
} else {
|
|
36
40
|
flags[key] = true;
|
|
37
41
|
}
|
|
@@ -42,9 +46,14 @@ function parseArgs(argv) {
|
|
|
42
46
|
return { positional, flags };
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** The effective value of a flag that may have been repeated. */
|
|
50
|
+
function last(value) {
|
|
51
|
+
return Array.isArray(value) ? value[value.length - 1] : value;
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
function config(flags) {
|
|
46
|
-
const baseUrl = (flags.url || process.env.OTTOPORT_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || BASE).replace(/\/+$/, "");
|
|
47
|
-
const apiKey = flags.key || process.env.OTTOPORT_API_KEY || process.env.OTTOPORT_KEY_SECRET;
|
|
55
|
+
const baseUrl = (last(flags.url) || process.env.OTTOPORT_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || BASE).replace(/\/+$/, "");
|
|
56
|
+
const apiKey = last(flags.key) || process.env.OTTOPORT_API_KEY || process.env.OTTOPORT_KEY_SECRET;
|
|
48
57
|
return { baseUrl, apiKey };
|
|
49
58
|
}
|
|
50
59
|
|
|
@@ -69,21 +78,73 @@ async function readError(res) {
|
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
// ── commands ─────────────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* One model in full — every parameter it reads, with its own limits filled
|
|
83
|
+
* in. `ottoport models` answers "which model"; this answers "and what may I
|
|
84
|
+
* send it", which the catalog view can only hint at.
|
|
85
|
+
*/
|
|
86
|
+
async function cmdModel(id, flags) {
|
|
87
|
+
const { baseUrl, apiKey } = config(flags);
|
|
88
|
+
const res = await fetch(`${baseUrl}/api/v1/models/${encodeURIComponent(id)}`, { headers: headers(apiKey, false) });
|
|
89
|
+
if (!res.ok) die(await readError(res));
|
|
90
|
+
const m = await res.json();
|
|
91
|
+
if (flags.json) return console.log(JSON.stringify(m, null, 2));
|
|
92
|
+
console.log(`${m.id} — ${m.display_name} [${m.modality}, ${m.owned_by}]`);
|
|
93
|
+
if (m.description) console.log(m.description);
|
|
94
|
+
if (m.credits_display) console.log(`rate: ${m.credits_display}`);
|
|
95
|
+
console.log(`endpoint: ${m.endpoint}`);
|
|
96
|
+
const caps = m.capabilities;
|
|
97
|
+
if (caps?.modes) {
|
|
98
|
+
const refs = caps.maxReferenceImages ? ` (up to ${caps.maxReferenceImages} reference images)` : "";
|
|
99
|
+
console.log(`modes: ${caps.modes.join(", ")}${refs}`);
|
|
100
|
+
}
|
|
101
|
+
if (typeof caps?.maxInputImages === "number") {
|
|
102
|
+
console.log(`inputs: ${caps.maxInputImages > 0 ? `up to ${caps.maxInputImages} reference images` : "text only"}`);
|
|
103
|
+
}
|
|
104
|
+
if (caps?.resolutions?.length) {
|
|
105
|
+
// The multiplier rides beside the tier: on video it is what it costs.
|
|
106
|
+
const tiers = caps.resolution_multipliers
|
|
107
|
+
? caps.resolutions.map((r) => (caps.resolution_multipliers[r] > 1 ? `${r} ×${caps.resolution_multipliers[r]}` : r))
|
|
108
|
+
: caps.resolutions;
|
|
109
|
+
console.log(`resolution: ${tiers.join(", ")}${caps.base_resolution ? ` (base ${caps.base_resolution})` : ""}`);
|
|
110
|
+
}
|
|
111
|
+
console.log("parameters:");
|
|
112
|
+
for (const p of m.parameters ?? []) {
|
|
113
|
+
console.log(` ${String(p.name).padEnd(21)}${p.type}${p.required ? ", required" : ""} — ${p.note}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
72
117
|
async function cmdModels(flags) {
|
|
73
118
|
const { baseUrl, apiKey } = config(flags);
|
|
74
|
-
const qs = flags.modality ? `?modality=${encodeURIComponent(flags.modality)}` : "";
|
|
119
|
+
const qs = last(flags.modality) ? `?modality=${encodeURIComponent(last(flags.modality))}` : "";
|
|
75
120
|
const res = await fetch(`${baseUrl}/api/v1/models${qs}`, { headers: headers(apiKey, false) });
|
|
76
121
|
if (!res.ok) die(await readError(res));
|
|
77
122
|
const { data } = await res.json();
|
|
78
123
|
if (flags.json) return console.log(JSON.stringify(data, null, 2));
|
|
79
124
|
const pad = (s, n) => String(s).padEnd(n);
|
|
80
|
-
console.log(pad("ID", 20) + pad("MODALITY", 10) + pad("PROVIDER", 12) + "
|
|
125
|
+
console.log(pad("ID", 20) + pad("MODALITY", 10) + pad("PROVIDER", 12) + "RATE");
|
|
81
126
|
for (const m of data) {
|
|
82
|
-
|
|
83
|
-
|
|
127
|
+
// The gateway formats the rate; this prints it. Anything else puts a unit
|
|
128
|
+
// in the client, where changing it means everyone reinstalling.
|
|
129
|
+
console.log(pad(m.id, 20) + pad(m.modality, 10) + pad(m.owned_by, 12) + (m.credits_display ?? ""));
|
|
130
|
+
// What the model accepts, on its own line: which modes, how many
|
|
131
|
+
// reference images, which resolutions. Picking a model for a
|
|
132
|
+
// first-and-last-frame shot is otherwise trial and error.
|
|
133
|
+
const accepts = capabilityLine(m.capabilities);
|
|
134
|
+
if (accepts) console.log(pad("", 42) + accepts);
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
|
|
138
|
+
function capabilityLine(caps) {
|
|
139
|
+
if (!caps) return "";
|
|
140
|
+
if (caps.modality === "video") {
|
|
141
|
+
const refs = caps.maxReferenceImages ? `, up to ${caps.maxReferenceImages} refs` : "";
|
|
142
|
+
return `accepts: ${caps.modes.join(", ")}${refs} · ${caps.resolutions.join("/")}`;
|
|
143
|
+
}
|
|
144
|
+
const inputs = caps.maxInputImages ? `up to ${caps.maxInputImages} input images` : "text only";
|
|
145
|
+
return `accepts: ${inputs} · ${caps.resolutions.join("/")}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
87
148
|
async function cmdChat(prompt, flags) {
|
|
88
149
|
if (!prompt) die("chat requires a prompt: ottoport chat \"hello\"");
|
|
89
150
|
const { baseUrl, apiKey } = config(flags);
|
|
@@ -93,11 +154,11 @@ async function cmdChat(prompt, flags) {
|
|
|
93
154
|
|
|
94
155
|
const stream = flags.stream !== false;
|
|
95
156
|
const body = {
|
|
96
|
-
model: flags.model || "claude-sonnet-5",
|
|
157
|
+
model: last(flags.model) || "claude-sonnet-5",
|
|
97
158
|
messages,
|
|
98
159
|
stream,
|
|
99
|
-
...(flags.temperature ? { temperature: Number(flags.temperature) } : {}),
|
|
100
|
-
...(flags["max-tokens"] ? { max_tokens: Number(flags["max-tokens"]) } : {}),
|
|
160
|
+
...(flags.temperature ? { temperature: Number(last(flags.temperature)) } : {}),
|
|
161
|
+
...(last(flags["max-tokens"]) ? { max_tokens: Number(last(flags["max-tokens"])) } : {}),
|
|
101
162
|
};
|
|
102
163
|
|
|
103
164
|
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
|
@@ -147,15 +208,35 @@ async function download(url, out) {
|
|
|
147
208
|
console.error(`saved → ${out} (${buf.length} bytes)`);
|
|
148
209
|
}
|
|
149
210
|
|
|
211
|
+
/**
|
|
212
|
+
* `--ref` given once or several times.
|
|
213
|
+
*
|
|
214
|
+
* The flag parser keeps the last value for a repeated flag and a comma list is
|
|
215
|
+
* what a shell user reaches for anyway, so both spellings are accepted:
|
|
216
|
+
* `--ref a.png --ref b.png` and `--ref a.png,b.png`.
|
|
217
|
+
*/
|
|
218
|
+
function references(flags) {
|
|
219
|
+
const raw = flags.ref ?? flags.reference;
|
|
220
|
+
const list = (Array.isArray(raw) ? raw : [raw])
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.flatMap((value) => String(value).split(",").map((one) => one.trim()))
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
return list.length ? list : undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
150
227
|
async function cmdImage(prompt, flags) {
|
|
151
228
|
if (!prompt) die("image requires a prompt: ottoport image \"a red fox\"");
|
|
152
229
|
const { baseUrl, apiKey } = config(flags);
|
|
153
230
|
const body = {
|
|
154
|
-
model: flags.model || "gpt-image-2",
|
|
231
|
+
model: last(flags.model) || "gpt-image-2",
|
|
155
232
|
prompt,
|
|
156
|
-
...(flags.size ? { size: flags.size } : {}),
|
|
157
|
-
...(flags.
|
|
158
|
-
...(flags
|
|
233
|
+
...(last(flags.size) ? { size: last(flags.size) } : {}),
|
|
234
|
+
...(last(flags.resolution) ? { resolution: last(flags.resolution) } : {}),
|
|
235
|
+
...(last(flags["aspect-ratio"]) ? { aspect_ratio: flags["aspect-ratio"] } : {}),
|
|
236
|
+
...(last(flags.n) ? { n: Number(last(flags.n)) } : {}),
|
|
237
|
+
...(last(flags.seed) ? { seed: Number(last(flags.seed)) } : {}),
|
|
238
|
+
...(last(flags.image) ? { image_url: flags.image } : {}),
|
|
239
|
+
...(references(flags) ? { reference_image_urls: references(flags) } : {}),
|
|
159
240
|
};
|
|
160
241
|
const res = await fetch(`${baseUrl}/api/v1/images/generations`, {
|
|
161
242
|
method: "POST",
|
|
@@ -166,18 +247,22 @@ async function cmdImage(prompt, flags) {
|
|
|
166
247
|
const json = await res.json();
|
|
167
248
|
const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
|
|
168
249
|
urls.forEach((u) => console.log(u));
|
|
169
|
-
if (flags.out && urls[0]) await download(urls[0], flags.out);
|
|
250
|
+
if (last(flags.out) && urls[0]) await download(urls[0], last(flags.out));
|
|
170
251
|
}
|
|
171
252
|
|
|
172
253
|
async function cmdVideo(prompt, flags) {
|
|
173
254
|
if (!prompt) die("video requires a prompt: ottoport video \"a drone shot\"");
|
|
174
255
|
const { baseUrl, apiKey } = config(flags);
|
|
175
256
|
const body = {
|
|
176
|
-
model: flags.model || "kling-3.0",
|
|
257
|
+
model: last(flags.model) || "kling-3.0",
|
|
177
258
|
prompt,
|
|
178
|
-
...(flags.duration ? { duration: Number(flags.duration) } : {}),
|
|
179
|
-
...(flags
|
|
180
|
-
...(flags
|
|
259
|
+
...(last(flags.duration) ? { duration: Number(last(flags.duration)) } : {}),
|
|
260
|
+
...(last(flags.resolution) ? { resolution: last(flags.resolution) } : {}),
|
|
261
|
+
...(last(flags["aspect-ratio"]) ? { aspect_ratio: flags["aspect-ratio"] } : {}),
|
|
262
|
+
...(last(flags.seed) ? { seed: Number(last(flags.seed)) } : {}),
|
|
263
|
+
...(last(flags.image) ? { image_url: flags.image } : {}),
|
|
264
|
+
...(last(flags["last-frame"]) ? { last_frame_url: flags["last-frame"] } : {}),
|
|
265
|
+
...(references(flags) ? { reference_image_urls: references(flags) } : {}),
|
|
181
266
|
};
|
|
182
267
|
console.error("submitting video job (this can take a minute)…");
|
|
183
268
|
const res = await fetch(`${baseUrl}/api/v1/videos/generations`, {
|
|
@@ -201,7 +286,7 @@ async function cmdVideo(prompt, flags) {
|
|
|
201
286
|
const url = job.data?.[0]?.url;
|
|
202
287
|
if (url) {
|
|
203
288
|
console.log(url);
|
|
204
|
-
if (flags.out) await download(url, flags.out);
|
|
289
|
+
if (flags.out) await download(url, last(flags.out));
|
|
205
290
|
} else {
|
|
206
291
|
console.log(JSON.stringify(job, null, 2));
|
|
207
292
|
}
|
|
@@ -211,11 +296,11 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
211
296
|
if (!prompt) die(`${kind} requires a prompt: ottoport ${kind} "hello"`);
|
|
212
297
|
const { baseUrl, apiKey } = config(flags);
|
|
213
298
|
const body = {
|
|
214
|
-
model: flags.model || (kind === "speech" ? "gpt-4o-mini-tts" : "suno-v5"),
|
|
299
|
+
model: last(flags.model) || (kind === "speech" ? "gpt-4o-mini-tts" : "suno-v5"),
|
|
215
300
|
prompt,
|
|
216
|
-
...(flags.voice ? { voice: flags.voice } : {}),
|
|
217
|
-
...(flags.duration ? { duration: Number(flags.duration) } : {}),
|
|
218
|
-
...(flags.format ? { format: flags.format } : {}),
|
|
301
|
+
...(last(flags.voice) ? { voice: last(flags.voice) } : {}),
|
|
302
|
+
...(last(flags.duration) ? { duration: Number(last(flags.duration)) } : {}),
|
|
303
|
+
...(last(flags.format) ? { format: last(flags.format) } : {}),
|
|
219
304
|
};
|
|
220
305
|
const path = kind === "speech" ? "audio/speech" : "audio/music";
|
|
221
306
|
const res = await fetch(`${baseUrl}/api/v1/${path}`, {
|
|
@@ -231,10 +316,10 @@ async function cmdAudio(kind, prompt, flags) {
|
|
|
231
316
|
if (flags.out) {
|
|
232
317
|
if (item.b64_json) {
|
|
233
318
|
const bytes = Buffer.from(item.b64_json, "base64");
|
|
234
|
-
await writeFile(flags.out, bytes);
|
|
235
|
-
console.error(`saved → ${flags.out} (${bytes.length} bytes)`);
|
|
319
|
+
await writeFile(last(flags.out), bytes);
|
|
320
|
+
console.error(`saved → ${last(flags.out)} (${bytes.length} bytes)`);
|
|
236
321
|
} else if (item.url) {
|
|
237
|
-
await download(item.url, flags.out);
|
|
322
|
+
await download(item.url, last(flags.out));
|
|
238
323
|
}
|
|
239
324
|
}
|
|
240
325
|
}
|
|
@@ -243,12 +328,16 @@ const HELP = `OttoPort — one command for every model.
|
|
|
243
328
|
|
|
244
329
|
Usage:
|
|
245
330
|
ottoport models [--modality chat|image|video|tts|music] [--json]
|
|
331
|
+
ottoport models <model-id> [--json] what that one model accepts
|
|
246
332
|
ottoport chat "<prompt>" [--model claude-sonnet-5] [--system "..."] [--no-stream]
|
|
247
333
|
[--temperature 0.7] [--max-tokens 512]
|
|
248
|
-
ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024
|
|
249
|
-
[--
|
|
250
|
-
|
|
251
|
-
|
|
334
|
+
ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k]
|
|
335
|
+
[--aspect-ratio 16:9] [--n 1] [--seed 7]
|
|
336
|
+
[--image <url>] [--ref <url>[,<url>…]] [--out file.png]
|
|
337
|
+
ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p]
|
|
338
|
+
[--aspect-ratio 16:9] [--seed 7] [--image <url>]
|
|
339
|
+
[--last-frame <url>] [--ref <url>[,<url>…]]
|
|
340
|
+
[--no-wait] [--out clip.mp4]
|
|
252
341
|
ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--format mp3] [--out speech.mp3]
|
|
253
342
|
ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--format mp3] [--out song.mp3]
|
|
254
343
|
ottoport mcp
|
|
@@ -262,10 +351,13 @@ Global flags:
|
|
|
262
351
|
|
|
263
352
|
Examples:
|
|
264
353
|
export OTTOPORT_API_KEY=op-... OTTOPORT_BASE_URL=https://ottoport.dev
|
|
265
|
-
ottoport models --modality image
|
|
354
|
+
ottoport models --modality image # prints what each one accepts
|
|
355
|
+
ottoport models veo-3.1 # its modes, tiers, and every parameter
|
|
266
356
|
ottoport chat "explain MCP in one line" --model claude-haiku-4.5
|
|
267
357
|
ottoport image "isometric city at dusk" --model gpt-image-2 --out city.png
|
|
268
|
-
ottoport video "cinematic product reveal" --model seedance-2.0-fast
|
|
358
|
+
ottoport video "cinematic product reveal" --model seedance-2.0-fast --resolution 1080p
|
|
359
|
+
ottoport video "the logo unfolds" --image start.png --last-frame end.png
|
|
360
|
+
ottoport image "her, on a beach" --ref face.png,style.png
|
|
269
361
|
ottoport speech "Welcome to OttoPort" --voice alloy --out welcome.mp3
|
|
270
362
|
ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
|
|
271
363
|
ottoport mcp
|
|
@@ -278,7 +370,8 @@ async function main() {
|
|
|
278
370
|
const prompt = rest.join(" ");
|
|
279
371
|
try {
|
|
280
372
|
switch (command) {
|
|
281
|
-
|
|
373
|
+
// `models <id>` is the detail view; bare `models` is the catalog.
|
|
374
|
+
case "models": return rest[0] ? await cmdModel(rest[0], flags) : await cmdModels(flags);
|
|
282
375
|
case "chat": return await cmdChat(prompt, flags);
|
|
283
376
|
case "image": return await cmdImage(prompt, flags);
|
|
284
377
|
case "video": return await cmdVideo(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>] [--
|
|
3
|
+
argument-hint: "<prompt> [--model <id>] [--resolution 2k] [--ref <url>…]"
|
|
4
4
|
allowed-tools: mcp__ottoport__ottoport_generate_image, mcp__ottoport__ottoport_list_models, Bash(curl:*)
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -9,6 +9,14 @@ 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 edit of an existing image goes through `image_url`, not the prompt.
|
|
12
|
+
- An edit of an existing image goes through `image_url`, not the prompt. Several
|
|
13
|
+
references at once — a character plus the scene, a product plus a style — go
|
|
14
|
+
through `reference_image_urls`.
|
|
15
|
+
- Detail is `resolution` (`1k`/`2k`/`4k`), optionally with `aspect_ratio`; pass
|
|
16
|
+
`size` instead when the user named exact pixels. A higher tier costs more, so
|
|
17
|
+
send one only when the request asks for it.
|
|
18
|
+
- Models differ in how many references and which resolutions they take. If a
|
|
19
|
+
request needs more than one reference, or 4k, check `ottoport_list_models`
|
|
20
|
+
first — it prints each model's menu — rather than spending a failed call.
|
|
13
21
|
- The tool returns URLs, and provider URLs expire. Print the URL, and if the
|
|
14
22
|
user asked for a file, download it to the path they named with `curl -sSL`.
|
package/commands/models.md
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
3
|
-
argument-hint: "[chat|image|video|tts|music]"
|
|
2
|
+
description: The OttoPort model catalog — what each model is for, its rate, and what one model accepts
|
|
3
|
+
argument-hint: "[chat|image|video|tts|music | <model-id>]"
|
|
4
4
|
allowed-tools: mcp__ottoport__ottoport_list_models
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Answer with the `ottoport_list_models` MCP tool.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Argument (may be empty — then list everything): `$1`
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
- A modality (`chat`, `image`, `video`, `tts`, `music`) → pass it as `modality`
|
|
12
|
+
and present a table: model id, what it is good at, and its rate.
|
|
13
|
+
- A model id (`veo-3.1`, `nano-banana-pro`, …) → pass it as `model`. The tool
|
|
14
|
+
then returns that one model in full: its input modes, the resolutions it
|
|
15
|
+
offers with their price multipliers, and every request parameter it reads.
|
|
16
|
+
Present it as-is — the parameter list is the answer to "what can I send it".
|
|
17
|
+
- Nothing → list everything, grouped by modality, and keep it to the models
|
|
18
|
+
worth choosing between rather than every row.
|
|
19
|
+
|
|
20
|
+
Rates are in credits — the unit the account's balance is kept in — so quote
|
|
21
|
+
them as the tool returns them and never convert to a currency. Never invent a
|
|
22
|
+
model id, a mode, or a resolution the tool did not return.
|
package/commands/video.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Generate a video through the OttoPort gateway
|
|
3
|
-
argument-hint: "<prompt> [--model <id>] [--duration 5]"
|
|
3
|
+
argument-hint: "<prompt> [--model <id>] [--duration 5] [--resolution 1080p]"
|
|
4
4
|
allowed-tools: mcp__ottoport__ottoport_generate_video, mcp__ottoport__ottoport_list_models, Bash(curl:*)
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,8 +8,20 @@ Generate a video with the `ottoport_generate_video` MCP tool.
|
|
|
8
8
|
|
|
9
9
|
Request: $ARGUMENTS
|
|
10
10
|
|
|
11
|
-
- Pass `model`, `duration`, `
|
|
11
|
+
- Pass `model`, `duration`, `resolution` and `aspect_ratio` only when the request
|
|
12
12
|
supplies them; the tool defaults to `kling-3.0`.
|
|
13
|
+
- Pick the mode from what the user gave you:
|
|
14
|
+
- nothing but a prompt → text-to-video;
|
|
15
|
+
- one still → `image_url`, the first frame;
|
|
16
|
+
- a start and an end → `image_url` **and** `last_frame_url`;
|
|
17
|
+
- a character, product or style to carry through the shot →
|
|
18
|
+
`reference_image_urls`.
|
|
19
|
+
- Not every model takes every mode or every resolution. When the request needs
|
|
20
|
+
a closing frame, references, or a tier above 1080p, read
|
|
21
|
+
`ottoport_list_models` first — each model prints its own menu — instead of
|
|
22
|
+
discovering it from a failed call.
|
|
23
|
+
- `resolution` is billed: a 1080p or 4k clip costs more per second than the
|
|
24
|
+
model's base tier. Send one only when the request asks for it.
|
|
13
25
|
- Video generation takes a minute or more and the call blocks until the job is
|
|
14
26
|
terminal. Say so before you start, and do not retry a call that is merely slow
|
|
15
27
|
— a retry is a second billable generation.
|
package/mcp/server.mjs
CHANGED
|
@@ -13,8 +13,18 @@
|
|
|
13
13
|
// claude mcp add ottoport -- node /abs/path/to/mcp/server.mjs
|
|
14
14
|
|
|
15
15
|
import { createInterface } from "node:readline";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
16
17
|
|
|
17
18
|
const PROTOCOL_VERSION = "2024-11-05";
|
|
19
|
+
// Read from the package rather than repeated here: a version that says 0.1.0
|
|
20
|
+
// while the package ships 1.3.x sends whoever is debugging to the wrong code.
|
|
21
|
+
const VERSION = (() => {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
24
|
+
} catch {
|
|
25
|
+
return "0.0.0";
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
18
28
|
const BASE = (process.env.OTTOPORT_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3014").replace(/\/+$/, "");
|
|
19
29
|
const KEY = process.env.OTTOPORT_API_KEY || process.env.OTTOPORT_KEY_SECRET;
|
|
20
30
|
|
|
@@ -34,15 +44,82 @@ async function gwError(res) {
|
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
// ── tool implementations ─────────────────────────────────────────────
|
|
37
|
-
|
|
47
|
+
/**
|
|
48
|
+
* What a model takes, in one clause.
|
|
49
|
+
*
|
|
50
|
+
* Without this the catalog answers "which model" and leaves "and what may I
|
|
51
|
+
* send it" to trial and error — which on a paid endpoint is a failed billable
|
|
52
|
+
* call, or worse, a field the provider silently ignores while charging in
|
|
53
|
+
* full. Rendered only when the gateway published it; an older deployment
|
|
54
|
+
* reads exactly as it did before.
|
|
55
|
+
*/
|
|
56
|
+
function accepts(capabilities) {
|
|
57
|
+
if (!capabilities) return "";
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (Array.isArray(capabilities.modes) && capabilities.modes.length) {
|
|
60
|
+
parts.push(capabilities.modes.join("/"));
|
|
61
|
+
}
|
|
62
|
+
if (typeof capabilities.maxInputImages === "number") {
|
|
63
|
+
parts.push(capabilities.maxInputImages > 0 ? `up to ${capabilities.maxInputImages} reference images` : "text only");
|
|
64
|
+
}
|
|
65
|
+
if (typeof capabilities.maxReferenceImages === "number") {
|
|
66
|
+
parts.push(`up to ${capabilities.maxReferenceImages} references`);
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(capabilities.resolutions) && capabilities.resolutions.length) {
|
|
69
|
+
parts.push(capabilities.resolutions.join("/"));
|
|
70
|
+
}
|
|
71
|
+
return parts.length ? ` · accepts ${parts.join(", ")}` : "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One model in full: what it accepts, what it costs, and its request body. */
|
|
75
|
+
async function describeModel(id) {
|
|
76
|
+
const res = await fetch(`${BASE}/api/v1/models/${encodeURIComponent(id)}`, { headers: headers(false) });
|
|
77
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
78
|
+
const m = await res.json();
|
|
79
|
+
const caps = m.capabilities;
|
|
80
|
+
const lines = [
|
|
81
|
+
`${m.id} — ${m.display_name} [${m.modality}, ${m.owned_by}]`,
|
|
82
|
+
...(m.description ? [m.description] : []),
|
|
83
|
+
...(m.credits_display ? [`Rate: ${m.credits_display}`] : []),
|
|
84
|
+
`Endpoint: ${m.endpoint}`,
|
|
85
|
+
];
|
|
86
|
+
if (caps?.modes) {
|
|
87
|
+
const refs = caps.maxReferenceImages ? ` (up to ${caps.maxReferenceImages} reference images)` : "";
|
|
88
|
+
lines.push(`Modes: ${caps.modes.join(", ")}${refs}`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof caps?.maxInputImages === "number") {
|
|
91
|
+
lines.push(`Input images: ${caps.maxInputImages > 0 ? `up to ${caps.maxInputImages}` : "none — text-to-image only"}`);
|
|
92
|
+
}
|
|
93
|
+
if (caps?.resolutions?.length) {
|
|
94
|
+
// A tier's multiplier prints beside it: on video, a resolution is a price.
|
|
95
|
+
const tiers = caps.resolution_multipliers
|
|
96
|
+
? caps.resolutions.map((r) => (caps.resolution_multipliers[r] > 1 ? `${r} ×${caps.resolution_multipliers[r]}` : r))
|
|
97
|
+
: caps.resolutions;
|
|
98
|
+
lines.push(`Resolutions: ${tiers.join(", ")}${caps.base_resolution ? ` (base ${caps.base_resolution})` : ""}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push("Parameters:");
|
|
101
|
+
for (const p of m.parameters ?? []) {
|
|
102
|
+
lines.push(` ${p.name} (${p.type}${p.required ? ", required" : ""}) — ${p.note}`);
|
|
103
|
+
}
|
|
104
|
+
return lines.join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listModels({ modality, model } = {}) {
|
|
108
|
+
// A named model is answered in full — its modes, its resolutions, and the
|
|
109
|
+
// exact body it takes — rather than as one line in a catalog of sixty.
|
|
110
|
+
if (model) return describeModel(model);
|
|
38
111
|
const qs = modality ? `?modality=${encodeURIComponent(modality)}` : "";
|
|
39
112
|
const res = await fetch(`${BASE}/api/v1/models${qs}`, { headers: headers(false) });
|
|
40
113
|
if (!res.ok) throw new Error(await gwError(res));
|
|
41
114
|
const { data } = await res.json();
|
|
115
|
+
// Lead with what the model is for: an agent picking one needs the capability,
|
|
116
|
+
// and the rate is the tiebreaker. The gateway formats the rate itself, so no
|
|
117
|
+
// unit is spelled out here.
|
|
42
118
|
return data
|
|
43
119
|
.map((m) => {
|
|
44
|
-
const
|
|
45
|
-
|
|
120
|
+
const rate = m.credits_display ? ` · ${m.credits_display}` : "";
|
|
121
|
+
const what = m.description ? ` — ${m.description}` : "";
|
|
122
|
+
return `${m.id} [${m.modality}, ${m.owned_by}]${what}${rate}${accepts(m.capabilities)}`;
|
|
46
123
|
})
|
|
47
124
|
.join("\n");
|
|
48
125
|
}
|
|
@@ -68,18 +145,18 @@ async function chat({ prompt, model, system, temperature, max_tokens }) {
|
|
|
68
145
|
return json?.choices?.[0]?.message?.content ?? "";
|
|
69
146
|
}
|
|
70
147
|
|
|
71
|
-
async function generateImage({ prompt, model,
|
|
148
|
+
async function generateImage({ prompt, model, ...rest }) {
|
|
72
149
|
if (!prompt) throw new Error("`prompt` is required");
|
|
73
150
|
const res = await fetch(`${BASE}/api/v1/images/generations`, {
|
|
74
151
|
method: "POST",
|
|
75
152
|
headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}),
|
|
153
|
+
// Everything else goes through untouched. Naming the fields here made this
|
|
154
|
+
// file a second copy of the gateway's request shape, and a copy that drops
|
|
155
|
+
// what it has not heard of: `reference_image_urls` and `resolution` were
|
|
156
|
+
// live on the gateway and unreachable from any MCP client, silently. The
|
|
157
|
+
// gateway validates against the model's capabilities and says what it
|
|
158
|
+
// does not accept; this layer has no business having an opinion.
|
|
159
|
+
body: JSON.stringify({ model: model || "gpt-image-2", prompt, ...rest }),
|
|
83
160
|
});
|
|
84
161
|
if (!res.ok) throw new Error(await gwError(res));
|
|
85
162
|
const json = await res.json();
|
|
@@ -87,18 +164,13 @@ async function generateImage({ prompt, model, size, n, image_url }) {
|
|
|
87
164
|
return urls.length ? urls.join("\n") : "(no image returned)";
|
|
88
165
|
}
|
|
89
166
|
|
|
90
|
-
async function generateVideo({ prompt, model,
|
|
167
|
+
async function generateVideo({ prompt, model, ...rest }) {
|
|
91
168
|
if (!prompt) throw new Error("`prompt` is required");
|
|
92
169
|
const res = await fetch(`${BASE}/api/v1/videos/generations`, {
|
|
93
170
|
method: "POST",
|
|
94
171
|
headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
prompt,
|
|
98
|
-
...(duration ? { duration } : {}),
|
|
99
|
-
...(aspect_ratio ? { aspect_ratio } : {}),
|
|
100
|
-
...(image_url ? { image_url } : {}),
|
|
101
|
-
}),
|
|
172
|
+
// Passed through; see `generateImage` for why this layer names nothing.
|
|
173
|
+
body: JSON.stringify({ model: model || "kling-3.0", prompt, ...rest }),
|
|
102
174
|
});
|
|
103
175
|
if (!res.ok) throw new Error(await gwError(res));
|
|
104
176
|
let job = await res.json();
|
|
@@ -143,11 +215,12 @@ async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
|
143
215
|
const TOOLS = [
|
|
144
216
|
{
|
|
145
217
|
name: "ottoport_list_models",
|
|
146
|
-
description: "
|
|
218
|
+
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.",
|
|
147
219
|
inputSchema: {
|
|
148
220
|
type: "object",
|
|
149
221
|
properties: {
|
|
150
222
|
modality: { type: "string", enum: ["chat", "image", "video", "tts", "music"], description: "Filter to one modality." },
|
|
223
|
+
model: { type: "string", description: "A model id, e.g. veo-3.1. Returns that one model's full parameter list instead of the catalog." },
|
|
151
224
|
},
|
|
152
225
|
},
|
|
153
226
|
handler: listModels,
|
|
@@ -170,15 +243,24 @@ const TOOLS = [
|
|
|
170
243
|
},
|
|
171
244
|
{
|
|
172
245
|
name: "ottoport_generate_image",
|
|
173
|
-
description: "Generate an image
|
|
246
|
+
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. Which a model accepts is in its capabilities — call ottoport_list_models. Returns image URL(s).",
|
|
174
247
|
inputSchema: {
|
|
175
248
|
type: "object",
|
|
176
249
|
properties: {
|
|
177
250
|
prompt: { type: "string" },
|
|
178
251
|
model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2, nano-banana-pro. Default gpt-image-2." },
|
|
179
|
-
size: { type: "string", description: 'e.g. "1024x1024"
|
|
252
|
+
size: { type: "string", description: 'Exact pixels, e.g. "1024x1024". Wins over `resolution`.' },
|
|
253
|
+
// No `enum`: the vocabulary is "1k"/"2k"/"4k", but which of them a given
|
|
254
|
+
// model actually offers differs per model, and an enum here reads as a
|
|
255
|
+
// promise that every value works everywhere. It does not — and since
|
|
256
|
+
// resolution is a price multiplier, the wrong guess is a refused call
|
|
257
|
+
// at best. The model's own menu is in `ottoport_list_models`.
|
|
258
|
+
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.' },
|
|
259
|
+
aspect_ratio: { type: "string", description: 'e.g. "16:9". Read only when `size` is absent.' },
|
|
180
260
|
n: { type: "number" },
|
|
181
|
-
|
|
261
|
+
seed: { type: "number" },
|
|
262
|
+
image_url: { type: "string", description: "One reference image, for editing and image-to-image." },
|
|
263
|
+
reference_image_urls: { type: "array", items: { type: "string" }, description: "Several references — a character sheet, a product plus a scene — up to the model's maxInputImages. Call ottoport_list_models to see it." },
|
|
182
264
|
},
|
|
183
265
|
required: ["prompt"],
|
|
184
266
|
},
|
|
@@ -186,15 +268,21 @@ const TOOLS = [
|
|
|
186
268
|
},
|
|
187
269
|
{
|
|
188
270
|
name: "ottoport_generate_video",
|
|
189
|
-
description: "Generate a video
|
|
271
|
+
description: "Generate a video through an OttoPort video model (Veo, Kling, Seedance, …). Four modes: text-to-video, image-to-video (`image_url`), first-and-last-frame (plus `last_frame_url`), and reference-to-video (`reference_image_urls`). Which a model accepts is in its capabilities — call ottoport_list_models. Blocks on the queue and returns a video URL.",
|
|
190
272
|
inputSchema: {
|
|
191
273
|
type: "object",
|
|
192
274
|
properties: {
|
|
193
275
|
prompt: { type: "string" },
|
|
194
276
|
model: { type: "string", description: "Model id, e.g. veo-3.1, kling-3.0, seedance-2.0-fast. Default kling-3.0." },
|
|
195
|
-
duration: { type: "number", description: "Seconds." },
|
|
277
|
+
duration: { type: "number", description: "Seconds. The main lever on what this costs." },
|
|
278
|
+
// Per-model, not universal — see the note on the image tool. kling-3.0
|
|
279
|
+
// has no 480p and no 4k; gemini-omni-flash has only 720p.
|
|
280
|
+
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.' },
|
|
196
281
|
aspect_ratio: { type: "string", description: 'e.g. "16:9".' },
|
|
197
|
-
|
|
282
|
+
seed: { type: "number" },
|
|
283
|
+
image_url: { type: "string", description: "First frame, for image-to-video." },
|
|
284
|
+
last_frame_url: { type: "string", description: "Final frame. With `image_url`, this is first-and-last-frame generation." },
|
|
285
|
+
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." },
|
|
198
286
|
},
|
|
199
287
|
required: ["prompt"],
|
|
200
288
|
},
|
|
@@ -255,7 +343,7 @@ async function handle(msg) {
|
|
|
255
343
|
return reply(id, {
|
|
256
344
|
protocolVersion: PROTOCOL_VERSION,
|
|
257
345
|
capabilities: { tools: {} },
|
|
258
|
-
serverInfo: { name: "ottoport", version:
|
|
346
|
+
serverInfo: { name: "ottoport", version: VERSION },
|
|
259
347
|
});
|
|
260
348
|
|
|
261
349
|
case "notifications/initialized":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ottoport",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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
|
"license": "MIT",
|
package/skills/ottoport/SKILL.md
CHANGED
|
@@ -14,10 +14,10 @@ and do not call the HTTP API, when a tool covers the job:
|
|
|
14
14
|
|
|
15
15
|
| Tool | For |
|
|
16
16
|
| --- | --- |
|
|
17
|
-
| `ottoport_list_models` | The catalog
|
|
17
|
+
| `ottoport_list_models` | The catalog: what each model is for, and its rate in credits. 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`, `n`, `image_url` (
|
|
20
|
-
| `ottoport_generate_video` | `prompt`, plus optional `model`, `duration`, `aspect_ratio`, `image_url`. |
|
|
19
|
+
| `ottoport_generate_image` | `prompt`, plus optional `model`, `size` or `resolution`, `aspect_ratio`, `n`, `seed`, `image_url` (one reference), `reference_image_urls` (several). |
|
|
20
|
+
| `ottoport_generate_video` | `prompt`, plus optional `model`, `duration`, `resolution`, `aspect_ratio`, `seed`, `image_url` (first frame), `last_frame_url`, `reference_image_urls`. |
|
|
21
21
|
| `ottoport_generate_speech` | `prompt` (the text), plus optional `model`, `voice`, `format`. |
|
|
22
22
|
| `ottoport_generate_music` | `prompt`, plus optional `model`, `duration`, `format`. |
|
|
23
23
|
|
|
@@ -25,6 +25,36 @@ If those tools are not present, the server is not registered in this client —
|
|
|
25
25
|
say so rather than reaching for a shell. `ottoport install <host>` registers it
|
|
26
26
|
(codex, claude, hermes, cursor, windsurf, claude-desktop).
|
|
27
27
|
|
|
28
|
+
## Modes
|
|
29
|
+
|
|
30
|
+
Video takes four, and which ones a model accepts is per-model — `ottoport_list_models`
|
|
31
|
+
prints each one's menu beside its price:
|
|
32
|
+
|
|
33
|
+
| Mode | Send |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| text-to-video | `prompt` alone |
|
|
36
|
+
| image-to-video | `image_url` — the first frame |
|
|
37
|
+
| first-and-last-frame | `image_url` **and** `last_frame_url` |
|
|
38
|
+
| reference-to-video | `reference_image_urls` — a character, a product, a style the shot carries through without either being a frame of it |
|
|
39
|
+
|
|
40
|
+
Images are the same idea with one axis: `image_url` for a single edit, or
|
|
41
|
+
`reference_image_urls` for several at once, up to that model's limit.
|
|
42
|
+
|
|
43
|
+
`resolution` is separate from all of it, and **it is priced** — a higher tier
|
|
44
|
+
costs more. The vocabulary is `"480p"`/`"720p"`/`"1080p"`/`"2k"`/`"4k"` for
|
|
45
|
+
video and `"1k"`/`"2k"`/`"4k"` for images, but **no model offers all of it**:
|
|
46
|
+
`kling-3.0` has no 480p and no 4k, `gemini-omni-flash` has only 720p,
|
|
47
|
+
`gpt-image-1.5` only 1k. Same for how many references a model takes —
|
|
48
|
+
`nano-banana-lite` accepts 3, `gpt-image-2` accepts 16.
|
|
49
|
+
|
|
50
|
+
So read the model's own menu before naming a mode, a resolution or a second
|
|
51
|
+
reference image: `ottoport_list_models` with `model` set to the one you are
|
|
52
|
+
about to call answers with exactly that — its modes, what each resolution tier
|
|
53
|
+
costs, and the parameter list, for that model alone. A value the model does not
|
|
54
|
+
list is refused with its actual menu in the message, which costs a round trip
|
|
55
|
+
rather than a generation — but the round trip is avoidable and the lookup is
|
|
56
|
+
one call. Give `size` instead when you know the exact pixels; it wins.
|
|
57
|
+
|
|
28
58
|
## Choosing a model
|
|
29
59
|
|
|
30
60
|
Pass `model` only when the request calls for a specific one; every tool has a
|
|
@@ -39,7 +69,9 @@ sensible default.
|
|
|
39
69
|
| music | `suno-v5` | `lyria-2` |
|
|
40
70
|
|
|
41
71
|
Never invent a model id. Call `ottoport_list_models` when unsure — the catalog
|
|
42
|
-
changes, and a wrong id is a failed billable call.
|
|
72
|
+
changes, and a wrong id is a failed billable call. Rates come back in credits,
|
|
73
|
+
the unit the account balance is kept in; quote them as given rather than
|
|
74
|
+
converting to a currency.
|
|
43
75
|
|
|
44
76
|
## What comes back
|
|
45
77
|
|