ottoport 0.1.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/README.md +167 -0
- package/cli/ottoport.mjs +294 -0
- package/mcp/server.mjs +308 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# OttoPort
|
|
2
|
+
|
|
3
|
+
**One API for every model.** A unified, OpenAI-compatible gateway that routes
|
|
4
|
+
LLM, image, video, and speech generation through a single surface. Calls are
|
|
5
|
+
dispatched through Provider Adapters, so the same public model can use an
|
|
6
|
+
official API, a reseller, or a backup provider without changing the client
|
|
7
|
+
contract.
|
|
8
|
+
|
|
9
|
+
Design language is shared with Ottofy: Inter + Space Grotesk, lime-on-slate
|
|
10
|
+
brand, soft shadows, hairline borders.
|
|
11
|
+
|
|
12
|
+
## Stack
|
|
13
|
+
|
|
14
|
+
- **Next.js 14** (App Router) + TypeScript
|
|
15
|
+
- **Tailwind CSS** + daisyui (`ottoport` theme)
|
|
16
|
+
- **Supabase** — auth, accounts, API keys, usage ledger
|
|
17
|
+
- **Stripe** — prepaid credits / plans
|
|
18
|
+
- Provider SDKs: `openai`, `@anthropic-ai/sdk`
|
|
19
|
+
|
|
20
|
+
## Unified API
|
|
21
|
+
|
|
22
|
+
Base URL: `/api/v1` · Auth: `Authorization: Bearer <key>`
|
|
23
|
+
|
|
24
|
+
| Endpoint | Modality | Notes |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `POST /v1/chat/completions` | LLM | OpenAI-compatible, streaming supported. Routes GPT → OpenAI, Claude → Anthropic. |
|
|
27
|
+
| `POST /v1/images/generations` | Image | `{ model, prompt, size?, n?, image_url? }` → `{ data: [{ url }] }`. |
|
|
28
|
+
| `POST /v1/videos/generations` | Video | Submits `{ model, prompt, duration?, aspect_ratio?, webhook_url?, webhook_secret? }` and returns `202` with an OttoPort job ID. |
|
|
29
|
+
| `GET /v1/videos/generations` | Video | Lists the authenticated project's persisted video generation history. |
|
|
30
|
+
| `GET /v1/videos/generations/:id` | Video | Fetches the latest persisted and provider status. |
|
|
31
|
+
| `POST /v1/videos/generations/:id/webhook` | Video | Retries a terminal job's webhook delivery. |
|
|
32
|
+
| `POST /v1/audio/speech` | Speech | `{ model, input, voice?, format? }`; directly calls OpenAI, ElevenLabs, or Google. |
|
|
33
|
+
| `GET /v1/models` | — | Catalog with pricing + modality. |
|
|
34
|
+
|
|
35
|
+
The gateway is the heart of the product:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
app/api/v1/* route handlers (thin)
|
|
39
|
+
libs/gateway/
|
|
40
|
+
registry.ts model catalog — single source of truth
|
|
41
|
+
router.ts dispatch by model → Provider Adapter
|
|
42
|
+
auth.ts API-key verification (+ dev escape hatch)
|
|
43
|
+
types.ts normalized request/response shapes
|
|
44
|
+
providers/
|
|
45
|
+
index.ts adapter registry + model route policy + fallback
|
|
46
|
+
openrouter.ts unified LLM transport (except Kimi)
|
|
47
|
+
moonshot.ts official Kimi chat API
|
|
48
|
+
openai.ts official OpenAI image API
|
|
49
|
+
google.ts official Gemini image API
|
|
50
|
+
video.ts official Veo / Kling / Volcengine video APIs
|
|
51
|
+
modelark.ts official Seedance 2.0 / Seedream 5.0 APIs
|
|
52
|
+
atlascloud.ts optional Atlas Cloud transport for Seedance / Seedream
|
|
53
|
+
legnext.ts Midjourney image generation via Legnext
|
|
54
|
+
audio.ts official OpenAI / ElevenLabs / Google speech APIs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Adding a model** is a one-line entry in `libs/gateway/registry.ts`. The
|
|
58
|
+
model's `provider` remains its original vendor; runtime transport selection is
|
|
59
|
+
separate. Configure model-specific transport routes and fallbacks in
|
|
60
|
+
`libs/gateway/provider-config.ts`.
|
|
61
|
+
For synchronous requests, routes are tried in order on provider failures. Video
|
|
62
|
+
jobs persist their chosen route so later status polling always reaches the API
|
|
63
|
+
that owns the upstream job ID.
|
|
64
|
+
|
|
65
|
+
## Getting started
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
cp .env.example .env.local # add OpenRouter and official media-provider keys
|
|
69
|
+
npm install
|
|
70
|
+
npm run dev # http://localhost:3014
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The landing-page playground requires a Supabase session. Run
|
|
74
|
+
[`sql/schema.sql`](sql/schema.sql), then [`sql/billing-wallet-migration.sql`](sql/billing-wallet-migration.sql)
|
|
75
|
+
and [`sql/projects-migration.sql`](sql/projects-migration.sql), then [`sql/wallet-holds-migration.sql`](sql/wallet-holds-migration.sql)
|
|
76
|
+
in the Supabase SQL editor, configure the
|
|
77
|
+
three Supabase environment variables from `.env.example`, then sign in with
|
|
78
|
+
email magic link or Google. Every new account receives the $5 free balance
|
|
79
|
+
defined by the `accounts.credits_cents` default.
|
|
80
|
+
|
|
81
|
+
### Async video jobs and webhooks
|
|
82
|
+
|
|
83
|
+
Run [`sql/generation-jobs-migration.sql`](sql/generation-jobs-migration.sql), then
|
|
84
|
+
[`sql/generation-queue-migration.sql`](sql/generation-queue-migration.sql), then
|
|
85
|
+
[`sql/account-concurrency-limits-migration.sql`](sql/account-concurrency-limits-migration.sql), after the project migration.
|
|
86
|
+
Video submissions are persisted and queued, so the returned job ID can be queried after a process restart. Add an HTTPS
|
|
87
|
+
`webhook_url` and a caller-held `webhook_secret` to receive a signed `video.completed` or `video.failed` event.
|
|
88
|
+
The signature header is `x-ottoport-signature` and uses `HMAC-SHA256(secret, "timestamp.payload")`.
|
|
89
|
+
|
|
90
|
+
Set `GENERATION_JOBS_CRON_SECRET`, then configure a trusted scheduler to call
|
|
91
|
+
`GET /api/internal/generation-jobs/dispatch` every minute with `Authorization: Bearer <secret>`.
|
|
92
|
+
The dispatcher atomically leases queued jobs, limits in-flight jobs per account and provider, retries provider
|
|
93
|
+
failures with exponential backoff, and sends terminal webhooks even when the API client is no longer polling.
|
|
94
|
+
|
|
95
|
+
### Dynamic account concurrency
|
|
96
|
+
|
|
97
|
+
Video concurrency is evaluated per account at the moment a job is claimed.
|
|
98
|
+
`concurrency_limit_tiers` maps successful paid cash top-ups in the current UTC
|
|
99
|
+
month to capacities; `account_concurrency_overrides` provides expiring
|
|
100
|
+
per-account adjustments. Update either table in Supabase to change capacity
|
|
101
|
+
without deploying. The migration seeds starter, growth, scale, and enterprise
|
|
102
|
+
defaults; treat their thresholds and limits as product configuration.
|
|
103
|
+
|
|
104
|
+
For example, to temporarily grant one account five in-flight video jobs for a
|
|
105
|
+
week, upsert `account_concurrency_overrides` with `video_concurrency = 5` and
|
|
106
|
+
an `expires_at` timestamp. The next worker claim uses that value immediately.
|
|
107
|
+
|
|
108
|
+
### Request reservations and idempotency
|
|
109
|
+
|
|
110
|
+
Chat and video calls require an `Idempotency-Key` request header. OttoPort atomically reserves the worst-case
|
|
111
|
+
cost before submitting upstream, then settles the reservation from actual usage. Reuse the same key only when
|
|
112
|
+
retrying the identical request; a new key represents a new billable generation. Configure the gateway caps in
|
|
113
|
+
`.env.example` before production traffic.
|
|
114
|
+
|
|
115
|
+
### Stripe prepaid wallet
|
|
116
|
+
|
|
117
|
+
Set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `NEXT_PUBLIC_APP_URL`.
|
|
118
|
+
The required billing migration adds paid-vs-bonus
|
|
119
|
+
balances, a tamper-resistant wallet ledger, saved payment methods, and
|
|
120
|
+
automatic top-up settings.
|
|
121
|
+
|
|
122
|
+
For an environment where `billing-wallet-migration.sql` was already applied,
|
|
123
|
+
run [`sql/auto-topup-safety-migration.sql`](sql/auto-topup-safety-migration.sql)
|
|
124
|
+
as well. Then configure a Stripe webhook for:
|
|
125
|
+
|
|
126
|
+
- `checkout.session.completed`
|
|
127
|
+
- `checkout.session.async_payment_succeeded`
|
|
128
|
+
- `payment_intent.succeeded`
|
|
129
|
+
|
|
130
|
+
pointing to `https://your-domain/api/billing/webhook`. The dashboard creates a
|
|
131
|
+
one-time Checkout Session (not a subscription). Users can also save a card and
|
|
132
|
+
enable automatic top-up after explicit consent. Credits are added only after
|
|
133
|
+
the webhook verifies Stripe's raw-body signature and records the Checkout
|
|
134
|
+
Session or PaymentIntent ID, so webhook retries cannot duplicate a top-up. Use
|
|
135
|
+
Stripe CLI during local testing to forward events to
|
|
136
|
+
`http://localhost:3014/api/billing/webhook`.
|
|
137
|
+
|
|
138
|
+
## Access surfaces
|
|
139
|
+
|
|
140
|
+
The `/api/v1` gateway is the product; every other surface is a thin client over
|
|
141
|
+
it. Point them at a running gateway with two env vars: `OTTOPORT_BASE_URL` and
|
|
142
|
+
`OTTOPORT_API_KEY` (the `op-…` key, or `OTTOPORT_KEY_SECRET` in dev).
|
|
143
|
+
|
|
144
|
+
- **HTTP** — OpenAI-compatible. Point any OpenAI SDK's `baseURL` at
|
|
145
|
+
`${OTTOPORT_BASE_URL}/api/v1`.
|
|
146
|
+
- **CLI** — [`cli/ottoport.mjs`](cli/ottoport.mjs), zero-dependency (Node 20+).
|
|
147
|
+
`npm run cli -- models` · `chat` · `image` · `video` · `speech` · `music`. Installs as the
|
|
148
|
+
`ottoport` bin. Streams chat, `--out` downloads image/video results.
|
|
149
|
+
- **MCP** — [`mcp/server.mjs`](mcp/server.mjs), a dependency-free stdio server
|
|
150
|
+
exposing `ottoport_list_models`, `ottoport_chat`, `ottoport_generate_image`,
|
|
151
|
+
`ottoport_generate_video`, `ottoport_generate_speech`, and `ottoport_generate_music`. Register with
|
|
152
|
+
`claude mcp add ottoport -- node $PWD/mcp/server.mjs`.
|
|
153
|
+
- **Skill** — [`skills/ottoport/SKILL.md`](skills/ottoport/SKILL.md) teaches an
|
|
154
|
+
agent to drive OttoPort via the CLI/MCP.
|
|
155
|
+
|
|
156
|
+
Shared TS client for embedding in your own code: [`libs/client.ts`](libs/client.ts).
|
|
157
|
+
|
|
158
|
+
## What's wired vs. stubbed
|
|
159
|
+
|
|
160
|
+
- ✅ Landing page, docs, playground, model catalog, unified gateway + adapters
|
|
161
|
+
- ✅ CLI, MCP server, and Skill — all thin clients over `/api/v1`
|
|
162
|
+
- ✅ Supabase sign-in requirement + metered landing-page playground (schema in `sql/schema.sql`)
|
|
163
|
+
- ✅ Stripe Checkout top-ups with verified, idempotent webhook crediting
|
|
164
|
+
- ✅ Hashed `api_keys` lookup in `libs/gateway/auth.ts` for the public `/api/v1` gateway
|
|
165
|
+
- 🔲 Dashboard key management UI
|
|
166
|
+
|
|
167
|
+
See the SQL migrations for the accounts / workspaces (projects) / api_keys / usage_events tables and RLS.
|
package/cli/ottoport.mjs
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ── OttoPort CLI ─────────────────────────────────────────────────────
|
|
3
|
+
// One command for every model. A thin, dependency-free client over the
|
|
4
|
+
// unified /api/v1 gateway — the same HTTP surface an external customer uses.
|
|
5
|
+
//
|
|
6
|
+
// ottoport models [--modality chat|image|video]
|
|
7
|
+
// ottoport chat "prompt" [--model claude-sonnet-5] [--system "..."] [--no-stream]
|
|
8
|
+
// ottoport image "prompt" [--model gpt-image-2] [--size 1024x1024] [--out img.png]
|
|
9
|
+
// ottoport video "prompt" [--model kling-3.0] [--duration 5] [--out clip.mp4]
|
|
10
|
+
// ottoport speech "text" [--model gpt-4o-mini-tts] [--voice alloy] [--out speech.mp3]
|
|
11
|
+
// ottoport music "prompt" [--model suno-v5] [--duration 15] [--out song.mp3]
|
|
12
|
+
// ottoport mcp
|
|
13
|
+
//
|
|
14
|
+
// Config (flags override env):
|
|
15
|
+
// OTTOPORT_API_KEY your op-… key (or the dev OTTOPORT_KEY_SECRET)
|
|
16
|
+
// OTTOPORT_BASE_URL gateway base URL (default http://localhost:3014)
|
|
17
|
+
|
|
18
|
+
import { writeFile } from "node:fs/promises";
|
|
19
|
+
|
|
20
|
+
const BASE = "http://localhost:3014";
|
|
21
|
+
|
|
22
|
+
// ── arg parsing ──────────────────────────────────────────────────────
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const positional = [];
|
|
25
|
+
const flags = {};
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (a.startsWith("--")) {
|
|
29
|
+
const key = a.slice(2);
|
|
30
|
+
if (key.startsWith("no-")) {
|
|
31
|
+
flags[key.slice(3)] = false;
|
|
32
|
+
} else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
33
|
+
flags[key] = argv[++i];
|
|
34
|
+
} else {
|
|
35
|
+
flags[key] = true;
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
positional.push(a);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { positional, flags };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function config(flags) {
|
|
45
|
+
const baseUrl = (flags.url || process.env.OTTOPORT_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || BASE).replace(/\/+$/, "");
|
|
46
|
+
const apiKey = flags.key || process.env.OTTOPORT_API_KEY || process.env.OTTOPORT_KEY_SECRET;
|
|
47
|
+
return { baseUrl, apiKey };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function headers(apiKey, json = true) {
|
|
51
|
+
const h = {};
|
|
52
|
+
if (json) h["content-type"] = "application/json";
|
|
53
|
+
if (apiKey) h["authorization"] = `Bearer ${apiKey}`;
|
|
54
|
+
return h;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function die(msg, code = 1) {
|
|
58
|
+
process.stderr.write(`ottoport: ${msg}\n`);
|
|
59
|
+
process.exit(code);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readError(res) {
|
|
63
|
+
try {
|
|
64
|
+
const body = await res.json();
|
|
65
|
+
if (body?.error?.message) return `${body.error.message} (${body.error.code ?? res.status})`;
|
|
66
|
+
} catch {}
|
|
67
|
+
return `${res.status} ${res.statusText}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── commands ─────────────────────────────────────────────────────────
|
|
71
|
+
async function cmdModels(flags) {
|
|
72
|
+
const { baseUrl, apiKey } = config(flags);
|
|
73
|
+
const qs = flags.modality ? `?modality=${encodeURIComponent(flags.modality)}` : "";
|
|
74
|
+
const res = await fetch(`${baseUrl}/api/v1/models${qs}`, { headers: headers(apiKey, false) });
|
|
75
|
+
if (!res.ok) die(await readError(res));
|
|
76
|
+
const { data } = await res.json();
|
|
77
|
+
if (flags.json) return console.log(JSON.stringify(data, null, 2));
|
|
78
|
+
const pad = (s, n) => String(s).padEnd(n);
|
|
79
|
+
console.log(pad("ID", 20) + pad("MODALITY", 10) + pad("PROVIDER", 12) + "PRICING");
|
|
80
|
+
for (const m of data) {
|
|
81
|
+
const price = Object.entries(m.pricing ?? {}).map(([k, v]) => `${k}=$${v}`).join(" ");
|
|
82
|
+
console.log(pad(m.id, 20) + pad(m.modality, 10) + pad(m.owned_by, 12) + price);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function cmdChat(prompt, flags) {
|
|
87
|
+
if (!prompt) die("chat requires a prompt: ottoport chat \"hello\"");
|
|
88
|
+
const { baseUrl, apiKey } = config(flags);
|
|
89
|
+
const messages = [];
|
|
90
|
+
if (flags.system) messages.push({ role: "system", content: flags.system });
|
|
91
|
+
messages.push({ role: "user", content: prompt });
|
|
92
|
+
|
|
93
|
+
const stream = flags.stream !== false;
|
|
94
|
+
const body = {
|
|
95
|
+
model: flags.model || "claude-sonnet-5",
|
|
96
|
+
messages,
|
|
97
|
+
stream,
|
|
98
|
+
...(flags.temperature ? { temperature: Number(flags.temperature) } : {}),
|
|
99
|
+
...(flags["max-tokens"] ? { max_tokens: Number(flags["max-tokens"]) } : {}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) die(await readError(res));
|
|
108
|
+
|
|
109
|
+
if (!stream) {
|
|
110
|
+
const json = await res.json();
|
|
111
|
+
console.log(json?.choices?.[0]?.message?.content ?? "");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const reader = res.body.getReader();
|
|
116
|
+
const decoder = new TextDecoder();
|
|
117
|
+
let buffer = "";
|
|
118
|
+
for (;;) {
|
|
119
|
+
const { done, value } = await reader.read();
|
|
120
|
+
if (done) break;
|
|
121
|
+
buffer += decoder.decode(value, { stream: true });
|
|
122
|
+
const lines = buffer.split("\n");
|
|
123
|
+
buffer = lines.pop() ?? "";
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
const t = line.trim();
|
|
126
|
+
if (!t.startsWith("data:")) continue;
|
|
127
|
+
const payload = t.slice(5).trim();
|
|
128
|
+
if (payload === "[DONE]") {
|
|
129
|
+
process.stdout.write("\n");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
|
|
134
|
+
if (delta) process.stdout.write(delta);
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
process.stdout.write("\n");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function download(url, out) {
|
|
142
|
+
const res = await fetch(url);
|
|
143
|
+
if (!res.ok) die(`could not download ${url}: ${res.status}`);
|
|
144
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
145
|
+
await writeFile(out, buf);
|
|
146
|
+
console.error(`saved → ${out} (${buf.length} bytes)`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function cmdImage(prompt, flags) {
|
|
150
|
+
if (!prompt) die("image requires a prompt: ottoport image \"a red fox\"");
|
|
151
|
+
const { baseUrl, apiKey } = config(flags);
|
|
152
|
+
const body = {
|
|
153
|
+
model: flags.model || "gpt-image-2",
|
|
154
|
+
prompt,
|
|
155
|
+
...(flags.size ? { size: flags.size } : {}),
|
|
156
|
+
...(flags.n ? { n: Number(flags.n) } : {}),
|
|
157
|
+
...(flags.image ? { image_url: flags.image } : {}),
|
|
158
|
+
};
|
|
159
|
+
const res = await fetch(`${baseUrl}/api/v1/images/generations`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
|
|
162
|
+
body: JSON.stringify(body),
|
|
163
|
+
});
|
|
164
|
+
if (!res.ok) die(await readError(res));
|
|
165
|
+
const json = await res.json();
|
|
166
|
+
const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
|
|
167
|
+
urls.forEach((u) => console.log(u));
|
|
168
|
+
if (flags.out && urls[0]) await download(urls[0], flags.out);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdVideo(prompt, flags) {
|
|
172
|
+
if (!prompt) die("video requires a prompt: ottoport video \"a drone shot\"");
|
|
173
|
+
const { baseUrl, apiKey } = config(flags);
|
|
174
|
+
const body = {
|
|
175
|
+
model: flags.model || "kling-3.0",
|
|
176
|
+
prompt,
|
|
177
|
+
...(flags.duration ? { duration: Number(flags.duration) } : {}),
|
|
178
|
+
...(flags["aspect-ratio"] ? { aspect_ratio: flags["aspect-ratio"] } : {}),
|
|
179
|
+
...(flags.image ? { image_url: flags.image } : {}),
|
|
180
|
+
};
|
|
181
|
+
console.error("submitting video job (this can take a minute)…");
|
|
182
|
+
const res = await fetch(`${baseUrl}/api/v1/videos/generations`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { ...headers(apiKey), "idempotency-key": crypto.randomUUID() },
|
|
185
|
+
body: JSON.stringify(body),
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok) die(await readError(res));
|
|
188
|
+
let job = await res.json();
|
|
189
|
+
if (job.status === "failed") die(job.error || "video generation failed", 2);
|
|
190
|
+
if (!flags["no-wait"]) {
|
|
191
|
+
process.stderr.write(`job ${job.id} accepted; waiting for completion…\n`);
|
|
192
|
+
while (job.status === "queued" || job.status === "processing") {
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
194
|
+
const status = await fetch(`${baseUrl}/api/v1/videos/generations/${encodeURIComponent(job.id)}`, { headers: headers(apiKey, false) });
|
|
195
|
+
if (!status.ok) die(await readError(status));
|
|
196
|
+
job = await status.json();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (job.status === "failed") die(job.error || "video generation failed", 2);
|
|
200
|
+
const url = job.data?.[0]?.url;
|
|
201
|
+
if (url) {
|
|
202
|
+
console.log(url);
|
|
203
|
+
if (flags.out) await download(url, flags.out);
|
|
204
|
+
} else {
|
|
205
|
+
console.log(JSON.stringify(job, null, 2));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function cmdAudio(kind, prompt, flags) {
|
|
210
|
+
if (!prompt) die(`${kind} requires a prompt: ottoport ${kind} "hello"`);
|
|
211
|
+
const { baseUrl, apiKey } = config(flags);
|
|
212
|
+
const body = {
|
|
213
|
+
model: flags.model || (kind === "speech" ? "gpt-4o-mini-tts" : "suno-v5"),
|
|
214
|
+
prompt,
|
|
215
|
+
...(flags.voice ? { voice: flags.voice } : {}),
|
|
216
|
+
...(flags.duration ? { duration: Number(flags.duration) } : {}),
|
|
217
|
+
...(flags.format ? { format: flags.format } : {}),
|
|
218
|
+
};
|
|
219
|
+
const path = kind === "speech" ? "audio/speech" : "audio/music";
|
|
220
|
+
const res = await fetch(`${baseUrl}/api/v1/${path}`, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: headers(apiKey),
|
|
223
|
+
body: JSON.stringify(body),
|
|
224
|
+
});
|
|
225
|
+
if (!res.ok) die(await readError(res));
|
|
226
|
+
const json = await res.json();
|
|
227
|
+
const item = json.data?.[0];
|
|
228
|
+
if (!item) die("no audio returned", 2);
|
|
229
|
+
if (item.url) console.log(item.url);
|
|
230
|
+
if (flags.out) {
|
|
231
|
+
if (item.b64_json) {
|
|
232
|
+
const bytes = Buffer.from(item.b64_json, "base64");
|
|
233
|
+
await writeFile(flags.out, bytes);
|
|
234
|
+
console.error(`saved → ${flags.out} (${bytes.length} bytes)`);
|
|
235
|
+
} else if (item.url) {
|
|
236
|
+
await download(item.url, flags.out);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const HELP = `OttoPort — one command for every model.
|
|
242
|
+
|
|
243
|
+
Usage:
|
|
244
|
+
ottoport models [--modality chat|image|video|tts|music] [--json]
|
|
245
|
+
ottoport chat "<prompt>" [--model claude-sonnet-5] [--system "..."] [--no-stream]
|
|
246
|
+
[--temperature 0.7] [--max-tokens 512]
|
|
247
|
+
ottoport image "<prompt>" [--model gpt-image-2] [--size 1024x1024] [--n 1]
|
|
248
|
+
[--image <url>] [--out file.png]
|
|
249
|
+
ottoport video "<prompt>" [--model kling-3.0] [--duration 5]
|
|
250
|
+
[--aspect-ratio 16:9] [--image <url>] [--out clip.mp4]
|
|
251
|
+
ottoport speech "<text>" [--model gpt-4o-mini-tts] [--voice alloy] [--format mp3] [--out speech.mp3]
|
|
252
|
+
ottoport music "<prompt>" [--model suno-v5] [--duration 15] [--format mp3] [--out song.mp3]
|
|
253
|
+
ottoport mcp
|
|
254
|
+
|
|
255
|
+
Global flags:
|
|
256
|
+
--url <base> gateway base URL (env OTTOPORT_BASE_URL)
|
|
257
|
+
--key <key> OttoPort API key (env OTTOPORT_API_KEY)
|
|
258
|
+
|
|
259
|
+
Examples:
|
|
260
|
+
export OTTOPORT_API_KEY=op-... OTTOPORT_BASE_URL=https://ottoport.dev
|
|
261
|
+
ottoport models --modality image
|
|
262
|
+
ottoport chat "explain MCP in one line" --model claude-haiku-4.5
|
|
263
|
+
ottoport image "isometric city at dusk" --model gpt-image-2 --out city.png
|
|
264
|
+
ottoport video "cinematic product reveal" --model seedance-2.0-fast
|
|
265
|
+
ottoport speech "Welcome to OttoPort" --voice alloy --out welcome.mp3
|
|
266
|
+
ottoport music "lo-fi focus beat" --duration 20 --out focus.mp3
|
|
267
|
+
ottoport mcp
|
|
268
|
+
`;
|
|
269
|
+
|
|
270
|
+
async function main() {
|
|
271
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
272
|
+
const [command, ...rest] = positional;
|
|
273
|
+
const prompt = rest.join(" ");
|
|
274
|
+
try {
|
|
275
|
+
switch (command) {
|
|
276
|
+
case "models": return await cmdModels(flags);
|
|
277
|
+
case "chat": return await cmdChat(prompt, flags);
|
|
278
|
+
case "image": return await cmdImage(prompt, flags);
|
|
279
|
+
case "video": return await cmdVideo(prompt, flags);
|
|
280
|
+
case "speech": return await cmdAudio("speech", prompt, flags);
|
|
281
|
+
case "music": return await cmdAudio("music", prompt, flags);
|
|
282
|
+
case "mcp": return await import(new URL("../mcp/server.mjs", import.meta.url));
|
|
283
|
+
case undefined:
|
|
284
|
+
case "help":
|
|
285
|
+
case "--help":
|
|
286
|
+
case "-h": return void process.stdout.write(HELP);
|
|
287
|
+
default: die(`unknown command "${command}". Run \`ottoport help\`.`);
|
|
288
|
+
}
|
|
289
|
+
} catch (err) {
|
|
290
|
+
die(err?.message || String(err));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
main();
|
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ── OttoPort MCP server ──────────────────────────────────────────────
|
|
3
|
+
// Exposes the unified OttoPort gateway as Model Context Protocol tools so any
|
|
4
|
+
// MCP client (Claude Desktop, Claude Code, Cursor, …) can call every model
|
|
5
|
+
// through one surface. Dependency-free: implements the stdio JSON-RPC 2.0
|
|
6
|
+
// transport directly (newline-delimited messages).
|
|
7
|
+
//
|
|
8
|
+
// Config via env:
|
|
9
|
+
// OTTOPORT_API_KEY your op-… key (or the dev OTTOPORT_KEY_SECRET)
|
|
10
|
+
// OTTOPORT_BASE_URL gateway base URL (default http://localhost:3014)
|
|
11
|
+
//
|
|
12
|
+
// Register with Claude Code:
|
|
13
|
+
// claude mcp add ottoport -- node /abs/path/to/mcp/server.mjs
|
|
14
|
+
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
|
|
17
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
18
|
+
const BASE = (process.env.OTTOPORT_BASE_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3014").replace(/\/+$/, "");
|
|
19
|
+
const KEY = process.env.OTTOPORT_API_KEY || process.env.OTTOPORT_KEY_SECRET;
|
|
20
|
+
|
|
21
|
+
function headers(json = true) {
|
|
22
|
+
const h = {};
|
|
23
|
+
if (json) h["content-type"] = "application/json";
|
|
24
|
+
if (KEY) h["authorization"] = `Bearer ${KEY}`;
|
|
25
|
+
return h;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function gwError(res) {
|
|
29
|
+
try {
|
|
30
|
+
const body = await res.json();
|
|
31
|
+
if (body?.error?.message) return `${body.error.message} (${body.error.code ?? res.status})`;
|
|
32
|
+
} catch {}
|
|
33
|
+
return `${res.status} ${res.statusText}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── tool implementations ─────────────────────────────────────────────
|
|
37
|
+
async function listModels({ modality } = {}) {
|
|
38
|
+
const qs = modality ? `?modality=${encodeURIComponent(modality)}` : "";
|
|
39
|
+
const res = await fetch(`${BASE}/api/v1/models${qs}`, { headers: headers(false) });
|
|
40
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
41
|
+
const { data } = await res.json();
|
|
42
|
+
return data
|
|
43
|
+
.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 : ""}`;
|
|
46
|
+
})
|
|
47
|
+
.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function chat({ prompt, model, system, temperature, max_tokens }) {
|
|
51
|
+
if (!prompt) throw new Error("`prompt` is required");
|
|
52
|
+
const messages = [];
|
|
53
|
+
if (system) messages.push({ role: "system", content: system });
|
|
54
|
+
messages.push({ role: "user", content: prompt });
|
|
55
|
+
const res = await fetch(`${BASE}/api/v1/chat/completions`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { ...headers(), "idempotency-key": crypto.randomUUID() },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
model: model || "claude-sonnet-5",
|
|
60
|
+
messages,
|
|
61
|
+
stream: false,
|
|
62
|
+
...(temperature != null ? { temperature } : {}),
|
|
63
|
+
...(max_tokens != null ? { max_tokens } : {}),
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
67
|
+
const json = await res.json();
|
|
68
|
+
return json?.choices?.[0]?.message?.content ?? "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function generateImage({ prompt, model, size, n, image_url }) {
|
|
72
|
+
if (!prompt) throw new Error("`prompt` is required");
|
|
73
|
+
const res = await fetch(`${BASE}/api/v1/images/generations`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
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
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
85
|
+
const json = await res.json();
|
|
86
|
+
const urls = (json.data ?? []).map((d) => d.url).filter(Boolean);
|
|
87
|
+
return urls.length ? urls.join("\n") : "(no image returned)";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function generateVideo({ prompt, model, duration, aspect_ratio, image_url }) {
|
|
91
|
+
if (!prompt) throw new Error("`prompt` is required");
|
|
92
|
+
const res = await fetch(`${BASE}/api/v1/videos/generations`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
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
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
104
|
+
let job = await res.json();
|
|
105
|
+
if (job.status === "failed") throw new Error(job.error || "video generation failed");
|
|
106
|
+
// The public video endpoint is deliberately asynchronous. MCP tools should
|
|
107
|
+
// still fulfil their promise of returning usable output, so wait for the
|
|
108
|
+
// job rather than returning a queued-job JSON blob to the agent.
|
|
109
|
+
const deadline = Date.now() + 10 * 60_000;
|
|
110
|
+
while (job.status === "queued" || job.status === "processing") {
|
|
111
|
+
if (Date.now() >= deadline) return `Video job ${job.id} is still processing. Poll /api/v1/videos/generations/${job.id} for its result.`;
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
113
|
+
const status = await fetch(`${BASE}/api/v1/videos/generations/${encodeURIComponent(job.id)}`, { headers: headers(false) });
|
|
114
|
+
if (!status.ok) throw new Error(await gwError(status));
|
|
115
|
+
job = await status.json();
|
|
116
|
+
}
|
|
117
|
+
if (job.status === "failed") throw new Error(job.error || "video generation failed");
|
|
118
|
+
const url = job.data?.[0]?.url;
|
|
119
|
+
return url || JSON.stringify(job);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function generateAudio(kind, { prompt, model, voice, duration, format }) {
|
|
123
|
+
if (!prompt) throw new Error("`prompt` is required");
|
|
124
|
+
const res = await fetch(`${BASE}/api/v1/audio/${kind === "tts" ? "speech" : "music"}`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: headers(),
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
model: model || (kind === "tts" ? "gpt-4o-mini-tts" : "suno-v5"),
|
|
129
|
+
prompt,
|
|
130
|
+
...(voice ? { voice } : {}),
|
|
131
|
+
...(duration ? { duration } : {}),
|
|
132
|
+
...(format ? { format } : {}),
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) throw new Error(await gwError(res));
|
|
136
|
+
const json = await res.json();
|
|
137
|
+
const audio = json.data?.[0];
|
|
138
|
+
if (!audio) return "(no audio returned)";
|
|
139
|
+
return audio.url || (audio.b64_json ? `data:audio;base64,${audio.b64_json}` : "(no audio returned)");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── tool registry ────────────────────────────────────────────────────
|
|
143
|
+
const TOOLS = [
|
|
144
|
+
{
|
|
145
|
+
name: "ottoport_list_models",
|
|
146
|
+
description: "List the models OttoPort exposes, with modality, provider, and pricing. Optionally filter by modality.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
modality: { type: "string", enum: ["chat", "image", "video", "tts", "music"], description: "Filter to one modality." },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
handler: listModels,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "ottoport_chat",
|
|
157
|
+
description: "Chat completion through any OttoPort LLM (GPT, Claude, Gemini, …). Returns the assistant's reply text.",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
prompt: { type: "string", description: "The user message." },
|
|
162
|
+
model: { type: "string", description: "Model id, e.g. gpt-5.5, claude-sonnet-5, gemini-3.5-flash. Default claude-sonnet-5." },
|
|
163
|
+
system: { type: "string", description: "Optional system prompt." },
|
|
164
|
+
temperature: { type: "number" },
|
|
165
|
+
max_tokens: { type: "number" },
|
|
166
|
+
},
|
|
167
|
+
required: ["prompt"],
|
|
168
|
+
},
|
|
169
|
+
handler: chat,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
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).",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: {
|
|
177
|
+
prompt: { type: "string" },
|
|
178
|
+
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".' },
|
|
180
|
+
n: { type: "number" },
|
|
181
|
+
image_url: { type: "string", description: "Source image URL for image-to-image / edits." },
|
|
182
|
+
},
|
|
183
|
+
required: ["prompt"],
|
|
184
|
+
},
|
|
185
|
+
handler: generateImage,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
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.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
prompt: { type: "string" },
|
|
194
|
+
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." },
|
|
196
|
+
aspect_ratio: { type: "string", description: 'e.g. "16:9".' },
|
|
197
|
+
image_url: { type: "string", description: "Source image URL for image-to-video." },
|
|
198
|
+
},
|
|
199
|
+
required: ["prompt"],
|
|
200
|
+
},
|
|
201
|
+
handler: generateVideo,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "ottoport_generate_speech",
|
|
205
|
+
description: "Convert text to speech through an OttoPort TTS model. Returns an audio URL or data URL.",
|
|
206
|
+
inputSchema: {
|
|
207
|
+
type: "object",
|
|
208
|
+
properties: {
|
|
209
|
+
prompt: { type: "string", description: "Text to synthesize." },
|
|
210
|
+
model: { type: "string", description: "TTS model id. Default gpt-4o-mini-tts." },
|
|
211
|
+
voice: { type: "string", description: "Optional provider voice id." },
|
|
212
|
+
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
213
|
+
},
|
|
214
|
+
required: ["prompt"],
|
|
215
|
+
},
|
|
216
|
+
handler: (args) => generateAudio("tts", args),
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "ottoport_generate_music",
|
|
220
|
+
description: "Generate music from a prompt through an OttoPort music model. Returns an audio URL or data URL.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {
|
|
224
|
+
prompt: { type: "string", description: "Music-generation prompt." },
|
|
225
|
+
model: { type: "string", description: "Music model id. Default suno-v5." },
|
|
226
|
+
duration: { type: "number", description: "Requested duration in seconds." },
|
|
227
|
+
format: { type: "string", description: 'e.g. "mp3" or "wav".' },
|
|
228
|
+
},
|
|
229
|
+
required: ["prompt"],
|
|
230
|
+
},
|
|
231
|
+
handler: (args) => generateAudio("music", args),
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
|
|
236
|
+
|
|
237
|
+
// ── JSON-RPC plumbing ────────────────────────────────────────────────
|
|
238
|
+
function send(msg) {
|
|
239
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function reply(id, result) {
|
|
243
|
+
send({ jsonrpc: "2.0", id, result });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function replyError(id, code, message) {
|
|
247
|
+
send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function handle(msg) {
|
|
251
|
+
const { id, method, params } = msg;
|
|
252
|
+
|
|
253
|
+
switch (method) {
|
|
254
|
+
case "initialize":
|
|
255
|
+
return reply(id, {
|
|
256
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
257
|
+
capabilities: { tools: {} },
|
|
258
|
+
serverInfo: { name: "ottoport", version: "0.1.0" },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
case "notifications/initialized":
|
|
262
|
+
case "notifications/cancelled":
|
|
263
|
+
return; // notifications: no response
|
|
264
|
+
|
|
265
|
+
case "ping":
|
|
266
|
+
return reply(id, {});
|
|
267
|
+
|
|
268
|
+
case "tools/list":
|
|
269
|
+
return reply(id, {
|
|
270
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
case "tools/call": {
|
|
274
|
+
const tool = TOOL_MAP.get(params?.name);
|
|
275
|
+
if (!tool) return replyError(id, -32602, `Unknown tool: ${params?.name}`);
|
|
276
|
+
try {
|
|
277
|
+
const text = await tool.handler(params.arguments ?? {});
|
|
278
|
+
return reply(id, { content: [{ type: "text", text: String(text) }] });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// Tool errors are reported in-band so the model can react.
|
|
281
|
+
return reply(id, {
|
|
282
|
+
content: [{ type: "text", text: `Error: ${err?.message || String(err)}` }],
|
|
283
|
+
isError: true,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
default:
|
|
289
|
+
if (id !== undefined) replyError(id, -32601, `Method not found: ${method}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const rl = createInterface({ input: process.stdin });
|
|
294
|
+
rl.on("line", (line) => {
|
|
295
|
+
const text = line.trim();
|
|
296
|
+
if (!text) return;
|
|
297
|
+
let msg;
|
|
298
|
+
try {
|
|
299
|
+
msg = JSON.parse(text);
|
|
300
|
+
} catch {
|
|
301
|
+
return; // ignore non-JSON lines
|
|
302
|
+
}
|
|
303
|
+
Promise.resolve(handle(msg)).catch((err) => {
|
|
304
|
+
if (msg?.id !== undefined) replyError(msg.id, -32603, err?.message || "internal error");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
process.stderr.write(`ottoport MCP server ready → ${BASE}${KEY ? "" : " (no OTTOPORT_API_KEY set)"}\n`);
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ottoport",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI and MCP server for OttoPort — one OpenAI-compatible API for every LLM, image, video, and speech model.",
|
|
5
|
+
"homepage": "https://ottoport.ai",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ai",
|
|
8
|
+
"llm",
|
|
9
|
+
"api-gateway",
|
|
10
|
+
"openai-compatible",
|
|
11
|
+
"image-generation",
|
|
12
|
+
"video-generation",
|
|
13
|
+
"text-to-speech",
|
|
14
|
+
"mcp",
|
|
15
|
+
"mcp-server",
|
|
16
|
+
"cli"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.0.0"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"ottoport": "cli/ottoport.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"cli",
|
|
26
|
+
"mcp",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "next dev -p 3014",
|
|
31
|
+
"build": "next build",
|
|
32
|
+
"start": "next start -p 3014",
|
|
33
|
+
"lint": "next lint",
|
|
34
|
+
"cli": "node cli/ottoport.mjs",
|
|
35
|
+
"mcp": "node mcp/server.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@anthropic-ai/sdk": "^0.65.0",
|
|
39
|
+
"@supabase/ssr": "^0.10.2",
|
|
40
|
+
"@supabase/supabase-js": "^2.43.0",
|
|
41
|
+
"@tailwindcss/typography": "^0.5.13",
|
|
42
|
+
"clsx": "^2.1.1",
|
|
43
|
+
"lucide-react": "^0.395.0",
|
|
44
|
+
"next": "^14.2.0",
|
|
45
|
+
"openai": "^4.104.0",
|
|
46
|
+
"react": "^18.3.0",
|
|
47
|
+
"react-dom": "^18.3.0",
|
|
48
|
+
"sonner": "^1.5.0",
|
|
49
|
+
"stripe": "^13.11.0",
|
|
50
|
+
"swr": "^2.2.5",
|
|
51
|
+
"tailwind-merge": "^2.3.0",
|
|
52
|
+
"zod": "^3.23.8"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^20.14.0",
|
|
56
|
+
"@types/react": "^18.3.3",
|
|
57
|
+
"@types/react-dom": "^18.3.0",
|
|
58
|
+
"autoprefixer": "^10.4.19",
|
|
59
|
+
"daisyui": "^4.12.0",
|
|
60
|
+
"eslint": "^8.57.0",
|
|
61
|
+
"eslint-config-next": "^14.2.0",
|
|
62
|
+
"postcss": "^8.4.38",
|
|
63
|
+
"tailwindcss": "^3.4.4",
|
|
64
|
+
"typescript": "^5.4.5"
|
|
65
|
+
}
|
|
66
|
+
}
|