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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef3c927159add14551e31ce03609ce42e150f9f161af9b68602f9c0e080fc31d
4
- data.tar.gz: b894b99f6d6e541c643d76810b9542364f9d0beef4d00efd7b87b80d36c071af
3
+ metadata.gz: c047801a2b7a5a642296f0ae84e81ab29b641353ffe8618b3ece44db0b0a3849
4
+ data.tar.gz: 05e4624f14a8e2045408dd21007758612f0644522bae4e250902fd60d66955e1
5
5
  SHA512:
6
- metadata.gz: 7dacd51baf534c7b226e1db52f09421b72d9939ec17af6cfd4b18db8bd7d9fbe2a22b488a8571d64f2194d46ed82ba045a726a309465b448f3a31a077a1f98c1
7
- data.tar.gz: 90f866305245b759ae821cd8932d518c8648f5a3afce588028b820ced1f8aa7b05ac5de7c55a6b0f89e5aac80052d348fbc3fec8cf3b9efc83dec5effa32b260
6
+ metadata.gz: 6e73e5f6818e70c607c3cabfc6e0dfb19f9271491375b71279e0a205f2ecff8fe0051c691c51230644d32f7ad8f377b8f8bddfcae919e33ff64015ccd9a942e7
7
+ data.tar.gz: fd4285734764621dc2cab77579b8c713aaff907930c1b6849472c470839b2a75e6975832baf4e43adeedd1d3326b92af816af4a63242bdfa2585fab2b72fdc58
data/.rdoc_options CHANGED
@@ -17,8 +17,8 @@ exclude:
17
17
  - \.json$
18
18
  footer_content:
19
19
  Documentation:
20
- Guides: https://rubyllm.com/next/
21
- Upgrading: https://rubyllm.com/next/upgrading/
20
+ Guides: https://rubyllm.com/
21
+ Upgrading: https://rubyllm.com/upgrading/
22
22
  Resources:
23
23
  GitHub: https://github.com/crmne/ruby_llm
24
24
  Issues: https://github.com/crmne/ruby_llm/issues
data/README.md CHANGED
@@ -7,7 +7,9 @@
7
7
 
8
8
  <strong>Build AI features the Ruby way</strong>
9
9
 
10
- <p>The Ruby-native AI framework. Build with chats, tools, agents, images, audio, and video through one consistent API, in plain Ruby or Rails.</p>
10
+ <p>RubyLLM is the Ruby-native AI framework. Work with models, tools, and agents through one consistent API, in plain Ruby or Rails.</p>
11
+
12
+ [Website](https://rubyllm.com/) · [Getting Started](https://rubyllm.com/getting-started/) · [What's New in 2.0](https://rubyllm.com/whats-new-in-2-0/)
11
13
 
12
14
  Battle tested at [<picture><source media="(prefers-color-scheme: dark)" srcset="https://chatwithwork.com/logotype-dark.svg"><img src="https://chatwithwork.com/logotype.svg" alt="Chat with Work" height="30" align="absmiddle"></picture>](https://chatwithwork.com) - *Fully private work AI*
13
15
 
@@ -24,26 +26,19 @@ Battle tested at [<picture><source media="(prefers-color-scheme: dark)" srcset="
24
26
 
25
27
  ---
26
28
 
27
- Work with OpenAI, xAI, Anthropic, Google, AWS, local models, and more. Seventeen providers are built in, and you can connect an OpenAI-compatible endpoint directly.
28
-
29
- ## Build a working Ruby AI chat in two minutes
30
-
31
- https://github.com/user-attachments/assets/65422091-9338-47da-a303-92b918bd1345
32
-
33
- ## Why RubyLLM?
29
+ ## 17 providers. One Ruby API.
34
30
 
35
- Use the same Ruby methods across providers. Add files to a conversation, give an agent tools, generate media, or build a search feature with embeddings and reranking. Read response text, generated files, and usage through Ruby objects.
31
+ Build with the models you want. Move between hosted and local providers without rewriting your application, or connect an OpenAI-compatible endpoint.
36
32
 
37
- In Rails, the API works on your own Chat and Message records, with Active Storage attachments, Hotwire streaming, and background jobs. RubyLLM maintains the supporting model registry, tool calls, usage ledger, and batches. A handful of small dependencies keeps it easy to bring into an existing application.
33
+ [Browse models and pricing](https://rubyllm.com/available-models/) · [Connect a provider](https://rubyllm.com/configuration-providers/)
38
34
 
39
- ## Show me the code
35
+ ## Start with one line. Add files, tools, and agents
40
36
 
41
- These examples use **2.0.0.rc2**. Follow [Getting Started](https://rubyllm.com/next/getting-started/) to install it and configure the providers you want to try.
37
+ These examples use **2.0.0.rc4 (prerelease)**. Follow [Getting Started](https://rubyllm.com/getting-started/) to install it and configure the providers you want to try. For 1.x, use the [stable-version docs](https://rubyllm.com/v1/).
42
38
 
43
39
  ```ruby
44
- # Just ask questions
45
- chat = RubyLLM.chat
46
- chat.ask "What's the best way to learn Ruby?"
40
+ # Just ask
41
+ RubyLLM.chat.ask "What's the best way to learn Ruby?"
47
42
  ```
48
43
 
49
44
  ```ruby
@@ -155,7 +150,9 @@ response = chat.with_schema(ProductSchema).ask "Analyze this product", with: "pr
155
150
  response.parsed
156
151
  ```
157
152
 
158
- ## Features
153
+ ## A complete AI framework for Ruby
154
+
155
+ Agents, workflows, RAG, images, audio, and video. Built in, with usage tracking and Rails integration to bring them into your app.
159
156
 
160
157
  * **Chat:** Conversational AI with `RubyLLM.chat`
161
158
  * **Vision:** Analyze images and videos
@@ -195,7 +192,7 @@ response.parsed
195
192
  Install the 2.0 release candidate:
196
193
 
197
194
  ```bash
198
- bundle add ruby_llm --version 2.0.0.rc2
195
+ bundle add ruby_llm --version 2.0.0.rc4
199
196
  ```
200
197
 
201
198
  Configure a provider in your script, or in `config/initializers/ruby_llm.rb` in Rails:
@@ -208,9 +205,13 @@ RubyLLM.configure do |config|
208
205
  end
209
206
  ```
210
207
 
211
- Configure the other providers used by the examples as needed: Gemini for files, xAI for video, Mistral for OCR, and Cohere for reranking. [Getting Started](https://rubyllm.com/next/getting-started/) shows each setup beside its example. If your app uses 1.16, follow the [upgrade guide](https://rubyllm.com/next/upgrading/) before deploying 2.0.
208
+ Configure the other providers used by the examples as needed: Gemini for files, xAI for video, Mistral for OCR, and Cohere for reranking. [Getting Started](https://rubyllm.com/getting-started/) shows each setup beside its example. If your app uses 1.16, follow the [upgrade guide](https://rubyllm.com/upgrading/) before deploying 2.0.
212
209
 
213
- ## Rails
210
+ ## Feels at home in Rails
211
+
212
+ Save conversations with Active Record and stream replies with Hotwire. The generators give you a working chat UI. Watch the two-minute demo.
213
+
214
+ https://github.com/user-attachments/assets/65422091-9338-47da-a303-92b918bd1345
214
215
 
215
216
  ```bash
216
217
  # Install Rails Integration
@@ -231,11 +232,21 @@ chat = Chat.create! model: "gpt-5.6-luna"
231
232
  chat.ask "What's in this file?", with: "report.pdf"
232
233
  ```
233
234
 
234
- Visit `http://localhost:3000/chats` for a ready-to-use chat interface!
235
+ Start your Rails server and visit `http://localhost:3000/chats` to try the chat interface. See the [Rails guide](https://rubyllm.com/rails/) for persistence, streaming, and background jobs.
236
+
237
+ ## AI coding assistants
238
+
239
+ Give your coding assistant the RubyLLM API and documentation that match your application. From your application directory, install the skill packaged with your gem:
240
+
241
+ ```bash
242
+ npx skills add "$(bundle show ruby_llm)" --skill rubyllm
243
+ ```
244
+
245
+ Choose your coding assistant and installation scope when prompted. See [AI Coding Assistants](https://rubyllm.com/ai-coding-assistants/) for setup and updates.
235
246
 
236
247
  ## Documentation
237
248
 
238
- [Guides](https://rubyllm.com/next/getting-started/) · [API reference](https://rubyllm.com/next/api/) · [Models](https://rubyllm.com/available-models/) · [Upgrading](https://rubyllm.com/next/upgrading/)
249
+ [Guides](https://rubyllm.com/getting-started/) · [API reference](https://rubyllm.com/api/) · [Models](https://rubyllm.com/available-models/) · [Upgrading](https://rubyllm.com/upgrading/) · [1.x docs](https://rubyllm.com/v1/)
239
250
 
240
251
  ## Contributing
241
252
 
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Generators
5
+ class LegacyContentSQL # :nodoc: all
6
+ SPACE = [9, 10, 11, 12, 13, 32, 133, 160, 5760, *8192..8202, 8232, 8233, 8239, 8287, 12_288].pack('U*').freeze
7
+
8
+ def initialize(connection)
9
+ @connection = connection
10
+ end
11
+
12
+ def render(content:, raw:)
13
+ normalized, rendered, nonblank = expressions(raw)
14
+ present = "#{normalized} NOT IN ('null', 'false', '[]', '{}') AND #{nonblank}"
15
+ "CASE WHEN #{present} THEN #{rendered} ELSE #{content} END"
16
+ end
17
+
18
+ private
19
+
20
+ def expressions(raw)
21
+ case @connection.adapter_name
22
+ when 'PostgreSQL'
23
+ ["(#{raw}::jsonb)::text", "#{raw}::text",
24
+ "btrim(#{raw}::jsonb #>> '{}', #{@connection.quote(SPACE)}) <> ''"]
25
+ when 'Mysql2'
26
+ ["CAST(#{raw} AS CHAR)", "CAST(#{raw} AS CHAR)",
27
+ "JSON_UNQUOTE(#{raw}) NOT REGEXP '^[[:space:]]*$'"]
28
+ else
29
+ ["json(#{raw})", raw, "trim(json_extract(#{raw}, '$'), #{@connection.quote(SPACE)}) <> ''"]
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../legacy_content_sql'
5
+ require_relative 'verification'
6
+
7
+ module RubyLLM
8
+ module Generators
9
+ class OnlineCopyMigration < UpgradeMigration
10
+ class Data < UpgradeMigration # :nodoc: all
11
+ BATCH_SIZE = 10_000
12
+ PROGRESS = :ruby_llm_v2_backfills
13
+ TASKS = %w[message_content tool_results usages].freeze
14
+
15
+ def initialize(connection:, settings:)
16
+ super(connection:)
17
+ @configuration = settings
18
+ end
19
+
20
+ def prepare
21
+ messages = configuration.fetch('message_table')
22
+ unless @connection.column_exists?(messages, :ruby_llm_content)
23
+ @connection.add_column(messages, :ruby_llm_content, :text)
24
+ end
25
+ %i[ruby_llm_tool_calls ruby_llm_usages].each do |table|
26
+ @connection.add_column(table, :legacy_key, :string) unless @connection.column_exists?(table, :legacy_key)
27
+ unless @connection.index_exists?(table, :legacy_key, unique: true)
28
+ @connection.add_index(table, :legacy_key, unique: true)
29
+ end
30
+ end
31
+ return if @connection.column_exists?(:ruby_llm_tool_calls, :legacy_chat_id)
32
+
33
+ @connection.add_column(:ruby_llm_tool_calls, :legacy_chat_id, :string)
34
+ @connection.add_index(:ruby_llm_tool_calls, :legacy_chat_id)
35
+ end
36
+
37
+ def backfill
38
+ prepare_progress
39
+ sync_models
40
+ sync_chat_models
41
+ TASKS.each do |task|
42
+ progress = records(PROGRESS).find_or_create_by!(task:)
43
+ next if progress.completed
44
+
45
+ unless progress.upper_id
46
+ upper = messages.order(messages.primary_key => :desc).pick(messages.primary_key)
47
+ unless upper
48
+ progress.update!(completed: true)
49
+ next
50
+ end
51
+ progress.update!(upper_id: upper&.to_s)
52
+ end
53
+ relation = legacy_messages
54
+ relation = relation.where("#{q(messages.primary_key)} <= ?", progress.upper_id) if progress.upper_id
55
+ relation = relation.where("#{q(messages.primary_key)} > ?", progress.last_id) if progress.last_id
56
+ each_batch(relation) do |batch|
57
+ messages.transaction do
58
+ copy_batch(task, batch)
59
+ progress.update!(last_id: batch.last.id)
60
+ end
61
+ end
62
+ progress.update!(completed: true)
63
+ end
64
+ end
65
+
66
+ def discard_incomplete_tool_calls
67
+ source = records(configuration.fetch('tool_call_table'))
68
+ result_key = configuration.fetch('tool_call_foreign_key')
69
+ results = messages.where.not(result_key => nil).select(result_key)
70
+ owners = legacy_messages.select(messages.primary_key)
71
+ source.where(configuration.fetch('message_foreign_key') => owners)
72
+ .where.not(source.primary_key => results).delete_all
73
+ end
74
+
75
+ def catch_up(passes: nil)
76
+ sync_models
77
+ pass = 0
78
+ loop do
79
+ upper = changes.maximum(:id)
80
+ break unless upper
81
+
82
+ changes.where(id: ..upper).in_batches(of: 100) do |events|
83
+ captured = events.to_a
84
+ chat_ids = affected_chats(captured)
85
+ sync_chat_models(chat_ids)
86
+ reconcile_chats(chat_ids)
87
+ acknowledge(captured)
88
+ end
89
+ pass += 1
90
+ break if passes && pass >= passes
91
+ end
92
+ end
93
+
94
+ def completed?
95
+ @connection.table_exists?(PROGRESS) &&
96
+ (TASKS - records(PROGRESS).where(completed: true).pluck(:task)).empty?
97
+ end
98
+
99
+ def finished?
100
+ @connection.table_exists?(PROGRESS) && records(PROGRESS).where(task: 'finished', completed: true).exists?
101
+ end
102
+
103
+ def sync_models
104
+ refresh = !finished?
105
+ records(configuration.fetch('model_table')).find_each(batch_size: BATCH_SIZE) do |model|
106
+ target = resolve_model(model.id)
107
+ next unless refresh
108
+
109
+ attributes = model.attributes.slice(*target.class.column_names).except(target.class.primary_key)
110
+ target.assign_attributes(attributes)
111
+ target.save! if target.changed?
112
+ end
113
+ end
114
+
115
+ def sync_chat_models(ids = nil)
116
+ relation = chats.where(VERSION_COLUMN => 1)
117
+ relation = relation.where(chats.primary_key => ids) if ids
118
+ models = records(configuration.fetch('model_table')).all.to_h do |model|
119
+ [model.id, resolve_model(model.id).id]
120
+ end
121
+ models.each do |legacy_id, target_id|
122
+ relation.where(configuration.fetch('model_foreign_key') => legacy_id)
123
+ .where('ruby_llm_model_id IS NULL OR ruby_llm_model_id != ?', target_id)
124
+ .update_all(ruby_llm_model_id: target_id)
125
+ end
126
+ end
127
+
128
+ def verify
129
+ raise 'The online copy journal is not empty; keep AI activity paused' if changes.exists?
130
+ raise 'RubyLLM 2.0 backfills are incomplete' unless completed?
131
+
132
+ tables = [configuration.fetch('chat_table'), configuration.fetch('message_table'),
133
+ 'ruby_llm_models', 'ruby_llm_tool_calls', 'ruby_llm_usages']
134
+ if @connection.adapter_name == 'PostgreSQL'
135
+ @connection.execute("ANALYZE #{tables.map { |table| qt(table) }.join(', ')}")
136
+ end
137
+ Verification.new(connection: @connection, settings: configuration).verify
138
+ end
139
+
140
+ def cleanup
141
+ table = configuration.fetch('message_table')
142
+ if @connection.column_exists?(table, :ruby_llm_content)
143
+ @connection.remove_column(table, :content) if @connection.column_exists?(table, :content)
144
+ @connection.rename_column(table, :ruby_llm_content, :content)
145
+ end
146
+ columns = %i[legacy_key legacy_chat_id]
147
+ %i[ruby_llm_tool_calls ruby_llm_usages].each do |target|
148
+ columns.each do |column|
149
+ next unless @connection.column_exists?(target, column)
150
+
151
+ @connection.remove_index(target, column) if @connection.index_exists?(target, column)
152
+ @connection.remove_column(target, column)
153
+ end
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def acknowledge(events)
160
+ matches = events.map { |event| "(id = #{Integer(event.id)} AND revision = #{Integer(event.revision)})" }
161
+ changes.where(matches.join(' OR ')).delete_all if matches.any?
162
+ end
163
+
164
+ def prepare_progress
165
+ unless @connection.table_exists?(PROGRESS)
166
+ @connection.create_table(PROGRESS) do |table|
167
+ table.string :task, null: false, index: { unique: true }
168
+ table.string :last_id
169
+ table.boolean :completed, null: false, default: false
170
+ end
171
+ end
172
+ @connection.add_column(PROGRESS, :upper_id, :string) unless @connection.column_exists?(PROGRESS, :upper_id)
173
+ records(PROGRESS).reset_column_information
174
+ end
175
+
176
+ def copy_batch(task, batch)
177
+ case task
178
+ when 'message_content' then copy_content(batch.map(&:id))
179
+ when 'tool_results' then copy_tools(batch)
180
+ when 'usages' then upsert(:ruby_llm_usages, usage_attributes(batch))
181
+ end
182
+ end
183
+
184
+ def copy_content(ids)
185
+ raw = @connection.column_exists?(messages.table_name, :content_raw) ? q(:content_raw) : 'NULL'
186
+ text = LegacyContentSQL.new(@connection).render(content: q(:content), raw:)
187
+ structured = @connection.adapter_name == 'PostgreSQL' ? "#{raw}::jsonb" : raw
188
+ messages.where(messages.primary_key => ids).update_all(
189
+ "#{q(:ruby_llm_content)} = #{text}, #{q(:raw_content)} = #{structured}"
190
+ )
191
+ end
192
+
193
+ def copy_tools(batch)
194
+ ids = batch.map(&:id)
195
+ source = records(configuration.fetch('tool_call_table'))
196
+ key = configuration.fetch('message_foreign_key')
197
+ result_key = configuration.fetch('tool_call_foreign_key')
198
+ calls = source.where(key => ids).to_a
199
+ results = messages.where(result_key => calls.map(&:id)).to_a.group_by { |message| message[result_key] }
200
+ chat_ids = batch.to_h { |message| [message.id, message[configuration.fetch('chat_foreign_key')]] }
201
+ target = records(:ruby_llm_tool_calls)
202
+ attributes = calls.map do |call|
203
+ tool_attributes(call, results.fetch(call.id, []), chat_ids.fetch(call[key]), target)
204
+ end
205
+ verify_tool_ids(target, attributes)
206
+ upsert(:ruby_llm_tool_calls, attributes)
207
+ end
208
+
209
+ def tool_attributes(call, results, chat_id, target)
210
+ raise "Multiple messages reference tool call #{call.id}" if results.size > 1
211
+
212
+ call.attributes.slice(*target.column_names).except(target.primary_key).symbolize_keys.merge(
213
+ legacy_key: call.id.to_s, legacy_chat_id: chat_id.to_s,
214
+ message_type: configuration.fetch('message_class'),
215
+ message_id: call[configuration.fetch('message_foreign_key')],
216
+ result_type: results.any? ? configuration.fetch('message_class') : nil, result_id: results.first&.id
217
+ )
218
+ end
219
+
220
+ def verify_tool_ids(target, attributes)
221
+ provider_ids = attributes.map { |row| row[:tool_call_id] }
222
+ reserved = target.where(tool_call_id: provider_ids).pluck(:tool_call_id, :legacy_key).to_h
223
+ attributes.each do |row|
224
+ next unless reserved.key?(row[:tool_call_id]) && reserved[row[:tool_call_id]] != row[:legacy_key]
225
+
226
+ raise "Tool call #{row[:legacy_key]} repeats provider ID #{row[:tool_call_id].inspect}"
227
+ end
228
+ end
229
+
230
+ def usage_attributes(batch)
231
+ models = records(configuration.fetch('model_table')).all.index_by(&:id)
232
+ chat_ids = batch.map { |message| message[configuration.fetch('chat_foreign_key')] }
233
+ chat_models = chats.where(chats.primary_key => chat_ids)
234
+ .pluck(chats.primary_key, configuration.fetch('model_foreign_key')).to_h
235
+ batch.filter_map do |message|
236
+ source = message.attributes
237
+ tokens = legacy_tokens(source)
238
+ costs = legacy_costs(source)
239
+ next unless source['role'] == 'assistant' || (tokens.values + costs.values).any? { |value| !value.nil? }
240
+
241
+ chat_id = source.fetch(configuration.fetch('chat_foreign_key'))
242
+ provider, model_id = usage_identity(message, models, chat_models[chat_id])
243
+
244
+ tokens.merge(costs).symbolize_keys.merge(
245
+ legacy_key: message.id.to_s, chat_type: configuration.fetch('chat_class'), chat_id:,
246
+ message_type: configuration.fetch('message_class'), message_id: message.id,
247
+ operation: 'chat', provider:, model: model_id, status: 'succeeded',
248
+ created_at: source['created_at'], updated_at: source['updated_at']
249
+ )
250
+ end
251
+ end
252
+
253
+ def usage_identity(message, models, chat_model_id)
254
+ key = configuration.fetch('model_foreign_key')
255
+ model = models[message[key]] || models[chat_model_id]
256
+ if string_model_reference?(message, key)
257
+ provider = message.attributes['provider'] || model&.provider
258
+ raise "Message #{message.id} has no identifiable provider" unless provider
259
+
260
+ return [provider, message[key]]
261
+ end
262
+ raise "Message #{message.id} has no identifiable legacy model" unless model
263
+
264
+ [model.provider, model.model_id]
265
+ end
266
+
267
+ def upsert(table, attributes)
268
+ return if attributes.empty?
269
+
270
+ options = { record_timestamps: false }
271
+ options[:unique_by] = :legacy_key unless @connection.adapter_name == 'Mysql2'
272
+ records(table).upsert_all(attributes, **options)
273
+ end
274
+
275
+ def string_model_reference?(message, key)
276
+ messages.columns_hash[key]&.type == :string && message[key]
277
+ end
278
+
279
+ def affected_chats(events)
280
+ ids = events.select { |event| event.kind == 'chat' }.map(&:record_id)
281
+ model_ids = events.select { |event| event.kind == 'model' }.map(&:record_id)
282
+ if model_ids.any?
283
+ sync_models
284
+ key = configuration.fetch('model_foreign_key')
285
+ ids.concat(chats.where(key => model_ids).pluck(chats.primary_key))
286
+ ids.concat(messages.where(key => model_ids).distinct.pluck(configuration.fetch('chat_foreign_key')))
287
+ end
288
+ ids.map(&:to_s).uniq
289
+ end
290
+
291
+ def reconcile_chats(ids)
292
+ protected_ids = chats.where(chats.primary_key => ids,
293
+ VERSION_COLUMN => 2).pluck(chats.primary_key).map(&:to_s)
294
+ ids -= protected_ids
295
+ return if ids.empty?
296
+
297
+ remove_deleted_copies(ids)
298
+ relation = legacy_messages.where(configuration.fetch('chat_foreign_key') => ids)
299
+ upper = relation.order(messages.primary_key => :desc).pick(messages.primary_key)
300
+ relation = relation.where("#{q(messages.primary_key)} <= ?", upper) if upper
301
+ each_batch(relation) do |batch|
302
+ messages.transaction do
303
+ TASKS.each { |task| copy_batch(task, batch) }
304
+ eligible = usage_attributes(batch).map { |row| row[:legacy_key] }
305
+ records(:ruby_llm_usages).where(legacy_key: batch.map { |message| message.id.to_s })
306
+ .where.not(legacy_key: eligible).delete_all
307
+ end
308
+ end
309
+ remove_deleted_copies(ids)
310
+ end
311
+
312
+ def remove_deleted_copies(ids)
313
+ current = messages.select(messages.primary_key)
314
+ records(:ruby_llm_usages).where(chat_type: configuration.fetch('chat_class'), chat_id: ids)
315
+ .where.not(legacy_key: nil).where.not(message_id: current).delete_all
316
+ source = records(configuration.fetch('tool_call_table'))
317
+ records(:ruby_llm_tool_calls).where(legacy_chat_id: ids).where.not(legacy_key: nil).find_in_batches do |batch|
318
+ existing = source.where(source.primary_key => batch.map(&:legacy_key)).pluck(source.primary_key).map(&:to_s)
319
+ removed = batch.reject { |call| existing.include?(call.legacy_key) }.map(&:id)
320
+ records(:ruby_llm_tool_calls).where(id: removed).delete_all if removed.any?
321
+ end
322
+ end
323
+
324
+ def each_batch(relation, &)
325
+ relation.find_in_batches(batch_size: BATCH_SIZE, &)
326
+ end
327
+
328
+ def legacy_messages
329
+ owned = chats.where(VERSION_COLUMN => 1).select(chats.primary_key)
330
+ messages.where(configuration.fetch('chat_foreign_key') => owned)
331
+ end
332
+
333
+ def messages = records(configuration.fetch('message_table'))
334
+ def chats = records(configuration.fetch('chat_table'))
335
+ def changes = records(Journal::TABLE)
336
+ def q(value) = @connection.quote_column_name(value)
337
+ def qt(value) = @connection.quote_table_name(value)
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Generators
5
+ class OnlineCopyMigration < UpgradeMigration
6
+ class Journal # :nodoc: all
7
+ TABLE = :ruby_llm_v2_changes
8
+ OPERATIONS = %w[INSERT UPDATE DELETE].freeze
9
+ MESSAGE_COLUMNS = %w[
10
+ role content content_raw input_tokens output_tokens cached_tokens cache_creation_tokens
11
+ cache_read_tokens cache_write_tokens thinking_tokens total_cost cost_details created_at updated_at
12
+ ].freeze
13
+
14
+ def initialize(connection:, settings:)
15
+ @connection = connection
16
+ @settings = settings
17
+ end
18
+
19
+ def install
20
+ unless @connection.table_exists?(TABLE)
21
+ @connection.create_table(TABLE) do |table|
22
+ table.string :kind, null: false
23
+ table.string :record_id, null: false
24
+ table.bigint :revision, null: false, default: 1
25
+ table.index %i[kind record_id], unique: true
26
+ end
27
+ end
28
+ sources.each do |kind, table|
29
+ OPERATIONS.each { |operation| install_trigger(kind, table, operation) }
30
+ end
31
+ end
32
+
33
+ def remove
34
+ sources.each do |kind, table|
35
+ OPERATIONS.each do |operation|
36
+ name = trigger_name(kind, operation)
37
+ if postgresql?
38
+ if @connection.table_exists?(table)
39
+ @connection.execute("DROP TRIGGER IF EXISTS #{q(name)} ON #{qt(table)}")
40
+ end
41
+ @connection.execute("DROP FUNCTION IF EXISTS #{q(name)}()")
42
+ else
43
+ @connection.execute("DROP TRIGGER IF EXISTS #{q(name)}")
44
+ end
45
+ end
46
+ end
47
+ @connection.drop_table(TABLE) if @connection.table_exists?(TABLE)
48
+ end
49
+
50
+ private
51
+
52
+ def sources
53
+ %w[chat message tool_call model].to_h { |kind| [kind, @settings.fetch("#{kind}_table")] }
54
+ end
55
+
56
+ def install_trigger(kind, table, operation)
57
+ name = trigger_name(kind, operation)
58
+ return if trigger_exists?(name)
59
+
60
+ body = event_sql(kind, operation)
61
+ condition = operation == 'UPDATE' ? changed_sql(kind, table) : '1 = 1'
62
+ state = qt(UpgradeMigration::TABLE)
63
+ condition = "(#{condition}) AND EXISTS (SELECT 1 FROM #{state} WHERE active_version = 1)"
64
+ sql = if postgresql?
65
+ <<~SQL
66
+ CREATE FUNCTION #{q(name)}() RETURNS trigger LANGUAGE plpgsql AS $ruby_llm$
67
+ BEGIN
68
+ IF #{condition} THEN #{body} END IF;
69
+ RETURN NULL;
70
+ END $ruby_llm$;
71
+ CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)}
72
+ FOR EACH ROW EXECUTE FUNCTION #{q(name)}();
73
+ SQL
74
+ elsif mysql?
75
+ <<~SQL
76
+ CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)} FOR EACH ROW
77
+ BEGIN IF #{condition} THEN #{body} END IF; END
78
+ SQL
79
+ else
80
+ <<~SQL
81
+ CREATE TRIGGER #{q(name)} AFTER #{operation} ON #{qt(table)} FOR EACH ROW
82
+ WHEN #{condition} BEGIN #{body} END
83
+ SQL
84
+ end
85
+ @connection.execute(sql)
86
+ end
87
+
88
+ def trigger_exists?(name)
89
+ quoted = @connection.quote(name)
90
+ sql = if postgresql?
91
+ "SELECT 1 FROM pg_trigger WHERE tgname = #{quoted} AND tgrelid IN " \
92
+ '(SELECT oid FROM pg_class WHERE relnamespace = current_schema()::regnamespace)'
93
+ elsif mysql?
94
+ "SELECT 1 FROM information_schema.triggers WHERE trigger_name = #{quoted} " \
95
+ 'AND trigger_schema = DATABASE()'
96
+ else
97
+ "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{quoted}"
98
+ end
99
+ @connection.select_value(sql).present?
100
+ end
101
+
102
+ def changed_sql(kind, table)
103
+ columns = @connection.columns(table).map(&:name)
104
+ columns &= source_columns(kind) unless kind == 'tool_call'
105
+ columns.map do |column|
106
+ old = "OLD.#{q(column)}"
107
+ new = "NEW.#{q(column)}"
108
+ if postgresql?
109
+ "#{old}::text IS DISTINCT FROM #{new}::text"
110
+ elsif mysql?
111
+ "NOT (CAST(#{old} AS BINARY) <=> CAST(#{new} AS BINARY))"
112
+ else
113
+ "#{old} IS NOT #{new}"
114
+ end
115
+ end.join(' OR ')
116
+ end
117
+
118
+ def source_columns(kind)
119
+ primary = @connection.primary_key(sources.fetch(kind))
120
+ case kind
121
+ when 'chat'
122
+ [primary, @settings.fetch('model_foreign_key')]
123
+ when 'message'
124
+ [primary,
125
+ *@settings.values_at('chat_foreign_key', 'model_foreign_key', 'tool_call_foreign_key')] + MESSAGE_COLUMNS
126
+ when 'model'
127
+ [primary, 'provider', 'model_id']
128
+ end
129
+ end
130
+
131
+ def event_sql(kind, operation)
132
+ images = if operation == 'UPDATE'
133
+ %w[OLD NEW]
134
+ else
135
+ [operation == 'INSERT' ? 'NEW' : 'OLD']
136
+ end
137
+ images.map do |row|
138
+ id = event_owner(kind, row)
139
+ event = kind == 'model' ? 'model' : 'chat'
140
+ "INSERT INTO #{qt(TABLE)} (kind, record_id) SELECT '#{event}', #{id} WHERE #{id} IS NOT NULL #{upsert_sql};"
141
+ end.join("\n")
142
+ end
143
+
144
+ def event_owner(kind, row)
145
+ case kind
146
+ when 'message'
147
+ "#{row}.#{q(@settings.fetch('chat_foreign_key'))}"
148
+ when 'tool_call'
149
+ "(SELECT #{q(@settings.fetch('chat_foreign_key'))} FROM #{qt(sources.fetch('message'))} " \
150
+ "WHERE #{q(@connection.primary_key(sources.fetch('message')))} = " \
151
+ "#{row}.#{q(@settings.fetch('message_foreign_key'))})"
152
+ else
153
+ "#{row}.#{q(@connection.primary_key(sources.fetch(kind)))}"
154
+ end
155
+ end
156
+
157
+ def upsert_sql
158
+ return 'ON DUPLICATE KEY UPDATE revision = revision + 1' if mysql?
159
+
160
+ "ON CONFLICT (kind, record_id) DO UPDATE SET revision = #{qt(TABLE)}.revision + 1"
161
+ end
162
+
163
+ def trigger_name(kind, operation) = "ruby_llm_v2_#{kind}_#{operation.downcase}"
164
+ def postgresql? = @connection.adapter_name == 'PostgreSQL'
165
+ def mysql? = @connection.adapter_name == 'Mysql2'
166
+ def q(value) = @connection.quote_column_name(value)
167
+ def qt(value) = @connection.quote_table_name(value)
168
+ end
169
+ end
170
+ end
171
+ end