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.
@@ -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.3.2",
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] [--n 1]
23
- [--image <url>] [--out file.png]
24
- ottoport video "<prompt>" [--model kling-3.0] [--duration 5]
25
- [--aspect-ratio 16:9] [--image <url>] [--out clip.mp4]
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] [--size 1024x1024] [--out img.png]
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
- flags[key] = argv[++i];
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) + "PRICING");
125
+ console.log(pad("ID", 20) + pad("MODALITY", 10) + pad("PROVIDER", 12) + "RATE");
81
126
  for (const m of data) {
82
- const price = Object.entries(m.pricing ?? {}).map(([k, v]) => `${k}=$${v}`).join(" ");
83
- console.log(pad(m.id, 20) + pad(m.modality, 10) + pad(m.owned_by, 12) + price);
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.n ? { n: Number(flags.n) } : {}),
158
- ...(flags.image ? { image_url: flags.image } : {}),
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["aspect-ratio"] ? { aspect_ratio: flags["aspect-ratio"] } : {}),
180
- ...(flags.image ? { image_url: flags.image } : {}),
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] [--n 1]
249
- [--image <url>] [--out file.png]
250
- ottoport video "<prompt>" [--model kling-3.0] [--duration 5]
251
- [--aspect-ratio 16:9] [--image <url>] [--out clip.mp4]
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
- case "models": return await cmdModels(flags);
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>] [--size 1024x1024]"
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`.
@@ -1,14 +1,22 @@
1
1
  ---
2
- description: List the OttoPort model catalog with live pricing, optionally filtered by modality
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
- List the OttoPort catalog using the `ottoport_list_models` MCP tool.
7
+ Answer with the `ottoport_list_models` MCP tool.
8
8
 
9
- Modality filter (may be empty — then list everything): `$1`
9
+ Argument (may be empty — then list everything): `$1`
10
10
 
11
- Present the result as a table: model id, what it is good at, and price. Group by
12
- modality when no filter was given, and keep it to the models worth choosing
13
- between rather than every row. Never invent a model id that the tool did not
14
- return.
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`, `aspect_ratio` and `image_url` only when the request
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
- async function listModels({ modality } = {}) {
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 price = Object.entries(m.pricing ?? {}).map(([k, v]) => `${k}=$${v}`).join(" ");
45
- return `${m.id} ${m.display_name} [${m.modality}, ${m.owned_by}]${price ? " · " + price : ""}`;
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, size, n, image_url }) {
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
- body: JSON.stringify({
77
- model: model || "gpt-image-2",
78
- prompt,
79
- ...(size ? { size } : {}),
80
- ...(n ? { n } : {}),
81
- ...(image_url ? { image_url } : {}),
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, duration, aspect_ratio, image_url }) {
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
- body: JSON.stringify({
96
- model: model || "kling-3.0",
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: "List the models OttoPort exposes, with modality, provider, and pricing. Optionally filter by modality.",
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 from a text prompt through an OttoPort image model (GPT Image, Nano Banana, …). Returns image URL(s).",
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" or "16:9".' },
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
- image_url: { type: "string", description: "Source image URL for image-to-image / edits." },
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 from a text prompt (or an image) through an OttoPort video model (Veo, Kling, Seedance, …). Blocks on the queue and returns a video URL.",
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
- image_url: { type: "string", description: "Source image URL for image-to-video." },
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: "0.1.0" },
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.2",
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",
@@ -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, with modality, provider, and price. Filter with `modality`. |
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` (edits / image-to-image). |
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