ruby_llm 1.16.0 → 2.0.0.rc4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (480) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +108 -44
  4. data/exe/ruby_llm +8 -0
  5. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +0 -1
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +3 -43
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +11 -3
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +4 -4
  10. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +1 -1
  11. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +1 -1
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +2 -2
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +1 -1
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +6 -4
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +1 -1
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +2 -2
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +5 -6
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +2 -2
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +5 -5
  23. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +1 -1
  24. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +1 -1
  25. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +1 -1
  26. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +2 -2
  27. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +1 -1
  28. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  29. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +6 -4
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +1 -1
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +4 -6
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +2 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +5 -6
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +2 -2
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -3
  37. data/lib/generators/ruby_llm/generator_helpers.rb +106 -62
  38. data/lib/generators/ruby_llm/install/install_generator.rb +3 -11
  39. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +2 -0
  40. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +14 -8
  41. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +117 -0
  42. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +0 -8
  43. data/lib/generators/ruby_llm/provider/cli.rb +175 -0
  44. data/lib/generators/ruby_llm/provider/scaffold.rb +323 -0
  45. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +37 -0
  46. data/lib/generators/ruby_llm/provider/templates/core/provider_spec.rb.erb +34 -0
  47. data/lib/generators/ruby_llm/provider/templates/gem/archspec.rb.erb +14 -0
  48. data/lib/generators/ruby_llm/provider/templates/gem/bin/console.erb +15 -0
  49. data/lib/generators/ruby_llm/provider/templates/gem/bin/setup.erb +5 -0
  50. data/lib/generators/ruby_llm/provider/templates/gem/chat_schema_spec.rb.erb +30 -0
  51. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +35 -0
  52. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +22 -0
  53. data/lib/generators/ruby_llm/provider/templates/gem/chat_tools_spec.rb.erb +30 -0
  54. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +32 -0
  55. data/lib/generators/ruby_llm/provider/templates/gem/embedding_spec.rb.erb +49 -0
  56. data/lib/generators/ruby_llm/provider/templates/gem/env.erb +2 -0
  57. data/lib/generators/ruby_llm/provider/templates/gem/fixtures_gitkeep.erb +1 -0
  58. data/lib/generators/ruby_llm/provider/templates/gem/flayignore.erb +1 -0
  59. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +23 -0
  60. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +28 -0
  61. data/lib/generators/ruby_llm/provider/templates/gem/gitignore.erb +7 -0
  62. data/lib/generators/ruby_llm/provider/templates/gem/gitleaks.yml.erb +22 -0
  63. data/lib/generators/ruby_llm/provider/templates/gem/image_spec.rb.erb +23 -0
  64. data/lib/generators/ruby_llm/provider/templates/gem/license.erb +21 -0
  65. data/lib/generators/ruby_llm/provider/templates/gem/models.rb.erb +19 -0
  66. data/lib/generators/ruby_llm/provider/templates/gem/models_spec.rb.erb +17 -0
  67. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +22 -0
  68. data/lib/generators/ruby_llm/provider/templates/gem/overcommit.yml.erb +31 -0
  69. data/lib/generators/ruby_llm/provider/templates/gem/provider.rb.erb +52 -0
  70. data/lib/generators/ruby_llm/provider/templates/gem/provider_spec.rb.erb +38 -0
  71. data/lib/generators/ruby_llm/provider/templates/gem/rakefile.erb +38 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/readme.md.erb +50 -0
  73. data/lib/generators/ruby_llm/provider/templates/gem/release.yml.erb +36 -0
  74. data/lib/generators/ruby_llm/provider/templates/gem/rerank_spec.rb.erb +23 -0
  75. data/lib/generators/ruby_llm/provider/templates/gem/rspec.erb +2 -0
  76. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +29 -0
  77. data/lib/generators/ruby_llm/provider/templates/gem/rubyllm_configuration.rb.erb +14 -0
  78. data/lib/generators/ruby_llm/provider/templates/gem/spec_helper.rb.erb +26 -0
  79. data/lib/generators/ruby_llm/provider/templates/gem/speech_spec.rb.erb +25 -0
  80. data/lib/generators/ruby_llm/provider/templates/gem/vcr_configuration.rb.erb +16 -0
  81. data/lib/generators/ruby_llm/provider/templates/gem/video_spec.rb.erb +27 -0
  82. data/lib/generators/ruby_llm/schema/schema_generator.rb +5 -1
  83. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +1 -1
  84. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +13 -0
  85. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +21 -0
  86. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +3 -3
  87. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +7 -12
  88. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +5 -2
  89. data/lib/generators/ruby_llm/tool/tool_generator.rb +25 -59
  90. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +34 -0
  91. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +341 -0
  92. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +171 -0
  93. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +197 -0
  94. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +174 -0
  95. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +468 -0
  96. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +101 -0
  97. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +247 -0
  98. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +662 -0
  99. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +251 -0
  100. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
  101. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +188 -0
  102. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +359 -0
  103. data/lib/ruby_llm/accounting/usage.rb +254 -0
  104. data/lib/ruby_llm/active_record/acts_as.rb +94 -111
  105. data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
  106. data/lib/ruby_llm/active_record/batch.rb +97 -0
  107. data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
  108. data/lib/ruby_llm/active_record/message_methods.rb +113 -136
  109. data/lib/ruby_llm/active_record/model.rb +135 -0
  110. data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
  111. data/lib/ruby_llm/active_record/tool_call.rb +33 -0
  112. data/lib/ruby_llm/active_record/usage.rb +61 -0
  113. data/lib/ruby_llm/agent.rb +1066 -151
  114. data/lib/ruby_llm/aliases.json +291 -101
  115. data/lib/ruby_llm/attachment.rb +192 -48
  116. data/lib/ruby_llm/batch.rb +432 -0
  117. data/lib/ruby_llm/cached_content.rb +112 -0
  118. data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
  119. data/lib/ruby_llm/chat.rb +1131 -198
  120. data/lib/ruby_llm/chunk.rb +10 -0
  121. data/lib/ruby_llm/citation.rb +105 -0
  122. data/lib/ruby_llm/configuration.rb +261 -24
  123. data/lib/ruby_llm/context.rb +128 -6
  124. data/lib/ruby_llm/cost.rb +217 -80
  125. data/lib/ruby_llm/downloaded_file.rb +33 -0
  126. data/lib/ruby_llm/embedding.rb +121 -17
  127. data/lib/ruby_llm/embedding_request.rb +53 -0
  128. data/lib/ruby_llm/error.rb +159 -23
  129. data/lib/ruby_llm/fallback.rb +133 -0
  130. data/lib/ruby_llm/files/mime_type.rb +97 -0
  131. data/lib/ruby_llm/image.rb +153 -32
  132. data/lib/ruby_llm/message.rb +242 -54
  133. data/lib/ruby_llm/model/modalities.rb +17 -4
  134. data/lib/ruby_llm/model/pricing.rb +24 -5
  135. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  136. data/lib/ruby_llm/model/pricing_tier.rb +55 -15
  137. data/lib/ruby_llm/model.rb +244 -2
  138. data/lib/ruby_llm/models/aliases.rb +41 -0
  139. data/lib/ruby_llm/models/registry.rb +165 -0
  140. data/lib/ruby_llm/models/schema.rb +99 -0
  141. data/lib/ruby_llm/models.json +76038 -34173
  142. data/lib/ruby_llm/models.rb +477 -215
  143. data/lib/ruby_llm/moderation.rb +139 -26
  144. data/lib/ruby_llm/ocr.rb +112 -0
  145. data/lib/ruby_llm/prompt.rb +79 -0
  146. data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
  147. data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
  148. data/lib/ruby_llm/protocol/streaming.rb +230 -0
  149. data/lib/ruby_llm/protocol.rb +662 -0
  150. data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
  151. data/lib/ruby_llm/protocols/anthropic/chat.rb +543 -0
  152. data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
  153. data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
  154. data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
  155. data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
  156. data/lib/ruby_llm/protocols/anthropic/streaming.rb +167 -0
  157. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  158. data/lib/ruby_llm/protocols/anthropic.rb +101 -0
  159. data/lib/ruby_llm/protocols/azure/files.rb +16 -0
  160. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +110 -0
  161. data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
  162. data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
  163. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
  164. data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
  165. data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
  166. data/lib/ruby_llm/protocols/chat_completions/chat.rb +484 -0
  167. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
  168. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
  169. data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
  170. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/media.rb +30 -17
  171. data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
  172. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
  173. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +63 -0
  174. data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
  175. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
  176. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +15 -17
  177. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
  178. data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
  179. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
  180. data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
  181. data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
  182. data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
  183. data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
  184. data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
  185. data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
  186. data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
  187. data/lib/ruby_llm/protocols/cohere/rerank.rb +59 -0
  188. data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
  189. data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
  190. data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
  191. data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
  192. data/lib/ruby_llm/protocols/cohere.rb +21 -0
  193. data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
  194. data/lib/ruby_llm/protocols/converse/chat.rb +694 -0
  195. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  196. data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
  197. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -0
  198. data/lib/ruby_llm/protocols/converse.rb +54 -0
  199. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  200. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  201. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  202. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  203. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  204. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  208. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  209. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  210. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  211. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  212. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  213. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  214. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  215. data/lib/ruby_llm/protocols/files.rb +119 -0
  216. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  217. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  218. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  219. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +91 -0
  220. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  221. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  222. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  223. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  224. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  225. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  226. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  227. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  228. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  229. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  230. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  231. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  232. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  233. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  234. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  235. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  236. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  237. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  238. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  239. data/lib/ruby_llm/protocols/interactions/tools.rb +53 -0
  240. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  241. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  242. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  243. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  244. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  245. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  246. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  247. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  248. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  249. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  250. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  251. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  252. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  253. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  254. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  255. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  256. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  257. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  258. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  259. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  260. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  261. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  262. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  263. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  264. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  265. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  266. data/lib/ruby_llm/protocols/responses/chat.rb +466 -0
  267. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  268. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  269. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  270. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  271. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  272. data/lib/ruby_llm/protocols/responses.rb +35 -0
  273. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  274. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  275. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  276. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  277. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  278. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  279. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  280. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  281. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  282. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  283. data/lib/ruby_llm/provider.rb +560 -128
  284. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  285. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  286. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  287. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  288. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  289. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  290. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  291. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  292. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  293. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  294. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  295. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  296. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  297. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  298. data/lib/ruby_llm/providers/azure.rb +77 -78
  299. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  300. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  301. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  302. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  303. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  304. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  305. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  306. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  307. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  308. data/lib/ruby_llm/providers/cohere.rb +31 -0
  309. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  310. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  311. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  312. data/lib/ruby_llm/providers/deepseek/responses.rb +67 -0
  313. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  314. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  315. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  316. data/lib/ruby_llm/providers/gemini.rb +15 -8
  317. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  318. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  319. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  320. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  321. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  322. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  323. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  324. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  325. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  326. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  327. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  328. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  329. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  330. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  331. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  332. data/lib/ruby_llm/providers/mistral/ocr.rb +51 -0
  333. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  334. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  335. data/lib/ruby_llm/providers/mistral.rb +16 -4
  336. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  337. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  338. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  339. data/lib/ruby_llm/providers/ollama.rb +9 -8
  340. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  341. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  342. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  343. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  344. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  345. data/lib/ruby_llm/providers/openai.rb +92 -11
  346. data/lib/ruby_llm/providers/openrouter/chat.rb +126 -108
  347. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  348. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  349. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  350. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  351. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  352. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  353. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  354. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  355. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  356. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  357. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  358. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  359. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  360. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  361. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  362. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  363. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  364. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  365. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  366. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  367. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  368. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  369. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  370. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  371. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  372. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  373. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  374. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  375. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  376. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  377. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  378. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  379. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  380. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  381. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  382. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  383. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  384. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  385. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  386. data/lib/ruby_llm/providers/xai.rb +15 -5
  387. data/lib/ruby_llm/railtie.rb +7 -16
  388. data/lib/ruby_llm/rerank.rb +105 -0
  389. data/lib/ruby_llm/research_job.rb +241 -0
  390. data/lib/ruby_llm/search_results.rb +68 -0
  391. data/lib/ruby_llm/server_tool_call.rb +73 -0
  392. data/lib/ruby_llm/speech.rb +159 -0
  393. data/lib/ruby_llm/speech_chunk.rb +33 -0
  394. data/lib/ruby_llm/support/deprecator.rb +22 -0
  395. data/lib/ruby_llm/support/inspectable.rb +49 -0
  396. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  397. data/lib/ruby_llm/support/utils.rb +147 -0
  398. data/lib/ruby_llm/thinking.rb +127 -20
  399. data/lib/ruby_llm/tokenization.rb +59 -0
  400. data/lib/ruby_llm/tokens.rb +103 -33
  401. data/lib/ruby_llm/tool.rb +266 -91
  402. data/lib/ruby_llm/tool_call.rb +36 -3
  403. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  404. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  405. data/lib/ruby_llm/transcription.rb +138 -13
  406. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  407. data/lib/ruby_llm/transport/connection.rb +193 -0
  408. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  409. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  410. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  411. data/lib/ruby_llm/uploaded_file.rb +144 -0
  412. data/lib/ruby_llm/version.rb +2 -1
  413. data/lib/ruby_llm/video.rb +136 -0
  414. data/lib/ruby_llm/video_job.rb +150 -0
  415. data/lib/ruby_llm/workflow.rb +91 -0
  416. data/lib/ruby_llm.rb +380 -6
  417. data/lib/tasks/ruby_llm.rake +21 -16
  418. data/skills/rubyllm/SKILL.md +81 -0
  419. data/skills/rubyllm/agents/openai.yaml +4 -0
  420. metadata +344 -97
  421. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  422. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  423. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  424. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  425. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  426. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  427. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  428. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  429. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  430. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  431. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  432. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  433. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  434. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  435. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  436. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  437. data/lib/ruby_llm/aliases.rb +0 -41
  438. data/lib/ruby_llm/connection.rb +0 -159
  439. data/lib/ruby_llm/content.rb +0 -91
  440. data/lib/ruby_llm/deprecator.rb +0 -24
  441. data/lib/ruby_llm/error_middleware.rb +0 -81
  442. data/lib/ruby_llm/instrumentation.rb +0 -36
  443. data/lib/ruby_llm/mime_type.rb +0 -96
  444. data/lib/ruby_llm/model/info.rb +0 -164
  445. data/lib/ruby_llm/model_registry.rb +0 -39
  446. data/lib/ruby_llm/models_schema.json +0 -171
  447. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  448. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  449. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  450. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  451. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  452. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  453. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  454. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  455. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  456. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  457. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  458. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  459. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  460. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  461. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  462. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  463. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  464. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  465. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  466. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  467. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  468. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  469. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  470. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  471. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  472. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  473. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  474. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  475. data/lib/ruby_llm/streaming.rb +0 -179
  476. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  477. data/lib/ruby_llm/utils.rb +0 -130
  478. data/lib/tasks/models.rake +0 -593
  479. data/lib/tasks/release.rake +0 -94
  480. data/lib/tasks/vcr.rake +0 -124
@@ -0,0 +1,359 @@
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
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module RubyLLM
6
+ module Accounting # :nodoc:
7
+ # Internal accounting for physical provider attempts.
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
+ )
22
+ end
23
+
24
+ # Shared storage for result objects carrying attempt accounting.
25
+ module Result
26
+ def ruby_llm_usage_entries # :nodoc:
27
+ @ruby_llm_usage_entries ||= []
28
+ end
29
+
30
+ def ruby_llm_usage_entries=(entries) # :nodoc:
31
+ @ruby_llm_usage_entries = Array(entries)
32
+ @ruby_llm_usage_entries.each { |entry| entry.message = self if is_a?(Message) }
33
+ end
34
+
35
+ private
36
+
37
+ def ruby_llm_usage_tokens
38
+ Tokens.aggregate(ruby_llm_usage_entries.map(&:tokens))
39
+ end
40
+
41
+ def ruby_llm_usage_cost
42
+ Cost.aggregate(
43
+ ruby_llm_usage_entries.map(&:cost),
44
+ complete: ruby_llm_usage_entries.all?(&:cost_available?)
45
+ )
46
+ end
47
+ end
48
+
49
+ # One physical provider request attempt and its accounting facts.
50
+ class Entry
51
+ OPERATIONS = %i[chat embedding moderation image speech transcription ocr rerank].freeze
52
+ STATUSES = %i[pending succeeded failed cancelled].freeze
53
+
54
+ attr_reader :operation, :provider, :model, :status, :tokens, :cost
55
+ attr_accessor :message
56
+
57
+ include Support::Inspectable
58
+
59
+ def inspect_attributes # :nodoc:
60
+ { operation: operation, provider: provider, model: model, status: status, tokens: tokens }
61
+ end
62
+
63
+ def initialize(operation:, provider:, model:, status: :pending, tokens: nil, cost: nil, message: nil)
64
+ @operation = operation.to_sym
65
+ @provider = provider.to_s
66
+ @model = model&.to_s
67
+ @status = status.to_sym
68
+ @tokens = tokens || Tokens.new
69
+ @cost = cost || Cost.new(tokens: @tokens)
70
+ @message = message
71
+ validate!
72
+ end
73
+
74
+ def pending? = status == :pending
75
+ def succeeded? = status == :succeeded
76
+ def failed? = status == :failed
77
+ def cancelled? = status == :cancelled
78
+ def usage_available? = tokens.to_h.any?
79
+ def cost_available? = !cost.total.nil?
80
+
81
+ def to_h
82
+ {
83
+ operation: operation,
84
+ provider: provider,
85
+ model: model,
86
+ status: status,
87
+ tokens: tokens.to_h,
88
+ cost: cost.to_h
89
+ }
90
+ end
91
+
92
+ def finish(status:, tokens: nil, cost: nil) # :nodoc:
93
+ @status = status.to_sym
94
+ @tokens = tokens || Tokens.new
95
+ @cost = cost || Cost.new(tokens: @tokens)
96
+ validate!
97
+ self
98
+ end
99
+
100
+ private
101
+
102
+ def validate!
103
+ raise ArgumentError, "Unknown usage operation: #{operation.inspect}" unless OPERATIONS.include?(operation)
104
+ raise ArgumentError, "Unknown usage status: #{status.inspect}" unless STATUSES.include?(status)
105
+ end
106
+ end
107
+
108
+ # Tracks the physical transport attempts belonging to one operation.
109
+ class Tracker # :nodoc:
110
+ CATEGORY_BY_OPERATION = {
111
+ chat: :text_tokens,
112
+ embedding: :embeddings,
113
+ moderation: :text_tokens,
114
+ image: :images,
115
+ speech: :audio_tokens,
116
+ transcription: :audio_tokens,
117
+ ocr: :text_tokens,
118
+ rerank: :embeddings
119
+ }.freeze
120
+
121
+ attr_reader :entries
122
+
123
+ def initialize(operation:, provider:, model:, config:, on_finish: nil)
124
+ @operation = operation.to_sym
125
+ @provider = provider
126
+ @model_info = model
127
+ @config = config
128
+ @on_finish = on_finish
129
+ @entries = []
130
+ @pending = []
131
+ end
132
+
133
+ def start
134
+ entry = Entry.new(operation: @operation, provider: @provider.slug, model: @model_info&.id)
135
+ @entries << entry
136
+ @pending << entry
137
+ entry
138
+ end
139
+
140
+ def observe(chunk)
141
+ entry = @pending.last
142
+ return unless entry
143
+ return unless chunk.respond_to?(:tokens)
144
+
145
+ entry.finish(
146
+ status: :pending,
147
+ tokens: merge_stream_tokens(entry.tokens, chunk.tokens)
148
+ )
149
+ end
150
+
151
+ def fail_attempt(entry, error)
152
+ return unless entry&.pending?
153
+
154
+ status = error.is_a?(CancelledError) ? :cancelled : :failed
155
+ finish(entry, status:, tokens: failure_tokens(entry, error))
156
+ end
157
+
158
+ def fail_pending(error)
159
+ @pending.dup.each { |entry| fail_attempt(entry, error) }
160
+ end
161
+
162
+ def succeed(result)
163
+ # A request that produced several results, like a multi-image
164
+ # 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)
167
+ pending = @pending.dup
168
+ if pending.empty?
169
+ attach_to_result(billed)
170
+ return result
171
+ end
172
+
173
+ tokens = billed.respond_to?(:tokens) ? billed.tokens : Tokens.new
174
+ cost = billed.cost if billed.respond_to?(:cost)
175
+ pending[0...-1].each do |entry|
176
+ finish(entry, status: :succeeded, tokens: Tokens.new)
177
+ end
178
+ finish(pending.last, status: :succeeded, tokens:, cost:)
179
+ attach_to_result(billed)
180
+ result
181
+ end
182
+
183
+ def succeed_attempts(tokens:)
184
+ @pending.dup.zip(tokens).each do |entry, usage|
185
+ finish(entry, status: :succeeded, tokens: usage)
186
+ end
187
+ end
188
+
189
+ private
190
+
191
+ def message_model(message)
192
+ return @model_info if message.model.nil? || message.model == @model_info&.id
193
+
194
+ RubyLLM.models.find(message.model, provider: @provider.slug, config: @config)
195
+ rescue ModelNotFoundError
196
+ @model_info
197
+ end
198
+
199
+ # A request that never reached the provider, or that the provider
200
+ # refused before running it, cannot have been billed; possibly-billed
201
+ # failures keep their usage unknown.
202
+ def failure_tokens(entry, error)
203
+ return entry.tokens unless @model_info
204
+ return entry.tokens if entry.usage_available? || (!never_sent?(error) && !refused?(error))
205
+
206
+ Tokens.new(input: 0, output: 0)
207
+ end
208
+
209
+ def never_sent?(error)
210
+ error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
211
+ end
212
+
213
+ def refused?(error)
214
+ return false unless error.is_a?(Error) && error.response.respond_to?(:status)
215
+
216
+ (400..499).cover?(error.response.status)
217
+ end
218
+
219
+ def finish(entry, status:, tokens:, **details)
220
+ supplied = details[:cost]
221
+ cost = (supplied if supplied&.total) || Cost.new(
222
+ tokens:,
223
+ model: @model_info,
224
+ category: CATEGORY_BY_OPERATION.fetch(@operation)
225
+ )
226
+ entry.finish(status:, tokens:, cost:)
227
+ @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)
234
+ end
235
+
236
+ def attach_to_result(result)
237
+ result&.ruby_llm_usage_entries = entries
238
+ 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
+ end
252
+ end
253
+ end
254
+ end