ruby_llm 0.1.0.pre3 → 0.1.0.pre5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,187 +2,170 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Providers
5
- class OpenAI < Base
6
- def chat(messages, model: nil, temperature: 0.7, stream: false, tools: nil, &block)
7
- payload = {
8
- model: model || RubyLLM.configuration.default_model,
9
- messages: messages.map(&:to_h),
10
- temperature: temperature,
11
- stream: stream
5
+ class OpenAI
6
+ include Provider
7
+
8
+ private
9
+
10
+ def api_base
11
+ 'https://api.openai.com'
12
+ end
13
+
14
+ def headers
15
+ {
16
+ 'Authorization' => "Bearer #{RubyLLM.config.openai_api_key}"
12
17
  }
18
+ end
13
19
 
14
- if tools&.any?
15
- payload[:functions] = tools.map { |tool| tool_to_function(tool) }
16
- payload[:function_call] = 'auto'
17
- end
20
+ def completion_url
21
+ '/v1/chat/completions'
22
+ end
18
23
 
19
- puts 'Sending payload to OpenAI:' if ENV['RUBY_LLM_DEBUG']
20
- puts JSON.pretty_generate(payload) if ENV['RUBY_LLM_DEBUG']
24
+ def models_url
25
+ '/v1/models'
26
+ end
21
27
 
22
- if stream && block_given?
23
- stream_chat_completion(payload, tools, &block)
24
- else
25
- create_chat_completion(payload, tools)
28
+ def build_payload(messages, tools, model:, temperature: 0.7, stream: false)
29
+ {
30
+ model: model,
31
+ messages: format_messages(messages),
32
+ temperature: temperature,
33
+ stream: stream
34
+ }.tap do |payload|
35
+ if tools.any?
36
+ payload[:tools] = tools.map { |t| tool_for(t) }
37
+ payload[:tool_choice] = 'auto'
38
+ end
26
39
  end
27
- rescue Faraday::TimeoutError
28
- raise RubyLLM::Error, 'Request timed out'
29
- rescue Faraday::ConnectionFailed
30
- raise RubyLLM::Error, 'Connection failed'
31
- rescue Faraday::ClientError => e
32
- raise RubyLLM::Error, 'Client error' unless e.response
33
-
34
- error_msg = e.response[:body]['error']&.fetch('message', nil) || "HTTP #{e.response[:status]}"
35
- raise RubyLLM::Error, "API error: #{error_msg}"
36
40
  end
37
41
 
38
- def list_models
39
- response = @connection.get('/v1/models') do |req|
40
- req.headers['Authorization'] = "Bearer #{RubyLLM.configuration.openai_api_key}"
42
+ def format_messages(messages)
43
+ messages.map do |msg|
44
+ {
45
+ role: msg.role.to_s,
46
+ content: msg.content,
47
+ tool_calls: format_tool_calls(msg.tool_calls)
48
+ }.compact
41
49
  end
50
+ end
42
51
 
43
- raise RubyLLM::Error, "API error: #{parse_error_message(response)}" if response.status >= 400
44
-
45
- capabilities = RubyLLM::ModelCapabilities::OpenAI.new
46
- (response.body['data'] || []).map do |model|
47
- ModelInfo.new(
48
- id: model['id'],
49
- created_at: Time.at(model['created']),
50
- display_name: capabilities.format_display_name(model['id']),
51
- provider: 'openai',
52
- metadata: {
53
- object: model['object'],
54
- owned_by: model['owned_by']
55
- },
56
- context_window: capabilities.determine_context_window(model['id']),
57
- max_tokens: capabilities.determine_max_tokens(model['id']),
58
- supports_vision: capabilities.supports_vision?(model['id']),
59
- supports_functions: capabilities.supports_functions?(model['id']),
60
- supports_json_mode: capabilities.supports_json_mode?(model['id']),
61
- input_price_per_million: capabilities.get_input_price(model['id']),
62
- output_price_per_million: capabilities.get_output_price(model['id'])
63
- )
52
+ def format_tool_calls(tool_calls)
53
+ return nil unless tool_calls
54
+
55
+ tool_calls.map do |tc|
56
+ {
57
+ id: tc[:id],
58
+ type: 'function',
59
+ function: {
60
+ name: tc[:name],
61
+ arguments: tc[:arguments]
62
+ }
63
+ }
64
64
  end
65
- rescue Faraday::Error => e
66
- handle_error(e)
67
65
  end
68
66
 
69
- private
70
-
71
- def tool_to_function(tool)
67
+ def tool_for(tool)
72
68
  {
73
- name: tool.name,
74
- description: tool.description,
75
- parameters: {
76
- type: 'object',
77
- properties: tool.parameters.transform_values { |v| v.reject { |k, _| k == :required } },
78
- required: tool.parameters.select { |_, v| v[:required] }.keys
69
+ type: 'function',
70
+ function: {
71
+ name: tool.name,
72
+ description: tool.description,
73
+ parameters: {
74
+ type: 'object',
75
+ properties: tool.parameters.transform_values { |param| param_schema(param) },
76
+ required: tool.parameters.select { |_, p| p.required }.keys
77
+ }
79
78
  }
80
79
  }
81
80
  end
82
81
 
83
- def create_chat_completion(payload, tools = nil)
84
- response = connection.post('/v1/chat/completions') do |req|
85
- req.headers['Authorization'] = "Bearer #{RubyLLM.configuration.openai_api_key}"
86
- req.headers['Content-Type'] = 'application/json'
87
- req.body = payload
88
- end
89
-
90
- puts 'Response from OpenAI:' if ENV['RUBY_LLM_DEBUG']
91
- puts JSON.pretty_generate(response.body) if ENV['RUBY_LLM_DEBUG']
92
-
93
- if response.status >= 400
94
- error_msg = response.body['error']&.fetch('message', nil) || "HTTP #{response.status}"
95
- raise RubyLLM::Error, "API error: #{error_msg}"
96
- end
97
-
98
- handle_response(response, tools, payload)
82
+ def param_schema(param)
83
+ {
84
+ type: param.type,
85
+ description: param.description
86
+ }.compact
99
87
  end
100
88
 
101
- def handle_response(response, tools, payload)
89
+ def parse_completion_response(response)
102
90
  data = response.body
103
- message_data = data.dig('choices', 0, 'message')
104
- return Message.new(role: :assistant, content: '') unless message_data
91
+ return if data.empty?
105
92
 
106
- if message_data['function_call'] && tools
107
- result = handle_function_call(message_data['function_call'], tools)
108
- puts "Function result: #{result}" if ENV['RUBY_LLM_DEBUG']
109
-
110
- # Create a new chat completion with the function results
111
- new_messages = payload[:messages] + [
112
- { role: 'assistant', content: message_data['content'], function_call: message_data['function_call'] },
113
- { role: 'function', name: message_data['function_call']['name'], content: result }
114
- ]
115
-
116
- return create_chat_completion(payload.merge(messages: new_messages), tools)
117
- end
118
-
119
- # Extract token usage from response
120
- token_usage = if data['usage']
121
- {
122
- input_tokens: data['usage']['prompt_tokens'],
123
- output_tokens: data['usage']['completion_tokens'],
124
- total_tokens: data['usage']['total_tokens']
125
- }
126
- end
93
+ message_data = data.dig('choices', 0, 'message')
94
+ return unless message_data
127
95
 
128
96
  Message.new(
129
97
  role: :assistant,
130
98
  content: message_data['content'],
131
- token_usage: token_usage,
99
+ tool_calls: parse_tool_calls(message_data['tool_calls']),
100
+ input_tokens: data['usage']['prompt_tokens'],
101
+ output_tokens: data['usage']['completion_tokens'],
132
102
  model_id: data['model']
133
103
  )
134
104
  end
135
105
 
136
- def handle_function_call(function_call, tools)
137
- return unless function_call && tools
106
+ def parse_tool_calls(tool_calls)
107
+ return nil unless tool_calls&.any?
138
108
 
139
- tool = tools.find { |t| t.name == function_call['name'] }
140
- return unless tool
141
-
142
- begin
143
- args = JSON.parse(function_call['arguments'])
144
- tool.call(args)
145
- rescue JSON::ParserError, ArgumentError => e
146
- "Error executing function #{tool.name}: #{e.message}"
109
+ tool_calls.map do |tc|
110
+ {
111
+ id: tc['id'],
112
+ name: tc.dig('function', 'name'),
113
+ arguments: tc.dig('function', 'arguments')
114
+ }
147
115
  end
148
116
  end
149
117
 
150
- def handle_error(error)
151
- case error
152
- when Faraday::TimeoutError
153
- raise RubyLLM::Error, 'Request timed out'
154
- when Faraday::ConnectionFailed
155
- raise RubyLLM::Error, 'Connection failed'
156
- when Faraday::ClientError
157
- raise RubyLLM::Error, 'Client error' unless error.response
158
-
159
- error_msg = error.response[:body]['error']&.fetch('message', nil) || "HTTP #{error.response[:status]}"
160
- raise RubyLLM::Error, "API error: #{error_msg}"
118
+ def parse_models_response(response)
119
+ (response.body['data'] || []).map do |model|
120
+ model_info = begin
121
+ Models.find(model['id'])
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ next unless model_info
161
126
 
162
- else
163
- raise error
164
- end
127
+ model_info.tap do |info|
128
+ info.metadata.merge!(
129
+ object: model['object'],
130
+ owned_by: model['owned_by']
131
+ )
132
+ end
133
+ end.compact
165
134
  end
166
135
 
167
- def handle_api_error(error)
168
- response_body = error.response[:body]
169
- if response_body.is_a?(String)
170
- begin
171
- error_data = JSON.parse(response_body)
172
- message = error_data.dig('error', 'message')
173
- raise RubyLLM::Error, "API error: #{message}" if message
174
- rescue JSON::ParserError
175
- raise RubyLLM::Error, "API error: #{error.response[:status]}"
176
- end
177
- elsif response_body['error']
178
- raise RubyLLM::Error, "API error: #{response_body['error']['message']}"
179
- else
180
- raise RubyLLM::Error, "API error: #{error.response[:status]}"
136
+ def handle_stream(&block)
137
+ to_json_stream do |data|
138
+ block.call(
139
+ Chunk.new(
140
+ role: :assistant,
141
+ model_id: data['model'],
142
+ content: data.dig('choices', 0, 'delta', 'content')
143
+ )
144
+ )
181
145
  end
182
146
  end
183
147
 
184
- def api_base
185
- 'https://api.openai.com'
148
+ def parse_list_models_response(response)
149
+ capabilities = ModelCapabilities::OpenAI
150
+ (response.body['data'] || []).map do |model|
151
+ ModelInfo.new(
152
+ id: model['id'],
153
+ created_at: Time.at(model['created']),
154
+ display_name: capabilities.format_display_name(model['id']),
155
+ provider: 'openai',
156
+ metadata: {
157
+ object: model['object'],
158
+ owned_by: model['owned_by']
159
+ },
160
+ context_window: capabilities.context_window_for(model['id']),
161
+ max_tokens: capabilities.max_tokens_for(model['id']),
162
+ supports_vision: capabilities.supports_vision?(model['id']),
163
+ supports_functions: capabilities.supports_functions?(model['id']),
164
+ supports_json_mode: capabilities.supports_json_mode?(model['id']),
165
+ input_price_per_million: capabilities.input_price_for(model['id']),
166
+ output_price_per_million: capabilities.output_price_for(model['id'])
167
+ )
168
+ end
186
169
  end
187
170
  end
188
171
  end
data/lib/ruby_llm/tool.rb CHANGED
@@ -1,75 +1,96 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- # Represents a tool/function that can be called by an LLM
5
4
  class Tool
6
- attr_reader :name, :description, :parameters, :handler
5
+ class Parameter
6
+ attr_reader :name, :type, :description, :required
7
+
8
+ def initialize(name, type: 'string', description: nil, required: true)
9
+ @name = name
10
+ @type = type
11
+ @description = description
12
+ @required = required
13
+ end
7
14
 
8
- def self.from_method(method_object, description: nil, parameter_descriptions: {})
9
- method_params = {}
10
- method_object.parameters.each do |param_type, param_name|
11
- next unless %i[req opt key keyreq].include?(param_type)
15
+ def to_h
16
+ {
17
+ type: type,
18
+ description: description,
19
+ required: required
20
+ }.compact
21
+ end
22
+ end
12
23
 
13
- method_params[param_name] = {
14
- type: 'string',
15
- description: parameter_descriptions[param_name] || param_name.to_s.tr('_', ' '),
16
- required: %i[req keyreq].include?(param_type)
17
- }
24
+ class Builder
25
+ def initialize(tool)
26
+ @tool = tool
18
27
  end
19
28
 
20
- new(
21
- name: method_object.name.to_s,
22
- description: description || "Executes the #{method_object.name} operation",
23
- parameters: method_params
24
- ) do |args|
25
- # Create an instance if it's an instance method
26
- instance = if method_object.owner.instance_methods.include?(method_object.name)
27
- method_object.owner.new
28
- else
29
- method_object.owner
30
- end
31
-
32
- # Call the method with the arguments
33
- if args.is_a?(Hash)
34
- instance.method(method_object.name).call(**args)
35
- else
36
- instance.method(method_object.name).call(args)
37
- end
29
+ def description(text)
30
+ @tool.instance_variable_set(:@description, text)
31
+ self
32
+ end
33
+
34
+ def param(name, type: 'string', description: nil, required: true)
35
+ @tool.parameters[name] = Parameter.new(name, type: type, description: description, required: required)
36
+ self
37
+ end
38
+
39
+ def handler(&block)
40
+ @tool.instance_variable_set(:@handler, block)
41
+ @tool
38
42
  end
39
43
  end
40
44
 
41
- def initialize(name:, description:, parameters: {}, &block)
42
- @name = name
43
- @description = description
44
- @parameters = parameters
45
- @handler = block
45
+ attr_reader :name, :description, :parameters, :handler
46
46
 
47
- validate!
47
+ def self.define(name, &block)
48
+ tool = new(name)
49
+ builder = Builder.new(tool)
50
+ builder.instance_eval(&block)
51
+ tool
48
52
  end
49
53
 
50
- def call(args)
51
- validated_args = validate_args!(args)
52
- handler.call(validated_args)
54
+ def initialize(name)
55
+ @name = name
56
+ @parameters = {}
53
57
  end
54
58
 
55
- private
59
+ def call(args)
60
+ raise Error, "No handler defined for tool #{name}" unless @handler
56
61
 
57
- def validate!
58
- raise ArgumentError, 'Name must be a string' unless name.is_a?(String)
59
- raise ArgumentError, 'Description must be a string' unless description.is_a?(String)
60
- raise ArgumentError, 'Parameters must be a hash' unless parameters.is_a?(Hash)
61
- raise ArgumentError, 'Block must be provided' unless handler.respond_to?(:call)
62
+ begin
63
+ args = symbolize_keys(args)
64
+ @handler.call(args)
65
+ rescue StandardError => e
66
+ { error: e.message }
67
+ end
62
68
  end
63
69
 
64
- def validate_args!(args)
65
- args = args.transform_keys(&:to_sym)
66
- required_params = parameters.select { |_, v| v[:required] }.keys
70
+ class << self
71
+ def from_method(method, description: nil)
72
+ define(method.name.to_s) do
73
+ description description if description
74
+
75
+ method.parameters.each do |type, name|
76
+ param name, required: (type == :req)
77
+ end
67
78
 
68
- required_params.each do |param|
69
- raise ArgumentError, "Missing required parameter: #{param}" unless args.key?(param.to_sym)
79
+ handler do |args|
80
+ method.owner.new.public_send(method.name, **args)
81
+ end
82
+ end
70
83
  end
84
+ end
71
85
 
72
- args
86
+ private
87
+
88
+ def symbolize_keys(hash)
89
+ hash.transform_keys do |key|
90
+ key.to_sym
91
+ rescue StandardError
92
+ key
93
+ end
73
94
  end
74
95
  end
75
96
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- VERSION = '0.1.0.pre3'
4
+ VERSION = '0.1.0.pre5'
5
5
  end
data/lib/ruby_llm.rb CHANGED
@@ -3,66 +3,64 @@
3
3
  require 'zeitwerk'
4
4
  require 'faraday'
5
5
  require 'json'
6
- require 'securerandom'
7
6
  require 'logger'
7
+ require 'event_stream_parser'
8
+ require 'securerandom'
8
9
 
9
- # Main module for RubyLLM functionality
10
10
  module RubyLLM
11
11
  class Error < StandardError; end
12
12
 
13
13
  class << self
14
- attr_writer :configuration
14
+ def chat(model: nil)
15
+ Chat.new(model: model)
16
+ end
15
17
 
16
- def configuration
17
- @configuration ||= Configuration.new
18
+ def models
19
+ Models
18
20
  end
19
21
 
20
22
  def configure
21
- yield(configuration) if block_given?
23
+ yield config
24
+ end
25
+
26
+ def config
27
+ @config ||= Configuration.new
22
28
  end
23
29
 
24
- def client
25
- @client ||= Client.new
30
+ def logger
31
+ @logger ||= Logger.new($stdout)
26
32
  end
33
+ end
34
+ end
27
35
 
28
- def loader
29
- @loader ||= begin
30
- loader = Zeitwerk::Loader.for_gem
36
+ loader = Zeitwerk::Loader.for_gem
31
37
 
32
- # Add lib directory to the load path
33
- loader.push_dir(File.expand_path('..', __dir__))
38
+ # Add lib directory to the load path
39
+ loader.push_dir(File.expand_path('..', __dir__))
34
40
 
35
- # Configure custom inflections
36
- loader.inflector.inflect(
37
- 'ruby_llm' => 'RubyLLM',
38
- 'llm' => 'LLM',
39
- 'openai' => 'OpenAI',
40
- 'api' => 'API'
41
- )
41
+ # Configure custom inflections
42
+ loader.inflector.inflect(
43
+ 'ruby_llm' => 'RubyLLM',
44
+ 'llm' => 'LLM',
45
+ 'openai' => 'OpenAI',
46
+ 'api' => 'API'
47
+ )
42
48
 
43
- # Ignore Rails-specific files and specs
44
- loader.ignore("#{__dir__}/ruby_llm/railtie.rb")
45
- loader.ignore("#{__dir__}/ruby_llm/active_record")
46
- loader.ignore(File.expand_path('../spec', __dir__).to_s)
49
+ # Ignore Rails-specific files and specs
50
+ loader.ignore("#{__dir__}/ruby_llm/railtie.rb")
51
+ loader.ignore("#{__dir__}/ruby_llm/active_record")
52
+ loader.ignore(File.expand_path('../spec', __dir__).to_s)
47
53
 
48
- # Log autoloading in debug mode
49
- loader.logger = Logger.new($stdout) if ENV['RUBY_LLM_DEBUG']
50
- loader.enable_reloading if ENV['RUBY_LLM_DEBUG']
54
+ loader.enable_reloading if ENV['RUBY_LLM_DEBUG']
51
55
 
52
- loader.setup
53
- loader.eager_load
54
- loader
55
- end
56
- end
57
- end
58
- end
56
+ loader.setup
57
+ loader.eager_load if ENV['RUBY_LLM_DEBUG']
59
58
 
60
- # Initialize loader
61
- RubyLLM.loader
59
+ RubyLLM::Provider.register :openai, RubyLLM::Providers::OpenAI
60
+ RubyLLM::Provider.register :anthropic, RubyLLM::Providers::Anthropic
62
61
 
63
62
  # Load Rails integration if Rails is defined
64
63
  if defined?(Rails)
65
- require 'active_support'
66
64
  require 'ruby_llm/railtie'
67
65
  require 'ruby_llm/active_record/acts_as'
68
66
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :ruby_llm do
4
+ desc 'Update available models from providers'
5
+ task :update_models do
6
+ require 'ruby_llm'
7
+
8
+ # Configure API keys
9
+ RubyLLM.configure do |config|
10
+ config.openai_api_key = ENV.fetch('OPENAI_API_KEY')
11
+ config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY')
12
+ end
13
+
14
+ # Get all models
15
+ models = RubyLLM.models.refresh
16
+
17
+ # Write to models.json
18
+ models_file = File.expand_path('../../lib/ruby_llm/models.json', __dir__)
19
+ File.write(models_file, JSON.pretty_generate(models.map(&:to_h)))
20
+
21
+ puts "Updated models.json with #{models.size} models:"
22
+ puts "OpenAI models: #{models.count { |m| m.provider == 'openai' }}"
23
+ puts "Anthropic models: #{models.count { |m| m.provider == 'anthropic' }}"
24
+ end
25
+ end
data/ruby_llm.gemspec CHANGED
@@ -32,6 +32,7 @@ Gem::Specification.new do |spec|
32
32
  spec.require_paths = ['lib']
33
33
 
34
34
  # Runtime dependencies
35
+ spec.add_dependency 'event_stream_parser', '>= 0.3.0', '< 2.0.0'
35
36
  spec.add_dependency 'faraday', '>= 2.0'
36
37
  spec.add_dependency 'faraday-multipart', '>= 1.0'
37
38
  spec.add_dependency 'zeitwerk', '>= 2.6'