llmr speaks the OpenAI shape (chat completions, embeddings, image generation, speech and
transcription), so any OpenAI SDK or framework works by changing its base URL to
http://<host>:8080/v1. This page is what it accepts, what it
refuses, and what it sends back. Setting up what it serves is the
management API.
| Method | Path | Token | |
|---|---|---|---|
POST |
/v1/chat/completions |
when set | A chat call, whole or streamed |
POST |
/v1/embeddings |
when set | Text as vectors |
POST |
/v1/images/generations |
when set | Pictures from a prompt |
POST |
/v1/audio/speech |
when set | Text read aloud |
POST |
/v1/audio/transcriptions |
when set | A recording written down |
GET |
/v1/models |
when set | The names a client can use |
GET |
/healthz |
never | Liveness: 200 ok while the process serves |
With LLMR_TOKEN set, a call presents one of its tokens as Authorization: Bearer <token>
or x-api-key: <token>. OpenAI SDKs send their api_key as the first, so setting the SDK's
key to the token is all it takes. Without LLMR_TOKEN, nothing is checked.
A route set, such as default, or an enabled model addressed directly as provider/model,
such as anthropic/claude-haiku-4-5. A model that exists and is not enabled is a
404 model_not_enabled saying so; any other name is a 404 model_not_found. A route set or
model of another kind, such as an embedding model, is a 400 wrong_endpoint naming the
endpoint it belongs to.
The reply's model is the model that actually answered, not the name asked for.
| Field | Notes |
|---|---|
messages |
system, developer, user, assistant and tool roles. system and developer text becomes the system prompt. Content as a string or as text parts |
| images | image_url parts in a user message, as a data: URL or an http(s) link ending in .png, .jpg, .jpeg, .gif or .webp |
| documents | file parts in a user message: {"type": "file", "file": {"file_data": "data:application/pdf;base64,...", "filename": "report.pdf"}}. file_data is a base64 data: URL, or bare base64 when filename ends in .pdf or .txt, which gives the type |
| audio | input_audio parts in a user message: {"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav"}}, with format one of wav, mp3, flac, ogg, aac, m4a, webm |
tools |
Function tools. tool_calls in assistant messages and tool results are carried both ways |
tool_choice |
"auto" only |
response_format |
text, or json_schema with a schema |
reasoning_effort |
none, minimal, low, medium, high, xhigh, max |
max_completion_tokens, max_tokens |
The newer one wins when both are set |
temperature, top_p |
|
stream, stream_options.include_usage |
See streaming below |
n |
1 only |
user, seed, metadata, store, parallel_tool_calls |
Accepted and not sent: they cannot change what the reply owes you |
Consecutive tool messages, and a user message straight after them, become one user turn,
which is the only arrangement every provider accepts.
A field llmr cannot carry to every provider is a 400 naming it in error.param,
never silently dropped, because a reply that ignored half the request is still billed:
nabove 1stoppresence_penaltyorfrequency_penaltyother than 0logprobs: true,logit_biastool_choiceother than"auto"response_format: {"type": "json_object"}; sendjson_schemawith the shape you wantaudio,modalities(audio in the reply),prediction,web_search_options, the legacyfunctions- an image link whose type cannot be read from its extension; send a data URL
- a
filepart withfile_id: llmr has no files endpoint, so send the file asfile_data - a
filepart whose bare base64file_datahas no.pdfor.txtname to type it - an
input_audiopart in any other format - a content part other than text, an image, a file or input audio
The request is matched against what each route can do. A request with tools skips routes
that cannot take tools; one with an image, a document or audio skips routes without the
images, documents or audio capability; one with a schema or a reasoning_effort other
than none skips routes without structured output or reasoning. Among the routes that fit,
they are tried in the route set's order until one answers.
What each provider takes beyond the capability:
| Provider | Documents | Audio |
|---|---|---|
anthropic |
PDF, and plain text (sent as text) | none: a request with audio fails this route |
openai, openai-compatible |
PDF only | wav and mp3 only |
gemini |
inline, with the type the client gave | inline, with the type the client gave |
A document or recording a route's provider does not take fails that route before anything is sent, and the next route is tried.
A route set marked on_device is only served by self-hosted routes. A client can ask for the
same floor on one request with the header x-llmr-on-device: true; a header can tighten the
floor and never loosen it.
When no route can serve the request, the answer is 400 no_route and the message says what
each route was missing.
The standard chat.completion object, with two additions:
choices[0].llmr_stop_reason: the engine's own stop reason (end_turn,tool_use,stop_sequence,max_tokens,refusal,pause_turn,context_window_exceeded,interrupted,other).finish_reasonhas five words and some of these would read as finished when they are not.choices[0].message.reasoning_content: reasoning text, when the model showed any.llmr_cost: what this request cost.{"status": "priced", "amount": "0.002264", "currency": "USD", "book": "anthropic-2026-09"}, wherebooknames the price edition that priced it (see prices);"partial"when the provider left some usage out and the amount is a floor;{"status": "unpriced"}for a paid provider llmr has no rate for;{"status": "free"}for a self hosted model. Never a zero standing in for "unknown".
usage is present only when the provider reported both prompt and output counts. It is
never filled with zeros, because a zero turns an unknown cost into a free one.
prompt_tokens includes cached tokens, and prompt_tokens_details.cached_tokens says how many.
A refusal is a successful reply. When a model declines, the reply is a 200 with
content: null, refusal set and finish_reason: "content_filter", which is how this shape
writes one. The next route is not asked the same question.
| Header | |
|---|---|
x-llmr-route |
The provider/model that answered |
x-llmr-attempts |
Calls made, retries included |
x-llmr-cost |
The amount and currency, 0.002264 USD, when the request was priced. Absent otherwise |
x-llmr-fell-through |
Entries for routes skipped or failed before the one that answered, one per failed attempt. Non zero on a successful call is a provider degrading while nothing is failing |
stream: true answers with server sent events: chat.completion.chunk objects, then
data: [DONE]. With stream_options.include_usage, a last chunk before [DONE] carries
usage and llmr_cost with empty choices, when the provider reported usage.
- A route is replaced only before the first byte. A provider that fails while the stream opens falls through to the next route, and the failure is an ordinary HTTP error status. After the first event nothing is replaced: a second model continuing half a sentence it did not write would be text nobody wrote.
- A stream that breaks says so. An error after the first event arrives as a final
data: {"error": {...}}frame, and no[DONE]follows. The same is true when the provider ends its stream without saying why the model stopped. A client that waits for[DONE]never treats a cut-off reply as finished. - Quiet streams are kept open. A
: keep-alivecomment is sent after 15 seconds without an event, so a proxy does not close a stream while a model thinks. - A client that leaves stops the call. The upstream connection is closed at once rather than paid for to the end.
- A route that cannot really stream still answers a streamed request, with the whole reply as one burst at the end.
Embeddings, image generation, speech and transcription each have their own endpoint in the
OpenAI shape, and are served by models of the matching kind: a model is chat,
embedding, image, speech or transcription, set when it is enabled
(management API). A route set serves one kind.
What they share:
modelis a route set or an enabledprovider/model, as for chat. A name of another kind is a400 wrong_endpointnaming the endpoint it belongs to; the same is true of an embedding model sent to/v1/chat/completions.- Routes are tried in the order listed. Each gets the set's
retry_attempts, and only a rate limit, a timeout or a transient failure is retried.deadline_secsbounds the whole request.on_device, and thex-llmr-on-deviceheader, keep it onself-hostedroutes as in chat. The set'sorderandbreakerapply to chat only: here there is no reordering by price or health, and a failing route is not rested. Aprovider/modelasked for directly gets two attempts and no deadline. - What a provider cannot honour fails that route before the call, and the next route is
tried: a size Gemini cannot draw, a speech format it does not produce. When every route
has failed, the answer is the last failure, for example
400 no_routewith the reason. - A refusal stops, as in chat: the next route is not asked. These shapes have no way to
write a refusal into a reply, so it is a
422 refused. - A field that cannot be carried is refused by name, never dropped.
- Every answer carries
x-llmr-route,x-llmr-attempts,x-llmr-fell-through,x-llmr-request-id(the id its usage row is recorded under) and, when priced,x-llmr-cost. A JSON reply also carriesllmr_cost. - Cost comes from the prices llmr keeps for the model that
answered: by the token where the vendor reports tokens, per picture for an image model
sold that way, per second of audio for a transcription model that reports the recording's
length, per million characters for a text to speech model sold by the character. A model
with no price, or whose unit the vendor did not report, is
unpriced; aself-hostedroute isfreeunless a price was set for it by hand. - Each request is recorded in usage like a chat call, with the route, attempts, the tokens the provider reported, and the cost.
Which provider type serves which kind:
| Kind | openai, openai-compatible |
gemini |
anthropic |
|---|---|---|---|
embedding |
/embeddings |
batchEmbedContents |
no |
image |
/images/generations |
generateContent |
no |
speech |
/audio/speech |
generateContent |
no |
transcription |
/audio/transcriptions |
no: send the recording to a chat model that takes audio |
no |
{ "model": "vectors", "input": ["first text", "second text"] }| Field | Notes |
|---|---|
model |
Required. A route set or provider/model of kind embedding |
input |
Required. A string, or a non-empty array of strings. Token arrays are refused: they are one tokenizer's numbers and mean nothing to another vendor's model |
encoding_format |
float (default) or base64, the little endian 32 bit floats, which is what the OpenAI SDKs ask for by default |
dimensions |
A positive integer, for models that can shorten their vectors |
{
"object": "list",
"data": [
{ "object": "embedding", "index": 0, "embedding": [0.0123, -0.0456, "..."] },
{ "object": "embedding", "index": 1, "embedding": [0.0789, 0.0012, "..."] }
],
"model": "text-embedding-3-small",
"usage": { "prompt_tokens": 8, "total_tokens": 8 },
"llmr_cost": { "status": "unpriced" }
}data is in the order of input. usage is left out when the provider reported no count,
which Gemini's embeddings API never does, rather than written as zero.
{ "model": "pictures", "prompt": "a lighthouse at dusk, watercolour", "size": "1024x1024" }| Field | Notes |
|---|---|
model |
Required. Kind image |
prompt |
Required, not blank |
n |
1 to 10 |
size, quality, background, output_format |
Passed on when given |
response_format |
url or b64_json. Sent only when given, so each model answers the way it always does: GPT image models with bytes, DALL·E models with a link |
Refused by name: stream and partial_images (a picture comes back whole), style (say it
in the prompt), moderation, output_compression.
{
"created": 1790467200,
"data": [ { "b64_json": "iVBORw0KGgo...", "revised_prompt": "..." } ],
"output_format": "png",
"usage": { "input_tokens": 14, "output_tokens": 4160, "total_tokens": 4174 },
"llmr_cost": { "status": "unpriced" }
}Each picture is b64_json or url, whichever the provider answered with. revised_prompt is
on the first picture when the provider rewrote the prompt. output_format is the format of
the first picture that came as bytes, and usage is present only when the provider
reported it.
Gemini draws through generateContent, which has less to say:
- one picture per call:
nother than 1 fails the route; sizebecomes an aspect ratio, and must reduce to one of1:1,2:3,3:2,3:4,4:3,4:5,5:4,9:16,16:9,21:9(or beauto);- no
quality,backgroundoroutput_format; - bytes only:
response_formatother thanb64_jsonfails the route; - a prompt its safety filters block is a refusal, a
422 refused.
Imagen models, which Google serves through :predict rather than generateContent, are not
supported.
{ "model": "voice", "input": "Your order has shipped.", "voice": "alloy", "response_format": "mp3" }| Field | Notes |
|---|---|
model |
Required. Kind speech |
input |
Required, not blank |
voice |
Required. A name, or an object with an id |
response_format |
See below |
speed |
0.25 to 4.0 |
instructions |
How to read it |
stream_format |
audio only; sse is refused |
The reply is the recording itself, with its Content-Type, and the x-llmr-* headers.
There is no JSON body, so the cost is in x-llmr-cost only.
| Provider | response_format |
Notes |
|---|---|---|
openai, openai-compatible |
mp3 (default), opus, aac, flac, wav, pcm |
pcm is raw 24 kHz 16 bit mono samples, typed audio/pcm |
gemini |
wav (default) or pcm |
No speed other than 1. voice is one of Gemini's prebuilt voice names. instructions are put in front of the text ("<instructions>: <input>"), which is how these models are directed. Raw samples are wrapped as WAV |
A multipart/form-data upload, as the OpenAI SDKs send it:
curl localhost:8080/v1/audio/transcriptions \
-F model=openai/gpt-4o-transcribe -F file=@meeting.m4a -F language=en| Field | Notes |
|---|---|
model |
Required. Kind transcription |
file |
Required. Its type is the part's Content-Type when that is audio/* or video/*, otherwise read from the name: .mp3, .mpga, .mpeg, .wav, .flac, .ogg, .webm, .m4a, .mp4 |
language, prompt |
Passed on |
temperature |
0 to 1 |
response_format |
json (default) or text |
Refused by name: response_format srt, vtt or verbose_json (most transcription models
cannot produce timings, and a caller who needs them should know before the call),
timestamp_granularities[], include[], stream, chunking_strategy.
{ "text": "Thanks everyone for joining.", "usage": { "type": "duration", "seconds": 42.0 },
"llmr_cost": { "status": "unpriced" } }usage is token counts (input_tokens, output_tokens, total_tokens) when the provider
reported them, or {"type": "duration", "seconds": ...} when it reported the length instead,
and absent otherwise. With response_format: "text" the body is the text alone, as
text/plain, and the cost is in x-llmr-cost.
Only openai and openai-compatible providers serve transcription. Gemini has no
transcription endpoint: send the recording to /v1/chat/completions as input_audio, to a
model with the audio capability, and ask for a transcript.
Every error is the OpenAI envelope:
{ "error": { "message": "...", "type": "...", "param": null, "code": "..." } }| Status | code |
Meaning |
|---|---|---|
| 400 | invalid_request |
The request is malformed or asks for something refused above; param names the field |
| 400 | no_route |
No usable route in the set can serve this request |
| 400 | upstream_rejected_request |
The provider refused the request's shape |
| 400 | wrong_endpoint |
The name is a route set or model of another kind; the message names the endpoint it belongs to |
| 401 | invalid_api_key |
LLMR_TOKEN is set and the call did not present one of its tokens |
| 404 | model_not_found |
Not a route set or a known model |
| 404 | model_not_enabled |
A model that exists and is switched off |
| 404 | not_found |
No such endpoint |
| 422 | refused |
The model declined, on an endpoint beside chat. A chat refusal is a 200, as above |
| 429 | rate_limited |
Every route that was tried is rate limited. Retry-After carries the provider's wait, rounded up |
| 502 | upstream_credential_rejected |
A provider rejected llmr's key for it. Your token was fine; the provider's credential needs fixing through the management API |
| 502 | upstream_not_found |
A provider does not have the model it was asked for |
| 502 | upstream_unreadable |
A provider answered and the answer could not be read |
| 503 | upstream_unavailable |
Every route that could serve this failed or is resting |
| 504 | timeout |
The configured deadline passed |
The split is by whose problem it is: a 4xx is something the client can change, a 5xx is behind llmr.
{ "object": "list", "data": [
{ "id": "default", "object": "model", "created": 0, "owned_by": "llmr", "llmr_kind": "chat" },
{ "id": "vectors", "object": "model", "created": 0, "owned_by": "llmr", "llmr_kind": "embedding" },
{ "id": "anthropic/claude-sonnet-5", "object": "model", "created": 0, "owned_by": "llmr", "llmr_kind": "chat" }
] }Route sets first, because those are the names a client is meant to use, then every enabled
model as provider/model. llmr_kind says which endpoint a name belongs to: chat,
embedding, image, speech or transcription. What each route set is made of, and which
of its routes are usable or resting, is GET /manage/routes.
200 ok, no key. The image's HEALTHCHECK runs llmr healthcheck, which asks this path
over loopback.