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,251 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLMUpgrade # :nodoc: all
4
- TABLE = 'ruby_llm_v2_upgrades'
5
- VERSION_COLUMN = 'ruby_llm_version'
6
- OPERATIONS = %i[ask say ask_later add_message generate compact step complete run_tools cancel resume
7
- approve deny with_model with_instructions cache_until_here reset_messages!].freeze
8
- READ_OPERATIONS = %i[reload to_llm count_tokens].freeze
9
-
10
- module Records
11
- extend ActiveSupport::Concern
12
-
13
- included do
14
- class_attribute :ruby_llm_upgrade_kind, instance_writer: false
15
- self.enumerate_columns_in_select_statements = true
16
- default_scope { RubyLLMUpgrade.visible(self) }
17
- after_initialize { @ruby_llm_upgrade_epoch = RubyLLMUpgrade.epoch }
18
- around_save :guard_ruby_llm_upgrade
19
- around_destroy :guard_ruby_llm_upgrade, prepend: true
20
- before_save { RubyLLMUpgrade.prepare_model(self) if ruby_llm_upgrade_kind == :chat }
21
- end
22
-
23
- private
24
-
25
- def guard_ruby_llm_upgrade(&)
26
- RubyLLMUpgrade.guard(self, &)
27
- end
28
- end
29
-
30
- class << self
31
- def install(chat:, message:, tool_call:)
32
- if version == 2 && message.table_exists? && message.column_names.include?('ruby_llm_content')
33
- message.alias_attribute :content, :ruby_llm_content
34
- end
35
- { chat => :chat, message => :message, tool_call => :tool_call }.each do |model, kind|
36
- next if model < Records
37
-
38
- model.include Records
39
- model.ruby_llm_upgrade_kind = kind
40
- model.prepend guarded_operations(model, READ_OPERATIONS, claim: false)
41
- end
42
- return if chat.instance_variable_defined?(:@ruby_llm_upgrade_operations)
43
-
44
- operations = guarded_operations(chat, OPERATIONS, claim: true)
45
- %i[persist_usage_entry persist_new_message persist_message_completion].each do |name|
46
- next unless chat.private_method_defined?(name)
47
-
48
- operations.define_method(name) do |*args, **options, &block|
49
- RubyLLMUpgrade.guard(self) { super(*args, **options, &block) }
50
- end
51
- operations.send(:private, name)
52
- end
53
- chat.prepend operations
54
- chat.instance_variable_set(:@ruby_llm_upgrade_operations, operations)
55
- end
56
-
57
- def visible(model)
58
- return model.where(nil) unless available?
59
-
60
- state = current_state
61
- check_version(state)
62
- return model.where(nil) if version == 2
63
-
64
- settings = state.settings
65
- return model.where(nil) unless ownership_column?(settings)
66
-
67
- chats = records(settings.fetch('chat_table')).where(VERSION_COLUMN => 1)
68
- case model.ruby_llm_upgrade_kind
69
- when :chat
70
- model.where(VERSION_COLUMN => 1)
71
- when :message
72
- model.where(settings.fetch('chat_foreign_key') => chats.select(chats.primary_key))
73
- when :tool_call
74
- messages = records(settings.fetch('message_table'))
75
- .where(settings.fetch('chat_foreign_key') => chats.select(chats.primary_key))
76
- model.where(settings.fetch('message_foreign_key') => messages.select(messages.primary_key))
77
- end
78
- end
79
-
80
- def prepare_model(chat)
81
- return unless available?
82
-
83
- state = current_state
84
- settings = state.settings
85
- return if version == 1 && settings['online'] && state.status == 'preparing'
86
- if version == 1
87
- return unless ActiveRecord::Base.connection.column_exists?(settings.fetch('chat_table'), :ruby_llm_model_id)
88
- return unless ActiveRecord::Base.connection.table_exists?('ruby_llm_models')
89
- end
90
- ensure_model_reference(chat, settings)
91
- end
92
-
93
- def guard(record, claim: true)
94
- return yield unless available?
95
-
96
- states = records(TABLE)
97
- states.uncached do
98
- states.transaction do
99
- if ActiveRecord::Base.connection.adapter_name == 'SQLite' && claim
100
- states.update_all('epoch = epoch')
101
- end
102
- state = states.lock(lock_clause).first!
103
- check_version(state)
104
- protect(record, state, claim: claim)
105
- if record.instance_variable_get(:@ruby_llm_upgrade_epoch) != state[:epoch].to_i
106
- raise ActiveRecord::ReadOnlyRecord, 'Reload the conversation after switching RubyLLM versions'
107
- end
108
- yield
109
- end
110
- end
111
- end
112
-
113
- def epoch
114
- available? ? current_state[:epoch].to_i : 0
115
- end
116
-
117
- private
118
-
119
- def lock_clause
120
- ActiveRecord::Base.connection.adapter_name == 'Mysql2' ? 'LOCK IN SHARE MODE' : 'FOR SHARE'
121
- end
122
-
123
- def guarded_operations(model, names, claim:)
124
- Module.new do
125
- names.each do |name|
126
- next unless model.method_defined?(name)
127
-
128
- define_method(name) do |*args, **options, &block|
129
- RubyLLMUpgrade.guard(self, claim: claim) { nil }
130
- super(*args, **options, &block)
131
- end
132
- end
133
- end
134
- end
135
-
136
- def protect(record, state, claim:)
137
- return if state.status == 'finalized'
138
-
139
- settings = state.settings
140
- return if version == 1 && !ownership_column?(settings)
141
-
142
- owners(record, settings).each { |chat| protect_owner(chat, claim: claim) }
143
- return unless claim && version == 2 && record.class.ruby_llm_upgrade_kind == :chat
144
-
145
- record[VERSION_COLUMN] = 2
146
- end
147
-
148
- def protect_owner(chat, claim:)
149
- chat.lock!
150
- if version == 1 && chat[VERSION_COLUMN] != 1
151
- raise ActiveRecord::ReadOnlyRecord, 'This conversation contains RubyLLM 2.0 changes'
152
- end
153
-
154
- chat.update!(VERSION_COLUMN => 2) if claim && version == 2 && chat[VERSION_COLUMN] != 2
155
- end
156
-
157
- def version
158
- Gem::Version.new(RubyLLM::VERSION).segments.first >= 2 ? 2 : 1
159
- end
160
-
161
- def available?
162
- ActiveRecord::Base.connection.table_exists?(TABLE)
163
- end
164
-
165
- def records(table)
166
- @records ||= {}
167
- model = @records[table] ||= Class.new(ActiveRecord::Base) do
168
- self.table_name = table
169
- self.inheritance_column = :_type_disabled
170
- end
171
- if !model.column_names.include?(VERSION_COLUMN) &&
172
- ActiveRecord::Base.connection.column_exists?(table, VERSION_COLUMN)
173
- model.reset_column_information
174
- end
175
- model
176
- end
177
-
178
- def current_state
179
- records(TABLE).uncached { records(TABLE).first! }
180
- end
181
-
182
- def ownership_column?(settings)
183
- ActiveRecord::Base.connection.column_exists?(settings.fetch('chat_table'), VERSION_COLUMN)
184
- end
185
-
186
- def check_version(state)
187
- return if version == 1 && state.active_version == 1 && state.settings['online'] && state.status == 'preparing'
188
- return if state.active_version == version && %w[active finalized].include?(state.status)
189
-
190
- raise ActiveRecord::ReadOnlyRecord,
191
- "RubyLLM #{version} cannot access conversations while the copy upgrade is " \
192
- "#{state.status} on #{state.active_version}"
193
- end
194
-
195
- def ensure_model_reference(chat, settings)
196
- if version == 1
197
- legacy_id = chat[settings.fetch('model_foreign_key')]
198
- legacy_models = records(settings.fetch('model_table'))
199
- legacy = legacy_models.find_by(legacy_models.primary_key => legacy_id)
200
- unless legacy
201
- chat.errors.add(:model, 'must identify an existing legacy model')
202
- raise ActiveRecord::RecordInvalid, chat
203
- end
204
-
205
- target = records('ruby_llm_models')
206
- model = target.find_by(provider: legacy.provider, model_id: legacy.model_id) ||
207
- target.create!(legacy.attributes.slice(*target.column_names).except(target.primary_key))
208
- chat[:ruby_llm_model_id] = model.id
209
- end
210
- return if chat[:ruby_llm_model_id]
211
-
212
- chat.errors.add(:model, 'must exist before saving the conversation')
213
- raise ActiveRecord::RecordInvalid, chat
214
- end
215
-
216
- def owners(record, settings)
217
- chats = records(settings.fetch('chat_table'))
218
- return chat_owner(record, chats) if record.class.ruby_llm_upgrade_kind == :chat
219
-
220
- values = owner_attributes(record, settings)
221
- ids = values.filter_map { |value| value[settings.fetch('chat_foreign_key')] }
222
- raise ActiveRecord::ReadOnlyRecord, 'The conversation no longer exists' if ids.empty?
223
-
224
- found = chats.where(chats.primary_key => ids).to_a
225
- raise ActiveRecord::ReadOnlyRecord, 'The conversation no longer exists' unless found.length == ids.uniq.length
226
-
227
- found
228
- end
229
-
230
- def chat_owner(record, chats)
231
- found = chats.where(chats.primary_key => record.id).to_a
232
- return found unless record.persisted? && found.empty?
233
-
234
- raise ActiveRecord::ReadOnlyRecord, 'The conversation no longer exists'
235
- end
236
-
237
- def owner_attributes(record, settings)
238
- values = [record.attributes]
239
- if record.persisted?
240
- stored = records(record.class.table_name).find(record.id)
241
- values << stored.attributes
242
- end
243
- if record.class.ruby_llm_upgrade_kind == :tool_call
244
- message_key = version == 1 ? settings.fetch('message_foreign_key') : 'message_id'
245
- messages = records(settings.fetch('message_table'))
246
- values = messages.where(messages.primary_key => values.map { |value| value[message_key] }).map(&:attributes)
247
- end
248
- values
249
- end
250
- end
251
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require Rails.root.join('app/models/concerns/ruby_llm_upgrade')
4
-
5
- Rails.application.config.to_prepare do
6
- require 'active_record/base'
7
- tool_call = if Gem::Version.new(RubyLLM::VERSION).segments.first >= 2
8
- RubyLLM::ActiveRecord::ToolCall
9
- else
10
- <%= v1_model_name(:tool_call) %>
11
- end
12
- RubyLLMUpgrade.install(chat: <%= chat_model_name %>, message: <%= message_model_name %>, tool_call: tool_call)
13
- end
@@ -1,359 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module Generators
5
- class UpgradeMigration # :nodoc: all
6
- TABLE = :ruby_llm_v2_upgrades
7
- VERSION_COLUMN = :ruby_llm_version
8
-
9
- def self.for(connection: ::ActiveRecord::Base.connection)
10
- require_relative 'online_copy_migration'
11
- OnlineCopyMigration.new(connection:)
12
- end
13
-
14
- def initialize(connection: ::ActiveRecord::Base.connection)
15
- @connection = connection
16
- end
17
-
18
- def prepare(settings)
19
- create_state_table unless @connection.table_exists?(TABLE)
20
- prepare_state(settings)
21
- yield if block_given?
22
- end
23
-
24
- def prepare_state(settings)
25
- existing = states.first
26
- if existing
27
- raise 'This database has a different RubyLLM copy upgrade' unless existing.settings == settings.stringify_keys
28
- unless existing.status == 'preparing' || (existing.active_version == 1 && existing.needs_reconcile)
29
- raise 'The copy upgrade is already active'
30
- end
31
-
32
- existing.update!(status: 'preparing')
33
- else
34
- states.create!(settings: settings.stringify_keys)
35
- end
36
- end
37
- private :prepare_state
38
-
39
- def online? = false
40
-
41
- def copy_table(source, target)
42
- return if @connection.table_exists?(target)
43
-
44
- source_records = records(source)
45
- create_copy_table(source, target)
46
- columns = source_records.column_names & records(target).column_names
47
- names = columns.map { |column| @connection.quote_column_name(column) }.join(', ')
48
- key = @connection.quote_column_name(source_records.primary_key)
49
- @connection.execute(<<~SQL)
50
- INSERT INTO #{@connection.quote_table_name(target)} (#{names})
51
- SELECT #{names} FROM #{@connection.quote_table_name(source)}
52
- WHERE #{key} NOT IN (SELECT #{key} FROM #{@connection.quote_table_name(target)})
53
- SQL
54
- @connection.reset_pk_sequence!(target) if @connection.respond_to?(:reset_pk_sequence!)
55
- end
56
-
57
- def copy_model_references
58
- records(configuration.fetch('chat_table')).find_each do |chat|
59
- model = resolve_model(chat[configuration.fetch('model_foreign_key')])
60
- chat.update!(ruby_llm_model_id: model&.id)
61
- end
62
- end
63
-
64
- def backfill(force: true)
65
- state = states.first!
66
- state.with_lock do
67
- unless %w[preparing reconciling].include?(state.status) ||
68
- (state.active_version == 1 && state.needs_reconcile)
69
- raise 'Expected an unfinished copy upgrade'
70
- end
71
- return if !force && !state.needs_reconcile
72
-
73
- state.update!(status: 'preparing', needs_reconcile: true)
74
- end
75
- reconcile
76
- state.update!(needs_reconcile: false)
77
- end
78
-
79
- def activate
80
- state = states.first!
81
- return if state.active_version == 2 && state.status == 'active'
82
-
83
- unless %w[preparing reconciling].include?(state.status) || (state.active_version == 1 && state.needs_reconcile)
84
- raise 'Expected an unfinished copy upgrade'
85
- end
86
-
87
- raise 'Run the copy backfill before activating 2.0' if state.needs_reconcile
88
-
89
- state.update!(active_version: 2, status: 'active', needs_reconcile: false)
90
- end
91
-
92
- def verify_settings(settings)
93
- return if configuration == settings.stringify_keys
94
-
95
- raise 'Use the same model mappings for every copy-upgrade phase'
96
- end
97
-
98
- def rollback
99
- state = states.first!
100
- if state.active_version == 1 && %w[preparing reconciling].include?(state.status)
101
- state.with_lock do
102
- state.update!(status: 'active', needs_reconcile: state.needs_reconcile || state.status == 'preparing')
103
- end
104
- return
105
- end
106
- transition(2, 1) { verify_completed_work }
107
- end
108
-
109
- def resume
110
- state = states.first!
111
- state.with_lock do
112
- unless state.active_version == 1 && %w[active reconciling].include?(state.status)
113
- raise 'Resume requires a copy upgrade running on 1.16'
114
- end
115
- raise 'Finish the initial copy migrations before resuming 2.0' if state.needs_reconcile
116
-
117
- state.update!(status: 'reconciling')
118
- end
119
- reconcile
120
- state.update!(active_version: 2, status: 'active')
121
- end
122
-
123
- def finalize
124
- state = states.first!
125
- state.with_lock do
126
- require_active_version(state, 2)
127
- verify_completed_work
128
- state.update!(status: 'finalized')
129
- end
130
- end
131
-
132
- def verify_cleanup
133
- state = states.first!
134
- return if state.active_version == 2 && state.status == 'finalized'
135
-
136
- raise 'Run ruby_llm:upgrade:finalize on 2.0 before copy-mode cleanup'
137
- end
138
-
139
- def cleanup
140
- verify_cleanup
141
- settings = configuration
142
- legacy_tables = settings.values_at('model_table', 'tool_call_table')
143
- remove_legacy_foreign_keys(legacy_tables)
144
- chats = settings.fetch('chat_table')
145
- column = settings.fetch('model_foreign_key')
146
- @connection.remove_column(chats, column) if @connection.column_exists?(chats, column)
147
- @connection.remove_column(chats, VERSION_COLUMN) if @connection.column_exists?(chats, VERSION_COLUMN)
148
- legacy_tables.reverse_each { |table| @connection.drop_table(table) if @connection.table_exists?(table) }
149
- end
150
-
151
- private
152
-
153
- def create_state_table
154
- if %i[ruby_llm_models ruby_llm_tool_calls].any? { |table| @connection.table_exists?(table) }
155
- raise 'Copy mode requires new RubyLLM supporting tables; do not reuse an earlier upgrade'
156
- end
157
-
158
- @connection.create_table(TABLE) do |table|
159
- table.integer :active_version, null: false, default: 1
160
- table.string :status, null: false, default: 'preparing'
161
- table.boolean :needs_reconcile, null: false, default: false
162
- table.bigint :epoch, null: false, default: 0
163
- table.json :settings, null: false
164
- end
165
- end
166
-
167
- def create_copy_table(source, target)
168
- primary = @connection.columns(source).find { |column| column.name == records(source).primary_key }
169
- @connection.create_table(target, primary_key: primary.name, id: copy_primary_options(primary)) do |table|
170
- @connection.columns(source).each do |column|
171
- next if column.name == primary.name
172
-
173
- table.column(column.name, column.type, **copy_column_options(source, column))
174
- end
175
- end
176
- end
177
-
178
- def copy_primary_options(primary)
179
- type = primary.type == :integer && primary.limit == 8 ? :bigint : primary.type
180
- unless %i[integer bigint uuid].include?(type)
181
- raise 'Copy mode requires integer or UUID model/tool-call primary keys'
182
- end
183
-
184
- options = { type: type }
185
- options[:default] = -> { primary.default_function } if type == :uuid && primary.default_function
186
- options
187
- end
188
-
189
- def copy_column_options(source, column)
190
- options = { null: column.null, limit: column.limit, precision: column.precision, scale: column.scale }.compact
191
- options[:default] = if column.default_function
192
- -> { column.default_function }
193
- else
194
- records(source).type_for_attribute(column.name).deserialize(column.default)
195
- end
196
- options
197
- end
198
-
199
- def remove_legacy_foreign_keys(legacy_tables)
200
- (@connection.tables - legacy_tables).each do |table|
201
- @connection.foreign_keys(table).each do |key|
202
- next unless legacy_tables.include?(key.to_table)
203
-
204
- allowed = table == configuration['chat_table'] && key.column == configuration['model_foreign_key']
205
- raise "Move the application foreign key #{table}.#{key.column} before cleanup" unless allowed
206
-
207
- @connection.remove_foreign_key(table, column: key.column)
208
- end
209
- end
210
- end
211
-
212
- def states
213
- records(TABLE)
214
- end
215
-
216
- def configuration
217
- @configuration ||= states.first!.settings
218
- end
219
-
220
- def records(table)
221
- @records ||= {}
222
- @records[table.to_s] ||= Class.new(::ActiveRecord::Base) do
223
- self.table_name = table.to_s
224
- self.inheritance_column = :_type_disabled
225
- end.tap(&:reset_column_information)
226
- end
227
-
228
- def transition(from, to)
229
- state = states.first!
230
- state.with_lock do
231
- require_active_version(state, from)
232
- yield
233
- state.update!(active_version: to)
234
- end
235
- end
236
-
237
- def require_active_version(state, version)
238
- return if state.active_version == version && state.status == 'active'
239
-
240
- raise "The copy upgrade must be active on version #{version}"
241
- end
242
-
243
- def verify_completed_work
244
- if @connection.table_exists?(:ruby_llm_batches) && records(:ruby_llm_batches).where(completed: false).exists?
245
- raise 'Finish or cancel pending RubyLLM batches before switching versions'
246
- end
247
-
248
- calls = records(:ruby_llm_tool_calls).where(message_type: configuration.fetch('message_class'), result_id: nil)
249
- return unless calls.exists?
250
-
251
- raise 'Finish pending tool calls and approvals before switching versions. ' \
252
- 'For abandoned legacy calls, see https://rubyllm.com/upgrading/#incomplete-tool-calls'
253
- end
254
-
255
- def reconcile
256
- settings = configuration
257
- chats = records(settings.fetch('chat_table'))
258
- messages = records(settings.fetch('message_table'))
259
- protected_chats = chats.where(VERSION_COLUMN => 2).select(chats.primary_key)
260
- protected_messages = messages.where(settings.fetch('chat_foreign_key') => protected_chats)
261
- .select(messages.primary_key)
262
- records(:ruby_llm_tool_calls).where(message_type: settings.fetch('message_class'))
263
- .where.not(message_id: protected_messages).delete_all
264
- records(:ruby_llm_usages).where(chat_type: settings.fetch('chat_class'))
265
- .where.not(chat_id: protected_chats).delete_all
266
- chats.where(VERSION_COLUMN => 1).find_each do |chat|
267
- chats.transaction { reconcile_chat(chat) }
268
- end
269
- end
270
-
271
- def reconcile_chat(chat)
272
- settings = configuration
273
- model = resolve_model(chat[settings.fetch('model_foreign_key')])
274
- raise "Chat #{chat.id} has no identifiable legacy model" unless model
275
-
276
- chat.update!(ruby_llm_model_id: model.id)
277
- messages = records(settings.fetch('message_table')).where(settings.fetch('chat_foreign_key') => chat.id)
278
- messages.find_each do |message|
279
- source = message.attributes
280
- raw = source['content_raw']
281
- message.update!(raw_content: raw, content: raw.present? ? JSON.generate(raw) : source['content'])
282
- reconcile_usage(chat, message, source, model)
283
- end
284
- reconcile_tools(messages)
285
- end
286
-
287
- def resolve_model(id)
288
- return unless id
289
-
290
- legacy = records(configuration.fetch('model_table')).find(id)
291
- target = records(:ruby_llm_models)
292
- target.find_by(provider: legacy.provider, model_id: legacy.model_id) ||
293
- target.create!(legacy.attributes.slice(*target.column_names).except(target.primary_key))
294
- end
295
-
296
- def reconcile_tools(messages)
297
- settings = configuration
298
- source = records(settings.fetch('tool_call_table'))
299
- target = records(:ruby_llm_tool_calls)
300
- ids = messages.select(messages.klass.primary_key)
301
- source.where(settings.fetch('message_foreign_key') => ids).find_each do |call|
302
- result = messages.find_by(settings.fetch('tool_call_foreign_key') => call.id)
303
- raise "Finish legacy tool call #{call.id} before resuming 2.0" unless result
304
-
305
- attributes = call.attributes.slice(*target.column_names).except(target.primary_key)
306
- attributes.merge!(
307
- message_type: settings.fetch('message_class'), message_id: call[settings.fetch('message_foreign_key')],
308
- result_type: settings.fetch('message_class'), result_id: result.id,
309
- tool_call_id: verified_tool_id(target, call)
310
- )
311
- target.create!(attributes)
312
- end
313
- end
314
-
315
- def verified_tool_id(target, call)
316
- if target.exists?(tool_call_id: call.tool_call_id)
317
- raise "Tool call #{call.id} repeats provider ID #{call.tool_call_id.inspect}; " \
318
- 'reconcile its history before retrying'
319
- end
320
-
321
- call.tool_call_id
322
- end
323
-
324
- def reconcile_usage(chat, message, source, chat_model)
325
- tokens = legacy_tokens(source)
326
- costs = legacy_costs(source)
327
- return unless source['role'] == 'assistant' || (tokens.values + costs.values).any?
328
-
329
- model = resolve_model(source[configuration.fetch('model_foreign_key')]) || chat_model
330
- raise "Message #{message.id} has no identifiable model" unless model
331
-
332
- attributes = tokens.merge(costs).merge(
333
- chat_type: configuration.fetch('chat_class'), chat_id: chat.id,
334
- message_type: configuration.fetch('message_class'), message_id: message.id,
335
- operation: 'chat', provider: model.provider, model: model.model_id, status: 'succeeded',
336
- created_at: source['created_at'], updated_at: source['updated_at']
337
- )
338
- records(:ruby_llm_usages).create!(attributes)
339
- end
340
-
341
- def legacy_tokens(source)
342
- {
343
- input_tokens: source['input_tokens'], output_tokens: source['output_tokens'],
344
- cache_read_tokens: source['cache_read_tokens'] || source['cached_tokens'],
345
- cache_write_tokens: source['cache_write_tokens'] || source['cache_creation_tokens'],
346
- thinking_tokens: source['thinking_tokens']
347
- }
348
- end
349
-
350
- def legacy_costs(source)
351
- details = source['cost_details'] || {}
352
- details = JSON.parse(details) if details.is_a?(String)
353
- costs = %w[input output cache_read cache_write thinking].to_h { |name| ["#{name}_cost", details[name]] }
354
- costs['total_cost'] = source['total_cost'] || details['total']
355
- costs
356
- end
357
- end
358
- end
359
- end