ruby_llm 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -44,12 +44,12 @@ module RubyLLM
44
44
  RubyLLM::Support::Utils.deep_merge(definition, tool.provider_options)
45
45
  end
46
46
 
47
- def format_tool_calls(tool_calls)
47
+ def render_tool_calls(tool_calls)
48
48
  return nil unless tool_calls&.any?
49
49
 
50
- tool_calls.map do |_, tc|
50
+ tool_calls.map do |tc|
51
51
  call = {
52
- id: tc.id,
52
+ id: tc.provider_call_id,
53
53
  type: 'function',
54
54
  function: {
55
55
  name: tc.name,
@@ -65,39 +65,26 @@ module RubyLLM
65
65
  end
66
66
  end
67
67
 
68
- def parse_tool_call_arguments(tool_call, response: nil, finish_reason: nil)
69
- arguments = tool_call.dig('function', 'arguments')
70
-
71
- if arguments.nil? || arguments.empty?
72
- {}
73
- else
74
- JSON.parse(arguments)
75
- end
76
- rescue JSON::ParserError => e
77
- raise ToolCallParseError.new(response: response, finish_reason: finish_reason), cause: e
78
- end
79
-
80
68
  # Streaming deltas key by index so id-less argument fragments find
81
69
  # their call even when a delta batches or interleaves several calls;
82
70
  # complete responses key by id, which tool results join on.
83
71
  def parse_tool_calls(tool_calls, parse_arguments: true, response: nil, finish_reason: nil, stream_keys: false)
84
72
  return nil unless tool_calls&.any?
85
73
 
86
- tool_calls.to_h do |tc|
74
+ calls = tool_calls.map do |tc|
87
75
  [
88
- stream_keys ? tc['index'] || tc['id'] : tc['id'],
89
- ToolCall.new(
90
- id: tc['id'],
91
- name: tc.dig('function', 'name'),
92
- arguments: if parse_arguments
93
- parse_tool_call_arguments(tc, response: response, finish_reason: finish_reason)
94
- else
95
- tc.dig('function', 'arguments')
96
- end,
97
- thought_signature: extract_tool_call_thought_signature(tc)
98
- )
76
+ tc['index'] || tc['id'],
77
+ parse_tool_call(tc, parse_arguments:, response:, finish_reason:)
99
78
  ]
100
79
  end
80
+ stream_keys ? calls.to_h : ToolCall.index(calls.map(&:last), by: :provider_call_id)
81
+ end
82
+
83
+ def parse_tool_call(call, parse_arguments:, response:, finish_reason:)
84
+ arguments = call.dig('function', 'arguments')
85
+ arguments = parse_arguments ? ToolCall.parse_arguments(arguments, response:, finish_reason:) : { arguments: }
86
+ ToolCall.new(provider_call_id: call['id'], name: call.dig('function', 'name'), **arguments,
87
+ thought_signature: parse_tool_call_thought_signature(call))
101
88
  end
102
89
 
103
90
  def build_tool_choice(tool_choice)
@@ -114,7 +101,7 @@ module RubyLLM
114
101
  end
115
102
  end
116
103
 
117
- def extract_tool_call_thought_signature(tool_call)
104
+ def parse_tool_call_thought_signature(tool_call)
118
105
  tool_call.dig('extra_content', 'google', 'thought_signature')
119
106
  end
120
107
  end
@@ -5,6 +5,13 @@ module RubyLLM
5
5
  class ChatCompletions
6
6
  # Audio transcription methods for the OpenAI API integration
7
7
  module Transcription
8
+ STREAM_TYPES = {
9
+ 'transcript.text.delta' => RubyLLM::TranscriptionChunk::DELTA,
10
+ 'transcript.text.partial' => RubyLLM::TranscriptionChunk::PARTIAL,
11
+ 'transcript.text.segment' => RubyLLM::TranscriptionChunk::SEGMENT,
12
+ 'transcript.text.done' => RubyLLM::TranscriptionChunk::DONE
13
+ }.freeze
14
+
8
15
  module_function
9
16
 
10
17
  def transcription_url
@@ -44,7 +51,7 @@ module RubyLLM
44
51
  return nil unless references
45
52
 
46
53
  references.map do |ref|
47
- Attachment.new(ref, config: @config).for_llm
54
+ (ref.is_a?(Attachment) ? ref : Attachment.new(ref, config: @config)).for_llm
48
55
  end
49
56
  end
50
57
 
@@ -74,7 +81,7 @@ module RubyLLM
74
81
  end
75
82
 
76
83
  def build_transcription_chunk(data)
77
- type = data['type']
84
+ type = STREAM_TYPES[data['type']]
78
85
 
79
86
  RubyLLM::TranscriptionChunk.new(
80
87
  type: type,
@@ -96,6 +103,7 @@ module RubyLLM
96
103
  language: data['language'],
97
104
  duration: transcription_duration(usage),
98
105
  segments: streamed_transcription_segments(chunks, data),
106
+ audio_seconds: billed_audio_seconds(usage),
99
107
  reported_cost: reported_cost(usage),
100
108
  **transcription_tokens(usage)
101
109
  )
@@ -129,6 +137,7 @@ module RubyLLM
129
137
  duration: data['duration'] || transcription_duration(usage),
130
138
  segments: data['segments'],
131
139
  words: data['words'],
140
+ audio_seconds: billed_audio_seconds(usage, data['duration']),
132
141
  reported_cost: reported_cost(usage),
133
142
  **transcription_tokens(usage)
134
143
  )
@@ -144,6 +153,12 @@ module RubyLLM
144
153
  def transcription_duration(usage)
145
154
  usage['seconds']
146
155
  end
156
+
157
+ def billed_audio_seconds(usage, duration = nil)
158
+ return usage['seconds'] if usage['type'] == 'duration'
159
+
160
+ duration if transcription_tokens(usage).values.all?(&:nil?)
161
+ end
147
162
  end
148
163
  end
149
164
  end
@@ -4,6 +4,10 @@ module RubyLLM
4
4
  module Protocols
5
5
  # The OpenAI Chat Completions API — the lingua franca of LLM APIs.
6
6
  class ChatCompletions < Protocol
7
+ # The endpoint paths a base URL of an OpenAI-compatible API must not
8
+ # end in, since RubyLLM adds them.
9
+ ENDPOINT_PATHS = %r{/(?:chat/completions|completions|responses|embeddings)\z}
10
+
7
11
  include ChatCompletions::Chat
8
12
  include ChatCompletions::Embeddings
9
13
  include ChatCompletions::Models
@@ -38,7 +38,7 @@ module RubyLLM
38
38
  return [] if data['output_dataset_id'].to_s.empty?
39
39
 
40
40
  file = datasets.wait_for_validation(data.fetch('output_dataset_id'))
41
- model = RubyLLM.models.find(data.fetch('model'), provider: @provider.slug, config: @config)
41
+ model = batch_model(data.fetch('model'))
42
42
  parser = self.class.new(@provider, model)
43
43
  datasets.records(file).map { |row| parse_batch_result(row, parser:, model: model.id) }
44
44
  end
@@ -49,6 +49,12 @@ module RubyLLM
49
49
  @datasets ||= Cohere::Datasets.new(@provider)
50
50
  end
51
51
 
52
+ def batch_model(id)
53
+ RubyLLM.models.find(id, provider: @provider.slug, config: @config)
54
+ rescue ModelNotFoundError
55
+ Model.default(id, @provider.slug)
56
+ end
57
+
52
58
  def batch_data(id)
53
59
  @connection.get("v2/batches/#{id}").body.fetch('batch')
54
60
  end
@@ -80,13 +86,14 @@ module RubyLLM
80
86
  body = row.fetch('body')
81
87
  return [index, nil, batch_failure(custom_id, 'generation failed')] if body['finish_reason'] == 'ERROR'
82
88
 
83
- result = if body['embeddings']
84
- parser.send(:parse_embedding_response, Response.new(body), model:,
85
- text: shape == 'array' ? [] : nil)
86
- else
87
- parser.send(:parse_completion_body, body, raw: body)
88
- end
89
- [index, result]
89
+ billed = body.merge('model' => model) unless body['embeddings']
90
+ parsed_batch_result(index, custom_id, billed, parser:) do
91
+ if body['embeddings']
92
+ parser.send(:parse_embedding_response, Response.new(body), model:, text: shape == 'array' ? [] : nil)
93
+ else
94
+ parser.send(:parse_completion_body, body, raw: body)
95
+ end
96
+ end
90
97
  end
91
98
  end
92
99
  end
@@ -16,21 +16,21 @@ module RubyLLM
16
16
  def normalize_finish_reason(reason)
17
17
  return nil if reason.nil?
18
18
 
19
- finish_reasons.fetch(reason.to_s) { reason.to_s.to_sym }
19
+ finish_reasons.fetch(reason.to_s) { reason.to_s.downcase.to_sym }
20
20
  end
21
21
 
22
22
  def completion_url
23
23
  'v2/chat'
24
24
  end
25
25
 
26
- # rubocop:disable-next Lint/UnusedMethodArgument
27
26
  def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
28
27
  schema: nil, thinking: nil, citations: false, caching: nil, tool_prefs: nil)
29
28
  warn_unsupported_citations(model) if citations && !model.supports?(:citations)
29
+ reject_caching_options(caching)
30
30
 
31
31
  payload = {
32
32
  model: model.id,
33
- messages: format_messages(messages, citations: citations),
33
+ messages: render_messages(messages, citations: citations),
34
34
  stream: stream
35
35
  }
36
36
 
@@ -40,10 +40,18 @@ module RubyLLM
40
40
  payload
41
41
  end
42
42
 
43
+ def cache_option_names = []
44
+
45
+ def reject_caching_options(caching)
46
+ return unless caching.is_a?(Hash) && caching.any?
47
+
48
+ raise ArgumentError, "Cohere caches on its own and does not accept #{caching.keys.join(', ')} for caching"
49
+ end
50
+
43
51
  def add_optional_fields(payload, messages, temperature:, max_output_tokens:, citations:, schema:)
44
52
  payload[:temperature] = temperature unless temperature.nil?
45
53
  payload[:max_tokens] = max_output_tokens unless max_output_tokens.nil?
46
- payload[:documents] = Media.format_documents(messages) if citations && Media.documents?(messages)
54
+ payload[:documents] = Media.render_documents(messages) if citations && Media.documents?(messages)
47
55
  payload[:response_format] = build_response_format(schema) if schema
48
56
  end
49
57
 
@@ -81,28 +89,28 @@ module RubyLLM
81
89
  payload[:thinking] = { type: 'enabled', token_budget: thinking.budget }.compact
82
90
  end
83
91
 
84
- def format_messages(messages, citations: false)
85
- messages.map { |msg| format_message(msg, citations: citations) }
92
+ def render_messages(messages, citations: false)
93
+ messages.map { |msg| render_message(msg, citations: citations) }
86
94
  end
87
95
 
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
96
+ def render_message(msg, citations: false)
97
+ return Tools.render_tool_result(msg) if msg.tool_result?
98
+ return render_assistant_message(msg) if msg.role == :assistant
91
99
 
92
100
  {
93
101
  role: msg.role.to_s,
94
- content: Media.format_content(msg.content, msg.attachments, citations: citations)
102
+ content: Media.render_content(msg.content, msg.attachments, citations: citations)
95
103
  }
96
104
  end
97
105
 
98
106
  # Cohere returns tool_plan alongside tool calls, and RubyLLM surfaces
99
107
  # it as thinking, but the newer models reject it on the way back in.
100
108
  # It is optional in a request, so the plan stays out of the history.
101
- def format_assistant_message(msg)
109
+ def render_assistant_message(msg)
102
110
  message = { role: 'assistant' }
103
- content = Media.format_content(msg.content, msg.attachments)
111
+ content = Media.render_content(msg.content, msg.attachments)
104
112
  message[:content] = content unless content.empty?
105
- message[:tool_calls] = Tools.format_tool_calls(msg.tool_calls) if msg.tool_call?
113
+ message[:tool_calls] = Tools.render_tool_calls(msg.tool_calls) if msg.tool_call?
106
114
  message
107
115
  end
108
116
 
@@ -111,46 +119,53 @@ module RubyLLM
111
119
 
112
120
  message_data = data['message'] || {}
113
121
  blocks = Array(message_data['content'])
114
- content, offsets = extract_text(blocks)
122
+ content, offsets = parse_text(blocks)
115
123
  finish_reason = normalize_finish_reason(data['finish_reason'])
116
124
 
117
125
  Message.new(
118
126
  role: :assistant,
119
127
  content: content,
120
128
  citations: parse_citations(message_data['citations'], offsets),
121
- thinking: Thinking.build(text: extract_thinking(blocks, message_data)),
129
+ thinking: Thinking.build(text: parse_thinking(blocks, message_data)),
122
130
  tool_calls: Tools.parse_tool_calls(message_data['tool_calls'], response: raw, finish_reason:),
123
131
  finish_reason: finish_reason,
124
132
  model: model&.id,
125
133
  raw: raw,
126
- **usage_tokens(data['usage'] || {})
134
+ **parse_completion_usage(data)
127
135
  )
128
136
  end
129
137
 
138
+ def parse_completion_usage(data)
139
+ usage_tokens(data['usage'] || {})
140
+ end
141
+
130
142
  # Cohere answers a generation that failed on its side with an ERROR
131
143
  # finish reason instead of an error status.
132
144
  def raise_failed_generation(message, response: nil)
133
- raise ServerError.new(message || 'The generation failed due to an internal error', response:)
145
+ raise ServerError.new('The generation failed due to an internal error', response:) unless message
146
+
147
+ raise ServerError.new(provider_message: message, response:, redact: @config&.redact_error_messages)
134
148
  end
135
149
 
136
- # Cohere reports what the model processed under tokens and what it
137
- # charges for under billed_units; the two differ because Cohere does
138
- # not bill its own preamble.
150
+ # Cohere reports what it charges for under billed_units and what the
151
+ # model processed under tokens. The processed counts and cached_tokens
152
+ # include Cohere's own preamble, which it does not bill, so a reply's
153
+ # usage is its billed units.
139
154
  def usage_tokens(usage)
140
155
  tokens = usage['tokens'] || {}
141
156
  billed = usage['billed_units'] || {}
142
157
 
143
158
  {
144
- input_tokens: tokens['input_tokens'] || billed['input_tokens'],
145
- output_tokens: tokens['output_tokens'] || billed['output_tokens'],
146
- cache_read_tokens: usage['cached_tokens']
159
+ input_tokens: billed['input_tokens'] || tokens['input_tokens'],
160
+ output_tokens: billed['output_tokens'] || tokens['output_tokens'],
161
+ thinking_tokens: tokens['reasoning_tokens']
147
162
  }
148
163
  end
149
164
 
150
165
  # Returns the joined text of the response along with the offset each
151
166
  # content block starts at, so citation spans resolve against the
152
167
  # content string RubyLLM exposes.
153
- def extract_text(blocks)
168
+ def parse_text(blocks)
154
169
  text = +''
155
170
  offsets = {}
156
171
 
@@ -166,7 +181,7 @@ module RubyLLM
166
181
 
167
182
  # The tool plan is the model's reasoning for a tool-calling turn, and
168
183
  # is the only reasoning Cohere returns when thinking blocks are absent.
169
- def extract_thinking(blocks, message_data)
184
+ def parse_thinking(blocks, message_data)
170
185
  thoughts = blocks.select { |block| block['type'] == 'thinking' }
171
186
  .map { |block| block['thinking'] }.join
172
187
  thoughts.empty? ? message_data['tool_plan'] : thoughts
@@ -191,6 +206,7 @@ module RubyLLM
191
206
  text: data['text'],
192
207
  start_index: start_index,
193
208
  end_index: end_index,
209
+ source_id: tool_output_id(source),
194
210
  source_index: source_index(source)
195
211
  )
196
212
  end
@@ -220,6 +236,12 @@ module RubyLLM
220
236
  url if url.is_a?(String) && url.match?(%r{\Ahttps?://}i)
221
237
  end
222
238
 
239
+ # Cohere cites a tool output by the id its document was sent with,
240
+ # so a search result comes back with its source_id or URL.
241
+ def tool_output_id(source)
242
+ source['id'] if source && source['type'] == 'tool'
243
+ end
244
+
223
245
  # Documents RubyLLM sends, and those Cohere numbers itself, are
224
246
  # identified as doc:N where N is the document's position.
225
247
  def source_index(source)
@@ -5,10 +5,14 @@ module RubyLLM
5
5
  class Cohere
6
6
  # Cohere datasets for batch input and generated results.
7
7
  class Datasets < Protocols::Files
8
+ def rejected_options(operation)
9
+ operation == :upload ? %i[expires_in uri] : super
10
+ end
11
+
12
+ # rubocop:disable-next Lint/UnusedMethodArgument
8
13
  def upload(file, filename: nil, purpose: nil, expires_in: nil, uri: nil, content_type: nil,
9
14
  provider_options: {})
10
15
  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
16
 
13
17
  attachment = file_attachment(file, filename:)
14
18
  options = { name: attachment.filename, type: purpose, keep_original_file: true }
@@ -88,7 +92,7 @@ module RubyLLM
88
92
  end
89
93
 
90
94
  def download_part(url)
91
- Transport::Connection.basic(@config).get(url).body
95
+ Transport::Connection.download(url, @config)
92
96
  end
93
97
 
94
98
  def load_avro
@@ -13,9 +13,11 @@ module RubyLLM
13
13
  'v2/embed'
14
14
  end
15
15
 
16
- # rubocop:disable-next Lint/UnusedMethodArgument
17
- def render_embedding_payload(text, model:, dimensions:, task_type: nil, title: nil, with: [],
18
- provider_options: {})
16
+ def rejected_options(operation)
17
+ operation == :embed ? %i[title] : super
18
+ end
19
+
20
+ def render_embedding_payload(text, model:, dimensions:, task_type: nil, with: [], provider_options: {}, **)
19
21
  image_only = with.any? && separate_image_embeddings?(model)
20
22
  payload = {
21
23
  model: model,
@@ -28,7 +30,7 @@ module RubyLLM
28
30
  Support::Utils.deep_merge(payload, provider_options)
29
31
  end
30
32
 
31
- def supports_embedding_media?
33
+ def embedding_media?
32
34
  true
33
35
  end
34
36
 
@@ -63,7 +65,7 @@ module RubyLLM
63
65
 
64
66
  raise ArgumentError, 'embed one text at a time when embedding attachments' if text.is_a?(Array)
65
67
 
66
- { inputs: [{ content: Media.format_content(text, attachments) }] }
68
+ { inputs: [{ content: Media.render_content(text, attachments) }] }
67
69
  end
68
70
  end
69
71
  end
@@ -18,32 +18,32 @@ module RubyLLM
18
18
  # Cohere user messages accept only text and image_url blocks. Text
19
19
  # attachments become citable documents when citations are on, so they
20
20
  # are pulled out of the message and left out of the content blocks.
21
- def format_content(content, attachments = [], citations: false)
21
+ def render_content(content, attachments = [], citations: false)
22
22
  parts = []
23
- parts << format_text(content) if content
23
+ parts << render_text(content) if content
24
24
 
25
25
  attachments.each do |attachment|
26
26
  raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
27
27
 
28
28
  case attachment.type
29
29
  when :image
30
- parts << format_image(attachment)
30
+ parts << render_image(attachment)
31
31
  when :text
32
- parts << format_text(attachment.for_llm) unless citations
32
+ parts << render_text(attachment.for_llm) unless citations
33
33
  end
34
34
  end
35
35
 
36
36
  parts
37
37
  end
38
38
 
39
- def format_text(text)
39
+ def render_text(text)
40
40
  {
41
41
  type: 'text',
42
42
  text: text
43
43
  }
44
44
  end
45
45
 
46
- def format_image(image)
46
+ def render_image(image)
47
47
  {
48
48
  type: 'image_url',
49
49
  image_url: { url: image_source(image) }
@@ -58,7 +58,7 @@ module RubyLLM
58
58
 
59
59
  # Text attachments across the conversation become the request's
60
60
  # top-level documents array, which is what Cohere cites against.
61
- def format_documents(messages)
61
+ def render_documents(messages)
62
62
  index = -1
63
63
 
64
64
  messages.flat_map do |message|
@@ -31,7 +31,8 @@ module RubyLLM
31
31
  results: parse_rerank_results(data, documents),
32
32
  model: model,
33
33
  raw: data,
34
- input_tokens: tokens['input_tokens'] || billed['input_tokens']
34
+ input_tokens: tokens['input_tokens'] || billed['input_tokens'],
35
+ search_units: billed['search_units']
35
36
  )
36
37
  end
37
38
 
@@ -11,23 +11,32 @@ module RubyLLM
11
11
  completion_url
12
12
  end
13
13
 
14
+ def stream_response(...)
15
+ @stream_thinking = false
16
+ super
17
+ end
18
+
14
19
  def build_chunk(data)
15
20
  type = data['type']
16
- raise_failed_generation(data.dig('delta', 'error')) if data.dig('delta', 'finish_reason') == 'ERROR'
21
+ raise_failed_stream(data) if data.dig('delta', 'finish_reason') == 'ERROR'
17
22
  track_content_lengths(data, type)
18
23
 
19
24
  Chunk.new(
20
25
  role: :assistant,
21
26
  model: model&.id,
22
- content: extract_content_delta(data, type),
23
- citations: extract_citations_delta(data, type),
24
- thinking: Thinking.build(text: extract_thinking_delta(data, type)),
27
+ content: parse_content_delta(data, type),
28
+ citations: parse_citations_delta(data, type),
29
+ thinking: Thinking.build(text: parse_thinking_delta(data, type)),
25
30
  tool_calls: extract_tool_calls(data, type),
26
31
  finish_reason: normalize_finish_reason(data.dig('delta', 'finish_reason')),
27
32
  **stream_usage(data.dig('delta', 'usage'))
28
33
  )
29
34
  end
30
35
 
36
+ def raise_failed_stream(data)
37
+ raise_failed_generation(data.dig('delta', 'error'), response: build_stream_error_response(data, nil, 500))
38
+ end
39
+
31
40
  # Citation spans are offsets into their own content block, so the
32
41
  # running length of every earlier text block is what turns them into
33
42
  # offsets into the accumulated response content.
@@ -47,18 +56,24 @@ module RubyLLM
47
56
  @stream_content_lengths.sum { |block_index, length| block_index < index ? length : 0 }
48
57
  end
49
58
 
50
- def extract_content_delta(data, type)
59
+ def parse_content_delta(data, type)
51
60
  data.dig('delta', 'message', 'content', 'text') if type == 'content-delta'
52
61
  end
53
62
 
54
- def extract_thinking_delta(data, type)
63
+ def parse_thinking_block_delta(data)
64
+ thinking = data.dig('delta', 'message', 'content', 'thinking')
65
+ @stream_thinking = true if thinking
66
+ thinking
67
+ end
68
+
69
+ def parse_thinking_delta(data, type)
55
70
  case type
56
- when 'content-delta' then data.dig('delta', 'message', 'content', 'thinking')
57
- when 'tool-plan-delta' then data.dig('delta', 'message', 'tool_plan')
71
+ when 'content-delta' then parse_thinking_block_delta(data)
72
+ when 'tool-plan-delta' then data.dig('delta', 'message', 'tool_plan') unless @stream_thinking
58
73
  end
59
74
  end
60
75
 
61
- def extract_citations_delta(data, type)
76
+ def parse_citations_delta(data, type)
62
77
  return nil unless type == 'citation-start'
63
78
 
64
79
  citation = data.dig('delta', 'message', 'citations')
@@ -81,7 +96,7 @@ module RubyLLM
81
96
 
82
97
  {
83
98
  data['index'] => ToolCall.new(
84
- id: tool_call['id'],
99
+ provider_call_id: tool_call['id'],
85
100
  name: tool_call.dig('function', 'name'),
86
101
  arguments: tool_call.dig('function', 'arguments').to_s
87
102
  )
@@ -92,7 +107,7 @@ module RubyLLM
92
107
  arguments = data.dig('delta', 'message', 'tool_calls', 'function', 'arguments')
93
108
  return nil unless arguments
94
109
 
95
- { data['index'] => ToolCall.new(id: nil, name: nil, arguments: arguments) }
110
+ { data['index'] => ToolCall.new(name: nil, arguments: arguments) }
96
111
  end
97
112
 
98
113
  def stream_usage(usage)
@@ -55,10 +55,10 @@ module RubyLLM
55
55
  end
56
56
  end
57
57
 
58
- def format_tool_calls(tool_calls)
59
- tool_calls.map do |_, tool_call|
58
+ def render_tool_calls(tool_calls)
59
+ tool_calls.map do |tool_call|
60
60
  {
61
- id: tool_call.id,
61
+ id: tool_call.provider_call_id,
62
62
  type: 'function',
63
63
  function: {
64
64
  name: tool_call.name,
@@ -68,11 +68,11 @@ module RubyLLM
68
68
  end
69
69
  end
70
70
 
71
- def format_tool_result(msg)
71
+ def render_tool_result(msg)
72
72
  {
73
73
  role: 'tool',
74
- tool_call_id: msg.tool_call_id,
75
- content: format_tool_result_content(msg)
74
+ tool_call_id: msg.provider_call_id,
75
+ content: render_tool_result_content(msg)
76
76
  }
77
77
  end
78
78
 
@@ -84,15 +84,15 @@ module RubyLLM
84
84
  # cites tool output against. A tool that attaches files sends its
85
85
  # text and each attachment as blocks, so the model reads what the
86
86
  # tool fetched rather than only the sentence describing it.
87
- def format_tool_result_content(msg)
87
+ def render_tool_result_content(msg)
88
88
  search_results = RubyLLM::SearchResults.from_content(msg.content)
89
89
  return document_blocks(search_results) if search_results
90
90
  return msg.content.to_s if msg.attachments.empty?
91
91
 
92
- [Media.format_text(msg.content.to_s)] + msg.attachments.map do |attachment|
92
+ [Media.render_text(msg.content.to_s)] + msg.attachments.map do |attachment|
93
93
  raise UnsupportedAttachmentError, attachment.mime_type unless supported_message_attachment?(msg, attachment)
94
94
 
95
- Media.format_text(attachment.for_llm)
95
+ Media.render_text(attachment.for_llm)
96
96
  end
97
97
  end
98
98
 
@@ -101,7 +101,7 @@ module RubyLLM
101
101
  {
102
102
  type: 'document',
103
103
  document: {
104
- id: result[:url] || "tool:#{index}",
104
+ id: result[:source_id] || result[:url] || "tool:#{index}",
105
105
  data: { title: result[:title], text: result[:text], url: result[:url] }.compact
106
106
  }
107
107
  }
@@ -111,24 +111,13 @@ module RubyLLM
111
111
  def parse_tool_calls(tool_calls, response: nil, finish_reason: nil)
112
112
  return nil unless tool_calls&.any?
113
113
 
114
- tool_calls.to_h do |tool_call|
115
- [
116
- tool_call['id'],
117
- ToolCall.new(
118
- id: tool_call['id'],
119
- name: tool_call.dig('function', 'name'),
120
- arguments: parse_arguments(tool_call.dig('function', 'arguments'), response, finish_reason)
121
- )
122
- ]
123
- end
124
- end
125
-
126
- def parse_arguments(arguments, response, finish_reason)
127
- return {} if arguments.nil? || arguments.empty?
128
-
129
- JSON.parse(arguments)
130
- rescue JSON::ParserError => e
131
- raise ToolCallParseError.new(response: response, finish_reason: finish_reason), cause: e
114
+ ToolCall.index(tool_calls.map do |tool_call|
115
+ ToolCall.new(
116
+ provider_call_id: tool_call['id'],
117
+ name: tool_call.dig('function', 'name'),
118
+ **ToolCall.parse_arguments(tool_call.dig('function', 'arguments'), response:, finish_reason:)
119
+ )
120
+ end, by: :provider_call_id)
132
121
  end
133
122
  end
134
123
  end