=== agent-guide.md === # VDO AI gateway — agent guide This is an AI gateway at `https://gw.mizint.com`. It speaks the OpenAI and Anthropic wire formats, serves every modality (text, embeddings, images, speech, transcription, video, music), exposes itself as MCP tools, and can forward to registered third-party HTTP APIs — all behind one gateway key with a per-key budget. Callers name **aliases** (`fast-writer`), never providers; which provider serves an alias is the admin's choice and can change without the caller noticing. Read this page, then call `GET /v1/models` with your key: that is the list of what *you* may call. Everything else here is how. ## 1. Authenticate Send your gateway key on every request: Authorization: Bearer gw-… `x-api-key: gw-…` (what Anthropic SDKs send) and `api-key: gw-…` (Azure-style OpenAI clients) mean the same thing. Keys are issued by the admin — there is no sign-up and no self-service. A key arrives as a one-time reveal link (`/gw/reveal/`, works once, 15 minutes). ## 2. First call Base URLs differ by SDK because the two dialects put `/v1` on opposite sides: an OpenAI client appends `/chat/completions` to its base, an Anthropic client builds `/v1/messages` itself. curl: curl https://gw.mizint.com/v1/chat/completions \ -H "Authorization: Bearer $GW_KEY" -H "content-type: application/json" \ -d '{"model":"fast-writer","messages":[{"role":"user","content":"Say hi."}]}' OpenAI Python SDK — base URL **with** `/v1`: from openai import OpenAI client = OpenAI(base_url="https://gw.mizint.com/v1", api_key=GW_KEY) r = client.chat.completions.create(model="fast-writer", messages=[{"role": "user", "content": "Say hi."}]) print(r.choices[0].message.content) Anthropic Python SDK — base URL **without** `/v1`: import anthropic client = anthropic.Anthropic(base_url="https://gw.mizint.com", api_key=GW_KEY) r = client.messages.create(model="fast-writer", max_tokens=200, messages=[{"role": "user", "content": "Say hi."}]) print(r.content[0].text) OpenAI Node SDK: import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://gw.mizint.com/v1", apiKey: process.env.GW_KEY }); const r = await client.chat.completions.create({ model: "fast-writer", messages: [{ role: "user", content: "Say hi." }] }); Streaming (`stream: true`), tools / tool calling, and `system` messages work exactly as in the dialect you chose — an alias may be served by a model of the *other* dialect and the gateway translates. Every response carries `x_gateway` with `model_used`, `call_id`, `cost_usd`, normalised `usage`, and — when it applies — `max_tokens_clamped`, `fallback_hops`, `dropped_fields`. Read it for observability; **never branch on it**, except `max_tokens_clamped` (section 6). ## 3. Discover what you may call curl https://gw.mizint.com/v1/models -H "Authorization: Bearer $GW_KEY" Returns only what **your key** may use: aliases first (`x_gateway.kind: "alias"`), then concrete `provider/model` names. Each entry says its `modality`, `invocation` (`sync` | `job`), `streaming`, `pricing` (USD per 1M tokens for token units, per unit otherwise; on an alias it is an indicative quote), and — for media aliases — `input_schema`, the JSON Schema of the arguments you may send. Send exactly those; an unknown key is refused before any provider is called. Prefer aliases. A concrete name (`openai/gpt-4o-mini`) works for experiments but opts out of substitution and fallback. ## 4. Sync vs job `invocation: sync` — call the endpoint for the modality and get the answer in the response: | Modality | Endpoint | Shape | |---|---|---| | text | `POST /v1/chat/completions`, `POST /v1/messages` | OpenAI / Anthropic | | embeddings | `POST /v1/embeddings` | OpenAI | | image | `POST /v1/images/generations` (JSON), `POST /v1/images/edits` (multipart or `{"file_id"}`) | OpenAI | | speech | `POST /v1/audio/speech` → audio bytes, `X-Gateway-*` headers | OpenAI | | transcription | `POST /v1/audio/transcriptions` (multipart or `{"file_id"}`) | OpenAI | `invocation: job` — video, music, long audio, task aliases: POST /gw/jobs {"model":"clip","input":{"prompt":"…","seconds":"5"}} → 202 {"job_id":"job_…","status":"queued","poll_after_ms":2000} GET /gw/jobs/{job_id} → {"status":"queued|running|succeeded|failed", "output":{"files":[{"url","mime_type","bytes","expires_at"}]}, "error":{…}} Poll no faster than `poll_after_ms`. `POST /v1/videos` is the same thing in the OpenAI video shape (the SDK's `videos.create` / `videos.retrieve` / `videos.download_content` work). Output files are served from `/gw/files/{id}` with your key; upload inputs with `POST /gw/files` (multipart) and reference them as `file_id`. ## 5. Budgets Every key has a daily budget (USD, resets at 00:00 UTC) and optionally a lifetime one. Text calls are admitted by clamping `max_tokens` to what the remaining budget affords; media and jobs by their declared units. `GET /gw/usage` shows your spend, ceilings and remaining headroom. ## 6. Errors Three outcomes, carried on the HTTP status and the `Retry-After` header: | Outcome | Status | Header | |---|---|---| | success | 2xx | — | | retry_after | 429, 503 | `Retry-After: ` — always present | | failed | other 4xx / 5xx | never present | The body is in your dialect's error shape plus `x_gateway.retryable` (the status class as one boolean) and a stable `code`. The full code table is at `/gw/docs/errors.md`. Two to know: - `daily_budget_exhausted` (429) — wait for `Retry-After`; tomorrow helps. `lifetime_budget_exhausted` (403) — retrying cannot help. - **`x_gateway.max_tokens_clamped`** on a 200 — the gateway lowered `max_tokens` to fit your budget and the answer may be cut short (`finish_reason: length`). Shorten the task; do not retry as-is. - `content: null` with `finish_reason: length` and no clamp — the model reasons before answering and your `max_tokens` was spent on the reasoning. Send a larger bound (a few hundred tokens at least). Upstream errors from providers are passed through with their own status; on pass-through routes they are passed through verbatim. ## 7. `native` — provider-specific parameters Aliases fix most parameters. To reach a provider-native field the alias does not expose (Anthropic's `thinking`, Kimi's `reasoning_effort`), send it under `native`; it is merged last, in the provider's vocabulary: {"model":"fast-writer","messages":[…],"native":{"thinking":{"type":"enabled","budget_tokens":2000}}} Rules: your key must have `allow_native` (off by default — ask the admin); a `native` key that an alias preset already sets is refused (`native_conflicts_with_preset`); a call with `native` gets no fallback. Typed SDKs drop unknown fields, so use their escape hatch: # OpenAI Python client.chat.completions.create(..., extra_body={"native": {...}}) # Anthropic Python client.messages.create(..., extra_body={"native": {...}}) // OpenAI Node client.chat.completions.create({ ..., native: {...} } as any) // Go (openai-go) option.WithJSONSet("native", map[string]any{...}) ## 8. MCP Point an MCP client at `https://gw.mizint.com/mcp` (streamable HTTP) with the same `Authorization: Bearer` header. `tools/list` returns `list_models`, `poll_job`, one tool per alias you may call (its `inputSchema` is the same schema `/v1/models` publishes), and one per third-party route published as a tool. A job alias's tool submits and returns a `job_id`; poll it with `poll_job`. ## 9. Third-party APIs (`/ext`) Registered upstream APIs are reachable at `/ext/{route}/…` with your key — the gateway injects the upstream credential and signs requests; you never hold it. Resource-scoped routes (a shop, a tenant) take `X-Gateway-Resource: `; `GET /gw/resources` lists the resources your key may use. Bodies and responses pass through verbatim. See `/gw/docs/pass-through.md`. ## Documents - `/llms.txt` — this site's pointer; `/llms-full.txt` — everything in one fetch - `/gw/docs/errors.md` — the error code table, generated from the running code - `/gw/docs/native.md` — `native` in detail, per SDK - `/gw/docs/jobs-and-files.md` — async jobs, `/v1/videos`, `/gw/files` - `/gw/docs/pass-through.md` — `/ext` for callers - `/gw/docs/mcp.md` — the MCP surface - `/admin/api/openapi.json` — the admin API (admins only) === endpoints.md === # URL map | Path | Method | Auth | Purpose | |---|---|---|---| | `/` | GET | none | These docs (`Accept: text/plain` for the raw guide) | | `/llms.txt`, `/llms-full.txt` | GET | none | Agent pointer; everything in one fetch | | `/gw/docs/{name}` | GET | none | One document, inline `text/plain` | | `/healthz` | GET | none | Liveness | | `/v1/chat/completions` | POST | key | OpenAI chat | | `/v1/messages` | POST | key | Anthropic messages | | `/v1/models` | GET | key | What this key may call, with schemas and prices | | `/v1/embeddings` | POST | key | OpenAI embeddings | | `/v1/images/generations`, `/v1/images/edits` | POST | key | OpenAI images (edits: multipart or `file_id`) | | `/v1/audio/transcriptions`, `/v1/audio/speech` | POST | key | OpenAI audio (transcriptions: multipart or `file_id`) | | `/v1/videos`, `/v1/videos/{id}`, `/v1/videos/{id}/content` | POST, GET | key | OpenAI video shape over jobs | | `/gw/jobs`, `/gw/jobs/{id}` | POST, GET | key | Async jobs | | `/gw/files`, `/gw/files/{id}` | POST, GET | key | Uploads and outputs | | `/gw/usage` | GET | key | This key's spend and headroom | | `/gw/resources` | GET | key | Third-party resources this key may use | | `/gw/reveal/{token}` | GET | none, single-use | One-time key reveal | | `/mcp` | POST | key | MCP, streamable HTTP | | `/ext/{route}/...` | any | key | Third-party pass-through | | `/ext/{route}/_callback` | GET | state | Provider consent redirect | | `/admin/`, `/admin/api/*` | — | admin session | Console and admin API (`/admin/api/openapi.json`) | `/v1` is the *dialect's* version, not the gateway's: there are no un-versioned duplicates and the gateway never puts its own version in a path. Gateway-native surfaces (`/gw`, `/ext`, `/mcp`) evolve additively. === errors.md === # Error codes Three outcomes, carried on the HTTP status and the `Retry-After` header. This table is generated from the running gateway's code table. | Code | Status | Outcome | Retry-After | |---|---|---|---| | `invalid_request` | 400 | failed | never | | `model_ambiguous` | 400 | failed | never | | `native_conflicts_with_preset` | 400 | failed | never | | `native_invalid` | 400 | failed | never | | `recursion_depth_exceeded` | 400 | failed | never | | `resource_required` | 400 | failed | never | | `unknown_model` | 400 | failed | never | | `unsupported_parameter` | 400 | failed | never | | `multiple_credentials` | 401 | failed | never | | `unauthenticated` | 401 | failed | never | | `lifetime_budget_exhausted` | 403 | failed | never | | `model_not_permitted` | 403 | failed | never | | `native_not_permitted` | 403 | failed | never | | `resource_not_permitted` | 403 | failed | never | | `route_not_permitted` | 403 | failed | never | | `task_alias_not_permitted` | 403 | failed | never | | `model_not_found` | 404 | failed | never | | `not_found` | 404 | failed | never | | `payload_too_large` | 413 | failed | never | | `daily_budget_exhausted` | 429 | retry_after | always | | `key_rate_limited` | 429 | retry_after | always | | `provider_rate_limited` | 429 | retry_after | always | | `gateway_error` | 500 | failed | never | | `native_no_fallback` | 502 | failed | never | | `provider_error` | 502 | failed | never | | `provider_unavailable` | 503 | retry_after | always | | `resource_needs_reauth` | 503 | retry_after | always | Body, OpenAI rendering: {"error":{"message":"…","type":"rate_limit_error","code":"daily_budget_exhausted","param":null}, "x_gateway":{"retryable":true,"retry_after_seconds":43200,"call_id":"call_…"}} Anthropic rendering (any request carrying an `anthropic-version` header): {"type":"error","error":{"type":"rate_limit_error","message":"…","code":"daily_budget_exhausted"}, "x_gateway":{"retryable":true,"retry_after_seconds":43200,"call_id":"call_…"}} `x_gateway.retryable` restates the status class in one boolean. Upstream provider errors pass through with their own status; on same-dialect calls their body is verbatim, on cross-dialect calls it is wrapped as `provider_error` with the upstream status kept. Pass-through routes (`/ext`) return upstream errors verbatim, always. === native.md === # `native` — the provider escape hatch An alias is a preset plus the keys its admin marked overridable. `native` is how a caller reaches a provider-native parameter the alias does not expose, without the gateway pretending to understand it. { "model": "fast-writer", "messages": [ … ], "native": { "thinking": { "type": "enabled", "budget_tokens": 2000 } } } Processing order, in the gateway: 1. Your key must carry `allow_native` (off by default). Otherwise `403 native_not_permitted`. 2. Any `native` key that the alias preset already sets → `400 native_conflicts_with_preset`, naming every colliding key. 3. `native`'s members are merged into the upstream body **last, in the provider's vocabulary** — after any dialect translation. The wrapper key `native` itself is never sent. 4. A call carrying `native` gets **no fallback**: if the primary target fails, the failure is returned rather than a different model run. 5. The budget clamp still applies afterwards and can only lower `max_tokens`. On a concrete model (`openai/gpt-4o-mini`) of the same dialect as your surface, `native` means the same as top level — every unknown top-level field is forwarded anyway. It matters on aliases (bypassing the preset) and on cross-dialect calls (an OpenAI-surface caller reaching an Anthropic-only field). ## Per-SDK incantation Typed SDKs strip unknown fields silently. Use each one's escape hatch: OpenAI Python: client.chat.completions.create(model="fast-writer", messages=[...], extra_body={"native": {"thinking": {"type": "enabled", "budget_tokens": 2000}}}) Anthropic Python: client.messages.create(model="fast-writer", max_tokens=1024, messages=[...], extra_body={"native": {"top_k": 40}}) OpenAI Node: await client.chat.completions.create({ model: "fast-writer", messages: [...], native: { thinking: { type: "enabled", budget_tokens: 2000 } }, } as any); openai-go: client.Chat.Completions.New(ctx, params, option.WithJSONSet("native", map[string]any{"thinking": map[string]any{"type": "enabled", "budget_tokens": 2000}})) curl: put `native` in the JSON body. ## What you cannot do with it `native` cannot override a preset key, cannot change `model`, and is not validated against a schema unless the admin fetched one for the target model — in which case unknown or ill-typed keys are `400 native_invalid`, naming them. With no schema cached the gateway forwards blind and the provider decides; it never rejects for want of a schema. === jobs-and-files.md === # Async jobs and files Video, music, long audio and task aliases run as jobs. No standard exists for this, so the shape is the gateway's. ## Submit POST /gw/jobs { "model": "clip", "input": { "prompt": "a red cube rotating", "seconds": "5" }, "native": { ... }, "gw_context": { "run": "r1" } } -> 202 { "job_id": "job_...", "status": "queued", "model": "clip", "created_at": "...", "updated_at": "...", "poll_after_ms": 2000 } `input` must satisfy the alias's `input_schema` (see `/v1/models`). The job is acknowledged only once it is durably recorded: a gateway restart loses no accepted job. ## Poll GET /gw/jobs/{job_id} | `status` | Meaning | Also present | |---|---|---| | `queued` | Accepted, not started | `poll_after_ms` | | `running` | In flight at the provider | `poll_after_ms` | | `succeeded` | Done | `output`, `x_gateway` | | `failed` | Terminal | `error` | `output.files[]` carries `{file_id, url, mime_type, bytes, expires_at, source_url}`. `url` is a gateway URL (`/gw/files/{id}`) that your key can read; `source_url` is the provider's own link, which expires on its schedule. A task alias's `output.completion` is the dialect-shaped completion the task endpoint returned. Honour `poll_after_ms`. Jobs and their files are kept for a retention window and then reaped. ## `/v1/videos` — the OpenAI video shape The same job, in the shape the OpenAI SDK's `videos` client speaks: POST /v1/videos (multipart, as the SDK sends it, or JSON) model=clip prompt=... seconds=5 -> 202 {"id":"job_...","object":"video","status":"queued","progress":0} GET /v1/videos/{id} -> {"status":"queued|in_progress|completed|failed", ...} GET /v1/videos/{id}/content -> the video bytes ## Files POST /gw/files multipart/form-data with one file part -> 201 {"file_id":"file_...","mime_type":"...","bytes":...,"expires_at":"..."} GET /gw/files/{id} -> the bytes (only with the key that uploaded it, or whose job produced it) `file_id` is accepted by `POST /v1/audio/transcriptions` and `POST /v1/images/edits` as a JSON body `{"model":...,"file_id":...}` in place of multipart — the only form that can be reused across calls. === pass-through.md === # Third-party APIs — `/ext/{route}/...` An admin can register any HTTP API as a **route**. Callers reach it at `/ext/{route}/` with an ordinary gateway key; the gateway injects the upstream's credential (and signs the request, where the API requires it), forwards method, path, query, headers and body **verbatim**, and returns the upstream's response verbatim plus `X-Gateway-*` headers. curl "https://gw.mizint.com/ext/weather/v1/now?city=Bangkok" -H "Authorization: Bearer $GW_KEY" Rules: - Routes are **deny-by-default**: your key must be granted each route (`403 route_not_permitted` otherwise). `/v1/models` does not list routes; ask the admin, or read `tools/list` on `/mcp`, where routes published as tools appear. - The upstream credential never reaches you. Your own `Authorization` header is stripped before forwarding. - Upstream errors are **not** translated: a 4xx/5xx and its body come back exactly as the upstream sent them. The gateway acts on exactly one class of failure — the upstream's declared *authentication* failures, which it remedies once (token refresh) or flags for the admin. ## Resource-scoped routes Some APIs are per-tenant (a shop, an account). Such routes need X-Gateway-Resource: on every call, and your key must be granted that resource (`403 resource_not_permitted`; missing header → `400 resource_required`). GET /gw/resources -> {"data":[{"key":"acme-shopee","route":"shopee","external_id":"12345","status":"active","expires_at":"..."}]} lists the resources your key may use — never any credential. A resource whose grant died is `status: needs_reauth` and calls on it are `503 resource_needs_reauth` until an admin re-authorises it; a route whose app secret died is likewise 503 until the admin installs a new one. ## Attribution and cost `X-Gateway-Context: {"run":"r1"}` (a JSON object) on the request is recorded on the spend log verbatim, the way `gw_context` is for AI calls — the body cannot carry it because the body is forwarded as-is. Each route carries a flat price per call, charged on every request or only on requests matching the route's `charge_on` rule — so polling a queue API costs nothing while the submit does. `X-Gateway-Cost-Usd` on the response says what this call cost. === mcp.md === # MCP endpoint: https://gw.mizint.com/mcp transport: streamable HTTP (POST JSON-RPC; no server-initiated stream) auth: Authorization: Bearer (x-api-key also accepted) Methods: `initialize`, `ping`, `tools/list`, `tools/call`. Notifications are acknowledged with 202. A `Mcp-Session-Id` is issued on `initialize` for clients that expect one; the server is stateless and never requires it. ## Tools `tools/list` is filtered by your key's policy, exactly like `/v1/models`: | Tool | What it does | |---|---| | `list_models` | The same document as `GET /v1/models` | | `poll_job` | `{job_id}` → the job's status and output | | `` (text) | `{prompt, system?, max_tokens?}` → the assistant's text; the full completion is `structuredContent` | | `` (image) | the alias's `input_schema` → `{data:[{url, file_id, mime_type}]}` | | `` (video, audio, task) | the alias's `input_schema` → a job: `{job_id, status}`; poll with `poll_job` | | `` | a third-party route published as a tool: its declared `input_schema` → the upstream's response | | `__` | a tool of an upstream MCP server aggregated behind this one | A tool call runs through the gateway exactly as the equivalent REST call would — same policy, budget, clamp, fallback and spend log. A policy or budget refusal comes back as a result with `isError: true` carrying the error document; only a malformed request is a JSON-RPC error. Every gateway-native tool's `inputSchema` is the one schema the alias or route declares — there is no second copy to drift.