ruby_llm 1.16.0 → 2.0.0.rc2

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 (475) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +85 -32
  4. data/exe/ruby_llm +8 -0
  5. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +0 -1
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +3 -43
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +11 -3
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +4 -4
  10. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +1 -1
  11. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +1 -1
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +2 -2
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +1 -1
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +6 -4
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +1 -1
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +2 -2
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +5 -6
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +2 -2
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +5 -5
  23. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +1 -1
  24. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +1 -1
  25. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +1 -1
  26. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +2 -2
  27. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +1 -1
  28. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  29. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +6 -4
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +1 -1
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +4 -6
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +2 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +5 -6
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +2 -2
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -3
  37. data/lib/generators/ruby_llm/generator_helpers.rb +106 -62
  38. data/lib/generators/ruby_llm/install/install_generator.rb +3 -11
  39. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +2 -0
  40. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +14 -8
  41. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +117 -0
  42. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +0 -8
  43. data/lib/generators/ruby_llm/provider/cli.rb +175 -0
  44. data/lib/generators/ruby_llm/provider/scaffold.rb +323 -0
  45. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +37 -0
  46. data/lib/generators/ruby_llm/provider/templates/core/provider_spec.rb.erb +34 -0
  47. data/lib/generators/ruby_llm/provider/templates/gem/archspec.rb.erb +14 -0
  48. data/lib/generators/ruby_llm/provider/templates/gem/bin/console.erb +15 -0
  49. data/lib/generators/ruby_llm/provider/templates/gem/bin/setup.erb +5 -0
  50. data/lib/generators/ruby_llm/provider/templates/gem/chat_schema_spec.rb.erb +30 -0
  51. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +35 -0
  52. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +22 -0
  53. data/lib/generators/ruby_llm/provider/templates/gem/chat_tools_spec.rb.erb +30 -0
  54. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +32 -0
  55. data/lib/generators/ruby_llm/provider/templates/gem/embedding_spec.rb.erb +49 -0
  56. data/lib/generators/ruby_llm/provider/templates/gem/env.erb +2 -0
  57. data/lib/generators/ruby_llm/provider/templates/gem/fixtures_gitkeep.erb +1 -0
  58. data/lib/generators/ruby_llm/provider/templates/gem/flayignore.erb +1 -0
  59. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +23 -0
  60. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +28 -0
  61. data/lib/generators/ruby_llm/provider/templates/gem/gitignore.erb +7 -0
  62. data/lib/generators/ruby_llm/provider/templates/gem/gitleaks.yml.erb +22 -0
  63. data/lib/generators/ruby_llm/provider/templates/gem/image_spec.rb.erb +23 -0
  64. data/lib/generators/ruby_llm/provider/templates/gem/license.erb +21 -0
  65. data/lib/generators/ruby_llm/provider/templates/gem/models.rb.erb +19 -0
  66. data/lib/generators/ruby_llm/provider/templates/gem/models_spec.rb.erb +17 -0
  67. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +22 -0
  68. data/lib/generators/ruby_llm/provider/templates/gem/overcommit.yml.erb +31 -0
  69. data/lib/generators/ruby_llm/provider/templates/gem/provider.rb.erb +52 -0
  70. data/lib/generators/ruby_llm/provider/templates/gem/provider_spec.rb.erb +38 -0
  71. data/lib/generators/ruby_llm/provider/templates/gem/rakefile.erb +38 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/readme.md.erb +50 -0
  73. data/lib/generators/ruby_llm/provider/templates/gem/release.yml.erb +36 -0
  74. data/lib/generators/ruby_llm/provider/templates/gem/rerank_spec.rb.erb +23 -0
  75. data/lib/generators/ruby_llm/provider/templates/gem/rspec.erb +2 -0
  76. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +29 -0
  77. data/lib/generators/ruby_llm/provider/templates/gem/rubyllm_configuration.rb.erb +14 -0
  78. data/lib/generators/ruby_llm/provider/templates/gem/spec_helper.rb.erb +26 -0
  79. data/lib/generators/ruby_llm/provider/templates/gem/speech_spec.rb.erb +25 -0
  80. data/lib/generators/ruby_llm/provider/templates/gem/vcr_configuration.rb.erb +16 -0
  81. data/lib/generators/ruby_llm/provider/templates/gem/video_spec.rb.erb +27 -0
  82. data/lib/generators/ruby_llm/schema/schema_generator.rb +5 -1
  83. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +1 -1
  84. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +13 -0
  85. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +21 -0
  86. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +3 -3
  87. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +7 -12
  88. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +5 -2
  89. data/lib/generators/ruby_llm/tool/tool_generator.rb +25 -59
  90. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +461 -0
  91. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +101 -0
  92. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +215 -0
  93. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +660 -0
  94. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +222 -0
  95. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
  96. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +167 -0
  97. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +344 -0
  98. data/lib/ruby_llm/accounting/usage.rb +245 -0
  99. data/lib/ruby_llm/active_record/acts_as.rb +94 -111
  100. data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
  103. data/lib/ruby_llm/active_record/message_methods.rb +113 -136
  104. data/lib/ruby_llm/active_record/model.rb +135 -0
  105. data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
  106. data/lib/ruby_llm/active_record/tool_call.rb +33 -0
  107. data/lib/ruby_llm/active_record/usage.rb +61 -0
  108. data/lib/ruby_llm/agent.rb +1065 -151
  109. data/lib/ruby_llm/aliases.json +268 -100
  110. data/lib/ruby_llm/attachment.rb +187 -48
  111. data/lib/ruby_llm/batch.rb +432 -0
  112. data/lib/ruby_llm/cached_content.rb +112 -0
  113. data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
  114. data/lib/ruby_llm/chat.rb +1127 -198
  115. data/lib/ruby_llm/chunk.rb +10 -0
  116. data/lib/ruby_llm/citation.rb +105 -0
  117. data/lib/ruby_llm/configuration.rb +261 -24
  118. data/lib/ruby_llm/context.rb +128 -6
  119. data/lib/ruby_llm/cost.rb +217 -80
  120. data/lib/ruby_llm/downloaded_file.rb +33 -0
  121. data/lib/ruby_llm/embedding.rb +121 -17
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +159 -23
  124. data/lib/ruby_llm/fallback.rb +133 -0
  125. data/lib/ruby_llm/files/mime_type.rb +97 -0
  126. data/lib/ruby_llm/image.rb +153 -32
  127. data/lib/ruby_llm/message.rb +233 -54
  128. data/lib/ruby_llm/model/modalities.rb +17 -4
  129. data/lib/ruby_llm/model/pricing.rb +24 -5
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +55 -15
  132. data/lib/ruby_llm/model.rb +244 -2
  133. data/lib/ruby_llm/models/aliases.rb +41 -0
  134. data/lib/ruby_llm/models/registry.rb +165 -0
  135. data/lib/ruby_llm/models/schema.rb +99 -0
  136. data/lib/ruby_llm/models.json +73261 -33733
  137. data/lib/ruby_llm/models.rb +477 -215
  138. data/lib/ruby_llm/moderation.rb +139 -26
  139. data/lib/ruby_llm/ocr.rb +112 -0
  140. data/lib/ruby_llm/prompt.rb +79 -0
  141. data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
  142. data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
  143. data/lib/ruby_llm/protocol/streaming.rb +230 -0
  144. data/lib/ruby_llm/protocol.rb +662 -0
  145. data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
  146. data/lib/ruby_llm/protocols/anthropic/chat.rb +548 -0
  147. data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
  148. data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
  149. data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
  150. data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
  151. data/lib/ruby_llm/protocols/anthropic/streaming.rb +167 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  153. data/lib/ruby_llm/protocols/anthropic.rb +101 -0
  154. data/lib/ruby_llm/protocols/azure/files.rb +16 -0
  155. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +109 -0
  156. data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
  157. data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
  158. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
  159. data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
  160. data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
  161. data/lib/ruby_llm/protocols/chat_completions/chat.rb +493 -0
  162. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
  163. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
  164. data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
  165. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/media.rb +30 -17
  166. data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
  167. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
  168. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +56 -0
  169. data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
  170. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
  171. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +15 -17
  172. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
  173. data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
  174. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
  175. data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
  176. data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
  177. data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
  178. data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
  179. data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
  180. data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
  181. data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
  182. data/lib/ruby_llm/protocols/cohere/rerank.rb +52 -0
  183. data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
  184. data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
  185. data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
  186. data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
  187. data/lib/ruby_llm/protocols/cohere.rb +21 -0
  188. data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
  189. data/lib/ruby_llm/protocols/converse/chat.rb +695 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
  192. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -0
  193. data/lib/ruby_llm/protocols/converse.rb +54 -0
  194. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  195. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  196. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  197. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  198. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  199. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  208. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  209. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  210. data/lib/ruby_llm/protocols/files.rb +119 -0
  211. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  212. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  213. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  214. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  215. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  216. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  217. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  218. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  219. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  220. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  221. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  222. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  223. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  224. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  225. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  226. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  227. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  228. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  229. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  230. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  231. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  232. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  233. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  234. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  235. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  236. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  237. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  238. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  239. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  240. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  242. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  243. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  244. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  248. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  249. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  250. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  251. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  252. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  253. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  254. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  255. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  256. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  257. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  258. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  259. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  261. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  262. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  263. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  264. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  265. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  266. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  267. data/lib/ruby_llm/protocols/responses.rb +35 -0
  268. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  271. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  272. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  273. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  274. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  275. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  276. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  277. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  278. data/lib/ruby_llm/provider.rb +560 -128
  279. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  280. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  281. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  282. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  283. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  284. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  285. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  286. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  287. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  288. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  289. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  290. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  291. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  292. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  293. data/lib/ruby_llm/providers/azure.rb +77 -78
  294. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  295. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  300. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  301. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  302. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  303. data/lib/ruby_llm/providers/cohere.rb +31 -0
  304. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  305. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  306. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  307. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  308. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  309. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  310. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  311. data/lib/ruby_llm/providers/gemini.rb +15 -8
  312. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  313. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  314. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  315. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  316. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  317. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  318. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  319. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  320. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  321. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  322. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  323. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  324. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  325. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  326. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  327. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  328. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  329. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  330. data/lib/ruby_llm/providers/mistral.rb +16 -4
  331. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  332. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  333. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  334. data/lib/ruby_llm/providers/ollama.rb +9 -8
  335. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  336. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  337. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  338. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  339. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  340. data/lib/ruby_llm/providers/openai.rb +92 -11
  341. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -108
  342. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  343. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  344. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  345. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  346. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  347. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  348. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  349. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  350. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  351. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  352. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  353. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  354. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  355. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  356. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  357. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  359. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  360. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  361. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  362. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  363. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  364. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  365. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  366. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  367. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  368. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  369. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  370. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  371. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  372. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  373. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  374. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  375. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  376. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  377. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  378. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  379. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  380. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  381. data/lib/ruby_llm/providers/xai.rb +15 -5
  382. data/lib/ruby_llm/railtie.rb +7 -16
  383. data/lib/ruby_llm/rerank.rb +105 -0
  384. data/lib/ruby_llm/research_job.rb +241 -0
  385. data/lib/ruby_llm/search_results.rb +68 -0
  386. data/lib/ruby_llm/server_tool_call.rb +73 -0
  387. data/lib/ruby_llm/speech.rb +159 -0
  388. data/lib/ruby_llm/speech_chunk.rb +33 -0
  389. data/lib/ruby_llm/support/deprecator.rb +22 -0
  390. data/lib/ruby_llm/support/inspectable.rb +49 -0
  391. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  392. data/lib/ruby_llm/support/utils.rb +147 -0
  393. data/lib/ruby_llm/thinking.rb +127 -20
  394. data/lib/ruby_llm/tokenization.rb +59 -0
  395. data/lib/ruby_llm/tokens.rb +103 -33
  396. data/lib/ruby_llm/tool.rb +266 -91
  397. data/lib/ruby_llm/tool_call.rb +36 -3
  398. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  399. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  400. data/lib/ruby_llm/transcription.rb +138 -13
  401. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  402. data/lib/ruby_llm/transport/connection.rb +193 -0
  403. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  404. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  405. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  406. data/lib/ruby_llm/uploaded_file.rb +144 -0
  407. data/lib/ruby_llm/version.rb +2 -1
  408. data/lib/ruby_llm/video.rb +136 -0
  409. data/lib/ruby_llm/video_job.rb +150 -0
  410. data/lib/ruby_llm/workflow.rb +91 -0
  411. data/lib/ruby_llm.rb +380 -6
  412. data/lib/tasks/ruby_llm.rake +21 -16
  413. data/skills/rubyllm/SKILL.md +81 -0
  414. data/skills/rubyllm/agents/openai.yaml +4 -0
  415. metadata +339 -97
  416. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  417. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  418. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  419. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  422. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  424. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  426. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  428. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  429. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  430. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  431. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  432. data/lib/ruby_llm/aliases.rb +0 -41
  433. data/lib/ruby_llm/connection.rb +0 -159
  434. data/lib/ruby_llm/content.rb +0 -91
  435. data/lib/ruby_llm/deprecator.rb +0 -24
  436. data/lib/ruby_llm/error_middleware.rb +0 -81
  437. data/lib/ruby_llm/instrumentation.rb +0 -36
  438. data/lib/ruby_llm/mime_type.rb +0 -96
  439. data/lib/ruby_llm/model/info.rb +0 -164
  440. data/lib/ruby_llm/model_registry.rb +0 -39
  441. data/lib/ruby_llm/models_schema.json +0 -171
  442. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  443. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  444. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  445. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  446. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  447. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  448. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  449. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  450. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  451. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  452. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  453. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  454. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  455. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  456. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  457. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  458. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  459. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  460. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  461. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  462. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  463. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  464. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  465. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  466. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  467. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  468. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  469. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  470. data/lib/ruby_llm/streaming.rb +0 -179
  471. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  472. data/lib/ruby_llm/utils.rb +0 -130
  473. data/lib/tasks/models.rake +0 -593
  474. data/lib/tasks/release.rake +0 -94
  475. data/lib/tasks/vcr.rake +0 -124
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Protocols
5
+ class Converse
6
+ # Media formatting for Bedrock Converse content blocks.
7
+ module Media
8
+ module_function
9
+
10
+ def format_content(content, attachments = [], used_document_names: nil, citations: false)
11
+ used_document_names ||= {}
12
+ blocks = []
13
+ blocks << { text: content } unless content.nil? || content.empty?
14
+ attachments.each do |attachment|
15
+ blocks << format_attachment(attachment, used_document_names:, citations:)
16
+ end
17
+ blocks
18
+ end
19
+
20
+ def format_attachment(attachment, used_document_names:, citations: false)
21
+ if attachment.provider_file?
22
+ return format_provider_file_attachment(attachment, used_document_names:, citations:)
23
+ end
24
+
25
+ case attachment.type
26
+ when :image
27
+ format_image_attachment(attachment)
28
+ when :audio
29
+ format_audio_attachment(attachment)
30
+ when :video
31
+ format_video_attachment(attachment)
32
+ when :pdf, :document
33
+ format_document_attachment(attachment, used_document_names:, citations:)
34
+ when :text
35
+ if citations
36
+ format_text_document_attachment(attachment, used_document_names:)
37
+ else
38
+ format_text_attachment(attachment)
39
+ end
40
+ else
41
+ raise UnsupportedAttachmentError, attachment.mime_type
42
+ end
43
+ end
44
+
45
+ SUPPORTED_DOCUMENT_FORMATS = %w[pdf csv doc docx xls xlsx html txt md].freeze
46
+ SUPPORTED_AUDIO_FORMATS = %w[mp3 opus wav aac flac mp4 ogg mkv mka x-aac m4a mpeg mpga pcm webm].freeze
47
+ SUPPORTED_VIDEO_FORMATS = %w[mkv mov mp4 webm flv mpeg mpg wmv three_gp].freeze
48
+ AUDIO_FORMAT_ALIASES = {
49
+ 'x-flac' => 'flac',
50
+ 'x-m4a' => 'm4a'
51
+ }.freeze
52
+ VIDEO_FORMAT_ALIASES = {
53
+ 'quicktime' => 'mov',
54
+ 'x-matroska' => 'mkv',
55
+ 'x-ms-wmv' => 'wmv',
56
+ '3gp' => 'three_gp',
57
+ '3gpp' => 'three_gp'
58
+ }.freeze
59
+
60
+ def format_image_attachment(attachment)
61
+ {
62
+ image: {
63
+ format: attachment.format,
64
+ source: {
65
+ bytes: attachment.encoded
66
+ }
67
+ }
68
+ }
69
+ end
70
+
71
+ def format_audio_attachment(attachment)
72
+ {
73
+ audio: {
74
+ format: media_format(attachment, AUDIO_FORMAT_ALIASES, SUPPORTED_AUDIO_FORMATS),
75
+ source: {
76
+ bytes: attachment.encoded
77
+ }
78
+ }
79
+ }
80
+ end
81
+
82
+ def format_video_attachment(attachment)
83
+ {
84
+ video: {
85
+ format: media_format(attachment, VIDEO_FORMAT_ALIASES, SUPPORTED_VIDEO_FORMATS),
86
+ source: {
87
+ bytes: attachment.encoded
88
+ }
89
+ }
90
+ }
91
+ end
92
+
93
+ def media_format(attachment, aliases, supported)
94
+ format = attachment.format.to_s
95
+ format = aliases.fetch(format, format)
96
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported.include?(format)
97
+
98
+ format
99
+ end
100
+
101
+ def format_text_attachment(attachment)
102
+ { text: attachment.for_llm }
103
+ end
104
+
105
+ def format_document_attachment(attachment, used_document_names:, citations: false)
106
+ format = document_format(attachment)
107
+
108
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_document_format?(attachment)
109
+
110
+ document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
111
+ document = {
112
+ format: format,
113
+ name: document_name,
114
+ source: {
115
+ bytes: attachment.encoded
116
+ }
117
+ }
118
+ document[:citations] = { enabled: true } if citations
119
+ { document: document }
120
+ end
121
+
122
+ # Text attachments become citable documents when citations are
123
+ # enabled. Bedrock rejects text-format documents sent as bytes when
124
+ # citations are on, so the text rides in the source's text member.
125
+ def format_text_document_attachment(attachment, used_document_names:)
126
+ document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
127
+ {
128
+ document: {
129
+ format: 'txt',
130
+ name: document_name,
131
+ source: { text: attachment.content },
132
+ citations: { enabled: true }
133
+ }
134
+ }
135
+ end
136
+
137
+ def format_provider_file_attachment(attachment, used_document_names:, citations: false)
138
+ raise UnsupportedAttachmentError, attachment.mime_type unless supported_document_format?(attachment)
139
+
140
+ document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
141
+ document = {
142
+ format: document_format(attachment),
143
+ name: document_name,
144
+ source: {
145
+ s3Location: {
146
+ uri: attachment.provider_file_uri || attachment.provider_file_id
147
+ }
148
+ }
149
+ }
150
+ document[:citations] = { enabled: true } if citations
151
+ { document: document }
152
+ end
153
+
154
+ def supported_document_format?(attachment)
155
+ SUPPORTED_DOCUMENT_FORMATS.include?(document_format(attachment))
156
+ end
157
+
158
+ def document_format(attachment)
159
+ attachment.extension || attachment.format
160
+ end
161
+
162
+ def sanitize_document_name(filename)
163
+ base = File.basename(filename.to_s, '.*')
164
+ safe = base.gsub(/[^a-zA-Z0-9_-]/, '_')
165
+ safe.empty? ? 'document' : safe
166
+ end
167
+
168
+ def unique_document_name(base_name, used_names)
169
+ count = used_names[base_name].to_i
170
+ used_names[base_name] = count + 1
171
+ return base_name if count.zero?
172
+
173
+ "#{base_name}_#{count + 1}"
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'faraday'
5
+ require 'json'
6
+
7
+ module RubyLLM
8
+ module Protocols
9
+ class Converse
10
+ # Streaming implementation for Bedrock ConverseStream (AWS Event Stream).
11
+ module Streaming
12
+ ErrorResponse = Struct.new(:body, :status)
13
+
14
+ private
15
+
16
+ def stream_url
17
+ "/model/#{escape_model_id(@model.id)}/converse-stream"
18
+ end
19
+
20
+ def stream_response(payload, additional_headers = {}, &block)
21
+ @thinking_stream = ThinkingStream.new
22
+ accumulator = RubyLLM::Protocol::StreamAccumulator.new
23
+ decoder = event_stream_decoder
24
+ body = JSON.generate(payload)
25
+ progress = {}
26
+
27
+ faraday_v1 = Faraday::VERSION.start_with?('1')
28
+ on_data = RubyLLM::Protocol::Streaming::FaradayHandlers.build(
29
+ faraday_v1: faraday_v1,
30
+ on_chunk: ->(chunk, _env) { parse_stream_chunk(decoder, chunk, accumulator, progress, &block) },
31
+ on_failed_response: ->(chunk, env) { handle_failed_stream(chunk, env) }
32
+ )
33
+
34
+ response = @connection.post(stream_url, payload, usage: @usage_tracker) do |req|
35
+ req.headers.merge!(@provider.sign_headers('POST', stream_url, body))
36
+ req.headers.merge!(additional_headers) unless additional_headers.empty?
37
+ req.headers['Accept'] = 'application/vnd.amazon.eventstream'
38
+ (req.options.context ||= {})[RubyLLM::Transport::Connection::STREAM_PROGRESS_KEY] = progress
39
+
40
+ if faraday_v1
41
+ req.options[:on_data] = on_data
42
+ else
43
+ req.options.on_data = on_data
44
+ end
45
+ end
46
+
47
+ message = accumulator.to_message(response)
48
+ RubyLLM.logger.debug { "Stream completed: #{message.content}" }
49
+ message
50
+ end
51
+
52
+ def event_stream_decoder
53
+ require 'aws-eventstream'
54
+ Aws::EventStream::Decoder.new
55
+ rescue LoadError
56
+ raise Error,
57
+ 'The aws-eventstream gem is required for Bedrock streaming. ' \
58
+ 'Please add it to your Gemfile: gem "aws-eventstream"'
59
+ end
60
+
61
+ # The error body arrives in as many reads as the adapter hands out, so
62
+ # it accumulates on the env, which ErrorMiddleware clears per attempt.
63
+ def handle_failed_stream(chunk, env)
64
+ buffer = failed_stream_buffer(env)
65
+ buffer << chunk
66
+ error_response = env.merge(body: JSON.parse(buffer))
67
+ Transport::ErrorMiddleware.parse_error(provider: self, response: error_response)
68
+ rescue JSON::ParserError
69
+ RubyLLM.logger.debug { "Accumulating Bedrock stream error chunk: #{chunk}" }
70
+ end
71
+
72
+ def failed_stream_buffer(env)
73
+ (env[:streaming_state] ||= RubyLLM::Protocol::Streaming::StreamState.new).buffer
74
+ end
75
+
76
+ def parse_stream_chunk(decoder, raw_chunk, accumulator, progress)
77
+ handle_non_eventstream_error_chunk(raw_chunk)
78
+
79
+ decode_events(decoder, raw_chunk).each do |event|
80
+ chunk = build_chunk(event)
81
+ next unless chunk
82
+
83
+ accumulator.add(chunk)
84
+ progress[:started] = true
85
+ yield chunk
86
+ end
87
+ end
88
+
89
+ def handle_non_eventstream_error_chunk(raw_chunk)
90
+ text = raw_chunk.to_s
91
+
92
+ if text.start_with?('event: error')
93
+ payload = text.lines.find { |line| line.start_with?('data:') }&.delete_prefix('data:')&.strip
94
+ raise_streaming_chunk_error(payload) if payload
95
+ return
96
+ end
97
+
98
+ return unless text.lstrip.start_with?('{') && text.include?('"error"')
99
+
100
+ raise_streaming_chunk_error(text)
101
+ end
102
+
103
+ def raise_streaming_chunk_error(payload)
104
+ parsed = JSON.parse(payload)
105
+ message = parsed.dig('error', 'message') || parsed['message'] || 'Bedrock streaming error'
106
+ response = ErrorResponse.new({ 'message' => message }, 500)
107
+ Transport::ErrorMiddleware.parse_error(provider: self, response: response)
108
+ rescue JSON::ParserError
109
+ nil
110
+ end
111
+
112
+ def decode_events(decoder, raw_chunk)
113
+ events = []
114
+ message, eof = decoder.decode_chunk(raw_chunk)
115
+
116
+ while message
117
+ event = decode_event(message)
118
+ if event && RubyLLM.config.log_stream_debug
119
+ RubyLLM.logger.debug do
120
+ "Bedrock stream event keys: #{event.keys}"
121
+ end
122
+ end
123
+ events << event if event
124
+ break if eof
125
+
126
+ message, eof = decoder.decode_chunk
127
+ end
128
+
129
+ events
130
+ end
131
+
132
+ # ConverseStream delivers modeled errors as a frame after the 200, with
133
+ # the type in the frame headers and a bare message as the payload. Rewrap
134
+ # it under the exception name so raise_stream_error classifies it instead
135
+ # of the frame passing for an empty chunk.
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 build_chunk(event)
186
+ raise_stream_error(event) if stream_error_event?(event)
187
+
188
+ metadata_usage, usage = event_usage(event)
189
+
190
+ Chunk.new(
191
+ role: :assistant,
192
+ model: event['modelId'] || @model&.id,
193
+ content: extract_content_delta(event),
194
+ citations: extract_citations_delta(event),
195
+ thinking: Thinking.build(
196
+ text: extract_thinking_delta(event),
197
+ signature: extract_thinking_signature(event)
198
+ ),
199
+ raw_reasoning: streamed_thinking_blocks(event),
200
+ tool_calls: extract_tool_calls(event),
201
+ server_tool_calls: extract_server_tool_call_events(event),
202
+ input_tokens: extract_input_tokens(metadata_usage, usage),
203
+ output_tokens: extract_output_tokens(metadata_usage, usage),
204
+ cache_read_tokens: extract_cache_read_tokens(metadata_usage, usage),
205
+ cache_write_tokens: extract_cache_write_tokens(metadata_usage, usage),
206
+ thinking_tokens: extract_reasoning_tokens(metadata_usage, usage),
207
+ finish_reason: extract_finish_reason(event)
208
+ )
209
+ end
210
+
211
+ def streamed_thinking_blocks(event)
212
+ @thinking_stream ||= ThinkingStream.new
213
+ @thinking_stream.add(event)
214
+ @thinking_stream.raw_reasoning if event.key?('messageStop') || event.key?('stopReason')
215
+ end
216
+
217
+ def extract_finish_reason(event)
218
+ normalize_finish_reason(event.dig('messageStop', 'stopReason') || event['stopReason'])
219
+ end
220
+
221
+ def event_usage(event)
222
+ [
223
+ event.dig('metadata', 'usage') || {},
224
+ event['usage'] || {}
225
+ ]
226
+ end
227
+
228
+ def extract_input_tokens(metadata_usage, usage)
229
+ bedrock_usage = metadata_usage['inputTokens'] ? metadata_usage : usage
230
+ Chat.input_tokens(bedrock_usage) if bedrock_usage['inputTokens']
231
+ end
232
+
233
+ def extract_output_tokens(metadata_usage, usage)
234
+ metadata_usage['outputTokens'] || usage['outputTokens']
235
+ end
236
+
237
+ def extract_cache_read_tokens(metadata_usage, usage)
238
+ metadata_usage['cacheReadInputTokens'] || usage['cacheReadInputTokens']
239
+ end
240
+
241
+ def extract_cache_write_tokens(metadata_usage, usage)
242
+ metadata_usage['cacheWriteInputTokens'] || usage['cacheWriteInputTokens']
243
+ end
244
+
245
+ def extract_reasoning_tokens(metadata_usage, usage)
246
+ Chat.reasoning_tokens(metadata_usage) || Chat.reasoning_tokens(usage)
247
+ end
248
+
249
+ def stream_error_event?(event)
250
+ event.keys.any? { |key| key.end_with?('Exception') } || event['type'] == 'error'
251
+ end
252
+
253
+ def raise_stream_error(event)
254
+ if event['type'] == 'error'
255
+ message = event.dig('error', 'message') || 'Bedrock streaming error'
256
+ response = ErrorResponse.new({ 'message' => message }, 500)
257
+ Transport::ErrorMiddleware.parse_error(provider: self, response: response)
258
+ return
259
+ end
260
+
261
+ key = event.keys.find { |candidate| candidate.end_with?('Exception') }
262
+ payload = event[key]
263
+ message = payload['message'] || key
264
+ status = case key
265
+ when 'throttlingException' then 429
266
+ when 'validationException' then 400
267
+ when 'accessDeniedException', 'unrecognizedClientException' then 401
268
+ when 'serviceUnavailableException' then 503
269
+ else 500
270
+ end
271
+
272
+ response = ErrorResponse.new({ 'message' => message }, status)
273
+ Transport::ErrorMiddleware.parse_error(provider: self, response: response)
274
+ end
275
+
276
+ def extract_content_delta(event)
277
+ normalized_delta(event)['text']
278
+ end
279
+
280
+ # The cited span itself streams as ordinary text deltas; the citation
281
+ # delta carries the source. The final message resolves the span text
282
+ # from the accumulated content when indices are known.
283
+ def extract_citations_delta(event)
284
+ citation = normalized_delta(event)['citation']
285
+ return nil unless citation.is_a?(Hash)
286
+
287
+ [Chat.parse_citation(citation)]
288
+ end
289
+
290
+ def extract_thinking_delta(event)
291
+ reasoning_content = normalized_delta(event)['reasoningContent'] || {}
292
+
293
+ reasoning_text = reasoning_content['reasoningText'] || {}
294
+ return reasoning_text['text'] if reasoning_text.key?('text')
295
+ return reasoning_content['text'] if reasoning_content.key?('text')
296
+ return '' if [reasoning_text, reasoning_content].any? { |block| block.key?('signature') }
297
+
298
+ extract_thinking_from_start(event)
299
+ end
300
+
301
+ def extract_thinking_from_start(event)
302
+ start = event.dig('contentBlockStart', 'start', 'reasoningContent') || {}
303
+ return unless start.key?('reasoningText')
304
+
305
+ start.dig('reasoningText', 'text').to_s
306
+ end
307
+
308
+ def extract_thinking_signature(event)
309
+ signature = extract_signature_from_delta(event)
310
+ return signature if signature
311
+
312
+ signature = extract_signature_from_start(event)
313
+ return signature if signature
314
+
315
+ nil
316
+ end
317
+
318
+ def extract_signature_from_delta(event)
319
+ reasoning_content = normalized_delta(event)['reasoningContent'] || {}
320
+ reasoning_text = reasoning_content['reasoningText'] || {}
321
+ reasoning_text['signature'] || reasoning_content['signature'] || reasoning_content['redactedContent']
322
+ end
323
+
324
+ def extract_signature_from_start(event)
325
+ start = event.dig('contentBlockStart', 'start', 'reasoningContent')
326
+ return nil unless start
327
+
328
+ reasoning_text = start['reasoningText'] || {}
329
+ return reasoning_text['signature'] if reasoning_text['signature']
330
+ return start['redactedContent'] if start['redactedContent']
331
+
332
+ nil
333
+ end
334
+
335
+ def extract_tool_calls(event)
336
+ return extract_tool_call_start(event) if tool_call_start_event?(event)
337
+ return extract_tool_call_delta(event) if tool_call_delta_event?(event)
338
+
339
+ nil
340
+ end
341
+
342
+ def tool_call_start_event?(event)
343
+ event['contentBlockStart'] || event['start']
344
+ end
345
+
346
+ def tool_call_delta_event?(event)
347
+ event['contentBlockDelta'] || event.dig('delta', 'toolUse')
348
+ end
349
+
350
+ def extract_tool_call_start(event)
351
+ tool_use = event.dig('contentBlockStart', 'start', 'toolUse') || event.dig('start', 'toolUse')
352
+ return nil unless tool_use
353
+
354
+ if Chat.server_tool_use?(tool_use)
355
+ remember_server_tool_block(event)
356
+ return nil
357
+ end
358
+
359
+ tool_use_id = tool_use['toolUseId']
360
+ {
361
+ tool_use_id => ToolCall.new(
362
+ id: tool_use_id,
363
+ name: tool_use['name'],
364
+ arguments: tool_use['input'] || {}
365
+ )
366
+ }
367
+ end
368
+
369
+ def extract_tool_call_delta(event)
370
+ return nil if server_tool_block_event?(event)
371
+
372
+ input = normalized_delta(event).dig('toolUse', 'input')
373
+ return nil unless input
374
+
375
+ { nil => ToolCall.new(id: nil, name: nil, arguments: input) }
376
+ end
377
+
378
+ # Server-executed tool steps stream as typed toolUse and toolResult
379
+ # block starts. Their input deltas are not function-call arguments,
380
+ # so the block index is remembered and its deltas skipped.
381
+ def extract_server_tool_call_events(event)
382
+ start = event.dig('contentBlockStart', 'start') || event['start']
383
+ return [] unless start.is_a?(Hash)
384
+
385
+ tool_use = start['toolUse']
386
+ tool_result = start['toolResult']
387
+ if tool_use && Chat.server_tool_use?(tool_use)
388
+ [ServerToolCall.new(type: tool_use['type'], name: tool_use['name'], id: tool_use['toolUseId'],
389
+ input: tool_use['input'], raw: start)]
390
+ elsif tool_result && Chat.server_tool_result?(tool_result)
391
+ [ServerToolCall.new(type: tool_result['type'], id: tool_result['toolUseId'],
392
+ result: tool_result['content'], raw: start)]
393
+ else
394
+ []
395
+ end
396
+ end
397
+
398
+ def remember_server_tool_block(event)
399
+ index = event_block_index(event)
400
+ return if index.nil?
401
+
402
+ @server_tool_block_indices ||= {}
403
+ @server_tool_block_indices[index] = true
404
+ end
405
+
406
+ def server_tool_block_event?(event)
407
+ index = event_block_index(event)
408
+ !index.nil? && @server_tool_block_indices&.key?(index)
409
+ end
410
+
411
+ def event_block_index(event)
412
+ event['contentBlockIndex'] ||
413
+ event.dig('contentBlockStart', 'contentBlockIndex') ||
414
+ event.dig('contentBlockDelta', 'contentBlockIndex')
415
+ end
416
+
417
+ def normalized_delta(event)
418
+ delta = event.dig('contentBlockDelta', 'delta') || event['delta'] || {}
419
+ return delta if delta.is_a?(Hash)
420
+
421
+ if delta.is_a?(String) && !delta.empty?
422
+ JSON.parse(delta)
423
+ else
424
+ {}
425
+ end
426
+ rescue JSON::ParserError
427
+ {}
428
+ end
429
+ end
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module RubyLLM
6
+ module Protocols
7
+ class Converse
8
+ class ThinkingStream # :nodoc: all
9
+ def initialize
10
+ @blocks = {}
11
+ end
12
+
13
+ def add(event)
14
+ @blocks.clear if event.key?('messageStart')
15
+ part = event['contentBlockStart'] || event['contentBlockDelta'] || event
16
+ index = part['contentBlockIndex']
17
+ return if index.nil?
18
+
19
+ start = part.dig('start', 'reasoningContent')
20
+ @blocks[index] = Support::Utils.deep_dup(start) if start
21
+ delta = part.dig('delta', 'reasoningContent')
22
+ append_delta(index, delta) if delta
23
+ end
24
+
25
+ def raw_reasoning
26
+ return if @blocks.empty?
27
+
28
+ { 'converse' => @blocks.sort.map { |_index, content| { 'reasoningContent' => content } } }
29
+ end
30
+
31
+ private
32
+
33
+ def append_delta(index, delta)
34
+ block = (@blocks[index] ||= {})
35
+ if delta.key?('redactedContent')
36
+ append_redacted(block, delta['redactedContent'])
37
+ else
38
+ text = (block['reasoningText'] ||= { 'text' => '' })
39
+ (delta['reasoningText'] || delta).slice('text', 'signature').each do |key, value|
40
+ text[key] = text[key].to_s + value.to_s
41
+ end
42
+ end
43
+ end
44
+
45
+ def append_redacted(block, data)
46
+ block['redactedContent'] = if block['redactedContent']
47
+ Base64.strict_encode64(Base64.decode64(block['redactedContent']) +
48
+ Base64.decode64(data))
49
+ else
50
+ data
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end