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,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ module RubyLLM
7
+ module Protocols
8
+ module Bedrock
9
+ # AWS event stream framing for Bedrock's streaming APIs, ConverseStream
10
+ # and InvokeModelWithResponseStream. Each frame carries one JSON event,
11
+ # bare or base64-encoded under "bytes", or an error.
12
+ module EventStream
13
+ ErrorResponse = Struct.new(:body, :status)
14
+
15
+ EXCEPTION_STATUSES = {
16
+ 'throttlingException' => 429,
17
+ 'validationException' => 400,
18
+ 'accessDeniedException' => 401,
19
+ 'unrecognizedClientException' => 401,
20
+ 'serviceUnavailableException' => 503
21
+ }.freeze
22
+
23
+ private
24
+
25
+ # Posts +body+ as an event stream request, yielding each decoded
26
+ # event Hash and raising the errors the stream reports. Returns the
27
+ # Faraday response.
28
+ def stream_events(url, body, additional_headers = {}, &handler)
29
+ decoder = event_stream_decoder
30
+ progress = {}
31
+
32
+ on_data = RubyLLM::Protocol::Streaming::FaradayHandlers.build(
33
+ faraday_v1: faraday_1?,
34
+ on_chunk: ->(chunk, _env) { parse_stream_chunk(decoder, chunk, progress, &handler) },
35
+ on_failed_response: ->(chunk, env) { handle_failed_stream(chunk, env) }
36
+ )
37
+
38
+ on_reset = -> { decoder = event_stream_decoder }
39
+ post_stream(url, body, on_data:, progress:, on_reset:) do |req|
40
+ req.headers.merge!(additional_headers) unless additional_headers.empty?
41
+ req.headers['Accept'] = 'application/vnd.amazon.eventstream'
42
+ end
43
+ end
44
+
45
+ def event_stream_decoder
46
+ require 'aws-eventstream'
47
+ Aws::EventStream::Decoder.new
48
+ rescue LoadError
49
+ raise Error,
50
+ 'The aws-eventstream gem is required for Bedrock streaming. ' \
51
+ 'Please add it to your Gemfile: gem "aws-eventstream"'
52
+ end
53
+
54
+ # The error body arrives in as many reads as the adapter hands out, so
55
+ # it accumulates on the env, which ErrorMiddleware clears per attempt.
56
+ def handle_failed_stream(chunk, env)
57
+ buffer = failed_stream_buffer(env)
58
+ buffer << chunk
59
+ error_response = env.merge(body: JSON.parse(buffer))
60
+ Transport::ErrorMiddleware.parse_error(provider: self, response: error_response)
61
+ rescue JSON::ParserError
62
+ RubyLLM.logger.debug { "Accumulating Bedrock stream error chunk: #{chunk}" }
63
+ end
64
+
65
+ def failed_stream_buffer(env)
66
+ (env[:streaming_state] ||= RubyLLM::Protocol::Streaming::StreamState.new).buffer
67
+ end
68
+
69
+ def parse_stream_chunk(decoder, raw_chunk, progress)
70
+ handle_non_eventstream_error_chunk(raw_chunk)
71
+
72
+ decode_events(decoder, raw_chunk).each do |event|
73
+ raise_stream_error(event) if stream_error_event?(event)
74
+
75
+ progress[:started] = true
76
+ yield event
77
+ end
78
+ end
79
+
80
+ def handle_non_eventstream_error_chunk(raw_chunk)
81
+ text = raw_chunk.to_s
82
+
83
+ if text.start_with?('event: error')
84
+ payload = text.lines.find { |line| line.start_with?('data:') }&.delete_prefix('data:')&.strip
85
+ raise_streaming_chunk_error(payload) if payload
86
+ return
87
+ end
88
+
89
+ return unless text.lstrip.start_with?('{') && text.include?('"error"')
90
+
91
+ raise_streaming_chunk_error(text)
92
+ end
93
+
94
+ def raise_streaming_chunk_error(payload)
95
+ message = chunk_error_message(JSON.parse(payload)) || 'Bedrock streaming error'
96
+ response = ErrorResponse.new({ 'message' => message }, 500)
97
+ Transport::ErrorMiddleware.parse_error(provider: self, response: response)
98
+ rescue JSON::ParserError
99
+ nil
100
+ end
101
+
102
+ def chunk_error_message(parsed)
103
+ return unless parsed.is_a?(Hash)
104
+
105
+ error = parsed['error']
106
+ (error.is_a?(Hash) ? error['message'] : error) || parsed['message']
107
+ end
108
+
109
+ def decode_events(decoder, raw_chunk)
110
+ events = []
111
+ message, eof = decoder.decode_chunk(raw_chunk)
112
+
113
+ while message
114
+ event = decode_event(message)
115
+ if event && RubyLLM.config.log_stream_debug
116
+ RubyLLM.logger.debug do
117
+ "Bedrock stream event keys: #{event.keys}"
118
+ end
119
+ end
120
+ events << event if event
121
+ break if eof
122
+
123
+ message, eof = decoder.decode_chunk
124
+ end
125
+
126
+ events
127
+ rescue Aws::EventStream::Errors::PreludeChecksumError
128
+ raise_streaming_chunk_error(raw_chunk)
129
+ raise Error, 'Bedrock returned an unreadable event stream'
130
+ end
131
+
132
+ # Modeled errors arrive as a frame after the 200, with the type in the
133
+ # frame headers and a bare message as the payload. Rewrap it under the
134
+ # exception name so raise_stream_error classifies it instead of the
135
+ # frame passing for an empty event.
136
+ def decode_event(message)
137
+ return decode_error_frame(message.headers) if stream_error_frame?(message)
138
+
139
+ exception_type = stream_exception_type(message)
140
+ payload = message.payload.read
141
+ event = decode_event_payload(payload)
142
+ return event unless exception_type
143
+
144
+ { exception_type => event || { 'message' => payload.to_s } }
145
+ end
146
+
147
+ # Failures the API does not model arrive as an error frame instead,
148
+ # with the code and message in the headers and nothing in the payload.
149
+ def stream_error_frame?(message)
150
+ event_header(message.headers, ':message-type') == 'error'
151
+ end
152
+
153
+ def decode_error_frame(headers)
154
+ detail = [event_header(headers, ':error-code'), event_header(headers, ':error-message')].compact.join(': ')
155
+ event = { 'type' => 'error' }
156
+ event['error'] = { 'message' => detail } unless detail.empty?
157
+ event
158
+ end
159
+
160
+ def stream_exception_type(message)
161
+ headers = message.headers
162
+ return nil unless event_header(headers, ':message-type') == 'exception'
163
+
164
+ event_header(headers, ':exception-type') || 'streamingException'
165
+ end
166
+
167
+ def event_header(headers, name)
168
+ header = headers[name]
169
+ header.respond_to?(:value) ? header.value : header
170
+ end
171
+
172
+ def decode_event_payload(payload)
173
+ outer = JSON.parse(payload)
174
+
175
+ if outer['bytes'].is_a?(String)
176
+ JSON.parse(Base64.decode64(outer['bytes']))
177
+ else
178
+ outer
179
+ end
180
+ rescue JSON::ParserError => e
181
+ RubyLLM.logger.debug { "Failed to decode Bedrock stream event payload: #{e.message}" }
182
+ nil
183
+ end
184
+
185
+ def stream_error_event?(event)
186
+ event.keys.any? { |key| key.end_with?('Exception') } || event['type'] == 'error'
187
+ end
188
+
189
+ def raise_stream_error(event)
190
+ if event['type'] == 'error'
191
+ message = event.dig('error', 'message') || 'Bedrock streaming error'
192
+ response = ErrorResponse.new({ 'message' => message }, 500)
193
+ Transport::ErrorMiddleware.parse_error(provider: self, response: response)
194
+ return
195
+ end
196
+
197
+ key = event.keys.find { |candidate| candidate.end_with?('Exception') }
198
+ message = event[key]['message'] || key
199
+ response = ErrorResponse.new({ 'message' => message }, EXCEPTION_STATUSES.fetch(key, 500))
200
+ Transport::ErrorMiddleware.parse_error(provider: self, response: response)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -5,16 +5,25 @@ module RubyLLM
5
5
  module Bedrock
6
6
  # S3-backed file storage for Bedrock batch input and output.
7
7
  class Files < Protocols::Files
8
+ # S3 object keys allow spaces and other characters URI() rejects or
9
+ # reads as a query or fragment.
10
+ S3_URI = %r{\As3://([^/]+)/?(.*)\z}m
11
+
12
+ def rejected_options(operation)
13
+ operation == :upload ? %i[purpose expires_in] : super
14
+ end
15
+
8
16
  # rubocop:disable-next Lint/UnusedMethodArgument
9
17
  def upload(file, filename: nil, purpose: nil, expires_in: nil, uri: nil, content_type: nil,
10
18
  provider_options: {})
11
19
  attachment = file_attachment(file, filename:)
12
20
  target_uri = uri || storage_uri_for(attachment)
13
21
  bucket, key = parse_s3_uri(target_uri)
22
+ mime_type = content_type || file_content_type(attachment)
14
23
 
15
24
  with_file_body(attachment) do |body|
16
- s3_client.put_object(bucket: bucket, key: key, body: body,
17
- content_type: content_type || file_content_type(attachment))
25
+ s3_client.put_object(**provider_options.transform_keys(&:to_sym), bucket:, key:, body:,
26
+ content_type: mime_type)
18
27
  end
19
28
 
20
29
  uploaded_file(
@@ -23,7 +32,7 @@ module RubyLLM
23
32
  uri: target_uri,
24
33
  filename: attachment.filename,
25
34
  byte_size: file_size(attachment),
26
- mime_type: content_type || file_content_type(attachment)
35
+ mime_type:
27
36
  )
28
37
  end
29
38
 
@@ -99,10 +108,10 @@ module RubyLLM
99
108
  end
100
109
 
101
110
  def parse_s3_uri(uri)
102
- parsed = URI(uri)
103
- raise ArgumentError, "Expected an s3:// URI, got: #{uri}" unless parsed.scheme == 's3'
111
+ match = S3_URI.match(uri.to_s)
112
+ raise ArgumentError, "Expected an s3:// URI, got: #{uri}" unless match
104
113
 
105
- [parsed.host, parsed.path.delete_prefix('/')]
114
+ [match[1], match[2]]
106
115
  end
107
116
  end
108
117
  end
@@ -46,7 +46,7 @@ module RubyLLM
46
46
  def parse_moderation_responses(responses)
47
47
  RubyLLM::Moderation.new(
48
48
  id: responses.one? ? responses.first.first.headers['x-amzn-requestid'] : nil,
49
- model: nil,
49
+ model: guardrail_model_id,
50
50
  results: responses.map(&:last),
51
51
  raw: responses.one? ? responses.first.first.body : responses.map { |response, _result| response.body }
52
52
  )
@@ -71,6 +71,8 @@ module RubyLLM
71
71
  RubyLLM::Rerank::Result.new(index:, document: documents[index], score: item['relevanceScore'])
72
72
  end
73
73
  end
74
+ raise Error, 'Bedrock reranking returned a duplicate document index' unless results.uniq(&:index) == results
75
+
74
76
  RubyLLM::Rerank.new(results:, model:, raw: pages.length == 1 ? pages.first : pages)
75
77
  end
76
78
  end
@@ -13,10 +13,41 @@ module RubyLLM
13
13
  OPENAI_INLINE_FILE_LIMIT = 50 * 1024 * 1024
14
14
  PROMPT_CACHE_OPTIONS = %i[key ttl mode retention].freeze
15
15
 
16
+ UNSUPPORTED_PARAMETER_CODES = %w[unsupported_parameter unsupported_value unknown_parameter].freeze
17
+ # The Responses API reports some of these, such as a temperature,
18
+ # with a +param+ and no +code+.
19
+ UNSUPPORTED_PARAMETER_MESSAGE = /\AUnsupported (?:parameter|value): /
20
+ PARAMETERS = {
21
+ 'temperature' => :temperature, 'reasoning_effort' => :thinking,
22
+ 'max_tokens' => :max_output_tokens, 'max_completion_tokens' => :max_output_tokens
23
+ }.freeze
24
+
16
25
  def completion_url
17
26
  'chat/completions'
18
27
  end
19
28
 
29
+ # OpenAI names the rejected parameter in +param+ and why in +code+.
30
+ def parse_unsupported_parameter(response)
31
+ body = response.body
32
+ body = JSON.parse(body) if body.is_a?(String)
33
+ error = body['error'] if body.is_a?(Hash)
34
+ return unless error.is_a?(Hash) && unsupported_parameter_error?(error)
35
+ return unless error['param'].is_a?(String) && !error['param'].empty?
36
+
37
+ wire_parameters.fetch(error['param'], error['param'])
38
+ rescue JSON::ParserError
39
+ nil
40
+ end
41
+
42
+ def unsupported_parameter_error?(error)
43
+ return UNSUPPORTED_PARAMETER_CODES.include?(error['code']) if error['code']
44
+
45
+ error['message'].to_s.match?(UNSUPPORTED_PARAMETER_MESSAGE)
46
+ end
47
+
48
+ # The wire parameters that carry RubyLLM settings, by name.
49
+ def wire_parameters = PARAMETERS
50
+
20
51
  module_function
21
52
 
22
53
  def finish_reasons = FINISH_REASONS
@@ -24,7 +55,7 @@ module RubyLLM
24
55
  def normalize_finish_reason(reason)
25
56
  return nil if reason.nil?
26
57
 
27
- finish_reasons.fetch(reason.to_s) { reason.to_s.to_sym }
58
+ finish_reasons.fetch(reason.to_s) { reason.to_s.downcase.to_sym }
28
59
  end
29
60
 
30
61
  # OpenAI strict mode rejects an object whose properties are not all
@@ -35,14 +66,15 @@ module RubyLLM
35
66
  end
36
67
 
37
68
  def strict_schema?(node)
38
- case node
39
- when Hash
40
- return false if optional_properties?(node)
69
+ return true unless node.is_a?(Hash)
70
+ return false if optional_properties?(node)
41
71
 
42
- node.values.all? { |value| strict_schema?(value) }
43
- when Array then node.all? { |value| strict_schema?(value) }
44
- else true
45
- end
72
+ subschemas(node).all? { |schema| strict_schema?(schema) }
73
+ end
74
+
75
+ def subschemas(node)
76
+ keyed = node.values_at(:properties, :$defs, :definitions).grep(Hash).flat_map(&:values)
77
+ keyed + node.values_at(:items, :prefixItems, :anyOf, :oneOf, :allOf, :additionalProperties).flatten(1)
46
78
  end
47
79
 
48
80
  def optional_properties?(node)
@@ -59,7 +91,7 @@ module RubyLLM
59
91
  tool_prefs ||= {}
60
92
  payload = {
61
93
  model: model.id,
62
- messages: format_messages(messages, caching: caching),
94
+ messages: render_messages(messages, caching: caching),
63
95
  stream: stream
64
96
  }
65
97
 
@@ -110,32 +142,41 @@ module RubyLLM
110
142
  message_data = data.dig('choices', 0, 'message')
111
143
  raise no_completion_message_error(data, raw) unless message_data
112
144
 
113
- usage = data['usage'] || {}
114
- thinking_tokens = thinking_tokens(usage)
115
- content, thinking_from_blocks = extract_content_and_thinking(message_data['content'])
116
- thinking_text = thinking_from_blocks || extract_thinking_text(message_data)
117
- thinking_signature = extract_thinking_signature(message_data)
145
+ content, thinking_from_blocks =
146
+ parse_content_and_thinking(message_data['content'] || message_data['refusal'])
147
+ thinking_text = thinking_from_blocks || parse_thinking_text(message_data)
148
+ thinking_signature = parse_thinking_signature(message_data)
149
+ answer, thinking_text, offset = parse_think_tags(content, thinking_text)
118
150
 
119
151
  finish_reason = normalize_finish_reason(data.dig('choices', 0, 'finish_reason'))
120
152
 
121
153
  Message.new(
122
154
  role: :assistant,
123
- content: content,
124
- citations: extract_citations(message_data, data, content),
155
+ content: answer,
156
+ citations: offset_citations(extract_citations(message_data, data, content), offset),
125
157
  thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
126
- raw_reasoning: extract_raw_reasoning(message_data),
158
+ raw_reasoning: parse_raw_reasoning(message_data),
127
159
  tool_calls: parse_tool_calls(message_data['tool_calls'], response: raw, finish_reason: finish_reason),
160
+ finish_reason: finish_reason,
161
+ content_filters: ContentFilters.parse_content_filters(data),
162
+ model: data['model'],
163
+ raw: raw,
164
+ **parse_completion_usage(data)
165
+ )
166
+ end
167
+
168
+ def parse_completion_usage(data)
169
+ usage = data['usage'] || {}
170
+
171
+ {
128
172
  input_tokens: input_tokens(usage),
129
173
  output_tokens: output_tokens(usage),
130
174
  cache_read_tokens: cache_read_tokens(usage),
131
175
  cache_write_tokens: cache_write_tokens(usage),
132
- thinking_tokens: thinking_tokens,
176
+ thinking_tokens: thinking_tokens(usage),
133
177
  server_tool_use: server_tool_use(usage),
134
- reported_cost: reported_cost(usage),
135
- finish_reason: finish_reason,
136
- model: data['model'],
137
- raw: raw
138
- )
178
+ reported_cost: reported_cost(usage)
179
+ }
139
180
  end
140
181
 
141
182
  def reported_cost(_usage)
@@ -189,7 +230,10 @@ module RubyLLM
189
230
  end
190
231
 
191
232
  def cache_write_tokens(usage)
233
+ return if usage.empty?
234
+
192
235
  usage.dig('prompt_tokens_details', 'cache_write_tokens') ||
236
+ usage.dig('prompt_tokens_details', 'created_cache_tokens') ||
193
237
  usage.dig('input_tokens_details', 'cache_write_tokens') ||
194
238
  0
195
239
  end
@@ -277,10 +321,14 @@ module RubyLLM
277
321
 
278
322
  {}.tap do |cache_options|
279
323
  cache_options[:mode] = options[:mode] if options[:mode]
280
- cache_options[:ttl] = ttl if ttl
324
+ cache_options[:ttl] = render_cache_lifetime(ttl) if ttl
281
325
  end
282
326
  end
283
327
 
328
+ def render_cache_lifetime(ttl)
329
+ Protocol::CacheLifetime.render(ttl)
330
+ end
331
+
284
332
  def retention_ttl(retention)
285
333
  return unless retention
286
334
 
@@ -301,39 +349,66 @@ module RubyLLM
301
349
  "got #{format_cache_option_keys(unsupported)}"
302
350
  end
303
351
 
352
+ def cache_option_names = PROMPT_CACHE_OPTIONS
353
+
304
354
  def format_cache_option_keys(keys)
305
355
  keys.map { |key| ":#{key}" }.join(', ')
306
356
  end
307
357
 
308
- def format_messages(messages, caching: nil)
358
+ def render_messages(messages, caching: nil)
309
359
  messages_for_provider(messages)
310
- .chunk_while { |previous, current| previous.tool_result? && current.tool_result? }
311
- .flat_map { |group| format_message_group(group, caching: caching) }
360
+ .chunk_while { |previous, current| current.tool_result? && (previous.tool_result? || carrier?(previous)) }
361
+ .flat_map { |group| render_message_group(group, caching: caching) }
312
362
  end
313
363
 
314
364
  # An assistant turn's tool results must stay consecutive, so the
315
- # attachment carriers of a parallel round follow the whole run.
316
- def format_message_group(group, caching: nil)
317
- formatted = group.map { |msg| format_message(msg, caching: caching) }
318
- carriers = group.select { |msg| msg.tool_result? && msg.attachments.any? }
365
+ # attachment carriers of a round follow the whole run.
366
+ def render_message_group(group, caching: nil)
367
+ formatted = group.filter_map do |msg|
368
+ next if attachments_only_assistant_message?(msg)
369
+
370
+ render_message(msg.role == :assistant ? msg.with_attachments([]) : msg, caching: caching)
371
+ end
372
+ carriers = group.select { |msg| carrier?(msg) }.map do |msg|
373
+ msg.tool_result? ? tool_attachment_message(msg) : assistant_attachment_message(msg)
374
+ end
319
375
 
320
- formatted + carriers.map { |msg| tool_attachment_message(msg) }
376
+ formatted + carriers
321
377
  end
322
378
 
323
- def format_message(msg, caching: nil)
379
+ def carrier?(msg)
380
+ (msg.tool_result? || msg.role == :assistant) && msg.attachments.any?
381
+ end
382
+
383
+ def attachments_only_assistant_message?(msg)
384
+ msg.role == :assistant && msg.attachments.any? && !msg.tool_call? && msg.thinking.nil? &&
385
+ msg.content.to_s.empty?
386
+ end
387
+
388
+ def render_message(msg, caching: nil)
324
389
  {
325
- role: format_role(msg.role),
326
- content: format_message_content(msg, caching: caching),
327
- tool_calls: format_tool_calls(msg.tool_calls),
328
- tool_call_id: msg.tool_call_id
329
- }.compact.merge(format_thinking(msg))
390
+ role: render_role(msg.role),
391
+ content: render_message_content(msg, caching: caching),
392
+ tool_calls: render_tool_calls(msg.tool_calls),
393
+ tool_call_id: msg.provider_call_id
394
+ }.compact.merge(render_thinking(msg))
330
395
  end
331
396
 
332
397
  # Chat Completions tool messages are text-only on the wire, so tool
333
398
  # attachments ride a user message spliced in after the results.
334
399
  def tool_attachment_message(msg)
335
- parts = [Media.format_text("Attachments from tool call #{msg.tool_call_id}:")]
336
- parts.concat(format_content(nil, msg.attachments))
400
+ attachment_message("Attachments from tool call #{msg.provider_call_id}:", msg.attachments)
401
+ end
402
+
403
+ # Assistant messages take only text, so a replayed turn's attachments
404
+ # ride a user message after it, as on Responses.
405
+ def assistant_attachment_message(msg)
406
+ attachment_message('Attachments from the assistant:', msg.attachments)
407
+ end
408
+
409
+ def attachment_message(label, attachments)
410
+ parts = [Media.render_text(label)]
411
+ parts.concat(render_content(nil, attachments))
337
412
  { role: 'user', content: parts }
338
413
  end
339
414
 
@@ -342,8 +417,8 @@ module RubyLLM
342
417
  system_messages + other_messages
343
418
  end
344
419
 
345
- def format_message_content(msg, caching: nil, **)
346
- content = format_content(msg.content, msg.tool_result? ? [] : msg.attachments)
420
+ def render_message_content(msg, caching: nil, **)
421
+ content = render_content(msg.content, msg.tool_result? ? [] : msg.attachments)
347
422
  return '' if content.nil? && thinking_only_assistant_message?(msg)
348
423
  return inject_cache_breakpoint(content) if caching != false && msg.cache_until_here? && openai_prompt_caching?
349
424
 
@@ -361,7 +436,7 @@ module RubyLLM
361
436
  def cache_breakpoint_parts(content)
362
437
  case content
363
438
  when Array then content.dup
364
- when String then [Media.format_text(content)] unless content.empty?
439
+ when String then [Media.render_text(content)] unless content.empty?
365
440
  end
366
441
  end
367
442
 
@@ -369,11 +444,11 @@ module RubyLLM
369
444
  msg.role == :assistant && msg.thinking && !msg.tool_call?
370
445
  end
371
446
 
372
- def format_content(content, attachments = [])
373
- Media.format_content(content, attachments)
447
+ def render_content(content, attachments = [])
448
+ Media.render_content(content, attachments)
374
449
  end
375
450
 
376
- def format_role(role)
451
+ def render_role(role)
377
452
  case role
378
453
  when :system
379
454
  @config.openai_use_system_role ? 'system' : 'developer'
@@ -398,7 +473,7 @@ module RubyLLM
398
473
  payload.merge(safety_identifier: identifier)
399
474
  end
400
475
 
401
- def supports_provider_file_references?
476
+ def provider_file_references?
402
477
  @provider.slug == 'openai'
403
478
  end
404
479
 
@@ -414,7 +489,7 @@ module RubyLLM
414
489
  { purpose: 'user_data' }
415
490
  end
416
491
 
417
- def format_thinking(msg)
492
+ def render_thinking(msg)
418
493
  return {} unless msg.role == :assistant
419
494
 
420
495
  thinking = msg.thinking
@@ -429,44 +504,69 @@ module RubyLLM
429
504
  payload
430
505
  end
431
506
 
432
- def extract_thinking_text(message_data)
507
+ def parse_thinking_text(message_data)
433
508
  candidate = message_data['reasoning_content'] || message_data['reasoning'] || message_data['thinking']
434
509
  candidate.is_a?(String) ? candidate : nil
435
510
  end
436
511
 
437
- def extract_thinking_signature(message_data)
512
+ def parse_thinking_signature(message_data)
438
513
  candidate = message_data['reasoning_signature'] || message_data['signature']
439
514
  candidate.is_a?(String) ? candidate : nil
440
515
  end
441
516
 
442
- def extract_raw_reasoning(_message_data)
517
+ def parse_think_tags(content, thinking_text)
518
+ return [content, thinking_text, 0] unless split_think_tags? && thinking_text.nil?
519
+ return [content, nil, 0] unless content.is_a?(String) && !content.empty?
520
+
521
+ ThinkTags.split(content)
522
+ end
523
+
524
+ # Annotation indices count from the start of the content the server
525
+ # sent, before the think block was removed.
526
+ def offset_citations(citations, offset)
527
+ return citations if offset.zero?
528
+
529
+ citations.map do |citation|
530
+ next citation unless citation.start_index && citation.end_index
531
+
532
+ start_index = citation.start_index - offset
533
+ end_index = citation.end_index - offset if start_index >= 0
534
+ Citation.new(**citation.to_h, start_index: end_index && start_index, end_index:)
535
+ end
536
+ end
537
+
538
+ def split_think_tags?
539
+ @config&.split_think_tags
540
+ end
541
+
542
+ def parse_raw_reasoning(_message_data)
443
543
  nil
444
544
  end
445
545
 
446
- def extract_content_and_thinking(content)
546
+ def parse_content_and_thinking(content)
447
547
  return [content, nil] unless content.is_a?(Array)
448
548
 
449
- text = extract_text_from_blocks(content)
450
- thinking = extract_thinking_from_blocks(content)
549
+ text = parse_text_from_blocks(content)
550
+ thinking = parse_thinking_from_blocks(content)
451
551
 
452
552
  [text.empty? ? nil : text, thinking.empty? ? nil : thinking]
453
553
  end
454
554
 
455
- def extract_text_from_blocks(blocks)
555
+ def parse_text_from_blocks(blocks)
456
556
  blocks.filter_map do |block|
457
557
  block['text'] if block['type'] == 'text' && block['text'].is_a?(String)
458
558
  end.join
459
559
  end
460
560
 
461
- def extract_thinking_from_blocks(blocks)
561
+ def parse_thinking_from_blocks(blocks)
462
562
  blocks.filter_map do |block|
463
563
  next unless block['type'] == 'thinking'
464
564
 
465
- extract_thinking_text_from_block(block)
565
+ parse_thinking_text_from_block(block)
466
566
  end.join
467
567
  end
468
568
 
469
- def extract_thinking_text_from_block(block)
569
+ def parse_thinking_text_from_block(block)
470
570
  thinking_block = block['thinking']
471
571
  return thinking_block if thinking_block.is_a?(String)
472
572