# frozen_string_literal: true require "webmock/rspec" CHAT_NON_STREAMING_FIXTURES_DIR = File.expand_path("../../../fixtures/responses", __dir__) RSpec.describe Dispatch::Adapter::Claude, "#chat (non-streaming integration)" do def load_fixture(filename) JSON.parse(File.read(File.join(CHAT_NON_STREAMING_FIXTURES_DIR, filename))) end let(:model_id) { "claude-sonnet-4-5-20250929" } let(:base_url) { "https://api.anthropic.com" } let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "What is the capital of France?")] )] end def make_adapter(api_key: "sk-ant-api03-test") described_class.new( model: model_id, api_key: api_key, base_url: base_url ) end def make_oauth_adapter # Build a temporary token store with a fake OAuth credential tmpdir = Dir.mktmpdir token_path = File.join(tmpdir, "oauth.json") creds = { "access_token" => "sk-ant-oat01-fake-oauth-token", "refresh_token" => "rt-fake", "expires_at_ms" => ((Time.now.to_f * 1000) + 3_600_000).to_i, "account_id" => nil, "email" => nil } File.write(token_path, JSON.generate(creds)) described_class.new( model: model_id, token_path: token_path, base_url: base_url ) end before { WebMock.disable_net_connect! } after { WebMock.reset! } def stub_messages_fixture(filename, status: 200, extra_headers: {}) body = File.read(File.join(CHAT_NON_STREAMING_FIXTURES_DIR, filename)) stub_request(:post, "#{base_url}/v1/messages") .to_return( status: status, body: body, headers: { "Content-Type" => "application/json" }.merge(extra_headers) ) end def stub_error(status:, message:, error_type: "invalid_request_error", extra_headers: {}) stub_request(:post, "#{base_url}/v1/messages") .to_return( status: status, body: JSON.generate({ "type" => "error", "error" => { "type" => error_type, "message" => message } }), headers: { "Content-Type" => "application/json" }.merge(extra_headers) ) end # ── Scenario 1: text-only ──────────────────────────────────────────────────── describe "scenario 1: text-only (messages-text.json)" do let(:fixture) { load_fixture("messages-text.json") } let(:adapter) { make_adapter } before { stub_messages_fixture("messages-text.json") } it "returns a Response" do expect(adapter.chat(messages)).to be_a(Dispatch::Adapter::Response) end it "has stop_reason :end_turn" do expect(adapter.chat(messages).stop_reason).to eq(:end_turn) end it "has content with one TextBlock" do content = adapter.chat(messages).content expect(content.length).to eq(1) expect(content.first).to be_a(Dispatch::Adapter::TextBlock) end it "has the correct text from the fixture" do expect(adapter.chat(messages).content.first.text).to eq(fixture["content"][0]["text"]) end it "has non-zero input_tokens" do expect(adapter.chat(messages).usage.input_tokens).to eq(fixture["usage"]["input_tokens"]) end it "has non-zero output_tokens" do expect(adapter.chat(messages).usage.output_tokens).to eq(fixture["usage"]["output_tokens"]) end it "usage.cost is a UsageCost with positive total" do cost = adapter.chat(messages).usage.cost expect(cost).to be_a(Dispatch::Adapter::UsageCost) expect(cost.total).to be > 0 end end # ── Scenario 2: tool_use with proxy_ stripping ─────────────────────────────── describe "scenario 2: tool_use (messages-tool-use.json, OAuth → proxy_ stripped)" do let(:fixture) { load_fixture("messages-tool-use.json") } let(:adapter) { make_oauth_adapter } before { stub_messages_fixture("messages-tool-use.json") } it "has stop_reason :tool_use" do expect(adapter.chat(messages).stop_reason).to eq(:tool_use) end it "has one tool_call in tool_calls" do expect(adapter.chat(messages).tool_calls.length).to eq(1) end it "tool_call is a ToolUseBlock" do tc = adapter.chat(messages).tool_calls.first expect(tc).to be_a(Dispatch::Adapter::ToolUseBlock) end it "tool_call id matches fixture" do tc = adapter.chat(messages).tool_calls.first expect(tc.id).to eq(fixture["content"][0]["id"]) end it "tool_call name has proxy_ stripped (OAuth mode)" do tc = adapter.chat(messages).tool_calls.first # fixture has "proxy_bash", OAuth stripping removes "proxy_" expect(tc.name).to eq("bash") expect(tc.name).not_to start_with("proxy_") end it "tool_call arguments match fixture input" do tc = adapter.chat(messages).tool_calls.first expect(tc.arguments).to eq(fixture["content"][0]["input"]) end it "has empty content (tool_use goes to tool_calls)" do expect(adapter.chat(messages).content).to be_empty end end # ── Scenario 3: with-thinking ──────────────────────────────────────────────── describe "scenario 3: with-thinking (messages-with-thinking.json)" do let(:fixture) { load_fixture("messages-with-thinking.json") } let(:adapter) { make_adapter } before { stub_messages_fixture("messages-with-thinking.json") } it "has stop_reason :end_turn" do expect(adapter.chat(messages).stop_reason).to eq(:end_turn) end it "has 2 content items (thinking + text)" do expect(adapter.chat(messages).content.length).to eq(2) end it "first content item is a ThinkingBlock" do content = adapter.chat(messages).content expect(content[0]).to be_a(Dispatch::Adapter::ThinkingBlock) end it "thinking block has the correct thinking text" do content = adapter.chat(messages).content expect(content[0].thinking).to eq(fixture["content"][0]["thinking"]) end it "thinking block has the correct signature" do content = adapter.chat(messages).content expect(content[0].signature).to eq(fixture["content"][0]["signature"]) end it "second content item is a TextBlock" do content = adapter.chat(messages).content expect(content[1]).to be_a(Dispatch::Adapter::TextBlock) end it "text block has the correct text" do content = adapter.chat(messages).content expect(content[1].text).to eq(fixture["content"][1]["text"]) end end # ── Scenario 4: error mapping ──────────────────────────────────────────────── describe "scenario 4: error mapping" do let(:adapter) { make_adapter } it "401 → AuthenticationError" do stub_error(status: 401, message: "Invalid API key") expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::AuthenticationError) end it "401 AuthenticationError has status_code 401" do stub_error(status: 401, message: "Invalid API key") begin adapter.chat(messages) rescue Dispatch::Adapter::AuthenticationError => e expect(e.status_code).to eq(401) end end it "429 → RateLimitError" do stub_error(status: 429, message: "Rate limit exceeded") expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::RateLimitError) end it "429 with Retry-After: 30 → RateLimitError(retry_after: 30)" do stub_error(status: 429, message: "Rate limit exceeded", extra_headers: { "Retry-After" => "30" }) begin adapter.chat(messages) rescue Dispatch::Adapter::RateLimitError => e expect(e.retry_after).to eq(30) end end it "529 → OverloadedError" do stub_error(status: 529, message: "Overloaded") expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::OverloadedError) end it "529 with Retry-After: 10 → OverloadedError(retry_after: 10)" do stub_error(status: 529, message: "Overloaded", extra_headers: { "Retry-After" => "10" }) begin adapter.chat(messages) rescue Dispatch::Adapter::OverloadedError => e expect(e.retry_after).to eq(10) end end it "500 → ServerError" do stub_error(status: 500, message: "Internal server error") expect { adapter.chat(messages) }.to raise_error(Dispatch::Adapter::ServerError) end it "WebMock prevents any real network access" do stub_error(status: 200, message: "OK") # WebMock is already enabled; just confirm the adapter works without hitting the real API expect { stub_error(status: 200, message: "OK") }.not_to raise_error end end end