# frozen_string_literal: true RSpec.describe Dispatch::Adapter::Claude::ResponseBuilder do let(:model_id) { "claude-sonnet-4-6" } let(:model_info) { Dispatch::Adapter::Claude::ModelCatalog.build(model_id) } def build(json, is_oauth: false, info: model_info) described_class.build(json, model_info: info, is_oauth: is_oauth) end # ── Basic structure ──────────────────────────────────────────────────────── describe ".build" do let(:minimal_json) do { "id" => "msg_01", "type" => "message", "model" => model_id, "role" => "assistant", "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "Hello" }], "usage" => { "input_tokens" => 10, "output_tokens" => 5 } } end it "returns a Response" do expect(build(minimal_json)).to be_a(Dispatch::Adapter::Response) end it "sets the model from the JSON body" do expect(build(minimal_json).model).to eq(model_id) end it "sets stop_reason to :end_turn" do expect(build(minimal_json).stop_reason).to eq(:end_turn) end end # ── Content block parsing ────────────────────────────────────────────────── describe "content parsing" do context "with a text block" do let(:json) do { "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "Hello world" }], "usage" => { "input_tokens" => 5, "output_tokens" => 3 } } end it "returns a TextBlock in content" do response = build(json) expect(response.content).to include(an_instance_of(Dispatch::Adapter::TextBlock)) end it "has the correct text" do response = build(json) expect(response.content.first.text).to eq("Hello world") end it "has no tool_calls" do expect(build(json).tool_calls).to be_empty end end context "with a thinking block" do let(:json) do { "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "thinking", "thinking" => "Step 1: think...", "signature" => "sig_abc123" }], "usage" => { "input_tokens" => 5, "output_tokens" => 3 } } end it "returns a ThinkingBlock in content" do response = build(json) expect(response.content).to include(an_instance_of(Dispatch::Adapter::ThinkingBlock)) end it "has the correct thinking text" do block = build(json).content.find { |b| b.is_a?(Dispatch::Adapter::ThinkingBlock) } expect(block.thinking).to eq("Step 1: think...") end it "has the correct signature" do block = build(json).content.find { |b| b.is_a?(Dispatch::Adapter::ThinkingBlock) } expect(block.signature).to eq("sig_abc123") end it "sets signature to nil when absent" do json_no_sig = json.dup json_no_sig["content"] = [{ "type" => "thinking", "thinking" => "hmm" }] block = build(json_no_sig).content.find { |b| b.is_a?(Dispatch::Adapter::ThinkingBlock) } expect(block.signature).to be_nil end end context "with a redacted_thinking block" do let(:json) do { "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "redacted_thinking", "data" => "REDACTED_DATA" }], "usage" => { "input_tokens" => 5, "output_tokens" => 3 } } end it "returns a RedactedThinkingBlock in content" do response = build(json) expect(response.content).to include(an_instance_of(Dispatch::Adapter::RedactedThinkingBlock)) end it "has the correct data" do block = build(json).content.find { |b| b.is_a?(Dispatch::Adapter::RedactedThinkingBlock) } expect(block.data).to eq("REDACTED_DATA") end end context "with a tool_use block" do let(:json) do { "model" => model_id, "stop_reason" => "tool_use", "content" => [{ "type" => "tool_use", "id" => "toolu_01", "name" => "bash", "input" => { "command" => "ls -la" } }], "usage" => { "input_tokens" => 10, "output_tokens" => 8 } } end it "puts tool_use blocks in tool_calls, not content" do response = build(json) expect(response.tool_calls).not_to be_empty expect(response.content).to be_empty end it "creates a ToolUseBlock with the correct id, name, arguments" do tc = build(json).tool_calls.first expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) expect(tc.id).to eq("toolu_01") expect(tc.name).to eq("bash") expect(tc.arguments).to eq({ "command" => "ls -la" }) end end context "with a tool_use block when is_oauth: true" do let(:json) do { "model" => model_id, "stop_reason" => "tool_use", "content" => [{ "type" => "tool_use", "id" => "toolu_01", "name" => "proxy_bash", "input" => { "command" => "echo hi" } }], "usage" => { "input_tokens" => 5, "output_tokens" => 5 } } end it "strips the proxy_ prefix from tool name" do tc = build(json, is_oauth: true).tool_calls.first expect(tc.name).to eq("bash") end it "does NOT strip prefix when is_oauth: false" do tc = build(json, is_oauth: false).tool_calls.first expect(tc.name).to eq("proxy_bash") end end context "with mixed content (text + tool_use)" do let(:json) do { "model" => model_id, "stop_reason" => "tool_use", "content" => [ { "type" => "text", "text" => "I'll call a tool." }, { "type" => "tool_use", "id" => "toolu_02", "name" => "find", "input" => { "path" => "/tmp" } } ], "usage" => { "input_tokens" => 12, "output_tokens" => 10 } } end it "separates text blocks and tool_calls" do response = build(json) expect(response.content.size).to eq(1) expect(response.content.first).to be_a(Dispatch::Adapter::TextBlock) expect(response.tool_calls.size).to eq(1) expect(response.tool_calls.first).to be_a(Dispatch::Adapter::ToolUseBlock) end end context "with empty text blocks" do let(:json) do { "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "" }], "usage" => { "input_tokens" => 2, "output_tokens" => 0 } } end it "omits empty text blocks" do expect(build(json).content).to be_empty end end end # ── Stop-reason mapping ──────────────────────────────────────────────────── describe "stop_reason mapping" do { "end_turn" => :end_turn, "max_tokens" => :max_tokens, "tool_use" => :tool_use, "pause_turn" => :pause_turn, "refusal" => :refusal, "sensitive" => :sensitive, "stop_sequence" => :end_turn, "unknown_new" => :end_turn # unknown falls back to :end_turn }.each do |raw, expected| it "maps #{raw.inspect} to #{expected.inspect}" do json = { "model" => model_id, "stop_reason" => raw, "content" => [], "usage" => { "input_tokens" => 1, "output_tokens" => 1 } } expect(build(json).stop_reason).to eq(expected) end end end # ── Usage ────────────────────────────────────────────────────────────────── describe "usage population" do let(:json) do { "model" => model_id, "stop_reason" => "end_turn", "content" => [], "usage" => { "input_tokens" => 100, "output_tokens" => 50, "cache_read_input_tokens" => 200, "cache_creation_input_tokens" => 400 } } end it "sets input_tokens" do expect(build(json).usage.input_tokens).to eq(100) end it "sets output_tokens" do expect(build(json).usage.output_tokens).to eq(50) end it "sets cache_read_tokens from cache_read_input_tokens" do expect(build(json).usage.cache_read_tokens).to eq(200) end it "sets cache_creation_tokens from cache_creation_input_tokens" do expect(build(json).usage.cache_creation_tokens).to eq(400) end it "sets usage.cost as a UsageCost" do expect(build(json).usage.cost).to be_a(Dispatch::Adapter::UsageCost) end it "usage.cost.total >= 0" do expect(build(json).usage.cost.total).to be >= 0 end it "usage.cost.total matches manual calculation for known pricing" do pricing = model_info.pricing usage = build(json).usage cost = usage.cost mtok = ->(tokens, rate) { (rate.to_f / 1_000_000.0) * tokens.to_i } expected_total = mtok.call(100, pricing.input_per_mtok) + mtok.call(50, pricing.output_per_mtok) + mtok.call(200, pricing.cache_read_per_mtok) + mtok.call(400, pricing.cache_write_per_mtok) expect(cost.total).to be_within(1e-9).of(expected_total) end end describe "usage when cache fields are absent" do let(:json) do { "model" => model_id, "stop_reason" => "end_turn", "content" => [], "usage" => { "input_tokens" => 10, "output_tokens" => 5 } } end it "defaults cache_read_tokens to 0" do expect(build(json).usage.cache_read_tokens).to eq(0) end it "defaults cache_creation_tokens to 0" do expect(build(json).usage.cache_creation_tokens).to eq(0) end end describe "usage when model has no pricing" do let(:unknown_info) do Dispatch::Adapter::ModelInfo.new( id: "unknown-model", name: "unknown-model", max_context_tokens: 200_000, supports_vision: true, supports_tool_use: true, supports_streaming: true, pricing: nil ) end it "sets usage.cost to nil when model has no pricing" do json = { "model" => "unknown-model", "stop_reason" => "end_turn", "content" => [], "usage" => { "input_tokens" => 10, "output_tokens" => 5 } } response = described_class.build(json, model_info: unknown_info, is_oauth: false) expect(response.usage.cost).to be_nil end end # ── STOP_REASON_MAP constant ────────────────────────────────────────────── describe "STOP_REASON_MAP" do it "maps all seven documented Anthropic stop_reason strings" do expected_keys = %w[end_turn max_tokens tool_use pause_turn refusal sensitive stop_sequence] expect(described_class::STOP_REASON_MAP.keys).to include(*expected_keys) end end end