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
@@ -20,21 +20,27 @@ module RubyLLM
20
20
  # never announce the status of tasks. Their subscription counts as
21
21
  # acknowledged once a ping confirms the session, starting a new one if
22
22
  # it ended.
23
- class Client # :nodoc:
23
+ #
24
+ # An MCP builds its own client. Applications use one only to read
25
+ # #meta, when they send a server requests of their own.
26
+ class Client
27
+ # :stopdoc:
24
28
  VERSION = '2026-07-28'
25
29
  LEGACY_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
26
30
  UNSUPPORTED_VERSION = -32_022
27
31
  MODERN_ERRORS = [-32_020, -32_021, UNSUPPORTED_VERSION].freeze
28
32
  METHOD_NOT_FOUND = -32_601
29
33
  DISCOVERY_TIMEOUT = 10
34
+ PAGE_LIMIT = 1_000
30
35
  ACKNOWLEDGED = 'notifications/subscriptions/acknowledged'
31
36
  CHANGES = %w[
32
37
  notifications/tools/list_changed notifications/prompts/list_changed
33
38
  notifications/resources/list_changed notifications/resources/updated
34
39
  ].freeze
40
+ # :startdoc:
35
41
 
36
42
  # The answer to a request the server sends: RubyLLM only answers pings.
37
- def self.reply(request)
43
+ def self.reply(request) # :nodoc:
38
44
  if request['method'] == 'ping'
39
45
  { jsonrpc: '2.0', id: request['id'], result: {} }
40
46
  else
@@ -42,9 +48,9 @@ module RubyLLM
42
48
  end
43
49
  end
44
50
 
45
- attr_reader :version
51
+ attr_reader :version # :nodoc:
46
52
 
47
- def initialize(transport, capabilities: {}, &on_change)
53
+ def initialize(transport, capabilities: {}, &on_change) # :nodoc:
48
54
  @transport = transport
49
55
  @capabilities = capabilities
50
56
  @on_change = on_change
@@ -52,25 +58,28 @@ module RubyLLM
52
58
  @subscribed = []
53
59
  end
54
60
 
55
- def server
61
+ def server # :nodoc:
56
62
  @connecting.synchronize { @server ||= discover || handshake }
57
63
  end
58
64
 
59
- def request(method, params = {}, headers: {}, &)
65
+ def request(method, params = {}, headers: {}, &) # :nodoc:
60
66
  server
61
67
  call(method, params, headers:, &)
62
- rescue SessionExpired
63
- @connecting.synchronize { @server = handshake }
68
+ rescue SessionExpired => e
69
+ reconnect(e.session)
64
70
  call(method, params, headers:, &)
65
71
  end
66
72
 
67
- def list(method, key)
73
+ def list(method, key) # :nodoc:
68
74
  page = request(method)
69
75
  items = page.fetch(key, [])
70
- while (cursor = page['nextCursor'])
76
+ PAGE_LIMIT.times do
77
+ cursor = page['nextCursor'] or break
71
78
  page = request(method, { cursor: })
72
79
  items += page.fetch(key, [])
73
80
  end
81
+ raise Error, "The server did not finish #{method} in #{PAGE_LIMIT} pages" if page['nextCursor']
82
+
74
83
  items
75
84
  end
76
85
 
@@ -78,18 +87,18 @@ module RubyLLM
78
87
  # each notification of the subscription, starting with the server's
79
88
  # acknowledgment. Returns when the server ends the subscription and
80
89
  # raises Error when it refuses one or the stream breaks.
81
- def listen(changes, &)
90
+ def listen(changes, &) # :nodoc:
82
91
  server
83
92
  raise ConfigurationError, 'The MCP transport does not respond to listen' unless @transport.respond_to?(:listen)
84
93
 
85
94
  modern? ? subscribe(changes, &) : listen_to_session(changes, &)
86
95
  end
87
96
 
88
- def modern?
97
+ def modern? # :nodoc:
89
98
  version == VERSION
90
99
  end
91
100
 
92
- def close
101
+ def close # :nodoc:
93
102
  @connecting.synchronize do
94
103
  @transport.close
95
104
  @server = nil
@@ -98,6 +107,27 @@ module RubyLLM
98
107
  end
99
108
  end
100
109
 
110
+ # Returns the +_meta+ the client adds to every request it sends a
111
+ # server on the 2026-07-28 revision: the revision, the client's name
112
+ # and version, and its capabilities. Merge it into requests you send
113
+ # the server yourself, so they describe the client the same way:
114
+ #
115
+ # meta = RubyLLM::MCP::Client.new(transport).meta
116
+ # # => { "io.modelcontextprotocol/protocolVersion" => "2026-07-28",
117
+ # # "io.modelcontextprotocol/clientInfo" => { name: "ruby_llm", version: "2.2.0" },
118
+ # # "io.modelcontextprotocol/clientCapabilities" => {} }
119
+ # params = { name: "roots", arguments: {}, _meta: meta }
120
+ #
121
+ # Servers on an older revision get none of these fields: they learn
122
+ # the same from the initialize handshake.
123
+ def meta
124
+ {
125
+ 'io.modelcontextprotocol/protocolVersion' => VERSION,
126
+ 'io.modelcontextprotocol/clientInfo' => client_info,
127
+ 'io.modelcontextprotocol/clientCapabilities' => @capabilities
128
+ }
129
+ end
130
+
101
131
  private
102
132
 
103
133
  def discover
@@ -169,11 +199,15 @@ module RubyLLM
169
199
  listened = changes.except(:taskIds).transform_keys(&:to_s)
170
200
  yield({ 'method' => ACKNOWLEDGED, 'params' => { 'notifications' => listened } })
171
201
  @transport.listen(nil, version:, &)
172
- rescue SessionExpired
173
- @connecting.synchronize { @server = handshake }
202
+ rescue SessionExpired => e
203
+ reconnect(e.session)
174
204
  raise
175
205
  end
176
206
 
207
+ def reconnect(expired)
208
+ @connecting.synchronize { @server = handshake if @server.nil? || @transport.session.equal?(expired) }
209
+ end
210
+
177
211
  def cancels?(notification, request)
178
212
  notification['method'] == 'notifications/cancelled' && notification.dig('params', 'requestId') == request[:id]
179
213
  end
@@ -184,7 +218,7 @@ module RubyLLM
184
218
 
185
219
  def answer(response)
186
220
  error = response['error']
187
- raise Error.new(error['message'], code: error['code'], data: error['data']) if error
221
+ raise Error.new(provider_message: error['message'], code: error['code'], data: error['data']) if error
188
222
 
189
223
  response['result']
190
224
  end
@@ -210,14 +244,6 @@ module RubyLLM
210
244
  { jsonrpc: '2.0', id:, method:, params: }.compact
211
245
  end
212
246
 
213
- def meta
214
- {
215
- 'io.modelcontextprotocol/protocolVersion' => VERSION,
216
- 'io.modelcontextprotocol/clientInfo' => client_info,
217
- 'io.modelcontextprotocol/clientCapabilities' => @capabilities
218
- }
219
- end
220
-
221
247
  def client_info
222
248
  { name: 'ruby_llm', version: RubyLLM::VERSION }
223
249
  end
@@ -14,11 +14,36 @@ module RubyLLM
14
14
 
15
15
  def initialize # :nodoc:
16
16
  @servers = {}
17
+ @built = {}.compare_by_identity
17
18
  end
18
19
 
19
- def <<(server) # :nodoc:
20
+ def initialize_copy(source) # :nodoc:
21
+ super
22
+ @servers = @servers.dup
23
+ @built = @built.dup
24
+ end
25
+
26
+ def connect(server, chat) # :nodoc:
27
+ server = server.new.tap { |built| @built[built] = chat } if server.is_a?(Class)
28
+ replaced = self[server.name]
29
+ check_name(server, replaced) if replaced
30
+ release(replaced, chat) unless replaced.equal?(server)
20
31
  @servers[server.name.to_sym] = server
21
- self
32
+ end
33
+
34
+ def disconnect(chat) # :nodoc:
35
+ each { |server| release(server, chat) }
36
+ end
37
+
38
+ # The tools of the servers that can be reached.
39
+ def reachable_tools # :nodoc:
40
+ flat_map { |server| Outages.tools(server) }
41
+ end
42
+
43
+ # The names of the servers that can't be reached.
44
+ def unreachable # :nodoc:
45
+ reachable_tools
46
+ select { |server| Outages.down?(server) }.map(&:name)
22
47
  end
23
48
 
24
49
  # Returns the server named +name+, or +nil+.
@@ -42,6 +67,18 @@ module RubyLLM
42
67
 
43
68
  private
44
69
 
70
+ def check_name(server, connected)
71
+ return unless server.class.derived_name? && connected.class.derived_name?
72
+ return if server.class.endpoint == connected.class.endpoint
73
+
74
+ raise ArgumentError, "MCP servers #{connected.class.endpoint} and #{server.class.endpoint} are both named " \
75
+ "#{server.name.inspect}. Pass name: to RubyLLM.mcp to tell them apart"
76
+ end
77
+
78
+ def release(server, chat)
79
+ server.close if @built[server].equal?(chat) && @built.delete(server)
80
+ end
81
+
45
82
  def method_missing(name, *arguments)
46
83
  return super unless arguments.empty? && @servers.key?(name)
47
84
 
@@ -29,9 +29,14 @@ module RubyLLM
29
29
  end
30
30
 
31
31
  def attachment(data, mime_type, name)
32
+ file(Base64.decode64(data.to_s), mime_type, name)
33
+ end
34
+
35
+ def file(bytes, mime_type, name)
36
+ name = name.to_s.empty? ? 'resource' : name.to_s
32
37
  extension = Marcel::TYPE_EXTS[mime_type]&.first
33
- name = "#{name}.#{extension}" unless name.to_s.include?('.') || extension.nil?
34
- Attachment.new(StringIO.new(Base64.decode64(data.to_s)), filename: name.to_s)
38
+ name = "#{name}.#{extension}" unless name.include?('.') || extension.nil?
39
+ Attachment.new(StringIO.new(bytes), filename: name)
35
40
  end
36
41
 
37
42
  def filename(uri)
@@ -17,10 +17,16 @@ module RubyLLM
17
17
  # Additional information the server attached to the error, or +nil+.
18
18
  attr_reader :data
19
19
 
20
- def initialize(message = nil, code: nil, data: nil, response: nil) # :nodoc:
20
+ def initialize(message = nil, code: nil, data: nil, response: nil, error_code: code, **) # :nodoc:
21
21
  @code = code
22
22
  @data = data
23
- super(message, response:)
23
+ super(message, response:, error_code:, **)
24
+ end
25
+
26
+ private
27
+
28
+ def redacted_source
29
+ 'The MCP server'
24
30
  end
25
31
  end
26
32
  end
@@ -4,14 +4,17 @@ module RubyLLM
4
4
  class MCP
5
5
  # Streamable HTTP. Every message is its own POST, answered with a JSON
6
6
  # body or with an event stream that carries the request's notifications
7
- # before its response. Plain HTTP is only allowed on loopback addresses,
8
- # URLs cannot carry credentials, and redirects are never followed. A cancelled chat closes the stream,
7
+ # before its response. Plain HTTP is only allowed on loopback addresses
8
+ # unless +allow_http+ opts in, URLs cannot carry credentials, and redirects
9
+ # are never followed. A cancelled chat closes the stream,
9
10
  # which is how 2026-07-28 cancels a request. Servers that predate it may
10
11
  # keep a session, which ends with a DELETE when the transport closes.
11
12
  #
12
13
  # When a stream ends before the response, 2026-07-28 sends the request
13
- # again with a new ID, while older servers resume the stream from its
14
- # last event ID after the wait they ask for, a few times at most.
14
+ # again with a new ID, unless it is a tool call that +idempotent+ does
15
+ # not vouch for, since the server may have run it. Older servers resume
16
+ # the stream from its last event ID after the wait they ask for. Either
17
+ # happens a few times at most.
15
18
  #
16
19
  # A subscription's stream stays open, so it gets a connection of its
17
20
  # own: callbacks it runs can make requests of their own, even through
@@ -30,6 +33,12 @@ module RubyLLM
30
33
  RECONNECT_DELAY = 1
31
34
  CHECK_INTERVAL = 0.1
32
35
 
36
+ def self.parse(url)
37
+ URI(url.to_s)
38
+ rescue URI::InvalidURIError
39
+ raise ArgumentError, 'The MCP server URL is malformed', cause: nil
40
+ end
41
+
33
42
  def self.secure?(url)
34
43
  uri = URI(url.to_s)
35
44
  return false if uri.userinfo
@@ -41,15 +50,27 @@ module RubyLLM
41
50
  LOOPBACK_HOSTS.include?(URI(url.to_s).hostname)
42
51
  end
43
52
 
44
- def initialize(url, headers: {}, timeout: nil, unauthorized: nil, responded: nil, config: RubyLLM.config)
45
- @url = URI(url)
46
- unless self.class.secure?(@url)
47
- raise ArgumentError, "MCP servers must use HTTPS without credentials in the URL: #{url}"
48
- end
53
+ # Returns whether +url+ is a web page's address, an +http+ or +https+
54
+ # one. A server's page to visit must be, since anything else, such as
55
+ # a javascript: address, would run in the app that links to it.
56
+ def self.web?(url)
57
+ URI(url.to_s).is_a?(URI::HTTP)
58
+ rescue URI::InvalidURIError
59
+ false
60
+ end
61
+
62
+ # The session of a server that predates 2026-07-28: the ID the server gave its session, or +nil+.
63
+ attr_reader :session
64
+
65
+ def initialize(url, allow_http: false, headers: {}, timeout: nil, unauthorized: nil, responded: nil,
66
+ idempotent: nil, config: RubyLLM.config)
67
+ @url = self.class.parse(url)
68
+ check_url(allow_http)
49
69
 
50
70
  @headers = headers
51
71
  @unauthorized = unauthorized
52
72
  @responded = responded
73
+ @idempotent = idempotent
53
74
  @timeout = timeout || config.request_timeout
54
75
  @connect = lambda do
55
76
  Transport::Connection.basic(config) do |faraday|
@@ -64,7 +85,7 @@ module RubyLLM
64
85
  stream = post(message, version:, timeout:, params: headers, &)
65
86
  RECONNECTS.times do
66
87
  if version == Client::VERSION
67
- break unless stream.interrupted?
88
+ break unless resend?(stream, message)
68
89
 
69
90
  stream = post(message.merge(id: SecureRandom.uuid), version:, timeout:, params: headers, &)
70
91
  else
@@ -90,8 +111,6 @@ module RubyLLM
90
111
 
91
112
  stream = post(message, version:, connection: streams, &)
92
113
  stream.answer || raise(Error, "#{@url.host} closed the subscription")
93
- rescue Faraday::Error => e
94
- raise Error, e.message
95
114
  end
96
115
 
97
116
  def close
@@ -111,6 +130,14 @@ module RubyLLM
111
130
 
112
131
  private
113
132
 
133
+ def check_url(allow_http)
134
+ raise ArgumentError, 'MCP server URLs cannot carry credentials' if @url.userinfo
135
+ return if self.class.secure?(@url) || (allow_http && @url.scheme == 'http')
136
+
137
+ raise ArgumentError, 'MCP servers must use HTTPS, or plain HTTP on a loopback address. ' \
138
+ 'For a server on a network you trust, pass allow_http: true with the url'
139
+ end
140
+
114
141
  def post(message, version:, timeout: nil, params: {}, connection: @connection, recovered: [], &on_notification)
115
142
  session = @session unless message[:method] == 'initialize'
116
143
  stream = Stream.new(message[:id], &receiver(version, on_notification))
@@ -126,11 +153,11 @@ module RubyLLM
126
153
  remember(message, version, stream.headers)
127
154
  stream
128
155
  rescue Faraday::Error => e
129
- raise unless e.response
156
+ raise unanswered(e, stream) unless e.response
130
157
 
131
158
  responded(e.response[:headers])
132
159
  return stream if answered?(stream)
133
- raise SessionExpired, "#{@url.host} ended the session" if expired?(e.response, message, session)
160
+ raise SessionExpired.new("#{@url.host} ended the session", session:) if expired?(e.response, message, session)
134
161
 
135
162
  recovery = recover(e.response, recovered)
136
163
  if recovery
@@ -143,29 +170,30 @@ module RubyLLM
143
170
 
144
171
  def resume(stream, version:, timeout:, &on_notification)
145
172
  wait(stream.retry_after || RECONNECT_DELAY)
146
- resumed = Stream.new(stream.id, &receiver(version, on_notification))
173
+ resumed = Stream.new(stream.id, stream, &receiver(version, on_notification))
147
174
  get(resumed, version:, timeout:, last_event_id: stream.last_event_id)
148
175
  rescue Faraday::Error
149
176
  resumed
150
177
  end
151
178
 
152
179
  def listen_to_session(version:, &on_notification)
180
+ session = @session
153
181
  stream = Stream.new(nil, &receiver(version, on_notification))
154
- get(stream, version:, connection: streams)
182
+ get(stream, version:, connection: streams, session:)
155
183
  nil
156
184
  rescue Faraday::Error => e
157
- raise Error, e.message unless e.response
158
- raise SessionExpired, "#{@url.host} ended the session" if e.response[:status] == 404 && @session
185
+ raise unanswered(e, stream) unless e.response
186
+ raise SessionExpired.new("#{@url.host} ended the session", session:) if e.response[:status] == 404 && session
159
187
  raise Error.new("#{@url.host} sends no events", code: Client::METHOD_NOT_FOUND) if e.response[:status] == 405
160
188
 
161
189
  raise failure(e.response, stream)
162
190
  end
163
191
 
164
- def get(stream, version:, timeout: nil, last_event_id: nil, connection: @connection)
192
+ def get(stream, version:, timeout: nil, last_event_id: nil, connection: @connection, session: @session)
165
193
  stream.read do
166
194
  connection.get(@url) do |request|
167
195
  request.headers.update({ 'Accept' => 'text/event-stream', 'MCP-Protocol-Version' => version,
168
- 'Mcp-Session-Id' => @session, 'Last-Event-ID' => last_event_id }.compact)
196
+ 'Mcp-Session-Id' => session, 'Last-Event-ID' => last_event_id }.compact)
169
197
  request.headers.update(custom_headers('GET'))
170
198
  stream.attach(request, timeout)
171
199
  end
@@ -187,7 +215,7 @@ module RubyLLM
187
215
 
188
216
  def answer(request, version:)
189
217
  post(Client.reply(request), version:, connection: answers)
190
- rescue RubyLLM::Error, Faraday::Error => e
218
+ rescue RubyLLM::Error => e
191
219
  RubyLLM.logger.debug { "#{@url.host} did not take the answer to #{request['method']}: #{e.message}" }
192
220
  end
193
221
 
@@ -195,6 +223,10 @@ module RubyLLM
195
223
  @answers ||= @connect.call
196
224
  end
197
225
 
226
+ def resend?(stream, message)
227
+ stream.interrupted? && (message[:method] != 'tools/call' || @idempotent&.call(message.dig(:params, :name)))
228
+ end
229
+
198
230
  def resumable?(stream, limit)
199
231
  stream.interrupted? && stream.last_event_id && (limit.nil? || (stream.retry_after || 0) <= limit)
200
232
  end
@@ -267,14 +299,32 @@ module RubyLLM
267
299
  def failure(response, stream)
268
300
  case response[:status]
269
301
  when 401 then UnauthorizedError.new("#{@url.host} requires authorization", response: response_for(response))
270
- when 403 then ForbiddenError.new("#{@url.host} refused the request", response: response_for(response))
302
+ when 403 then forbidden(response)
271
303
  else
272
304
  error = stream.replies.first&.fetch('error', nil) || {}
273
- Error.new(error['message'] || "#{@url.host} answered HTTP #{response[:status]}",
274
- code: error['code'], data: error['data'], response: response_for(response))
305
+ fallback = "#{@url.host} answered HTTP #{response[:status]}" unless error['message']
306
+ Error.new(fallback, provider_message: error['message'], provider: @url.host,
307
+ code: error['code'], data: error['data'], response: response_for(response))
275
308
  end
276
309
  end
277
310
 
311
+ def forbidden(response)
312
+ challenges = OAuth.challenge(response[:headers]&.[]('WWW-Authenticate')).values.select { |c| c[:error] }
313
+ challenge = challenges.find { |c| c[:error] == 'insufficient_scope' } || challenges.first || {}
314
+ details = { response: response_for(response), provider: @url.host, error_code: challenge[:error],
315
+ provider_message: challenge[:error_description] || challenge[:error] }
316
+ message = "#{@url.host} refused the request"
317
+ return ForbiddenError.new(message, **details) unless challenge[:error] == 'insufficient_scope'
318
+
319
+ InsufficientScopeError.new(message, scopes: challenge[:scope].to_s.split, **details)
320
+ end
321
+
322
+ def unanswered(error, stream)
323
+ return error if stream.raised?(error)
324
+
325
+ ConnectionError.new(error.message, provider: @url.host)
326
+ end
327
+
278
328
  def response_for(response)
279
329
  Faraday::Response.new(status: response[:status], response_headers: response[:headers],
280
330
  body: response[:body])
@@ -285,15 +335,24 @@ module RubyLLM
285
335
  # The body is either a single JSON value or a server-sent event
286
336
  # stream; the first character tells them apart. An event stream stops
287
337
  # being read once it carries the answer to request +id+, since servers
288
- # may keep it open, and one that breaks midway counts as ended. Only
289
- # answers are kept, so a stream that stays open does not grow.
290
- # Faraday 2 passes the response headers with each chunk; Faraday 1
291
- # has them only once the response ends.
338
+ # may keep it open, and one that breaks midway counts as ended. The
339
+ # stop raises so adapters that pool sockets can discard the connection
340
+ # instead of returning it with unread bytes. Only answers are kept, so
341
+ # a stream that stays open does not grow. A stream that resumes another
342
+ # starts from its last event ID and reconnection time, as an event
343
+ # source keeps both across reconnections. Faraday 2 passes the response
344
+ # headers with each chunk; Faraday 1 has them only once the response ends,
345
+ # so there it reads on, and a stream that stays open ends at the timeout.
292
346
  class Stream # :nodoc:
347
+ class Abandoned < StandardError # :nodoc:
348
+ end
349
+
293
350
  attr_reader :id, :headers
294
351
 
295
- def initialize(id, &on_message)
352
+ def initialize(id, resumed = nil, &on_message)
296
353
  @id = id
354
+ @last_event_id = resumed&.last_event_id
355
+ @retry_after = resumed&.retry_after
297
356
  @on_message = on_message
298
357
  @parser = Transport::EventStreamParser.new
299
358
  @body = +''
@@ -307,13 +366,23 @@ module RubyLLM
307
366
  end
308
367
 
309
368
  def read(&)
310
- response = catch(self, &)
369
+ response = yield
311
370
  @headers = response.headers if @headers.empty? && response.respond_to?(:headers)
312
371
  self
313
- rescue Faraday::ConnectionFailed
314
- raise unless events?
372
+ rescue Abandoned
373
+ self
374
+ rescue Faraday::ConnectionFailed => e
375
+ raise if raised?(e) || !events?
315
376
 
316
377
  self
378
+ rescue Faraday::TimeoutError => e
379
+ raise if raised?(e) || !answer
380
+
381
+ self
382
+ end
383
+
384
+ def raised?(error)
385
+ @raised.equal?(error)
317
386
  end
318
387
 
319
388
  def feed(chunk, _size = nil, env = nil)
@@ -331,11 +400,11 @@ module RubyLLM
331
400
  return unless @events
332
401
 
333
402
  @parser.feed(chunk) { |_type, data| receive_event(data) }
334
- throw self if answer
403
+ abandon_if_answered
335
404
  end
336
405
 
337
406
  def replies
338
- receive(@body) if @events == false && @replies.empty?
407
+ receive(@body.slice!(0..)) if @events == false && !@body.empty?
339
408
  @replies
340
409
  end
341
410
 
@@ -348,11 +417,11 @@ module RubyLLM
348
417
  end
349
418
 
350
419
  def last_event_id
351
- @parser.last_event_id unless @parser.last_event_id.empty?
420
+ @parser.last_event_id.empty? ? @last_event_id : @parser.last_event_id
352
421
  end
353
422
 
354
423
  def retry_after
355
- @parser.reconnection_time / 1000.0 if @parser.reconnection_time
424
+ @parser.reconnection_time ? @parser.reconnection_time / 1000.0 : @retry_after
356
425
  end
357
426
 
358
427
  private
@@ -361,6 +430,10 @@ module RubyLLM
361
430
  @events == true
362
431
  end
363
432
 
433
+ def abandon_if_answered
434
+ raise Abandoned if answer && !@headers.empty?
435
+ end
436
+
364
437
  def receive_event(data)
365
438
  receive(data) unless data.empty?
366
439
  end
@@ -368,11 +441,18 @@ module RubyLLM
368
441
  def receive(data)
369
442
  parsed = JSON.parse(data)
370
443
  (parsed.is_a?(Array) ? parsed : [parsed]).grep(Hash).each do |reply|
371
- reply['method'] ? @on_message&.call(reply) : @replies << reply
444
+ reply['method'] ? deliver(reply) : @replies << reply
372
445
  end
373
446
  rescue JSON::ParserError
374
447
  RubyLLM.logger.debug { 'MCP server sent a message that is not JSON' }
375
448
  end
449
+
450
+ def deliver(message)
451
+ @on_message&.call(message)
452
+ rescue StandardError => e
453
+ @raised = e
454
+ raise
455
+ end
376
456
  end
377
457
  end
378
458
  end