Mock the Anthropic API (Claude) โ€” test agents and clients without burning tokens

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.

To be clear about what this is: there is no model behind it. It returns the deterministic responses you define โ€” which is exactly what you want in tests. You're testing your parsing, streaming, retry, timeout and fallback logic, not Claude.

1. Create the mock (one paste)

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."}

2. Point the SDK at it โ€” no code change

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.

3. Streaming โ€” Anthropic's named-event SSE, parsed by the real SDK

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.

4. Failure drills โ€” Overloaded on demand

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:

5. See what your client actually did

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.

Honest comparison

In-process stub (respx / nock / VCR)Self-hosted fake containerMockbird
Setupper test-runner, per languagedocker + maintenanceone paste, hosted
Visible to subprocesses / other services / CI previewsโœ—โœ” on your networkโœ” anywhere
Real Anthropic SSE streamingyou fake it in-processdependsโœ” (verified w/ official SDK)
529/429 drills with real envelopesโœ” if you write themdependsโœ” routes above
Request inspector across processesโœ—rarelyโœ”
Offline / air-gappedโœ”โœ”โœ— โ€” it's hosted
Token-paced streamingyou write itsome 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.

Notes & limits

Create yours

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.

โšก Skip the terminal: paste your file at /app#import โ€” an OpenAPI spec, db.json, CSV, Postman collection, or HAR export becomes a live hosted API in seconds, no signup. Or create a seeded example project in one click.