llm_mock_anthropic
When you test Ruby code that calls the Anthropic API, you usually don't want it
hitting the network. One clean way to avoid that is to stub your Anthropic
client and return a canned response object — but that runs into a wall:
constructing a realistic Anthropic SDK response by hand is genuinely painful.
A real Anthropic::Message needs id, model, role, type, usage,
stop_reason, and typed content blocks; tool calls are nested; and streaming
responses have no simple object to fake at all.
llm_mock_anthropic gives you small, ergonomic stand-ins for exactly those
response shapes — messages, text blocks, tool_use blocks, and streams — so your
stub can return something your code happily consumes:
allow(client.).to receive(:create).and_return(
LlmMock::Anthropic.([
LlmMock::Anthropic.tool_use(id: "tu_1", name: "save_summary", input: {"text" => "…"}),
])
)
Why object-level (and when not to)
The common community approach is to stub at the HTTP layer (VCR/WebMock): record real HTTP and let the SDK deserialize it. That's a great fit when you can make a real call once. Stub at the object layer — what this gem is for — when that's awkward, most often because:
- Streaming. Replaying SSE streams through VCR is fiddly; returning a
Streamdouble is trivial. - You want to script the model's behavior deterministically (e.g. "this turn
calls the
completetool") without recording anything.
If you want to record real calls once and replay them rather than hand-script
responses, see deja — it builds on this
gem.
Installation
# Gemfile
group :test do
gem "llm_mock_anthropic"
end
What you get
Response value objects (duck-typed to the SDK's response surface — .content,
block fields, .text, .accumulated_message):
| Builder | Returns | Shape |
|---|---|---|
LlmMock::Anthropic.text(str) |
TextBlock |
.type, .text |
LlmMock::Anthropic.tool_use(id:, name:, input:) |
ToolUseBlock |
.type, .id, .name, .input |
LlmMock::Anthropic.message(blocks) |
Message |
.content |
LlmMock::Anthropic.stream(text_chunks:, message:) |
Stream |
.text, .accumulated_message |
The structs are also available directly (LlmMock::Anthropic::Message.new(...))
if you prefer.
Example: a streamed tutor turn that ends by calling a tool
Say the code under test streams a reply and finishes when the model calls a
complete tool — it calls messages.stream, renders the incremental text, then
inspects the final message:
class TutorTurn
def run(client, conversation)
stream = client..stream(
model: "claude-sonnet-4-5",
max_tokens: 1024,
messages: conversation,
tools: [ complete_tool ],
)
stream.text.each {|chunk| broadcast(chunk) } # render text as it arrives
stream..content.each do |block|
finish! if block.type == :tool_use && block.name == "complete"
end
end
end
In a test, return a fake stream so that code runs without the network — one text
chunk plus a final message that includes the complete tool call:
fake = LlmMock::Anthropic.stream(
text_chunks: [ "Here's the core idea. " ],
message: LlmMock::Anthropic.([
LlmMock::Anthropic.text("Here's the core idea. "),
LlmMock::Anthropic.tool_use(id: "tu_done", name: "complete", input: {}),
]),
)
allow(client.).to receive(:stream).and_return(fake)
stream.text yields the chunks and stream.accumulated_message.content is the
final block list — exactly the surface TutorTurn#run reads from a real stream.
For tool authors
LlmMock::Anthropic::Provider implements the
llm_mock contract — it builds a stub
client routed through a responder, invokes the real client, and
serializes/deserializes responses to/from plain hashes. That's how deja uses
this gem to record and replay Anthropic calls. You don't need any of that to use
the builders above.
License
MIT — see LICENSE.