Blog

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.

Leo Kaka18 min read
Matrix of seven protocol dimensions across OpenAI Chat Completions, Anthropic Messages and Gemini generateContent, with the field name each vendor uses

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.

DimensionOpenAI Chat CompletionsAnthropic MessagesGemini generateContent
Message containermessages[] with role + contentmessages[] with role + content (string or blocks)contents[] with role + parts[]
System prompta developer or system message, anywhere, any numbertop-level system field, one per requesttop-level systemInstruction, “Currently, text only”
Model’s turn roleassistantassistantmodel
Tool definitiontools[].function.parameters (JSON Schema)tools[].input_schema (JSON Schema)tools[].functionDeclarations[].parameters (OpenAPI 3.03 Schema) or parametersJsonSchema
Tool call in outputtool_calls[].function.arguments — a JSON stringtool_use block, input — an objectfunctionCall.args — an object
Tool resultits own role: "tool" message with tool_call_ida tool_result block inside a user message, tool_use_ida functionResponse part inside a user content, matched by name (and optional id)
Output capmax_completion_tokens (max_tokens deprecated)max_tokens, requiredgenerationConfig.maxOutputTokens, optional
Streamingstream: true, chat.completion.chunk objects, data: [DONE]stream: true, named SSE events message_startmessage_stop:streamGenerateContent?alt=sse, one GenerateContentResponse per frame
Usage objectusage.prompt_tokens / completion_tokens / total_tokensusage.input_tokens / output_tokens + cache fieldsusageMetadata.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.

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
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). 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 sentAnthropic compatGoogle compatGroq compat
messages[].nameIgnoredNot listed in the supported table (so ignored by its rule)400
logprobs / top_logprobsIgnored, response logprobs “Always empty”Not listed400
logit_biasIgnoredNot listed400
n > 1“Must be exactly 1”n listed as supported“must be equal to 1”
response_formatIgnoredjson_object / json_schema (no recursive schemas)Not addressed on the compatibility page
tools[].function.strictIgnoredNot listedNot addressed
tool_choice: "required"Not listed in the field tableRewritten to ANYNot addressed
temperatureCapped at 1Passed0 becomes 1e-8
reasoning_effortIgnored (use extra_body.thinking)Rewritten to thinking_level / thinking_budget; cannot be none on 2.5 Pro or 3Not addressed
Multiple system / developer messagesConcatenated with \n into oneNot addressedNot addressed
Prompt caching controls“not supported” via this layercached_content only via extra_body.googleNot 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.

#DifferenceCan a translator hide it?
1Where the system prompt lives, and how many there can beConcatenation is lossy for role-ordered prompts
2Tool schema dialect and strictOpenAPI vs JSON Schema; strict has no Anthropic equivalent in this layer
3The tool_choice vocabularyrequired / any / ANY / VALIDATED / disable_parallel_tool_use do not map one-to-one
4Reasoning state across turnsSignatures, thinking blocks and previous_response_id are three different state models
5Cache controlThree cache APIs, three sets of usage fields
6Stream shape and interruptionOnly one protocol documents resuming a stream
7Error codes and rate-limit headers529, 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.” requiredanyANY 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:

  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 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.