diff --git a/lib/ruby_llm/chat.rb b/lib/ruby_llm/chat.rb index 3b5bfa83a..fdb719b03 100644 --- a/lib/ruby_llm/chat.rb +++ b/lib/ruby_llm/chat.rb @@ -125,6 +125,7 @@ def reset_messages! private def handle_tool_calls(response, &) + # Execute all tools and collect results first response.tool_calls.each_value do |tool_call| @on[:new_message]&.call result = execute_tool tool_call @@ -132,6 +133,7 @@ def handle_tool_calls(response, &) @on[:end_message]&.call(message) end + # Make a single API call after all tools have been executed complete(&) end diff --git a/lib/ruby_llm/providers/anthropic/chat.rb b/lib/ruby_llm/providers/anthropic/chat.rb index 2ba96009d..6940853fe 100644 --- a/lib/ruby_llm/providers/anthropic/chat.rb +++ b/lib/ruby_llm/providers/anthropic/chat.rb @@ -55,9 +55,9 @@ def parse_completion_response(response) content_blocks = data['content'] || [] text_content = extract_text_content(content_blocks) - tool_use = Tools.find_tool_use(content_blocks) + tool_use_blocks = content_blocks.select { |c| c['type'] == 'tool_use' } - build_message(data, text_content, tool_use) + build_message(data, text_content, tool_use_blocks) end def extract_text_content(blocks) @@ -65,11 +65,11 @@ def extract_text_content(blocks) text_blocks.map { |c| c['text'] }.join end - def build_message(data, content, tool_use) + def build_message(data, content, tool_use_blocks) Message.new( role: :assistant, content: content, - tool_calls: Tools.parse_tool_calls(tool_use), + tool_calls: Tools.parse_tool_calls(tool_use_blocks), input_tokens: data.dig('usage', 'input_tokens'), output_tokens: data.dig('usage', 'output_tokens'), model_id: data['model'] diff --git a/lib/ruby_llm/providers/anthropic/tools.rb b/lib/ruby_llm/providers/anthropic/tools.rb index 7fce52836..3b1a5075f 100644 --- a/lib/ruby_llm/providers/anthropic/tools.rb +++ b/lib/ruby_llm/providers/anthropic/tools.rb @@ -12,15 +12,19 @@ def find_tool_use(blocks) end def format_tool_call(msg) - tool_call = msg.tool_calls.values.first - content = [] - content << Media.format_text(msg.content) unless msg.content.nil? || msg.content.empty? - content << format_tool_use_block(tool_call) + + # Only add text content if it's not empty + content << Media.format_text(msg.content) if msg.content && !msg.content.to_s.empty? + + # Add all tool use blocks, not just the first one + msg.tool_calls.each_value do |tool_call| + content << format_tool_use_block(tool_call) + end { role: 'assistant', - content: + content: content } end @@ -68,16 +72,24 @@ def extract_tool_calls(data) end end - def parse_tool_calls(content_block) - return nil unless content_block && content_block['type'] == 'tool_use' + def parse_tool_calls(content_blocks) + return nil if content_blocks.nil? - { - content_block['id'] => ToolCall.new( - id: content_block['id'], - name: content_block['name'], - arguments: content_block['input'] + # Handle single content block (backward compatibility) + content_blocks = [content_blocks] unless content_blocks.is_a?(Array) + + tool_calls = {} + content_blocks.each do |block| + next unless block && block['type'] == 'tool_use' + + tool_calls[block['id']] = ToolCall.new( + id: block['id'], + name: block['name'], + arguments: block['input'] ) - } + end + + tool_calls.empty? ? nil : tool_calls end def clean_parameters(parameters) diff --git a/spec/fixtures/vcr_cassettes/chat_function_calling_anthropic_claude-3-5-haiku-20241022_can_handle_multiple_tool_calls_in_a_single_response.yml b/spec/fixtures/vcr_cassettes/chat_function_calling_anthropic_claude-3-5-haiku-20241022_can_handle_multiple_tool_calls_in_a_single_response.yml new file mode 100644 index 000000000..dcca5941d --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_function_calling_anthropic_claude-3-5-haiku-20241022_can_handle_multiple_tool_calls_in_a_single_response.yml @@ -0,0 +1,170 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.anthropic.com/v1/messages + body: + encoding: UTF-8 + string: '{"model":"claude-3-5-haiku-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Roll + the dice 3 times"}]}],"temperature":0.7,"stream":false,"max_tokens":8192,"tools":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","input_schema":{"type":"object","properties":{},"required":[]}}],"system":"{type: + \"text\", text: \"You must call the dice_roll tool exactly 3 times when asked + to roll dice 3 times.\"}"}' + headers: + User-Agent: + - Faraday v2.12.2 + X-Api-Key: + - "" + Anthropic-Version: + - '2023-06-01' + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:30:48 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Anthropic-Ratelimit-Input-Tokens-Limit: + - '400000' + Anthropic-Ratelimit-Input-Tokens-Remaining: + - '400000' + Anthropic-Ratelimit-Input-Tokens-Reset: + - '2025-06-10T20:30:47Z' + Anthropic-Ratelimit-Output-Tokens-Limit: + - '80000' + Anthropic-Ratelimit-Output-Tokens-Remaining: + - '80000' + Anthropic-Ratelimit-Output-Tokens-Reset: + - '2025-06-10T20:30:48Z' + Anthropic-Ratelimit-Requests-Limit: + - '4000' + Anthropic-Ratelimit-Requests-Remaining: + - '3999' + Anthropic-Ratelimit-Requests-Reset: + - '2025-06-10T20:30:46Z' + Anthropic-Ratelimit-Tokens-Limit: + - '480000' + Anthropic-Ratelimit-Tokens-Remaining: + - '480000' + Anthropic-Ratelimit-Tokens-Reset: + - '2025-06-10T20:30:47Z' + Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Anthropic-Organization-Id: + - 0ca8b3c1-9af3-46f0-99fc-e3a6df276ff6 + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + X-Robots-Tag: + - none + Server: + - cloudflare + Cf-Ray: + - "" + body: + encoding: ASCII-8BIT + string: '{"id":"msg_01HarzmMXEZcE8q61vzedQEw","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"text","text":"I''ll + roll the dice three times for you using the dice_roll tool."},{"type":"tool_use","id":"toolu_01TTBFAGitKFwKCXDbher1Pj","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_01XpAWEKikDnDB6mTVZ9XQhv","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_011n2vjgnt8tZJrjVx5etoEM","name":"dice_roll","input":{}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":362,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":91,"service_tier":"standard"}}' + recorded_at: Tue, 10 Jun 2025 20:30:48 GMT +- request: + method: post + uri: https://api.anthropic.com/v1/messages + body: + encoding: UTF-8 + string: '{"model":"claude-3-5-haiku-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Roll + the dice 3 times"}]},{"role":"assistant","content":[{"type":"text","text":"I''ll + roll the dice three times for you using the dice_roll tool."},{"type":"tool_use","id":"toolu_01TTBFAGitKFwKCXDbher1Pj","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_01XpAWEKikDnDB6mTVZ9XQhv","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_011n2vjgnt8tZJrjVx5etoEM","name":"dice_roll","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01TTBFAGitKFwKCXDbher1Pj","content":"{roll: + 4}"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01XpAWEKikDnDB6mTVZ9XQhv","content":"{roll: + 6}"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_011n2vjgnt8tZJrjVx5etoEM","content":"{roll: + 1}"}]}],"temperature":0.7,"stream":false,"max_tokens":8192,"tools":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","input_schema":{"type":"object","properties":{},"required":[]}}],"system":"{type: + \"text\", text: \"You must call the dice_roll tool exactly 3 times when asked + to roll dice 3 times.\"}"}' + headers: + User-Agent: + - Faraday v2.12.2 + X-Api-Key: + - "" + Anthropic-Version: + - '2023-06-01' + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:30:49 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Anthropic-Ratelimit-Input-Tokens-Limit: + - '400000' + Anthropic-Ratelimit-Input-Tokens-Remaining: + - '400000' + Anthropic-Ratelimit-Input-Tokens-Reset: + - '2025-06-10T20:30:48Z' + Anthropic-Ratelimit-Output-Tokens-Limit: + - '80000' + Anthropic-Ratelimit-Output-Tokens-Remaining: + - '80000' + Anthropic-Ratelimit-Output-Tokens-Reset: + - '2025-06-10T20:30:49Z' + Anthropic-Ratelimit-Requests-Limit: + - '4000' + Anthropic-Ratelimit-Requests-Remaining: + - '3999' + Anthropic-Ratelimit-Requests-Reset: + - '2025-06-10T20:30:48Z' + Anthropic-Ratelimit-Tokens-Limit: + - '480000' + Anthropic-Ratelimit-Tokens-Remaining: + - '480000' + Anthropic-Ratelimit-Tokens-Reset: + - '2025-06-10T20:30:48Z' + Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Anthropic-Organization-Id: + - 0ca8b3c1-9af3-46f0-99fc-e3a6df276ff6 + Via: + - 1.1 google + Cf-Cache-Status: + - DYNAMIC + X-Robots-Tag: + - none + Server: + - cloudflare + Cf-Ray: + - "" + body: + encoding: ASCII-8BIT + string: '{"id":"msg_01TPH41MXjaCh5HJWAeMmi1B","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"text","text":"Here + are the results of the three dice rolls:\n1st roll: 4\n2nd roll: 6\n3rd roll: + 1"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":552,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":35,"service_tier":"standard"}}' + recorded_at: Tue, 10 Jun 2025 20:30:49 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/chat_function_calling_bedrock_anthropic_claude-3-5-haiku-20241022-v1_0_can_handle_multiple_tool_calls_in_a_single_response.yml b/spec/fixtures/vcr_cassettes/chat_function_calling_bedrock_anthropic_claude-3-5-haiku-20241022-v1_0_can_handle_multiple_tool_calls_in_a_single_response.yml new file mode 100644 index 000000000..82d8c91ea --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_function_calling_bedrock_anthropic_claude-3-5-haiku-20241022-v1_0_can_handle_multiple_tool_calls_in_a_single_response.yml @@ -0,0 +1,116 @@ +--- +http_interactions: +- request: + method: post + uri: https://bedrock-runtime..amazonaws.com/model/anthropic.claude-3-5-haiku-20241022-v1:0/invoke + body: + encoding: UTF-8 + string: '{"anthropic_version":"bedrock-2023-05-31","messages":[{"role":"user","content":[{"type":"text","text":"Roll + the dice 3 times"}]}],"temperature":0.7,"max_tokens":4096,"tools":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","input_schema":{"type":"object","properties":{},"required":[]}}],"system":"{type: + \"text\", text: \"You must call the dice_roll tool exactly 3 times when asked + to roll dice 3 times.\"}"}' + headers: + User-Agent: + - Faraday v2.12.2 + Host: + - bedrock-runtime..amazonaws.com + X-Amz-Date: + - 20250610T203049Z + X-Amz-Content-Sha256: + - 7032d52005bf8d75fa9174c7f5272301beef534c231c34ba6f7d37fe48a7231e + Authorization: + - AWS4-HMAC-SHA256 Credential=/20250610//bedrock/aws4_request, + SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=b229f1ee935caa96f56dedd9fb0f570cc8e9c2e6dd685e850c8fce4212f3e4f9 + Content-Type: + - application/json + Accept: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:30:49 GMT + Content-Type: + - application/json + Content-Length: + - '651' + Connection: + - keep-alive + X-Amzn-Requestid: + - 9262f4e7-921f-4531-b9dc-575b18a69f05 + X-Amzn-Bedrock-Invocation-Latency: + - '1670' + X-Amzn-Bedrock-Output-Token-Count: + - '91' + X-Amzn-Bedrock-Input-Token-Count: + - '362' + body: + encoding: UTF-8 + string: '{"id":"msg_bdrk_01HarzmMXEZcE8q61vzedQEw","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"text","text":"I''ll + roll the dice three times for you using the dice_roll tool."},{"type":"tool_use","id":"toolu_bdrk_01TTBFAGitKFwKCXDbher1Pj","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_bdrk_01XpAWEKikDnDB6mTVZ9XQhv","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_bdrk_011n2vjgnt8tZJrjVx5etoEM","name":"dice_roll","input":{}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":362,"output_tokens":91}}' + recorded_at: Tue, 10 Jun 2025 20:30:49 GMT +- request: + method: post + uri: https://bedrock-runtime..amazonaws.com/model/anthropic.claude-3-5-haiku-20241022-v1:0/invoke + body: + encoding: UTF-8 + string: '{"anthropic_version":"bedrock-2023-05-31","messages":[{"role":"user","content":[{"type":"text","text":"Roll + the dice 3 times"}]},{"role":"assistant","content":[{"type":"text","text":"I''ll + roll the dice three times for you using the dice_roll tool."},{"type":"tool_use","id":"toolu_bdrk_01TTBFAGitKFwKCXDbher1Pj","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_bdrk_01XpAWEKikDnDB6mTVZ9XQhv","name":"dice_roll","input":{}},{"type":"tool_use","id":"toolu_bdrk_011n2vjgnt8tZJrjVx5etoEM","name":"dice_roll","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_bdrk_01TTBFAGitKFwKCXDbher1Pj","content":"{roll: + 1}"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_bdrk_01XpAWEKikDnDB6mTVZ9XQhv","content":"{roll: + 2}"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_bdrk_011n2vjgnt8tZJrjVx5etoEM","content":"{roll: + 3}"}]}],"temperature":0.7,"max_tokens":4096,"tools":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","input_schema":{"type":"object","properties":{},"required":[]}}],"system":"{type: + \"text\", text: \"You must call the dice_roll tool exactly 3 times when asked + to roll dice 3 times.\"}"}' + headers: + User-Agent: + - Faraday v2.12.2 + Host: + - bedrock-runtime..amazonaws.com + X-Amz-Date: + - 20250610T203050Z + X-Amz-Content-Sha256: + - 7032d52005bf8d75fa9174c7f5272301beef534c231c34ba6f7d37fe48a7231e + Authorization: + - AWS4-HMAC-SHA256 Credential=/20250610//bedrock/aws4_request, + SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=b229f1ee935caa96f56dedd9fb0f570cc8e9c2e6dd685e850c8fce4212f3e4f9 + Content-Type: + - application/json + Accept: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:30:50 GMT + Content-Type: + - application/json + Content-Length: + - '372' + Connection: + - keep-alive + X-Amzn-Requestid: + - 9262f4e7-921f-4531-b9dc-575b18a69f06 + X-Amzn-Bedrock-Invocation-Latency: + - '1680' + X-Amzn-Bedrock-Output-Token-Count: + - '35' + X-Amzn-Bedrock-Input-Token-Count: + - '552' + body: + encoding: UTF-8 + string: '{"id":"msg_bdrk_01TPH41MXjaCh5HJWAeMmi1B","type":"message","role":"assistant","model":"claude-3-5-haiku-20241022","content":[{"type":"text","text":"Here + are the results of the three dice rolls:\n1st roll: 1\n2nd roll: 2\n3rd roll: + 3"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":552,"output_tokens":35}}' + recorded_at: Tue, 10 Jun 2025 20:30:50 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/chat_function_calling_deepseek_deepseek-chat_can_handle_multiple_tool_calls_in_a_single_response.yml b/spec/fixtures/vcr_cassettes/chat_function_calling_deepseek_deepseek-chat_can_handle_multiple_tool_calls_in_a_single_response.yml new file mode 100644 index 000000000..0d07dc295 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_function_calling_deepseek_deepseek-chat_can_handle_multiple_tool_calls_in_a_single_response.yml @@ -0,0 +1,117 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.deepseek.com/chat/completions + body: + encoding: UTF-8 + string: '{"model":"deepseek-chat","messages":[{"role":"system","content":"You + must call the dice_roll tool exactly 3 times when asked to roll dice 3 times."},{"role":"user","content":"Roll + the dice 3 times"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","parameters":{"type":"object","properties":{},"required":[]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:30:52 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Vary: + - origin, access-control-request-method, access-control-request-headers + Access-Control-Allow-Credentials: + - 'true' + X-Ds-Trace-Id: + - c7ec6bb33a238f7a7a7a1ab3e86d93b1 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + Server: + - cloudflare + Cf-Ray: + - "" + body: + encoding: ASCII-8BIT + string: '{"id":"92d5bebb-bdd7-4a9f-8857-c03eca8ab3cf","object":"chat.completion","created":1749587452,"model":"deepseek-chat","choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"index":0,"id":"call_0_d5663832-ecee-42e1-b3d0-744b0f4e157b","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"index":1,"id":"call_1_8780ed95-db9b-4a19-8c61-1c4a15934506","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"index":2,"id":"call_2_15b44c04-745c-44bb-b603-cf077aed2114","type":"function","function":{"name":"dice_roll","arguments":"{}"}}]},"logprobs":null,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":108,"completion_tokens":48,"total_tokens":156,"prompt_tokens_details":{"cached_tokens":64},"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":44},"system_fingerprint":"fp_8802369eaa_prod0425fp8"}' + recorded_at: Tue, 10 Jun 2025 20:31:10 GMT +- request: + method: post + uri: https://api.deepseek.com/chat/completions + body: + encoding: UTF-8 + string: '{"model":"deepseek-chat","messages":[{"role":"system","content":"You + must call the dice_roll tool exactly 3 times when asked to roll dice 3 times."},{"role":"user","content":"Roll + the dice 3 times"},{"role":"assistant","content":"","tool_calls":[{"id":"call_0_d5663832-ecee-42e1-b3d0-744b0f4e157b","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"id":"call_1_8780ed95-db9b-4a19-8c61-1c4a15934506","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"id":"call_2_15b44c04-745c-44bb-b603-cf077aed2114","type":"function","function":{"name":"dice_roll","arguments":"{}"}}]},{"role":"tool","content":"{roll: + 3}","tool_call_id":"call_0_d5663832-ecee-42e1-b3d0-744b0f4e157b"},{"role":"tool","content":"{roll: + 1}","tool_call_id":"call_1_8780ed95-db9b-4a19-8c61-1c4a15934506"},{"role":"tool","content":"{roll: + 4}","tool_call_id":"call_2_15b44c04-745c-44bb-b603-cf077aed2114"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","parameters":{"type":"object","properties":{},"required":[]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:31:10 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Vary: + - origin, access-control-request-method, access-control-request-headers + Access-Control-Allow-Credentials: + - 'true' + X-Ds-Trace-Id: + - ff9fcc826eed8b59c7cc688d184b8270 + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + Server: + - cloudflare + Cf-Ray: + - "" + body: + encoding: ASCII-8BIT + string: '{"id":"47961a35-c600-4973-bde8-0c3b0330fc40","object":"chat.completion","created":1749587470,"model":"deepseek-chat","choices":[{"index":0,"message":{"role":"assistant","content":"The + results of rolling the dice 3 times are: 3, 1, and 4."},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":183,"completion_tokens":21,"total_tokens":204,"prompt_tokens_details":{"cached_tokens":128},"prompt_cache_hit_tokens":128,"prompt_cache_miss_tokens":55},"system_fingerprint":"fp_8802369eaa_prod0425fp8"}' + recorded_at: Tue, 10 Jun 2025 20:31:18 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/chat_function_calling_gemini_gemini-2_0-flash_can_handle_multiple_tool_calls_in_a_single_response.yml b/spec/fixtures/vcr_cassettes/chat_function_calling_gemini_gemini-2_0-flash_can_handle_multiple_tool_calls_in_a_single_response.yml new file mode 100644 index 000000000..41ac40aa4 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_function_calling_gemini_gemini-2_0-flash_can_handle_multiple_tool_calls_in_a_single_response.yml @@ -0,0 +1,374 @@ +--- +http_interactions: +- request: + method: post + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent + body: + encoding: UTF-8 + string: '{"contents":[{"role":"user","parts":[{"text":"You must call the dice_roll + tool exactly 3 times when asked to roll dice 3 times."}]},{"role":"user","parts":[{"text":"Roll + the dice 3 times"}]}],"generationConfig":{"temperature":0.7},"tools":[{"functionDeclarations":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result"}]}]}' + headers: + User-Agent: + - Faraday v2.12.2 + X-Goog-Api-Key: + - "" + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Tue, 10 Jun 2025 20:30:50 GMT + Server: + - scaffolding on HTTPServer2 + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Server-Timing: + - gfet4t7; dur=624 + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: | + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "dice_roll", + "args": {} + } + }, + { + "functionCall": { + "name": "dice_roll", + "args": {} + } + }, + { + "functionCall": { + "name": "dice_roll", + "args": {} + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.024323465095625982 + } + ], + "usageMetadata": { + "promptTokenCount": 41, + "candidatesTokenCount": 9, + "totalTokenCount": 50, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 41 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 9 + } + ] + }, + "modelVersion": "gemini-2.0-flash", + "responseId": "-ZVIaIn7MtGU7dcPjoS34QI" + } + recorded_at: Tue, 10 Jun 2025 20:30:50 GMT +- request: + method: post + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent + body: + encoding: UTF-8 + string: '{"contents":[{"role":"user","parts":[{"text":"You must call the dice_roll + tool exactly 3 times when asked to roll dice 3 times."}]},{"role":"user","parts":[{"text":"Roll + the dice 3 times"}]},{"role":"model","parts":[{"functionCall":{"name":"dice_roll","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"ac36b171-c6f2-4d53-af5a-b9bc30a6549e","response":{"name":"ac36b171-c6f2-4d53-af5a-b9bc30a6549e","content":"{roll: + 5}"}}}]}],"generationConfig":{"temperature":0.7},"tools":[{"functionDeclarations":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result"}]}]}' + headers: + User-Agent: + - Faraday v2.12.2 + X-Goog-Api-Key: + - "" + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Tue, 10 Jun 2025 20:30:50 GMT + Server: + - scaffolding on HTTPServer2 + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Server-Timing: + - gfet4t7; dur=545 + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: | + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "dice_roll", + "args": {} + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": 2.2158104305466018e-06 + } + ], + "usageMetadata": { + "promptTokenCount": 118, + "candidatesTokenCount": 3, + "totalTokenCount": 121, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 118 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 3 + } + ] + }, + "modelVersion": "gemini-2.0-flash", + "responseId": "-pVIaPH0IIi87dcPmeaIkA0" + } + recorded_at: Tue, 10 Jun 2025 20:30:50 GMT +- request: + method: post + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent + body: + encoding: UTF-8 + string: '{"contents":[{"role":"user","parts":[{"text":"You must call the dice_roll + tool exactly 3 times when asked to roll dice 3 times."}]},{"role":"user","parts":[{"text":"Roll + the dice 3 times"}]},{"role":"model","parts":[{"functionCall":{"name":"dice_roll","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"ac36b171-c6f2-4d53-af5a-b9bc30a6549e","response":{"name":"ac36b171-c6f2-4d53-af5a-b9bc30a6549e","content":"{roll: + 5}"}}}]},{"role":"model","parts":[{"functionCall":{"name":"dice_roll","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"17ad1183-ed71-49d4-bebc-6128b0a184b9","response":{"name":"17ad1183-ed71-49d4-bebc-6128b0a184b9","content":"{roll: + 5}"}}}]}],"generationConfig":{"temperature":0.7},"tools":[{"functionDeclarations":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result"}]}]}' + headers: + User-Agent: + - Faraday v2.12.2 + X-Goog-Api-Key: + - "" + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Tue, 10 Jun 2025 20:30:51 GMT + Server: + - scaffolding on HTTPServer2 + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Server-Timing: + - gfet4t7; dur=517 + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: | + { + "candidates": [ + { + "content": { + "parts": [ + { + "functionCall": { + "name": "dice_roll", + "args": {} + } + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": 4.7836704955746727e-06 + } + ], + "usageMetadata": { + "promptTokenCount": 193, + "candidatesTokenCount": 3, + "totalTokenCount": 196, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 193 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 3 + } + ] + }, + "modelVersion": "gemini-2.0-flash", + "responseId": "-5VIaJOHCtGU7dcPjoS34QI" + } + recorded_at: Tue, 10 Jun 2025 20:30:51 GMT +- request: + method: post + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent + body: + encoding: UTF-8 + string: '{"contents":[{"role":"user","parts":[{"text":"You must call the dice_roll + tool exactly 3 times when asked to roll dice 3 times."}]},{"role":"user","parts":[{"text":"Roll + the dice 3 times"}]},{"role":"model","parts":[{"functionCall":{"name":"dice_roll","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"ac36b171-c6f2-4d53-af5a-b9bc30a6549e","response":{"name":"ac36b171-c6f2-4d53-af5a-b9bc30a6549e","content":"{roll: + 5}"}}}]},{"role":"model","parts":[{"functionCall":{"name":"dice_roll","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"17ad1183-ed71-49d4-bebc-6128b0a184b9","response":{"name":"17ad1183-ed71-49d4-bebc-6128b0a184b9","content":"{roll: + 5}"}}}]},{"role":"model","parts":[{"functionCall":{"name":"dice_roll","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"88b85371-953e-4d44-be9d-e7b45d236a7a","response":{"name":"88b85371-953e-4d44-be9d-e7b45d236a7a","content":"{roll: + 1}"}}}]}],"generationConfig":{"temperature":0.7},"tools":[{"functionDeclarations":[{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result"}]}]}' + headers: + User-Agent: + - Faraday v2.12.2 + X-Goog-Api-Key: + - "" + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Content-Type: + - application/json; charset=UTF-8 + Vary: + - Origin + - Referer + - X-Origin + Date: + - Tue, 10 Jun 2025 20:30:52 GMT + Server: + - scaffolding on HTTPServer2 + X-Xss-Protection: + - '0' + X-Frame-Options: + - SAMEORIGIN + X-Content-Type-Options: + - nosniff + Server-Timing: + - gfet4t7; dur=644 + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Transfer-Encoding: + - chunked + body: + encoding: ASCII-8BIT + string: | + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": "I have rolled the dice three times. The results are 5, 5, and 1.\n" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "avgLogprobs": -0.012146733023903587 + } + ], + "usageMetadata": { + "promptTokenCount": 274, + "candidatesTokenCount": 22, + "totalTokenCount": 296, + "promptTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 274 + } + ], + "candidatesTokensDetails": [ + { + "modality": "TEXT", + "tokenCount": 22 + } + ] + }, + "modelVersion": "gemini-2.0-flash", + "responseId": "-5VIaNjsLoi87dcPmeaIkA0" + } + recorded_at: Tue, 10 Jun 2025 20:30:52 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/chat_function_calling_openai_gpt-4_1-nano_can_handle_multiple_tool_calls_in_a_single_response.yml b/spec/fixtures/vcr_cassettes/chat_function_calling_openai_gpt-4_1-nano_can_handle_multiple_tool_calls_in_a_single_response.yml new file mode 100644 index 000000000..5f2509442 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_function_calling_openai_gpt-4_1-nano_can_handle_multiple_tool_calls_in_a_single_response.yml @@ -0,0 +1,256 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-nano","messages":[{"role":"developer","content":"You + must call the dice_roll tool exactly 3 times when asked to roll dice 3 times."},{"role":"user","content":"Roll + the dice 3 times"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","parameters":{"type":"object","properties":{},"required":[]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:31:19 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '643' + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '648' + X-Ratelimit-Limit-Requests: + - '30000' + X-Ratelimit-Limit-Tokens: + - '150000000' + X-Ratelimit-Remaining-Requests: + - '29999' + X-Ratelimit-Remaining-Tokens: + - '149999971' + X-Ratelimit-Reset-Requests: + - 2ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BgzupjebNolOk8VvskdxNDXGlb52p", + "object": "chat.completion", + "created": 1749587479, + "model": "gpt-4.1-nano-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_FTb5KgcytLObWP93VkA4ZxUh", + "type": "function", + "function": { + "name": "dice_roll", + "arguments": "{}" + } + }, + { + "id": "call_VjloKpwvuCiIrbvX2GqqabHT", + "type": "function", + "function": { + "name": "dice_roll", + "arguments": "{}" + } + }, + { + "id": "call_57dtkj5RVAbsTemlz7aR3rc6", + "type": "function", + "function": { + "name": "dice_roll", + "arguments": "{}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 68, + "completion_tokens": 53, + "total_tokens": 121, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_f12167b370" + } + recorded_at: Tue, 10 Jun 2025 20:31:19 GMT +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-nano","messages":[{"role":"developer","content":"You + must call the dice_roll tool exactly 3 times when asked to roll dice 3 times."},{"role":"user","content":"Roll + the dice 3 times"},{"role":"assistant","tool_calls":[{"id":"call_FTb5KgcytLObWP93VkA4ZxUh","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"id":"call_VjloKpwvuCiIrbvX2GqqabHT","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"id":"call_57dtkj5RVAbsTemlz7aR3rc6","type":"function","function":{"name":"dice_roll","arguments":"{}"}}]},{"role":"tool","content":"{roll: + 2}","tool_call_id":"call_FTb5KgcytLObWP93VkA4ZxUh"},{"role":"tool","content":"{roll: + 1}","tool_call_id":"call_VjloKpwvuCiIrbvX2GqqabHT"},{"role":"tool","content":"{roll: + 5}","tool_call_id":"call_57dtkj5RVAbsTemlz7aR3rc6"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","parameters":{"type":"object","properties":{},"required":[]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:31:20 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '505' + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '510' + X-Ratelimit-Limit-Requests: + - '30000' + X-Ratelimit-Limit-Tokens: + - '150000000' + X-Ratelimit-Remaining-Requests: + - '29999' + X-Ratelimit-Remaining-Tokens: + - '149999960' + X-Ratelimit-Reset-Requests: + - 2ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-Bgzuq1x3YPHeQQuKGWBhGoAjS1jje", + "object": "chat.completion", + "created": 1749587480, + "model": "gpt-4.1-nano-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The results of the three dice rolls are 2, 1, and 5.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 151, + "completion_tokens": 19, + "total_tokens": 170, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_f12167b370" + } + recorded_at: Tue, 10 Jun 2025 20:31:20 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/chat_function_calling_openrouter_anthropic_claude-3_5-haiku_can_handle_multiple_tool_calls_in_a_single_response.yml b/spec/fixtures/vcr_cassettes/chat_function_calling_openrouter_anthropic_claude-3_5-haiku_can_handle_multiple_tool_calls_in_a_single_response.yml new file mode 100644 index 000000000..aa998ae38 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/chat_function_calling_openrouter_anthropic_claude-3_5-haiku_can_handle_multiple_tool_calls_in_a_single_response.yml @@ -0,0 +1,114 @@ +--- +http_interactions: +- request: + method: post + uri: https://openrouter.ai/api/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"anthropic/claude-3.5-haiku","messages":[{"role":"developer","content":"You + must call the dice_roll tool exactly 3 times when asked to roll dice 3 times."},{"role":"user","content":"Roll + the dice 3 times"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","parameters":{"type":"object","properties":{},"required":[]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:31:21 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Allow-Origin: + - "*" + X-Clerk-Auth-Message: + - Invalid JWT form. A JWT consists of three parts separated by dots. (reason=token-invalid, + token-carrier=header) + X-Clerk-Auth-Reason: + - token-invalid + X-Clerk-Auth-Status: + - signed-out + Vary: + - Accept-Encoding + Server: + - cloudflare + Cf-Ray: + - "" + body: + encoding: ASCII-8BIT + string: "\n \n\n \n\n \n\n \n{\"id\":\"gen-1749587480-emRxjCDiNfxMfbbImoD9\",\"provider\":\"Anthropic\",\"model\":\"anthropic/claude-3.5-haiku\",\"object\":\"chat.completion\",\"created\":1749587480,\"choices\":[{\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\",\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"I'll + roll a six-sided die three times for you.\",\"refusal\":null,\"reasoning\":null,\"tool_calls\":[{\"id\":\"toolu_01Kb2EPwanJh4DrJPeWRU3wM\",\"index\":0,\"type\":\"function\",\"function\":{\"name\":\"dice_roll\",\"arguments\":\"\"}},{\"id\":\"toolu_012oTqsd64aBbTi31E7QbQC1\",\"index\":1,\"type\":\"function\",\"function\":{\"name\":\"dice_roll\",\"arguments\":\"\"}},{\"id\":\"toolu_013Jn8Y5uW2R4x7cy9q1F5sF\",\"index\":2,\"type\":\"function\",\"function\":{\"name\":\"dice_roll\",\"arguments\":\"\"}}]}}],\"usage\":{\"prompt_tokens\":352,\"completion_tokens\":88,\"total_tokens\":440}}" + recorded_at: Tue, 10 Jun 2025 20:31:22 GMT +- request: + method: post + uri: https://openrouter.ai/api/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"anthropic/claude-3.5-haiku","messages":[{"role":"developer","content":"You + must call the dice_roll tool exactly 3 times when asked to roll dice 3 times."},{"role":"user","content":"Roll + the dice 3 times"},{"role":"assistant","content":"I''ll roll a six-sided die + three times for you.","tool_calls":[{"id":"toolu_01Kb2EPwanJh4DrJPeWRU3wM","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"id":"toolu_012oTqsd64aBbTi31E7QbQC1","type":"function","function":{"name":"dice_roll","arguments":"{}"}},{"id":"toolu_013Jn8Y5uW2R4x7cy9q1F5sF","type":"function","function":{"name":"dice_roll","arguments":"{}"}}]},{"role":"tool","content":"{roll: + 3}","tool_call_id":"toolu_01Kb2EPwanJh4DrJPeWRU3wM"},{"role":"tool","content":"{roll: + 1}","tool_call_id":"toolu_012oTqsd64aBbTi31E7QbQC1"},{"role":"tool","content":"{roll: + 3}","tool_call_id":"toolu_013Jn8Y5uW2R4x7cy9q1F5sF"}],"temperature":0.7,"stream":false,"tools":[{"type":"function","function":{"name":"dice_roll","description":"Rolls + a single six-sided die and returns the result","parameters":{"type":"object","properties":{},"required":[]}}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 10 Jun 2025 20:31:24 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Allow-Origin: + - "*" + X-Clerk-Auth-Message: + - Invalid JWT form. A JWT consists of three parts separated by dots. (reason=token-invalid, + token-carrier=header) + X-Clerk-Auth-Reason: + - token-invalid + X-Clerk-Auth-Status: + - signed-out + Vary: + - Accept-Encoding + Server: + - cloudflare + Cf-Ray: + - "" + body: + encoding: ASCII-8BIT + string: "\n \n\n \n{\"id\":\"gen-1749587483-4w2glAfe4toiUgtzMQ0m\",\"provider\":\"Anthropic\",\"model\":\"anthropic/claude-3.5-haiku\",\"object\":\"chat.completion\",\"created\":1749587483,\"choices\":[{\"logprobs\":null,\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\",\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"Here + are the results of the three dice rolls:\\n1st roll: 3\\n2nd roll: 1\\n3rd + roll: 3\",\"refusal\":null,\"reasoning\":null}}],\"usage\":{\"prompt_tokens\":539,\"completion_tokens\":35,\"total_tokens\":574}}" + recorded_at: Tue, 10 Jun 2025 20:31:24 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/chat_tools_spec.rb b/spec/ruby_llm/chat_tools_spec.rb index 4a0a12f65..11a23d74c 100644 --- a/spec/ruby_llm/chat_tools_spec.rb +++ b/spec/ruby_llm/chat_tools_spec.rb @@ -31,6 +31,14 @@ def execute end end + class DiceRoll < RubyLLM::Tool # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration + description 'Rolls a single six-sided die and returns the result' + + def execute + { roll: rand(1..6) } + end + end + describe 'function calling' do CHAT_MODELS.each do |model_info| model = model_info[:model] @@ -127,6 +135,41 @@ def execute expect(response.content).to include('10') end end + + CHAT_MODELS.each do |model_info| # rubocop:disable Style/CombinableLoops + model = model_info[:model] + provider = model_info[:provider] + it "#{provider}/#{model} can handle multiple tool calls in a single response" do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + skip 'Ollama models do not reliably use tools' if provider == :ollama + chat = RubyLLM.chat(model: model, provider: provider) + .with_tool(DiceRoll) + .with_instructions( + 'You must call the dice_roll tool exactly 3 times when asked to roll dice 3 times.' + ) + + # Track tool calls to ensure all 3 are executed + tool_call_count = 0 + + original_execute = DiceRoll.instance_method(:execute) + DiceRoll.define_method(:execute) do + tool_call_count += 1 + # Return a fixed result for VCR consistency + { roll: tool_call_count } + end + + response = chat.ask('Roll the dice 3 times') + + # Restore original method + DiceRoll.define_method(:execute, original_execute) + + # Verify all 3 tool calls were made + expect(tool_call_count).to eq(3) + + # Verify the response contains some dice roll results + expect(response.content).to match(/\d+/) # Contains at least one number + expect(response.content.downcase).to match(/roll|dice|result/) # Mentions rolling or results + end + end end describe 'error handling' do diff --git a/spec/ruby_llm/providers/anthropic/tools_spec.rb b/spec/ruby_llm/providers/anthropic/tools_spec.rb index 56f190621..6d8b0ebff 100644 --- a/spec/ruby_llm/providers/anthropic/tools_spec.rb +++ b/spec/ruby_llm/providers/anthropic/tools_spec.rb @@ -91,6 +91,51 @@ }) end end + + it 'formats messages with multiple tool calls correctly' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + tool_calls = { + 'tool_1' => RubyLLM::ToolCall.new(id: 'tool_1', name: 'dice_roll', arguments: {}), + 'tool_2' => RubyLLM::ToolCall.new(id: 'tool_2', name: 'dice_roll', arguments: {}), + 'tool_3' => RubyLLM::ToolCall.new(id: 'tool_3', name: 'dice_roll', arguments: {}) + } + + msg = RubyLLM::Message.new( + role: :assistant, + content: 'Rolling dice 3 times', + tool_calls: tool_calls + ) + + formatted = described_class.format_tool_call(msg) + + expect(formatted[:role]).to eq('assistant') + expect(formatted[:content].size).to eq(4) # 1 text + 3 tool_use blocks + + # Check text content + expect(formatted[:content][0]).to eq({ type: 'text', text: 'Rolling dice 3 times' }) + # Check all 3 tool use blocks are present + tool_use_blocks = formatted[:content][1..3] + expect(tool_use_blocks.map { |b| b[:type] }).to all(eq('tool_use')) + expect(tool_use_blocks.map { |b| b[:id] }).to contain_exactly('tool_1', 'tool_2', 'tool_3') + expect(tool_use_blocks.map { |b| b[:name] }).to all(eq('dice_roll')) + end + + it 'does not include empty text content with multiple tool calls' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + tool_calls = { + 'tool_1' => RubyLLM::ToolCall.new(id: 'tool_1', name: 'dice_roll', arguments: {}) + } + + msg = RubyLLM::Message.new( + role: :assistant, + content: '', # Empty content + tool_calls: tool_calls + ) + + formatted = described_class.format_tool_call(msg) + + expect(formatted[:role]).to eq('assistant') + expect(formatted[:content].size).to eq(1) # Only tool_use block, no text + expect(formatted[:content][0][:type]).to eq('tool_use') + end end describe '.format_tool_result' do @@ -115,4 +160,37 @@ }) end end -end + + describe '.parse_tool_calls' do + it 'parses multiple tool calls from content blocks' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + content_blocks = [ + { 'type' => 'text', 'text' => 'Rolling dice' }, + { 'type' => 'tool_use', 'id' => 'tool_1', 'name' => 'dice_roll', 'input' => {} }, + { 'type' => 'tool_use', 'id' => 'tool_2', 'name' => 'dice_roll', 'input' => {} }, + { 'type' => 'tool_use', 'id' => 'tool_3', 'name' => 'dice_roll', 'input' => {} } + ] + + tool_calls = described_class.parse_tool_calls(content_blocks) + + expect(tool_calls).to be_a(Hash) + expect(tool_calls.size).to eq(3) + expect(tool_calls.keys).to contain_exactly('tool_1', 'tool_2', 'tool_3') + expect(tool_calls.values.map(&:name)).to all(eq('dice_roll')) + end + + it 'handles single tool call for backward compatibility' do # rubocop:disable RSpec/MultipleExpectations + single_block = { 'type' => 'tool_use', 'id' => 'tool_1', 'name' => 'dice_roll', 'input' => {} } + + tool_calls = described_class.parse_tool_calls(single_block) + + expect(tool_calls).to be_a(Hash) + expect(tool_calls.size).to eq(1) + expect(tool_calls['tool_1'].name).to eq('dice_roll') + end + + it 'returns nil for empty or nil input' do # rubocop:disable RSpec/MultipleExpectations + expect(described_class.parse_tool_calls(nil)).to be_nil + expect(described_class.parse_tool_calls([])).to be_nil + end + end +end \ No newline at end of file