ruby_llm 1.15.0 → 2.0.0.rc1

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 (470) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +87 -33
  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 -6
  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 +25 -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 +100 -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 -136
  100. data/lib/ruby_llm/active_record/attachment_helpers.rb +180 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +823 -305
  103. data/lib/ruby_llm/active_record/message_methods.rb +119 -75
  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 -150
  109. data/lib/ruby_llm/aliases.json +338 -167
  110. data/lib/ruby_llm/attachment.rb +217 -61
  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 +1208 -150
  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 +274 -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 +141 -7
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +161 -89
  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 +155 -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 +43 -14
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +66 -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 +70380 -33380
  137. data/lib/ruby_llm/models.rb +528 -201
  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 +540 -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 +166 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +43 -34
  153. data/lib/ruby_llm/protocols/anthropic.rb +100 -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/protocols/chat_completions/media.rb +121 -0
  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 +17 -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 +685 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +424 -0
  192. data/lib/ruby_llm/protocols/converse.rb +54 -0
  193. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  194. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  195. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  196. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  197. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  198. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  199. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  208. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  209. data/lib/ruby_llm/protocols/files.rb +119 -0
  210. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  211. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  212. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  213. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  214. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  215. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  216. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  217. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  218. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  219. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +33 -20
  220. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  221. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  222. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  223. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  224. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  225. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  226. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  227. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  228. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  229. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  230. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  231. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  232. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  233. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  234. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  235. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  236. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  237. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  238. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  239. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  240. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  242. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  243. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  244. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  248. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  249. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  250. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  251. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  252. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  253. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  254. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  255. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  256. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  257. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  258. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  259. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  261. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  262. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  263. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  264. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  265. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  266. data/lib/ruby_llm/protocols/responses.rb +35 -0
  267. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  268. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  271. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  272. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  273. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  274. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  275. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  276. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  277. data/lib/ruby_llm/provider.rb +565 -124
  278. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  279. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  280. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  281. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  282. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  283. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  284. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  285. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  286. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  287. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  288. data/lib/ruby_llm/providers/azure/media.rb +6 -15
  289. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  290. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  291. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  292. data/lib/ruby_llm/providers/azure.rb +77 -78
  293. data/lib/ruby_llm/providers/bedrock/auth.rb +61 -41
  294. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  295. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  300. data/lib/ruby_llm/providers/bedrock/models.rb +194 -42
  301. data/lib/ruby_llm/providers/bedrock.rb +217 -46
  302. data/lib/ruby_llm/providers/cohere.rb +31 -0
  303. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  304. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -8
  305. data/lib/ruby_llm/providers/deepseek/chat.rb +56 -0
  306. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  307. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  308. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  309. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  310. data/lib/ruby_llm/providers/gemini.rb +15 -8
  311. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -9
  312. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  313. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  314. data/lib/ruby_llm/providers/gpustack/models.rb +72 -60
  315. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  316. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  317. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  318. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  319. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  320. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  321. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  322. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  323. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  324. data/lib/ruby_llm/providers/mistral/media.rb +43 -0
  325. data/lib/ruby_llm/providers/mistral/models.rb +57 -21
  326. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  327. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  328. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  329. data/lib/ruby_llm/providers/mistral.rb +18 -6
  330. data/lib/ruby_llm/providers/ollama/chat.rb +9 -8
  331. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  332. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  333. data/lib/ruby_llm/providers/ollama.rb +9 -8
  334. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  335. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  336. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  337. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  338. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  339. data/lib/ruby_llm/providers/openai.rb +92 -11
  340. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -104
  341. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  342. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  343. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  344. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  345. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  346. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  347. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  348. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  349. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  350. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  351. data/lib/ruby_llm/providers/perplexity/media.rb +46 -0
  352. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  353. data/lib/ruby_llm/providers/perplexity.rb +29 -21
  354. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  355. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  356. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  357. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  359. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  360. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  361. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  362. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  363. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  364. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  365. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  366. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  367. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  368. data/lib/ruby_llm/providers/vertexai.rb +164 -17
  369. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  370. data/lib/ruby_llm/providers/xai/chat.rb +10 -0
  371. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  372. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  373. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  374. data/lib/ruby_llm/providers/xai/models.rb +32 -48
  375. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  376. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  377. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  378. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  379. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  380. data/lib/ruby_llm/providers/xai.rb +17 -7
  381. data/lib/ruby_llm/railtie.rb +11 -16
  382. data/lib/ruby_llm/rerank.rb +105 -0
  383. data/lib/ruby_llm/research_job.rb +241 -0
  384. data/lib/ruby_llm/search_results.rb +68 -0
  385. data/lib/ruby_llm/server_tool_call.rb +73 -0
  386. data/lib/ruby_llm/speech.rb +159 -0
  387. data/lib/ruby_llm/speech_chunk.rb +33 -0
  388. data/lib/ruby_llm/support/deprecator.rb +22 -0
  389. data/lib/ruby_llm/support/inspectable.rb +49 -0
  390. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  391. data/lib/ruby_llm/support/utils.rb +147 -0
  392. data/lib/ruby_llm/thinking.rb +127 -20
  393. data/lib/ruby_llm/tokenization.rb +59 -0
  394. data/lib/ruby_llm/tokens.rb +103 -33
  395. data/lib/ruby_llm/tool.rb +266 -91
  396. data/lib/ruby_llm/tool_call.rb +36 -3
  397. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  398. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  399. data/lib/ruby_llm/transcription.rb +139 -13
  400. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  401. data/lib/ruby_llm/transport/connection.rb +193 -0
  402. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  403. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  404. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  405. data/lib/ruby_llm/uploaded_file.rb +144 -0
  406. data/lib/ruby_llm/version.rb +2 -1
  407. data/lib/ruby_llm/video.rb +136 -0
  408. data/lib/ruby_llm/video_job.rb +150 -0
  409. data/lib/ruby_llm/workflow.rb +91 -0
  410. data/lib/ruby_llm.rb +385 -4
  411. data/lib/tasks/ruby_llm.rake +21 -16
  412. data/skills/rubyllm/SKILL.md +81 -0
  413. data/skills/rubyllm/agents/openai.yaml +4 -0
  414. metadata +340 -92
  415. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  416. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  417. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  418. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  419. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  422. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  424. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  426. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  428. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -530
  429. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  430. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  431. data/lib/ruby_llm/aliases.rb +0 -38
  432. data/lib/ruby_llm/connection.rb +0 -130
  433. data/lib/ruby_llm/content.rb +0 -77
  434. data/lib/ruby_llm/mime_type.rb +0 -71
  435. data/lib/ruby_llm/model/info.rb +0 -130
  436. data/lib/ruby_llm/models_schema.json +0 -171
  437. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -257
  438. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  439. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  440. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  441. data/lib/ruby_llm/providers/anthropic/models.rb +0 -57
  442. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -69
  443. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -403
  444. data/lib/ruby_llm/providers/bedrock/media.rb +0 -90
  445. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -322
  446. data/lib/ruby_llm/providers/gemini/chat.rb +0 -543
  447. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  448. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  449. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  450. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -96
  451. data/lib/ruby_llm/providers/gemini/tools.rb +0 -232
  452. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  453. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  454. data/lib/ruby_llm/providers/openai/chat.rb +0 -221
  455. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  456. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  457. data/lib/ruby_llm/providers/openai/media.rb +0 -84
  458. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  459. data/lib/ruby_llm/providers/openai/streaming.rb +0 -53
  460. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  461. data/lib/ruby_llm/providers/openai/transcription.rb +0 -70
  462. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  463. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  464. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  465. data/lib/ruby_llm/stream_accumulator.rb +0 -203
  466. data/lib/ruby_llm/streaming.rb +0 -175
  467. data/lib/ruby_llm/utils.rb +0 -91
  468. data/lib/tasks/models.rake +0 -565
  469. data/lib/tasks/release.rake +0 -67
  470. data/lib/tasks/vcr.rake +0 -124
data/lib/ruby_llm/chat.rb CHANGED
@@ -1,220 +1,938 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module RubyLLM
4
- # Represents a conversation with an AI model
5
- 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
6
25
  include Enumerable
26
+ include Support::Inspectable
27
+
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
32
+
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
7
83
 
8
- attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
84
+ # Whether #with_citations asked the provider for citations.
85
+ attr_reader :citations
9
86
 
10
- def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
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)
11
110
  if assume_model_exists && !provider
12
111
  raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
13
112
  end
14
113
 
15
114
  @context = context
16
115
  @config = context&.config || RubyLLM.config
17
- model_id = model || @config.default_model
18
- 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)
19
117
  @temperature = nil
118
+ @max_output_tokens = nil
20
119
  @messages = []
120
+ @usage_entries = []
21
121
  @tools = {}
122
+ @server_tools = []
22
123
  @tool_prefs = { choice: nil, calls: nil }
23
- @params = {}
124
+ @concurrency = normalize_tool_concurrency(@config.tool_concurrency)
125
+ @provider_options = {}
24
126
  @headers = {}
25
127
  @schema = nil
26
128
  @thinking = nil
27
- @on = {
28
- new_message: nil,
29
- end_message: nil,
30
- tool_call: nil,
31
- tool_result: nil
32
- }
129
+ @citations = false
130
+ @caching = nil
131
+ @compaction = nil
132
+ @end_user = nil
133
+ @fallbacks = []
134
+ @fallback_errors = Fallback::DEFAULT_ERRORS
33
135
  @callbacks = Hash.new { |callbacks, name| callbacks[name] = [] }
136
+ @cancelled = false
137
+ @cancellation_checker = nil
138
+ @tool_call_decisions = {}
139
+ @approval_checker = nil
34
140
  end
35
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
+ #
36
153
  def ask(message = nil, with: nil, &)
37
- add_message role: :user, content: build_content(message, with)
154
+ ask_later(message, with: with)
38
155
  complete(&)
39
156
  end
40
157
 
41
158
  alias say ask
42
159
 
43
- def with_instructions(instructions, append: false, replace: nil)
44
- 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
45
176
 
46
- if append
47
- append_system_instruction(instructions)
48
- else
49
- 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?
50
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
51
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
52
301
  self
53
302
  end
54
303
 
55
- def with_tool(tool, choice: nil, calls: nil)
56
- unless tool.nil?
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
323
+ self
324
+ end
325
+
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|
57
340
  tool_instance = tool.is_a?(Class) ? tool.new : tool
58
341
  @tools[tool_instance.name.to_sym] = tool_instance
59
342
  end
60
- update_tool_options(choice:, calls:)
61
343
  self
62
344
  end
63
345
 
64
- def with_tools(*tools, replace: false, choice: nil, calls: nil)
65
- @tools.clear if replace
66
- tools.compact.each { |tool| with_tool tool }
67
- update_tool_options(choice:, calls:)
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)
372
+ self
373
+ end
374
+
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
68
397
  self
69
398
  end
70
399
 
71
- def with_model(model_id, provider: nil, assume_exists: false)
72
- @model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
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)
73
420
  @connection = @provider.connection
421
+ @protocol = protocol
422
+ self
423
+ end
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
74
437
  self
75
438
  end
76
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
+ #
77
445
  def with_temperature(temperature)
78
446
  @temperature = temperature
79
447
  self
80
448
  end
81
449
 
82
- def with_thinking(effort: nil, budget: nil)
83
- raise ArgumentError, 'with_thinking requires :effort or :budget' if effort.nil? && budget.nil?
84
-
85
- @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
86
458
  self
87
459
  end
88
460
 
89
- def with_context(context)
90
- @context = context
91
- @config = context.config
92
- 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
93
494
  self
94
495
  end
95
496
 
96
- def with_params(**params)
97
- @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
98
523
  self
99
524
  end
100
525
 
101
- def with_headers(**headers)
102
- @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
103
547
  self
104
548
  end
105
549
 
106
- def with_schema(schema)
107
- 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
108
579
 
109
- @schema = normalize_schema_payload(
110
- schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
111
- )
580
+ @compaction = options == false ? false : normalize_compaction(options)
581
+ self
582
+ end
112
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
113
595
  self
114
596
  end
115
597
 
116
- def on_new_message(&)
117
- 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
118
606
  end
119
607
 
120
- def on_end_message(&)
121
- 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
122
617
  end
123
618
 
124
- def on_tool_call(&)
125
- 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
126
627
  end
127
628
 
128
- def on_tool_result(&)
129
- 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
130
652
  end
131
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+.
132
657
  def before_message(&)
133
658
  add_callback(:before_message, &)
134
659
  end
135
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
+ #
136
666
  def after_message(&)
137
667
  add_callback(:after_message, &)
138
668
  end
139
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
+ #
140
675
  def before_tool_call(&)
141
676
  add_callback(:before_tool_call, &)
142
677
  end
143
678
 
679
+ # Registers a callback that receives each local tool's result after
680
+ # execution. Returns +self+.
144
681
  def after_tool_result(&)
145
682
  add_callback(:after_tool_result, &)
146
683
  end
147
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.
148
711
  def each(&)
149
712
  messages.each(&)
150
713
  end
151
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
+ #
152
729
  def cost
153
- Cost.aggregate(messages.map(&:cost))
730
+ Cost.aggregate(usage_entries.map(&:cost), complete: usage_entries.all?(&:cost_available?))
154
731
  end
155
732
 
156
- def complete(&)
157
- response = @provider.complete(
158
- messages,
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,
159
750
  tools: @tools,
160
751
  tool_prefs: @tool_prefs,
161
- temperature: @temperature,
162
- model: @model,
163
- params: @params,
164
- headers: @headers,
752
+ thinking: resolved_thinking,
165
753
  schema: @schema,
166
- thinking: @thinking,
167
- &wrap_streaming_block(&)
754
+ citations: @citations,
755
+ caching: @caching,
756
+ protocol: @protocol
168
757
  )
758
+ end
169
759
 
170
- run_callbacks(:before_message, :new_message) unless block_given?
171
-
172
- if @schema && response.content.is_a?(String) && !response.tool_call?
173
- begin
174
- response.content = JSON.parse(response.content)
175
- rescue JSON::ParserError
176
- # If parsing fails, keep content as string
177
- end
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
178
784
  end
785
+ end
179
786
 
180
- add_message response
181
- run_callbacks(:after_message, :end_message, response)
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
182
793
 
183
- if response.tool_call?
184
- handle_tool_calls(response, &)
185
- else
186
- response
187
- end
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)
188
798
  end
189
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
+ #
190
809
  def add_message(message_or_attributes)
191
- message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
810
+ message = coerce_message(message_or_attributes)
192
811
  messages << message
193
812
  message
194
813
  end
195
814
 
196
- def reset_messages!
197
- @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
198
825
  end
199
826
 
200
- def instance_variables
201
- super - %i[@connection @config]
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
+ )
863
+ end
864
+
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.'
202
877
  end
203
878
 
204
879
  private
205
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
+
206
923
  def normalize_schema_payload(raw_schema)
207
924
  return nil if raw_schema.nil?
208
925
  return raw_schema unless raw_schema.is_a?(Hash)
209
926
 
210
- schema = RubyLLM::Utils.deep_symbolize_keys(raw_schema)
927
+ schema = RubyLLM::Support::Utils.deep_symbolize_keys(raw_schema)
211
928
  schema_def = extract_schema_definition(schema)
212
929
  strict = extract_schema_strict(schema, schema_def)
213
930
  build_schema_payload(schema, schema_def, strict)
214
931
  end
215
932
 
216
933
  def extract_schema_definition(schema)
217
- 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)
218
936
  end
219
937
 
220
938
  def extract_schema_strict(schema, schema_def)
@@ -226,9 +944,9 @@ module RubyLLM
226
944
 
227
945
  def build_schema_payload(schema, schema_def, strict)
228
946
  {
229
- name: sanitize_schema_name(schema[:name] || 'response'),
947
+ name: sanitize_schema_name(schema[:name] || schema[:title] || 'response'),
230
948
  schema: schema_def,
231
- strict: strict.nil? || strict,
949
+ strict: strict,
232
950
  description: schema[:description]
233
951
  }.compact
234
952
  end
@@ -243,53 +961,373 @@ module RubyLLM
243
961
  self
244
962
  end
245
963
 
246
- def set_legacy_callback(name, legacy_name, additive_name, &block)
247
- warn_legacy_callback_deprecation(legacy_name, additive_name) if block
964
+ def raise_if_cancelled!
965
+ external_cancelled = @cancellation_checker&.call
966
+ return unless @cancelled || external_cancelled
967
+
968
+ @cancelled = false
969
+ raise CancelledError
970
+ end
971
+
972
+ def generate_once(stream_tracker: nil, usage_start: nil, &block)
973
+ raise_if_cancelled!
974
+
975
+ result = nil
976
+ entries_before = usage_entries.length
977
+ usage_start ||= entries_before
978
+ payload = instrumentation_payload(streaming: block_given?)
979
+
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)
985
+ end
986
+ result
987
+ end
988
+
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
+ {
1000
+ chat: self,
1001
+ provider: @provider.slug,
1002
+ provider_class: @provider.class.display_name,
1003
+ model: @model.id,
1004
+ model_info: @model,
1005
+ input_messages: messages.dup,
1006
+ message_count: messages.size,
1007
+ tools: tools.keys,
1008
+ server_tools: server_tools,
1009
+ tool_choice: tool_prefs[:choice],
1010
+ tool_call_limit: tool_prefs[:calls],
1011
+ temperature: @temperature,
1012
+ max_output_tokens: @max_output_tokens,
1013
+ provider_options: provider_options,
1014
+ schema: schema,
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)
1021
+ }
1022
+ end
248
1023
 
249
- @on[name] = block
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
+ )
1062
+ end
1063
+ end
1064
+ end
1065
+
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
250
1096
  self
251
1097
  end
252
1098
 
253
- def warn_legacy_callback_deprecation(legacy_name, additive_name)
254
- RubyLLM.logger.warn(
255
- "`#{legacy_name}` is deprecated and will be removed in RubyLLM 2.0. " \
256
- "Use `#{additive_name}` instead."
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
+
1143
+ @provider.complete(
1144
+ preprocessed_messages,
1145
+ tools: @tools,
1146
+ server_tools: @server_tools,
1147
+ tool_prefs: @tool_prefs,
1148
+ temperature: @temperature,
1149
+ max_output_tokens: @max_output_tokens,
1150
+ model: @model,
1151
+ provider_options: Support::Utils.deep_dup(@provider_options),
1152
+ headers: @headers,
1153
+ schema: @schema,
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:, &)
1163
+ )
1164
+ end
1165
+
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)
257
1171
  )
258
1172
  end
259
1173
 
260
- def run_callbacks(name, legacy_name, *args)
1174
+ def record_usage_entry(entry)
1175
+ usage_entries << entry
1176
+ @usage_recorder&.call(entry)
1177
+ entry
1178
+ end
1179
+
1180
+ def link_completion_usage(response, usage_start)
1181
+ response.ruby_llm_usage_entries = usage_entries.drop(usage_start)
1182
+ end
1183
+
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
1193
+ )
1194
+ response.ruby_llm_usage_entries = [entry]
1195
+ Accounting::Usage.instrument(entry, config: @config)
1196
+ record_usage_entry(entry)
1197
+ end
1198
+
1199
+ def run_callbacks(name, *args)
261
1200
  @callbacks[name].each { |callback| callback.call(*args) }
262
- @on[legacy_name]&.call(*args)
263
1201
  end
264
1202
 
265
- def wrap_streaming_block(&block)
266
- return nil unless block_given?
1203
+ def wrap_streaming_block(stream_tracker: nil, &block)
1204
+ return nil unless block
267
1205
 
268
- run_callbacks(:before_message, :new_message)
1206
+ run_callbacks(:before_message)
269
1207
 
270
1208
  proc do |chunk|
271
- block.call chunk
1209
+ raise_if_cancelled!
1210
+ stream_tracker&.call(chunk)
1211
+ block.call(chunk)
1212
+ raise_if_cancelled!
272
1213
  end
273
1214
  end
274
1215
 
275
- def handle_tool_calls(response, &)
276
- halt_result = nil
1216
+ def execute_pending_tool_calls(response)
1217
+ raise_if_cancelled!
277
1218
 
278
- response.tool_calls.each_value do |tool_call|
279
- run_callbacks(:before_message, :new_message)
280
- run_callbacks(:before_tool_call, :tool_call, tool_call)
281
- result = execute_tool tool_call
282
- run_callbacks(:after_tool_result, :tool_result, result)
283
- tool_payload = result.is_a?(Tool::Halt) ? result.content : result
284
- content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
285
- message = add_message role: :tool, content:, tool_call_id: tool_call.id
286
- run_callbacks(:after_message, :end_message, message)
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
1228
+
1229
+ @tool_prefs[:choice] = nil if forced_tool_choice?
1230
+ end
287
1231
 
288
- halt_result = result if result.is_a?(Tool::Halt)
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." })
289
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
290
1277
 
291
- reset_tool_choice if forced_tool_choice?
292
- halt_result || complete(&)
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)
1295
+ tool_calls.each_value do |tool_call|
1296
+ raise_if_cancelled!
1297
+ run_callbacks(:before_message)
1298
+ result = execute_tool_with_callbacks(tool_call)
1299
+ add_tool_result_message(tool_call, result)
1300
+ end
1301
+ end
1302
+
1303
+ def handle_concurrent_tool_calls(tool_calls)
1304
+ execute_tools_concurrently(tool_calls) do |tool_call, result|
1305
+ raise_if_cancelled!
1306
+ run_callbacks(:before_message)
1307
+ add_tool_result_message(tool_call, result)
1308
+ end
1309
+ end
1310
+
1311
+ def execute_tools_concurrently(tool_calls, &on_result)
1312
+ ToolConcurrency.run(concurrency, tool_calls, on_result:) do |tool_call|
1313
+ execute_tool_with_callbacks(tool_call)
1314
+ end
1315
+ end
1316
+
1317
+ def execute_tool_with_callbacks(tool_call)
1318
+ raise_if_cancelled!
1319
+ run_callbacks(:before_tool_call, tool_call)
1320
+ result = execute_tool tool_call
1321
+ raise_if_cancelled!
1322
+ run_callbacks(:after_tool_result, result)
1323
+ result
1324
+ end
1325
+
1326
+ def add_tool_result_message(tool_call, result)
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)
1330
+ message
293
1331
  end
294
1332
 
295
1333
  def execute_tool(tool_call)
@@ -302,11 +1340,32 @@ module RubyLLM
302
1340
  end
303
1341
 
304
1342
  args = tool_call.arguments
305
- tool.call(args)
1343
+ payload = {
1344
+ chat: self,
1345
+ provider: @provider.slug,
1346
+ provider_class: @provider.class.display_name,
1347
+ model: @model.id,
1348
+ model_info: @model,
1349
+ tool: tool,
1350
+ tool_call: tool_call,
1351
+ tool_name: tool.name,
1352
+ tool_arguments: args,
1353
+ tool_call_id: tool_call.id
1354
+ }
1355
+
1356
+ RubyLLM.instrument('tool_call.ruby_llm', payload, config: @config) do |event|
1357
+ result = tool.call(**args, tool_call: tool_call)
1358
+ event[:result] = result
1359
+ event[:result_content] = result
1360
+ event[:result_class] = result.class.name
1361
+ result
1362
+ end
306
1363
  end
307
1364
 
308
- def update_tool_options(choice:, calls:)
309
- unless choice.nil?
1365
+ def apply_tool_choice(choice)
1366
+ if choice.nil?
1367
+ @tool_prefs[:choice] = nil
1368
+ else
310
1369
  normalized_choice = normalize_tool_choice(choice)
311
1370
  valid_tool_choices = %i[auto none required] + tools.keys
312
1371
  unless valid_tool_choices.include?(normalized_choice)
@@ -316,8 +1375,18 @@ module RubyLLM
316
1375
 
317
1376
  @tool_prefs[:choice] = normalized_choice
318
1377
  end
1378
+ end
1379
+
1380
+ def normalize_tool_concurrency(concurrency)
1381
+ return nil if concurrency.nil? || concurrency == false
1382
+ return :threads if concurrency == true
319
1383
 
320
- @tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
1384
+ normalized = concurrency.to_sym
1385
+ return normalized if ToolConcurrency::MODES.include?(normalized)
1386
+
1387
+ raise ArgumentError,
1388
+ "Unknown tool concurrency: #{concurrency.inspect}. " \
1389
+ "Available modes: #{ToolConcurrency::MODES.join(', ')}"
321
1390
  end
322
1391
 
323
1392
  def normalize_calls(calls)
@@ -341,52 +1410,41 @@ module RubyLLM
341
1410
  def tool_name_for_choice_class(tool_class)
342
1411
  matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
343
1412
  return matched_tool_name if matched_tool_name
1413
+ return tool_class.tool_name.to_sym if tool_class.respond_to?(:tool_name)
344
1414
 
345
- classify_tool_name(tool_class.name)
346
- end
347
-
348
- def classify_tool_name(class_name)
349
- class_name.split('::').last
350
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
351
- .downcase
352
- .to_sym
1415
+ tool_class.name.to_s.to_sym
353
1416
  end
354
1417
 
355
1418
  def forced_tool_choice?
356
1419
  @tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
357
1420
  end
358
1421
 
359
- def reset_tool_choice
360
- @tool_prefs[:choice] = nil
1422
+ def last_non_system_message
1423
+ messages.reverse.find { |message| message.role != :system }
361
1424
  end
362
1425
 
363
- def build_content(message, attachments)
364
- return message if content_like?(message)
365
-
366
- 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?
367
1429
  end
368
1430
 
369
- def content_like?(object)
370
- 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)
371
1434
  end
372
1435
 
373
- def append_system_instruction(instructions)
374
- system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
375
- system_messages << Message.new(role: :system, content: instructions)
376
- @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
+ }
377
1444
  end
378
1445
 
379
- def replace_system_instruction(instructions)
380
- system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
381
-
382
- if system_messages.empty?
383
- system_messages = [Message.new(role: :system, content: instructions)]
384
- else
385
- system_messages.first.content = instructions
386
- system_messages = [system_messages.first]
387
- end
388
-
389
- @messages = system_messages + non_system_messages
1446
+ def awaiting_approval_names
1447
+ pending_approvals.map(&:name).uniq
390
1448
  end
391
1449
  end
392
1450
  end