ruby_llm 1.15.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 (470) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +87 -33
  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 -136
  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 +119 -75
  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 -150
  109. data/lib/ruby_llm/aliases.json +338 -167
  110. data/lib/ruby_llm/attachment.rb +217 -61
  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 +1208 -150
  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 +274 -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 +141 -7
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +161 -89
  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 +155 -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 +43 -14
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +66 -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 +70380 -33380
  137. data/lib/ruby_llm/models.rb +528 -201
  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 +43 -34
  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/protocols/chat_completions/media.rb +121 -0
  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 +17 -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 +33 -20
  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 +565 -124
  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 +6 -15
  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 +61 -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 +194 -42
  301. data/lib/ruby_llm/providers/bedrock.rb +217 -46
  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 -8
  305. data/lib/ruby_llm/providers/deepseek/chat.rb +56 -0
  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 -9
  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 -60
  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 +43 -0
  325. data/lib/ruby_llm/providers/mistral/models.rb +57 -21
  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 +18 -6
  330. data/lib/ruby_llm/providers/ollama/chat.rb +9 -8
  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 -104
  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 +4 -0
  350. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  351. data/lib/ruby_llm/providers/perplexity/media.rb +46 -0
  352. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  353. data/lib/ruby_llm/providers/perplexity.rb +29 -21
  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 +164 -17
  369. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  370. data/lib/ruby_llm/providers/xai/chat.rb +10 -0
  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 -48
  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 +17 -7
  381. data/lib/ruby_llm/railtie.rb +11 -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 +139 -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 +385 -4
  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 +340 -92
  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 -530
  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 -38
  432. data/lib/ruby_llm/connection.rb +0 -130
  433. data/lib/ruby_llm/content.rb +0 -77
  434. data/lib/ruby_llm/mime_type.rb +0 -71
  435. data/lib/ruby_llm/model/info.rb +0 -130
  436. data/lib/ruby_llm/models_schema.json +0 -171
  437. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -257
  438. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  439. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  440. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  441. data/lib/ruby_llm/providers/anthropic/models.rb +0 -57
  442. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -69
  443. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -403
  444. data/lib/ruby_llm/providers/bedrock/media.rb +0 -90
  445. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -322
  446. data/lib/ruby_llm/providers/gemini/chat.rb +0 -543
  447. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  448. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  449. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  450. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -96
  451. data/lib/ruby_llm/providers/gemini/tools.rb +0 -232
  452. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  453. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  454. data/lib/ruby_llm/providers/openai/chat.rb +0 -221
  455. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  456. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  457. data/lib/ruby_llm/providers/openai/media.rb +0 -84
  458. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  459. data/lib/ruby_llm/providers/openai/streaming.rb +0 -53
  460. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  461. data/lib/ruby_llm/providers/openai/transcription.rb +0 -70
  462. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  463. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  464. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  465. data/lib/ruby_llm/stream_accumulator.rb +0 -203
  466. data/lib/ruby_llm/streaming.rb +0 -175
  467. data/lib/ruby_llm/utils.rb +0 -91
  468. data/lib/tasks/models.rake +0 -565
  469. data/lib/tasks/release.rake +0 -67
  470. data/lib/tasks/vcr.rake +0 -124
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class ChatCompletions
6
+ # Audio transcription methods for the OpenAI API integration
7
+ module Transcription
8
+ module_function
9
+
10
+ def transcription_url
11
+ 'audio/transcriptions'
12
+ end
13
+
14
+ def render_transcription_options(timestamps:, format:, streaming:)
15
+ return {} if timestamps.nil?
16
+
17
+ values = Array(timestamps).map(&:to_s)
18
+ unless values.any? && (values - %w[word segment]).empty?
19
+ raise ArgumentError, 'Transcription timestamps must be word or segment'
20
+ end
21
+ if streaming || (format && format != 'verbose_json')
22
+ raise ArgumentError, 'Transcription timestamps require a non-streaming verbose_json response'
23
+ end
24
+
25
+ { response_format: 'verbose_json', timestamp_granularities: values }
26
+ end
27
+
28
+ def render_transcription_payload(file_part, model:, language:, format: nil, speaker_names: nil,
29
+ speaker_references: nil, provider_options: {}, prompt: nil,
30
+ temperature: nil)
31
+ {
32
+ model: model,
33
+ file: file_part,
34
+ language: language,
35
+ response_format: format || default_response_format(model),
36
+ prompt: prompt,
37
+ temperature: temperature,
38
+ known_speaker_names: speaker_names,
39
+ known_speaker_references: encode_speaker_references(speaker_references)
40
+ }.compact.merge(provider_options)
41
+ end
42
+
43
+ def encode_speaker_references(references)
44
+ return nil unless references
45
+
46
+ references.map do |ref|
47
+ Attachment.new(ref, config: @config).for_llm
48
+ end
49
+ end
50
+
51
+ def reported_cost(_usage)
52
+ nil
53
+ end
54
+
55
+ # Diarization models return plain text with no segments unless the
56
+ # response format asks for them.
57
+ def default_response_format(model)
58
+ 'diarized_json' if model.include?('diarize')
59
+ end
60
+
61
+ # OpenAI streams transcriptions as server-sent events carrying text
62
+ # deltas, completed segments on diarization models, and a final
63
+ # event with the whole transcript and its usage.
64
+ def stream_transcription(payload, model:, &block)
65
+ chunks = []
66
+
67
+ stream_events(transcription_url, payload.merge(stream: 'true')) do |data|
68
+ chunk = build_transcription_chunk(data)
69
+ chunks << chunk
70
+ block.call chunk
71
+ end
72
+
73
+ build_streamed_transcription(chunks, model: model)
74
+ end
75
+
76
+ def build_transcription_chunk(data)
77
+ type = data['type']
78
+
79
+ RubyLLM::TranscriptionChunk.new(
80
+ type: type,
81
+ delta: data['delta'],
82
+ text: (data['text'] if type == RubyLLM::TranscriptionChunk::DONE),
83
+ segment: (data.except('type') if type == RubyLLM::TranscriptionChunk::SEGMENT),
84
+ raw: data
85
+ )
86
+ end
87
+
88
+ def build_streamed_transcription(chunks, model:)
89
+ final = chunks.reverse.find(&:done?)
90
+ data = final&.raw || {}
91
+ usage = data['usage'] || {}
92
+
93
+ RubyLLM::Transcription.new(
94
+ text: final&.text || streamed_transcript_text(chunks),
95
+ model: model,
96
+ language: data['language'],
97
+ duration: transcription_duration(usage),
98
+ segments: streamed_transcription_segments(chunks, data),
99
+ reported_cost: reported_cost(usage),
100
+ **transcription_tokens(usage)
101
+ )
102
+ end
103
+
104
+ # Diarization models stream segments instead of deltas, so the
105
+ # transcript is rebuilt from whichever the provider sent.
106
+ def streamed_transcript_text(chunks)
107
+ deltas = chunks.filter_map(&:delta)
108
+ return deltas.join if deltas.any?
109
+
110
+ chunks.filter_map { |chunk| chunk.segment&.fetch('text', nil) }.join(' ')
111
+ end
112
+
113
+ def streamed_transcription_segments(chunks, data)
114
+ segments = data['segments'] || chunks.filter_map(&:segment)
115
+ segments.empty? ? nil : segments
116
+ end
117
+
118
+ def parse_transcription_response(response, model:)
119
+ data = response.body
120
+
121
+ return RubyLLM::Transcription.new(text: data, model: model) if data.is_a?(String)
122
+
123
+ usage = data['usage'] || {}
124
+
125
+ RubyLLM::Transcription.new(
126
+ text: data['text'],
127
+ model: model,
128
+ language: data['language'],
129
+ duration: data['duration'] || transcription_duration(usage),
130
+ segments: data['segments'],
131
+ words: data['words'],
132
+ reported_cost: reported_cost(usage),
133
+ **transcription_tokens(usage)
134
+ )
135
+ end
136
+
137
+ def transcription_tokens(usage)
138
+ {
139
+ input_tokens: usage['input_tokens'] || usage['prompt_tokens'],
140
+ output_tokens: usage['output_tokens'] || usage['completion_tokens']
141
+ }
142
+ end
143
+
144
+ def transcription_duration(usage)
145
+ usage['seconds']
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ # The OpenAI Chat Completions API — the lingua franca of LLM APIs.
6
+ class ChatCompletions < Protocol
7
+ include ChatCompletions::Chat
8
+ include ChatCompletions::Embeddings
9
+ include ChatCompletions::Models
10
+ include ChatCompletions::Moderation
11
+ include ChatCompletions::Streaming
12
+ include ChatCompletions::Tools
13
+ include ChatCompletions::Images
14
+ include ChatCompletions::Media
15
+ include ChatCompletions::Speech
16
+ include ChatCompletions::Transcription
17
+
18
+ public :render_transcription_options
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Cohere
6
+ module BatchRequests # :nodoc: all
7
+ CHAT_FIELDS = %w[
8
+ messages tools temperature p frequency_penalty presence_penalty reasoning thinking_budget
9
+ return_prompt logprobs max_tokens max_input_tokens k seed
10
+ ].freeze
11
+ EMBEDDING_FIELDS = %w[texts images input_type inputs max_tokens output_dimension embedding_types
12
+ truncate].freeze
13
+ private_constant :CHAT_FIELDS, :EMBEDDING_FIELDS
14
+
15
+ def batch_dataset_type(requests)
16
+ types = requests.map do |request|
17
+ body = request.fetch(:payload)
18
+ body.key?(:messages) || body.key?('messages') ? 'batch-chat-v2-input' : 'batch-embed-v2-input'
19
+ end.uniq
20
+ raise ArgumentError, 'Cohere batches cannot mix chat and embeddings' unless types.one?
21
+
22
+ types.first
23
+ end
24
+
25
+ def render_batch_request(request, type:)
26
+ body = JSON.parse(JSON.generate(batch_payload(request, except: :model)))
27
+ if type == 'batch-embed-v2-input' && body['output_dimension']
28
+ raise ArgumentError,
29
+ 'Cohere batch datasets currently reject dimensions; omit dimensions to use the model default'
30
+ end
31
+
32
+ render_batch_chat(body) if type == 'batch-chat-v2-input'
33
+ allowed = type == 'batch-chat-v2-input' ? CHAT_FIELDS : EMBEDDING_FIELDS
34
+ unsupported = body.keys - allowed
35
+ unless unsupported.empty?
36
+ raise ArgumentError, "Cohere batches do not support these request options: #{unsupported.join(', ')}"
37
+ end
38
+
39
+ custom_id = request.fetch(:custom_id)
40
+ custom_id = "#{custom_id}:array" if request[:text].is_a?(Array)
41
+ { custom_id:, body: }
42
+ end
43
+
44
+ def render_batch_chat(body)
45
+ if (thinking = body.delete('thinking'))
46
+ body['reasoning'] = thinking['type'] != 'disabled'
47
+ body['thinking_budget'] = thinking['token_budget'] if thinking['token_budget']
48
+ end
49
+ Array(body['messages']).each { |message| render_batch_message(message) }
50
+ Array(body['tools']).each do |tool|
51
+ function = tool.fetch('function')
52
+ parameters = function['parameters']
53
+ function['parameters'] = JSON.generate(parameters) unless parameters.is_a?(String)
54
+ end
55
+ end
56
+
57
+ def render_batch_message(message)
58
+ content = message['content']
59
+ content = [{ 'type' => 'text', 'text' => content }] if content.is_a?(String)
60
+ message['content'] = content&.map { |part| render_batch_content(part) }
61
+ end
62
+
63
+ def render_batch_content(part)
64
+ unless %w[text thinking image_url].include?(part['type'])
65
+ raise ArgumentError, "Cohere batches do not support #{part['type']} content"
66
+ end
67
+
68
+ part = part.dup
69
+ part['image_url'] = part['image_url'].fetch('url') if part['image_url'].is_a?(Hash)
70
+ part
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Cohere
6
+ module Batches # :nodoc: all
7
+ include RubyLLM::Batch::Helpers
8
+ include Cohere::BatchRequests
9
+
10
+ TERMINAL_STATUSES = %w[BATCH_STATUS_COMPLETED BATCH_STATUS_FAILED BATCH_STATUS_CANCELED].freeze
11
+ Response = Struct.new(:body)
12
+ private_constant :TERMINAL_STATUSES, :Response
13
+
14
+ def create_batch(requests)
15
+ model = single_batch_model!(requests, 'Cohere')
16
+ type = batch_dataset_type(requests)
17
+ rows = requests.map { |request| render_batch_request(request, type:) }
18
+ file = datasets.upload(StringIO.new(rows.map { |row| "#{JSON.generate(row)}\n" }.join),
19
+ filename: 'ruby-llm-batch.jsonl', purpose: type)
20
+ datasets.wait_for_validation(file.id)
21
+ response = @connection.post('v2/batches', {
22
+ name: 'ruby-llm-batch', input_dataset_id: file.id, model: model
23
+ }, idempotent: false)
24
+ parse_batch_response(response.body.fetch('batch'))
25
+ end
26
+
27
+ def find_batch(id)
28
+ parse_batch_response(batch_data(id))
29
+ end
30
+
31
+ def cancel_batch(id)
32
+ @connection.post("v2/batches/#{id}/cancel", {})
33
+ find_batch(id)
34
+ end
35
+
36
+ def batch_results(id)
37
+ data = batch_data(id)
38
+ return [] if data['output_dataset_id'].to_s.empty?
39
+
40
+ file = datasets.wait_for_validation(data.fetch('output_dataset_id'))
41
+ model = RubyLLM.models.find(data.fetch('model'), provider: @provider.slug, config: @config)
42
+ parser = self.class.new(@provider, model)
43
+ results = datasets.records(file).map { |row| parse_batch_result(row, parser:, model: model.id) }
44
+ unless results.map(&:first).uniq.size == results.size
45
+ raise Error,
46
+ 'Cohere returned duplicate batch request IDs'
47
+ end
48
+
49
+ results
50
+ end
51
+
52
+ private
53
+
54
+ def datasets
55
+ @datasets ||= Cohere::Datasets.new(@provider)
56
+ end
57
+
58
+ def batch_data(id)
59
+ @connection.get("v2/batches/#{id}").body.fetch('batch')
60
+ end
61
+
62
+ def parse_batch_response(data)
63
+ {
64
+ id: data.fetch('id'), raw_status: data.fetch('status'),
65
+ completed: TERMINAL_STATUSES.include?(data['status']), request_count: data['num_records'],
66
+ request_counts: {
67
+ 'total' => data['num_records'], 'succeeded' => data['num_successful_records'],
68
+ 'failed' => data['num_failed_records']
69
+ }.compact
70
+ }
71
+ end
72
+
73
+ def parse_batch_status(raw_status, completed:)
74
+ return :pending unless completed
75
+ return :succeeded if raw_status == 'BATCH_STATUS_COMPLETED'
76
+ return :cancelled if raw_status == 'BATCH_STATUS_CANCELED'
77
+
78
+ :failed
79
+ end
80
+
81
+ def parse_batch_result(row, parser:, model:)
82
+ custom_id, shape = row.fetch('custom_id').split(':', 2)
83
+ index = batch_result_index(custom_id)
84
+ return [index, nil, batch_failure(custom_id, row['error'])] if !row['error'].to_s.empty? || !row['body']
85
+
86
+ body = row.fetch('body')
87
+ result = if body['embeddings']
88
+ parser.send(:parse_embedding_response, Response.new(body), model:,
89
+ text: shape == 'array' ? [] : nil)
90
+ else
91
+ parser.send(:parse_completion_body, body, raw: body)
92
+ end
93
+ [index, result]
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Cohere
6
+ # Chat methods for the Cohere v2 API implementation
7
+ module Chat
8
+ FINISH_REASONS = {
9
+ 'COMPLETE' => :stop, 'STOP_SEQUENCE' => :stop, 'MAX_TOKENS' => :max_tokens, 'TOOL_CALL' => :tool_calls
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def finish_reasons = FINISH_REASONS
15
+
16
+ def normalize_finish_reason(reason)
17
+ return nil if reason.nil?
18
+
19
+ finish_reasons.fetch(reason.to_s) { reason.to_s.to_sym }
20
+ end
21
+
22
+ def completion_url
23
+ 'v2/chat'
24
+ end
25
+
26
+ # rubocop:disable-next Lint/UnusedMethodArgument
27
+ def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
28
+ schema: nil, thinking: nil, citations: false, caching: nil, tool_prefs: nil)
29
+ warn_unsupported_citations(model) if citations && !model.supports?(:citations)
30
+
31
+ payload = {
32
+ model: model.id,
33
+ messages: format_messages(messages, citations: citations),
34
+ stream: stream
35
+ }
36
+
37
+ add_optional_fields(payload, messages, temperature:, max_output_tokens:, citations:, schema:)
38
+ add_tools(payload, tools, tool_prefs || {})
39
+ add_thinking(payload, thinking)
40
+ payload
41
+ end
42
+
43
+ def add_optional_fields(payload, messages, temperature:, max_output_tokens:, citations:, schema:)
44
+ payload[:temperature] = temperature unless temperature.nil?
45
+ payload[:max_tokens] = max_output_tokens unless max_output_tokens.nil?
46
+ payload[:documents] = Media.format_documents(messages) if citations && Media.documents?(messages)
47
+ payload[:response_format] = build_response_format(schema) if schema
48
+ end
49
+
50
+ def warn_unsupported_citations(model)
51
+ RubyLLM.logger.warn(
52
+ "#{model.id} does not support citations according to the model registry. " \
53
+ 'with_citations may have no effect.'
54
+ )
55
+ end
56
+
57
+ def add_tools(payload, tools, tool_prefs)
58
+ return if tools.empty?
59
+
60
+ payload[:tools] = tools.values.map { |tool| Tools.function_for(tool) }
61
+ tool_choice = Tools.build_tool_choice(tool_prefs[:choice])
62
+ payload[:tool_choice] = tool_choice if tool_choice
63
+ end
64
+
65
+ # Cohere takes a bare JSON Schema under json_schema, with no name or
66
+ # strict wrapper.
67
+ def build_response_format(schema)
68
+ normalized = RubyLLM::Support::Utils.deep_dup(schema[:schema])
69
+ normalized.delete(:strict)
70
+ normalized.delete('strict')
71
+
72
+ { type: 'json_object', json_schema: normalized }
73
+ end
74
+
75
+ # Reasoning is on by default for models that support it, so an
76
+ # explicit disable is as meaningful as an explicit enable.
77
+ def add_thinking(payload, thinking)
78
+ return unless thinking&.enabled?
79
+ return payload[:thinking] = { type: 'disabled' } if thinking.disabled?
80
+
81
+ payload[:thinking] = { type: 'enabled', token_budget: thinking.budget }.compact
82
+ end
83
+
84
+ def format_messages(messages, citations: false)
85
+ messages.map { |msg| format_message(msg, citations: citations) }
86
+ end
87
+
88
+ def format_message(msg, citations: false)
89
+ return Tools.format_tool_result(msg) if msg.tool_result?
90
+ return format_assistant_message(msg) if msg.role == :assistant
91
+
92
+ {
93
+ role: msg.role.to_s,
94
+ content: Media.format_content(msg.content, msg.attachments, citations: citations)
95
+ }
96
+ end
97
+
98
+ # Cohere returns tool_plan alongside tool calls, and RubyLLM surfaces
99
+ # it as thinking, but the newer models reject it on the way back in.
100
+ # It is optional in a request, so the plan stays out of the history.
101
+ def format_assistant_message(msg)
102
+ message = { role: 'assistant' }
103
+ content = Media.format_content(msg.content, msg.attachments)
104
+ message[:content] = content unless content.empty?
105
+ message[:tool_calls] = Tools.format_tool_calls(msg.tool_calls) if msg.tool_call?
106
+ message
107
+ end
108
+
109
+ def parse_completion_body(data, raw:)
110
+ message_data = data['message'] || {}
111
+ blocks = Array(message_data['content'])
112
+ content, offsets = extract_text(blocks)
113
+ finish_reason = normalize_finish_reason(data['finish_reason'])
114
+
115
+ Message.new(
116
+ role: :assistant,
117
+ content: content,
118
+ citations: parse_citations(message_data['citations'], offsets),
119
+ thinking: Thinking.build(text: extract_thinking(blocks, message_data)),
120
+ tool_calls: Tools.parse_tool_calls(message_data['tool_calls'], response: raw, finish_reason:),
121
+ finish_reason: finish_reason,
122
+ model: model&.id,
123
+ raw: raw,
124
+ **usage_tokens(data['usage'] || {})
125
+ )
126
+ end
127
+
128
+ # Cohere reports what the model processed under tokens and what it
129
+ # charges for under billed_units; the two differ because Cohere does
130
+ # not bill its own preamble.
131
+ def usage_tokens(usage)
132
+ tokens = usage['tokens'] || {}
133
+ billed = usage['billed_units'] || {}
134
+
135
+ {
136
+ input_tokens: tokens['input_tokens'] || billed['input_tokens'],
137
+ output_tokens: tokens['output_tokens'] || billed['output_tokens'],
138
+ cache_read_tokens: usage['cached_tokens']
139
+ }
140
+ end
141
+
142
+ # Returns the joined text of the response along with the offset each
143
+ # content block starts at, so citation spans resolve against the
144
+ # content string RubyLLM exposes.
145
+ def extract_text(blocks)
146
+ text = +''
147
+ offsets = {}
148
+
149
+ blocks.each_with_index do |block, index|
150
+ next unless block['type'] == 'text'
151
+
152
+ offsets[index] = text.length
153
+ text << block['text'].to_s
154
+ end
155
+
156
+ [text, offsets]
157
+ end
158
+
159
+ # The tool plan is the model's reasoning for a tool-calling turn, and
160
+ # is the only reasoning Cohere returns when thinking blocks are absent.
161
+ def extract_thinking(blocks, message_data)
162
+ thoughts = blocks.select { |block| block['type'] == 'thinking' }
163
+ .map { |block| block['thinking'] }.join
164
+ thoughts.empty? ? message_data['tool_plan'] : thoughts
165
+ end
166
+
167
+ def parse_citations(citations, offsets)
168
+ Array(citations).map { |citation| parse_citation(citation, offsets) }
169
+ end
170
+
171
+ # Only citations of the response text carry offsets into #content.
172
+ # Citations of the thinking blocks or the tool plan point into text
173
+ # RubyLLM exposes elsewhere, so they keep their snippet without a span.
174
+ def parse_citation(data, offsets = {})
175
+ source = Array(data['sources']).first
176
+ document = source_document(source)
177
+ start_index, end_index = citation_span(data, offsets)
178
+
179
+ Citation.new(
180
+ url: citation_url(document),
181
+ title: document['title'] || document['id'],
182
+ cited_text: document['text'] || document['snippet'],
183
+ text: data['text'],
184
+ start_index: start_index,
185
+ end_index: end_index,
186
+ source_index: source_index(source)
187
+ )
188
+ end
189
+
190
+ def citation_span(data, offsets)
191
+ return [nil, nil] unless text_citation?(data)
192
+
193
+ offset = offsets[data['content_index'] || 0]
194
+ return [nil, nil] unless offset
195
+
196
+ [data['start'] && (offset + data['start']), data['end'] && (offset + data['end'])]
197
+ end
198
+
199
+ def text_citation?(data)
200
+ type = data['type']
201
+ type.nil? || type == 'TEXT_CONTENT'
202
+ end
203
+
204
+ def source_document(source)
205
+ return {} unless source
206
+
207
+ source['document'] || source['tool_output'] || {}
208
+ end
209
+
210
+ def citation_url(document)
211
+ url = document['url']
212
+ url if url.is_a?(String) && url.match?(%r{\Ahttps?://}i)
213
+ end
214
+
215
+ # Documents RubyLLM sends, and those Cohere numbers itself, are
216
+ # identified as doc:N where N is the document's position.
217
+ def source_index(source)
218
+ id = source && source['id']
219
+ return unless id.is_a?(String)
220
+
221
+ match = id.match(/\Adoc:(\d+)\z/)
222
+ match && match[1].to_i
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Cohere
6
+ # Cohere datasets for batch input and generated results.
7
+ class Datasets < Protocols::Files
8
+ def upload(file, filename: nil, purpose: nil, expires_in: nil, uri: nil, content_type: nil,
9
+ provider_options: {})
10
+ raise ArgumentError, 'Cohere datasets require purpose: with a dataset type' unless purpose
11
+ raise ArgumentError, 'Cohere datasets do not accept expires_in or uri' if expires_in || uri
12
+
13
+ attachment = file_attachment(file, filename:)
14
+ options = { name: attachment.filename, type: purpose, keep_original_file: true }
15
+ .merge(provider_options.transform_keys(&:to_sym))
16
+ response = @connection.post('v1/datasets', { data: file_part(attachment, content_type:) },
17
+ idempotent: false) do |request|
18
+ request.headers.delete('Content-Type')
19
+ request.params.update(options)
20
+ end
21
+ find(response.body.fetch('id'))
22
+ end
23
+
24
+ def download(file_id)
25
+ file = wait_for_validation(file_id)
26
+ parts = dataset_parts(file.metadata)
27
+ originals = parts.filter_map { |part| part['original_url'] }.uniq
28
+ if parts.any? && parts.all? { |part| part['original_url'] }
29
+ return originals.map do |url|
30
+ download_part(url)
31
+ end.join
32
+ end
33
+
34
+ records(file).map { |row| "#{JSON.generate(row)}\n" }.join
35
+ end
36
+
37
+ def records(file)
38
+ load_avro
39
+ dataset_parts(file.metadata).flat_map do |part|
40
+ reader = nil
41
+ content = StringIO.new(download_part(part.fetch('url')))
42
+ reader = Avro::DataFile::Reader.new(content, Avro::IO::DatumReader.new)
43
+ reader.to_a
44
+ ensure
45
+ reader&.close
46
+ end
47
+ end
48
+
49
+ def wait_for_validation(id)
50
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @config.request_timeout
51
+ loop do
52
+ file = find(id)
53
+ return file if file.status == 'validated'
54
+ if file.status == 'failed'
55
+ raise Error, "Cohere dataset #{id} failed validation: #{file.metadata['validation_error']}"
56
+ end
57
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
58
+ raise Error, "Cohere dataset validation timed out: #{id}"
59
+ end
60
+
61
+ sleep 1
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def files_url
68
+ 'v1/datasets'
69
+ end
70
+
71
+ def parse_file_response(response)
72
+ data = response.fetch('dataset')
73
+ parts = dataset_parts(data)
74
+ original = parts.first&.fetch('original_url', nil)
75
+ filename = original ? File.basename(URI.parse(original).path) : "#{data.fetch('name')}.jsonl"
76
+ mime_type = if File.extname(filename) == '.jsonl'
77
+ 'application/jsonl'
78
+ else
79
+ RubyLLM::Files::MimeType.for(name: filename)
80
+ end
81
+ uploaded_file(data, id: data.fetch('id'), filename:, mime_type:,
82
+ created_at: timestamp(data['created_at']), status: data['validation_status'],
83
+ purpose: data['dataset_type'], downloadable: !parts.empty?)
84
+ end
85
+
86
+ def dataset_parts(data)
87
+ Array(data['dataset_parts']).sort_by.with_index { |part, index| part.fetch('index', index) }
88
+ end
89
+
90
+ def download_part(url)
91
+ Transport::Connection.basic(@config).get(url).body
92
+ end
93
+
94
+ def load_avro
95
+ require 'avro'
96
+ rescue LoadError
97
+ raise LoadError, 'Add gem "avro" to your Gemfile to read Cohere batch results and processed datasets'
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end