AI agents

Mocking a Model Endpoint Without Losing Fidelity

Antonio Evans10 min read

A split scene: on the left, a developer's test suite runs against a small local mock server drawn as a tidy card index; on the right, a real provider endpoint is drawn as a large machine.
Key points

Key points

  1. 1Hand-written fixtures drift because they encode assumptions that nothing checks against the provider's schema or a recorded exchange.
  2. 2Generate mocks from the provider's published schema plus a small set of recorded exchanges, then pin the mock to a named schema version.
  3. 3Normalize volatile fields (request IDs, timestamps, variable token counts) at record time so diffs reflect real behavior changes.
  4. 4Run a scheduled contract test that replays the recorded exchanges against a real sandbox and fails on any change in status codes, error bodies, streaming frame order or required fields.

Where mocks actually diverge

Most teams discover drift the same way: an incident. Production code that worked fine against a mock starts throwing parse errors once it talks to the real provider. The mock was green the whole time. The gap between the mock and the provider is rarely a single bug; it is a classification of gaps, and knowing the classes tells you which checks to write.

The first class is transport-level. Status codes are the obvious case: a mock that returns 200 for everything teaches the client that malformed requests succeed. A permissive catch-all mock that answers 200 to any path is the single most damaging pattern, because it silently removes error handling from the test surface stop mock APIs from drifting. Error bodies matter just as much. If the provider returns a structured error object with a type, code and message, and your mock returns a bare string, your client's error parser is never exercised.

The second class is streaming. When a provider sets stream=true, the response arrives as server-sent events, and the client's parser is sensitive to frame order, the exact event names and the terminal frame. A mock that sends the concatenated text as one chunk, or that omits the final [DONE]-style frame, passes tests and fails in production. This is not hypothetical: in LiteLLM, rate-limit headers are populated by a post-call success hook, and for stream=true requests the SSE headers are flushed before that hook runs, so the client never sees the x-ratelimit-* keys at all the LiteLLM issue. A mock built by capturing a non-streaming response would never reveal that difference.

The third class is metadata: token accounting and rate-limit headers. Usage numbers appear in the response body, and limit headers appear in the envelope. A mock that hardcodes input_tokens: 12 is fine as a fixture, but if your application parses usage to compute cost or budget, the mock must carry the same field names and nesting as the provider, or the budget code is untested.

The fourth class is schema drift over time. Providers retire models and change defaults on a published schedule; OpenAI, for instance, publishes tiered notice periods, at least six months for generally available models and as little as two weeks for preview models OpenAI deprecations, and a cross-vendor calendar collects the same for Anthropic, Google and Bedrock the lifecycle calendar. The deeper problem is that pinned IDs are not always what they look like: Anthropic states that dateless IDs for the 4.6 generation and later are fixed snapshots, not evergreen pointers, and that serving infrastructure changes can produce minor behavioral differences even when weights do not change Claude model IDs and versioning. A fixture pinned to "whatever the provider does today" has no anchor.

Divergence classSymptom you will seeWhat the check must assert
Status codesClient never handles 429 or 5xxExact status per scenario, including error paths
Error bodyCrash or generic message on failuresSame error type, code and required fields
Streaming framesTruncated or garbled output under streamFrame order, event names, terminal frame
Token accountingCost or budget code silently wrongSame field names and nesting as provider
Rate-limit headersQuota tracking shows nothingHeaders present per endpoint and per mode
Schema versionField renamed or removed upstreamMock built from a pinned spec artifact

Derive shapes from specs and captures, not memory

The fix begins before anyone writes a fixture. A mock should be an executable view of a versioned contract, and that contract should be verified against the real provider in CI; a contract without provider verification can drift, and provider verification without a consumer-owned expectation can miss what consumers actually use stop mock APIs from drifting.

That gives you two sources of truth, and you need both. The provider's published schema (an OpenAPI document, or an equivalent machine-readable description) tells you which fields are required, their types and their enums. Recorded traffic tells you what the provider actually does in a given scenario, including the parts a schema cannot express. Schemas are not contracts: they validate structure at a point in time but cannot verify that a running service honors its own spec, and they cannot state preconditions, optionality semantics or ordering guarantees schemas can be contracts. Contract testing as a discipline exists precisely to check endpoints, request and response schemas, status codes, headers, auth and data types on every commit what API contract testing is.

A practical recipe:

  1. Pin the spec. Download the provider's schema artifact once and commit it with a version label. The mock server reads that file, not a hand-maintained JSON file.
  2. Record a small set of exchanges. Ten to twenty representative calls are enough to start: a happy path, a long response, a tool call, an embedding, a 401, a 429 and one 5xx. Capture both request and response, including headers.
  3. Normalize at record time. Replace request IDs, trace IDs and timestamps with typed placeholders. Keep token counts in the captured file (they are load-bearing for cost code) but have the recorder mark them as matching by type rather than by literal value.
  4. Validate scenarios against the spec at mock startup. A representative example is not automatically a valid instance merely because it sits beside a schema; fail fast on unknown operations and unexpected request bodies stop mock APIs from drifting.

The benefit of this approach is proportion. If you write fixtures by hand, you write the cases you can imagine. Traffic-driven capture finds the cases you cannot: recording live API requests and responses (read-only) and replaying them in staging is documented to reach roughly 95 percent coverage of production behavior against about 40 percent for simplified synthetic data Keploy environment replay. Synthetic load misses real-world patterns, integration complexity and data-handling edge cases precisely because it lacks real payloads Speedscale on traffic replay, and a replay pipeline can capture production traffic without adding latency and anonymize it before storing traffic replay in Kubernetes.

A contract test that fails on drift

The mock is only half the system. The other half is a test that runs on a schedule (nightly is typical), sends the recorded exchanges to a real sandbox, and compares provider behavior to the fixture. When they disagree, it fails and tells you which field or frame changed.

Keep the comparison narrow and explicit. Checking everything produces noise from volatile fields; checking nothing produces false confidence.

ALLOWED_VOLATILE = {"id", "created", "system_fingerprint"}

def diff(recorded, live, path=""):
    problems = []
    for key in recorded:
        if key in ALLOWED_VOLATILE:
            continue
        if key not in live:
            problems.append(f"missing field: {path}{key}")
        elif type(recorded[key]) is not type(live[key]):
            problems.append(f"type change: {path}{key}")
        elif isinstance(recorded[key], dict):
            problems.extend(diff(recorded[key], live[key], f"{path}{key}."))
    return problems

Run it against the happy path and against every error case. An error-path test asserts the exact status code, the error type string and the required fields in the error body. Those are the cases most likely to be skipped and most likely to break a client: a timeout is a transport failure, a 429 needs a retry-after header, and a 401 needs a distinguishable error code so the client can refresh credentials rather than retry.

The error-path coverage is not optional polish. Testing agents is hard because they are non-deterministic and can reach a correct answer by an unsafe path, which is why evaluators look at trajectories and tool calls, not just final output Splunk on agent testing. A mock that never emits a malformed response or a rate-limit rejection leaves that entire surface untested. Teams that adopt contract testing in the pull request get feedback in milliseconds per test because the check touches only the interface layer what API contract testing is.

Pin the mock to a schema version, and plan for model retirement

The single cheapest decision is to make the schema version part of the mock's identity. Name the artifact, commit it, and have the mock refuse to start if the artifact is missing. Then a provider change becomes a visible diff in a pull request rather than a surprise in production.

The second decision is what to do when a model ID is retired. Providers publish deprecation dates and recommended replacements, and the notice window varies by tier: generally available models get the longest window, specialized variants shorter, preview models the shortest OpenAI deprecations. Snapshot behavior matters here too. A pinned dated snapshot is a promise that the underlying model stays constant for the life of that ID, while a convenience alias may resolve to the newest dated snapshot Claude model IDs and versioning. Multi-platform models diverge further, since each partner platform sets its own retirement schedule; the same model can have different dates on different platforms the lifecycle calendar.

The decision rule: if your mock embeds a model ID, treat that ID as a dependency with an expiry date. Record the deprecation date next to the artifact, and when the retirement date approaches, create a new fixture set for the replacement model rather than editing the old one. Editing in place destroys the ability to diff old and new behavior, which is exactly what you need during a migration.

A worked example makes the cost concrete. Suppose the recorded set has 18 exchanges: 11 happy path, 3 streaming, 2 tool call and 2 error, and the nightly contract run costs 18 live calls. At a few hundred input and output tokens per call, that is a few thousand tokens per night, a trivial spend at any current per-million rate. The alternative, a manual check before each release, is one engineer-hour and catches less. The automated check wins on both axes, which is the decision rule: automate the contract check when the live-call cost per run is below the cost of one manual verification.

When your fixtures need to cover several providers at once, routing them through one OpenAI-compatible key reduces the number of endpoints and error body formats your mock layer has to model. Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image, video, audio and embedding models behind one API, which also means one schema surface and one set of error shapes to verify.

Checklist before you trust a mock

Use this before the mock goes into CI.

CheckPass condition
Spec artifact presentMock starts only from a committed, versioned schema file
Scenario validityEvery fixture instance validates against the spec at startup
Unknown routesMock fails, does not answer 200
Error paths401, 429 and 5xx fixtures exist with exact status and body
StreamingFrame order, event names and terminal frame asserted
Volatile fieldsIDs and timestamps normalized; token fields present
Metadata headersRate-limit headers asserted per endpoint and stream mode
Contract runNightly replay against sandbox diffs cleanly
Model IDDeprecation date recorded next to the fixture set

Two operational notes. First, keep the recorder separate from the mock server, so a change to the mock cannot change the recordings. Second, store recordings as plain files in the repository, not in a database. A fixture is data, and data in a repository shows up in code review, which is where drift gets caught.

Where agents make this harder

If the consumer of the mock is an agent rather than a plain client, drift shows up as wrong tool selection rather than a parse error. The agent reads tool documentation, and that documentation is often under-specified, over-specified or misaligned with the implementation, which is exactly the failure mode automated tool testing targets: ToolFuzz generates natural queries that cause runtime errors or incorrect agent responses and reports that many community and production tools are erroneous, with fixes raising GitHub task accuracy from 22.9 percent to 35.4 percent and file management from 29 percent to 39 percent ToolFuzz on arXiv and the ICLR submission.

The practical consequence for mocks is that the schema is not enough for agent consumers. The mock must also exercise the documented contract of each tool: which arguments are required, which values are valid, and what the error looks like when the argument is wrong. Mock at the right level deliberately. Mocking a tool function tests agent reasoning and parameter passing without testing the tool implementation; mocking the API boundary tests the tool's HTTP calls; dependency injection tests the whole path but requires designing for it up front mocking external APIs in agent tests. Pick one level per test and be explicit about what it covers and what it does not.

The parallel with general API testing is direct: the well-known advice for external integrations is to mock the boundary, unit test request construction and response parsing, and keep a small number of real integration checks for side effects integration tests for external APIs. Mock APIs exist precisely so you can test the AI-specific parts, such as whether the model decides to call a tool, without the noise of a live system, and so you can simulate timeouts and rate limiting on demand WireMock on testing agents. Start with deterministic tooling you already run to establish a baseline before layering on agent-specific evaluation Tricentis on deterministic tooling. A mock generated from the provider's own framing, including streaming SSE, removes one whole class of false failures MockServer LLM mocking.

If building and maintaining that verification layer is where your team stalls, a specialist can take it on end to end. Quanta (https://quantaintelligence.ai/) picks up a described job, such as a contract-test harness for a model endpoint, and builds it with you.

FAQ

FAQ

Is it acceptable to hand-write fixtures if the team reviews them carefully?

Hand-written fixtures are fine as scenario data, but they should not be the source of the shape. Validate every hand-written response against a pinned schema at mock startup, or drift becomes invisible to review because reviewers check the example, not the contract.

Should the contract test run on every commit or on a schedule?

Both, with different scopes. A schema-only check is fast enough to run per commit, while replaying recorded exchanges against a real sandbox costs live calls and belongs on a nightly schedule with alerts.

How many recorded exchanges do I need before the mock is useful?

Enough to cover each distinct response type and each error path: a happy path, a long response, a streaming response, a tool call, an embedding, and 401, 429 and 5xx. Ten to twenty exchanges usually covers that, and coverage grows as you add cases from production traffic.

What about token counts that change between runs?

Keep them in the fixture and match by type rather than value. They are load-bearing for cost and budget code, so removing them hides bugs, but exact equality would make the contract test fail on every run.

How do I know the mock is still faithful after a provider deprecation?

Record the model ID and its published retirement date next to the fixture set, and build a new set for the replacement model instead of editing the old one. Then diff old against new to see exactly what behavior changed during migration.

Sources

Sources

  1. How to Stop Mock APIs from Drifting Away from the Real ...
  2. x-ratelimit-* headers dropped on streaming responses and ...
  3. Deprecations | OpenAI API
  4. AI Model Deprecation and Lifecycle Calendar - Anthropic, ...
  5. Model IDs and versioning - Claude Platform Docs
  6. Schemas Can Be Contracts | Introducing Drift - Pact Flow
  7. API Contract Testing: What It Is, How It Works and Tools
  8. Environment Replay - Real Production Traffic, Safely ...
  9. Traffic Replay: Production Without Production Risk
  10. How to Build Production Traffic Replay Testing Pipelines ...
  11. How To Test AI Agents Effectively - Splunk
  12. ToolFuzz - Automated Agent Tool Testing
  13. ToolFuzz: Automated Agent Tool Testing
  14. Mocking External APIs in Agent Tests, Scenario
  15. How are integration tests written for interacting with ...
  16. Test AI agents' API interactions using WireMock Cloud and ...
  17. Start your AI agent testing with deterministic tooling - Tricentis
  18. Mock LLM APIs (OpenAI, Anthropic, Gemini)
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