ruby_llm 1.16.0 → 2.0.0.rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (475) hide show
  1. checksums.yaml +4 -4
  2. data/.rdoc_options +25 -0
  3. data/README.md +85 -32
  4. data/exe/ruby_llm +8 -0
  5. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +0 -1
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +3 -43
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +11 -3
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +4 -4
  10. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +1 -1
  11. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +1 -1
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +2 -2
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +1 -1
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +6 -4
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +1 -1
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +2 -2
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +5 -6
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +2 -2
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +5 -5
  23. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +1 -1
  24. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +1 -1
  25. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +1 -1
  26. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +2 -2
  27. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +1 -1
  28. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  29. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +6 -4
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +1 -1
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +4 -6
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +2 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +5 -6
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +2 -2
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -3
  37. data/lib/generators/ruby_llm/generator_helpers.rb +106 -62
  38. data/lib/generators/ruby_llm/install/install_generator.rb +3 -11
  39. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +2 -0
  40. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +14 -8
  41. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +117 -0
  42. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +0 -8
  43. data/lib/generators/ruby_llm/provider/cli.rb +175 -0
  44. data/lib/generators/ruby_llm/provider/scaffold.rb +323 -0
  45. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +37 -0
  46. data/lib/generators/ruby_llm/provider/templates/core/provider_spec.rb.erb +34 -0
  47. data/lib/generators/ruby_llm/provider/templates/gem/archspec.rb.erb +14 -0
  48. data/lib/generators/ruby_llm/provider/templates/gem/bin/console.erb +15 -0
  49. data/lib/generators/ruby_llm/provider/templates/gem/bin/setup.erb +5 -0
  50. data/lib/generators/ruby_llm/provider/templates/gem/chat_schema_spec.rb.erb +30 -0
  51. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +35 -0
  52. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +22 -0
  53. data/lib/generators/ruby_llm/provider/templates/gem/chat_tools_spec.rb.erb +30 -0
  54. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +32 -0
  55. data/lib/generators/ruby_llm/provider/templates/gem/embedding_spec.rb.erb +49 -0
  56. data/lib/generators/ruby_llm/provider/templates/gem/env.erb +2 -0
  57. data/lib/generators/ruby_llm/provider/templates/gem/fixtures_gitkeep.erb +1 -0
  58. data/lib/generators/ruby_llm/provider/templates/gem/flayignore.erb +1 -0
  59. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +23 -0
  60. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +28 -0
  61. data/lib/generators/ruby_llm/provider/templates/gem/gitignore.erb +7 -0
  62. data/lib/generators/ruby_llm/provider/templates/gem/gitleaks.yml.erb +22 -0
  63. data/lib/generators/ruby_llm/provider/templates/gem/image_spec.rb.erb +23 -0
  64. data/lib/generators/ruby_llm/provider/templates/gem/license.erb +21 -0
  65. data/lib/generators/ruby_llm/provider/templates/gem/models.rb.erb +19 -0
  66. data/lib/generators/ruby_llm/provider/templates/gem/models_spec.rb.erb +17 -0
  67. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +22 -0
  68. data/lib/generators/ruby_llm/provider/templates/gem/overcommit.yml.erb +31 -0
  69. data/lib/generators/ruby_llm/provider/templates/gem/provider.rb.erb +52 -0
  70. data/lib/generators/ruby_llm/provider/templates/gem/provider_spec.rb.erb +38 -0
  71. data/lib/generators/ruby_llm/provider/templates/gem/rakefile.erb +38 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/readme.md.erb +50 -0
  73. data/lib/generators/ruby_llm/provider/templates/gem/release.yml.erb +36 -0
  74. data/lib/generators/ruby_llm/provider/templates/gem/rerank_spec.rb.erb +23 -0
  75. data/lib/generators/ruby_llm/provider/templates/gem/rspec.erb +2 -0
  76. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +29 -0
  77. data/lib/generators/ruby_llm/provider/templates/gem/rubyllm_configuration.rb.erb +14 -0
  78. data/lib/generators/ruby_llm/provider/templates/gem/spec_helper.rb.erb +26 -0
  79. data/lib/generators/ruby_llm/provider/templates/gem/speech_spec.rb.erb +25 -0
  80. data/lib/generators/ruby_llm/provider/templates/gem/vcr_configuration.rb.erb +16 -0
  81. data/lib/generators/ruby_llm/provider/templates/gem/video_spec.rb.erb +27 -0
  82. data/lib/generators/ruby_llm/schema/schema_generator.rb +5 -1
  83. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +1 -1
  84. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +13 -0
  85. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +21 -0
  86. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +3 -3
  87. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +7 -12
  88. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +5 -2
  89. data/lib/generators/ruby_llm/tool/tool_generator.rb +25 -59
  90. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +461 -0
  91. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +101 -0
  92. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +215 -0
  93. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +660 -0
  94. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +222 -0
  95. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +13 -0
  96. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +167 -0
  97. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +344 -0
  98. data/lib/ruby_llm/accounting/usage.rb +245 -0
  99. data/lib/ruby_llm/active_record/acts_as.rb +94 -111
  100. data/lib/ruby_llm/active_record/attachment_helpers.rb +186 -0
  101. data/lib/ruby_llm/active_record/batch.rb +97 -0
  102. data/lib/ruby_llm/active_record/chat_methods.rb +828 -305
  103. data/lib/ruby_llm/active_record/message_methods.rb +113 -136
  104. data/lib/ruby_llm/active_record/model.rb +135 -0
  105. data/lib/ruby_llm/active_record/payload_helpers.rb +1 -2
  106. data/lib/ruby_llm/active_record/tool_call.rb +33 -0
  107. data/lib/ruby_llm/active_record/usage.rb +61 -0
  108. data/lib/ruby_llm/agent.rb +1065 -151
  109. data/lib/ruby_llm/aliases.json +268 -100
  110. data/lib/ruby_llm/attachment.rb +187 -48
  111. data/lib/ruby_llm/batch.rb +432 -0
  112. data/lib/ruby_llm/cached_content.rb +112 -0
  113. data/lib/ruby_llm/chat/tool_concurrency.rb +111 -0
  114. data/lib/ruby_llm/chat.rb +1127 -198
  115. data/lib/ruby_llm/chunk.rb +10 -0
  116. data/lib/ruby_llm/citation.rb +105 -0
  117. data/lib/ruby_llm/configuration.rb +261 -24
  118. data/lib/ruby_llm/context.rb +128 -6
  119. data/lib/ruby_llm/cost.rb +217 -80
  120. data/lib/ruby_llm/downloaded_file.rb +33 -0
  121. data/lib/ruby_llm/embedding.rb +121 -17
  122. data/lib/ruby_llm/embedding_request.rb +53 -0
  123. data/lib/ruby_llm/error.rb +159 -23
  124. data/lib/ruby_llm/fallback.rb +133 -0
  125. data/lib/ruby_llm/files/mime_type.rb +97 -0
  126. data/lib/ruby_llm/image.rb +153 -32
  127. data/lib/ruby_llm/message.rb +233 -54
  128. data/lib/ruby_llm/model/modalities.rb +17 -4
  129. data/lib/ruby_llm/model/pricing.rb +24 -5
  130. data/lib/ruby_llm/model/pricing_category.rb +103 -14
  131. data/lib/ruby_llm/model/pricing_tier.rb +55 -15
  132. data/lib/ruby_llm/model.rb +244 -2
  133. data/lib/ruby_llm/models/aliases.rb +41 -0
  134. data/lib/ruby_llm/models/registry.rb +165 -0
  135. data/lib/ruby_llm/models/schema.rb +99 -0
  136. data/lib/ruby_llm/models.json +73261 -33733
  137. data/lib/ruby_llm/models.rb +477 -215
  138. data/lib/ruby_llm/moderation.rb +139 -26
  139. data/lib/ruby_llm/ocr.rb +112 -0
  140. data/lib/ruby_llm/prompt.rb +79 -0
  141. data/lib/ruby_llm/protocol/binary_streaming.rb +65 -0
  142. data/lib/ruby_llm/protocol/stream_accumulator.rb +214 -0
  143. data/lib/ruby_llm/protocol/streaming.rb +230 -0
  144. data/lib/ruby_llm/protocol.rb +662 -0
  145. data/lib/ruby_llm/protocols/anthropic/batches.rb +73 -0
  146. data/lib/ruby_llm/protocols/anthropic/chat.rb +548 -0
  147. data/lib/ruby_llm/protocols/anthropic/embeddings.rb +14 -0
  148. data/lib/ruby_llm/protocols/anthropic/files.rb +38 -0
  149. data/lib/ruby_llm/protocols/anthropic/media.rb +141 -0
  150. data/lib/ruby_llm/protocols/anthropic/models.rb +129 -0
  151. data/lib/ruby_llm/protocols/anthropic/streaming.rb +167 -0
  152. data/lib/ruby_llm/{providers → protocols}/anthropic/tools.rb +24 -41
  153. data/lib/ruby_llm/protocols/anthropic.rb +101 -0
  154. data/lib/ruby_llm/protocols/azure/files.rb +16 -0
  155. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +109 -0
  156. data/lib/ruby_llm/protocols/bedrock/batches.rb +129 -0
  157. data/lib/ruby_llm/protocols/bedrock/files.rb +110 -0
  158. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +122 -0
  159. data/lib/ruby_llm/protocols/bedrock/rerank.rb +95 -0
  160. data/lib/ruby_llm/protocols/chat_completions/batches.rb +32 -0
  161. data/lib/ruby_llm/protocols/chat_completions/chat.rb +493 -0
  162. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +35 -0
  163. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +60 -0
  164. data/lib/ruby_llm/protocols/chat_completions/images.rb +127 -0
  165. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/media.rb +30 -17
  166. data/lib/ruby_llm/protocols/chat_completions/models.rb +39 -0
  167. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +52 -0
  168. data/lib/ruby_llm/protocols/chat_completions/rerank.rb +56 -0
  169. data/lib/ruby_llm/protocols/chat_completions/speech.rb +40 -0
  170. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +69 -0
  171. data/lib/ruby_llm/{providers/openai → protocols/chat_completions}/tools.rb +15 -17
  172. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +150 -0
  173. data/lib/ruby_llm/protocols/chat_completions.rb +21 -0
  174. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +75 -0
  175. data/lib/ruby_llm/protocols/cohere/batches.rb +98 -0
  176. data/lib/ruby_llm/protocols/cohere/chat.rb +227 -0
  177. data/lib/ruby_llm/protocols/cohere/datasets.rb +102 -0
  178. data/lib/ruby_llm/protocols/cohere/embeddings.rb +68 -0
  179. data/lib/ruby_llm/protocols/cohere/media.rb +77 -0
  180. data/lib/ruby_llm/protocols/cohere/models.rb +92 -0
  181. data/lib/ruby_llm/protocols/cohere/ocr.rb +63 -0
  182. data/lib/ruby_llm/protocols/cohere/rerank.rb +52 -0
  183. data/lib/ruby_llm/protocols/cohere/streaming.rb +105 -0
  184. data/lib/ruby_llm/protocols/cohere/tokenization.rb +22 -0
  185. data/lib/ruby_llm/protocols/cohere/tools.rb +132 -0
  186. data/lib/ruby_llm/protocols/cohere/transcription.rb +41 -0
  187. data/lib/ruby_llm/protocols/cohere.rb +21 -0
  188. data/lib/ruby_llm/protocols/converse/batches.rb +55 -0
  189. data/lib/ruby_llm/protocols/converse/chat.rb +695 -0
  190. data/lib/ruby_llm/protocols/converse/media.rb +178 -0
  191. data/lib/ruby_llm/protocols/converse/streaming.rb +432 -0
  192. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +56 -0
  193. data/lib/ruby_llm/protocols/converse.rb +54 -0
  194. data/lib/ruby_llm/protocols/deepgram/models.rb +74 -0
  195. data/lib/ruby_llm/protocols/deepgram/speech.rb +93 -0
  196. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +89 -0
  197. data/lib/ruby_llm/protocols/deepgram/transcription.rb +96 -0
  198. data/lib/ruby_llm/protocols/deepgram.rb +19 -0
  199. data/lib/ruby_llm/protocols/deepseek/files.rb +31 -0
  200. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +36 -0
  201. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +74 -0
  202. data/lib/ruby_llm/protocols/elevenlabs/flows/media.rb +42 -0
  203. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +93 -0
  204. data/lib/ruby_llm/protocols/elevenlabs/flows.rb +14 -0
  205. data/lib/ruby_llm/protocols/elevenlabs/models.rb +64 -0
  206. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +67 -0
  207. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +127 -0
  208. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +61 -0
  209. data/lib/ruby_llm/protocols/elevenlabs.rb +15 -0
  210. data/lib/ruby_llm/protocols/files.rb +119 -0
  211. data/lib/ruby_llm/protocols/gemini/batches.rb +162 -0
  212. data/lib/ruby_llm/protocols/gemini/caches.rb +59 -0
  213. data/lib/ruby_llm/protocols/gemini/chat.rb +453 -0
  214. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +86 -0
  215. data/lib/ruby_llm/protocols/gemini/embeddings.rb +70 -0
  216. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +32 -0
  217. data/lib/ruby_llm/protocols/gemini/files.rb +115 -0
  218. data/lib/ruby_llm/protocols/gemini/images.rb +183 -0
  219. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +140 -0
  220. data/lib/ruby_llm/{providers → protocols}/gemini/media.rb +17 -11
  221. data/lib/ruby_llm/protocols/gemini/models.rb +71 -0
  222. data/lib/ruby_llm/protocols/gemini/speech.rb +56 -0
  223. data/lib/ruby_llm/protocols/gemini/streaming.rb +96 -0
  224. data/lib/ruby_llm/protocols/gemini/tools.rb +157 -0
  225. data/lib/ruby_llm/{providers → protocols}/gemini/transcription.rb +22 -22
  226. data/lib/ruby_llm/protocols/gemini/videos.rb +103 -0
  227. data/lib/ruby_llm/protocols/gemini.rb +35 -0
  228. data/lib/ruby_llm/protocols/gpustack/responses.rb +111 -0
  229. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +22 -0
  230. data/lib/ruby_llm/protocols/gpustack/videos.rb +96 -0
  231. data/lib/ruby_llm/protocols/interactions/chat.rb +145 -0
  232. data/lib/ruby_llm/protocols/interactions/content.rb +90 -0
  233. data/lib/ruby_llm/protocols/interactions/streaming.rb +91 -0
  234. data/lib/ruby_llm/protocols/interactions/tools.rb +51 -0
  235. data/lib/ruby_llm/protocols/interactions/transcription.rb +58 -0
  236. data/lib/ruby_llm/protocols/interactions.rb +29 -0
  237. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +51 -0
  238. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +111 -0
  239. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +50 -0
  240. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +103 -0
  241. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +33 -0
  242. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +44 -0
  243. data/lib/ruby_llm/protocols/invoke_model.rb +57 -0
  244. data/lib/ruby_llm/protocols/mistral/content.rb +49 -0
  245. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +160 -0
  246. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +43 -0
  247. data/lib/ruby_llm/protocols/mistral/conversations/streaming.rb +83 -0
  248. data/lib/ruby_llm/protocols/mistral/conversations.rb +30 -0
  249. data/lib/ruby_llm/protocols/mistral/files.rb +36 -0
  250. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +160 -0
  251. data/lib/ruby_llm/protocols/openai/batches.rb +126 -0
  252. data/lib/ruby_llm/protocols/openai/files.rb +42 -0
  253. data/lib/ruby_llm/protocols/openrouter/batches.rb +147 -0
  254. data/lib/ruby_llm/protocols/openrouter/files.rb +24 -0
  255. data/lib/ruby_llm/protocols/openrouter/responses.rb +53 -0
  256. data/lib/ruby_llm/protocols/openrouter/transcription.rb +51 -0
  257. data/lib/ruby_llm/protocols/perplexity/files.rb +48 -0
  258. data/lib/ruby_llm/protocols/perplexity/router.rb +59 -0
  259. data/lib/ruby_llm/protocols/responses/approvals.rb +32 -0
  260. data/lib/ruby_llm/protocols/responses/batches.rb +32 -0
  261. data/lib/ruby_llm/protocols/responses/chat.rb +476 -0
  262. data/lib/ruby_llm/protocols/responses/compaction.rb +29 -0
  263. data/lib/ruby_llm/protocols/responses/media.rb +61 -0
  264. data/lib/ruby_llm/protocols/responses/streaming.rb +117 -0
  265. data/lib/ruby_llm/protocols/responses/token_counting.rb +26 -0
  266. data/lib/ruby_llm/protocols/responses/tools.rb +39 -0
  267. data/lib/ruby_llm/protocols/responses.rb +35 -0
  268. data/lib/ruby_llm/protocols/vertexai/batch_prediction.rb +155 -0
  269. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +85 -0
  270. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/results.rb +74 -0
  271. data/lib/ruby_llm/protocols/vertexai/embedding_prediction.rb +56 -0
  272. data/lib/ruby_llm/protocols/vertexai/files.rb +101 -0
  273. data/lib/ruby_llm/protocols/vertexai/ranking.rb +69 -0
  274. data/lib/ruby_llm/protocols/vertexai/research.rb +193 -0
  275. data/lib/ruby_llm/protocols/xai/files.rb +30 -0
  276. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +120 -0
  277. data/lib/ruby_llm/protocols/xai/tokenization.rb +23 -0
  278. data/lib/ruby_llm/provider.rb +560 -128
  279. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -7
  280. data/lib/ruby_llm/providers/anthropic.rb +4 -6
  281. data/lib/ruby_llm/providers/azure/audio.rb +18 -0
  282. data/lib/ruby_llm/providers/azure/capabilities.rb +16 -0
  283. data/lib/ruby_llm/providers/azure/chat.rb +2 -9
  284. data/lib/ruby_llm/providers/azure/chat_completions/batches.rb +29 -0
  285. data/lib/ruby_llm/providers/azure/chat_completions.rb +80 -0
  286. data/lib/ruby_llm/providers/azure/cohere.rb +33 -0
  287. data/lib/ruby_llm/providers/azure/embeddings.rb +3 -2
  288. data/lib/ruby_llm/providers/azure/images.rb +22 -0
  289. data/lib/ruby_llm/providers/azure/media.rb +5 -14
  290. data/lib/ruby_llm/providers/azure/models.rb +35 -0
  291. data/lib/ruby_llm/providers/azure/responses.rb +26 -0
  292. data/lib/ruby_llm/providers/azure/videos.rb +64 -0
  293. data/lib/ruby_llm/providers/azure.rb +77 -78
  294. data/lib/ruby_llm/providers/bedrock/auth.rb +60 -41
  295. data/lib/ruby_llm/providers/bedrock/capabilities.rb +18 -0
  296. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +39 -0
  297. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +23 -0
  298. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +24 -0
  299. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +96 -0
  300. data/lib/ruby_llm/providers/bedrock/mantle.rb +57 -0
  301. data/lib/ruby_llm/providers/bedrock/models.rb +193 -41
  302. data/lib/ruby_llm/providers/bedrock.rb +216 -45
  303. data/lib/ruby_llm/providers/cohere.rb +31 -0
  304. data/lib/ruby_llm/providers/deepgram.rb +37 -0
  305. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -51
  306. data/lib/ruby_llm/providers/deepseek/chat.rb +49 -2
  307. data/lib/ruby_llm/providers/deepseek/responses.rb +68 -0
  308. data/lib/ruby_llm/providers/deepseek.rb +9 -2
  309. data/lib/ruby_llm/providers/elevenlabs.rb +35 -0
  310. data/lib/ruby_llm/providers/gemini/capabilities.rb +8 -107
  311. data/lib/ruby_llm/providers/gemini.rb +15 -8
  312. data/lib/ruby_llm/providers/gpustack/chat.rb +2 -16
  313. data/lib/ruby_llm/providers/gpustack/embeddings.rb +28 -0
  314. data/lib/ruby_llm/providers/gpustack/media.rb +17 -16
  315. data/lib/ruby_llm/providers/gpustack/models.rb +72 -62
  316. data/lib/ruby_llm/providers/gpustack/speech.rb +15 -0
  317. data/lib/ruby_llm/providers/gpustack/transcription.rb +29 -0
  318. data/lib/ruby_llm/providers/gpustack.rb +35 -11
  319. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -155
  320. data/lib/ruby_llm/providers/mistral/chat.rb +37 -61
  321. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +120 -0
  322. data/lib/ruby_llm/providers/mistral/chat_completions.rb +21 -0
  323. data/lib/ruby_llm/providers/mistral/conversations.rb +12 -0
  324. data/lib/ruby_llm/providers/mistral/embeddings.rb +6 -4
  325. data/lib/ruby_llm/providers/mistral/media.rb +6 -18
  326. data/lib/ruby_llm/providers/mistral/models.rb +57 -23
  327. data/lib/ruby_llm/providers/mistral/ocr.rb +47 -0
  328. data/lib/ruby_llm/providers/mistral/speech.rb +51 -0
  329. data/lib/ruby_llm/providers/mistral/transcription.rb +62 -0
  330. data/lib/ruby_llm/providers/mistral.rb +16 -4
  331. data/lib/ruby_llm/providers/ollama/chat.rb +7 -13
  332. data/lib/ruby_llm/providers/ollama/media.rb +6 -15
  333. data/lib/ruby_llm/providers/ollama/models.rb +50 -9
  334. data/lib/ruby_llm/providers/ollama.rb +9 -8
  335. data/lib/ruby_llm/providers/ollama_cloud/models.rb +14 -0
  336. data/lib/ruby_llm/providers/ollama_cloud.rb +40 -0
  337. data/lib/ruby_llm/providers/openai/capabilities.rb +54 -259
  338. data/lib/ruby_llm/providers/openai/models.rb +23 -23
  339. data/lib/ruby_llm/providers/openai/responses.rb +13 -0
  340. data/lib/ruby_llm/providers/openai.rb +92 -11
  341. data/lib/ruby_llm/providers/openrouter/chat.rb +130 -108
  342. data/lib/ruby_llm/providers/openrouter/embeddings.rb +51 -0
  343. data/lib/ruby_llm/providers/openrouter/images.rb +44 -43
  344. data/lib/ruby_llm/providers/openrouter/media.rb +34 -0
  345. data/lib/ruby_llm/providers/openrouter/models.rb +50 -11
  346. data/lib/ruby_llm/providers/openrouter/speech.rb +32 -0
  347. data/lib/ruby_llm/providers/openrouter/streaming.rb +31 -38
  348. data/lib/ruby_llm/providers/openrouter/videos.rb +81 -0
  349. data/lib/ruby_llm/providers/openrouter.rb +78 -20
  350. data/lib/ruby_llm/providers/perplexity/chat.rb +2 -9
  351. data/lib/ruby_llm/providers/perplexity/embeddings.rb +32 -0
  352. data/lib/ruby_llm/providers/perplexity/media.rb +5 -21
  353. data/lib/ruby_llm/providers/perplexity/models.rb +80 -13
  354. data/lib/ruby_llm/providers/perplexity.rb +28 -20
  355. data/lib/ruby_llm/providers/vertexai/anthropic/batches.rb +52 -0
  356. data/lib/ruby_llm/providers/vertexai/anthropic.rb +34 -0
  357. data/lib/ruby_llm/providers/vertexai/capabilities.rb +19 -0
  358. data/lib/ruby_llm/providers/vertexai/chat_completions/batches.rb +54 -0
  359. data/lib/ruby_llm/providers/vertexai/chat_completions.rb +15 -0
  360. data/lib/ruby_llm/providers/vertexai/embed_content.rb +42 -0
  361. data/lib/ruby_llm/providers/vertexai/embeddings.rb +22 -7
  362. data/lib/ruby_llm/providers/vertexai/gemini/batches.rb +42 -0
  363. data/lib/ruby_llm/providers/vertexai/gemini.rb +69 -0
  364. data/lib/ruby_llm/providers/vertexai/live_transcription.rb +24 -0
  365. data/lib/ruby_llm/providers/vertexai/mistral.rb +28 -0
  366. data/lib/ruby_llm/providers/vertexai/models.rb +145 -43
  367. data/lib/ruby_llm/providers/vertexai/transcription.rb +49 -4
  368. data/lib/ruby_llm/providers/vertexai/videos.rb +61 -0
  369. data/lib/ruby_llm/providers/vertexai.rb +160 -17
  370. data/lib/ruby_llm/providers/xai/capabilities.rb +18 -0
  371. data/lib/ruby_llm/providers/xai/chat.rb +3 -2
  372. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +108 -0
  373. data/lib/ruby_llm/providers/xai/chat_completions.rb +19 -0
  374. data/lib/ruby_llm/providers/xai/images.rb +91 -0
  375. data/lib/ruby_llm/providers/xai/models.rb +32 -36
  376. data/lib/ruby_llm/providers/xai/reported_cost.rb +18 -0
  377. data/lib/ruby_llm/providers/xai/responses.rb +52 -0
  378. data/lib/ruby_llm/providers/xai/speech.rb +45 -0
  379. data/lib/ruby_llm/providers/xai/transcription.rb +48 -0
  380. data/lib/ruby_llm/providers/xai/videos.rb +87 -0
  381. data/lib/ruby_llm/providers/xai.rb +15 -5
  382. data/lib/ruby_llm/railtie.rb +7 -16
  383. data/lib/ruby_llm/rerank.rb +105 -0
  384. data/lib/ruby_llm/research_job.rb +241 -0
  385. data/lib/ruby_llm/search_results.rb +68 -0
  386. data/lib/ruby_llm/server_tool_call.rb +73 -0
  387. data/lib/ruby_llm/speech.rb +159 -0
  388. data/lib/ruby_llm/speech_chunk.rb +33 -0
  389. data/lib/ruby_llm/support/deprecator.rb +22 -0
  390. data/lib/ruby_llm/support/inspectable.rb +49 -0
  391. data/lib/ruby_llm/support/instrumentation.rb +41 -0
  392. data/lib/ruby_llm/support/utils.rb +147 -0
  393. data/lib/ruby_llm/thinking.rb +127 -20
  394. data/lib/ruby_llm/tokenization.rb +59 -0
  395. data/lib/ruby_llm/tokens.rb +103 -33
  396. data/lib/ruby_llm/tool.rb +266 -91
  397. data/lib/ruby_llm/tool_call.rb +36 -3
  398. data/lib/ruby_llm/tools/server_tools.rb +109 -0
  399. data/lib/ruby_llm/transcription/wav_audio.rb +62 -0
  400. data/lib/ruby_llm/transcription.rb +138 -13
  401. data/lib/ruby_llm/transcription_chunk.rb +68 -0
  402. data/lib/ruby_llm/transport/connection.rb +193 -0
  403. data/lib/ruby_llm/transport/error_middleware.rb +131 -0
  404. data/lib/ruby_llm/transport/usage_middleware.rb +28 -0
  405. data/lib/ruby_llm/transport/websocket_connection.rb +220 -0
  406. data/lib/ruby_llm/uploaded_file.rb +144 -0
  407. data/lib/ruby_llm/version.rb +2 -1
  408. data/lib/ruby_llm/video.rb +136 -0
  409. data/lib/ruby_llm/video_job.rb +150 -0
  410. data/lib/ruby_llm/workflow.rb +91 -0
  411. data/lib/ruby_llm.rb +380 -6
  412. data/lib/tasks/ruby_llm.rake +21 -16
  413. data/skills/rubyllm/SKILL.md +81 -0
  414. data/skills/rubyllm/agents/openai.yaml +4 -0
  415. metadata +339 -97
  416. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  417. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +0 -39
  418. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  419. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +0 -3
  420. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +0 -3
  421. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  422. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  423. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  424. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  425. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  426. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  427. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  428. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  429. data/lib/ruby_llm/active_record/acts_as_legacy.rb +0 -597
  430. data/lib/ruby_llm/active_record/model_methods.rb +0 -82
  431. data/lib/ruby_llm/active_record/tool_call_methods.rb +0 -18
  432. data/lib/ruby_llm/aliases.rb +0 -41
  433. data/lib/ruby_llm/connection.rb +0 -159
  434. data/lib/ruby_llm/content.rb +0 -91
  435. data/lib/ruby_llm/deprecator.rb +0 -24
  436. data/lib/ruby_llm/error_middleware.rb +0 -81
  437. data/lib/ruby_llm/instrumentation.rb +0 -36
  438. data/lib/ruby_llm/mime_type.rb +0 -96
  439. data/lib/ruby_llm/model/info.rb +0 -164
  440. data/lib/ruby_llm/model_registry.rb +0 -39
  441. data/lib/ruby_llm/models_schema.json +0 -171
  442. data/lib/ruby_llm/providers/anthropic/chat.rb +0 -291
  443. data/lib/ruby_llm/providers/anthropic/content.rb +0 -44
  444. data/lib/ruby_llm/providers/anthropic/embeddings.rb +0 -20
  445. data/lib/ruby_llm/providers/anthropic/media.rb +0 -92
  446. data/lib/ruby_llm/providers/anthropic/models.rb +0 -59
  447. data/lib/ruby_llm/providers/anthropic/streaming.rb +0 -71
  448. data/lib/ruby_llm/providers/bedrock/chat.rb +0 -405
  449. data/lib/ruby_llm/providers/bedrock/media.rb +0 -108
  450. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -328
  451. data/lib/ruby_llm/providers/gemini/chat.rb +0 -542
  452. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -37
  453. data/lib/ruby_llm/providers/gemini/images.rb +0 -47
  454. data/lib/ruby_llm/providers/gemini/models.rb +0 -38
  455. data/lib/ruby_llm/providers/gemini/streaming.rb +0 -98
  456. data/lib/ruby_llm/providers/gemini/tools.rb +0 -234
  457. data/lib/ruby_llm/providers/gpustack/capabilities.rb +0 -20
  458. data/lib/ruby_llm/providers/ollama/capabilities.rb +0 -20
  459. data/lib/ruby_llm/providers/openai/chat.rb +0 -236
  460. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -33
  461. data/lib/ruby_llm/providers/openai/images.rb +0 -90
  462. data/lib/ruby_llm/providers/openai/moderation.rb +0 -34
  463. data/lib/ruby_llm/providers/openai/streaming.rb +0 -55
  464. data/lib/ruby_llm/providers/openai/temperature.rb +0 -28
  465. data/lib/ruby_llm/providers/openai/transcription.rb +0 -71
  466. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -72
  467. data/lib/ruby_llm/providers/vertexai/chat.rb +0 -14
  468. data/lib/ruby_llm/providers/vertexai/streaming.rb +0 -14
  469. data/lib/ruby_llm/stream_accumulator.rb +0 -218
  470. data/lib/ruby_llm/streaming.rb +0 -179
  471. data/lib/ruby_llm/tool_concurrency.rb +0 -105
  472. data/lib/ruby_llm/utils.rb +0 -130
  473. data/lib/tasks/models.rake +0 -593
  474. data/lib/tasks/release.rake +0 -94
  475. data/lib/tasks/vcr.rake +0 -124
@@ -1,484 +1,1007 @@
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
57
+
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
20
109
 
21
- if self.class.model_association_name == :model
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
151
+ model&.provider || @pending_provider
42
152
  end
43
153
 
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.to_h
69
- m.pricing = model_info.pricing.to_h
70
- m.metadata = model_info.metadata || {}
71
- end
72
-
73
- self.model_association = model_record
74
- @model_string = nil
75
- @provider_string = nil
76
- end
77
-
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)
272
778
 
273
- @chat.before_message { persist_new_message }
274
- @chat.after_message { |msg| persist_message_completion(msg) }
779
+ associations = %i[ruby_llm_tool_calls ruby_llm_parent_tool_call ruby_llm_usages]
780
+ associations << { attachments_attachments: :blob } if attachment_association?(assoc.klass)
275
781
 
276
- @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
277
- @chat
782
+ ::ActiveRecord::Associations::Preloader.new(records: messages, associations: associations).call
783
+ messages
278
784
  end
279
785
 
280
- def replace_persisted_system_instructions(instructions)
281
- system_messages = messages_association.where(role: :system).order(:id).to_a
786
+ def attachment_association?(message_class)
787
+ message_class.reflect_on_association(:attachments_attachments).present?
788
+ end
282
789
 
283
- if system_messages.empty?
284
- messages_association.create!(role: :system, content: instructions)
285
- return
790
+ def build_llm_chat
791
+ chat = (context || RubyLLM).chat(
792
+ model: model_id,
793
+ provider: provider&.to_sym,
794
+ protocol: protocol,
795
+ assume_model_exists: assume_model_exists || false
796
+ )
797
+ sync_messages(chat)
798
+ chat.cancellation_checker = proc { consume_persisted_cancellation_request }
799
+ chat.approval_checker = proc { |tool_call| persisted_tool_call_approval(tool_call) }
800
+ install_persistence_callbacks(chat)
801
+ end
802
+
803
+ def record_tool_call_decision(tool_call, decision)
804
+ id = tool_call.respond_to?(:tool_call_id) ? tool_call.tool_call_id : tool_call
805
+ id = id.id if id.respond_to?(:id) && !id.is_a?(String)
806
+ record = find_tool_call(id)
807
+ raise ArgumentError, "Unknown tool call: #{id.inspect}" unless record
808
+
809
+ record.update!(approval: decision)
810
+ self
811
+ end
812
+
813
+ def persisted_tool_call_approval(tool_call)
814
+ record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }
815
+ return unless record&.has_attribute?(:approval)
816
+
817
+ case record.approval
818
+ when 'approved' then true
819
+ when 'denied' then false
286
820
  end
821
+ end
287
822
 
288
- primary_message = system_messages.shift
289
- primary_message.update!(content: instructions) if primary_message.content != instructions
290
- system_messages.each(&:destroy!)
823
+ def sync_messages(chat = @chat)
824
+ message_records = eager_load_messages
825
+ chat.messages = message_records
826
+ linked_entry_pairs = message_records.zip(chat.messages).flat_map do |record, message|
827
+ record.ruby_llm_usages.zip(message.ruby_llm_usage_entries)
828
+ end
829
+ linked_entries = linked_entry_pairs.to_h { |record, entry| [record.id, entry] }
830
+ chat.usage_entries = ruby_llm_usages.map do |record|
831
+ linked_entries.fetch(record.id) { record.to_entry }
832
+ end
833
+ reapply_runtime_instructions(chat)
834
+ chat
291
835
  end
292
836
 
293
- def append_instructions?(append:, replace:)
294
- return append if replace.nil?
837
+ def install_persistence_callbacks(chat)
838
+ chat.before_message { persist_new_message }
839
+ chat.usage_recorder = method(:persist_usage_entry)
840
+ chat.after_message { |msg| persist_message_completion(msg) }
841
+ chat
842
+ end
295
843
 
296
- append || (replace == false)
844
+ def clear_persisted_system_instructions
845
+ association = messages_association
846
+ association.where(role: :system).destroy_all
847
+ association.reset
297
848
  end
298
849
 
299
- def persist_system_instruction(instructions, append:)
850
+ def replace_persisted_system_instructions(instructions, cache_until_here:)
851
+ existing = messages_association.where(role: :system).order(:id).to_a
852
+ if existing.one?
853
+ update_persisted_system_instruction(existing.first, instructions, cache_until_here:)
854
+ else
855
+ clear_persisted_system_instructions
856
+ messages_association.create!(role: :system, content: instructions, cache_until_here: cache_until_here)
857
+ end
858
+ end
859
+
860
+ # Rewriting the same instructions every turn would move the system row
861
+ # behind the user messages and rebroadcast it each time.
862
+ def update_persisted_system_instruction(record, instructions, cache_until_here:)
863
+ attributes = { content: instructions }
864
+ attributes[:cache_until_here] = cache_until_here if record.has_attribute?(:cache_until_here)
865
+ changed = attributes.any? { |column, value| record[column] != value }
866
+ record.update!(attributes) if changed
867
+ messages_association.reset
868
+ record
869
+ end
870
+
871
+ def persist_system_instruction(instructions, append:, cache_until_here:)
300
872
  transaction do
301
873
  if append
302
- messages_association.create!(role: :system, content: instructions)
874
+ messages_association.create!(
875
+ role: :system,
876
+ content: instructions,
877
+ cache_until_here: cache_until_here
878
+ )
303
879
  else
304
- replace_persisted_system_instructions(instructions)
880
+ replace_persisted_system_instructions(instructions, cache_until_here:)
305
881
  end
306
882
  end
307
883
  end
308
884
 
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
885
+ def unpersisted_instructions
886
+ @unpersisted_instructions ||= []
312
887
  end
313
888
 
314
- def runtime_instructions
315
- @runtime_instructions ||= []
316
- end
889
+ def store_unpersisted_instruction(instructions, append:, cache_until_here:)
890
+ if instructions.nil?
891
+ @unpersisted_instructions = []
892
+ return
893
+ end
317
894
 
318
- def store_runtime_instruction(instructions, append:)
895
+ entry = [instructions, append, cache_until_here]
319
896
  if append
320
- runtime_instructions << instructions
897
+ unpersisted_instructions << entry
321
898
  else
322
- @runtime_instructions = [instructions]
899
+ @unpersisted_instructions = [entry]
323
900
  end
324
901
  end
325
902
 
326
903
  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) }
904
+ unpersisted_instructions.each do |instructions, append, cache_until_here|
905
+ chat.with_instructions(instructions, append:, cache_until_here:)
906
+ end
332
907
  end
333
908
 
334
909
  def persist_new_message
335
- @message = messages_association.create!(role: :assistant, content: '')
910
+ @message.destroy if blank_placeholder?
911
+
912
+ attrs = { role: :assistant, content: '' }
913
+ @message = messages_association.create!(attrs)
914
+ end
915
+
916
+ def blank_placeholder?
917
+ return false unless @message&.persisted? && @message.content.blank?
918
+ return false if @message.respond_to?(:attachments) && @message.attachments.attached?
919
+
920
+ !@message.ruby_llm_tool_calls.exists?
336
921
  end
337
922
 
338
- # rubocop:disable Metrics/PerceivedComplexity
339
923
  def persist_message_completion(message)
340
924
  return unless message
341
925
 
342
- tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
926
+ tool_call = find_tool_call(message.tool_call_id) if message.tool_call_id
927
+ attrs = message_attributes(message)
343
928
 
344
929
  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
930
  @message.assign_attributes(attrs)
370
- @message.content_raw = content_raw if @message.respond_to?(:content_raw=)
371
931
  @message.save!
932
+ tool_call&.update!(result: @message)
372
933
 
373
- persist_content(@message, attachments_to_persist) if attachments_to_persist
934
+ persist_content(@message, message.attachments) if message.attachments.any?
374
935
  persist_tool_calls(message.tool_calls) if message.tool_calls.present?
936
+ link_usage_entries(message)
375
937
  end
376
938
  end
377
- # rubocop:enable Metrics/PerceivedComplexity
378
939
 
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')
940
+ def message_attributes(message)
941
+ attrs = { role: message.role, content: message.content }
942
+ assign_supported_attribute(attrs, :thinking_text, message.thinking&.text)
943
+ assign_supported_attribute(attrs, :thinking_signature, message.thinking&.signature)
944
+ assign_supported_attribute(attrs, :citations, message.citations.map(&:to_h).presence)
945
+ assign_supported_attribute(attrs, :server_tool_calls, message.server_tool_calls.map(&:to_h).presence)
946
+ assign_supported_attribute(attrs, :raw_content, message.raw_content)
947
+ assign_supported_attribute(attrs, :raw_reasoning, message.raw_reasoning)
948
+ assign_supported_attribute(attrs, :finish_reason, message.finish_reason)
949
+ assign_supported_attribute(attrs, :cache_until_here, message.cache_until_here?)
950
+ attrs
951
+ end
382
952
 
953
+ def assign_supported_attribute(attributes, name, value)
954
+ attributes[name] = value if messages_association.klass.column_names.include?(name.to_s)
955
+ end
956
+
957
+ def persist_tool_calls(tool_calls, message_record: @message)
383
958
  tool_calls.each_value do |tool_call|
384
959
  attributes = tool_call.to_h
385
- attributes.delete(:thought_signature) unless supports_thought_signature
386
960
  attributes[:tool_call_id] = attributes.delete(:id)
387
- message_record.tool_calls_association.create!(**attributes)
961
+ message_record.ruby_llm_tool_calls.create!(**attributes)
388
962
  end
389
963
  end
390
964
 
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
965
+ def find_tool_call(tool_call_id)
966
+ RubyLLM::ActiveRecord::ToolCall.find_by(
967
+ tool_call_id: tool_call_id,
968
+ message_type: self.class.message_class.constantize.polymorphic_name,
969
+ message_id: messages_association.select(:id)
970
+ )
425
971
  end
426
972
 
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
- }
973
+ def consume_persisted_cancellation_request
974
+ if self[:cancelled]
975
+ clear_cancellation_request
976
+ return :cancelled
440
977
  end
441
- rescue StandardError => e
442
- RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
443
- nil
444
- end
978
+ return unless persisted?
979
+ return unless cancellation_poll_due?
980
+ return unless persisted_cancellation_request?
445
981
 
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
982
+ clear_cancellation_request
983
+ :cancelled
452
984
  end
453
985
 
454
- def build_content(message, attachments)
455
- return message if content_like?(message)
456
-
457
- RubyLLM::Content.new(message, attachments)
986
+ # Jobs and requests run with the query cache on, which would replay the
987
+ # first poll's answer for the rest of the run.
988
+ def persisted_cancellation_request?
989
+ self.class.uncached do
990
+ self.class.unscoped.where(self.class.primary_key => id).pick(:cancelled)
991
+ end
458
992
  end
459
993
 
460
- def content_like?(object)
461
- object.is_a?(RubyLLM::Content) || object.is_a?(RubyLLM::Content::Raw)
994
+ def clear_cancellation_request
995
+ self[:cancelled] = false
996
+ self.class.unscoped.where(self.class.primary_key => id).update_all(cancelled: false) if persisted?
462
997
  end
463
998
 
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
999
+ def cancellation_poll_due?
1000
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1001
+ return false if @last_cancellation_poll_at && now - @last_cancellation_poll_at < CANCELLATION_POLL_INTERVAL
480
1002
 
481
- [content_text, attachments, content_raw]
1003
+ @last_cancellation_poll_at = now
1004
+ true
482
1005
  end
483
1006
  end
484
1007
  end