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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Keeps the assistant record a run writes its answer into. An answer
6
+ # the provider paused keeps its record, with the continuation that
7
+ # resumes it, so a later run continues it instead of starting again.
8
+ module ChatPlaceholders # :nodoc:
9
+ def persist_message_progress(message) # :nodoc:
10
+ return unless @message.has_attribute?(:continuation)
11
+
12
+ transaction do
13
+ @message.update!(message_attributes(message))
14
+ link_usage_entries(message)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def persist_new_message
21
+ unfinished = unfinished_message_record
22
+ return @message = unfinished if unfinished
23
+
24
+ @message.destroy if blank_placeholder?
25
+ remove_abandoned_placeholder
26
+
27
+ attrs = { role: :assistant, content: '' }
28
+ @message = messages_association.create!(attrs)
29
+ @last_message_id = @message.id
30
+ @message
31
+ end
32
+
33
+ def blank_placeholder?
34
+ return false unless @message&.persisted? && @message.content.blank?
35
+ return false unless @message.role.to_s == 'assistant'
36
+ return false if unfinished_record?(@message)
37
+ return false if @message.respond_to?(:attachments) && @message.attachments.attached?
38
+
39
+ !@message.ruby_llm_tool_calls.exists?
40
+ end
41
+
42
+ # A process killed before its reply arrived leaves its placeholder
43
+ # last, and the run that takes over the chat's claim writes a reply
44
+ # of its own, so it removes that one. A reply the model gave records
45
+ # usage, even a blank one. The claim is renewed first, so a run that
46
+ # lost it never removes the placeholder of the run that took over.
47
+ def remove_abandoned_placeholder
48
+ return unless @run_claim_token
49
+
50
+ transaction do
51
+ renew_run_claim(force: true)
52
+ record = messages_association.order(:id).last
53
+ record.destroy if to_llm.send(:placeholder?, record&.to_llm) && !record.ruby_llm_usages.exists?
54
+ end
55
+ end
56
+
57
+ def unfinished_message_record
58
+ record = messages_association.order(:id).last
59
+ record if record&.role.to_s == 'assistant' && unfinished_record?(record)
60
+ end
61
+
62
+ def unfinished_record?(record)
63
+ record.has_attribute?(:continuation) && record.continuation.present?
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # The questions a persisted chat waits on, kept on its tool call rows:
6
+ # approvals, and the questions tools and MCP servers ask (see
7
+ # RubyLLM::Question). Any process reads them from the database, without
8
+ # building the chat, and records answers on those rows under a row
9
+ # lock, so a web request can answer what a job asked and another job
10
+ # can carry the call on. ChatMethods includes it.
11
+ #
12
+ # chat.pending_questions.each { |question| render "questions/question", question: }
13
+ # chat.answer(params[:question_id], params[:answer])
14
+ # ChatResponseJob.perform_later(chat.id)
15
+ #
16
+ module ChatQuestions
17
+ # Returns the questions the chat waits on a person to answer, as
18
+ # RubyLLM::Question objects in the order the model made their calls:
19
+ # approvals, and the questions tools and MCP servers ask, as
20
+ # RubyLLM::Chat#pending_questions describes. It reads the database
21
+ # only, so it never builds the chat's tools or connects to its MCP
22
+ # servers, and it lists what any process recorded. Each question
23
+ # records its answer on this chat.
24
+ #
25
+ # Whether a call requires approval is decided when it is saved, by the
26
+ # process that received it, so a call whose tool decides with a
27
+ # Tool.requires_approval block stays listed until the chat runs again.
28
+ def pending_questions
29
+ approvals = persisted_pending_approvals.to_a
30
+ rows = RubyLLM::ActiveRecord::ToolCall.uncached { chat_tool_calls.where(result_id: nil).order(:created_at, :id).to_a }
31
+ rows.flat_map do |row|
32
+ tool_call = row.to_llm
33
+ next [RubyLLM::Question.approval(tool_call, chat: self)] if approvals.include?(row)
34
+
35
+ Array(row.mcp_state&.[]('requests')).filter_map do |data|
36
+ RubyLLM::Question.from_h(data, tool_call:, chat: self) unless data['response']
37
+ end
38
+ end
39
+ end
40
+
41
+ # Answers +question+, a RubyLLM::Question from #pending_questions or
42
+ # its id, on its tool call record, so the next +complete+ in any
43
+ # process carries the call on, as RubyLLM::Chat#answer describes.
44
+ # Answering an approval approves it, as +approve+ does, unless another
45
+ # decision came first, in this process or another. Takes the
46
+ # ActionController::Parameters a form sends for several fields, and
47
+ # keeps only those the question asks for. Returns +self+.
48
+ #
49
+ # chat.answer(params[:question_id], params[:answer])
50
+ #
51
+ # Raises ArgumentError for a question the chat never asked, and for an
52
+ # answer the question can't take.
53
+ def answer(question, value = nil, **values)
54
+ pending = persisted_pending_question(question)
55
+ response = pending&.response_to(value, values)
56
+ return self unless pending && settle_persisted_question(pending, response)
57
+
58
+ question.respond(response) if question.is_a?(RubyLLM::Question)
59
+ self
60
+ end
61
+
62
+ # Declines +question+, a RubyLLM::Question from #pending_questions,
63
+ # its id, or the call of an approval, on its tool call record, as
64
+ # RubyLLM::Chat#decline describes, unless another decision came
65
+ # first. The +note+ persists with it, so whichever process carries
66
+ # the call on gives the model the same note. A call saved waiting for
67
+ # approval before 2.2 is declined by its id, as +approve+ approves
68
+ # it, though #pending_questions leaves it out. Returns +self+.
69
+ #
70
+ # chat.decline(params[:question_id], params[:note])
71
+ #
72
+ # Raises ArgumentError for a question the chat never asked, and
73
+ # RubyLLM::ToolCallRunningError for an approval still pending whose
74
+ # call is already running.
75
+ def decline(question, note = nil)
76
+ pending = persisted_pending_question(question) || unmarked_approval(question)
77
+ response = RubyLLM::Question.declined(note)
78
+ return self unless pending && settle_persisted_question(pending, response)
79
+
80
+ question.respond(response) if question.is_a?(RubyLLM::Question)
81
+ self
82
+ end
83
+
84
+ def settle_persisted_input(tool_call) # :nodoc:
85
+ transaction do
86
+ # Writing first takes the row's lock on every database, SQLite
87
+ # included, so the state read next is the latest.
88
+ scope = chat_tool_calls.where(tool_call_id: tool_call.id, result_id: nil)
89
+ next unless scope.update_all(updated_at: Time.current) == 1
90
+
91
+ locked = RubyLLM::ActiveRecord::ToolCall.uncached { scope.lock.take }
92
+ next if locked.mcp_state.nil?
93
+
94
+ settled = yield(locked.mcp_state)
95
+ next if settled == locked.mcp_state
96
+
97
+ forget_stale_cancellation
98
+ locked.update!(mcp_state: settled)
99
+ end
100
+ end
101
+
102
+ # Returns the note +tool_call+ was declined with while it waits for
103
+ # its refusal. It is kept in +mcp_result+, which the call's result
104
+ # replaces, and never in +mcp_state+, where a note would look like
105
+ # a question the call waits on.
106
+ def persisted_decline_note(tool_call) # :nodoc:
107
+ RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(tool_call.id) }&.mcp_result&.[]('note')
108
+ end
109
+
110
+ def record_persisted_decline_note(id, note) # :nodoc:
111
+ chat_tool_calls.where(tool_call_id: id, result_id: nil)
112
+ .update_all(mcp_result: { 'note' => note }, updated_at: Time.current)
113
+ end
114
+
115
+ private
116
+
117
+ def persisted_pending_question(question)
118
+ id = persisted_question_id(question)
119
+ pending_questions.find { |pending| pending.id == id } ||
120
+ (persisted_question?(id) ? nil : raise(ArgumentError, "Unknown question: #{id.inspect}"))
121
+ end
122
+
123
+ def persisted_question_id(question)
124
+ id = question.respond_to?(:tool_call_id) ? question.tool_call_id : question
125
+ (id.respond_to?(:id) ? id.id : id).to_s
126
+ end
127
+
128
+ # A call saved before 2.2 waits for approval with no +pending+ mark,
129
+ # so #pending_questions can't list it, but it can be declined as
130
+ # +approve+ approves it.
131
+ def unmarked_approval(question)
132
+ record = RubyLLM::ActiveRecord::ToolCall.uncached { find_tool_call(persisted_question_id(question)) }
133
+ return unless record && record.approval.nil? && record.result_id.nil?
134
+
135
+ RubyLLM::Question.approval(record.to_llm, chat: self)
136
+ end
137
+
138
+ def persisted_question?(id)
139
+ RubyLLM::ActiveRecord::ToolCall.uncached do
140
+ [id, id.rpartition('/').first].any? { |tool_call_id| find_tool_call(tool_call_id) }
141
+ end
142
+ end
143
+
144
+ def record_persisted_answer(question, response)
145
+ settle_persisted_input(question.tool_call) { |input| question.answered_input(input, response) }
146
+ end
147
+
148
+ # Records +response+ to +question+ and returns whether it did. An
149
+ # approval is approved or denied only while it still waits, so of
150
+ # two people deciding at once, in any processes, the first one's
151
+ # decision stands.
152
+ def settle_persisted_question(question, response)
153
+ unless question.approval?
154
+ record_persisted_answer(question, response)
155
+ return true
156
+ end
157
+
158
+ waiting = RubyLLM::ActiveRecord::ToolCall.where(id: tool_call_record(question.tool_call.id).id,
159
+ approval: ['pending', nil], result_id: nil)
160
+ transaction do
161
+ # Writing first takes the row's lock on every database, SQLite
162
+ # included, so a decision made at the same time waits for this
163
+ # one and then finds the call decided.
164
+ next false unless waiting.update_all(updated_at: Time.current) == 1
165
+
166
+ response == RubyLLM::Question::ACCEPTED ? approve(question) : refuse(question, response['note'])
167
+ true
168
+ end
169
+ end
170
+
171
+ def refuse(question, note)
172
+ record_tool_call_decision(question.tool_call, 'denied')
173
+ record_persisted_decline_note(question.tool_call.id, note) if note
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm/active_record/attachment_helpers'
4
+
5
+ module RubyLLM
6
+ module ActiveRecord
7
+ # Keeps a persisted chat's queued messages in +ruby_llm_queued_messages+,
8
+ # so any process can queue one and the process running the chat takes
9
+ # it. Taking or withdrawing deletes the row, and only the process whose
10
+ # delete removes it goes on, so each message joins once or not at all.
11
+ # Joining runs Chat#around_join inside the transaction that deletes the
12
+ # row, so a callback that raises puts the row back. The message record
13
+ # gets the row's sender when its model has a +sender+ association.
14
+ class ChatQueue # :nodoc:
15
+ include AttachmentHelpers
16
+
17
+ def initialize(chat)
18
+ @chat = chat
19
+ end
20
+
21
+ def push(content:, attachments:, steer:, sender:)
22
+ QueuedMessage.transaction do
23
+ @chat.forget_stale_cancellation
24
+ queued = rows.create!(content:, steer:, sender:)
25
+ persist_content(queued, attachments) unless attachments.nil?
26
+ queued
27
+ end
28
+ end
29
+
30
+ def delete(queued)
31
+ id = queued.respond_to?(:id) ? queued.id : queued
32
+ record = read { rows.find_by(id:) }
33
+ record if record&.withdraw
34
+ end
35
+
36
+ def to_a
37
+ read { rows.to_a }
38
+ end
39
+
40
+ def none?(steering_only: false)
41
+ !available? || read { !scope(steering_only).exists? }
42
+ end
43
+
44
+ def take(steering_only: false)
45
+ return unless available?
46
+
47
+ read { scope(steering_only).to_a }.each do |queued|
48
+ taken = take_one(queued)
49
+ yield taken.first, queued if taken
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def take_one(queued)
56
+ taken = nil
57
+ QueuedMessage.transaction do
58
+ next unless QueuedMessage.where(id: queued.id).delete_all == 1
59
+
60
+ taken = [@chat.to_llm.run_around_join(queued) { |**attributes| join(queued, attributes) }]
61
+ rescue StandardError
62
+ @chat.messages_association.reset
63
+ raise
64
+ end
65
+ queued.attachments.purge_later if taken == [nil] && attachments?(queued)
66
+ taken
67
+ end
68
+
69
+ def join(queued, attributes)
70
+ blobs = queued.attachments.blobs.to_a if attachments?(queued)
71
+ message = @chat.persist_message({ role: :user, content: queued.content, attachments: blobs },
72
+ sender_attributes(queued).merge(attributes))
73
+ detach(queued) if blobs
74
+ message
75
+ end
76
+
77
+ def sender_attributes(queued)
78
+ association = @chat.messages_association.klass.reflect_on_association(:sender)
79
+ sender = queued_sender(queued) if association
80
+ return {} unless sender && (association.polymorphic? || sender.is_a?(association.klass))
81
+
82
+ { sender: }
83
+ end
84
+
85
+ # Nil once the sender's class, or the subclass its row names, is
86
+ # gone, say renamed since the message was queued, so the message
87
+ # joins without its sender instead of holding up the queue.
88
+ def queued_sender(queued)
89
+ queued.sender
90
+ rescue NameError, ::ActiveRecord::SubclassNotFound
91
+ nil
92
+ end
93
+
94
+ def detach(queued)
95
+ ActiveStorage::Attachment.where(record_type: QueuedMessage.polymorphic_name, record_id: queued.id,
96
+ name: 'attachments').delete_all
97
+ end
98
+
99
+ def scope(steering_only)
100
+ steering_only ? rows.where(steer: true) : rows
101
+ end
102
+
103
+ def rows
104
+ @chat.ruby_llm_queued_messages.scope
105
+ end
106
+
107
+ def read(&)
108
+ QueuedMessage.uncached(&)
109
+ end
110
+
111
+ def attachments?(queued)
112
+ queued.respond_to?(:attachments) && queued.attachments.attached?
113
+ end
114
+
115
+ def available?
116
+ @chat.persisted? && QueuedMessage.table_exists?
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Keeps a message for the person about the last failed reply in the
6
+ # chats table's +reply_error+ column, when it has one, so the chat's
7
+ # page shows it however its broadcasts arrive. A failed run writes it,
8
+ # and the next run clears it. A stop is not a failure.
9
+ module ChatReplyError # :nodoc:
10
+ MESSAGES = {
11
+ failed: 'The reply failed. Send your message again.',
12
+ too_long: 'This conversation is too long for the model. Start a new chat.'
13
+ }.freeze
14
+
15
+ private
16
+
17
+ def clear_reply_error
18
+ update_columns(reply_error: nil) if reply_error_column? && self[:reply_error]
19
+ end
20
+
21
+ def record_reply_error(error)
22
+ return if error.is_a?(CancelledError) || !reply_error_column?
23
+
24
+ key = error.is_a?(ContextLengthExceededError) ? :too_long : :failed
25
+ update_columns(reply_error: I18n.t(key, scope: %i[ruby_llm reply_error], default: MESSAGES[key]))
26
+ rescue StandardError => e
27
+ RubyLLM.logger.warn("RubyLLM could not save the reply error: #{e.class}: #{e.message}")
28
+ end
29
+
30
+ def reply_error_column?
31
+ persisted? && has_attribute?(:reply_error)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Connects a persisted chat's conversation loop to its record: usage
6
+ # and progress land in the database, each cancellation checkpoint
7
+ # renews the run claim and polls for a request from another process,
8
+ # losing the claim ends the run, and the owner is the record that
9
+ # +acts_as_chat owner:+ names.
10
+ class ChatRun < Chat::Run # :nodoc:
11
+ def initialize(chat)
12
+ super()
13
+ @chat = chat
14
+ end
15
+
16
+ def record_usage(entry)
17
+ @chat.persist_usage_entry(entry)
18
+ end
19
+
20
+ def record_progress(partial)
21
+ @chat.persist_message_progress(partial)
22
+ end
23
+
24
+ def chat_id
25
+ @chat.id
26
+ end
27
+
28
+ # Stable across processes and opaque: the provider sees neither the
29
+ # table nor the id.
30
+ def cache_key
31
+ id = @chat.id
32
+ return unless id
33
+
34
+ "ruby_llm-#{Digest::SHA256.hexdigest("#{@chat.class.base_class.name}/#{id}")[0, 32]}"
35
+ end
36
+
37
+ def owner
38
+ name = @chat.class.owner_association_name
39
+ @chat.public_send(name) if name
40
+ end
41
+
42
+ def cancellation_requested?
43
+ @chat.poll_run_cancellation == :cancelled
44
+ end
45
+
46
+ def interrupted_by?(error)
47
+ @chat.run_claim_lost?(error)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Keeps a persisted chat's tool call state on its
6
+ # +ruby_llm_tool_calls+ rows, read outside the query cache, so every
7
+ # process sees the same state.
8
+ class ChatToolCallState < Chat::ToolCallState # :nodoc:
9
+ def initialize(chat)
10
+ super()
11
+ @chat = chat
12
+ @contested = []
13
+ end
14
+
15
+ def input(tool_call)
16
+ record = RubyLLM::ActiveRecord::ToolCall.uncached { @chat.find_tool_call(tool_call.id) }
17
+ record.mcp_state if record&.has_attribute?(:mcp_state)
18
+ end
19
+
20
+ def record_input(tool_call, input)
21
+ record = RubyLLM::ActiveRecord::ToolCall.uncached { @chat.find_tool_call(tool_call.id) }
22
+ return if !record&.has_attribute?(:mcp_state) || record.mcp_state == input
23
+
24
+ @chat.transaction do
25
+ @chat.forget_stale_cancellation
26
+ record.update!(mcp_state: input)
27
+ end
28
+ end
29
+
30
+ def decline_note(tool_call)
31
+ @chat.persisted_decline_note(tool_call)
32
+ end
33
+
34
+ def record_decline_note(tool_call, note)
35
+ @chat.record_persisted_decline_note(tool_call.id, note)
36
+ end
37
+
38
+ def settle(tool_call, &)
39
+ @chat.settle_persisted_input(tool_call, &)
40
+ end
41
+
42
+ def decision(tool_call)
43
+ decided = super
44
+ decided.nil? ? @chat.persisted_tool_call_approval(tool_call) : decided
45
+ end
46
+
47
+ def remembered?(call)
48
+ super || @chat.persisted_remembered_tool?(call)
49
+ end
50
+
51
+ def claim(requested, executing:)
52
+ @contested = []
53
+ return requested if requested.empty?
54
+
55
+ claimed = @chat.claim_persisted_tool_calls(yield, executing:)
56
+ @contested = requested - claimed
57
+ claimed
58
+ end
59
+
60
+ def release(ids, stopped_by = nil, finished: false)
61
+ @chat.release_persisted_tool_calls(ids, stopped_by, finished:)
62
+ end
63
+
64
+ def started(tool_call)
65
+ @chat.persisted_tool_call_started(tool_call.id)
66
+ end
67
+
68
+ def interrupted?(tool_call)
69
+ @chat.persisted_tool_call_interrupted?(tool_call.id)
70
+ end
71
+
72
+ def running_elsewhere?(tool_call)
73
+ @contested.include?(tool_call.id) && @chat.tool_call_running_elsewhere?(tool_call)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Gives each RubyLLM::Chunk streamed from a persisted chat the message
6
+ # record it streams into.
7
+ #
8
+ # chat.ask("Tell me a story", &:broadcast)
9
+ #
10
+ module ChunkMethods
11
+ BROADCASTING_OFF = 'Messages do not broadcast. Set config.broadcast_messages = true in the ' \
12
+ 'RubyLLM initializer, or pass broadcasts: true to acts_as_message, with ' \
13
+ 'turbo-rails and Action Cable loaded.' # :nodoc:
14
+
15
+ # Returns a block that gives each chunk the record +message+ returns
16
+ # before passing it to +block+, or +nil+ without a block. Calls
17
+ # +on_error+ when +block+ raises, then re-raises the error.
18
+ def self.streaming(block, on_error: nil, &message) # :nodoc:
19
+ return unless block
20
+
21
+ lambda do |chunk|
22
+ block.call(attach(chunk, message.call))
23
+ rescue StandardError
24
+ on_error&.call
25
+ raise
26
+ end
27
+ end
28
+
29
+ # Gives +chunk+ the record +message+ it streams into. Returns +chunk+.
30
+ def self.attach(chunk, message) # :nodoc:
31
+ chunk.extend(ChunkMethods)
32
+ chunk.message = message
33
+ chunk
34
+ end
35
+
36
+ # Returns the persisted message record this chunk streams into.
37
+ attr_accessor :message
38
+
39
+ # Appends this chunk to its message on the chat's page. See
40
+ # MessageBroadcasting#broadcast_chunk. Raises
41
+ # RubyLLM::ConfigurationError when the message does not broadcast.
42
+ def broadcast
43
+ raise RubyLLM::ConfigurationError, BROADCASTING_OFF unless message.respond_to?(:broadcast_chunk)
44
+
45
+ message.broadcast_chunk(self)
46
+ end
47
+ end
48
+ end
49
+ end