Skip to content

Media backends

merido also fronts image generation, text-to-speech (TTS), and speech-to-text (transcription/STT) — separate endpoints from chat completions, but the same gateway key, the same connected providers, and the same dashboard. Any provider you already have an API-key connection for can be turned into a media backend; no separate credential is needed.

A related but separate endpoint, POST /v1/ocr (Mistral-OCR-style document OCR — body {model, document}, forwarded to the resolved provider's /ocr, defaulting to mistral/mistral-ocr-latest), is not part of the media gate below — no dashboard toggle or default model required, just an allowed model on your key. See API endpoints for the full request/response shape.

How it's different from chat

  • Separate endpoints. Media doesn't go through the chat completions / messages / responses surface — it's /v1/images/* and /v1/audio/* (below). There's no cross-format translation the way there is for text (see Virtual models & fallback): a request is shaped for whichever backend the resolved provider maps to and the result relayed back in an OpenAI-compatible envelope.
  • Must be explicitly enabled. Unlike chat models — servable the moment a provider is connected — an image/audio backend must be turned on in the dashboard first (see below). This keeps a provider you use for chat from silently becoming an image/speech bill too.
  • Not token-billed — but not free. Media requests aren't measured in tokens (there's nothing to count), so their usage rows always show zero prompt/completion tokens. They are still priced, from a separate rate card: per generated image, per second of audio, per character of synthesized speech. See Media pricing below.

The enabled gate

Every /v1/images/* and /v1/audio/* request is checked against your org's saved media configuration before it's forwarded upstream:

  • No saved config, or saved but disabled → 403 Forbidden. Having a connected provider is not enough by itself — the provider must also be turned on as a media backend.
  • Enabled, but no model specified and no default configured → 400 Bad Request. See how the model field is resolved below.

Enable a backend from the dashboard: Models (/app/models) → Media tab. Every provider you have a connection for shows up there — flip it on, optionally set a default model (and a default voice for TTS), and use the tab's Test button to confirm the saved credential actually reaches the provider. Picking a default model in that panel is backed by the same /v1/models/image catalog described below.

Known media-capable providers, plus anything you've connected

merido ships a static capability hint for providers with a well-known image/TTS/STT API (OpenAI, Gemini, xAI, Groq, Mistral, Together, Fireworks, DeepInfra, Novita, NVIDIA, Hyperbolic, SiliconFlow, sdWebUI, Ideogram, HuggingFace, …) so the Media tab can show you which modality each one likely supports. But the gate itself doesn't care about that list — any provider you have a connection to can be enabled as a media backend, known or not. If a provider's actual wire format has no OpenAI-compatible image or audio API (e.g. a native Anthropic or native Gemini connection), the request still reaches the gate, but the upstream call itself then fails with 400 since there's no equivalent endpoint to call.

How the model field is resolved

merido tries these in order, and the first match wins:

What you sendWhat happens
"openai/dall-e-3"used as-is — an explicit provider/model always wins
"art" (a virtual model name)resolved to that VM's targets, in the order its strategy chose
"openai" (a provider name)the provider's configured default model (and default voice for TTS)
"gpt-image-2" (a bare model id)looked up across the backends you have enabled, see below
anything else400, naming what was tried

Set the default model and voice in the Media tab's Configure panel for that backend; they're exactly what a bare-provider request resolves to. If a provider is enabled but has no default model, a bare-provider request fails with a 400 telling you which provider needs one.

Bare model ids

Most OpenAI-compatible clients namespace models by provider on their side and strip the prefix before sending — so a client configured with merido/gpt-image-2 puts {"model": "gpt-image-2"} on the wire. merido resolves that by looking for the model across the backends you have enabled.

If exactly one enabled backend serves it, that's the one used. If more than one does, the request is refused with a 400 naming the candidates, and you qualify it yourself:

json
{ "model": "openai/gpt-image-2" }

merido never picks for you here — guessing could bill you on a provider you didn't choose.

Virtual models for images

A virtual model can route /v1/images/* the same way it routes chat: one name your client calls, an ordered list of real targets, and automatic failover between them.

Create one in the dashboard under Virtual Models, set Routes for to Image, and list image targets:

json
{
  "name": "art",
  "modality": "image",
  "strategy": "failover",
  "targets": [
    { "model": "openai/gpt-image-2" },
    { "model": "fal/fal-ai/flux/dev" }
  ]
}
bash
curl http://127.0.0.1:8788/v1/images/generations \
  -H "Authorization: Bearer $MERIDO_KEY" -H "Content-Type: application/json" \
  -d '{"model":"art","prompt":"a red bicycle"}'

A retryable failure on the first target (401, 429, 5xx) moves to the next; a 400 — your own request being wrong — surfaces immediately instead of being replayed against every target. Every attempt is recorded on the request's log row, so you can see which targets were tried and why each one moved on.

Targets are not translated

There is no canonical IR for media. A virtual model can mix providers that speak different protocols and merido will shape the request for whichever one it lands on — but provider-specific parameters do not translate. OpenAI's quality has no Fal equivalent, so it is dropped on that hop. size does translate. Targets whose provider isn't enabled as a media backend are skipped before the request is sent, not discovered mid-walk.

Modality is enforced both ways. An image virtual model is not resolvable from /v1/chat/completions, and a text one is not resolvable from /v1/images/* — an image target cannot serve a chat turn, so allowing the crossover would only produce a confusing failure deep in dispatch. Virtual models created before this existed are all text, which is what they were.

A media virtual model also may not be named after a provider (openai, fal, …): that name already means "that backend's default model", and shadowing it would silently reroute requests.

The three endpoint families

All three take the same gateway key you use for chat (Authorization: Bearer <key>) and respect the same rate limits. Every media request gets a row in Usage with zero prompt/completion tokens (there is nothing to count) and a real cost from the media rate card — including requests that fail, recorded with the status the caller got, so a failing backend is visible rather than silent.

Media requests also appear in the Logs view alongside chat, with the provider and model that served them, the latency, the cost, every fallback attempt that was tried, and — when a request was refused before it reached a provider — the reason. Uploaded media and generated image data are never stored in a log: an image response is recorded as a summary ({"images": 2, "format": "b64_json"}), and a multipart upload keeps only its text fields.

Media pricing

Media is not token-billed, so it is priced from a separate rate card with three units:

UnitApplies toRate
per image/v1/images/*per_image_usd × the number of images produced
per character/v1/audio/speechper_character_usd × characters of input
per second/v1/audio/transcriptions, /v1/audio/translationsper_second_usd × seconds of audio

The card resolves in the same order as the token card — operator override → synced snapshot → built-in table — so a rate you set always wins. merido ships approximate list prices for the well-known models (gpt-image-2, dall-e-3, FLUX, Imagen, tts-1, whisper-1, …) and picks up output_cost_per_image / input_cost_per_second / input_cost_per_character from the synced pricing snapshot when it carries them.

Set your own with the same endpoint that overrides token prices:

bash
curl -X PUT http://127.0.0.1:8788/api/pricing/overrides \
  -H "Content-Type: application/json" \
  -d '{"model_key":"dall-e-3","input_per_1m":0,"output_per_1m":0,"per_image_usd":0.04}'

A model with no rate on any layer costs $0 — the request still runs and is still recorded, it just carries no cost. That is a gap in the rate card, not a free request, and it is worth filling with an override if you care about the number.

Two different kinds of number

Some models really are billed flat per image, and for those the built-in card is exact. The gpt-image-* family is not: OpenAI bills it per output token, so merido's flat per-image figure is a point estimate at 1024×1024 medium quality and drifts with size and quality. If exact image spend matters to you, set an override. The audio rates are exact.

Media counts against your spend

Because media now carries real cost, image and audio requests show up in cost reports and in the Advisor where they previously read $0. Transcription is the exception: Whisper-shaped upstreams do not report audio duration, so merido has no honest number of seconds to bill and records $0 rather than estimating one from the upload size.

Image generation — POST /v1/images/generations

bash
curl http://127.0.0.1:8788/v1/images/generations \
  -H "Authorization: Bearer $MERIDO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"openai","prompt":"a red bicycle"}'

Returns the OpenAI image envelope, { "created": …, "data": [{ "url": … } | { "b64_json": … }] }. The OpenAI-compatible path (the default for most providers) forwards the request as-is; a few providers — Fal, Black Forest Labs (BFL), Stability AI, ComfyUI, Hyperbolic, sdWebUI (Automatic1111), Ideogram — speak their own protocol instead, and merido shapes the request and maps the response back to the same envelope, so your client code doesn't need to know which backend actually served it:

  • Hyperbolic — Bearer auth; sizing is sent as {"width": …, "height": …}; the response comes back as base64 (b64_json).
  • sdWebUI / AUTOMATIC1111 — a self-hosted sdapi/v1/txt2img endpoint, no auth required (a configured key is sent as Bearer if you set one, but it's optional); the response is a bare base64 string, no wrapper object.
  • Ideogram — auth via a custom Api-Key header (not Authorization: Bearer); size is converted to Ideogram's aspect-ratio shorthand instead of pixel dimensions; the response is usually a hosted url (falls back to b64_json if Ideogram returns one instead).

HuggingFace (Inference API) is the outlier even among those: it isn't a JSON-in/JSON-out protocol at all — merido posts { "inputs": prompt } and the upstream responds with the raw image bytes directly, which merido base64-encodes into the same b64_json envelope. Auth is an optional Bearer token; size is ignored since the Inference API doesn't accept width/height.

POST /v1/images/edits and POST /v1/images/variations cover image editing and variation generation the same way, as OpenAI-compatible multipart uploads (up to 64 MB) — see API endpoints for the full list.

ChatGPT subscription images (Codex)

codex (alias chatgpt) is the one image backend that isn't backed by an API-key connection at all: it generates through a signed-in Codex OAuth account — the same subscription account the Codex chat data plane uses — instead of a per-request API key. There's no key to add for it; sign in with merido login codex (or connect it from the dashboard) on a ChatGPT Plus or Pro plan, then enable codex as a media backend the same way as any other provider (Media tab, or upsert_media_backend_for_org).

bash
curl http://127.0.0.1:8788/v1/images/generations \
  -H "Authorization: Bearer $MERIDO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"codex/gpt-5.5","prompt":"a red bicycle"}'

Under the hood this runs through the hosted image_generation Responses tool on the same account your Codex chat traffic shares — a heavier per-account workload than a chat turn, so treat it as a shared, rate-limited resource rather than something to hammer in a loop. To keep one request from monopolizing that account, n (the number of images per call) is capped server-side regardless of what you ask for.

Discovering image models — GET /v1/models/image

Before picking a model for /v1/images/generations, list the image models your key can actually reach: providers enabled as a media backend (with the image capability), namespaced provider/model, and — same as chat's /v1/models — filtered down to your key's model allow-list if it has one.

Your image virtual models are listed too, by name, with "owned_by": "virtual" and the targets they route to — otherwise you would have to already know a route's name to be able to call it.

bash
curl http://127.0.0.1:8788/v1/models/image \
  -H "Authorization: Bearer $MERIDO_KEY"
json
{
  "object": "list",
  "data": [
    {
      "id": "openai/gpt-image-2",
      "object": "model",
      "created": 1737936000,
      "owned_by": "openai",
      "modality": "image",
      "capabilities": ["text2img", "edit"],
      "supported_sizes": ["1024x1024", "1024x1536", "1536x1024", "auto"],
      "input_modalities": ["text", "image"]
    }
  ]
}

capabilities, supported_sizes, and input_modalities come from merido's curated registry of well-known image models — gpt-image-2, gpt-image-1.5, gpt-image-1-mini, gpt-image-1, dall-e-3, dall-e-2, grok-2-image, Black Forest Labs' FLUX, Stability AI, Fal's FLUX, and Imagen are all recognized out of the box. A model not in that list can still be enabled and called — see the fail-open behavior below — and any OpenAI-shaped image model works via passthrough even if merido has never heard of it, as long as you address it by its explicit provider/model.

Look up a single model instead of listing the whole catalog with GET /v1/models/info?id=<id>:

bash
curl "http://127.0.0.1:8788/v1/models/info?id=openai/gpt-image-2" \
  -H "Authorization: Bearer $MERIDO_KEY"

Both endpoints share the same fail-open rule set — merido never blocks a call just because a model is unfamiliar, only because the provider itself isn't enabled:

Situation/v1/models/info response
Provider enabled as a media backend, model is in the curated registry200 — full object, same shape as a /v1/models/image entry
Provider enabled as a media backend, model is not in the curated registry200 — best-effort object with "capabilities": [] and "note": "not in curated registry"; merido doesn't know its exact capabilities but won't stand in the way of calling it
Provider serves images but isn't enabled as a media backend403 Forbidden — the same enabled gate that guards /v1/images/*
provider isn't one merido recognizes as an image provider at all404 Not Found

Sizes

size on a generation (or edit) request accepts either OpenAI's WxH shape ("1024x1024") or an aspect-ratio shorthand ("16:9"). merido maps whichever form you send to what the resolved backend actually expects — Fal and Black Forest Labs want {"width": …, "height": …}, Stability AI wants aspect_ratio, and OpenAI-compatible backends get WxH passed straight through. Omit size entirely and nothing is shaped or sent — the upstream's own default applies.

For a model in the curated registry, a size merido knows the model doesn't support is rejected with a 400 before the request ever leaves the gateway. For an uncurated or unknown model, size is forwarded as-is, unvalidated — same passthrough-first stance as the fail-open rules above.

Text-to-speech — POST /v1/audio/speech

bash
curl http://127.0.0.1:8788/v1/audio/speech \
  -H "Authorization: Bearer $MERIDO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"openai","input":"hello","voice":"alloy"}' --output speech.mp3

The response is the raw audio bytes, streamed straight through with the upstream's Content-Type (e.g. audio/mpeg) — --output (or your client's binary body handling) writes it to a file. Omit voice to use the backend's configured default voice.

Discover the voices a provider offers with GET /v1/audio/voices?provider=openai — each entry's model field is already namespaced (openai/alloy) so you can drop it straight into the model field above to skip the default and pick a specific voice explicitly.

Transcription — POST /v1/audio/transcriptions

bash
curl http://127.0.0.1:8788/v1/audio/transcriptions \
  -H "Authorization: Bearer $MERIDO_KEY" \
  -F model=openai -F file=@audio.mp3

A multipart upload (up to 64 MB); the result format depends on the upstream (json, verbose_json, plain text, srt, vtt, …) and is relayed with its original Content-Type. POST /v1/audio/translations is the same shape but always returns an English translation of the spoken audio, regardless of the source language.

Where to go next

© merido. All rights reserved.