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 standalone process from your checkout of the Oda source:
pnpm run mock:llm \ --port 8000 \ --api-key mock-key \ --sequence partial_disconnect,success \ --partial-text "discard this half"
127. 0. 0. 1 port 8000 unless you change --host or --port. --sequence is required and has no default.ready record first, with the / v1 base URL and the seed used for random selection:{"type":"ready","baseURL":"http://127.0.0.1:8000/v1","randomSeed":42}request record and a result record for every accepted request; both name the attempt number, the scripted entry and the concrete behaviour it resolved to (the two differ for random), and the result adds the outcome and how many stream chunks were sent;connection_refused first, an unavailable record is printed before the listener delay, carrying the base URL and the delay.--help prints the usage text.--sequence is a comma-separated first-in, first-out list; each accepted request consumes exactly one entry, in arrival order.mock script exhausted. --repeat-last reuses the final entry instead.connection_refused is special. It is accepted only as the first entry and is not a request behaviour: it delays binding the port so that requests during --listen-delay-ms (default 750 ms) get a real TCP refusal. With it, --port is required and must be nonzero. The remaining entries begin once the listener is up.The catalogue of 24 behaviours:
| Behaviour | What the wire does |
|---|---|
success | Streams a complete text response; stop reason end_turn. |
slow_success | Like success, with --chunk-delay-ms between text chunks. |
reasoning_success | Streams a reasoning block first, then a complete text response. |
tool_call_success | Streams a tool call, using --tool-name and --tool-arguments; stop reason tool_use. |
max_tokens | Streams a complete text response; stop reason max_tokens. |
empty | Sends a valid content-less completion: a stop with no text, then the closing message_stop marker. |
empty_body | Ends cleanly without any events or the closing marker. |
stream_eof | Sends the message start, then ends without the closing marker. |
partial_eof | Sends text from --partial-text, then ends without the closing marker. |
partial_disconnect | Sends text deltas, then resets the connection. |
connection_reset | Destroys the socket before any HTTP headers are sent. |
stream_disconnect | Sends SSE headers, then resets the connection before the first event. |
stall | Sends SSE headers and stays idle until the client or the server closes the connection. |
malformed_json | Sends a data event that is not valid JSON. |
malformed_event | Sends a chunk with an invalid shape. |
wrong_content_type | Sends a valid SSE body under application/. |
rate_limit | Returns a 429 error with a Retry-After header. |
server_error | Returns a 500 error. |
service_unavailable | Returns a 503 error. |
auth_error | Returns a 401 authentication error. |
invalid_request | Returns a 400 invalid-request error. |
context_overflow | Returns a 400 error with the context-length-exceeded code. |
quota_exceeded | Returns a 429 insufficient-quota error. |
random | Draws the request's concrete behaviour from seeded weights. |
random entry resolves at request time through a seeded draw over relative weights, so a run is reproducible from its printed seed. Omitting --seed generates one and prints it in the ready record.--random-weights takes behavior=weight entries with non-negative relative weights and requires at least one positive entry; the entries must name concrete behaviours (random itself is not allowed).--random-weights, random uses a success-heavy default (success 48, slow_success 10, partial_disconnect 10, connection_reset 5, stream_disconnect 5, empty 5, rate_limit 5, server_error 4, max_tokens 2, stall 2, service_unavailable 2, partial_eof 1, malformed_json 1). It is test pressure, not an estimate of how often these failures happen in production.stall needs a client-side timeout. When the weights can produce stall, give the client under test a short stream-idle timeout so the scenario ends promptly.--seed and --random-weights are refused when the sequence contains no random.| Flag | Default | What it controls |
|---|---|---|
--success-text | mock response recovered | Text of a complete response. |
--partial-text | discarded partial response | Text sent before a partial EOF or a reset. |
--reasoning-text | mock reasoning | Reasoning block of reasoning_success. |
--chunk-size | 8 | Unicode code points per stream delta. |
--chunk-delay-ms | 25 | Delay between chunks in slow_success. |
--disconnect-delay-ms | 10 | Delay before a forced disconnect. |
--retry-after-ms | 1000 | Provider retry delay; the Retry-After header rounds up to whole seconds. |
--request-id | (none) | Value returned as x-request-id on error responses. |
--tool-name | mock_tool | Tool 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. |
POST whose path ends in / v1/ messages (a gateway prefix before it is fine), carrying a matching key when one is configured and a body that parses as JSON, consumes the script. An empty body is accepted.| Refusal | Answer |
|---|---|
| Wrong method | 405 with allow: POST |
Path that does not end in / | 404 |
x-api-key does not match the configured key | 401 invalid mock API key |
| Body that does not parse as JSON | 400 request body must be valid JSON |
A misconfigured client can burn retries this way without advancing the sequence.
| Message | Status | Code |
|---|---|---|
mock rate limit | 429 | rate_limit, with Retry-After |
mock server error | 500 | server_error |
mock service unavailable | 503 | service_unavailable |
mock authentication failed | 401 | invalid_api_key |
mock invalid request | 400 | invalid_request |
mock input exceeds the model context window | 400 | context_length_exceeded |
mock insufficient quota | 429 | insufficient_quota |
mock script exhausted | 500 | MOCK_SCRIPT_EXHAUSTED |
mock server handler failed | 500 | MOCK_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.
Each request record carries one outcome:
completed: the behaviour ran to its end, including error responses.reset: a disconnect behaviour destroyed the connection.stalled: the stall behaviour is holding the connection open.client_closed: the client went away before the response finished.server_error: the handler itself failed.ready record; the same seed and weights replay the same draw sequence.--listen-delay-ms window must overlap the client's attempt, and random cannot draw connection_refused because a bound listener cannot refuse.For the keyless counterpart that replays recorded model streams instead of faulting, see Session replay fixtures.
Invalid requests never consume a script entry. Only a POST ending in /, 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.
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.
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.
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.
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.
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.
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.
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.
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.
--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.