ruby_llm 2.0.0.rc2 → 2.0.0.rc3

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +34 -0
  4. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +332 -0
  5. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +171 -0
  6. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +197 -0
  7. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +170 -0
  8. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +14 -11
  9. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +6 -6
  10. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +31 -5
  11. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +16 -14
  12. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +36 -7
  13. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +15 -5
  14. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +14 -0
  15. data/lib/ruby_llm/accounting/usage.rb +9 -0
  16. data/lib/ruby_llm/agent.rb +10 -9
  17. data/lib/ruby_llm/aliases.json +26 -4
  18. data/lib/ruby_llm/attachment.rb +5 -0
  19. data/lib/ruby_llm/chat.rb +4 -0
  20. data/lib/ruby_llm/message.rb +14 -5
  21. data/lib/ruby_llm/models.json +3820 -1346
  22. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +2 -1
  23. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +8 -1
  24. data/lib/ruby_llm/protocols/cohere/rerank.rb +8 -1
  25. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +5 -0
  26. data/lib/ruby_llm/protocols/interactions/tools.rb +3 -1
  27. data/lib/ruby_llm/providers/deepseek/responses.rb +0 -1
  28. data/lib/ruby_llm/providers/mistral/ocr.rb +5 -1
  29. data/lib/ruby_llm/version.rb +1 -1
  30. data/lib/tasks/ruby_llm.rake +1 -1
  31. metadata +7 -2
@@ -12,7 +12,9 @@ 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) }
17
+ after_initialize { @ruby_llm_upgrade_epoch = RubyLLMUpgrade.epoch }
16
18
  around_save :guard_ruby_llm_upgrade, prepend: true
17
19
  around_destroy :guard_ruby_llm_upgrade, prepend: true
18
20
  before_save { RubyLLMUpgrade.prepare_model(self) if ruby_llm_upgrade_kind == :chat }
@@ -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,
@@ -25,7 +25,7 @@ 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
29
 
30
30
  argument :model_mappings,
31
31
  type: :array,
@@ -62,15 +62,25 @@ module RubyLLM
62
62
 
63
63
  Copy mode retains the legacy tables and a separate chat model reference.
64
64
  Install the generated RubyLLMUpgrade concern and initializer in BOTH the
65
- 1.16 rollback build and the 2.0 build. They protect whole conversations
65
+ running 1.16 build and the 2.0 build BEFORE running prepare. Restart
66
+ all affected 1.16 processes so they load the guards and explicit column
67
+ selects. The guards protect whole conversations
66
68
  changed by 2.0. Direct SQL, bulk updates/deletes and attachment purges
67
69
  bypass these guards; review those application paths before upgrading.
68
70
 
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.
71
+ Rehearse prepare, backfill and finish on a production database copy.
72
+ Run prepare and backfill from the 2.0 build while 1.16 serves traffic.
73
+ Stop affected traffic, workers, scheduled jobs and retries BEFORE finish.
74
+ Finish catches up intervening writes, validates and activates 2.0.
75
+ Then load models and restart the 2.0 application.
72
76
  Keep the old model/tool-call classes in the 1.16 build.
73
77
 
78
+ PostgreSQL, MySQL and SQLite support this workflow. Schema changes can
79
+ block writes; SQLite also serializes backfill and app writes.
80
+ Use a direct connection or session-mode pool for copy migrations.
81
+ db:migrate runs ALL pending phases. Stop at the backfill timestamp to
82
+ defer finish, or pause AI before running all three together.
83
+
74
84
  With all affected processes stopped, use the 2.0 build to run:
75
85
  bin/rails ruby_llm:upgrade:rollback # activates the protected 1.16 view
76
86
  bin/rails ruby_llm:upgrade:resume # reconciles 1.16 writes, activates 2.0
@@ -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
@@ -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
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"]
@@ -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