# 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.

Source: https://tokentriage.com/docs/build/providers-cross-dialect

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

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](/docs/build/providers-dialect-matrix),
the full inventory of what a crossing can drop lives on [What a translation drops, and how you find
out](/docs/build/providers-translation-loss), and frames arriving one at a time are
[Translated streams](/docs/build/providers-streaming-translation).

## 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:

```yaml
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.

<Callout type="warning">
Cross-dialect translation reads the target upstream's dialect from your config, not from the
compiled descriptor, and an upstream configured with `provider:` alone leaves that field empty.
Name **both** `provider:` and `dialect:` on any upstream you intend to reach across dialects. With
`provider:` alone the request is routed to that upstream *untranslated*, and the section below
shows what arrives. This is a defect in the shipped build, not a design choice; the committed example
config in the product repository has the same problem.
</Callout>

## The config that works

```yaml
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:

```json
{ "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:

```json
{ "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.

```text
=== 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](/docs/build/providers-streaming-translation) 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.

<Details summary="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.

</Details>

## 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 |

<NextSteps items={[
  {title: "Which client formats reach which upstreams", href: "/docs/build/providers-dialect-matrix", description: "Check pair by pair that your format reaches the upstream you want, before you change any config."},
  {title: "What a translation drops, and how you find out", href: "/docs/build/providers-translation-loss", description: "Every name a crossing can write on the row, for when the counts above are not enough."},
  {title: "Translated streams", href: "/docs/build/providers-streaming-translation", description: "The second flag adds a failure mode the buffered path on this page does not have."},
  {title: "Which pricing key your model lands under", href: "/docs/build/providers-pricing-keys", description: "How the served upstream's namespace is chosen, and the reasons a row comes back with no price."}
]} />
