ruby_llm 1.16.0 → 2.0.0.rc2

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 (475) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +85 -32
  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/templates/backfill_v2_data.rb.tt +461 -0
  91. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +101 -0
  92. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +215 -0
  93. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +660 -0
  94. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +222 -0
  95. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
  96. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +167 -0
  97. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +344 -0
  98. data/lib/ruby_llm/accounting/usage.rb +245 -0
  99. data/lib/ruby_llm/active_record/acts_as.rb +94 -111
  100. data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
  103. data/lib/ruby_llm/active_record/message_methods.rb +113 -136
  104. data/lib/ruby_llm/active_record/model.rb +135 -0
  105. data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
  106. data/lib/ruby_llm/active_record/tool_call.rb +33 -0
  107. data/lib/ruby_llm/active_record/usage.rb +61 -0
  108. data/lib/ruby_llm/agent.rb +1065 -151
  109. data/lib/ruby_llm/aliases.json +268 -100
  110. data/lib/ruby_llm/attachment.rb +187 -48
  111. data/lib/ruby_llm/batch.rb +432 -0
  112. data/lib/ruby_llm/cached_content.rb +112 -0
  113. data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
  114. data/lib/ruby_llm/chat.rb +1127 -198
  115. data/lib/ruby_llm/chunk.rb +10 -0
  116. data/lib/ruby_llm/citation.rb +105 -0
  117. data/lib/ruby_llm/configuration.rb +261 -24
  118. data/lib/ruby_llm/context.rb +128 -6
  119. data/lib/ruby_llm/cost.rb +217 -80
  120. data/lib/ruby_llm/downloaded_file.rb +33 -0
  121. data/lib/ruby_llm/embedding.rb +121 -17
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +159 -23
  124. data/lib/ruby_llm/fallback.rb +133 -0
  125. data/lib/ruby_llm/files/mime_type.rb +97 -0
  126. data/lib/ruby_llm/image.rb +153 -32
  127. data/lib/ruby_llm/message.rb +233 -54
  128. data/lib/ruby_llm/model/modalities.rb +17 -4
  129. data/lib/ruby_llm/model/pricing.rb +24 -5
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +55 -15
  132. data/lib/ruby_llm/model.rb +244 -2
  133. data/lib/ruby_llm/models/aliases.rb +41 -0
  134. data/lib/ruby_llm/models/registry.rb +165 -0
  135. data/lib/ruby_llm/models/schema.rb +99 -0
  136. data/lib/ruby_llm/models.json +73261 -33733
  137. data/lib/ruby_llm/models.rb +477 -215
  138. data/lib/ruby_llm/moderation.rb +139 -26
  139. data/lib/ruby_llm/ocr.rb +112 -0
  140. data/lib/ruby_llm/prompt.rb +79 -0
  141. data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
  142. data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
  143. data/lib/ruby_llm/protocol/streaming.rb +230 -0
  144. data/lib/ruby_llm/protocol.rb +662 -0
  145. data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
  146. data/lib/ruby_llm/protocols/anthropic/chat.rb +548 -0
  147. data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
  148. data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
  149. data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
  150. data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
  151. data/lib/ruby_llm/protocols/anthropic/streaming.rb +167 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  153. data/lib/ruby_llm/protocols/anthropic.rb +101 -0
  154. data/lib/ruby_llm/protocols/azure/files.rb +16 -0
  155. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +109 -0
  156. data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
  157. data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
  158. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
  159. data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
  160. data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
  161. data/lib/ruby_llm/protocols/chat_completions/chat.rb +493 -0
  162. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
  163. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
  164. data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
  165. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/media.rb +30 -17
  166. data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
  167. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
  168. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +56 -0
  169. data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
  170. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
  171. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +15 -17
  172. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
  173. data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
  174. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
  175. data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
  176. data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
  177. data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
  178. data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
  179. data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
  180. data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
  181. data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
  182. data/lib/ruby_llm/protocols/cohere/rerank.rb +52 -0
  183. data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
  184. data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
  185. data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
  186. data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
  187. data/lib/ruby_llm/protocols/cohere.rb +21 -0
  188. data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
  189. data/lib/ruby_llm/protocols/converse/chat.rb +695 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
  192. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -0
  193. data/lib/ruby_llm/protocols/converse.rb +54 -0
  194. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  195. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  196. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  197. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  198. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  199. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  208. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  209. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  210. data/lib/ruby_llm/protocols/files.rb +119 -0
  211. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  212. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  213. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  214. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  215. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  216. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  217. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  218. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  219. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  220. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  221. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  222. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  223. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  224. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  225. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  226. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  227. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  228. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  229. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  230. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  231. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  232. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  233. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  234. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  235. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  236. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  237. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  238. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  239. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  240. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  242. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  243. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  244. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  248. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  249. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  250. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  251. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  252. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  253. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  254. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  255. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  256. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  257. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  258. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  259. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  261. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  262. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  263. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  264. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  265. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  266. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  267. data/lib/ruby_llm/protocols/responses.rb +35 -0
  268. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  271. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  272. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  273. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  274. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  275. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  276. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  277. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  278. data/lib/ruby_llm/provider.rb +560 -128
  279. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  280. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  281. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  282. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  283. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  284. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  285. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  286. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  287. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  288. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  289. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  290. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  291. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  292. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  293. data/lib/ruby_llm/providers/azure.rb +77 -78
  294. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  295. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  300. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  301. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  302. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  303. data/lib/ruby_llm/providers/cohere.rb +31 -0
  304. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  305. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  306. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  307. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  308. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  309. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  310. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  311. data/lib/ruby_llm/providers/gemini.rb +15 -8
  312. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  313. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  314. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  315. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  316. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  317. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  318. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  319. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  320. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  321. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  322. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  323. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  324. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  325. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  326. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  327. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  328. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  329. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  330. data/lib/ruby_llm/providers/mistral.rb +16 -4
  331. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  332. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  333. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  334. data/lib/ruby_llm/providers/ollama.rb +9 -8
  335. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  336. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  337. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  338. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  339. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  340. data/lib/ruby_llm/providers/openai.rb +92 -11
  341. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -108
  342. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  343. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  344. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  345. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  346. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  347. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  348. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  349. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  350. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  351. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  352. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  353. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  354. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  355. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  356. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  357. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  359. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  360. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  361. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  362. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  363. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  364. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  365. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  366. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  367. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  368. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  369. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  370. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  371. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  372. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  373. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  374. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  375. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  376. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  377. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  378. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  379. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  380. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  381. data/lib/ruby_llm/providers/xai.rb +15 -5
  382. data/lib/ruby_llm/railtie.rb +7 -16
  383. data/lib/ruby_llm/rerank.rb +105 -0
  384. data/lib/ruby_llm/research_job.rb +241 -0
  385. data/lib/ruby_llm/search_results.rb +68 -0
  386. data/lib/ruby_llm/server_tool_call.rb +73 -0
  387. data/lib/ruby_llm/speech.rb +159 -0
  388. data/lib/ruby_llm/speech_chunk.rb +33 -0
  389. data/lib/ruby_llm/support/deprecator.rb +22 -0
  390. data/lib/ruby_llm/support/inspectable.rb +49 -0
  391. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  392. data/lib/ruby_llm/support/utils.rb +147 -0
  393. data/lib/ruby_llm/thinking.rb +127 -20
  394. data/lib/ruby_llm/tokenization.rb +59 -0
  395. data/lib/ruby_llm/tokens.rb +103 -33
  396. data/lib/ruby_llm/tool.rb +266 -91
  397. data/lib/ruby_llm/tool_call.rb +36 -3
  398. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  399. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  400. data/lib/ruby_llm/transcription.rb +138 -13
  401. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  402. data/lib/ruby_llm/transport/connection.rb +193 -0
  403. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  404. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  405. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  406. data/lib/ruby_llm/uploaded_file.rb +144 -0
  407. data/lib/ruby_llm/version.rb +2 -1
  408. data/lib/ruby_llm/video.rb +136 -0
  409. data/lib/ruby_llm/video_job.rb +150 -0
  410. data/lib/ruby_llm/workflow.rb +91 -0
  411. data/lib/ruby_llm.rb +380 -6
  412. data/lib/tasks/ruby_llm.rake +21 -16
  413. data/skills/rubyllm/SKILL.md +81 -0
  414. data/skills/rubyllm/agents/openai.yaml +4 -0
  415. metadata +339 -97
  416. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  417. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  418. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  419. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  422. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  424. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  426. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  428. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  429. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  430. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  431. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  432. data/lib/ruby_llm/aliases.rb +0 -41
  433. data/lib/ruby_llm/connection.rb +0 -159
  434. data/lib/ruby_llm/content.rb +0 -91
  435. data/lib/ruby_llm/deprecator.rb +0 -24
  436. data/lib/ruby_llm/error_middleware.rb +0 -81
  437. data/lib/ruby_llm/instrumentation.rb +0 -36
  438. data/lib/ruby_llm/mime_type.rb +0 -96
  439. data/lib/ruby_llm/model/info.rb +0 -164
  440. data/lib/ruby_llm/model_registry.rb +0 -39
  441. data/lib/ruby_llm/models_schema.json +0 -171
  442. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  443. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  444. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  445. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  446. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  447. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  448. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  449. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  450. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  451. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  452. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  453. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  454. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  455. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  456. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  457. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  458. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  459. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  460. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  461. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  462. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  463. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  464. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  465. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  466. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  467. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  468. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  469. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  470. data/lib/ruby_llm/streaming.rb +0 -179
  471. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  472. data/lib/ruby_llm/utils.rb +0 -130
  473. data/lib/tasks/models.rake +0 -593
  474. data/lib/tasks/release.rake +0 -94
  475. data/lib/tasks/vcr.rake +0 -124
@@ -0,0 +1,344 @@
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 initialize(connection: ::ActiveRecord::Base.connection)
10
+ @connection = connection
11
+ end
12
+
13
+ def prepare(settings)
14
+ create_state_table unless @connection.table_exists?(TABLE)
15
+ existing = states.first
16
+ if existing
17
+ raise 'This database has a different RubyLLM copy upgrade' unless existing.settings == settings.stringify_keys
18
+ unless existing.status == 'preparing' || (existing.active_version == 1 && existing.needs_reconcile)
19
+ raise 'The copy upgrade is already active'
20
+ end
21
+
22
+ existing.update!(status: 'preparing')
23
+ else
24
+ states.create!(settings: settings.stringify_keys)
25
+ end
26
+ end
27
+
28
+ def copy_table(source, target)
29
+ return if @connection.table_exists?(target)
30
+
31
+ source_records = records(source)
32
+ create_copy_table(source, target)
33
+ columns = source_records.column_names & records(target).column_names
34
+ names = columns.map { |column| @connection.quote_column_name(column) }.join(', ')
35
+ key = @connection.quote_column_name(source_records.primary_key)
36
+ @connection.execute(<<~SQL)
37
+ INSERT INTO #{@connection.quote_table_name(target)} (#{names})
38
+ SELECT #{names} FROM #{@connection.quote_table_name(source)}
39
+ WHERE #{key} NOT IN (SELECT #{key} FROM #{@connection.quote_table_name(target)})
40
+ SQL
41
+ @connection.reset_pk_sequence!(target) if @connection.respond_to?(:reset_pk_sequence!)
42
+ end
43
+
44
+ def copy_model_references
45
+ records(configuration.fetch('chat_table')).find_each do |chat|
46
+ model = resolve_model(chat[configuration.fetch('model_foreign_key')])
47
+ chat.update!(ruby_llm_model_id: model&.id)
48
+ end
49
+ end
50
+
51
+ def backfill(force: true)
52
+ state = states.first!
53
+ state.with_lock do
54
+ unless %w[preparing reconciling].include?(state.status) ||
55
+ (state.active_version == 1 && state.needs_reconcile)
56
+ raise 'Expected an unfinished copy upgrade'
57
+ end
58
+ return if !force && !state.needs_reconcile
59
+
60
+ state.update!(status: 'preparing', needs_reconcile: true)
61
+ end
62
+ reconcile
63
+ state.update!(needs_reconcile: false)
64
+ end
65
+
66
+ def activate
67
+ state = states.first!
68
+ return if state.active_version == 2 && state.status == 'active'
69
+
70
+ unless %w[preparing reconciling].include?(state.status) || (state.active_version == 1 && state.needs_reconcile)
71
+ raise 'Expected an unfinished copy upgrade'
72
+ end
73
+
74
+ raise 'Run the copy backfill before activating 2.0' if state.needs_reconcile
75
+
76
+ state.update!(active_version: 2, status: 'active', needs_reconcile: false)
77
+ end
78
+
79
+ def verify_settings(settings)
80
+ return if configuration == settings.stringify_keys
81
+
82
+ raise 'Use the same model mappings for every copy-upgrade phase'
83
+ end
84
+
85
+ def rollback
86
+ state = states.first!
87
+ if state.active_version == 1 && %w[preparing reconciling].include?(state.status)
88
+ state.with_lock do
89
+ state.update!(status: 'active', needs_reconcile: state.needs_reconcile || state.status == 'preparing')
90
+ end
91
+ return
92
+ end
93
+ transition(2, 1) { verify_completed_work }
94
+ end
95
+
96
+ def resume
97
+ state = states.first!
98
+ state.with_lock do
99
+ unless state.active_version == 1 && %w[active reconciling].include?(state.status)
100
+ raise 'Resume requires a copy upgrade running on 1.16'
101
+ end
102
+ raise 'Finish the initial copy migrations before resuming 2.0' if state.needs_reconcile
103
+
104
+ state.update!(status: 'reconciling')
105
+ end
106
+ reconcile
107
+ state.update!(active_version: 2, status: 'active')
108
+ end
109
+
110
+ def finalize
111
+ state = states.first!
112
+ state.with_lock do
113
+ require_active_version(state, 2)
114
+ verify_completed_work
115
+ state.update!(status: 'finalized')
116
+ end
117
+ end
118
+
119
+ def verify_cleanup
120
+ state = states.first!
121
+ return if state.active_version == 2 && state.status == 'finalized'
122
+
123
+ raise 'Run ruby_llm:upgrade:finalize on 2.0 before copy-mode cleanup'
124
+ end
125
+
126
+ def cleanup
127
+ verify_cleanup
128
+ settings = configuration
129
+ legacy_tables = settings.values_at('model_table', 'tool_call_table')
130
+ remove_legacy_foreign_keys(legacy_tables)
131
+ chats = settings.fetch('chat_table')
132
+ column = settings.fetch('model_foreign_key')
133
+ @connection.remove_column(chats, column) if @connection.column_exists?(chats, column)
134
+ @connection.remove_column(chats, VERSION_COLUMN) if @connection.column_exists?(chats, VERSION_COLUMN)
135
+ legacy_tables.reverse_each { |table| @connection.drop_table(table) if @connection.table_exists?(table) }
136
+ end
137
+
138
+ private
139
+
140
+ def create_state_table
141
+ if %i[ruby_llm_models ruby_llm_tool_calls].any? { |table| @connection.table_exists?(table) }
142
+ raise 'Copy mode requires new RubyLLM supporting tables; do not reuse an earlier upgrade'
143
+ end
144
+
145
+ @connection.create_table(TABLE) do |table|
146
+ table.integer :active_version, null: false, default: 1
147
+ table.string :status, null: false, default: 'preparing'
148
+ table.boolean :needs_reconcile, null: false, default: false
149
+ table.json :settings, null: false
150
+ end
151
+ end
152
+
153
+ def create_copy_table(source, target)
154
+ primary = @connection.columns(source).find { |column| column.name == records(source).primary_key }
155
+ @connection.create_table(target, primary_key: primary.name, id: copy_primary_options(primary)) do |table|
156
+ @connection.columns(source).each do |column|
157
+ next if column.name == primary.name
158
+
159
+ table.column(column.name, column.type, **copy_column_options(source, column))
160
+ end
161
+ end
162
+ end
163
+
164
+ def copy_primary_options(primary)
165
+ type = primary.type == :integer && primary.limit == 8 ? :bigint : primary.type
166
+ unless %i[integer bigint uuid].include?(type)
167
+ raise 'Copy mode requires integer or UUID model/tool-call primary keys'
168
+ end
169
+
170
+ options = { type: type }
171
+ options[:default] = -> { primary.default_function } if type == :uuid && primary.default_function
172
+ options
173
+ end
174
+
175
+ def copy_column_options(source, column)
176
+ options = { null: column.null, limit: column.limit, precision: column.precision, scale: column.scale }.compact
177
+ options[:default] = if column.default_function
178
+ -> { column.default_function }
179
+ else
180
+ records(source).type_for_attribute(column.name).deserialize(column.default)
181
+ end
182
+ options
183
+ end
184
+
185
+ def remove_legacy_foreign_keys(legacy_tables)
186
+ (@connection.tables - legacy_tables).each do |table|
187
+ @connection.foreign_keys(table).each do |key|
188
+ next unless legacy_tables.include?(key.to_table)
189
+
190
+ allowed = table == configuration['chat_table'] && key.column == configuration['model_foreign_key']
191
+ raise "Move the application foreign key #{table}.#{key.column} before cleanup" unless allowed
192
+
193
+ @connection.remove_foreign_key(table, column: key.column)
194
+ end
195
+ end
196
+ end
197
+
198
+ def states
199
+ records(TABLE)
200
+ end
201
+
202
+ def configuration
203
+ @configuration ||= states.first!.settings
204
+ end
205
+
206
+ def records(table)
207
+ @records ||= {}
208
+ @records[table.to_s] ||= Class.new(::ActiveRecord::Base) do
209
+ self.table_name = table.to_s
210
+ self.inheritance_column = :_type_disabled
211
+ end.tap(&:reset_column_information)
212
+ end
213
+
214
+ def transition(from, to)
215
+ state = states.first!
216
+ state.with_lock do
217
+ require_active_version(state, from)
218
+ yield
219
+ state.update!(active_version: to)
220
+ end
221
+ end
222
+
223
+ def require_active_version(state, version)
224
+ return if state.active_version == version && state.status == 'active'
225
+
226
+ raise "The copy upgrade must be active on version #{version}"
227
+ end
228
+
229
+ def verify_completed_work
230
+ if @connection.table_exists?(:ruby_llm_batches) && records(:ruby_llm_batches).where(completed: false).exists?
231
+ raise 'Finish or cancel pending RubyLLM batches before switching versions'
232
+ end
233
+
234
+ calls = records(:ruby_llm_tool_calls).where(message_type: configuration.fetch('message_class'), result_id: nil)
235
+ return unless calls.exists?
236
+
237
+ raise 'Finish pending tool calls and approvals before switching versions'
238
+ end
239
+
240
+ def reconcile
241
+ settings = configuration
242
+ chats = records(settings.fetch('chat_table'))
243
+ messages = records(settings.fetch('message_table'))
244
+ protected_chats = chats.where(VERSION_COLUMN => 2).select(chats.primary_key)
245
+ protected_messages = messages.where(settings.fetch('chat_foreign_key') => protected_chats)
246
+ .select(messages.primary_key)
247
+ records(:ruby_llm_tool_calls).where(message_type: settings.fetch('message_class'))
248
+ .where.not(message_id: protected_messages).delete_all
249
+ records(:ruby_llm_usages).where(chat_type: settings.fetch('chat_class'))
250
+ .where.not(chat_id: protected_chats).delete_all
251
+ chats.where(VERSION_COLUMN => 1).find_each do |chat|
252
+ chats.transaction { reconcile_chat(chat) }
253
+ end
254
+ end
255
+
256
+ def reconcile_chat(chat)
257
+ settings = configuration
258
+ model = resolve_model(chat[settings.fetch('model_foreign_key')])
259
+ raise "Chat #{chat.id} has no identifiable legacy model" unless model
260
+
261
+ chat.update!(ruby_llm_model_id: model.id)
262
+ messages = records(settings.fetch('message_table')).where(settings.fetch('chat_foreign_key') => chat.id)
263
+ messages.find_each do |message|
264
+ source = message.attributes
265
+ raw = source['content_raw']
266
+ message.update!(raw_content: raw, content: raw.present? ? JSON.generate(raw) : source['content'])
267
+ reconcile_usage(chat, message, source, model)
268
+ end
269
+ reconcile_tools(messages)
270
+ end
271
+
272
+ def resolve_model(id)
273
+ return unless id
274
+
275
+ legacy = records(configuration.fetch('model_table')).find(id)
276
+ target = records(:ruby_llm_models)
277
+ target.find_by(provider: legacy.provider, model_id: legacy.model_id) ||
278
+ target.create!(legacy.attributes.slice(*target.column_names).except(target.primary_key))
279
+ end
280
+
281
+ def reconcile_tools(messages)
282
+ settings = configuration
283
+ source = records(settings.fetch('tool_call_table'))
284
+ target = records(:ruby_llm_tool_calls)
285
+ ids = messages.select(messages.klass.primary_key)
286
+ source.where(settings.fetch('message_foreign_key') => ids).find_each do |call|
287
+ result = messages.find_by(settings.fetch('tool_call_foreign_key') => call.id)
288
+ raise "Finish legacy tool call #{call.id} before resuming 2.0" unless result
289
+
290
+ attributes = call.attributes.slice(*target.column_names).except(target.primary_key)
291
+ attributes.merge!(
292
+ message_type: settings.fetch('message_class'), message_id: call[settings.fetch('message_foreign_key')],
293
+ result_type: settings.fetch('message_class'), result_id: result.id,
294
+ tool_call_id: verified_tool_id(target, call)
295
+ )
296
+ target.create!(attributes)
297
+ end
298
+ end
299
+
300
+ def verified_tool_id(target, call)
301
+ if target.exists?(tool_call_id: call.tool_call_id)
302
+ raise "Tool call #{call.id} repeats provider ID #{call.tool_call_id.inspect}; " \
303
+ 'reconcile its history before retrying'
304
+ end
305
+
306
+ call.tool_call_id
307
+ end
308
+
309
+ def reconcile_usage(chat, message, source, chat_model)
310
+ tokens = legacy_tokens(source)
311
+ costs = legacy_costs(source)
312
+ return unless source['role'] == 'assistant' || (tokens.values + costs.values).any?
313
+
314
+ model = resolve_model(source[configuration.fetch('model_foreign_key')]) || chat_model
315
+ raise "Message #{message.id} has no identifiable model" unless model
316
+
317
+ attributes = tokens.merge(costs).merge(
318
+ chat_type: configuration.fetch('chat_class'), chat_id: chat.id,
319
+ message_type: configuration.fetch('message_class'), message_id: message.id,
320
+ operation: 'chat', provider: model.provider, model: model.model_id, status: 'succeeded',
321
+ created_at: source['created_at'], updated_at: source['updated_at']
322
+ )
323
+ records(:ruby_llm_usages).create!(attributes)
324
+ end
325
+
326
+ def legacy_tokens(source)
327
+ {
328
+ input_tokens: source['input_tokens'], output_tokens: source['output_tokens'],
329
+ cache_read_tokens: source['cache_read_tokens'] || source['cached_tokens'],
330
+ cache_write_tokens: source['cache_write_tokens'] || source['cache_creation_tokens'],
331
+ thinking_tokens: source['thinking_tokens']
332
+ }
333
+ end
334
+
335
+ def legacy_costs(source)
336
+ details = source['cost_details'] || {}
337
+ details = JSON.parse(details) if details.is_a?(String)
338
+ costs = %w[input output cache_read cache_write thinking].to_h { |name| ["#{name}_cost", details[name]] }
339
+ costs['total_cost'] = source['total_cost'] || details['total']
340
+ costs
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,245 @@
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
+ pending = @pending.dup
167
+ if pending.empty?
168
+ attach_to_result(billed)
169
+ return result
170
+ end
171
+
172
+ tokens = billed.respond_to?(:tokens) ? billed.tokens : Tokens.new
173
+ cost = billed.cost if billed.respond_to?(:cost)
174
+ pending[0...-1].each do |entry|
175
+ finish(entry, status: :succeeded, tokens: Tokens.new)
176
+ end
177
+ finish(pending.last, status: :succeeded, tokens:, cost:)
178
+ attach_to_result(billed)
179
+ result
180
+ end
181
+
182
+ def succeed_attempts(tokens:)
183
+ @pending.dup.zip(tokens).each do |entry, usage|
184
+ finish(entry, status: :succeeded, tokens: usage)
185
+ end
186
+ end
187
+
188
+ private
189
+
190
+ # A request that never reached the provider, or that the provider
191
+ # refused before running it, cannot have been billed; possibly-billed
192
+ # failures keep their usage unknown.
193
+ def failure_tokens(entry, error)
194
+ return entry.tokens unless @model_info
195
+ return entry.tokens if entry.usage_available? || (!never_sent?(error) && !refused?(error))
196
+
197
+ Tokens.new(input: 0, output: 0)
198
+ end
199
+
200
+ def never_sent?(error)
201
+ error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
202
+ end
203
+
204
+ def refused?(error)
205
+ return false unless error.is_a?(Error) && error.response.respond_to?(:status)
206
+
207
+ (400..499).cover?(error.response.status)
208
+ end
209
+
210
+ def finish(entry, status:, tokens:, **details)
211
+ supplied = details[:cost]
212
+ cost = (supplied if supplied&.total) || Cost.new(
213
+ tokens:,
214
+ model: @model_info,
215
+ category: CATEGORY_BY_OPERATION.fetch(@operation)
216
+ )
217
+ entry.finish(status:, tokens:, cost:)
218
+ @pending.delete(entry)
219
+ @on_finish&.call(entry)
220
+ instrument(entry)
221
+ end
222
+
223
+ def instrument(entry)
224
+ Accounting::Usage.instrument(entry, config: @config)
225
+ end
226
+
227
+ def attach_to_result(result)
228
+ result&.ruby_llm_usage_entries = entries
229
+ end
230
+
231
+ def merge_stream_tokens(existing, incoming)
232
+ Tokens.new(
233
+ input: incoming.input.nil? ? existing.input : incoming.input,
234
+ output: incoming.output.nil? ? existing.output : incoming.output,
235
+ cache_read: incoming.cache_read.nil? ? existing.cache_read : incoming.cache_read,
236
+ cache_write: incoming.cache_write.nil? ? existing.cache_write : incoming.cache_write,
237
+ thinking: incoming.thinking.nil? ? existing.thinking : incoming.thinking,
238
+ server_tool_use: incoming.server_tool_use.nil? ? existing.server_tool_use : incoming.server_tool_use,
239
+ reported_cost: incoming.reported_cost.nil? ? existing.reported_cost : incoming.reported_cost
240
+ )
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end