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
@@ -1,484 +1,1002 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/concern'
4
+ require 'ruby_llm/active_record/attachment_helpers'
4
5
 
5
6
  module RubyLLM
6
7
  module ActiveRecord
7
- # Methods mixed into chat models.
8
+ # ChatMethods provides the RubyLLM::Chat API on ActiveRecord models
9
+ # declared with <tt>acts_as_chat</tt>, persisting every message to the
10
+ # database. Configuration methods return +self+ so calls can be chained.
11
+ #
12
+ # class Chat < ApplicationRecord
13
+ # acts_as_chat
14
+ # end
15
+ #
16
+ # chat = Chat.create!(model: 'gpt-5.6-luna')
17
+ # chat.ask "What is the capital of France?"
18
+ # chat.messages.count # => 2
19
+ #
8
20
  module ChatMethods
9
21
  extend ActiveSupport::Concern
22
+ include Enumerable
23
+ include AttachmentHelpers
24
+
25
+ CANCELLATION_POLL_INTERVAL = 1.0 # :nodoc:
26
+ COMPLETION_ERRORS = [ # :nodoc:
27
+ RubyLLM::CancelledError, RubyLLM::Error, Faraday::Error, Timeout::Error, Errno::ETIMEDOUT
28
+ ].freeze
10
29
 
11
30
  included do
12
- before_save :resolve_model_from_strings
31
+ before_save :resolve_model
13
32
  end
14
33
 
15
- attr_accessor :assume_model_exists, :context
34
+ # When +true+, skips the model registry lookup so unregistered model ids
35
+ # are accepted. Not persisted; set it again after reloading the record.
36
+ attr_accessor :assume_model_exists
16
37
 
17
- def model=(value)
18
- @model_string = value if value.is_a?(String)
19
- return if value.is_a?(String)
38
+ # Overrides the wire protocol the provider would pick for the model, such
39
+ # as +:responses+ or +:chat_completions+ for OpenAI, or +nil+ for the
40
+ # provider default. Not persisted; set it again after reloading the record.
41
+ attr_accessor :protocol
42
+
43
+ # An optional RubyLLM::Context supplying per-chat configuration, used
44
+ # when building the underlying chat. Not persisted; set it again after
45
+ # reloading the record.
46
+ attr_accessor :context
47
+
48
+ # Requests cancellation of the current in-flight chat operation. The
49
+ # request is persisted so a background job can observe it from another
50
+ # process.
51
+ def cancel
52
+ if persisted?
53
+ update_column(:cancelled, true)
54
+ else
55
+ self[:cancelled] = true
56
+ end
20
57
 
21
- if self.class.model_association_name == :model
58
+ @chat&.cancel
59
+ self
60
+ end
61
+
62
+ # Returns whether this record or its memoized in-memory chat has a
63
+ # pending cancellation request.
64
+ def cancelled?
65
+ @chat&.cancelled? || self[:cancelled]
66
+ end
67
+
68
+ # Records approval for +tool_call+ (a ToolCall, a tool call record, or
69
+ # an id) on the persisted tool call, so the next #complete executes it
70
+ # from any process. Returns +self+.
71
+ #
72
+ # chat.approve(params[:tool_call_id])
73
+ # CompleteJob.perform_later(chat.id)
74
+ #
75
+ def approve(tool_call)
76
+ record_tool_call_decision(tool_call, 'approved')
77
+ end
78
+
79
+ # Records denial for +tool_call+ (a ToolCall, a tool call record, or
80
+ # an id) on the persisted tool call. The next #complete appends a
81
+ # structured denial result instead of executing the tool. Returns
82
+ # +self+.
83
+ def deny(tool_call)
84
+ record_tool_call_decision(tool_call, 'denied')
85
+ end
86
+
87
+ # Returns whether the conversation is waiting on tool calls that
88
+ # require approval and have no recorded decision. See
89
+ # RubyLLM::Chat#awaiting_approval?.
90
+ def awaiting_approval?
91
+ to_llm.awaiting_approval?
92
+ end
93
+
94
+ # Returns the persisted tool call records that require approval and
95
+ # have no recorded decision, ready to render as approval cards. Pass
96
+ # a record (or its tool_call_id) to #approve or #deny.
97
+ # A record's +remote?+ identifies a provider-executed call.
98
+ #
99
+ # chat.pending_approvals.each { |record| render record }
100
+ #
101
+ def pending_approvals
102
+ ids = to_llm.pending_approvals.map(&:id)
103
+ RubyLLM::ActiveRecord::ToolCall.where(
104
+ tool_call_id: ids,
105
+ message_type: self.class.message_class.constantize.polymorphic_name,
106
+ message_id: messages_association.select(:id)
107
+ )
108
+ end
109
+
110
+ def messages_association # :nodoc:
111
+ send(messages_association_name)
112
+ end
113
+
114
+ # Sets the chat's model from an id, a RubyLLM::Model value, or the
115
+ # associated internal model record.
116
+ #
117
+ # chat.model = 'gpt-5.6-luna'
118
+ #
119
+ def model=(value)
120
+ if value.is_a?(RubyLLM::ActiveRecord::Model)
121
+ @pending_model_id = nil
122
+ @pending_provider = nil
22
123
  super
23
124
  else
24
- self.model_association = value
125
+ @pending_model_id = value.respond_to?(:id) ? value.id : value
126
+ @pending_provider = value.provider if value.respond_to?(:provider)
25
127
  end
26
128
  end
27
129
 
130
+ # Stores +value+ as the model id, resolved to a model record before save.
28
131
  def model_id=(value)
29
- @model_string = value
132
+ @pending_model_id = value
30
133
  end
31
134
 
135
+ # Returns the model id of the associated model record, or +nil+.
136
+ #
137
+ # chat.model_id # => "gpt-5.6-luna"
138
+ #
32
139
  def model_id
33
- model_association&.model_id
140
+ model&.model_id || @pending_model_id
34
141
  end
35
142
 
143
+ # Stores +value+ as the provider used when resolving the model id
144
+ # before save.
36
145
  def provider=(value)
37
- @provider_string = value
146
+ @pending_provider = value
38
147
  end
39
148
 
149
+ # Returns the provider of the associated model record, or +nil+.
40
150
  def provider
41
- model_association&.provider
42
- end
43
-
44
- private
45
-
46
- def resolve_model_from_strings # rubocop:disable Metrics/PerceivedComplexity
47
- config = context&.config || RubyLLM.config
48
- @model_string ||= config.default_model unless model_association
49
- return unless @model_string
50
-
51
- model_info, _provider = Models.resolve(
52
- @model_string,
53
- provider: @provider_string,
54
- assume_exists: assume_model_exists || false,
55
- config: config
56
- )
57
-
58
- model_class = self.class.model_class.constantize
59
- model_record = model_class.find_or_create_by!(
60
- model_id: model_info.id,
61
- provider: model_info.provider
62
- ) do |m|
63
- m.name = model_info.name || model_info.id
64
- m.family = model_info.family
65
- m.context_window = model_info.context_window
66
- m.max_output_tokens = model_info.max_output_tokens
67
- m.capabilities = model_info.capabilities || []
68
- m.modalities = model_info.modalities || {}
69
- m.pricing = model_info.pricing || {}
70
- m.metadata = model_info.metadata || {}
71
- end
72
-
73
- self.model_association = model_record
74
- @model_string = nil
75
- @provider_string = nil
151
+ model&.provider || @pending_provider
76
152
  end
77
153
 
78
- public
79
-
154
+ # Returns the underlying RubyLLM::Chat for this record, building it on
155
+ # first call and memoizing it. The chat is loaded with the persisted
156
+ # messages and wired to persist new ones. Subsequent calls return the
157
+ # same chat without touching the database; use #reload to refresh its
158
+ # message history from the record.
80
159
  def to_llm
81
- model_record = model_association
82
- @chat ||= (context || RubyLLM).chat(
83
- model: model_record.model_id,
84
- provider: model_record.provider.to_sym,
85
- assume_model_exists: assume_model_exists || false
86
- )
87
- @chat.reset_messages!
88
-
89
- ordered_messages = order_messages_for_llm(messages_association.to_a)
90
- ordered_messages.each do |msg|
91
- @chat.add_message(msg.to_llm)
92
- end
93
- reapply_runtime_instructions(@chat)
94
-
95
- setup_persistence_callbacks
160
+ @chat ||= build_llm_chat # rubocop:disable Naming/MemoizedInstanceVariableName
96
161
  end
97
162
 
98
- def with_instructions(instructions, append: false, replace: nil)
99
- append = append_instructions?(append:, replace:)
100
- persist_system_instruction(instructions, append:)
163
+ # Reloads the record from the database, Rails-style, and refreshes the
164
+ # underlying chat's persisted message history to match. Runtime-only
165
+ # configuration such as tools, temperature, and callbacks is preserved.
166
+ # Returns +self+.
167
+ def reload(...)
168
+ super
169
+ sync_messages if @chat
170
+ self
171
+ end
101
172
 
102
- to_llm.with_instructions(instructions, append:, replace:)
173
+ # Rebinds the underlying chat to +value+ so subsequent requests use its
174
+ # configuration. Pass +nil+ to return to the global RubyLLM
175
+ # configuration. The Context itself is runtime-only and is not
176
+ # persisted.
177
+ def with_context(value)
178
+ self.context = value
179
+ @chat&.with_context(value)
103
180
  self
104
181
  end
105
182
 
106
- def with_runtime_instructions(instructions, append: false, replace: nil)
107
- append = append_instructions?(append:, replace:)
108
- store_runtime_instruction(instructions, append:)
183
+ # Sets the system instructions, persisting them as a message with the
184
+ # +:system+ role. Replaces any persisted system messages unless
185
+ # +append:+ is true. Pass <tt>persist: false</tt> to apply the
186
+ # instructions only to the in-memory chat for this record instance.
187
+ # With <tt>cache_until_here: true</tt> the instruction becomes an
188
+ # explicit prompt cache boundary. Returns +self+.
189
+ #
190
+ # chat.with_instructions "You are a Ruby expert."
191
+ # chat.with_instructions "Use short bullet points.", append: true
192
+ # chat.with_instructions current_context, persist: false
193
+ #
194
+ def with_instructions(instructions, append: false, persist: true, cache_until_here: false)
195
+ to_llm
196
+
197
+ if persist
198
+ if instructions.nil?
199
+ clear_persisted_system_instructions
200
+ else
201
+ persist_system_instruction(instructions, append:, cache_until_here:)
202
+ end
203
+ else
204
+ store_unpersisted_instruction(instructions, append:, cache_until_here:)
205
+ end
109
206
 
110
- to_llm.with_instructions(instructions, append:, replace:)
207
+ sync_messages
111
208
  self
112
209
  end
113
210
 
114
- def with_tool(...)
115
- to_llm.with_tool(...)
116
- self
211
+ # Chat configuration and callback methods forwarded to the underlying
212
+ # RubyLLM::Chat. Each behaves exactly as documented on RubyLLM::Chat,
213
+ # then returns the record so calls chain.
214
+ CHAINABLE_CHAT_DELEGATES = %i[
215
+ with_tools with_tool_options with_server_tools with_fallbacks with_temperature
216
+ with_max_output_tokens with_thinking with_citations with_caching
217
+ with_end_user with_compaction
218
+ with_provider_options with_headers with_schema
219
+ before_request before_message after_message before_tool_call after_tool_result
220
+ before_fallback after_fallback
221
+ ].freeze
222
+
223
+ ##
224
+ # :method: with_tools
225
+ # :call-seq: with_tools(*tools)
226
+ #
227
+ # Applies Chat#with_tools and returns this record.
228
+
229
+ ##
230
+ # :method: with_tool_options
231
+ # :call-seq: with_tool_options(**options)
232
+ #
233
+ # Applies Chat#with_tool_options and returns this record.
234
+
235
+ ##
236
+ # :method: with_server_tools
237
+ # :call-seq: with_server_tools(*tools, **tools_with_options)
238
+ #
239
+ # Applies Chat#with_server_tools and returns this record.
240
+
241
+ ##
242
+ # :method: with_fallbacks
243
+ # :call-seq: with_fallbacks(*models, on: Fallback::DEFAULT_ERRORS)
244
+ #
245
+ # Applies Chat#with_fallbacks and returns this record.
246
+
247
+ ##
248
+ # :method: with_temperature
249
+ # :call-seq: with_temperature(temperature)
250
+ #
251
+ # Applies Chat#with_temperature and returns this record.
252
+
253
+ ##
254
+ # :method: with_max_output_tokens
255
+ # :call-seq: with_max_output_tokens(max_output_tokens)
256
+ #
257
+ # Applies Chat#with_max_output_tokens and returns this record.
258
+
259
+ ##
260
+ # :method: with_thinking
261
+ # :call-seq: with_thinking(enabled = true, **options)
262
+ #
263
+ # Applies Chat#with_thinking and returns this record.
264
+
265
+ ##
266
+ # :method: with_citations
267
+ # :call-seq: with_citations(enabled = true)
268
+ #
269
+ # Applies Chat#with_citations and returns this record.
270
+
271
+ ##
272
+ # :method: with_caching
273
+ # :call-seq: with_caching(options = {})
274
+ #
275
+ # Applies Chat#with_caching and returns this record.
276
+
277
+ ##
278
+ # :method: with_end_user
279
+ # :call-seq: with_end_user(end_user)
280
+ #
281
+ # Applies Chat#with_end_user and returns this record.
282
+
283
+ ##
284
+ # :method: with_compaction
285
+ # :call-seq: with_compaction(options = {})
286
+ #
287
+ # Applies Chat#with_compaction and returns this record.
288
+
289
+ ##
290
+ # :method: with_provider_options
291
+ # :call-seq: with_provider_options(provider_options)
292
+ #
293
+ # Applies Chat#with_provider_options and returns this record.
294
+
295
+ ##
296
+ # :method: with_headers
297
+ # :call-seq: with_headers(headers)
298
+ #
299
+ # Applies Chat#with_headers and returns this record.
300
+
301
+ ##
302
+ # :method: with_schema
303
+ # :call-seq: with_schema(schema)
304
+ #
305
+ # Applies Chat#with_schema and returns this record.
306
+
307
+ ##
308
+ # :method: before_request
309
+ # :call-seq: before_request(&block)
310
+ #
311
+ # Applies Chat#before_request and returns this record.
312
+
313
+ ##
314
+ # :method: before_message
315
+ # :call-seq: before_message(&block)
316
+ #
317
+ # Applies Chat#before_message and returns this record.
318
+
319
+ ##
320
+ # :method: after_message
321
+ # :call-seq: after_message(&block)
322
+ #
323
+ # Applies Chat#after_message and returns this record.
324
+
325
+ ##
326
+ # :method: before_tool_call
327
+ # :call-seq: before_tool_call(&block)
328
+ #
329
+ # Applies Chat#before_tool_call and returns this record.
330
+
331
+ ##
332
+ # :method: after_tool_result
333
+ # :call-seq: after_tool_result(&block)
334
+ #
335
+ # Applies Chat#after_tool_result and returns this record.
336
+
337
+ ##
338
+ # :method: before_fallback
339
+ # :call-seq: before_fallback(&block)
340
+ #
341
+ # Applies Chat#before_fallback and returns this record.
342
+
343
+ ##
344
+ # :method: after_fallback
345
+ # :call-seq: after_fallback(&block)
346
+ #
347
+ # Applies Chat#after_fallback and returns this record.
348
+
349
+ ##
350
+ # :method: caching
351
+ # :call-seq: caching
352
+ #
353
+ # Delegates to Chat#caching. See that method for arguments and return values.
354
+
355
+ ##
356
+ # :method: citations
357
+ # :call-seq: citations
358
+ #
359
+ # Delegates to Chat#citations. See that method for arguments and return values.
360
+
361
+ ##
362
+ # :method: compaction
363
+ # :call-seq: compaction
364
+ #
365
+ # Delegates to Chat#compaction. See that method for arguments and return values.
366
+
367
+ ##
368
+ # :method: concurrency
369
+ # :call-seq: concurrency
370
+ #
371
+ # Delegates to Chat#concurrency. See that method for arguments and return values.
372
+
373
+ ##
374
+ # :method: end_user
375
+ # :call-seq: end_user
376
+ #
377
+ # Delegates to Chat#end_user. See that method for arguments and return values.
378
+
379
+ ##
380
+ # :method: fallbacks
381
+ # :call-seq: fallbacks
382
+ #
383
+ # Delegates to Chat#fallbacks. See that method for arguments and return values.
384
+
385
+ ##
386
+ # :method: headers
387
+ # :call-seq: headers
388
+ #
389
+ # Delegates to Chat#headers. See that method for arguments and return values.
390
+
391
+ ##
392
+ # :method: max_output_tokens
393
+ # :call-seq: max_output_tokens
394
+ #
395
+ # Delegates to Chat#max_output_tokens. See that method for arguments and return values.
396
+
397
+ ##
398
+ # :method: provider_options
399
+ # :call-seq: provider_options
400
+ #
401
+ # Delegates to Chat#provider_options. See that method for arguments and return values.
402
+
403
+ ##
404
+ # :method: schema
405
+ # :call-seq: schema
406
+ #
407
+ # Delegates to Chat#schema. See that method for arguments and return values.
408
+
409
+ ##
410
+ # :method: server_tools
411
+ # :call-seq: server_tools
412
+ #
413
+ # Delegates to Chat#server_tools. See that method for arguments and return values.
414
+
415
+ ##
416
+ # :method: temperature
417
+ # :call-seq: temperature
418
+ #
419
+ # Delegates to Chat#temperature. See that method for arguments and return values.
420
+
421
+ ##
422
+ # :method: thinking
423
+ # :call-seq: thinking()
424
+ #
425
+ # Delegates to Chat#thinking. See that method for arguments and return values.
426
+
427
+ ##
428
+ # :method: tool_options
429
+ # :call-seq: tool_options()
430
+ #
431
+ # Delegates to Chat#tool_options. See that method for arguments and return values.
432
+
433
+ ##
434
+ # :method: tools
435
+ # :call-seq: tools
436
+ #
437
+ # Delegates to Chat#tools. See that method for arguments and return values.
438
+
439
+ CHAINABLE_CHAT_DELEGATES.each do |name|
440
+ define_method(name) do |*args, **kwargs, &block|
441
+ to_llm.public_send(name, *args, **kwargs, &block)
442
+ self
443
+ end
117
444
  end
118
445
 
119
- def with_tools(...)
120
- to_llm.with_tools(...)
121
- self
446
+ # Chat values and operations whose return values pass through unchanged.
447
+ #
448
+ # The public methods behave as documented on RubyLLM::Chat.
449
+
450
+ PASSTHROUGH_CHAT_DELEGATES = %i[
451
+ caching citations compaction concurrency end_user fallbacks headers max_output_tokens provider_options
452
+ schema server_tools temperature thinking tool_options tools
453
+ add_completion count_tokens each render
454
+ ].freeze
455
+
456
+ ##
457
+ # :method: count_tokens
458
+ # :call-seq: count_tokens(message = nil)
459
+ #
460
+ # Returns the number of input tokens the next request would carry,
461
+ # counted by the provider over the persisted conversation.
462
+
463
+ ##
464
+ # :method: each
465
+ # :call-seq: each(&block)
466
+ #
467
+ # Yields each message in the conversation. Returns an Enumerator without a block.
468
+
469
+ ##
470
+ # :method: render
471
+ # :call-seq: render
472
+ #
473
+ # Returns the next request payload with #before_request hooks applied.
474
+
475
+ PASSTHROUGH_CHAT_DELEGATES.each do |name|
476
+ define_method(name) do |*args, **kwargs, &block|
477
+ to_llm.public_send(name, *args, **kwargs, &block)
478
+ end
122
479
  end
123
480
 
124
- def with_model(model_name, provider: nil, assume_exists: false)
481
+ private_constant :CHAINABLE_CHAT_DELEGATES, :PASSTHROUGH_CHAT_DELEGATES
482
+
483
+ # Switches the chat to +model_name+, resolving and saving the model
484
+ # record and updating the underlying chat. Falls back to the configured
485
+ # default model when +model_name+ is +nil+. Pass +protocol:+ to override
486
+ # the wire protocol the provider would pick for the model. Returns +self+.
487
+ #
488
+ # chat.with_model 'claude-sonnet-5'
489
+ #
490
+ def with_model(model_name, provider: nil, protocol: nil, assume_model_exists: false)
491
+ model_name ||= (context&.config || RubyLLM.config).default_model
125
492
  self.model = model_name
126
493
  self.provider = provider if provider
127
- self.assume_model_exists = assume_exists
128
- resolve_model_from_strings
494
+ self.protocol = protocol
495
+ self.assume_model_exists = assume_model_exists
496
+ resolve_model
129
497
  save!
130
- to_llm.with_model(model_association.model_id, provider: model_association.provider.to_sym, assume_exists:)
498
+ to_llm.with_model(model_id, provider: provider&.to_sym, protocol:, assume_model_exists:)
131
499
  self
132
500
  end
133
501
 
134
- def with_temperature(...)
135
- to_llm.with_temperature(...)
136
- self
137
- end
502
+ # Persists +message_or_attributes+ as a message record, including any
503
+ # attachments and tool calls. Accepts a RubyLLM::Message, an attributes
504
+ # Hash, or a record responding to +to_llm+.
505
+ # Returns the message record.
506
+ #
507
+ # chat.add_message(role: :user, content: long_context)
508
+ #
509
+ def add_message(message_or_attributes)
510
+ llm_message = message_or_attributes
511
+ llm_message = llm_message.to_llm if llm_message.respond_to?(:to_llm)
512
+ llm_message = RubyLLM::Message.new(llm_message) unless llm_message.is_a?(RubyLLM::Message)
138
513
 
139
- def with_thinking(...)
140
- to_llm.with_thinking(...)
141
- self
514
+ message_record = messages_association.create!(message_attributes(llm_message))
515
+
516
+ if llm_message.tool_call_id && (tool_call = find_tool_call(llm_message.tool_call_id))
517
+ tool_call.update!(result: message_record)
518
+ end
519
+
520
+ persist_content(message_record, llm_message.attachments) if llm_message.attachments.any?
521
+ persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
522
+
523
+ @chat&.add_message(llm_message)
524
+
525
+ message_record
142
526
  end
143
527
 
144
- def with_params(...)
145
- to_llm.with_params(...)
528
+ # Marks the latest persisted message as a prompt cache boundary, or the
529
+ # latest in-memory message when none is persisted yet. Returns +self+.
530
+ #
531
+ # chat.with_instructions('Reusable analysis prompt').cache_until_here
532
+ #
533
+ # Raises ArgumentError if the chat has no messages.
534
+ def cache_until_here
535
+ message_record = messages_association.order(:id).last
536
+ if message_record
537
+ message_record.cache_until_here
538
+ elsif @chat&.messages&.any?
539
+ @chat.cache_until_here
540
+ else
541
+ raise ArgumentError, 'No messages to cache'
542
+ end
543
+
146
544
  self
147
545
  end
148
546
 
149
- def with_headers(...)
150
- to_llm.with_headers(...)
151
- self
547
+ # Returns token usage aggregated across every persisted usage entry,
548
+ # including retries and attempts that did not produce a message.
549
+ #
550
+ # chat.tokens.input
551
+ #
552
+ def tokens
553
+ RubyLLM::Tokens.aggregate(ruby_llm_usages.map(&:tokens))
152
554
  end
153
555
 
154
- def with_schema(...)
155
- to_llm.with_schema(...)
156
- self
556
+ # Returns a RubyLLM::Cost aggregating every persisted usage entry,
557
+ # including retries and attempts that did not produce a message.
558
+ #
559
+ # chat.cost.total
560
+ #
561
+ def cost
562
+ records = ruby_llm_usages.to_a
563
+ RubyLLM::Cost.aggregate(records.map(&:cost), complete: records.all?(&:cost_available?))
157
564
  end
158
565
 
159
- def on_new_message(&)
160
- to_llm.on_new_message(&)
161
- self
566
+ # Persists +message+ as a user message, then runs the conversation loop
567
+ # and returns the latest assistant RubyLLM::Message. The loop pauses
568
+ # when #awaiting_approval? is true. Yields streaming chunks to a block.
569
+ #
570
+ # chat.ask "What is the capital of France?"
571
+ # chat.ask "What's in this file?", with: "diagram.png"
572
+ #
573
+ def ask(message = nil, with: nil, &)
574
+ ask_later(message, with: with)
575
+ complete(&)
162
576
  end
163
577
 
164
- def on_end_message(&)
165
- to_llm.on_end_message(&)
578
+ alias say ask
579
+
580
+ # Persists +message+ as a user message without calling the model, so
581
+ # #complete can run later. Returns +self+.
582
+ #
583
+ # chat.ask_later "Summarize this document."
584
+ # chat.complete
585
+ #
586
+ def ask_later(message = nil, with: nil)
587
+ to_llm.raise_if_pending_tool_calls!
588
+ add_message(role: :user, content: message, attachments: with)
166
589
  self
167
590
  end
168
591
 
169
- def before_message(...)
170
- to_llm.before_message(...)
171
- self
592
+ # Makes a single generation attempt, persists the response, and returns it as a
593
+ # RubyLLM::Message. Tool calls in the response are not executed. See
594
+ # RubyLLM::Chat#generate.
595
+ def generate(...)
596
+ to_llm.generate(...)
597
+ rescue *COMPLETION_ERRORS => e
598
+ cleanup_after_failure(e)
599
+ raise
172
600
  end
173
601
 
174
- def after_message(...)
175
- to_llm.after_message(...)
176
- self
602
+ # Compacts the model context and persists its assistant Message without
603
+ # deleting earlier messages. See RubyLLM::Chat#compact.
604
+ def compact
605
+ to_llm.compact
606
+ rescue *COMPLETION_ERRORS => e
607
+ cleanup_after_failure(e)
608
+ raise
177
609
  end
178
610
 
179
- def before_tool_call(...)
180
- to_llm.before_tool_call(...)
611
+ # Executes the pending tool calls and persists their results without
612
+ # calling the model. See RubyLLM::Chat#run_tools. Returns +self+.
613
+ def run_tools
614
+ to_llm.run_tools
181
615
  self
182
616
  end
183
617
 
184
- def after_tool_result(...)
185
- to_llm.after_tool_result(...)
186
- self
618
+ # Advances the conversation by one move: runs the pending tool calls if
619
+ # there are any, otherwise generates a response. Returns +nil+ once the
620
+ # chat is complete or waiting for approval. See RubyLLM::Chat#step.
621
+ #
622
+ # chat.step until chat.complete? || chat.awaiting_approval?
623
+ #
624
+ def step(...)
625
+ to_llm.step(...)
626
+ rescue *COMPLETION_ERRORS => e
627
+ cleanup_after_failure(e)
628
+ raise
187
629
  end
188
630
 
189
- def on_tool_call(...)
190
- to_llm.on_tool_call(...)
191
- self
631
+ # Returns whether the conversation has no pending work, neither a
632
+ # response to generate nor tool calls to run. See
633
+ # RubyLLM::Chat#complete?.
634
+ def complete?
635
+ to_llm.complete?
192
636
  end
193
637
 
194
- def on_tool_result(...)
195
- to_llm.on_tool_result(...)
196
- self
638
+ # Runs the completion loop on the underlying chat, persisting each
639
+ # message, and returns the latest RubyLLM::Message. Pauses when a tool
640
+ # requires approval. When the API call fails, destroys the empty assistant
641
+ # message and any orphaned tool results, then re-raises the error.
642
+ def complete(...)
643
+ to_llm.complete(...)
644
+ rescue *COMPLETION_ERRORS => e
645
+ cleanup_after_failure(e)
646
+ raise
197
647
  end
198
648
 
199
- def add_message(message_or_attributes)
200
- llm_message = message_or_attributes.is_a?(RubyLLM::Message) ? message_or_attributes : RubyLLM::Message.new(message_or_attributes)
201
- content_text, attachments, content_raw = prepare_content_for_storage(llm_message.content)
202
-
203
- attrs = { role: llm_message.role, content: content_text }
204
- parent_tool_call_assoc = messages_association.klass.reflect_on_association(:parent_tool_call)
205
- if parent_tool_call_assoc && llm_message.tool_call_id
206
- tool_call_id = find_tool_call_id(llm_message.tool_call_id)
207
- attrs[parent_tool_call_assoc.foreign_key] = tool_call_id if tool_call_id
649
+ private
650
+
651
+ def persist_usage_entry(entry)
652
+ tokens = entry.tokens
653
+ cost = entry.cost
654
+ attributes = {
655
+ operation: entry.operation,
656
+ provider: entry.provider,
657
+ model: entry.model,
658
+ status: entry.status,
659
+ input_tokens: tokens.input,
660
+ output_tokens: tokens.output,
661
+ cache_read_tokens: tokens.cache_read,
662
+ cache_write_tokens: tokens.cache_write,
663
+ thinking_tokens: tokens.thinking,
664
+ input_cost: cost.input,
665
+ output_cost: cost.output,
666
+ cache_read_cost: cost.cache_read,
667
+ cache_write_cost: cost.cache_write,
668
+ thinking_cost: cost.thinking,
669
+ total_cost: cost.total
670
+ }
671
+ record = ruby_llm_usages.create!(attributes)
672
+ usage_records_by_entry[entry] = record
673
+ end
674
+
675
+ def link_usage_entries(message)
676
+ message.ruby_llm_usage_entries.each do |entry|
677
+ record = usage_records_by_entry[entry]
678
+ record&.update!(message: @message)
208
679
  end
680
+ end
209
681
 
210
- message_record = messages_association.create!(attrs)
211
- message_record.update!(content_raw:) if message_record.respond_to?(:content_raw=)
682
+ def usage_records_by_entry
683
+ @usage_records_by_entry ||= {}.compare_by_identity
684
+ end
212
685
 
213
- persist_content(message_record, attachments) if attachments.present?
214
- persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
686
+ def resolve_model
687
+ config = context&.config || RubyLLM.config
688
+ @pending_model_id ||= config.default_model unless model
689
+ return unless @pending_model_id
215
690
 
216
- message_record
691
+ model_info = resolve_model_info
692
+
693
+ self.model = find_or_create_model(model_info)
694
+ @pending_model_id = nil
695
+ @pending_provider = nil
217
696
  end
218
697
 
219
- def cost
220
- RubyLLM::Cost.aggregate(messages_association.map(&:cost))
698
+ def resolve_model_info
699
+ return find_registered_model unless assume_model_exists
700
+
701
+ raise ArgumentError, 'Provider must be specified if assume_model_exists is true' unless @pending_provider
702
+
703
+ begin
704
+ find_registered_model
705
+ rescue RubyLLM::ModelNotFoundError
706
+ RubyLLM::Model.default(@pending_model_id, @pending_provider)
707
+ end
221
708
  end
222
709
 
223
- def create_user_message(content, with: nil)
224
- add_message(role: :user, content: build_content(content, with))
710
+ def find_registered_model
711
+ RubyLLM.models.find(@pending_model_id, provider: @pending_provider, config: context&.config)
225
712
  end
226
713
 
227
- def ask(message = nil, with: nil, &)
228
- add_message(role: :user, content: build_content(message, with))
229
- complete(&)
714
+ # An empty store would otherwise hold only this chat's model on the next
715
+ # boot, since the registry reads whatever the store returns.
716
+ def load_model_registry_into_store
717
+ RubyLLM::ActiveRecord::Model.save_to_database(RubyLLM.models)
718
+ rescue ::ActiveRecord::RecordInvalid, ::ActiveRecord::RecordNotUnique
719
+ nil
230
720
  end
231
721
 
232
- alias say ask
722
+ def find_or_create_model(model_info)
723
+ attributes = { model_id: model_info.id, provider: model_info.provider }
724
+ load_model_registry_into_store if RubyLLM::ActiveRecord::Model.none?
725
+
726
+ RubyLLM::ActiveRecord::Model.find_or_create_by!(attributes) do |record|
727
+ record.name = model_info.name || model_info.id
728
+ record.family = model_info.family
729
+ record.model_created_at = model_info.created_at
730
+ record.context_window = model_info.context_window
731
+ record.max_output_tokens = model_info.max_output_tokens
732
+ record.knowledge_cutoff = model_info.knowledge_cutoff
733
+ record.capabilities = model_info.capabilities || []
734
+ record.modalities = model_info.modalities.to_h
735
+ record.pricing = model_info.pricing.to_h
736
+ record.metadata = model_info.metadata || {}
737
+ end
738
+ rescue ::ActiveRecord::RecordInvalid, ::ActiveRecord::RecordNotUnique
739
+ # Another process can insert the row between the lookup and the insert.
740
+ RubyLLM::ActiveRecord::Model.find_by(attributes) || raise
741
+ end
233
742
 
234
- def complete(...)
235
- to_llm.complete(...)
236
- rescue RubyLLM::Error => e
237
- cleanup_failed_messages if @message&.persisted? && @message.content.blank?
743
+ def cleanup_after_failure(error)
744
+ reason = error.is_a?(RubyLLM::CancelledError) ? 'chat cancelled' : 'API call failed'
745
+ cleanup_failed_messages(reason:) if blank_placeholder?
238
746
  cleanup_orphaned_tool_results
239
- raise e
240
747
  end
241
748
 
242
- private
243
-
244
- def cleanup_failed_messages
245
- RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}"
749
+ def cleanup_failed_messages(reason:)
750
+ RubyLLM.logger.warn "RubyLLM: #{reason}, destroying message: #{@message.id}"
246
751
  @message.destroy
247
752
  end
248
753
 
249
754
  def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
250
755
  messages_association.reload
251
- last = messages_association.order(:id).last
756
+ last = eager_load_messages.last
252
757
 
253
758
  return unless last&.tool_call? || last&.tool_result?
254
759
 
255
760
  if last.tool_call?
256
761
  last.destroy
257
762
  elsif last.tool_result?
258
- tool_call_message = last.parent_tool_call.message_association
259
- expected_results = tool_call_message.tool_calls_association.pluck(:id)
260
- fk_column = tool_call_message.class.reflections['tool_results'].foreign_key
261
- actual_results = tool_call_message.tool_results.pluck(fk_column)
763
+ parent = last.ruby_llm_parent_tool_call
764
+ tool_call_message = parent.message
765
+ calls = tool_call_message.ruby_llm_tool_calls
262
766
 
263
- if expected_results.sort != actual_results.sort
264
- tool_call_message.tool_results.each(&:destroy)
767
+ if calls.any? { |call| call.result.nil? }
768
+ calls.filter_map(&:result).each(&:destroy)
265
769
  tool_call_message.destroy
266
770
  end
267
771
  end
268
772
  end
269
773
 
270
- def setup_persistence_callbacks
271
- return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
774
+ def eager_load_messages
775
+ assoc = messages_association
776
+ messages = assoc.to_a
777
+ return messages unless assoc.respond_to?(:klass)
778
+
779
+ associations = %i[ruby_llm_tool_calls ruby_llm_parent_tool_call ruby_llm_usages]
780
+
781
+ ::ActiveRecord::Associations::Preloader.new(records: messages, associations: associations).call
782
+ messages
783
+ end
784
+
785
+ def build_llm_chat
786
+ chat = (context || RubyLLM).chat(
787
+ model: model_id,
788
+ provider: provider&.to_sym,
789
+ protocol: protocol,
790
+ assume_model_exists: assume_model_exists || false
791
+ )
792
+ sync_messages(chat)
793
+ chat.cancellation_checker = proc { consume_persisted_cancellation_request }
794
+ chat.approval_checker = proc { |tool_call| persisted_tool_call_approval(tool_call) }
795
+ install_persistence_callbacks(chat)
796
+ end
272
797
 
273
- @chat.before_message { persist_new_message }
274
- @chat.after_message { |msg| persist_message_completion(msg) }
798
+ def record_tool_call_decision(tool_call, decision)
799
+ id = tool_call.respond_to?(:tool_call_id) ? tool_call.tool_call_id : tool_call
800
+ id = id.id if id.respond_to?(:id) && !id.is_a?(String)
801
+ record = find_tool_call(id)
802
+ raise ArgumentError, "Unknown tool call: #{id.inspect}" unless record
275
803
 
276
- @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
277
- @chat
804
+ record.update!(approval: decision)
805
+ self
278
806
  end
279
807
 
280
- def replace_persisted_system_instructions(instructions)
281
- system_messages = messages_association.where(role: :system).order(:id).to_a
808
+ def persisted_tool_call_approval(tool_call)
809
+ record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }
810
+ return unless record&.has_attribute?(:approval)
282
811
 
283
- if system_messages.empty?
284
- messages_association.create!(role: :system, content: instructions)
285
- return
812
+ case record.approval
813
+ when 'approved' then true
814
+ when 'denied' then false
286
815
  end
816
+ end
287
817
 
288
- primary_message = system_messages.shift
289
- primary_message.update!(content: instructions) if primary_message.content != instructions
290
- system_messages.each(&:destroy!)
818
+ def sync_messages(chat = @chat)
819
+ message_records = eager_load_messages
820
+ chat.messages = message_records
821
+ linked_entry_pairs = message_records.zip(chat.messages).flat_map do |record, message|
822
+ record.ruby_llm_usages.zip(message.ruby_llm_usage_entries)
823
+ end
824
+ linked_entries = linked_entry_pairs.to_h { |record, entry| [record.id, entry] }
825
+ chat.usage_entries = ruby_llm_usages.map do |record|
826
+ linked_entries.fetch(record.id) { record.to_entry }
827
+ end
828
+ reapply_runtime_instructions(chat)
829
+ chat
291
830
  end
292
831
 
293
- def append_instructions?(append:, replace:)
294
- return append if replace.nil?
832
+ def install_persistence_callbacks(chat)
833
+ chat.before_message { persist_new_message }
834
+ chat.usage_recorder = method(:persist_usage_entry)
835
+ chat.after_message { |msg| persist_message_completion(msg) }
836
+ chat
837
+ end
838
+
839
+ def clear_persisted_system_instructions
840
+ association = messages_association
841
+ association.where(role: :system).destroy_all
842
+ association.reset
843
+ end
295
844
 
296
- append || (replace == false)
845
+ def replace_persisted_system_instructions(instructions, cache_until_here:)
846
+ existing = messages_association.where(role: :system).order(:id).to_a
847
+ if existing.one?
848
+ update_persisted_system_instruction(existing.first, instructions, cache_until_here:)
849
+ else
850
+ clear_persisted_system_instructions
851
+ messages_association.create!(role: :system, content: instructions, cache_until_here: cache_until_here)
852
+ end
297
853
  end
298
854
 
299
- def persist_system_instruction(instructions, append:)
855
+ # Rewriting the same instructions every turn would move the system row
856
+ # behind the user messages and rebroadcast it each time.
857
+ def update_persisted_system_instruction(record, instructions, cache_until_here:)
858
+ attributes = { content: instructions }
859
+ attributes[:cache_until_here] = cache_until_here if record.has_attribute?(:cache_until_here)
860
+ changed = attributes.any? { |column, value| record[column] != value }
861
+ record.update!(attributes) if changed
862
+ messages_association.reset
863
+ record
864
+ end
865
+
866
+ def persist_system_instruction(instructions, append:, cache_until_here:)
300
867
  transaction do
301
868
  if append
302
- messages_association.create!(role: :system, content: instructions)
869
+ messages_association.create!(
870
+ role: :system,
871
+ content: instructions,
872
+ cache_until_here: cache_until_here
873
+ )
303
874
  else
304
- replace_persisted_system_instructions(instructions)
875
+ replace_persisted_system_instructions(instructions, cache_until_here:)
305
876
  end
306
877
  end
307
878
  end
308
879
 
309
- def order_messages_for_llm(messages)
310
- system_messages, non_system_messages = messages.partition { |msg| msg.role.to_s == 'system' }
311
- system_messages + non_system_messages
880
+ def unpersisted_instructions
881
+ @unpersisted_instructions ||= []
312
882
  end
313
883
 
314
- def runtime_instructions
315
- @runtime_instructions ||= []
316
- end
884
+ def store_unpersisted_instruction(instructions, append:, cache_until_here:)
885
+ if instructions.nil?
886
+ @unpersisted_instructions = []
887
+ return
888
+ end
317
889
 
318
- def store_runtime_instruction(instructions, append:)
890
+ entry = [instructions, append, cache_until_here]
319
891
  if append
320
- runtime_instructions << instructions
892
+ unpersisted_instructions << entry
321
893
  else
322
- @runtime_instructions = [instructions]
894
+ @unpersisted_instructions = [entry]
323
895
  end
324
896
  end
325
897
 
326
898
  def reapply_runtime_instructions(chat)
327
- return if runtime_instructions.empty?
328
-
329
- first, *rest = runtime_instructions
330
- chat.with_instructions(first)
331
- rest.each { |instruction| chat.with_instructions(instruction, append: true) }
899
+ unpersisted_instructions.each do |instructions, append, cache_until_here|
900
+ chat.with_instructions(instructions, append:, cache_until_here:)
901
+ end
332
902
  end
333
903
 
334
904
  def persist_new_message
335
- @message = messages_association.create!(role: :assistant, content: '')
905
+ @message.destroy if blank_placeholder?
906
+
907
+ attrs = { role: :assistant, content: '' }
908
+ @message = messages_association.create!(attrs)
909
+ end
910
+
911
+ def blank_placeholder?
912
+ return false unless @message&.persisted? && @message.content.blank?
913
+ return false if @message.respond_to?(:attachments) && @message.attachments.attached?
914
+
915
+ !@message.ruby_llm_tool_calls.exists?
336
916
  end
337
917
 
338
- # rubocop:disable Metrics/PerceivedComplexity
339
918
  def persist_message_completion(message)
340
919
  return unless message
341
920
 
342
- tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
921
+ tool_call = find_tool_call(message.tool_call_id) if message.tool_call_id
922
+ attrs = message_attributes(message)
343
923
 
344
924
  transaction do
345
- content_text, attachments_to_persist, content_raw = prepare_content_for_storage(message.content)
346
-
347
- attrs = {
348
- role: message.role,
349
- content: content_text,
350
- input_tokens: message.input_tokens,
351
- output_tokens: message.output_tokens
352
- }
353
- attrs[:cached_tokens] = message.cached_tokens if @message.has_attribute?(:cached_tokens)
354
- if @message.has_attribute?(:cache_creation_tokens)
355
- attrs[:cache_creation_tokens] = message.cache_creation_tokens
356
- end
357
- attrs[:thinking_text] = message.thinking&.text if @message.has_attribute?(:thinking_text)
358
- attrs[:thinking_signature] = message.thinking&.signature if @message.has_attribute?(:thinking_signature)
359
- attrs[:thinking_tokens] = message.thinking_tokens if @message.has_attribute?(:thinking_tokens)
360
-
361
- # Add model association dynamically
362
- attrs[self.class.model_association_name] = model_association
363
-
364
- if tool_call_id
365
- parent_tool_call_assoc = @message.class.reflect_on_association(:parent_tool_call)
366
- attrs[parent_tool_call_assoc.foreign_key] = tool_call_id
367
- end
368
-
369
925
  @message.assign_attributes(attrs)
370
- @message.content_raw = content_raw if @message.respond_to?(:content_raw=)
371
926
  @message.save!
927
+ tool_call&.update!(result: @message)
372
928
 
373
- persist_content(@message, attachments_to_persist) if attachments_to_persist
929
+ persist_content(@message, message.attachments) if message.attachments.any?
374
930
  persist_tool_calls(message.tool_calls) if message.tool_calls.present?
931
+ link_usage_entries(message)
375
932
  end
376
933
  end
377
- # rubocop:enable Metrics/PerceivedComplexity
378
934
 
379
- def persist_tool_calls(tool_calls, message_record: @message)
380
- tool_call_klass = message_record.tool_calls_association.klass
381
- supports_thought_signature = tool_call_klass.column_names.include?('thought_signature')
935
+ def message_attributes(message)
936
+ attrs = { role: message.role, content: message.content }
937
+ assign_supported_attribute(attrs, :thinking_text, message.thinking&.text)
938
+ assign_supported_attribute(attrs, :thinking_signature, message.thinking&.signature)
939
+ assign_supported_attribute(attrs, :citations, message.citations.map(&:to_h).presence)
940
+ assign_supported_attribute(attrs, :server_tool_calls, message.server_tool_calls.map(&:to_h).presence)
941
+ assign_supported_attribute(attrs, :raw_content, message.raw_content)
942
+ assign_supported_attribute(attrs, :raw_reasoning, message.raw_reasoning)
943
+ assign_supported_attribute(attrs, :finish_reason, message.finish_reason)
944
+ assign_supported_attribute(attrs, :cache_until_here, message.cache_until_here?)
945
+ attrs
946
+ end
382
947
 
948
+ def assign_supported_attribute(attributes, name, value)
949
+ attributes[name] = value if messages_association.klass.column_names.include?(name.to_s)
950
+ end
951
+
952
+ def persist_tool_calls(tool_calls, message_record: @message)
383
953
  tool_calls.each_value do |tool_call|
384
954
  attributes = tool_call.to_h
385
- attributes.delete(:thought_signature) unless supports_thought_signature
386
955
  attributes[:tool_call_id] = attributes.delete(:id)
387
- message_record.tool_calls_association.create!(**attributes)
956
+ message_record.ruby_llm_tool_calls.create!(**attributes)
388
957
  end
389
958
  end
390
959
 
391
- def find_tool_call_id(tool_call_id)
392
- messages = messages_association
393
- message_class = messages.klass
394
- tool_calls_assoc = message_class.tool_calls_association_name
395
- tool_call_table_name = message_class.reflect_on_association(tool_calls_assoc).table_name
396
-
397
- message_with_tool_call = messages.joins(tool_calls_assoc)
398
- .find_by(tool_call_table_name => { tool_call_id: tool_call_id })
399
- return nil unless message_with_tool_call
400
-
401
- tool_call = message_with_tool_call.tool_calls_association.find_by(tool_call_id: tool_call_id)
402
- tool_call&.id
403
- end
404
-
405
- def persist_content(message_record, attachments)
406
- return unless message_record.respond_to?(:attachments)
407
-
408
- attachables = prepare_for_active_storage(attachments)
409
- message_record.attachments.attach(attachables) if attachables.any?
410
- end
411
-
412
- def prepare_for_active_storage(attachments)
413
- Utils.to_safe_array(attachments).filter_map do |attachment|
414
- case attachment
415
- when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
416
- attachment
417
- when ActiveStorage::Attachment, ActiveStorage::Attached::One, ActiveStorage::Attached::Many
418
- active_storage_blobs(attachment)
419
- when Hash
420
- attachment.values.map { |v| prepare_for_active_storage(v) }
421
- else
422
- convert_to_active_storage_format(attachment)
423
- end
424
- end.flatten.compact
960
+ def find_tool_call(tool_call_id)
961
+ RubyLLM::ActiveRecord::ToolCall.find_by(
962
+ tool_call_id: tool_call_id,
963
+ message_type: self.class.message_class.constantize.polymorphic_name,
964
+ message_id: messages_association.select(:id)
965
+ )
425
966
  end
426
967
 
427
- def convert_to_active_storage_format(source)
428
- return if source.blank?
429
-
430
- attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
431
-
432
- if attachment.active_storage?
433
- active_storage_blobs(attachment.source)
434
- else
435
- {
436
- io: StringIO.new(attachment.content),
437
- filename: attachment.filename,
438
- content_type: attachment.mime_type
439
- }
968
+ def consume_persisted_cancellation_request
969
+ if self[:cancelled]
970
+ clear_cancellation_request
971
+ return :cancelled
440
972
  end
441
- rescue StandardError => e
442
- RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
443
- nil
444
- end
973
+ return unless persisted?
974
+ return unless cancellation_poll_due?
975
+ return unless persisted_cancellation_request?
445
976
 
446
- def active_storage_blobs(attachment)
447
- case attachment
448
- when ActiveStorage::Blob then attachment
449
- when ActiveStorage::Attachment, ActiveStorage::Attached::One then attachment.blob
450
- when ActiveStorage::Attached::Many then attachment.blobs
451
- end
977
+ clear_cancellation_request
978
+ :cancelled
452
979
  end
453
980
 
454
- def build_content(message, attachments)
455
- return message if content_like?(message)
456
-
457
- RubyLLM::Content.new(message, attachments)
981
+ # Jobs and requests run with the query cache on, which would replay the
982
+ # first poll's answer for the rest of the run.
983
+ def persisted_cancellation_request?
984
+ self.class.uncached do
985
+ self.class.unscoped.where(self.class.primary_key => id).pick(:cancelled)
986
+ end
458
987
  end
459
988
 
460
- def content_like?(object)
461
- object.is_a?(RubyLLM::Content) || object.is_a?(RubyLLM::Content::Raw)
989
+ def clear_cancellation_request
990
+ self[:cancelled] = false
991
+ self.class.unscoped.where(self.class.primary_key => id).update_all(cancelled: false) if persisted?
462
992
  end
463
993
 
464
- def prepare_content_for_storage(content)
465
- attachments = nil
466
- content_raw = nil
467
- content_text = content
468
-
469
- case content
470
- when RubyLLM::Content::Raw
471
- content_raw = content.value
472
- content_text = nil
473
- when RubyLLM::Content
474
- attachments = content.attachments if content.attachments.any?
475
- content_text = content.text
476
- when Hash, Array
477
- content_raw = content
478
- content_text = nil
479
- end
994
+ def cancellation_poll_due?
995
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
996
+ return false if @last_cancellation_poll_at && now - @last_cancellation_poll_at < CANCELLATION_POLL_INTERVAL
480
997
 
481
- [content_text, attachments, content_raw]
998
+ @last_cancellation_poll_at = now
999
+ true
482
1000
  end
483
1001
  end
484
1002
  end