ruby_llm 2.1.0 → 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 +162 -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 +522 -277
  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 +1598 -332
  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 +67 -28
  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 +134 -109
  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 +23 -2
  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 +2 -9
  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 +8 -3
  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 +219 -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
@@ -2,117 +2,8 @@
2
2
 
3
3
  module RubyLLM
4
4
  class MCP
5
- # A server's request for input from the user while it works on a call,
6
- # received by MCP.before_input_request callbacks. A form request asks
7
- # for values; a URL request asks the user to visit a page, such as to
8
- # connect an account.
9
- #
10
- # before_input_request do |request|
11
- # if request.url?
12
- # system "open", request.url
13
- # request.answer
14
- # else
15
- # request.answer(environment: "staging")
16
- # end
17
- # end
18
- #
19
- # In a chat, a request no callback answers pauses the tool call until
20
- # Chat#answer or Chat#decline settles it; see Chat#pending_inputs.
21
- class InputRequest
22
- include Support::Inspectable
23
-
24
- # A value a form request asks for.
25
- Field = Struct.new(:name, :type, :title, :description, :required, :choices, :default, keyword_init: true) do
26
- # Returns whether the server needs this value.
27
- def required?
28
- required == true
29
- end
30
- end
31
-
32
- # Why the server needs the input.
33
- attr_reader :message
34
-
35
- # The page a URL request asks the user to visit, or +nil+.
36
- attr_reader :url
37
-
38
- # The values a form request asks for, as Field objects.
39
- attr_reader :fields
40
-
41
- attr_reader :key, :response # :nodoc:
42
-
43
- # The ToolCall paused on this request, when it came from a chat.
44
- attr_reader :tool_call
45
-
46
- def self.from_h(data, tool_call: nil) # :nodoc:
47
- new(data['key'], data['params'], response: data['response'], tool_call:)
48
- end
49
-
50
- def initialize(key, params, response: nil, tool_call: nil) # :nodoc:
51
- @key = key
52
- @params = params
53
- @response = response
54
- @tool_call = tool_call
55
- @message = params['message']
56
- @url = params['url'] if params['mode'] == 'url'
57
- @fields = fields_from(params['requestedSchema'] || {})
58
- end
59
-
60
- # Returns whether the server asks the user to visit #url.
61
- def url?
62
- !url.nil?
63
- end
64
-
65
- # Returns whether the server asks for #fields.
66
- def form?
67
- !url?
68
- end
69
-
70
- # Accepts the request. A form request takes the values as keywords,
71
- # and the fields you leave out take the defaults the server gave
72
- # them; a URL request takes none, meaning the user agreed to visit
73
- # the page.
74
- def answer(**values)
75
- @response = { action: 'accept', content: (with_defaults(values) unless url?) }.compact
76
- end
77
-
78
- # Declines the request.
79
- def decline
80
- @response = { action: 'decline' }
81
- end
82
-
83
- # Returns whether the request has been answered or declined.
84
- def answered?
85
- !response.nil?
86
- end
87
-
88
- def to_h # :nodoc:
89
- { 'key' => key, 'params' => @params, 'response' => response&.transform_keys(&:to_s) }.compact
90
- end
91
-
92
- private
93
-
94
- def with_defaults(values)
95
- fields.reject { |field| field.default.nil? }.to_h { |field| [field.name, field.default] }
96
- .merge(values).transform_keys(&:to_s)
97
- end
98
-
99
- def fields_from(schema)
100
- required = Array(schema['required'])
101
- schema.fetch('properties', {}).map do |name, property|
102
- Field.new(name: name.to_sym, type: property['type'], title: property['title'],
103
- description: property['description'], required: required.include?(name),
104
- choices: choices(property), default: property['default'])
105
- end
106
- end
107
-
108
- def choices(property)
109
- options = property['enum'] || Array(property['oneOf'] || property.dig('items', 'anyOf')).map { |o| o['const'] }
110
- options unless options.empty?
111
- end
112
-
113
- def inspect_attributes
114
- { message:, url:, fields: fields.map(&:name) }
115
- end
116
- end
5
+ # A server's question to the person, the same Question a tool asks
6
+ # with Tool#ask, under its 2.1 name.
7
+ InputRequest = RubyLLM::Question
117
8
  end
118
9
  end
@@ -3,27 +3,9 @@
3
3
  module RubyLLM
4
4
  class MCP
5
5
  # Raised when a server needs input from the user and no
6
- # MCP.before_input_request callback answered. Its message includes what
7
- # the server asked for, and #requests has the MCP::InputRequest objects.
8
- # In a chat, the tool call pauses instead, and Chat#pending_inputs
9
- # returns the requests.
10
- class InputRequiredError < Error
11
- def initialize(server, input) # :nodoc:
12
- @input = input
13
- asks = requests.map { |request| [request.message, request.url].compact.join(' ') }
14
- super("#{server} needs input from the user: #{asks.join('; ')}")
15
- end
16
-
17
- # The unanswered MCP::InputRequest objects.
18
- def requests
19
- @input['requests'].reject(&:answered?)
20
- end
21
-
22
- # Returns everything needed to answer the requests later and resume
23
- # the call, as a Hash that serializes to JSON.
24
- def to_h # :nodoc:
25
- @input.merge('requests' => @input['requests'].map(&:to_h)).compact
26
- end
6
+ # MCP.before_input_request callback answered. Rescue
7
+ # RubyLLM::InputRequiredError to catch a tool's own questions too.
8
+ class InputRequiredError < RubyLLM::InputRequiredError
27
9
  end
28
10
  end
29
11
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # Raised when an MCP server refuses a request because the token lacks a
6
+ # scope it needs. The message carries the server's reason, and #scopes
7
+ # the scopes it asked for. RubyLLM never widens a grant by itself: to
8
+ # grant them, send the user through MCP#authorization_url again.
9
+ #
10
+ # begin
11
+ # chat.ask "List the open issues in crmne/ruby_llm"
12
+ # rescue RubyLLM::MCP::InsufficientScopeError => e
13
+ # e.message # => "api.githubcopilot.com refused the request: Additional scopes required: repo"
14
+ # e.scopes # => ["repo"]
15
+ # end
16
+ class InsufficientScopeError < RubyLLM::ForbiddenError
17
+ # The scopes the server's challenge asked for, as Strings. Empty when
18
+ # it named none.
19
+ attr_reader :scopes
20
+
21
+ def initialize(message = nil, scopes: [], **) # :nodoc:
22
+ @scopes = scopes
23
+ super(message, **)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -58,7 +58,13 @@ module RubyLLM
58
58
  @changes = changes
59
59
  @pid = Process.pid
60
60
  @state = nil
61
- @thread = Thread.new { Support::ProgressReporter.listen(nil) { run } }
61
+ @thread = Thread.new do
62
+ Support::Cancellation.watch(nil) do
63
+ Support::Instrumentation.with_workflow(nil) do
64
+ Support::ProgressReporter.listen(nil) { Accounting::Usage.with_owner(nil) { run } }
65
+ end
66
+ end
67
+ end
62
68
  @thread.name = 'ruby_llm-mcp-listener'
63
69
  @thread.report_on_exception = false
64
70
  acknowledgment
@@ -34,6 +34,9 @@ module RubyLLM
34
34
  REFRESH_EARLY = 60
35
35
  ASSERTION_FOR = 60
36
36
  GRANTS = %i[authorization_code client_credentials jwt_bearer].freeze
37
+ RESERVED_AUTHORIZATION_PARAMS = %w[
38
+ client_id redirect_uri state code_challenge code_challenge_method response_type resource scope
39
+ ].freeze
37
40
  JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
38
41
  TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'
39
42
  ID_JAG = 'urn:ietf:params:oauth:token-type:id-jag'
@@ -45,7 +48,7 @@ module RubyLLM
45
48
  issuer token_endpoint token_endpoint_auth_methods_supported token_endpoint_auth_signing_alg_values_supported
46
49
  authorization_response_iss_parameter_supported
47
50
  ].freeze
48
- CLIENT_FIELDS = %w[client_id client_secret issuer server redirect_uri dpop_key].freeze
51
+ CLIENT_FIELDS = %w[client_id client_secret issuer server redirect_uri dpop_key resource].freeze
49
52
  TOKEN = /[!\#$%&'*+.^_`|~0-9A-Za-z-]+/
50
53
  PARAMETER = /#{TOKEN}\s*=/
51
54
  QUOTED = /"((?:[^"\\]|\\.)*)"/
@@ -55,7 +58,7 @@ module RubyLLM
55
58
  '%<path>s/.well-known/openid-configuration'
56
59
  ].freeze
57
60
 
58
- @refreshes = Hash.new { |refreshes, key| refreshes[key] = Mutex.new }
61
+ @refreshes = {}
59
62
  @refreshes_lock = Mutex.new
60
63
 
61
64
  def self.memory_store
@@ -75,9 +78,19 @@ module RubyLLM
75
78
  end
76
79
 
77
80
  # Runs the block while no other thread in this process refreshes the
78
- # credentials stored under +key+.
81
+ # credentials stored under +key+. A key's lock lives only while a
82
+ # thread holds it or waits for it.
79
83
  def self.refreshing(key, &)
80
- @refreshes_lock.synchronize { @refreshes[key] }.synchronize(&)
84
+ lock = @refreshes_lock.synchronize do
85
+ entry = @refreshes[key] ||= [Mutex.new, 0]
86
+ entry[1] += 1
87
+ entry.first
88
+ end
89
+ begin
90
+ lock.synchronize(&)
91
+ ensure
92
+ @refreshes_lock.synchronize { @refreshes.delete(key) if (@refreshes[key][1] -= 1).zero? }
93
+ end
81
94
  end
82
95
 
83
96
  # Reads the challenges of a WWW-Authenticate header (RFC 9110
@@ -98,8 +111,9 @@ module RubyLLM
98
111
  end
99
112
 
100
113
  def initialize(server_url, owner:, scopes: nil, client_id: nil, client_secret: nil, grant: nil,
101
- private_key: nil, assertion: nil, identity_provider: nil, config: RubyLLM.config,
102
- resolve: ->(value) { value })
114
+ private_key: nil, assertion: nil, identity_provider: nil, authorization_params: nil,
115
+ config: RubyLLM.config, resolve: ->(value) { value })
116
+ HTTP.parse(server_url)
103
117
  @server_url = server_url.to_s
104
118
  @owner = owner
105
119
  @scopes = scopes
@@ -108,20 +122,22 @@ module RubyLLM
108
122
  @private_key = resolve.call(private_key)
109
123
  @assertion = assertion
110
124
  @identity_provider = identity_provider
125
+ @authorization_params = authorization_params
111
126
  @grant = grant || (assertion || identity_provider ? :jwt_bearer : :authorization_code)
112
127
  @resolve = resolve
113
128
  @config = config
114
129
  end
115
130
 
116
131
  def authorized?
117
- credential&.key?('access_token')
132
+ @credential = store.read(key) unless @credential&.key?('access_token')
133
+ @credential.to_h.key?('access_token')
118
134
  end
119
135
 
120
136
  def access_token
121
137
  return unless authorized?
122
138
 
123
139
  renew if expiring?
124
- credential['access_token']
140
+ credential.to_h['access_token']
125
141
  end
126
142
 
127
143
  # Returns the headers that authorize a +verb+ request to the server:
@@ -179,27 +195,44 @@ module RubyLLM
179
195
  verifier = SecureRandom.urlsafe_base64(64)
180
196
  state = SecureRandom.urlsafe_base64(32)
181
197
  pending = client.merge('state' => state, 'verifier' => verifier, 'redirect_uri' => redirect_uri,
182
- 'issuer' => server['issuer'], 'scope' => scopes_for(server),
183
- 'dpop_key' => binding_key, 'expires_at' => Time.now.to_i + PENDING_FOR)
184
- write(credential.to_h.merge('pending' => pending.merge('server' => server.slice(*SERVER_FIELDS))))
198
+ 'issuer' => server['issuer'], 'scope' => scopes_for(server), 'resource' => resource,
199
+ 'dpop_key' => binding_key, 'expires_at' => Time.now.to_i + PENDING_FOR,
200
+ 'server' => server.slice(*SERVER_FIELDS))
201
+ synchronize { write(store.read(key).to_h.merge('pending' => pending)) }
185
202
 
186
203
  query = { response_type: 'code', client_id: client['client_id'], redirect_uri:, state:,
187
204
  code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false),
188
205
  code_challenge_method: 'S256', resource:, scope: pending['scope'] }.compact
189
- "#{endpoint(server['authorization_endpoint'])}?#{URI.encode_www_form(query)}"
206
+ uri = URI(endpoint(server['authorization_endpoint']))
207
+ uri.query = URI.encode_www_form(URI.decode_www_form(uri.query.to_s) + extra_authorization_params + query.to_a)
208
+ uri.to_s
209
+ end
210
+
211
+ def self.check_authorization_params(params) # :nodoc:
212
+ reserved = params.keys.map(&:to_s) & RESERVED_AUTHORIZATION_PARAMS
213
+ return params if reserved.empty?
214
+
215
+ raise ArgumentError, "RubyLLM sets #{reserved.join(', ')} itself; remove it from authorization_params"
190
216
  end
191
217
 
192
218
  def authorize(params)
193
- pending = credential&.fetch('pending', nil) or raise Error, 'No authorization in progress'
194
- check_callback(pending, params)
195
- tokens = token_request('authorization_code', code: value(params, :code), redirect_uri: pending['redirect_uri'],
196
- code_verifier: pending['verifier'], client: pending)
197
- store_tokens(tokens, client: pending.slice(*CLIENT_FIELDS, 'scope'))
219
+ synchronize do
220
+ @credential = store.read(key)
221
+ pending = credential&.fetch('pending', nil) or raise Error, 'No authorization in progress'
222
+ check_callback(pending, params)
223
+ tokens = token_request('authorization_code', code: value(params, :code),
224
+ redirect_uri: pending['redirect_uri'],
225
+ code_verifier: pending['verifier'], client: pending)
226
+ @credential = credential.except('pending')
227
+ store_tokens(tokens, client: pending.slice(*CLIENT_FIELDS, 'scope'))
228
+ end
198
229
  end
199
230
 
200
231
  def deauthorize
201
- @credential = nil
202
- store.delete(key)
232
+ synchronize do
233
+ @credential = nil
234
+ store.delete(key)
235
+ end
203
236
  end
204
237
 
205
238
  private
@@ -208,6 +241,11 @@ module RubyLLM
208
241
  @credential ||= store.read(key)
209
242
  end
210
243
 
244
+ def extra_authorization_params
245
+ params = Hash(@resolve.call(@authorization_params))
246
+ self.class.check_authorization_params(params).map { |name, value| [name.to_s, @resolve.call(value).to_s] }
247
+ end
248
+
211
249
  def write(data)
212
250
  @credential = data
213
251
  store.write(key, data, owner: @owner)
@@ -218,7 +256,7 @@ module RubyLLM
218
256
  end
219
257
 
220
258
  def store_tokens(tokens, client: nil)
221
- data = credential.to_h.except('pending')
259
+ data = credential.to_h
222
260
  data = data.except(*CLIENT_FIELDS).merge(client) if client
223
261
  data = data.merge('access_token' => tokens['access_token'], 'scope' => tokens['scope'] || data['scope'],
224
262
  'expires_at' => (Time.now.to_i + tokens['expires_in'].to_i if tokens['expires_in']),
@@ -333,7 +371,10 @@ module RubyLLM
333
371
  check_issuer(pending, value(params, :iss))
334
372
  check_state(pending, value(params, :state).to_s)
335
373
  error = value(params, :error)
336
- raise Error, "Authorization failed: #{value(params, :error_description) || error}" if error
374
+ return unless error
375
+
376
+ raise Error.new('Authorization failed', provider_message: value(params, :error_description) || error,
377
+ error_code: error)
337
378
  end
338
379
 
339
380
  def check_issuer(pending, issuer)
@@ -366,17 +407,25 @@ module RubyLLM
366
407
  # attempt signs a new proof and a new client assertion.
367
408
  def send_token_request(server, client, key, params, retried: false)
368
409
  url = server['token_endpoint']
369
- form = params.merge(client_id: client['client_id'], resource:)
410
+ form = params.merge(client_id: client['client_id'], resource: resource(client))
370
411
  headers = { 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json' }
371
412
  authenticate(client, server, form, headers, key)
372
413
  headers['DPoP'] = proofs.sign(client['dpop_key'], url) if client['dpop_key']
373
- post(url, URI.encode_www_form(form.compact), headers)
414
+ tokens = post(url, URI.encode_www_form(form.compact), headers)
415
+ return tokens if granted?(tokens)
416
+
417
+ # Keep only the error fields: a partial response may still carry a refresh or ID token.
418
+ raise rejection(url, tokens.slice('error', 'error_description', 'error_uri'))
374
419
  rescue Error => e
375
420
  raise if retried || !client['dpop_key'] || oauth_error(e) != 'use_dpop_nonce'
376
421
 
377
422
  send_token_request(server, client, key, params, retried: true)
378
423
  end
379
424
 
425
+ def granted?(tokens)
426
+ tokens['access_token'] && !tokens['error']
427
+ end
428
+
380
429
  def oauth_error(error)
381
430
  error.data['error'] if error.data.is_a?(Hash)
382
431
  end
@@ -394,6 +443,7 @@ module RubyLLM
394
443
  elsif client['client_secret']
395
444
  methods = server['token_endpoint_auth_methods_supported'] || ['client_secret_basic']
396
445
  if methods.include?('client_secret_basic')
446
+ form.delete(:client_id)
397
447
  headers['Authorization'] = "Basic #{basic_credentials(client['client_id'], client['client_secret'])}"
398
448
  else
399
449
  form[:client_secret] = client['client_secret']
@@ -483,19 +533,15 @@ module RubyLLM
483
533
  server.path == path || server.path.start_with?("#{path}/")
484
534
  end
485
535
 
536
+ # RFC 8414 section 3.3: metadata whose issuer is not the authorization
537
+ # server it was fetched for must not be used.
486
538
  def discover_authorization_server(issuer)
487
539
  server = authorization_server_metadata(issuer)
488
540
  return server if same_issuer?(server['issuer'], issuer)
489
541
 
490
- named = server['issuer']
491
- raise Error, "#{issuer} metadata names a different issuer" unless named.is_a?(String) && URI(named).host
492
-
493
- server = authorization_server_metadata(named)
494
- raise Error, "#{issuer} metadata names a different issuer" unless same_issuer?(server['issuer'], named)
495
-
496
- server
497
- rescue URI::InvalidURIError
498
542
  raise Error, "#{issuer} metadata names a different issuer"
543
+ rescue URI::InvalidURIError
544
+ raise Error, "#{issuer} is not a valid authorization server URL"
499
545
  end
500
546
 
501
547
  def authorization_server_metadata(issuer)
@@ -578,14 +624,14 @@ module RubyLLM
578
624
  @challenge.to_h.each_value.filter_map { |parameters| parameters[name] }.first
579
625
  end
580
626
 
581
- def resource
582
- @resource || @server_url.chomp('/')
627
+ def resource(client = nil)
628
+ client&.fetch('resource', nil) || @resource || @server_url.chomp('/')
583
629
  end
584
630
 
585
631
  def first_json(urls)
586
632
  urls.each do |url|
587
633
  return get_json(url)
588
- rescue Faraday::Error, Error
634
+ rescue Error
589
635
  next
590
636
  end
591
637
  nil
@@ -593,6 +639,8 @@ module RubyLLM
593
639
 
594
640
  def get_json(url)
595
641
  parse(connection(url).get(url, nil, 'Accept' => 'application/json'))
642
+ rescue Faraday::Error => e
643
+ raise failure(url, e)
596
644
  end
597
645
 
598
646
  def post(url, body, headers)
@@ -600,8 +648,15 @@ module RubyLLM
600
648
  proofs.remember(url, response.headers['dpop-nonce'])
601
649
  parse(response)
602
650
  rescue Faraday::Error => e
603
- raise refusal(url, e.response && Faraday::Response.new(status: e.response[:status], body: e.response[:body],
604
- response_headers: e.response[:headers]))
651
+ raise failure(url, e)
652
+ end
653
+
654
+ def failure(url, error)
655
+ response = error.response
656
+ return ConnectionError.new(error.message, provider: URI(url).host) unless response
657
+
658
+ refusal(url, Faraday::Response.new(status: response[:status], body: response[:body],
659
+ response_headers: response[:headers]))
605
660
  end
606
661
 
607
662
  def refusal(url, response)
@@ -611,15 +666,23 @@ module RubyLLM
611
666
  rescue JSON::ParserError
612
667
  {}
613
668
  end
669
+ rejection(url, details, response)
670
+ end
671
+
672
+ def rejection(url, details, response = nil)
614
673
  details = {} unless details.is_a?(Hash)
615
- Error.new("#{URI(url).host} refused the request: #{details['error_description'] || details['error']}",
674
+ Error.new("#{URI(url).host} refused the request",
675
+ provider_message: details['error_description'] || details['error'], error_code: details['error'],
616
676
  data: details, response:)
617
677
  end
618
678
 
619
679
  def parse(response)
620
- JSON.parse(response.body)
680
+ document = JSON.parse(response.body)
681
+ raise JSON::ParserError unless document.is_a?(Hash)
682
+
683
+ document
621
684
  rescue JSON::ParserError
622
- raise Error, 'The authorization server did not answer with JSON'
685
+ raise Error, 'The authorization server did not answer with a JSON object'
623
686
  end
624
687
 
625
688
  def connection(url)
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # The servers that could not be reached lately, for this whole process.
6
+ # A server waits this long, or as long as its Retry-After asks, before
7
+ # anyone tries it again, since a server that never answers would
8
+ # otherwise hold up every request, and each request in a job builds its
9
+ # MCP objects anew. A server that answers that it is unavailable, with
10
+ # HTTP 429 or a 5xx status, counts as one that can't be reached; one
11
+ # that refuses the credentials or the request does not, since trying
12
+ # again won't fix that.
13
+ module Outages # :nodoc:
14
+ BACKOFF = 60
15
+ PARTLY_RUN = 'The call may have partly run, so check before trying it again.'
16
+
17
+ # The tool error of a call the server failed, which a chat gives the
18
+ # model like any tool error.
19
+ class Failure < Hash # :nodoc:
20
+ end
21
+ UNAVAILABLE = [ConnectionError, RateLimitError, ServerError, ServiceUnavailableError, OverloadedError,
22
+ Stdio::Unavailable].freeze
23
+
24
+ @lock = Mutex.new
25
+ @retry_at = {}
26
+
27
+ class << self
28
+ def tools(server)
29
+ return [] if down?(server)
30
+
31
+ server.tools
32
+ rescue RubyLLM::Error => e
33
+ raise unless unavailable?(e)
34
+
35
+ report(server, e) if record(server, backoff(e))
36
+ []
37
+ end
38
+
39
+ # Yields to call one of +server+'s tools, and returns the tool error
40
+ # when the server fails the call, or right away while the server
41
+ # sits out. A handler declared on the server's class or on MCP::Tool
42
+ # takes the error instead, but not one every Tool inherits, so a
43
+ # catch-all on RubyLLM::Tool keeps the outage handling. Checking on
44
+ # a task still raises, since the task may still be running.
45
+ def calling(server, tool, input)
46
+ return yield if input&.key?('task')
47
+ return Failure[error: "The #{server.name} MCP server can't be reached right now"] if down?(server)
48
+
49
+ begin
50
+ yield
51
+ rescue StandardError => e
52
+ raise if server.class.rescue_handler_for(e) || tool.class.rescue_handler_for(e, within: MCP::Tool)
53
+
54
+ failed_call(server, tool, e) || raise
55
+ end
56
+ end
57
+
58
+ # Returns the tool error for a call +server+ failed, or +nil+ for an
59
+ # error the call should raise: one that is not the server's, or a
60
+ # refusal such as missing credentials or an unknown endpoint. A
61
+ # server that is unavailable sits out until its backoff ends. The
62
+ # model reads what the server wrote, even with redacted error
63
+ # messages, since it is the one deciding what to do next.
64
+ def failed_call(server, tool, error)
65
+ return unless error.is_a?(Error) || (error.is_a?(RubyLLM::Error) && unavailable?(error))
66
+ return if refused_status?(error.response&.status)
67
+
68
+ if unavailable?(error)
69
+ report(server, error) if record(server, backoff(error))
70
+ server.send(:forget_tools)
71
+ end
72
+ Failure[error: failure_text(server, tool, error)]
73
+ end
74
+
75
+ def down?(server)
76
+ return false if server.tools_listed?
77
+
78
+ key = key(server)
79
+ @lock.synchronize { (@retry_at[key] || 0) > monotonic_now }
80
+ end
81
+
82
+ def clear
83
+ @lock.synchronize { @retry_at.clear }
84
+ end
85
+
86
+ private
87
+
88
+ # Names the error by its class and status, never its message, since
89
+ # a connection error's message can carry the server's host or URL.
90
+ def report(server, error)
91
+ status = error.response&.status
92
+ RubyLLM.logger.warn("MCP server #{server.name} is unreachable, so its tools sit out: " \
93
+ "#{[error.class, ("HTTP #{status}" if status)].compact.join(', ')}")
94
+ payload = { server: server.name, error: error.class.name, status: }.compact
95
+ RubyLLM.instrument('mcp_unreachable.ruby_llm', payload, config: server.send(:config))
96
+ end
97
+
98
+ # What the server wrote, or the error's own message when it wrote
99
+ # nothing, as an empty message would tell the model nothing.
100
+ def failure_text(server, tool, error)
101
+ detail = [error.provider_message, error.message].find { |candidate| candidate.to_s.strip != '' }
102
+ text = "The #{server.name} MCP server failed: #{detail}"
103
+ partly_run?(tool, error) ? "#{text.delete_suffix('.')}. #{PARTLY_RUN}" : text
104
+ end
105
+
106
+ # A call that broke off, or that the server failed with a 5xx
107
+ # status, may have done some of its work before it stopped, unless
108
+ # the tool says it has no effect or the same call again has none.
109
+ def partly_run?(tool, error)
110
+ return false if tool.read_only? || (tool.idempotent? && !tool.destructive?)
111
+
112
+ unavailable?(error) && error.response&.status != 429
113
+ end
114
+
115
+ def unavailable?(error)
116
+ UNAVAILABLE.any? { |kind| error.is_a?(kind) } || unavailable_status?(error.response&.status)
117
+ end
118
+
119
+ def refused_status?(status)
120
+ (400..499).cover?(status) && !unavailable_status?(status)
121
+ end
122
+
123
+ def unavailable_status?(status)
124
+ status == 429 || (500..599).cover?(status)
125
+ end
126
+
127
+ def backoff(error)
128
+ value = error.response&.headers&.[]('Retry-After').to_s
129
+ seconds = Float(value, exception: false) || seconds_until(value)
130
+ seconds&.positive? ? seconds : BACKOFF
131
+ end
132
+
133
+ def seconds_until(date)
134
+ Time.httpdate(date) - Time.now
135
+ rescue ArgumentError
136
+ nil
137
+ end
138
+
139
+ # Returns whether this caller started the backoff.
140
+ def record(server, backoff)
141
+ key = key(server)
142
+ @lock.synchronize do
143
+ now = monotonic_now
144
+ @retry_at.delete_if { |_key, retry_at| retry_at <= now }
145
+ next false if @retry_at.key?(key)
146
+
147
+ @retry_at[key] = now + backoff
148
+ true
149
+ end
150
+ end
151
+
152
+ # Settings, not classes, since RubyLLM.mcp builds a class per call.
153
+ # Settings that depend on runtime state can differ per object, and so
154
+ # can the connection settings of an object's context, so one tenant's
155
+ # server never sidelines another's.
156
+ def key(server)
157
+ settings = server.class
158
+ endpoint = [settings.url, *settings.command]
159
+ return server if settings.transport || !endpoint.compact.all?(String)
160
+
161
+ config = server.send(:config)
162
+ ssl = Transport::Connection.ssl_snapshot(config.ssl) if config.ssl
163
+ [*endpoint, config.http_proxy, ssl, config.faraday_adapter, config.request_timeout]
164
+ end
165
+
166
+ def monotonic_now
167
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end