# One API for OpenAI, Anthropic and Google models: what translates, and the seven things that leak through

> Three ways to call OpenAI, Anthropic and Google through one API — a vendor's OpenAI-compatible endpoint, a self-hosted proxy, a hosted gateway — read against the official docs. All three flatten the request shape. None can flatten tool_choice, reasoning state, cache control, stream recovery or error codes.

- Published: Aug 29, 2026
- Author: Leo Kaka, Engineering
- Tags: gateway, routing, agents
- Canonical: https://pirouter.ai/blog/one-api-openai-anthropic-google

---
Yes, you can call OpenAI, Anthropic and Google through one API, and you have three
ways to do it: point the OpenAI SDK at a vendor's OpenAI-compatible endpoint, put a
self-hosted proxy such as LiteLLM in front of the three native APIs, or pay a hosted
gateway such as OpenRouter to do the same. All three work for the common case, which is
a `messages` array, a `tools` list and a stream of text. **What they flatten is the
request shape. What they cannot flatten is the semantics** — and after reading the three
vendors' API references and the three vendors' own compatibility pages side by side, I
count seven places where a difference is guaranteed to leak into your application code
no matter which route you pick. This post is the field-by-field version of that
sentence, with every claim pinned to the documentation section that makes it.

A scope note first: "OpenAI's API" is itself two protocols now — Chat Completions,
which everyone else copies, and the Responses API, which OpenAI treats as the main path
and a few hosts (Groq, OpenRouter) also expose. Every compatibility page in this post is
written against Chat Completions, so that is the shape I compare against.

## The same tool call, written three ways

Here is one conversation — a system instruction, a user question, one tool call, one
tool result — as each vendor's native API wants it. Field names are copied from the
references; nothing is paraphrased. Model names are the ones that appear in each
vendor's own documentation examples.

OpenAI Chat Completions
([Create chat completion](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)):

```json
{
  "model": "gpt-5.6-sol",
  "messages": [
    {"role": "developer", "content": "You are a terse weather assistant."},
    {"role": "user", "content": "Weather in Chicago?"},
    {"role": "assistant", "content": null, "tool_calls": [
      {"id": "call_1", "type": "function",
       "function": {"name": "get_weather", "arguments": "{\"location\": \"Chicago, IL\"}"}}
    ]},
    {"role": "tool", "tool_call_id": "call_1", "content": "{\"temp_c\": 24}"}
  ],
  "tools": [{"type": "function", "function": {
    "name": "get_weather", "description": "Current weather for a city",
    "parameters": {"type": "object",
      "properties": {"location": {"type": "string"}}, "required": ["location"]}
  }}],
  "tool_choice": "auto",
  "max_completion_tokens": 1024,
  "stream": true
}
```

Anthropic Messages
([Create a Message](https://platform.claude.com/docs/en/api/messages)):

```json
{
  "model": "claude-sonnet-5",
  "max_tokens": 1024,
  "system": "You are a terse weather assistant.",
  "messages": [
    {"role": "user", "content": "Weather in Chicago?"},
    {"role": "assistant", "content": [
      {"type": "tool_use", "id": "toolu_1", "name": "get_weather",
       "input": {"location": "Chicago, IL"}}
    ]},
    {"role": "user", "content": [
      {"type": "tool_result", "tool_use_id": "toolu_1", "content": "{\"temp_c\": 24}"}
    ]}
  ],
  "tools": [{"name": "get_weather", "description": "Current weather for a city",
    "input_schema": {"type": "object",
      "properties": {"location": {"type": "string"}}, "required": ["location"]}}],
  "tool_choice": {"type": "auto"},
  "stream": true
}
```

Gemini `models.generateContent`
([Generating content](https://ai.google.dev/api/generate-content?hl=en)); the model
name lives in the URL, `models/gemini-3.7-flash:streamGenerateContent?alt=sse`:

```json
{
  "systemInstruction": {"parts": [{"text": "You are a terse weather assistant."}]},
  "contents": [
    {"role": "user", "parts": [{"text": "Weather in Chicago?"}]},
    {"role": "model", "parts": [{"functionCall":
      {"name": "get_weather", "args": {"location": "Chicago, IL"}}}]},
    {"role": "user", "parts": [{"functionResponse":
      {"name": "get_weather", "response": {"temp_c": 24}}}]}
  ],
  "tools": [{"functionDeclarations": [{
    "name": "get_weather", "description": "Current weather for a city",
    "parameters": {"type": "object",
      "properties": {"location": {"type": "string"}}, "required": ["location"]}}]}],
  "toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
  "generationConfig": {"maxOutputTokens": 1024}
}
```

None of the shape differences below is exotic; a translator handles all of them in an
afternoon. I list them because the interesting part is what is *not* on this list.

| Dimension | OpenAI Chat Completions | Anthropic Messages | Gemini generateContent |
|---|---|---|---|
| Message container | `messages[]` with `role` + `content` | `messages[]` with `role` + `content` (string or blocks) | `contents[]` with `role` + `parts[]` |
| System prompt | a `developer` or `system` message, anywhere, any number | top-level `system` field, one per request | top-level `systemInstruction`, "Currently, text only" |
| Model's turn role | `assistant` | `assistant` | `model` |
| Tool definition | `tools[].function.parameters` (JSON Schema) | `tools[].input_schema` (JSON Schema) | `tools[].functionDeclarations[].parameters` (OpenAPI 3.03 Schema) or `parametersJsonSchema` |
| Tool call in output | `tool_calls[].function.arguments` — a JSON **string** | `tool_use` block, `input` — an **object** | `functionCall.args` — an **object** |
| Tool result | its own `role: "tool"` message with `tool_call_id` | a `tool_result` block inside a **`user`** message, `tool_use_id` | a `functionResponse` part inside a **`user`** content, matched by `name` (and optional `id`) |
| Output cap | `max_completion_tokens` (`max_tokens` deprecated) | `max_tokens`, **required** | `generationConfig.maxOutputTokens`, optional |
| Streaming | `stream: true`, `chat.completion.chunk` objects, `data: [DONE]` | `stream: true`, named SSE events `message_start` … `message_stop` | `:streamGenerateContent?alt=sse`, one `GenerateContentResponse` per frame |
| Usage object | `usage.prompt_tokens` / `completion_tokens` / `total_tokens` | `usage.input_tokens` / `output_tokens` + cache fields | `usageMetadata.promptTokenCount` / `candidatesTokenCount` / `thoughtsTokenCount` / `totalTokenCount` |

Two details are already load-bearing. Anthropic's `max_tokens` is the only mandatory
output cap of the three, which is why LiteLLM's Anthropic page admits it injects
`max_tokens=4096` when you did not send one
([LiteLLM — Anthropic, Supported OpenAI Parameters](https://docs.litellm.ai/docs/providers/anthropic#supported-openai-parameters)).
And OpenAI is the only one that hands you tool arguments as a JSON string you have to
parse; the other two give you an object. A translator that turns one into the other is
making a decision on your behalf about malformed JSON, and you will want to know which
one it made.

![Data-flow diagram: one client request shape enters a gateway, is translated into OpenAI, Anthropic and Gemini native requests, with callouts marking where system prompts, tool_choice, reasoning state and cache control are dropped or rewritten](/blog/images/one-api-openai-anthropic-google-translation-flow.png "Fig. 1 — The translation layer, wherever you host it, rewrites the shape on the way out and normalises it on the way back. The callouts are the seven leaks discussed below.")

## Route 1: point the OpenAI SDK at the vendor

Two of the three vendors publish an OpenAI-compatible endpoint, and so do most
inference hosts. The pitch is identical — change `base_url`, the key and the model
string — so the differences live in what each page says happens to fields the endpoint
does not understand.

Anthropic's page is the most explicit, and it opens with a disclaimer worth quoting in
full: the layer "is primarily intended to test and compare model capabilities, and is
not considered a long-term or production-ready solution for most use cases"
([OpenAI SDK compatibility](https://platform.claude.com/docs/en/cli-sdks-libraries/libraries/openai-sdk)).
Then it gives a field table. `logprobs`, `metadata`, `response_format`, `prediction`,
`presence_penalty`, `frequency_penalty`, `seed`, `service_tier`, `audio`, `logit_bias`,
`store`, `user`, `modalities`, `top_logprobs` and `reasoning_effort` are all
"Ignored". `temperature` is accepted "Between 0 and 1 (inclusive). Values greater than 1
are capped at 1." `n` "Must be exactly 1". The `strict` flag on a tool definition is
ignored, "which means the tool use JSON is not guaranteed to follow the supplied
schema." System and developer messages are "hoisted and concatenated to the beginning of
the conversation" with a newline between them. And the summary line: "Most unsupported
fields are silently ignored rather than producing errors."

Google publishes two OpenAI-compatibility pages, one for the Gemini Developer API and
one for the Gemini Enterprise Agent Platform, and both land on the same rule. The
platform page's "Supported parameters" table ends with one sentence: "If you pass any
unsupported parameter, it is ignored"
([Using OpenAI libraries with Gemini Enterprise Agent Platform — Supported parameters](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/migrate/openai/overview?hl=en)).
Above it, the table has more texture than Anthropic's. `tool_choice` accepts `none`,
`auto`, `required` — "Corresponds to the mode `ANY` in the `FunctionCallingConfig`" —
and a fourth value, `validated`, that OpenAI does not have. `tools[].parameters` is
read as an OpenAPI schema, "This differs from the OpenAI parameters field, which is
described as a JSON Schema object." `response_format: json_schema` is supported but
"Fully recursive schemas are not supported." `image_url.detail` is per-image in OpenAI
and request-level in Gemini, "and passing multiple detail types in one request will
throw an error." Anything Gemini-specific — `safety_settings`, `cached_content`,
`thinking_config`, `thought_signature` — has to be wrapped in `extra_body.google` or
`extra_content.google` "or they will be ignored." The developer-API page adds the
reasoning mapping: OpenAI's `reasoning_effort` `low` / `medium` / `high` become Gemini
`thinking_level` values on Gemini 3 and `thinking_budget` values of 1,024 / 8,192 /
24,576 on Gemini 2.5, and "Reasoning cannot be turned off for Gemini 2.5 Pro or 3
models"
([Gemini API — OpenAI compatibility, Thinking](https://ai.google.dev/gemini-api/docs/openai?hl=en)).

Groq is the counter-example. Its page says the unsupported fields "will result in a
400 error (yikes) if they are supplied": `logprobs`, `logit_bias`, `top_logprobs`,
`messages[].name`; `N` "must be equal to 1"; and, quietly, "If you set a `temperature`
value of 0, it will be converted to `1e-8`"
([Groq — OpenAI Compatibility, Currently Unsupported OpenAI Features](https://console.groq.com/docs/openai)).

Put the three pages in one table and the routing-relevant fact is not which fields
are missing — it is that two vendors fail silent and one fails loud, on the same
input.

| OpenAI field sent | Anthropic compat | Google compat | Groq compat |
|---|---|---|---|
| `messages[].name` | ✗ Ignored | ⚠ Not listed in the supported table (so ignored by its rule) | ✗ 400 |
| `logprobs` / `top_logprobs` | ✗ Ignored, response `logprobs` "Always empty" | ⚠ Not listed | ✗ 400 |
| `logit_bias` | ✗ Ignored | ⚠ Not listed | ✗ 400 |
| `n` > 1 | ✗ "Must be exactly 1" | ✓ `n` listed as supported | ✗ "must be equal to 1" |
| `response_format` | ✗ Ignored | ✓ `json_object` / `json_schema` (no recursive schemas) | ⚠ Not addressed on the compatibility page |
| `tools[].function.strict` | ✗ Ignored | ⚠ Not listed | ⚠ Not addressed |
| `tool_choice: "required"` | ⚠ Not listed in the field table | ⚠ Rewritten to `ANY` | ⚠ Not addressed |
| `temperature` | ⚠ Capped at 1 | ✓ Passed | ⚠ 0 becomes `1e-8` |
| `reasoning_effort` | ✗ Ignored (use `extra_body.thinking`) | ⚠ Rewritten to `thinking_level` / `thinking_budget`; cannot be `none` on 2.5 Pro or 3 | ⚠ Not addressed |
| Multiple `system` / `developer` messages | ⚠ Concatenated with `\n` into one | ⚠ Not addressed | ⚠ Not addressed |
| Prompt caching controls | ✗ "not supported" via this layer | ⚠ `cached_content` only via `extra_body.google` | ⚠ Not addressed |

"Not addressed" means the compatibility page is silent; I am not inferring behaviour
the vendor has not written down.

## Route 2: a self-hosted proxy

A proxy such as LiteLLM speaks Chat Completions to your code and each native protocol
upstream. The parts of its documentation that matter here are the ones about what it
does when the two disagree.

The default is loud. "By default, LiteLLM raises an exception if you send a parameter
to a model that doesn't support it." Set `drop_params=True` and it "will drop the
unsupported parameter instead of raising an exception"; `additional_drop_params` lets
you name specific fields, down to nested paths like `tools[*].input_examples`, and
`allowed_openai_params` does the reverse, forcing a field through "as is to the model"
([LiteLLM — Drop Unsupported Params](https://docs.litellm.ai/docs/completion/drop_params)).
The catch is in the input-params page: this "ONLY DROPS UNSUPPORTED OPENAI PARAMS.
LiteLLM assumes any non-openai param is provider specific and passes it in as a kwarg
in the request body" ([LiteLLM — Input Params](https://docs.litellm.ai/docs/completion/input)).
A typo in a field name is therefore not dropped but forwarded, and what happens next is
the upstream vendor's rule from the previous section.

The translations that are not one-to-one are documented per provider. For
Anthropic, `reasoning_effort` becomes a `thinking`
block — `low` is `budget_tokens: 1024`, `medium` 2048, `high` 4096 on older models, and
on Claude 4.6 and 4.7 it becomes `thinking: {"type": "adaptive"}` plus
`output_config.effort` instead
([LiteLLM — Anthropic, Usage - Thinking](https://docs.litellm.ai/docs/providers/anthropic#usage---thinking--reasoning_content)).
For Gemini 3 the same field becomes `thinking_level`, and `none` maps to `minimal`
or `low` because you "Cannot fully disable thinking in Gemini 3"
([LiteLLM — Gemini, Usage - Thinking](https://docs.litellm.ai/docs/providers/gemini#usage---thinking--reasoning_content)).
One input field, three output meanings, only one of which is "off".

The sharpest paragraph in the whole LiteLLM corpus is on the reasoning page, and it is
about tool calls. "When using Anthropic models with `thinking` enabled and tool calling,
you must include `thinking_blocks` from the previous assistant response when sending
tool results back. Failure to do so will result in a `400 Bad Request` error." The
reason is structural: "OpenAI's Chat Completions spec has no field for
`thinking_blocks`", so "OpenAI-compatible clients (LibreChat, Open WebUI, Vercel AI SDK,
etc.) ignore the `thinking_blocks` field in responses" and the next turn arrives at
Anthropic with the reasoning stripped
([LiteLLM — 'Thinking' / 'Reasoning Content', OpenAI-Compatible API Limitations](https://docs.litellm.ai/docs/reasoning_content#important-openai-compatible-api-limitations)).
LiteLLM's workaround, `modify_params = True`, drops the `thinking` parameter for that
turn. That is a proxy silently turning off a feature to avoid a 400, which is the right
call for uptime and the wrong call for anyone who believed thinking was on.

## Route 3: a hosted gateway

OpenRouter's reference states the promise plainly: it "normalizes the schema across
models and providers so you only need to learn one." The same page also says how the
normalisation treats what it does not know: "If the chosen model doesn't support a
request parameter (such as `logit_bias` in non-OpenAI models, or `top_k` for OpenAI),
then the parameter is ignored. The rest are forwarded to the underlying model API"
([OpenRouter — API Reference, Non-standard parameters](https://openrouter.ai/docs/api-reference/overview)).
For tools specifically, the request schema comment reads: "Will be passed down as-is
for providers implementing OpenAI's interface. For providers with custom interfaces, we
transform and map the properties. Otherwise, we transform the tools into a YAML
template."

Three design choices on that page are the ones a self-hosted proxy usually lacks, and
they are worth copying whatever you run. First, `require_parameters: true` in the
`provider` object changes silent-ignore into not-routed: "providers that don't support
all the LLM parameters specified in your request can still receive the request, but
will ignore unknown parameters. When you set `require_parameters` to `true`, the
request won't even be routed to that provider"
([OpenRouter — Provider Routing, Requiring Providers to Support All Parameters](https://openrouter.ai/docs/guides/routing/provider-selection)).
Second, the response keeps both truths about why generation stopped: `finish_reason`
is normalised to `tool_calls`, `stop`, `length`, `content_filter` or `error`, and "The
raw finish_reason string returned by the model is available via the
`native_finish_reason` property." Third, absent sampling parameters are omitted
upstream, not defaulted: "OpenRouter omits it upstream rather than substituting a
hardcoded value, so the provider applies its own default"
([OpenRouter — Parameters](https://openrouter.ai/docs/api-reference/parameters)).

And then a tell. Alongside `/chat/completions` and `/responses`, OpenRouter runs a
`POST /api/v1/messages` endpoint that "Creates a message using the Anthropic Messages
API format. Supports text, images, PDFs, tools, and extended thinking"
([OpenRouter — Anthropic Messages, Create a message](https://openrouter.ai/docs/api/api-reference/anthropic-messages/create-a-message)).
A gateway whose thesis is "learn one schema" ships a second schema. It does so for
the same reason LiteLLM has a `thinking_blocks` page: the Anthropic protocol carries
state that Chat Completions has no field for, and the cleanest fix is to stop
translating.

## Seven things that leak through, whichever route you take

Here is the list I promised. ✓ marks a difference a translator can hide completely, ⚠
one it can approximate with a documented loss, ✗ one that reaches your code regardless.

| # | Difference | Can a translator hide it? |
|---|---|---|
| 1 | Where the system prompt lives, and how many there can be | ⚠ Concatenation is lossy for role-ordered prompts |
| 2 | Tool schema dialect and `strict` | ⚠ OpenAPI vs JSON Schema; `strict` has no Anthropic equivalent in this layer |
| 3 | The `tool_choice` vocabulary | ✗ `required` / `any` / `ANY` / `VALIDATED` / `disable_parallel_tool_use` do not map one-to-one |
| 4 | Reasoning state across turns | ✗ Signatures, thinking blocks and `previous_response_id` are three different state models |
| 5 | Cache control | ✗ Three cache APIs, three sets of usage fields |
| 6 | Stream shape and interruption | ✗ Only one protocol documents resuming a stream |
| 7 | Error codes and rate-limit headers | ✗ 529, `RESOURCE_EXHAUSTED` and four kinds of 429 are not the same signal |

**1. System prompt.** OpenAI accepts `developer` and `system` messages at any
position in the array — Anthropic's compatibility page describes them as prompts that
"can be put throughout a chat conversation via OpenAI" — and the Responses API states
an instruction hierarchy: `developer` and `system` instructions "take precedence over
instructions given with the `user` role"
([Create a model response, input](https://developers.openai.com/api/reference/resources/responses/methods/create)).
Anthropic has one top-level `system` field; Gemini one top-level `systemInstruction`,
"Currently, text only." A translator collapses N messages into one newline-joined
string — Anthropic's own layer says so — and any prompt that relied on a system message
*between* two user turns has changed meaning without an error.

**2. Tool schema.** Gemini's `FunctionDeclaration.parameters` is "defined by the
OpenAPI 3.03 specification"; the JSON Schema form is a separate field,
`parametersJsonSchema`, and the two are "mutually exclusive." OpenAI and Anthropic take
JSON Schema. Google's own compatibility page flags the gap — the OpenAI field "is
described as a JSON Schema object" — and points to its OpenAPI guide for the keyword
differences rather than listing them. On top of that, OpenAI's `strict: true` is
a promise of schema conformance that Anthropic's compatibility layer ignores outright,
so the same request gets guaranteed-valid JSON from one vendor and best-effort JSON
from another.

**3. `tool_choice`.** OpenAI: `none`, `auto`, `required`, a named function, or an
`allowed_tools` object with its own `auto` / `required` mode. Anthropic: `auto`, `any`,
`tool`, `none`, each with an optional `disable_parallel_tool_use` that under `any` means
the model "will output exactly one tool use." Gemini: `AUTO`, `ANY`, `NONE`,
`VALIDATED`, plus `allowedFunctionNames` which "should only be set when the Mode is ANY
or VALIDATED." `required` → `any` → `ANY` is the obvious mapping and it holds. Nothing
maps to `VALIDATED`, which is why Google's compatibility layer added a `validated`
value that OpenAI's own `tool_choice` enum does not have. Nothing in
OpenAI or Gemini maps to `disable_parallel_tool_use`; OpenAI's `parallel_tool_calls`
is a request-level flag with a different scope. If your agent loop depends on "exactly
one tool call per turn", you are writing that per provider.

**4. Reasoning state.** This is the one that produces 400s in production. Anthropic
returns `thinking` blocks with a `signature` "used to verify the integrity of the
thinking block", and the next turn must carry them back. Gemini attaches
`thoughtSignature` to parts — "signatures are metadata that can be attached to any
part, such as living inside `functionCall` parts" — and lists `MISSING_THOUGHT_SIGNATURE`
as a `FinishReason`: "Request has at least one thought signature missing"
([Gemini API — FinishReason](https://ai.google.dev/api/generate-content?hl=en#FinishReason)).
OpenAI's Responses API keeps the state server-side behind `previous_response_id` and
`store`, and its `reasoning.context` decides whether reasoning from "all turns" or only
the "current turn" is visible to the model. Chat Completions has no field for any of
this, which is the exact gap LiteLLM's `thinking_blocks` warning and OpenRouter's
`reasoning_details` field both exist to patch. OpenRouter is explicit that when you
echo those blocks back "the entire sequence of consecutive reasoning blocks must match
the outputs generated by the model during the original request"
([OpenRouter — Reasoning Tokens, Preserving Reasoning](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens)).
A translator can carry opaque blobs. It cannot make a stateless protocol stateful.

**5. Cache control.** Anthropic caches "`tools`, `system`, and `messages` (in that
order) up to and including the block designated with `cache_control`", with a 5-minute
default and a `1h` option, and reports `cache_creation_input_tokens` and
`cache_read_input_tokens`
([Prompt caching — How prompt caching works](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)).
OpenAI caches automatically, keys it with `prompt_cache_key`, and on `gpt-5.6` and
later adds `prompt_cache_options` with explicit `prompt_cache_breakpoint` markers whose
`ttl` "defaults to `30m`, which is currently the only supported value." Gemini has a
separate `cachedContents/{cachedContent}` resource you create first and then reference
by name, reported as `cachedContentTokenCount`. Anthropic's OpenAI-compatible layer
says caching "is not supported" through it at all. Three different objects, three
different lifetimes, three different usage fields — and the usage fields are what your
billing reconciliation reads. OpenRouter's answer is `prompt_tokens_details.cached_tokens`
plus `cache_write_tokens`, which is a reasonable union; it is still a union, not the
native numbers.

**6. Streams.** OpenAI Chat Completions streams `chat.completion.chunk` objects and
puts usage in one final chunk if you ask for it, with a warning that reads like an
incident report: "If the stream is interrupted, you may not receive the final usage
chunk which contains the total token usage for the request." Anthropic streams named
events — `message_start`, `content_block_start`, `content_block_delta`,
`content_block_stop`, `message_delta`, `message_stop` — and can send an `error` event
after the HTTP 200 is already on the wire: "an error can occur after the API returns a
200 response. In that case, error handling doesn't follow these standard mechanisms"
([Claude API errors](https://platform.claude.com/docs/en/api/errors)). Tool arguments
arrive as `input_json_delta` events carrying `partial_json` strings that you accumulate
and parse at `content_block_stop`. Gemini streams whole `GenerateContentResponse`
objects with no event names. Only OpenAI's Responses API documents *resuming*: every
event has a `sequence_number`, and the retrieve endpoint takes `starting_after`, "The
sequence number of the event after which to start streaming"
([Get a model response](https://developers.openai.com/api/reference/resources/responses/methods/retrieve)).
A gateway can normalise the frame format. It cannot give Anthropic or Gemini a
resume cursor they do not have, or recover a usage chunk the upstream never sent.

**7. Errors and rate limits.** Anthropic has a status code the others do not: `529
overloaded_error`, which also appears mid-stream. It returns a 400 when a spend limit
you set is reached, and warns that "A tier spend-cap 429 has no `retry-after` header
and keeps failing until access resumes"
([Claude API errors — HTTP errors](https://platform.claude.com/docs/en/api/errors)).
OpenAI's 429 carries four distinct `error.code` values — `credit_balance_exhausted`,
`organization_spend_limit_exceeded`, `project_spend_limit_exceeded`,
`organization_usage_limit_exceeded` — and the guide says outright that "Retrying
billing, spend, or quota errors won't restore API access"
([OpenAI — Error codes](https://developers.openai.com/api/docs/guides/error-codes)).
Gemini's troubleshooting page talks in status names, `429 RESOURCE_EXHAUSTED` and `503
UNAVAILABLE`, and its error reference separates `rate_limit_exceeded` from
`quota_exceeded` ("You have exceeded your daily quota")
([Gemini API errors](https://ai.google.dev/gemini-api/docs/api-errors?hl=en)). Headers
follow suit: OpenAI's `x-ratelimit-reset-tokens` is a duration like `6m0s`, Anthropic's
`anthropic-ratelimit-tokens-reset` is an RFC 3339 timestamp, and Gemini's rate-limit
page does not document response headers at all. We wrote up the consequence for
failover policies in [Automatic failover in LiteLLM, Portkey and OpenRouter](/blog/failover-signals):
a 429 that a retry will fix and a 429 that only a payment will fix look identical to a
retry loop unless somebody preserves the vendor's code. Every compatibility layer in
this post reshapes the error envelope, and Anthropic's says so: "the detailed error
messages will not be equivalent."

## What to keep in your own code

None of the above is an argument against a single endpoint. It is an argument for
knowing which five lines stay provider-aware behind it. In order of how often they
bite:

1. **Keep the raw stop reason.** Whatever the gateway normalises `finish_reason` to,
   store the native value next to it. Gemini alone has twenty-one `FinishReason`
   values, and `MALFORMED_FUNCTION_CALL`, `UNEXPECTED_TOOL_CALL` and
   `MISSING_THOUGHT_SIGNATURE` each want different handling from `stop`.
2. **Treat reasoning blocks as opaque and mandatory.** Round-trip whatever the model
   returned — `thinking` blocks, `thoughtSignature`, `reasoning_details` — byte for
   byte. If your framework drops fields it does not recognise, that is the first thing
   to fix, and it is the one LiteLLM's `modify_params` papers over.
3. **Branch on the tool-choice contract, not the word.** "At most one tool call" is
   `disable_parallel_tool_use` at Anthropic and does not exist elsewhere;
   `parallel_tool_calls: false` is the closest OpenAI has and is a different scope.
4. **Decide once whether an unsupported parameter is an error.** The vendors disagree
   — Anthropic and Google ignore, Groq returns 400, LiteLLM raises unless told not to,
   OpenRouter ignores unless `require_parameters`. Pick one policy and enforce it at
   the edge, because you will not get a consistent one from upstream.
5. **Read usage in native units.** `cache_read_input_tokens`, `cached_tokens`,
   `cachedContentTokenCount` and `thoughtsTokenCount` are billed differently, and a
   summed `total_tokens` hides the line items you actually pay for.

The data plane we are building, cobb-gateway, is designed around the same conclusion:
three native protocol adapters rather than one translation table, with every field a
translation would have to drop surfaced as an explicit error instead of a silent
omission. That describes the design, not a shipped behaviour you can call today. The
related arguments are in [Routers aren't dead — per-request routing is](/blog/routers-arent-dead)
on where translation earns its keep, and in
[Is the vLLM tool-call parser safe?](/blog/tool-call-parser-attack-surface) on why the
tool-call parsing step in particular deserves a hostile reading.

## The shape converges; the contract does not

Every vendor in this post now speaks Chat Completions well enough for a demo, and
the documentation is honest about the cost: Anthropic calls its layer a testing tool,
Google ignores what it does not support, Groq 400s it, LiteLLM makes you choose between
raising and dropping, OpenRouter gives you a flag to refuse providers that would drop.
The request shape has converged. The seven contracts underneath it — where instructions
live, how a tool is forced, what state a turn carries, what a cache costs, how a stream
dies, what a 429 means — have not. Use one API. Just keep the seven contracts somewhere
your code can still see them.

---

## Sources

### developers.openai.com

- [Create chat completion](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
- [Create a model response, input](https://developers.openai.com/api/reference/resources/responses/methods/create)
- [Get a model response](https://developers.openai.com/api/reference/resources/responses/methods/retrieve)
- [OpenAI — Error codes](https://developers.openai.com/api/docs/guides/error-codes)

### platform.claude.com

- [Create a Message](https://platform.claude.com/docs/en/api/messages)
- [OpenAI SDK compatibility](https://platform.claude.com/docs/en/cli-sdks-libraries/libraries/openai-sdk)
- [Prompt caching — How prompt caching works](https://platform.claude.com/docs/en/build-with-claude/prompt-caching)
- [Claude API errors](https://platform.claude.com/docs/en/api/errors)

### ai.google.dev

- [Generating content](https://ai.google.dev/api/generate-content?hl=en)
- [Gemini API — OpenAI compatibility, Thinking](https://ai.google.dev/gemini-api/docs/openai?hl=en)
- [Gemini API — FinishReason](https://ai.google.dev/api/generate-content?hl=en#FinishReason)
- [Gemini API errors](https://ai.google.dev/gemini-api/docs/api-errors?hl=en)

### docs.litellm.ai

- [LiteLLM — Anthropic, Supported OpenAI Parameters](https://docs.litellm.ai/docs/providers/anthropic#supported-openai-parameters)
- [LiteLLM — Drop Unsupported Params](https://docs.litellm.ai/docs/completion/drop_params)
- [LiteLLM — Input Params](https://docs.litellm.ai/docs/completion/input)
- [LiteLLM — Anthropic, Usage - Thinking](https://docs.litellm.ai/docs/providers/anthropic#usage---thinking--reasoning_content)
- [LiteLLM — Gemini, Usage - Thinking](https://docs.litellm.ai/docs/providers/gemini#usage---thinking--reasoning_content)
- [LiteLLM — 'Thinking' / 'Reasoning Content', OpenAI-Compatible API Limitations](https://docs.litellm.ai/docs/reasoning_content#important-openai-compatible-api-limitations)

### docs.cloud.google.com

- [Using OpenAI libraries with Gemini Enterprise Agent Platform — Supported…](https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/migrate/openai/overview?hl=en)

### console.groq.com

- [Groq — OpenAI Compatibility, Currently Unsupported OpenAI Features](https://console.groq.com/docs/openai)

### openrouter.ai

- [OpenRouter — API Reference, Non-standard parameters](https://openrouter.ai/docs/api-reference/overview)
- [OpenRouter — Provider Routing, Requiring Providers to Support All Parameters](https://openrouter.ai/docs/guides/routing/provider-selection)
- [OpenRouter — Parameters](https://openrouter.ai/docs/api-reference/parameters)
- [OpenRouter — Anthropic Messages, Create a message](https://openrouter.ai/docs/api/api-reference/anthropic-messages/create-a-message)
- [OpenRouter — Reasoning Tokens, Preserving Reasoning](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens)
