ruby_llm 2.1.1 → 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 +160 -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 +515 -274
  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 +1593 -331
  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 +50 -24
  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 +127 -106
  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 +15 -15
  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 +1 -7
  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 +5 -14
  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 +213 -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
@@ -21,37 +21,37 @@ module RubyLLM
21
21
  end
22
22
  end
23
23
 
24
- def format_content(content, attachments = [], used_document_names: nil, citations: false)
24
+ def render_content(content, attachments = [], used_document_names: nil, citations: false)
25
25
  used_document_names ||= {}
26
26
  blocks = []
27
27
  blocks << { text: content } unless content.nil? || content.empty?
28
28
  attachments.each do |attachment|
29
- blocks << format_attachment(attachment, used_document_names:, citations:)
29
+ blocks << render_attachment(attachment, used_document_names:, citations:)
30
30
  end
31
31
  blocks
32
32
  end
33
33
 
34
- def format_attachment(attachment, used_document_names:, citations: false)
34
+ def render_attachment(attachment, used_document_names:, citations: false)
35
35
  raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
36
36
 
37
37
  if attachment.provider_file?
38
- return format_provider_file_attachment(attachment, used_document_names:, citations:)
38
+ return render_provider_file_attachment(attachment, used_document_names:, citations:)
39
39
  end
40
40
 
41
41
  case attachment.type
42
42
  when :image
43
- format_image_attachment(attachment)
43
+ render_image_attachment(attachment)
44
44
  when :audio
45
- format_audio_attachment(attachment)
45
+ render_audio_attachment(attachment)
46
46
  when :video
47
- format_video_attachment(attachment)
47
+ render_video_attachment(attachment)
48
48
  when :pdf, :document
49
- format_document_attachment(attachment, used_document_names:, citations:)
49
+ render_document_attachment(attachment, used_document_names:, citations:)
50
50
  when :text
51
51
  if citations
52
- format_text_document_attachment(attachment, used_document_names:)
52
+ render_text_document_attachment(attachment, used_document_names:)
53
53
  else
54
- format_text_attachment(attachment)
54
+ render_text_attachment(attachment)
55
55
  end
56
56
  end
57
57
  end
@@ -71,7 +71,7 @@ module RubyLLM
71
71
  '3gpp' => 'three_gp'
72
72
  }.freeze
73
73
 
74
- def format_image_attachment(attachment)
74
+ def render_image_attachment(attachment)
75
75
  {
76
76
  image: {
77
77
  format: attachment.format,
@@ -82,7 +82,7 @@ module RubyLLM
82
82
  }
83
83
  end
84
84
 
85
- def format_audio_attachment(attachment)
85
+ def render_audio_attachment(attachment)
86
86
  {
87
87
  audio: {
88
88
  format: media_format(attachment, AUDIO_FORMAT_ALIASES, SUPPORTED_AUDIO_FORMATS),
@@ -93,7 +93,7 @@ module RubyLLM
93
93
  }
94
94
  end
95
95
 
96
- def format_video_attachment(attachment)
96
+ def render_video_attachment(attachment)
97
97
  {
98
98
  video: {
99
99
  format: media_format(attachment, VIDEO_FORMAT_ALIASES, SUPPORTED_VIDEO_FORMATS),
@@ -112,11 +112,11 @@ module RubyLLM
112
112
  format
113
113
  end
114
114
 
115
- def format_text_attachment(attachment)
115
+ def render_text_attachment(attachment)
116
116
  { text: attachment.for_llm }
117
117
  end
118
118
 
119
- def format_document_attachment(attachment, used_document_names:, citations: false)
119
+ def render_document_attachment(attachment, used_document_names:, citations: false)
120
120
  format = document_format(attachment)
121
121
 
122
122
  raise UnsupportedAttachmentError, attachment.mime_type unless supported_document_format?(attachment)
@@ -136,7 +136,7 @@ module RubyLLM
136
136
  # Text attachments become citable documents when citations are
137
137
  # enabled. Bedrock rejects text-format documents sent as bytes when
138
138
  # citations are on, so the text rides in the source's text member.
139
- def format_text_document_attachment(attachment, used_document_names:)
139
+ def render_text_document_attachment(attachment, used_document_names:)
140
140
  document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
141
141
  {
142
142
  document: {
@@ -148,7 +148,7 @@ module RubyLLM
148
148
  }
149
149
  end
150
150
 
151
- def format_provider_file_attachment(attachment, used_document_names:, citations: false)
151
+ def render_provider_file_attachment(attachment, used_document_names:, citations: false)
152
152
  raise UnsupportedAttachmentError, attachment.mime_type unless supported_document_format?(attachment)
153
153
 
154
154
  document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
@@ -180,11 +180,13 @@ module RubyLLM
180
180
  end
181
181
 
182
182
  def unique_document_name(base_name, used_names)
183
- count = used_names[base_name].to_i
184
- used_names[base_name] = count + 1
185
- return base_name if count.zero?
186
-
187
- "#{base_name}_#{count + 1}"
183
+ name = base_name
184
+ while used_names.key?(name)
185
+ used_names[base_name] += 1
186
+ name = "#{base_name}_#{used_names[base_name]}"
187
+ end
188
+ used_names[name] = 1
189
+ name
188
190
  end
189
191
  end
190
192
  end
@@ -14,17 +14,17 @@ module RubyLLM
14
14
 
15
15
  module_function
16
16
 
17
- def format_reasoning_fields(thinking, model, max_output_tokens = nil)
17
+ def render_reasoning_fields(thinking, model, max_output_tokens = nil)
18
18
  return nil unless thinking&.enabled?
19
- return format_nova_reasoning_fields(thinking, model) if nova_model?(model)
19
+ return render_nova_reasoning_fields(thinking, model) if nova_model?(model)
20
20
  return disabled_reasoning_fields(model) if thinking.enabled == false
21
- return format_adaptive_reasoning_fields(thinking) if adaptive_thinking?(thinking, model)
21
+ return render_adaptive_reasoning_fields(thinking) if adaptive_thinking?(thinking, model)
22
22
 
23
23
  effort = thinking.effort.to_s
24
24
  budget = reasoning_budget(thinking, effort, model, max_output_tokens)
25
25
  return { reasoning_config: { type: 'enabled', budget_tokens: budget } } if budget
26
26
 
27
- format_effort_fields(effort, model) unless effort.empty?
27
+ render_effort_fields(effort, model) unless effort.empty?
28
28
  end
29
29
 
30
30
  def disabled_reasoning_fields(model)
@@ -42,7 +42,7 @@ module RubyLLM
42
42
 
43
43
  # Models that publish a reasoning_config enum, such as OpenAI's GPT
44
44
  # models, take the effort as its value and reject reasoning_effort.
45
- def format_effort_fields(effort, model)
45
+ def render_effort_fields(effort, model)
46
46
  return { reasoning_config: effort } if reasoning_config_schema(model)
47
47
 
48
48
  { reasoning_effort: effort } unless effort == 'none'
@@ -52,14 +52,14 @@ module RubyLLM
52
52
  # adaptively, and reject a reasoning_config that enables a budget.
53
53
  def adaptive_thinking?(thinking, model)
54
54
  return false unless thinking.budget.nil?
55
- return false unless Chat.foundation_model_id(model&.id).start_with?('anthropic.claude')
55
+ return false unless claude_model?(model)
56
56
 
57
57
  registered = registered_model(model)
58
58
  !registered.nil? && !registered.reasoning_option(:effort).nil? &&
59
59
  registered.reasoning_option(:budget_tokens).nil?
60
60
  end
61
61
 
62
- def format_adaptive_reasoning_fields(thinking)
62
+ def render_adaptive_reasoning_fields(thinking)
63
63
  effort = thinking.effort.to_s
64
64
  return nil if effort == 'none'
65
65
  return { thinking: { type: 'adaptive' } } if effort.empty?
@@ -79,13 +79,17 @@ module RubyLLM
79
79
  end
80
80
  end
81
81
 
82
+ def claude_model?(model)
83
+ Chat.foundation_model_id(model&.id).start_with?('anthropic.claude')
84
+ end
85
+
82
86
  def nova_model?(model)
83
87
  Chat.foundation_model_id(model&.id).start_with?('amazon.nova')
84
88
  end
85
89
 
86
90
  # Nova 2 takes reasoningConfig with an effort level instead of a token budget,
87
91
  # and rejects an enabled reasoningConfig that names no effort.
88
- def format_nova_reasoning_fields(thinking, model)
92
+ def render_nova_reasoning_fields(thinking, model)
89
93
  if thinking.budget
90
94
  raise ArgumentError,
91
95
  "#{model&.id} takes a reasoning effort, not a token budget of #{thinking.budget}. " \
@@ -103,12 +107,22 @@ module RubyLLM
103
107
 
104
108
  def reasoning_budget(thinking, effort, model, max_output_tokens)
105
109
  return thinking.budget if thinking.budget.is_a?(Integer)
106
- return nil if effort.empty? || effort == 'none'
110
+ return nil if effort == 'none'
111
+ return toggle_budget(thinking, model) if effort.empty?
107
112
 
108
113
  schema = reasoning_budget_schema(model)
109
114
  schema && effort_budget_tokens(effort, schema, max_output_tokens)
110
115
  end
111
116
 
117
+ # Claude generations that take a budget have no bare switch, so
118
+ # turning thinking on sends the smallest budget the model accepts.
119
+ def toggle_budget(thinking, model)
120
+ return nil unless thinking.enabled == true && claude_model?(model)
121
+
122
+ minimum = registered_model(model)&.reasoning_option(:budget_tokens)&.fetch(:min, nil)
123
+ [minimum, MINIMUM_BUDGET_TOKENS].max if minimum.is_a?(Integer)
124
+ end
125
+
112
126
  def reasoning_budget_schema(model)
113
127
  request_fields_schema(model) { |schema| schema.dig(:reasoningConfig, :budgetTokens) }
114
128
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'base64'
4
- require 'faraday'
5
3
  require 'json'
6
4
 
7
5
  module RubyLLM
@@ -9,7 +7,7 @@ module RubyLLM
9
7
  class Converse
10
8
  # Streaming implementation for Bedrock ConverseStream (AWS Event Stream).
11
9
  module Streaming
12
- ErrorResponse = Struct.new(:body, :status)
10
+ include Protocols::Bedrock::EventStream
13
11
 
14
12
  private
15
13
 
@@ -19,28 +17,14 @@ module RubyLLM
19
17
 
20
18
  def stream_response(payload, additional_headers = {}, &block)
21
19
  @thinking_stream = ThinkingStream.new
20
+ @stream_text_lengths = Hash.new(0)
21
+ @stream_citations = {}
22
22
  accumulator = RubyLLM::Protocol::StreamAccumulator.new
23
- decoder = event_stream_decoder
24
- body = JSON.generate(payload)
25
- progress = {}
26
-
27
- faraday_v1 = Faraday::VERSION.start_with?('1')
28
- on_data = RubyLLM::Protocol::Streaming::FaradayHandlers.build(
29
- faraday_v1: faraday_v1,
30
- on_chunk: ->(chunk, _env) { parse_stream_chunk(decoder, chunk, accumulator, progress, &block) },
31
- on_failed_response: ->(chunk, env) { handle_failed_stream(chunk, env) }
32
- )
33
-
34
- response = @connection.post(stream_url, body, usage: @usage_tracker, stream: true) do |req|
35
- req.headers.merge!(additional_headers) unless additional_headers.empty?
36
- req.headers['Accept'] = 'application/vnd.amazon.eventstream'
37
- (req.options.context ||= {})[RubyLLM::Transport::Connection::STREAM_PROGRESS_KEY] = progress
38
23
 
39
- if faraday_v1
40
- req.options[:on_data] = on_data
41
- else
42
- req.options.on_data = on_data
43
- end
24
+ response = stream_events(stream_url, JSON.generate(payload), additional_headers) do |event|
25
+ chunk = build_chunk(event)
26
+ accumulator.add(chunk)
27
+ block.call(chunk)
44
28
  end
45
29
 
46
30
  message = accumulator.to_message(response)
@@ -48,139 +32,6 @@ module RubyLLM
48
32
  message
49
33
  end
50
34
 
51
- def event_stream_decoder
52
- require 'aws-eventstream'
53
- Aws::EventStream::Decoder.new
54
- rescue LoadError
55
- raise Error,
56
- 'The aws-eventstream gem is required for Bedrock streaming. ' \
57
- 'Please add it to your Gemfile: gem "aws-eventstream"'
58
- end
59
-
60
- # The error body arrives in as many reads as the adapter hands out, so
61
- # it accumulates on the env, which ErrorMiddleware clears per attempt.
62
- def handle_failed_stream(chunk, env)
63
- buffer = failed_stream_buffer(env)
64
- buffer << chunk
65
- error_response = env.merge(body: JSON.parse(buffer))
66
- Transport::ErrorMiddleware.parse_error(provider: self, response: error_response)
67
- rescue JSON::ParserError
68
- RubyLLM.logger.debug { "Accumulating Bedrock stream error chunk: #{chunk}" }
69
- end
70
-
71
- def failed_stream_buffer(env)
72
- (env[:streaming_state] ||= RubyLLM::Protocol::Streaming::StreamState.new).buffer
73
- end
74
-
75
- def parse_stream_chunk(decoder, raw_chunk, accumulator, progress)
76
- handle_non_eventstream_error_chunk(raw_chunk)
77
-
78
- decode_events(decoder, raw_chunk).each do |event|
79
- chunk = build_chunk(event)
80
- next unless chunk
81
-
82
- accumulator.add(chunk)
83
- progress[:started] = true
84
- yield chunk
85
- end
86
- end
87
-
88
- def handle_non_eventstream_error_chunk(raw_chunk)
89
- text = raw_chunk.to_s
90
-
91
- if text.start_with?('event: error')
92
- payload = text.lines.find { |line| line.start_with?('data:') }&.delete_prefix('data:')&.strip
93
- raise_streaming_chunk_error(payload) if payload
94
- return
95
- end
96
-
97
- return unless text.lstrip.start_with?('{') && text.include?('"error"')
98
-
99
- raise_streaming_chunk_error(text)
100
- end
101
-
102
- def raise_streaming_chunk_error(payload)
103
- parsed = JSON.parse(payload)
104
- message = parsed.dig('error', 'message') || parsed['message'] || 'Bedrock streaming error'
105
- response = ErrorResponse.new({ 'message' => message }, 500)
106
- Transport::ErrorMiddleware.parse_error(provider: self, response: response)
107
- rescue JSON::ParserError
108
- nil
109
- end
110
-
111
- def decode_events(decoder, raw_chunk)
112
- events = []
113
- message, eof = decoder.decode_chunk(raw_chunk)
114
-
115
- while message
116
- event = decode_event(message)
117
- if event && RubyLLM.config.log_stream_debug
118
- RubyLLM.logger.debug do
119
- "Bedrock stream event keys: #{event.keys}"
120
- end
121
- end
122
- events << event if event
123
- break if eof
124
-
125
- message, eof = decoder.decode_chunk
126
- end
127
-
128
- events
129
- end
130
-
131
- # ConverseStream delivers modeled errors as a frame after the 200, with
132
- # the type in the frame headers and a bare message as the payload. Rewrap
133
- # it under the exception name so raise_stream_error classifies it instead
134
- # of the frame passing for an empty chunk.
135
- def decode_event(message)
136
- return decode_error_frame(message.headers) if stream_error_frame?(message)
137
-
138
- exception_type = stream_exception_type(message)
139
- payload = message.payload.read
140
- event = decode_event_payload(payload)
141
- return event unless exception_type
142
-
143
- { exception_type => event || { 'message' => payload.to_s } }
144
- end
145
-
146
- # Failures the API does not model arrive as an error frame instead,
147
- # with the code and message in the headers and nothing in the payload.
148
- def stream_error_frame?(message)
149
- event_header(message.headers, ':message-type') == 'error'
150
- end
151
-
152
- def decode_error_frame(headers)
153
- detail = [event_header(headers, ':error-code'), event_header(headers, ':error-message')].compact.join(': ')
154
- event = { 'type' => 'error' }
155
- event['error'] = { 'message' => detail } unless detail.empty?
156
- event
157
- end
158
-
159
- def stream_exception_type(message)
160
- headers = message.headers
161
- return nil unless event_header(headers, ':message-type') == 'exception'
162
-
163
- event_header(headers, ':exception-type') || 'streamingException'
164
- end
165
-
166
- def event_header(headers, name)
167
- header = headers[name]
168
- header.respond_to?(:value) ? header.value : header
169
- end
170
-
171
- def decode_event_payload(payload)
172
- outer = JSON.parse(payload)
173
-
174
- if outer['bytes'].is_a?(String)
175
- JSON.parse(Base64.decode64(outer['bytes']))
176
- else
177
- outer
178
- end
179
- rescue JSON::ParserError => e
180
- RubyLLM.logger.debug { "Failed to decode Bedrock stream event payload: #{e.message}" }
181
- nil
182
- end
183
-
184
35
  def build_chunk(event)
185
36
  raise_stream_error(event) if stream_error_event?(event)
186
37
 
@@ -189,22 +40,22 @@ module RubyLLM
189
40
  Chunk.new(
190
41
  role: :assistant,
191
42
  model: event['modelId'] || @model&.id,
192
- content: extract_content_delta(event),
193
- citations: extract_citations_delta(event),
43
+ content: parse_content_delta(event),
44
+ citations: parse_block_citations(event),
194
45
  thinking: Thinking.build(
195
- text: extract_thinking_delta(event),
196
- signature: extract_thinking_signature(event)
46
+ text: parse_thinking_delta(event),
47
+ signature: parse_thinking_signature(event)
197
48
  ),
198
49
  raw_reasoning: streamed_thinking_blocks(event),
199
50
  tool_calls: extract_tool_calls(event),
200
- server_tool_calls: extract_server_tool_call_events(event),
201
- input_tokens: extract_input_tokens(metadata_usage, usage),
202
- output_tokens: extract_output_tokens(metadata_usage, usage),
203
- cache_read_tokens: extract_cache_read_tokens(metadata_usage, usage),
204
- cache_write_tokens: extract_cache_write_tokens(metadata_usage, usage),
51
+ server_tool_calls: parse_server_tool_call_events(event),
52
+ input_tokens: parse_input_tokens(metadata_usage, usage),
53
+ output_tokens: parse_output_tokens(metadata_usage, usage),
54
+ cache_read_tokens: parse_cache_read_tokens(metadata_usage, usage),
55
+ cache_write_tokens: parse_cache_write_tokens(metadata_usage, usage),
205
56
  cache_write_tokens_by_ttl: Chat.cache_write_by_ttl(metadata_usage) || Chat.cache_write_by_ttl(usage),
206
- thinking_tokens: extract_reasoning_tokens(metadata_usage, usage),
207
- finish_reason: extract_finish_reason(event)
57
+ thinking_tokens: parse_reasoning_tokens(metadata_usage, usage),
58
+ finish_reason: parse_finish_reason(event)
208
59
  )
209
60
  end
210
61
 
@@ -214,7 +65,7 @@ module RubyLLM
214
65
  @thinking_stream.raw_reasoning if event.key?('messageStop') || event.key?('stopReason')
215
66
  end
216
67
 
217
- def extract_finish_reason(event)
68
+ def parse_finish_reason(event)
218
69
  normalize_finish_reason(event.dig('messageStop', 'stopReason') || event['stopReason'])
219
70
  end
220
71
 
@@ -225,69 +76,66 @@ module RubyLLM
225
76
  ]
226
77
  end
227
78
 
228
- def extract_input_tokens(metadata_usage, usage)
79
+ def parse_input_tokens(metadata_usage, usage)
229
80
  bedrock_usage = metadata_usage['inputTokens'] ? metadata_usage : usage
230
81
  Chat.input_tokens(bedrock_usage) if bedrock_usage['inputTokens']
231
82
  end
232
83
 
233
- def extract_output_tokens(metadata_usage, usage)
84
+ def parse_output_tokens(metadata_usage, usage)
234
85
  metadata_usage['outputTokens'] || usage['outputTokens']
235
86
  end
236
87
 
237
- def extract_cache_read_tokens(metadata_usage, usage)
88
+ def parse_cache_read_tokens(metadata_usage, usage)
238
89
  metadata_usage['cacheReadInputTokens'] || usage['cacheReadInputTokens']
239
90
  end
240
91
 
241
- def extract_cache_write_tokens(metadata_usage, usage)
92
+ def parse_cache_write_tokens(metadata_usage, usage)
242
93
  metadata_usage['cacheWriteInputTokens'] || usage['cacheWriteInputTokens']
243
94
  end
244
95
 
245
- def extract_reasoning_tokens(metadata_usage, usage)
96
+ def parse_reasoning_tokens(metadata_usage, usage)
246
97
  Chat.reasoning_tokens(metadata_usage) || Chat.reasoning_tokens(usage)
247
98
  end
248
99
 
249
- def stream_error_event?(event)
250
- event.keys.any? { |key| key.end_with?('Exception') } || event['type'] == 'error'
100
+ def parse_content_delta(event)
101
+ normalized_delta(event)['text']
251
102
  end
252
103
 
253
- def raise_stream_error(event)
254
- if event['type'] == 'error'
255
- message = event.dig('error', 'message') || 'Bedrock streaming error'
256
- response = ErrorResponse.new({ 'message' => message }, 500)
257
- Transport::ErrorMiddleware.parse_error(provider: self, response: response)
258
- return
259
- end
104
+ # A citation delta arrives before the text it supports, so a block's
105
+ # citations wait for the block to stop and take its span of the response.
106
+ # A block still open when the message stops gives up what it has.
107
+ def parse_block_citations(event)
108
+ track_citation_block(event)
109
+ finished_citation_blocks(event).flat_map { |index| take_block_citations(index) }
110
+ end
260
111
 
261
- key = event.keys.find { |candidate| candidate.end_with?('Exception') }
262
- payload = event[key]
263
- message = payload['message'] || key
264
- status = case key
265
- when 'throttlingException' then 429
266
- when 'validationException' then 400
267
- when 'accessDeniedException', 'unrecognizedClientException' then 401
268
- when 'serviceUnavailableException' then 503
269
- else 500
270
- end
271
-
272
- response = ErrorResponse.new({ 'message' => message }, status)
273
- Transport::ErrorMiddleware.parse_error(provider: self, response: response)
112
+ def track_citation_block(event)
113
+ @stream_text_lengths ||= Hash.new(0)
114
+ @stream_citations ||= {}
115
+ index = event_block_index(event)
116
+ return unless index
117
+
118
+ delta = normalized_delta(event)
119
+ @stream_text_lengths[index] += delta['text'].length if delta['text'].is_a?(String)
120
+ (@stream_citations[index] ||= []) << delta['citation'] if delta['citation'].is_a?(Hash)
274
121
  end
275
122
 
276
- def extract_content_delta(event)
277
- normalized_delta(event)['text']
123
+ def finished_citation_blocks(event)
124
+ return @stream_citations.keys.sort if event.key?('messageStop') || event.key?('stopReason')
125
+ return [] if %w[delta start contentBlockDelta contentBlockStart].any? { |key| event.key?(key) }
126
+
127
+ [event_block_index(event)].compact
278
128
  end
279
129
 
280
- # The cited span itself streams as ordinary text deltas; the citation
281
- # delta carries the source. The final message resolves the span text
282
- # from the accumulated content when indices are known.
283
- def extract_citations_delta(event)
284
- citation = normalized_delta(event)['citation']
285
- return nil unless citation.is_a?(Hash)
130
+ def take_block_citations(index)
131
+ length = @stream_text_lengths[index]
132
+ start_index = @stream_text_lengths.sum { |block_index, size| block_index < index ? size : 0 }
133
+ span = length.zero? ? {} : { start_index:, end_index: start_index + length }
286
134
 
287
- [Chat.parse_citation(citation)]
135
+ Array(@stream_citations.delete(index)).map { |citation| Chat.parse_citation(citation, **span) }
288
136
  end
289
137
 
290
- def extract_thinking_delta(event)
138
+ def parse_thinking_delta(event)
291
139
  reasoning_content = normalized_delta(event)['reasoningContent'] || {}
292
140
 
293
141
  reasoning_text = reasoning_content['reasoningText'] || {}
@@ -295,33 +143,33 @@ module RubyLLM
295
143
  return reasoning_content['text'] if reasoning_content.key?('text')
296
144
  return '' if [reasoning_text, reasoning_content].any? { |block| block.key?('signature') }
297
145
 
298
- extract_thinking_from_start(event)
146
+ parse_thinking_from_start(event)
299
147
  end
300
148
 
301
- def extract_thinking_from_start(event)
149
+ def parse_thinking_from_start(event)
302
150
  start = event.dig('contentBlockStart', 'start', 'reasoningContent') || {}
303
151
  return unless start.key?('reasoningText')
304
152
 
305
153
  start.dig('reasoningText', 'text').to_s
306
154
  end
307
155
 
308
- def extract_thinking_signature(event)
309
- signature = extract_signature_from_delta(event)
156
+ def parse_thinking_signature(event)
157
+ signature = parse_signature_from_delta(event)
310
158
  return signature if signature
311
159
 
312
- signature = extract_signature_from_start(event)
160
+ signature = parse_signature_from_start(event)
313
161
  return signature if signature
314
162
 
315
163
  nil
316
164
  end
317
165
 
318
- def extract_signature_from_delta(event)
166
+ def parse_signature_from_delta(event)
319
167
  reasoning_content = normalized_delta(event)['reasoningContent'] || {}
320
168
  reasoning_text = reasoning_content['reasoningText'] || {}
321
169
  reasoning_text['signature'] || reasoning_content['signature'] || reasoning_content['redactedContent']
322
170
  end
323
171
 
324
- def extract_signature_from_start(event)
172
+ def parse_signature_from_start(event)
325
173
  start = event.dig('contentBlockStart', 'start', 'reasoningContent')
326
174
  return nil unless start
327
175
 
@@ -333,8 +181,8 @@ module RubyLLM
333
181
  end
334
182
 
335
183
  def extract_tool_calls(event)
336
- return extract_tool_call_start(event) if tool_call_start_event?(event)
337
- return extract_tool_call_delta(event) if tool_call_delta_event?(event)
184
+ return parse_tool_call_start(event) if tool_call_start_event?(event)
185
+ return parse_tool_call_delta(event) if tool_call_delta_event?(event)
338
186
 
339
187
  nil
340
188
  end
@@ -347,7 +195,7 @@ module RubyLLM
347
195
  event['contentBlockDelta'] || event.dig('delta', 'toolUse')
348
196
  end
349
197
 
350
- def extract_tool_call_start(event)
198
+ def parse_tool_call_start(event)
351
199
  tool_use = event.dig('contentBlockStart', 'start', 'toolUse') || event.dig('start', 'toolUse')
352
200
  return nil unless tool_use
353
201
 
@@ -358,27 +206,27 @@ module RubyLLM
358
206
 
359
207
  tool_use_id = tool_use['toolUseId']
360
208
  {
361
- tool_use_id => ToolCall.new(
362
- id: tool_use_id,
209
+ event_block_index(event) => ToolCall.new(
210
+ provider_call_id: tool_use_id,
363
211
  name: tool_use['name'],
364
212
  arguments: tool_use['input'] || {}
365
213
  )
366
214
  }
367
215
  end
368
216
 
369
- def extract_tool_call_delta(event)
217
+ def parse_tool_call_delta(event)
370
218
  return nil if server_tool_block_event?(event)
371
219
 
372
220
  input = normalized_delta(event).dig('toolUse', 'input')
373
221
  return nil unless input
374
222
 
375
- { nil => ToolCall.new(id: nil, name: nil, arguments: input) }
223
+ { event_block_index(event) => ToolCall.new(name: nil, arguments: input) }
376
224
  end
377
225
 
378
226
  # Server-executed tool steps stream as typed toolUse and toolResult
379
227
  # block starts. Their input deltas are not function-call arguments,
380
228
  # so the block index is remembered and its deltas skipped.
381
- def extract_server_tool_call_events(event)
229
+ def parse_server_tool_call_events(event)
382
230
  start = event.dig('contentBlockStart', 'start') || event['start']
383
231
  return [] unless start.is_a?(Hash)
384
232