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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8ff2f4da0c39e4909925217affa2b76908207e2c936a6dc05b56cffb2781863
4
- data.tar.gz: 0eebb76434b049d4332a247e7581bb721d4dd86b0496be8655d15a4adcb42f65
3
+ metadata.gz: efa046d43a24a45287ca68b9ec54c12374dd7a716a41bda2226974925067f547
4
+ data.tar.gz: e7d5187b9cb8d543e8b3b6b2590569cdcedec745f7ff35b6944d68768f803a6c
5
5
  SHA512:
6
- metadata.gz: 95a3eb5a1c6a50c69dd8166044d99d77848fc8dcdd5d31b748e8303a3e8c5756cc86a8ce0220dd3c643bc0b32080f6c2125c79f5e437bb654725a119b4b4b601
7
- data.tar.gz: 5efbd317193ca7f5e28df0a2d9bcc7ffd8a1740f40d29e7b872bed8a2ee910db376dc2dc890723b0c253718404993e55deab87356d414afdfd741d9392bd5e0f
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
 
@@ -53,8 +59,6 @@ module RubyLLM
53
59
  foreign_key: model_foreign_key,
54
60
  optional: true
55
61
 
56
- delegate :add_message, to: :to_llm
57
-
58
62
  define_method :messages_association do
59
63
  send(messages_association_name)
60
64
  end
@@ -18,8 +18,6 @@ module RubyLLM
18
18
  class_name: @message_class,
19
19
  inverse_of: :chat,
20
20
  dependent: :destroy
21
-
22
- delegate :add_message, to: :to_llm
23
21
  end
24
22
 
25
23
  def acts_as_message(chat_class: 'Chat',
@@ -99,6 +97,7 @@ module RubyLLM
99
97
  ordered_messages.each do |msg|
100
98
  @chat.add_message(msg.to_llm)
101
99
  end
100
+ reapply_runtime_instructions(@chat)
102
101
 
103
102
  setup_persistence_callbacks
104
103
  end
@@ -111,6 +110,14 @@ module RubyLLM
111
110
  self
112
111
  end
113
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
+
114
121
  def with_tool(...)
115
122
  to_llm.with_tool(...)
116
123
  self
@@ -185,14 +192,34 @@ module RubyLLM
185
192
  self
186
193
  end
187
194
 
188
- def create_user_message(content, with: nil)
189
- message_record = messages.create!(role: :user, content: content)
190
- persist_content(message_record, with) if with.present?
195
+ def add_message(message_or_attributes)
196
+ llm_message = message_or_attributes.is_a?(RubyLLM::Message) ? message_or_attributes : RubyLLM::Message.new(message_or_attributes)
197
+ content, attachments_to_persist = prepare_content_for_storage(llm_message.content)
198
+
199
+ attrs = { role: llm_message.role, content: }
200
+ tool_call_foreign_key = messages.klass.tool_call_foreign_key
201
+ if llm_message.tool_call_id && tool_call_foreign_key
202
+ tool_call_id = find_tool_call_id(llm_message.tool_call_id)
203
+ attrs[tool_call_foreign_key] = tool_call_id if tool_call_id
204
+ end
205
+
206
+ message_record = messages.create!(attrs)
207
+ persist_content(message_record, attachments_to_persist) if attachments_to_persist.present?
208
+ persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
209
+
191
210
  message_record
192
211
  end
193
212
 
194
- def ask(message, with: nil, &)
195
- create_user_message(message, with:)
213
+ def create_user_message(content, with: nil)
214
+ RubyLLM.logger.warn(
215
+ '`create_user_message` is deprecated and will be removed in RubyLLM 2.0. ' \
216
+ 'Use `add_message(role: :user, content: ...)` instead.'
217
+ )
218
+ add_message(role: :user, content: build_content(content, with))
219
+ end
220
+
221
+ def ask(message = nil, with: nil, &)
222
+ add_message(role: :user, content: build_content(message, with))
196
223
  complete(&)
197
224
  end
198
225
 
@@ -267,6 +294,26 @@ module RubyLLM
267
294
  system_messages + non_system_messages
268
295
  end
269
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
+
270
317
  def setup_persistence_callbacks
271
318
  return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
272
319
 
@@ -281,21 +328,13 @@ module RubyLLM
281
328
  @message = messages.create!(role: :assistant, content: '')
282
329
  end
283
330
 
284
- def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity
331
+ def persist_message_completion(message)
285
332
  return unless message
286
333
 
287
334
  tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
288
335
 
289
336
  transaction do
290
- content = message.content
291
- attachments_to_persist = nil
292
-
293
- if content.is_a?(RubyLLM::Content)
294
- attachments_to_persist = content.attachments if content.attachments.any?
295
- content = content.text
296
- elsif content.is_a?(Hash) || content.is_a?(Array)
297
- content = content.to_json
298
- end
337
+ content, attachments_to_persist = prepare_content_for_storage(message.content)
299
338
 
300
339
  @message.update!(
301
340
  role: message.role,
@@ -312,14 +351,17 @@ module RubyLLM
312
351
  end
313
352
  end
314
353
 
315
- def persist_tool_calls(tool_calls)
316
- supports_thought_signature = tool_calls.klass.column_names.include?('thought_signature')
354
+ def persist_tool_calls(tool_calls, message_record: @message)
355
+ tool_call_assoc = message_record.respond_to?(:tool_calls) ? message_record.tool_calls : nil
356
+ return unless tool_call_assoc
357
+
358
+ supports_thought_signature = tool_call_assoc.klass.column_names.include?('thought_signature')
317
359
 
318
360
  tool_calls.each_value do |tool_call|
319
361
  attributes = tool_call.to_h
320
362
  attributes.delete(:thought_signature) unless supports_thought_signature
321
363
  attributes[:tool_call_id] = attributes.delete(:id)
322
- @message.tool_calls.create!(**attributes)
364
+ tool_call_assoc.create!(**attributes)
323
365
  end
324
366
  end
325
367
 
@@ -363,6 +405,29 @@ module RubyLLM
363
405
  RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
364
406
  nil
365
407
  end
408
+
409
+ def build_content(message, attachments)
410
+ return message if content_like?(message)
411
+
412
+ RubyLLM::Content.new(message, attachments)
413
+ end
414
+
415
+ def content_like?(object)
416
+ object.is_a?(RubyLLM::Content) || object.is_a?(RubyLLM::Content::Raw)
417
+ end
418
+
419
+ def prepare_content_for_storage(content)
420
+ attachments = nil
421
+
422
+ if content.is_a?(RubyLLM::Content)
423
+ attachments = content.attachments if content.attachments.any?
424
+ [content.text, attachments]
425
+ elsif content.is_a?(Hash) || content.is_a?(Array)
426
+ [content.to_json, attachments]
427
+ else
428
+ [content, attachments]
429
+ end
430
+ end
366
431
  end
367
432
 
368
433
  # Methods mixed into message models.
@@ -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
@@ -178,22 +188,32 @@ module RubyLLM
178
188
  self
179
189
  end
180
190
 
181
- def create_user_message(content, with: nil)
182
- content_text, attachments, content_raw = prepare_content_for_storage(content)
191
+ def add_message(message_or_attributes)
192
+ llm_message = message_or_attributes.is_a?(RubyLLM::Message) ? message_or_attributes : RubyLLM::Message.new(message_or_attributes)
193
+ content_text, attachments, content_raw = prepare_content_for_storage(llm_message.content)
183
194
 
184
- message_record = messages_association.build(role: :user)
185
- message_record.content = content_text
186
- message_record.content_raw = content_raw if message_record.respond_to?(:content_raw=)
187
- message_record.save!
195
+ attrs = { role: llm_message.role, content: content_text }
196
+ parent_tool_call_assoc = messages_association.klass.reflect_on_association(:parent_tool_call)
197
+ if parent_tool_call_assoc && llm_message.tool_call_id
198
+ tool_call_id = find_tool_call_id(llm_message.tool_call_id)
199
+ attrs[parent_tool_call_assoc.foreign_key] = tool_call_id if tool_call_id
200
+ end
201
+
202
+ message_record = messages_association.create!(attrs)
203
+ message_record.update!(content_raw:) if message_record.respond_to?(:content_raw=)
188
204
 
189
- persist_content(message_record, with) if with.present?
190
205
  persist_content(message_record, attachments) if attachments.present?
206
+ persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
191
207
 
192
208
  message_record
193
209
  end
194
210
 
195
- def ask(message, with: nil, &)
196
- create_user_message(message, with:)
211
+ def create_user_message(content, with: nil)
212
+ add_message(role: :user, content: build_content(content, with))
213
+ end
214
+
215
+ def ask(message = nil, with: nil, &)
216
+ add_message(role: :user, content: build_content(message, with))
197
217
  complete(&)
198
218
  end
199
219
 
@@ -223,9 +243,10 @@ module RubyLLM
223
243
  if last.tool_call?
224
244
  last.destroy
225
245
  elsif last.tool_result?
226
- tool_call_message = last.parent_tool_call.message
227
- expected_results = tool_call_message.tool_calls.pluck(:id)
228
- 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)
229
250
 
230
251
  if expected_results.sort != actual_results.sort
231
252
  tool_call_message.tool_results.each(&:destroy)
@@ -278,6 +299,26 @@ module RubyLLM
278
299
  system_messages + non_system_messages
279
300
  end
280
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
+
281
322
  def persist_new_message
282
323
  @message = messages_association.create!(role: :assistant, content: '')
283
324
  end
@@ -323,15 +364,15 @@ module RubyLLM
323
364
  end
324
365
  # rubocop:enable Metrics/PerceivedComplexity
325
366
 
326
- def persist_tool_calls(tool_calls)
327
- tool_call_klass = @message.tool_calls_association.klass
367
+ def persist_tool_calls(tool_calls, message_record: @message)
368
+ tool_call_klass = message_record.tool_calls_association.klass
328
369
  supports_thought_signature = tool_call_klass.column_names.include?('thought_signature')
329
370
 
330
371
  tool_calls.each_value do |tool_call|
331
372
  attributes = tool_call.to_h
332
373
  attributes.delete(:thought_signature) unless supports_thought_signature
333
374
  attributes[:tool_call_id] = attributes.delete(:id)
334
- @message.tool_calls_association.create!(**attributes)
375
+ message_record.tool_calls_association.create!(**attributes)
335
376
  end
336
377
  end
337
378
 
@@ -386,6 +427,16 @@ module RubyLLM
386
427
  nil
387
428
  end
388
429
 
430
+ def build_content(message, attachments)
431
+ return message if content_like?(message)
432
+
433
+ RubyLLM::Content.new(message, attachments)
434
+ end
435
+
436
+ def content_like?(object)
437
+ object.is_a?(RubyLLM::Content) || object.is_a?(RubyLLM::Content::Raw)
438
+ end
439
+
389
440
  def prepare_content_for_storage(content)
390
441
  attachments = nil
391
442
  content_raw = nil
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'erb'
4
+ require 'forwardable'
4
5
  require 'pathname'
6
+ require 'ruby_llm/schema'
5
7
 
6
8
  module RubyLLM
7
9
  # Base class for simple, class-configured agents.
8
10
  class Agent
11
+ extend Forwardable
9
12
  include Enumerable
10
13
 
11
14
  class << self
@@ -136,7 +139,10 @@ module RubyLLM
136
139
 
137
140
  def render_prompt(name, chat:, inputs:, locals:)
138
141
  path = prompt_path_for(name)
139
- 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
140
146
 
141
147
  resolved_locals = resolve_prompt_locals(locals, runtime: runtime_context(chat:, inputs:), chat:, inputs:)
142
148
  ERB.new(File.read(path)).result_with_hash(resolved_locals)
@@ -175,7 +181,10 @@ module RubyLLM
175
181
  value = resolved_instructions_value(chat_object, runtime, inputs:)
176
182
  return if value.nil?
177
183
 
178
- 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)
179
188
  end
180
189
 
181
190
  def apply_tools(llm_chat, runtime)
@@ -202,10 +211,21 @@ module RubyLLM
202
211
  end
203
212
 
204
213
  def apply_schema(llm_chat, runtime)
205
- value = evaluate(schema, runtime)
214
+ value = resolved_schema_value(runtime)
206
215
  llm_chat.with_schema(value) if value
207
216
  end
208
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
+
209
229
  def llm_chat_for(chat_object)
210
230
  chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
211
231
  end
@@ -215,7 +235,7 @@ module RubyLLM
215
235
  end
216
236
 
217
237
  def resolved_instructions_value(chat_object, runtime, inputs:)
218
- value = evaluate(instructions, runtime)
238
+ value = evaluate(@instructions, runtime)
219
239
  return value unless prompt_instruction?(value)
220
240
 
221
241
  runtime.prompt(
@@ -232,10 +252,20 @@ module RubyLLM
232
252
  if persist || !chat_object.respond_to?(:to_llm)
233
253
  chat_object
234
254
  else
235
- chat_object.to_llm
255
+ runtime_instruction_target(chat_object)
236
256
  end
237
257
  end
238
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
+
239
269
  def resolve_prompt_locals(locals, runtime:, chat:, inputs:)
240
270
  base = { chat: chat }.merge(inputs)
241
271
  evaluated = locals.each_with_object({}) do |(key, value), acc|
@@ -316,8 +346,9 @@ module RubyLLM
316
346
 
317
347
  attr_reader :chat
318
348
 
319
- delegate :ask, :say, :complete, :add_message, :messages,
320
- :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :each,
321
- to: :chat
349
+ def_delegators :chat, :model, :messages, :tools, :params, :headers, :schema, :ask, :say, :with_tool, :with_tools,
350
+ :with_model, :with_temperature, :with_thinking, :with_context, :with_params, :with_headers,
351
+ :with_schema, :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :each, :complete,
352
+ :add_message, :reset_messages!
322
353
  end
323
354
  end
@@ -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"