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,197 +0,0 @@
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
@@ -1,174 +0,0 @@
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