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
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Protocol
5
+ # Prompt cache APIs spell lifetimes as short strings such as "5m" and
6
+ # "1h". A string column stores a Duration as its digits, so digits in a
7
+ # String count as seconds.
8
+ module CacheLifetime # :nodoc:
9
+ UNITS = { 'h' => 3600, 'm' => 60 }.freeze
10
+
11
+ module_function
12
+
13
+ def render(ttl)
14
+ seconds = ttl.is_a?(String) ? Float(ttl, exception: false)&.to_i : ttl.to_i
15
+ return ttl unless seconds
16
+
17
+ unit, size = UNITS.find { |_unit, length| (seconds % length).zero? }
18
+ unit ? "#{seconds / size}#{unit}" : "#{seconds}s"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Protocol
5
+ # Decides which provider-native parts of an assistant message, such as
6
+ # thinking signatures, raw content blocks, and continuations, replay to
7
+ # the model a request goes to.
8
+ module NativeContent # :nodoc:
9
+ private
10
+
11
+ # A thinking signature or a provider-shaped content block belongs to the
12
+ # model that produced it, and one provider can serve models that do not
13
+ # read each other's, so a message another model produced replays
14
+ # without them. A message with no known producer replays as it is.
15
+ def foreign_native_content?(message)
16
+ return false unless message.role == :assistant && carries_native_content?(message)
17
+
18
+ producer = producing_entry(message)
19
+ !producer.nil? && (producer.provider != @provider.slug || !current_model?(producer.model))
20
+ end
21
+
22
+ # Usage records the id a chat resolved its model to, and a message built
23
+ # by hand may name the model by an alias of it.
24
+ def current_model?(model_id)
25
+ return true if @model.nil? || model_id == @model.id
26
+
27
+ RubyLLM.models.find(model_id, provider: @provider.slug, config: @config).id == @model.id
28
+ rescue ModelNotFoundError
29
+ false
30
+ end
31
+
32
+ # Another model of this provider gets the raw content any of its models
33
+ # reads, such as a tool search, and none of the producing model's own.
34
+ def without_foreign_native_content(message)
35
+ portable = portable_raw_content(message) if producer_slug(message) == @provider.slug
36
+ message.without_native_content(raw_content: portable)
37
+ end
38
+
39
+ def portable_raw_content(_message)
40
+ nil
41
+ end
42
+
43
+ def carries_native_content?(message)
44
+ return true if message.thinking || message.raw_reasoning || message.raw_content || message.phase
45
+ return true if message.continuation
46
+
47
+ message.tool_call? && message.tool_calls.any?(&:thought_signature)
48
+ end
49
+
50
+ # Only a usage entry names the producer: a model id alone can belong
51
+ # to several providers.
52
+ def producer_slug(message)
53
+ producing_entry(message)&.provider
54
+ end
55
+
56
+ def producing_entry(message)
57
+ message.ruby_llm_usage_entries.reverse.find(&:succeeded?)
58
+ end
59
+
60
+ # A thinking signature can be another provider's opaque blob, and a
61
+ # message restored without its usage could come from anyone, so a
62
+ # protocol that verifies signatures gets back only its own.
63
+ def own_signature(message)
64
+ signature = message.thinking&.signature
65
+ signature if signature && producer_slug(message) == @provider.slug
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Protocol
5
+ # Replayed provider content keeps the argument text of calls that were
6
+ # not a JSON object, and some providers reject such a request. These
7
+ # helpers swap that text for empty arguments.
8
+ module ReplayedArguments # :nodoc:
9
+ module_function
10
+
11
+ def malformed_call_ids(message)
12
+ message.tool_calls.filter_map { |call| call.provider_call_id if call.malformed_arguments? }
13
+ end
14
+
15
+ def blank(entries, message, id:, arguments:, empty:)
16
+ ids = malformed_call_ids(message)
17
+ return entries if ids.empty? || !entries.is_a?(Array)
18
+
19
+ entries.map do |entry|
20
+ next entry unless entry.is_a?(Hash) && entry.key?(arguments)
21
+ next entry unless Array(id).any? { |key| ids.include?(entry[key]) }
22
+
23
+ entry.merge(arguments => empty)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -10,6 +10,7 @@ module RubyLLM
10
10
 
11
11
  def initialize
12
12
  @content = +''
13
+ @attachments = []
13
14
  @citations = []
14
15
  @citation_keys = {}
15
16
  @thinking_text = nil
@@ -20,8 +21,9 @@ module RubyLLM
20
21
  @raw_content = nil
21
22
  @raw_reasoning = nil
22
23
  @finish_reason = nil
23
- @latest_tool_call_id = nil
24
- @tool_call_ids_by_index = {}
24
+ @content_filters = {}
25
+ @latest_provider_call_id = nil
26
+ @provider_call_ids_by_index = {}
25
27
  end
26
28
 
27
29
  def add(chunk)
@@ -29,12 +31,13 @@ module RubyLLM
29
31
  @model = chunk.model if @model.to_s.empty?
30
32
 
31
33
  handle_chunk_content(chunk)
34
+ @attachments.concat(chunk.attachments)
32
35
  accumulate_citations(chunk.citations)
33
36
  append_thinking_from_chunk(chunk)
34
37
  accumulate_server_tool_calls(chunk.server_tool_calls)
35
- @raw_content = chunk.raw_content if chunk.raw_content
36
- @raw_reasoning = chunk.raw_reasoning if chunk.raw_reasoning
38
+ keep_native_fields(chunk)
37
39
  @finish_reason = chunk.finish_reason if chunk.finish_reason
40
+ accumulate_content_filters(chunk.content_filters)
38
41
  count_tokens chunk
39
42
  RubyLLM.logger.debug { inspect } if RubyLLM.config.log_stream_debug
40
43
  end
@@ -43,6 +46,7 @@ module RubyLLM
43
46
  Message.new(
44
47
  role: :assistant,
45
48
  content: content.empty? ? nil : content,
49
+ attachments: @attachments,
46
50
  citations: resolved_citations,
47
51
  thinking: Thinking.build(
48
52
  text: @thinking_text,
@@ -52,7 +56,10 @@ module RubyLLM
52
56
  server_tool_calls: @server_tool_calls,
53
57
  raw_content: @raw_content,
54
58
  raw_reasoning: @raw_reasoning,
59
+ phase: @phase,
60
+ continuation: @continuation,
55
61
  finish_reason: @finish_reason,
62
+ content_filters: @content_filters.values,
56
63
  model: model,
57
64
  tool_calls: tool_calls_from_stream(response),
58
65
  raw: response
@@ -61,6 +68,22 @@ module RubyLLM
61
68
 
62
69
  private
63
70
 
71
+ def keep_native_fields(chunk)
72
+ @raw_content = chunk.raw_content if chunk.raw_content
73
+ @raw_reasoning = chunk.raw_reasoning if chunk.raw_reasoning
74
+ @phase = chunk.phase if chunk.phase
75
+ @continuation = chunk.continuation if chunk.continuation
76
+ end
77
+
78
+ # A stream can judge the same category again as it goes; a verdict
79
+ # that filtered stays, and otherwise the latest one does.
80
+ def accumulate_content_filters(filters)
81
+ filters.each do |filter|
82
+ key = [filter.source, filter.category]
83
+ @content_filters[key] = filter unless @content_filters[key]&.filtered?
84
+ end
85
+ end
86
+
64
87
  # Providers like Perplexity repeat the full citation list on every chunk.
65
88
  def accumulate_citations(new_citations)
66
89
  new_citations.each do |citation|
@@ -98,53 +121,73 @@ module RubyLLM
98
121
 
99
122
  def tool_calls_from_stream(response)
100
123
  tool_calls.transform_values do |tc|
101
- arguments = if tc.arguments.is_a?(String) && !tc.arguments.empty?
102
- parse_tool_call_arguments(tc.arguments, response)
103
- elsif tc.arguments.is_a?(String)
104
- {}
105
- else
106
- tc.arguments
107
- end
108
-
109
124
  ToolCall.new(
110
125
  id: tc.id,
126
+ provider_call_id: tc.provider_call_id,
111
127
  name: tc.name,
112
- arguments: arguments,
128
+ namespace: tc.namespace,
129
+ **streamed_arguments(tc, response),
113
130
  thought_signature: tc.thought_signature,
114
131
  remote: tc.remote?
115
132
  )
116
133
  end
117
134
  end
118
135
 
119
- def parse_tool_call_arguments(arguments, response)
120
- JSON.parse(arguments)
121
- rescue JSON::ParserError => e
122
- raise ToolCallParseError.new(response: response, finish_reason: @finish_reason), cause: e
136
+ def streamed_arguments(tool_call, response)
137
+ arguments = tool_call.arguments
138
+ return { malformed_arguments: tool_call.malformed_arguments } if tool_call.malformed_arguments?
139
+ return { arguments: } unless arguments.is_a?(String)
140
+ raise ToolCallTruncatedError.new(response:) if arguments.empty? && cut_off?(tool_call)
141
+
142
+ ToolCall.parse_arguments(arguments, response:, finish_reason: @finish_reason)
143
+ end
144
+
145
+ def cut_off?(tool_call)
146
+ @finish_reason == :max_tokens && tool_calls.values.last.equal?(tool_call)
123
147
  end
124
148
 
125
149
  def accumulate_tool_calls(new_tool_calls)
126
150
  RubyLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if RubyLLM.config.log_stream_debug
127
- new_tool_calls.each do |stream_key, tool_call|
128
- if tool_call.id
129
- start_tool_call(stream_key, tool_call)
130
- else
151
+ new_tool_calls.to_h.each do |stream_key, tool_call|
152
+ if continues_tool_call?(stream_key, tool_call)
131
153
  append_tool_call_fragment(stream_key, tool_call)
154
+ else
155
+ if tool_call.provider_call_id.to_s.empty?
156
+ tool_call = ToolCall.new(**tool_call.to_h, provider_call_id: SecureRandom.uuid)
157
+ new_tool_calls[stream_key] = tool_call
158
+ end
159
+ start_tool_call(stream_key, tool_call)
132
160
  end
133
161
  end
134
162
  end
135
163
 
164
+ def continues_tool_call?(stream_key, tool_call)
165
+ case tool_call.provider_call_id
166
+ when nil then tool_call.name.to_s.empty? || !find_tool_call(stream_key).nil?
167
+ when '' then tool_call.name.to_s.empty?
168
+ else
169
+ !tool_call.arguments.is_a?(Hash) && @provider_call_ids_by_index[stream_key] == tool_call.provider_call_id
170
+ end
171
+ end
172
+
136
173
  def start_tool_call(stream_key, tool_call)
137
- tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
174
+ provider_call_id = tool_call.provider_call_id
175
+ if @tool_calls.key?(provider_call_id) && @provider_call_ids_by_index[stream_key] != provider_call_id
176
+ raise Error, "Duplicate tool call ID in one response: #{provider_call_id.inspect}"
177
+ end
138
178
 
139
- @tool_calls[tool_call_id] = ToolCall.new(
140
- id: tool_call_id,
179
+ @tool_calls[provider_call_id] = ToolCall.new(
180
+ id: tool_call.id,
181
+ provider_call_id:,
141
182
  name: tool_call.name,
183
+ namespace: tool_call.namespace,
142
184
  arguments: initial_tool_call_arguments(tool_call),
185
+ malformed_arguments: tool_call.malformed_arguments,
143
186
  thought_signature: tool_call.thought_signature,
144
187
  remote: tool_call.remote?
145
188
  )
146
- @tool_call_ids_by_index[stream_key] = tool_call_id unless stream_key.nil?
147
- @latest_tool_call_id = tool_call_id
189
+ @provider_call_ids_by_index[stream_key] = provider_call_id unless stream_key.nil?
190
+ @latest_provider_call_id = provider_call_id
148
191
  end
149
192
 
150
193
  def initial_tool_call_arguments(tool_call)
@@ -158,18 +201,16 @@ module RubyLLM
158
201
  existing = find_tool_call(stream_key)
159
202
  return unless existing
160
203
 
161
- fragment = tool_call.arguments
162
- fragment = '' if fragment.nil?
163
- existing.arguments << fragment
204
+ existing.arguments << (tool_call.arguments || '') unless existing.arguments.is_a?(Hash)
164
205
  return unless tool_call.thought_signature && existing.thought_signature.nil?
165
206
 
166
207
  existing.thought_signature = tool_call.thought_signature
167
208
  end
168
209
 
169
210
  def find_tool_call(stream_key)
170
- return @tool_calls[@latest_tool_call_id] if stream_key.nil?
211
+ return @tool_calls[@latest_provider_call_id] if stream_key.nil?
171
212
 
172
- @tool_calls[@tool_call_ids_by_index[stream_key]] || @tool_calls[stream_key]
213
+ @tool_calls[@provider_call_ids_by_index[stream_key]] || @tool_calls[stream_key]
173
214
  end
174
215
 
175
216
  def count_tokens(chunk)
@@ -179,8 +220,7 @@ module RubyLLM
179
220
  def handle_chunk_content(chunk)
180
221
  accumulate_tool_calls(chunk.tool_calls) if chunk.tool_call?
181
222
 
182
- content_text = chunk.content || ''
183
- @content << (content_text.is_a?(String) ? content_text : content_text.to_s)
223
+ @content << chunk.content.to_s
184
224
  end
185
225
 
186
226
  def append_thinking_from_chunk(chunk)
@@ -8,16 +8,48 @@ module RubyLLM
8
8
  module Streaming # :nodoc: all
9
9
  MAX_JSON_BODY_BYTES = 1024 * 1024
10
10
 
11
- StreamState = Struct.new(:parser, :buffer, :json_body) do
11
+ StreamState = Struct.new(:parser, :buffer, :json_body, :stray_json, :stray_error) do
12
12
  def initialize
13
13
  super
14
14
  reset
15
15
  end
16
16
 
17
17
  def reset
18
- self.parser = Transport::EventStreamParser.new
18
+ self.parser = Transport::EventStreamParser.new(on_unknown_line: method(:keep_stray_line))
19
19
  self.buffer = +''
20
20
  self.json_body = nil
21
+ self.stray_json = +''
22
+ self.stray_error = nil
23
+ end
24
+
25
+ # A provider can answer in the middle of a stream with a bare JSON
26
+ # error, which is not an event. Only a line that starts with "{" can
27
+ # open one, so other lines the stream doesn't understand can't hide it.
28
+ # Each object is read as its last line arrives, because more lines can
29
+ # follow it in the same read.
30
+ def keep_stray_line(line)
31
+ return if stray_error
32
+
33
+ self.stray_json = +'' if line.start_with?('{')
34
+ return if stray_json.empty? && !line.start_with?('{')
35
+ if stray_json.bytesize + line.bytesize > MAX_JSON_BODY_BYTES
36
+ raise Error, "Streaming JSON response exceeds #{MAX_JSON_BODY_BYTES} bytes"
37
+ end
38
+
39
+ text = line.force_encoding(Encoding::UTF_8).scrub
40
+ stray_json << text << "\n"
41
+ read_stray_json if text.rstrip.end_with?('}')
42
+ end
43
+
44
+ private
45
+
46
+ # A complete object without an error is a line to ignore, and an
47
+ # incomplete one may finish on a later line.
48
+ def read_stray_json
49
+ self.stray_error = stray_json if JSON.parse(stray_json)['error']
50
+ self.stray_json = +''
51
+ rescue JSON::ParserError
52
+ nil
21
53
  end
22
54
  end
23
55
 
@@ -25,42 +57,47 @@ module RubyLLM
25
57
 
26
58
  def stream_response(payload, additional_headers = {}, &block)
27
59
  accumulator = StreamAccumulator.new
60
+ received = false
28
61
 
29
62
  response = stream_events(stream_url, payload, additional_headers) do |data|
63
+ received = true
30
64
  chunk = build_chunk(data)
31
65
  accumulator.add chunk
32
66
  block.call chunk
33
67
  end
68
+ raise Error.new('Provider returned no stream events', response:) unless received
69
+
70
+ if (remainder = stream_remainder)
71
+ accumulator.add remainder
72
+ block.call remainder
73
+ end
34
74
 
35
75
  message = accumulator.to_message(response)
36
76
  RubyLLM.logger.debug { "Stream completed: #{message.content}" }
37
77
  message
38
78
  end
39
79
 
80
+ # Returns a last Chunk for text a protocol held back while streaming,
81
+ # or +nil+.
82
+ def stream_remainder
83
+ nil
84
+ end
85
+
40
86
  # Posts +payload+ to +url+ as a server-sent event stream, yielding each
41
87
  # parsed event Hash. Returns the Faraday response.
42
- def stream_events(url, payload, additional_headers = {}, &block)
88
+ def stream_events(url, payload, additional_headers = {}, &handler)
43
89
  progress = {}
44
90
  fallback_state = StreamState.new
45
- on_data = build_on_data_handler(progress, fallback_state: fallback_state) do |data|
46
- block.call(data) if data.is_a?(Hash)
91
+ on_data = build_on_data_handler(progress, fallback_state: fallback_state) do |event|
92
+ handler.call(event)
93
+ rescue StandardError => e
94
+ progress[:callback_error] = e
95
+ raise
47
96
  end
48
97
 
49
- @connection.post url, payload, usage: @usage_tracker, stream: true do |req|
98
+ post_stream(url, payload, on_data:, progress:, on_reset: fallback_state.method(:reset),
99
+ on_failure: stream_failure(fallback_state)) do |req|
50
100
  req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
51
- (req.options.context ||= {})[Transport::Connection::STREAM_PROGRESS_KEY] = progress
52
- req.options.context[Transport::ErrorMiddleware::STREAM_RESET_KEY] = fallback_state.method(:reset)
53
- if faraday_1?
54
- req.options[:on_data] = on_data
55
- else
56
- req.options.on_data = on_data
57
- end
58
- end
59
- end
60
-
61
- def handle_stream(&block)
62
- build_on_data_handler do |data|
63
- block.call(build_chunk(data)) if data.is_a?(Hash)
64
101
  end
65
102
  end
66
103
 
@@ -70,6 +107,21 @@ module RubyLLM
70
107
  Faraday::VERSION.start_with?('1')
71
108
  end
72
109
 
110
+ def post_stream(url, payload, on_data:, progress:, on_reset:, on_failure: nil)
111
+ @connection.post url, payload, usage: @usage_tracker, stream: true do |request|
112
+ yield request if block_given?
113
+ context = (request.options.context ||= {})
114
+ context[Transport::Connection::STREAM_PROGRESS_KEY] = progress
115
+ context[Transport::ErrorMiddleware::STREAM_RESET_KEY] = on_reset
116
+ context[Transport::ErrorMiddleware::STREAM_FAILURE_KEY] = on_failure if on_failure
117
+ request.options.on_data = on_data
118
+ end
119
+ end
120
+
121
+ def stream_failure(fallback_state)
122
+ ->(env) { raise_stream_failure(env[:streaming_state] || fallback_state, env) }
123
+ end
124
+
73
125
  # Some adapters omit the env, so ErrorMiddleware also resets the
74
126
  # request's fallback state before each attempt.
75
127
  def build_on_data_handler(progress = {}, fallback_state: StreamState.new, &handler)
@@ -96,10 +148,22 @@ module RubyLLM
96
148
  if json_body?(chunk, state)
97
149
  handle_json_body(chunk, state, env)
98
150
  else
151
+ keep_for_failure(chunk, state)
99
152
  handle_sse(chunk, state.parser, env, progress, &)
153
+ raise_stray_error(state, env)
100
154
  end
101
155
  end
102
156
 
157
+ def raise_stray_error(state, env)
158
+ raise_stream_error(state.stray_error, JSON.parse(state.stray_error), env) if state.stray_error
159
+ end
160
+
161
+ # Faraday 1 knows the status only once the response ends, so a failure
162
+ # body that is not JSON has to be kept until then.
163
+ def keep_for_failure(chunk, state)
164
+ state.buffer << chunk if faraday_1? && state.buffer.bytesize + chunk.bytesize <= MAX_JSON_BODY_BYTES
165
+ end
166
+
103
167
  # Adapters cut reads anywhere, so a read inside an event can start with
104
168
  # "{". Only the first bytes of a body can open a bare JSON error.
105
169
  def json_body?(chunk, state)
@@ -107,18 +171,35 @@ module RubyLLM
107
171
  state.json_body
108
172
  end
109
173
 
174
+ # Faraday 1 knows the status only once the response ends, so the error
175
+ # waits in the buffer until the error middleware reports it.
110
176
  def handle_json_body(chunk, state, env)
111
177
  if state.buffer.bytesize + chunk.bytesize > MAX_JSON_BODY_BYTES
112
178
  raise Error, "Streaming JSON response exceeds #{MAX_JSON_BODY_BYTES} bytes"
113
179
  end
114
180
 
115
181
  state.buffer << chunk
182
+ raise_buffered_error(state, env) unless faraday_1?
183
+ end
184
+
185
+ def raise_buffered_error(state, env)
116
186
  parsed = JSON.parse(state.buffer)
117
187
  raise_stream_error(state.buffer, parsed, env) if parsed.is_a?(Hash) && parsed['error']
118
188
  rescue JSON::ParserError
119
189
  RubyLLM.logger.debug { 'Waiting for the remaining JSON response bytes' }
120
190
  end
121
191
 
192
+ # Runs once the response has ended, when a body that never parsed as
193
+ # JSON is the only account of the failure.
194
+ def raise_stream_failure(state, env)
195
+ state.parser.finish
196
+ raise_stray_error(state, env)
197
+ raise_buffered_error(state, env)
198
+ return unless failed_http_status(env) && !state.buffer.empty?
199
+
200
+ env[:streaming_error_response] = build_stream_error_response(state.buffer.dup, env, nil)
201
+ end
202
+
122
203
  def handle_failed_response(chunk, buffer, env)
123
204
  return if buffer.bytesize + chunk.bytesize > MAX_JSON_BODY_BYTES
124
205
 
@@ -142,6 +223,8 @@ module RubyLLM
142
223
  next if data == '[DONE]'
143
224
 
144
225
  parsed = handle_data(data, env)
226
+ next unless parsed.is_a?(Hash)
227
+
145
228
  progress[:started] = true
146
229
  yield parsed
147
230
  end
@@ -150,7 +233,7 @@ module RubyLLM
150
233
 
151
234
  def handle_data(data, env)
152
235
  parsed = JSON.parse(data)
153
- return parsed unless parsed.is_a?(Hash) && parsed.key?('error')
236
+ return parsed unless parsed.is_a?(Hash) && parsed['error']
154
237
 
155
238
  raise_stream_error(data, parsed, env)
156
239
  rescue JSON::ParserError => e
@@ -188,7 +271,7 @@ module RubyLLM
188
271
  error_status = failed_http_status(env) || status || 500
189
272
 
190
273
  if faraday_1? || env.nil?
191
- Struct.new(:body, :status).new(parsed_data, error_status)
274
+ Struct.new(:body, :status, :response_headers).new(parsed_data, error_status, env&.response_headers)
192
275
  else
193
276
  # Faraday writes body into the request until a status is set.
194
277
  env.merge(status: error_status, body: parsed_data)
@@ -224,7 +307,7 @@ module RubyLLM
224
307
  # Some adapters do not expose response status during on_data.
225
308
  status = env&.status
226
309
 
227
- if status && status != 200
310
+ if status && !status.between?(200, 299)
228
311
  on_failed_response.call(chunk, env)
229
312
  else
230
313
  on_chunk.call(chunk, env)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Protocol
5
+ # How a protocol carries Tool.namespace. A namespace the API has no
6
+ # native form for is folded into the function name,
7
+ # <tt>namespace_name</tt>, and the calls that come back under that name
8
+ # are mapped back onto the namespace and the tool's own name.
9
+ module ToolNamespaces
10
+ # Raises for a forced tool choice that names a namespaced tool, for
11
+ # protocols whose tool choice cannot name one.
12
+ def self.validate_tool_choice(tools, choice) # :nodoc:
13
+ return unless tools[choice]&.namespace
14
+
15
+ raise Error, "The namespaced tool #{choice} cannot be forced; use choice: :required instead"
16
+ end
17
+
18
+ # Returns the error a call answers with when an earlier call of its
19
+ # namespace in the same response failed, or +nil+ when the API runs
20
+ # each call on its own. Protocols whose client toolsets run a
21
+ # response's calls in order and stop at the first failure override
22
+ # this.
23
+ def skipped_tool_call_error(_tool_call)
24
+ nil
25
+ end
26
+
27
+ def native_tool_namespace?(_namespace) # :nodoc:
28
+ false
29
+ end
30
+
31
+ # Returns whether +call+ came back under a namespace the API's own tool
32
+ # search put a deferred function in, rather than one +tools+ declare.
33
+ # Protocols whose tool search does that override this.
34
+ def searched_tool_namespace?(_call, _tools) # :nodoc:
35
+ false
36
+ end
37
+
38
+ def complete(messages, tools:, **options, &block) # :nodoc:
39
+ restore_tool_namespaces(super, tools)
40
+ end
41
+
42
+ def render(messages, tools:, **) # :nodoc:
43
+ super(messages.map { |message| flatten_message(message) }, tools: flatten_tools(tools), **)
44
+ end
45
+
46
+ def count_tokens(messages, tools:, **) # :nodoc:
47
+ super(messages.map { |message| flatten_message(message) }, tools: flatten_tools(tools), **)
48
+ end
49
+
50
+ private
51
+
52
+ def flatten_tools(tools)
53
+ tools.transform_values { |tool| flattened?(tool) ? Tool::Flattened.wrap(tool) : tool }
54
+ end
55
+
56
+ def restore_tool_namespaces(message, tools)
57
+ return message unless message.is_a?(Message) && message.tool_call?
58
+
59
+ message.with_tool_calls(message.tool_calls.transform_values { |call| restore_tool_namespace(call, tools) })
60
+ end
61
+
62
+ def restore_tool_namespace(call, tools)
63
+ if call.namespace.nil?
64
+ tool = tools[call.name.to_sym]
65
+ tool && flattened?(tool) ? restore_tool_call(call, tool) : call
66
+ elsif searched_tool_namespace?(call, tools)
67
+ rebuild_tool_call(call, name: call.name, namespace: nil)
68
+ else
69
+ call
70
+ end
71
+ end
72
+
73
+ def flattened?(tool_or_call)
74
+ !tool_or_call.namespace.nil? && !native_tool_namespace?(tool_or_call.namespace)
75
+ end
76
+
77
+ def flatten_message(message)
78
+ return message unless message.tool_calls.any? { |call| flattened?(call) }
79
+
80
+ message.with_tool_calls(message.tool_calls.transform_values { |call| flatten_tool_call(call) })
81
+ end
82
+
83
+ def flatten_tool_call(call)
84
+ return call unless flattened?(call)
85
+
86
+ rebuild_tool_call(call, name: call.qualified_name, namespace: nil)
87
+ end
88
+
89
+ def restore_tool_call(call, tool)
90
+ rebuild_tool_call(call, name: tool.name, namespace: tool.namespace)
91
+ end
92
+
93
+ def rebuild_tool_call(call, name:, namespace:)
94
+ ToolCall.new(id: call.id, provider_call_id: call.provider_call_id, name:, namespace:,
95
+ arguments: call.arguments, malformed_arguments: call.malformed_arguments,
96
+ thought_signature: call.thought_signature, remote: call.remote?)
97
+ end
98
+ end
99
+ end
100
+ end