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,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Calls its block every +interval+ seconds from a thread of its own
6
+ # until stopped. The thread connects as the starting thread did, to the
7
+ # same roles and shards, and each beat checks out a connection from the
8
+ # pool of each of +models+ and returns it, so the thread holds none
9
+ # between beats.
10
+ #
11
+ # A beat that fails is skipped, and the next one tries again. A lease
12
+ # spans at least four intervals, so a claim outlasts a skipped beat,
13
+ # but after two in a row it may lapse before the next one lands, so
14
+ # the second logs a warning.
15
+ #
16
+ # Once its process is about to exit, ::hurry_all makes every live
17
+ # heartbeat beat at once and then every HURRIED_INTERVAL seconds, so a
18
+ # claim a run wrote just before learning of the exit is cut back too.
19
+ class ClaimHeartbeat # :nodoc:
20
+ HURRIED_INTERVAL = 1
21
+
22
+ LIVE_LOCK = Mutex.new
23
+ private_constant :LIVE_LOCK
24
+
25
+ @live = Set.new
26
+
27
+ class << self
28
+ def hurry_all
29
+ LIVE_LOCK.synchronize { @live.to_a }.each(&:hurry)
30
+ end
31
+
32
+ def add(heartbeat)
33
+ LIVE_LOCK.synchronize { @live << heartbeat }
34
+ end
35
+
36
+ def delete(heartbeat)
37
+ LIVE_LOCK.synchronize { @live.delete(heartbeat) }
38
+ end
39
+ end
40
+
41
+ def initialize(models, interval, &beat)
42
+ @models = models
43
+ @interval = interval
44
+ @beat = beat
45
+ @failed_beats = 0
46
+ @lock = Mutex.new
47
+ @wake = ConditionVariable.new
48
+ @stopped = false
49
+ @hurried = false
50
+ @thread = start(::ActiveRecord::Base.connected_to_stack.to_a)
51
+ self.class.add(self)
52
+ end
53
+
54
+ def stop
55
+ self.class.delete(self)
56
+ @lock.synchronize do
57
+ @stopped = true
58
+ @wake.signal
59
+ end
60
+ @thread.join
61
+ end
62
+
63
+ def hurry
64
+ @lock.synchronize do
65
+ @interval = [@interval, HURRIED_INTERVAL].min
66
+ @hurried = true
67
+ @wake.signal
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def start(connected_to)
74
+ thread = Thread.new do
75
+ ::ActiveRecord::Base.connected_to_stack.concat(connected_to)
76
+ beat until stopped_after_interval?
77
+ end
78
+ thread.name = 'ruby_llm claim heartbeat'
79
+ thread.report_on_exception = false
80
+ thread
81
+ end
82
+
83
+ def stopped_after_interval?
84
+ @lock.synchronize do
85
+ @wake.wait(@lock, @interval) unless @stopped || @hurried
86
+ @hurried = false
87
+ @stopped
88
+ end
89
+ end
90
+
91
+ def beat
92
+ deadline = monotonic_now + (@interval / 4.0)
93
+ connected(@models.map(&:connection_pool).uniq, deadline) { @beat.call }
94
+ @failed_beats = 0
95
+ rescue StandardError => e
96
+ beat_failed(e)
97
+ end
98
+
99
+ def beat_failed(error)
100
+ @failed_beats += 1
101
+ if @failed_beats == 1 && busy?(error)
102
+ RubyLLM.logger.debug("RubyLLM skipped renewing a chat's claims, the database was busy")
103
+ else
104
+ RubyLLM.logger.warn("RubyLLM could not renew a chat's claims (#{@failed_beats} in a row): " \
105
+ "#{error.class}: #{error.message}")
106
+ end
107
+ end
108
+
109
+ def busy?(error)
110
+ [error, error.cause].any?(::SQLite3::BusyException) if defined?(::SQLite3::BusyException)
111
+ end
112
+
113
+ def connected(pools, deadline, &)
114
+ return yield if pools.empty?
115
+
116
+ pools.first.with_connection do |connection|
117
+ waiting_until(connection, deadline) { connected(pools.drop(1), deadline, &) }
118
+ end
119
+ end
120
+
121
+ # Before Rails 8, SQLite waits for a lock through busy_timeout, which
122
+ # holds the GVL and stops every thread in the process, including the
123
+ # run whose transaction holds the lock. The beat waits with the GVL
124
+ # released instead, until its deadline, and then disconnects, so the
125
+ # next query on that connection reconnects with the application's own
126
+ # busy settings. An in-memory database has no other connection to wait
127
+ # for, and disconnecting would drop it.
128
+ def waiting_until(connection, deadline)
129
+ return yield unless gvl_held_while_busy?(connection)
130
+
131
+ begin
132
+ connection.raw_connection.busy_handler { monotonic_now < deadline && sleep(0.001) }
133
+ yield
134
+ ensure
135
+ connection.disconnect!
136
+ end
137
+ end
138
+
139
+ def gvl_held_while_busy?(connection)
140
+ connection.adapter_name == 'SQLite' && ::ActiveRecord::VERSION::MAJOR < 8 &&
141
+ connection.supports_concurrent_connections?
142
+ end
143
+
144
+ def monotonic_now
145
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'tempfile'
5
+
6
+ module RubyLLM
7
+ module ActiveRecord
8
+ # Keeps a persisted chat's files in +ruby_llm_chat_files+, with their
9
+ # content in Active Storage, so they live on whatever service Active
10
+ # Storage uses: a disk now, a bucket later. A persisted chat uses one
11
+ # when ChatFiles#with_file_store is given no store.
12
+ #
13
+ # chat.with_file_store
14
+ # chat.file_store.list
15
+ #
16
+ # The store writes files for the chat's owner, the record
17
+ # <tt>acts_as_chat owner:</tt> names or one set with
18
+ # ChatMethods#with_owner, and sees only their files and the files
19
+ # without an owner, so in a chat several people share, one person's
20
+ # tool results stay theirs:
21
+ #
22
+ # chat.with_owner(Current.user).with_file_store
23
+ #
24
+ # What the model last read of a file is kept in the row's
25
+ # +read_digest+ column, so it holds across processes, and #with_lock
26
+ # locks the chat's row. Converted text is kept in the row's +text+
27
+ # column. Encrypt it, like
28
+ # anything else the model reads, with
29
+ # <tt>RubyLLM::ActiveRecord::ChatFile.encrypts :text</tt>.
30
+ class FileStore < RubyLLM::FileStore
31
+ # The chat record the files belong to.
32
+ attr_reader :chat
33
+
34
+ # Creates a store for the files of the +chat+ record, writing them for
35
+ # +owner+ when given, and otherwise for the chat's owner.
36
+ #
37
+ # Raises ArgumentError when +chat+ is not an +acts_as_chat+ record,
38
+ # and ConfigurationError when Active Storage is not loaded.
39
+ def initialize(chat, owner: nil)
40
+ unless chat.is_a?(ChatFiles)
41
+ raise ArgumentError, "#{self.class} needs an acts_as_chat record, not #{chat.class}"
42
+ end
43
+ unless ChatFile.respond_to?(:reflect_on_attachment) && ChatFile.reflect_on_attachment(:file)
44
+ raise ConfigurationError, "#{self.class} needs Active Storage. " \
45
+ 'Load it with require "active_storage/engine" in config/application.rb, ' \
46
+ 'then run bin/rails active_storage:install and bin/rails db:migrate.'
47
+ end
48
+
49
+ super()
50
+ @chat = chat
51
+ @owner = owner
52
+ end
53
+
54
+ # Returns the owner the store writes files for: the one it was
55
+ # created with, or else the chat's owner when that is a record.
56
+ # Returns +nil+ when there is neither.
57
+ def owner
58
+ return @owner unless @owner.nil?
59
+
60
+ owner = @chat.to_llm.owner
61
+ owner if owner.is_a?(::ActiveRecord::Base)
62
+ end
63
+
64
+ # Returns the files inside the folder +prefix+, or every file.
65
+ def list(prefix = nil)
66
+ scope = prefix.present? ? inside(path(prefix)) : visible
67
+ preferred(scope.includes(:owner).order(:path, :id).to_a).map(&:to_stored_file)
68
+ end
69
+
70
+ # Returns the StoredFile at +path+, or +nil+.
71
+ def [](path)
72
+ row(path)&.to_stored_file
73
+ end
74
+
75
+ # Returns the content of the file at +path+.
76
+ def read(path)
77
+ record = row(path) || raise(FileNotFoundError, "No file at #{path(path)}")
78
+ record.file.download.b
79
+ end
80
+
81
+ # Writes +content+ to +path+ and returns its StoredFile.
82
+ def write(path, content, content_type: nil)
83
+ path = path(path)
84
+ rewindable(content) do |io|
85
+ type = content_type || RubyLLM::FileStore.content_type_for(path, io)
86
+ digest = RubyLLM::FileStore.digest(io)
87
+ io.rewind
88
+ blob = ::ActiveStorage::Blob.create_and_upload!(io:, filename: File.basename(path),
89
+ content_type: type, identify: false)
90
+ save(path, blob.byte_size, type, digest) { |record| record.file.attach(blob) }
91
+ end
92
+ end
93
+
94
+ # Deletes the file at +path+ and returns its StoredFile, or +nil+. A
95
+ # store with an owner deletes only that owner's files.
96
+ def delete(path)
97
+ path = path(path)
98
+ with_lock do
99
+ record = owned.find_by(path:)
100
+ refuse_folder(path) if record.nil? && rows_in_folder(path).any? { |row| row.path != path }
101
+ record&.destroy
102
+ record&.to_stored_file
103
+ end
104
+ end
105
+
106
+ # Deletes everything inside the folder +path+ and returns the deleted
107
+ # StoredFile objects. A store with an owner deletes only that
108
+ # owner's files.
109
+ def delete_folder(path)
110
+ path = path(path)
111
+ with_lock do
112
+ refuse_file(path) if row(path)
113
+
114
+ rows_in_folder(path, owned).each(&:destroy).map(&:to_stored_file)
115
+ end
116
+ end
117
+
118
+ # Returns the digest of what the model last read at +path+, from the
119
+ # file's row, or +nil+.
120
+ def read_digest(path) # :nodoc:
121
+ return super unless read_digests?
122
+
123
+ row(path)&.read_digest
124
+ end
125
+
126
+ # Keeps +digest+ as what the model read at +path+, in the file's row.
127
+ def record_read(path, digest) # :nodoc:
128
+ return super unless read_digests?
129
+
130
+ row(path)&.update_columns(read_digest: digest)
131
+ end
132
+
133
+ # Runs the block in a transaction that locks the chat's row, past
134
+ # the query cache, so other processes wait for it.
135
+ def with_lock
136
+ super do
137
+ ChatFile.uncached do
138
+ ChatFile.transaction do
139
+ lock_chat
140
+ yield
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ # Returns the text kept for +file+, or +nil+.
147
+ def cached_text(file) # :nodoc:
148
+ record = row(file.path)
149
+ record.text if record&.digest == file.digest && record.has_attribute?(:text)
150
+ end
151
+
152
+ # Keeps +text+ for +file+ in its row.
153
+ def cache_text(file, text) # :nodoc:
154
+ record = row(file.path)
155
+ record.update_columns(text:) if record&.digest == file.digest && record.has_attribute?(:text)
156
+ end
157
+
158
+ # Keeps an attachment from Active Storage by its blob, without
159
+ # copying it: the file and the message share the blob.
160
+ def store_attachment(attachment) # :nodoc:
161
+ blob = blob_of(attachment)
162
+ return super unless blob
163
+
164
+ existing = visible.joins(:file_blob).find_by(active_storage_blobs: { id: blob.id })
165
+ existing ? existing.to_stored_file : super
166
+ end
167
+
168
+ private
169
+
170
+ # Active Storage reads an IO from its start, so one that cannot
171
+ # rewind, or stands past its start, is copied to a tempfile first.
172
+ def rewindable(content)
173
+ return yield StringIO.new(content.to_s.b) unless content.respond_to?(:read)
174
+ return yield content if at_start?(content)
175
+
176
+ Tempfile.create('ruby_llm-file-', binmode: true) do |copy|
177
+ IO.copy_stream(content, copy)
178
+ copy.rewind
179
+ yield copy
180
+ end
181
+ end
182
+
183
+ def at_start?(io)
184
+ %i[rewind pos].all? { |method| io.respond_to?(method) } && io.pos.zero?
185
+ rescue Errno::ESPIPE
186
+ false
187
+ end
188
+
189
+ def read_digests?
190
+ ChatFile.column_names.include?('read_digest')
191
+ end
192
+
193
+ def rows_in_folder(folder, scope = visible)
194
+ inside(folder, scope).includes(:owner).order(:path, :id)
195
+ .select { |row| RubyLLM::FileStore.inside?(row.path, folder) }
196
+ end
197
+
198
+ def write_attachment(path, attachment, content)
199
+ blob = blob_of(attachment)
200
+ return super unless blob
201
+
202
+ save(path, blob.byte_size, attachment.mime_type, RubyLLM::FileStore.digest(content)) do |record|
203
+ record.file.attach(blob)
204
+ end
205
+ end
206
+
207
+ def save(path, byte_size, content_type, digest)
208
+ ChatFile.transaction do
209
+ lock_chat
210
+ record = owned.lock.find_or_initialize_by(path:)
211
+ record.assign_attributes(byte_size:, content_type:, digest:)
212
+ record.text = nil if record.has_attribute?(:text)
213
+ yield record
214
+ record.save!
215
+ record.to_stored_file
216
+ end
217
+ end
218
+
219
+ # SQLite has no row locks, and a deferred transaction that reads
220
+ # first fails when it later writes beside another writer, so it takes
221
+ # the write lock at once with a no-op update.
222
+ def lock_chat
223
+ key = @chat.class.primary_key
224
+ scope = @chat.class.unscoped.where(key => @chat.id)
225
+ return scope.lock.pick(key) unless @chat.class.connection.adapter_name.match?(/sqlite/i)
226
+
227
+ column = @chat.class.connection.quote_column_name(key)
228
+ scope.update_all("#{column} = #{column}")
229
+ end
230
+
231
+ def row(path)
232
+ preferred(visible.where(path: path(path)).order(:id).to_a).first
233
+ end
234
+
235
+ def preferred(records)
236
+ records.group_by(&:path).values.map { |same| same.find(&:owner_id) || same.first }
237
+ end
238
+
239
+ def rows
240
+ ChatFile.where(chat: @chat)
241
+ end
242
+
243
+ def visible
244
+ shared = rows.where(owner_type: nil, owner_id: nil)
245
+ owner ? shared.or(rows.where(owner:)) : shared
246
+ end
247
+
248
+ def owned
249
+ owner ? rows.where(owner:) : rows.where(owner_type: nil, owner_id: nil)
250
+ end
251
+
252
+ def inside(folder, scope = visible)
253
+ inside = "#{ChatFile.sanitize_sql_like(folder, '!')}/%"
254
+ scope.where("path = :folder OR path LIKE :inside ESCAPE '!'", folder:, inside:)
255
+ end
256
+
257
+ def path(name)
258
+ RubyLLM::FileStore.path(name)
259
+ end
260
+
261
+ def blob_of(attachment)
262
+ source = attachment.source
263
+ return source if defined?(::ActiveStorage::Blob) && source.is_a?(::ActiveStorage::Blob)
264
+
265
+ source.blob if source.respond_to?(:blob) && source.blob.is_a?(::ActiveStorage::Blob)
266
+ end
267
+ end
268
+ end
269
+ end
@@ -2,9 +2,23 @@
2
2
 
3
3
  module RubyLLM
4
4
  module ActiveRecord
5
- # RubyLLM's persistence for MCP OAuth credentials, encrypted with Active
6
- # Record encryption.
7
- class MCPCredential < Record # :nodoc:
5
+ # The OAuth state RubyLLM::MCP keeps for a server, in RubyLLM's
6
+ # +ruby_llm_mcp_credentials+ table. +data+ is encrypted with Active
7
+ # Record encryption, so the application needs encryption keys.
8
+ #
9
+ # RubyLLM::ActiveRecord::MCPCredential.where(owner: current_user).pluck(:updated_at)
10
+ #
11
+ # Each row has an optional polymorphic +owner+, the record the
12
+ # credentials were authorized for. Its columns:
13
+ #
14
+ # [+key+] The key RubyLLM stores the state under, unique across the table.
15
+ # [+data+] Tokens, client registrations, and signing keys, as a Hash.
16
+ # Treat it as a secret.
17
+ #
18
+ # RubyLLM writes these rows. Read and query them, and add scopes or
19
+ # columns of your own, but do not create, update, or delete them: call
20
+ # +deauthorize+ on the server to forget a user's credentials.
21
+ class MCPCredential < Record
8
22
  self.table_name = 'ruby_llm_mcp_credentials'
9
23
 
10
24
  belongs_to :owner, polymorphic: true, optional: true
@@ -15,21 +29,22 @@ module RubyLLM
15
29
  validates :key, presence: true
16
30
 
17
31
  class << self
18
- def read(key)
32
+ def read(key) # :nodoc:
19
33
  find_by(key:)&.data
20
34
  end
21
35
 
22
- def write(key, data, owner: nil)
23
- record = find_or_initialize_by(key:)
24
- record.update!(data:, owner: owner.is_a?(::ActiveRecord::Base) ? owner : nil)
36
+ def write(key, data, owner: nil) # :nodoc:
37
+ attributes = { data:, owner: owner.is_a?(::ActiveRecord::Base) ? owner : nil }
38
+ record = find_by(key:) || create_or_find_by!(key:) { |created| created.assign_attributes(attributes) }
39
+ record.update!(attributes)
25
40
  end
26
41
 
27
- def delete(key)
42
+ def delete(key) # :nodoc:
28
43
  where(key:).delete_all
29
44
  end
30
45
 
31
46
  # Updating the row locks it on every adapter; SQLite ignores FOR UPDATE.
32
- def synchronize(key)
47
+ def synchronize(key) # :nodoc:
33
48
  transaction do
34
49
  where(key:).touch_all
35
50
  yield
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module RubyLLM
6
+ module ActiveRecord
7
+ # Shows a chat's messages live with Turbo when
8
+ # Configuration#broadcast_messages, or the model's +broadcasts:+
9
+ # option, is on. An empty assistant reply is
10
+ # appended when it is created, so its chunks have a place to land, and
11
+ # each chunk is appended as a numbered span that the chat UI's Stimulus
12
+ # controller puts back in order. A saved message replaces itself, a
13
+ # destroyed one is removed, and every change refreshes the chat page.
14
+ # A refresh carries no request id, so it reaches the page whose request
15
+ # made the change too, and Turbo never skips a burst of refreshes as
16
+ # that page's own. Broadcasts go to the chat record's stream:
17
+ #
18
+ # <%= turbo_stream_from @chat %>
19
+ #
20
+ module MessageBroadcasting
21
+ extend ActiveSupport::Concern
22
+
23
+ # Returns whether Turbo broadcasting can run: turbo-rails and Action
24
+ # Cable are loaded.
25
+ def self.available?
26
+ defined?(::Turbo::Broadcastable) && defined?(::ActionCable) ? true : false
27
+ end
28
+
29
+ included do
30
+ after_create_commit -> { broadcast_append_to chat_association }, if: :streaming_placeholder?
31
+ after_update_commit -> { broadcast_replace_to chat_association }, if: :broadcasting?
32
+ after_destroy_commit -> { broadcast_remove_to chat_association }, if: :broadcasting?
33
+ after_commit -> { broadcast_refresh_to chat_association, request_id: nil }, if: :broadcasting?
34
+ end
35
+
36
+ class_methods do
37
+ # Returns whether this model's messages broadcast: its
38
+ # +broadcasts:+ option when given, Configuration#broadcast_messages
39
+ # otherwise.
40
+ def broadcasting?
41
+ ruby_llm_broadcasts.nil? ? RubyLLM.config.broadcast_messages == true : ruby_llm_broadcasts
42
+ end
43
+ end
44
+
45
+ # Returns whether this message broadcasts. See ::broadcasting?.
46
+ def broadcasting?
47
+ self.class.broadcasting?
48
+ end
49
+
50
+ # Appends the content of +chunk+, a RubyLLM::Chunk streamed into this
51
+ # message, to the message's <tt><dom_id>_stream</tt> element as
52
+ # <tt><span data-seq="index"></tt>, escaped. Each span names the
53
+ # index of the span before it in <tt>data-after</tt>, so the page can
54
+ # order them. A chunk can reach the page before the message does, and
55
+ # Turbo drops an append with no target, so a page of your own must hold
56
+ # it, as the chat UI's Stimulus controller does. Returns +nil+ for a
57
+ # chunk without content. Raises
58
+ # RubyLLM::ConfigurationError when the message does not broadcast.
59
+ #
60
+ # chat.ask("Tell me a story") { |chunk| chunk.message.broadcast_chunk(chunk) }
61
+ #
62
+ def broadcast_chunk(chunk)
63
+ raise RubyLLM::ConfigurationError, ChunkMethods::BROADCASTING_OFF unless broadcasting?
64
+ return if chunk.content.to_s.empty?
65
+
66
+ after = @last_broadcast_chunk_index
67
+ @last_broadcast_chunk_index = chunk.index
68
+ broadcast_append_to chat_association,
69
+ target: "#{ActionView::RecordIdentifier.dom_id(self)}_stream",
70
+ content: chunk_span(chunk, after)
71
+ end
72
+
73
+ private
74
+
75
+ def streaming_placeholder?
76
+ broadcasting? && role.to_s == 'assistant' && content.blank?
77
+ end
78
+
79
+ def chunk_span(chunk, after)
80
+ after_attribute = %( data-after="#{after}") if after
81
+ %(<span data-seq="#{chunk.index}"#{after_attribute}>#{ERB::Util.html_escape(chunk.content)}</span>)
82
+ end
83
+ end
84
+ end
85
+ end