# frozen_string_literal: true require "webmock/rspec" RSpec.describe Dispatch::Adapter::Claude, "#count_tokens" do let(:model_id) { "claude-sonnet-4-6" } let(:api_key) { "sk-ant-api03-test" } let(:base_url) { "https://api.anthropic.com" } let(:count_tokens_url) { "#{base_url}/v1/messages/count_tokens" } subject(:adapter) do described_class.new( model: model_id, api_key: api_key, base_url: base_url ) end let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hello, how many tokens?")] )] end before { WebMock.disable_net_connect! } after { WebMock.reset! } # ── Happy path ──────────────────────────────────────────────────────────── describe "successful count" do before do stub_request(:post, count_tokens_url) .to_return( status: 200, body: JSON.generate({ "input_tokens" => 1234 }), headers: { "Content-Type" => "application/json" } ) end it "returns the integer token count" do expect(adapter.count_tokens(messages)).to eq(1234) end it "posts to /v1/messages/count_tokens (not /v1/messages)" do adapter.count_tokens(messages) expect(WebMock).to have_requested(:post, count_tokens_url) expect(WebMock).not_to have_requested(:post, "#{base_url}/v1/messages") end it "includes the model in the request body" do adapter.count_tokens(messages) expect(WebMock).to(have_requested(:post, count_tokens_url) .with { |req| JSON.parse(req.body)["model"] == model_id }) end it "includes messages in the request body" do adapter.count_tokens(messages) expect(WebMock).to(have_requested(:post, count_tokens_url) .with { |req| JSON.parse(req.body)["messages"].is_a?(Array) }) end end # ── Unsupported fields stripped ─────────────────────────────────────────── describe "stripped fields" do before do stub_request(:post, count_tokens_url) .to_return( status: 200, body: JSON.generate({ "input_tokens" => 42 }), headers: { "Content-Type" => "application/json" } ) end it "does NOT include 'stream' in the request body" do adapter.count_tokens(messages) expect(WebMock).to(have_requested(:post, count_tokens_url) .with { |req| !JSON.parse(req.body).key?("stream") }) end it "does NOT include 'max_tokens' in the request body" do adapter.count_tokens(messages) expect(WebMock).to(have_requested(:post, count_tokens_url) .with { |req| !JSON.parse(req.body).key?("max_tokens") }) end it "does NOT include 'metadata' in the request body" do adapter.count_tokens(messages) expect(WebMock).to(have_requested(:post, count_tokens_url) .with { |req| !JSON.parse(req.body).key?("metadata") }) end it "does NOT include 'output_config' in the request body" do adapter.count_tokens(messages) expect(WebMock).to(have_requested(:post, count_tokens_url) .with { |req| !JSON.parse(req.body).key?("output_config") }) end end # ── With tools ──────────────────────────────────────────────────────────── describe "with tools" do let(:tool) do Dispatch::Adapter::ToolDefinition.new( name: "bash", description: "Run a bash command", parameters: { "type" => "object", "properties" => { "command" => { "type" => "string" } }, "required" => ["command"] } ) end before do stub_request(:post, count_tokens_url) .to_return( status: 200, body: JSON.generate({ "input_tokens" => 500 }), headers: { "Content-Type" => "application/json" } ) end it "includes tools in the request body" do adapter.count_tokens(messages, tools: [tool]) expect(WebMock).to(have_requested(:post, count_tokens_url) .with do |req| body = JSON.parse(req.body) body.key?("tools") && body["tools"].is_a?(Array) && body["tools"].length == 1 end) end it "returns the token count" do expect(adapter.count_tokens(messages, tools: [tool])).to eq(500) end end # ── With system prompt ──────────────────────────────────────────────────── describe "with system prompt" do before do stub_request(:post, count_tokens_url) .to_return( status: 200, body: JSON.generate({ "input_tokens" => 300 }), headers: { "Content-Type" => "application/json" } ) end it "includes system in the request body" do adapter.count_tokens(messages, system: "You are a helpful assistant") expect(WebMock).to(have_requested(:post, count_tokens_url) .with do |req| body = JSON.parse(req.body) body.key?("system") end) end it "returns the token count" do expect(adapter.count_tokens(messages, system: "Be helpful")).to eq(300) end end # ── Error handling ──────────────────────────────────────────────────────── describe "network failure" do before do stub_request(:post, count_tokens_url).to_raise(Errno::ECONNREFUSED) end it "returns -1 without raising" do expect(adapter.count_tokens(messages)).to eq(-1) end end describe "HTTP error response" do before do stub_request(:post, count_tokens_url) .to_return( status: 401, body: JSON.generate({ "error" => { "message" => "Unauthorized" } }), headers: { "Content-Type" => "application/json" } ) end it "returns -1 on AuthenticationError" do expect(adapter.count_tokens(messages)).to eq(-1) end end describe "server error" do before do stub_request(:post, count_tokens_url) .to_return( status: 500, body: JSON.generate({ "error" => { "message" => "Internal error" } }), headers: { "Content-Type" => "application/json" } ) end it "returns -1 on ServerError" do expect(adapter.count_tokens(messages)).to eq(-1) end end describe "malformed JSON response" do before do stub_request(:post, count_tokens_url) .to_return(status: 200, body: "not json", headers: {}) end it "returns -1 on JSON parse error" do expect(adapter.count_tokens(messages)).to eq(-1) end end # ── Default -1 from Base ────────────────────────────────────────────────── describe "Base#count_tokens default" do it "returns -1 as the default implementation in Base" do base = Dispatch::Adapter::Base.new expect(base.count_tokens([])).to eq(-1) end end end