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
@@ -35,8 +35,9 @@ module RubyLLM
35
35
  @mutex.synchronize { @subscribers = (@subscribers - [subscriber]).freeze }
36
36
  end
37
37
 
38
- def capture_context
39
- @subscribers.filter_map do |subscriber|
38
+ def capture_context(config: nil)
39
+ instrumenter = (config || RubyLLM.config).instrumenter
40
+ (@subscribers | [instrumenter]).filter_map do |subscriber|
40
41
  [subscriber, subscriber.capture_context] if subscriber.respond_to?(:capture_context)
41
42
  end
42
43
  end
@@ -63,15 +64,15 @@ module RubyLLM
63
64
  private_class_method :dispatch
64
65
 
65
66
  def current_workflow
66
- Thread.current[WORKFLOW_CONTEXT_KEY]
67
+ Fiber[WORKFLOW_CONTEXT_KEY]
67
68
  end
68
69
 
69
70
  def with_workflow(context)
70
71
  previous_context = current_workflow
71
- Thread.current[WORKFLOW_CONTEXT_KEY] = context
72
+ Fiber[WORKFLOW_CONTEXT_KEY] = context
72
73
  yield
73
74
  ensure
74
- Thread.current[WORKFLOW_CONTEXT_KEY] = previous_context
75
+ Fiber[WORKFLOW_CONTEXT_KEY] = previous_context
75
76
  end
76
77
  end
77
78
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Support
5
+ # The +rescue_from+ macro Agent, Tool, and MCP share: handlers named
6
+ # with +with:+ or given as a block, searched in reverse declaration
7
+ # order from the class up through its ancestors when an exception is
8
+ # raised, and run on the instance whose work raised.
9
+ module Rescuable # :nodoc:
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ # The class side of Rescuable.
15
+ module ClassMethods # :nodoc:
16
+ def rescue_from(*exception_classes, with: nil, &block)
17
+ raise ArgumentError, 'rescue_from needs a handler: pass with: or a block' unless with || block
18
+ raise ArgumentError, 'rescue_from takes with: or a block, not both' if with && block
19
+
20
+ exception_classes.flatten.each do |exception_class|
21
+ rescue_handlers << [rescue_handler_key(exception_class), with || block]
22
+ end
23
+ end
24
+
25
+ def rescue_handlers
26
+ @rescue_handlers ||= []
27
+ end
28
+
29
+ # Returns the handler for +exception+, searched from this class up
30
+ # through +within+, or through every ancestor without it.
31
+ def rescue_handler_for(exception, within: nil)
32
+ rescue_handlers.reverse_each do |class_name, handler|
33
+ exception_class = rescue_handler_class(class_name)
34
+ return handler if exception_class && exception.is_a?(exception_class)
35
+ end
36
+ return if equal?(within)
37
+
38
+ superclass.rescue_handler_for(exception, within:) if superclass.respond_to?(:rescue_handler_for)
39
+ end
40
+
41
+ def rescue_handlers?
42
+ rescue_handlers.any? || (superclass.respond_to?(:rescue_handlers?) && superclass.rescue_handlers?)
43
+ end
44
+
45
+ private
46
+
47
+ def rescue_handler_key(exception_class)
48
+ case exception_class
49
+ when ->(klass) { klass.is_a?(Module) && !(klass.is_a?(Class) && (klass <=> StandardError).nil?) }
50
+ exception_class.name || exception_class
51
+ when String then exception_class
52
+ else raise ArgumentError, "#{exception_class.inspect} is not a StandardError class or its name"
53
+ end
54
+ end
55
+
56
+ def rescue_handler_class(class_name)
57
+ return class_name if class_name.is_a?(Module)
58
+
59
+ Object.const_get(class_name)
60
+ rescue NameError
61
+ nil
62
+ end
63
+ end
64
+
65
+ # Runs the ::rescue_from handler matching +exception+ and returns its
66
+ # value, or re-raises when no handler matches.
67
+ def rescue_with_handler(exception)
68
+ handler = self.class.rescue_handler_for(exception)
69
+ raise exception unless handler
70
+
71
+ run_rescue_handler(handler, exception)
72
+ end
73
+
74
+ private
75
+
76
+ def run_rescue_handler(handler, exception)
77
+ return instance_exec(exception, &handler) unless handler.is_a?(Symbol)
78
+
79
+ handler_method = method(handler)
80
+ handler_method.arity.zero? ? handler_method.call : handler_method.call(exception)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -12,9 +12,24 @@ module RubyLLM
12
12
 
13
13
  module_function
14
14
 
15
- def hash_get(hash, key)
16
- symbol_key = key.to_sym
17
- hash.key?(symbol_key) ? hash[symbol_key] : hash[key.to_s]
15
+ def cache_directory
16
+ home = Dir.home
17
+ host_os = RbConfig::CONFIG['host_os']
18
+
19
+ if host_os.match?(/darwin/i)
20
+ File.join(home, 'Library', 'Caches', 'RubyLLM')
21
+ elsif host_os.match?(/mswin|mingw|cygwin/i)
22
+ local_app_data = ENV.fetch('LOCALAPPDATA', nil)
23
+ local_app_data = File.join(home, 'AppData', 'Local') if local_app_data.to_s.empty?
24
+ File.join(local_app_data, 'RubyLLM', 'Cache')
25
+ else
26
+ xdg_cache = ENV.fetch('XDG_CACHE_HOME', nil)
27
+ xdg_cache = File.join(home, '.cache') if xdg_cache.to_s.empty?
28
+ File.join(xdg_cache, 'ruby_llm')
29
+ end
30
+ rescue ArgumentError
31
+ # Dir.home raises when the home directory cannot be determined.
32
+ nil
18
33
  end
19
34
 
20
35
  def strip_schema_metadata(schema)
@@ -28,6 +43,14 @@ module RubyLLM
28
43
  name.gsub(UNDERSCORE_BOUNDARY, '_').downcase
29
44
  end
30
45
 
46
+ # The path Rails autoloads a class from, which follows the application's
47
+ # inflections when Active Support is loaded.
48
+ def file_path(class_name)
49
+ return ActiveSupport::Inflector.underscore(class_name) if defined?(ActiveSupport::Inflector)
50
+
51
+ underscore(class_name.gsub('::', '/'))
52
+ end
53
+
31
54
  def to_safe_array(item)
32
55
  case item
33
56
  when Array then item
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # A tool call that finishes later. Return one from Tool#execute and a
5
+ # chat pauses the call instead of waiting for it, the way it pauses for
6
+ # an approval. Chat#pending_tasks returns the tasks it waits on, and each
7
+ # Chat#complete hands every one back to its tool's Tool#resume, which
8
+ # returns the call's result once it has one:
9
+ #
10
+ # class RequestSignature < RubyLLM::Tool
11
+ # description "Sends a contract for signature and reports when it is signed"
12
+ #
13
+ # def execute(contract_id:)
14
+ # envelope = Signatures.send_for_signature(Contract.find(contract_id))
15
+ # RubyLLM::Task.new(status: :input_required, data: { "envelope_id" => envelope.id })
16
+ # end
17
+ #
18
+ # def resume(task, **)
19
+ # envelope = Signatures.find(task.data["envelope_id"])
20
+ # envelope.signed? ? "Signed on #{envelope.signed_on}" : task
21
+ # end
22
+ # end
23
+ #
24
+ # A task is saved with its tool call as #to_h, so a Rails chat can pause
25
+ # in one process and resume in another. MCP::Task is the task of an MCP
26
+ # tool call that a server runs in the background.
27
+ class Task
28
+ include Support::Inspectable
29
+ include Support::DeprecatedJobPredicates
30
+
31
+ FINISHED = %i[completed failed cancelled].freeze
32
+ STATUSES = %i[working input_required].concat(FINISHED).freeze
33
+ private_constant :FINISHED, :STATUSES
34
+
35
+ # The task's state as a Symbol: +:working+, +:input_required+,
36
+ # +:completed+, +:failed+, or +:cancelled+.
37
+ attr_reader :status
38
+
39
+ # What the task is doing, such as how far it got, or +nil+.
40
+ attr_reader :status_message
41
+
42
+ # How many seconds to wait before checking on the task again, or +nil+.
43
+ attr_reader :poll_interval
44
+
45
+ # The tool's own state for the task, a Hash with String keys that
46
+ # serializes to JSON.
47
+ attr_reader :data
48
+
49
+ # The ToolCall paused on the task, when it came from a chat.
50
+ attr_reader :tool_call
51
+
52
+ # Returns the Task that +state+, the Hash #to_h returned, describes.
53
+ def self.load(state, tool_call: nil)
54
+ task = state.fetch('task')
55
+ new(status: task.fetch('status'), status_message: task['status_message'],
56
+ poll_interval: task['poll_interval'], data: task.fetch('data', {}), tool_call:)
57
+ end
58
+
59
+ # Returns a task with +status+, +:working+ unless the call waits on a
60
+ # person, +:input_required+. +data+ is what Tool#resume needs to carry
61
+ # the call on, as a Hash that serializes to JSON. Raises ArgumentError
62
+ # for a status other than the five #status lists.
63
+ def initialize(status: :working, status_message: nil, poll_interval: nil, data: {}, tool_call: nil)
64
+ @status = status.to_sym
65
+ raise ArgumentError, "Unknown task status: #{status.inspect}" unless STATUSES.include?(@status)
66
+
67
+ @status_message = status_message
68
+ @poll_interval = poll_interval
69
+ @data = Support::Utils.deep_stringify_keys(Support::Utils.deep_dup(data))
70
+ @tool_call = tool_call
71
+ end
72
+
73
+ # Returns +true+ when the task waits on a person, so checking on it
74
+ # changes nothing until they act.
75
+ def input_required?
76
+ status == :input_required
77
+ end
78
+
79
+ # Returns +true+ once the task completed, failed, or was cancelled.
80
+ def complete?
81
+ FINISHED.include?(status)
82
+ end
83
+
84
+ # Returns +true+ when the task finished with a result.
85
+ def succeeded?
86
+ status == :completed
87
+ end
88
+
89
+ # Returns +true+ when the task failed with an error.
90
+ def failed?
91
+ status == :failed
92
+ end
93
+
94
+ # Returns +true+ when the task was cancelled before it finished.
95
+ def cancelled?
96
+ status == :cancelled
97
+ end
98
+
99
+ # Checks on the task once and returns +self+. Subclasses that can check
100
+ # on their work override it; the base implementation does nothing.
101
+ def refresh
102
+ self
103
+ end
104
+
105
+ # Stops the task and returns +self+. A chat calls it when it is
106
+ # cancelled while the call waits. Subclasses that can stop their work
107
+ # override it; the base implementation does nothing.
108
+ def cancel
109
+ self
110
+ end
111
+
112
+ # Returns the task as a Hash that serializes to JSON, which ::load turns
113
+ # back into the task.
114
+ def to_h
115
+ task = { 'status' => status.to_s, 'status_message' => status_message,
116
+ 'poll_interval' => poll_interval, 'data' => (data unless data.empty?) }
117
+ { 'task' => task.compact }
118
+ end
119
+
120
+ private
121
+
122
+ def inspect_attributes
123
+ { status:, status_message: }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ module RubyLLM
6
+ # Lets a test script what the model replies, with no provider and no API
7
+ # key. Replies go through the real conversation loop, so streaming, tools,
8
+ # persistence, cost, and #cancel all behave as they do in production.
9
+ # Require it in your test helper, and include it like ActiveJob::TestHelper:
10
+ #
11
+ # require "ruby_llm/test_helper"
12
+ #
13
+ # class SupportAgentTest < ActiveSupport::TestCase
14
+ # include RubyLLM::TestHelper
15
+ #
16
+ # test "answers" do
17
+ # stub_replies "Hello there", cost: 0.01
18
+ #
19
+ # reply = SupportAgent.new.ask("Hi")
20
+ #
21
+ # assert_equal "Hello there", reply.content
22
+ # assert_equal 0.01, reply.cost.total
23
+ # end
24
+ # end
25
+ #
26
+ # It also works in RSpec: <tt>config.include RubyLLM::TestHelper</tt>. A
27
+ # chat's request reaches the stub. Other operations, such as RubyLLM.embed,
28
+ # still call their provider.
29
+ module TestHelper
30
+ # The scripted replies, shared across threads because a system test's
31
+ # server and its jobs run in threads of their own.
32
+ module Script # :nodoc:
33
+ @lock = Mutex.new
34
+ @replies = nil
35
+
36
+ class << self
37
+ def stub(replies, options)
38
+ @lock.synchronize { @replies = [replies, options] }
39
+ end
40
+
41
+ def clear
42
+ @lock.synchronize { @replies = nil }
43
+ end
44
+
45
+ def active?
46
+ @lock.synchronize { !@replies.nil? }
47
+ end
48
+
49
+ # The next reply. The last one repeats, so a test that doesn't count
50
+ # requests only names one.
51
+ def next_reply
52
+ @lock.synchronize do
53
+ replies, options = @replies
54
+ [replies.size > 1 ? replies.shift : replies.first, options]
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ # Answers requests from the script, once #stub_replies has been called.
61
+ module StubbedProvider # :nodoc:
62
+ # Skips the API key check, which some providers define for themselves.
63
+ def initialize(config)
64
+ if Script.active?
65
+ @config = config
66
+ @connection = Transport::Connection.new(self, config)
67
+ else
68
+ super
69
+ end
70
+ end
71
+
72
+ def complete(messages, usage_recorder: nil, progress_recorder: nil, **options, &)
73
+ return super unless Script.active?
74
+
75
+ reply, settings = Script.next_reply
76
+ raise reply if reply.is_a?(Exception)
77
+
78
+ tool_call = reply if reply.is_a?(ToolCall)
79
+ text = tool_call ? '' : reply.to_s
80
+ stream(text, settings, options[:model], &) if block_given?
81
+ Message.new(role: :assistant, content: text, model_id: options[:model].id,
82
+ tool_calls: tool_call && { tool_call.id => tool_call }, input_tokens: 1, output_tokens: 1,
83
+ reported_cost: settings[:cost])
84
+ end
85
+
86
+ private
87
+
88
+ def stream(text, settings, model)
89
+ text.scan(/\S+\s*|\s+/).each do |word|
90
+ sleep settings[:interval] if settings[:interval]
91
+ yield Chunk.new(role: :assistant, content: word, model_id: model.id)
92
+ end
93
+ end
94
+ end
95
+
96
+ Provider.prepend(StubbedProvider)
97
+
98
+ def self.included(base) # :nodoc:
99
+ base.after { Script.clear } if base.respond_to?(:after)
100
+ end
101
+
102
+ def after_teardown # :nodoc:
103
+ super
104
+ ensure
105
+ Script.clear
106
+ end
107
+
108
+ # Makes every chat answer with +replies+ until the test ends, in order,
109
+ # repeating the last, instead of calling a provider. A String streams
110
+ # word by word, #tool_call asks the conversation loop to run a tool, and
111
+ # an exception such as RubyLLM::ServerError fails the request.
112
+ # +cost:+ is what each reply reports, in dollars. +interval:+ is the
113
+ # seconds between words, so a test can press Stop while a reply streams.
114
+ #
115
+ # stub_replies "Hello there", cost: 0.01
116
+ # stub_replies tool_call(:weather, city: "Berlin"), "It is sunny."
117
+ # stub_replies "A long reply", interval: 0.1
118
+ #
119
+ def stub_replies(*replies, cost: nil, interval: nil)
120
+ Script.stub(replies, { cost:, interval: })
121
+ end
122
+
123
+ # Returns a reply that calls the tool +name+ with +arguments+, for
124
+ # #stub_replies.
125
+ def tool_call(name, **arguments)
126
+ ToolCall.new(id: "call_#{SecureRandom.hex(4)}", name: name.to_s, arguments:)
127
+ end
128
+ end
129
+ end
@@ -134,14 +134,14 @@ module RubyLLM
134
134
  enabled == false || effort == :none
135
135
  end
136
136
 
137
- def resolve(model)
137
+ def resolve(model, strict: true)
138
138
  return self if @intent.nil?
139
139
 
140
140
  options = Controls.new(model).public_send(@intent)
141
141
  return nil if options == :already_on
142
- raise ArgumentError, resolution_error(model) unless options
142
+ raise ArgumentError, resolution_error(model) if strict && !options
143
143
 
144
- self.class.new(**options)
144
+ self.class.new(**(options || { enabled: @intent == :enable }))
145
145
  end
146
146
 
147
147
  private
@@ -12,11 +12,23 @@ module RubyLLM
12
12
  # response.tokens.cache_read # prompt cache reads
13
13
  # response.tokens.cache_write # prompt cache writes
14
14
  #
15
+ # Operations a provider bills in other units count those units here too,
16
+ # so Cost prices them the same way:
17
+ #
18
+ # RubyLLM.speak("Hello").tokens.characters # => 5
19
+ # RubyLLM.transcribe("call.mp3").tokens.audio_seconds # => 42.3
20
+ # RubyLLM.rerank(query, documents).tokens.search_units # => 1
21
+ #
15
22
  class Tokens
16
23
  include Support::Inspectable
17
24
 
18
25
  READERS = %i[
19
- input output cache_read cache_write thinking cache_write_by_ttl server_tool_use reported_cost
26
+ input output cache_read cache_write thinking characters audio_seconds search_units cache_write_by_ttl
27
+ server_tool_use reported_cost
28
+ ].freeze # :nodoc:
29
+
30
+ SUMMED = %i[
31
+ input output cache_read cache_write thinking characters audio_seconds search_units reported_cost
20
32
  ].freeze # :nodoc:
21
33
 
22
34
  # The number of standard (non-cached) input tokens, or +nil+ if the
@@ -50,6 +62,20 @@ module RubyLLM
50
62
  # does not report them.
51
63
  attr_reader :thinking
52
64
 
65
+ # The number of input characters the provider bills, such as the text
66
+ # sent to a text-to-speech model, or +nil+ if the operation is not
67
+ # billed by character.
68
+ attr_reader :characters
69
+
70
+ # The seconds of audio the provider bills, such as the length of a
71
+ # transcribed recording, or +nil+ if the operation is not billed by
72
+ # duration. May be fractional.
73
+ attr_reader :audio_seconds
74
+
75
+ # The number of search units the provider bills, such as Cohere's
76
+ # rerank searches, or +nil+ if the operation is not billed by search.
77
+ attr_reader :search_units
78
+
53
79
  # How many times each provider-executed tool ran, as a Hash of String
54
80
  # keys and Integer counts, or +nil+ if the provider reported none.
55
81
  # Web searches are <tt>"web_search_requests"</tt> on every provider
@@ -72,20 +98,26 @@ module RubyLLM
72
98
  # even when every count is +nil+.
73
99
  #
74
100
  # tokens = RubyLLM::Tokens.new(input: 100, output: 20)
75
- # RubyLLM.models.find("gpt-5.6").cost_for(tokens).total
101
+ # RubyLLM.models.find("gpt-6.1-sol").cost_for(tokens).total
76
102
  #
103
+ # +characters:+, +audio_seconds:+, and +search_units:+ count usage
104
+ # billed in those units;
77
105
  # +cache_write_by_ttl:+ splits the cache writes by lifetime, keyed as
78
106
  # #cache_write_by_ttl describes;
79
107
  # +server_tool_use:+ counts the provider-executed tools that ran, keyed
80
108
  # as #server_tool_use describes;
81
109
  # +reported_cost:+ holds the provider's total price in US dollars.
82
110
  def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil, thinking: nil,
111
+ characters: nil, audio_seconds: nil, search_units: nil,
83
112
  cache_write_by_ttl: nil, server_tool_use: nil, reported_cost: nil)
84
113
  @input = input
85
114
  @output = output
86
115
  @cache_read = cache_read
87
116
  @cache_write = cache_write
88
117
  @thinking = thinking
118
+ @characters = characters
119
+ @audio_seconds = audio_seconds
120
+ @search_units = search_units
89
121
  @cache_write_by_ttl = positive_counts(cache_write_by_ttl)
90
122
  @server_tool_use = positive_counts(server_tool_use)
91
123
  @reported_cost = reported_cost
@@ -95,10 +127,9 @@ module RubyLLM
95
127
  # no attempt reported it.
96
128
  def self.aggregate(tokens)
97
129
  tokens = Array(tokens).compact
98
- return new if tokens.empty?
99
130
  return tokens.first if tokens.one?
100
131
 
101
- values = %i[input output cache_read cache_write thinking reported_cost].to_h do |component|
132
+ values = SUMMED.to_h do |component|
102
133
  reported = tokens.filter_map { |usage| usage.public_send(component) }
103
134
  [component, reported.empty? ? nil : reported.sum]
104
135
  end
@@ -114,20 +145,22 @@ module RubyLLM
114
145
  end)
115
146
  end
116
147
 
117
- def self.aggregate_counts(tokens, reader) # :nodoc:
118
- reported = tokens.filter_map(&reader)
119
- return nil if reported.empty?
148
+ def without_reported_cost # :nodoc:
149
+ self.class.new(**(READERS - [:reported_cost]).to_h { |reader| [reader, public_send(reader)] })
150
+ end
120
151
 
121
- reported.each_with_object({}) do |counters, total|
122
- counters.each { |key, count| total[key] = total.fetch(key, 0) + count.to_i }
152
+ def self.aggregate_counts(tokens, reader) # :nodoc:
153
+ tokens.filter_map(&reader).each_with_object({}) do |counters, total|
154
+ counters.each { |key, count| total[key] = total.fetch(key, 0) + (count.is_a?(Float) ? count : count.to_i) }
123
155
  end
124
156
  end
125
157
  private_class_method :aggregate_counts
126
158
 
127
159
  # Returns the counts as a hash with keys +:input_tokens+,
128
160
  # +:output_tokens+, +:cache_read_tokens+, +:cache_write_tokens+, and
129
- # +:thinking_tokens+, omitting +nil+ counts. Includes
130
- # +:cache_write_tokens_by_ttl+ and +:server_tool_use+ when reported.
161
+ # +:thinking_tokens+, omitting +nil+ counts. Includes +:characters+,
162
+ # +:audio_seconds+, +:search_units+, +:cache_write_tokens_by_ttl+, and
163
+ # +:server_tool_use+ when reported.
131
164
  # The provider's reported cost is available separately
132
165
  # through #reported_cost.
133
166
  #
@@ -141,6 +174,9 @@ module RubyLLM
141
174
  cache_read_tokens: cache_read,
142
175
  cache_write_tokens: cache_write,
143
176
  thinking_tokens: thinking,
177
+ characters: characters,
178
+ audio_seconds: audio_seconds,
179
+ search_units: search_units,
144
180
  cache_write_tokens_by_ttl: cache_write_by_ttl,
145
181
  server_tool_use: server_tool_use
146
182
  }.compact
@@ -153,6 +189,9 @@ module RubyLLM
153
189
  cache_read: cache_read,
154
190
  cache_write: cache_write,
155
191
  thinking: thinking,
192
+ characters: characters,
193
+ audio_seconds: audio_seconds,
194
+ search_units: search_units,
156
195
  cache_write_by_ttl: cache_write_by_ttl,
157
196
  server_tool_use: server_tool_use
158
197
  }.compact
@@ -161,7 +200,8 @@ module RubyLLM
161
200
  private
162
201
 
163
202
  def positive_counts(counts)
164
- used = counts.to_h.to_h { |key, count| [key.to_s, count.to_i] }.select { |_, count| count.positive? }
203
+ used = counts.to_h.to_h { |key, count| [key.to_s, count.is_a?(Float) ? count : count.to_i] }
204
+ .select { |_, count| count.positive? }
165
205
  used unless used.empty?
166
206
  end
167
207
  end