The mock LLM server

The mock LLM server is a small standalone process that stands in for a model provider, so tests and demos can exercise provider failures and successes without a provider key. It serves the messages endpoint that model adapters speak, and each accepted request plays the next behaviour from a script you pass on the command line. The server never retries a request and never interprets recovery policy of its own. To choose between the test benches, see Testing tiers.

Run the server

Run the standalone process from your checkout of the Oda source:

sh
pnpm run mock:llm \
  --port 8000 \
  --api-key mock-key \
  --sequence partial_disconnect,success \
  --partial-text "discard this half"
json
{"type":"ready","baseURL":"http://127.0.0.1:8000/v1","randomSeed":42}

Script the behaviours

The catalogue of 24 behaviours:

BehaviourWhat the wire does
successStreams a complete text response; stop reason end_turn.
slow_successLike success, with --chunk-delay-ms between text chunks.
reasoning_successStreams a reasoning block first, then a complete text response.
tool_call_successStreams a tool call, using --tool-name and --tool-arguments; stop reason tool_use.
max_tokensStreams a complete text response; stop reason max_tokens.
emptySends a valid content-less completion: a stop with no text, then the closing message_stop marker.
empty_bodyEnds cleanly without any events or the closing marker.
stream_eofSends the message start, then ends without the closing marker.
partial_eofSends text from --partial-text, then ends without the closing marker.
partial_disconnectSends text deltas, then resets the connection.
connection_resetDestroys the socket before any HTTP headers are sent.
stream_disconnectSends SSE headers, then resets the connection before the first event.
stallSends SSE headers and stays idle until the client or the server closes the connection.
malformed_jsonSends a data event that is not valid JSON.
malformed_eventSends a chunk with an invalid shape.
wrong_content_typeSends a valid SSE body under application/json.
rate_limitReturns a 429 error with a Retry-After header.
server_errorReturns a 500 error.
service_unavailableReturns a 503 error.
auth_errorReturns a 401 authentication error.
invalid_requestReturns a 400 invalid-request error.
context_overflowReturns a 400 error with the context-length-exceeded code.
quota_exceededReturns a 429 insufficient-quota error.
randomDraws the request's concrete behaviour from seeded weights.

Random mode

Timing, content and the key

FlagDefaultWhat it controls
--success-textmock response recoveredText of a complete response.
--partial-textdiscarded partial responseText sent before a partial EOF or a reset.
--reasoning-textmock reasoningReasoning block of reasoning_success.
--chunk-size8Unicode code points per stream delta.
--chunk-delay-ms25Delay between chunks in slow_success.
--disconnect-delay-ms10Delay before a forced disconnect.
--retry-after-ms1000Provider retry delay; the Retry-After header rounds up to whole seconds.
--request-id(none)Value returned as x-request-id on error responses.
--tool-namemock_toolTool name used by tool_call_success.
--tool-arguments{"value":"mock"}Raw JSON arguments used by tool_call_success.
--api-key(none)When set, requests must carry this exact value in x-api-key; without it, any token is accepted.

Which requests consume the script

RefusalAnswer
Wrong method405 with allow: POST
Path that does not end in /v1/messages404
x-api-key does not match the configured key401 invalid mock API key
Body that does not parse as JSON400 request body must be valid JSON

A misconfigured client can burn retries this way without advancing the sequence.

The structured errors a script can return

MessageStatusCode
mock rate limit429rate_limit, with Retry-After
mock server error500server_error
mock service unavailable503service_unavailable
mock authentication failed401invalid_api_key
mock invalid request400invalid_request
mock input exceeds the model context window400context_length_exceeded
mock insufficient quota429insufficient_quota
mock script exhausted500MOCK_SCRIPT_EXHAUSTED
mock server handler failed500MOCK_HANDLER_FAILED

An error response counts as a completed outcome for the request record. When --request-id is set, these responses carry it as x-request-id; only rate_limit adds Retry-After.

How a request ends

Each request record carries one outcome:

Limits

For the keyless counterpart that replays recorded model streams instead of faulting, see Session replay fixtures.

Questions, answered

Why did my request get a 4xx and the sequence not advance?

Invalid requests never consume a script entry. Only a POST ending in /v1/messages, with a matching key when one is configured and a JSON body, advances the sequence; a wrong method, path, key or body gets its ordinary 4xx first.

The server answered mock script exhausted. What should I do?

The sequence ran out. Lengthen --sequence, or pass --repeat-last so the final entry is reused for every further request.

How do I make a random run reproducible?

Pass a fixed --seed, or record the generated seed from the ready record, together with your --random-weights. The same seed and weights replay the same sequence of draws.

Can two tests share one mock server instance?

Not safely for deterministic faults: scripts are consumed in arrival order and concurrent callers share one cursor. Run a separate instance per test or session.

What does connection_refused require?

It must be the first sequence entry, the port must be explicit and nonzero, and its window is --listen-delay-ms (default 750 ms). During that window requests get a real TCP refusal; afterwards the remaining entries play in order.

Why did empty_body end without the closing marker?

That is its purpose: empty_body, stream_eof and partial_eof deliberately omit the closing stream marker so clients that require it fail. Use empty when you want a well-formed, content-less completion.

Which failures add a Retry-After header?

Only rate_limit does, as a whole number of seconds rounded up from --retry-after-ms (default 1000 ms, so one second). Other errors never add the header.

Does the API key have to match exactly?

Only when you start the server with --api-key. Then every request must carry that exact value in x-api-key, or it gets 401 invalid mock API key. Without --api-key, any token is accepted.

What happens when my client disconnects mid-stream?

The request record settles as client_closed, and the server keeps serving other requests. Closing the server stops it accepting requests and terminates stalled connections.

Why is --listen-delay-ms refused with an ordinary sequence?

The flag only applies to the pre-listen refusal phase, so it requires connection_refused as the first entry. With an ordinary sequence there is no unavailable window to configure.