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 bef6bfa35..2ec582e06 100644 --- a/lib/ruby_llm/providers/gemini/tools.rb +++ b/lib/ruby_llm/providers/gemini/tools.rb @@ -53,10 +53,18 @@ 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 + 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, required: parameters.select { |_, p| p.required }.keys.map(&:to_s) } diff --git a/lib/ruby_llm/providers/openai/tools.rb b/lib/ruby_llm/providers/openai/tools.rb index 94bb97429..d147dbc59 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: param.item_type || 'string' } if param.type.to_s.downcase == 'array' + + schema end def format_tool_calls(tool_calls) 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 new file mode 100644 index 000000000..5c89c5e3a --- /dev/null +++ b/spec/ruby_llm/providers/gemini/tools_spec.rb @@ -0,0 +1,205 @@ +# 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 with default STRING type' 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 eq( + type: 'ARRAY', + description: 'List of fields to return', + 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 + it 'correctly formats all parameter types including arrays with items field' do + parameters = { + name: RubyLLM::Parameter.new('name', type: 'string', desc: 'Name 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') + } + + result = instance.send(:format_parameters, parameters) + + expect(result[:type]).to eq('OBJECT') + + # String parameter - no items field + expect(result[:properties][:name]).to eq( + type: 'STRING', + description: 'Name field' + ) + + # Integer parameter - no items field + expect(result[:properties][:count]).to eq( + type: 'NUMBER', + description: 'Count field' + ) + + # Boolean parameter - no items field + expect(result[:properties][:active]).to eq( + type: 'BOOLEAN', + description: 'Active status' + ) + + # Array parameter - MUST have items field + expect(result[:properties][:tags]).to eq( + type: 'ARRAY', + description: 'List of tags', + items: { type: 'STRING' } + ) + + # Object parameter - no items field + 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), + 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', '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 for Gemini 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 + + def execute(fields:, format: 'json') + "Processed #{fields.length} fields in #{format} format" + end + end + + 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('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 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..4a46bb172 --- /dev/null +++ b/spec/ruby_llm/providers/openai/tools_spec.rb @@ -0,0 +1,198 @@ +# 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 with default string type' 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 + + 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 + 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