AI coding

Payload Shapes Across Model Families: An Acceptance Table

Antonio Evans8 min read

A wide, flat table rendered as a technical diagram: rows are model families, columns are payload features (tools, system prompt, JSON mode, images, stop sequences).
Key points

Key points

  1. 1A payload is accepted only if it passes the fixture stored for the target model family, not because it worked on the model you routed away from.
  2. 2Model families differ in where the system prompt lives, how tool schemas are shaped, how JSON mode is enveloped, and how streaming events are named.
  3. 3Build a two-dimensional acceptance table (model families by payload features) and store the exact request, the response status and body, and one minimum assertion per cell.
  4. 4Run that table in CI on every routing change so a cutover fails in the build, not in production traffic.

Why a body that looks valid is not portable

Every major API accepts a messages array and something that looks like a tool definition, so it is tempting to treat them as interchangeable. In practice each provider has its own opinion about what "valid" means, and those opinions disagree with each other and sometimes with the JSON Schema spec. A team running tens of thousands of calls a day across Anthropic, Google and OpenAI reported accumulating provider-specific patches precisely because the surface convergence is only skin deep: Gemini rejects an array whose items is an empty schema, no major provider accepts a top-level $ref, and OpenAI's own Chat Completions and Responses APIs nest the same structured-output request in two different shapes (FutureSearch on provider quirks).

The structural differences go deeper than validation. The system prompt is a top-level field in three of the four big request formats and a message role in OpenAI Chat Completions, the conversation container is messages[] in one, contents[] in another and input plus a previous-response identifier in a third, and Bedrock routes anything model-specific through a separate field rather than the unified inference parameters (cross-provider parameter reference). A router that forwards a body unchanged is therefore not "provider agnostic". It is a body shaped for whichever family the author tested against last.

There is also a failure mode that is not a 400 at all. When a downstream tool renamed a response field and the agent's parser raised on it, the retry policy classified the parse error as transient, retried three times, and the fallback degraded so gracefully that users got a worse answer rather than an error. The retry budget dashboard stayed green the whole time because retries were failing within budget, not exhausting it (the tool schema migration writeup). Shape mismatches can be silent on the request side and loud on the response side. Your fixture matrix has to cover both.

What belongs on each axis

The table has two axes. On one axis, model families (not individual model IDs, unless two models in the same family differ). On the other, payload features that routing can break. The features worth a column are the ones where a body that passes on one family returns a 400, gets silently rewritten, or produces a response your parser cannot read.

Payload featureWhat varies across familiesMinimum assertion per cell
System prompt placementTop-level field vs. role inside the message array vs. an instructions fieldRequest returns 200 and the system instruction is honored (model refuses an out-of-scope request)
Message array shapemessages[] vs. contents[] vs. input with previous response id200, response parses into your internal text type
Content partsOrdering of text and image parts, part type names200, expected modality echoed back
Tool schemaEmpty items, top-level $ref, $defs handling, name casing200, tool call emitted with the expected argument names
Tool choiceForcing a tool call while reasoning is enabled200 or the documented error, never an unhandled 400
Response format enveloperesponse_format.json_schema.schema vs. text.format.schema200, body parses as JSON matching the schema
Streaming eventsEvent type names, delta vs. full snapshot, termination signalStream terminates cleanly and reassembles to the same text as non-streaming
Error envelopeProblem-details style vs. {error: {code, message}} vs. errors[]Your classifier maps the response to a known error class

Google's own function-calling reference documents the declaration and FunctionCallingConfig parameters as its contract, which is a good reminder that the tool schema is an API contract, not a helper type (Gemini function calling reference). OpenAI's function-calling guide likewise states that some model generations require one API surface for tool calling and that the other examples use a specific model with reasoning disabled, which means "tool call works on this family" is itself a conditional claim you have to encode (OpenAI function calling).

Storing each cell as a fixture, not a unit test

Each cell of the table is a fixture with four parts: the exact request body that was sent, the response status, the response body as received, and the minimum assertion that must hold. The first two are facts you record. The third becomes the golden file. The fourth is the decision you are actually testing.

The golden-file pattern is well established: store the expected output on disk, compare actual output against it, and provide a flag to regenerate the file when behavior legitimately changes (writing integration tests for a Go CLI). Table-driven tests make the feature list cheap to extend, which is exactly what you want when a new payload feature or a new family arrives.

The important discipline is what you compare. Pact's consumer-testing guidance is blunt about this: assert only on things that would affect the consumer if they changed, and choose exact or loose matching field by field. A full-schema snapshot breaks on harmless serialization changes, while targeted assertions survive them (Pact consumer tests). For an acceptance table that means asserting the tool name, the required fields, the field types and your own routing invariant, not the entire serialized provider schema.

A second technique worth combining in: record real traffic against each family, then replay it against the candidate after a routing change. Record-replay tooling captures actual requests and responses, auto-generates mocks from dependency responses, and stores cases as version-controlled YAML that can be diffed in review (Keploy record and replay guide). The matrix tells you what to test. Replay tells you whether the new route reproduces the recorded behavior.

A worked example, with numbers

Suppose your router sends a support-triage workload to three families: family A (your current default), family B (a cheaper family you want to shift batch traffic to), and family C (a fallback).

You build the matrix as 3 families by 8 payload features, which is 24 cells. Filling it honestly, you find that family B rejects a top-level $ref in the tool schema, so 2 of the 8 features (tool schema and tool choice) fail on B for the affected tools. Family C accepts the streaming request but emits a different event name for the terminal event, so your reassembler times out on 1 feature.

Without the matrix, the cutover for B and C looks fine on the happy-path smoke test. With it, the failure rate is visible before shipping. A simple way to quantify the decision: if B carries 70 percent of batch traffic and 2 of 8 features are broken for the tool-calling subset, then surface the break against the share of requests that actually use tools, not against total traffic. A defect rate expressed per feature hides how much of the workload it touches; a defect rate per request-hour does not.

The cost side is equally concrete. A full 24-cell run against live endpoints is a small number of calls, so it belongs in the pipeline that gates a routing change rather than in a nightly job. The pattern to copy is the ASP.NET Core test host: a test project references the system under test, spins up a host with test configuration, sends real requests through the client, and asserts on the actual response (ASP.NET Core integration tests). Swap the HTTP client for your router and the assertions become your minimum assertions.

Decision rules for the matrix

Three rules keep the table from becoming unmaintainable.

Rule one: block on broken cells, warn on unverified cells. A cell with a recorded failure for a feature the workload uses must fail the build. A cell nobody has recorded yet must produce a warning, not a pass, because an unverified cell is exactly where a cutover surprise hides. Keploy's comparison of record-replay against manual authoring makes the same point from the maintenance side: manual tests drift, while re-recording refreshes coverage cheaply (record and replay).

Rule two: classify errors by transience, not by exception type. Retry on rate limits, 5xx and timeouts. Do not retry on 400, 401, 404 or a capability refusal, because a schema mismatch will fail identically on every attempt (error handling between agents). This rule changes what your fixture must assert: it must assert the error class your classifier produces, not only that the call succeeded.

Rule three: assert the error envelope, not just the happy path. Providers return different error shapes, and the mature ones follow a consistent structure with a code, a message, and enough context for a machine to act. The advertising protocol spec formalizes this with a two-layer model, where a fatal failure populates both the payload errors[] array and a typed transport signal, and warns that populating only one of the two is the single most common source of interop bugs (AdCP error handling). That is a good default for your own error mapping: one typed code your router can switch on, plus a human-readable message in the body.

{
  "family": "B",
  "feature": "tool_schema",
  "request": { "method": "POST", "path": "/v1/messages", "body": { "tools": [ "..." ], "messages": [ "..." ] } },
  "observed": { "status": 400, "body": { "error": { "code": "invalid_schema", "message": "items must declare a type" } } },
  "assert": { "status_is": 200, "tool_call_arguments": ["order_id"] },
  "verdict": "block"
}

The verdict field is what makes the fixture a decision. Copy the observed response verbatim, then write down the assertion you require. When the provider fixes the bug or your adapter adds a rewrite, you re-record and the diff shows exactly what changed.

Wiring the table into CI and cutover

The integration test sequence maps neatly onto this: arrange the request for the target family, act by sending it through the router, assert against the minimum assertion, and report per cell (ASP.NET Core integration tests). Make the test runner emit one result per (family, feature) pair so a failing cell names both coordinates in the report.

Gate cutover on the table, not on a smoke test. A backward-compatibility program treats schema diff, contract testing, recorded replay and end-to-end journey checks as complementary rather than substitutes, because each catches a different category of break (backward compatibility testing). Your matrix is the contract-testing layer for provider payloads specifically. Replay covers the response-shape drift. End-to-end checks cover the agent workflow on top.

Name an owner for each cell. The schema-drift incident above turned on a boring administrative fact: the agent was a consumer nobody had on the deprecation list, so the migration window protected every human consumer and none of the automated ones (tool schema migration). If a row in your matrix has no named owner, treat its cells as unverified by default.

One practical shortlist before you write any code: pick the five features that cause the most breaks in your own logs (system placement, tool schema, tool choice, response envelope, streaming termination are the usual suspects), record a passing and a failing example for each family, and only then automate.

When the matrix row is a hosted platform

Teams increasingly route through a single gateway rather than calling each family's native endpoint. That removes some shape differences in the request, but it does not remove them from the contract: a translating gateway carries, converts, drops or rejects different fields per route, and thinking blocks, cache breakpoints and tool call IDs are common casualties (what a translating gateway drops). Add a row to the matrix for every route you expose, even if the body is nominally identical.

If you would rather spend engineering effort on the workload than on building and maintaining routing adapters per provider, a single OpenAI-compatible endpoint for text, image, video, audio and embedding models through one prepaid API key is the natural next step: Quanta Inference lets you keep one request shape and test against the families you actually route to.

FAQ

FAQ

Does the matrix replace contract tests against the provider?

No, it complements them. Provider-side contract tests verify the provider honors its documented interface; the matrix verifies your router produces an acceptable body for a specific family and that your parser can read the response. The backward-compatibility literature treats schema diff, contract testing and replay as separate layers that catch different breaks (Virtuoso).

How many cells do I need before a cutover is safe?

You need every cell whose feature the workload uses to be recorded and passing. Unrecorded cells should warn rather than pass, because a missing cell is where the surprise lives. A focused set of read, write, update and delete scenarios is usually enough to cover database integration, so resist the urge to test every permutation (Microsoft).

Should I assert the full provider schema in each fixture?

No. Assert the tool name, the required fields, the field types and the constraints your app depends on, and keep loose matching for fields whose exact serialization you do not read. Full snapshots break on harmless changes and teach the team to regenerate fixtures without reading the diff (Pact).

Where does record-replay fit if I already have fixtures?

Use replay to refresh them. Capture real traffic against a family, store the cases as version-controlled files, re-record when the provider changes, and diff the result in code review. Recording also catches the response-side drift that a hand-written request fixture will miss (Keploy).

What is the most common mistake in a failure cell?

Treating a schema rejection as retryable. Retries with backoff fix rate limits, 5xx and timeouts; they cannot fix a body the family will never accept, and they waste budget while masking the break. Mark those cells as blocking and route the request to a family that accepts the shape (agent error handling).

Sources

Sources

  1. LLM API Differences That Break Your Code: Anthropic vs ...
  2. LLM API Parameter Compatibility Reference - Anthropic, ...
  3. The Tool Schema Migration That Broke Your Agent's ...
  4. Function calling reference | Gemini Enterprise Agent Platform
  5. Function calling | OpenAI API
  6. Writing integration tests for a Go CLI application
  7. Writing Consumer tests
  8. Record and Replay Testing Guide
  9. Integration tests in ASP.NET Core
  10. Error Handling and Retry Logic Between Agents
  11. Error Handling - AdCP
  12. Backward Compatibility Testing for APIs Explained
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles