Appearance
API endpoints
merido exposes two HTTP surfaces on the same port: the data plane (/v1/*, the LLM request path) and the control plane (/api/*, the dashboard/admin API). This page lists the user-relevant endpoints.
Authentication
/v1/*(data plane) — a client API key asAuthorization: Bearer <key>. WhenMERIDO_REQUIRE_API_KEY=true, anonymous requests are rejected. Create keys withmerido keys createor in the dashboard./api/*(control plane) — a dashboard session (POST /api/login→ bearer JWT) or a management token. When no dashboard password is configured (local default), these are open./healthz— unauthenticated./metricsis unauthenticated on the wide-open local default, but once the deploy is hardened (MERIDO_REQUIRE_API_KEY, a dashboard password, or multi-tenant mode) it requires a session JWT or API key (Authorization: Bearer/X-Api-Key).
Data plane (/v1/*)
| Endpoint | Method | Wire format / purpose |
|---|---|---|
/v1/chat/completions | POST | OpenAI Chat Completions (and Gemini generate-content). |
/v1/messages | POST | Anthropic Messages. |
/v1/messages/count_tokens | POST | Anthropic token counting. |
/v1/responses | POST | OpenAI Responses API. |
/v1/responses/compact | POST | Compact a Responses conversation. |
/v1/embeddings | POST | OpenAI embeddings. |
/v1/rerank | POST | Cohere/Jina/Voyage-style document reranking. |
/v1/moderations | POST | OpenAI-compatible content moderation. |
/v1/ocr | POST | Mistral-OCR-style document OCR passthrough ({model, document}; defaults to mistral/mistral-ocr-latest). |
/v1beta/models/{model}:generateContent | POST | Native Gemini surface. :streamGenerateContent streams: ?alt=sse returns Server-Sent Events, and without it you get the chunked JSON array Google's own SDKs expect by default. A model id containing / (every Virtual Model name does) works unencoded — models/merido/fast:generateContent — as well as percent-encoded. |
/v1/images/generations | POST | Image generation. |
/v1/images/edits | POST | Image editing (multipart upload, up to 64 MB). |
/v1/images/variations | POST | Image variations (multipart upload, up to 64 MB). |
/v1/audio/speech | POST | Text-to-speech. |
/v1/audio/voices | GET | List available text-to-speech voices. |
/v1/audio/transcriptions | POST | Speech-to-text (multipart upload, up to 64 MB). |
/v1/audio/translations | POST | Speech translation to English text (multipart upload, up to 64 MB). |
/v1/models | GET | List available models (client key required). OpenAI-compatible objects (id, object, created, owned_by), enriched with context_window, max_output_tokens, mode, a capabilities block, and a pricing block when known. Virtual models carry the same enrichment, aggregated across their targets. |
/v1/model_group/info | GET | Rich per-model / per-virtual-model metadata: context window, output ceiling, mode, and supports_* capability flags. Virtual models aggregate across targets (largest context window, smallest output ceiling, union of capabilities — the group can serve a capability if any target supports it). |
/v1/web/fetch | POST | Server-side web fetch. |
/v1/search | POST | Search. |
/v1/batches | POST / GET | Submit (inline requests) / list async batch jobs. |
/v1/batches/{id} | GET | Get a batch's status + results; /{id}/cancel (POST) cancels it. |
Chat responses carry an x-merido-cache: hit|miss header (x-merido-cache-type: exact|semantic on a hit) so clients can observe the response cache.
Model metadata honesty
pricing reports USD per 1M tokens as { input, output, cache_read, cache_write, unit }. Two rules matter to any client that does its own cost accounting:
- A field is absent when merido does not know it. Pricing is never reported as zero — a zero would read as "this model is free" and silently corrupt the client's budgeting. The same holds for
context_windowandmax_output_tokens: an absent value means the client should fall back to its own default, not to a number merido invented. - A virtual model reports its dearest target's price. A group may route to any of its targets, so there is no single true price. Over-reserving is correctable after the fact; under-promising cost is not.
See Media backends for the /v1/images/* / /v1/audio/* enabled gate, bare-provider defaults, and per-endpoint curl examples.
Streaming
Streaming requests ("stream": true / stream=true) are Server-Sent Events. Three behaviors apply across all four client wire formats (OpenAI Chat, OpenAI Responses, Anthropic, Gemini):
Usage accounting (stream_options.include_usage)
merido always requests usage from an OpenAI-compatible upstream internally — accounting and budgets are never blind, even when the client doesn't ask for it. What reaches the client depends on the client's own request:
stream_options.include_usagenot set totrue(OpenAI Chat callers only — the default): no usage frame is forwarded. The real token counts are still recorded for cost tracking; they just aren't sent over this wire.Set to
true: usage arrives as its own terminal frame — after the finish-reason chunk, beforedata: [DONE]— with an emptychoicesarray, matching OpenAI's own shape:data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1720000000,"model":"gpt-4o","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46}} data: [DONE]
Heartbeat (stream_heartbeat_ms)
While the upstream is quiet mid-stream, merido emits an SSE comment frame — literally : ping — every stream_heartbeat_ms (default 15000; 0 disables). It's spec-legal SSE that every client parser ignores (comments aren't events), so it's invisible to your code; its only job is to stop an idle reverse proxy or load balancer from severing a slow time-to-first-token or a long tool-building pause. See Environment variables — MERIDO_STREAM_HEARTBEAT_MS.
Mid-stream errors
An upstream failure after streaming has already started — whether the provider sent its own in-band error or the connection simply dropped — now surfaces as an error frame in your wire format, instead of merido silently fabricating a clean finish. The exact shape mirrors that format's own error convention, for example:
| Your format | What you see |
|---|---|
| OpenAI Chat | A chunk carrying an error object in place of a normal choices delta. |
| Anthropic | An error frame: {"type":"error","error":{"type":"api_error","message":"…"}}. |
| Gemini | A data: frame: {"error":{"message":"…"}}. |
| OpenAI Responses | A named response.failed event. |
Whatever streamed before the failure is still billed and recorded (a 502 row in Logs) — the same "you received tokens, you pay for tokens" accounting already applied when a client itself aborted a stream (499).
Cross-provider translation
All formats translate through one canonical representation, so a request in one dialect can be served by a model in another (or by a virtual model). The model field is a provider/model string or a virtual-model name.
Two parameters now survive that round-trip instead of being dropped on a cross-provider hop:
| Parameter | OpenAI | Anthropic | Gemini | Responses |
|---|---|---|---|---|
| Stop sequences | stop (string or array) | stop_sequences | generationConfig.stopSequences | not supported |
| Determinism seed | seed | not supported | generationConfig.seed | not supported |
merido also preserves two OpenAI request shapes it used to mangle: the optional participant name on a user/assistant message round-trips back out on an OpenAI target (other targets ignore it rather than erroring on it), and a system/developer or tool message whose content is the array-of-text-parts form (instead of a plain string) has its text parts joined, rather than being read as empty.
/v1/* also accepts Helicone-compatible request headers, rewritten to merido's native equivalents.
Errors
Every error is an OpenAI-shaped envelope:
json
{ "error": { "message": "…", "type": "…", "param": null, "code": null } }param names the offending field on a validation error; code carries a merido-specific slug for the errors below that don't fit OpenAI's type taxonomy alone. Both keys are always present in the JSON, null when not applicable.
Request validation (/v1/chat/completions only)
OpenAI-format chat requests get early, param-named 400s before anything reaches an upstream — the first violation found wins (Anthropic, Gemini, and Responses requests aren't validated this way):
| Param | Rule |
|---|---|
messages | Required; must be a non-empty array. |
temperature | Number in [0, 2]. |
top_p | Number in [0, 1] — merido is deliberately more lenient than OpenAI's own 0 < top_p ≤ 1 and also accepts exactly 0. |
n | Integer ≥ 1. |
max_tokens / max_completion_tokens | Integer ≥ 1 (each checked independently). |
Nothing else is validated — no unknown-field rejection, no deeper role/content checks; unrecognized fields still pass through to the upstream untouched. An explicit null on any of these is treated the same as omitting it. All of these render as type: "invalid_request_error", e.g. an out-of-range temperature:
json
{"error":{"message":"Invalid value for 'temperature': must be between 0 and 2, got 3.","type":"invalid_request_error","param":"temperature","code":null}}Status codes & code
| Status | type | code | When |
|---|---|---|---|
400 | invalid_request_error | null | A validation failure above, or a malformed request body. |
403 | permission_error | null | The model isn't in the calling key's allowed_models list (was 401). Enforced on every data-plane endpoint — see Per-key scoping coverage. |
404 | invalid_request_error | unknown_url | An unrouted /v1/* or /api/* path. |
413 | invalid_request_error | request_too_large | The request body exceeds this endpoint's size limit. max_body_bytes (default 20 MiB) caps /v1/chat/completions, /v1/messages, /v1/responses, and /v1/embeddings; the media routes (/v1/images/*, /v1/audio/*) allow up to 64 MB; every other route — including /v1/messages/count_tokens — falls back to axum's 2 MiB default. |
See Environment variables — MERIDO_MAX_BODY_BYTES. 429 handling (rate limits vs. quota exhaustion) is an operator/reliability concern, not a validation error — see Circuit breaker & cooldowns.
Per-key scoping coverage
A gateway key's allowed_models glob list and its rate_limit_rpm / rate_limit_tpm caps apply to every data-plane endpoint — /v1/chat/completions, /v1/messages, /v1/messages/count_tokens, /v1/responses, /v1/embeddings, /v1/images/*, /v1/audio/*, /v1/moderations, /v1/rerank, /v1/ocr, /v1/search and /v1/batches. The allow-list is matched against the model string as you send it, so one glob behaves identically everywhere.
A batch is rejected as a whole (403) when any sub-request names a disallowed model, rather than being accepted with entries silently dropped.
Behaviour change
Earlier releases enforced both controls on the chat path only: a key restricted to, say, ["openai/gpt-4o-mini"] could still call /v1/embeddings, /v1/images/* and /v1/audio/* with any model, and its RPM/TPM caps were ignored there. Those calls now return 403 / 429. If a client depended on the gap, widen the key's allowed_models (PATCH /api/keys/{id}) rather than removing the restriction.
Image requests (/v1/images/*) are now recorded as usage rows, so they appear in Logs, /api/usage and a key's request count. Media is not token-billed, so those rows carry zero tokens and no cost — per-image pricing is not yet modelled.
Security fix
POST /v1/responses/compact previously required no API key at all. With mode: "model" it runs a summarizing completion on the operator's provider credentials, so an anonymous caller could spend the operator's money unmetered. It now authenticates like every other /v1 route and honours the key's allow-list and rate caps. If you deploy publicly, treat any pre-fix deployment's provider spend as potentially attributable to unauthenticated traffic.
/v1/search and /v1/web/fetch also apply the caller's per-key rate cap (they authenticated but were unthrottled). /v1/search's provider path runs a real chat completion on your credential, so it applies the model allow-list too, and /v1/messages/count_tokens does the same because it proxies to the provider.
Behaviour change
A key whose allowed_models excludes the target is now refused (403) on /v1/search and /v1/messages/count_tokens instead of being served.
MERIDO_VIRTUAL_MODELS_ONLY covers every endpoint
When the gateway is configured to serve only curated Virtual Models, a raw provider/model id is refused on the non-chat endpoints as well — /v1/embeddings, /v1/rerank, /v1/ocr, /v1/moderations, /v1/images/*, /v1/audio/* and /v1/search. Enforcing it on the chat path alone left the control bypassable by choosing a different endpoint.
Virtual Models themselves are resolved by the chat endpoints only (/v1/chat/completions, /v1/messages, /v1/responses). Sending a Virtual Model name to any other endpoint returns a 400 saying so — those endpoints take a concrete provider/model id. On a virtual-models-only gateway that means the non-chat endpoints are effectively unavailable; that is deliberate, not an oversight.
Failover on the non-chat endpoints
/v1/embeddings, /v1/rerank, /v1/ocr, /v1/moderations, /v1/search and /v1/images/* now walk all of your connections for a provider in priority order instead of using only the highest-priority one, so an expired or throttled credential falls over to the next. A 4xx caused by your request (a 400) is returned immediately rather than retried against every credential.
/v1/audio/* still uses the first connection only.
Usage rows on the non-chat endpoints
/v1/ocr, /v1/rerank, /v1/moderations and /v1/search now record a usage row, so they appear in Logs, /api/usage and a key's request count. Previously they spent your upstream credential without leaving one.
Control plane (/api/*)
A selection of the most useful management endpoints (full CRUD shapes vary by resource):
Providers, keys, accounts
| Endpoint | Methods | Purpose |
|---|---|---|
/api/providers | GET, POST | List / add upstream provider connections. |
/api/providers/{id} | PUT, DELETE | Update / remove a connection. |
/api/keys | GET, POST | List / create client (gateway) keys. Create accepts per-key scoping: allowed_models (model globs), rate_limit_rpm, rate_limit_tpm. |
/api/keys/{id} | DELETE, PATCH | Revoke / update a key; /{id}/rotate (POST) rotates it. |
/api/oauth/providers | GET | List OAuth-capable providers. |
/api/oauth/accounts | GET | List connected OAuth accounts (/{id} DELETE to remove). |
/api/registry | GET | The provider registry. |
/api/models | GET | Session-authed model discovery for the dashboard. |
/api/models/info | GET | Session-authed rich model/group metadata (the dashboard twin of /v1/model_group/info). |
/api/model-catalog | GET | Grouped per-account model suggestions (each model badged with its context window + capabilities). |
Virtual models
| Endpoint | Methods | Purpose |
|---|---|---|
/api/virtual-models | GET, POST | List / create virtual models. Create/update return 409 if the name is already taken in the org, 400 on invalid strategy/targets. |
/api/virtual-models/{id} | GET, PUT, DELETE | Read / update / delete one. |
/api/virtual-models/{id}/toggle | POST | Enable / disable. |
/api/virtual-models/{id}/preview | GET | Read-only routing preview: the order targets would be tried right now, with live cost/latency/quota signals and which targets are dropped (locked/quota-exhausted). Never advances the rotation cursor. |
/api/virtual-models/reorder | POST | Reorder. |
Usage, savings, advisor
Exact figures vs. capped samples
/api/reports aggregates in SQL over the whole window, so its numbers are exact. /api/usage, /api/usage/timeseries and /api/eval load raw rows and stop at a server-side cap; when they hit it they return "truncated": true and their totals are a sample of the newest rows, not the full window. The dashboard surfaces this as a notice — treat it the same way in your own integrations, and reach for /api/reports when a figure has to be exact.
| Endpoint | Methods | Purpose |
|---|---|---|
/api/usage | GET | Usage summary. Window via ?window=24h|7d|30d or an explicit half-open ?since=YYYY-MM-DD[&until=YYYY-MM-DD]. |
/api/usage/split | GET | Free-pool vs own-provider split. Same window parameters as /api/usage. |
/api/eval | GET | Model ranking from recorded usage (?optimize=score|cost|latency|quality). |
/api/reports | GET | Invoice-ready showback/chargeback rollups (JSON / CSV / Parquet). |
/api/savings | GET | Savings-ledger receipts (/totals, /rollup, /export). |
/api/token-saver/filters | GET | Active token-saver filters. |
/api/advisor | GET | Token-Optimization Advisor recommendations. |
/api/advisor/apply | POST | Apply one action behind a probation window. |
/api/advisor/applied | GET | List applied actions. |
/api/advisor/confirm/{id} | POST | Promote an action past probation. |
/api/advisor/rollback/{id} | POST | Roll an applied action back. |
Logs
| Endpoint | Methods | Purpose |
|---|---|---|
/api/logs | GET | Filtered, keyset-paginated list of request-log rows (metadata only). |
/api/logs/facets | GET | { providers, models, sources } — the distinct values seen across the last 5,000 log rows (org-scoped in multi-tenant mode, same as /api/logs). Powers the Logs page's Provider/Source/Model filter controls. |
/api/logs/{request_id} | GET, DELETE | Full detail incl. attempt timeline / remove a single row + its bodies. |
/api/logs/{request_id}/export | GET | Detail as downloadable JSON. |
See Request Logs for the full filter-parameter list, the body-capture policy, and the attribution headers.
Settings, budgets, policy, quota
| Endpoint | Methods | Purpose |
|---|---|---|
/api/settings | GET, PUT | Feature toggles (guardrails, cache injection, semantic cache, …). |
/api/budgets | GET, POST | Budgets (/{id} PUT/DELETE; /{id}/increase POST). |
/api/policy | GET | Credential ToS policy (/{provider_id} PUT to set a mode). Reads are open to any member; writes (PUT /{provider_id}, POST /reset-verdicts) require an org admin in multi-tenant mode — the override is stored globally and applies to every tenant. |
/api/quota | GET | Provider quota snapshots (/refresh POST). |
/api/pricing/resolve | GET | Resolved prices; /api/pricing/overrides to manage overrides. |
Behaviour change — proxy pools are per-org
/api/proxy-pools is scoped to the caller's organization. It was previously a single gateway-wide pool: any member of any org could list, disable, or delete every other tenant's proxies, and POST /api/proxy-pools/assign accepted anyconnection_id — so one tenant could route another tenant's upstream traffic through a host it controlled.
A proxy (or connection) belonging to another org now answers 404, identically to an unknown id. Migration 0075 adds proxy_pools.org_id and adopts pools already referenced by an account; a pool no account referenced keeps org_id = NULL and is visible only in single-user mode — re-create it in the owning org if you need it.
Behaviour change — host endpoints are single-user only
/api/tunnel/* and /api/mitm/* are no longer mounted when MERIDO_PROFILE=cloud (multi-tenant); they answer 404 there. Both act on the machine rather than on a tenant: a quick-tunnel publishes the entire gateway — every org's data plane and control plane — on a public URL, and /api/mitm/ca generates an OS-level TLS trust anchor. Previously any authenticated member of any org could call them. The dashboard already hid both tabs on cloud; this makes it enforcement rather than presentation. Unchanged in the local single-user profile.
Webhooks
| Endpoint | Methods | Purpose |
|---|---|---|
/api/webhooks/endpoints | GET, POST | List / create webhook endpoints (/{id} PUT/DELETE). Signing secret returned once on create. |
/api/webhooks/endpoints/{id}/test | POST | Deliver a signed test event; /{id}/rotate-secret (POST) rotates the secret. |
/api/webhooks/deliveries | GET | Delivery log (?endpoint_id&status&limit); /{id}/redeliver (POST) re-queues a failed or dead delivery. |
See Webhooks for the event catalog, payload reference, and signature verification.
Auth & session
| Endpoint | Methods | Purpose |
|---|---|---|
/api/login | POST | Dashboard login → session JWT. |
/api/auth/signup, /login, /verify, /forgot, /reset | POST/GET | Self-serve auth (multi-tenant). |
/api/auth/me | GET | Current session identity. |
/api/events | GET | SSE stream of completed requests (live dashboard). |
Login throttling. Failed attempts on /api/login, /api/auth/login, and /api/auth/signup are counted per targeted account and per source IP (X-Forwarded-For / X-Real-IP; requests with neither share one strict bucket), with a much higher gateway-wide backstop. Exceeding any bucket returns 429 for the rest of the minute.
This replaces a single global counter, under which ten bad passwords a minute from one script locked every user out of the gateway. If you script against these endpoints, note that a burst now exhausts your own IP's budget rather than everyone's — and that POST /api/auth/signup answering 429 means the throttle, not a validation failure.
Health & metrics
| Endpoint | Method | Purpose |
|---|---|---|
/healthz | GET | Status, profile, database + Redis health. Unauthenticated. |
/health/liveliness | GET | Liveness probe — process is up (no dependency checks). |
/health/readiness | GET | Readiness probe — 503 when the database is unreachable. |
/metrics | GET | Prometheus exposition (core + OpenTelemetry GenAI metrics). |
An unrouted
/api/*or/v1/*path returns the same typed error envelope as the data plane (404,code: "unknown_url"— see Errors), not the dashboard HTML, so clients get a clean, machine-readable error for a not-yet-implemented endpoint.