Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

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.

ExplanationLast updated

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.

A translated request crosses two boundaries, and both write their losses onto the same rowA translated request crosses a dialect boundary twice, and each crossing writes down what it could not carry. Follow the path: your client sends in its own format, the first crossing renders that request into the upstream's dialect, the upstream answers on its own wire, and the second crossing renders the answer back into yours. The first crossing can lose a parameter you sent that the target wire has no home for, or a content block the target cannot express. The second can lose a fact the upstream sent that your wire cannot carry, and for a streaming answer, anything a frame could not be re-encoded into. Both taps drain into one place: a single translation block on the decision row, holding the dialect it came from, the dialect it went to, the dropped parameter names and the approximations. An approximation is not a drop. It is a value that crossed in a changed form, such as a required field on the target that your request left unset, and naming it is the difference between a documented default and a silent one. The block rides the row rather than a response header, and that is the part with consequences. A header has to be written before the body goes out, and a stream's losses are not all known until its last frame, so a header-borne disclosure would have to guess or stay quiet. The row is built when the tap closes, so a discovery made in the middle of a stream still reaches the reader. A translated row always carries the block, which means an empty pair of lists is the recorded statement that this crossing lost nothing, and no block at all means no translation happened.one translated request, two crossingsthe answer, back in your dialectYour clientone wire format,in and outCrossing 1render into theupstream's dialectThe upstreamits own wire,byte for byteCrossing 2render the answerback into yoursloss taploss tapCrossing 1 · what could not be senta parameter you sent that the target wire has nohome for, or a content block it cannot expresslogit_bias content:thinkingCrossing 2 · what could not come backa fact the upstream sent that your wire cannotcarry, and per frame on a streaming answerresponse.usage stream.text_deltaOne block, on the ledger rowdisclosures.translation.from · .todisclosures.translation.dropped_params · .approximationsBoth crossings write into the same block, and a stream folds its own losses in when the tap closes.The row is built after the last frame, which is why a mid-stream discovery still lands.A header would have had to go out ahead of the body, before the losses were known.A translated row always carries the block, so an empty pair of lists records a crossing that lost nothing.

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.

  1. 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.
  2. Check your content blocks. Thinking blocks reach an anthropic target and no other.
  3. Send one request and read its row before you send the second. The row lives in the decision log, at ~/.tokentriage/decisions.jsonl by default, and Trace one request end to end walks from a request id to the row that carries it.
  4. 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.