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
@@ -13,6 +13,7 @@ module RubyLLM
13
13
  class Rerank
14
14
  include Support::Inspectable
15
15
  include Accounting::Usage::Result
16
+ include Accounting::Usage::ModelInfo
16
17
 
17
18
  # One reranked document: its +index+ in the input array, the
18
19
  # +document+ text, and the provider's relevance +score+.
@@ -27,11 +28,12 @@ module RubyLLM
27
28
  # The raw provider response body.
28
29
  attr_reader :raw
29
30
 
30
- def initialize(results:, model:, raw: nil, input_tokens: nil, reported_cost: nil) # :nodoc:
31
+ def initialize(results:, model:, raw: nil, input_tokens: nil, search_units: nil, reported_cost: nil) # :nodoc:
31
32
  @results = results
32
33
  @model = model
33
34
  @raw = raw
34
35
  @input_tokens = input_tokens
36
+ @search_units = search_units
35
37
  @reported_cost = reported_cost
36
38
  end
37
39
 
@@ -39,7 +41,7 @@ module RubyLLM
39
41
  def tokens
40
42
  return ruby_llm_usage_tokens unless ruby_llm_usage_entries.empty?
41
43
 
42
- Tokens.new(input: @input_tokens, reported_cost: @reported_cost)
44
+ Tokens.new(input: @input_tokens, search_units: @search_units, reported_cost: @reported_cost)
43
45
  end
44
46
 
45
47
  # Returns the rerank cost across every provider attempt.
@@ -49,14 +51,6 @@ module RubyLLM
49
51
  Cost.new(tokens:, model: model_info, category: :embeddings)
50
52
  end
51
53
 
52
- attr_writer :model_info # :nodoc:
53
-
54
- def model_info # :nodoc:
55
- @model_info ||= RubyLLM.models.find(model)
56
- rescue ModelNotFoundError
57
- nil
58
- end
59
-
60
54
  def inspect_attributes # :nodoc:
61
55
  { model: model, results: results.length }
62
56
  end
@@ -67,7 +61,7 @@ module RubyLLM
67
61
  # limits how many results come back. +provider_options:+ merges
68
62
  # options into the request in the provider's vocabulary.
69
63
  # +owner:+ attributes the usage to a record, such as a user, and wins
70
- # over RubyLLM.with_usage_owner.
64
+ # over RubyLLM.with_owner.
71
65
  #
72
66
  # RubyLLM.rerank "what is ruby", docs, model: "voyageai/rerank-2.5-lite", provider: :openrouter
73
67
  #
@@ -80,32 +74,11 @@ module RubyLLM
80
74
  provider_options: {},
81
75
  metadata: nil,
82
76
  owner: nil)
83
- config = context&.config || RubyLLM.config
84
- model, provider_instance = Models.resolve(model, provider: provider, assume_model_exists: assume_model_exists,
85
- config: config)
86
- empty_tokens = Tokens.new
87
- payload = {
88
- provider: provider_instance.slug,
89
- provider_class: provider_instance.name,
90
- model: model.id,
91
- model_info: model,
92
- query: query,
93
- document_count: documents.length,
94
- top_n: top_n,
95
- provider_options: provider_options,
96
- metadata: metadata,
97
- tokens: empty_tokens,
98
- cost: Cost.new(tokens: empty_tokens, model:, category: :embeddings)
99
- }
100
-
101
- RubyLLM.instrument('rerank.ruby_llm', payload, config: config) do |event|
102
- result = Accounting::Usage.owned_by(owner) do
103
- provider_instance.rerank(query, documents, model:, top_n:, provider_options:)
104
- end
105
- event[:result] = result
106
- event[:tokens] = result.tokens
107
- event[:cost] = result.cost
108
- result
77
+ Accounting::Operation.new(
78
+ 'rerank', model, category: :embeddings, provider:, assume_model_exists:, context:, owner:
79
+ ).run(query:, document_count: documents.length, top_n:, provider_options:,
80
+ metadata:) do |provider_instance, model|
81
+ provider_instance.rerank(query, documents, model:, top_n:, provider_options:)
109
82
  end
110
83
  end
111
84
  end
@@ -11,6 +11,7 @@ module RubyLLM
11
11
  #
12
12
  class ResearchJob
13
13
  include Support::Inspectable
14
+ include Support::DeprecatedJobPredicates
14
15
 
15
16
  class DeadlineExpired < StandardError; end # :nodoc:
16
17
  private_constant :DeadlineExpired
@@ -70,7 +71,7 @@ module RubyLLM
70
71
  # +provider_tools:+ accepts an array of aliases or a Hash of aliases
71
72
  # and their options, as on Chat#with_provider_tools. The job records
72
73
  # its usage, attributed to +owner:+ or the owner of
73
- # RubyLLM.with_usage_owner, when it finishes.
74
+ # RubyLLM.with_owner, when it finishes.
74
75
  def self.research_later(prompt, provider:, agent:, with: nil, provider_tools: nil,
75
76
  context: nil, provider_options: {}, metadata: nil, owner: nil)
76
77
  config = context&.config || RubyLLM.config
@@ -128,6 +129,7 @@ module RubyLLM
128
129
  @agent = agent
129
130
  @protocol = protocol
130
131
  @usage_owner = Accounting::Usage.owner
132
+ @usage_workflow = Accounting::Usage.workflow
131
133
  apply_state(state)
132
134
  end
133
135
 
@@ -135,10 +137,10 @@ module RubyLLM
135
137
  def pending? = status == :pending
136
138
 
137
139
  # Returns whether the task reached any terminal state.
138
- def done? = !pending?
140
+ def complete? = !pending?
139
141
 
140
142
  # Returns whether the task finished with a complete report.
141
- def completed? = status == :completed
143
+ def succeeded? = status == :completed
142
144
 
143
145
  # Returns whether the provider stopped before completing the report.
144
146
  def incomplete? = status == :incomplete
@@ -153,7 +155,7 @@ module RubyLLM
153
155
  # task finishes. +timeout:+ limits this request in seconds.
154
156
  def refresh(timeout: nil)
155
157
  self.class.validate_polling_options(timeout, 1) unless timeout.nil?
156
- unless done?
158
+ unless complete?
157
159
  state = request_with_timeout(timeout) { @protocol.refresh_research_job(self, timeout:) }
158
160
  apply_state(state)
159
161
  end
@@ -166,7 +168,7 @@ module RubyLLM
166
168
  def wait(timeout: 600, interval: 5)
167
169
  self.class.validate_polling_options(timeout, interval)
168
170
  deadline = monotonic_time + timeout
169
- until done?
171
+ until complete?
170
172
  remaining = deadline - monotonic_time
171
173
  raise TimeoutError.new("Research timed out (job #{id})", job: self) unless remaining.positive?
172
174
 
@@ -182,7 +184,7 @@ module RubyLLM
182
184
  # can confirm cancellation; this does not delete stored task data.
183
185
  def cancel(timeout: 5)
184
186
  self.class.validate_polling_options(timeout, 1)
185
- unless done?
187
+ unless complete?
186
188
  state = request_with_timeout(timeout) { @protocol.cancel_research_job(self, timeout:) }
187
189
  apply_state(state)
188
190
  end
@@ -212,19 +214,29 @@ module RubyLLM
212
214
  def request_with_timeout(timeout, &)
213
215
  Timeout.timeout(timeout, DeadlineExpired, &)
214
216
  rescue DeadlineExpired, Faraday::TimeoutError => e
215
- raise TimeoutError.new("Research request timed out (job #{id})", job: self), cause: e
217
+ raise request_timed_out, cause: e
216
218
  rescue Error
217
219
  raise
220
+ rescue ConnectionError => e
221
+ raise e.timeout? ? request_timed_out : request_failed(e), cause: e
218
222
  rescue RubyLLM::Error, Faraday::Error => e
219
- response = e.response if e.respond_to?(:response)
223
+ raise request_failed(e), cause: e
224
+ end
225
+
226
+ def request_timed_out
227
+ TimeoutError.new("Research request timed out (job #{id})", job: self)
228
+ end
229
+
230
+ def request_failed(error)
231
+ response = error.response if error.respond_to?(:response)
220
232
  @raw = response.body if response.respond_to?(:body)
221
- raise Error.new("Research request failed: #{e.message} (job #{id})", job: self, response:), cause: e
233
+ Error.new("Research request failed: #{error.message} (job #{id})", job: self, response:)
222
234
  end
223
235
 
224
- # A job found by ID may have been submitted and recorded elsewhere, so
225
- # only a finish this object observes records usage.
236
+ # Each object records its job's usage once: when it first sees the job
237
+ # finished, at construction or on a later refresh.
226
238
  def apply_state(state)
227
- observed_finish = pending? && state.fetch(:status) != :pending
239
+ observed_finish = (@status.nil? || pending?) && state.fetch(:status) != :pending
228
240
  @status = state.fetch(:status)
229
241
  @raw = state[:raw]
230
242
  @error = state[:error]
@@ -236,7 +248,8 @@ module RubyLLM
236
248
  def record_usage
237
249
  status = { completed: :succeeded, incomplete: :succeeded, cancelled: :cancelled }.fetch(@status, :failed)
238
250
  entry = Accounting::Usage::Entry.new(operation: :research, provider:, model: agent, status:, tokens:, cost:,
239
- owner: @usage_owner)
251
+ owner: @usage_owner,
252
+ workflow: @usage_workflow)
240
253
  Accounting::Usage.report(entry, config: @protocol.config)
241
254
  end
242
255
 
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Sandbox
5
+ # Rewrites a policy for Landlock, which grants whole directory trees
6
+ # and cannot take part of one away. Each readable or writable path that
7
+ # holds a denied one is replaced by the entries around it, down to the
8
+ # denied path, so the policy hides the same paths without denying
9
+ # anything inside a granted tree. Entries added later to a directory
10
+ # granted entry by entry stay hidden. A writable path cannot be split
11
+ # that way, since creating files needs the directory itself, so a
12
+ # denied path inside one raises ConfigurationError. Paths compare by
13
+ # where they really lead, since that is what Landlock grants.
14
+ module Landlock # :nodoc:
15
+ module_function
16
+
17
+ def policy(sandbox)
18
+ denied, readable, writable = [sandbox.denied, sandbox.readable, sandbox.writable].map do |paths|
19
+ paths.map { |path| real(path) }
20
+ end
21
+ refuse_writable(writable, denied)
22
+ sandbox.dup
23
+ .with_readable(nil).with_readable(*around(readable, denied))
24
+ .with_writable(nil).with_writable(*around(writable, denied))
25
+ .with_denied(nil)
26
+ .with_denied(*denied.reject { |path| (readable + writable).any? { |root| inside?(path, root) } })
27
+ .with_backend(:landlock)
28
+ end
29
+
30
+ def refuse_writable(writable, denied)
31
+ writable.each do |path|
32
+ hidden = denied.find { |candidate| candidate != path && inside?(candidate, path) }
33
+ next unless hidden
34
+
35
+ raise ConfigurationError, "Landlock cannot hide #{hidden} inside the writable #{path}. " \
36
+ 'Leave it out of the writable path, or run on Bubblewrap'
37
+ end
38
+ end
39
+
40
+ def around(paths, denied)
41
+ paths.flat_map { |path| entries(path, denied) }.uniq
42
+ end
43
+
44
+ def entries(path, denied)
45
+ return [] if hidden?(path, denied)
46
+
47
+ return [path] if denied.none? { |hidden| inside?(hidden, path) }
48
+ return [] unless File.directory?(path)
49
+
50
+ Dir.children(path).sort.flat_map { |child| entries(File.join(path, child), denied) }
51
+ end
52
+
53
+ def hidden?(path, denied)
54
+ denied.any? { |hidden| inside?(path, hidden) } || reaches?(path, denied)
55
+ end
56
+
57
+ def reaches?(path, denied)
58
+ return false unless File.symlink?(path)
59
+
60
+ target = File.realpath(path)
61
+ denied.any? { |hidden| inside?(target, hidden) || inside?(hidden, target) }
62
+ rescue SystemCallError
63
+ true
64
+ end
65
+
66
+ def real(path)
67
+ path = File.expand_path(path)
68
+ File.realpath(path)
69
+ rescue SystemCallError
70
+ File.join(real(File.dirname(path)), File.basename(path))
71
+ end
72
+
73
+ def inside?(path, root)
74
+ path == root || path.start_with?(File.join(root, ''))
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Sandbox
5
+ # Runs commands with an MXC::Sandbox class or instance from the mxc gem:
6
+ # Bubblewrap on Linux, unless Configuration#sandbox_backend or the
7
+ # sandbox asks for Landlock, Seatbelt on macOS, and process containers
8
+ # on Windows. Each run starts from a fresh copy of the sandbox's policy
9
+ # and adds only what RubyLLM passes for that run.
10
+ class MXC # :nodoc:
11
+ DOCS = 'https://rubyllm.com/sandboxes/#running-on-landlock'
12
+ NAMESPACES = /namespace/i
13
+ BUBBLEWRAP_REMEDIATION =
14
+ 'Allow unprivileged user namespaces for Bubblewrap: on Ubuntu 23.10 and later, including 24.04, ' \
15
+ 'give bwrap an AppArmor profile that allows userns, or, host-wide and weaker, run ' \
16
+ 'sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0; on kernels with ' \
17
+ 'kernel.unprivileged_userns_clone, set it to 1. In Docker, run with a seccomp profile that allows ' \
18
+ 'namespaces: https://crmne.github.io/mxc/running-in-docker/. ' \
19
+ 'See https://rubyllm.com/sandboxes/#running-on-linux'
20
+ LANDLOCK_OPT_IN = 'Or set config.sandbox_backend = :landlock to run on Landlock, which isolates less ' \
21
+ "and has no network access: #{DOCS}".freeze
22
+ MINIMUM_VERSION = '0.4.1'
23
+ private_constant :MINIMUM_VERSION
24
+
25
+ attr_reader :policy, :libraries
26
+
27
+ # Raises ConfigurationError when the installed mxc is older than
28
+ # MINIMUM_VERSION.
29
+ def initialize(policy, backend: :bubblewrap, libraries: [])
30
+ require_minimum_version
31
+ @policy = policy
32
+ @backend = backend
33
+ @libraries = libraries
34
+ end
35
+
36
+ def supported?
37
+ sandbox.supported?
38
+ end
39
+
40
+ def reason
41
+ current = sandbox
42
+ availability = current.availability
43
+ return if availability.supported?
44
+
45
+ [availability.reason, *remediation(current.backend, availability)].compact.join(' ')
46
+ end
47
+
48
+ def backend = sandbox.backend
49
+ def readable = sandbox.readable
50
+ def writable = sandbox.writable
51
+ def network = sandbox.network
52
+ def directory = sandbox.directory
53
+ def timeout = sandbox.timeout
54
+ def limits = sandbox.respond_to?(:limits) ? sandbox.limits : {}
55
+
56
+ # Returns a runner for a sandbox with none of this one's access, on
57
+ # the same backend, for RubyLLM's own package installs.
58
+ def isolated
59
+ self.class.new(::MXC::Sandbox.new(backend: sandbox.backend), backend: @backend, libraries: @libraries)
60
+ end
61
+
62
+ # Returns a runner whose commands may load the libraries +binaries+
63
+ # link, such as an interpreter's extensions. mxc before 0.4 cannot
64
+ # grant them, and runs without.
65
+ def with_libraries_of(*binaries)
66
+ self.class.new(@policy, backend: @backend, libraries: [*@libraries, *binaries])
67
+ end
68
+
69
+ def run(*command, timeout:, readable: [], writable: [], denied: [], directory: nil, env: {}, network: nil, &)
70
+ run = sandbox.with_readable(*readable).with_writable(*writable).with_denied(*denied).with_timeout(timeout)
71
+ run.with_directory(directory) if run.directory.nil? && directory
72
+ run.with_env(env.reject { |name, _| run.env.key?(name) })
73
+ apply_network(run, network)
74
+ run.with_libraries_of(*@libraries) if run.respond_to?(:with_libraries_of)
75
+ run = Landlock.policy(run) if run.backend == :landlock
76
+ block_given? ? run_by_line(run, command, &) : run.run(*command)
77
+ end
78
+
79
+ def inspect
80
+ "#<#{self.class.name} #{sandbox.inspect}>"
81
+ end
82
+
83
+ private
84
+
85
+ def require_minimum_version
86
+ return unless defined?(::MXC::VERSION)
87
+
88
+ installed = ::MXC::VERSION
89
+ return if Gem::Version.new(installed) >= Gem::Version.new(MINIMUM_VERSION)
90
+
91
+ raise ConfigurationError,
92
+ "RubyLLM's sandbox needs mxc #{MINIMUM_VERSION} or later; you have #{installed}. Run bundle update mxc."
93
+ end
94
+
95
+ def run_by_line(run, command)
96
+ pending = Hash.new { |buffers, stream| buffers[stream] = String.new(encoding: Encoding::BINARY) }
97
+ result = run.run(*command) do |chunk|
98
+ buffer = pending[chunk.stream]
99
+ scanned = buffer.bytesize
100
+ buffer << chunk.content.b
101
+ while (newline = buffer.index("\n", scanned))
102
+ yield text(buffer.slice!(0..newline))
103
+ scanned = 0
104
+ end
105
+ end
106
+ pending.each_value { |rest| yield text(rest) unless rest.empty? }
107
+ result
108
+ end
109
+
110
+ def text(bytes)
111
+ bytes.force_encoding(Encoding::UTF_8).scrub
112
+ end
113
+
114
+ def sandbox
115
+ sandbox = @policy.is_a?(Class) ? @policy.new : @policy.dup
116
+ sandbox.backend == :auto && linux? ? sandbox.with_backend(@backend) : sandbox
117
+ end
118
+
119
+ def remediation(backend, availability)
120
+ return availability.remediation unless backend == :bubblewrap
121
+
122
+ namespaces = [availability.reason, availability.remediation].join(' ').match?(NAMESPACES)
123
+ [namespaces ? BUBBLEWRAP_REMEDIATION : availability.remediation, LANDLOCK_OPT_IN]
124
+ end
125
+
126
+ def linux?
127
+ RUBY_PLATFORM.include?('linux')
128
+ end
129
+
130
+ def apply_network(run, network)
131
+ return if network.nil?
132
+
133
+ network.is_a?(Hash) ? run.with_network(**network) : run.with_network(network)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # A sandbox runs commands RubyLLM did not write, such as the code a model
5
+ # sends to Chat#with_code_execution, and holds the whole policy for them:
6
+ # what they may read and write, which hosts they reach, where they start,
7
+ # and how long they run. Define one as an MXC::Sandbox class from the mxc
8
+ # gem:
9
+ #
10
+ # class ReadOnlySandbox < MXC::Sandbox
11
+ # timeout 120
12
+ # end
13
+ #
14
+ # class WorkspaceSandbox < MXC::Sandbox
15
+ # directory Rails.root.join("storage/workspace")
16
+ # writable Rails.root.join("storage/workspace")
17
+ # timeout 300
18
+ # end
19
+ #
20
+ # RubyLLM.configure do |config|
21
+ # config.sandbox = "ReadOnlySandbox"
22
+ # end
23
+ #
24
+ # A String names the class and is looked up whenever a chat needs it,
25
+ # so in Rails the classes live in +app/sandboxes+, autoload, and reload
26
+ # like the rest of the application.
27
+ #
28
+ # A sandbox may be an MXC::Sandbox class or instance, or any other class
29
+ # or object whose instances answer two methods:
30
+ #
31
+ # class RemoteSandbox
32
+ # def supported?
33
+ # true
34
+ # end
35
+ #
36
+ # def run(*command, timeout:, readable: [], writable: [], denied: [], directory: nil, env: {}, network: nil)
37
+ # # Runs +command+ and returns an object answering stdout, stderr,
38
+ # # exit_code, and timed_out?
39
+ # end
40
+ # end
41
+ #
42
+ # +run+ takes the command like Kernel#system: one String for the shell,
43
+ # or a program and its arguments. The keywords are what RubyLLM adds to
44
+ # the sandbox's own policy for one run: +readable+ and +writable+ paths
45
+ # holding the run's files, +denied+ paths it must not see, even inside
46
+ # readable ones, the +directory+ to start in when the sandbox has none of
47
+ # its own, and +env+, the environment the run's languages and packages
48
+ # need. +timeout+ is the sandbox's own, or 120 seconds when it answers
49
+ # +timeout+ with +nil+ or not at all. +network+ is +nil+ for the
50
+ # sandbox's own access; RubyLLM passes <tt>{ allow: [...] }</tt> only to
51
+ # install declared packages from their registries. With a block, +run+
52
+ # may yield each line of output as it arrives, from standard output and
53
+ # standard error, like IO#each_line, and code execution reports the
54
+ # lines as progress; a sandbox may ignore the block. The result may also
55
+ # answer +blocked+, the hosts the network policy refused. A sandbox may
56
+ # answer +reason+, which RubyLLM adds to the error it raises when
57
+ # #supported? is false, and +with_libraries_of+, taking paths and
58
+ # returning a sandbox whose runs may load the libraries those files
59
+ # link, which RubyLLM calls with Ruby and its extensions. It may also
60
+ # answer +readable+, +writable+, +network+, +directory+, and +timeout+,
61
+ # which describe it to the model and to the person approving an
62
+ # escalation.
63
+ module Sandbox
64
+ # Seconds a run may take when the sandbox sets no timeout.
65
+ DEFAULT_TIMEOUT = 120
66
+
67
+ # The values Configuration#sandbox_backend accepts.
68
+ BACKENDS = %i[bubblewrap landlock].freeze
69
+
70
+ class << self
71
+ # Returns a runner for the sandbox +value+ describes, or +nil+ for
72
+ # none. A String names the sandbox class, looked up on each call.
73
+ # An MXC::Sandbox runs on +backend+ on Linux unless it names its own.
74
+ #
75
+ # Raises ConfigurationError for a value that is not a sandbox, or for
76
+ # a backend other than those in BACKENDS.
77
+ def resolve(value, backend: RubyLLM.config.sandbox_backend) # :nodoc:
78
+ return if value.nil?
79
+
80
+ value = constant(value) if value.is_a?(String)
81
+ return MXC.new(value, backend: backend_name(backend)) if mxc?(value)
82
+
83
+ instance = value.is_a?(Class) ? value.new : value
84
+ return instance if instance.respond_to?(:run) && instance.respond_to?(:supported?)
85
+
86
+ raise ConfigurationError,
87
+ "A sandbox is an MXC::Sandbox class or an object answering supported? and run, got #{value.inspect}"
88
+ end
89
+
90
+ # Returns the name the model uses for +value+: its class name without
91
+ # a +Sandbox+ suffix, in snake case, so WorkspaceSandbox is
92
+ # +workspace+.
93
+ #
94
+ # Raises ArgumentError when the class has no name.
95
+ def name_for(value) # :nodoc:
96
+ base = class_name(value).to_s.split('::').last.to_s.delete_suffix('Sandbox')
97
+ if base.empty?
98
+ raise ArgumentError, "Name the sandbox #{value.inspect} with a Hash: escalate_to: { name => sandbox }"
99
+ end
100
+
101
+ base.gsub(/([A-Z\d])([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
102
+ end
103
+
104
+ # Raises ConfigurationError unless +sandbox+ can run on this machine.
105
+ def verify(sandbox) # :nodoc:
106
+ return if sandbox.nil? || sandbox.supported?
107
+
108
+ reason = sandbox.reason if sandbox.respond_to?(:reason)
109
+ raise ConfigurationError, ['The configured sandbox cannot run on this machine.', reason].compact.join(' ')
110
+ end
111
+
112
+ # Returns the class a String names. Raises ConfigurationError when
113
+ # nothing by that name is defined.
114
+ def constant(name) # :nodoc:
115
+ Object.const_get(name)
116
+ rescue NameError
117
+ raise ConfigurationError, "The sandbox #{name} is not defined"
118
+ end
119
+
120
+ private
121
+
122
+ def backend_name(backend)
123
+ name = backend.respond_to?(:to_sym) ? backend.to_sym : backend
124
+ return name if BACKENDS.include?(name)
125
+
126
+ raise ConfigurationError,
127
+ "config.sandbox_backend is #{BACKENDS.map(&:inspect).join(' or ')}, got #{backend.inspect}"
128
+ end
129
+
130
+ def class_name(value)
131
+ return value if value.is_a?(String)
132
+
133
+ (value.is_a?(Class) ? value : value.class).name
134
+ end
135
+
136
+ def mxc?(value)
137
+ defined?(::MXC::Sandbox) && (value.is_a?(::MXC::Sandbox) || (value.is_a?(Class) && value <= ::MXC::Sandbox))
138
+ end
139
+ end
140
+ end
141
+ end
@@ -13,12 +13,16 @@ module RubyLLM
13
13
  # )
14
14
  # end
15
15
  #
16
- # Cited passages come back on the response as Message#citations.
16
+ # Cited passages come back on the response as Message#citations. Give a
17
+ # result a +:source_id+ String, such as a Global ID, to identify it to
18
+ # your app; its citations return it as Citation#source_id.
19
+ #
20
+ # RubyLLM::SearchResults.new(title: doc.name, source_id: doc.to_global_id.to_s, text: doc.body)
17
21
  class SearchResults
18
22
  KEY = 'search_results' # :nodoc:
19
23
 
20
24
  # The normalized results, as an array of hashes with +:title+, +:text+,
21
- # and optional +:url+ keys.
25
+ # and optional +:url+ and +:source_id+ keys.
22
26
  attr_reader :results
23
27
 
24
28
  # Returns a new SearchResults built from one or more result hashes, or
@@ -27,7 +31,8 @@ module RubyLLM
27
31
  # RubyLLM::SearchResults.new(title: 'Q4 Report', url: report_url, text: report_text)
28
32
  # RubyLLM::SearchResults.new({ title: 'A', text: '...' }, { title: 'B', text: '...' })
29
33
  #
30
- # Each result is reduced to its +:title+, +:url+, and +:text+ entries.
34
+ # Each result is reduced to its +:title+, +:url+, +:source_id+, and
35
+ # +:text+ entries.
31
36
  # Raises ArgumentError if no results are given or a result is missing
32
37
  # +:title+ or +:text+.
33
38
  def initialize(*results, **result)
@@ -62,7 +67,7 @@ module RubyLLM
62
67
  entry = Support::Utils.deep_symbolize_keys(entry.to_h)
63
68
  raise ArgumentError, 'Search results require :title and :text' unless entry[:title] && entry[:text]
64
69
 
65
- entry.slice(:title, :url, :text)
70
+ entry.slice(:title, :url, :source_id, :text)
66
71
  end
67
72
  end
68
73
  end
@@ -46,7 +46,7 @@ module RubyLLM
46
46
  attr_reader :search_suggestions
47
47
 
48
48
  def self.from_h(data) # :nodoc:
49
- data = Support::Utils.deep_symbolize_keys(data)
49
+ data = data.transform_keys(&:to_sym)
50
50
  new(type: data[:type], name: data[:name], id: data[:id], input: data[:input],
51
51
  result: data[:result], raw: data[:raw])
52
52
  end