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
@@ -6,182 +6,25 @@ require_relative '../generator_helpers'
6
6
 
7
7
  module RubyLLM
8
8
  module Generators
9
- # Upgrades the schema generated by RubyLLM 1.16 to RubyLLM 2.0.
9
+ # Upgrades the schema of an application on RubyLLM 2.0 to 2.1.
10
10
  class UpgradeGenerator < Rails::Generators::Base
11
11
  include Rails::Generators::Migration
12
12
  include RubyLLM::Generators::GeneratorHelpers
13
13
 
14
- MODEL_MAPPING_DEFAULTS = {
15
- chat: 'Chat',
16
- message: 'Message',
17
- model: 'Model',
18
- tool_call: 'ToolCall'
19
- }.freeze
20
- MODEL_MAPPING_TYPES = MODEL_MAPPING_DEFAULTS.keys.map(&:to_s).freeze
21
-
22
14
  namespace 'ruby_llm:upgrade'
23
15
  source_root File.expand_path('templates', __dir__)
24
16
 
25
- class_option :phase, type: :string, enum: %w[prepare backfill finish cleanup],
26
- desc: 'Generate one upgrade phase; cleanup runs in a later deployment'
27
- class_option :mode, type: :string, enum: %w[rename copy], default: 'rename',
28
- desc: 'Copy prepares and backfills online, with protected 1.16 rollback until cleanup'
29
- class_option :discard_incomplete_tool_calls, type: :boolean, default: false,
30
- desc: 'Discard all incomplete legacy tool calls at copy finish'
31
-
32
- argument :model_mappings,
33
- type: :array,
34
- default: [],
35
- banner: 'chat:ChatName message:MessageName model:ModelName tool_call:ToolCallName'
17
+ argument :model_mappings, type: :array, default: [], banner: 'chat:ChatName message:MessageName'
36
18
 
37
- desc 'Upgrades a RubyLLM 1.16 Rails schema to 2.0'
19
+ desc 'Upgrades a RubyLLM 2.0 Rails schema to 2.1\n' \
20
+ 'Usage: bin/rails g ruby_llm:upgrade [chat:ChatName] [message:MessageName]'
38
21
 
39
22
  def self.next_migration_number(dirname)
40
23
  ::ActiveRecord::Generators::Base.next_migration_number(dirname)
41
24
  end
42
25
 
43
- def create_migration_files
44
- validate_cleanup_options
45
- parse_model_mappings(allowed_types: MODEL_MAPPING_TYPES, defaults: MODEL_MAPPING_DEFAULTS)
46
- say_status :models, resolved_model_mappings
47
-
48
- if copy_mode? && !options[:phase].in?(%w[backfill finish cleanup])
49
- template 'ruby_llm_upgrade.rb.tt', 'app/models/concerns/ruby_llm_upgrade.rb'
50
- template 'upgrade_initializer.rb.tt', 'config/initializers/ruby_llm_upgrade.rb'
51
- end
52
-
53
- phases = options[:phase] ? [options[:phase]] : %w[prepare backfill finish]
54
- phases.each do |phase|
55
- template = phase == 'backfill' ? 'backfill_v2_data' : "#{phase}_v2_upgrade"
56
- migration = phase == 'backfill' ? 'backfill_ruby_llm_v2_data' : "#{phase}_ruby_llm_v2_upgrade"
57
- migration_template "#{template}.rb.tt", "db/migrate/#{migration}.rb"
58
- end
59
- end
60
-
61
- def show_next_steps
62
- say_status :success, 'Upgrade prepared!', :green
63
- if copy_mode?
64
- say <<~INSTRUCTIONS
65
-
66
- Copy mode retains the legacy tables and a separate chat model reference.
67
- Install the generated RubyLLMUpgrade concern and initializer in BOTH the
68
- running 1.16 build and the 2.0 build BEFORE running prepare. Restart
69
- all affected 1.16 processes so they load the guards and explicit column
70
- selects. The guards protect whole conversations
71
- changed by 2.0. Direct SQL, bulk updates/deletes and attachment purges
72
- bypass these guards; review those application paths before upgrading.
73
-
74
- Rehearse prepare, backfill and finish on a production database copy.
75
- Run prepare and backfill from the 2.0 build while 1.16 serves traffic.
76
- Stop affected traffic, workers, scheduled jobs and retries BEFORE finish.
77
- Finish catches up intervening writes, validates and activates 2.0.
78
- Then load models and restart the 2.0 application.
79
- Keep the old model/tool-call classes in the 1.16 build.
80
-
81
- PostgreSQL, MySQL and SQLite support this workflow. Schema changes can
82
- block writes; SQLite also serializes backfill and app writes.
83
- Use a direct connection or session-mode pool for copy migrations.
84
- db:migrate runs ALL pending phases. Stop at the backfill timestamp to
85
- defer finish, or pause AI before running all three together.
86
-
87
- With all affected processes stopped, use the 2.0 build to run:
88
- bin/rails ruby_llm:upgrade:rollback # activates the protected 1.16 view
89
- bin/rails ruby_llm:upgrade:resume # reconciles 1.16 writes, activates 2.0
90
-
91
- Finish pending tool calls, approvals and batches before switching.
92
- Once rollback is no longer needed, run ruby_llm:upgrade:finalize, then
93
- generate --mode copy --phase cleanup in a later deployment. Keep the
94
- compatibility files until cleanup succeeds; remove them afterward.
95
-
96
- Pass the same --mode and model mappings to every generator invocation.
97
- Full guide: https://rubyllm.com/next/upgrading/
98
-
99
- INSTRUCTIONS
100
- return
101
- end
102
- say <<~INSTRUCTIONS
103
-
104
- These migrations expect the schema generated by RubyLLM 1.16. They move
105
- RubyLLM's model and tool-call records, preserve message content and usage,
106
- and retain the 1.x message columns until a later cleanup deployment.
107
-
108
- Next steps:
109
- 1. Review the generated migrations
110
- 2. Rehearse them on a recent production snapshot
111
- 3. Stop affected web processes, workers, scheduled jobs, and retries
112
- 4. Run preparation, backfill, and finish before reopening traffic
113
- 5. Run: bin/rails ruby_llm:load_models
114
- 6. Keep only the current declarations in your application models:
115
- #{chat_model_path}: #{acts_as_chat_declaration}
116
- #{message_model_path}: #{acts_as_message_declaration}
117
- 7. Remove the 1.x model and tool-call classes, config.model_registry_class,
118
- and config.use_new_acts_as
119
- 8. Update 1.x API calls using the upgrade guide
120
-
121
- Generate one phase with --phase prepare, backfill, or finish when the
122
- backfill needs to run outside your deployment's release phase.
123
- After verifying the upgrade, generate --phase cleanup in a later release.
124
- Pass the same model mappings to every invocation.
125
-
126
- The migrations are irreversible. Retained columns do not make a code
127
- rollback safe; recovery requires the previous database and application.
128
-
129
- Full upgrade guide:
130
- https://rubyllm.com/next/upgrading/
131
-
132
- INSTRUCTIONS
133
- end
134
-
135
- private
136
-
137
- def copy_mode? = options[:mode] == 'copy'
138
- def discard_incomplete_tool_calls? = options[:discard_incomplete_tool_calls]
139
-
140
- def validate_cleanup_options
141
- return unless discard_incomplete_tool_calls?
142
- return if copy_mode? && options[:phase].in?([nil, 'finish'])
143
-
144
- raise Thor::Error, 'Discard incomplete tool calls only with --mode copy and the finish phase'
145
- end
146
-
147
- def copy_upgrade_settings
148
- {
149
- chat_table: chat_table_name, message_table: message_table_name,
150
- model_table: v1_model_table_name, tool_call_table: v1_tool_call_table_name,
151
- chat_class: chat_model_name, message_class: message_model_name,
152
- model_foreign_key: v1_model_foreign_key, tool_call_foreign_key: v1_tool_call_foreign_key,
153
- chat_foreign_key: chat_foreign_key, message_foreign_key: message_foreign_key
154
- }
155
- end
156
-
157
- def prepare_migration_class_name = 'prepare_ruby_llm_v2_upgrade'.camelize
158
- def backfill_migration_class_name = 'backfill_ruby_llm_v2_data'.camelize
159
- def finish_migration_class_name = 'finish_ruby_llm_v2_upgrade'.camelize
160
- def cleanup_migration_class_name = 'cleanup_ruby_llm_v2_upgrade'.camelize
161
-
162
- def resolved_model_mappings
163
- @model_names.map do |type, model_name|
164
- "#{type}:#{model_name} -> #{table_name_for(model_name)}"
165
- end.join(', ')
166
- end
167
-
168
- def v1_model_name(type)
169
- @model_names.fetch(type)
170
- end
171
-
172
- def v1_model_table_name = table_name_for(v1_model_name(:model))
173
- def v1_tool_call_table_name = table_name_for(v1_model_name(:tool_call))
174
- def v1_model_foreign_key = "#{v1_model_table_name.singularize}_id"
175
- def v1_tool_call_foreign_key = "#{v1_tool_call_table_name.singularize}_id"
176
- def message_foreign_key = "#{message_table_name.singularize}_id"
177
- def chat_foreign_key = "#{chat_table_name.singularize}_id"
178
-
179
- def chat_model_path
180
- "app/models/#{chat_model_name.underscore}.rb"
181
- end
182
-
183
- def message_model_path
184
- "app/models/#{message_model_name.underscore}.rb"
26
+ def create_migration_file
27
+ migration_template 'upgrade_ruby_llm_to_2_1.rb.tt', 'db/migrate/upgrade_ruby_llm_to_2_1.rb'
185
28
  end
186
29
  end
187
30
  end
@@ -6,19 +6,55 @@ module RubyLLM
6
6
  module Accounting # :nodoc:
7
7
  # Internal accounting for physical provider attempts.
8
8
  class Usage # :nodoc: all
9
- def self.instrument(entry, config:) # :nodoc:
10
- RubyLLM.instrument(
11
- 'usage.ruby_llm',
12
- {
13
- operation: entry.operation,
14
- provider: entry.provider,
15
- model: entry.model,
16
- status: entry.status,
17
- tokens: entry.tokens,
18
- cost: entry.cost
19
- },
20
- config:
21
- )
9
+ OWNER_KEY = :ruby_llm_usage_owner
10
+
11
+ class << self
12
+ # Persists finished entries that no chat records, when the
13
+ # application provides a store, such as the Rails usage ledger.
14
+ attr_accessor :ledger
15
+
16
+ # The owner lives in fiber storage, which isolates concurrent
17
+ # fibers and threads and is inherited by those started inside it.
18
+ def owner
19
+ Fiber[OWNER_KEY]
20
+ end
21
+
22
+ def with_owner(owner)
23
+ previous = Fiber[OWNER_KEY]
24
+ Fiber[OWNER_KEY] = owner
25
+ yield
26
+ ensure
27
+ Fiber[OWNER_KEY] = previous
28
+ end
29
+
30
+ def owned_by(owner, &)
31
+ owner.nil? ? yield : with_owner(owner, &)
32
+ end
33
+
34
+ def record(entry)
35
+ ledger&.record(entry)
36
+ end
37
+
38
+ def report(entry, config:)
39
+ record(entry)
40
+ instrument(entry, config:)
41
+ end
42
+
43
+ def instrument(entry, config:)
44
+ RubyLLM.instrument(
45
+ 'usage.ruby_llm',
46
+ {
47
+ operation: entry.operation,
48
+ provider: entry.provider,
49
+ model: entry.model,
50
+ status: entry.status,
51
+ tokens: entry.tokens,
52
+ cost: entry.cost,
53
+ owner: entry.owner
54
+ },
55
+ config:
56
+ )
57
+ end
22
58
  end
23
59
 
24
60
  # Shared storage for result objects carrying attempt accounting.
@@ -48,10 +84,10 @@ module RubyLLM
48
84
 
49
85
  # One physical provider request attempt and its accounting facts.
50
86
  class Entry
51
- OPERATIONS = %i[chat embedding moderation image speech transcription ocr rerank].freeze
87
+ OPERATIONS = %i[chat embedding moderation image speech transcription ocr rerank judgment video research].freeze
52
88
  STATUSES = %i[pending succeeded failed cancelled].freeze
53
89
 
54
- attr_reader :operation, :provider, :model, :status, :tokens, :cost
90
+ attr_reader :operation, :provider, :model, :status, :tokens, :owner
55
91
  attr_accessor :message
56
92
 
57
93
  include Support::Inspectable
@@ -60,17 +96,23 @@ module RubyLLM
60
96
  { operation: operation, provider: provider, model: model, status: status, tokens: tokens }
61
97
  end
62
98
 
63
- def initialize(operation:, provider:, model:, status: :pending, tokens: nil, cost: nil, message: nil)
99
+ def initialize(operation:, provider:, model:, status: :pending, tokens: nil, cost: nil, message: nil,
100
+ owner: nil)
64
101
  @operation = operation.to_sym
65
102
  @provider = provider.to_s
66
103
  @model = model&.to_s
67
104
  @status = status.to_sym
68
105
  @tokens = tokens || Tokens.new
69
- @cost = cost || Cost.new(tokens: @tokens)
106
+ @cost = cost
70
107
  @message = message
108
+ @owner = owner
71
109
  validate!
72
110
  end
73
111
 
112
+ def cost
113
+ @cost ||= Cost.new(tokens:)
114
+ end
115
+
74
116
  def pending? = status == :pending
75
117
  def succeeded? = status == :succeeded
76
118
  def failed? = status == :failed
@@ -92,7 +134,7 @@ module RubyLLM
92
134
  def finish(status:, tokens: nil, cost: nil) # :nodoc:
93
135
  @status = status.to_sym
94
136
  @tokens = tokens || Tokens.new
95
- @cost = cost || Cost.new(tokens: @tokens)
137
+ @cost = cost
96
138
  validate!
97
139
  self
98
140
  end
@@ -115,15 +157,17 @@ module RubyLLM
115
157
  speech: :audio_tokens,
116
158
  transcription: :audio_tokens,
117
159
  ocr: :text_tokens,
118
- rerank: :embeddings
160
+ rerank: :embeddings,
161
+ judgment: :text_tokens
119
162
  }.freeze
120
163
 
121
164
  attr_reader :entries
122
165
 
123
- def initialize(operation:, provider:, model:, config:, on_finish: nil)
166
+ def initialize(operation:, provider:, model:, config:, on_finish: nil, model_id: nil)
124
167
  @operation = operation.to_sym
125
168
  @provider = provider
126
169
  @model_info = model
170
+ @model_id = model_id
127
171
  @config = config
128
172
  @on_finish = on_finish
129
173
  @entries = []
@@ -131,21 +175,25 @@ module RubyLLM
131
175
  end
132
176
 
133
177
  def start
134
- entry = Entry.new(operation: @operation, provider: @provider.slug, model: @model_info&.id)
178
+ entry = Entry.new(operation: @operation, provider: @provider.slug, model: @model_id || @model_info&.id,
179
+ owner: Usage.owner)
135
180
  @entries << entry
136
181
  @pending << entry
137
182
  entry
138
183
  end
139
184
 
140
185
  def observe(chunk)
186
+ observe_tokens(chunk.tokens) if chunk.respond_to?(:tokens)
187
+ end
188
+
189
+ # Keeps the usage a response reported for the current attempt, so an
190
+ # attempt that fails after the provider answered, such as a blocked
191
+ # one, still records what it billed.
192
+ def observe_tokens(tokens)
141
193
  entry = @pending.last
142
194
  return unless entry
143
- return unless chunk.respond_to?(:tokens)
144
195
 
145
- entry.finish(
146
- status: :pending,
147
- tokens: merge_stream_tokens(entry.tokens, chunk.tokens)
148
- )
196
+ entry.finish(status: :pending, tokens: entry.tokens.merge(tokens))
149
197
  end
150
198
 
151
199
  def fail_attempt(entry, error)
@@ -162,8 +210,9 @@ module RubyLLM
162
210
  def succeed(result)
163
211
  # A request that produced several results, like a multi-image
164
212
  # generation, is billed once: the first result carries the call.
165
- billed = result.is_a?(Array) ? result.first : result
166
- billed.model_info = message_model(billed) if billed.is_a?(Message)
213
+ results = result.is_a?(Array) ? result : [result]
214
+ results.each { |item| item.model_info = result_model(item) if item.respond_to?(:model_info=) }
215
+ billed = results.first
167
216
  pending = @pending.dup
168
217
  if pending.empty?
169
218
  attach_to_result(billed)
@@ -188,10 +237,10 @@ module RubyLLM
188
237
 
189
238
  private
190
239
 
191
- def message_model(message)
192
- return @model_info if message.model.nil? || message.model == @model_info&.id
240
+ def result_model(result)
241
+ return @model_info if result.model.nil? || result.model == @model_info&.id
193
242
 
194
- RubyLLM.models.find(message.model, provider: @provider.slug, config: @config)
243
+ RubyLLM.models.find(result.model, provider: @provider.slug, config: @config)
195
244
  rescue ModelNotFoundError
196
245
  @model_info
197
246
  end
@@ -225,29 +274,17 @@ module RubyLLM
225
274
  )
226
275
  entry.finish(status:, tokens:, cost:)
227
276
  @pending.delete(entry)
228
- @on_finish&.call(entry)
229
- instrument(entry)
230
- end
231
-
232
- def instrument(entry)
233
- Accounting::Usage.instrument(entry, config: @config)
277
+ if @on_finish
278
+ @on_finish.call(entry)
279
+ Accounting::Usage.instrument(entry, config: @config)
280
+ else
281
+ Accounting::Usage.report(entry, config: @config)
282
+ end
234
283
  end
235
284
 
236
285
  def attach_to_result(result)
237
286
  result&.ruby_llm_usage_entries = entries
238
287
  end
239
-
240
- def merge_stream_tokens(existing, incoming)
241
- Tokens.new(
242
- input: incoming.input.nil? ? existing.input : incoming.input,
243
- output: incoming.output.nil? ? existing.output : incoming.output,
244
- cache_read: incoming.cache_read.nil? ? existing.cache_read : incoming.cache_read,
245
- cache_write: incoming.cache_write.nil? ? existing.cache_write : incoming.cache_write,
246
- thinking: incoming.thinking.nil? ? existing.thinking : incoming.thinking,
247
- server_tool_use: incoming.server_tool_use.nil? ? existing.server_tool_use : incoming.server_tool_use,
248
- reported_cost: incoming.reported_cost.nil? ? existing.reported_cost : incoming.reported_cost
249
- )
250
- end
251
288
  end
252
289
  end
253
290
  end
@@ -104,6 +104,7 @@ module RubyLLM
104
104
  -> { chronological },
105
105
  as: :chat,
106
106
  class_name: 'RubyLLM::ActiveRecord::Usage',
107
+ inverse_of: :chat,
107
108
  dependent: :destroy
108
109
  end
109
110
 
@@ -120,6 +121,7 @@ module RubyLLM
120
121
  touch: touch_chat
121
122
 
122
123
  has_many :ruby_llm_tool_calls,
124
+ -> { order(:created_at, :id) },
123
125
  as: :message,
124
126
  class_name: 'RubyLLM::ActiveRecord::ToolCall',
125
127
  dependent: :destroy
@@ -133,6 +135,7 @@ module RubyLLM
133
135
  -> { chronological },
134
136
  as: :message,
135
137
  class_name: 'RubyLLM::ActiveRecord::Usage',
138
+ inverse_of: :message,
136
139
  dependent: :nullify
137
140
  end
138
141
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/core_ext/object/blank'
4
- require 'tempfile'
5
4
 
6
5
  module RubyLLM
7
6
  module ActiveRecord
@@ -12,7 +11,27 @@ module RubyLLM
12
11
  return unless message_record.respond_to?(:attachments)
13
12
 
14
13
  attachables = prepare_for_active_storage(attachments)
15
- message_record.attachments.attach(attachables) if attachables.any?
14
+ return if attachables.empty?
15
+
16
+ message_record.attachments.attach(attachables)
17
+ Support::Utils.to_safe_array(attachments).grep(RubyLLM::Attachment).each do |attachment|
18
+ attachment.provider_file_store ||= ProviderFile.store { persisted_blob_key(message_record, attachment) }
19
+ end
20
+ end
21
+
22
+ # Active Storage records the size and MD5 of every blob, so the
23
+ # message's blob of the same size holds the attachment's bytes. Only
24
+ # attachments of equal size need the MD5 to tell them apart.
25
+ def persisted_blob_key(message_record, attachment)
26
+ blob = active_storage_blobs(attachment.source) if attachment.active_storage?
27
+ return blob.key if blob.is_a?(ActiveStorage::Blob)
28
+
29
+ content = attachment.content
30
+ candidates = message_record.attachments.blobs.where(byte_size: content.bytesize).to_a
31
+ return candidates.first&.key unless candidates.many?
32
+
33
+ checksum = OpenSSL::Digest::MD5.base64digest(content)
34
+ candidates.find { |candidate| candidate.checksum == checksum }&.key
16
35
  end
17
36
 
18
37
  def prepare_for_active_storage(attachments)
@@ -41,7 +60,8 @@ module RubyLLM
41
60
  {
42
61
  io: StringIO.new(attachment.content),
43
62
  filename: attachment.filename,
44
- content_type: attachment.mime_type
63
+ content_type: attachment.mime_type,
64
+ metadata: { resolution: attachment.resolution&.to_s }.compact
45
65
  }
46
66
  end
47
67
  rescue StandardError => e
@@ -162,7 +182,23 @@ module RubyLLM
162
182
  end
163
183
 
164
184
  def active_storage_attachments?
165
- respond_to?(:attachments) && attachments.attached?
185
+ return false unless respond_to?(:attachments)
186
+
187
+ preloaded = preloaded_attachments
188
+ preloaded ? preloaded.any? : attachments.attached?
189
+ end
190
+
191
+ def preloaded_attachments
192
+ return if pending_attachment_change || !self.class.reflect_on_association(:attachments_attachments)
193
+
194
+ preloaded_records(:attachments_attachments)
195
+ end
196
+
197
+ # Building an association proxy evaluates the association's scope,
198
+ # which costs more than reading the records a preload already loaded.
199
+ def preloaded_records(name)
200
+ association = association(name)
201
+ association.target if association.loaded?
166
202
  end
167
203
 
168
204
  def collect_attachments(action_text_attachments)
@@ -194,12 +230,13 @@ module RubyLLM
194
230
  end
195
231
 
196
232
  def stored_attachment(attachment, attachable)
197
- if pending_upload_attachable?(attachable)
198
- pending_upload_attachment(attachable)
199
- else
200
- tempfile = download_attachment(attachment)
201
- RubyLLM::Attachment.new(tempfile, filename: attachment.filename.to_s)
202
- end
233
+ stored = if pending_upload_attachable?(attachable)
234
+ pending_upload_attachment(attachable)
235
+ else
236
+ RubyLLM::Attachment.new(attachment, resolution: attachment.metadata['resolution']&.to_sym)
237
+ end
238
+ stored.provider_file_store = ProviderFile.store { attachment.blob&.key }
239
+ stored
203
240
  end
204
241
 
205
242
  def pending_upload_attachable?(attachable)
@@ -225,7 +262,9 @@ module RubyLLM
225
262
 
226
263
  def pending_upload_attachment(attachable)
227
264
  if attachable.is_a?(Hash)
228
- RubyLLM::Attachment.new(attachment_hash_io(attachable), filename: attachment_hash_filename(attachable))
265
+ RubyLLM::Attachment.new(attachment_hash_io(attachable),
266
+ filename: attachment_hash_filename(attachable),
267
+ resolution: attachable.dig(:metadata, :resolution)&.to_sym)
229
268
  else
230
269
  RubyLLM::Attachment.new(attachable)
231
270
  end
@@ -245,20 +284,6 @@ module RubyLLM
245
284
  rescue NameError
246
285
  false
247
286
  end
248
-
249
- def download_attachment(attachment)
250
- ext = File.extname(attachment.filename.to_s)
251
- basename = File.basename(attachment.filename.to_s, ext)
252
- tempfile = Tempfile.new([basename, ext])
253
- tempfile.binmode
254
-
255
- attachment.download { |chunk| tempfile.write(chunk) }
256
-
257
- tempfile.flush
258
- tempfile.rewind
259
- @_tempfiles << tempfile
260
- tempfile
261
- end
262
287
  end
263
288
  end
264
289
  end
@@ -3,7 +3,7 @@
3
3
  module RubyLLM
4
4
  module ActiveRecord
5
5
  # RubyLLM's private persistence for provider-side batches.
6
- class Batch < ::ActiveRecord::Base # :nodoc:
6
+ class Batch < Record # :nodoc:
7
7
  self.table_name = 'ruby_llm_batches'
8
8
 
9
9
  STATUSES = %w[pending succeeded failed cancelled].freeze