Skip to main content

ADR-0011: Field Forwarding on Translated Paths

Status

Accepted

Context

GoModel exposes an OpenAI-compatible API (/v1/*) and an Anthropic Messages ingress, and routes each request to a provider whose native API may differ from what the client sent. ADR-0002 already decided that unknown JSON fields are preserved rather than dropped, and ExtraFields on the request types implements that for requests. What happens to a field the gateway does know about, when the selected provider or model cannot take it, was never written down. The adapters answer the same question three different ways today:
  • The OpenAI adapter maps max_tokens to max_completion_tokens and drops temperature for reasoning models (o-series, GPT-5).
  • The shared cache-control helper strips Anthropic cache_control directives before a request goes to a provider that does not accept them.
  • The Anthropic adapter rejects verbosity and any response_format other than type text with a 400, and silently accepts a text format because it changes nothing.
Each answer is reasonable on its own. Without a shared rule the next adapter picks one at random, and clients cannot predict whether a field will be honored, ignored, or refused.

Decision

On translated paths, a field the client sends is handled by the first rule that matches, in this order:
  1. Unknown to the gateway. Forward untouched, in both directions. This is the ADR-0002 forward-compatibility promise and is how clients reach provider-native extras (for example OpenRouter routing preferences) without waiting for a GoModel release.
  2. Known, and the target accepts it. Forward as-is.
  3. Known, and the target needs a different shape. Translate. The client asked for something the provider can do; only the spelling differs.
  4. Known, the target cannot honor it, and ignoring it does not change what the client asked for. Drop it. A hint the provider would not have acted on anyway is not worth a failed request.
  5. Known, the target cannot honor it, and dropping it would change the result. Reject with a 400 that names the field and the provider. A silent drop here returns a wrong answer under a 200.
“Known” means the gateway has a rule for the field, not that the field has a typed struct member. cache_control travels in ExtraFields and is still a known field. A known field with no rule for the selected target is forwarded as-is (rule 2 is the default). The provider is the authority on what it accepts and answers with its own error when it does not. Add a rule for a target only when that answer is wrong or unhelpful: the provider silently ignores the field, needs another spelling, or returns an error a client cannot act on. Gateway-reserved fields are outside the ladder. Members the gateway itself sets or interprets, such as provider on requests and responses and the usage options it injects into streams, are never forwarded from the client and never taken from the provider.

Scope

The ladder applies to translated paths only: /v1/* and the Anthropic Messages ingress, where the client speaks the gateway’s dialect and the gateway promises to adapt it. Passthrough routes (/p/{provider}/...) are exempt. The client speaks the provider’s dialect and chose the provider by name, so the body reaches the provider byte for byte. The gateway substitutes credentials and records audit and usage, and it does not edit the body. The reverse also holds: a translated endpoint must not be served with passthrough semantics, because every gateway invariant then has to be re-implemented on the shortcut.

Where the rules live

Each provider adapter owns its own field decisions, table-driven and tested per field, as the OpenAI adapter does today. There is no central field-capability registry; ADR-0004’s capability model describes routes and features, not fields. Revisit if three adapters end up with the same table shape. A rule that translates or drops a field should log the decision at debug level so operators can see why an outbound request differs from the inbound one. The adapters that already translate or drop (OpenAI reasoning models, cache-control stripping) do not log today; adding that is a follow-up, not a prerequisite for this ADR. Nothing is added to the response.

Example ladder for /v1/chat/completions

This table records the current decisions so they can be reviewed and updated as providers change. Add a row when an adapter gains a rule; change a row when a provider starts accepting a field. Two request rows deserve a note. Anthropic rejects a request that carries both temperature and top_p on every current model, and xAI rejects metadata on its native /responses endpoint; both are rule 4 because the dropped half does not change what the caller asked for, and rule 5 would lock OpenAI SDKs that fill in both sampling defaults out of Anthropic entirely. The dropped value is logged. metadata is removed only from the outbound provider request, so the gateway can still echo the member back to the client.

Response direction

Rule 1 applies in both directions, so a member the gateway has no rule for comes back to the client exactly as the provider sent it. That is deliberate: it is how a client reads provider-native extras, and it is why vendor members such as Groq’s x_groq and its queue_time/prompt_time usage timings, or DeepSeek’s prompt_cache_hit_tokens/prompt_cache_miss_tokens, are relayed rather than stripped. reasoning is rule 3 rather than rule 1 because the gateway does have a rule for the field: reasoning_content is the spelling every other adapter emits and the one the Responses and Messages translation layers and the dashboard read, so relaying Groq’s spelling would make identical client code work on DeepSeek and silently lose the reasoning on Groq. It is renamed rather than duplicated: two copies of the same chain of thought would double the payload of every reasoning response. On the streaming path the rename is gated on a byte scan for "reasoning", so only the reasoning deltas of a reasoning model are decoded and re-encoded; every other line keeps the upstream bytes. Anything that would force a decode of every chunk needs the same justification, because the verbatim relay of chat SSE is a deliberate hot-path property.

Consequences

  • Clients get one predictable answer per field and provider, and a 400 only when honoring the request is impossible.
  • Adapters gain an explicit place to record their decisions, and reviewers have a rule to check a new mapping against.
  • Response types need the same ExtraFields treatment requests already have, so rule 1 holds in both directions. ChatResponse and Choice gain it in #859; Usage already keeps extras in raw_usage.
  • Some existing behavior may move between rungs once reviewed against the ladder; each move is a behavior change and gets its own PR and changelog entry.
Last modified on September 12, 2026