ruby_llm 1.12.0 → 1.13.0

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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +1 -1
  4. data/lib/generators/ruby_llm/generator_helpers.rb +4 -0
  5. data/lib/generators/ruby_llm/install/install_generator.rb +5 -4
  6. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  7. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -1
  8. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +1 -6
  9. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
  10. data/lib/ruby_llm/active_record/acts_as.rb +8 -4
  11. data/lib/ruby_llm/active_record/acts_as_legacy.rb +85 -20
  12. data/lib/ruby_llm/active_record/chat_methods.rb +67 -16
  13. data/lib/ruby_llm/agent.rb +39 -8
  14. data/lib/ruby_llm/aliases.json +19 -9
  15. data/lib/ruby_llm/chat.rb +107 -11
  16. data/lib/ruby_llm/configuration.rb +18 -0
  17. data/lib/ruby_llm/connection.rb +10 -4
  18. data/lib/ruby_llm/content.rb +6 -2
  19. data/lib/ruby_llm/error.rb +32 -1
  20. data/lib/ruby_llm/message.rb +5 -3
  21. data/lib/ruby_llm/model/info.rb +1 -1
  22. data/lib/ruby_llm/models.json +3535 -2894
  23. data/lib/ruby_llm/models.rb +5 -3
  24. data/lib/ruby_llm/provider.rb +5 -1
  25. data/lib/ruby_llm/providers/anthropic/capabilities.rb +22 -4
  26. data/lib/ruby_llm/providers/anthropic/chat.rb +22 -5
  27. data/lib/ruby_llm/providers/anthropic/models.rb +1 -1
  28. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  29. data/lib/ruby_llm/providers/anthropic.rb +1 -1
  30. data/lib/ruby_llm/providers/azure/chat.rb +1 -1
  31. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -1
  32. data/lib/ruby_llm/providers/azure/models.rb +1 -1
  33. data/lib/ruby_llm/providers/azure.rb +88 -0
  34. data/lib/ruby_llm/providers/bedrock/chat.rb +50 -5
  35. data/lib/ruby_llm/providers/bedrock/models.rb +17 -1
  36. data/lib/ruby_llm/providers/bedrock/streaming.rb +8 -4
  37. data/lib/ruby_llm/providers/bedrock.rb +5 -1
  38. data/lib/ruby_llm/providers/deepseek/capabilities.rb +8 -0
  39. data/lib/ruby_llm/providers/deepseek.rb +1 -1
  40. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -0
  41. data/lib/ruby_llm/providers/gemini/chat.rb +19 -4
  42. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  43. data/lib/ruby_llm/providers/gemini/streaming.rb +1 -1
  44. data/lib/ruby_llm/providers/gemini/tools.rb +19 -0
  45. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  46. data/lib/ruby_llm/providers/gpustack.rb +4 -0
  47. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  48. data/lib/ruby_llm/providers/mistral/chat.rb +2 -1
  49. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  50. data/lib/ruby_llm/providers/ollama.rb +7 -1
  51. data/lib/ruby_llm/providers/openai/capabilities.rb +10 -2
  52. data/lib/ruby_llm/providers/openai/chat.rb +15 -5
  53. data/lib/ruby_llm/providers/openai/media.rb +4 -1
  54. data/lib/ruby_llm/providers/openai/temperature.rb +2 -2
  55. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  56. data/lib/ruby_llm/providers/openrouter/chat.rb +19 -5
  57. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  58. data/lib/ruby_llm/providers/openrouter.rb +31 -1
  59. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  60. data/lib/ruby_llm/providers/vertexai.rb +14 -6
  61. data/lib/ruby_llm/stream_accumulator.rb +10 -5
  62. data/lib/ruby_llm/streaming.rb +6 -6
  63. data/lib/ruby_llm/tool.rb +48 -3
  64. data/lib/ruby_llm/version.rb +1 -1
  65. data/lib/tasks/models.rake +33 -7
  66. data/lib/tasks/release.rake +1 -1
  67. data/lib/tasks/ruby_llm.rake +7 -0
  68. data/lib/tasks/vcr.rake +1 -1
  69. metadata +8 -5
data/lib/ruby_llm/chat.rb CHANGED
@@ -5,7 +5,7 @@ module RubyLLM
5
5
  class Chat
6
6
  include Enumerable
7
7
 
8
- attr_reader :model, :messages, :tools, :params, :headers, :schema
8
+ attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
9
9
 
10
10
  def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
11
11
  if assume_model_exists && !provider
@@ -19,6 +19,7 @@ module RubyLLM
19
19
  @temperature = nil
20
20
  @messages = []
21
21
  @tools = {}
22
+ @tool_prefs = { choice: nil, calls: nil }
22
23
  @params = {}
23
24
  @headers = {}
24
25
  @schema = nil
@@ -50,15 +51,19 @@ module RubyLLM
50
51
  self
51
52
  end
52
53
 
53
- def with_tool(tool)
54
- tool_instance = tool.is_a?(Class) ? tool.new : tool
55
- @tools[tool_instance.name.to_sym] = tool_instance
54
+ def with_tool(tool, choice: nil, calls: nil)
55
+ unless tool.nil?
56
+ tool_instance = tool.is_a?(Class) ? tool.new : tool
57
+ @tools[tool_instance.name.to_sym] = tool_instance
58
+ end
59
+ update_tool_options(choice:, calls:)
56
60
  self
57
61
  end
58
62
 
59
- def with_tools(*tools, replace: false)
63
+ def with_tools(*tools, replace: false, choice: nil, calls: nil)
60
64
  @tools.clear if replace
61
65
  tools.compact.each { |tool| with_tool tool }
66
+ update_tool_options(choice:, calls:)
62
67
  self
63
68
  end
64
69
 
@@ -100,12 +105,9 @@ module RubyLLM
100
105
  def with_schema(schema)
101
106
  schema_instance = schema.is_a?(Class) ? schema.new : schema
102
107
 
103
- # Accept both RubyLLM::Schema instances and plain JSON schemas
104
- @schema = if schema_instance.respond_to?(:to_json_schema)
105
- schema_instance.to_json_schema[:schema]
106
- else
107
- schema_instance
108
- end
108
+ @schema = normalize_schema_payload(
109
+ schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
110
+ )
109
111
 
110
112
  self
111
113
  end
@@ -138,6 +140,7 @@ module RubyLLM
138
140
  response = @provider.complete(
139
141
  messages,
140
142
  tools: @tools,
143
+ tool_prefs: @tool_prefs,
141
144
  temperature: @temperature,
142
145
  model: @model,
143
146
  params: @params,
@@ -183,6 +186,36 @@ module RubyLLM
183
186
 
184
187
  private
185
188
 
189
+ def normalize_schema_payload(raw_schema)
190
+ return nil if raw_schema.nil?
191
+ return raw_schema unless raw_schema.is_a?(Hash)
192
+
193
+ schema = RubyLLM::Utils.deep_symbolize_keys(raw_schema)
194
+ schema_def = extract_schema_definition(schema)
195
+ strict = extract_schema_strict(schema, schema_def)
196
+ build_schema_payload(schema, schema_def, strict)
197
+ end
198
+
199
+ def extract_schema_definition(schema)
200
+ RubyLLM::Utils.deep_dup(schema[:schema] || schema)
201
+ end
202
+
203
+ def extract_schema_strict(schema, schema_def)
204
+ return schema[:strict] if schema.key?(:strict)
205
+ return schema_def.delete(:strict) if schema_def.is_a?(Hash)
206
+
207
+ nil
208
+ end
209
+
210
+ def build_schema_payload(schema, schema_def, strict)
211
+ {
212
+ name: schema[:name] || 'response',
213
+ schema: schema_def,
214
+ strict: strict.nil? || strict,
215
+ description: schema[:description]
216
+ }.compact
217
+ end
218
+
186
219
  def wrap_streaming_block(&block)
187
220
  return nil unless block_given?
188
221
 
@@ -209,15 +242,78 @@ module RubyLLM
209
242
  halt_result = result if result.is_a?(Tool::Halt)
210
243
  end
211
244
 
245
+ reset_tool_choice if forced_tool_choice?
212
246
  halt_result || complete(&)
213
247
  end
214
248
 
215
249
  def execute_tool(tool_call)
216
250
  tool = tools[tool_call.name.to_sym]
251
+ if tool.nil?
252
+ return {
253
+ error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
254
+ "Available tools: #{tools.keys.to_json}."
255
+ }
256
+ end
257
+
217
258
  args = tool_call.arguments
218
259
  tool.call(args)
219
260
  end
220
261
 
262
+ def update_tool_options(choice:, calls:)
263
+ unless choice.nil?
264
+ normalized_choice = normalize_tool_choice(choice)
265
+ valid_tool_choices = %i[auto none required] + tools.keys
266
+ unless valid_tool_choices.include?(normalized_choice)
267
+ raise InvalidToolChoiceError,
268
+ "Invalid tool choice: #{choice}. Valid choices are: #{valid_tool_choices.join(', ')}"
269
+ end
270
+
271
+ @tool_prefs[:choice] = normalized_choice
272
+ end
273
+
274
+ @tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
275
+ end
276
+
277
+ def normalize_calls(calls)
278
+ case calls
279
+ when :many, 'many'
280
+ :many
281
+ when :one, 'one', 1
282
+ :one
283
+ else
284
+ raise ArgumentError, "Invalid calls value: #{calls.inspect}. Valid values are: :many, :one, or 1"
285
+ end
286
+ end
287
+
288
+ def normalize_tool_choice(choice)
289
+ return choice.to_sym if choice.is_a?(String) || choice.is_a?(Symbol)
290
+ return tool_name_for_choice_class(choice) if choice.is_a?(Class)
291
+
292
+ choice.respond_to?(:name) ? choice.name.to_sym : choice.to_sym
293
+ end
294
+
295
+ def tool_name_for_choice_class(tool_class)
296
+ matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
297
+ return matched_tool_name if matched_tool_name
298
+
299
+ classify_tool_name(tool_class.name)
300
+ end
301
+
302
+ def classify_tool_name(class_name)
303
+ class_name.split('::').last
304
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
305
+ .downcase
306
+ .to_sym
307
+ end
308
+
309
+ def forced_tool_choice?
310
+ @tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
311
+ end
312
+
313
+ def reset_tool_choice
314
+ @tool_prefs[:choice] = nil
315
+ end
316
+
221
317
  def build_content(message, attachments)
222
318
  return message if content_like?(message)
223
319
 
@@ -11,20 +11,25 @@ module RubyLLM
11
11
  :azure_api_base,
12
12
  :azure_api_key,
13
13
  :azure_ai_auth_token,
14
+ :anthropic_api_base,
14
15
  :anthropic_api_key,
15
16
  :gemini_api_key,
16
17
  :gemini_api_base,
17
18
  :vertexai_project_id,
18
19
  :vertexai_location,
20
+ :vertexai_service_account_key,
19
21
  :deepseek_api_key,
22
+ :deepseek_api_base,
20
23
  :perplexity_api_key,
21
24
  :bedrock_api_key,
22
25
  :bedrock_secret_key,
23
26
  :bedrock_region,
24
27
  :bedrock_session_token,
28
+ :openrouter_api_base,
25
29
  :openrouter_api_key,
26
30
  :xai_api_key,
27
31
  :ollama_api_base,
32
+ :ollama_api_key,
28
33
  :gpustack_api_base,
29
34
  :gpustack_api_key,
30
35
  :mistral_api_key,
@@ -51,6 +56,7 @@ module RubyLLM
51
56
  :log_file,
52
57
  :log_level,
53
58
  :log_stream_debug
59
+ attr_reader :log_regexp_timeout
54
60
 
55
61
  def initialize
56
62
  @request_timeout = 300
@@ -73,10 +79,22 @@ module RubyLLM
73
79
  @log_file = $stdout
74
80
  @log_level = ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO
75
81
  @log_stream_debug = ENV['RUBYLLM_STREAM_DEBUG'] == 'true'
82
+ self.log_regexp_timeout = Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil
76
83
  end
77
84
 
78
85
  def instance_variables
79
86
  super.reject { |ivar| ivar.to_s.match?(/_id|_key|_secret|_token$/) }
80
87
  end
88
+
89
+ def log_regexp_timeout=(value)
90
+ if value.nil?
91
+ @log_regexp_timeout = nil
92
+ elsif Regexp.respond_to?(:timeout)
93
+ @log_regexp_timeout = value
94
+ else
95
+ RubyLLM.logger.warn("log_regexp_timeout is not supported on Ruby #{RUBY_VERSION}")
96
+ @log_regexp_timeout = value
97
+ end
98
+ end
81
99
  end
82
100
  end
@@ -65,19 +65,25 @@ module RubyLLM
65
65
  errors: true,
66
66
  headers: false,
67
67
  log_level: :debug do |logger|
68
- logger.filter(%r{[A-Za-z0-9+/=]{100,}}, '[BASE64 DATA]')
69
- logger.filter(/[-\d.e,\s]{100,}/, '[EMBEDDINGS ARRAY]')
68
+ logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}'), '[BASE64 DATA]')
69
+ logger.filter(logging_regexp('[-\\d.e,\\s]{100,}'), '[EMBEDDINGS ARRAY]')
70
70
  end
71
71
  end
72
72
 
73
+ def logging_regexp(pattern)
74
+ return Regexp.new(pattern) if @config.log_regexp_timeout.nil? || !Regexp.respond_to?(:timeout)
75
+
76
+ Regexp.new(pattern, timeout: @config.log_regexp_timeout)
77
+ end
78
+
73
79
  def setup_retry(faraday)
74
80
  faraday.request :retry, {
75
81
  max: @config.max_retries,
76
82
  interval: @config.retry_interval,
77
83
  interval_randomness: @config.retry_interval_randomness,
78
84
  backoff_factor: @config.retry_backoff_factor,
79
- exceptions: retry_exceptions,
80
- retry_statuses: [429, 500, 502, 503, 504, 529]
85
+ methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS + [:post],
86
+ exceptions: retry_exceptions
81
87
  }
82
88
  end
83
89
 
@@ -35,10 +35,16 @@ module RubyLLM
35
35
 
36
36
  def process_attachments_array_or_string(attachments)
37
37
  Utils.to_safe_array(attachments).each do |file|
38
+ next if blank_attachment_entry?(file)
39
+
38
40
  add_attachment(file)
39
41
  end
40
42
  end
41
43
 
44
+ def blank_attachment_entry?(file)
45
+ file.nil? || (file.is_a?(String) && file.strip.empty?)
46
+ end
47
+
42
48
  def process_attachments(attachments)
43
49
  if attachments.is_a?(Hash)
44
50
  attachments.each_value { |attachment| process_attachments_array_or_string(attachment) }
@@ -47,9 +53,7 @@ module RubyLLM
47
53
  end
48
54
  end
49
55
  end
50
- end
51
56
 
52
- module RubyLLM
53
57
  class Content
54
58
  # Represents provider-specific payloads that should bypass RubyLLM formatting.
55
59
  class Raw
@@ -14,13 +14,16 @@ module RubyLLM
14
14
 
15
15
  # Error classes for non-HTTP errors
16
16
  class ConfigurationError < StandardError; end
17
+ class PromptNotFoundError < StandardError; end
17
18
  class InvalidRoleError < StandardError; end
19
+ class InvalidToolChoiceError < StandardError; end
18
20
  class ModelNotFoundError < StandardError; end
19
21
  class UnsupportedAttachmentError < StandardError; end
20
22
 
21
23
  # Error classes for different HTTP status codes
22
24
  class BadRequestError < Error; end
23
25
  class ForbiddenError < Error; end
26
+ class ContextLengthExceededError < Error; end
24
27
  class OverloadedError < Error; end
25
28
  class PaymentRequiredError < Error; end
26
29
  class RateLimitError < Error; end
@@ -42,6 +45,18 @@ module RubyLLM
42
45
  end
43
46
 
44
47
  class << self
48
+ CONTEXT_LENGTH_PATTERNS = [
49
+ /context length/i,
50
+ /context window/i,
51
+ /maximum context/i,
52
+ /request too large/i,
53
+ /too many tokens/i,
54
+ /token count exceeds/i,
55
+ /input[_\s-]?token/i,
56
+ /input or output tokens? must be reduced/i,
57
+ /reduce the length of messages/i
58
+ ].freeze
59
+
45
60
  def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
46
61
  message = provider&.parse_error(response)
47
62
 
@@ -49,6 +64,10 @@ module RubyLLM
49
64
  when 200..399
50
65
  message
51
66
  when 400
67
+ if context_length_exceeded?(message)
68
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
69
+ end
70
+
52
71
  raise BadRequestError.new(response, message || 'Invalid request - please check your input')
53
72
  when 401
54
73
  raise UnauthorizedError.new(response, message || 'Invalid API key - check your credentials')
@@ -58,10 +77,14 @@ module RubyLLM
58
77
  raise ForbiddenError.new(response,
59
78
  message || 'Forbidden - you do not have permission to access this resource')
60
79
  when 429
80
+ if context_length_exceeded?(message)
81
+ raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
82
+ end
83
+
61
84
  raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
62
85
  when 500
63
86
  raise ServerError.new(response, message || 'API server error - please try again')
64
- when 502..503
87
+ when 502..504
65
88
  raise ServiceUnavailableError.new(response, message || 'API server unavailable - please try again later')
66
89
  when 529
67
90
  raise OverloadedError.new(response, message || 'Service overloaded - please try again later')
@@ -69,6 +92,14 @@ module RubyLLM
69
92
  raise Error.new(response, message || 'An unknown error occurred')
70
93
  end
71
94
  end
95
+
96
+ private
97
+
98
+ def context_length_exceeded?(message)
99
+ return false if message.to_s.empty?
100
+
101
+ CONTEXT_LENGTH_PATTERNS.any? { |pattern| message.match?(pattern) }
102
+ end
72
103
  end
73
104
  end
74
105
  end
@@ -10,9 +10,9 @@ module RubyLLM
10
10
 
11
11
  def initialize(options = {})
12
12
  @role = options.fetch(:role).to_sym
13
- @content = normalize_content(options.fetch(:content))
14
- @model_id = options[:model_id]
15
13
  @tool_calls = options[:tool_calls]
14
+ @content = normalize_content(options.fetch(:content), role: @role, tool_calls: @tool_calls)
15
+ @model_id = options[:model_id]
16
16
  @tool_call_id = options[:tool_call_id]
17
17
  @tokens = options[:tokens] || Tokens.build(
18
18
  input: options[:input_tokens],
@@ -90,7 +90,9 @@ module RubyLLM
90
90
 
91
91
  private
92
92
 
93
- def normalize_content(content)
93
+ def normalize_content(content, role:, tool_calls:)
94
+ return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
95
+
94
96
  case content
95
97
  when String then Content.new(content)
96
98
  when Hash then Content.new(content[:text], content)
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  @name = data[:name]
25
25
  @provider = data[:provider]
26
26
  @family = data[:family]
27
- @created_at = Utils.to_time(data[:created_at])
27
+ @created_at = Utils.to_time(data[:created_at])&.utc
28
28
  @context_window = data[:context_window]
29
29
  @max_output_tokens = data[:max_output_tokens]
30
30
  @knowledge_cutoff = Utils.to_date(data[:knowledge_cutoff])