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
@@ -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,174 @@
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(discard_incomplete_tool_calls: false)
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
+ if discard_incomplete_tool_calls
59
+ discarded = data.discard_incomplete_tool_calls
60
+ ::ActiveRecord::Migration.say "Discarded #{discarded} incomplete legacy tool calls"
61
+ end
62
+ data.catch_up
63
+ data.verify
64
+ verify_completed_work
65
+ yield
66
+ journal.install
67
+ state.update!(active_version: 2, status: 'active', needs_reconcile: false)
68
+ end
69
+ end
70
+
71
+ def rollback
72
+ with_migration_lock do
73
+ state = states.first!
74
+ unless data.finished?
75
+ raise 'The unfinished upgrade is not running on 1.16' unless state.active_version == 1
76
+
77
+ state.update!(status: 'preparing', needs_reconcile: true)
78
+ return
79
+ end
80
+ state.with_lock do
81
+ require_active_version(state, 2)
82
+ verify_completed_work
83
+ state.update!(active_version: 1, epoch: state.epoch + 1)
84
+ end
85
+ end
86
+ end
87
+
88
+ def resume
89
+ with_migration_lock do
90
+ raise 'Finish the initial copy migrations before resuming 2.0' unless data.finished?
91
+
92
+ state = states.first!
93
+ pause(state, from: 1, statuses: %w[active finishing])
94
+ data.catch_up
95
+ data.verify
96
+ verify_completed_work
97
+ state.update!(active_version: 2, status: 'active', needs_reconcile: false)
98
+ end
99
+ end
100
+
101
+ def finalize
102
+ with_migration_lock { super }
103
+ end
104
+
105
+ def cleanup
106
+ with_migration_lock do
107
+ verify_cleanup
108
+ journal.remove
109
+ yield if block_given?
110
+ data.cleanup
111
+ super
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def pause(state, from:, statuses:)
118
+ state.with_lock do
119
+ unless state.active_version == from && statuses.include?(state.status)
120
+ raise 'The copy upgrade is not ready for this version switch'
121
+ end
122
+
123
+ state.update!(status: 'finishing', epoch: state.epoch + 1) unless state.status == 'finishing'
124
+ end
125
+ end
126
+
127
+ def data
128
+ @data ||= Data.new(connection: @connection, settings: configuration)
129
+ end
130
+
131
+ def journal
132
+ @journal ||= Journal.new(connection: @connection, settings: configuration)
133
+ end
134
+
135
+ def with_migration_lock(&)
136
+ return with_mysql_lock(&) if @connection.adapter_name == 'Mysql2'
137
+ return with_sqlite_lock(&) if @connection.adapter_name == 'SQLite'
138
+
139
+ key = "hashtextextended(current_database() || '.' || current_schema() || '.ruby_llm_upgrade', 0)"
140
+ locked = @connection.select_value("SELECT pg_try_advisory_lock(#{key})")
141
+ raise 'Another RubyLLM copy migration is running' unless locked
142
+
143
+ begin
144
+ yield
145
+ ensure
146
+ @connection.execute("SELECT pg_advisory_unlock(#{key})")
147
+ end
148
+ end
149
+
150
+ def with_mysql_lock
151
+ key = @connection.quote("ruby_llm_upgrade:#{@connection.current_database}".slice(0, 64))
152
+ locked = @connection.select_value("SELECT GET_LOCK(#{key}, 0)")
153
+ raise 'Another RubyLLM copy migration is running' unless locked == 1
154
+
155
+ begin
156
+ yield
157
+ ensure
158
+ @connection.execute("SELECT RELEASE_LOCK(#{key})")
159
+ end
160
+ end
161
+
162
+ def with_sqlite_lock
163
+ database = @connection.pool.db_config.database
164
+ return yield if database == ':memory:'
165
+
166
+ File.open("#{database}.ruby_llm_upgrade.lock", File::RDWR | File::CREAT, 0o600) do |file|
167
+ raise 'Another RubyLLM copy migration is running' unless file.flock(File::LOCK_EX | File::LOCK_NB)
168
+
169
+ yield
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -1,33 +1,34 @@
1
1
  # frozen_string_literal: true
2
- <% if copy_mode? -%>
3
2
 
3
+ <% if copy_mode? -%>
4
4
  require 'generators/ruby_llm/upgrade/upgrade_migration'
5
+ <% else -%>
6
+ require 'generators/ruby_llm/upgrade/legacy_content_sql'
5
7
  <% end -%>
6
8
 
7
9
  class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
8
10
  disable_ddl_transaction!
9
11
 
12
+ <% unless copy_mode? -%>
10
13
  BATCH_SIZE = 10_000
14
+ <% end -%>
11
15
  PROGRESS_TABLE = :ruby_llm_v2_backfills
12
16
 
13
17
  def up
14
18
  <% if copy_mode? -%>
15
- RubyLLM::Generators::UpgradeMigration.new.verify_settings(<%= copy_upgrade_settings.inspect %>)
19
+ RubyLLM::Generators::UpgradeMigration.for.verify_settings(<%= copy_upgrade_settings.inspect %>)
20
+ verify_upgrade_in_progress
21
+ RubyLLM::Generators::UpgradeMigration.for.backfill
16
22
  <% else -%>
17
23
  raise 'Generate this migration with --mode copy' if table_exists?(:ruby_llm_v2_upgrades)
18
- <% end -%>
19
24
  verify_upgrade_in_progress
20
25
  create_progress_table
21
26
  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
27
  backfill_message_content
27
28
  backfill_tool_results
28
29
  backfill_usages
29
- <% end -%>
30
30
  verify_backfills
31
+ <% end -%>
31
32
  end
32
33
 
33
34
  def down
@@ -47,6 +48,7 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
47
48
  raise 'The RubyLLM 2.0 upgrade is already finished. Do not run the backfill again.'
48
49
  end
49
50
 
51
+ <% unless copy_mode? -%>
50
52
  def backfill_required_defaults
51
53
  {
52
54
  <%= chat_table_name %>: {cancelled: false},
@@ -69,18 +71,16 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
69
71
  content = message_value(:content)
70
72
  <% if postgresql? -%>
71
73
  structured_content = "#{content_raw}::jsonb"
72
- rendered_content = "#{content_raw}::text"
73
74
  <% elsif mysql? -%>
74
75
  structured_content = content_raw
75
- rendered_content = "CAST(#{content_raw} AS CHAR)"
76
76
  <% else -%>
77
77
  structured_content = content_raw
78
- rendered_content = content_raw
79
78
  <% end -%>
79
+ rendered_content = RubyLLM::Generators::LegacyContentSQL.new(connection).render(content: content, raw: content_raw)
80
80
  execute <<~SQL
81
81
  UPDATE #{quote_table(:<%= message_table_name %>)} AS legacy_messages
82
82
  SET #{quote_column(:raw_content)} = COALESCE(#{raw_content}, #{structured_content}),
83
- #{quote_column(:content)} = COALESCE(#{content}, #{rendered_content})
83
+ #{quote_column(:content)} = #{rendered_content}
84
84
  WHERE #{range}
85
85
  AND #{content_raw} IS NOT NULL
86
86
  SQL
@@ -187,6 +187,10 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
187
187
  end
188
188
 
189
189
  def verify_backfills
190
+ <% if postgresql? -%>
191
+ tables = %i[<%= chat_table_name %> <%= message_table_name %> ruby_llm_models ruby_llm_tool_calls ruby_llm_usages]
192
+ with_upgrade_safety { execute "ANALYZE #{tables.map { |table| quote_table(table) }.join(', ')}" }
193
+ <% end -%>
190
194
  verify_message_content
191
195
  verify_tool_results
192
196
  verify_usages
@@ -300,6 +304,7 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
300
304
 
301
305
  def usage_candidate_sql(conditions, joins, range)
302
306
  usages = quote_table(:ruby_llm_usages)
307
+ usage_range = range.gsub(message_value(connection.primary_key(:<%= message_table_name %>)), 'existing_usages.message_id')
303
308
  <<~SQL
304
309
  FROM #{quote_table(:<%= message_table_name %>)} legacy_messages
305
310
  #{joins}
@@ -309,6 +314,7 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
309
314
  SELECT 1
310
315
  FROM #{usages} existing_usages
311
316
  WHERE existing_usages.message_type = #{connection.quote('<%= message_model_name %>')}
317
+ AND (#{usage_range})
312
318
  AND existing_usages.message_id = #{message_value(connection.primary_key(:<%= message_table_name %>))}
313
319
  AND existing_usages.operation = 'chat'
314
320
  AND existing_usages.status = 'succeeded'
@@ -450,6 +456,7 @@ class <%= backfill_migration_class_name %> < ActiveRecord::Migration<%= migratio
450
456
  yield
451
457
  end
452
458
 
459
+ <% end -%>
453
460
  def progress_records = migration_record(PROGRESS_TABLE)
454
461
 
455
462
  def migration_record(table)
@@ -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<% if discard_incomplete_tool_calls? %>(discard_incomplete_tool_calls: true)<% end %> 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 -%>
@@ -87,6 +113,7 @@ class <%= finish_migration_class_name %> < ActiveRecord::Migration<%= migration_
87
113
  raise "RubyLLM 2.0 backfills are incomplete: #{missing.join(', ')}"
88
114
  end
89
115
 
116
+ <% unless copy_mode? -%>
90
117
  def verify_message_content
91
118
  return unless column_exists?(:<%= message_table_name %>, :content_raw)
92
119
 
@@ -194,17 +221,22 @@ class <%= finish_migration_class_name %> < ActiveRecord::Migration<%= migration_
194
221
  <% end -%>
195
222
  end
196
223
 
224
+ <% end -%>
197
225
  def with_upgrade_safety(&)
198
226
  return safety_assured(&) if respond_to?(:safety_assured, true)
199
227
 
200
228
  yield
201
229
  end
202
230
 
203
- def message_value(column) = "legacy_messages.#{quote_column(column)}"
204
231
  def column_definition(table, column) = connection.columns(table).find { |candidate| candidate.name == column.to_s }
232
+ <% unless copy_mode? -%>
233
+ def message_value(column) = "legacy_messages.#{quote_column(column)}"
205
234
  def quoted_primary_key(table) = quote_column(connection.primary_key(table))
206
235
  def quote_table(table) = connection.quote_table_name(table)
236
+ <% end -%>
237
+ <% if postgresql? || !copy_mode? -%>
207
238
  def quote_column(column) = connection.quote_column_name(column)
239
+ <% end -%>
208
240
 
209
241
  def migration_record(table)
210
242
  Class.new(ActiveRecord::Base) do
@@ -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