From 76fc325b8802a65d02bef6ab534407f2817312eb Mon Sep 17 00:00:00 2001 From: Finbarr Taylor Date: Thu, 21 Aug 2025 10:28:57 -0700 Subject: [PATCH 1/4] Fix Gemini array parameter handling in tool declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gemini API requires an 'items' field for array-type parameters in tool declarations to specify the type of array elements. Without this field, the API returns errors like: "GenerateContentRequest.tools[0].function_declarations[2].parameters.properties[fields].items: missing field" This fix adds the required 'items: { type: 'STRING' }' field for all array parameters when formatting tools for the Gemini provider. Changes: - Modified format_parameters method to add items field for array types - Added comprehensive unit tests for the fix - Added integration tests to verify proper tool formatting This resolves issues when using tools with array parameters in Gemini models, enabling proper function calling with arrays. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- lib/ruby_llm/providers/gemini/tools.rb | 7 +- .../tools_array_parameter_handling_spec.rb | 134 ++++++++++++++++++ spec/ruby_llm/providers/gemini/tools_spec.rb | 123 ++++++++++++++++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb create mode 100644 spec/ruby_llm/providers/gemini/tools_spec.rb diff --git a/lib/ruby_llm/providers/gemini/tools.rb b/lib/ruby_llm/providers/gemini/tools.rb index bef6bfa35..8a071feed 100644 --- a/lib/ruby_llm/providers/gemini/tools.rb +++ b/lib/ruby_llm/providers/gemini/tools.rb @@ -53,10 +53,15 @@ def format_parameters(parameters) { type: 'OBJECT', properties: parameters.transform_values do |param| - { + property = { type: param_type_for_gemini(param.type), description: param.description }.compact + + # Add items field for array types + property[:items] = { type: 'STRING' } if param.type.to_s.downcase == 'array' + + property end, required: parameters.select { |_, p| p.required }.keys.map(&:to_s) } diff --git a/spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb b/spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb new file mode 100644 index 000000000..9778e5148 --- /dev/null +++ b/spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'ruby_llm/tool' + +RSpec.describe RubyLLM::Providers::Gemini::Tools do + let(:provider_class) do + Class.new do + include RubyLLM::Providers::Gemini::Tools + end + end + + let(:provider) { provider_class.new } + + it 'properly formats tools with array parameters for Gemini API' do + # Define a tool with array parameter similar to the error case + tool_with_array = Class.new(RubyLLM::Tool) do + def self.name + 'DataFetcher' + end + + description 'Fetches data with specific fields' + param :fields, type: 'array', desc: 'List of fields to return', required: true + + def execute(fields:) + { selected_fields: fields } + end + end + + # Another tool with array parameter + another_tool = Class.new(RubyLLM::Tool) do + def self.name + 'FilterTool' + end + + description 'Filters data by criteria' + param :fields, type: 'array', desc: 'Fields to filter on', required: true + param :query, type: 'string', desc: 'Query string', required: false + + def execute(fields:, query: nil) + { filtered_fields: fields, query: query } + end + end + + tools = { + data_fetcher: tool_with_array.new, + filter: another_tool.new + } + + result = provider.send(:format_tools, tools) + + # Verify the structure matches what Gemini expects + expect(result).to be_an(Array) + expect(result.first).to have_key(:functionDeclarations) + + function_decls = result.first[:functionDeclarations] + expect(function_decls).to be_an(Array) + expect(function_decls.size).to eq(2) + + # Check first tool (data_fetcher) + first_tool = function_decls[0] + expect(first_tool[:name]).to eq('data_fetcher') + expect(first_tool[:description]).to eq('Fetches data with specific fields') + expect(first_tool[:parameters][:type]).to eq('OBJECT') + + # Most important: verify the array parameter has the 'items' field + fields_param = first_tool[:parameters][:properties][:fields] + expect(fields_param).to eq({ + type: 'ARRAY', + description: 'List of fields to return', + items: { type: 'STRING' } + }) + + expect(first_tool[:parameters][:required]).to contain_exactly('fields') + + # Check second tool (filter) + second_tool = function_decls[1] + expect(second_tool[:name]).to eq('filter') + + # Verify array parameter in second tool also has 'items' + filter_fields_param = second_tool[:parameters][:properties][:fields] + expect(filter_fields_param).to include( + type: 'ARRAY', + items: { type: 'STRING' } + ) + + # Verify string parameter doesn't have items + query_param = second_tool[:parameters][:properties][:query] + expect(query_param).to eq({ + type: 'STRING', + description: 'Query string' + }) + expect(query_param).not_to have_key(:items) + + # Only required field should be in required array + expect(second_tool[:parameters][:required]).to contain_exactly('fields') + end + + it 'handles tools without array parameters correctly' do + simple_tool = Class.new(RubyLLM::Tool) do + def self.name + 'SimpleTool' + end + + description 'A simple tool' + param :name, type: 'string', desc: 'Name parameter' + param :count, type: 'integer', desc: 'Count parameter' + + def execute(name:, count:) + { name: name, count: count } + end + end + + tools = { simple: simple_tool.new } + result = provider.send(:format_tools, tools) + + function_decl = result.first[:functionDeclarations].first + + # Verify non-array parameters don't have items field + name_param = function_decl[:parameters][:properties][:name] + expect(name_param).to eq({ + type: 'STRING', + description: 'Name parameter' + }) + expect(name_param).not_to have_key(:items) + + count_param = function_decl[:parameters][:properties][:count] + expect(count_param).to eq({ + type: 'NUMBER', + description: 'Count parameter' + }) + expect(count_param).not_to have_key(:items) + end +end diff --git a/spec/ruby_llm/providers/gemini/tools_spec.rb b/spec/ruby_llm/providers/gemini/tools_spec.rb new file mode 100644 index 000000000..56cf6a8f6 --- /dev/null +++ b/spec/ruby_llm/providers/gemini/tools_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'ruby_llm/tool' + +RSpec.describe RubyLLM::Providers::Gemini::Tools do + let(:dummy_class) do + Class.new do + include RubyLLM::Providers::Gemini::Tools + end + end + + let(:instance) { dummy_class.new } + + describe '#format_parameters' do + context 'with array type parameter' do + it 'includes items field for array parameters' do + parameters = { + fields: RubyLLM::Parameter.new('fields', type: 'array', desc: 'List of fields to return') + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:type]).to eq('OBJECT') + expect(result[:properties][:fields]).to include( + type: 'ARRAY', + description: 'List of fields to return', + items: { type: 'STRING' } + ) + end + end + + context 'with mixed parameter types' do + it 'correctly formats all parameter types' do + parameters = { + name: RubyLLM::Parameter.new('name', type: 'string', desc: 'Name field'), + age: RubyLLM::Parameter.new('age', type: 'integer', desc: 'Age field'), + active: RubyLLM::Parameter.new('active', type: 'boolean', desc: 'Active status'), + tags: RubyLLM::Parameter.new('tags', type: 'array', desc: 'List of tags'), + metadata: RubyLLM::Parameter.new('metadata', type: 'object', desc: 'Additional metadata') + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:type]).to eq('OBJECT') + + # Check string parameter + expect(result[:properties][:name]).to eq( + type: 'STRING', + description: 'Name field' + ) + + # Check integer parameter + expect(result[:properties][:age]).to eq( + type: 'NUMBER', + description: 'Age field' + ) + + # Check boolean parameter + expect(result[:properties][:active]).to eq( + type: 'BOOLEAN', + description: 'Active status' + ) + + # Check array parameter with items field + expect(result[:properties][:tags]).to eq( + type: 'ARRAY', + description: 'List of tags', + items: { type: 'STRING' } + ) + + # Check object parameter + expect(result[:properties][:metadata]).to eq( + type: 'OBJECT', + description: 'Additional metadata' + ) + end + end + + context 'with required and optional parameters' do + it 'correctly identifies required parameters' do + parameters = { + required_field: RubyLLM::Parameter.new('required_field', type: 'string', desc: 'Required', required: true), + optional_field: RubyLLM::Parameter.new('optional_field', type: 'string', desc: 'Optional', required: false) + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:required]).to contain_exactly('required_field') + end + end + end + + describe '#format_tools' do + it 'formats tools with array parameters correctly' do + tool_class = Class.new(RubyLLM::Tool) do + def self.name + 'ProcessFieldsTool' + end + + description 'Process multiple fields' + param :fields, type: 'array', desc: 'Fields to process' + + def execute(fields:) + "Processed #{fields.length} fields" + end + end + + tools = { process_fields: tool_class.new } + result = instance.send(:format_tools, tools) + + expect(result).to be_an(Array) + expect(result.first[:functionDeclarations]).to be_an(Array) + + function_decl = result.first[:functionDeclarations].first + expect(function_decl[:name]).to eq('process_fields') + expect(function_decl[:parameters][:properties][:fields]).to include( + type: 'ARRAY', + items: { type: 'STRING' } + ) + end + end +end From fa826af06c986ec53f97b86c35a1a59ccf7e81ed Mon Sep 17 00:00:00 2001 From: Finbarr Taylor Date: Thu, 21 Aug 2025 14:34:25 -0700 Subject: [PATCH 2/4] Consolidate Gemini tools tests into single file Removed duplicate test coverage by consolidating tools_array_parameter_handling_spec.rb into tools_spec.rb. The single test file now comprehensively covers: - Array parameters with items field - Mixed parameter types - Required vs optional parameters - Integration test of format_tools method --- .../tools_array_parameter_handling_spec.rb | 134 ------------------ spec/ruby_llm/providers/gemini/tools_spec.rb | 64 ++++++--- 2 files changed, 43 insertions(+), 155 deletions(-) delete mode 100644 spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb diff --git a/spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb b/spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb deleted file mode 100644 index 9778e5148..000000000 --- a/spec/ruby_llm/providers/gemini/tools_array_parameter_handling_spec.rb +++ /dev/null @@ -1,134 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'ruby_llm/tool' - -RSpec.describe RubyLLM::Providers::Gemini::Tools do - let(:provider_class) do - Class.new do - include RubyLLM::Providers::Gemini::Tools - end - end - - let(:provider) { provider_class.new } - - it 'properly formats tools with array parameters for Gemini API' do - # Define a tool with array parameter similar to the error case - tool_with_array = Class.new(RubyLLM::Tool) do - def self.name - 'DataFetcher' - end - - description 'Fetches data with specific fields' - param :fields, type: 'array', desc: 'List of fields to return', required: true - - def execute(fields:) - { selected_fields: fields } - end - end - - # Another tool with array parameter - another_tool = Class.new(RubyLLM::Tool) do - def self.name - 'FilterTool' - end - - description 'Filters data by criteria' - param :fields, type: 'array', desc: 'Fields to filter on', required: true - param :query, type: 'string', desc: 'Query string', required: false - - def execute(fields:, query: nil) - { filtered_fields: fields, query: query } - end - end - - tools = { - data_fetcher: tool_with_array.new, - filter: another_tool.new - } - - result = provider.send(:format_tools, tools) - - # Verify the structure matches what Gemini expects - expect(result).to be_an(Array) - expect(result.first).to have_key(:functionDeclarations) - - function_decls = result.first[:functionDeclarations] - expect(function_decls).to be_an(Array) - expect(function_decls.size).to eq(2) - - # Check first tool (data_fetcher) - first_tool = function_decls[0] - expect(first_tool[:name]).to eq('data_fetcher') - expect(first_tool[:description]).to eq('Fetches data with specific fields') - expect(first_tool[:parameters][:type]).to eq('OBJECT') - - # Most important: verify the array parameter has the 'items' field - fields_param = first_tool[:parameters][:properties][:fields] - expect(fields_param).to eq({ - type: 'ARRAY', - description: 'List of fields to return', - items: { type: 'STRING' } - }) - - expect(first_tool[:parameters][:required]).to contain_exactly('fields') - - # Check second tool (filter) - second_tool = function_decls[1] - expect(second_tool[:name]).to eq('filter') - - # Verify array parameter in second tool also has 'items' - filter_fields_param = second_tool[:parameters][:properties][:fields] - expect(filter_fields_param).to include( - type: 'ARRAY', - items: { type: 'STRING' } - ) - - # Verify string parameter doesn't have items - query_param = second_tool[:parameters][:properties][:query] - expect(query_param).to eq({ - type: 'STRING', - description: 'Query string' - }) - expect(query_param).not_to have_key(:items) - - # Only required field should be in required array - expect(second_tool[:parameters][:required]).to contain_exactly('fields') - end - - it 'handles tools without array parameters correctly' do - simple_tool = Class.new(RubyLLM::Tool) do - def self.name - 'SimpleTool' - end - - description 'A simple tool' - param :name, type: 'string', desc: 'Name parameter' - param :count, type: 'integer', desc: 'Count parameter' - - def execute(name:, count:) - { name: name, count: count } - end - end - - tools = { simple: simple_tool.new } - result = provider.send(:format_tools, tools) - - function_decl = result.first[:functionDeclarations].first - - # Verify non-array parameters don't have items field - name_param = function_decl[:parameters][:properties][:name] - expect(name_param).to eq({ - type: 'STRING', - description: 'Name parameter' - }) - expect(name_param).not_to have_key(:items) - - count_param = function_decl[:parameters][:properties][:count] - expect(count_param).to eq({ - type: 'NUMBER', - description: 'Count parameter' - }) - expect(count_param).not_to have_key(:items) - end -end diff --git a/spec/ruby_llm/providers/gemini/tools_spec.rb b/spec/ruby_llm/providers/gemini/tools_spec.rb index 56cf6a8f6..db0448ae6 100644 --- a/spec/ruby_llm/providers/gemini/tools_spec.rb +++ b/spec/ruby_llm/providers/gemini/tools_spec.rb @@ -22,7 +22,7 @@ result = instance.send(:format_parameters, parameters) expect(result[:type]).to eq('OBJECT') - expect(result[:properties][:fields]).to include( + expect(result[:properties][:fields]).to eq( type: 'ARRAY', description: 'List of fields to return', items: { type: 'STRING' } @@ -31,10 +31,10 @@ end context 'with mixed parameter types' do - it 'correctly formats all parameter types' do + it 'correctly formats all parameter types including arrays with items field' do parameters = { name: RubyLLM::Parameter.new('name', type: 'string', desc: 'Name field'), - age: RubyLLM::Parameter.new('age', type: 'integer', desc: 'Age field'), + count: RubyLLM::Parameter.new('count', type: 'integer', desc: 'Count field'), active: RubyLLM::Parameter.new('active', type: 'boolean', desc: 'Active status'), tags: RubyLLM::Parameter.new('tags', type: 'array', desc: 'List of tags'), metadata: RubyLLM::Parameter.new('metadata', type: 'object', desc: 'Additional metadata') @@ -44,32 +44,32 @@ expect(result[:type]).to eq('OBJECT') - # Check string parameter + # String parameter - no items field expect(result[:properties][:name]).to eq( type: 'STRING', description: 'Name field' ) - # Check integer parameter - expect(result[:properties][:age]).to eq( + # Integer parameter - no items field + expect(result[:properties][:count]).to eq( type: 'NUMBER', - description: 'Age field' + description: 'Count field' ) - # Check boolean parameter + # Boolean parameter - no items field expect(result[:properties][:active]).to eq( type: 'BOOLEAN', description: 'Active status' ) - # Check array parameter with items field + # Array parameter - MUST have items field expect(result[:properties][:tags]).to eq( type: 'ARRAY', description: 'List of tags', items: { type: 'STRING' } ) - # Check object parameter + # Object parameter - no items field expect(result[:properties][:metadata]).to eq( type: 'OBJECT', description: 'Additional metadata' @@ -81,43 +81,65 @@ it 'correctly identifies required parameters' do parameters = { required_field: RubyLLM::Parameter.new('required_field', type: 'string', desc: 'Required', required: true), - optional_field: RubyLLM::Parameter.new('optional_field', type: 'string', desc: 'Optional', required: false) + optional_field: RubyLLM::Parameter.new('optional_field', type: 'string', desc: 'Optional', required: false), + required_array: RubyLLM::Parameter.new('required_array', type: 'array', desc: 'Required array', + required: true) } result = instance.send(:format_parameters, parameters) - expect(result[:required]).to contain_exactly('required_field') + expect(result[:required]).to contain_exactly('required_field', 'required_array') + + # Also verify array parameter has items field + expect(result[:properties][:required_array]).to include( + type: 'ARRAY', + items: { type: 'STRING' } + ) end end end describe '#format_tools' do - it 'formats tools with array parameters correctly' do + it 'formats tools with array parameters correctly for Gemini API' do tool_class = Class.new(RubyLLM::Tool) do def self.name - 'ProcessFieldsTool' + 'DataProcessor' end - description 'Process multiple fields' - param :fields, type: 'array', desc: 'Fields to process' + description 'Process data with specific fields' + param :fields, type: 'array', desc: 'Fields to process', required: true + param :format, type: 'string', desc: 'Output format', required: false - def execute(fields:) - "Processed #{fields.length} fields" + def execute(fields:, format: 'json') + "Processed #{fields.length} fields in #{format} format" end end - tools = { process_fields: tool_class.new } + tools = { data_processor: tool_class.new } result = instance.send(:format_tools, tools) expect(result).to be_an(Array) expect(result.first[:functionDeclarations]).to be_an(Array) function_decl = result.first[:functionDeclarations].first - expect(function_decl[:name]).to eq('process_fields') - expect(function_decl[:parameters][:properties][:fields]).to include( + expect(function_decl[:name]).to eq('data_processor') + expect(function_decl[:description]).to eq('Process data with specific fields') + + # Verify array parameter has items field (this is the critical fix) + expect(function_decl[:parameters][:properties][:fields]).to eq( type: 'ARRAY', + description: 'Fields to process', items: { type: 'STRING' } ) + + # Verify string parameter doesn't have items field + expect(function_decl[:parameters][:properties][:format]).to eq( + type: 'STRING', + description: 'Output format' + ) + + # Verify only required parameters are marked as required + expect(function_decl[:parameters][:required]).to contain_exactly('fields') end end end From 9498fbc7ed08674b0fb768110cb00c44ea0bcf6a Mon Sep 17 00:00:00 2001 From: Finbarr Taylor Date: Thu, 21 Aug 2025 14:47:48 -0700 Subject: [PATCH 3/4] Fix OpenAI array parameter handling in tool declarations Similar to the Gemini fix, OpenAI also requires an 'items' field for array-type parameters in tool/function declarations. Without this field, tools with array parameters may not work correctly. This commit adds the required 'items: { type: 'string' }' field for all array-type parameters when formatting tools for OpenAI. Changes: - Modified param_schema method to add items field for array types - Added comprehensive tests for array parameter handling - Tests verify both single and multiple array parameters work correctly --- lib/ruby_llm/providers/openai/tools.rb | 7 +- spec/ruby_llm/providers/openai/tools_spec.rb | 150 +++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 spec/ruby_llm/providers/openai/tools_spec.rb diff --git a/lib/ruby_llm/providers/openai/tools.rb b/lib/ruby_llm/providers/openai/tools.rb index e4b76c0cf..8ba8c49f8 100644 --- a/lib/ruby_llm/providers/openai/tools.rb +++ b/lib/ruby_llm/providers/openai/tools.rb @@ -23,10 +23,15 @@ def tool_for(tool) end def param_schema(param) - { + schema = { type: param.type, description: param.description }.compact + + # Add items field for array types + schema[:items] = { type: 'string' } if param.type.to_s.downcase == 'array' + + schema end def format_tool_calls(tool_calls) diff --git a/spec/ruby_llm/providers/openai/tools_spec.rb b/spec/ruby_llm/providers/openai/tools_spec.rb new file mode 100644 index 000000000..57c2dd189 --- /dev/null +++ b/spec/ruby_llm/providers/openai/tools_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'ruby_llm/tool' + +RSpec.describe RubyLLM::Providers::OpenAI::Tools do # rubocop:disable RSpec/SpecFilePathFormat + describe '.param_schema' do + context 'with array type parameter' do + it 'includes items field for array parameters' do + param = RubyLLM::Parameter.new('tags', type: 'array', desc: 'List of tags') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'array', + description: 'List of tags', + items: { type: 'string' } + ) + end + end + + context 'with non-array parameters' do + it 'does not include items field for string parameters' do + param = RubyLLM::Parameter.new('name', type: 'string', desc: 'Name field') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'string', + description: 'Name field' + ) + end + + it 'does not include items field for integer parameters' do + param = RubyLLM::Parameter.new('age', type: 'integer', desc: 'Age field') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'integer', + description: 'Age field' + ) + end + + it 'does not include items field for boolean parameters' do + param = RubyLLM::Parameter.new('active', type: 'boolean', desc: 'Active status') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'boolean', + description: 'Active status' + ) + end + + it 'does not include items field for object parameters' do + param = RubyLLM::Parameter.new('metadata', type: 'object', desc: 'Metadata') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'object', + description: 'Metadata' + ) + end + end + end + + describe '.tool_for' do + it 'formats tools with array parameters correctly for OpenAI API' do + tool_class = Class.new(RubyLLM::Tool) do + def self.name + 'DataProcessor' + end + + description 'Process data with specific fields' + param :fields, type: 'array', desc: 'Fields to process', required: true + param :format, type: 'string', desc: 'Output format', required: false + param :limit, type: 'integer', desc: 'Limit results', required: false + + def execute(fields:, format: 'json', limit: 10) + "Processed #{fields.length} fields in #{format} format (limit: #{limit})" + end + end + + tool = tool_class.new + result = described_class.tool_for(tool) + + expect(result[:type]).to eq('function') + expect(result[:function][:name]).to eq('data_processor') + expect(result[:function][:description]).to eq('Process data with specific fields') + + # Verify array parameter has items field + expect(result[:function][:parameters][:properties][:fields]).to eq( + type: 'array', + description: 'Fields to process', + items: { type: 'string' } + ) + + # Verify string parameter doesn't have items field + expect(result[:function][:parameters][:properties][:format]).to eq( + type: 'string', + description: 'Output format' + ) + + # Verify integer parameter doesn't have items field + expect(result[:function][:parameters][:properties][:limit]).to eq( + type: 'integer', + description: 'Limit results' + ) + + # Verify only required parameters are marked as required + expect(result[:function][:parameters][:required]).to contain_exactly(:fields) + end + + it 'handles tools with multiple array parameters' do + tool_class = Class.new(RubyLLM::Tool) do + def self.name + 'MultiArrayTool' + end + + description 'Tool with multiple array parameters' + param :tags, type: 'array', desc: 'Tags list', required: true + param :categories, type: 'array', desc: 'Categories list', required: false + + def execute(tags:, categories: []) + { tags: tags, categories: categories } + end + end + + tool = tool_class.new + result = described_class.tool_for(tool) + + # Both array parameters should have items field + expect(result[:function][:parameters][:properties][:tags]).to eq( + type: 'array', + description: 'Tags list', + items: { type: 'string' } + ) + + expect(result[:function][:parameters][:properties][:categories]).to eq( + type: 'array', + description: 'Categories list', + items: { type: 'string' } + ) + + expect(result[:function][:parameters][:required]).to contain_exactly(:tags) + end + end +end From b5a79f70df7112ccb0610e12ac08045cc339b3d5 Mon Sep 17 00:00:00 2001 From: Finbarr Taylor Date: Wed, 10 Sep 2025 11:21:55 -0700 Subject: [PATCH 4/4] Support all array item types in tool declarations, not just strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, array parameters in tool declarations only supported string item types. This was limiting for tools that needed arrays of numbers, booleans, objects, or other types. Changes: - Add item_type parameter to RubyLLM::Parameter class - Update OpenAI provider to use item_type for array parameters - Update Gemini provider to use item_type with proper type mapping - Add comprehensive tests for different array item types This enables tools to declare arrays with specific item types: - Arrays of integers/numbers for numeric data - Arrays of booleans for flag collections - Arrays of objects for complex structured data Addresses feedback from PR #358 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- gemfiles/rails_7.1.gemfile.lock | 5 ++ gemfiles/rails_7.2.gemfile.lock | 5 ++ gemfiles/rails_8.0.gemfile.lock | 5 ++ lib/ruby_llm/providers/gemini/tools.rb | 5 +- lib/ruby_llm/providers/openai/tools.rb | 2 +- lib/ruby_llm/tool.rb | 5 +- spec/ruby_llm/providers/gemini/tools_spec.rb | 62 +++++++++++++++++++- spec/ruby_llm/providers/openai/tools_spec.rb | 50 +++++++++++++++- 8 files changed, 133 insertions(+), 6 deletions(-) diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index 1b031bd85..6e3036639 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -148,6 +148,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -224,6 +225,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -355,6 +358,7 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.3-arm64-darwin) sqlite3 (2.7.3-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) @@ -380,6 +384,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES diff --git a/gemfiles/rails_7.2.gemfile.lock b/gemfiles/rails_7.2.gemfile.lock index 3a533a585..3d03f5a99 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -142,6 +142,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -217,6 +218,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -348,6 +351,7 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.3-arm64-darwin) sqlite3 (2.7.3-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) @@ -374,6 +378,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES diff --git a/gemfiles/rails_8.0.gemfile.lock b/gemfiles/rails_8.0.gemfile.lock index 3a02ad918..8ec206052 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -142,6 +142,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -217,6 +218,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -348,6 +351,7 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.3-arm64-darwin) sqlite3 (2.7.3-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) @@ -374,6 +378,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES diff --git a/lib/ruby_llm/providers/gemini/tools.rb b/lib/ruby_llm/providers/gemini/tools.rb index 8a071feed..2ec582e06 100644 --- a/lib/ruby_llm/providers/gemini/tools.rb +++ b/lib/ruby_llm/providers/gemini/tools.rb @@ -59,7 +59,10 @@ def format_parameters(parameters) }.compact # Add items field for array types - property[:items] = { type: 'STRING' } if param.type.to_s.downcase == 'array' + if param.type.to_s.downcase == 'array' + item_type = param.item_type ? param_type_for_gemini(param.item_type) : 'STRING' + property[:items] = { type: item_type } + end property end, diff --git a/lib/ruby_llm/providers/openai/tools.rb b/lib/ruby_llm/providers/openai/tools.rb index 604125df1..d147dbc59 100644 --- a/lib/ruby_llm/providers/openai/tools.rb +++ b/lib/ruby_llm/providers/openai/tools.rb @@ -29,7 +29,7 @@ def param_schema(param) }.compact # Add items field for array types - schema[:items] = { type: 'string' } if param.type.to_s.downcase == 'array' + schema[:items] = { type: param.item_type || 'string' } if param.type.to_s.downcase == 'array' schema end diff --git a/lib/ruby_llm/tool.rb b/lib/ruby_llm/tool.rb index 20fe4d7fc..4dc4dd44d 100644 --- a/lib/ruby_llm/tool.rb +++ b/lib/ruby_llm/tool.rb @@ -3,13 +3,14 @@ module RubyLLM # Parameter definition for Tool methods. class Parameter - attr_reader :name, :type, :description, :required + attr_reader :name, :type, :description, :required, :item_type - def initialize(name, type: 'string', desc: nil, required: true) + def initialize(name, type: 'string', desc: nil, required: true, item_type: nil) @name = name @type = type @description = desc @required = required + @item_type = item_type end end diff --git a/spec/ruby_llm/providers/gemini/tools_spec.rb b/spec/ruby_llm/providers/gemini/tools_spec.rb index db0448ae6..5c89c5e3a 100644 --- a/spec/ruby_llm/providers/gemini/tools_spec.rb +++ b/spec/ruby_llm/providers/gemini/tools_spec.rb @@ -14,7 +14,7 @@ describe '#format_parameters' do context 'with array type parameter' do - it 'includes items field for array parameters' do + it 'includes items field for array parameters with default STRING type' do parameters = { fields: RubyLLM::Parameter.new('fields', type: 'array', desc: 'List of fields to return') } @@ -28,6 +28,66 @@ items: { type: 'STRING' } ) end + + it 'supports arrays of numbers' do + parameters = { + scores: RubyLLM::Parameter.new('scores', type: 'array', desc: 'List of scores', item_type: 'number') + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:type]).to eq('OBJECT') + expect(result[:properties][:scores]).to eq( + type: 'ARRAY', + description: 'List of scores', + items: { type: 'NUMBER' } + ) + end + + it 'supports arrays of integers' do + parameters = { + ids: RubyLLM::Parameter.new('ids', type: 'array', desc: 'List of IDs', item_type: 'integer') + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:type]).to eq('OBJECT') + expect(result[:properties][:ids]).to eq( + type: 'ARRAY', + description: 'List of IDs', + items: { type: 'NUMBER' } + ) + end + + it 'supports arrays of booleans' do + parameters = { + flags: RubyLLM::Parameter.new('flags', type: 'array', desc: 'List of flags', item_type: 'boolean') + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:type]).to eq('OBJECT') + expect(result[:properties][:flags]).to eq( + type: 'ARRAY', + description: 'List of flags', + items: { type: 'BOOLEAN' } + ) + end + + it 'supports arrays of objects' do + parameters = { + users: RubyLLM::Parameter.new('users', type: 'array', desc: 'List of users', item_type: 'object') + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:type]).to eq('OBJECT') + expect(result[:properties][:users]).to eq( + type: 'ARRAY', + description: 'List of users', + items: { type: 'OBJECT' } + ) + end end context 'with mixed parameter types' do diff --git a/spec/ruby_llm/providers/openai/tools_spec.rb b/spec/ruby_llm/providers/openai/tools_spec.rb index 57c2dd189..4a46bb172 100644 --- a/spec/ruby_llm/providers/openai/tools_spec.rb +++ b/spec/ruby_llm/providers/openai/tools_spec.rb @@ -6,7 +6,7 @@ RSpec.describe RubyLLM::Providers::OpenAI::Tools do # rubocop:disable RSpec/SpecFilePathFormat describe '.param_schema' do context 'with array type parameter' do - it 'includes items field for array parameters' do + it 'includes items field for array parameters with default string type' do param = RubyLLM::Parameter.new('tags', type: 'array', desc: 'List of tags') result = described_class.param_schema(param) @@ -17,6 +17,54 @@ items: { type: 'string' } ) end + + it 'supports arrays of integers' do + param = RubyLLM::Parameter.new('scores', type: 'array', desc: 'List of scores', item_type: 'integer') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'array', + description: 'List of scores', + items: { type: 'integer' } + ) + end + + it 'supports arrays of numbers' do + param = RubyLLM::Parameter.new('prices', type: 'array', desc: 'List of prices', item_type: 'number') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'array', + description: 'List of prices', + items: { type: 'number' } + ) + end + + it 'supports arrays of booleans' do + param = RubyLLM::Parameter.new('flags', type: 'array', desc: 'List of flags', item_type: 'boolean') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'array', + description: 'List of flags', + items: { type: 'boolean' } + ) + end + + it 'supports arrays of objects' do + param = RubyLLM::Parameter.new('users', type: 'array', desc: 'List of users', item_type: 'object') + + result = described_class.param_schema(param) + + expect(result).to eq( + type: 'array', + description: 'List of users', + items: { type: 'object' } + ) + end end context 'with non-array parameters' do