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
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../legacy_content_sql'
4
+
5
+ module RubyLLM
6
+ module Generators
7
+ class OnlineCopyMigration < UpgradeMigration
8
+ class Verification # :nodoc: all
9
+ def initialize(connection:, settings:)
10
+ @connection = connection
11
+ @settings = settings
12
+ end
13
+
14
+ def verify
15
+ verify_chats
16
+ verify_content
17
+ verify_usages
18
+ verify_tools
19
+ end
20
+
21
+ private
22
+
23
+ def verify_chats
24
+ mismatch = %w[provider model_id].map do |column|
25
+ different("copied.#{q(column)}", "original.#{q(column)}", string: true)
26
+ end
27
+ reject_mismatch('chat model', <<~SQL)
28
+ SELECT c.#{pk('chat')} FROM #{table('chat')} c
29
+ LEFT JOIN #{table('model')} original ON original.#{pk('model')} = c.#{q(@settings.fetch('model_foreign_key'))}
30
+ LEFT JOIN #{qt(:ruby_llm_models)} copied ON copied.#{pk('model')} = c.ruby_llm_model_id
31
+ WHERE c.ruby_llm_version = 1
32
+ AND (original.#{pk('model')} IS NULL OR copied.#{pk('model')} IS NULL OR #{mismatch.join(' OR ')})
33
+ SQL
34
+ end
35
+
36
+ def verify_content
37
+ raw = source(:content_raw)
38
+ rendered = LegacyContentSQL.new(@connection).render(content: "m.#{q(:content)}", raw:)
39
+ fields = { ruby_llm_content: rendered, raw_content: raw }
40
+ failed = fields.map do |column, expected|
41
+ different("m.#{q(column)}", expected, json: column == :raw_content, string: true)
42
+ end
43
+ reject_mismatch('message content', "SELECT m.#{pk('message')} #{message_from} " \
44
+ "WHERE c.ruby_llm_version = 1 AND (#{failed.join(' OR ')})")
45
+ end
46
+
47
+ def verify_usages
48
+ fields = usage_fields
49
+ eligible = fields.slice(*%i[input_tokens output_tokens cache_read_tokens cache_write_tokens thinking_tokens
50
+ input_cost output_cost cache_read_cost cache_write_cost thinking_cost total_cost])
51
+ .values.map { |value| "#{value} IS NOT NULL" }
52
+ eligible << "m.#{q(:role)} = 'assistant'"
53
+ strings = %i[chat_type message_type provider model operation status]
54
+ failed = fields.map do |column, expected|
55
+ different("u.#{q(column)}", expected, string: strings.include?(column))
56
+ end
57
+ reject_mismatch('usage entry', <<~SQL)
58
+ SELECT m.#{pk('message')} #{message_from}
59
+ LEFT JOIN #{table('model')} cm ON cm.#{pk('model')} = c.#{q(@settings.fetch('model_foreign_key'))}
60
+ #{message_model_join}
61
+ LEFT JOIN #{qt(:ruby_llm_usages)} u
62
+ ON u.legacy_key = #{copied_key(:ruby_llm_usages, "m.#{pk('message')}")}
63
+ WHERE c.ruby_llm_version = 1 AND (#{eligible.join(' OR ')})
64
+ AND (u.id IS NULL OR #{failed.join(' OR ')})
65
+ SQL
66
+ end
67
+
68
+ def usage_fields
69
+ fields = {
70
+ input_tokens: source(:input_tokens), output_tokens: source(:output_tokens),
71
+ cache_read_tokens: coalesce(source(:cache_read_tokens), source(:cached_tokens)),
72
+ cache_write_tokens: coalesce(source(:cache_write_tokens), source(:cache_creation_tokens)),
73
+ thinking_tokens: source(:thinking_tokens)
74
+ }
75
+ %w[input output cache_read cache_write thinking].each { |key| fields[:"#{key}_cost"] = cost(key) }
76
+ fields[:total_cost] = coalesce(source(:total_cost), cost('total'))
77
+ provider, model = model_identity
78
+ fields.merge(chat_type: value(@settings.fetch('chat_class')), chat_id: "c.#{pk('chat')}",
79
+ message_type: value(@settings.fetch('message_class')), message_id: "m.#{pk('message')}",
80
+ provider:, model:, operation: "'chat'", status: "'succeeded'")
81
+ end
82
+
83
+ def model_identity
84
+ key = @settings.fetch('model_foreign_key')
85
+ if message_columns[key]&.type == :string
86
+ ["COALESCE(#{source(:provider)}, cm.provider)", "COALESCE(#{source(key)}, cm.model_id)"]
87
+ elsif message_columns.key?(key)
88
+ ['COALESCE(mm.provider, cm.provider)', 'COALESCE(mm.model_id, cm.model_id)']
89
+ else
90
+ ['cm.provider', 'cm.model_id']
91
+ end
92
+ end
93
+
94
+ def message_model_join
95
+ key = @settings.fetch('model_foreign_key')
96
+ return '' if !message_columns.key?(key) || message_columns[key].type == :string
97
+
98
+ "LEFT JOIN #{table('model')} mm ON mm.#{pk('model')} = m.#{q(key)}"
99
+ end
100
+
101
+ def cost(key)
102
+ return 'NULL' unless message_columns.key?('cost_details')
103
+
104
+ details = source(:cost_details)
105
+ case @connection.adapter_name
106
+ when 'PostgreSQL' then "NULLIF(#{details}::jsonb ->> '#{key}', '')::numeric"
107
+ when 'Mysql2'
108
+ "CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(#{details}, '$.#{key}')), 'null') AS DECIMAL(16, 10))"
109
+ else "json_extract(#{details}, '$.#{key}')"
110
+ end
111
+ end
112
+
113
+ def verify_tools
114
+ result_type = "CASE WHEN r.#{pk('message')} IS NOT NULL THEN #{value(@settings.fetch('message_class'))} END"
115
+ fields = { message_id: "s.#{q(@settings.fetch('message_foreign_key'))}",
116
+ message_type: value(@settings.fetch('message_class')), result_id: "r.#{pk('message')}",
117
+ result_type:,
118
+ tool_call_id: 's.tool_call_id', name: 's.name', arguments: 's.arguments' }
119
+ strings = %i[message_type result_type tool_call_id name arguments]
120
+ failed = fields.map do |column, expected|
121
+ different("t.#{q(column)}", expected, json: column == :arguments, string: strings.include?(column))
122
+ end
123
+ reject_mismatch('tool call', <<~SQL)
124
+ SELECT s.#{pk('tool_call')} FROM #{table('tool_call')} s
125
+ LEFT JOIN #{table('message')} m ON m.#{pk('message')} = s.#{q(@settings.fetch('message_foreign_key'))}
126
+ LEFT JOIN #{table('chat')} c ON c.#{pk('chat')} = m.#{q(@settings.fetch('chat_foreign_key'))}
127
+ LEFT JOIN #{table('message')} r ON r.#{q(@settings.fetch('tool_call_foreign_key'))} = s.#{pk('tool_call')}
128
+ LEFT JOIN #{qt(:ruby_llm_tool_calls)} t
129
+ ON t.legacy_key = #{copied_key(:ruby_llm_tool_calls, "s.#{pk('tool_call')}")}
130
+ WHERE c.ruby_llm_version = 1 AND (t.id IS NULL OR #{failed.join(' OR ')})
131
+ SQL
132
+ end
133
+
134
+ def message_from
135
+ "FROM #{table('message')} m JOIN #{table('chat')} c " \
136
+ "ON c.#{pk('chat')} = m.#{q(@settings.fetch('chat_foreign_key'))}"
137
+ end
138
+
139
+ def different(left, right, json: false, string: false)
140
+ left, right = comparable([left, right], json:, string:)
141
+ case @connection.adapter_name
142
+ when 'PostgreSQL' then "#{left} IS DISTINCT FROM #{right}"
143
+ when 'Mysql2' then "NOT (#{left} <=> #{right})"
144
+ else "#{left} IS NOT #{right}"
145
+ end
146
+ end
147
+
148
+ def comparable(expressions, json:, string:)
149
+ case @connection.adapter_name
150
+ when 'PostgreSQL'
151
+ json ? expressions.map { |expression| "(#{expression})::jsonb" } : expressions
152
+ when 'Mysql2'
153
+ string ? expressions.map { |expression| "CAST(#{expression} AS BINARY)" } : expressions
154
+ else
155
+ json ? expressions.map { |expression| "json(#{expression})" } : expressions
156
+ end
157
+ end
158
+
159
+ def reject_mismatch(label, sql)
160
+ id = @connection.select_value("#{sql.strip} LIMIT 1")
161
+ raise "Record #{id} did not preserve its #{label}" if id
162
+ end
163
+
164
+ def text(expression) = mysql? ? "CAST(#{expression} AS BINARY)" : "CAST(#{expression} AS TEXT)"
165
+
166
+ def copied_key(table, expression)
167
+ return text(expression) unless mysql?
168
+
169
+ collation = @connection.columns(table).find { |column| column.name == 'legacy_key' }.collation
170
+ charset = collation.split('_').first
171
+ "CAST(#{expression} AS CHAR CHARACTER SET #{q(charset)}) COLLATE #{q(collation)}"
172
+ end
173
+
174
+ def coalesce(*expressions)
175
+ values = expressions.reject { |expression| expression == 'NULL' }
176
+ return 'NULL' if values.empty?
177
+ return values.first if values.one?
178
+
179
+ "COALESCE(#{values.join(', ')})"
180
+ end
181
+
182
+ def source(column) = message_columns.key?(column.to_s) ? "m.#{q(column)}" : 'NULL'
183
+
184
+ def message_columns
185
+ @message_columns ||= @connection.columns(@settings.fetch('message_table')).index_by(&:name)
186
+ end
187
+
188
+ def mysql? = @connection.adapter_name == 'Mysql2'
189
+ def table(kind) = qt(@settings.fetch("#{kind}_table"))
190
+ def pk(kind) = q(@connection.primary_key(@settings.fetch("#{kind}_table")))
191
+ def value(value) = @connection.quote(value)
192
+ def q(value) = @connection.quote_column_name(value)
193
+ def qt(value) = @connection.quote_table_name(value)
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'upgrade_migration'
4
+ require_relative 'online_copy_migration/journal'
5
+ require_relative 'online_copy_migration/data'
6
+
7
+ module RubyLLM
8
+ module Generators
9
+ class OnlineCopyMigration < UpgradeMigration # :nodoc: all
10
+ def online? = true
11
+
12
+ def prepare(settings)
13
+ with_migration_lock do
14
+ super(settings.merge(online: true), &nil)
15
+ unless @connection.column_exists?(TABLE, :epoch)
16
+ @connection.add_column(TABLE, :epoch, :bigint, null: false, default: 0)
17
+ end
18
+ yield if block_given?
19
+ data.prepare
20
+ journal.install
21
+ end
22
+ end
23
+
24
+ def verify_settings(settings)
25
+ super(settings.merge(online: true))
26
+ end
27
+
28
+ def copy_table(source, target)
29
+ create_copy_table(source, target) unless @connection.table_exists?(target)
30
+ end
31
+
32
+ def copy_model_references
33
+ nil
34
+ end
35
+
36
+ def backfill
37
+ with_migration_lock do
38
+ state = states.first!
39
+ unless state.active_version == 1 && %w[preparing active].include?(state.status)
40
+ raise 'Online copy backfill requires RubyLLM 1.16 to be active'
41
+ end
42
+ raise 'Use resume after rolling back a finished upgrade' if data.finished?
43
+
44
+ data.backfill
45
+ data.catch_up(passes: 2)
46
+ state.update!(needs_reconcile: false)
47
+ end
48
+ end
49
+
50
+ def finish
51
+ with_migration_lock do
52
+ state = states.first!
53
+ return if state.active_version == 2 && state.status == 'active' && data.finished?
54
+
55
+ raise 'Run the copy backfill before finish' unless data.completed?
56
+
57
+ pause(state, from: 1, statuses: %w[preparing active finishing])
58
+ data.catch_up
59
+ data.verify
60
+ verify_completed_work
61
+ yield
62
+ journal.install
63
+ state.update!(active_version: 2, status: 'active', needs_reconcile: false)
64
+ end
65
+ end
66
+
67
+ def rollback
68
+ with_migration_lock do
69
+ state = states.first!
70
+ unless data.finished?
71
+ raise 'The unfinished upgrade is not running on 1.16' unless state.active_version == 1
72
+
73
+ state.update!(status: 'preparing', needs_reconcile: true)
74
+ return
75
+ end
76
+ state.with_lock do
77
+ require_active_version(state, 2)
78
+ verify_completed_work
79
+ state.update!(active_version: 1, epoch: state.epoch + 1)
80
+ end
81
+ end
82
+ end
83
+
84
+ def resume
85
+ with_migration_lock do
86
+ raise 'Finish the initial copy migrations before resuming 2.0' unless data.finished?
87
+
88
+ state = states.first!
89
+ pause(state, from: 1, statuses: %w[active finishing])
90
+ data.catch_up
91
+ data.verify
92
+ verify_completed_work
93
+ state.update!(active_version: 2, status: 'active', needs_reconcile: false)
94
+ end
95
+ end
96
+
97
+ def finalize
98
+ with_migration_lock { super }
99
+ end
100
+
101
+ def cleanup
102
+ with_migration_lock do
103
+ verify_cleanup
104
+ journal.remove
105
+ yield if block_given?
106
+ data.cleanup
107
+ super
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def pause(state, from:, statuses:)
114
+ state.with_lock do
115
+ unless state.active_version == from && statuses.include?(state.status)
116
+ raise 'The copy upgrade is not ready for this version switch'
117
+ end
118
+
119
+ state.update!(status: 'finishing', epoch: state.epoch + 1) unless state.status == 'finishing'
120
+ end
121
+ end
122
+
123
+ def data
124
+ @data ||= Data.new(connection: @connection, settings: configuration)
125
+ end
126
+
127
+ def journal
128
+ @journal ||= Journal.new(connection: @connection, settings: configuration)
129
+ end
130
+
131
+ def with_migration_lock(&)
132
+ return with_mysql_lock(&) if @connection.adapter_name == 'Mysql2'
133
+ return with_sqlite_lock(&) if @connection.adapter_name == 'SQLite'
134
+
135
+ key = "hashtextextended(current_database() || '.' || current_schema() || '.ruby_llm_upgrade', 0)"
136
+ locked = @connection.select_value("SELECT pg_try_advisory_lock(#{key})")
137
+ raise 'Another RubyLLM copy migration is running' unless locked
138
+
139
+ begin
140
+ yield
141
+ ensure
142
+ @connection.execute("SELECT pg_advisory_unlock(#{key})")
143
+ end
144
+ end
145
+
146
+ def with_mysql_lock
147
+ key = @connection.quote("ruby_llm_upgrade:#{@connection.current_database}".slice(0, 64))
148
+ locked = @connection.select_value("SELECT GET_LOCK(#{key}, 0)")
149
+ raise 'Another RubyLLM copy migration is running' unless locked == 1
150
+
151
+ begin
152
+ yield
153
+ ensure
154
+ @connection.execute("SELECT RELEASE_LOCK(#{key})")
155
+ end
156
+ end
157
+
158
+ def with_sqlite_lock
159
+ database = @connection.pool.db_config.database
160
+ return yield if database == ':memory:'
161
+
162
+ File.open("#{database}.ruby_llm_upgrade.lock", File::RDWR | File::CREAT, 0o600) do |file|
163
+ raise 'Another RubyLLM copy migration is running' unless file.flock(File::LOCK_EX | File::LOCK_NB)
164
+
165
+ yield
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -1,4 +1,6 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require 'generators/ruby_llm/upgrade/legacy_content_sql'
2
4
  <% if copy_mode? -%>
3
5
 
4
6
  require 'generators/ruby_llm/upgrade/upgrade_migration'
@@ -12,22 +14,19 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
12
14
 
13
15
  def up
14
16
  <% if copy_mode? -%>
15
- RubyLLM::Generators::UpgradeMigration.new.verify_settings(<%= copy_upgrade_settings.inspect %>)
17
+ RubyLLM::Generators::UpgradeMigration.for.verify_settings(<%= copy_upgrade_settings.inspect %>)
18
+ verify_upgrade_in_progress
19
+ RubyLLM::Generators::UpgradeMigration.for.backfill
16
20
  <% else -%>
17
21
  raise 'Generate this migration with --mode copy' if table_exists?(:ruby_llm_v2_upgrades)
18
- <% end -%>
19
22
  verify_upgrade_in_progress
20
23
  create_progress_table
21
24
  backfill_required_defaults
22
- <% if copy_mode? -%>
23
- RubyLLM::Generators::UpgradeMigration.new.backfill
24
- %w[message_content tool_results usages].each { |task| mark_completed(task) }
25
- <% else -%>
26
25
  backfill_message_content
27
26
  backfill_tool_results
28
27
  backfill_usages
29
- <% end -%>
30
28
  verify_backfills
29
+ <% end -%>
31
30
  end
32
31
 
33
32
  def down
@@ -69,18 +68,16 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
69
68
  content = message_value(:content)
70
69
  <% if postgresql? -%>
71
70
  structured_content = "#{content_raw}::jsonb"
72
- rendered_content = "#{content_raw}::text"
73
71
  <% elsif mysql? -%>
74
72
  structured_content = content_raw
75
- rendered_content = "CAST(#{content_raw} AS CHAR)"
76
73
  <% else -%>
77
74
  structured_content = content_raw
78
- rendered_content = content_raw
79
75
  <% end -%>
76
+ rendered_content = RubyLLM::Generators::LegacyContentSQL.new(connection).render(content: content, raw: content_raw)
80
77
  execute <<~SQL
81
78
  UPDATE #{quote_table(:<%= message_table_name %>)} AS legacy_messages
82
79
  SET #{quote_column(:raw_content)} = COALESCE(#{raw_content}, #{structured_content}),
83
- #{quote_column(:content)} = COALESCE(#{content}, #{rendered_content})
80
+ #{quote_column(:content)} = #{rendered_content}
84
81
  WHERE #{range}
85
82
  AND #{content_raw} IS NOT NULL
86
83
  SQL
@@ -187,6 +184,10 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
187
184
  end
188
185
 
189
186
  def verify_backfills
187
+ <% if postgresql? -%>
188
+ tables = %i[<%= chat_table_name %> <%= message_table_name %> ruby_llm_models ruby_llm_tool_calls ruby_llm_usages]
189
+ with_upgrade_safety { execute "ANALYZE #{tables.map { |table| quote_table(table) }.join(', ')}" }
190
+ <% end -%>
190
191
  verify_message_content
191
192
  verify_tool_results
192
193
  verify_usages
@@ -300,6 +301,7 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
300
301
 
301
302
  def usage_candidate_sql(conditions, joins, range)
302
303
  usages = quote_table(:ruby_llm_usages)
304
+ usage_range = range.gsub(message_value(connection.primary_key(:<%= message_table_name %>)), 'existing_usages.message_id')
303
305
  <<~SQL
304
306
  FROM #{quote_table(:<%= message_table_name %>)} legacy_messages
305
307
  #{joins}
@@ -309,6 +311,7 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
309
311
  SELECT 1
310
312
  FROM #{usages} existing_usages
311
313
  WHERE existing_usages.message_type = #{connection.quote('<%= message_model_name %>')}
314
+ AND (#{usage_range})
312
315
  AND existing_usages.message_id = #{message_value(connection.primary_key(:<%= message_table_name %>))}
313
316
  AND existing_usages.operation = 'chat'
314
317
  AND existing_usages.status = 'succeeded'
@@ -11,15 +11,14 @@ class <%= cleanup_migration_class_name %> < ActiveRecord::Migration<%= migration
11
11
 
12
12
  def up
13
13
  <% if copy_mode? -%>
14
- RubyLLM::Generators::UpgradeMigration.new.verify_settings(<%= copy_upgrade_settings.inspect %>)
15
- RubyLLM::Generators::UpgradeMigration.new.verify_cleanup
14
+ RubyLLM::Generators::UpgradeMigration.for.verify_settings(<%= copy_upgrade_settings.inspect %>)
15
+ RubyLLM::Generators::UpgradeMigration.for.verify_cleanup
16
16
  <% else -%>
17
17
  raise 'Generate cleanup with --mode copy for this database' if table_exists?(:ruby_llm_v2_upgrades)
18
18
  <% end -%>
19
19
  unless table_exists?(PROGRESS_TABLE)
20
20
  <% if copy_mode? -%>
21
- remove_legacy_message_columns
22
- RubyLLM::Generators::UpgradeMigration.new.cleanup
21
+ RubyLLM::Generators::UpgradeMigration.for.cleanup { remove_legacy_message_columns }
23
22
  return
24
23
  <% else -%>
25
24
  return unless legacy_columns.any?
@@ -31,9 +30,10 @@ class <%= cleanup_migration_class_name %> < ActiveRecord::Migration<%= migration
31
30
  raise 'Run <%= finish_migration_class_name %> before removing the legacy message columns'
32
31
  end
33
32
 
34
- remove_legacy_message_columns
35
33
  <% if copy_mode? -%>
36
- RubyLLM::Generators::UpgradeMigration.new.cleanup
34
+ RubyLLM::Generators::UpgradeMigration.for.cleanup { remove_legacy_message_columns }
35
+ <% else -%>
36
+ remove_legacy_message_columns
37
37
  <% end -%>
38
38
  drop_table PROGRESS_TABLE
39
39
  end
@@ -12,20 +12,26 @@ class <%= finish_migration_class_name %> < ActiveRecord::Migration<%= migration_
12
12
 
13
13
  def up
14
14
  <% if copy_mode? -%>
15
- RubyLLM::Generators::UpgradeMigration.new.verify_settings(<%= copy_upgrade_settings.inspect %>)
16
- RubyLLM::Generators::UpgradeMigration.new.backfill(force: false)
15
+ RubyLLM::Generators::UpgradeMigration.for.verify_settings(<%= copy_upgrade_settings.inspect %>)
16
+ RubyLLM::Generators::UpgradeMigration.for.finish do
17
+ verify_completed_backfills
18
+ enforce_required_defaults
19
+ relax_legacy_message_constraints
20
+ mark_finished
21
+ end
17
22
  <% else -%>
18
23
  raise 'Generate this migration with --mode copy' if table_exists?(:ruby_llm_v2_upgrades)
19
- <% end -%>
20
24
  verify_completed_backfills
25
+ <% if postgresql? -%>
26
+ tables = %i[<%= chat_table_name %> <%= message_table_name %> ruby_llm_models ruby_llm_tool_calls ruby_llm_usages]
27
+ with_upgrade_safety { execute "ANALYZE #{tables.map { |table| quote_table(table) }.join(', ')}" }
28
+ <% end -%>
21
29
  verify_message_content
22
30
  verify_tool_results
23
31
  verify_usages
24
32
  enforce_required_defaults
25
33
  relax_legacy_message_constraints
26
34
  mark_finished
27
- <% if copy_mode? -%>
28
- RubyLLM::Generators::UpgradeMigration.new.activate
29
35
  <% end -%>
30
36
  end
31
37
 
@@ -36,6 +42,17 @@ class <%= finish_migration_class_name %> < ActiveRecord::Migration<%= migration_
36
42
  private
37
43
 
38
44
  def relax_legacy_message_constraints
45
+ <% if copy_mode? -%>
46
+ model_column = :<%= v1_model_foreign_key %>
47
+ if column_definition(:<%= chat_table_name %>, model_column)&.null == false
48
+ with_upgrade_safety { change_column_null :<%= chat_table_name %>, model_column, true }
49
+ end
50
+ connection.foreign_keys(:<%= v1_tool_call_table_name %>).each do |key|
51
+ next unless key.column == '<%= message_foreign_key %>'
52
+
53
+ remove_foreign_key :<%= v1_tool_call_table_name %>, column: key.column
54
+ end
55
+ <% end -%>
39
56
  %i[<%= v1_model_foreign_key %> <%= v1_tool_call_foreign_key %>].each do |column|
40
57
  next unless column_definition(:<%= message_table_name %>, column)&.null == false
41
58
 
@@ -51,6 +68,15 @@ class <%= finish_migration_class_name %> < ActiveRecord::Migration<%= migration_
51
68
  end
52
69
 
53
70
  def enforce_required_defaults
71
+ {
72
+ <%= chat_table_name %>: {cancelled: false},
73
+ <%= message_table_name %>: {cache_until_here: false},
74
+ ruby_llm_tool_calls: {message_type: '<%= message_model_name %>'}
75
+ }.each do |table, attributes|
76
+ migration_record(table).where(attributes.transform_values { nil }).in_batches(of: 10_000) do |batch|
77
+ batch.update_all(attributes)
78
+ end
79
+ end
54
80
  <% if copy_mode? -%>
55
81
  enforce_not_null(:<%= chat_table_name %>, :ruby_llm_model_id)
56
82
  <% end -%>
@@ -9,16 +9,11 @@ class <%= prepare_migration_class_name %> < ActiveRecord::Migration<%= migration
9
9
 
10
10
  def up
11
11
  <% if copy_mode? -%>
12
- RubyLLM::Generators::UpgradeMigration.new.prepare(<%= copy_upgrade_settings.inspect %>)
12
+ RubyLLM::Generators::UpgradeMigration.for.prepare(<%= copy_upgrade_settings.inspect %>) { prepare_schema }
13
13
  <% else -%>
14
14
  raise 'This database uses --mode copy' if table_exists?(:ruby_llm_v2_upgrades)
15
+ prepare_schema
15
16
  <% end -%>
16
- validate_upgrade
17
- move_models
18
- add_chat_and_message_columns
19
- move_tool_calls
20
- create_usages
21
- create_batches
22
17
  end
23
18
 
24
19
  def down
@@ -27,6 +22,15 @@ class <%= prepare_migration_class_name %> < ActiveRecord::Migration<%= migration
27
22
 
28
23
  private
29
24
 
25
+ def prepare_schema
26
+ validate_upgrade
27
+ move_models
28
+ add_chat_and_message_columns
29
+ move_tool_calls
30
+ create_usages
31
+ create_batches
32
+ end
33
+
30
34
  def validate_upgrade
31
35
  %i[<%= chat_table_name %> <%= message_table_name %>].each do |table|
32
36
  raise "Expected #{table} to exist before upgrading RubyLLM" unless table_exists?(table)
@@ -247,8 +251,7 @@ class <%= prepare_migration_class_name %> < ActiveRecord::Migration<%= migration
247
251
  unless column_exists?(table, :ruby_llm_model_id)
248
252
  add_column table, :ruby_llm_model_id, reference_type_for(:ruby_llm_models)
249
253
  end
250
- RubyLLM::Generators::UpgradeMigration.new.copy_model_references
251
- with_upgrade_safety { change_column_null table, column, true } unless column_definition(table, column).null
254
+ RubyLLM::Generators::UpgradeMigration.for.copy_model_references
252
255
  <% else -%>
253
256
  with_upgrade_safety { rename_column table, column, :ruby_llm_model_id } if column != :ruby_llm_model_id
254
257
  <% end -%>
@@ -288,7 +291,9 @@ class <%= prepare_migration_class_name %> < ActiveRecord::Migration<%= migration
288
291
  end
289
292
 
290
293
  def ensure_boolean_column(table, column)
291
- add_column table, column, :boolean unless column_exists?(table, column)
294
+ unless column_exists?(table, column)
295
+ with_upgrade_safety { add_column table, column, :boolean, default: false }
296
+ end
292
297
  return if ActiveRecord::Type::Boolean.new.cast(column_definition(table, column).default) == false
293
298
 
294
299
  with_upgrade_safety { change_column_default table, column, false }
@@ -296,9 +301,6 @@ class <%= prepare_migration_class_name %> < ActiveRecord::Migration<%= migration
296
301
 
297
302
  def move_tool_calls
298
303
  move_table(:<%= v1_tool_call_table_name %>, :ruby_llm_tool_calls)
299
- <% if copy_mode? -%>
300
- remove_column_foreign_keys(:<%= v1_tool_call_table_name %>, :<%= message_foreign_key %>)
301
- <% end -%>
302
304
  table = :ruby_llm_tool_calls
303
305
  <% if postgresql? -%>
304
306
  normalize_postgresql_json_columns(table, %i[arguments])
@@ -524,7 +526,7 @@ class <%= prepare_migration_class_name %> < ActiveRecord::Migration<%= migration
524
526
 
525
527
  def move_table(source, target)
526
528
  <% if copy_mode? -%>
527
- with_upgrade_safety { RubyLLM::Generators::UpgradeMigration.new.copy_table(source, target) }
529
+ with_upgrade_safety { RubyLLM::Generators::UpgradeMigration.for.copy_table(source, target) }
528
530
  <% else -%>
529
531
  return if source == target || !table_exists?(source)
530
532