ottoport 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
4
+ "version": "1.5.1",
5
5
  "author": {
6
6
  "name": "LITBOX LLC",
7
7
  "email": "support@ottoport.ai"
package/README.md CHANGED
@@ -22,6 +22,7 @@ ottoport chat "<prompt>" [--model claude-sonnet-5] [--system "..."] [--no-strea
22
22
  ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k]
23
23
  [--aspect-ratio 16:9] [--n 1] [--seed 7]
24
24
  [--image <url>] [--ref <url>[,<url>…]] [--out file.png]
25
+ ottoport image --model qwen-image-layered --image <url> [--layers 4]
25
26
  ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p]
26
27
  [--aspect-ratio 16:9] [--image <url>] [--last-frame <url>]
27
28
  [--ref <url>[,<url>…]] [--out clip.mp4]
@@ -86,6 +87,11 @@ first-and-last-frame (`image_url` + `last_frame_url`), and reference-to-video
86
87
  (`reference_image_urls`). Both take a `resolution`: `480p`…`4k` for video,
87
88
  `1k`/`2k`/`4k` for images, and a tier above a model's base rate costs more.
88
89
 
90
+ Two image models take a picture apart instead: `qwen-image-layered` and
91
+ `seedream-5.0-layers` split `image_url` into RGBA layers and return one URL per
92
+ layer, bottom first (the prompt is an optional caption; `num_layers`, 2–10, is
93
+ honoured by Qwen only). Each layer returned bills separately.
94
+
89
95
  Which modes and tiers a model accepts differs per model, so
90
96
  `ottoport_list_models` — and `ottoport models` in the CLI — prints each one's
91
97
  menu beside its price. For one model in full, including every parameter it
package/cli/ottoport.mjs CHANGED
@@ -92,6 +92,7 @@ async function cmdModel(id, flags) {
92
92
  console.log(`${m.id} — ${m.display_name} [${m.modality}, ${m.owned_by}]`);
93
93
  if (m.description) console.log(m.description);
94
94
  if (m.credits_display) console.log(`rate: ${m.credits_display}`);
95
+ if (m.pricing_display) console.log(`price: ${m.pricing_display}`);
95
96
  console.log(`endpoint: ${m.endpoint}`);
96
97
  const caps = m.capabilities;
97
98
  if (caps?.modes) {
@@ -225,11 +226,13 @@ function references(flags) {
225
226
  }
226
227
 
227
228
  async function cmdImage(prompt, flags) {
228
- if (!prompt) die("image requires a prompt: ottoport image \"a red fox\"");
229
+ // A layer-decomposition model needs only --image; the prompt is a caption.
230
+ if (!prompt && !last(flags.image)) die("image requires a prompt: ottoport image \"a red fox\" (or --image <url> on a layer model)");
229
231
  const { baseUrl, apiKey } = config(flags);
230
232
  const body = {
231
233
  model: last(flags.model) || "gpt-image-2",
232
- prompt,
234
+ ...(prompt ? { prompt } : {}),
235
+ ...(last(flags.layers) ? { num_layers: Number(last(flags.layers)) } : {}),
233
236
  ...(last(flags.size) ? { size: last(flags.size) } : {}),
234
237
  ...(last(flags.resolution) ? { resolution: last(flags.resolution) } : {}),
235
238
  ...(last(flags["aspect-ratio"]) ? { aspect_ratio: flags["aspect-ratio"] } : {}),
@@ -246,7 +249,9 @@ async function cmdImage(prompt, flags) {
246
249
  if (!res.ok) die(await readError(res));
247
250
  const json = await res.json();
248
251
  const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
249
- urls.forEach((u) => console.log(u));
252
+ // A layer stack names each line, so `headline` can be found among eight URLs.
253
+ (json.data ?? []).filter((d) => d.url).forEach((d) =>
254
+ console.log(d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}: ${d.url}` : d.url));
250
255
  if (last(flags.out) && urls[0]) await download(urls[0], last(flags.out));
251
256
  }
252
257
 
@@ -334,6 +339,8 @@ Usage:
334
339
  ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k]
335
340
  [--aspect-ratio 16:9] [--n 1] [--seed 7]
336
341
  [--image <url>] [--ref <url>[,<url>…]] [--out file.png]
342
+ ottoport image --model qwen-image-layered --image <url> [--layers 4]
343
+ split one image into RGBA layers (prompt optional)
337
344
  ottoport video "<prompt>" [--model kling-3.0] [--duration 5] [--resolution 1080p]
338
345
  [--aspect-ratio 16:9] [--seed 7] [--image <url>]
339
346
  [--last-frame <url>] [--ref <url>[,<url>…]]
@@ -358,6 +365,7 @@ Examples:
358
365
  ottoport video "cinematic product reveal" --model seedance-2.0-fast --resolution 1080p
359
366
  ottoport video "the logo unfolds" --image start.png --last-frame end.png
360
367
  ottoport image "her, on a beach" --ref face.png,style.png
368
+ ottoport image --model seedream-5.0-layers --image https://…/poster.png
361
369
  ottoport speech "Welcome to OttoPort" --voice alloy --out welcome.mp3
362
370
  ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
363
371
  ottoport mcp
@@ -17,6 +17,6 @@ Argument (may be empty — then list everything): `$1`
17
17
  - Nothing → list everything, grouped by modality, and keep it to the models
18
18
  worth choosing between rather than every row.
19
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
20
+ Rates come in credits (`credits_display`, the unit the ledger bills in) and in USD (`pricing_display`, what the balance shows) — so quote
21
+ whichever the person asked for, as the tool returns it, without converting yourself. Never invent a
22
22
  model id, a mode, or a resolution the tool did not return.
package/mcp/server.mjs CHANGED
@@ -59,7 +59,10 @@ function accepts(capabilities) {
59
59
  if (Array.isArray(capabilities.modes) && capabilities.modes.length) {
60
60
  parts.push(capabilities.modes.join("/"));
61
61
  }
62
- if (typeof capabilities.maxInputImages === "number") {
62
+ if (capabilities.output === "layers") {
63
+ // Not a reference image: the one input IS the job, and the answer is a stack.
64
+ parts.push(`one image_url, split into RGBA layers${capabilities.maxLayers ? ` (num_layers 2–${capabilities.maxLayers})` : " (count chosen by the model)"}`);
65
+ } else if (typeof capabilities.maxInputImages === "number") {
63
66
  parts.push(capabilities.maxInputImages > 0 ? `up to ${capabilities.maxInputImages} reference images` : "text only");
64
67
  }
65
68
  if (typeof capabilities.maxReferenceImages === "number") {
@@ -80,14 +83,17 @@ async function describeModel(id) {
80
83
  const lines = [
81
84
  `${m.id} — ${m.display_name} [${m.modality}, ${m.owned_by}]`,
82
85
  ...(m.description ? [m.description] : []),
83
- ...(m.credits_display ? [`Rate: ${m.credits_display}`] : []),
86
+ ...(m.credits_display ? [`Rate: ${m.credits_display}${m.pricing_display ? ` (${m.pricing_display})` : ""}`] : []),
84
87
  `Endpoint: ${m.endpoint}`,
85
88
  ];
86
89
  if (caps?.modes) {
87
90
  const refs = caps.maxReferenceImages ? ` (up to ${caps.maxReferenceImages} reference images)` : "";
88
91
  lines.push(`Modes: ${caps.modes.join(", ")}${refs}`);
89
92
  }
90
- if (typeof caps?.maxInputImages === "number") {
93
+ if (caps?.output === "layers") {
94
+ lines.push("Output: the input image split into RGBA layers — one `data` entry per layer, bottom first, each with a `layer` object (index; name, z_index and bounding_box where the model reports them). Billed per layer returned.");
95
+ lines.push(`Input images: exactly one, in image_url. The prompt is an optional caption.${caps.maxLayers ? ` num_layers picks the count, 2–${caps.maxLayers}.` : " The model picks the layer count."}`);
96
+ } else if (typeof caps?.maxInputImages === "number") {
91
97
  lines.push(`Input images: ${caps.maxInputImages > 0 ? `up to ${caps.maxInputImages}` : "none — text-to-image only"}`);
92
98
  }
93
99
  if (caps?.resolutions?.length) {
@@ -117,7 +123,7 @@ async function listModels({ modality, model } = {}) {
117
123
  // unit is spelled out here.
118
124
  return data
119
125
  .map((m) => {
120
- const rate = m.credits_display ? ` · ${m.credits_display}` : "";
126
+ const rate = m.credits_display ? ` · ${m.credits_display}${m.pricing_display ? ` (${m.pricing_display})` : ""}` : "";
121
127
  const what = m.description ? ` — ${m.description}` : "";
122
128
  return `${m.id} [${m.modality}, ${m.owned_by}]${what}${rate}${accepts(m.capabilities)}`;
123
129
  })
@@ -146,7 +152,10 @@ async function chat({ prompt, model, system, temperature, max_tokens }) {
146
152
  }
147
153
 
148
154
  async function generateImage({ prompt, model, ...rest }) {
149
- if (!prompt) throw new Error("`prompt` is required");
155
+ // A layer-decomposition model splits `image_url` and takes the prompt as an
156
+ // optional caption; every other image model needs the prompt. The gateway
157
+ // knows which is which and says so.
158
+ if (!prompt && !rest.image_url) throw new Error("`prompt` is required (or `image_url` alone, on a layer-decomposition model)");
150
159
  const res = await fetch(`${BASE}/api/v1/images/generations`, {
151
160
  method: "POST",
152
161
  headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
@@ -156,12 +165,16 @@ async function generateImage({ prompt, model, ...rest }) {
156
165
  // live on the gateway and unreachable from any MCP client, silently. The
157
166
  // gateway validates against the model's capabilities and says what it
158
167
  // does not accept; this layer has no business having an opinion.
159
- body: JSON.stringify({ model: model || "gpt-image-2", prompt, ...rest }),
168
+ body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest }),
160
169
  });
161
170
  if (!res.ok) throw new Error(await gwError(res));
162
171
  const json = await res.json();
163
- const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
164
- return urls.length ? urls.join("\n") : "(no image returned)";
172
+ // A layer stack prints one line per layer with what the model called it, so
173
+ // the agent can pick "headline" out of eight URLs without opening them.
174
+ const lines = (json.data ?? []).filter((d) => d.url).map((d) =>
175
+ d.layer ? `layer ${d.layer.index}${d.layer.name ? ` (${d.layer.name})` : ""}: ${d.url}` : d.url,
176
+ );
177
+ return lines.length ? lines.join("\n") : "(no image returned)";
165
178
  }
166
179
 
167
180
  async function generateVideo({ prompt, model, ...rest }) {
@@ -243,11 +256,11 @@ const TOOLS = [
243
256
  },
244
257
  {
245
258
  name: "ottoport_generate_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).",
259
+ description: "Generate an image through an OttoPort image model (GPT Image, Nano Banana, …). Text-to-image, editing from one reference, or several references at once. Also layer decomposition: qwen-image-layered and seedream-5.0-layers take `image_url` alone and return the image split into RGBA layers, one URL per layer, billed per layer. Which a model accepts is in its capabilities — call ottoport_list_models. Returns image URL(s).",
247
260
  inputSchema: {
248
261
  type: "object",
249
262
  properties: {
250
- prompt: { type: "string" },
263
+ prompt: { type: "string", description: "What to generate. On a layer-decomposition model, an optional caption of the input image." },
251
264
  model: { type: "string", description: "Model id, e.g. gpt-image-2, nano-banana-2, nano-banana-pro. Default gpt-image-2." },
252
265
  size: { type: "string", description: 'Exact pixels, e.g. "1024x1024". Wins over `resolution`.' },
253
266
  // No `enum`: the vocabulary is "1k"/"2k"/"4k", but which of them a given
@@ -259,10 +272,10 @@ const TOOLS = [
259
272
  aspect_ratio: { type: "string", description: 'e.g. "16:9". Read only when `size` is absent.' },
260
273
  n: { type: "number" },
261
274
  seed: { type: "number" },
262
- image_url: { type: "string", description: "One reference image, for editing and image-to-image." },
275
+ image_url: { type: "string", description: "One reference image, for editing and image-to-image — or, on a layer-decomposition model, the image to split." },
263
276
  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." },
277
+ num_layers: { type: "number", description: "Layer models that let you choose (qwen-image-layered, 2–10): how many RGBA layers to split into. Each bills separately. seedream-5.0-layers decides for itself and refuses this." },
264
278
  },
265
- required: ["prompt"],
266
279
  },
267
280
  handler: generateImage,
268
281
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ottoport",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Claude Code plugin, CLI and MCP server for OttoPort — one OpenAI-compatible API for every LLM, image, video, and speech model.",
5
5
  "homepage": "https://ottoport.ai",
6
6
  "license": "MIT",
@@ -14,9 +14,9 @@ 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: 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. |
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`, `aspect_ratio`, `n`, `seed`, `image_url` (one reference), `reference_image_urls` (several). |
19
+ | `ottoport_generate_image` | `prompt`, plus optional `model`, `size` or `resolution`, `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
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`. |
@@ -40,6 +40,13 @@ prints each one's menu beside its price:
40
40
  Images are the same idea with one axis: `image_url` for a single edit, or
41
41
  `reference_image_urls` for several at once, up to that model's limit.
42
42
 
43
+ Two image models do not generate at all — they take an image apart.
44
+ `qwen-image-layered` and `seedream-5.0-layers` split `image_url` into RGBA
45
+ layers and return one URL per layer, bottom of the stack first; the prompt is
46
+ an optional caption of the image, `num_layers` (2–10) is honoured by Qwen only,
47
+ and **each layer returned bills separately**. Reach for them when the user wants
48
+ a poster, ad or product shot as editable parts rather than a new picture.
49
+
43
50
  `resolution` is separate from all of it, and **it is priced** — a higher tier
44
51
  costs more. The vocabulary is `"480p"`/`"720p"`/`"1080p"`/`"2k"`/`"4k"` for
45
52
  video and `"1k"`/`"2k"`/`"4k"` for images, but **no model offers all of it**:
@@ -69,9 +76,9 @@ sensible default.
69
76
  | music | `suno-v5` | `lyria-2` |
70
77
 
71
78
  Never invent a model id. Call `ottoport_list_models` when unsure — the catalog
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.
79
+ changes, and a wrong id is a failed billable call. Rates come back in credits (the unit the ledger
80
+ bills in) and in USD (the unit the balance shows); quote whichever is asked for
81
+ as given rather than converting yourself.
75
82
 
76
83
  ## What comes back
77
84