ruby_llm 2.1.0 → 2.2.0

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 (522) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +162 -120
  3. data/app/assets/javascripts/ruby_llm/chat_message_controller.js +163 -0
  4. data/app/assets/javascripts/ruby_llm/dompurify.js +9 -0
  5. data/app/assets/javascripts/ruby_llm/marked.js +71 -0
  6. data/config/importmap.rb +5 -0
  7. data/lib/generators/ruby_llm/agent/agent_generator.rb +11 -3
  8. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -3
  9. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +201 -55
  10. data/lib/generators/ruby_llm/chat_ui/templates/agents/chat_agent.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/controllers/answers_controller.rb.tt +31 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +16 -5
  13. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +27 -9
  14. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +1 -1
  15. data/lib/generators/ruby_llm/chat_ui/templates/controllers/queued_messages_controller.rb.tt +14 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/javascript/chat_message_controller.js +1 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +5 -8
  18. data/lib/generators/ruby_llm/chat_ui/templates/jobs/resume_interrupted_chats_job.rb.tt +7 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/migrations/add_reply_error_migration.rb.tt +5 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_files.html.erb.tt +15 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +5 -7
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_model_select.html.erb.tt +7 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +32 -3
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +17 -2
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_attachments.html.erb.tt +14 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +1 -1
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +28 -18
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_queued_message.html.erb.tt +18 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +1 -1
  30. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -1
  31. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +4 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +1 -1
  33. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +4 -2
  34. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +101 -0
  35. data/lib/generators/ruby_llm/chat_ui/templates/test/agents/chat_agent_test.rb.tt +28 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/test/application_system_test_case.rb.tt +5 -0
  37. data/lib/generators/ruby_llm/chat_ui/templates/test/system/chats_test.rb.tt +240 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/answers/create.turbo_stream.erb.tt +6 -0
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_files.html.erb.tt +15 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -7
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_model_select.html.erb.tt +7 -0
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_questions.html.erb.tt +13 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +29 -3
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +16 -2
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_attachments.html.erb.tt +14 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +1 -1
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +26 -16
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_queued_message.html.erb.tt +8 -0
  49. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +1 -1
  50. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -1
  51. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +3 -2
  52. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +1 -0
  53. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +1 -1
  54. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +3 -2
  55. data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +78 -0
  56. data/lib/generators/ruby_llm/evaluation/evaluation_generator.rb +43 -0
  57. data/lib/generators/ruby_llm/evaluation/templates/evaluation.rb.tt +9 -0
  58. data/lib/generators/ruby_llm/evaluation/templates/evaluation.yml.tt +4 -0
  59. data/lib/generators/ruby_llm/generator_helpers.rb +116 -3
  60. data/lib/generators/ruby_llm/install/install_generator.rb +5 -47
  61. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -0
  62. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +5 -3
  63. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +41 -1
  64. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -1
  65. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +1 -2
  66. data/lib/generators/ruby_llm/judge/judge_generator.rb +35 -0
  67. data/lib/generators/ruby_llm/judge/templates/judge.rb.tt +2 -0
  68. data/lib/generators/ruby_llm/mcp/mcp_generator.rb +58 -0
  69. data/lib/generators/ruby_llm/mcp/templates/mcp.rb.tt +7 -0
  70. data/lib/generators/ruby_llm/provider/scaffold.rb +5 -12
  71. data/lib/generators/ruby_llm/provider/templates/core/provider.rb.erb +6 -0
  72. data/lib/generators/ruby_llm/provider/templates/gem/chat_spec.rb.erb +0 -1
  73. data/lib/generators/ruby_llm/provider/templates/gem/chat_streaming_spec.rb.erb +0 -1
  74. data/lib/generators/ruby_llm/provider/templates/gem/moderation_spec.rb.erb +1 -1
  75. data/lib/generators/ruby_llm/sandbox/sandbox_generator.rb +68 -0
  76. data/lib/generators/ruby_llm/sandbox/templates/sandbox.rb.tt +3 -0
  77. data/lib/generators/ruby_llm/skill/skill_generator.rb +48 -0
  78. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_call.html.erb.tt +1 -1
  79. data/lib/generators/ruby_llm/tool/templates/tailwind/tool_result.html.erb.tt +1 -1
  80. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +0 -3
  81. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +1 -1
  82. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +1 -1
  83. data/lib/generators/ruby_llm/tool/tool_generator.rb +1 -5
  84. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_2.rb.tt +164 -0
  85. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +41 -3
  86. data/lib/ruby_llm/accounting/operation.rb +73 -0
  87. data/lib/ruby_llm/accounting/usage.rb +74 -24
  88. data/lib/ruby_llm/active_record/acts_as.rb +78 -11
  89. data/lib/ruby_llm/active_record/attachment_helpers.rb +10 -4
  90. data/lib/ruby_llm/active_record/batch.rb +67 -9
  91. data/lib/ruby_llm/active_record/chat_agent_hooks.rb +17 -0
  92. data/lib/ruby_llm/active_record/chat_approvals.rb +71 -0
  93. data/lib/ruby_llm/active_record/chat_cancellation.rb +106 -0
  94. data/lib/ruby_llm/active_record/chat_column_guard.rb +51 -0
  95. data/lib/ruby_llm/active_record/chat_file.rb +37 -0
  96. data/lib/ruby_llm/active_record/chat_files.rb +30 -0
  97. data/lib/ruby_llm/active_record/chat_helper.rb +73 -0
  98. data/lib/ruby_llm/active_record/chat_methods.rb +522 -277
  99. data/lib/ruby_llm/active_record/chat_placeholders.rb +67 -0
  100. data/lib/ruby_llm/active_record/chat_questions.rb +177 -0
  101. data/lib/ruby_llm/active_record/chat_queue.rb +120 -0
  102. data/lib/ruby_llm/active_record/chat_reply_error.rb +35 -0
  103. data/lib/ruby_llm/active_record/chat_run.rb +51 -0
  104. data/lib/ruby_llm/active_record/chat_tool_call_state.rb +77 -0
  105. data/lib/ruby_llm/active_record/chunk_methods.rb +49 -0
  106. data/lib/ruby_llm/active_record/claim_heartbeat.rb +149 -0
  107. data/lib/ruby_llm/active_record/file_store.rb +269 -0
  108. data/lib/ruby_llm/active_record/mcp_credential.rb +24 -9
  109. data/lib/ruby_llm/active_record/message_broadcasting.rb +85 -0
  110. data/lib/ruby_llm/active_record/message_methods.rb +83 -11
  111. data/lib/ruby_llm/active_record/model.rb +37 -9
  112. data/lib/ruby_llm/active_record/provider_file.rb +21 -4
  113. data/lib/ruby_llm/active_record/queued_message.rb +50 -0
  114. data/lib/ruby_llm/active_record/record.rb +3 -1
  115. data/lib/ruby_llm/active_record/run_claims.rb +608 -0
  116. data/lib/ruby_llm/active_record/solid_queue_shutdown.rb +33 -0
  117. data/lib/ruby_llm/active_record/system_instructions.rb +30 -0
  118. data/lib/ruby_llm/active_record/tool_call.rb +55 -4
  119. data/lib/ruby_llm/active_record/usage.rb +79 -9
  120. data/lib/ruby_llm/agent/callbacks.rb +98 -0
  121. data/lib/ruby_llm/agent/context_budget.rb +26 -0
  122. data/lib/ruby_llm/agent/join_callbacks.rb +28 -0
  123. data/lib/ruby_llm/agent/model_options.rb +56 -0
  124. data/lib/ruby_llm/agent/ownership.rb +22 -0
  125. data/lib/ruby_llm/agent/tool_conversion.rb +19 -0
  126. data/lib/ruby_llm/agent.rb +620 -236
  127. data/lib/ruby_llm/agent_tool.rb +91 -0
  128. data/lib/ruby_llm/aliases.json +84 -91
  129. data/lib/ruby_llm/attachment.rb +101 -20
  130. data/lib/ruby_llm/batch.rb +132 -46
  131. data/lib/ruby_llm/cached_content.rb +4 -4
  132. data/lib/ruby_llm/chat/agent_run.rb +22 -0
  133. data/lib/ruby_llm/chat/context_budget.rb +119 -0
  134. data/lib/ruby_llm/chat/file_references.rb +259 -0
  135. data/lib/ruby_llm/chat/message_queue.rb +56 -0
  136. data/lib/ruby_llm/chat/ownership.rb +18 -0
  137. data/lib/ruby_llm/chat/rescuable.rb +48 -0
  138. data/lib/ruby_llm/chat/run.rb +32 -0
  139. data/lib/ruby_llm/chat/tool_call_ids.rb +46 -0
  140. data/lib/ruby_llm/chat/tool_call_state.rb +115 -0
  141. data/lib/ruby_llm/chat/tool_concurrency.rb +90 -49
  142. data/lib/ruby_llm/chat/tool_search.rb +36 -1
  143. data/lib/ruby_llm/chat/tool_sequences.rb +84 -0
  144. data/lib/ruby_llm/chat.rb +1598 -332
  145. data/lib/ruby_llm/chunk.rb +27 -1
  146. data/lib/ruby_llm/citation.rb +2 -1
  147. data/lib/ruby_llm/code_execution/language.rb +28 -0
  148. data/lib/ruby_llm/code_execution/node_installation.rb +56 -0
  149. data/lib/ruby_llm/code_execution/packages.rb +241 -0
  150. data/lib/ruby_llm/code_execution/ruby_installation.rb +170 -0
  151. data/lib/ruby_llm/code_execution/workspace.rb +314 -0
  152. data/lib/ruby_llm/code_execution.rb +781 -0
  153. data/lib/ruby_llm/configuration.rb +151 -4
  154. data/lib/ruby_llm/content_filter.rb +68 -0
  155. data/lib/ruby_llm/cost.rb +68 -41
  156. data/lib/ruby_llm/downloaded_file.rb +10 -9
  157. data/lib/ruby_llm/embedding.rb +30 -45
  158. data/lib/ruby_llm/embedding_request.rb +1 -1
  159. data/lib/ruby_llm/error.rb +167 -14
  160. data/lib/ruby_llm/evaluation/dataset.rb +5 -4
  161. data/lib/ruby_llm/evaluation/evidence.rb +5 -3
  162. data/lib/ruby_llm/evaluation/report.rb +7 -3
  163. data/lib/ruby_llm/evaluation/result.rb +1 -1
  164. data/lib/ruby_llm/evaluation.rb +2 -1
  165. data/lib/ruby_llm/fallback.rb +32 -4
  166. data/lib/ruby_llm/file_store/converters/ocr.rb +28 -0
  167. data/lib/ruby_llm/file_store/converters/office/markup.rb +148 -0
  168. data/lib/ruby_llm/file_store/converters/office/outline.rb +193 -0
  169. data/lib/ruby_llm/file_store/converters/office/prose.rb +161 -0
  170. data/lib/ruby_llm/file_store/converters/office/workbook.rb +187 -0
  171. data/lib/ruby_llm/file_store/converters/office/zip.rb +149 -0
  172. data/lib/ruby_llm/file_store/converters/office.rb +106 -0
  173. data/lib/ruby_llm/file_store/converters/text.rb +27 -0
  174. data/lib/ruby_llm/file_store/converters.rb +84 -0
  175. data/lib/ruby_llm/file_store/directory.rb +190 -0
  176. data/lib/ruby_llm/file_store/memory.rb +75 -0
  177. data/lib/ruby_llm/file_store/tools/base.rb +99 -0
  178. data/lib/ruby_llm/file_store/tools/delete.rb +48 -0
  179. data/lib/ruby_llm/file_store/tools/edit.rb +155 -0
  180. data/lib/ruby_llm/file_store/tools/list.rb +57 -0
  181. data/lib/ruby_llm/file_store/tools/read.rb +120 -0
  182. data/lib/ruby_llm/file_store/tools/search.rb +208 -0
  183. data/lib/ruby_llm/file_store/tools/write.rb +40 -0
  184. data/lib/ruby_llm/file_store/tools.rb +39 -0
  185. data/lib/ruby_llm/file_store.rb +338 -0
  186. data/lib/ruby_llm/files/saving.rb +14 -0
  187. data/lib/ruby_llm/image.rb +67 -45
  188. data/lib/ruby_llm/input_required_error.rb +28 -0
  189. data/lib/ruby_llm/judge/builder.rb +6 -1
  190. data/lib/ruby_llm/judge.rb +1 -1
  191. data/lib/ruby_llm/judgment.rb +6 -24
  192. data/lib/ruby_llm/mcp/client.rb +67 -28
  193. data/lib/ruby_llm/mcp/collection.rb +39 -2
  194. data/lib/ruby_llm/mcp/content.rb +7 -2
  195. data/lib/ruby_llm/mcp/error.rb +8 -2
  196. data/lib/ruby_llm/mcp/http.rb +117 -37
  197. data/lib/ruby_llm/mcp/input_request.rb +3 -112
  198. data/lib/ruby_llm/mcp/input_required_error.rb +3 -21
  199. data/lib/ruby_llm/mcp/insufficient_scope_error.rb +27 -0
  200. data/lib/ruby_llm/mcp/listener.rb +7 -1
  201. data/lib/ruby_llm/mcp/oauth.rb +102 -39
  202. data/lib/ruby_llm/mcp/outages.rb +172 -0
  203. data/lib/ruby_llm/mcp/resource.rb +9 -6
  204. data/lib/ruby_llm/mcp/resource_template.rb +3 -2
  205. data/lib/ruby_llm/mcp/session_expired.rb +10 -2
  206. data/lib/ruby_llm/mcp/stdio.rb +66 -18
  207. data/lib/ruby_llm/mcp/task.rb +13 -40
  208. data/lib/ruby_llm/mcp/tool.rb +34 -11
  209. data/lib/ruby_llm/mcp.rb +123 -59
  210. data/lib/ruby_llm/message.rb +217 -23
  211. data/lib/ruby_llm/model/modalities.rb +1 -1
  212. data/lib/ruby_llm/model/pricing.rb +26 -4
  213. data/lib/ruby_llm/model/pricing_category.rb +38 -15
  214. data/lib/ruby_llm/model/pricing_tier.rb +3 -2
  215. data/lib/ruby_llm/model.rb +24 -6
  216. data/lib/ruby_llm/models/lookup.rb +37 -4
  217. data/lib/ruby_llm/models/registry.rb +4 -19
  218. data/lib/ruby_llm/models.json +36186 -27832
  219. data/lib/ruby_llm/models.rb +44 -39
  220. data/lib/ruby_llm/moderation.rb +10 -30
  221. data/lib/ruby_llm/ocr.rb +5 -22
  222. data/lib/ruby_llm/open_telemetry/attributes.rb +68 -19
  223. data/lib/ruby_llm/open_telemetry/content.rb +105 -0
  224. data/lib/ruby_llm/open_telemetry.rb +18 -4
  225. data/lib/ruby_llm/prompt/context.rb +2 -2
  226. data/lib/ruby_llm/prompt.rb +7 -5
  227. data/lib/ruby_llm/protocol/binary_streaming.rb +2 -8
  228. data/lib/ruby_llm/protocol/cache_lifetime.rb +22 -0
  229. data/lib/ruby_llm/protocol/native_content.rb +69 -0
  230. data/lib/ruby_llm/protocol/replayed_arguments.rb +28 -0
  231. data/lib/ruby_llm/protocol/stream_accumulator.rb +73 -33
  232. data/lib/ruby_llm/protocol/streaming.rb +105 -22
  233. data/lib/ruby_llm/protocol/tool_namespaces.rb +100 -0
  234. data/lib/ruby_llm/protocol/unsupported_operations.rb +42 -0
  235. data/lib/ruby_llm/protocol.rb +134 -109
  236. data/lib/ruby_llm/protocols/anthropic/batches.rb +41 -4
  237. data/lib/ruby_llm/protocols/anthropic/block_binding.rb +30 -0
  238. data/lib/ruby_llm/protocols/anthropic/chat.rb +135 -60
  239. data/lib/ruby_llm/protocols/anthropic/files.rb +9 -0
  240. data/lib/ruby_llm/protocols/anthropic/media.rb +14 -14
  241. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -10
  242. data/lib/ruby_llm/protocols/anthropic/streaming.rb +43 -22
  243. data/lib/ruby_llm/protocols/anthropic/tools.rb +36 -24
  244. data/lib/ruby_llm/protocols/anthropic.rb +61 -6
  245. data/lib/ruby_llm/protocols/azure/speech/transcription.rb +125 -0
  246. data/lib/ruby_llm/protocols/azure/speech.rb +14 -0
  247. data/lib/ruby_llm/protocols/bedrock/batches.rb +26 -8
  248. data/lib/ruby_llm/protocols/bedrock/event_stream.rb +205 -0
  249. data/lib/ruby_llm/protocols/bedrock/files.rb +15 -6
  250. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +1 -1
  251. data/lib/ruby_llm/protocols/bedrock/rerank.rb +2 -0
  252. data/lib/ruby_llm/protocols/chat_completions/chat.rb +158 -58
  253. data/lib/ruby_llm/protocols/chat_completions/content_filters.rb +45 -0
  254. data/lib/ruby_llm/protocols/chat_completions/embedding_batches.rb +23 -2
  255. data/lib/ruby_llm/protocols/chat_completions/embeddings.rb +26 -3
  256. data/lib/ruby_llm/protocols/chat_completions/images.rb +7 -9
  257. data/lib/ruby_llm/protocols/chat_completions/media.rb +20 -20
  258. data/lib/ruby_llm/protocols/chat_completions/moderation.rb +8 -5
  259. data/lib/ruby_llm/protocols/chat_completions/speech.rb +8 -2
  260. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +38 -17
  261. data/lib/ruby_llm/protocols/chat_completions/think_tags.rb +148 -0
  262. data/lib/ruby_llm/protocols/chat_completions/tools.rb +15 -28
  263. data/lib/ruby_llm/protocols/chat_completions/transcription.rb +17 -2
  264. data/lib/ruby_llm/protocols/chat_completions.rb +4 -0
  265. data/lib/ruby_llm/protocols/cohere/batches.rb +15 -8
  266. data/lib/ruby_llm/protocols/cohere/chat.rb +47 -25
  267. data/lib/ruby_llm/protocols/cohere/datasets.rb +6 -2
  268. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -5
  269. data/lib/ruby_llm/protocols/cohere/media.rb +7 -7
  270. data/lib/ruby_llm/protocols/cohere/rerank.rb +2 -1
  271. data/lib/ruby_llm/protocols/cohere/streaming.rb +26 -11
  272. data/lib/ruby_llm/protocols/cohere/tools.rb +17 -28
  273. data/lib/ruby_llm/protocols/cohere/transcription.rb +5 -4
  274. data/lib/ruby_llm/protocols/converse/batches.rb +2 -24
  275. data/lib/ruby_llm/protocols/converse/chat.rb +210 -75
  276. data/lib/ruby_llm/protocols/converse/media.rb +24 -22
  277. data/lib/ruby_llm/protocols/converse/reasoning.rb +23 -9
  278. data/lib/ruby_llm/protocols/converse/streaming.rb +68 -220
  279. data/lib/ruby_llm/protocols/converse.rb +17 -1
  280. data/lib/ruby_llm/protocols/deepgram/speech.rb +1 -2
  281. data/lib/ruby_llm/protocols/deepgram/streaming_transcription.rb +1 -3
  282. data/lib/ruby_llm/protocols/deepgram/transcription.rb +28 -19
  283. data/lib/ruby_llm/protocols/deepgram.rb +3 -2
  284. data/lib/ruby_llm/protocols/deepseek/files.rb +1 -1
  285. data/lib/ruby_llm/protocols/elevenlabs/assets.rb +6 -4
  286. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +4 -0
  287. data/lib/ruby_llm/protocols/elevenlabs/speech.rb +3 -3
  288. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +5 -2
  289. data/lib/ruby_llm/protocols/elevenlabs/transcription.rb +6 -3
  290. data/lib/ruby_llm/protocols/files.rb +27 -12
  291. data/lib/ruby_llm/protocols/gemini/caches.rb +4 -4
  292. data/lib/ruby_llm/protocols/gemini/chat.rb +118 -51
  293. data/lib/ruby_llm/protocols/gemini/embedding_batches.rb +9 -5
  294. data/lib/ruby_llm/protocols/gemini/embeddings.rb +3 -3
  295. data/lib/ruby_llm/protocols/gemini/file_transcription.rb +4 -0
  296. data/lib/ruby_llm/protocols/gemini/files.rb +5 -4
  297. data/lib/ruby_llm/protocols/gemini/images.rb +11 -9
  298. data/lib/ruby_llm/protocols/gemini/live_transcription.rb +1 -1
  299. data/lib/ruby_llm/protocols/gemini/long_decode.rb +104 -0
  300. data/lib/ruby_llm/protocols/gemini/media.rb +31 -28
  301. data/lib/ruby_llm/protocols/gemini/retry_info.rb +29 -0
  302. data/lib/ruby_llm/protocols/gemini/schemas.rb +126 -0
  303. data/lib/ruby_llm/protocols/gemini/speech.rb +9 -3
  304. data/lib/ruby_llm/protocols/gemini/streaming.rb +37 -13
  305. data/lib/ruby_llm/protocols/gemini/tools.rb +18 -10
  306. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -9
  307. data/lib/ruby_llm/protocols/gemini/videos.rb +14 -3
  308. data/lib/ruby_llm/protocols/gemini.rb +16 -0
  309. data/lib/ruby_llm/protocols/gpustack/responses.rb +3 -3
  310. data/lib/ruby_llm/protocols/gpustack/tokenization.rb +14 -0
  311. data/lib/ruby_llm/protocols/interactions/chat.rb +29 -14
  312. data/lib/ruby_llm/protocols/interactions/content.rb +19 -14
  313. data/lib/ruby_llm/protocols/interactions/streaming.rb +1 -1
  314. data/lib/ruby_llm/protocols/interactions/tools.rb +10 -13
  315. data/lib/ruby_llm/protocols/interactions/videos.rb +99 -0
  316. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  317. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +28 -10
  318. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +1 -1
  319. data/lib/ruby_llm/protocols/invoke_model/nova_embeddings.rb +45 -11
  320. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +5 -2
  321. data/lib/ruby_llm/protocols/invoke_model/titan_multimodal_embeddings.rb +11 -3
  322. data/lib/ruby_llm/protocols/invoke_model.rb +21 -2
  323. data/lib/ruby_llm/protocols/meta/transcription/stream.rb +108 -0
  324. data/lib/ruby_llm/protocols/meta/transcription.rb +76 -0
  325. data/lib/ruby_llm/protocols/minimax/files.rb +58 -0
  326. data/lib/ruby_llm/protocols/minimax/images.rb +54 -0
  327. data/lib/ruby_llm/protocols/minimax/speech.rb +53 -0
  328. data/lib/ruby_llm/protocols/minimax/transcription.rb +82 -0
  329. data/lib/ruby_llm/protocols/minimax/videos.rb +93 -0
  330. data/lib/ruby_llm/protocols/minimax.rb +21 -0
  331. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +44 -21
  332. data/lib/ruby_llm/protocols/mistral/files.rb +6 -4
  333. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +15 -4
  334. data/lib/ruby_llm/protocols/moonshot/files.rb +48 -0
  335. data/lib/ruby_llm/protocols/openai/batches.rb +3 -2
  336. data/lib/ruby_llm/protocols/openai/decisions.rb +6 -4
  337. data/lib/ruby_llm/protocols/openai/files.rb +13 -6
  338. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -17
  339. data/lib/ruby_llm/protocols/openrouter/responses.rb +6 -10
  340. data/lib/ruby_llm/protocols/perplexity/agent.rb +19 -35
  341. data/lib/ruby_llm/protocols/perplexity/batches.rb +37 -0
  342. data/lib/ruby_llm/protocols/perplexity/files.rb +19 -9
  343. data/lib/ruby_llm/protocols/responses/approvals.rb +12 -11
  344. data/lib/ruby_llm/protocols/responses/chat.rb +165 -75
  345. data/lib/ruby_llm/protocols/responses/citation_markers.rb +167 -0
  346. data/lib/ruby_llm/protocols/responses/compaction.rb +2 -2
  347. data/lib/ruby_llm/protocols/responses/media.rb +17 -9
  348. data/lib/ruby_llm/protocols/responses/streaming.rb +71 -12
  349. data/lib/ruby_llm/protocols/responses/token_counting.rb +1 -1
  350. data/lib/ruby_llm/protocols/responses/tools.rb +48 -1
  351. data/lib/ruby_llm/protocols/responses.rb +21 -0
  352. data/lib/ruby_llm/protocols/system_one/responses.rb +9 -2
  353. data/lib/ruby_llm/protocols/vertexai/files.rb +4 -3
  354. data/lib/ruby_llm/protocols/vertexai/text_to_speech.rb +116 -0
  355. data/lib/ruby_llm/protocols/xai/files.rb +13 -6
  356. data/lib/ruby_llm/protocols/zai/files.rb +37 -0
  357. data/lib/ruby_llm/provider.rb +128 -31
  358. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  359. data/lib/ruby_llm/providers/azure/chat.rb +17 -3
  360. data/lib/ruby_llm/providers/azure/chat_completions.rb +1 -0
  361. data/lib/ruby_llm/providers/azure/decisions.rb +17 -0
  362. data/lib/ruby_llm/providers/azure/embeddings.rb +1 -2
  363. data/lib/ruby_llm/providers/azure/media.rb +6 -6
  364. data/lib/ruby_llm/providers/azure/mistral.rb +17 -0
  365. data/lib/ruby_llm/providers/azure/models.rb +16 -2
  366. data/lib/ruby_llm/providers/azure/moderation.rb +16 -0
  367. data/lib/ruby_llm/providers/azure/responses.rb +9 -0
  368. data/lib/ruby_llm/providers/azure/videos.rb +2 -2
  369. data/lib/ruby_llm/providers/azure.rb +43 -1
  370. data/lib/ruby_llm/providers/bedrock/anthropic/batches.rb +32 -0
  371. data/lib/ruby_llm/providers/bedrock/anthropic.rb +134 -0
  372. data/lib/ruby_llm/providers/bedrock/auth.rb +12 -3
  373. data/lib/ruby_llm/providers/bedrock/connection.rb +19 -0
  374. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +5 -1
  375. data/lib/ruby_llm/providers/bedrock/mantle/chat_completions.rb +2 -2
  376. data/lib/ruby_llm/providers/bedrock/mantle/responses.rb +12 -2
  377. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -1
  378. data/lib/ruby_llm/providers/bedrock/mantle.rb +7 -0
  379. data/lib/ruby_llm/providers/bedrock/models.rb +2 -9
  380. data/lib/ruby_llm/providers/bedrock.rb +51 -8
  381. data/lib/ruby_llm/providers/cohere.rb +5 -1
  382. data/lib/ruby_llm/providers/deepgram/chat.rb +25 -0
  383. data/lib/ruby_llm/providers/deepgram.rb +21 -2
  384. data/lib/ruby_llm/providers/deepseek/chat.rb +7 -16
  385. data/lib/ruby_llm/providers/deepseek/responses.rb +25 -8
  386. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  387. data/lib/ruby_llm/providers/elevenlabs.rb +2 -1
  388. data/lib/ruby_llm/providers/gemini.rb +13 -1
  389. data/lib/ruby_llm/providers/gpustack/chat.rb +23 -3
  390. data/lib/ruby_llm/providers/gpustack/embeddings.rb +2 -2
  391. data/lib/ruby_llm/providers/gpustack/media.rb +8 -8
  392. data/lib/ruby_llm/providers/gpustack/transcription.rb +14 -6
  393. data/lib/ruby_llm/providers/gpustack.rb +1 -1
  394. data/lib/ruby_llm/providers/hetzner/chat.rb +23 -6
  395. data/lib/ruby_llm/providers/hetzner.rb +5 -1
  396. data/lib/ruby_llm/providers/meta/responses.rb +81 -0
  397. data/lib/ruby_llm/providers/meta.rb +37 -0
  398. data/lib/ruby_llm/providers/minimax/anthropic.rb +95 -0
  399. data/lib/ruby_llm/providers/minimax/chat_completions.rb +48 -0
  400. data/lib/ruby_llm/providers/minimax/responses.rb +52 -0
  401. data/lib/ruby_llm/providers/minimax.rb +104 -0
  402. data/lib/ruby_llm/providers/mistral/chat.rb +34 -4
  403. data/lib/ruby_llm/providers/mistral/chat_completions/batches.rb +8 -3
  404. data/lib/ruby_llm/providers/mistral/embeddings.rb +1 -12
  405. data/lib/ruby_llm/providers/mistral/input_tokens.rb +35 -0
  406. data/lib/ruby_llm/providers/mistral/media.rb +8 -8
  407. data/lib/ruby_llm/providers/mistral/transcription.rb +8 -6
  408. data/lib/ruby_llm/providers/mistral.rb +13 -1
  409. data/lib/ruby_llm/providers/moonshot/capabilities.rb +16 -0
  410. data/lib/ruby_llm/providers/moonshot/chat.rb +100 -0
  411. data/lib/ruby_llm/providers/moonshot/documents.rb +79 -0
  412. data/lib/ruby_llm/providers/moonshot/responses.rb +55 -0
  413. data/lib/ruby_llm/providers/moonshot.rb +54 -0
  414. data/lib/ruby_llm/providers/ollama/chat.rb +5 -6
  415. data/lib/ruby_llm/providers/ollama/embeddings.rb +41 -0
  416. data/lib/ruby_llm/providers/ollama/media.rb +6 -6
  417. data/lib/ruby_llm/providers/ollama/speech.rb +24 -0
  418. data/lib/ruby_llm/providers/ollama.rb +13 -1
  419. data/lib/ruby_llm/providers/ollama_cloud.rb +22 -1
  420. data/lib/ruby_llm/providers/openai/responses.rb +8 -0
  421. data/lib/ruby_llm/providers/openai.rb +7 -3
  422. data/lib/ruby_llm/providers/openrouter/chat.rb +15 -9
  423. data/lib/ruby_llm/providers/openrouter/embeddings.rb +12 -8
  424. data/lib/ruby_llm/providers/openrouter/images.rb +16 -10
  425. data/lib/ruby_llm/providers/openrouter/judgments.rb +47 -0
  426. data/lib/ruby_llm/providers/openrouter/media.rb +5 -5
  427. data/lib/ruby_llm/providers/openrouter/models.rb +19 -7
  428. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -4
  429. data/lib/ruby_llm/providers/openrouter/videos.rb +1 -1
  430. data/lib/ruby_llm/providers/openrouter.rb +32 -5
  431. data/lib/ruby_llm/providers/perplexity/chat.rb +3 -3
  432. data/lib/ruby_llm/providers/perplexity/embeddings.rb +1 -1
  433. data/lib/ruby_llm/providers/perplexity/media.rb +6 -6
  434. data/lib/ruby_llm/providers/perplexity.rb +13 -2
  435. data/lib/ruby_llm/providers/typesafe.rb +1 -1
  436. data/lib/ruby_llm/providers/vertexai/anthropic.rb +5 -1
  437. data/lib/ruby_llm/providers/vertexai/credentials.rb +27 -3
  438. data/lib/ruby_llm/providers/vertexai/embed_content.rb +1 -1
  439. data/lib/ruby_llm/providers/vertexai/embeddings.rb +2 -3
  440. data/lib/ruby_llm/providers/vertexai/gemini.rb +1 -1
  441. data/lib/ruby_llm/providers/vertexai/mistral.rb +5 -0
  442. data/lib/ruby_llm/providers/vertexai/models.rb +24 -2
  443. data/lib/ruby_llm/providers/vertexai/transcription.rb +2 -2
  444. data/lib/ruby_llm/providers/vertexai/videos.rb +1 -1
  445. data/lib/ruby_llm/providers/vertexai.rb +56 -9
  446. data/lib/ruby_llm/providers/xai/chat.rb +3 -3
  447. data/lib/ruby_llm/providers/xai/chat_completions/batches.rb +8 -3
  448. data/lib/ruby_llm/providers/xai/images.rb +9 -4
  449. data/lib/ruby_llm/providers/xai/models.rb +2 -2
  450. data/lib/ruby_llm/providers/xai/responses.rb +10 -0
  451. data/lib/ruby_llm/providers/xai/speech.rb +2 -2
  452. data/lib/ruby_llm/providers/xai/transcription.rb +14 -9
  453. data/lib/ruby_llm/providers/xai/videos.rb +1 -1
  454. data/lib/ruby_llm/providers/xai.rb +9 -1
  455. data/lib/ruby_llm/providers/zai/chat.rb +117 -0
  456. data/lib/ruby_llm/providers/zai/ocr.rb +65 -0
  457. data/lib/ruby_llm/providers/zai/token_counting.rb +25 -0
  458. data/lib/ruby_llm/providers/zai/videos.rb +67 -0
  459. data/lib/ruby_llm/providers/zai.rb +66 -0
  460. data/lib/ruby_llm/question.rb +390 -0
  461. data/lib/ruby_llm/queued_message.rb +74 -0
  462. data/lib/ruby_llm/railtie.rb +43 -0
  463. data/lib/ruby_llm/request_shape/part.rb +2 -2
  464. data/lib/ruby_llm/request_shape/problem.rb +2 -2
  465. data/lib/ruby_llm/request_shape/tool_round.rb +2 -2
  466. data/lib/ruby_llm/request_shape/turn.rb +2 -2
  467. data/lib/ruby_llm/request_shape.rb +1 -1
  468. data/lib/ruby_llm/rerank.rb +10 -37
  469. data/lib/ruby_llm/research_job.rb +26 -13
  470. data/lib/ruby_llm/sandbox/landlock.rb +78 -0
  471. data/lib/ruby_llm/sandbox/mxc.rb +137 -0
  472. data/lib/ruby_llm/sandbox.rb +141 -0
  473. data/lib/ruby_llm/search_results.rb +9 -4
  474. data/lib/ruby_llm/server_tool_call.rb +1 -1
  475. data/lib/ruby_llm/skill/loader.rb +131 -0
  476. data/lib/ruby_llm/skill.rb +263 -0
  477. data/lib/ruby_llm/speech.rb +25 -53
  478. data/lib/ruby_llm/stored_file.rb +94 -0
  479. data/lib/ruby_llm/support/cancellation.rb +4 -4
  480. data/lib/ruby_llm/support/deprecated_enumerable.rb +13 -0
  481. data/lib/ruby_llm/support/deprecated_job_predicates.rb +24 -0
  482. data/lib/ruby_llm/support/inspectable.rb +1 -1
  483. data/lib/ruby_llm/support/instrumentation.rb +6 -5
  484. data/lib/ruby_llm/support/rescuable.rb +84 -0
  485. data/lib/ruby_llm/support/utils.rb +26 -3
  486. data/lib/ruby_llm/task.rb +126 -0
  487. data/lib/ruby_llm/test_helper.rb +129 -0
  488. data/lib/ruby_llm/thinking.rb +3 -3
  489. data/lib/ruby_llm/tokens.rb +52 -12
  490. data/lib/ruby_llm/tool.rb +339 -5
  491. data/lib/ruby_llm/tool_call/collection.rb +132 -0
  492. data/lib/ruby_llm/tool_call.rb +68 -6
  493. data/lib/ruby_llm/tools/provider_tools.rb +23 -0
  494. data/lib/ruby_llm/tools/search.rb +118 -0
  495. data/lib/ruby_llm/transcription/segment.rb +67 -0
  496. data/lib/ruby_llm/transcription/wav_audio.rb +2 -1
  497. data/lib/ruby_llm/transcription.rb +25 -49
  498. data/lib/ruby_llm/transcription_chunk.rb +9 -9
  499. data/lib/ruby_llm/transport/connection.rb +146 -33
  500. data/lib/ruby_llm/transport/error_middleware.rb +94 -49
  501. data/lib/ruby_llm/transport/event_stream_parser.rb +18 -4
  502. data/lib/ruby_llm/transport/json_response.rb +9 -3
  503. data/lib/ruby_llm/transport/log_formatter.rb +20 -0
  504. data/lib/ruby_llm/transport/release_request.rb +35 -0
  505. data/lib/ruby_llm/transport/websocket_connection.rb +26 -30
  506. data/lib/ruby_llm/uploaded_file.rb +15 -6
  507. data/lib/ruby_llm/version.rb +1 -1
  508. data/lib/ruby_llm/video.rb +34 -10
  509. data/lib/ruby_llm/video_job.rb +55 -42
  510. data/lib/ruby_llm/web_source.rb +38 -0
  511. data/lib/ruby_llm/workflow.rb +2 -2
  512. data/lib/ruby_llm.rb +63 -25
  513. data/lib/tasks/ruby_llm/evaluations.rb +1 -1
  514. data/skills/rubyllm/SKILL.md +128 -54
  515. data/skills/rubyllm/references/cost-and-tracing.md +8 -0
  516. data/skills/rubyllm/references/files-code-and-context.md +6 -0
  517. data/skills/rubyllm/references/operations.md +8 -0
  518. data/skills/rubyllm/references/rails-chats.md +22 -0
  519. data/skills/rubyllm/references/tools-and-agents.md +11 -0
  520. metadata +219 -14
  521. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  522. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +0 -78
@@ -7,7 +7,8 @@ module RubyLLM
7
7
  # Rails integration brings the RubyLLM API to Active Record models, with
8
8
  # Active Storage attachments and a persisted message lifecycle for Hotwire
9
9
  # streaming and background jobs. Applications own chats and messages;
10
- # RubyLLM owns the supporting usage, tool-call, registry, and batch records.
10
+ # RubyLLM owns the supporting records (see Record), which applications
11
+ # read and query.
11
12
  module ActiveRecord
12
13
  # Adds the RubyLLM conversation API to application-owned records.
13
14
  #
@@ -17,10 +18,9 @@ module RubyLLM
17
18
  #
18
19
  # class Message < ApplicationRecord
19
20
  # acts_as_message
20
- # has_many_attached :attachments
21
21
  # end
22
22
  #
23
- # chat = Chat.create!(model: "gpt-5.6-luna")
23
+ # chat = Chat.create!(model: "gpt-6-luna")
24
24
  # response = chat.ask "Summarize this report.", with: report.document
25
25
  # response.content
26
26
  # chat.cost.total
@@ -40,7 +40,7 @@ module RubyLLM
40
40
  ##
41
41
  # :singleton-method: acts_as_chat
42
42
  # :call-seq:
43
- # acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil)
43
+ # acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, owner: nil)
44
44
  #
45
45
  # Turns an application record into a persisted RubyLLM chat.
46
46
  #
@@ -50,19 +50,34 @@ module RubyLLM
50
50
  #
51
51
  # Adds the RubyLLM::Chat API (see ChatMethods), a +has_many+
52
52
  # association to the messages, a +belongs_to :model+ association to
53
- # the registry row, and a +has_many :ruby_llm_usages+ association over
54
- # the attempts the chat paid for.
53
+ # the registry row, a +has_many :ruby_llm_usages+ association over
54
+ # the attempts the chat paid for, a
55
+ # +has_many :ruby_llm_queued_messages+ association over the messages
56
+ # waiting in its queue (see ChatMethods#queue), and a
57
+ # +has_many :ruby_llm_chat_files+ association over its files (see
58
+ # ChatFiles#with_file_store).
55
59
  #
56
60
  # Options:
57
61
  #
58
62
  # [+messages:+] Name of the messages association. Defaults to +:messages+.
59
63
  # [+message_class:+] Class of the messages. Inferred from the association name.
60
64
  # [+messages_foreign_key:+] Foreign key on the messages table. Inferred by Rails.
65
+ # [+owner:+] Name of the association whose record the chat works for,
66
+ # such as +:user+. Every attempt the chat makes is
67
+ # attributed to it in the usage ledger, and the Active
68
+ # Storage file store keeps the chat's files for it (see
69
+ # ChatFiles#with_file_store). Defaults to +nil+, which
70
+ # leaves usage to RubyLLM.with_owner.
71
+ #
72
+ # class Chat < ApplicationRecord
73
+ # belongs_to :user
74
+ # acts_as_chat owner: :user
75
+ # end
61
76
 
62
77
  ##
63
78
  # :singleton-method: acts_as_message
64
79
  # :call-seq:
65
- # acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false)
80
+ # acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false, broadcasts: nil)
66
81
  #
67
82
  # Turns an application record into a persisted RubyLLM message.
68
83
  #
@@ -71,8 +86,15 @@ module RubyLLM
71
86
  # end
72
87
  #
73
88
  # Adds the RubyLLM::Message readers (see MessageMethods), a
74
- # +belongs_to+ association to the chat, and the tool-call and usage
75
- # associations behind +tool_calls+, +tokens+, and +cost+.
89
+ # +belongs_to+ association to the chat, the associations behind
90
+ # +tool_calls+, +tokens+, and +cost+ (+has_many :ruby_llm_tool_calls+
91
+ # over the ToolCall rows it requested, +has_one :ruby_llm_parent_tool_call+
92
+ # for the call a tool message answers, and +has_many :ruby_llm_usages+
93
+ # over its Usage rows), and
94
+ # <tt>has_many_attached :attachments</tt>, so Active Storage is
95
+ # required. When turbo-rails and Action Cable are loaded, it also
96
+ # broadcasts the chat's messages with Turbo when
97
+ # Configuration#broadcast_messages is on (see MessageBroadcasting).
76
98
  #
77
99
  # Options:
78
100
  #
@@ -80,14 +102,21 @@ module RubyLLM
80
102
  # [+chat_class:+] Class of the chat. Inferred from the association name.
81
103
  # [+chat_foreign_key:+] Foreign key on this table. Inferred by Rails.
82
104
  # [+touch_chat:+] Touch the chat when a message is saved. Defaults to +false+.
105
+ # [+broadcasts:+] Broadcast messages with Turbo. Defaults to +nil+,
106
+ # which follows Configuration#broadcast_messages.
107
+ # Overrides it with +true+ or +false+.
83
108
 
84
109
  class_methods do # rubocop:disable Metrics/BlockLength
85
- def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil)
110
+ def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, owner: nil)
86
111
  include RubyLLM::ActiveRecord::ChatMethods
112
+ include RubyLLM::ActiveRecord::ChatFiles
113
+ singleton_class.prepend RubyLLM::ActiveRecord::ChatColumnGuard
87
114
 
88
115
  class_attribute :messages_association_name, :message_class
116
+ class_attribute :owner_association_name, instance_accessor: false
89
117
  self.messages_association_name = messages
90
118
  self.message_class = (message_class || messages.to_s.classify).to_s
119
+ self.owner_association_name = owner&.to_sym
91
120
 
92
121
  has_many messages,
93
122
  -> { order(:created_at, :id) },
@@ -105,10 +134,27 @@ module RubyLLM
105
134
  as: :chat,
106
135
  class_name: 'RubyLLM::ActiveRecord::Usage',
107
136
  inverse_of: :chat,
137
+ dependent: :nullify
138
+
139
+ has_many :ruby_llm_queued_messages,
140
+ -> { order(:created_at, :id) },
141
+ as: :chat,
142
+ class_name: 'RubyLLM::ActiveRecord::QueuedMessage',
143
+ inverse_of: :chat,
108
144
  dependent: :destroy
145
+
146
+ has_many :ruby_llm_chat_files,
147
+ -> { order(:path, :id) },
148
+ as: :chat,
149
+ class_name: 'RubyLLM::ActiveRecord::ChatFile',
150
+ inverse_of: :chat,
151
+ dependent: :destroy
152
+
153
+ RubyLLM::ActiveRecord::QueuedMessage.declare_attachments
154
+ RubyLLM::ActiveRecord::ChatFile.declare_attachments
109
155
  end
110
156
 
111
- def acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false)
157
+ def acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false, broadcasts: nil)
112
158
  include RubyLLM::ActiveRecord::MessageMethods
113
159
 
114
160
  class_attribute :chat_association_name, :chat_class
@@ -137,6 +183,27 @@ module RubyLLM
137
183
  class_name: 'RubyLLM::ActiveRecord::Usage',
138
184
  inverse_of: :message,
139
185
  dependent: :nullify
186
+
187
+ ruby_llm_attachments
188
+ ruby_llm_broadcasting(broadcasts)
189
+ end
190
+
191
+ private
192
+
193
+ def ruby_llm_attachments
194
+ if respond_to?(:has_many_attached)
195
+ has_many_attached :attachments unless reflect_on_attachment(:attachments)
196
+ else
197
+ RubyLLM.logger.warn "#{name} has no attachments because Active Storage is not loaded. " \
198
+ 'Load it with require "active_storage/engine" in config/application.rb, ' \
199
+ 'then run bin/rails active_storage:install and bin/rails db:migrate.'
200
+ end
201
+ end
202
+
203
+ def ruby_llm_broadcasting(broadcasts)
204
+ class_attribute :ruby_llm_broadcasts, instance_writer: false
205
+ self.ruby_llm_broadcasts = broadcasts
206
+ include RubyLLM::ActiveRecord::MessageBroadcasting if broadcasts || MessageBroadcasting.available?
140
207
  end
141
208
  end
142
209
  end
@@ -8,7 +8,11 @@ module RubyLLM
8
8
  private
9
9
 
10
10
  def persist_content(message_record, attachments)
11
- return unless message_record.respond_to?(:attachments)
11
+ unless message_record.respond_to?(:attachments)
12
+ raise RubyLLM::ConfigurationError,
13
+ "#{message_record.class.name} cannot store attachments without Active Storage. " \
14
+ 'Load it with require "active_storage/engine", then run bin/rails active_storage:install.'
15
+ end
12
16
 
13
17
  attachables = prepare_for_active_storage(attachments)
14
18
  return if attachables.empty?
@@ -61,7 +65,7 @@ module RubyLLM
61
65
  io: StringIO.new(attachment.content),
62
66
  filename: attachment.filename,
63
67
  content_type: attachment.mime_type,
64
- metadata: { resolution: attachment.resolution&.to_s }.compact
68
+ metadata: { resolution: attachment.resolution&.to_s, processing: attachment.processing&.to_s }.compact
65
69
  }
66
70
  end
67
71
  rescue StandardError => e
@@ -233,7 +237,8 @@ module RubyLLM
233
237
  stored = if pending_upload_attachable?(attachable)
234
238
  pending_upload_attachment(attachable)
235
239
  else
236
- RubyLLM::Attachment.new(attachment, resolution: attachment.metadata['resolution']&.to_sym)
240
+ RubyLLM::Attachment.new(attachment, resolution: attachment.metadata['resolution']&.to_sym,
241
+ processing: attachment.metadata['processing']&.to_sym)
237
242
  end
238
243
  stored.provider_file_store = ProviderFile.store { attachment.blob&.key }
239
244
  stored
@@ -264,7 +269,8 @@ module RubyLLM
264
269
  if attachable.is_a?(Hash)
265
270
  RubyLLM::Attachment.new(attachment_hash_io(attachable),
266
271
  filename: attachment_hash_filename(attachable),
267
- resolution: attachable.dig(:metadata, :resolution)&.to_sym)
272
+ resolution: attachable.dig(:metadata, :resolution)&.to_sym,
273
+ processing: attachable.dig(:metadata, :processing)&.to_sym)
268
274
  else
269
275
  RubyLLM::Attachment.new(attachable)
270
276
  end
@@ -2,17 +2,37 @@
2
2
 
3
3
  module RubyLLM
4
4
  module ActiveRecord
5
- # RubyLLM's private persistence for provider-side batches.
6
- class Batch < Record # :nodoc:
5
+ # A provider-side batch of persisted chats, in RubyLLM's
6
+ # +ruby_llm_batches+ table. RubyLLM.batch writes a row when it submits
7
+ # persisted chats of one class, and RubyLLM::Batch.find reads it back
8
+ # in any process.
9
+ #
10
+ # RubyLLM::ActiveRecord::Batch.where(completed: false).pluck(:provider_batch_id)
11
+ #
12
+ # Its columns:
13
+ #
14
+ # [+provider_batch_id+] The provider's id for the batch.
15
+ # [+provider+] The provider's slug.
16
+ # [+status+] <tt>"pending"</tt>, <tt>"succeeded"</tt>, <tt>"failed"</tt>, or <tt>"cancelled"</tt>.
17
+ # [+raw_status+] The status as the provider last reported it.
18
+ # [+completed+] Whether the provider has finished the batch.
19
+ # [+request_counts+] The provider's request counts, as a Hash.
20
+ # [+reported_cost+] The cost the provider reported, as a Hash.
21
+ # [+chat_type+, +chat_ids+] The class and ids of the submitted chats.
22
+ #
23
+ # RubyLLM writes these rows. Read and query them, and add scopes and
24
+ # columns of your own, but do not create or update them.
25
+ # Use RubyLLM::Batch to refresh a batch and deliver its results.
26
+ class Batch < Record
7
27
  self.table_name = 'ruby_llm_batches'
8
28
 
9
- STATUSES = %w[pending succeeded failed cancelled].freeze
29
+ STATUSES = %w[pending succeeded failed cancelled].freeze # :nodoc:
10
30
 
11
31
  validates :provider_batch_id, :provider, :status, presence: true
12
32
  validates :status, inclusion: { in: STATUSES }
13
33
 
14
34
  class << self
15
- def persist(batch, chats)
35
+ def persist(batch, chats) # :nodoc:
16
36
  records = Array(chats)
17
37
  return unless table_exists?
18
38
  return unless persistable_chats?(records)
@@ -31,11 +51,11 @@ module RubyLLM
31
51
  )
32
52
  end
33
53
 
34
- def fetch(id, provider: nil, context: nil)
54
+ def fetch(id, provider: nil, context: nil) # :nodoc:
35
55
  find_record(id, provider:)&.to_llm(context:, store: self)
36
56
  end
37
57
 
38
- def sync(batch)
58
+ def sync(batch) # :nodoc:
39
59
  find_record(batch.id, provider: batch.provider)&.sync_from(batch)
40
60
  end
41
61
 
@@ -58,6 +78,8 @@ module RubyLLM
58
78
  end
59
79
  end
60
80
 
81
+ # Returns the submitted chat records in submission order, with +nil+
82
+ # for a chat that no longer exists.
61
83
  def chats
62
84
  klass = chat_type.constantize
63
85
  by_id = klass.where(klass.primary_key => Array(chat_ids))
@@ -65,12 +87,15 @@ module RubyLLM
65
87
  Array(chat_ids).map { |id| by_id[id] }
66
88
  end
67
89
 
68
- def to_llm(context: nil, store: nil)
90
+ def to_llm(context: nil, store: nil) # :nodoc:
69
91
  config = context&.config || RubyLLM.config
70
92
  provider_instance = RubyLLM::Provider.resolve!(provider).new(config)
93
+ records = chats
71
94
  RubyLLM::Batch.new(
72
95
  provider: provider_instance,
73
- chats: chats.map { |chat| chat&.to_llm },
96
+ chats: records.map { |chat| chat&.to_llm },
97
+ delivered: records.each_index.select { |index| answered?(records[index]) },
98
+ delivery: ->(index, failed: false, &deliver) { deliver_once(records[index], failed:, &deliver) },
74
99
  id: provider_batch_id,
75
100
  raw_status: raw_status,
76
101
  completed: completed,
@@ -81,7 +106,7 @@ module RubyLLM
81
106
  )
82
107
  end
83
108
 
84
- def sync_from(batch)
109
+ def sync_from(batch) # :nodoc:
85
110
  attributes = {
86
111
  status: batch.status,
87
112
  raw_status: batch.raw_status,
@@ -92,6 +117,39 @@ module RubyLLM
92
117
  attributes[:reported_cost] = batch.reported_cost.to_h if batch.reported_cost
93
118
  update!(attributes)
94
119
  end
120
+
121
+ private
122
+
123
+ # Two pollers can load a batch before either delivers, so the check
124
+ # is repeated once this one holds the chat.
125
+ def deliver_once(chat, failed: false)
126
+ return yield.then { true } unless chat
127
+
128
+ chat.claiming_delivery do
129
+ yield unless failed ? billed_failure?(chat) : answered?(chat)
130
+ true
131
+ end
132
+ end
133
+
134
+ # A failed slot adds no message, so its usage row is what marks it.
135
+ def billed_failure?(chat)
136
+ usages = chat.ruby_llm_usages
137
+ usages.klass.uncached do
138
+ usages.where(status: 'failed').where(usages.klass.arel_table[:created_at].gt(created_at)).exists?
139
+ end
140
+ end
141
+
142
+ # The chat was awaiting the model when the batch was submitted, so an
143
+ # assistant message newer than the batch answers that turn, whatever
144
+ # the chat went on to stage after it.
145
+ def answered?(chat)
146
+ return false unless chat
147
+
148
+ messages = chat.messages_association
149
+ messages.klass.uncached do
150
+ messages.where(role: :assistant).where(messages.klass.arel_table[:created_at].gt(created_at)).exists?
151
+ end
152
+ end
95
153
  end
96
154
  end
97
155
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # What the Agent that configured a persisted chat adds to its
6
+ # operations: its rescue_from handlers, and one +agent.ruby_llm+ event
7
+ # around each run, shared with the chat the record wraps.
8
+ module ChatAgentHooks # :nodoc:
9
+ include RubyLLM::Chat::AgentRun
10
+ include RubyLLM::Chat::Rescuable
11
+
12
+ private
13
+
14
+ def agent_run_chat = @chat
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Keeps approval decisions on tool call records, read outside the query
6
+ # cache, so every process decides tool calls the same way. A call
7
+ # approved with <tt>remember: true</tt> is recorded as +remembered+,
8
+ # and later calls to the same tool in the chat run without asking.
9
+ # A call saved while it requires approval is recorded as +pending+, so
10
+ # pending approvals are read from the rows without building the chat's
11
+ # tools.
12
+ module ChatApprovals # :nodoc:
13
+ def persisted_tool_call_approval(tool_call) # :nodoc:
14
+ record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }
15
+ return unless record&.has_attribute?(:approval)
16
+
17
+ case record.approval
18
+ when 'approved', 'remembered' then true
19
+ when 'denied' then false
20
+ end
21
+ end
22
+
23
+ def persisted_remembered_tool?(call) # :nodoc:
24
+ return false unless persisted? && row_supports_approval?
25
+
26
+ RubyLLM::ActiveRecord::ToolCall.uncached do
27
+ identity = tool_identity.to_h { |column| [column, call.public_send(column)] }
28
+ chat_tool_calls.where(**identity, approval: 'remembered').exists?
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def persisted_pending_approvals
35
+ pending = chat_tool_calls.where(approval: 'pending', result_id: nil)
36
+ remembered = RubyLLM::ActiveRecord::ToolCall.uncached do
37
+ chat_tool_calls.where(approval: 'remembered').distinct.pluck(*tool_identity)
38
+ end
39
+ remembered.reduce(pending) do |calls, identity|
40
+ calls.where.not(id: pending.where(tool_identity.zip(Array(identity)).to_h).select(:id))
41
+ end.order(:created_at, :id).skip_query_cache!
42
+ end
43
+
44
+ def tool_identity
45
+ RubyLLM::ActiveRecord::ToolCall.column_names.include?('namespace') ? %i[namespace name] : %i[name]
46
+ end
47
+
48
+ def chat_tool_calls
49
+ RubyLLM::ActiveRecord::ToolCall.where(
50
+ message_type: messages_association.klass.polymorphic_name,
51
+ message_id: messages_association.select(:id)
52
+ )
53
+ end
54
+
55
+ def record_tool_call_decision(tool_call, decision)
56
+ record = tool_call_record(tool_call)
57
+ decide_persisted_tool_call(record, decision)
58
+ record
59
+ end
60
+
61
+ def tool_call_record(tool_call)
62
+ id = tool_call.respond_to?(:tool_call_id) ? tool_call.tool_call_id : tool_call
63
+ id = id.id if id.respond_to?(:id) && !id.is_a?(String)
64
+ record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(id) }
65
+ raise ArgumentError, "Unknown tool call: #{id.inspect}" unless record
66
+
67
+ record
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Reads a cancellation request another process persisted on the chat
6
+ # record, at most once per poll interval, and clears it once observed.
7
+ # A request is for the run holding the chat, or for the run about to
8
+ # answer what waits. New work recorded while no run holds the chat, and
9
+ # a run that finds nothing waiting, clear a request that is left over.
10
+ module ChatCancellation # :nodoc:
11
+ def poll_run_cancellation # :nodoc:
12
+ renew_run_claim
13
+ consume_persisted_cancellation_request
14
+ end
15
+
16
+ def forget_stale_cancellation # :nodoc:
17
+ return if @run_claim_token || !run_claims? || !has_attribute?(:cancelled)
18
+
19
+ requested = unclaimed(chat_row.where(cancelled: true), Time.current)
20
+ remember_cancellation(false) if requested.update_all(cancelled: false) == 1
21
+ end
22
+
23
+ private
24
+
25
+ def request_cancellation
26
+ return request_run_cancellation if run_claims?
27
+
28
+ persisted? ? update_column(:cancelled, true) : self[:cancelled] = true
29
+ @chat&.cancel
30
+ end
31
+
32
+ # The conditional update can't land after the run's release, which
33
+ # clears the request, so it can't outlive the run it was meant for.
34
+ def request_run_cancellation
35
+ claimed_until = chat_row.arel_table[:claimed_until]
36
+ if chat_row.where(claimed_until.gt(Time.current)).update_all(cancelled: true) == 1
37
+ remember_cancellation(true)
38
+ @chat&.cancel if @run_claim_token
39
+ elsif !nothing_to_stop?
40
+ update_column(:cancelled, true)
41
+ end
42
+ end
43
+
44
+ def nothing_to_stop?
45
+ return false unless ChatQueue.new(self).none?
46
+
47
+ messages_association.klass.uncached do
48
+ latest = messages_association.where.not(role: :system).reorder(created_at: :desc, id: :desc).first
49
+ latest.nil? || finished_reply?(latest)
50
+ end
51
+ end
52
+
53
+ def finished_reply?(message)
54
+ message.role.to_s == 'assistant' && message.content.present? &&
55
+ !unfinished_record?(message) && !message.tool_call?
56
+ end
57
+
58
+ def settle_cancellation_request
59
+ return unless @run_claim_token && has_attribute?(:cancelled)
60
+
61
+ remember_cancellation(self.class.uncached { chat_row.pick(:cancelled) })
62
+ clear_cancellation_request if self[:cancelled] && to_llm.complete? && ChatQueue.new(self).none?
63
+ end
64
+
65
+ def remember_cancellation(requested)
66
+ self[:cancelled] = requested
67
+ clear_attribute_changes([:cancelled])
68
+ end
69
+
70
+ def consume_persisted_cancellation_request
71
+ if self[:cancelled]
72
+ clear_cancellation_request
73
+ return :cancelled
74
+ end
75
+ return unless persisted?
76
+ return unless cancellation_poll_due?
77
+ return unless persisted_cancellation_request?
78
+
79
+ clear_cancellation_request
80
+ :cancelled
81
+ end
82
+
83
+ # Jobs and requests run with the query cache on, which would replay the
84
+ # first poll's answer for the rest of the run.
85
+ def persisted_cancellation_request?
86
+ self.class.uncached do
87
+ self.class.unscoped.where(self.class.primary_key => id).pick(:cancelled)
88
+ end
89
+ end
90
+
91
+ def clear_cancellation_request
92
+ self[:cancelled] = false
93
+ self.class.unscoped.where(self.class.primary_key => id).update_all(cancelled: false) if persisted?
94
+ end
95
+
96
+ def cancellation_poll_due?
97
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ interval = ChatMethods::CANCELLATION_POLL_INTERVAL
99
+ return false if @last_cancellation_poll_at && now - @last_cancellation_poll_at < interval
100
+
101
+ @last_cancellation_poll_at = now
102
+ true
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Refuses a chat table with a column, or a chat model with an
6
+ # association, that a RubyLLM reader on the record would hide, such as
7
+ # a +temperature+ column or a +tools+ association: reading it would
8
+ # return RubyLLM's setting instead of the stored value. The check runs
9
+ # when the class loads its columns, so it needs no database at boot. A
10
+ # reader hides a column or an association when it comes from
11
+ # ChatMethods or ChatFiles, or a module they bring along, such as
12
+ # Enumerable.
13
+ module ChatColumnGuard # :nodoc:
14
+ OWNERS = (ChatMethods.ancestors | ChatFiles.ancestors).freeze
15
+
16
+ private
17
+
18
+ def load_schema!
19
+ super
20
+ hidden = columns_hash.keys.select { |column| hidden_by_ruby_llm?(column) }
21
+ raise ArgumentError, hidden_columns_message(hidden) if hidden.any?
22
+
23
+ associations = reflect_on_all_associations.select do |association|
24
+ association.name.to_s != messages_association_name.to_s && hidden_by_ruby_llm?(association.name)
25
+ end
26
+ raise ArgumentError, hidden_associations_message(associations) if associations.any?
27
+ end
28
+
29
+ def hidden_by_ruby_llm?(name)
30
+ method_defined?(name) && OWNERS.include?(instance_method(name).owner)
31
+ end
32
+
33
+ def hidden_columns_message(hidden)
34
+ columns = hidden.map { |column| "#{table_name}.#{column}" }.join(', ')
35
+ readers = hidden.map { |column| "#{name}##{column}" }.join(', ')
36
+ them = hidden.one? ? 'it' : 'them'
37
+ "#{columns} cannot be read on #{name}: acts_as_chat defines #{readers}, which would hide #{them}. " \
38
+ "Rename the column in a migration, such as rename_column :#{table_name}, :#{hidden.first}, " \
39
+ ":#{hidden.first}_setting."
40
+ end
41
+
42
+ def hidden_associations_message(associations)
43
+ names = associations.map { |association| "#{association.macro} :#{association.name}" }.join(', ')
44
+ readers = associations.map { |association| "#{name}##{association.name}" }.join(', ')
45
+ them = associations.one? ? 'it' : 'them'
46
+ "#{names} cannot be read on #{name}: acts_as_chat defines #{readers}, which would hide #{them}. " \
47
+ "Rename #{them}, with class_name: and foreign_key: options that keep the same table and columns."
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # A file in a persisted chat's FileStore, in RubyLLM's
6
+ # +ruby_llm_chat_files+ table, with its content in Active Storage. It
7
+ # keeps the text a converter made of the file, so each version is
8
+ # converted once.
9
+ #
10
+ # chat.ruby_llm_chat_files.pluck(:path) # => ["contract.pdf", "results/search-call_1.json"]
11
+ #
12
+ # Rows with an +owner+ are seen only through a store for that owner
13
+ # (see ChatFiles#with_file_store); rows without one are seen by every
14
+ # store of the chat.
15
+ class ChatFile < Record
16
+ self.table_name = 'ruby_llm_chat_files'
17
+
18
+ belongs_to :chat, polymorphic: true
19
+ belongs_to :owner, polymorphic: true, optional: true
20
+
21
+ # Declares the content attachment when Active Storage is loaded.
22
+ # Called by +acts_as_chat+, since Active Storage loads after
23
+ # RubyLLM's records.
24
+ def self.declare_attachments # :nodoc:
25
+ return unless respond_to?(:has_one_attached) && !reflect_on_attachment(:file)
26
+
27
+ has_one_attached :file
28
+ end
29
+
30
+ # Returns the file as a RubyLLM::StoredFile.
31
+ def to_stored_file
32
+ RubyLLM::StoredFile.new(path:, byte_size:, content_type:, digest:, updated_at:,
33
+ owner: (owner if owner_id))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # The file store of a persisted chat, added by +acts_as_chat+ with
6
+ # ChatMethods: the chat's files live in +ruby_llm_chat_files+, with
7
+ # their content in Active Storage.
8
+ module ChatFiles
9
+ # Applies Chat#with_file_store and returns this record. Without a
10
+ # +store+, the chat keeps its files in Active Storage, in
11
+ # +ruby_llm_chat_files+ (see ActiveRecord::FileStore), for the
12
+ # chat's owner, the record <tt>acts_as_chat owner:</tt> names: that
13
+ # store writes files for the owner and sees only their files and the
14
+ # files without an owner. Pass +nil+ to remove the store.
15
+ #
16
+ # chat.with_file_store
17
+ # chat.with_file_store("tmp/chats/42")
18
+ # chat.with_file_store(:memory)
19
+ # chat.with_file_store(nil)
20
+ #
21
+ # :call-seq:
22
+ # with_file_store(store = active_storage_store, inline_limit: 10_000, tools: %i[read write edit])
23
+ def with_file_store(store = RubyLLM::Chat::FileReferences::DEFAULT_STORE, **)
24
+ store = FileStore.new(self) if store.equal?(RubyLLM::Chat::FileReferences::DEFAULT_STORE)
25
+ to_llm.with_file_store(store, **)
26
+ self
27
+ end
28
+ end
29
+ end
30
+ end