ruby_llm 1.16.0 → 2.0.0.rc1

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 (474) 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 -6
  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 +25 -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 +100 -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 +180 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +823 -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 +71172 -33253
  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 +540 -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 +166 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  153. data/lib/ruby_llm/protocols/anthropic.rb +100 -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 +685 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +424 -0
  192. data/lib/ruby_llm/protocols/converse.rb +54 -0
  193. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  194. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  195. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  196. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  197. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  198. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  199. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  208. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  209. data/lib/ruby_llm/protocols/files.rb +119 -0
  210. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  211. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  212. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  213. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  214. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  215. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  216. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  217. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  218. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  219. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  220. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  221. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  222. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  223. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  224. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  225. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  226. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  227. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  228. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  229. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  230. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  231. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  232. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  233. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  234. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  235. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  236. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  237. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  238. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  239. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  240. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  242. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  243. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  244. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  248. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  249. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  250. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  251. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  252. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  253. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  254. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  255. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  256. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  257. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  258. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  259. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  261. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  262. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  263. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  264. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  265. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  266. data/lib/ruby_llm/protocols/responses.rb +35 -0
  267. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  268. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  271. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  272. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  273. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  274. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  275. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  276. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  277. data/lib/ruby_llm/provider.rb +560 -128
  278. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  279. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  280. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  281. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  282. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  283. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  284. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  285. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  286. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  287. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  288. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  289. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  290. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  291. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  292. data/lib/ruby_llm/providers/azure.rb +77 -78
  293. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  294. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  295. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  300. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  301. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  302. data/lib/ruby_llm/providers/cohere.rb +31 -0
  303. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  304. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  305. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  306. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  307. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  308. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  309. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  310. data/lib/ruby_llm/providers/gemini.rb +15 -8
  311. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  312. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  313. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  314. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  315. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  316. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  317. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  318. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  319. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  320. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  321. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  322. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  323. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  324. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  325. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  326. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  327. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  328. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  329. data/lib/ruby_llm/providers/mistral.rb +16 -4
  330. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  331. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  332. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  333. data/lib/ruby_llm/providers/ollama.rb +9 -8
  334. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  335. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  336. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  337. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  338. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  339. data/lib/ruby_llm/providers/openai.rb +92 -11
  340. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -108
  341. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  342. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  343. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  344. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  345. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  346. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  347. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  348. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  349. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  350. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  351. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  352. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  353. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  354. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  355. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  356. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  357. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  359. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  360. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  361. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  362. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  363. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  364. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  365. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  366. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  367. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  368. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  369. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  370. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  371. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  372. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  373. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  374. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  375. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  376. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  377. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  378. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  379. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  380. data/lib/ruby_llm/providers/xai.rb +15 -5
  381. data/lib/ruby_llm/railtie.rb +7 -16
  382. data/lib/ruby_llm/rerank.rb +105 -0
  383. data/lib/ruby_llm/research_job.rb +241 -0
  384. data/lib/ruby_llm/search_results.rb +68 -0
  385. data/lib/ruby_llm/server_tool_call.rb +73 -0
  386. data/lib/ruby_llm/speech.rb +159 -0
  387. data/lib/ruby_llm/speech_chunk.rb +33 -0
  388. data/lib/ruby_llm/support/deprecator.rb +22 -0
  389. data/lib/ruby_llm/support/inspectable.rb +49 -0
  390. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  391. data/lib/ruby_llm/support/utils.rb +147 -0
  392. data/lib/ruby_llm/thinking.rb +127 -20
  393. data/lib/ruby_llm/tokenization.rb +59 -0
  394. data/lib/ruby_llm/tokens.rb +103 -33
  395. data/lib/ruby_llm/tool.rb +266 -91
  396. data/lib/ruby_llm/tool_call.rb +36 -3
  397. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  398. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  399. data/lib/ruby_llm/transcription.rb +138 -13
  400. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  401. data/lib/ruby_llm/transport/connection.rb +193 -0
  402. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  403. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  404. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  405. data/lib/ruby_llm/uploaded_file.rb +144 -0
  406. data/lib/ruby_llm/version.rb +2 -1
  407. data/lib/ruby_llm/video.rb +136 -0
  408. data/lib/ruby_llm/video_job.rb +150 -0
  409. data/lib/ruby_llm/workflow.rb +91 -0
  410. data/lib/ruby_llm.rb +380 -6
  411. data/lib/tasks/ruby_llm.rake +21 -16
  412. data/skills/rubyllm/SKILL.md +81 -0
  413. data/skills/rubyllm/agents/openai.yaml +4 -0
  414. metadata +338 -97
  415. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  416. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  417. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  418. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  419. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  422. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  424. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  426. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  428. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  429. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  430. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  431. data/lib/ruby_llm/aliases.rb +0 -41
  432. data/lib/ruby_llm/connection.rb +0 -159
  433. data/lib/ruby_llm/content.rb +0 -91
  434. data/lib/ruby_llm/deprecator.rb +0 -24
  435. data/lib/ruby_llm/error_middleware.rb +0 -81
  436. data/lib/ruby_llm/instrumentation.rb +0 -36
  437. data/lib/ruby_llm/mime_type.rb +0 -96
  438. data/lib/ruby_llm/model/info.rb +0 -164
  439. data/lib/ruby_llm/model_registry.rb +0 -39
  440. data/lib/ruby_llm/models_schema.json +0 -171
  441. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  442. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  443. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  444. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  445. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  446. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  447. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  448. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  449. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  450. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  451. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  452. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  453. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  454. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  455. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  456. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  457. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  458. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  459. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  460. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  461. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  462. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  463. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  464. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  465. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  466. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  467. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  468. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  469. data/lib/ruby_llm/streaming.rb +0 -179
  470. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  471. data/lib/ruby_llm/utils.rb +0 -130
  472. data/lib/tasks/models.rake +0 -593
  473. data/lib/tasks/release.rake +0 -94
  474. data/lib/tasks/vcr.rake +0 -124
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Mistral
6
+ class Conversations
7
+ module Streaming # :nodoc:
8
+ module_function
9
+
10
+ def stream_response(payload, additional_headers = {})
11
+ @conversation_output = {}
12
+ @conversation_response = { 'object' => 'conversation.response' }
13
+ @conversation_done = false
14
+ response = stream_events(completion_url, payload, additional_headers) { |data| yield build_chunk(data) }
15
+ raise Error.new('Mistral conversation stream ended before completion', response:) unless @conversation_done
16
+
17
+ parse_completion_body(streamed_conversation_response, raw: response)
18
+ end
19
+
20
+ def build_chunk(data)
21
+ @conversation_output ||= {}
22
+ @conversation_response ||= { 'object' => 'conversation.response' }
23
+ case data['type']
24
+ when 'conversation.response.done'
25
+ @conversation_done = true
26
+ @conversation_response['usage'] = data['usage']
27
+ return final_conversation_chunk
28
+ when 'conversation.response.error'
29
+ raise Error, data['message'] || data.dig('error', 'message') || 'Mistral conversation failed'
30
+ when 'message.output.delta'
31
+ return conversation_text_chunk(data)
32
+ when 'function.call.delta', 'tool.execution.started', 'tool.execution.delta', 'tool.execution.done'
33
+ accumulate_conversation_entry(data)
34
+ end
35
+ Chunk.new(role: :assistant, content: nil)
36
+ end
37
+
38
+ def streamed_conversation_response
39
+ @conversation_response.merge('outputs' => @conversation_output.sort.map(&:last))
40
+ end
41
+
42
+ def accumulate_conversation_entry(data)
43
+ type = data['type'].delete_suffix('.delta').delete_suffix('.started').delete_suffix('.done')
44
+ entry = @conversation_output[data.fetch('output_index', 0)] ||= { 'type' => type, 'arguments' => +'' }
45
+ entry.merge!(data.slice('id', 'model', 'name', 'tool_call_id', 'confirmation_status', 'function', 'info'))
46
+ entry['arguments'] << data['arguments'].to_s
47
+ end
48
+
49
+ def conversation_text_chunk(data)
50
+ entry = @conversation_output[data.fetch('output_index',
51
+ 0)] ||= { 'type' => 'message.output', 'content' => [] }
52
+ entry.merge!(data.slice('id', 'model', 'role'))
53
+ part = data['content'].is_a?(String) ? { 'type' => 'text', 'text' => data['content'] } : data['content']
54
+ merge_conversation_part(entry['content'], data.fetch('content_index', 0), part)
55
+ content = { text: +'', thinking: +'', attachments: [], citations: [] }
56
+ parse_conversation_parts([part], content)
57
+ Chunk.new(role: :assistant, content: content[:text], model: data['model'],
58
+ thinking: Thinking.build(text: content[:thinking].empty? ? nil : content[:thinking]))
59
+ end
60
+
61
+ def merge_conversation_part(parts, index, part)
62
+ existing = parts[index]
63
+ if existing && part['type'] == 'text'
64
+ existing['text'] << part['text'].to_s
65
+ elsif existing && part['type'] == 'thinking'
66
+ existing['thinking'].concat(Array(part['thinking']))
67
+ else
68
+ parts[index] = Support::Utils.deep_dup(part)
69
+ end
70
+ end
71
+
72
+ def final_conversation_chunk
73
+ message = parse_completion_body(streamed_conversation_response, raw: nil)
74
+ Chunk.new(role: :assistant, content: nil, model: message.model, tokens: message.tokens,
75
+ citations: message.citations, tool_calls: message.tool_calls,
76
+ server_tool_calls: message.server_tool_calls, raw_content: message.raw_content,
77
+ attachments: message.attachments, finish_reason: message.finish_reason)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Mistral
6
+ # Mistral's Conversations API, including provider-executed tools.
7
+ class Conversations < ChatCompletions
8
+ include Mistral::Content
9
+ include Conversations::Chat
10
+ include Conversations::Streaming
11
+ include Conversations::Images
12
+
13
+ public :render
14
+
15
+ SERVER_TOOL_ALIASES = {
16
+ web_search: { tool: { type: 'web_search' } },
17
+ web_fetch: { tool: { type: 'web_search' } },
18
+ code_execution: { tool: { type: 'code_interpreter' } },
19
+ file_search: { tool: { type: 'document_library' } },
20
+ image_generation: { tool: { type: 'image_generation' } },
21
+ mcp: { tool: { type: 'connector' } }
22
+ }.freeze
23
+
24
+ def server_tool_aliases
25
+ SERVER_TOOL_ALIASES
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Mistral
6
+ # Mistral Files API.
7
+ class Files < Protocols::Files
8
+ private
9
+
10
+ # rubocop:disable-next Lint/UnusedMethodArgument
11
+ def render_upload_payload(attachment, purpose: nil, expires_in: nil, visibility: nil,
12
+ display_name: nil, uri: nil, content_type: nil)
13
+ multipart_payload(attachment, purpose:, expiry: expiry_hours(expires_in), visibility:)
14
+ end
15
+
16
+ def expiry_hours(expires_in)
17
+ expires_in&.fdiv(3600)&.ceil
18
+ end
19
+
20
+ def parse_file_response(data)
21
+ uploaded_file(
22
+ data,
23
+ id: data['id'],
24
+ filename: data['filename'],
25
+ byte_size: data['bytes'],
26
+ created_at: timestamp(data['created_at']),
27
+ expires_at: timestamp(data['expires_at']),
28
+ mime_type: data['mimetype'],
29
+ purpose: data['purpose'],
30
+ status: data['deleted'] ? 'deleted' : nil
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Mistral
6
+ module MultiCompletion # :nodoc:
7
+ include Mistral::Content
8
+
9
+ SERVER_TOOL_ALIASES = {
10
+ image_generation: { tool: { type: 'image_generation' } },
11
+ mcp: { tool: { type: 'connector' } }
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def server_tool_aliases
17
+ SERVER_TOOL_ALIASES
18
+ end
19
+
20
+ def format_message_group(group, **options)
21
+ content = group.first.raw_content
22
+ return super unless group.one? && content.is_a?(Array) &&
23
+ content.all? { |entry| entry.is_a?(Hash) && entry['role'] }
24
+
25
+ content.map { |entry| entry.except('index') }
26
+ end
27
+
28
+ def parse_completion_body(data, raw:)
29
+ messages = data.dig('choices', 0, 'messages')
30
+ return super unless messages.is_a?(Array)
31
+
32
+ parse_multi_message(data, messages, raw:)
33
+ end
34
+
35
+ def multi_content(messages)
36
+ output = messages.filter_map do |message|
37
+ message.merge('type' => 'message.output') if message['role'] == 'assistant'
38
+ end
39
+ parse_conversation_content(output)
40
+ end
41
+
42
+ def multi_pending_calls(calls, results, raw:)
43
+ pending = calls.reject { |call| results.key?(call['id']) }
44
+ if pending.any? { |call| call.dig('metadata', 'tool_type') || call.dig('metadata', 'integration_id') }
45
+ raise Error.new('Mistral returned an unfinished hosted tool call on Chat Completions', response: raw)
46
+ end
47
+
48
+ pending
49
+ end
50
+
51
+ def parse_multi_message(data, messages, raw:)
52
+ content = multi_content(messages)
53
+ results = messages.filter_map do |message|
54
+ [message['tool_call_id'], message] if message['role'] == 'tool'
55
+ end.to_h
56
+ calls = messages.flat_map { |message| Array(message['tool_calls']) }
57
+ pending = multi_pending_calls(calls, results, raw:)
58
+ usage = data['usage'] || {}
59
+ Message.new(role: :assistant, content: content[:text], thinking: Thinking.build(text: content[:thinking]),
60
+ attachments: content[:attachments], citations: content[:citations],
61
+ tool_calls: parse_tool_calls(pending, response: raw, finish_reason: :tool_calls),
62
+ server_tool_calls: parse_multi_steps(calls, results), raw_content: messages,
63
+ input_tokens: input_tokens(usage), output_tokens: output_tokens(usage),
64
+ cache_read_tokens: cache_read_tokens(usage), model: data['model'], raw: raw,
65
+ finish_reason: normalize_finish_reason(data.dig('choices', 0, 'finish_reason')))
66
+ end
67
+
68
+ def parse_multi_steps(calls, results)
69
+ calls.filter_map do |call|
70
+ result = results[call['id']]
71
+ next unless result
72
+
73
+ ServerToolCall.new(type: result['role'], id: call['id'], name: call.dig('function', 'name'),
74
+ input: call.dig('function', 'arguments'), result: result['content'], raw: result)
75
+ end
76
+ end
77
+
78
+ def stream_response(payload, additional_headers = {}, &block)
79
+ return super unless Array(payload[:tools]).any? { |tool| (tool[:type] || tool['type']) != 'function' }
80
+
81
+ @multi_messages = {}
82
+ @multi_usage = {}
83
+ @multi_finish_reason = nil
84
+ response = stream_events(completion_url, payload, additional_headers) do |data|
85
+ block.call(build_multi_chunk(data))
86
+ end
87
+ raise Error.new('Mistral tool stream ended before completion', response:) unless @multi_finish_reason
88
+
89
+ message = parse_completion_body(multi_response, raw: response)
90
+ block.call(Chunk.new(role: :assistant, content: nil, tokens: message.tokens, model: message.model,
91
+ citations: message.citations, server_tool_calls: message.server_tool_calls,
92
+ tool_calls: message.tool_calls, raw_content: message.raw_content,
93
+ attachments: message.attachments, finish_reason: message.finish_reason))
94
+ message
95
+ end
96
+
97
+ def build_multi_chunk(data)
98
+ @multi_model = data['model']
99
+ @multi_usage[data['id']] = data['usage'] if data['usage']
100
+ choice = data.dig('choices', 0) || {}
101
+ delta = choice['delta'] || {}
102
+ index = delta.fetch('index', 0)
103
+ @multi_finish_reason = nil unless @multi_messages.key?(index)
104
+ entry = @multi_messages[index] ||= { 'content' => [], 'tool_calls' => [] }
105
+ entry.merge!(delta.slice('role', 'tool_call_id', 'metadata'))
106
+ append_multi_content(entry, delta['content'])
107
+ append_multi_calls(entry, delta['tool_calls'])
108
+ @multi_finish_reason = choice['finish_reason'] if choice['finish_reason']
109
+ content, thinking = extract_content_and_thinking(delta['content']) if entry['role'] == 'assistant'
110
+ Chunk.new(role: :assistant, content: content, thinking: Thinking.build(text: thinking), model: data['model'])
111
+ end
112
+
113
+ def append_multi_content(entry, content)
114
+ return if content.nil?
115
+
116
+ if content.is_a?(String)
117
+ entry['content'] << { 'type' => 'text', 'text' => content }
118
+ else
119
+ entry['content'].concat(content)
120
+ end
121
+ end
122
+
123
+ def append_multi_calls(entry, calls)
124
+ Array(calls).each do |call|
125
+ target = entry['tool_calls'][call.fetch('index', 0)] ||= { 'function' => { 'arguments' => +'' } }
126
+ target.merge!(call.slice('id', 'type', 'metadata'))
127
+ target['function']['name'] = call.dig('function', 'name') if call.dig('function', 'name')
128
+ target['function']['arguments'] << call.dig('function', 'arguments').to_s
129
+ end
130
+ end
131
+
132
+ def multi_response
133
+ { 'model' => @multi_model, 'usage' => multi_usage,
134
+ 'choices' => [{ 'messages' => multi_messages, 'finish_reason' => @multi_finish_reason }] }
135
+ end
136
+
137
+ def multi_usage
138
+ usages = @multi_usage.values
139
+ usage = %w[prompt_tokens completion_tokens total_tokens].to_h do |key|
140
+ [key, usages.sum { |value| value[key].to_i }]
141
+ end
142
+ usage['prompt_tokens_details'] = { 'cached_tokens' => usages.sum do |value|
143
+ value.dig('prompt_tokens_details', 'cached_tokens').to_i
144
+ end }
145
+ usage
146
+ end
147
+
148
+ def multi_messages
149
+ @multi_messages.sort.map do |_, message|
150
+ message = message.reject { |key, value| key == 'tool_calls' && value.empty? }
151
+ if message['role'] == 'tool' && message['content'].all? { |part| part['type'] == 'text' }
152
+ message['content'] = message['content'].map { |part| part['text'] }.join
153
+ end
154
+ message
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module RubyLLM
6
+ module Protocols
7
+ module OpenAI
8
+ # The OpenAI file-backed Batch API: upload JSONL, create /batches,
9
+ # then read output/error files back in custom_id order.
10
+ module Batches
11
+ include RubyLLM::Batch::Helpers
12
+
13
+ TERMINAL_STATUSES = %w[completed failed expired cancelled].freeze
14
+ private_constant :TERMINAL_STATUSES
15
+
16
+ def create_batch(requests)
17
+ single_batch_model!(requests, @provider.slug)
18
+ validate_batch_requests!(requests)
19
+
20
+ response = @connection.post(batch_create_url, {
21
+ input_file_id: upload_batch_file(requests),
22
+ endpoint: batch_endpoint,
23
+ completion_window: '24h'
24
+ }, idempotent: false)
25
+
26
+ parse_batch_response(response.body)
27
+ end
28
+
29
+ def find_batch(id)
30
+ parse_batch_response @connection.get(batch_url(id)).body
31
+ end
32
+
33
+ def cancel_batch(id)
34
+ parse_batch_response @connection.post("#{batch_url(id)}/cancel", {}).body
35
+ end
36
+
37
+ # Results are JSONL and may arrive out of order. Each line carries the
38
+ # custom_id we assigned from the submission index.
39
+ def batch_results(id)
40
+ batch = @connection.get(batch_url(id)).body
41
+ %w[output_file_id error_file_id].flat_map do |key|
42
+ file_id = batch[key]
43
+ next [] unless file_id && !file_id.to_s.empty?
44
+
45
+ download_batch_file(file_id).to_s.each_line.filter_map { |line| parse_batch_result(JSON.parse(line)) }
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def upload_batch_file(requests)
52
+ @provider.upload_file(
53
+ StringIO.new(batch_jsonl(requests)),
54
+ purpose: 'batch',
55
+ filename: 'ruby_llm_batch.jsonl'
56
+ ).id
57
+ end
58
+
59
+ def download_batch_file(file_id)
60
+ @provider.download_file(file_id)
61
+ end
62
+
63
+ def batch_jsonl(requests)
64
+ requests.map { |request| JSON.generate(batch_request_line(request)) }.join("\n")
65
+ end
66
+
67
+ def batch_request_line(request)
68
+ {
69
+ custom_id: request[:custom_id],
70
+ method: 'POST',
71
+ url: batch_endpoint,
72
+ body: batch_payload(request)
73
+ }
74
+ end
75
+
76
+ def validate_batch_requests!(_requests); end
77
+
78
+ def batch_endpoint
79
+ raise NotImplementedError
80
+ end
81
+
82
+ def batch_create_url
83
+ 'batches'
84
+ end
85
+
86
+ def batch_url(id)
87
+ "batches/#{id}"
88
+ end
89
+
90
+ def parse_batch_response(data)
91
+ request_counts = data['request_counts']
92
+
93
+ {
94
+ id: data['id'],
95
+ raw_status: data['status'],
96
+ completed: TERMINAL_STATUSES.include?(data['status']),
97
+ request_counts:,
98
+ request_count: request_counts&.fetch('total', nil),
99
+ endpoint: data['endpoint']
100
+ }.compact
101
+ end
102
+
103
+ def parse_batch_status(raw_status, completed:)
104
+ return :pending unless completed
105
+
106
+ case raw_status
107
+ when 'completed' then :succeeded
108
+ when 'cancelled' then :cancelled
109
+ else :failed
110
+ end
111
+ end
112
+
113
+ def parse_batch_result(line)
114
+ index = batch_result_index(line['custom_id'])
115
+ response = line['response']
116
+
117
+ if response && response['status_code'].to_i.between?(200, 299)
118
+ [index, parse_batch_completion_response(response['body'])]
119
+ else
120
+ [index, nil, batch_failure(line['custom_id'], batch_error_message(line))]
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module OpenAI
6
+ # OpenAI Files API.
7
+ class Files < Protocols::Files
8
+ UPLOAD_PURPOSES = %w[assistants batch fine-tune vision user_data evals].freeze
9
+
10
+ private
11
+
12
+ # rubocop:disable-next Lint/UnusedMethodArgument
13
+ def render_upload_payload(attachment, purpose: nil, expires_in: nil, visibility: nil,
14
+ display_name: nil, uri: nil, content_type: nil)
15
+ unless purpose
16
+ raise ArgumentError, "#{@provider.name} file uploads require purpose: " \
17
+ "#{UPLOAD_PURPOSES.join(', ')}"
18
+ end
19
+
20
+ multipart_payload(attachment, purpose:, expires_after: expires_after(expires_in))
21
+ end
22
+
23
+ def expires_after(expires_in)
24
+ { anchor: 'created_at', seconds: expires_in } if expires_in
25
+ end
26
+
27
+ def parse_file_response(data)
28
+ uploaded_file(
29
+ data,
30
+ id: data['id'],
31
+ filename: data['filename'],
32
+ byte_size: data['bytes'],
33
+ created_at: timestamp(data['created_at']),
34
+ expires_at: timestamp(data['expires_at']),
35
+ status: data['status'],
36
+ purpose: data['purpose']
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module OpenRouter
6
+ module Batches # :nodoc: all
7
+ include RubyLLM::Batch::Helpers
8
+
9
+ TERMINAL_STATUSES = %w[completed failed expired cancelled].freeze
10
+ MEDIA_TYPES = %w[image image_url input_image input_audio audio video video_url input_video file input_file
11
+ document].freeze
12
+ Response = Struct.new(:body)
13
+ private_constant :TERMINAL_STATUSES, :MEDIA_TYPES, :Response
14
+
15
+ def create_batch(requests)
16
+ model = single_batch_model!(requests, 'OpenRouter')
17
+ endpoints = requests.map { |request| batch_request_endpoint(request) }.uniq
18
+ raise ArgumentError, 'OpenRouter batches require one API protocol per submission' unless endpoints.one?
19
+
20
+ endpoint = endpoints.first
21
+ rows = requests.map { |request| render_batch_request(request, endpoint:) }
22
+ response = @connection.post(@provider.batch_api_base, { endpoint:, model:, requests: rows },
23
+ idempotent: false)
24
+ parse_batch_response(response.body)
25
+ end
26
+
27
+ def find_batch(id)
28
+ parse_batch_response(batch_data(id))
29
+ end
30
+
31
+ def cancel_batch(_id)
32
+ raise Error, 'OpenRouter does not expose batch cancellation'
33
+ end
34
+
35
+ def batch_results(id)
36
+ data = batch_data(id)
37
+ results = Array(data['results']).map { |row| parse_batch_result(row, data:) }
38
+ unless results.map(&:first).uniq.size == results.size
39
+ raise Error, 'OpenRouter returned duplicate batch request IDs'
40
+ end
41
+
42
+ results
43
+ end
44
+
45
+ private
46
+
47
+ def batch_data(id)
48
+ @connection.get("#{@provider.batch_api_base}/#{id}").body
49
+ end
50
+
51
+ def batch_request_endpoint(request)
52
+ return '/v1/embeddings' if request.key?(:text)
53
+
54
+ payload = Support::Utils.deep_symbolize_keys(request.fetch(:payload))
55
+ return '/v1/chat/completions' if payload.key?(:messages)
56
+ return '/v1/responses' if payload.key?(:input)
57
+
58
+ raise ArgumentError, 'OpenRouter batches require chat or embedding requests'
59
+ end
60
+
61
+ def render_batch_request(request, endpoint:)
62
+ body = Support::Utils.deep_symbolize_keys(batch_payload(request))
63
+ validate_batch_body(body, endpoint:)
64
+ id = request.fetch(:custom_id)
65
+ id = "#{id}:array" if endpoint == '/v1/embeddings' && request.fetch(:text).is_a?(Array)
66
+ { custom_id: id, body: }
67
+ end
68
+
69
+ def validate_batch_body(body, endpoint:)
70
+ if endpoint == '/v1/embeddings'
71
+ unsupported = body.keys & %i[input_type provider]
72
+ if unsupported.any? || !text_embedding_input?(body[:input])
73
+ raise ArgumentError,
74
+ 'OpenRouter embedding batches accept text only, without task_type or provider preferences'
75
+ end
76
+ elsif batch_media?(body) || body.keys.intersect?(%i[modalities audio image_config])
77
+ raise ArgumentError, 'OpenRouter batches accept text input and output only'
78
+ end
79
+ end
80
+
81
+ def text_embedding_input?(input)
82
+ case input
83
+ when String then true
84
+ when Array then input.flatten.all? { |part| part.is_a?(String) || part.is_a?(Integer) }
85
+ else false
86
+ end
87
+ end
88
+
89
+ def batch_media?(value)
90
+ case value
91
+ when Hash then MEDIA_TYPES.include?(value[:type]) || value.values.any? { |part| batch_media?(part) }
92
+ when Array then value.any? { |part| batch_media?(part) }
93
+ else false
94
+ end
95
+ end
96
+
97
+ def parse_batch_response(data)
98
+ {
99
+ id: data.fetch('id'), raw_status: data.fetch('status'),
100
+ completed: TERMINAL_STATUSES.include?(data['status']),
101
+ reported_cost: parse_batch_reported_cost(data['usage']),
102
+ request_counts: data['request_counts'], request_count: data.dig('request_counts', 'total')
103
+ }
104
+ end
105
+
106
+ def parse_batch_reported_cost(usage)
107
+ return if usage.nil? || usage['cost'].nil?
108
+
109
+ Cost.from_h({ total: usage.fetch('cost') })
110
+ end
111
+
112
+ def parse_batch_status(raw_status, completed:)
113
+ return :pending unless completed
114
+ return :succeeded if raw_status == 'completed'
115
+ return :cancelled if raw_status == 'cancelled'
116
+
117
+ :failed
118
+ end
119
+
120
+ def parse_batch_result(row, data:)
121
+ custom_id, shape = row.fetch('custom_id').split(':', 2)
122
+ index = batch_result_index(custom_id)
123
+ response = row['response']
124
+ unless response && response['status_code'].to_i.between?(200, 299) && !row['error']
125
+ return [index, nil, batch_failure(custom_id, batch_error_message(row))]
126
+ end
127
+
128
+ [index, parse_batch_body(response.fetch('body'), endpoint: data.fetch('endpoint'),
129
+ model: data.fetch('model'), shape:)]
130
+ end
131
+
132
+ def parse_batch_body(body, endpoint:, model:, shape:)
133
+ return parse_batch_embedding(body, model:, shape:) if endpoint == '/v1/embeddings'
134
+
135
+ parser = endpoint == '/v1/responses' ? OpenRouter::Responses.new(@provider) : self
136
+ parser.send(:parse_completion_body, body, raw: body)
137
+ end
138
+
139
+ def parse_batch_embedding(body, model:, shape:)
140
+ ordered = body.merge('data' => body.fetch('data').sort_by { |row| row.fetch('index') })
141
+ parse_embedding_response(Response.new(ordered), model: body['model'] || model,
142
+ text: shape == 'array' ? [] : nil)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module OpenRouter
6
+ # OpenRouter Files API.
7
+ class Files < Protocols::Files
8
+ private
9
+
10
+ def parse_file_response(data)
11
+ uploaded_file(
12
+ data,
13
+ id: data['id'],
14
+ filename: data['filename'],
15
+ byte_size: data['size_bytes'],
16
+ created_at: timestamp(data['created_at']),
17
+ mime_type: data['mime_type'],
18
+ downloadable: data['downloadable']
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end