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
@@ -38,27 +38,27 @@ module RubyLLM
38
38
 
39
39
  Chunk.new(
40
40
  role: :assistant,
41
- model: extract_model_id(data),
42
- content: extract_content_delta(data, delta_type),
43
- citations: extract_citations_delta(data, delta_type),
41
+ model: parse_model_id(data),
42
+ content: parse_content_delta(data, delta_type),
43
+ citations: parse_block_citations(data),
44
44
  thinking: Thinking.build(
45
- text: extract_thinking_delta(data, delta_type),
46
- signature: extract_signature_delta(data, delta_type)
45
+ text: parse_thinking_delta(data, delta_type),
46
+ signature: parse_signature_delta(data, delta_type)
47
47
  ),
48
- input_tokens: extract_input_tokens(data),
49
- output_tokens: extract_output_tokens(data),
50
- thinking_tokens: extract_thinking_tokens(data),
51
- cache_read_tokens: extract_cache_read_tokens(data),
52
- cache_write_tokens: extract_cache_write_tokens(data),
53
- cache_write_tokens_by_ttl: extract_cache_write_by_ttl(data),
54
- server_tool_use: extract_server_tool_use(data),
48
+ input_tokens: parse_input_tokens(data),
49
+ output_tokens: parse_output_tokens(data),
50
+ thinking_tokens: parse_thinking_tokens(data),
51
+ cache_read_tokens: parse_cache_read_tokens(data),
52
+ cache_write_tokens: parse_cache_write_tokens(data),
53
+ cache_write_tokens_by_ttl: parse_cache_write_by_ttl(data),
54
+ server_tool_use: parse_server_tool_use(data),
55
55
  tool_calls: extract_tool_calls(data),
56
56
  finish_reason: normalize_finish_reason(data.dig('delta', 'stop_reason')),
57
57
  **stream_end_fields(data)
58
58
  )
59
59
  end
60
60
 
61
- def extract_server_tool_use(data)
61
+ def parse_server_tool_use(data)
62
62
  data.dig('message', 'usage', 'server_tool_use') || data.dig('usage', 'server_tool_use')
63
63
  end
64
64
 
@@ -129,35 +129,56 @@ module RubyLLM
129
129
  def stream_end_fields(data)
130
130
  return {} unless data['type'] == 'message_stop'
131
131
 
132
- blocks = (@stream_blocks || {}).sort.map { |_index, block| block }
132
+ blocks = stream_block_list
133
133
  {
134
- server_tool_calls: extract_server_tool_calls(blocks),
134
+ server_tool_calls: parse_server_tool_calls(blocks),
135
135
  raw_content: @saw_server_block ? blocks : nil,
136
136
  raw_reasoning: parse_thinking_blocks(blocks)
137
137
  }
138
138
  end
139
139
 
140
- def extract_content_delta(data, delta_type)
140
+ def stream_block_list
141
+ (@stream_blocks || {}).sort.map { |_index, block| block }
142
+ end
143
+
144
+ def parse_content_delta(data, delta_type)
141
145
  return data.dig('delta', 'text') if delta_type == 'text_delta'
142
146
 
143
147
  nil
144
148
  end
145
149
 
146
- def extract_citations_delta(data, delta_type)
147
- return nil unless delta_type == 'citations_delta'
150
+ # A citation delta arrives before the text it supports, so a block's
151
+ # citations wait for the block to stop and take its span of the response.
152
+ def parse_block_citations(data)
153
+ return nil unless data['type'] == 'content_block_stop'
154
+
155
+ block = @stream_blocks&.dig(data['index'])
156
+ citations = Array(block && block['citations']).compact
157
+ return nil if citations.empty?
158
+
159
+ text = block['text'].to_s
160
+ start_index = stream_text_length_before(data['index'])
161
+ citations.map do |citation|
162
+ Chat.parse_citation(citation, text:, start_index:, end_index: start_index + text.length)
163
+ end
164
+ end
148
165
 
149
- citation = data.dig('delta', 'citation')
150
- citation ? [Chat.parse_citation(citation)] : nil
166
+ def stream_text_length_before(index)
167
+ @stream_blocks.sum do |block_index, block|
168
+ block_index < index && block['type'] == 'text' ? block['text'].to_s.length : 0
169
+ end
151
170
  end
152
171
 
153
- def extract_thinking_delta(data, delta_type)
172
+ def parse_thinking_delta(data, delta_type)
154
173
  return data.dig('delta', 'thinking') if delta_type == 'thinking_delta'
174
+ return '' if delta_type == 'signature_delta'
155
175
 
156
176
  nil
157
177
  end
158
178
 
159
- def extract_signature_delta(data, delta_type)
179
+ def parse_signature_delta(data, delta_type)
160
180
  return data.dig('delta', 'signature') if delta_type == 'signature_delta'
181
+ return parse_thinking_signature(stream_block_list) if data['type'] == 'message_stop'
161
182
 
162
183
  nil
163
184
  end
@@ -22,42 +22,58 @@ module RubyLLM
22
22
  block['type'] == 'server_tool_use' && block['name'].to_s.start_with?('tool_search_tool_')
23
23
  end
24
24
 
25
- def format_tool_result(msg)
25
+ def render_tool_result(msg, citations: false)
26
26
  {
27
27
  role: 'user',
28
- content: [format_tool_result_block(msg)]
28
+ content: [render_tool_result_block(msg, citations:)]
29
29
  }
30
30
  end
31
31
 
32
- def format_tool_result_block(msg)
33
- {
32
+ def render_tool_result_block(msg, citations: false, tool_call: nil)
33
+ block = {
34
34
  type: 'tool_result',
35
- tool_use_id: msg.tool_call_id,
36
- content: format_tool_result_content(msg)
35
+ tool_use_id: msg.provider_call_id,
36
+ content: render_tool_result_content(msg, citations:)
37
37
  }
38
+ return block unless tool_call&.namespace
39
+
40
+ block[:toolset_name] = tool_call.namespace
41
+ error = msg.tool_error_message
42
+ return block unless error
43
+
44
+ block.merge(is_error: true, content: [{ type: 'text', text: error.to_s }])
38
45
  end
39
46
 
40
- def format_tool_result_content(msg)
47
+ def render_tool_result_content(msg, citations: false)
41
48
  search_results = RubyLLM::SearchResults.from_content(msg.content)
42
49
  return search_results.results.map { |result| search_result_block(result) } if search_results
43
50
 
44
51
  content = msg.content
45
- content = nil if content && content.empty?
52
+ content = nil if content&.strip&.empty?
46
53
  content = '(no output)' if content.nil? && msg.attachments.empty?
47
54
 
48
- Media.format_content(content, msg.attachments)
55
+ Media.render_content(content, msg.attachments, citations:)
49
56
  end
50
57
 
51
58
  def search_result_block(result)
52
59
  {
53
60
  type: 'search_result',
54
- source: result[:url] || result[:title],
61
+ source: result[:source_id] || result[:url] || result[:title],
55
62
  title: result[:title],
56
63
  content: [{ type: 'text', text: result[:text] }],
57
64
  citations: { enabled: true }
58
65
  }
59
66
  end
60
67
 
68
+ CLIENT_TOOLSETS = {
69
+ 'computer' => 'Not executed: an earlier computer action in this turn failed.',
70
+ 'browser' => 'Not executed: an earlier action in this turn failed.'
71
+ }.freeze
72
+
73
+ def render_tools(tools)
74
+ tools.values.reject(&:namespace).map { |tool| function_for(tool) }
75
+ end
76
+
61
77
  def function_for(tool)
62
78
  input_schema = tool.parameters_schema ||
63
79
  RubyLLM::Tool::SchemaDefinition.from_parameters(tool.declared_parameters)&.json_schema
@@ -69,8 +85,6 @@ module RubyLLM
69
85
  }
70
86
  declaration[:defer_loading] = true if deferred?(tool)
71
87
 
72
- return declaration if tool.provider_options.empty?
73
-
74
88
  RubyLLM::Support::Utils.deep_merge(declaration, tool.provider_options)
75
89
  end
76
90
 
@@ -107,19 +121,19 @@ module RubyLLM
107
121
 
108
122
  def extract_tool_calls(data)
109
123
  if json_delta?(data)
110
- extract_tool_call_delta(data)
124
+ parse_tool_call_delta(data)
111
125
  elsif content_block_start?(data)
112
- extract_tool_call_start(data)
126
+ parse_tool_call_start(data)
113
127
  else
114
128
  parse_tool_calls(data['content_block'])
115
129
  end
116
130
  end
117
131
 
118
- def extract_tool_call_delta(data)
119
- { data['index'] => ToolCall.new(id: nil, name: nil, arguments: data.dig('delta', 'partial_json')) }
132
+ def parse_tool_call_delta(data)
133
+ { data['index'] => ToolCall.new(name: nil, arguments: data.dig('delta', 'partial_json')) }
120
134
  end
121
135
 
122
- def extract_tool_call_start(data)
136
+ def parse_tool_call_start(data)
123
137
  tool_calls = parse_tool_calls(data['content_block'])
124
138
  return tool_calls if tool_calls.nil? || data['index'].nil?
125
139
 
@@ -131,22 +145,20 @@ module RubyLLM
131
145
  end
132
146
 
133
147
  def parse_tool_calls(content_blocks)
134
- return nil if content_blocks.nil?
135
-
136
148
  content_blocks = [content_blocks] unless content_blocks.is_a?(Array)
137
149
 
138
- tool_calls = {}
139
- content_blocks.each do |block|
150
+ calls = content_blocks.filter_map do |block|
140
151
  next unless block && block['type'] == 'tool_use'
141
152
 
142
- tool_calls[block['id']] = ToolCall.new(
143
- id: block['id'],
153
+ ToolCall.new(
154
+ provider_call_id: block['id'],
144
155
  name: block['name'],
156
+ namespace: block['toolset_name'],
145
157
  arguments: block['input']
146
158
  )
147
159
  end
148
160
 
149
- tool_calls.empty? ? nil : tool_calls
161
+ ToolCall.index(calls, by: :provider_call_id) unless calls.empty?
150
162
  end
151
163
 
152
164
  def default_input_schema
@@ -21,6 +21,7 @@ module RubyLLM
21
21
  # web_search/web_fetch pin allowed_callers to direct invocation: their
22
22
  # 2026 tool versions otherwise default to requiring programmatic tool
23
23
  # calling, which not every model supports.
24
+ MCP_BETA = 'mcp-client-2025-11-20'
24
25
  SERVER_TOOL_ALIASES = {
25
26
  web_search: { tool: { type: 'web_search_20260318', name: 'web_search', allowed_callers: ['direct'] } },
26
27
  web_fetch: { tool: { type: 'web_fetch_20260318', name: 'web_fetch', allowed_callers: ['direct'] } },
@@ -35,11 +36,16 @@ module RubyLLM
35
36
  .merge(Anthropic.mcp_tool_filter(options))
36
37
  .merge(options.slice(:default_config, :configs)),
37
38
  payload: { mcp_servers: [server] },
38
- headers: { 'anthropic-beta' => 'mcp-client-2025-11-20' }
39
+ headers: { 'anthropic-beta' => MCP_BETA }
39
40
  }
40
41
  end
41
42
  }.freeze
42
43
 
44
+ # Returns the pages the message's web searches returned, as WebSource objects.
45
+ def self.parse_web_sources(message)
46
+ Chat.parse_web_sources(message)
47
+ end
48
+
43
49
  def self.mcp_tool_filter(options) # :nodoc:
44
50
  approval = options[:require_approval]
45
51
  unless approval.nil? || approval.to_s == 'never'
@@ -58,6 +64,14 @@ module RubyLLM
58
64
  SERVER_TOOL_ALIASES
59
65
  end
60
66
 
67
+ def tool_search?
68
+ true
69
+ end
70
+
71
+ def skipped_tool_call_error(tool_call)
72
+ Tools::CLIENT_TOOLSETS[tool_call.namespace]
73
+ end
74
+
61
75
  # Server tools can pause a long turn with stop_reason pause_turn; the
62
76
  # API expects the conversation back verbatim to continue it. Segments
63
77
  # are merged into the single Message the caller sees.
@@ -79,11 +93,26 @@ module RubyLLM
79
93
  private
80
94
 
81
95
  def sync_response(payload, additional_headers = {})
82
- super(payload, apply_files_beta(additional_headers, payload))
96
+ super(payload, beta_headers(additional_headers, payload))
83
97
  end
84
98
 
85
99
  def stream_response(payload, additional_headers = {}, &)
86
- super(payload, apply_files_beta(additional_headers, payload), &)
100
+ super(payload, beta_headers(additional_headers, payload), &)
101
+ end
102
+
103
+ def post_count_tokens(payload)
104
+ @connection.post(count_tokens_url, payload) { |request| request.headers.merge!(beta_headers({}, payload)) }
105
+ end
106
+
107
+ def beta_headers(additional_headers, payload)
108
+ apply_binding_beta(apply_files_beta(canonical_beta_headers(additional_headers), payload), payload)
109
+ end
110
+
111
+ def merge_tool_headers(tool_headers, headers)
112
+ headers = canonical_beta_headers(headers)
113
+ return super unless tool_headers[BETA_HEADER] && headers[BETA_HEADER]
114
+
115
+ super.merge(BETA_HEADER => join_betas(tool_headers[BETA_HEADER], headers[BETA_HEADER]))
87
116
  end
88
117
 
89
118
  def merge_turn_segments(segments)
@@ -93,10 +122,10 @@ module RubyLLM
93
122
  Message.new(
94
123
  role: :assistant,
95
124
  content: segments.filter_map(&:content).join,
96
- citations: segments.flat_map(&:citations),
125
+ citations: merge_citations(segments),
97
126
  server_tool_calls: segments.flat_map(&:server_tool_calls),
98
127
  raw_content: merge_raw_content(segments),
99
- thinking: final.thinking,
128
+ thinking: merge_thinking(segments),
100
129
  tool_calls: merge_tool_calls(segments),
101
130
  tokens: Tokens.aggregate(segments.map(&:tokens)),
102
131
  finish_reason: final.finish_reason,
@@ -105,10 +134,36 @@ module RubyLLM
105
134
  )
106
135
  end
107
136
 
137
+ # A signature covers one segment's thinking, so joined text carries
138
+ # none; replay sends each segment's own blocks from raw_content.
139
+ def merge_thinking(segments)
140
+ thoughts = segments.filter_map(&:thinking)
141
+ return thoughts.first if thoughts.size <= 1
142
+
143
+ texts = thoughts.filter_map(&:text)
144
+ Thinking.build(text: (texts.join unless texts.empty?))
145
+ end
146
+
147
+ def merge_citations(segments)
148
+ offset = 0
149
+ segments.flat_map do |segment|
150
+ citations = segment.citations.map { |citation| offset_citation(citation, offset) }
151
+ offset += segment.content.to_s.length
152
+ citations
153
+ end
154
+ end
155
+
156
+ def offset_citation(citation, offset)
157
+ return citation unless citation.start_index && citation.end_index
158
+
159
+ Citation.new(citation.to_h.merge(start_index: citation.start_index + offset,
160
+ end_index: citation.end_index + offset))
161
+ end
162
+
108
163
  def merge_raw_content(segments)
109
164
  return unless segments.any?(&:raw_content)
110
165
 
111
- segments.flat_map { |segment| segment.raw_content || format_message(segment)[:content] }
166
+ segments.flat_map { |segment| segment.raw_content || render_message(segment)[:content] }
112
167
  end
113
168
 
114
169
  def merge_tool_calls(segments)
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Protocols
7
+ module Azure
8
+ class Speech
9
+ # Fast transcription on Azure AI Speech. The request is multipart:
10
+ # the audio, and a JSON definition carrying the options. Several
11
+ # languages make a candidate list Azure identifies the spoken one
12
+ # from, a prompt becomes a phrase list, and asking for speakers
13
+ # turns on diarization.
14
+ module Transcription
15
+ # Azure masks profanity unless told otherwise. Turned off so the
16
+ # transcript reads like the transcripts every other provider
17
+ # returns; pass <tt>provider_options: { profanityFilterMode: "Masked" }</tt>
18
+ # to mask it.
19
+ DEFAULT_DEFINITION = { profanityFilterMode: 'None' }.freeze
20
+
21
+ def render_transcription_options(timestamps:, **)
22
+ return {} if timestamps.nil? || timestamps == :word
23
+
24
+ raise ArgumentError, 'Azure Speech transcription timestamps must be word'
25
+ end
26
+
27
+ def rejected_options(operation)
28
+ operation == :transcribe ? %i[format temperature speaker_references named_speakers] : super
29
+ end
30
+
31
+ def transcription_url
32
+ @provider.azure_speech_transcription_url
33
+ end
34
+
35
+ def render_transcription_payload(file_part, language: nil, speaker_names: nil, prompt: nil,
36
+ provider_options: {}, **)
37
+ {
38
+ audio: file_part,
39
+ definition: JSON.generate(render_transcription_definition(language:, speaker_names:, prompt:,
40
+ provider_options:))
41
+ }
42
+ end
43
+
44
+ def render_transcription_definition(language: nil, speaker_names: nil, prompt: nil, provider_options: {})
45
+ definition = DEFAULT_DEFINITION.dup
46
+ definition[:locales] = Array(language) if language
47
+ definition[:diarization] = { enabled: true } if speaker_names
48
+ definition[:phraseList] = { phrases: Array(prompt) } if prompt
49
+ Support::Utils.deep_merge(definition, provider_options)
50
+ end
51
+
52
+ def transcribe(audio_file, model:, language:, speaker_names: nil, provider_options: {}, prompt: nil, **)
53
+ raise_transcription_streaming_unsupported if block_given?
54
+
55
+ track_usage(:transcription) do
56
+ file_part = build_audio_file_part(audio_file)
57
+ payload = render_transcription_payload(file_part, language:, speaker_names:, prompt:, provider_options:)
58
+ parse_transcription_response(post_transcription(payload), model:)
59
+ rescue RubyLLM::Error => e
60
+ raise unless no_language_identified?(e.response)
61
+
62
+ RubyLLM::Transcription.new(text: '', model:)
63
+ end
64
+ end
65
+
66
+ def parse_transcription_response(response, model:)
67
+ data = response.body || {}
68
+ phrases = Array(data['phrases'])
69
+ duration = data['durationMilliseconds'] && (data['durationMilliseconds'] / 1000.0)
70
+
71
+ RubyLLM::Transcription.new(
72
+ text: Array(data['combinedPhrases']).map { |phrase| phrase['text'] }.join("\n"),
73
+ model: model,
74
+ language: phrases.filter_map { |phrase| phrase['locale'] }.first,
75
+ duration:,
76
+ audio_seconds: billed_seconds(response) || duration,
77
+ segments: phrases.map { |phrase| timed(phrase.except('words')) },
78
+ words: words_in(phrases)
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def post_transcription(payload)
85
+ @connection.post(transcription_url, payload, usage: @usage_tracker) do |request|
86
+ request.headers.merge!(@provider.azure_speech_headers)
87
+ end
88
+ end
89
+
90
+ # Given candidate languages, Azure answers silence with a 422 that
91
+ # bills nothing, where every other provider returns an empty
92
+ # transcript.
93
+ def no_language_identified?(response)
94
+ return false unless response&.status == 422
95
+
96
+ body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
97
+ body.is_a?(Hash) && body.dig('innerError', 'code') == 'NoLanguageIdentified'
98
+ rescue JSON::ParserError
99
+ false
100
+ end
101
+
102
+ # Azure bills whole seconds and reports them in a header, as
103
+ # <tt>CognitiveServices.SpeechServices.BatchSpeechtoTextSync=4</tt>.
104
+ def billed_seconds(response)
105
+ response.headers['csp-billing-usage'].to_s.scan(/=(\d+(?:\.\d+)?)/).flatten.map(&:to_f).max
106
+ end
107
+
108
+ def words_in(phrases)
109
+ phrases.flat_map do |phrase|
110
+ Array(phrase['words']).map { |word| timed(word.merge(phrase.slice('speaker'))) }
111
+ end
112
+ end
113
+
114
+ def timed(part)
115
+ offset = part['offsetMilliseconds']
116
+ return part unless offset
117
+
118
+ part.merge('start' => offset / 1000.0,
119
+ 'end' => (offset + part['durationMilliseconds'].to_i) / 1000.0)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ module Azure
6
+ # The Azure AI Speech API on an Azure AI Services resource. Only fast
7
+ # transcription is implemented; chat, embeddings, and images go through
8
+ # the resource's OpenAI-compatible protocols.
9
+ class Speech < Protocol
10
+ include Speech::Transcription
11
+ end
12
+ end
13
+ end
14
+ end
@@ -45,7 +45,8 @@ module RubyLLM
45
45
 
46
46
  def find_batch(id)
47
47
  data = @provider.signed_get(@provider.control_api_base, bedrock_job_url(id)).body
48
- protocol = @provider.embedding_batch_protocol(data['modelId']) if data['modelInvocationType'] == 'InvokeModel'
48
+ invoke_model = data['modelInvocationType'] == 'InvokeModel'
49
+ protocol = @provider.invoke_model_batch_protocol(data['modelId']) if invoke_model
49
50
  parser = protocol ? protocol.new(@provider) : self
50
51
  parser.send(:parse_batch_response, data).tap do |result|
51
52
  result[:batch_protocol] = protocol if protocol
@@ -104,8 +105,7 @@ module RubyLLM
104
105
  {
105
106
  id: data['jobArn'] || data['jobIdentifier'],
106
107
  raw_status: status,
107
- completed: TERMINAL.include?(status),
108
- request_counts: bedrock_request_counts(data)
108
+ completed: TERMINAL.include?(status)
109
109
  }
110
110
  end
111
111
 
@@ -117,11 +117,29 @@ module RubyLLM
117
117
  :failed
118
118
  end
119
119
 
120
- def bedrock_request_counts(data)
121
- {
122
- 'submitted' => data['submitTime'],
123
- 'completed' => data['endTime']
124
- }.compact
120
+ def bedrock_batch_jsonl(requests)
121
+ requests.map do |request|
122
+ JSON.generate(recordId: request[:custom_id], modelInput: bedrock_model_input(request))
123
+ end.join("\n")
124
+ end
125
+
126
+ def bedrock_model_input(request)
127
+ batch_payload(request)
128
+ end
129
+
130
+ def parse_bedrock_outputs(outputs, model:)
131
+ outputs.flat_map do |body|
132
+ body.to_s.each_line.filter_map { |line| parse_bedrock_record(JSON.parse(line), model:) }
133
+ end
134
+ end
135
+
136
+ def parse_bedrock_record(line, model:)
137
+ record_id = line['recordId'] || line['record_id']
138
+ index = batch_result_index(record_id)
139
+ output = line['modelOutput']
140
+ return [index, parse_bedrock_model_output(output, model:)] if output
141
+
142
+ [index, nil, batch_failure(record_id, line.dig('error', 'message') || line['errorMessage'])]
125
143
  end
126
144
  end
127
145
  end