ruby_llm 1.16.0 → 2.0.0.rc4

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 (480) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +108 -44
  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/legacy_content_sql.rb +34 -0
  91. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +341 -0
  92. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +171 -0
  93. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +197 -0
  94. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +174 -0
  95. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +468 -0
  96. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +101 -0
  97. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +247 -0
  98. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +662 -0
  99. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +251 -0
  100. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
  101. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +188 -0
  102. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +359 -0
  103. data/lib/ruby_llm/accounting/usage.rb +254 -0
  104. data/lib/ruby_llm/active_record/acts_as.rb +94 -111
  105. data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
  106. data/lib/ruby_llm/active_record/batch.rb +97 -0
  107. data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
  108. data/lib/ruby_llm/active_record/message_methods.rb +113 -136
  109. data/lib/ruby_llm/active_record/model.rb +135 -0
  110. data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
  111. data/lib/ruby_llm/active_record/tool_call.rb +33 -0
  112. data/lib/ruby_llm/active_record/usage.rb +61 -0
  113. data/lib/ruby_llm/agent.rb +1066 -151
  114. data/lib/ruby_llm/aliases.json +291 -101
  115. data/lib/ruby_llm/attachment.rb +192 -48
  116. data/lib/ruby_llm/batch.rb +432 -0
  117. data/lib/ruby_llm/cached_content.rb +112 -0
  118. data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
  119. data/lib/ruby_llm/chat.rb +1131 -198
  120. data/lib/ruby_llm/chunk.rb +10 -0
  121. data/lib/ruby_llm/citation.rb +105 -0
  122. data/lib/ruby_llm/configuration.rb +261 -24
  123. data/lib/ruby_llm/context.rb +128 -6
  124. data/lib/ruby_llm/cost.rb +217 -80
  125. data/lib/ruby_llm/downloaded_file.rb +33 -0
  126. data/lib/ruby_llm/embedding.rb +121 -17
  127. data/lib/ruby_llm/embedding_request.rb +53 -0
  128. data/lib/ruby_llm/error.rb +159 -23
  129. data/lib/ruby_llm/fallback.rb +133 -0
  130. data/lib/ruby_llm/files/mime_type.rb +97 -0
  131. data/lib/ruby_llm/image.rb +153 -32
  132. data/lib/ruby_llm/message.rb +242 -54
  133. data/lib/ruby_llm/model/modalities.rb +17 -4
  134. data/lib/ruby_llm/model/pricing.rb +24 -5
  135. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  136. data/lib/ruby_llm/model/pricing_tier.rb +55 -15
  137. data/lib/ruby_llm/model.rb +244 -2
  138. data/lib/ruby_llm/models/aliases.rb +41 -0
  139. data/lib/ruby_llm/models/registry.rb +165 -0
  140. data/lib/ruby_llm/models/schema.rb +99 -0
  141. data/lib/ruby_llm/models.json +76038 -34173
  142. data/lib/ruby_llm/models.rb +477 -215
  143. data/lib/ruby_llm/moderation.rb +139 -26
  144. data/lib/ruby_llm/ocr.rb +112 -0
  145. data/lib/ruby_llm/prompt.rb +79 -0
  146. data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
  147. data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
  148. data/lib/ruby_llm/protocol/streaming.rb +230 -0
  149. data/lib/ruby_llm/protocol.rb +662 -0
  150. data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
  151. data/lib/ruby_llm/protocols/anthropic/chat.rb +543 -0
  152. data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
  153. data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
  154. data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
  155. data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
  156. data/lib/ruby_llm/protocols/anthropic/streaming.rb +167 -0
  157. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  158. data/lib/ruby_llm/protocols/anthropic.rb +101 -0
  159. data/lib/ruby_llm/protocols/azure/files.rb +16 -0
  160. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +110 -0
  161. data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
  162. data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
  163. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
  164. data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
  165. data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
  166. data/lib/ruby_llm/protocols/chat_completions/chat.rb +484 -0
  167. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
  168. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
  169. data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
  170. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/media.rb +30 -17
  171. data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
  172. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
  173. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +63 -0
  174. data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
  175. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
  176. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +15 -17
  177. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
  178. data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
  179. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
  180. data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
  181. data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
  182. data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
  183. data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
  184. data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
  185. data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
  186. data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
  187. data/lib/ruby_llm/protocols/cohere/rerank.rb +59 -0
  188. data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
  189. data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
  190. data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
  191. data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
  192. data/lib/ruby_llm/protocols/cohere.rb +21 -0
  193. data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
  194. data/lib/ruby_llm/protocols/converse/chat.rb +694 -0
  195. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  196. data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
  197. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -0
  198. data/lib/ruby_llm/protocols/converse.rb +54 -0
  199. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  200. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  201. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  202. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  203. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  204. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  208. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  209. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  210. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  211. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  212. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  213. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  214. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  215. data/lib/ruby_llm/protocols/files.rb +119 -0
  216. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  217. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  218. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  219. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +91 -0
  220. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  221. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  222. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  223. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  224. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  225. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  226. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  227. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  228. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  229. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  230. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  231. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  232. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  233. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  234. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  235. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  236. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  237. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  238. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  239. data/lib/ruby_llm/protocols/interactions/tools.rb +53 -0
  240. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  241. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  242. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  243. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  244. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  245. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  246. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  247. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  248. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  249. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  250. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  251. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  252. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  253. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  254. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  255. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  256. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  257. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  258. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  259. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  260. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  261. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  262. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  263. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  264. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  265. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  266. data/lib/ruby_llm/protocols/responses/chat.rb +466 -0
  267. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  268. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  269. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  270. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  271. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  272. data/lib/ruby_llm/protocols/responses.rb +35 -0
  273. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  274. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  275. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  276. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  277. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  278. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  279. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  280. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  281. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  282. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  283. data/lib/ruby_llm/provider.rb +560 -128
  284. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  285. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  286. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  287. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  288. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  289. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  290. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  291. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  292. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  293. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  294. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  295. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  296. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  297. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  298. data/lib/ruby_llm/providers/azure.rb +77 -78
  299. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  300. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  301. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  302. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  303. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  304. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  305. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  306. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  307. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  308. data/lib/ruby_llm/providers/cohere.rb +31 -0
  309. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  310. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  311. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  312. data/lib/ruby_llm/providers/deepseek/responses.rb +67 -0
  313. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  314. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  315. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  316. data/lib/ruby_llm/providers/gemini.rb +15 -8
  317. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  318. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  319. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  320. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  321. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  322. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  323. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  324. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  325. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  326. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  327. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  328. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  329. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  330. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  331. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  332. data/lib/ruby_llm/providers/mistral/ocr.rb +51 -0
  333. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  334. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  335. data/lib/ruby_llm/providers/mistral.rb +16 -4
  336. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  337. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  338. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  339. data/lib/ruby_llm/providers/ollama.rb +9 -8
  340. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  341. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  342. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  343. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  344. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  345. data/lib/ruby_llm/providers/openai.rb +92 -11
  346. data/lib/ruby_llm/providers/openrouter/chat.rb +126 -108
  347. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  348. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  349. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  350. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  351. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  352. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  353. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  354. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  355. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  356. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  357. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  358. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  359. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  360. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  361. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  362. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  363. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  364. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  365. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  366. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  367. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  368. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  369. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  370. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  371. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  372. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  373. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  374. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  375. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  376. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  377. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  378. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  379. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  380. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  381. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  382. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  383. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  384. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  385. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  386. data/lib/ruby_llm/providers/xai.rb +15 -5
  387. data/lib/ruby_llm/railtie.rb +7 -16
  388. data/lib/ruby_llm/rerank.rb +105 -0
  389. data/lib/ruby_llm/research_job.rb +241 -0
  390. data/lib/ruby_llm/search_results.rb +68 -0
  391. data/lib/ruby_llm/server_tool_call.rb +73 -0
  392. data/lib/ruby_llm/speech.rb +159 -0
  393. data/lib/ruby_llm/speech_chunk.rb +33 -0
  394. data/lib/ruby_llm/support/deprecator.rb +22 -0
  395. data/lib/ruby_llm/support/inspectable.rb +49 -0
  396. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  397. data/lib/ruby_llm/support/utils.rb +147 -0
  398. data/lib/ruby_llm/thinking.rb +127 -20
  399. data/lib/ruby_llm/tokenization.rb +59 -0
  400. data/lib/ruby_llm/tokens.rb +103 -33
  401. data/lib/ruby_llm/tool.rb +266 -91
  402. data/lib/ruby_llm/tool_call.rb +36 -3
  403. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  404. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  405. data/lib/ruby_llm/transcription.rb +138 -13
  406. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  407. data/lib/ruby_llm/transport/connection.rb +193 -0
  408. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  409. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  410. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  411. data/lib/ruby_llm/uploaded_file.rb +144 -0
  412. data/lib/ruby_llm/version.rb +2 -1
  413. data/lib/ruby_llm/video.rb +136 -0
  414. data/lib/ruby_llm/video_job.rb +150 -0
  415. data/lib/ruby_llm/workflow.rb +91 -0
  416. data/lib/ruby_llm.rb +380 -6
  417. data/lib/tasks/ruby_llm.rake +21 -16
  418. data/skills/rubyllm/SKILL.md +81 -0
  419. data/skills/rubyllm/agents/openai.yaml +4 -0
  420. metadata +344 -97
  421. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  422. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  423. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  424. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  425. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  426. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  427. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  428. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  429. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  430. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  431. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  432. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  433. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  434. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  435. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  436. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  437. data/lib/ruby_llm/aliases.rb +0 -41
  438. data/lib/ruby_llm/connection.rb +0 -159
  439. data/lib/ruby_llm/content.rb +0 -91
  440. data/lib/ruby_llm/deprecator.rb +0 -24
  441. data/lib/ruby_llm/error_middleware.rb +0 -81
  442. data/lib/ruby_llm/instrumentation.rb +0 -36
  443. data/lib/ruby_llm/mime_type.rb +0 -96
  444. data/lib/ruby_llm/model/info.rb +0 -164
  445. data/lib/ruby_llm/model_registry.rb +0 -39
  446. data/lib/ruby_llm/models_schema.json +0 -171
  447. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  448. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  449. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  450. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  451. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  452. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  453. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  454. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  455. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  456. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  457. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  458. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  459. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  460. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  461. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  462. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  463. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  464. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  465. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  466. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  467. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  468. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  469. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  470. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  471. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  472. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  473. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  474. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  475. data/lib/ruby_llm/streaming.rb +0 -179
  476. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  477. data/lib/ruby_llm/utils.rb +0 -130
  478. data/lib/tasks/models.rake +0 -593
  479. data/lib/tasks/release.rake +0 -94
  480. data/lib/tasks/vcr.rake +0 -124
data/lib/ruby_llm/chat.rb CHANGED
@@ -3,194 +3,940 @@
3
3
  require 'json'
4
4
 
5
5
  module RubyLLM
6
- # Represents a conversation with an AI model
7
- class Chat
6
+ # A Chat is a conversation with an AI model. It holds the messages
7
+ # exchanged so far, the tools the model may call, and the settings applied
8
+ # to each request. RubyLLM.chat is the usual way to create one.
9
+ #
10
+ # chat = RubyLLM.chat
11
+ # chat.ask "What's the best way to learn Ruby?"
12
+ #
13
+ # Configuration methods return +self+, so calls chain:
14
+ #
15
+ # chat = RubyLLM.chat(model: 'claude-sonnet-5')
16
+ # chat.with_instructions("Be terse.").with_tools(Weather)
17
+ #
18
+ # #ask runs the conversation loop, executing tools until the model answers
19
+ # or a call needs approval. #ask_later, #generate, #run_tools, and #step
20
+ # expose individual operations. Resume an approval pause with #approve
21
+ # or #deny followed by #complete.
22
+ #
23
+ # A Chat is Enumerable over its messages.
24
+ class Chat # rubocop:disable Metrics/ClassLength
8
25
  include Enumerable
26
+ include Support::Inspectable
9
27
 
10
- attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema, :concurrency
28
+ # The provider-neutral options #with_compaction accepts.
29
+ COMPACTION_OPTIONS = %i[at instructions pause_after].freeze
30
+ THINKING_OPTIONS = %i[effort budget display].freeze
31
+ private_constant :THINKING_OPTIONS
11
32
 
12
- def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
33
+ # The Model the chat sends requests to.
34
+ attr_reader :model
35
+
36
+ # The Provider instance handling requests for the current model.
37
+ attr_reader :provider
38
+
39
+ # The Message objects exchanged so far, including system instructions.
40
+ attr_reader :messages
41
+
42
+ # The registered tools, as a Hash of tool name Symbols to Tool instances.
43
+ attr_reader :tools
44
+
45
+ # The server tools enabled with #with_server_tools, as an array of
46
+ # normalized entry Hashes.
47
+ attr_reader :server_tools
48
+
49
+ # Extra request options set with #with_provider_options, expressed in
50
+ # the provider's request vocabulary.
51
+ attr_reader :provider_options
52
+
53
+ # Extra HTTP headers set with #with_headers.
54
+ attr_reader :headers
55
+
56
+ # The sampling temperature set with #with_temperature, or +nil+ to let
57
+ # the model use its default.
58
+ attr_reader :temperature
59
+
60
+ # The output cap set with #with_max_output_tokens, or +nil+.
61
+ attr_reader :max_output_tokens
62
+
63
+ # The normalized structured output schema set with #with_schema, or +nil+.
64
+ attr_reader :schema
65
+
66
+ # The tool concurrency mode, or +nil+ when tools run sequentially.
67
+ attr_reader :concurrency
68
+
69
+ # The prompt caching options set with #with_caching, +false+ when
70
+ # explicitly disabled, or +nil+ when not configured.
71
+ attr_reader :caching
72
+
73
+ # The context compaction options set with #with_compaction, +false+ when
74
+ # explicitly disabled, or +nil+ when not configured.
75
+ attr_reader :compaction
76
+
77
+ # The opaque per-user identifier set with #with_end_user, or
78
+ # +nil+.
79
+ attr_reader :end_user
80
+
81
+ # The Fallback models tried in order when generation fails.
82
+ attr_reader :fallbacks
83
+
84
+ # Whether #with_citations asked the provider for citations.
85
+ attr_reader :citations
86
+
87
+ # The Context this chat sends requests through, or +nil+ for the global
88
+ # configuration.
89
+ attr_reader :context
90
+
91
+ attr_reader :tool_prefs, :fallback_errors, :usage_entries # :nodoc:
92
+
93
+ # Returns the +choice+, +calls+, and +concurrency+ set with
94
+ # #with_tool_options, with +nil+ for anything left at the default.
95
+ def tool_options
96
+ { choice: tool_prefs[:choice], calls: tool_prefs[:calls], concurrency: concurrency }
97
+ end
98
+
99
+ # Creates a chat with +model:+, or with the configured default model
100
+ # when +model:+ is +nil+. Most code calls RubyLLM.chat instead.
101
+ #
102
+ # A model is identified by its name, an optional +provider:+, and an
103
+ # optional +protocol:+. Pass +provider:+ to disambiguate models
104
+ # available from several providers, and +protocol:+ to override the wire
105
+ # protocol the provider would otherwise pick for the model. With
106
+ # <tt>assume_model_exists: true</tt> the registry lookup is skipped,
107
+ # which requires +provider:+. Pass a Context as +context:+ to use its
108
+ # configuration instead of the global one.
109
+ def initialize(model: nil, provider: nil, protocol: nil, assume_model_exists: false, context: nil)
13
110
  if assume_model_exists && !provider
14
111
  raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
15
112
  end
16
113
 
17
114
  @context = context
18
115
  @config = context&.config || RubyLLM.config
19
- model_id = model || @config.default_model
20
- with_model(model_id, provider: provider, assume_exists: assume_model_exists)
116
+ with_model(model, provider: provider, protocol: protocol, assume_model_exists: assume_model_exists)
21
117
  @temperature = nil
118
+ @max_output_tokens = nil
22
119
  @messages = []
120
+ @usage_entries = []
23
121
  @tools = {}
122
+ @server_tools = []
24
123
  @tool_prefs = { choice: nil, calls: nil }
25
124
  @concurrency = normalize_tool_concurrency(@config.tool_concurrency)
26
- @params = {}
125
+ @provider_options = {}
27
126
  @headers = {}
28
127
  @schema = nil
29
128
  @thinking = nil
30
- @on = {
31
- new_message: nil,
32
- end_message: nil,
33
- tool_call: nil,
34
- tool_result: nil
35
- }
129
+ @citations = false
130
+ @caching = nil
131
+ @compaction = nil
132
+ @end_user = nil
133
+ @fallbacks = []
134
+ @fallback_errors = Fallback::DEFAULT_ERRORS
36
135
  @callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
136
+ @cancelled = false
137
+ @cancellation_checker = nil
138
+ @tool_call_decisions = {}
139
+ @approval_checker = nil
37
140
  end
38
141
 
142
+ # Adds +message+ as a user message and runs the conversation loop,
143
+ # executing tools until the model answers or a call needs approval.
144
+ # Returns the latest assistant Message; check #awaiting_approval? before
145
+ # treating it as a final answer. Attach files with +with:+.
146
+ # A given block receives streamed Chunk objects as they arrive.
147
+ #
148
+ # String attachments read local paths or fetch URLs. Only pass trusted,
149
+ # authorized sources; validate user uploads before calling this method.
150
+ # See Attachment.new.
151
+ #
152
+ # chat.ask "What's the best way to learn Ruby?"
153
+ # chat.ask "What's in this image?", with: "ruby_conf.jpg"
154
+ # chat.ask "Analyze these files", with: ["diagram.png", "report.pdf"]
155
+ # chat.ask("Tell me a story") { |chunk| print chunk.content }
156
+ #
39
157
  def ask(message = nil, with: nil, &)
40
- add_message role: :user, content: build_content(message, with)
158
+ ask_later(message, with: with)
41
159
  complete(&)
42
160
  end
43
161
 
44
162
  alias say ask
45
163
 
46
- def with_instructions(instructions, append: false, replace: nil)
47
- append ||= (replace == false) unless replace.nil?
164
+ # Stages +message+ as a user message without requesting a completion,
165
+ # leaving the chat ready for #complete, a single #step, or a
166
+ # provider-side batch via RubyLLM.batch. Accepts attachments with
167
+ # +with:+ like #ask. Returns +self+.
168
+ #
169
+ # chats = tickets.map { |t| RubyLLM.chat.ask_later(t.body) }
170
+ # RubyLLM.batch(chats)
171
+ #
172
+ # Raises PendingToolCallsError while the last response has unanswered
173
+ # tool calls: finish the round first, recording #approve or #deny
174
+ # decisions for calls that require approval.
175
+ def ask_later(message = nil, with: nil)
176
+ raise_if_pending_tool_calls!
177
+ add_message role: :user, content: message, attachments: with
178
+ self
179
+ end
48
180
 
49
- if append
50
- append_system_instruction(instructions)
51
- else
52
- replace_system_instruction(instructions)
181
+ # Requests one completion from the model, appends the response to the
182
+ # conversation, and returns it as a Message. Honors the fallbacks
183
+ # configured with #with_fallbacks. A given block receives streamed
184
+ # Chunk objects. Tool calls in the response are not executed; that is
185
+ # #run_tools.
186
+ def generate(&)
187
+ raise_if_cancelled!
188
+
189
+ return generate_once(&) if fallbacks.empty?
190
+
191
+ with_model_restored { generate_with_fallbacks(&) }
192
+ end
193
+
194
+ # Executes the tool calls pending in the latest response and appends
195
+ # their result messages, without asking the model to respond. Tool
196
+ # calls that already have results are skipped, so a chat reloaded
197
+ # mid-round resumes with only the remaining tools. Calls whose tool
198
+ # was declared with Tool.requires_approval only execute once #approve
199
+ # records a decision; denied calls receive a structured denial result,
200
+ # and undecided calls stay pending. Does nothing when no tool calls
201
+ # are pending. The chat is then ready for the next #generate, or the
202
+ # next batch round. Returns +self+.
203
+ def run_tools
204
+ raise_if_cancelled!
205
+
206
+ message = pending_tool_response
207
+ execute_pending_tool_calls(message) if message
208
+ self
209
+ end
210
+
211
+ # Advances the conversation by one move: runs the pending tool calls
212
+ # if any are unanswered, otherwise generates the next response.
213
+ # Returns the Message that move produced, and +nil+ once there is
214
+ # nothing left to do or the loop is parked on an approval.
215
+ def step(&)
216
+ return if complete?
217
+
218
+ raise_if_cancelled!
219
+ return generate(&) unless pending_tool_response
220
+
221
+ before = messages.length
222
+ run_tools
223
+ messages.last if messages.length > before
224
+ end
225
+
226
+ # Runs the conversation loop until #complete? or #awaiting_approval?
227
+ # is +true+. Returns the last conversation Message, or +nil+ for an
228
+ # empty chat. Used after #ask_later; #ask calls #complete for you.
229
+ #
230
+ # When a pending tool call requires approval and no decision has been
231
+ # recorded, the loop pauses. Record #approve or #deny decisions, then
232
+ # call #complete again to continue.
233
+ def complete(&)
234
+ step(&) until complete? || awaiting_approval?
235
+ last_non_system_message || messages.last
236
+ end
237
+
238
+ # Returns whether the chat has no pending response or tool execution:
239
+ # nothing is staged, or the model answered without requesting tools.
240
+ def complete?
241
+ last = last_non_system_message
242
+ case last&.role
243
+ when nil then true
244
+ when :user, :tool then false
245
+ else !last.tool_call?
53
246
  end
247
+ end
248
+
249
+ # Records approval for +tool_call+, a ToolCall or its id, so the next
250
+ # #complete or #run_tools executes a local tool or records permission for
251
+ # the provider to execute a remote tool on the next request. Returns +self+.
252
+ #
253
+ # chat.approve(tool_call)
254
+ # chat.complete
255
+ #
256
+ def approve(tool_call)
257
+ record_tool_call_decision(tool_call, true)
258
+ end
259
+
260
+ # Records denial for +tool_call+, a ToolCall or its id. The next
261
+ # #complete or #run_tools appends a structured denial result instead
262
+ # of executing a local tool, or sends a refusal for a remote tool on the
263
+ # next request. The model continues from there. Returns
264
+ # +self+.
265
+ def deny(tool_call)
266
+ record_tool_call_decision(tool_call, false)
267
+ end
268
+
269
+ # Returns whether the conversation can make no progress without an
270
+ # approval decision: every remaining pending tool call requires
271
+ # approval and has none recorded. While +true+, #complete returns
272
+ # without executing them; record decisions with #approve or #deny,
273
+ # then call #complete again. Tool calls that need no approval still
274
+ # execute before the loop pauses.
275
+ #
276
+ # Consults each pending tool's approval resolver when one is declared,
277
+ # so resolvers must be idempotent reads.
278
+ def awaiting_approval?
279
+ response = pending_tool_response
280
+ return false unless response
281
+
282
+ pending = pending_tool_calls(response)
283
+ pending.any? && pending.all? { |_, tool_call| approval_pending?(tool_call) }
284
+ end
285
+
286
+ # Returns the tool calls from the latest response that require approval
287
+ # and have no recorded decision, as an array of ToolCall objects. Pairs
288
+ # with #approve and #deny. ToolCall#remote? identifies provider-executed calls.
289
+ #
290
+ # chat.pending_approvals.each { |tool_call| puts tool_call.name }
291
+ # chat.approve(chat.pending_approvals.first)
292
+ #
293
+ def pending_approvals
294
+ response = pending_tool_response
295
+ return [] unless response
296
+
297
+ pending_tool_calls(response).values.select { |tool_call| approval_pending?(tool_call) }
298
+ end
299
+
300
+ # Cancels the current in-flight chat operation. The next cancellation
301
+ # checkpoint raises CancelledError and clears the flag so the chat can be
302
+ # reused.
303
+ def cancel
304
+ @cancelled = true
305
+ self
306
+ end
54
307
 
308
+ # Returns whether this in-memory chat has been marked for cancellation.
309
+ def cancelled?
310
+ @cancelled
311
+ end
312
+
313
+ # Sets the system instructions for the conversation, replacing any
314
+ # existing system messages. With <tt>append: true</tt> the instructions
315
+ # are added alongside the existing ones. With <tt>cache_until_here:
316
+ # true</tt> the instruction becomes an explicit prompt cache boundary.
317
+ # Pass +nil+ to remove all system instructions. Returns +self+.
318
+ #
319
+ # chat.with_instructions "You are a helpful Ruby tutor."
320
+ # chat.with_instructions "Use exactly one short paragraph.", append: true
321
+ # chat.with_instructions nil
322
+ #
323
+ def with_instructions(instructions, append: false, cache_until_here: false)
324
+ @messages.reject! { |message| message.role == :system } unless append
325
+ @messages << Message.new(role: :system, content: instructions) unless instructions.nil?
326
+ @messages.last.cache_until_here if instructions && cache_until_here
55
327
  self
56
328
  end
57
329
 
58
- def with_tool(tool, choice: nil, calls: nil, concurrency: @concurrency)
59
- unless tool.nil?
330
+ # Registers +tools+, each a Tool class or instance, for the model to
331
+ # call. Configure how the model uses them with #with_tool_options.
332
+ # Pass +nil+ to remove all registered tools. Returns +self+.
333
+ #
334
+ # chat.with_tools(Weather, Search)
335
+ # chat.with_tools(Weather).with_tool_options(choice: :required)
336
+ #
337
+ # To replace the registered tools, clear them first:
338
+ #
339
+ # chat.with_tools(nil).with_tools(NewTool)
340
+ #
341
+ def with_tools(*tools)
342
+ @tools.clear if tools == [nil]
343
+ tools.flatten.compact.each do |tool|
60
344
  tool_instance = tool.is_a?(Class) ? tool.new : tool
61
345
  @tools[tool_instance.name.to_sym] = tool_instance
62
346
  end
63
- update_tool_options(choice:, calls:)
64
- update_tool_concurrency(concurrency)
65
347
  self
66
348
  end
67
349
 
68
- def with_tools(*tools, replace: false, choice: nil, calls: nil, concurrency: @concurrency)
69
- @tools.clear if replace
70
- tools.compact.each { |tool| with_tool tool }
71
- update_tool_options(choice:, calls:)
72
- update_tool_concurrency(concurrency)
350
+ # Enables tools that run on the provider's servers, such as web search
351
+ # or code execution. Accepts portable alias Symbols, alias-with-options
352
+ # keywords whose options use the provider's own vocabulary, and raw
353
+ # Hashes passed to the provider verbatim, so provider tools RubyLLM has
354
+ # no alias for yet work without a gem update. Entries add to any tools
355
+ # enabled earlier; pass +nil+ to clear them all. Returns +self+.
356
+ #
357
+ # chat.with_server_tools(:web_search)
358
+ # chat.with_server_tools(:web_search, :code_execution)
359
+ # chat.with_server_tools(web_search: { allowed_domains: ["ruby-lang.org"] })
360
+ # chat.with_server_tools({ type: "web_search_20260318", name: "web_search" })
361
+ #
362
+ # The tool steps the model ran come back on
363
+ # Message#server_tool_calls, citations from search tools on
364
+ # Message#citations, and per-use billing counters on
365
+ # <tt>message.tokens.server_tool_use</tt>.
366
+ #
367
+ # Raises UnsupportedServerToolError at request time when the provider
368
+ # has no server-tool support or does not define a requested alias.
369
+ def with_server_tools(*tools, **tools_with_options)
370
+ if tools == [nil] && tools_with_options.empty?
371
+ @server_tools = []
372
+ return self
373
+ end
374
+
375
+ @server_tools += RubyLLM::Tools::ServerTools.normalize(tools, tools_with_options)
73
376
  self
74
377
  end
75
378
 
76
- def with_model(model_id, provider: nil, assume_exists: false)
77
- @model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
379
+ # Configures how the model uses the registered tools. +choice:+
380
+ # constrains tool use to +:auto+, +:none+, +:required+, a tool name, or
381
+ # a Tool class. +calls:+ limits how many tool calls one response may
382
+ # contain (+:many+ or +:one+). +concurrency:+ runs tool calls
383
+ # concurrently: +true+ or +:threads+ for threads, +:fibers+ for fibers.
384
+ # An omitted option is left unchanged; passing +nil+ explicitly resets
385
+ # that option (+concurrency: nil+ returns to the configured default).
386
+ # Returns +self+.
387
+ #
388
+ # chat.with_tools(Weather, Search).with_tool_options(choice: :required)
389
+ # chat.with_tool_options(calls: :one, concurrency: :threads)
390
+ # chat.with_tool_options(choice: nil)
391
+ #
392
+ def with_tool_options(**options)
393
+ options.each do |option, value|
394
+ case option
395
+ when :choice then apply_tool_choice(value)
396
+ when :calls then @tool_prefs[:calls] = value.nil? ? nil : normalize_calls(value)
397
+ when :concurrency then @concurrency = normalize_tool_concurrency(value.nil? ? @config.tool_concurrency : value)
398
+ else raise ArgumentError, "Unknown tool option: #{option}. Valid options are: choice, calls, concurrency"
399
+ end
400
+ end
401
+ self
402
+ end
403
+
404
+ # Switches the chat to +model_id+ and its provider. Pass +provider:+ to
405
+ # disambiguate, and <tt>assume_model_exists: true</tt> to skip registry
406
+ # validation for custom or private models. Pass +nil+ to return to the
407
+ # configured default model. Returns +self+.
408
+ #
409
+ # +protocol:+ overrides the wire protocol the provider would pick for the
410
+ # model, such as +:responses+ or +:chat_completions+ for OpenAI. It stays
411
+ # +nil+ by default, meaning the provider chooses the protocol for each
412
+ # request. A bare #with_model resets the override to +nil+, just as it
413
+ # re-resolves the provider from the model.
414
+ #
415
+ # Raises ModelNotFoundError if +model_id+ is not in the registry and
416
+ # +assume_model_exists:+ is false.
417
+ #
418
+ # chat.with_model('claude-sonnet-5')
419
+ # chat.with_model('gpt-5.6', protocol: :chat_completions)
420
+ #
421
+ def with_model(model_id, provider: nil, protocol: nil, assume_model_exists: false)
422
+ model_id ||= @config.default_model
423
+ @model, @provider = Models.resolve(model_id, provider:, assume_model_exists:, config: @config)
78
424
  @connection = @provider.connection
425
+ @protocol = protocol
79
426
  self
80
427
  end
81
428
 
429
+ # Sets fallback models to try, in order, when generation fails. +on:+
430
+ # selects the error classes that trigger a fallback; the default covers
431
+ # transient provider and network errors. Pass +nil+ to remove all
432
+ # fallbacks and restore the default error classes. Returns +self+.
433
+ #
434
+ # chat.with_fallbacks("gpt-4.1-mini", "claude-haiku-4-5")
435
+ # chat.with_fallbacks(nil)
436
+ #
437
+ def with_fallbacks(*models, on: Fallback::DEFAULT_ERRORS)
438
+ fallback_models = models.flatten.compact
439
+ @fallbacks = fallback_models.map { |model| Fallback.build(model) }
440
+ @fallback_errors = fallback_models.empty? ? Fallback::DEFAULT_ERRORS : Array(on).flatten.compact
441
+ self
442
+ end
443
+
444
+ # Sets the sampling temperature for subsequent requests. Pass +nil+ to
445
+ # return to the model's default sampling behavior. Returns +self+.
446
+ #
447
+ # chat.with_temperature(0.2)
448
+ #
82
449
  def with_temperature(temperature)
83
450
  @temperature = temperature
84
451
  self
85
452
  end
86
453
 
87
- def with_thinking(effort: nil, budget: nil)
88
- raise ArgumentError, 'with_thinking requires :effort or :budget' if effort.nil? && budget.nil?
89
-
90
- @thinking = Thinking::Config.new(effort: effort, budget: budget)
454
+ # Caps the number of tokens the model may generate.
455
+ # Pass +nil+ to remove the limit.
456
+ # Returns +self+.
457
+ #
458
+ # chat.with_max_output_tokens(1000)
459
+ #
460
+ def with_max_output_tokens(max_output_tokens)
461
+ @max_output_tokens = max_output_tokens
91
462
  self
92
463
  end
93
464
 
94
- def with_context(context)
95
- @context = context
96
- @config = context.config
97
- with_model(@model.id, provider: @provider.slug, assume_exists: true)
465
+ # Configures extended thinking for models that support it. With no
466
+ # arguments, RubyLLM uses the current model's registered default. Pass
467
+ # +false+ to disable thinking, or tune it with
468
+ # +effort:+ (+:low+, +:medium+, +:high+, +:none+, or a
469
+ # provider-specific tier such as +:minimal+, +:xhigh+, or +:max+,
470
+ # passed through as-is), +budget:+ (a token count), and +display:+
471
+ # (+:summarized+ or +:omitted+, controlling whether providers that
472
+ # support it return readable thinking text). Accepts keywords or an options
473
+ # Hash. Passing +nil+ raises ArgumentError. Returns +self+.
474
+ #
475
+ # chat.with_thinking
476
+ # chat.with_thinking(false)
477
+ # chat.with_thinking(effort: :high)
478
+ # chat.with_thinking(budget: 10_000)
479
+ # chat.with_thinking(display: :summarized)
480
+ #
481
+ def with_thinking(enabled = true, **options) # rubocop:disable Metrics/PerceivedComplexity
482
+ return with_thinking(**enabled.transform_keys(&:to_sym), **options) if enabled.is_a?(Hash)
483
+
484
+ raise ArgumentError, 'with_thinking accepts false or thinking options' unless [true, false].include?(enabled)
485
+ raise ArgumentError, 'with_thinking(false) does not accept options' if !enabled && options.any?
486
+ raise ArgumentError, 'thinking options cannot be nil; pass false to disable' if options.value?(nil)
487
+ if (unsupported = options.keys - THINKING_OPTIONS).any?
488
+ raise ArgumentError,
489
+ "with_thinking accepts #{format_option_keys(THINKING_OPTIONS)}, " \
490
+ "got #{format_option_keys(unsupported)}"
491
+ end
492
+
493
+ @thinking = if enabled
494
+ options.empty? ? Thinking::Config.default : Thinking::Config.new(**options)
495
+ else
496
+ Thinking::Config.disabled
497
+ end
98
498
  self
99
499
  end
100
500
 
101
- def with_params(**params)
102
- @params = params
501
+ # Returns the thinking options resolved for the current model, or +nil+
502
+ # when thinking was not configured or needs no provider control.
503
+ def thinking
504
+ config = resolved_thinking
505
+ return unless config
506
+
507
+ {
508
+ effort: config.effort,
509
+ budget: config.budget,
510
+ display: config.display,
511
+ enabled: config.enabled
512
+ }.compact
513
+ end
514
+
515
+ # Enables document citations, so the model backs its claims with quotes
516
+ # from attached files. Pass +false+ to disable. Passing +nil+ raises
517
+ # ArgumentError. Returns +self+.
518
+ #
519
+ # chat.with_citations
520
+ # response = chat.ask "Who created Ruby?", with: "facts.txt"
521
+ # response.citations.each { |citation| puts citation.cited_text }
522
+ #
523
+ def with_citations(enabled = true)
524
+ raise ArgumentError, 'with_citations accepts true or false' unless [true, false].include?(enabled)
525
+
526
+ @citations = enabled
103
527
  self
104
528
  end
105
529
 
106
- def with_headers(**headers)
107
- @headers = headers
530
+ # Enables provider prompt caching. With no arguments the provider's
531
+ # default behavior applies; options such as +ttl:+ apply where
532
+ # supported. Pass +id:+ with a CachedContent (or its name) from
533
+ # RubyLLM.cache to attach an explicit
534
+ # content cache. Pass +false+ to stop RubyLLM from sending cache
535
+ # controls or rendering explicit cache boundaries. A provider may still
536
+ # cache prompts implicitly. Passing +nil+ raises ArgumentError.
537
+ # Returns +self+.
538
+ #
539
+ # chat.with_caching
540
+ # chat.with_caching(ttl: "1h")
541
+ # chat.with_caching(id: cache)
542
+ # chat.with_caching(false)
543
+ #
544
+ def with_caching(options = {})
545
+ options = {} if options == true
546
+ unless options == false || options.is_a?(Hash)
547
+ raise ArgumentError, 'with_caching accepts true, false, or caching options'
548
+ end
549
+
550
+ @caching = options == false ? false : options.transform_keys(&:to_sym).freeze
108
551
  self
109
552
  end
110
553
 
111
- def with_schema(schema)
112
- schema_instance = schema.is_a?(Class) ? schema.new : schema
554
+ # Enables provider-side context compaction, so a long conversation keeps
555
+ # going instead of overflowing the context window. The provider condenses
556
+ # the earlier turns itself and returns a block that RubyLLM replays on
557
+ # later requests. With no arguments the provider's own defaults apply.
558
+ # The options are provider-neutral:
559
+ #
560
+ # +at+:: the input-token count that triggers compaction.
561
+ # +instructions+:: a custom prompt for the summary the provider writes.
562
+ # +pause_after+:: end the turn once compaction runs, instead of
563
+ # continuing straight into the answer.
564
+ #
565
+ # Each provider applies the options it supports. Unsupported options
566
+ # are ignored with a debug log. Pass +false+ to disable; passing +nil+
567
+ # raises ArgumentError. Returns +self+.
568
+ #
569
+ # chat.with_compaction
570
+ # chat.with_compaction(at: 50_000)
571
+ # chat.with_compaction(at: 100_000, instructions: "Keep every decision.")
572
+ # chat.with_compaction(false)
573
+ #
574
+ # What a provider does when the threshold is crossed differs. Anthropic
575
+ # and OpenAI summarize the compacted span into an opaque block that
576
+ # replaces it; OpenRouter drops messages from the middle of the
577
+ # conversation instead, and has no threshold of its own.
578
+ def with_compaction(options = {})
579
+ options = {} if options == true
580
+ unless options == false || options.is_a?(Hash)
581
+ raise ArgumentError, 'with_compaction accepts true, false, or compaction options'
582
+ end
113
583
 
114
- @schema = normalize_schema_payload(
115
- schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
116
- )
584
+ @compaction = options == false ? false : normalize_compaction(options)
585
+ self
586
+ end
117
587
 
588
+ # Identifies the end user behind the conversation for the provider's
589
+ # abuse monitoring. Providers without an equivalent field omit it.
590
+ # Pass +nil+ to remove it.
591
+ # Returns +self+.
592
+ #
593
+ # chat.with_end_user("user-123").ask "Hello"
594
+ #
595
+ # The value is sent as given, so use an opaque id such as a hash of
596
+ # your user id, never personal data.
597
+ def with_end_user(end_user)
598
+ @end_user = end_user
118
599
  self
119
600
  end
120
601
 
121
- def on_new_message(&)
122
- set_legacy_callback(:new_message, :on_new_message, :before_message, &)
602
+ # Rebinds the chat to +context+, a Context built with RubyLLM.context,
603
+ # so subsequent requests use its configuration. Pass +nil+ to return to
604
+ # the global RubyLLM.config. Returns +self+.
605
+ def with_context(context)
606
+ @context = context
607
+ @config = context&.config || RubyLLM.config
608
+ with_model(@model.id, provider: @provider.slug, protocol: @protocol, assume_model_exists: true)
609
+ self
123
610
  end
124
611
 
125
- def on_end_message(&)
126
- set_legacy_callback(:end_message, :on_end_message, :after_message, &)
612
+ # Sets options in the provider's request vocabulary, merged into the
613
+ # request payload as-is and overriding RubyLLM's defaults. Replaces any
614
+ # previously set provider options; +nil+ clears them. Returns +self+.
615
+ #
616
+ # chat.with_provider_options(service_tier: "flex")
617
+ #
618
+ def with_provider_options(provider_options)
619
+ @provider_options = provider_options.to_h
620
+ self
127
621
  end
128
622
 
129
- def on_tool_call(&)
130
- set_legacy_callback(:tool_call, :on_tool_call, :before_tool_call, &)
623
+ # Sets extra HTTP headers sent with completion requests, replacing any
624
+ # previously set headers; +nil+ clears them. Returns +self+.
625
+ #
626
+ # chat.with_headers('anthropic-beta' => 'fine-grained-tool-streaming-2025-05-14')
627
+ #
628
+ def with_headers(headers)
629
+ @headers = headers.to_h
630
+ self
131
631
  end
132
632
 
133
- def on_tool_result(&)
134
- set_legacy_callback(:tool_result, :on_tool_result, :after_tool_result, &)
633
+ # Sets the schema for structured output. Accepts a JSON Schema Hash, a
634
+ # Schematist::Schema class or instance, or any object responding to
635
+ # +to_json_schema+. Returns +self+.
636
+ #
637
+ # class PersonSchema < Schematist::Schema
638
+ # string :name
639
+ # integer :age
640
+ # end
641
+ #
642
+ # chat.with_schema(PersonSchema)
643
+ # response = chat.ask("Generate a person named Alice who is 30 years old")
644
+ # response.parsed # => {"name" => "Alice", "age" => 30}
645
+ #
646
+ # Pass +nil+ to remove the schema, returning the chat to plain text
647
+ # responses.
648
+ def with_schema(schema)
649
+ schema_instance = schema.is_a?(Class) ? schema.new : schema
650
+
651
+ @schema = normalize_schema_payload(
652
+ schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
653
+ )
654
+
655
+ self
135
656
  end
136
657
 
658
+ # Registers a callback that runs before each assistant response or tool
659
+ # result is appended to the conversation. Callbacks are additive: every
660
+ # registered block runs. Returns +self+.
137
661
  def before_message(&)
138
662
  add_callback(:before_message, &)
139
663
  end
140
664
 
665
+ # Registers a callback that receives each assistant response and each
666
+ # tool result message once it has been appended. Returns +self+.
667
+ #
668
+ # chat.after_message { |message| puts message.content }
669
+ #
141
670
  def after_message(&)
142
671
  add_callback(:after_message, &)
143
672
  end
144
673
 
674
+ # Registers a callback that receives each local ToolCall before the tool
675
+ # executes. Returns +self+.
676
+ #
677
+ # chat.before_tool_call { |tool_call| puts tool_call.name }
678
+ #
145
679
  def before_tool_call(&)
146
680
  add_callback(:before_tool_call, &)
147
681
  end
148
682
 
683
+ # Registers a callback that receives each local tool's result after
684
+ # execution. Returns +self+.
149
685
  def after_tool_result(&)
150
686
  add_callback(:after_tool_result, &)
151
687
  end
152
688
 
689
+ # Registers a callback that receives the Fallback attempt after the
690
+ # current model fails and before the fallback model is tried. Returns
691
+ # +self+.
692
+ def before_fallback(&)
693
+ add_callback(:before_fallback, &)
694
+ end
695
+
696
+ # Registers a callback that receives the Fallback attempt once it has
697
+ # succeeded or failed. Returns +self+.
698
+ def after_fallback(&)
699
+ add_callback(:after_fallback, &)
700
+ end
701
+
702
+ # Registers a callback that receives the fully rendered request payload
703
+ # before it is sent and may mutate it in place. Runs after all RubyLLM
704
+ # formatting and #with_provider_options merging. Returns +self+.
705
+ #
706
+ # chat.before_request { |payload| logger.debug payload }
707
+ #
708
+ def before_request(&)
709
+ add_callback(:before_request, &)
710
+ end
711
+
712
+ # Yields each Message in the conversation. Returns an Enumerator when
713
+ # no block is given. Chat includes Enumerable, so the usual collection
714
+ # methods are available.
153
715
  def each(&)
154
716
  messages.each(&)
155
717
  end
156
718
 
719
+ # Returns token usage aggregated across every provider attempt this chat
720
+ # has made, including retries and attempts that produced no message.
721
+ #
722
+ # chat.tokens.input
723
+ #
724
+ def tokens
725
+ Tokens.aggregate(usage_entries.map(&:tokens))
726
+ end
727
+
728
+ # Returns a Cost aggregating every provider attempt this chat has made,
729
+ # including retries and attempts that produced no message.
730
+ #
731
+ # chat.cost.total
732
+ #
157
733
  def cost
158
- Cost.aggregate(messages.map { |message| message.cost(model: message.model_info || model) })
734
+ Cost.aggregate(usage_entries.map(&:cost), complete: usage_entries.all?(&:cost_available?))
159
735
  end
160
736
 
161
- def complete(&)
162
- instrument_completion(&)
737
+ # Counts input tokens for the conversation, including instructions,
738
+ # function tools, structured output, thinking, and attachments.
739
+ # Pass +message+ to include it as a staged user message without
740
+ # mutating the chat. Returns an Integer.
741
+ #
742
+ # chat.with_instructions("Be terse.").with_tools(Weather)
743
+ # chat.count_tokens("What's the weather in Berlin?")
744
+ #
745
+ # Server tools, provider_options, compaction, and before_request hooks
746
+ # are not included. Raises Error when the provider has no token counting
747
+ # endpoint.
748
+ def count_tokens(message = nil)
749
+ request_messages = messages.dup
750
+ request_messages << coerce_message(role: :user, content: message) unless message.nil?
751
+ @provider.count_tokens(
752
+ preprocessed_messages(request_messages),
753
+ model: @model,
754
+ tools: @tools,
755
+ tool_prefs: @tool_prefs,
756
+ thinking: resolved_thinking,
757
+ schema: @schema,
758
+ citations: @citations,
759
+ caching: @caching,
760
+ protocol: @protocol
761
+ )
762
+ end
763
+
764
+ # Compacts the conversation's model context and returns an assistant
765
+ # Message. The message can have empty text and carries the provider's
766
+ # compacted context internally. Every earlier message remains in
767
+ # #messages, including on persisted Rails chats.
768
+ #
769
+ # chat.ask "Remember these project requirements..."
770
+ # chat.compact
771
+ # chat.ask "Which requirement should we implement first?"
772
+ #
773
+ # Uses the current instructions, headers, and request hooks. Records
774
+ # reported usage and runs the normal message callbacks. Raises Error
775
+ # when the provider has no manual compaction endpoint, and
776
+ # PendingToolCallsError until pending tool calls have been answered.
777
+ def compact
778
+ raise_if_cancelled!
779
+ raise_if_pending_tool_calls!
780
+ usage_start = usage_entries.length
781
+ payload = instrumentation_payload(streaming: false)
782
+ RubyLLM.instrument('compaction.ruby_llm', payload, config: @config) do |event|
783
+ result = provider_compaction
784
+ record_out_of_band_usage(result) if usage_entries.length == usage_start
785
+ record_generated_message(result, usage_start)
786
+ record_completion_event(event, result)
787
+ result
788
+ end
163
789
  end
164
790
 
791
+ # Replaces the conversation with +new_messages+, coercing each element
792
+ # into a Message. Accepts Message objects, attribute Hashes, and
793
+ # records responding to +to_llm+.
794
+ def messages=(new_messages)
795
+ @messages = message_list(new_messages).map { |message| coerce_message(message) }
796
+ end
797
+
798
+ # Replaces the usage ledger. Used by the Rails integration when
799
+ # rebuilding a persisted chat.
800
+ def usage_entries=(entries) # :nodoc:
801
+ @usage_entries = Array(entries)
802
+ end
803
+
804
+ # Hooks installed by the Rails integration.
805
+ attr_writer :cancellation_checker, :usage_recorder, :approval_checker # :nodoc:
806
+
807
+ # Appends a message to the conversation and returns it as a Message.
808
+ # Accepts a Message, an attribute Hash, or a record responding to
809
+ # +to_llm+.
810
+ #
811
+ # chat.add_message(role: :user, content: "What's the capital of France?")
812
+ #
165
813
  def add_message(message_or_attributes)
166
- message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
814
+ message = coerce_message(message_or_attributes)
167
815
  messages << message
168
816
  message
169
817
  end
170
818
 
171
- # Mutates this chat by removing all in-memory messages.
172
- def reset_messages!
173
- @messages.clear
819
+ # Marks the latest message as an explicit prompt cache boundary, asking
820
+ # the provider to cache everything up to this point. Returns +self+.
821
+ #
822
+ # Raises ArgumentError if the chat has no messages.
823
+ def cache_until_here
824
+ message = messages.last
825
+ raise ArgumentError, 'No messages to cache' unless message
826
+
827
+ message.cache_until_here
828
+ self
829
+ end
830
+
831
+ # Receives a completion produced out-of-band (e.g. by a batch), running the
832
+ # same callbacks as a synchronous completion so persistence works unchanged.
833
+ def add_completion(response, record_usage: false) # :nodoc:
834
+ if response.ruby_llm_usage_entries.empty?
835
+ record_out_of_band_usage(response)
836
+ elsif record_usage
837
+ response.ruby_llm_usage_entries.each { |entry| record_usage_entry(entry) }
838
+ end
839
+ run_callbacks(:before_message)
840
+ add_message response
841
+ run_callbacks(:after_message, response)
842
+ response
843
+ end
844
+
845
+ # Returns the request payload this chat would send to the provider for
846
+ # its next completion, with #before_request hooks applied. Useful for
847
+ # inspecting and testing request output.
848
+ def render
849
+ @provider.render(
850
+ preprocessed_messages,
851
+ tools: @tools,
852
+ server_tools: @server_tools,
853
+ tool_prefs: @tool_prefs,
854
+ temperature: @temperature,
855
+ max_output_tokens: @max_output_tokens,
856
+ model: @model,
857
+ provider_options: Support::Utils.deep_dup(@provider_options),
858
+ schema: @schema,
859
+ thinking: resolved_thinking,
860
+ citations: @citations,
861
+ caching: @caching,
862
+ compaction: @compaction,
863
+ end_user: @end_user,
864
+ protocol: @protocol,
865
+ before_request: @callbacks[:before_request]
866
+ )
174
867
  end
175
868
 
176
- def instance_variables
177
- super - %i[@connection @config]
869
+ # Refuses to stage a user message onto an unfinished tool round, which
870
+ # providers reject. Called by #ask_later here and in the Rails
871
+ # integration before it persists anything.
872
+ def raise_if_pending_tool_calls! # :nodoc:
873
+ response = pending_tool_response
874
+ return unless response
875
+
876
+ names = pending_tool_calls(response).values.map(&:name).uniq
877
+ raise PendingToolCallsError,
878
+ "The last response has unanswered tool calls (#{names.join(', ')}). " \
879
+ 'Run complete, recording approve or deny decisions for calls that ' \
880
+ 'require approval, before asking again.'
178
881
  end
179
882
 
180
883
  private
181
884
 
885
+ def resolved_thinking
886
+ @thinking&.resolve(@model)
887
+ end
888
+
889
+ def normalize_compaction(options)
890
+ compaction = options.to_h.transform_keys(&:to_sym)
891
+ unsupported = compaction.keys - COMPACTION_OPTIONS
892
+ return compaction.freeze if unsupported.empty?
893
+
894
+ raise ArgumentError,
895
+ "with_compaction accepts #{format_option_keys(COMPACTION_OPTIONS)}, " \
896
+ "got #{format_option_keys(unsupported)}. Provider-specific settings " \
897
+ 'go through with_provider_options.'
898
+ end
899
+
900
+ def format_option_keys(keys)
901
+ keys.map { |key| ":#{key}" }.join(', ')
902
+ end
903
+
904
+ def message_list(new_messages)
905
+ return [] if new_messages.nil?
906
+ if new_messages.is_a?(Hash) || new_messages.is_a?(Message) || new_messages.respond_to?(:to_llm)
907
+ return [new_messages]
908
+ end
909
+
910
+ new_messages.respond_to?(:to_a) ? new_messages.to_a : [new_messages]
911
+ end
912
+
913
+ def coerce_message(message_or_attributes)
914
+ raise ArgumentError, 'Message cannot be nil' if message_or_attributes.nil?
915
+
916
+ message = if message_or_attributes.respond_to?(:to_llm)
917
+ message_or_attributes.to_llm
918
+ else
919
+ message_or_attributes
920
+ end
921
+
922
+ message = Message.new(message.merge(config: @config)) unless message.is_a?(Message)
923
+ message.conversation = self
924
+ message
925
+ end
926
+
182
927
  def normalize_schema_payload(raw_schema)
183
928
  return nil if raw_schema.nil?
184
929
  return raw_schema unless raw_schema.is_a?(Hash)
185
930
 
186
- schema = RubyLLM::Utils.deep_symbolize_keys(raw_schema)
931
+ schema = RubyLLM::Support::Utils.deep_symbolize_keys(raw_schema)
187
932
  schema_def = extract_schema_definition(schema)
188
933
  strict = extract_schema_strict(schema, schema_def)
189
934
  build_schema_payload(schema, schema_def, strict)
190
935
  end
191
936
 
192
937
  def extract_schema_definition(schema)
193
- RubyLLM::Utils.deep_dup(schema[:schema] || schema)
938
+ definition = RubyLLM::Support::Utils.deep_dup(schema[:schema] || schema)
939
+ RubyLLM::Support::Utils.strip_schema_metadata(definition)
194
940
  end
195
941
 
196
942
  def extract_schema_strict(schema, schema_def)
@@ -202,9 +948,9 @@ module RubyLLM
202
948
 
203
949
  def build_schema_payload(schema, schema_def, strict)
204
950
  {
205
- name: sanitize_schema_name(schema[:name] || 'response'),
951
+ name: sanitize_schema_name(schema[:name] || schema[:title] || 'response'),
206
952
  schema: schema_def,
207
- strict: strict.nil? || strict,
953
+ strict: strict,
208
954
  description: schema[:description]
209
955
  }.compact
210
956
  end
@@ -219,150 +965,351 @@ module RubyLLM
219
965
  self
220
966
  end
221
967
 
222
- def complete_once(&)
223
- response = provider_completion(&)
968
+ def raise_if_cancelled!
969
+ external_cancelled = @cancellation_checker&.call
970
+ return unless @cancelled || external_cancelled
224
971
 
225
- run_callbacks(:before_message, :new_message) unless block_given?
972
+ @cancelled = false
973
+ raise CancelledError
974
+ end
226
975
 
227
- normalize_schema_response(response)
976
+ def generate_once(stream_tracker: nil, usage_start: nil, &block)
977
+ raise_if_cancelled!
228
978
 
229
- add_message response
230
- run_callbacks(:after_message, :end_message, response)
979
+ result = nil
980
+ entries_before = usage_entries.length
981
+ usage_start ||= entries_before
982
+ payload = instrumentation_payload(streaming: block_given?)
231
983
 
232
- if response.tool_call?
233
- handle_tool_calls(response, &)
234
- else
235
- response
984
+ RubyLLM.instrument('chat.ruby_llm', payload, config: @config) do |event|
985
+ result = provider_completion(usage_recorder: method(:record_usage_entry), stream_tracker:, &block)
986
+ record_out_of_band_usage(result) if usage_entries.length == entries_before
987
+ record_generated_message(result, usage_start, streaming: block_given?)
988
+ record_completion_event(event, result)
236
989
  end
990
+ result
237
991
  end
238
992
 
239
- def instrument_completion(&block)
240
- result = nil
241
- streaming = block_given?
242
- payload = {
993
+ def record_generated_message(result, usage_start, streaming: false)
994
+ raise_if_cancelled!
995
+ link_completion_usage(result, usage_start)
996
+ run_callbacks(:before_message) unless streaming
997
+ add_message result
998
+ run_callbacks(:after_message, result)
999
+ end
1000
+
1001
+ def instrumentation_payload(streaming:)
1002
+ empty_tokens = Tokens.new
1003
+ {
243
1004
  chat: self,
244
1005
  provider: @provider.slug,
245
- provider_class: @provider.class.name,
1006
+ provider_class: @provider.name,
246
1007
  model: @model.id,
247
1008
  model_info: @model,
248
1009
  input_messages: messages.dup,
249
1010
  message_count: messages.size,
250
1011
  tools: tools.keys,
1012
+ server_tools: server_tools,
251
1013
  tool_choice: tool_prefs[:choice],
252
1014
  tool_call_limit: tool_prefs[:calls],
253
1015
  temperature: @temperature,
254
- params: params,
1016
+ max_output_tokens: @max_output_tokens,
1017
+ provider_options: provider_options,
255
1018
  schema: schema,
256
- thinking: @thinking,
257
- streaming: streaming
1019
+ thinking: resolved_thinking,
1020
+ citations: @citations,
1021
+ caching: @caching,
1022
+ streaming: streaming,
1023
+ tokens: empty_tokens,
1024
+ cost: Cost.new(tokens: empty_tokens, model: @model)
258
1025
  }
1026
+ end
259
1027
 
260
- RubyLLM.instrument('chat.ruby_llm', payload, config: @config) do |event|
261
- result = complete_once(&block)
262
- event[:response] = result
263
- event[:messages_after] = messages.dup
264
- event[:response_role] = result.role if result.respond_to?(:role)
265
-
266
- if result.respond_to?(:tool_call?)
267
- event[:response_model] = result.model_id
268
- event[:tool_call] = result.tool_call?
269
- event[:tool_calls] = result.tool_calls
270
- event[:input_tokens] = result.input_tokens
271
- event[:output_tokens] = result.output_tokens
272
- event[:cached_tokens] = result.cached_tokens
273
- event[:cache_creation_tokens] = result.cache_creation_tokens
274
- event[:thinking_tokens] = result.thinking_tokens
1028
+ def record_completion_event(event, result)
1029
+ event[:response] = result
1030
+ event[:messages_after] = messages.dup
1031
+ event[:response_role] = result.role if result.respond_to?(:role)
1032
+ event[:tokens] = result.tokens
1033
+ event[:cost] = result.cost
1034
+ return unless result.respond_to?(:tool_call?)
1035
+
1036
+ event[:response_model] = result.model
1037
+ event[:tool_call] = result.tool_call?
1038
+ event[:tool_calls] = result.tool_calls
1039
+ end
1040
+
1041
+ def generate_with_fallbacks(&block)
1042
+ fallback_queue = fallbacks.dup
1043
+ attempt = 0
1044
+ active_fallback = nil
1045
+ streaming = block_given?
1046
+ usage_start = usage_entries.length
1047
+
1048
+ loop do
1049
+ chunks_yielded = false
1050
+
1051
+ begin
1052
+ result = generate_once(stream_tracker: proc { chunks_yielded = true }, usage_start:, &block)
1053
+ finish_fallback(active_fallback, response: result)
1054
+ return result
1055
+ rescue StandardError => e
1056
+ finish_fallback(active_fallback, fallback_error: e)
1057
+ raise e unless fallback_error?(e)
1058
+
1059
+ active_fallback, attempt = fallback_to_next_model!(
1060
+ fallback_queue,
1061
+ error: e,
1062
+ attempt: attempt,
1063
+ streaming: streaming,
1064
+ chunks_yielded: chunks_yielded
1065
+ )
275
1066
  end
276
1067
  end
277
- result
278
1068
  end
279
1069
 
280
- def provider_completion(&)
1070
+ def with_model_restored
1071
+ original_model = @model
1072
+ original_provider = @provider
1073
+ original_connection = @connection
1074
+ original_protocol = @protocol
1075
+
1076
+ yield
1077
+ ensure
1078
+ @model = original_model
1079
+ @provider = original_provider
1080
+ @connection = original_connection
1081
+ @protocol = original_protocol
1082
+ end
1083
+
1084
+ def switch_to_fallback_model(fallback)
1085
+ from_provider = @provider.slug
1086
+ if fallback.model
1087
+ with_resolved_model(fallback.model)
1088
+ else
1089
+ with_model(fallback.id, provider: fallback.provider, protocol: @protocol)
1090
+ end
1091
+ @protocol = nil unless @provider.slug == from_provider
1092
+ self
1093
+ end
1094
+
1095
+ def with_resolved_model(model)
1096
+ provider_class = Provider.resolve!(model.provider)
1097
+ @model = model
1098
+ @provider = provider_class.new(@config)
1099
+ @connection = @provider.connection
1100
+ self
1101
+ end
1102
+
1103
+ def fallback_to_next_model!(fallback_queue, error:, attempt:, streaming:, chunks_yielded:)
1104
+ fallback = fallback_queue.shift
1105
+ raise error unless fallback
1106
+
1107
+ attempt += 1
1108
+ from_model = @model
1109
+ switch_to_fallback_model(fallback)
1110
+ fallback = fallback.with_attempt(
1111
+ chat: self,
1112
+ error: error,
1113
+ from: from_model,
1114
+ to: @model,
1115
+ attempt: attempt,
1116
+ streaming: streaming,
1117
+ chunks_yielded: chunks_yielded
1118
+ )
1119
+ run_callbacks(:before_fallback, fallback)
1120
+ [fallback, attempt]
1121
+ end
1122
+
1123
+ def finish_fallback(fallback, response: nil, fallback_error: nil)
1124
+ return unless fallback
1125
+
1126
+ fallback.finish(response: response, fallback_error: fallback_error)
1127
+ run_callbacks(:after_fallback, fallback)
1128
+ end
1129
+
1130
+ def fallback_error?(error)
1131
+ fallback_errors.any? { |error_class| error.is_a?(error_class) }
1132
+ end
1133
+
1134
+ # Preprocessing builds a per-request view of the conversation: the
1135
+ # provider can change through fallbacks or with_model, so history keeps
1136
+ # the original attachments while each provider's upload is memoized on
1137
+ # them. Reloaded Rails chats rebuild history from rows and upload again.
1138
+ def preprocessed_messages(list = messages)
1139
+ return list unless @provider
1140
+
1141
+ list.map { |message| @provider.preprocess_message(message, model: @model, protocol: @protocol) }
1142
+ end
1143
+
1144
+ def provider_completion(usage_recorder:, stream_tracker: nil, &)
1145
+ raise_if_cancelled!
1146
+
281
1147
  @provider.complete(
282
- messages,
1148
+ preprocessed_messages,
283
1149
  tools: @tools,
1150
+ server_tools: @server_tools,
284
1151
  tool_prefs: @tool_prefs,
285
1152
  temperature: @temperature,
1153
+ max_output_tokens: @max_output_tokens,
286
1154
  model: @model,
287
- params: @params,
1155
+ provider_options: Support::Utils.deep_dup(@provider_options),
288
1156
  headers: @headers,
289
1157
  schema: @schema,
290
- thinking: @thinking,
291
- &wrap_streaming_block(&)
1158
+ thinking: resolved_thinking,
1159
+ citations: @citations,
1160
+ caching: @caching,
1161
+ compaction: @compaction,
1162
+ end_user: @end_user,
1163
+ protocol: @protocol,
1164
+ before_request: @callbacks[:before_request],
1165
+ usage_recorder: usage_recorder,
1166
+ &wrap_streaming_block(stream_tracker:, &)
292
1167
  )
293
1168
  end
294
1169
 
295
- def normalize_schema_response(response)
296
- return unless @schema && response.content.is_a?(String) && !response.tool_call?
297
-
298
- response.content = JSON.parse(response.content)
299
- rescue JSON::ParserError
300
- # If parsing fails, keep content as string.
1170
+ def provider_compaction
1171
+ @provider.compact(
1172
+ preprocessed_messages, model: @model, protocol: @protocol,
1173
+ headers: @headers, before_request: @callbacks[:before_request],
1174
+ usage_recorder: method(:record_usage_entry)
1175
+ )
301
1176
  end
302
1177
 
303
- def set_legacy_callback(name, legacy_name, additive_name, &block)
304
- warn_legacy_callback_deprecation(legacy_name, additive_name) if block
1178
+ def record_usage_entry(entry)
1179
+ usage_entries << entry
1180
+ @usage_recorder&.call(entry)
1181
+ entry
1182
+ end
305
1183
 
306
- @on[name] = block
307
- self
1184
+ def link_completion_usage(response, usage_start)
1185
+ response.ruby_llm_usage_entries = usage_entries.drop(usage_start)
308
1186
  end
309
1187
 
310
- def warn_legacy_callback_deprecation(legacy_name, additive_name)
311
- RubyLLM.deprecator.warn(
312
- "`#{legacy_name}` is deprecated and will be removed in RubyLLM 2.0. " \
313
- "Use `#{additive_name}` instead."
1188
+ def record_out_of_band_usage(response)
1189
+ entry = Accounting::Usage::Entry.new(
1190
+ operation: :chat,
1191
+ provider: @provider.slug,
1192
+ model: response.model || @model.id,
1193
+ status: :succeeded,
1194
+ tokens: response.tokens,
1195
+ cost: response.cost,
1196
+ message: response
314
1197
  )
1198
+ response.ruby_llm_usage_entries = [entry]
1199
+ Accounting::Usage.instrument(entry, config: @config)
1200
+ record_usage_entry(entry)
315
1201
  end
316
1202
 
317
- def run_callbacks(name, legacy_name, *args)
1203
+ def run_callbacks(name, *args)
318
1204
  @callbacks[name].each { |callback| callback.call(*args) }
319
- @on[legacy_name]&.call(*args)
320
1205
  end
321
1206
 
322
- def wrap_streaming_block(&block)
323
- return nil unless block_given?
1207
+ def wrap_streaming_block(stream_tracker: nil, &block)
1208
+ return nil unless block
324
1209
 
325
- run_callbacks(:before_message, :new_message)
1210
+ run_callbacks(:before_message)
326
1211
 
327
1212
  proc do |chunk|
328
- block.call chunk
1213
+ raise_if_cancelled!
1214
+ stream_tracker&.call(chunk)
1215
+ block.call(chunk)
1216
+ raise_if_cancelled!
329
1217
  end
330
1218
  end
331
1219
 
332
- def handle_tool_calls(response, &)
333
- halt_result = if concurrency
334
- handle_concurrent_tool_calls(response.tool_calls)
335
- else
336
- handle_sequential_tool_calls(response.tool_calls)
337
- end
1220
+ def execute_pending_tool_calls(response)
1221
+ raise_if_cancelled!
1222
+
1223
+ server_calls, local_calls = pending_tool_calls(response).partition { |_, call| call.remote? }.map(&:to_h)
1224
+ respond_to_tool_approvals(server_calls)
1225
+ executable, denied = partition_pending_tool_calls(local_calls)
1226
+ deny_tool_calls(denied)
1227
+ if concurrency
1228
+ handle_concurrent_tool_calls(executable)
1229
+ else
1230
+ handle_sequential_tool_calls(executable)
1231
+ end
338
1232
 
339
- reset_tool_choice if forced_tool_choice?
340
- halt_result || complete(&)
1233
+ @tool_prefs[:choice] = nil if forced_tool_choice?
341
1234
  end
342
1235
 
343
- def handle_sequential_tool_calls(tool_calls)
344
- halt_result = nil
1236
+ def respond_to_tool_approvals(tool_calls)
1237
+ tool_calls.each_value do |tool_call|
1238
+ decision = tool_call_approval(nil, tool_call)
1239
+ next if decision.nil?
1240
+
1241
+ raise_if_cancelled!
1242
+ run_callbacks(:before_message)
1243
+ response = @provider.tool_approval_response(tool_call, approved: decision, model: @model, protocol: @protocol)
1244
+ message = add_message(response)
1245
+ run_callbacks(:after_message, message)
1246
+ end
1247
+ end
1248
+
1249
+ def partition_pending_tool_calls(pending)
1250
+ executable = {}
1251
+ denied = {}
1252
+ pending.each do |id, tool_call|
1253
+ tool = tools[tool_call.name.to_sym]
1254
+ if tool&.requires_approval?
1255
+ decision = tool_call_approval(tool, tool_call)
1256
+ next if decision.nil?
1257
+
1258
+ (decision ? executable : denied)[id] = tool_call
1259
+ elsif @tool_call_decisions[tool_call.id] == false
1260
+ denied[id] = tool_call
1261
+ else
1262
+ executable[id] = tool_call
1263
+ end
1264
+ end
1265
+ [executable, denied]
1266
+ end
1267
+
1268
+ def deny_tool_calls(tool_calls)
1269
+ tool_calls.each_value do |tool_call|
1270
+ raise_if_cancelled!
1271
+ run_callbacks(:before_message)
1272
+ add_tool_result_message(tool_call, { error: "The user denied the #{tool_call.name} tool call." })
1273
+ end
1274
+ end
1275
+
1276
+ def record_tool_call_decision(tool_call, decision)
1277
+ id = tool_call.respond_to?(:id) ? tool_call.id : tool_call
1278
+ @tool_call_decisions[id] = decision
1279
+ self
1280
+ end
345
1281
 
1282
+ def approval_pending?(tool_call)
1283
+ return tool_call_approval(nil, tool_call).nil? if tool_call.remote?
1284
+
1285
+ tool = tools[tool_call.name.to_sym]
1286
+ return false unless tool&.requires_approval?
1287
+
1288
+ tool_call_approval(tool, tool_call).nil?
1289
+ end
1290
+
1291
+ def tool_call_approval(tool, tool_call)
1292
+ return tool.approval_resolver.call(tool_call) if tool&.approval_resolver
1293
+ return @tool_call_decisions[tool_call.id] if @tool_call_decisions.key?(tool_call.id)
1294
+
1295
+ @approval_checker&.call(tool_call)
1296
+ end
1297
+
1298
+ def handle_sequential_tool_calls(tool_calls)
346
1299
  tool_calls.each_value do |tool_call|
347
- run_callbacks(:before_message, :new_message)
1300
+ raise_if_cancelled!
1301
+ run_callbacks(:before_message)
348
1302
  result = execute_tool_with_callbacks(tool_call)
349
1303
  add_tool_result_message(tool_call, result)
350
- halt_result = result if result.is_a?(Tool::Halt)
351
1304
  end
352
-
353
- halt_result
354
1305
  end
355
1306
 
356
1307
  def handle_concurrent_tool_calls(tool_calls)
357
- halt_result = nil
358
-
359
1308
  execute_tools_concurrently(tool_calls) do |tool_call, result|
360
- run_callbacks(:before_message, :new_message)
1309
+ raise_if_cancelled!
1310
+ run_callbacks(:before_message)
361
1311
  add_tool_result_message(tool_call, result)
362
- halt_result = result if result.is_a?(Tool::Halt)
363
1312
  end
364
-
365
- halt_result
366
1313
  end
367
1314
 
368
1315
  def execute_tools_concurrently(tool_calls, &on_result)
@@ -372,17 +1319,18 @@ module RubyLLM
372
1319
  end
373
1320
 
374
1321
  def execute_tool_with_callbacks(tool_call)
375
- run_callbacks(:before_tool_call, :tool_call, tool_call)
1322
+ raise_if_cancelled!
1323
+ run_callbacks(:before_tool_call, tool_call)
376
1324
  result = execute_tool tool_call
377
- run_callbacks(:after_tool_result, :tool_result, result)
1325
+ raise_if_cancelled!
1326
+ run_callbacks(:after_tool_result, result)
378
1327
  result
379
1328
  end
380
1329
 
381
1330
  def add_tool_result_message(tool_call, result)
382
- tool_payload = result.is_a?(Tool::Halt) ? result.content : result
383
- content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
384
- message = add_message role: :tool, content:, tool_call_id: tool_call.id
385
- run_callbacks(:after_message, :end_message, message)
1331
+ content, attachments = Tool.split_result(result)
1332
+ message = add_message role: :tool, content:, attachments:, tool_call_id: tool_call.id
1333
+ run_callbacks(:after_message, message)
386
1334
  message
387
1335
  end
388
1336
 
@@ -399,7 +1347,7 @@ module RubyLLM
399
1347
  payload = {
400
1348
  chat: self,
401
1349
  provider: @provider.slug,
402
- provider_class: @provider.class.name,
1350
+ provider_class: @provider.name,
403
1351
  model: @model.id,
404
1352
  model_info: @model,
405
1353
  tool: tool,
@@ -410,16 +1358,18 @@ module RubyLLM
410
1358
  }
411
1359
 
412
1360
  RubyLLM.instrument('tool_call.ruby_llm', payload, config: @config) do |event|
413
- result = tool.call(args)
1361
+ result = tool.call(**args, tool_call: tool_call)
414
1362
  event[:result] = result
415
- event[:result_content] = result.is_a?(Tool::Halt) ? result.content : result
1363
+ event[:result_content] = result
416
1364
  event[:result_class] = result.class.name
417
1365
  result
418
1366
  end
419
1367
  end
420
1368
 
421
- def update_tool_options(choice:, calls:)
422
- unless choice.nil?
1369
+ def apply_tool_choice(choice)
1370
+ if choice.nil?
1371
+ @tool_prefs[:choice] = nil
1372
+ else
423
1373
  normalized_choice = normalize_tool_choice(choice)
424
1374
  valid_tool_choices = %i[auto none required] + tools.keys
425
1375
  unless valid_tool_choices.include?(normalized_choice)
@@ -429,12 +1379,6 @@ module RubyLLM
429
1379
 
430
1380
  @tool_prefs[:choice] = normalized_choice
431
1381
  end
432
-
433
- @tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
434
- end
435
-
436
- def update_tool_concurrency(concurrency)
437
- @concurrency = normalize_tool_concurrency(concurrency)
438
1382
  end
439
1383
 
440
1384
  def normalize_tool_concurrency(concurrency)
@@ -442,11 +1386,11 @@ module RubyLLM
442
1386
  return :threads if concurrency == true
443
1387
 
444
1388
  normalized = concurrency.to_sym
445
- return normalized if ToolConcurrency.supported?(normalized)
1389
+ return normalized if ToolConcurrency::MODES.include?(normalized)
446
1390
 
447
1391
  raise ArgumentError,
448
1392
  "Unknown tool concurrency: #{concurrency.inspect}. " \
449
- "Available modes: #{ToolConcurrency.modes.join(', ')}"
1393
+ "Available modes: #{ToolConcurrency::MODES.join(', ')}"
450
1394
  end
451
1395
 
452
1396
  def normalize_calls(calls)
@@ -470,52 +1414,41 @@ module RubyLLM
470
1414
  def tool_name_for_choice_class(tool_class)
471
1415
  matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
472
1416
  return matched_tool_name if matched_tool_name
1417
+ return tool_class.tool_name.to_sym if tool_class.respond_to?(:tool_name)
473
1418
 
474
- classify_tool_name(tool_class.name)
475
- end
476
-
477
- def classify_tool_name(class_name)
478
- class_name.split('::').last
479
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
480
- .downcase
481
- .to_sym
1419
+ tool_class.name.to_s.to_sym
482
1420
  end
483
1421
 
484
1422
  def forced_tool_choice?
485
1423
  @tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
486
1424
  end
487
1425
 
488
- def reset_tool_choice
489
- @tool_prefs[:choice] = nil
1426
+ def last_non_system_message
1427
+ messages.reverse.find { |message| message.role != :system }
490
1428
  end
491
1429
 
492
- def build_content(message, attachments)
493
- return message if content_like?(message)
494
-
495
- Content.new(message, attachments)
1430
+ def pending_tool_response
1431
+ response = messages.reverse.find { |message| message.role != :system && !message.tool_result? }
1432
+ response if response&.tool_call? && pending_tool_calls(response).any?
496
1433
  end
497
1434
 
498
- def content_like?(object)
499
- object.is_a?(Content) || object.is_a?(Content::Raw)
1435
+ def pending_tool_calls(response)
1436
+ answered = messages.filter_map { |message| message.tool_call_id if message.tool_result? }
1437
+ response.tool_calls.except(*answered)
500
1438
  end
501
1439
 
502
- def append_system_instruction(instructions)
503
- system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
504
- system_messages << Message.new(role: :system, content: instructions)
505
- @messages = system_messages + non_system_messages
1440
+ def inspect_attributes # :nodoc:
1441
+ {
1442
+ model: model.id,
1443
+ provider: provider.slug,
1444
+ messages: messages.count,
1445
+ tools: tools.keys,
1446
+ awaiting_approval: awaiting_approval_names
1447
+ }
506
1448
  end
507
1449
 
508
- def replace_system_instruction(instructions)
509
- system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
510
-
511
- if system_messages.empty?
512
- system_messages = [Message.new(role: :system, content: instructions)]
513
- else
514
- system_messages.first.content = instructions
515
- system_messages = [system_messages.first]
516
- end
517
-
518
- @messages = system_messages + non_system_messages
1450
+ def awaiting_approval_names
1451
+ pending_approvals.map(&:name).uniq
519
1452
  end
520
1453
  end
521
1454
  end