ottoport 1.8.0 → 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.
@@ -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.8.0",
4
+ "version": "1.9.0",
5
5
  "author": {
6
6
  "name": "LITBOX LLC",
7
7
  "email": "support@ottoport.ai"
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
- body: JSON.stringify({ model: model || "gpt-image-2", ...(prompt ? { prompt } : {}), ...rest }),
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
- const json = await res.json();
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
- let job = await res.json();
195
- if (job.status === "failed") throw new Error(job.error || "video generation failed");
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 ${job.id} is ${job.status}. ${webhook_url} will receive video.completed or video.failed when it finishes; check deliveries with ottoport_video_webhook.`;
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 deadline = Date.now() + 10 * 60_000;
203
- while (job.status === "queued" || job.status === "processing") {
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}/api/v1/videos/generations/${encodeURIComponent(job_id)}`;
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.8.0",
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": {