ruby_llm 1.12.1 → 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 -2
  11. data/lib/ruby_llm/active_record/acts_as_legacy.rb +29 -0
  12. data/lib/ruby_llm/active_record/chat_methods.rb +35 -4
  13. data/lib/ruby_llm/agent.rb +33 -5
  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 +0 -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 +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f2aa1f16058fca83243f2b098b0a3f454fb9383e410a00b04955cd5b48cbf54
4
- data.tar.gz: fb4591fe16b50449dc1baf90f77a2b92baa986a51b9573f5c0b79dc231d9a9b9
3
+ metadata.gz: efa046d43a24a45287ca68b9ec54c12374dd7a716a41bda2226974925067f547
4
+ data.tar.gz: e7d5187b9cb8d543e8b3b6b2590569cdcedec745f7ff35b6944d68768f803a6c
5
5
  SHA512:
6
- metadata.gz: 687200d2c127d604e0bbff56c888ef5bb5ab2938b585ec2f8959f77b74ad35ae0d0dbad588068ccccd3219ab0aa4208dbc47b8436606c7b12ec1de10cf2928c2
7
- data.tar.gz: 9aab7e7a79aa98b2772e4a01f4269c17a1969755276fde9b0fb4b3311a81dd4ff3a4a1e4e4c4a1f0fa863a162caaa4f3d3cd05cc044cfc5116ceaaa878963737
6
+ metadata.gz: 61a499df157a4276b21aaaebab88518ad8a57a497dc032307bce6a02522ae45eccad01e7989ae1dfafb7149502eb5878d1604cba99ab3f4e2c0a34ef33e43c56
7
+ data.tar.gz: e2a03066ca8fdc265316bff01a9afed4c16a8e9bb32d7ed7f05bb594a00b3897c621039800b7fa8a75eb729b4511af133e240fdd054065d9aa82617899664595
data/README.md CHANGED
@@ -159,6 +159,8 @@ end
159
159
  ```bash
160
160
  # Install Rails Integration
161
161
  rails generate ruby_llm:install
162
+ rails db:migrate
163
+ rails ruby_llm:load_models # v1.13+
162
164
 
163
165
  # Add Chat UI (optional)
164
166
  rails generate ruby_llm:chat_ui
@@ -3,7 +3,7 @@ class <%= chat_job_class_name %> < ApplicationJob
3
3
  <%= chat_variable_name %> = <%= chat_model_name %>.find(<%= chat_variable_name %>_id)
4
4
 
5
5
  <%= chat_variable_name %>.ask(content) do |chunk|
6
- if chunk.content && !chunk.content.blank?
6
+ if chunk.content && !chunk.content.empty?
7
7
  <%= message_variable_name %> = <%= chat_variable_name %>.<%= message_table_name %>.last
8
8
  <%= message_variable_name %>.broadcast_append_chunk(chunk.content)
9
9
  end
@@ -121,6 +121,10 @@ module RubyLLM
121
121
  "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
122
122
  end
123
123
 
124
+ def create_migration_class_name(table_name)
125
+ "create_#{table_name}".camelize
126
+ end
127
+
124
128
  def postgresql?
125
129
  ::ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql')
126
130
  rescue StandardError
@@ -77,12 +77,13 @@ module RubyLLM
77
77
 
78
78
  say "\n Next steps:", :yellow
79
79
  say ' 1. Run: rails db:migrate'
80
- say ' 2. Set your API keys in config/initializers/ruby_llm.rb'
80
+ say ' 2. Run: rails ruby_llm:load_models'
81
+ say ' 3. Set your API keys in config/initializers/ruby_llm.rb'
81
82
 
82
- say " 3. Start chatting: #{chat_model_name}.create!(model: 'gpt-4.1-nano').ask('Hello!')"
83
+ say " 4. Start chatting: #{chat_model_name}.create!(model: 'gpt-4.1-nano').ask('Hello!')"
83
84
 
84
- say "\n 🚀 Model registry is database-backed!", :cyan
85
- say ' Models automatically load from the database'
85
+ say "\n 🚀 Model registry supports database + JSON fallback!", :cyan
86
+ say ' Models load from database when present, otherwise from models.json'
86
87
  say ' Pass model names as strings - RubyLLM handles the rest!'
87
88
  say " Specify provider when needed: Chat.create!(model: 'gemini-2.5-flash', provider: 'vertexai')"
88
89
 
@@ -1,4 +1,4 @@
1
- class Create<%= chat_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %>
1
+ class <%= create_migration_class_name(chat_table_name) %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :<%= chat_table_name %> do |t|
4
4
  t.timestamps
@@ -1,4 +1,4 @@
1
- class Create<%= message_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %>
1
+ class <%= create_migration_class_name(message_table_name) %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :<%= message_table_name %> do |t|
4
4
  t.string :role, null: false
@@ -1,4 +1,4 @@
1
- class Create<%= model_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %>
1
+ class <%= create_migration_class_name(model_table_name) %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :<%= model_table_name %> do |t|
4
4
  t.string :model_id, null: false
@@ -36,10 +36,5 @@ class Create<%= model_model_name.gsub('::', '').pluralize %> < ActiveRecord::Mig
36
36
  <% end %>
37
37
  end
38
38
 
39
- # Load models from JSON
40
- say_with_time "Loading models from models.json" do
41
- RubyLLM.models.load_from_json!
42
- <%= model_model_name %>.save_to_database
43
- end
44
39
  end
45
40
  end
@@ -1,5 +1,5 @@
1
1
  <%#- # Migration for creating tool_calls table with database-specific JSON handling -%>
2
- class Create<%= tool_call_model_name.gsub('::', '').pluralize %> < ActiveRecord::Migration<%= migration_version %>
2
+ class <%= create_migration_class_name(tool_call_table_name) %> < ActiveRecord::Migration<%= migration_version %>
3
3
  def change
4
4
  create_table :<%= tool_call_table_name %> do |t|
5
5
  t.string :tool_call_id, null: false
@@ -12,15 +12,21 @@ module RubyLLM
12
12
  # Monkey-patch Models to use database when ActsAs is active
13
13
  RubyLLM::Models.class_eval do
14
14
  def self.load_models
15
- read_from_database
15
+ database_models = read_from_database
16
+ return database_models if database_models.any?
17
+
18
+ RubyLLM.logger.debug { 'Model registry is empty in database, falling back to JSON registry' }
19
+ read_from_json
16
20
  rescue StandardError => e
17
- RubyLLM.logger.debug "Failed to load models from database: #{e.message}, falling back to JSON"
21
+ RubyLLM.logger.debug { "Failed to load models from database: #{e.message}, falling back to JSON" }
18
22
  read_from_json
19
23
  end
20
24
 
21
25
  def self.read_from_database
22
26
  model_class = RubyLLM.config.model_registry_class
23
27
  model_class = model_class.constantize if model_class.is_a?(String)
28
+ return [] unless model_class.table_exists?
29
+
24
30
  model_class.all.map(&:to_llm)
25
31
  end
26
32
 
@@ -97,6 +97,7 @@ module RubyLLM
97
97
  ordered_messages.each do |msg|
98
98
  @chat.add_message(msg.to_llm)
99
99
  end
100
+ reapply_runtime_instructions(@chat)
100
101
 
101
102
  setup_persistence_callbacks
102
103
  end
@@ -109,6 +110,14 @@ module RubyLLM
109
110
  self
110
111
  end
111
112
 
113
+ def with_runtime_instructions(instructions, append: false, replace: nil)
114
+ append = append_instructions?(append:, replace:)
115
+ store_runtime_instruction(instructions, append:)
116
+
117
+ to_llm.with_instructions(instructions, append:, replace:)
118
+ self
119
+ end
120
+
112
121
  def with_tool(...)
113
122
  to_llm.with_tool(...)
114
123
  self
@@ -285,6 +294,26 @@ module RubyLLM
285
294
  system_messages + non_system_messages
286
295
  end
287
296
 
297
+ def runtime_instructions
298
+ @runtime_instructions ||= []
299
+ end
300
+
301
+ def store_runtime_instruction(instructions, append:)
302
+ if append
303
+ runtime_instructions << instructions
304
+ else
305
+ @runtime_instructions = [instructions]
306
+ end
307
+ end
308
+
309
+ def reapply_runtime_instructions(chat)
310
+ return if runtime_instructions.empty?
311
+
312
+ first, *rest = runtime_instructions
313
+ chat.with_instructions(first)
314
+ rest.each { |instruction| chat.with_instructions(instruction, append: true) }
315
+ end
316
+
288
317
  def setup_persistence_callbacks
289
318
  return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
290
319
 
@@ -79,7 +79,8 @@ module RubyLLM
79
79
  model_record = model_association
80
80
  @chat ||= (context || RubyLLM).chat(
81
81
  model: model_record.model_id,
82
- provider: model_record.provider.to_sym
82
+ provider: model_record.provider.to_sym,
83
+ assume_model_exists: assume_model_exists || false
83
84
  )
84
85
  @chat.reset_messages!
85
86
 
@@ -87,6 +88,7 @@ module RubyLLM
87
88
  ordered_messages.each do |msg|
88
89
  @chat.add_message(msg.to_llm)
89
90
  end
91
+ reapply_runtime_instructions(@chat)
90
92
 
91
93
  setup_persistence_callbacks
92
94
  end
@@ -99,6 +101,14 @@ module RubyLLM
99
101
  self
100
102
  end
101
103
 
104
+ def with_runtime_instructions(instructions, append: false, replace: nil)
105
+ append = append_instructions?(append:, replace:)
106
+ store_runtime_instruction(instructions, append:)
107
+
108
+ to_llm.with_instructions(instructions, append:, replace:)
109
+ self
110
+ end
111
+
102
112
  def with_tool(...)
103
113
  to_llm.with_tool(...)
104
114
  self
@@ -233,9 +243,10 @@ module RubyLLM
233
243
  if last.tool_call?
234
244
  last.destroy
235
245
  elsif last.tool_result?
236
- tool_call_message = last.parent_tool_call.message
237
- expected_results = tool_call_message.tool_calls.pluck(:id)
238
- actual_results = tool_call_message.tool_results.pluck(:tool_call_id)
246
+ tool_call_message = last.parent_tool_call.message_association
247
+ expected_results = tool_call_message.tool_calls_association.pluck(:id)
248
+ fk_column = tool_call_message.class.reflections['tool_results'].foreign_key
249
+ actual_results = tool_call_message.tool_results.pluck(fk_column)
239
250
 
240
251
  if expected_results.sort != actual_results.sort
241
252
  tool_call_message.tool_results.each(&:destroy)
@@ -288,6 +299,26 @@ module RubyLLM
288
299
  system_messages + non_system_messages
289
300
  end
290
301
 
302
+ def runtime_instructions
303
+ @runtime_instructions ||= []
304
+ end
305
+
306
+ def store_runtime_instruction(instructions, append:)
307
+ if append
308
+ runtime_instructions << instructions
309
+ else
310
+ @runtime_instructions = [instructions]
311
+ end
312
+ end
313
+
314
+ def reapply_runtime_instructions(chat)
315
+ return if runtime_instructions.empty?
316
+
317
+ first, *rest = runtime_instructions
318
+ chat.with_instructions(first)
319
+ rest.each { |instruction| chat.with_instructions(instruction, append: true) }
320
+ end
321
+
291
322
  def persist_new_message
292
323
  @message = messages_association.create!(role: :assistant, content: '')
293
324
  end
@@ -3,6 +3,7 @@
3
3
  require 'erb'
4
4
  require 'forwardable'
5
5
  require 'pathname'
6
+ require 'ruby_llm/schema'
6
7
 
7
8
  module RubyLLM
8
9
  # Base class for simple, class-configured agents.
@@ -138,7 +139,10 @@ module RubyLLM
138
139
 
139
140
  def render_prompt(name, chat:, inputs:, locals:)
140
141
  path = prompt_path_for(name)
141
- return nil unless File.exist?(path)
142
+ unless File.exist?(path)
143
+ raise RubyLLM::PromptNotFoundError,
144
+ "Prompt file not found for #{self}: #{path}. Create the file or use inline instructions."
145
+ end
142
146
 
143
147
  resolved_locals = resolve_prompt_locals(locals, runtime: runtime_context(chat:, inputs:), chat:, inputs:)
144
148
  ERB.new(File.read(path)).result_with_hash(resolved_locals)
@@ -177,7 +181,10 @@ module RubyLLM
177
181
  value = resolved_instructions_value(chat_object, runtime, inputs:)
178
182
  return if value.nil?
179
183
 
180
- instruction_target(chat_object, persist:).with_instructions(value)
184
+ target = instruction_target(chat_object, persist:)
185
+ return target.with_runtime_instructions(value) if use_runtime_instructions?(target, persist:)
186
+
187
+ target.with_instructions(value)
181
188
  end
182
189
 
183
190
  def apply_tools(llm_chat, runtime)
@@ -204,10 +211,21 @@ module RubyLLM
204
211
  end
205
212
 
206
213
  def apply_schema(llm_chat, runtime)
207
- value = evaluate(schema, runtime)
214
+ value = resolved_schema_value(runtime)
208
215
  llm_chat.with_schema(value) if value
209
216
  end
210
217
 
218
+ def resolved_schema_value(runtime)
219
+ value = schema
220
+ return value unless value.is_a?(Proc)
221
+
222
+ evaluate(value, runtime)
223
+ rescue NoMethodError => e
224
+ raise unless e.receiver.equal?(runtime)
225
+
226
+ RubyLLM::Schema.create(&value)
227
+ end
228
+
211
229
  def llm_chat_for(chat_object)
212
230
  chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
213
231
  end
@@ -217,7 +235,7 @@ module RubyLLM
217
235
  end
218
236
 
219
237
  def resolved_instructions_value(chat_object, runtime, inputs:)
220
- value = evaluate(instructions, runtime)
238
+ value = evaluate(@instructions, runtime)
221
239
  return value unless prompt_instruction?(value)
222
240
 
223
241
  runtime.prompt(
@@ -234,10 +252,20 @@ module RubyLLM
234
252
  if persist || !chat_object.respond_to?(:to_llm)
235
253
  chat_object
236
254
  else
237
- chat_object.to_llm
255
+ runtime_instruction_target(chat_object)
238
256
  end
239
257
  end
240
258
 
259
+ def runtime_instruction_target(chat_object)
260
+ return chat_object if chat_object.respond_to?(:with_runtime_instructions)
261
+
262
+ chat_object.to_llm
263
+ end
264
+
265
+ def use_runtime_instructions?(target, persist:)
266
+ !persist && target.respond_to?(:with_runtime_instructions)
267
+ end
268
+
241
269
  def resolve_prompt_locals(locals, runtime:, chat:, inputs:)
242
270
  base = { chat: chat }.merge(inputs)
243
271
  evaluated = locals.each_with_object({}) do |(key, value), acc|
@@ -1,8 +1,4 @@
1
1
  {
2
- "chatgpt-4o": {
3
- "openai": "chatgpt-4o-latest",
4
- "openrouter": "openai/chatgpt-4o-latest"
5
- },
6
2
  "claude-3-5-haiku": {
7
3
  "anthropic": "claude-3-5-haiku-20241022",
8
4
  "openrouter": "anthropic/claude-3.5-haiku",
@@ -31,7 +27,7 @@
31
27
  },
32
28
  "claude-3-opus": {
33
29
  "anthropic": "claude-3-opus-20240229",
34
- "bedrock": "anthropic.claude-3-opus-20240229-v1:0:200k"
30
+ "bedrock": "anthropic.claude-3-opus-20240229-v1:0"
35
31
  },
36
32
  "claude-3-sonnet": {
37
33
  "anthropic": "claude-3-sonnet-20240229",
@@ -82,6 +78,11 @@
82
78
  "bedrock": "anthropic.claude-sonnet-4-5-20250929-v1:0",
83
79
  "azure": "claude-sonnet-4-5-20250929"
84
80
  },
81
+ "claude-sonnet-4-6": {
82
+ "anthropic": "claude-sonnet-4-6",
83
+ "openrouter": "anthropic/claude-sonnet-4.6",
84
+ "bedrock": "anthropic.claude-sonnet-4-6"
85
+ },
85
86
  "deepseek-chat": {
86
87
  "deepseek": "deepseek-chat",
87
88
  "openrouter": "deepseek/deepseek-chat"
@@ -181,14 +182,19 @@
181
182
  "openrouter": "google/gemini-3-pro-preview",
182
183
  "vertexai": "gemini-3-pro-preview"
183
184
  },
185
+ "gemini-3.1-pro-preview": {
186
+ "gemini": "gemini-3.1-pro-preview",
187
+ "openrouter": "google/gemini-3.1-pro-preview",
188
+ "vertexai": "gemini-3.1-pro-preview"
189
+ },
190
+ "gemini-3.1-pro-preview-customtools": {
191
+ "gemini": "gemini-3.1-pro-preview-customtools",
192
+ "vertexai": "gemini-3.1-pro-preview-customtools"
193
+ },
184
194
  "gemini-embedding-001": {
185
195
  "gemini": "gemini-embedding-001",
186
196
  "vertexai": "gemini-embedding-001"
187
197
  },
188
- "gemini-exp-1206": {
189
- "gemini": "gemini-exp-1206",
190
- "vertexai": "gemini-exp-1206"
191
- },
192
198
  "gemini-flash": {
193
199
  "gemini": "gemini-flash-latest",
194
200
  "vertexai": "gemini-flash-latest"
@@ -347,6 +353,10 @@
347
353
  "openai": "gpt-5.2-pro",
348
354
  "openrouter": "openai/gpt-5.2-pro"
349
355
  },
356
+ "gpt-5.3-codex": {
357
+ "openai": "gpt-5.3-codex",
358
+ "openrouter": "openai/gpt-5.3-codex"
359
+ },
350
360
  "gpt-audio": {
351
361
  "openai": "gpt-audio",
352
362
  "openrouter": "openai/gpt-audio"
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
 
@@ -53,9 +53,7 @@ module RubyLLM
53
53
  end
54
54
  end
55
55
  end
56
- end
57
56
 
58
- module RubyLLM
59
57
  class Content
60
58
  # Represents provider-specific payloads that should bypass RubyLLM formatting.
61
59
  class Raw