ruby_llm 2.0.0.rc2 → 2.0.0.rc4

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +2 -2
  3. data/README.md +32 -21
  4. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +34 -0
  5. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +341 -0
  6. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +171 -0
  7. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +197 -0
  8. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +174 -0
  9. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +19 -12
  10. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +6 -6
  11. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +38 -6
  12. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +16 -14
  13. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +37 -8
  14. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +26 -5
  16. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +16 -1
  17. data/lib/ruby_llm/accounting/usage.rb +9 -0
  18. data/lib/ruby_llm/agent.rb +10 -9
  19. data/lib/ruby_llm/aliases.json +26 -4
  20. data/lib/ruby_llm/attachment.rb +5 -0
  21. data/lib/ruby_llm/batch.rb +2 -2
  22. data/lib/ruby_llm/chat.rb +6 -2
  23. data/lib/ruby_llm/embedding.rb +1 -1
  24. data/lib/ruby_llm/image.rb +1 -1
  25. data/lib/ruby_llm/message.rb +14 -5
  26. data/lib/ruby_llm/models.json +5364 -3027
  27. data/lib/ruby_llm/moderation.rb +1 -1
  28. data/lib/ruby_llm/ocr.rb +1 -1
  29. data/lib/ruby_llm/protocols/anthropic/chat.rb +1 -6
  30. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +2 -1
  31. data/lib/ruby_llm/protocols/chat_completions/chat.rb +2 -11
  32. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +8 -1
  33. data/lib/ruby_llm/protocols/cohere/rerank.rb +8 -1
  34. data/lib/ruby_llm/protocols/converse/chat.rb +0 -1
  35. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +5 -0
  36. data/lib/ruby_llm/protocols/interactions/tools.rb +3 -1
  37. data/lib/ruby_llm/protocols/responses/chat.rb +0 -10
  38. data/lib/ruby_llm/providers/deepseek/responses.rb +0 -1
  39. data/lib/ruby_llm/providers/mistral/ocr.rb +5 -1
  40. data/lib/ruby_llm/providers/openrouter/chat.rb +1 -5
  41. data/lib/ruby_llm/rerank.rb +1 -1
  42. data/lib/ruby_llm/speech.rb +1 -1
  43. data/lib/ruby_llm/transcription.rb +1 -1
  44. data/lib/ruby_llm/version.rb +1 -1
  45. data/lib/ruby_llm/video_job.rb +1 -1
  46. data/lib/ruby_llm.rb +1 -1
  47. data/lib/tasks/ruby_llm.rake +1 -1
  48. metadata +11 -6
@@ -12,8 +12,10 @@ module RubyLLMUpgrade # :nodoc: all
12
12
 
13
13
  included do
14
14
  class_attribute :ruby_llm_upgrade_kind, instance_writer: false
15
+ self.enumerate_columns_in_select_statements = true
15
16
  default_scope { RubyLLMUpgrade.visible(self) }
16
- around_save :guard_ruby_llm_upgrade, prepend: true
17
+ after_initialize { @ruby_llm_upgrade_epoch = RubyLLMUpgrade.epoch }
18
+ around_save :guard_ruby_llm_upgrade
17
19
  around_destroy :guard_ruby_llm_upgrade, prepend: true
18
20
  before_save { RubyLLMUpgrade.prepare_model(self) if ruby_llm_upgrade_kind == :chat }
19
21
  end
@@ -27,6 +29,9 @@ module RubyLLMUpgrade # :nodoc: all
27
29
 
28
30
  class << self
29
31
  def install(chat:, message:, tool_call:)
32
+ if version == 2 && message.table_exists? && message.column_names.include?('ruby_llm_content')
33
+ message.alias_attribute :content, :ruby_llm_content
34
+ end
30
35
  { chat => :chat, message => :message, tool_call => :tool_call }.each do |model, kind|
31
36
  next if model < Records
32
37
 
@@ -37,11 +42,13 @@ module RubyLLMUpgrade # :nodoc: all
37
42
  return if chat.instance_variable_defined?(:@ruby_llm_upgrade_operations)
38
43
 
39
44
  operations = guarded_operations(chat, OPERATIONS, claim: true)
40
- if chat.private_method_defined?(:persist_usage_entry)
41
- operations.define_method(:persist_usage_entry) do |entry|
42
- RubyLLMUpgrade.guard(self) { super(entry) }
45
+ %i[persist_usage_entry persist_new_message persist_message_completion].each do |name|
46
+ next unless chat.private_method_defined?(name)
47
+
48
+ operations.define_method(name) do |*args, **options, &block|
49
+ RubyLLMUpgrade.guard(self) { super(*args, **options, &block) }
43
50
  end
44
- operations.send(:private, :persist_usage_entry)
51
+ operations.send(:private, name)
45
52
  end
46
53
  chat.prepend operations
47
54
  chat.instance_variable_set(:@ruby_llm_upgrade_operations, operations)
@@ -73,7 +80,9 @@ module RubyLLMUpgrade # :nodoc: all
73
80
  def prepare_model(chat)
74
81
  return unless available?
75
82
 
76
- settings = current_state.settings
83
+ state = current_state
84
+ settings = state.settings
85
+ return if version == 1 && settings['online'] && state.status == 'preparing'
77
86
  if version == 1
78
87
  return unless ActiveRecord::Base.connection.column_exists?(settings.fetch('chat_table'), :ruby_llm_model_id)
79
88
  return unless ActiveRecord::Base.connection.table_exists?('ruby_llm_models')
@@ -87,16 +96,30 @@ module RubyLLMUpgrade # :nodoc: all
87
96
  states = records(TABLE)
88
97
  states.uncached do
89
98
  states.transaction do
90
- state = states.lock.first!
99
+ if ActiveRecord::Base.connection.adapter_name == 'SQLite' && claim
100
+ states.update_all('epoch = epoch')
101
+ end
102
+ state = states.lock(lock_clause).first!
91
103
  check_version(state)
92
104
  protect(record, state, claim: claim)
105
+ if record.instance_variable_get(:@ruby_llm_upgrade_epoch) != state[:epoch].to_i
106
+ raise ActiveRecord::ReadOnlyRecord, 'Reload the conversation after switching RubyLLM versions'
107
+ end
93
108
  yield
94
109
  end
95
110
  end
96
111
  end
97
112
 
113
+ def epoch
114
+ available? ? current_state[:epoch].to_i : 0
115
+ end
116
+
98
117
  private
99
118
 
119
+ def lock_clause
120
+ ActiveRecord::Base.connection.adapter_name == 'Mysql2' ? 'LOCK IN SHARE MODE' : 'FOR SHARE'
121
+ end
122
+
100
123
  def guarded_operations(model, names, claim:)
101
124
  Module.new do
102
125
  names.each do |name|
@@ -141,10 +164,15 @@ module RubyLLMUpgrade # :nodoc: all
141
164
 
142
165
  def records(table)
143
166
  @records ||= {}
144
- @records[table] ||= Class.new(ActiveRecord::Base) do
167
+ model = @records[table] ||= Class.new(ActiveRecord::Base) do
145
168
  self.table_name = table
146
169
  self.inheritance_column = :_type_disabled
147
170
  end
171
+ if !model.column_names.include?(VERSION_COLUMN) &&
172
+ ActiveRecord::Base.connection.column_exists?(table, VERSION_COLUMN)
173
+ model.reset_column_information
174
+ end
175
+ model
148
176
  end
149
177
 
150
178
  def current_state
@@ -156,6 +184,7 @@ module RubyLLMUpgrade # :nodoc: all
156
184
  end
157
185
 
158
186
  def check_version(state)
187
+ return if version == 1 && state.active_version == 1 && state.settings['online'] && state.status == 'preparing'
159
188
  return if state.active_version == version && %w[active finalized].include?(state.status)
160
189
 
161
190
  raise ActiveRecord::ReadOnlyRecord,
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_record/base'
4
3
  require Rails.root.join('app/models/concerns/ruby_llm_upgrade')
5
4
 
6
5
  Rails.application.config.to_prepare do
6
+ require 'active_record/base'
7
7
  tool_call = if Gem::Version.new(RubyLLM::VERSION).segments.first >= 2
8
8
  RubyLLM::ActiveRecord::ToolCall
9
9
  else
@@ -25,7 +25,9 @@ module RubyLLM
25
25
  class_option :phase, type: :string, enum: %w[prepare backfill finish cleanup],
26
26
  desc: 'Generate one upgrade phase; cleanup runs in a later deployment'
27
27
  class_option :mode, type: :string, enum: %w[rename copy], default: 'rename',
28
- desc: 'Copy keeps a protected 1.16 rollback path until cleanup'
28
+ desc: 'Copy prepares and backfills online, with protected 1.16 rollback until cleanup'
29
+ class_option :discard_incomplete_tool_calls, type: :boolean, default: false,
30
+ desc: 'Discard all incomplete legacy tool calls at copy finish'
29
31
 
30
32
  argument :model_mappings,
31
33
  type: :array,
@@ -39,6 +41,7 @@ module RubyLLM
39
41
  end
40
42
 
41
43
  def create_migration_files
44
+ validate_cleanup_options
42
45
  parse_model_mappings(allowed_types: MODEL_MAPPING_TYPES, defaults: MODEL_MAPPING_DEFAULTS)
43
46
  say_status :models, resolved_model_mappings
44
47
 
@@ -62,15 +65,25 @@ module RubyLLM
62
65
 
63
66
  Copy mode retains the legacy tables and a separate chat model reference.
64
67
  Install the generated RubyLLMUpgrade concern and initializer in BOTH the
65
- 1.16 rollback build and the 2.0 build. They protect whole conversations
68
+ running 1.16 build and the 2.0 build BEFORE running prepare. Restart
69
+ all affected 1.16 processes so they load the guards and explicit column
70
+ selects. The guards protect whole conversations
66
71
  changed by 2.0. Direct SQL, bulk updates/deletes and attachment purges
67
72
  bypass these guards; review those application paths before upgrading.
68
73
 
69
- Pause affected traffic, workers, scheduled jobs and retries. Review and
70
- rehearse prepare, backfill and finish on a production database copy,
71
- then migrate and load models before restarting the 2.0 application.
74
+ Rehearse prepare, backfill and finish on a production database copy.
75
+ Run prepare and backfill from the 2.0 build while 1.16 serves traffic.
76
+ Stop affected traffic, workers, scheduled jobs and retries BEFORE finish.
77
+ Finish catches up intervening writes, validates and activates 2.0.
78
+ Then load models and restart the 2.0 application.
72
79
  Keep the old model/tool-call classes in the 1.16 build.
73
80
 
81
+ PostgreSQL, MySQL and SQLite support this workflow. Schema changes can
82
+ block writes; SQLite also serializes backfill and app writes.
83
+ Use a direct connection or session-mode pool for copy migrations.
84
+ db:migrate runs ALL pending phases. Stop at the backfill timestamp to
85
+ defer finish, or pause AI before running all three together.
86
+
74
87
  With all affected processes stopped, use the 2.0 build to run:
75
88
  bin/rails ruby_llm:upgrade:rollback # activates the protected 1.16 view
76
89
  bin/rails ruby_llm:upgrade:resume # reconciles 1.16 writes, activates 2.0
@@ -122,6 +135,14 @@ module RubyLLM
122
135
  private
123
136
 
124
137
  def copy_mode? = options[:mode] == 'copy'
138
+ def discard_incomplete_tool_calls? = options[:discard_incomplete_tool_calls]
139
+
140
+ def validate_cleanup_options
141
+ return unless discard_incomplete_tool_calls?
142
+ return if copy_mode? && options[:phase].in?([nil, 'finish'])
143
+
144
+ raise Thor::Error, 'Discard incomplete tool calls only with --mode copy and the finish phase'
145
+ end
125
146
 
126
147
  def copy_upgrade_settings
127
148
  {
@@ -6,12 +6,22 @@ module RubyLLM
6
6
  TABLE = :ruby_llm_v2_upgrades
7
7
  VERSION_COLUMN = :ruby_llm_version
8
8
 
9
+ def self.for(connection: ::ActiveRecord::Base.connection)
10
+ require_relative 'online_copy_migration'
11
+ OnlineCopyMigration.new(connection:)
12
+ end
13
+
9
14
  def initialize(connection: ::ActiveRecord::Base.connection)
10
15
  @connection = connection
11
16
  end
12
17
 
13
18
  def prepare(settings)
14
19
  create_state_table unless @connection.table_exists?(TABLE)
20
+ prepare_state(settings)
21
+ yield if block_given?
22
+ end
23
+
24
+ def prepare_state(settings)
15
25
  existing = states.first
16
26
  if existing
17
27
  raise 'This database has a different RubyLLM copy upgrade' unless existing.settings == settings.stringify_keys
@@ -24,6 +34,9 @@ module RubyLLM
24
34
  states.create!(settings: settings.stringify_keys)
25
35
  end
26
36
  end
37
+ private :prepare_state
38
+
39
+ def online? = false
27
40
 
28
41
  def copy_table(source, target)
29
42
  return if @connection.table_exists?(target)
@@ -146,6 +159,7 @@ module RubyLLM
146
159
  table.integer :active_version, null: false, default: 1
147
160
  table.string :status, null: false, default: 'preparing'
148
161
  table.boolean :needs_reconcile, null: false, default: false
162
+ table.bigint :epoch, null: false, default: 0
149
163
  table.json :settings, null: false
150
164
  end
151
165
  end
@@ -234,7 +248,8 @@ module RubyLLM
234
248
  calls = records(:ruby_llm_tool_calls).where(message_type: configuration.fetch('message_class'), result_id: nil)
235
249
  return unless calls.exists?
236
250
 
237
- raise 'Finish pending tool calls and approvals before switching versions'
251
+ raise 'Finish pending tool calls and approvals before switching versions. ' \
252
+ 'For abandoned legacy calls, see https://rubyllm.com/upgrading/#incomplete-tool-calls'
238
253
  end
239
254
 
240
255
  def reconcile
@@ -163,6 +163,7 @@ module RubyLLM
163
163
  # A request that produced several results, like a multi-image
164
164
  # generation, is billed once: the first result carries the call.
165
165
  billed = result.is_a?(Array) ? result.first : result
166
+ billed.model_info = message_model(billed) if billed.is_a?(Message)
166
167
  pending = @pending.dup
167
168
  if pending.empty?
168
169
  attach_to_result(billed)
@@ -187,6 +188,14 @@ module RubyLLM
187
188
 
188
189
  private
189
190
 
191
+ def message_model(message)
192
+ return @model_info if message.model.nil? || message.model == @model_info&.id
193
+
194
+ RubyLLM.models.find(message.model, provider: @provider.slug, config: @config)
195
+ rescue ModelNotFoundError
196
+ @model_info
197
+ end
198
+
190
199
  # A request that never reached the provider, or that the provider
191
200
  # refused before running it, cannot have been billed; possibly-billed
192
201
  # failures keep their usage unknown.
@@ -53,8 +53,7 @@ module RubyLLM
53
53
  :@input_names => [],
54
54
  :@fallbacks => [],
55
55
  :@fallback_options => {},
56
- :@rescue_handlers => [],
57
- :@instructions => []
56
+ :@rescue_handlers => []
58
57
  }.freeze
59
58
  # Simple value options: a class-level getter/setter macro whose value the
60
59
  # agent forwards to the matching Chat#with_* when it builds its chat.
@@ -177,15 +176,16 @@ module RubyLLM
177
176
  # "Today is #{Date.current}"
178
177
  # end
179
178
  #
180
- # A named agent uses its conventional template automatically when it
181
- # exists, even without calling this method. In Rails mode, declarations
182
- # persist when the record is created unless <tt>persist: false</tt>;
179
+ # The class's own declarations take precedence over its conventional
180
+ # template. Inherited declarations are used only when neither exists.
181
+ # In Rails mode, declarations persist when the record is created unless
182
+ # <tt>persist: false</tt>;
183
183
  # ::find always reapplies them without rewriting history. Called with no
184
184
  # arguments, returns the declarations.
185
185
  def instructions(text = nil, append: false, persist: true, cache_until_here: false, **prompt_locals, &block)
186
186
  return instruction_declarations if text.nil? && prompt_locals.empty? && !block_given?
187
187
 
188
- instruction_declarations << {
188
+ (@instruction_declarations ||= []) << {
189
189
  value: block || text || { prompt: 'instructions', locals: prompt_locals },
190
190
  append: append,
191
191
  persist: persist,
@@ -611,6 +611,8 @@ module RubyLLM
611
611
  end
612
612
 
613
613
  def copy_inherited_config_to(subclass)
614
+ subclass.instance_variable_set(:@inherited_instruction_declarations, instruction_declarations.dup)
615
+
614
616
  DUPED_INHERITED_CONFIG.each do |ivar, default|
615
617
  value = instance_variable_defined?(ivar) ? instance_variable_get(ivar) : default
616
618
  subclass.instance_variable_set(ivar, value.respond_to?(:dup) ? value.dup : value)
@@ -767,8 +769,7 @@ module RubyLLM
767
769
  end
768
770
 
769
771
  def instructions_config
770
- return instruction_declarations if instruction_declarations.any?
771
- return [] unless default_instructions_prompt_exists?
772
+ return instruction_declarations if @instruction_declarations&.any? || !default_instructions_prompt_exists?
772
773
 
773
774
  [{
774
775
  value: { prompt: 'instructions', locals: {} },
@@ -779,7 +780,7 @@ module RubyLLM
779
780
  end
780
781
 
781
782
  def instruction_declarations
782
- @instruction_declarations ||= []
783
+ @instruction_declarations || @inherited_instruction_declarations || []
783
784
  end
784
785
 
785
786
  def rails_chat_record?(chat)
@@ -1,8 +1,4 @@
1
1
  {
2
- "claude-3-haiku": {
3
- "bedrock": "anthropic.claude-3-haiku-20240307-v1:0",
4
- "openrouter": "anthropic/claude-3-haiku"
5
- },
6
2
  "claude-fable-5": {
7
3
  "anthropic": "claude-fable-5",
8
4
  "openrouter": "anthropic/claude-fable-5",
@@ -471,6 +467,14 @@
471
467
  "openrouter": "openai/gpt-image-2",
472
468
  "azure": "gpt-image-2"
473
469
  },
470
+ "gpt-image-2.5-flare": {
471
+ "openai": "gpt-image-2.5-flare",
472
+ "openrouter": "openai/gpt-image-2.5-flare"
473
+ },
474
+ "gpt-image-2.5-sunburst": {
475
+ "openai": "gpt-image-2.5-sunburst",
476
+ "openrouter": "openai/gpt-image-2.5-sunburst"
477
+ },
474
478
  "gpt-oss-120b": {
475
479
  "vertexai": "openai/gpt-oss-120b-maas",
476
480
  "openrouter": "openai/gpt-oss-120b"
@@ -532,6 +536,24 @@
532
536
  "grok-4-latest": {
533
537
  "xai": "grok-4.3"
534
538
  },
539
+ "grok-4.1-fast-non-reasoning": {
540
+ "vertexai": "xai/grok-4.1-fast-non-reasoning"
541
+ },
542
+ "grok-4.1-fast-reasoning": {
543
+ "vertexai": "xai/grok-4.1-fast-reasoning"
544
+ },
545
+ "grok-4.20-non-reasoning": {
546
+ "vertexai": "xai/grok-4.20-non-reasoning"
547
+ },
548
+ "grok-4.20-reasoning": {
549
+ "vertexai": "xai/grok-4.20-reasoning"
550
+ },
551
+ "grok-4.3": {
552
+ "vertexai": "xai/grok-4.3"
553
+ },
554
+ "grok-4.6": {
555
+ "vertexai": "xai/grok-4.6"
556
+ },
535
557
  "grok-latest": {
536
558
  "xai": "grok-4.3"
537
559
  },
@@ -67,6 +67,11 @@ module RubyLLM
67
67
  #
68
68
  # +config:+ is the Configuration a URL source is downloaded with, and
69
69
  # defaults to the global one.
70
+ #
71
+ # Paths and URLs must be trusted and authorized by the application.
72
+ # They can be read or fetched during construction to detect the MIME type.
73
+ # Validate upload parameters before passing them here: an unchecked String
74
+ # can access local files or internal network endpoints.
70
75
  def initialize(source, filename: nil, config: nil)
71
76
  @config = config
72
77
  @source = source
@@ -101,7 +101,7 @@ module RubyLLM
101
101
  chats = normalize_chats(records)
102
102
 
103
103
  provider = shared_provider(chats)
104
- payload = { provider: provider.slug, provider_class: provider.class.display_name, requests: chats.size }
104
+ payload = { provider: provider.slug, provider_class: provider.name, requests: chats.size }
105
105
  RubyLLM.instrument('batch.ruby_llm', payload, config: provider.config) do |event|
106
106
  requests = chats.each_with_index.map do |chat, index|
107
107
  { custom_id: index.to_s, model: chat.model.id, payload: chat.render }
@@ -120,7 +120,7 @@ module RubyLLM
120
120
  end
121
121
 
122
122
  provider = shared_provider(requests)
123
- payload = { provider: provider.slug, provider_class: provider.class.display_name, requests: requests.size }
123
+ payload = { provider: provider.slug, provider_class: provider.name, requests: requests.size }
124
124
  RubyLLM.instrument('batch.ruby_llm', payload, config: provider.config) do |event|
125
125
  lines = requests.each_with_index.map do |request, index|
126
126
  { custom_id: index.to_s, model: request.model.id, payload: request.render, text: request.text }
data/lib/ruby_llm/chat.rb CHANGED
@@ -145,6 +145,10 @@ module RubyLLM
145
145
  # treating it as a final answer. Attach files with +with:+.
146
146
  # A given block receives streamed Chunk objects as they arrive.
147
147
  #
148
+ # String attachments read local paths or fetch URLs. Only pass trusted,
149
+ # authorized sources; validate user uploads before calling this method.
150
+ # See Attachment.new.
151
+ #
148
152
  # chat.ask "What's the best way to learn Ruby?"
149
153
  # chat.ask "What's in this image?", with: "ruby_conf.jpg"
150
154
  # chat.ask "Analyze these files", with: ["diagram.png", "report.pdf"]
@@ -999,7 +1003,7 @@ module RubyLLM
999
1003
  {
1000
1004
  chat: self,
1001
1005
  provider: @provider.slug,
1002
- provider_class: @provider.class.display_name,
1006
+ provider_class: @provider.name,
1003
1007
  model: @model.id,
1004
1008
  model_info: @model,
1005
1009
  input_messages: messages.dup,
@@ -1343,7 +1347,7 @@ module RubyLLM
1343
1347
  payload = {
1344
1348
  chat: self,
1345
1349
  provider: @provider.slug,
1346
- provider_class: @provider.class.display_name,
1350
+ provider_class: @provider.name,
1347
1351
  model: @model.id,
1348
1352
  model_info: @model,
1349
1353
  tool: tool,
@@ -117,7 +117,7 @@ module RubyLLM
117
117
 
118
118
  payload = {
119
119
  provider: provider_instance.slug,
120
- provider_class: provider_instance.class.display_name,
120
+ provider_class: provider_instance.name,
121
121
  model: model_id,
122
122
  model_info: model,
123
123
  input: text,
@@ -75,7 +75,7 @@ module RubyLLM
75
75
  empty_tokens = Tokens.new
76
76
  payload = {
77
77
  provider: provider_instance.slug,
78
- provider_class: provider_instance.class.display_name,
78
+ provider_class: provider_instance.name,
79
79
  model: model.id,
80
80
  model_info: model,
81
81
  prompt: prompt,
@@ -35,6 +35,8 @@ module RubyLLM
35
35
  # The ID of the model that produced the message, +nil+ on user messages.
36
36
  attr_reader :model
37
37
 
38
+ attr_writer :model_info # :nodoc:
39
+
38
40
  # The tool calls the assistant requested, as a Hash of ToolCall objects
39
41
  # keyed by call ID, or +nil+.
40
42
  attr_reader :tool_calls
@@ -238,12 +240,19 @@ module RubyLLM
238
240
  }.merge(tokens.to_h).compact
239
241
  end
240
242
 
241
- # Returns the Model record for #model from the model registry, or
242
- # +nil+ when the message has no model or the model is unknown.
243
+ # Returns the response's Model from its provider's registry, falling
244
+ # back to the requested model when the response ID is unknown.
245
+ # Restored messages use the last successful attempt's provider and model.
246
+ # Messages without request context look up #model, or return +nil+ if unknown.
243
247
  def model_info
244
- return unless model
245
-
246
- @model_info ||= RubyLLM.models.find(model)
248
+ return @model_info if @model_info
249
+
250
+ entry = ruby_llm_usage_entries.reverse.find(&:succeeded?)
251
+ @model_info = if entry&.model
252
+ RubyLLM.models.find(entry.model, provider: entry.provider)
253
+ elsif model
254
+ RubyLLM.models.find(model)
255
+ end
247
256
  rescue ModelNotFoundError
248
257
  nil
249
258
  end