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,695 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubyLLM
6
+ module Protocols
7
+ class Converse
8
+ # Chat methods for Bedrock Converse API.
9
+ module Chat
10
+ FINISH_REASONS = {
11
+ 'end_turn' => :stop, 'stop_sequence' => :stop, 'max_tokens' => :max_tokens,
12
+ 'model_context_window_exceeded' => :max_tokens, 'tool_use' => :tool_calls,
13
+ 'guardrail_intervened' => :content_filter, 'content_filtered' => :content_filter
14
+ }.freeze
15
+
16
+ BEDROCK_INLINE_DOCUMENT_LIMIT = 4_500_000
17
+ PROMPT_CACHE_OPTIONS = %i[ttl].freeze
18
+ COUNT_TOKENS_KEYS = %i[messages system toolConfig additionalModelRequestFields].freeze
19
+ RANGED_EFFORTS = %w[low medium high].freeze
20
+ MINIMUM_BUDGET_TOKENS = 1
21
+
22
+ module_function
23
+
24
+ def finish_reasons = FINISH_REASONS
25
+
26
+ def normalize_finish_reason(reason)
27
+ return nil if reason.nil?
28
+
29
+ finish_reasons.fetch(reason.to_s) { reason.to_s.to_sym }
30
+ end
31
+
32
+ def completion_url
33
+ "/model/#{escape_model_id(@model.id)}/converse"
34
+ end
35
+
36
+ # Application inference profile ARNs contain '/', but Bedrock expects the model id
37
+ # to remain one path segment.
38
+ def escape_model_id(model_id)
39
+ model_id.to_s.gsub('/', '%2F')
40
+ end
41
+
42
+ # rubocop:disable-next Lint/UnusedMethodArgument
43
+ def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
44
+ schema: nil, thinking: nil, citations: false, caching: nil, tool_prefs: nil)
45
+ tool_prefs ||= {}
46
+ @used_document_names = {}
47
+ system_messages, chat_messages = messages.partition { |msg| msg.role == :system }
48
+ prompt_cache_options(caching)
49
+ automatic_cache_target = automatic_cache_target(system_messages, chat_messages, caching)
50
+ payload = {
51
+ messages: format_messages(chat_messages, caching:, automatic_cache_target:, citations:)
52
+ }
53
+
54
+ system_blocks = format_system(system_messages, caching:, automatic_cache_target:)
55
+ payload[:system] = system_blocks unless system_blocks.empty?
56
+
57
+ payload[:inferenceConfig] = format_inference_config(model, temperature, max_output_tokens)
58
+
59
+ tool_config = format_tool_config(tools, tool_prefs)
60
+ payload[:toolConfig] = tool_config if tool_config
61
+
62
+ additional_fields = format_additional_model_request_fields(thinking, model, max_output_tokens)
63
+ payload[:additionalModelRequestFields] = additional_fields if additional_fields
64
+
65
+ output_config = build_output_config(schema)
66
+ payload[:outputConfig] = output_config if output_config
67
+
68
+ payload
69
+ end
70
+
71
+ def count_tokens_url
72
+ "/model/#{escape_model_id(@model.id)}/count-tokens"
73
+ end
74
+
75
+ def render_count_tokens_payload(messages, tools:, model:, tool_prefs: nil, thinking: nil, schema: nil,
76
+ citations: false, caching: nil)
77
+ request = render_payload(
78
+ messages,
79
+ tools: tools,
80
+ tool_prefs: tool_prefs,
81
+ temperature: nil,
82
+ model: model,
83
+ schema: schema,
84
+ thinking: thinking,
85
+ citations: citations,
86
+ caching: caching
87
+ ).slice(*COUNT_TOKENS_KEYS)
88
+
89
+ { input: { converse: request } }
90
+ end
91
+
92
+ def parse_count_tokens_response(response)
93
+ response.body['inputTokens']
94
+ end
95
+
96
+ def supports_provider_file_references?
97
+ true
98
+ end
99
+
100
+ def default_large_file_upload_threshold
101
+ BEDROCK_INLINE_DOCUMENT_LIMIT
102
+ end
103
+
104
+ def provider_file_attachable?(attachment)
105
+ (attachment.pdf? || attachment.document? || attachment.text?) &&
106
+ Media.supported_document_format?(attachment)
107
+ end
108
+
109
+ def parse_completion_body(data, raw:)
110
+ content_blocks = data.dig('output', 'message', 'content') || []
111
+ usage = data['usage'] || {}
112
+ thinking_text, thinking_signature = parse_thinking(content_blocks)
113
+ text_content, citations = extract_text_and_citations(content_blocks)
114
+
115
+ Message.new(
116
+ role: :assistant,
117
+ content: text_content,
118
+ citations: citations,
119
+ thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
120
+ raw_reasoning: parse_thinking_blocks(content_blocks),
121
+ tool_calls: parse_tool_calls(content_blocks),
122
+ server_tool_calls: extract_server_tool_calls(content_blocks),
123
+ input_tokens: input_tokens(usage),
124
+ output_tokens: usage['outputTokens'],
125
+ cache_read_tokens: usage['cacheReadInputTokens'],
126
+ cache_write_tokens: usage['cacheWriteInputTokens'],
127
+ thinking_tokens: reasoning_tokens(usage),
128
+ finish_reason: normalize_finish_reason(data['stopReason']),
129
+ model: data['modelId'] || @model&.id,
130
+ raw: raw
131
+ )
132
+ end
133
+
134
+ def input_tokens(usage)
135
+ # AWS Bedrock reports inputTokens as already non-cached; cacheReadInputTokens and
136
+ # cacheWriteInputTokens are separate buckets, not folded into inputTokens. Subtracting
137
+ # them (as inclusive providers require) understates input and floors to zero on cache
138
+ # hits. See https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
139
+ usage['inputTokens']
140
+ end
141
+
142
+ def reasoning_tokens(usage)
143
+ usage['reasoningTokens'] || usage.dig('outputTokensDetails', 'reasoningTokens')
144
+ end
145
+
146
+ def format_messages(messages, caching: nil, automatic_cache_target: nil, citations: false)
147
+ rendered = []
148
+ tool_result_blocks = []
149
+
150
+ messages.each do |msg|
151
+ if msg.tool_result?
152
+ tool_result_blocks << format_tool_result_block(msg)
153
+ next
154
+ end
155
+
156
+ unless tool_result_blocks.empty?
157
+ rendered << { role: 'user', content: tool_result_blocks }
158
+ tool_result_blocks = []
159
+ end
160
+
161
+ message = format_non_tool_message(msg, caching:, automatic_cache_target:, citations:)
162
+ rendered << message if message
163
+ end
164
+
165
+ rendered << { role: 'user', content: tool_result_blocks } unless tool_result_blocks.empty?
166
+ rendered
167
+ end
168
+
169
+ def format_non_tool_message(msg, caching: nil, automatic_cache_target: nil, citations: false)
170
+ content = format_message_content(msg, caching:, automatic_cache_target:, citations:)
171
+ return nil if content.empty?
172
+
173
+ {
174
+ role: format_role(msg.role),
175
+ content: content
176
+ }
177
+ end
178
+
179
+ def format_message_content(msg, caching: nil, automatic_cache_target: nil, citations: false)
180
+ blocks = format_structured_message_content(msg, citations:)
181
+
182
+ if msg.tool_call?
183
+ msg.tool_calls.each_value do |tool_call|
184
+ blocks << {
185
+ toolUse: {
186
+ toolUseId: tool_call.id,
187
+ name: tool_call.name,
188
+ input: tool_call.arguments
189
+ }
190
+ }
191
+ end
192
+ end
193
+ blocks << converse_cache_block_for(caching) if cache_boundary?(msg, automatic_cache_target, caching:)
194
+
195
+ blocks
196
+ end
197
+
198
+ def format_structured_message_content(msg, citations: false)
199
+ blocks = msg.role == :assistant ? format_thinking_blocks(msg) : []
200
+
201
+ blocks.concat(
202
+ Media.format_content(msg.content, msg.attachments, used_document_names: @used_document_names, citations:)
203
+ )
204
+
205
+ blocks
206
+ end
207
+
208
+ def format_thinking_blocks(msg)
209
+ blocks = msg.raw_reasoning['converse'] if msg.raw_reasoning.is_a?(Hash)
210
+ return Support::Utils.deep_dup(blocks) if blocks
211
+
212
+ [format_thinking_block(msg.thinking)].compact
213
+ end
214
+
215
+ def format_tool_result_block(msg)
216
+ {
217
+ toolResult: {
218
+ toolUseId: msg.tool_call_id,
219
+ content: format_tool_result_content(msg)
220
+ }
221
+ }
222
+ end
223
+
224
+ def format_tool_result_content(msg)
225
+ search_results = RubyLLM::SearchResults.from_content(msg.content)
226
+ return search_results.results.map { |result| search_result_block(result) } if search_results
227
+
228
+ blocks = Media.format_content(msg.content, msg.attachments, used_document_names: @used_document_names)
229
+ blocks.empty? ? [text_tool_result_block(nil)] : blocks
230
+ end
231
+
232
+ def search_result_block(result)
233
+ {
234
+ searchResult: {
235
+ source: result[:url] || result[:title],
236
+ title: result[:title],
237
+ content: [{ text: result[:text] }],
238
+ citations: { enabled: true }
239
+ }
240
+ }
241
+ end
242
+
243
+ def text_tool_result_block(text)
244
+ text = text.to_s
245
+ text = '(no output)' if text.empty?
246
+ { text: text }
247
+ end
248
+
249
+ def format_role(role)
250
+ case role
251
+ when :assistant then 'assistant'
252
+ else 'user'
253
+ end
254
+ end
255
+
256
+ def format_system(messages, caching: nil, automatic_cache_target: nil)
257
+ messages.flat_map do |msg|
258
+ blocks = Media.format_content(msg.content, msg.attachments, used_document_names: @used_document_names)
259
+ if cache_boundary?(msg, automatic_cache_target, caching:)
260
+ blocks + [converse_cache_block_for(caching)]
261
+ else
262
+ blocks
263
+ end
264
+ end
265
+ end
266
+
267
+ def automatic_cache_target(system_messages, chat_messages, caching)
268
+ return unless caching
269
+ return if (system_messages + chat_messages).any?(&:cache_until_here?)
270
+
271
+ (chat_messages.reverse + system_messages.reverse).find { |msg| cacheable_message?(msg) }
272
+ end
273
+
274
+ def cacheable_message?(message)
275
+ !message.tool_result?
276
+ end
277
+
278
+ def cache_boundary?(message, automatic_cache_target, caching: nil)
279
+ caching != false && (message.cache_until_here? || message.equal?(automatic_cache_target))
280
+ end
281
+
282
+ def converse_cache_block_for(caching)
283
+ options = prompt_cache_options(caching)
284
+ point = { type: 'default' }
285
+ point[:ttl] = options[:ttl] if options[:ttl]
286
+ { cachePoint: point }
287
+ end
288
+
289
+ def prompt_cache_options(caching)
290
+ return {} unless caching
291
+
292
+ options = caching.to_h.transform_keys(&:to_sym)
293
+ unsupported = options.keys - PROMPT_CACHE_OPTIONS
294
+ return options if unsupported.empty?
295
+
296
+ raise ArgumentError,
297
+ "Bedrock Converse prompt caching accepts :ttl, got #{format_cache_option_keys(unsupported)}"
298
+ end
299
+
300
+ def format_cache_option_keys(keys)
301
+ keys.map { |key| ":#{key}" }.join(', ')
302
+ end
303
+
304
+ def format_inference_config(_model, temperature, max_output_tokens = nil)
305
+ config = {}
306
+ config[:temperature] = temperature unless temperature.nil?
307
+ config[:maxTokens] = max_output_tokens unless max_output_tokens.nil?
308
+ config
309
+ end
310
+
311
+ def format_tool_config(tools, tool_prefs)
312
+ return nil if tools.empty?
313
+
314
+ config = {
315
+ tools: tools.values.map { |tool| format_tool(tool) }
316
+ }
317
+
318
+ return config if tool_prefs.nil? || tool_prefs[:choice].nil?
319
+
320
+ tool_choice = format_tool_choice(tool_prefs[:choice])
321
+ config[:toolChoice] = tool_choice if tool_choice
322
+ config
323
+ end
324
+
325
+ def format_tool_choice(choice)
326
+ case choice
327
+ when :auto
328
+ { auto: {} }
329
+ when :none
330
+ nil
331
+ when :required
332
+ { any: {} }
333
+ else
334
+ { tool: { name: choice.to_s } }
335
+ end
336
+ end
337
+
338
+ def format_tool(tool)
339
+ input_schema = tool.parameters_schema ||
340
+ RubyLLM::Tool::SchemaDefinition.from_parameters(tool.declared_parameters)&.json_schema
341
+
342
+ tool_spec = {
343
+ toolSpec: {
344
+ name: tool.name,
345
+ description: tool.description,
346
+ inputSchema: {
347
+ json: input_schema || default_input_schema
348
+ }
349
+ }
350
+ }
351
+
352
+ return tool_spec if tool.provider_options.empty?
353
+
354
+ RubyLLM::Support::Utils.deep_merge(tool_spec, tool.provider_options)
355
+ end
356
+
357
+ def format_additional_model_request_fields(thinking, model, max_output_tokens = nil)
358
+ fields = {}
359
+
360
+ reasoning_fields = format_reasoning_fields(thinking, model, max_output_tokens)
361
+ fields = RubyLLM::Support::Utils.deep_merge(fields, reasoning_fields) if reasoning_fields
362
+
363
+ fields.empty? ? nil : fields
364
+ end
365
+
366
+ def build_output_config(schema)
367
+ return nil unless schema
368
+
369
+ cleaned = RubyLLM::Support::Utils.deep_dup(schema[:schema])
370
+ cleaned.delete(:strict)
371
+ cleaned.delete('strict')
372
+
373
+ {
374
+ textFormat: {
375
+ type: 'json_schema',
376
+ structure: {
377
+ jsonSchema: {
378
+ schema: JSON.generate(cleaned),
379
+ name: schema[:name]
380
+ }
381
+ }
382
+ }
383
+ }
384
+ end
385
+
386
+ NOVA_DEFAULT_REASONING_EFFORT = 'low'
387
+
388
+ def format_reasoning_fields(thinking, model, max_output_tokens = nil)
389
+ return nil unless thinking&.enabled?
390
+ return format_nova_reasoning_fields(thinking, model) if nova_model?(model)
391
+ return { reasoning_config: { type: 'disabled' } } if thinking.enabled == false
392
+
393
+ effort = thinking.effort.to_s
394
+ budget = reasoning_budget(thinking, effort, model, max_output_tokens)
395
+ return { reasoning_config: { type: 'enabled', budget_tokens: budget } } if budget
396
+ return nil if effort.empty? || effort == 'none'
397
+
398
+ { reasoning_effort: effort }
399
+ end
400
+
401
+ def nova_model?(model)
402
+ foundation_model_id(model&.id).start_with?('amazon.nova')
403
+ end
404
+
405
+ # Nova 2 takes reasoningConfig with an effort level instead of a token budget,
406
+ # and rejects an enabled reasoningConfig that names no effort.
407
+ def format_nova_reasoning_fields(thinking, model)
408
+ if thinking.budget
409
+ raise ArgumentError,
410
+ "#{model&.id} takes a reasoning effort, not a token budget of #{thinking.budget}. " \
411
+ 'Pass with_thinking(effort:) instead.'
412
+ end
413
+
414
+ return { reasoningConfig: { type: 'disabled' } } if thinking.enabled == false
415
+
416
+ effort = thinking.effort.to_s
417
+ return nil if effort == 'none'
418
+
419
+ effort = NOVA_DEFAULT_REASONING_EFFORT if effort.empty?
420
+ { reasoningConfig: { type: 'enabled', maxReasoningEffort: effort } }
421
+ end
422
+
423
+ def reasoning_budget(thinking, effort, model, max_output_tokens)
424
+ return thinking.budget if thinking.budget.is_a?(Integer)
425
+ return nil if effort.empty? || effort == 'none'
426
+
427
+ schema = reasoning_budget_schema(model)
428
+ schema && effort_budget_tokens(effort, schema, model, max_output_tokens)
429
+ end
430
+
431
+ # Bedrock only publishes Converse metadata for some regional entries, so use the
432
+ # schema from another entry for the same foundation model when needed.
433
+ def reasoning_budget_schema(model)
434
+ schema = budget_tokens_schema(model)
435
+ return schema if schema
436
+ return unless model
437
+
438
+ foundation_id = foundation_model_id(model.id)
439
+ RubyLLM.models.all.each do |candidate|
440
+ next unless candidate.provider == 'bedrock' && candidate.id != model.id
441
+ next unless foundation_model_id(candidate.id) == foundation_id
442
+
443
+ return schema if (schema = budget_tokens_schema(candidate))
444
+ end
445
+
446
+ nil
447
+ end
448
+
449
+ def budget_tokens_schema(model)
450
+ metadata = RubyLLM::Support::Utils.deep_symbolize_keys(model&.metadata || {})
451
+ raw_schema = metadata.dig(:converse, :additionalRequestFieldsSchema)
452
+ return unless raw_schema.is_a?(String)
453
+
454
+ schema = JSON.parse(raw_schema, symbolize_names: true)
455
+ budget = schema.is_a?(Hash) ? schema.dig(:reasoningConfig, :budgetTokens) : nil
456
+ budget if budget.is_a?(Hash)
457
+ rescue JSON::ParserError
458
+ nil
459
+ end
460
+
461
+ # Inference profile and foundation model ARNs name the model in their last segment.
462
+ def foundation_model_id(model_id)
463
+ prefixes = Converse::REGION_PREFIXES.join('|')
464
+ model_id.to_s.rpartition('/').last.sub(/\A(?:#{prefixes})\./, '')
465
+ end
466
+
467
+ # Models that take a budget reject reasoning_effort, so effort has to become a budget.
468
+ # Bedrock names the levels of an enumerated budget after the efforts they stand for;
469
+ # otherwise the effort spans the range the schema allows.
470
+ def effort_budget_tokens(effort, schema, model, max_output_tokens)
471
+ budget = enumerated_budget(effort, schema) || ranged_budget(effort, schema)
472
+ return nil unless budget
473
+
474
+ minimum = schema[:minimum].is_a?(Integer) ? schema[:minimum] : MINIMUM_BUDGET_TOKENS
475
+ return [budget, minimum].max unless max_output_tokens
476
+
477
+ budget.clamp(minimum, budget_ceiling(model, minimum, max_output_tokens))
478
+ end
479
+
480
+ # Bedrock rejects a budget that leaves no room for the answer.
481
+ def budget_ceiling(model, minimum, max_output_tokens)
482
+ ceiling = max_output_tokens - 1
483
+ return ceiling if minimum <= ceiling
484
+
485
+ raise ArgumentError, "#{model&.id} reasons on a budget of at least #{minimum} tokens, and " \
486
+ "max_output_tokens: #{max_output_tokens} leaves room for #{ceiling}. " \
487
+ "Raise max_output_tokens above #{minimum} or turn thinking off."
488
+ end
489
+
490
+ def enumerated_budget(effort, schema)
491
+ levels = schema[:enum]
492
+ level = levels.is_a?(Hash) ? levels[effort.to_sym] : nil
493
+ level if level.is_a?(Integer)
494
+ end
495
+
496
+ def ranged_budget(effort, schema)
497
+ minimum = schema[:minimum]
498
+ maximum = schema[:maximum]
499
+ return nil unless minimum.is_a?(Integer) && maximum.is_a?(Integer)
500
+
501
+ case effort
502
+ when 'low' then minimum
503
+ when 'medium' then minimum + ((maximum - minimum) / 2)
504
+ when 'high' then maximum
505
+ else raise ArgumentError, unknown_effort_message(effort, schema)
506
+ end
507
+ end
508
+
509
+ def unknown_effort_message(effort, schema)
510
+ levels = schema[:enum].is_a?(Hash) ? schema[:enum].keys.map(&:to_s) : []
511
+ levels |= RANGED_EFFORTS
512
+ "Bedrock has no reasoning budget for effort #{effort.inspect}. " \
513
+ "Use #{levels.join(', ')}, or pass an explicit budget."
514
+ end
515
+
516
+ def format_thinking_block(thinking)
517
+ return nil unless thinking
518
+
519
+ if thinking.text
520
+ {
521
+ reasoningContent: {
522
+ reasoningText: {
523
+ text: thinking.text,
524
+ signature: thinking.signature
525
+ }.compact
526
+ }
527
+ }
528
+ elsif thinking.signature
529
+ {
530
+ reasoningContent: {
531
+ redactedContent: thinking.signature
532
+ }
533
+ }
534
+ end
535
+ end
536
+
537
+ def extract_text_and_citations(content_blocks)
538
+ text = +''
539
+ citations = []
540
+
541
+ content_blocks.each do |block|
542
+ if block['text'].is_a?(String)
543
+ text << block['text']
544
+ elsif block['citationsContent'].is_a?(Hash)
545
+ append_citations_content(block['citationsContent'], text, citations)
546
+ end
547
+ end
548
+
549
+ [text.empty? ? nil : text, citations]
550
+ end
551
+
552
+ # A citationsContent block replaces the text block for a cited span:
553
+ # the generated text lives in its content member, and each citation
554
+ # points back at the source document or search result.
555
+ def append_citations_content(citations_content, text, citations)
556
+ block_text = joined_text(citations_content['content'])
557
+ span = {}
558
+ unless block_text.empty?
559
+ span = { text: block_text, start_index: text.length, end_index: text.length + block_text.length }
560
+ end
561
+
562
+ Array(citations_content['citations']).each do |citation|
563
+ citations << parse_citation(citation, **span)
564
+ end
565
+ text << block_text
566
+ end
567
+
568
+ def parse_citation(data, text: nil, start_index: nil, end_index: nil)
569
+ location = data['location'] || {}
570
+ page = location['documentPage'] || {}
571
+ end_page = page['end']
572
+ cited_text = joined_text(data['sourceContent'])
573
+
574
+ Citation.new(
575
+ url: citation_url(data, location),
576
+ title: data['title'],
577
+ cited_text: cited_text.empty? ? nil : cited_text,
578
+ text: text,
579
+ start_index: start_index,
580
+ end_index: end_index,
581
+ source_index: citation_source_index(location),
582
+ start_page: page['start'],
583
+ end_page: end_page && (end_page - 1)
584
+ )
585
+ end
586
+
587
+ def joined_text(parts)
588
+ Array(parts).filter_map { |part| part['text'] if part.is_a?(Hash) }.join
589
+ end
590
+
591
+ # Search result citations carry the developer-provided source string.
592
+ def citation_url(data, location)
593
+ url = location.dig('web', 'url') || data['source']
594
+ url if url&.match?(%r{\Ahttps?://}i)
595
+ end
596
+
597
+ def citation_source_index(location)
598
+ %w[documentChar documentPage documentChunk].each do |key|
599
+ index = location.dig(key, 'documentIndex')
600
+ return index if index
601
+ end
602
+
603
+ location.dig('searchResultLocation', 'searchResultIndex')
604
+ end
605
+
606
+ def parse_thinking(content_blocks)
607
+ text = nil
608
+ signature = nil
609
+
610
+ content_blocks.each do |block|
611
+ chunk_text, chunk_signature = parse_reasoning_content_block(block)
612
+ if chunk_text
613
+ text ||= +''
614
+ text << chunk_text
615
+ end
616
+ signature ||= chunk_signature
617
+ end
618
+
619
+ [text, signature]
620
+ end
621
+
622
+ def parse_thinking_blocks(content_blocks)
623
+ blocks = content_blocks.select { |block| block['reasoningContent'].is_a?(Hash) }
624
+ { 'converse' => blocks } unless blocks.empty?
625
+ end
626
+
627
+ def parse_reasoning_content_block(block)
628
+ reasoning_content = block['reasoningContent']
629
+ return [nil, nil] unless reasoning_content.is_a?(Hash)
630
+
631
+ reasoning_text = reasoning_content['reasoningText'] || {}
632
+ text = reasoning_text['text'].is_a?(String) ? reasoning_text['text'] : nil
633
+ signature = reasoning_text['signature'] if reasoning_text['signature'].is_a?(String)
634
+ signature ||= reasoning_content['redactedContent'] if reasoning_content['redactedContent'].is_a?(String)
635
+ [text, signature]
636
+ end
637
+
638
+ def parse_tool_calls(content_blocks)
639
+ tool_calls = {}
640
+
641
+ content_blocks.each do |block|
642
+ tool_use = block['toolUse']
643
+ next unless tool_use
644
+ next if server_tool_use?(tool_use)
645
+
646
+ tool_call_id = tool_use['toolUseId']
647
+ tool_calls[tool_call_id] = ToolCall.new(
648
+ id: tool_call_id,
649
+ name: tool_use['name'],
650
+ arguments: tool_use['input'] || {}
651
+ )
652
+ end
653
+
654
+ tool_calls.empty? ? nil : tool_calls
655
+ end
656
+
657
+ # Provider-executed tool steps come back with a distinguishing type,
658
+ # such as server_tool_use; function calls carry no type or the plain
659
+ # tool_use type.
660
+ def server_tool_use?(tool_use)
661
+ type = tool_use['type']
662
+ !type.nil? && type != 'tool_use'
663
+ end
664
+
665
+ def server_tool_result?(tool_result)
666
+ type = tool_result['type']
667
+ !type.nil? && type != 'tool_result'
668
+ end
669
+
670
+ def extract_server_tool_calls(content_blocks)
671
+ content_blocks.filter_map do |block|
672
+ tool_use = block['toolUse']
673
+ tool_result = block['toolResult']
674
+
675
+ if tool_use && server_tool_use?(tool_use)
676
+ ServerToolCall.new(type: tool_use['type'], name: tool_use['name'], id: tool_use['toolUseId'],
677
+ input: tool_use['input'], raw: block)
678
+ elsif tool_result && server_tool_result?(tool_result)
679
+ ServerToolCall.new(type: tool_result['type'], id: tool_result['toolUseId'],
680
+ result: tool_result['content'], raw: block)
681
+ end
682
+ end
683
+ end
684
+
685
+ def default_input_schema
686
+ {
687
+ 'type' => 'object',
688
+ 'properties' => {},
689
+ 'required' => []
690
+ }
691
+ end
692
+ end
693
+ end
694
+ end
695
+ end