# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "#chat (non-streaming)" do let(:model_id) { "claude-sonnet-4-6" } let(:api_key) { "sk-ant-api03-test" } let(:base_url) { "https://api.anthropic.com" } # Build a Claude adapter without loading real tokens from disk subject(:adapter) do described_class.new( model: model_id, api_key: api_key, base_url: base_url ) end before { WebMock.disable_net_connect! } after { WebMock.reset! } # A minimal valid Anthropic non-streaming response def stub_messages(response_body, status: 200) stub_request(:post, "#{base_url}/v1/messages") .to_return( status: status, body: JSON.generate(response_body), headers: { "Content-Type" => "application/json" } ) end let(:text_response) do { "id" => "msg_01", "type" => "message", "role" => "assistant", "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "Hello from Claude!" }], "usage" => { "input_tokens" => 20, "output_tokens" => 10 } } end let(:tool_use_response) do { "id" => "msg_02", "type" => "message", "role" => "assistant", "model" => model_id, "stop_reason" => "tool_use", "content" => [{ "type" => "tool_use", "id" => "toolu_01", "name" => "bash", "input" => { "command" => "ls -la" } }], "usage" => { "input_tokens" => 30, "output_tokens" => 15 } } end let(:cache_response) do { "id" => "msg_03", "type" => "message", "role" => "assistant", "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "Hi" }], "usage" => { "input_tokens" => 50, "output_tokens" => 10, "cache_read_input_tokens" => 200, "cache_creation_input_tokens" => 400 } } end let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hello")] )] end # ── Returns a correct Response ──────────────────────────────────────────── describe "text response" do before { stub_messages(text_response) } it "returns a Response object" do expect(adapter.chat(messages)).to be_a(Dispatch::Adapter::Response) end it "sets content as TextBlock array" do response = adapter.chat(messages) expect(response.content).to include(an_instance_of(Dispatch::Adapter::TextBlock)) end it "sets the text correctly" do response = adapter.chat(messages) expect(response.content.first.text).to eq("Hello from Claude!") end it "sets stop_reason to :end_turn" do expect(adapter.chat(messages).stop_reason).to eq(:end_turn) end it "sets the model" do expect(adapter.chat(messages).model).to eq(model_id) end end # ── Tool-call response ──────────────────────────────────────────────────── describe "tool_use response" do before { stub_messages(tool_use_response) } it "sets stop_reason to :tool_use" do expect(adapter.chat(messages).stop_reason).to eq(:tool_use) end it "populates tool_calls" do response = adapter.chat(messages) expect(response.tool_calls).not_to be_empty end it "populates tool_calls with ToolUseBlock" do tc = adapter.chat(messages).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 # ── Usage population ────────────────────────────────────────────────────── describe "usage" do before { stub_messages(text_response) } it "sets input_tokens" do expect(adapter.chat(messages).usage.input_tokens).to eq(20) end it "sets output_tokens" do expect(adapter.chat(messages).usage.output_tokens).to eq(10) end it "populates cost as UsageCost" do expect(adapter.chat(messages).usage.cost).to be_a(Dispatch::Adapter::UsageCost) end it "cost.total is a positive number" do expect(adapter.chat(messages).usage.cost.total).to be > 0 end end describe "cache token fields" do before { stub_messages(cache_response) } it "sets cache_read_tokens" do expect(adapter.chat(messages).usage.cache_read_tokens).to eq(200) end it "sets cache_creation_tokens" do expect(adapter.chat(messages).usage.cache_creation_tokens).to eq(400) end it "includes cache costs in cost.total" do cost = adapter.chat(messages).usage.cost expect(cost.cache_read).to be > 0 expect(cost.cache_write).to be > 0 end end # ── Error propagation ────────────────────────────────────────────────────── describe "error handling" do it "raises AuthenticationError on 401" do stub_messages({ "error" => { "message" => "Unauthorized" } }, status: 401) expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) end it "raises RateLimitError on 429" do stub_messages({ "error" => { "message" => "Rate limited" } }, status: 429) expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) end it "raises ServerError on 500" do stub_messages({ "error" => { "message" => "Server error" } }, status: 500) expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) end end # ── Request body sent to API ────────────────────────────────────────────── describe "request body construction" do before { stub_messages(text_response) } it "includes model in the request body" do adapter.chat(messages) expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") .with { |req| JSON.parse(req.body)["model"] == model_id }) end it "includes messages in the request body" do adapter.chat(messages) expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") .with { |req| JSON.parse(req.body)["messages"].is_a?(Array) }) end it "sets stream: false in the request body" do adapter.chat(messages) expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") .with { |req| JSON.parse(req.body)["stream"] == false }) end it "includes max_tokens in the request body" do adapter.chat(messages) expect(WebMock).to(have_requested(:post, "#{base_url}/v1/messages") .with { |req| JSON.parse(req.body).key?("max_tokens") }) end end end