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.

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):
{
"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):
{
"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); the model
name lives in the URL, models/gemini-3.7-flash:streamGenerateContent?alt=sse:
{
"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).
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.

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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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). 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).
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).
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).
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). 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:
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:
- Keep the raw stop reason. Whatever the gateway normalises
finish_reasonto, store the native value next to it. Gemini alone has twenty-oneFinishReasonvalues, andMALFORMED_FUNCTION_CALL,UNEXPECTED_TOOL_CALLandMISSING_THOUGHT_SIGNATUREeach want different handling fromstop. - Treat reasoning blocks as opaque and mandatory. Round-trip whatever the model
returned —
thinkingblocks,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’smodify_paramspapers over. - Branch on the tool-choice contract, not the word. “At most one tool call” is
disable_parallel_tool_useat Anthropic and does not exist elsewhere;parallel_tool_calls: falseis the closest OpenAI has and is a different scope. - 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. - Read usage in native units.
cache_read_input_tokens,cached_tokens,cachedContentTokenCountandthoughtsTokenCountare billed differently, and a summedtotal_tokenshides 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 on where translation earns its keep, and in Is the vLLM tool-call parser safe? 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.