The unified API speaks three wire protocols:
| Route | Protocol |
|---|---|
POST /v1/chat/completions |
OpenAI Chat Completions |
POST /v1/responses |
OpenAI Responses |
POST /v1/messages |
Anthropic Messages |
POST /v1/messages/count_tokens |
Anthropic token count (local estimate) |
Any route accepts any provider:model, auto:* route, free:* pool or
auto:intelligence. When the selected model speaks the caller's protocol the
request passes through unchanged; otherwise it is translated statelessly, sent
to the model's native endpoint, and the answer is translated back, including
event-by-event streams. Chat Completions is the pivot: a Responses request for a
Messages-only model is translated Responses to Chat to Messages and back.
The translators live in services/protocol_translation/. Their current
event schemas follow the
Anthropic streaming reference
and the OpenAI Responses streaming events.
providers/protocols.py declares native protocols as data:
PROVIDER_NATIVE_ENDPOINTSlists providers whose whole catalog also accepts Responses or Messages (codex-easy,linkapi: Responses;nanogpt,navyai: Responses and Messages). Every other provider speaks Chat Completions.MODEL_ENDPOINT_RESOLVERSmaps a provider to a per-model resolver. OpenCode usesopencode_model_endpoint, soopencode:grok-4.6is Responses-only andopencode:minimax-m3is Messages-only.
To declare another provider, add it to one of these tables. GET /v1/models
reports these OpenCode models with supports_chat: true, and
provider_metadata.api_endpoint/api_protocol still name the native protocol.
| Request | Selected model | Behaviour |
|---|---|---|
| Chat | speaks Chat | Unchanged (existing behaviour) |
| Chat | Responses- or Messages-only | Translated to its native endpoint |
| Responses or Messages | explicit model that speaks it | Native passthrough |
| Responses or Messages | explicit model that does not | Translated through Chat |
| Responses or Messages | auto:*, free:*, auto:intelligence, routing |
Translated to Chat and dispatched by the normal Chat dispatcher |
Automatic routes keep their own failover rules, because the translated request
runs through the same Chat dispatcher: candidates are tried in order and a
translated error keeps its status code, so 401, 402, 403, 404, 429 and
an open circuit still move to the next candidate while any other failure is
returned unchanged. X-MultiLLM-Auto-Route, X-MultiLLM-Auto-Selected-Model,
X-MultiLLM-Auto-Attempts and X-MultiLLM-Auto-Selected-Priority are kept.
An automatic route whose candidates only generate images or video returns
400 on /v1/responses and /v1/messages.
NanoGPT in subscription-only mode and Kimi Code are exceptions kept from the
Responses route: NanoGPT then uses the Chat bridge instead of its native
endpoints, and an explicit kimi-code:* model is rejected on /v1/responses.
/optimize/v1/chat/completions uses the same Chat dispatcher, so optimized
requests reach Responses- and Messages-only models too.
Function tools on /v1/chat/completions, /v1/messages, and /v1/responses
are checked at the Container's Chat pivot, including native endpoints, free
pools, and intelligence routing. X-MultiLLM-Tool-Repair: repair repairs JSON
syntax, unambiguous names and schema primitive types, and extracts declared
calls from known text formats. Calls that carry Claude's native turn in
extra_content.anthropic.content (signed thinking or advisor blocks) pass
through unrepaired, because that turn replays only while the calls match it.
Tagged calls may occur within prose; fenced/bare
JSON requires standalone content or a preamble of at most 200 characters, unless
a tool is forced.
Multiple fences are left as content. A bare name line requires JSON-object
arguments, with one optional surrounding fence. Multiline prose and untagged
undeclared names are left as content without invalid counts; forced choices and
explicit tool-call tags retain unknown-name rejection. Invalid extraction
candidates preserve the entire original message and report extraction_rejected.
Missing arguments remain invalid.
A native Messages request that declares a tool Claude runs itself, such as the
advisor or web search, is not repaired: repair works
on Chat Completions, which cannot carry server tool blocks (server_tool_use,
advisor_tool_result) or per-model usage.iterations, so the reply passes
through as Claude sent it. Anthropic's client tools (bash_*, text_editor_*,
computer_*, memory_*) are called with ordinary tool_use blocks and are
repaired like custom tools.
off preserves the existing response bytes. full allows one non-streaming
follow-up to the same model, includes its usage, and respects free-pool and
intelligence attempt limits. TOOL_CALL_REPAIR_DEFAULT defaults to repair;
the request header overrides it. Browser CORS permits and exposes the header.
When tools are declared, non-streaming response headers report checked,
repaired, extracted, invalid, and reasked. Streamed responses send
X-MultiLLM-Tool-Repair: mode=<mode>; streaming=1, including mode off.
Streaming sends text immediately and buffers each tool call until completion;
it neither extracts text calls nor re-asks. The tool_call_repair log records
final counts without output or argument values. Existing validation in free and
intelligence routes still
rejects calls that remain invalid. See the implementation design
for limits and protocol caveats.
| Feature | Chat ↔ Messages | Chat ↔ Responses |
|---|---|---|
| System prompts | system/developer messages ↔ top-level system (joined in order) |
system/developer messages ↔ instructions and system input items; developer becomes system in Chat |
| Multi-turn text | Consecutive same-role turns are merged | Message items ↔ messages |
| Images | Data URL ↔ base64 source, URL ↔ url source |
image_url ↔ input_image |
| Files | Inline base64 PDF file part ↔ document; text documents become text |
file ↔ input_file |
| Tools | function tools ↔ custom tools with input_schema |
function tools ↔ flat function tools (strict: false unless set) |
| Tool calls and results | tool_calls ↔ tool_use; tool messages ↔ tool_result blocks placed first in the user turn; tool-result images follow in a user message; is_error prefixes Error: |
tool_calls ↔ function_call items; tool messages ↔ function_call_output |
tool_choice |
auto/none ↔ same, required ↔ any, function ↔ tool; parallel_tool_calls: false ↔ disable_parallel_tool_use |
auto/none/required and function; allowed_tools filters the tool list |
| Structured output | json_schema ↔ output_config.format; json_object adds a JSON-only system instruction |
response_format ↔ text.format |
| Stop sequences | stop ↔ stop_sequences |
Dropped: Responses has none |
| Max tokens | max_tokens/max_completion_tokens ↔ max_tokens (Messages default 8192) |
↔ max_output_tokens |
Temperature, top_p |
Temperature is clamped to 0–1 for Messages | Passed through |
| Reasoning | reasoning_effort → thinking with a budget (low 2048 … max 32768, kept below max_tokens, dropped under 1024); none disables thinking. thinking budgets and output_config.effort → reasoning_effort |
reasoning_effort ↔ reasoning.effort |
| Reasoning output | thinking blocks ↔ reasoning_content |
reasoning items (reasoning_text or summaries) ↔ reasoning_content |
| Usage | Anthropic input excludes cache reads and writes; both are kept (cached_tokens, cache_write_tokens) |
input_tokens_details/output_tokens_details ↔ prompt_tokens_details/completion_tokens_details |
| Stop reasons | end_turn/stop_sequence → stop, max_tokens/model_context_window_exceeded/pause_turn → length, tool_use → tool_calls, refusal → content_filter |
incomplete with max_output_tokens → length, content_filter → content_filter |
| Errors | Anthropic {"type":"error","error":{...}} ↔ OpenAI {"error":{...}}, status kept |
Both use the OpenAI envelope and pass through |
Reasoning is round-tripped only where the target can use it: thinking chat
models such as Kimi and DeepSeek need reasoning_content back on assistant
tool-call turns, so it is sent there and dropped elsewhere. Thinking blocks
reconstructed from plain reasoning text carry an empty signature; they are
removed before a request is passed natively to a Messages model. Native signed
Anthropic tool turns retain their complete assistant blocks in extra_content
on the first Chat tool call or Responses function call. Replay that metadata
unchanged with the complete assistant output when submitting tool results;
see Anthropic continuation handling.
Chat: logprobs, top_logprobs, logit_bias, penalties, seed,
service_tier, prompt_cache_key, metadata. Messages: metadata.user_id
(free pools reject user), cache_control, top_k, citations,
context_management, anthropic-version and anthropic-beta (ignored when the
target does not speak Messages). Responses: store (responses are never
stored; translated requests send store: false), include, truncation,
text.verbosity, metadata, prompt_cache_key. Server-tool history blocks in
Messages conversations are dropped.
These need server-side state or a provider runtime the route cannot reproduce:
- Responses:
previous_response_id,conversation,background,prompt,item_referenceinput items, built-in tools (web_search,file_search,code_interpreter,computer_use_preview,image_generation,mcp,local_shell, custom tools) and built-in tool history items or tool choices. - Messages:
container,mcp_servers, and every typed Anthropic tool (web_search_*,web_fetch_*,code_execution_*,bash_*,text_editor_*,computer_*,memory_*, tool search). File-ID images and documents, and URL documents. - Chat into Messages or Responses:
nabove 1, audio output, legacyfunctions, audio input parts.
The 400 body is an OpenAI error envelope with param naming the field; on
/v1/messages it is Anthropic's invalid_request_error. Native passthrough
routes do not apply these checks.
Streams are translated event by event without buffering: each upstream SSE event is parsed as it arrives and written as the target's events.
- Chat targets receive
chat.completion.chunkframes, a final chunk withfinish_reasonandusage, thendata: [DONE]. - Messages targets receive
message_start, sequential content blocks (thinking,text,tool_usewithinput_json_delta),message_deltawith the stop reason and cumulative usage, andmessage_stop. - Responses targets receive
response.created,response.in_progress, output item and content part events (response.output_text.delta,response.reasoning_text.delta,response.function_call_arguments.delta), andresponse.completedorresponse.incomplete, with increasingsequence_number.
Provider keep-alive comments and Anthropic ping events are forwarded as SSE
comments, and translated streams keep Content-Type: text/event-stream, so the
Worker's idle heartbeat (SSE_STREAM_HEARTBEAT_MS) still applies.
A stream is complete only when it reaches its terminal event (message_stop,
response.completed/response.incomplete, or a Chat finish_reason). If the
upstream ends early or the connection breaks, the translated stream ends with a
well-formed terminal event that marks it unfinished, never a fake success:
| Target | Upstream ended early | Upstream sent an error |
|---|---|---|
| Chat | {"error":{"code":"stream_interrupted",...}} then [DONE] |
{"error":{...}} then [DONE] |
| Messages | event: error with api_error |
event: error with the mapped type |
| Responses | response.incomplete with incomplete_details.reason: "upstream_interrupted" |
error then response.failed |
upstream_interrupted is a MultiLLM value outside OpenAI's documented reasons.
When a model answers a streaming request with JSON, or a non-streaming request
with SSE, the body is replayed or folded so the caller receives the mode it
asked for.
POST /v1/messages authenticates with x-api-key or Authorization: Bearer
(or X-MultiLLM-Api-Key) using the normal proxy keys and the chat scope.
anthropic-version and anthropic-beta are accepted and never required. Every
non-streamed error on /v1/messages, including authentication, rate-limit and
validation errors, uses Anthropic's error envelope.
POST /v1/messages/count_tokens returns {"input_tokens": N} from a local
estimate (about four characters per token, the gateway's rate-limit
heuristic), marked with X-MultiLLM-Token-Count: estimate. It makes no
provider call and is not the target model's tokenizer.
export ANTHROPIC_BASE_URL="$PROXY_BASE_URL"
export ANTHROPIC_API_KEY="$MULTILLM_API_KEY"
export ANTHROPIC_MODEL="auto:glm-5.2"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="opencode:minimax-m3"
claudeANTHROPIC_AUTH_TOKEN works instead of ANTHROPIC_API_KEY (it is sent as a
Bearer token). Model names are MultiLLM IDs. Claude Code's web search and other
Anthropic server tools are unavailable unless the model is a native Messages
model that provides them. Following Claude Code's
gateway protocol reference:
- Claude Code posts to
/v1/messages?beta=true; thebetaflag is dropped before dispatch, so it never reaches a provider or a free pool. - It aborts a stream after 300 seconds without bytes. Provider keep-alives are
forwarded and the Worker adds
: multillm-keepalivecomments on idle streams. - It prepends an attribution block to the system prompt. Translation joins the
system blocks, so the block reaches translated models; set
CLAUDE_CODE_ATTRIBUTION_HEADER=0to omit it. - It sends
thinking: {"type": "adaptive"}and, with experimental betas,context_management. Translated routes drop both safely. A native Messages model that rejects them makes Claude Code retry without thinking; setCLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1ifcontext_managementis rejected. GET /v1/modelsacceptsx-api-key, soCLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1works, but Claude Code lists only IDs containingclaudeoranthropic. Set other models withANTHROPIC_MODELor/model <id>.- Native Messages errors pass through unmodified, which Claude Code's retry logic relies on; translated errors keep the upstream message and status.
import anthropic
client = anthropic.Anthropic(base_url=PROXY_BASE_URL, api_key=MULTILLM_API_KEY)
message = client.messages.create(
model="opencode:kimi-k3",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}],
)import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ baseURL: process.env.PROXY_BASE_URL, apiKey: process.env.MULTILLM_API_KEY });
const stream = client.messages.stream({
model: "auto:glm-5.2",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello" }],
});The Worker forwards /v1/messages and /v1/messages/count_tokens to the
Container like other /v1 routes. The LinkAPI fast path handles only
/linkapi/..., so /linkapi/v1/messages stays a raw LinkAPI passthrough.
curl "$PROXY_BASE_URL/v1/responses" \
-H "Authorization: Bearer $MULTILLM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "auto:glm-5.2", "input": "Hello", "stream": true}'Send the whole conversation in input each turn; translated routes keep no
response state.