OpenAI-compatible APIs (LLMs)
Two endpoints call an API that speaks the OpenAI wire format: OpenAI itself, and the servers that copied it, such as Ollama, vLLM, LM Studio, Groq and Mistral.
| Endpoint | Calls | Requests |
|---|---|---|
openai_chat | /chat/completions | one per message, or one per messages_per_request |
openai_embeddings | /embeddings | one per batch |
Both answer; they write nothing. Use them as the from of a
lookup, which puts the answer into the message, or
as the to of a request endpoint, which
forwards the answer. Recipes are in the LLM cookbook.
A request endpoint sends its messages one by one, so under it both endpoints
make one API request per message: messages_per_request bundles nothing and
openai_embeddings embeds one text per request. Put a
buffer on the to endpoint and the sends
of a batch leave together again, 40 messages as one embeddings request:
output:
request:
to:
openai_embeddings: { model: text-embedding-3-small, input: "${payload:text}" }
middlewares:
- buffer: { max_messages: 500, max_delay_ms: 0 }
forward_to: { file: { path: "vectors.jsonl", format: raw } }
A lookup, or the endpoint as the output itself, batches without it.
mq-bridge is not affiliated with OpenAI; the names say which API the endpoints speak.
openai_chat
Sends one chat completion per message and answers with the text of the first choice.
output:
middlewares:
- lookup:
from:
openai_chat:
url: "http://localhost:11434" # Ollama; leave out for api.openai.com
model: llama3.2
system: "Answer with one word: positive, negative or neutral."
prompt: "${payload:text}"
into: sentiment
kafka: { topic: "reviews.scored", url: "localhost:9092" }
With json: true the endpoint asks for a JSON object and parses it, so the
answer is an object in the message instead of text:
output:
middlewares:
- lookup:
concurrency: 4
from:
openai_chat:
api_key: "${env:OPENAI_API_KEY}"
model: gpt-4o-mini
json: true
prompt: 'Extract {"city": string, "urgent": boolean} as JSON from: ${payload:text}'
options: { temperature: 0 }
into: extracted
kafka: { topic: "tickets.extracted", url: "localhost:9092" }
On the command line the scheme is openai:// or openai-chat://, for example
openai://localhost:11434?model=llama3.2&prompt=….
Several messages in one request
With messages_per_request the endpoint sends that many messages as numbered
inputs of one completion and hands each message its own answer. The system
message, and with it most of the prompt tokens, is sent once per request
instead of once per message:
reviews_scored:
batch_size: 100
input: { kafka: { topic: "reviews", url: "localhost:9092" } }
output:
middlewares:
- lookup:
from:
openai_chat:
api_key: "${env:OPENAI_API_KEY}"
model: gpt-4o-mini
system: "Answer with one word: positive, negative or neutral."
prompt: "${payload:text}"
messages_per_request: 20
options: { temperature: 0 }
into: sentiment
kafka: { topic: "reviews.scored", url: "localhost:9092" }
The request asks for a JSON object {"answers": [{"n": 1, "answer": …}, …]}
with one entry per input. Each answer goes to the message whose number it
carries, so the order the model answers in does not matter. With json: true
each answer is a JSON object, otherwise a string.
On the command line it is one more parameter:
openai://localhost:11434?model=llama3.2&prompt=…&messages_per_request=20.
- Answers that cannot be matched are asked again in halves. A response that is not that object, has more or fewer entries than inputs, or holds a number twice or one no input has, is not used. Its messages are sent again in two requests, and so on, until what cannot be answered stands alone and fails for good. A request the API refuses with 400, 409, 413 or 422, such as one over the context length, is halved the same way, so one oversized message does not fail its neighbours.
- Halving costs requests. One bad message among 20 costs about ten requests instead of one; a model that loses count at every size costs up to twice as many requests as without bundling. Pick a size your model handles; how many inputs a model keeps apart depends on the model and on the length of the texts. Each halving logs a warning with the reason.
- Under a
lookup, a failed message fails alone too. The other messages of the batch keep their answers and are not asked again. - Leave room for the answers. A
max_tokensinoptionsbounds the whole response, andrequest_timeout_msthe whole request. Unset, a request fails when no response byte arrives for 300 s; setrequest_timeout_mshigher for a model that takes longer to answer, or to0for no limit. An answer that was cut off cannot be matched; the warning then says the response ended withlength. - Size is counted in messages and bytes, not tokens.
max_request_bytesbounds the request body. A single message over the model’s context is refused by the API and fails alone. - The route’s
batch_sizeis the upper bound. A bundle is cut from one batch, so a route withbatch_size: 1still sends one message per request. Nothing is held back to fill a bundle. - 429, 408 and 5xx fail the whole batch as retryable and are not halved;
retrysends it again. ARetry-Afterheader is not read: set the backoff inretry. systemis fixed text here. It is sent once per request, so it cannot take values from a message; put those intoprompt. Being the same in every request, it is also what a provider’s prompt cache can reuse.- Put the task into
systemand only the data intoprompt. The model sees all inputs of a request together, and a text that reads like an instruction can change the answers of its neighbours. Do not bundle texts from sources you do not trust with ones whose answers matter, and leavemessages_per_requestout where each message needs a request of its own. - The inputs are sent as text parts of one user message
(
content: [{"type": "text", …}]), and the request setsresponse_format: json_objectunlessoptionssets another, such as a strictjson_schema. OpenAI, Ollama and vLLM take both; a server that does not needs the one-per-message form. - A payload that is not UTF-8:
${payload}is sent with the invalid bytes replaced. A field such as${payload:text}needs a JSON payload, which is UTF-8. - No metrics per bundle. The token
usageof a response is not kept, and an answer carries no mark of the bundle it came from.
| Field | Default | Meaning |
|---|---|---|
url | https://api.openai.com/v1 | Base URL. One without a path gets /v1. openai://host is plain HTTP, openai+https://host is HTTPS |
api_key | none | Sent as Authorization: Bearer |
model | required | Model name |
prompt | required | Template of the user message |
system | none | Template of the system message |
json | false | Asks for response_format: json_object and parses the answer. An answer that is not JSON fails its message for good |
options | none | Further request fields, e.g. temperature, max_tokens, response_format. It cannot set model, messages or stream. JSON text in a URI |
concurrency | 8 | Requests in flight when a batch is sent to the endpoint as an output. Under a lookup, the lookup’s own concurrency (default 16) decides, unless messages_per_request is set |
messages_per_request | none | Messages answered by one request, see above. Unset: one request per message |
max_request_bytes | 10 MiB | With messages_per_request: a bundle over this size is cut earlier |
request_timeout_ms | see right | Timeout of one request; 0: none. Unset: a request fails after 300 s without a response byte |
tls | none | TLS options as on http_bulk. JSON text in a URI |
auth | none | oauth2 or aws_sigv4 credentials as on http_bulk, in place of api_key. JSON text in a URI |
openai_embeddings
Sends the texts of a whole batch in one request and answers each message with its vector.
output:
middlewares:
- lookup:
from:
openai_embeddings:
api_key: "${env:OPENAI_API_KEY}"
model: text-embedding-3-small
input: "${payload:title}: ${payload:body}"
into: vector
qdrant: { url: "http://localhost:6333", collection: docs }
That writes the message with a vector field; shape it into a point first, as
on the Qdrant page. On the command line the scheme is
openai-embeddings://.
| Field | Default | Meaning |
|---|---|---|
url | https://api.openai.com/v1 | Base URL, as above. openai-embeddings://host is plain HTTP |
api_key | none | Sent as Authorization: Bearer |
model | required | Embedding model |
input | required | Template of the text to embed |
dimensions | none | Length of the vectors, for models that can shorten them |
max_request_bytes | 10 MiB | A larger batch is split into several requests |
request_timeout_ms | see right | Timeout of one request; 0: none. Unset: a request fails after 300 s without a response byte |
tls | none | TLS options as on http_bulk. JSON text in a URI |
auth | none | oauth2 or aws_sigv4 credentials as on http_bulk, in place of api_key. JSON text in a URI |
Amazon Bedrock
Bedrock serves the chat completions API at
https://bedrock-runtime.<region>.amazonaws.com/openai/v1. Use a Bedrock API key as
api_key, or sign the requests with the AWS credentials of the environment (needs a build
with the aws feature):
output:
request:
to:
openai_chat:
url: https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1
auth:
aws_sigv4: { region: us-west-2, service: bedrock }
model: openai.gpt-oss-20b-1:0
prompt: "Summarise: ${payload:text}"
forward_to: { file: { path: "summaries.jsonl", format: raw } }
This was checked for its configuration only, not run against Bedrock.
From Rust
OpenAiChatConfig and OpenAiEmbeddingsConfig are the typed forms of the
tables above. Either converts into an Endpoint; it needs the http-bulk
feature and no registration.
use mq_bridge::endpoints::http_bulk::OpenAiChatConfig;
use mq_bridge::models::Endpoint;
let llm: Endpoint = OpenAiChatConfig {
model: "gpt-4o-mini".into(),
prompt: "Summarise: ${payload:text}".into(),
..Default::default()
}
.into();
What to know
- Templates are escaped. Text taken from a message is JSON-escaped, so a quote or a line break in it cannot break the request.
- A temporary error fails only its message. 408, 429 and 5xx are retryable.
List
retryafter thelookup; it repeats only the messages that failed, so answers already paid for are kept. - One request per batch means one fate per batch for embeddings. A request
the API refuses fails every message in it. OpenAI refuses an empty text, so
drop such messages with a
filterfirst. - Vectors are matched by position. The API must answer in input order, which OpenAI documents. A response with another number of vectors fails the batch for good.
- Batch size is the request size. OpenAI takes at most 2048 texts per
embeddings request; keep the route’s
batch_sizeat or below your provider’s limit. - Nothing is cached. A replayed message is sent, and billed, again. Use
deduplicationbefore the lookup if that matters. - No streaming here. For a token stream, use the
httpoutput withstream_response_to; see the LLM cookbook.
Both endpoints are the generic http_bulk query with the
requests filled in. An API with another shape, such as Anthropic’s
/v1/messages, is a query with per_message: true:
output:
http_bulk:
url: https://api.anthropic.com
headers:
x-api-key: <api key>
anthropic-version: "2023-06-01"
query:
path: /v1/messages
format: json_array
per_message: true
request: '{"model":"claude-haiku-4-5","max_tokens":256,"messages":[{"role":"user","content":"${payload:text}"}]}'
value: /content/0/text
error: /error/message