Cross-dialect translation
Turn on the opt-in that lets an OpenAI- or Anthropic-format client reach an upstream speaking another wire, and read what the crossing dropped.
On this page
Your application speaks one wire. The cheapest model that can do the job may be behind another. Without translation, a routing rule pointing at a different-dialect upstream is skipped and the request escalates to a same-dialect one, so half your catalog is unreachable from any given client.
Translation makes it reachable, and it costs something to do it: the request shapes are not the same size. Anthropic’s Messages format cannot express most of OpenAI’s generation parameters, so a translated request arrives with fewer knobs than it left with. The question is not whether that happens; it is whether you are told.
This page is the opt-in, the config shape it needs, and the argument behind both. The pair-by-pair lookup lives on Which client formats reach which upstreams, the full inventory of what a crossing can drop lives on What a translation drops, and how you find out, and frames arriving one at a time are Translated streams.
What it is, and why it is off out of the box
routing.allow_cross_dialect is off out of the box. With it off, a routing rule whose tier targets
an upstream speaking a different dialect is skipped, and the request goes to the same-dialect
default. Nothing is translated and nothing is lost.
Streaming is a second opt-in on top of the first, because a stream commits to its upstream before the first frame is decoded:
routing:
allow_cross_dialect: true # opt-in; default false
allow_cross_dialect_streaming: true # additive, and requires the line above
Setting the streaming flag alone fails validation, and the message says why: “a stream may not cross a dialect boundary that buffered traffic may not cross.”
Which pairs cross
Two client formats go in, and four upstream dialects come out. An OpenAI-format or Anthropic-format
client reaches an openai, anthropic, gemini or bedrock upstream, streaming and not. That is
twelve translated cells beside four native ones, and each translated cell has an end-to-end test
that drives a request through it against a mock shaped like that provider.
Gemini-format and Bedrock-format clients are the two blanks, and they are a decision rather than a missing method: those two dialects are translation targets, not accepted client formats, because there is no observed population of Gemini- or Bedrock-SDK clients pointed at a third-party gateway. A Gemini-format request is not rejected. The translator returns a named error, and the request is served byte-faithfully on its own dialect, reaching a Gemini upstream and losing nothing at all.
The config that works
mode: route
upstreams:
oai:
provider: openai
dialect: openai # name both
base_url: https://api.openai.com # host only — your client already sends /v1
api_key_env: OPENAI_API_KEY
ant:
provider: anthropic
dialect: anthropic # name both
base_url: https://api.anthropic.com
api_key_env: ANTHROPIC_API_KEY
tiers:
- name: cheap
upstream: ant
model: claude-haiku-4-5
- name: frontier
upstream: oai
model: gpt-4o
routing:
default_tier: cheap
allow_cross_dialect: true
Naming both is legal and checked: a dialect: that disagrees with the descriptor’s is a validation
error naming both values, so you cannot silently declare the wrong one.
What arrives, in both configurations
Here is one request sent to /v1/chat/completions carrying frequency_penalty and logit_bias,
routed to an Anthropic-dialect tier, captured at a mock upstream.
With dialect: named, the upstream receives an Anthropic request:
{ "path": "/v1/messages",
"body": { "model": "claude-haiku-4-5",
"messages": [{ "role": "user", "content": "hi" }],
"max_tokens": 4096 } }
With provider: alone, the same upstream receives the OpenAI body, on the OpenAI path:
{ "path": "/v1/chat/completions",
"body": { "model": "claude-haiku-4-5",
"messages": [{ "role": "user", "content": "hi" }],
"frequency_penalty": 0.5, "logit_bias": { "1": 1 } } }
Only the model was rewritten. The two parameters that should have been dropped and disclosed reached the wire, the row carried no disclosure block and no fail-safe flag, and usage came back zero because an OpenAI parser found no OpenAI usage object in an Anthropic response. Against a live Anthropic endpoint that request would be rejected outright.
Both captures are from mock upstreams that answer any path, which is why the second one returned 200 at all.
The disclosure
A translated request writes a disclosures.translation block on its decision-log row, naming every
parameter that could not cross and every value that was approximated. One block carries both
crossings: what your client sent that could not reach the upstream, and what the upstream sent that
could not reach your client.
=== RUN TestCrossDialectResponseLossOnTheSameRow
translate_response_test.go:262: EVIDENCE row: from=openai to=anthropic dropped=[logit_bias] approx=[max_tokens:defaulted-to-4096 usage.cache_write:unrepresentable-in-openai] pricing_key="anthropic/claude-haiku-4-5" usd=0.000356400
That is one run of go test ./test/e2e/ -run '^TestCrossDialectResponseLossOnTheSameRow$' -v -count=1
in the product repository, 2026-08-24, against an Anthropic mock configured to report 16 cache-write
tokens. The dropped list, the approximation list and the dollar are functions of the tree and the
price table; the test’s line number is not. Read the three entries in order. The client sent
logit_bias and Anthropic’s Messages API has no equivalent, so it did not cross. The client sent no
max_tokens and Anthropic requires one, so 4096 was supplied and named. And the upstream reported
16 tokens written to its prompt cache, which the published OpenAI usage object has no field for:
that entry is the record of a slight under-billing, written on the row rather than left for you to
infer.
A translation that drops nothing still writes the block, with empty lists. “Translated, nothing lost” is itself a fact worth recording, and the absence of the block is what tells you no translation happened.
What can and cannot cross
Every top-level parameter on the inbound request is either rendered into the target’s wire or dropped and disclosed. The classification is a pair of closed sets rather than a judgement call, and a parameter in neither set stops the translation instead of passing through unclassified.
There is no single rendered-and-dropped pair for the feature, because each target classifies the same parameters differently. This is the inventory per target, counted from the four render inventories in the product’s translate package on 2026-08-24:
| Target dialect | Parameters rendered | Dropped, with disclosure | Classified today |
|---|---|---|---|
openai |
19 | 26 | 45 |
anthropic |
12 | 33 | 45 |
gemini |
17 | 28 | 45 |
bedrock |
12 | 33 | 45 |
All four inventories classify the same 45 parameters today, and no test requires them to stay one set. Read 45 as the size of the universe on that date rather than as a fixed denominator, and re-derive the column you care about rather than quoting a remembered pair.
One entry still looks like it should work: top_k exists in Anthropic’s API, and the render does
not emit it, so it is dropped and disclosed like any parameter the target cannot express.
Message content is a second, smaller classification of five block types: text, image, tool call,
tool result and thinking. An Anthropic target expresses all five, and the product keeps that empty
drop set with a test of its own. The other three targets express four and drop thinking, disclosed
as content:thinking.
The response direction is not a closed set, so no dropped count is publishable for it. A response key the adapter does not model is disclosed as a drop under its own name instead of failing the whole response, because failing there would cost you the answer. 52 response-side drop names are declared across the four dialects today, and an unknown upstream key mints its own name at runtime, so 52 is a floor.
Approximations are recorded separately from drops, because a value was supplied rather than
lost. Anthropic requires max_tokens; a request without one gets 4096 and the row says so. A
message role outside user and assistant is coerced to user, and the row says that too.
The four limits
Streaming needs its own flag, and the pair is checked before the request goes out. With
allow_cross_dialect_streaming off, a streaming request that would cross a dialect boundary takes
the same-dialect default instead. With it on, the route asks whether the upstream has a decoder and
your client has an encoder, one line above the point of no return, so a missing half degrades to a
same-dialect passthrough rather than a stream that dies on its first frame.
Errors relay in the served dialect’s shape. A non-2xx reply from the upstream is passed through with its status preserved, in that provider’s error format rather than translated into the client’s. Both dialects expose a message and a type, so a client reading those fields copes.
Stream-phase guardrails and translated streams cannot both be enabled. A config that arms the
guardrail layer, gives a guard the stream phase and sets allow_cross_dialect_streaming: true is
rejected at tokentriage validate, naming both YAML paths. Translated
streams carries that refusal in full, and the two
single-flag ways out of it.
Translation fails toward passthrough. Any adapter gap or unmodelled parameter degrades to a
byte-faithful same-dialect passthrough with fail_safe recorded on the row. Not a 500, and not a
silently mangled body.
Cost, and what it does to savings
A translated row is priced under the upstream that served it, using that provider’s namespace,
like any other row. The capture above resolved the key anthropic/claude-haiku-4-5 and came to
$0.000356400 on a mock’s token counts, which is arithmetic you can check and nobody’s bill.
Which bytes that price was read from depends on whether the response streamed. A buffered translated response is priced from the body re-rendered for your client; a translated stream is priced from the upstream’s own frames. Same feature, two provenances, and reconciling one against the other needs the difference.
Translated rows are excluded from savings totals, keyed on the presence of the disclosure block. A saving is a comparison between two things you could have done, and a request whose parameters changed on the way out is not the same request the counterfactual assumed.
Where the capability check fits
With allow_cross_dialect: true, validation walks every tier and rejects one whose upstream serves
no chat endpoint, because chat is the only translatable kind. That is the whole check today: it
does not consult per-parameter capabilities. Without the flag it does not run at all, so a tier
pointed at a rerank-only descriptor passes validation in a config that leaves translation off.
What can go wrong
| Symptom | Cause | Fix |
|---|---|---|
| the cross-dialect tier is never chosen | allow_cross_dialect not set |
it defaults to off |
| a streaming request lands on the same-dialect tier | allow_cross_dialect_streaming not set |
set it; it needs allow_cross_dialect as well |
| the upstream gets an OpenAI body on an OpenAI path | provider: without dialect: |
name both on the target upstream |
a row has fail_safe: true and no disclosure |
the crossing was declined before forwarding | check the client format, and the stream flag if it streamed |
| the config will not load, naming a guard phase | a stream-phase guard meets translated streams |
turn one of the two off |
| savings look lower than expected | translated rows are excluded | expected; compare on same-dialect traffic |