Skip to content

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 as Authorization: Bearer <key>. When MERIDO_REQUIRE_API_KEY=true, anonymous requests are rejected. Create keys with merido keys create or 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. /metrics is 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/*)

EndpointMethodWire format / purpose
/v1/chat/completionsPOSTOpenAI Chat Completions (and Gemini generate-content).
/v1/messagesPOSTAnthropic Messages.
/v1/messages/count_tokensPOSTAnthropic token counting.
/v1/responsesPOSTOpenAI Responses API.
/v1/responses/compactPOSTCompact a Responses conversation.
/v1/embeddingsPOSTOpenAI embeddings.
/v1/rerankPOSTCohere/Jina/Voyage-style document reranking.
/v1/moderationsPOSTOpenAI-compatible content moderation.
/v1/ocrPOSTMistral-OCR-style document OCR passthrough ({model, document}; defaults to mistral/mistral-ocr-latest).
/v1beta/models/{model}:generateContentPOSTNative 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/generationsPOSTImage generation.
/v1/images/editsPOSTImage editing (multipart upload, up to 64 MB).
/v1/images/variationsPOSTImage variations (multipart upload, up to 64 MB).
/v1/audio/speechPOSTText-to-speech.
/v1/audio/voicesGETList available text-to-speech voices.
/v1/audio/transcriptionsPOSTSpeech-to-text (multipart upload, up to 64 MB).
/v1/audio/translationsPOSTSpeech translation to English text (multipart upload, up to 64 MB).
/v1/modelsGETList 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/infoGETRich 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/fetchPOSTServer-side web fetch.
/v1/searchPOSTSearch.
/v1/batchesPOST / GETSubmit (inline requests) / list async batch jobs.
/v1/batches/{id}GETGet 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_window and max_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_usage not set to true (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, before data: [DONE] — with an empty choices array, 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 variablesMERIDO_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 formatWhat you see
OpenAI ChatA chunk carrying an error object in place of a normal choices delta.
AnthropicAn error frame: {"type":"error","error":{"type":"api_error","message":"…"}}.
GeminiA data: frame: {"error":{"message":"…"}}.
OpenAI ResponsesA 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:

ParameterOpenAIAnthropicGeminiResponses
Stop sequencesstop (string or array)stop_sequencesgenerationConfig.stopSequencesnot supported
Determinism seedseednot supportedgenerationConfig.seednot 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):

ParamRule
messagesRequired; must be a non-empty array.
temperatureNumber in [0, 2].
top_pNumber in [0, 1] — merido is deliberately more lenient than OpenAI's own 0 < top_p ≤ 1 and also accepts exactly 0.
nInteger ≥ 1.
max_tokens / max_completion_tokensInteger ≥ 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

StatustypecodeWhen
400invalid_request_errornullA validation failure above, or a malformed request body.
403permission_errornullThe model isn't in the calling key's allowed_models list (was 401). Enforced on every data-plane endpoint — see Per-key scoping coverage.
404invalid_request_errorunknown_urlAn unrouted /v1/* or /api/* path.
413invalid_request_errorrequest_too_largeThe 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 variablesMERIDO_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

EndpointMethodsPurpose
/api/providersGET, POSTList / add upstream provider connections.
/api/providers/{id}PUT, DELETEUpdate / remove a connection.
/api/keysGET, POSTList / create client (gateway) keys. Create accepts per-key scoping: allowed_models (model globs), rate_limit_rpm, rate_limit_tpm.
/api/keys/{id}DELETE, PATCHRevoke / update a key; /{id}/rotate (POST) rotates it.
/api/oauth/providersGETList OAuth-capable providers.
/api/oauth/accountsGETList connected OAuth accounts (/{id} DELETE to remove).
/api/registryGETThe provider registry.
/api/modelsGETSession-authed model discovery for the dashboard.
/api/models/infoGETSession-authed rich model/group metadata (the dashboard twin of /v1/model_group/info).
/api/model-catalogGETGrouped per-account model suggestions (each model badged with its context window + capabilities).

Virtual models

EndpointMethodsPurpose
/api/virtual-modelsGET, POSTList / 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, DELETERead / update / delete one.
/api/virtual-models/{id}/togglePOSTEnable / disable.
/api/virtual-models/{id}/previewGETRead-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/reorderPOSTReorder.

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.

EndpointMethodsPurpose
/api/usageGETUsage summary. Window via ?window=24h|7d|30d or an explicit half-open ?since=YYYY-MM-DD[&until=YYYY-MM-DD].
/api/usage/splitGETFree-pool vs own-provider split. Same window parameters as /api/usage.
/api/evalGETModel ranking from recorded usage (?optimize=score|cost|latency|quality).
/api/reportsGETInvoice-ready showback/chargeback rollups (JSON / CSV / Parquet).
/api/savingsGETSavings-ledger receipts (/totals, /rollup, /export).
/api/token-saver/filtersGETActive token-saver filters.
/api/advisorGETToken-Optimization Advisor recommendations.
/api/advisor/applyPOSTApply one action behind a probation window.
/api/advisor/appliedGETList applied actions.
/api/advisor/confirm/{id}POSTPromote an action past probation.
/api/advisor/rollback/{id}POSTRoll an applied action back.

Logs

EndpointMethodsPurpose
/api/logsGETFiltered, keyset-paginated list of request-log rows (metadata only).
/api/logs/facetsGET{ 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, DELETEFull detail incl. attempt timeline / remove a single row + its bodies.
/api/logs/{request_id}/exportGETDetail as downloadable JSON.

See Request Logs for the full filter-parameter list, the body-capture policy, and the attribution headers.

Settings, budgets, policy, quota

EndpointMethodsPurpose
/api/settingsGET, PUTFeature toggles (guardrails, cache injection, semantic cache, …).
/api/budgetsGET, POSTBudgets (/{id} PUT/DELETE; /{id}/increase POST).
/api/policyGETCredential 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/quotaGETProvider quota snapshots (/refresh POST).
/api/pricing/resolveGETResolved 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

EndpointMethodsPurpose
/api/webhooks/endpointsGET, POSTList / create webhook endpoints (/{id} PUT/DELETE). Signing secret returned once on create.
/api/webhooks/endpoints/{id}/testPOSTDeliver a signed test event; /{id}/rotate-secret (POST) rotates the secret.
/api/webhooks/deliveriesGETDelivery 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

EndpointMethodsPurpose
/api/loginPOSTDashboard login → session JWT.
/api/auth/signup, /login, /verify, /forgot, /resetPOST/GETSelf-serve auth (multi-tenant).
/api/auth/meGETCurrent session identity.
/api/eventsGETSSE 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

EndpointMethodPurpose
/healthzGETStatus, profile, database + Redis health. Unauthenticated.
/health/livelinessGETLiveness probe — process is up (no dependency checks).
/health/readinessGETReadiness probe — 503 when the database is unreachable.
/metricsGETPrometheus 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.

© merido. All rights reserved.