ruby_llm 1.16.0 → 2.0.0.rc2

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