ottoport 1.7.1 → 1.9.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/cli/ottoport.mjs +41 -3
- package/mcp/server.mjs +47 -15
- package/package.json +1 -1
|
@@ -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.9.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "LITBOX LLC",
|
|
7
7
|
"email": "support@ottoport.ai"
|
package/cli/ottoport.mjs
CHANGED
|
@@ -5,6 +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
|
+
// [--video <url|file>] ask about a video (defaults to video-understanding)
|
|
8
9
|
// ottoport image "prompt" [--model gpt-image-2] [--resolution 2k] [--ref a.png,b.png] [--out img.png]
|
|
9
10
|
// ottoport video "prompt" [--model kling-3.0] [--duration 5] [--out clip.mp4]
|
|
10
11
|
// ottoport speech "text" [--model gpt-4o-mini-tts] [--voice alloy] [--out speech.mp3]
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
// The public CLI always targets https://ottoport.ai unless --url is passed
|
|
18
19
|
// explicitly for local development or a self-hosted gateway.
|
|
19
20
|
|
|
20
|
-
import { writeFile } from "node:fs/promises";
|
|
21
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
21
22
|
|
|
22
23
|
const BASE = "https://ottoport.ai";
|
|
23
24
|
|
|
@@ -152,11 +153,13 @@ async function cmdChat(prompt, flags) {
|
|
|
152
153
|
const { baseUrl, apiKey } = config(flags);
|
|
153
154
|
const messages = [];
|
|
154
155
|
if (flags.system) messages.push({ role: "system", content: flags.system });
|
|
155
|
-
|
|
156
|
+
const videos = await videoParts(flags);
|
|
157
|
+
messages.push({ role: "user", content: videos.length ? [{ type: "text", text: prompt }, ...videos] : prompt });
|
|
156
158
|
|
|
157
159
|
const stream = flags.stream !== false;
|
|
158
160
|
const body = {
|
|
159
|
-
|
|
161
|
+
// A video question goes to the model built for it unless one is named.
|
|
162
|
+
model: last(flags.model) || (videos.length ? "video-understanding" : "claude-sonnet-5"),
|
|
160
163
|
messages,
|
|
161
164
|
stream,
|
|
162
165
|
...(flags.temperature ? { temperature: Number(last(flags.temperature)) } : {}),
|
|
@@ -202,6 +205,39 @@ async function cmdChat(prompt, flags) {
|
|
|
202
205
|
process.stdout.write("\n");
|
|
203
206
|
}
|
|
204
207
|
|
|
208
|
+
const VIDEO_TYPES = { ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm", ".mpeg": "video/mpeg", ".mpg": "video/mpeg" };
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `--video`, once or several times (or comma-separated), as chat content parts.
|
|
212
|
+
*
|
|
213
|
+
* A URL is sent as-is: the gateway's transport fetches it, so it must be
|
|
214
|
+
* publicly reachable. A local file is inlined as a base64 data URL, which is
|
|
215
|
+
* convenient for trying a clip but costs a third more than the file on the
|
|
216
|
+
* wire — for anything large, host it and pass the URL.
|
|
217
|
+
*/
|
|
218
|
+
async function videoParts(flags) {
|
|
219
|
+
const list = (Array.isArray(flags.video) ? flags.video : [flags.video])
|
|
220
|
+
.filter((value) => typeof value === "string")
|
|
221
|
+
.flatMap((value) => value.split(",").map((one) => one.trim()))
|
|
222
|
+
.filter(Boolean);
|
|
223
|
+
const parts = [];
|
|
224
|
+
for (const source of list) {
|
|
225
|
+
if (/^(https?:|data:)/i.test(source)) {
|
|
226
|
+
parts.push({ type: "video_url", video_url: { url: source } });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const info = await stat(source).catch(() => null);
|
|
230
|
+
if (!info?.isFile()) die(`--video ${source}: not a URL and no such file`);
|
|
231
|
+
const ext = source.slice(source.lastIndexOf(".")).toLowerCase();
|
|
232
|
+
const type = VIDEO_TYPES[ext];
|
|
233
|
+
if (!type) die(`--video ${source}: unsupported type ${ext || "(none)"}; use ${Object.keys(VIDEO_TYPES).join(", ")}`);
|
|
234
|
+
const mb = info.size / 1e6;
|
|
235
|
+
if (mb > 20) console.error(`ottoport: ${source} is ${mb.toFixed(0)}MB; inlined it will be ~${(mb * 4 / 3).toFixed(0)}MB and may be refused — host it and pass the URL instead`);
|
|
236
|
+
parts.push({ type: "video_url", video_url: { url: `data:${type};base64,${(await readFile(source)).toString("base64")}` } });
|
|
237
|
+
}
|
|
238
|
+
return parts;
|
|
239
|
+
}
|
|
240
|
+
|
|
205
241
|
async function download(url, out) {
|
|
206
242
|
const res = await fetch(url);
|
|
207
243
|
if (!res.ok) die(`could not download ${url}: ${res.status}`);
|
|
@@ -377,6 +413,7 @@ Usage:
|
|
|
377
413
|
ottoport models [--modality chat|image|video|tts|music] [--json]
|
|
378
414
|
ottoport models <model-id> [--json] what that one model accepts
|
|
379
415
|
ottoport chat "<prompt>" [--model claude-sonnet-5] [--system "..."] [--no-stream]
|
|
416
|
+
[--video <url|file>[,<url|file>…]] ask about a video
|
|
380
417
|
[--temperature 0.7] [--max-tokens 512]
|
|
381
418
|
ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024 | --resolution 2k] [--quality high]
|
|
382
419
|
[--aspect-ratio 16:9] [--n 1] [--seed 7]
|
|
@@ -409,6 +446,7 @@ Examples:
|
|
|
409
446
|
ottoport models --modality image # prints what each one accepts
|
|
410
447
|
ottoport models veo-3.1 # its modes, tiers, and every parameter
|
|
411
448
|
ottoport chat "explain MCP in one line" --model claude-haiku-4.5
|
|
449
|
+
ottoport chat "break this clip down shot by shot" --video https://example.com/clip.mp4
|
|
412
450
|
ottoport image "isometric city at dusk" --model gpt-image-2 --out city.png
|
|
413
451
|
ottoport video "cinematic product reveal" --model seedance-2.0-fast --resolution 1080p
|
|
414
452
|
ottoport video "the logo unfolds" --image start.png --last-frame end.png
|
package/mcp/server.mjs
CHANGED
|
@@ -151,6 +151,34 @@ async function chat({ prompt, model, system, temperature, max_tokens }) {
|
|
|
151
151
|
return json?.choices?.[0]?.message?.content ?? "";
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Poll a job until it finishes, or until `timeoutMs` runs out.
|
|
156
|
+
*
|
|
157
|
+
* `statusPath` is the job's status route with `{id}` for the id — the same
|
|
158
|
+
* template the gateway's quote reports as `job_path` — and it is always read
|
|
159
|
+
* from BASE: every request this server makes goes to OTTOPORT_BASE_URL, which
|
|
160
|
+
* is what lets a reseller put its own gateway in front without a code change.
|
|
161
|
+
*
|
|
162
|
+
* Returns the finished job, or the last one seen with `timedOut` set so the
|
|
163
|
+
* caller can say where to pick it up. A failed job throws.
|
|
164
|
+
*/
|
|
165
|
+
async function waitForJob(statusPath, job, { timeoutMs, label }) {
|
|
166
|
+
const path = statusPath.replace("{id}", encodeURIComponent(job.id));
|
|
167
|
+
const deadline = Date.now() + timeoutMs;
|
|
168
|
+
while (job.status === "queued" || job.status === "processing") {
|
|
169
|
+
if (Date.now() >= deadline) return { job, timedOut: true, path };
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
171
|
+
const status = await fetch(`${BASE}${path}`, { headers: headers(false) });
|
|
172
|
+
if (!status.ok) throw new Error(await gwError(status));
|
|
173
|
+
job = await status.json();
|
|
174
|
+
}
|
|
175
|
+
if (job.status === "failed") throw new Error(job.error || `${label} generation failed`);
|
|
176
|
+
return { job, timedOut: false, path };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const IMAGE_JOB_PATH = "/api/v1/images/generations/{id}";
|
|
180
|
+
const VIDEO_JOB_PATH = "/api/v1/videos/generations/{id}";
|
|
181
|
+
|
|
154
182
|
async function generateImage({ prompt, model, ...rest }) {
|
|
155
183
|
// A layer-decomposition model splits `image_url` and takes the prompt as an
|
|
156
184
|
// optional caption; every other image model needs the prompt. The gateway
|
|
@@ -165,10 +193,21 @@ async function generateImage({ prompt, model, ...rest }) {
|
|
|
165
193
|
// live on the gateway and unreachable from any MCP client, silently. The
|
|
166
194
|
// gateway validates against the model's capabilities and says what it
|
|
167
195
|
// does not accept; this layer has no business having an opinion.
|
|
168
|
-
|
|
196
|
+
//
|
|
197
|
+
// Queued as a job and polled: a 4K or layered image can take longer than
|
|
198
|
+
// a proxy in front of the gateway holds a request open, and the hold is
|
|
199
|
+
// then settled on what actually came back.
|
|
200
|
+
body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest, async: true }),
|
|
169
201
|
});
|
|
170
202
|
if (!res.ok) throw new Error(await gwError(res));
|
|
171
|
-
|
|
203
|
+
let json = await res.json();
|
|
204
|
+
// A 202 is a job. A 200 with `data` is the image itself — the dev key, or a
|
|
205
|
+
// gateway that predates image jobs — and is read exactly as before.
|
|
206
|
+
if (res.status === 202) {
|
|
207
|
+
const { job, timedOut, path } = await waitForJob(IMAGE_JOB_PATH, json, { timeoutMs: 5 * 60_000, label: "image" });
|
|
208
|
+
if (timedOut) return `Image job ${job.id} is still processing. Poll ${path} for its result.`;
|
|
209
|
+
json = job;
|
|
210
|
+
}
|
|
172
211
|
// A layer stack prints one line per layer with what the model called it, so
|
|
173
212
|
// the agent can pick "headline" out of eight URLs without opening them.
|
|
174
213
|
const lines = (json.data ?? []).filter((d) => d.url).map((d) =>
|
|
@@ -191,30 +230,23 @@ async function generateVideo({ prompt, model, webhook_url, webhook_secret, ...re
|
|
|
191
230
|
body: JSON.stringify({ model: model || "kling-3.0", ...(prompt ? { prompt } : {}), ...rest, ...(webhook_url ? { webhook_url, webhook_secret: secret } : {}) }),
|
|
192
231
|
});
|
|
193
232
|
if (!res.ok) throw new Error(await gwError(res));
|
|
194
|
-
|
|
195
|
-
if (
|
|
233
|
+
const submitted = await res.json();
|
|
234
|
+
if (submitted.status === "failed") throw new Error(submitted.error || "video generation failed");
|
|
196
235
|
// With a webhook the caller has said where the result should go; holding the
|
|
197
236
|
// tool call open for minutes as well would only block the agent.
|
|
198
|
-
if (webhook_url) return `Video job ${
|
|
237
|
+
if (webhook_url) return `Video job ${submitted.id} is ${submitted.status}. ${webhook_url} will receive video.completed or video.failed when it finishes; check deliveries with ottoport_video_webhook.`;
|
|
199
238
|
// The public video endpoint is deliberately asynchronous. MCP tools should
|
|
200
239
|
// still fulfil their promise of returning usable output, so wait for the
|
|
201
240
|
// job rather than returning a queued-job JSON blob to the agent.
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
if (Date.now() >= deadline) return `Video job ${job.id} is still processing. Poll /api/v1/videos/generations/${job.id} for its result.`;
|
|
205
|
-
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
206
|
-
const status = await fetch(`${BASE}/api/v1/videos/generations/${encodeURIComponent(job.id)}`, { headers: headers(false) });
|
|
207
|
-
if (!status.ok) throw new Error(await gwError(status));
|
|
208
|
-
job = await status.json();
|
|
209
|
-
}
|
|
210
|
-
if (job.status === "failed") throw new Error(job.error || "video generation failed");
|
|
241
|
+
const { job, timedOut, path } = await waitForJob(VIDEO_JOB_PATH, submitted, { timeoutMs: 10 * 60_000, label: "video" });
|
|
242
|
+
if (timedOut) return `Video job ${job.id} is still processing. Poll ${path} for its result.`;
|
|
211
243
|
const url = job.data?.[0]?.url;
|
|
212
244
|
return url || JSON.stringify(job);
|
|
213
245
|
}
|
|
214
246
|
|
|
215
247
|
async function videoWebhook({ job_id, action = "status" }) {
|
|
216
248
|
if (!job_id) throw new Error("`job_id` is required");
|
|
217
|
-
const path = `${BASE}
|
|
249
|
+
const path = `${BASE}${VIDEO_JOB_PATH.replace("{id}", encodeURIComponent(job_id))}`;
|
|
218
250
|
const res = action === "retry"
|
|
219
251
|
? await fetch(`${path}/webhook`, { method: "POST", headers: headers(false) })
|
|
220
252
|
: await fetch(path, { headers: headers(false) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ottoport",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Claude Code plugin, CLI and MCP server for OttoPort — one OpenAI-compatible API for every LLM, image, video, and speech model.",
|
|
5
5
|
"homepage": "https://ottoport.ai",
|
|
6
6
|
"repository": {
|