ruby_llm 2.0.0 → 2.1.0

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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -1,341 +0,0 @@
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
@@ -1,171 +0,0 @@
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