What a translation drops, and how you find out
A translated request crosses two boundaries, and each records what it could not express on the ledger row. Here is how to read that.
On this page
You set logit_bias on a chat request. The reply came back 200, the text looked fine, and the bias
changed nothing. The response body says nothing about why.
Your request was translated on the way out. The upstream speaks a different dialect, the wire
format a provider’s API accepts, and that dialect has no equivalent for logit_bias, so the
parameter did not reach it. TokenTriage recorded the loss when it happened, on the decision-ledger
row for that request rather than in a response header.
A translated request crosses two boundaries, not one. Your request is rendered into the upstream’s dialect going out, and the upstream’s answer is rendered back into yours coming in. Each crossing records what it could not express, and both records land in one block on one row.
This page is about that record. Cross-dialect translation covers the config that turns translation on and where a request goes when it is off, and Which client formats reach which upstreams covers which pairs translate at all.
The parameter that did not cross
The quickest way to recognise this is to see one. Here is a translated request captured end to end:
an OpenAI-format client, an Anthropic-format upstream, one logit_bias key going out and a
cache-write count coming back.
$ go test ./test/e2e/ -run '^TestCrossDialectResponseLossOnTheSameRow$' -v -count=1
=== RUN TestCrossDialectResponseLossOnTheSameRow
translate_response_test.go:260: EVIDENCE openai-client → anthropic-upstream
translate_response_test.go:261: EVIDENCE client body: {"choices":[{"finish_reason":"stop","index":0,"message":{"content":"hello from anthropic","role":"assistant"}}],"created":1787629033,"id":"msg_mock_0000000000","model":"claude-haiku-4-5","object":"chat.completion","usage":{"completion_tokens":30,"prompt_tokens":264,"prompt_tokens_details":{"cached_tokens":64},"total_tokens":294}}
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
--- PASS: TestCrossDialectResponseLossOnTheSameRow (0.03s)
PASS
ok github.com/tokentriage/platform/test/e2e 0.071s
Ours in that capture: the test name, the translate_response_test.go line numbers, and the mock’s
created and id values, all from the product tree as we ran it on 2026-08-24 against price table
litellm@2026-07-14#179affb. The upstream is a mock shaped like Anthropic’s Messages API, not
Anthropic’s endpoint, so the dollar is arithmetic you can check rather than anybody’s bill. Yours is
the shape of the row: the same fields under the same names, whatever your route.
The row carries three entries, and they came from two different crossings.
| Entry on the row | Crossing | What happened |
|---|---|---|
dropped=[logit_bias] |
request | anthropic’s Messages API has no equivalent, so the parameter did not reach the wire |
max_tokens:defaulted-to-4096 |
request | anthropic requires max_tokens; the client sent none, so a value it never wrote went upstream |
usage.cache_write:unrepresentable-in-openai |
response | the upstream wrote 16 tokens to its prompt cache, and openai’s usage schema has no field for that count |
A drop and an approximation are different records. A drop says the thing did not cross. An approximation says a value crossed, but not the one you sent.
The third entry costs money. A translated non-streaming row prices from the body that was rendered
back into the client’s dialect, and that body has nowhere to put a cache-write count, so those 16
tokens sit outside the usd=0.000356400 on the row. The figure is low, and the row names the fact
that is missing from it.
Where the loss is written down
A record you cannot find is not a disclosure. This one has four fields, one home, and a naming scheme that says which crossing wrote each entry.
disclosures.translation.from (string, always present)
disclosures.translation.to (string, always present)
disclosures.translation.dropped_params ([]string, omitempty)
disclosures.translation.approximations ([]string, omitempty)
There is no second block for the response half. It appends into the same two lists, and a translated stream folds its own entries in when the stream closes. Both fields and both lists are catalogued in the decision ledger field reference.
One flat list, several origins: an entry names its own by prefix.
| Prefix on the entry | Written by | Means |
|---|---|---|
| a bare parameter name | the request render | a parameter you sent that did not reach the wire |
content:<blocktype> |
the request render | a content block the target dialect cannot express |
response.<dotted-key> |
the response render | something the upstream sent that your dialect cannot carry |
error.<key> |
the error render | an error-envelope field with no home on your dialect |
stream.<…> |
the stream codec | an event or frame member that could not be re-encoded |
Approximations use the same prefixes where the crossing has one, and are otherwise named for the
field they changed. On the capture above, max_tokens:defaulted-to-4096 came from the request half
and usage.cache_write:unrepresentable-in-openai from the response half.
That last sentence has a trap in it. An upstream configured with provider: and no dialect:
leaves its dialect empty, the cross-dialect branch is not taken, and the request is forwarded
untranslated with no disclosure block at all. The absence looks identical to a route that had
nothing to translate. That is a defect in the shipped build, and
Cross-dialect translation carries both captures and the
config shape that avoids it.
Here are the two crossings and the single record they share.
Read the top row left to right: your client, the crossing out, the upstream, and the crossing back. Each crossing has a loss box under it, and both boxes drain into the single block at the foot of the figure. That block is not sealed until the row is built, which is why a loss discovered on the last frame of a stream still lands.
How much each target can carry
Before you point a route at a different-dialect upstream, the useful question is how much of your
request survives the crossing. Each target dialect sorts every top-level parameter on your request
into one of two sets: rendered, or dropped with a disclosure. A parameter in neither set stops the
translation rather than passing through unclassified, and the request degrades to a byte-faithful
same-dialect passthrough with fail_safe recorded on the row.
| Target dialect | Parameters rendered | Parameters dropped, with disclosure | Classified today |
|---|---|---|---|
openai |
19 | 26 | 45 |
anthropic |
12 | 33 | 45 |
gemini |
17 | 28 | 45 |
bedrock |
12 | 33 | 45 |
Those figures were counted on 2026-08-24 from the four target renderers’ own parameter inventories in the product source, and a test you can run asserts that no parameter sits in both halves of one inventory. All four sort the same 45 keys today. Nothing in the tree requires them to stay identical, so read 45 as the size of the classified set today rather than a fixed denominator.
Message content is a second inventory and it partitions differently. Five block types are defined, the set is closed, and one of them is the only block any target drops.
| Target dialect | Blocks it can express | Blocks dropped |
|---|---|---|
openai |
text, image, tool call, tool result | thinking, disclosed as content:thinking |
anthropic |
all five | none |
gemini |
text, image, tool call, tool result | thinking, disclosed as content:thinking |
bedrock |
text, image, tool call, tool result | thinking, disclosed as content:thinking |
If your traffic carries thinking blocks and your target is anything but anthropic, they do not
cross, and every such request says so on its row.
The response half has no denominator
The obvious next question is the same table for the response direction. It cannot be written, and the reason is worth more than the table would have been.
Coming back, the rule inverts. A field the upstream sent that your dialect cannot carry is disclosed as a drop under its own name, and a field nobody classified mints a name at runtime rather than stopping the translation. Failing closed on the way back would cost you the whole answer over one unrecognised key.
So there is no closed set to count. 52 response-side drop names are declared across the four dialects, counted on 2026-08-24 from their response inventories, and that is a floor rather than a total: the next field an upstream adds arrives in your row under a new dotted name, with no release here. A published dropped count would be a figure the next upstream field invalidates without telling anybody.
What you get instead of a number is the list itself, per request, on the row. It names what the upstream sent and your dialect could not carry, which is the question a count was standing in for.
Two translated rows can price from different bytes
A translated non-streaming row prices from the body that was rendered back into your dialect: the upstream’s JSON is buffered and re-rendered before the ledger tap reads it. A translated streaming row prices from the upstream’s own frames, because there is no buffered body to re-render and the answer is re-encoded one event at a time.
Same feature, two provenances. Both are defensible and they are not the same story, so anyone reconciling a streamed dollar against a non-streamed one needs to know which row they hold.
One pair shows it plainly. An Anthropic-format client streaming from an OpenAI upstream sees zeros in its own usage event while the ledger prices the request correctly. OpenAI emits its usage chunk after the finish chunk, and the Anthropic encoder has to write usage at finish time. Two receipts ride the row and say so:
stream.anthropic.message_start.usage:not-yet-known→0
stream.anthropic.message_delta.usage:absent→0
Reconcile against the row, not against the streamed response. Translated streams covers the rest of the streaming half, including what a frame that will not encode does to the request.
Before you turn this on
Four checks, in the order that catches the most.
- List the parameters your client sets on a normal request, and compare them against the rendered column for the dialect your target upstream speaks. Anything outside that column is dropped on every request, not on some of them.
- Check your content blocks. Thinking blocks reach an
anthropictarget and no other. - Send one request and read its row before you send the second. The row lives in the decision
log, at
~/.tokentriage/decisions.jsonlby default, and Trace one request end to end walks from a request id to the row that carries it. - Read the approximations list before you forward a figure to anyone with a budget. An entry
beginning
usage.means a token bucket did not survive the crossing, and the dollar on that row is missing it.
What the row does not tell you
Three things this block is silent about, each for a different reason.
A genuine error is not translated. A non-2xx reply from the upstream relays untranslated, in the
serving provider’s own error shape, with its status preserved. The error.-prefixed entries come
from a 2xx response that carried an error body, which is the only error path that crosses.
The four parameter inventories are aligned today, and nothing holds them there. They sort the same 45 keys as of 2026-08-24, and no test asserts that they must. Re-read the table rather than assuming a key one target drops is dropped by all four.
A count of what the response side can drop does not exist, for the reason in the preceding section. The per-request list is the answer, and there is no denominator behind it.