# frozen_string_literal: true require "webmock/rspec" # ── Helpers ──────────────────────────────────────────────────────────────────── module RequestBuilderSpecHelpers def msg(role, *blocks) Dispatch::Adapter::Message.new(role: role, content: blocks) end def text_msg(role, text) Dispatch::Adapter::Message.new( role: role, content: [Dispatch::Adapter::TextBlock.new(text: text)] ) end def tool(name:, description: "A tool", params: nil) Dispatch::Adapter::ToolDefinition.new( name: name, description: description, parameters: params || { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] } ) end def build(model_id:, messages:, system: nil, tools: [], is_oauth: false, stream: false, max_tokens: nil, thinking: nil, tool_choice: nil, cache_retention: nil, metadata: nil, disable_strict_tools: false, base_url: "https://api.anthropic.com") Dispatch::Adapter::Claude::RequestBuilder.build( model_id: model_id, messages: messages, system: system, tools: tools, is_oauth: is_oauth, base_url: base_url, stream: stream, max_tokens: max_tokens, thinking: thinking, tool_choice: tool_choice, cache_retention: cache_retention, metadata: metadata, disable_strict_tools: disable_strict_tools ) end end RSpec.describe Dispatch::Adapter::Claude::RequestBuilder, ".build" do include RequestBuilderSpecHelpers let(:sonnet_id) { "claude-sonnet-4-5-20250929" } let(:opus47_id) { "claude-opus-4-7-20251018" } let(:haiku_id) { "claude-3-5-haiku-20241022" } # ── Scenario 1: simple-text ────────────────────────────────────────────────── describe "scenario 1: simple-text (single user message, no tools, OAuth)" do let(:params) do build( model_id: sonnet_id, messages: [text_msg("user", "Hello!")], is_oauth: true ) end it "has the correct model" do expect(params[:model]).to eq(sonnet_id) end it "has stream: false" do expect(params[:stream]).to be(false) end it "has a positive max_tokens" do expect(params[:max_tokens]).to be > 0 end it "has one message with role user" do expect(params[:messages].length).to eq(1) expect(params[:messages][0][:role]).to eq("user") end it "user message content contains a text block" do content = params[:messages][0][:content] expect(content).to be_an(Array) text_block = content.find { |b| b[:type] == "text" } expect(text_block).not_to be_nil expect(text_block[:text]).to eq("Hello!") end it "has no tools key" do expect(params).not_to have_key(:tools) end it "has OAuth system blocks (billing + agent + user)" do expect(params[:system]).to be_an(Array) expect(params[:system].length).to eq(2) # billing + agent (no user system provided) expect(params[:system][0]["text"]).to start_with("x-anthropic-billing-header:") end it "has a metadata user_id in cloaking format" do uid = params.dig(:metadata, :user_id) expect(uid).not_to be_nil expect(Dispatch::Adapter::Claude::Cloaking.cloaking_user_id?(uid)).to be(true) end end # ── Scenario 2: with-tools-strict ─────────────────────────────────────────── describe "scenario 2: with-tools-strict (2 strict-eligible + 1 non-strict tool)" do let(:strict_schema) do { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] } end let(:tools) do [ Dispatch::Adapter::ToolDefinition.new(name: "bash", description: "Run bash", parameters: strict_schema), Dispatch::Adapter::ToolDefinition.new(name: "edit", description: "Edit file", parameters: strict_schema), Dispatch::Adapter::ToolDefinition.new(name: "grep", description: "Search text", parameters: strict_schema) ] end let(:params) do build( model_id: sonnet_id, messages: [text_msg("user", "Run it")], tools: tools, is_oauth: false ) end it "includes all 3 tools" do expect(params[:tools].length).to eq(3) end it "sets strict: true for bash" do wire = params[:tools].find { |t| t[:name] == "bash" } expect(wire[:strict]).to eq(true) end it "sets strict: true for edit" do wire = params[:tools].find { |t| t[:name] == "edit" } expect(wire[:strict]).to eq(true) end it "does NOT set strict for grep" do wire = params[:tools].find { |t| t[:name] == "grep" } expect(wire[:strict]).to be_nil end it "sets additionalProperties: false on all tools" do params[:tools].each do |t| expect(t[:input_schema]["additionalProperties"]).to eq(false) end end end # ── Scenario 3: forced-tool-choice strips thinking ─────────────────────────── describe "scenario 3: forced-tool-choice (tool_choice: {type: :tool, name: 'edit'} strips thinking)" do let(:tools) do [Dispatch::Adapter::ToolDefinition.new( name: "edit", description: "Edit", parameters: { "type" => "object", "properties" => {}, "required" => [] } )] end let(:params) do build( model_id: sonnet_id, messages: [text_msg("user", "Edit this")], tools: tools, thinking: { type: :enabled, budget_tokens: 4096 }, tool_choice: { type: :tool, name: "edit" }, is_oauth: false, max_tokens: 16_000 ) end it "does NOT include thinking key" do expect(params).not_to have_key(:thinking) end it "does NOT include output_config key" do expect(params).not_to have_key(:output_config) end it "includes tool_choice with type: 'tool'" do expect(params[:tool_choice]).to eq({ type: "tool", name: "edit" }) end end # ── Scenario 4: assistant thinking roundtrip ───────────────────────────────── describe "scenario 4: assistant-thinking-roundtrip" do let(:messages) do [ text_msg("user", "Think about this"), Dispatch::Adapter::Message.new( role: "assistant", content: [ Dispatch::Adapter::ThinkingBlock.new( thinking: "I am reasoning...", signature: "sig-abc-123" ), Dispatch::Adapter::TextBlock.new(text: "My answer"), Dispatch::Adapter::ToolUseBlock.new( id: "toolu_01", name: "bash", arguments: { "cmd" => "ls" } ) ] ), Dispatch::Adapter::Message.new( role: "user", content: [ Dispatch::Adapter::ToolResultBlock.new( tool_use_id: "toolu_01", content: "file1.txt\nfile2.txt" ) ] ) ] end let(:params) do build( model_id: sonnet_id, messages: messages, is_oauth: false ) end it "has 3 wire messages" do expect(params[:messages].length).to eq(3) end it "first message is user" do expect(params[:messages][0][:role]).to eq("user") end it "second message is assistant" do expect(params[:messages][1][:role]).to eq("assistant") end it "assistant message has thinking block" do content = params[:messages][1][:content] thinking = content.find { |b| b[:type] == "thinking" } expect(thinking).not_to be_nil expect(thinking[:thinking]).to eq("I am reasoning...") expect(thinking[:signature]).to eq("sig-abc-123") end it "assistant message has text block" do content = params[:messages][1][:content] text = content.find { |b| b[:type] == "text" } expect(text[:text]).to eq("My answer") end it "assistant message has tool_use block" do content = params[:messages][1][:content] tu = content.find { |b| b[:type] == "tool_use" } expect(tu[:name]).to eq("bash") expect(tu[:id]).to eq("toolu_01") end it "third message is user with tool_result" do msg = params[:messages][2] expect(msg[:role]).to eq("user") tr = msg[:content].find { |b| b[:type] == "tool_result" } expect(tr[:tool_use_id]).to eq("toolu_01") end end # ── Scenario 5: tool-results-batched ──────────────────────────────────────── describe "scenario 5: tool-results-batched (3 consecutive tool-result messages)" do let(:messages) do [ text_msg("user", "Do things"), Dispatch::Adapter::Message.new( role: "assistant", content: [ Dispatch::Adapter::ToolUseBlock.new(id: "t1", name: "bash", arguments: { "cmd" => "ls" }), Dispatch::Adapter::ToolUseBlock.new(id: "t2", name: "bash", arguments: { "cmd" => "pwd" }), Dispatch::Adapter::ToolUseBlock.new(id: "t3", name: "bash", arguments: { "cmd" => "echo hi" }) ] ), Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t1", content: "out1")] ), Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t2", content: "out2")] ), Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::ToolResultBlock.new(tool_use_id: "t3", content: "out3")] ) ] end let(:params) do build( model_id: sonnet_id, messages: messages, is_oauth: false ) end it "has exactly 3 wire messages (user, assistant, batched-user)" do expect(params[:messages].length).to eq(3) end it "the last wire message is role: user" do expect(params[:messages].last[:role]).to eq("user") end it "the batched user message contains all 3 tool_result blocks" do content = params[:messages].last[:content] tr_blocks = content.select { |b| b[:type] == "tool_result" } expect(tr_blocks.length).to eq(3) end it "the batched tool_results have the right tool_use_ids" do content = params[:messages].last[:content] ids = content.select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] } expect(ids).to contain_exactly("t1", "t2", "t3") end end # ── Scenario 6: cache-retention-long ──────────────────────────────────────── describe "scenario 6: cache-retention-long (last user message gets ttl: '1h')" do let(:tool_with_desc) do Dispatch::Adapter::ToolDefinition.new( name: "bash", description: "Run bash", parameters: { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] } ) end let(:params) do build( model_id: sonnet_id, messages: [text_msg("user", "Hello")], system: "Be helpful.", tools: [tool_with_desc], is_oauth: false, cache_retention: :long, base_url: "https://api.anthropic.com" ) end it "the last system block has cache_control with type: ephemeral" do last_system = params[:system]&.last expect(last_system).not_to be_nil cc = last_system["cache_control"] expect(cc).not_to be_nil expect(cc["type"]).to eq("ephemeral") end it "either system or tool or messages has a cache_control with ttl: '1h'" do # find any cache_control with ttl 1h anywhere in the body all_ccs = Array(params[:system]).map { |b| b["cache_control"] } Array(params[:tools]).each { |t| all_ccs << t[:cache_control] } Array(params[:messages]).each do |m| Array(m[:content]).each { |b| all_ccs << b[:cache_control] } end all_ccs.compact! expect(all_ccs.any? { |cc| cc && cc["ttl"] == "1h" }).to be(true) end end # ── Scenario 7: image-block stripped when vision not supported ─────────────── describe "scenario 7: image-block-stripped-when-vision-off" do let(:vision_off_model_id) { "claude-text-only-fake" } let(:vision_off_model_info) do Dispatch::Adapter::ModelInfo.new( id: vision_off_model_id, name: "Text Only", max_context_tokens: 200_000, supports_vision: false, supports_tool_use: true, supports_streaming: true, pricing: nil ) end let(:messages) do [ Dispatch::Adapter::Message.new( role: "user", content: [ Dispatch::Adapter::TextBlock.new(text: "Describe this image"), Dispatch::Adapter::ImageBlock.new( source: "base64data", media_type: "image/png" ) ] ) ] end before do allow(Dispatch::Adapter::Claude::ModelCatalog).to receive(:build) .with(vision_off_model_id) .and_return(vision_off_model_info) end let(:params) do build( model_id: vision_off_model_id, messages: messages, is_oauth: false ) end it "strips the image block from the user message" do content = params[:messages][0][:content] types = content.map { |b| b[:type] } expect(types).not_to include("image") end it "retains the text block" do content = params[:messages][0][:content] text_block = content.find { |b| b[:type] == "text" } expect(text_block[:text]).to eq("Describe this image") end end # ── Scenario 8: opus-4.7-adaptive thinking ─────────────────────────────────── describe "scenario 8: opus-4.7-adaptive (thinking: 'high' → adaptive + output_config)" do let(:params) do build( model_id: opus47_id, messages: [text_msg("user", "Think hard")], thinking: "high", is_oauth: false ) end it "sets thinking type to adaptive" do expect(params[:thinking]).not_to be_nil expect(params[:thinking][:type]).to eq("adaptive") end it "sets thinking display to summarized" do expect(params[:thinking][:display]).to eq("summarized") end it "sets output_config with effort: high" do expect(params[:output_config]).not_to be_nil expect(params[:output_config][:effort]).to eq("high") end end # ── Scenario 9: haiku-no-agent-instruction ─────────────────────────────────── describe "scenario 9: haiku-no-agent-instruction (system: billing + user only)" do let(:params) do build( model_id: haiku_id, messages: [text_msg("user", "Hello")], system: "Be concise.", is_oauth: true ) end it "has exactly 2 system blocks (billing + user)" do expect(params[:system].length).to eq(2) end it "first block is billing header" do expect(params[:system][0]["text"]).to start_with("x-anthropic-billing-header:") end it "second block is user system text" do expect(params[:system][1]["text"]).to eq("Be concise.") end it "does NOT include an agent instruction block" do texts = params[:system].map { |b| b["text"] } expect(texts.any? { |t| t.include?("Claude agent") }).to be(false) end end # ── Scenario 10: api-key-mode-no-cloaking ──────────────────────────────────── describe "scenario 10: api-key-mode-no-cloaking" do let(:tools) do [ Dispatch::Adapter::ToolDefinition.new( name: "my_custom_tool", description: "Custom", parameters: { "type" => "object", "properties" => {}, "required" => [] } ) ] end let(:params) do build( model_id: sonnet_id, messages: [text_msg("user", "Go")], system: "System prompt.", tools: tools, is_oauth: false ) end it "has exactly 1 system block" do expect(params[:system].length).to eq(1) end it "system block is just the user system" do expect(params[:system][0]["text"]).to eq("System prompt.") end it "tool name is NOT proxy-prefixed" do tool_wire = params[:tools][0] expect(tool_wire[:name]).to eq("my_custom_tool") end it "does NOT include metadata user_id" do expect(params.dig(:metadata, :user_id)).to be_nil end it "system block has no billing header" do expect(params[:system][0]["text"]).not_to start_with("x-anthropic-billing-header:") end end end