ruby_llm 2.1.1 → 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 +160 -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 +515 -274
  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 +1593 -331
  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 +50 -24
  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 +127 -106
  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 +15 -15
  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 +1 -7
  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 +5 -14
  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 +213 -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
@@ -0,0 +1,608 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Claims a persisted chat for the process that runs its conversation
6
+ # loop, and each tool call for the process that answers it. Claims are
7
+ # conditional updates, so no transaction stays open while a model or a
8
+ # tool runs.
9
+ #
10
+ # A claim is a lease of CLAIM_LEASE seconds that a heartbeat thread
11
+ # renews every CLAIM_RENEWAL seconds, or every quarter of a shorter
12
+ # Configuration#max_run_time, so the work of a process that dies
13
+ # is free again within a lease. Renewal stops at the claim's deadline:
14
+ # Configuration#max_run_time after the run last made progress, or for
15
+ # a tool call, its run time after it starts, so a run that hangs past
16
+ # it counts as crashed. A call waiting for the calls before it in its
17
+ # round keeps its claim as long as the chat's claim lasts. A run inside
18
+ # a transaction can't renew from another connection, so its claims
19
+ # last until their deadline instead.
20
+ module RunClaims # :nodoc:
21
+ class ClaimLost < StandardError; end # :nodoc:
22
+ private_constant :ClaimLost
23
+
24
+ # A tool call this process claimed: its row, when its claim ends, the
25
+ # expiries written to the row, the seconds it may run, whether it
26
+ # started, and whether it was interrupted: claimed from a process that
27
+ # had started it and stopped before it saved the result.
28
+ ToolCallClaim = Struct.new(:row, :deadline, :written, :seconds, :started, :interrupted)
29
+ private_constant :ToolCallClaim
30
+
31
+ CLAIM_LEASE = 60
32
+ CLAIM_RENEWAL = 15
33
+
34
+ RUN_STATE = %i[
35
+ @chat @message @last_message_id @last_cancellation_poll_at @unpersisted_instructions
36
+ @usage_records_by_entry @run_claim_token @run_claim_deadline @run_claim_renewed_at
37
+ @run_claim_lost_token @synced_run_claim_token @synced_usage_count @tool_call_claims
38
+ @claim_heartbeat @claim_lock @tool_call_renewal_lock @lost_tool_calls
39
+ ].freeze
40
+ private_constant :RUN_STATE
41
+
42
+ EXIT_LOCK = Mutex.new
43
+ private_constant :EXIT_LOCK
44
+
45
+ # Tells the runs of this process that it will exit by +time+ without
46
+ # unwinding, as a Solid Queue worker does once its shutdown timeout
47
+ # passes, so no run can expire its claims as it stops. Claims end by
48
+ # then from now on, and live heartbeats cut theirs back to it at once,
49
+ # so a chat still running at the exit counts as interrupted as the
50
+ # process ends rather than a lease later. A run that finishes first
51
+ # releases its claims as usual. The earliest time wins, to the whole
52
+ # second, which a column that rounds fractions up still holds.
53
+ def self.process_exits_by(time)
54
+ time = time.change(usec: 0)
55
+ EXIT_LOCK.synchronize { @exit_deadline = [@exit_deadline, time].compact.min }
56
+ ClaimHeartbeat.hurry_all
57
+ end
58
+
59
+ def self.exit_deadline # :nodoc:
60
+ @exit_deadline
61
+ end
62
+
63
+ # The chats whose run died holding its claim. Every run that ends
64
+ # releases its claim, so a token whose lease expired is left only by
65
+ # a process that died or shut down mid-run. A chat someone stopped
66
+ # stays stopped. It skips the query cache, since a job polls it for
67
+ # what other processes wrote.
68
+ def self.interrupted(relation)
69
+ chat = relation.klass
70
+ return relation.none unless (%w[claim_token claimed_until] - chat.column_names).empty?
71
+
72
+ died = relation.where.not(claim_token: nil).where(claimed_until: ..Time.current)
73
+ died = died.where(cancelled: false) if chat.column_names.include?('cancelled')
74
+ died.skip_query_cache!
75
+ end
76
+
77
+ def run_claim_lost?(error) # :nodoc:
78
+ error.is_a?(ClaimLost)
79
+ end
80
+
81
+ def claim_persisted_tool_calls(leases, executing:) # :nodoc:
82
+ return leases.keys.map(&:id) unless tool_call_claims?
83
+
84
+ start_claim_heartbeat
85
+ claimed = claim_tool_call_rows(leases, executing)
86
+ resync_messages if claimed.size < leases.size
87
+ claimed
88
+ rescue StandardError
89
+ claim_lock.synchronize { leases.each_key { |tool_call| tool_call_claims.delete(tool_call.id) } }
90
+ stop_idle_claim_heartbeat
91
+ raise
92
+ end
93
+
94
+ # A call that was running when its process shut down may have done
95
+ # its work, such as a message it already sent, so it keeps its claim,
96
+ # expired at once like the chat's: the expired claim is the only
97
+ # record that it started. So does an interrupted call this run didn't
98
+ # get to answer, which stays interrupted for the next one. Every other
99
+ # call is released. A round that
100
+ # stopped before it +finished+ writes its claims without yielding to a
101
+ # fiber scheduler, as #unyielding explains, while one that finished
102
+ # releases the calls that paused as usual.
103
+ def release_persisted_tool_calls(tool_call_ids, stopped_by = nil, finished: false) # :nodoc:
104
+ expired = Time.current.change(usec: 0)
105
+ shutting_down = shutting_down?(stopped_by)
106
+ tool_call_ids.each do |tool_call_id|
107
+ claim = claim_lock.synchronize { tool_call_claims.delete(tool_call_id) }
108
+ next unless claim
109
+
110
+ kept = claim.interrupted || (claim.started && shutting_down)
111
+ unyielding(!finished) do
112
+ RubyLLM::ActiveRecord::ToolCall.where(id: claim.row, result_id: nil, claimed_until: claim.written)
113
+ .update_all(claimed_until: (expired if kept))
114
+ end
115
+ end
116
+ ensure
117
+ stop_idle_claim_heartbeat
118
+ end
119
+
120
+ # Counts a claimed tool call's run time from now, when it starts, not
121
+ # from when its round was claimed, so waiting for the calls before it
122
+ # costs it nothing, and stretches the chat's claim to cover it. Until
123
+ # then the call keeps its claim as long as the chat does. Both claims
124
+ # are written before the call runs, so neither can lapse before the
125
+ # heartbeat's next renewal, and the call doesn't run once another
126
+ # process took either over.
127
+ def persisted_tool_call_started(tool_call_id) # :nodoc:
128
+ now = Time.current.change(usec: 0)
129
+ claim = claim_lock.synchronize do
130
+ started = tool_call_claims[tool_call_id]
131
+ raise ClaimLost if started.nil? && lost_tool_calls.include?(tool_call_id)
132
+ next unless started
133
+
134
+ started.started = true
135
+ started.deadline = now + started.seconds
136
+ @run_claim_deadline = [@run_claim_deadline, started.deadline].max if @run_claim_token
137
+ started
138
+ end
139
+ return unless claim
140
+
141
+ write_run_claim(now) if @run_claim_token
142
+ raise ClaimLost unless started_tool_call_claimed?(tool_call_id, claim, now)
143
+ end
144
+
145
+ # Whether this run claimed +tool_call_id+ from a process that had
146
+ # started it and stopped, by a shutdown or a crash, before it saved
147
+ # the result. Its claim is left expired, or lapses, while a call that
148
+ # never started is released.
149
+ def persisted_tool_call_interrupted?(tool_call_id) # :nodoc:
150
+ claim_lock.synchronize { tool_call_claims[tool_call_id]&.interrupted }
151
+ end
152
+
153
+ def tool_call_running_elsewhere?(tool_call) # :nodoc:
154
+ RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }&.running? || false
155
+ end
156
+
157
+ private
158
+
159
+ def claim_tool_call_rows(leases, executing)
160
+ rows = RubyLLM::ActiveRecord::ToolCall.uncached { tool_call_rows(leases.keys.map(&:id)) }
161
+ RubyLLM::ActiveRecord::ToolCall.transaction do
162
+ ids = leases.filter_map do |tool_call, seconds|
163
+ row = rows[tool_call.id]
164
+ tool_call.id if row.nil? || tool_call_claimed?(tool_call.id, row, seconds, executing.include?(tool_call.id))
165
+ end
166
+ extend_run_claim_for_tool_calls
167
+ ids
168
+ end
169
+ end
170
+
171
+ def initialize_dup(*)
172
+ super
173
+ RUN_STATE.each { |name| instance_variable_set(name, nil) }
174
+ %i[claim_token claimed_until].each { |name| self[name] = nil if has_attribute?(name) }
175
+ self[:cancelled] = false if has_attribute?(:cancelled)
176
+ end
177
+
178
+ def exclusively(busy, &)
179
+ return yield if @run_claim_token
180
+
181
+ holding_run_claim(busy, &)
182
+ end
183
+
184
+ # A run stopped because its process is shutting down didn't end, so
185
+ # it keeps its claim but expires it, and the chat counts as
186
+ # interrupted at once.
187
+ def holding_run_claim(busy)
188
+ if run_claims?
189
+ start_claim_heartbeat
190
+ return busy.call unless run_claim_acquired?
191
+ end
192
+
193
+ settle_cancellation_request
194
+ result = yield
195
+ completed = true
196
+ result
197
+ rescue ClaimLost
198
+ @message.destroy if blank_placeholder?
199
+ resync_messages if @chat
200
+ busy.call
201
+ rescue SignalException, SystemExit => e
202
+ stopped_by = e
203
+ raise
204
+ ensure
205
+ unyielding(!completed) do
206
+ stop_claim_heartbeat
207
+ ensure
208
+ shutting_down?(stopped_by) ? expire_run_claim : release_run_claim(completed)
209
+ end
210
+ end
211
+
212
+ # Under a fiber scheduler, as in Async or Solid Queue's fiber workers,
213
+ # a fiber whose thread is exiting or whose reactor is stopping is
214
+ # cancelled, or never resumed, once it waits on a socket, a file or a
215
+ # thread. So a run that +stopped+ before it finished ends its claims
216
+ # without yielding to the scheduler, and one that finished yields as
217
+ # usual.
218
+ def unyielding(stopped, &)
219
+ stopped ? Fiber.blocking(&) : yield
220
+ end
221
+
222
+ # Whether the process is shutting down: a thread killed at exit, or
223
+ # +stopped_by+ a signal such as TERM or an exit. Interrupt doesn't
224
+ # count: Ctrl-C in a console stops the run, and a job backend that
225
+ # interrupts a job on shutdown, as Sidekiq does, runs it again.
226
+ def shutting_down?(stopped_by)
227
+ return true if Thread.current.status == 'aborting'
228
+
229
+ (stopped_by.is_a?(SignalException) || stopped_by.is_a?(SystemExit)) && !stopped_by.is_a?(Interrupt)
230
+ end
231
+
232
+ def run_claim_acquired?
233
+ now = Time.current
234
+ deadline = now + run_claim_duration
235
+ claim = { claim_token: SecureRandom.uuid, claimed_until: leased_until(deadline, now) }
236
+ available = unclaimed(chat_row, now)
237
+ unless available.where(claim_token: @synced_run_claim_token).update_all(claim) == 1
238
+ return false unless available.update_all(claim) == 1
239
+
240
+ stale = true
241
+ end
242
+
243
+ @run_claim_token = claim[:claim_token]
244
+ @run_claim_deadline = deadline
245
+ @run_claim_renewed_at = now
246
+ resync_messages if @chat && (stale || run_finished_elsewhere?)
247
+ true
248
+ end
249
+
250
+ # A released claim leaves no token behind, so what another process's
251
+ # run wrote is the only sign of it. A failed attempt adds no message,
252
+ # but each model call adds a usage row. Continuing an unfinished reply
253
+ # keeps its id and may lose its usage row, so such a reply is always
254
+ # read again: repeating it would bill the model twice.
255
+ def run_finished_elsewhere?
256
+ @chat.messages.last&.continuation || latest_message_id != @last_message_id ||
257
+ usage_row_count != @synced_usage_count
258
+ end
259
+
260
+ def usage_row_count
261
+ ruby_llm_usages.klass.uncached { ruby_llm_usages.count }
262
+ end
263
+
264
+ # Whole seconds, so a column that rounds fractions up still holds a
265
+ # time that has passed.
266
+ def expire_run_claim
267
+ token = @run_claim_token
268
+ @synced_usage_count = @synced_run_claim_token = @run_claim_token = nil
269
+ chat_row.where(claim_token: token).update_all(claimed_until: Time.current.change(usec: 0)) if token
270
+ end
271
+
272
+ def release_run_claim(completed)
273
+ token = @run_claim_token
274
+ return unless token
275
+
276
+ @synced_usage_count = @synced_run_claim_token = @run_claim_token = nil
277
+ @synced_usage_count = usage_row_count if completed
278
+ ensure
279
+ free_chat_row(token) if token
280
+ end
281
+
282
+ def free_chat_row(token)
283
+ cancellable = has_attribute?(:cancelled)
284
+ released = { claim_token: nil, claimed_until: nil }
285
+ released[:cancelled] = false if cancellable
286
+ self[:cancelled] = false if chat_row.where(claim_token: token).update_all(released) == 1 && cancellable
287
+ end
288
+
289
+ def renew_run_claim(force: false)
290
+ return unless @run_claim_token
291
+ raise ClaimLost if @run_claim_lost_token == @run_claim_token
292
+
293
+ now = Time.current
294
+ claim_lock.synchronize { @run_claim_deadline = [@run_claim_deadline, now + run_claim_duration].max }
295
+ write_run_claim(now) if force || now >= @run_claim_renewed_at + claim_renewal_interval
296
+ end
297
+
298
+ def write_run_claim(now = Time.current)
299
+ claimed_until = leased_until(@run_claim_deadline, now)
300
+ raise ClaimLost unless chat_claim_extended?(@run_claim_token, claimed_until)
301
+
302
+ @run_claim_renewed_at = now
303
+ end
304
+
305
+ # Writes the chat's claim, moving its stored expiry forward and never
306
+ # back: the heartbeat and a starting tool call write it from separate
307
+ # threads, and the older of two writes can land last. The one way back
308
+ # is to the process's exit, past which no claim lasts. Returns whether
309
+ # the claim is still this run's.
310
+ def chat_claim_extended?(token, claimed_until)
311
+ exit_deadline = RunClaims.exit_deadline
312
+ cut = ' WHEN claimed_until > ? THEN ?' if exit_deadline
313
+ chat_row.where(claim_token: token).update_all(
314
+ ["claimed_until = CASE WHEN claimed_until IS NULL OR claimed_until < ? THEN ?#{cut} ELSE claimed_until END",
315
+ claimed_until, claimed_until, *([exit_deadline] * 2 if exit_deadline)]
316
+ ) == 1
317
+ end
318
+
319
+ # Runs on the heartbeat's thread. A chat claim it finds taken is lost,
320
+ # but only the run's own thread raises, at its next renewal.
321
+ def renew_claim_leases
322
+ now = Time.current
323
+ renew_run_claim_lease(now)
324
+ renew_tool_call_leases(now)
325
+ end
326
+
327
+ def renew_run_claim_lease(now)
328
+ token = @run_claim_token
329
+ deadline = @run_claim_deadline
330
+ return unless token && deadline > now
331
+
332
+ claimed_until = by_exit([now + CLAIM_LEASE, deadline].min)
333
+ if chat_claim_extended?(token, claimed_until)
334
+ @run_claim_renewed_at = now
335
+ else
336
+ @run_claim_lost_token = token
337
+ end
338
+ end
339
+
340
+ def renew_tool_call_leases(now)
341
+ tool_call_renewal_lock.synchronize do
342
+ renewals = claim_lock.synchronize do
343
+ tool_call_claims.filter_map do |tool_call_id, claim|
344
+ deadline = tool_call_deadline(claim)
345
+ next unless deadline
346
+
347
+ claimed_until = by_exit([now.change(usec: 0) + CLAIM_LEASE, deadline].min)
348
+ next if deadline <= now || claim.written.include?(claimed_until)
349
+
350
+ [tool_call_id, claim.row, claim.written.dup, claim.written.push(claimed_until).last]
351
+ end
352
+ end
353
+ renewals.each { |renewal| renew_tool_call_lease(*renewal) }
354
+ end
355
+ end
356
+
357
+ # Writes a starting call's claim to its row, unless another process
358
+ # took the row over, and returns whether this process still holds it.
359
+ def started_tool_call_claimed?(tool_call_id, claim, now)
360
+ tool_call_renewal_lock.synchronize do
361
+ claimed_until = leased_until(claim.deadline, now)
362
+ written = claim_lock.synchronize { claim.written.dup.tap { claim.written.push(claimed_until) } }
363
+ renew_tool_call_lease(tool_call_id, claim.row, written, claimed_until)
364
+ claim_lock.synchronize { tool_call_claims[tool_call_id].equal?(claim) }
365
+ end
366
+ end
367
+
368
+ # A call that hasn't started yet, waiting for the calls before it in
369
+ # its round, keeps its claim as long as the chat's claim lasts. Once
370
+ # another process takes the chat over, this run won't start it, so
371
+ # there is no deadline to renew to.
372
+ def tool_call_deadline(claim)
373
+ return claim.deadline if claim.started || !@run_claim_token
374
+ return if @run_claim_lost_token == @run_claim_token
375
+
376
+ [claim.deadline, @run_claim_deadline].max
377
+ end
378
+
379
+ def renew_tool_call_lease(tool_call_id, row, written, claimed_until)
380
+ renewed = RubyLLM::ActiveRecord::ToolCall.where(id: row, result_id: nil, claimed_until: written)
381
+ .update_all(claimed_until:) == 1
382
+ claim_lock.synchronize do
383
+ claim = tool_call_claims[tool_call_id]
384
+ next unless claim&.row == row
385
+ next claim.written = [claimed_until] if renewed
386
+
387
+ tool_call_claims.delete(tool_call_id)
388
+ lost_tool_calls << tool_call_id
389
+ end
390
+ end
391
+
392
+ def start_claim_heartbeat
393
+ return if @claim_heartbeat || claims_in_transaction?
394
+
395
+ claim_lock
396
+ @claim_heartbeat = ClaimHeartbeat.new([self.class, RubyLLM::ActiveRecord::ToolCall], claim_renewal_interval) do
397
+ renew_claim_leases
398
+ end
399
+ end
400
+
401
+ def stop_idle_claim_heartbeat
402
+ stop_claim_heartbeat unless @run_claim_token || claim_lock.synchronize { tool_call_claims.any? }
403
+ end
404
+
405
+ def stop_claim_heartbeat
406
+ heartbeat = @claim_heartbeat
407
+ @claim_heartbeat = nil
408
+ heartbeat&.stop
409
+ end
410
+
411
+ def claims_in_transaction?
412
+ self.class.connection_pool.with_connection(&:transaction_open?)
413
+ end
414
+
415
+ def leased_until(deadline, now)
416
+ by_exit(@claim_heartbeat ? [now + CLAIM_LEASE, deadline].min : deadline)
417
+ end
418
+
419
+ def by_exit(claimed_until)
420
+ exit_deadline = RunClaims.exit_deadline
421
+ exit_deadline && exit_deadline < claimed_until ? exit_deadline : claimed_until
422
+ end
423
+
424
+ # A max_run_time shorter than the lease is the claim's whole life, so
425
+ # a renewal has to land within it.
426
+ def claim_renewal_interval
427
+ [CLAIM_RENEWAL, [CLAIM_LEASE, run_claim_duration].min / 4.0].min
428
+ end
429
+
430
+ def running_elsewhere?
431
+ return false unless run_claims?
432
+
433
+ token, claimed_until = self.class.uncached { chat_row.pick(:claim_token, :claimed_until) }
434
+ (claimed_until&.future? && token != @run_claim_token) || false
435
+ end
436
+
437
+ def work_arrived_during_run?
438
+ return false if @run_claim_token || !run_claims?
439
+
440
+ resync_messages if latest_message_id != @last_message_id
441
+ !to_llm.complete? && !to_llm.waiting?
442
+ end
443
+
444
+ def latest_message_id
445
+ messages_association.klass.uncached do
446
+ messages_association.reorder(created_at: :desc, id: :desc).pick(:id)
447
+ end
448
+ end
449
+
450
+ def resync_messages
451
+ messages_association.reset
452
+ reload_usages
453
+ messages_association.klass.uncached { sync_messages }
454
+ end
455
+
456
+ def reload_usages
457
+ ruby_llm_usages.reset
458
+ ruby_llm_usages.klass.uncached { ruby_llm_usages.load }
459
+ end
460
+
461
+ def snapshot_run_claim
462
+ return unless run_claims?
463
+
464
+ # Counting first means a run that starts after the token is read
465
+ # still changes the count this process compares against.
466
+ @synced_usage_count = usage_row_count
467
+ @synced_run_claim_token = self.class.uncached { chat_row.pick(:claim_token) }
468
+ end
469
+
470
+ # Tool call rows carry no token, so this process recognizes its claim
471
+ # on a row by the whole-second expiries it wrote there.
472
+ def tool_call_claimed?(tool_call_id, row, seconds, executing)
473
+ now = Time.current.change(usec: 0)
474
+ seconds = seconds.to_f.ceil
475
+ deadline = now + seconds
476
+ claimed_until = leased_until(deadline, now)
477
+ scope = unclaimed(RubyLLM::ActiveRecord::ToolCall.where(id: row, result_id: nil), now)
478
+ if executing && row_supports_approval?
479
+ approval = RubyLLM::ActiveRecord::ToolCall.arel_table[:approval]
480
+ scope = scope.where(approval.eq(nil).or(approval.not_eq('denied')))
481
+ end
482
+ claimed, expired = claim_tool_call_row(scope, { claimed_until:, updated_at: now })
483
+ if claimed
484
+ claim = ToolCallClaim.new(row, deadline, [claimed_until], seconds, false, expired)
485
+ claim_lock.synchronize do
486
+ lost_tool_calls.delete(tool_call_id)
487
+ tool_call_claims[tool_call_id] = claim
488
+ end
489
+ end
490
+ claimed
491
+ end
492
+
493
+ # A released call has no expiry, while a call that started keeps its
494
+ # expired claim when its process shuts down, or lets it lapse when the
495
+ # process dies. Returns whether the claim was written, and whether it
496
+ # took over an expired one.
497
+ def claim_tool_call_row(scope, written)
498
+ return [true, false] if scope.where(claimed_until: nil).update_all(written) == 1
499
+
500
+ [scope.where.not(claimed_until: nil).update_all(written) == 1, true]
501
+ end
502
+
503
+ def extend_run_claim_for_tool_calls
504
+ extended = claim_lock.synchronize do
505
+ latest = tool_call_claims.values.map(&:deadline).max
506
+ @run_claim_deadline = [@run_claim_deadline, latest].max if @run_claim_token && latest
507
+ end
508
+ write_run_claim if extended
509
+ end
510
+
511
+ def tool_result_recorded?(tool_call, message)
512
+ attributes = { result_type: @message.class.polymorphic_name, result_id: @message.id, updated_at: Time.current }
513
+ attributes[:mcp_result] = message.mcp_result&.dump if tool_call.has_attribute?(:mcp_result)
514
+ attributes[:claimed_until] = nil if tool_call.has_attribute?(:claimed_until)
515
+ claim_lock.synchronize { tool_call_claims.delete(tool_call.tool_call_id) }
516
+ RubyLLM::ActiveRecord::ToolCall.where(id: tool_call.id, result_id: nil).update_all(attributes) == 1
517
+ end
518
+
519
+ def decide_persisted_tool_call(record, decision)
520
+ return if record.approval == decision || decision_recorded?(record, decision)
521
+ return unless decision == 'denied' && RubyLLM::ActiveRecord::ToolCall.uncached { record.reload }.running?
522
+
523
+ raise ToolCallRunningError, "The #{record.name} tool call is already running"
524
+ end
525
+
526
+ def decision_recorded?(record, decision)
527
+ now = Time.current
528
+ scope = RubyLLM::ActiveRecord::ToolCall.where(id: record.id, result_id: nil)
529
+ scope = unclaimed(scope, now) if record.has_attribute?(:claimed_until)
530
+ transaction do
531
+ forget_stale_cancellation
532
+ scope.update_all(approval: decision, updated_at: now) == 1 || raise(::ActiveRecord::Rollback)
533
+ end
534
+ end
535
+
536
+ def discard_superseded_message
537
+ @message.destroy
538
+ raise ClaimLost
539
+ end
540
+
541
+ # Renewing inside the transaction holds the chat row until the result
542
+ # commits, so no other process can take the chat over in between.
543
+ def fenced_by_run_claim(&)
544
+ transaction do
545
+ renew_run_claim(force: true)
546
+ yield
547
+ end
548
+ rescue ClaimLost
549
+ @message.destroy
550
+ raise
551
+ end
552
+
553
+ def tool_call_rows(tool_call_ids)
554
+ RubyLLM::ActiveRecord::ToolCall.where(
555
+ tool_call_id: tool_call_ids,
556
+ message_type: messages_association.klass.polymorphic_name,
557
+ message_id: messages_association.select(:id)
558
+ ).pluck(:tool_call_id, :id).to_h
559
+ end
560
+
561
+ def unclaimed(scope, now)
562
+ claimed_until = scope.arel_table[:claimed_until]
563
+ scope.where(claimed_until.eq(nil).or(claimed_until.lteq(now)))
564
+ end
565
+
566
+ def chat_row
567
+ self.class.unscoped.where(self.class.primary_key => id)
568
+ end
569
+
570
+ def tool_call_claims
571
+ @tool_call_claims ||= {}
572
+ end
573
+
574
+ # Calls whose claim the heartbeat found taken, so their start fails
575
+ # rather than taking them for calls that were never claimed.
576
+ def lost_tool_calls
577
+ @lost_tool_calls ||= Set.new
578
+ end
579
+
580
+ def claim_lock
581
+ @claim_lock ||= Mutex.new
582
+ end
583
+
584
+ # Keeps the heartbeat and a starting call from writing a tool call's
585
+ # row at once, when one write would find the other's expiry there and
586
+ # take the claim for lost.
587
+ def tool_call_renewal_lock
588
+ claim_lock.synchronize { @tool_call_renewal_lock ||= Mutex.new }
589
+ end
590
+
591
+ def run_claims?
592
+ persisted? && has_attribute?(:claim_token) && has_attribute?(:claimed_until)
593
+ end
594
+
595
+ def tool_call_claims?
596
+ RubyLLM::ActiveRecord::ToolCall.column_names.include?('claimed_until')
597
+ end
598
+
599
+ def row_supports_approval?
600
+ RubyLLM::ActiveRecord::ToolCall.column_names.include?('approval')
601
+ end
602
+
603
+ def run_claim_duration
604
+ (context&.config || RubyLLM.config).max_run_time.to_f.ceil
605
+ end
606
+ end
607
+ end
608
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Solid Queue stops a worker on TERM and waits its shutdown timeout for
6
+ # the jobs to finish. Then it ends the process with exit!, which runs no
7
+ # ensure block, so a run still going can't expire its claims as it stops.
8
+ # Once a process that ends that way starts stopping, its claims end by
9
+ # the time it exits. A forked worker's process ends with the worker. In
10
+ # async mode the workers are threads of the supervisor's process, which
11
+ # ends when the supervisor stops, while a worker the supervisor replaces
12
+ # leaves the process running.
13
+ module SolidQueueShutdown # :nodoc:
14
+ module_function
15
+
16
+ def install(solid_queue)
17
+ solid_queue.on_worker_stop { |worker| exiting(solid_queue) if forked?(worker) }
18
+ solid_queue.on_stop { |supervisor| exiting(solid_queue) if supervisor.try(:mode).to_s == 'async' }
19
+ end
20
+
21
+ def forked?(worker)
22
+ supervisor = worker.supervisor
23
+ !supervisor.nil? && supervisor.pid != ::Process.pid
24
+ end
25
+
26
+ def exiting(solid_queue)
27
+ require 'ruby_llm/active_record/claim_heartbeat'
28
+ require 'ruby_llm/active_record/run_claims'
29
+ RunClaims.process_exits_by(Time.current + solid_queue.shutdown_timeout)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Compares the system messages a chat record persisted with the
6
+ # instructions about to be applied to it.
7
+ module SystemInstructions # :nodoc:
8
+ # Returns whether the persisted system messages are exactly
9
+ # +instructions+, pairs of content and +cache_until_here+ in order.
10
+ # An agent applied to a record on every request asks before it
11
+ # rewrites them.
12
+ def system_instructions_persisted?(instructions)
13
+ records = messages_association.where(role: :system).order(:id).to_a
14
+ records.size == instructions.size && records.zip(instructions).all? do |record, (content, cache_until_here)|
15
+ system_instruction_attributes(record, content, cache_until_here).all? do |column, value|
16
+ record[column] == value
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def system_instruction_attributes(record, instructions, cache_until_here)
24
+ boundary = cache_boundary_attributes(cache_until_here)
25
+ boundary.delete(:cache_until_here) unless record.has_attribute?(:cache_until_here)
26
+ { content: instructions, **boundary }
27
+ end
28
+ end
29
+ end
30
+ end