BYOKchat Blog

How to Build an AI Provider Simulator for Testing

Build a deterministic AI provider simulator that reproduces streaming, errors, rate limits, malformed events, tools, latency, retries, attachments, and background jobs without consuming real API quota.

· 8 min read

On this page
  1. The simulator should speak the same boundary as the adapter
  2. Transport/protocol simulator
  3. Semantic provider fake
  4. Do not replace protocol tests with only semantic mocks
  5. Make scenarios data, not bespoke test code
  6. Separate request matching from response scripting
  7. Request assertions catch adapter regressions
  8. Build deterministic streaming events
  9. Split UTF-8 characters across transport chunks
  10. Split SSE fields across arbitrary reads
  11. Test multiline SSE data
  12. Simulate valid transport with invalid semantic JSON
  13. Malformed fixtures are first-class test cases
  14. Unknown events should test forward compatibility
  15. Latency should be virtualizable
  16. Keep a small real-time integration layer too
  17. Simulate phase-specific timeouts
  18. Rate-limit scenarios need headers and scope
  19. Simulate stacked retries deliberately
  20. Unknown request outcome is essential
  21. Simulate partial output before failure
  22. Cancellation has several race points
  23. Tool calling needs structured fixtures
  24. Test multiple tool calls in one turn
  25. Test dependencies across rounds, not fake parallel dependencies
  26. Invalid tool arguments should stay model errors, not tool crashes
  27. Human approvals need deterministic waiting
  28. Side-effecting tools need idempotency fixtures
  29. Reasoning events need their own scenarios
  30. Structured output needs success and failure fixtures
  31. Attachments need upload simulators
  32. Test base URL resolution
  33. Authentication tests should never use real secrets
  34. Redirect behavior deserves explicit tests
  35. Circuit breaker tests need repeated scenarios
  36. Health scoring tests need noisy evidence
  37. Request queue tests need controllable concurrency
  38. Background jobs need durable IDs
  39. Webhooks can be simulated without public networking
  40. Offline/degraded behavior needs a switchboard
  41. App lifecycle is part of the simulator environment
  42. Store scenario results for diagnostics
  43. Snapshot normalized events, not raw unordered dictionaries
  44. Golden fixtures should be small and readable
  45. Captured real responses need sanitization
  46. Provider simulators should be versioned with adapters
  47. Test unknown future behavior separately from legacy behavior
  48. A simulator should expose developer controls
  49. Keep simulation obviously non-production
  50. Simulated pricing should not leak into production paywalls or public metadata
  51. Deterministic pseudo-randomness can help fuzz scenarios
  52. Property tests fit stream parsers well
  53. Fuzz only within bounded resource limits
  54. One simulator can serve many provider adapters, but do not erase native differences
  55. A layered simulator architecture
  56. Useful invariants
  57. Minimum high-value scenario suite
  58. Where BYOKchat fits
  59. Further reading

A multi-provider AI client has a testing problem that ordinary HTTP apps often do not.

The hardest bugs happen in states such as:

stream starts, then disconnects
one tool-call argument arrives across six chunks
provider returns 429 with a retry hint
JSON event is malformed halfway through
request succeeds but final usage never arrives
app cancels during a tool round
provider changes finish reason
background job completes after relaunch

You cannot reliably wait for real providers to produce these failures on demand.

A deterministic provider simulator should be treated as part of the client architecture, not as a toy mock used only for unit tests.

The simulator should speak the same boundary as the adapter

There are two useful testing levels.

Transport/protocol simulator

Acts like an HTTP provider:

request -> status/headers/body/stream

This tests:

  • request encoding;
  • authentication/header construction;
  • SSE parsing;
  • JSON decoding;
  • retry headers;
  • cancellation;
  • timeout behavior.

Semantic provider fake

Implements the normalized provider interface directly:

interface AIProvider {
  send(request: NormalizedRequest): AsyncStream<ProviderEvent>
}

This is faster for application-level tests.

A strong test suite uses both.

Do not replace protocol tests with only semantic mocks

If every test starts after the provider adapter, you can miss bugs in:

SSE framing
UTF-8 chunk boundaries
HTTP status mapping
headers
JSON event routing
multipart upload

The provider adapter is exactly where compatibility bugs live.

See How AI Provider Adapters Work.

Make scenarios data, not bespoke test code

Instead of writing a new fake for each test, define a scenario:

{
  "name": "stream_then_disconnect",
  "steps": [
    {"after_ms": 10, "event": "message_start"},
    {"after_ms": 20, "event": "text_delta", "text": "Hello"},
    {"after_ms": 30, "transport": "disconnect"}
  ]
}

Then the simulator executes the script deterministically.

This creates reusable fixtures across:

unit tests
integration tests
manual developer UI
regression tests

Separate request matching from response scripting

A scenario can declare expectations:

{
  "expect": {
    "model": "test-model",
    "stream": true,
    "tools": ["get_weather"]
  }
}

If the client sends the wrong request, the simulator should fail loudly instead of returning a plausible response anyway.

Request assertions catch adapter regressions

Useful assertions include:

method
path
query parameters
content type
auth header present but value redacted
provider version header
model ID
message ordering
tool schema
reasoning setting
stream flag

Never snapshot real API key values.

Use sentinel credentials such as:

sk-test-sentinel

and assert only that the adapter places them where expected.

Build deterministic streaming events

Streaming scenarios should let tests control:

number of chunks
chunk timing
transport packet boundaries
semantic event boundaries
final event
usage timing

Do not assume:

one network chunk = one SSE event

That is a common parser bug.

See How to Parse Server-Sent Events Correctly.

Split UTF-8 characters across transport chunks

A robust simulator should test bytes such as an emoji or non-Latin character split across reads.

For example:

chunk 1: first bytes of character
chunk 2: remaining bytes

The client must decode the byte stream correctly rather than treating each network read as standalone UTF-8 text.

Split SSE fields across arbitrary reads

Test:

"da"
"ta: {\"type\":..."
"}\n\n"

and multiple events in one read:

event1\n\nevent2\n\n

The parser should reconstruct protocol frames independently of socket chunking.

Test multiline SSE data

SSE supports multiple data: lines for one event.

A simulator can emit:

data: first
data: second

and verify the parser follows the event-stream rules instead of supporting only the provider’s most common happy path.

Simulate valid transport with invalid semantic JSON

Example:

data: {"type":"delta",

followed by event termination.

The client should classify this as a protocol/decoding failure, not a network error.

See How to Classify AI API Errors.

Malformed fixtures are first-class test cases

Useful cases:

invalid JSON
missing required field
unknown event type
wrong type for usage field
negative token count
unknown finish reason
duplicate start event
final event before start
text delta after completion

Decide which should:

fail request
ignore safely
preserve as unknown extension

and test that contract.

Unknown events should test forward compatibility

Providers add event types over time.

A simulator can insert:

{"type":"future.event","foo":"bar"}

between known events.

If the adapter can safely ignore unknown noncritical events, test that behavior.

If an unknown event makes state ambiguous, fail clearly.

Latency should be virtualizable

Real sleeps make tests slow and flaky.

Abstract time:

interface Clock {
  now: Date
  sleep(duration: Duration) async
}

Then a test can advance virtual time:

+500 ms -> first byte timeout not yet
+500 ms -> timeout fires

without actually waiting one second.

Keep a small real-time integration layer too

Virtual clocks are ideal for scheduling logic.

A few end-to-end tests should still exercise real asynchronous IO to catch race conditions in:

URLSession
async sequences
cancellation
socket/server cleanup

Balance speed with realism.

Simulate phase-specific timeouts

Scenarios should cover:

connect stalls
headers arrive but first model event stalls
reasoning event arrives but first text stalls
stream goes idle after tokens
overall deadline expires
queued request times out before dispatch
tool call exceeds deadline

See Timeout Design for AI Applications.

Rate-limit scenarios need headers and scope

A useful 429 fixture can include:

Retry-After: 3

or an HTTP-date.

Provider-specific fixtures can include documented native quota headers.

Tests should verify:

adapter parses native metadata
shared scheduler respects not-before time
no tight retry loop
user cancellation clears queued retry

See Retry-After and Rate-Limit Headers Explained.

Simulate stacked retries deliberately

Configure:

transport retry enabled
SDK retry enabled
app retry enabled

in a test harness and assert the actual number of provider attempts.

This catches accidental retry multiplication.

Unknown request outcome is essential

A scenario can model:

request body fully received by simulator
simulator records operation as completed
connection drops before response reaches client

The client sees an ambiguous network failure even though the server-side action happened.

This is the core case for idempotency and reconciliation testing.

See Designing Reliable AI Retries.

Simulate partial output before failure

Example:

100 text deltas
then connection reset

Assert:

partial text preserved
message marked interrupted
usage unknown or partial according to fixture
retry does not overwrite old branch

See Why AI Streams Break in the Middle.

Cancellation has several race points

Test cancellation:

before request dispatch
while connecting
after headers
while streaming text
while tool arguments stream
while tool executes
while waiting for retry
while background job polls

The expected cleanup differs at each phase.

Tool calling needs structured fixtures

A scenario can emit:

tool call start id=call_1 name=get_weather
arguments fragment {"city":
arguments fragment "Tokyo"}
call complete

Then the client executes a fake tool and returns:

{"temperature_c":31}

The simulator can expect the next model request to contain that result.

Test multiple tool calls in one turn

Emit:

call_1 get_weather(Tokyo)
call_2 get_weather(Paris)

and verify correlation by call ID.

Do not rely on arrival order alone.

See How Streaming Tool Calls Work.

Test dependencies across rounds, not fake parallel dependencies

If call B depends on call A’s result, the model should need another model round after A.

A single response containing both calls should be treated as independent proposals unless the protocol explicitly says otherwise.

Fixtures should reinforce this architecture.

Invalid tool arguments should stay model errors, not tool crashes

Simulate:

{"city": 123}

against a schema expecting string.

The host should reject before execution and return the configured tool-error path.

See How to Validate AI Tool Arguments Safely.

Human approvals need deterministic waiting

A tool fixture can pause at:

approval_required

The test drives:

approve
deny
cancel app
relaunch then approve
arguments mutate before execution

Approval must remain bound to the operation that was reviewed.

Side-effecting tools need idempotency fixtures

A fake send_email tool can record operation IDs.

Then simulate:

tool completes
client crashes before persistence
workflow resumes
same operation requested again

Assert only one external side effect occurs when the idempotency design promises that.

See Idempotency for AI Tool Execution.

Reasoning events need their own scenarios

A reasoning-capable provider adapter can receive:

reasoning start
reasoning delta/summary
text start
text delta
usage

Test that reasoning events do not accidentally appear as answer text unless the UI contract calls for it.

See How Streaming Reasoning Differs From Streaming Answers.

Structured output needs success and failure fixtures

Test:

valid schema-constrained output
valid JSON but schema invalid
truncated JSON
provider refusal
provider reports schema unsupported

The host should distinguish transport success from application validation success.

Attachments need upload simulators

A file-capable simulator should model:

upload accepted
upload rejected for size
processing pending
processing ready
processing failed
provider file expired
upload success + generation failure

This tests the attachment state machine independently from model text streaming.

See How File Attachments Flow Through AI APIs.

Test base URL resolution

Custom endpoints commonly fail because clients concatenate paths incorrectly.

Fixtures should cover base URLs such as:

https://example.com
https://example.com/v1
https://example.com/proxy/ai/

and assert the final request path.

See How to Design a Custom OpenAI-Compatible Provider Connection.

Authentication tests should never use real secrets

Use sentinel values and assert:

header present
correct scheme
protected header forwarded only to allowed host
not included in redirect to different host
not logged

This makes credential handling testable without sensitive fixtures.

Redirect behavior deserves explicit tests

Simulate:

same-host redirect
different-host redirect
HTTP -> HTTPS
HTTPS -> HTTP
redirect loop

Verify credential forwarding and transport policy follow the client security rules.

See Certificate Validation for Custom AI Endpoints.

Circuit breaker tests need repeated scenarios

A scripted sequence:

503
503
503
circuit opens
cooldown
half-open probe -> 503
longer cooldown
half-open probe -> success
circuit closes

is easy to test deterministically with a virtual clock.

See Circuit Breakers for AI Providers.

Health scoring tests need noisy evidence

Feed observations such as:

success
401
success
context overflow
503
cancelled
success

and assert only relevant service-health events affect the health window.

This catches classification pollution.

Request queue tests need controllable concurrency

A simulator can block requests until released.

Then assert:

max 2 in flight
foreground request jumps ahead of maintenance work
cancel queued request before dispatch
rate-limited target does not block unrelated provider

See How to Build an AI Request Queue.

Background jobs need durable IDs

Simulate:

create job -> job_123
app terminates
relaunch
GET job_123 -> completed

Assert the app reconciles the existing operation rather than creating a new one.

See How Long-Running AI Tasks Work.

Webhooks can be simulated without public networking

Feed signed fixture events directly into the webhook verification/dispatch layer.

Test:

valid signature
invalid signature
duplicate event
out-of-order event
completed before local poll notices
failure after stale queued notification

Keep webhook transport tests separate from business-state reconciliation tests.

Offline/degraded behavior needs a switchboard

The simulator/harness can independently disable:

public internet provider
local server
MCP server
web search
file service

Then verify the UI preserves capabilities that remain available.

See Designing Offline and Degraded Modes for AI Apps.

App lifecycle is part of the simulator environment

For mobile clients, the test harness should trigger:

background
foreground
process termination
restore persisted state

while provider scenarios continue or stall.

This reveals bugs that pure network mocks cannot.

Store scenario results for diagnostics

A failed test should report:

scenario name
expected request number
received request summary
script step
virtual timestamp
client state

Do not dump full secret-bearing headers or private content.

Snapshot normalized events, not raw unordered dictionaries

Provider events can contain unstable fields such as IDs/timestamps.

Normalize fixtures:

replace dynamic request ID
normalize timestamp
preserve semantic event sequence

Then snapshots remain useful rather than constantly changing.

Golden fixtures should be small and readable

A huge captured production stream is hard to review.

Prefer a hand-curated fixture that isolates one behavior:

three text deltas + final usage

Use larger captured samples only for known compatibility cases.

Captured real responses need sanitization

If you record traffic from a live provider to create fixtures, remove:

API keys
user prompts
private files
real tool arguments/results
request IDs tied to account
private URLs

Prefer synthetic test content from the start.

Provider simulators should be versioned with adapters

When an adapter changes because the provider API changes, update fixtures intentionally.

Keep scenarios labeled:

provider protocol generation/version

so older behavior can remain covered when compatibility matters.

Test unknown future behavior separately from legacy behavior

Two distinct classes:

future unknown event/field -> forward compatibility
old deprecated event/schema -> backward compatibility

Do not conflate them.

A simulator should expose developer controls

A manual developer screen can select:

Success stream
Slow TTFT
429 then recover
Disconnect at 30%
Malformed JSON
Tool approval
Tool failure
Background completion

This is useful for UI testing without editing fixture code.

Keep simulation obviously non-production

Use model/provider names such as:

Simulator / Slow Stream
Simulator / Rate Limited

and a distinct developer-only connection type.

Never let simulated responses be confused with real provider traffic in user analytics.

Simulated pricing should not leak into production paywalls or public metadata

A provider simulator may need fake usage/pricing to test UI.

Keep it explicitly marked:

simulated

and isolated from production pricing catalogs or public product copy.

Deterministic pseudo-randomness can help fuzz scenarios

For parser robustness, generate chunk boundaries from a fixed seed:

seed = 42

The test remains reproducible while exercising many splits.

On failure, print the seed.

Property tests fit stream parsers well

Useful invariant:

for any arbitrary transport chunking of the same valid SSE byte stream,
semantic event output is identical

Generate many chunk partitions to catch framing assumptions.

Fuzz only within bounded resource limits

Malformed payload testing should not create unbounded strings or allocations that make CI unstable.

Use explicit maximum fixture sizes and time budgets.

One simulator can serve many provider adapters, but do not erase native differences

A generic engine can execute scripts while provider-specific fixture encoders produce:

OpenAI-style events
Anthropic-style events
Gemini-style events
compatible-server variants

The shared engine handles timing/failure transport.

Native encoders preserve real protocol differences.

A layered simulator architecture

Diagram illustrating the surrounding section

For faster unit tests, the scenario engine can also feed the normalized app pipeline directly.

Useful invariants

same scenario + seed -> same semantic outcome
arbitrary network chunking does not change parsed events
credentials never appear in fixture snapshots
unknown provider events are handled by documented policy
cancellation stops retries and queued work
provider attempts are countable
simulated time controls timeout/backoff/circuit state deterministically

Minimum high-value scenario suite

If you build only a small first version, include:

successful text stream
successful non-stream response
401
403
429 + Retry-After
500/503 transient failure
slow first token
idle stream timeout
partial stream disconnect
user cancellation
malformed event
unknown event
streamed tool call
invalid tool arguments
tool denied
tool side-effect idempotency
structured-output invalid JSON
file upload + generation
background job recovery

That covers a surprisingly large share of production failure logic.

Where BYOKchat fits

A provider-neutral client with several direct/native adapters gets disproportionate value from a simulator because every adapter must feed the same chat, streaming, tool, analytics, and recovery pipelines while preserving provider-specific semantics at the boundary.

A deterministic simulator lets those pipelines be tested before real API calls, without consuming user quota or waiting for a real provider to fail in exactly the right way.

Further reading

Keep reading