Your test suite calls the Anthropic API. So every CI run costs money, needs a real key in a secret store, returns something different every time, and now and then eats a 529 {"type":"overloaded_error"} that has nothing to do with your code. Ironically that last one is the hardest thing to test on purpose: you can't ask the real API to be overloaded on demand.
Mockbird gives you a hosted Anthropic-compatible mock instead: a real HTTPS URL serving deterministic /v1/messages responses, streaming that speaks Anthropic's named-event SSE protocol, and error routes that fail exactly how and when you want. Point ANTHROPIC_BASE_URL at it and your code doesn't change at all. Everything below was verified with the official anthropic Python SDK (v0.104) against the commands shown.
BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
-d '{"name":"anthropic-mock","blank":true}')
PID=$(echo $P | sed 's/.*"id": *"\([^"]*\)".*/\1/')
KEY=$(echo $P | sed 's/.*"adminKey": *"\([^"]*\)".*/\1/')
# messages (JSON) โ echoes the model, your messages array AND your system prompt back
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/v1/messages","status":200,
"body":"{\"id\":\"msg_{{uuid}}\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"{{body.model}}\",\"content\":[{\"type\":\"text\",\"text\":\"Hello from your Mockbird mock! This reply is deterministic - assert on it.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":57,\"output_tokens\":17},\"mockbird_echo\":{{{body.messages}}},\"mockbird_system\":\"{{body.system}}\"}"
}'
# models list
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"GET","path":"/v1/models","status":200,
"body":"{\"data\":[{\"type\":\"model\",\"id\":\"mock-claude\",\"display_name\":\"Mock Claude\",\"created_at\":\"2026-01-01T00:00:00Z\"}],\"has_more\":false,\"first_id\":\"mock-claude\",\"last_id\":\"mock-claude\"}"
}'
# count_tokens โ deterministic
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/v1/messages/count_tokens","status":200,
"body":"{\"input_tokens\":42}"
}'
echo "base_url: $BASE/m/$PID"
Your mock's base URL is https://mockbird.mockbird.workers.dev/m/<PID> โ the SDK appends /v1/messages itself. The {{body.model}}, {{{body.messages}}} and {{body.system}} bits are response templates: the reply echoes back whatever model you asked for, plus mockbird_echo / mockbird_system fields carrying your exact messages array and top-level system prompt โ so a test can assert the prompt your agent actually sent. A raw curl looks like this:
curl -s https://mockbird.mockbird.workers.dev/m/<PID>/v1/messages -X POST \
-H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-5","max_tokens":100,"system":"You are a test.",
"messages":[{"role":"user","content":"ping"}]}'
{"id":"msg_424b0d47-โฆ","type":"message","role":"assistant","model":"claude-sonnet-4-5",
"content":[{"type":"text","text":"Hello from your Mockbird mock! This reply is deterministic - assert on it."}],
"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":57,"output_tokens":17},
"mockbird_echo":[{"role":"user","content":"ping"}],"mockbird_system":"You are a test."}
export ANTHROPIC_BASE_URL="https://mockbird.mockbird.workers.dev/m/<PID>"
export ANTHROPIC_API_KEY="mock-key" # any non-empty string; the mock doesn't check it
import anthropic
client = anthropic.Anthropic() # reads both env vars โ your production code, untouched
r = client.messages.create(model="claude-sonnet-4-5", max_tokens=100,
system="You are a test.",
messages=[{"role": "user", "content": "ping"}])
assert r.content[0].text.startswith("Hello from your Mockbird mock!")
assert r.model == "claude-sonnet-4-5" # echoed from the request
assert r.stop_reason == "end_turn"
assert r.usage.input_tokens == 57 # deterministic
assert r.model_extra["mockbird_system"] == "You are a test." # the system prompt arrived intact
assert r.model_extra["mockbird_echo"][0]["content"] == "ping"
The response parses into the SDK's typed Message โ content blocks, stop_reason, usage all present. client.models.list() and client.messages.count_tokens(โฆ) hit the other two routes and parse the same way. The TypeScript SDK works identically (baseURL option or the same env vars), as does anything that speaks the Messages API โ LangChain, LiteLLM, the Vercel AI SDK's Anthropic provider.
Anthropic streaming is stricter than OpenAI's: it's text/event-stream with named events (event: message_start, content_block_delta, โฆ) in a fixed sequence, and the SDK assembles the final message from them. A custom route serves one fixed body, so give streaming its own prefix and serve the whole frame sequence:
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/stream/v1/messages","status":200,
"contentType":"text/event-stream",
"body":"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_mock1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"mock-claude\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":57,\"output_tokens\":1}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello from \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"your mock!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":17}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}'
stream_client = anthropic.Anthropic(
base_url=f"{BASE}/m/{PID}/stream", api_key="mock-key")
with stream_client.messages.stream(model="mock-claude", max_tokens=100,
messages=[{"role": "user", "content": "ping"}]) as stream:
parts = list(stream.text_stream) # ['Hello from ', 'your mock!']
final = stream.get_final_message()
assert "".join(parts) == "Hello from your mock!"
assert final.stop_reason == "end_turn" # merged from message_delta
assert final.usage.output_tokens == 17
The SDK's stream helper consumes the events one by one, yields each text_delta through text_stream, and builds the final Message from message_start + deltas โ your token-by-token rendering, stop-button and stream-assembly code all get exercised. One honest caveat: the whole body arrives at once rather than paced over seconds; add "delayMs": 1500 to the route if you want the request to take realistic time before the frames land.
Every production Claude integration eventually meets 529 Overloaded. Almost nobody's test suite has ever seen one. Make routes that serve Anthropic's exact error envelope:
# 529 Overloaded โ the classic
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/overloaded/v1/messages","status":529,
"body":"{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}"
}'
# 429 rate limit, with a retry-after header
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/ratelimited/v1/messages","status":429,
"headers":{"retry-after":"7"},
"body":"{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"This request would exceed your organization'"'"'s rate limit\"}}"
}'
drill = anthropic.Anthropic(base_url=f"{BASE}/m/{PID}/overloaded",
api_key="mock-key", max_retries=0)
try:
drill.messages.create(model="mock-claude", max_tokens=10,
messages=[{"role": "user", "content": "x"}])
except anthropic.APIStatusError as e:
assert e.status_code == 529
assert type(e).__name__ == "OverloadedError" # yes, it has its own exception class
Two things we verified that are worth knowing: the SDK raises a dedicated OverloadedError class for 529s (subclass of APIStatusError; note it's not exported top-level in v0.104, so catch the parent) and a top-level anthropic.RateLimitError for the 429 route โ and with default settings it auto-retries 529s twice with backoff โ pointing the default client at the route above took ~2.4s and produced exactly 3 requests before raising. Which brings us to:
Every hit on the mock lands in the project's request inspector โ method, path, status, headers, body. After the failure drill above, GET /api/projects/<PID>/requests (or the dashboard) shows the SDK's invisible retry behavior made visible:
POST /overloaded/v1/messages 529 โ attempt 1
POST /overloaded/v1/messages 529 โ retry 1
POST /overloaded/v1/messages 529 โ retry 2, then OverloadedError raised
That turns "I think our retry logic works" into an assertion: point your agent at the mock, run the scenario, then query the inspector and assert the exact number and shape of requests it made โ including the system prompt and tool definitions it sent, via the logged request bodies.
| In-process stub (respx / nock / VCR) | Self-hosted fake container | Mockbird | |
|---|---|---|---|
| Setup | per test-runner, per language | docker + maintenance | one paste, hosted |
| Visible to subprocesses / other services / CI previews | โ | โ on your network | โ anywhere |
| Real Anthropic SSE streaming | you fake it in-process | depends | โ (verified w/ official SDK) |
| 529/429 drills with real envelopes | โ if you write them | depends | โ routes above |
| Request inspector across processes | โ | rarely | โ |
| Offline / air-gapped | โ | โ | โ โ it's hosted |
| Token-paced streaming | you write it | some do it | โ โ body arrives at once |
If your tests must run offline, an in-process stub is the right call. The moment the code under test is an agent spawning subprocesses, a service in a compose stack, a teammate's notebook, or CI hitting a deployed preview โ an in-process stub can't see any of it, and a hosted URL can.
x-api-key or anthropic-version โ and never send a real key anywhere near test config anyway.{{uuid}}). Need several canned replies โ a tool-use turn, a refusal, a max_tokens truncation? Add more routes (/tooluse/v1/โฆ, /refusal/v1/โฆ) and switch base_url per test; stop_reason and content are yours to script. Scripted status sequences (fail-twice-then-succeed) work with mock_seq.The one-paste block in section 1 is the whole setup โ or start from the dashboard and add the routes in the UI. Mocking the REST backend your agent talks to as well? The same project serves a full CRUD mock API, GraphQL, and webhooks. Agents can even set all of this up themselves over MCP.