When the OpenAI-Compatible Endpoint Returns Tool Calls in a Slightly Different Shape
A practical guide to the small serialization differences in tool-call responses returned by different backends behind an OpenAI-compatible endpoint, plus a normalizing wrapper that keeps client parsers from throwing.
Why tool calls differ across compatible backends
An OpenAI-compatible endpoint is a contract, not a single implementation. Behind the same /v1/chat/completions surface you may be talking to Claude, GPT, DeepSeek, Qwen, GLM, or Kimi through one aggregator key. Each backend emits tool calls in its own native format, and the compatibility layer translates it into something close to the OpenAI schema.
"Close" is the operative word. The translation is usually correct at the semantic level but can differ at the serialization level. Your parser sees the same logical call but not always the same byte shape.
The usual suspects
These are the differences that most often trip strict client parsers:
- Argument encoding. Some backends return
argumentsas a JSON string; others can return an already-parsed object, or a string when the schema is empty or the call is truncated. - Null vs. absent fields. A backend may emit
"arguments": null, omitargumentsentirely, or include"id": nullfor a streamed delta where the id arrives in a later chunk. - Name normalization. Tool names may be passed through untouched, lowercased, or sanitized to match the provider's own identifier rules. A name that round-trips fine on one backend may not match your local dispatch table on another.
- Multiple calls in one message. The number of entries in
tool_calls, their order, and whether an empty array is present at all can vary. - Streaming shape. Deltas may attach
indexdifferently, repeat theidon every chunk, or emit partial JSON fragments that are only valid when concatenated. - Empty or partial arguments. When a model decides not to pass any parameters, some backends send
"{}", some send"", and some sendnull.
None of these are bugs in the strict sense. They are translation seams. But a parser that assumes one shape will throw on another.
A normalizing wrapper
The robust fix is not to patch each call site. Put one adapter between the HTTP response and your application logic, and normalize every tool call into a single internal shape before it reaches the rest of your code.
Define an internal record with predictable types:
id: always a string, generated if the backend omitted it.name: always a non-empty string, matched against your dispatch table with an explicit normalization step.arguments: always a parsed object, never a string or null.
Then normalize in a fixed order:
- Collect. Read
toolcallsif present; if the backend instead returns a legacy singlefunctioncall, wrap it in a one-element list. - Default the container. Treat a missing or null
tool_callsas an empty list. - Coerce arguments. If
argumentsis a string, trim it; empty strings and whitespace become{}; otherwise parse as JSON and fail loudly with the raw text attached if parsing fails. If it is already an object, use it directly. - Stabilize the name. Trim whitespace and apply the same case rule to both the incoming name and your dispatch keys, so a case difference cannot cause a silent miss.
- Fill the id. If
idis missing or null, generate one so downstream code never has to branch on null. - Preserve the raw payload. Keep the original object on a
_rawfield for logging and debugging; normalization should be lossless for diagnostics even when it is lossy for shape.
Handling streamed deltas
Streaming needs the same discipline applied incrementally. Accumulate fragments by index, concatenate argument strings before parsing, and only emit a normalized call once the stream signals completion for that call. Do not attempt to parse a partial argument string mid-stream; you will almost always get invalid JSON on the first few chunks.
A small state machine per index, flushed at end-of-stream, avoids the most common streaming parser exceptions.
Test against the shapes, not the backends
Because the differences are structural, record real responses to the same tool-using request across several backends and turn them into fixtures. Exercise the wrapper with:
- string, object, null, empty, and partial
arguments; - calls with and without
id; - zero, one, and several
tool_calls; - legacy
function_callpayloads; - concatenated stream fragments that split a JSON token across chunks.
The endpoint may change backends behind you at any time. A wrapper with fixture-driven tests keeps client code stable across all of them, which is the whole point of using one compatible key to reach many models.
Cost note
This pattern is about reliability, not pricing. For the record, calls through the aggregator are billed at official model price × 1.3, and contributors who add keys are credited official price × 1.1 (or × 1.2 for premium) in USDC. Normalizing responses does not change billing; it just keeps your client from crashing when the backend behind the endpoint differs from the last one you tested.