ruby_llm 2.0.0 → 2.1.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 (284) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +65 -8
  3. data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
  4. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
  5. data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
  6. data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
  7. data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
  8. data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
  9. data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
  10. data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
  11. data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
  12. data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
  13. data/lib/ruby_llm/accounting/usage.rb +86 -49
  14. data/lib/ruby_llm/active_record/acts_as.rb +3 -0
  15. data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
  16. data/lib/ruby_llm/active_record/batch.rb +1 -1
  17. data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
  18. data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
  19. data/lib/ruby_llm/active_record/message_methods.rb +32 -7
  20. data/lib/ruby_llm/active_record/model.rb +1 -1
  21. data/lib/ruby_llm/active_record/provider_file.rb +64 -0
  22. data/lib/ruby_llm/active_record/record.rb +19 -0
  23. data/lib/ruby_llm/active_record/tool_call.rb +1 -1
  24. data/lib/ruby_llm/active_record/usage.rb +53 -3
  25. data/lib/ruby_llm/agent.rb +198 -33
  26. data/lib/ruby_llm/aliases.json +66 -8
  27. data/lib/ruby_llm/attachment.rb +54 -10
  28. data/lib/ruby_llm/batch.rb +26 -3
  29. data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
  30. data/lib/ruby_llm/chat/tool_search.rb +26 -0
  31. data/lib/ruby_llm/chat.rb +488 -82
  32. data/lib/ruby_llm/choice.rb +38 -0
  33. data/lib/ruby_llm/configuration.rb +36 -11
  34. data/lib/ruby_llm/context.rb +45 -32
  35. data/lib/ruby_llm/cost.rb +42 -1
  36. data/lib/ruby_llm/embedding.rb +9 -2
  37. data/lib/ruby_llm/error.rb +34 -2
  38. data/lib/ruby_llm/evaluation/assertions.rb +40 -0
  39. data/lib/ruby_llm/evaluation/case.rb +52 -0
  40. data/lib/ruby_llm/evaluation/dataset.rb +65 -0
  41. data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
  42. data/lib/ruby_llm/evaluation/evidence.rb +125 -0
  43. data/lib/ruby_llm/evaluation/minitest.rb +26 -0
  44. data/lib/ruby_llm/evaluation/report.rb +103 -0
  45. data/lib/ruby_llm/evaluation/result.rb +75 -0
  46. data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
  47. data/lib/ruby_llm/evaluation/rspec.rb +23 -0
  48. data/lib/ruby_llm/evaluation/runner.rb +93 -0
  49. data/lib/ruby_llm/evaluation/trial.rb +101 -0
  50. data/lib/ruby_llm/evaluation.rb +289 -0
  51. data/lib/ruby_llm/image.rb +12 -4
  52. data/lib/ruby_llm/judge/builder.rb +51 -0
  53. data/lib/ruby_llm/judge/data.rb +43 -0
  54. data/lib/ruby_llm/judge/question.rb +101 -0
  55. data/lib/ruby_llm/judge.rb +179 -0
  56. data/lib/ruby_llm/judgment.rb +125 -0
  57. data/lib/ruby_llm/mcp/apps.rb +25 -0
  58. data/lib/ruby_llm/mcp/client.rb +213 -0
  59. data/lib/ruby_llm/mcp/collection.rb +56 -0
  60. data/lib/ruby_llm/mcp/content.rb +44 -0
  61. data/lib/ruby_llm/mcp/error.rb +27 -0
  62. data/lib/ruby_llm/mcp/http.rb +379 -0
  63. data/lib/ruby_llm/mcp/input_request.rb +118 -0
  64. data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
  65. data/lib/ruby_llm/mcp/listener.rb +168 -0
  66. data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
  67. data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
  68. data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
  69. data/lib/ruby_llm/mcp/oauth.rb +653 -0
  70. data/lib/ruby_llm/mcp/param_headers.rb +83 -0
  71. data/lib/ruby_llm/mcp/prompt.rb +71 -0
  72. data/lib/ruby_llm/mcp/resource.rb +84 -0
  73. data/lib/ruby_llm/mcp/resource_template.rb +99 -0
  74. data/lib/ruby_llm/mcp/result.rb +76 -0
  75. data/lib/ruby_llm/mcp/session_expired.rb +10 -0
  76. data/lib/ruby_llm/mcp/stdio.rb +250 -0
  77. data/lib/ruby_llm/mcp/task.rb +151 -0
  78. data/lib/ruby_llm/mcp/tool.rb +130 -0
  79. data/lib/ruby_llm/mcp.rb +1101 -0
  80. data/lib/ruby_llm/message.rb +41 -6
  81. data/lib/ruby_llm/model.rb +2 -1
  82. data/lib/ruby_llm/models/aliases.rb +5 -2
  83. data/lib/ruby_llm/models/lookup.rb +80 -0
  84. data/lib/ruby_llm/models/registry.rb +2 -2
  85. data/lib/ruby_llm/models/schema.rb +1 -1
  86. data/lib/ruby_llm/models.json +23082 -10158
  87. data/lib/ruby_llm/models.rb +20 -69
  88. data/lib/ruby_llm/moderation.rb +7 -2
  89. data/lib/ruby_llm/ocr.rb +7 -2
  90. data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
  91. data/lib/ruby_llm/open_telemetry.rb +82 -0
  92. data/lib/ruby_llm/probability.rb +30 -0
  93. data/lib/ruby_llm/progress.rb +44 -0
  94. data/lib/ruby_llm/prompt/context.rb +55 -0
  95. data/lib/ruby_llm/prompt.rb +1 -1
  96. data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
  97. data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
  98. data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
  99. data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
  100. data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
  101. data/lib/ruby_llm/protocol/streaming.rb +38 -31
  102. data/lib/ruby_llm/protocol.rb +192 -40
  103. data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
  104. data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
  105. data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
  106. data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
  107. data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
  108. data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
  109. data/lib/ruby_llm/protocols/anthropic.rb +20 -1
  110. data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
  111. data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
  112. data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
  113. data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
  114. data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
  115. data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
  116. data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
  117. data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
  118. data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
  119. data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
  120. data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
  121. data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
  122. data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
  123. data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
  124. data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
  125. data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
  126. data/lib/ruby_llm/protocols/cohere.rb +1 -0
  127. data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
  128. data/lib/ruby_llm/protocols/converse/media.rb +16 -2
  129. data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
  130. data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
  131. data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
  132. data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
  133. data/lib/ruby_llm/protocols/converse.rb +3 -5
  134. data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
  135. data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
  136. data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
  137. data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
  138. data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
  139. data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
  140. data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
  141. data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
  142. data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
  143. data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
  144. data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
  145. data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
  146. data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
  147. data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
  148. data/lib/ruby_llm/protocols/gemini.rb +1 -0
  149. data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
  150. data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
  151. data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
  152. data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
  153. data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
  154. data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
  155. data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
  156. data/lib/ruby_llm/protocols/interactions.rb +1 -0
  157. data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
  158. data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
  159. data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
  160. data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
  161. data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
  162. data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
  163. data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
  164. data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
  165. data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
  166. data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
  167. data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
  168. data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
  169. data/lib/ruby_llm/protocols/openai/files.rb +0 -7
  170. data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
  171. data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
  172. data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
  173. data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
  174. data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
  175. data/lib/ruby_llm/protocols/responses/media.rb +11 -7
  176. data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
  177. data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
  178. data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
  179. data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
  180. data/lib/ruby_llm/protocols/responses.rb +1 -0
  181. data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
  182. data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
  183. data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
  184. data/lib/ruby_llm/protocols/system_one.rb +27 -0
  185. data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
  186. data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
  187. data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
  188. data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
  189. data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
  190. data/lib/ruby_llm/provider.rb +87 -15
  191. data/lib/ruby_llm/providers/anthropic.rb +23 -0
  192. data/lib/ruby_llm/providers/azure/chat.rb +4 -0
  193. data/lib/ruby_llm/providers/azure/images.rb +2 -2
  194. data/lib/ruby_llm/providers/azure/media.rb +8 -3
  195. data/lib/ruby_llm/providers/azure/videos.rb +7 -2
  196. data/lib/ruby_llm/providers/azure.rb +24 -4
  197. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
  198. data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
  199. data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
  200. data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
  201. data/lib/ruby_llm/providers/bedrock.rb +38 -6
  202. data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
  203. data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
  204. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  205. data/lib/ruby_llm/providers/gemini.rb +4 -0
  206. data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
  207. data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
  208. data/lib/ruby_llm/providers/hetzner.rb +41 -0
  209. data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
  210. data/lib/ruby_llm/providers/mistral/media.rb +8 -4
  211. data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
  212. data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
  213. data/lib/ruby_llm/providers/ollama/media.rb +6 -2
  214. data/lib/ruby_llm/providers/ollama/models.rb +2 -0
  215. data/lib/ruby_llm/providers/ollama.rb +11 -0
  216. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
  217. data/lib/ruby_llm/providers/openai.rb +14 -0
  218. data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
  219. data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
  220. data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
  221. data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
  222. data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
  223. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  224. data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
  225. data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
  226. data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
  227. data/lib/ruby_llm/providers/perplexity.rb +11 -0
  228. data/lib/ruby_llm/providers/typesafe.rb +32 -0
  229. data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
  230. data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
  231. data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
  232. data/lib/ruby_llm/providers/vertexai.rb +27 -28
  233. data/lib/ruby_llm/providers/xai/chat.rb +6 -0
  234. data/lib/ruby_llm/providers/xai/images.rb +9 -10
  235. data/lib/ruby_llm/providers/xai/responses.rb +10 -8
  236. data/lib/ruby_llm/providers/xai/videos.rb +11 -9
  237. data/lib/ruby_llm/providers/xai.rb +4 -0
  238. data/lib/ruby_llm/railtie.rb +11 -0
  239. data/lib/ruby_llm/request_shape/part.rb +117 -0
  240. data/lib/ruby_llm/request_shape/problem.rb +63 -0
  241. data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
  242. data/lib/ruby_llm/request_shape/turn.rb +54 -0
  243. data/lib/ruby_llm/request_shape.rb +160 -0
  244. data/lib/ruby_llm/rerank.rb +9 -2
  245. data/lib/ruby_llm/research_job.rb +21 -5
  246. data/lib/ruby_llm/score.rb +42 -0
  247. data/lib/ruby_llm/search_results.rb +2 -2
  248. data/lib/ruby_llm/server_tool_call.rb +10 -2
  249. data/lib/ruby_llm/speech.rb +8 -1
  250. data/lib/ruby_llm/support/cancellation.rb +39 -0
  251. data/lib/ruby_llm/support/inspectable.rb +3 -2
  252. data/lib/ruby_llm/support/instrumentation.rb +38 -1
  253. data/lib/ruby_llm/support/process_cache.rb +60 -0
  254. data/lib/ruby_llm/support/progress_reporter.rb +33 -0
  255. data/lib/ruby_llm/thinking.rb +2 -1
  256. data/lib/ruby_llm/tokens.rb +57 -14
  257. data/lib/ruby_llm/tool.rb +50 -3
  258. data/lib/ruby_llm/tool_call.rb +17 -2
  259. data/lib/ruby_llm/transcription.rb +10 -3
  260. data/lib/ruby_llm/transport/connection.rb +127 -44
  261. data/lib/ruby_llm/transport/error_middleware.rb +90 -24
  262. data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
  263. data/lib/ruby_llm/transport/json_response.rb +33 -0
  264. data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
  265. data/lib/ruby_llm/version.rb +1 -1
  266. data/lib/ruby_llm/video.rb +5 -3
  267. data/lib/ruby_llm/video_job.rb +48 -5
  268. data/lib/ruby_llm.rb +106 -5
  269. data/lib/tasks/ruby_llm/evaluations.rb +43 -0
  270. data/lib/tasks/ruby_llm.rake +6 -9
  271. data/skills/rubyllm/SKILL.md +33 -12
  272. metadata +95 -37
  273. data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
  274. data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
  275. data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
  276. data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
  277. data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
  278. data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
  279. data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
  280. data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
  281. data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
  282. data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
  283. data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
  284. data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Defines reusable probability, choice, and score questions over application data.
5
+ #
6
+ # class TicketTriage < RubyLLM::Judge
7
+ # probability :urgent, "Does this need attention today?"
8
+ # choice :department, "Which team should handle this?" do
9
+ # billing "Payments and refunds"
10
+ # technical "Bugs and integrations"
11
+ # end
12
+ # end
13
+ #
14
+ # TicketTriage.judge("Please refund my duplicate charge today.").urgent.probability
15
+ #
16
+ # Uses Configuration#default_judgment_model unless a model is declared or
17
+ # supplied for the call. An isolated context supplies its own default.
18
+ #
19
+ # Blocks and procs resolve once per judgment, with declared inputs available
20
+ # as methods. Hashes and arrays preserve structured instructions and criteria.
21
+ class Judge
22
+ class << self
23
+ def inherited(subclass) # :nodoc:
24
+ super
25
+ subclass.instance_variable_set(:@model_options, model.dup)
26
+ subclass.instance_variable_set(:@input_names, inputs.dup)
27
+ subclass.instance_variable_set(:@question_definitions, question_definitions.dup)
28
+ subclass.instance_variable_set(:@provider_options, provider_options)
29
+ end
30
+
31
+ # Sets the model and provider used by this judge. A block or proc resolves
32
+ # the model using runtime inputs. With no arguments, returns the settings.
33
+ # Without a declaration, uses Configuration#default_judgment_model.
34
+ def model(value = nil, **options, &block)
35
+ return @model_options || {} if value.nil? && options.empty? && !block
36
+
37
+ raise ArgumentError, 'Pass a model or a block, not both' if !value.nil? && block
38
+
39
+ @model_options = options.merge(model: block || value).freeze
40
+ end
41
+
42
+ # Declares required runtime inputs available in question and data blocks.
43
+ # With no arguments, returns the declared names.
44
+ #
45
+ # inputs :teams
46
+ # choice :department, "Which team?", -> { teams.to_h { |team| [team.slug, team.description] } }
47
+ def inputs(*names)
48
+ return @input_names || [] if names.empty?
49
+
50
+ @input_names = names.flatten.map(&:to_sym).freeze
51
+ end
52
+
53
+ # Sets provider-specific request options as a Hash, proc, or block.
54
+ # With no arguments, returns the declared options.
55
+ def provider_options(value = nil, &block)
56
+ return @provider_options || {} if value.nil? && !block
57
+
58
+ raise ArgumentError, 'Pass provider options or a block, not both' if !value.nil? && block
59
+
60
+ @provider_options = Data.copy(block || value)
61
+ end
62
+
63
+ # Declares a yes/no question whose answer is the probability of yes.
64
+ # Optional criteria describe +yes+ and +no+ in a Hash, proc, or block.
65
+ #
66
+ # probability :urgent, "Does this need attention today?" do
67
+ # yes "An explicit deadline today"
68
+ # no "No deadline or a later deadline"
69
+ # end
70
+ def probability(name, instructions = nil, criteria = nil, &)
71
+ declare_question(name, :probability, instructions, criteria, &)
72
+ end
73
+
74
+ # Declares a question selecting one named option. Options are a Hash,
75
+ # a proc returning a Hash, or a block declaring named descriptions.
76
+ #
77
+ # choice :department, "Which team?" do
78
+ # billing "Payments and refunds"
79
+ # other nil
80
+ # end
81
+ def choice(name, instructions = nil, options = nil, &)
82
+ declare_question(name, :choice, instructions, options, &)
83
+ end
84
+
85
+ # Declares a question scored against ordered levels. Levels are an Array
86
+ # or a proc or block returning one. Scores can fall between level indexes.
87
+ #
88
+ # score :frustration, "How frustrated is the customer?", ["Calm", "Frustrated", "Angry"]
89
+ def score(name, instructions = nil, levels = nil, &)
90
+ declare_question(name, :score, instructions, levels, &)
91
+ end
92
+
93
+ # Judges text, a Hash, an Array, or images passed as +with:+ and returns
94
+ # a Judgment. A block or proc can supply the input. Declared inputs are
95
+ # accepted as keyword arguments; remaining options are forwarded to #judge.
96
+ #
97
+ # TicketTriage.judge do
98
+ # message "Please refund the duplicate charge today."
99
+ # end
100
+ def judge(input = nil, **options, &)
101
+ values = options.slice(*inputs)
102
+ new(**values).judge(input, **options.except(*inputs), &)
103
+ end
104
+
105
+ def question_definitions # :nodoc:
106
+ @question_definitions ||= {}
107
+ end
108
+
109
+ private
110
+
111
+ def declare_question(name, type, instructions, criteria, &)
112
+ question = Question.new(name, type:, instructions:, criteria:, &)
113
+ @declared_names ||= []
114
+ raise ArgumentError, "Duplicate question: #{name}" if @declared_names.include?(name.to_s)
115
+
116
+ @declared_names << name.to_s
117
+ question_definitions[name.to_s] = question
118
+ self
119
+ end
120
+ end
121
+
122
+ # Creates a judge with the runtime inputs declared by its class.
123
+ def initialize(**inputs)
124
+ missing = self.class.inputs - inputs.keys
125
+ extra = inputs.keys - self.class.inputs
126
+ raise ArgumentError, "Missing judge inputs: #{missing.join(', ')}" unless missing.empty?
127
+ raise ArgumentError, "Unknown judge inputs: #{extra.join(', ')}" unless extra.empty?
128
+
129
+ inputs.each do |name, value|
130
+ raise ArgumentError, "Judge input conflicts with a method: #{name}" if respond_to?(name, true)
131
+
132
+ define_singleton_method(name) { value }
133
+ end
134
+ end
135
+
136
+ # Judges the supplied input and returns a Judgment. Accepts model and
137
+ # provider overrides, images as +with:+, an isolated +context:+,
138
+ # +provider_options:+, instrumentation +metadata:+, and a usage +owner:+,
139
+ # as RubyLLM.with_usage_owner describes. Additional
140
+ # +questions:+ are a Hash keyed by question name, with +type:+,
141
+ # +instructions:+, and +criteria:+ (probability), +options:+ (choice), or
142
+ # +levels:+ (score). The block supplies input only. Input may be omitted
143
+ # when images are supplied.
144
+ #
145
+ # RubyLLM.judge("Please help today",
146
+ # questions: { urgent: { type: :probability, instructions: "Is this urgent?" } })
147
+ # Receipt.judge(with: "receipt.png")
148
+ def judge(input = nil, questions: {}, with: nil, context: nil, metadata: nil, owner: nil, **options, &block)
149
+ raise ArgumentError, 'Pass judgment input or a block, not both' if !input.nil? && block
150
+
151
+ data = resolve_data(block || input)
152
+ definitions = resolve_questions(questions)
153
+ settings = self.class.model.merge(provider_options: self.class.provider_options).merge(options)
154
+ settings = settings.transform_values { |value| resolve_data(value) }
155
+ Judgment.judge(data, questions: definitions, with:, context:, metadata:, owner:, **settings)
156
+ end
157
+
158
+ private
159
+
160
+ def resolve_data(value)
161
+ Data.copy(value) { |callable| Builder.resolve(callable, scope: self) }
162
+ end
163
+
164
+ def resolve_questions(questions)
165
+ questions = resolve_data(questions)
166
+ raise ArgumentError, 'Questions must be a Hash' unless questions.is_a?(Hash)
167
+
168
+ definitions = self.class.question_definitions.dup
169
+ questions.each do |name, definition|
170
+ raise ArgumentError, "Duplicate question: #{name}" if definitions.key?(name.to_s)
171
+
172
+ definitions[name.to_s] = Question.from_h(name, definition)
173
+ end
174
+ raise ArgumentError, 'A judgment needs at least one question' if definitions.empty?
175
+
176
+ definitions.transform_values { |definition| definition.resolve(self) }
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # The typed answers to a set of questions, with model identity and accounting.
5
+ # Obtain one through RubyLLM.judge or Judge.judge.
6
+ #
7
+ # judgment.urgent.probability
8
+ # judgment.department.choice
9
+ # judgment.frustration.score
10
+ #
11
+ # Named answers are available as readers. Use #[] or #fetch for dynamic
12
+ # names or names that conflict with existing methods, such as #model.
13
+ class Judgment
14
+ include Enumerable
15
+ include Support::Inspectable
16
+ include Accounting::Usage::Result
17
+
18
+ # Returns the answers keyed by their declared String or Symbol names.
19
+ attr_reader :answers
20
+
21
+ # Returns the actual model ID reported by the provider.
22
+ attr_reader :model
23
+
24
+ # Returns the original provider response.
25
+ attr_reader :raw
26
+
27
+ def initialize(answers:, model:, tokens: Tokens.new, raw: nil, model_info: nil) # :nodoc:
28
+ @answers = answers.dup.freeze
29
+ @answer_keys = answers.keys.to_h { |key| [key.to_s, key] }.freeze
30
+ @model = model
31
+ @tokens = tokens
32
+ @raw = raw
33
+ @model_info = model_info
34
+ end
35
+
36
+ # Returns the named answer, accepting a String or Symbol, or nil if absent.
37
+ def [](name)
38
+ answers[@answer_keys[name.to_s]]
39
+ end
40
+
41
+ # Returns the named answer or raises KeyError if it is absent.
42
+ def fetch(name)
43
+ answers.fetch(@answer_keys.fetch(name.to_s))
44
+ end
45
+
46
+ # Yields each question name and answer, or returns an Enumerator.
47
+ def each(&)
48
+ answers.each(&)
49
+ end
50
+
51
+ # Returns token usage across all provider attempts.
52
+ def tokens
53
+ ruby_llm_usage_entries.empty? ? @tokens : ruby_llm_usage_tokens
54
+ end
55
+
56
+ # Returns cost across all provider attempts, preserving unknown amounts.
57
+ def cost
58
+ return ruby_llm_usage_cost unless ruby_llm_usage_entries.empty?
59
+
60
+ Cost.new(tokens:, model: @model_info)
61
+ end
62
+
63
+ # Returns the model, typed answers, token usage, and cost as a Hash.
64
+ def to_h
65
+ { model:, answers: answers.transform_values(&:to_h), tokens: tokens.to_h, cost: cost.to_h }
66
+ end
67
+
68
+ def inspect_attributes # :nodoc:
69
+ { model:, answers: answers.keys }
70
+ end
71
+
72
+ def self.judge(input, questions:, with: nil, model: nil, provider: nil, context: nil, # :nodoc:
73
+ assume_model_exists: false, provider_options: {}, metadata: nil, owner: nil)
74
+ config = context&.config || RubyLLM.config
75
+ attachments = Attachment.wrap(with, config:)
76
+ validate_input!(input, attachments)
77
+ raise ArgumentError, 'A judgment requires a model' unless model || config.default_judgment_model
78
+
79
+ model, provider_instance = Models.resolve(model, provider:, assume_model_exists:, config:, operation: :judge,
80
+ default_model: config.default_judgment_model)
81
+ empty_tokens = Tokens.new
82
+ payload = {
83
+ provider: provider_instance.slug,
84
+ provider_class: provider_instance.name,
85
+ model: model.id,
86
+ model_info: model,
87
+ question_count: questions.size,
88
+ attachment_count: attachments.size,
89
+ provider_options:,
90
+ metadata:,
91
+ tokens: empty_tokens,
92
+ cost: Cost.new(tokens: empty_tokens, model:)
93
+ }
94
+
95
+ RubyLLM.instrument('judgment.ruby_llm', payload, config:) do |event|
96
+ result = Accounting::Usage.owned_by(owner) do
97
+ provider_instance.judge(input, questions:, model:, with: attachments, provider_options:)
98
+ end
99
+ event[:result] = result
100
+ event[:tokens] = result.tokens
101
+ event[:cost] = result.cost
102
+ result
103
+ end
104
+ end
105
+
106
+ def self.validate_input!(input, attachments)
107
+ return if input.is_a?(String) || input.is_a?(Hash) || input.is_a?(Array) || (input.nil? && attachments.any?)
108
+
109
+ raise ArgumentError, 'Judgment input must be text, a Hash, or an Array'
110
+ end
111
+ private_class_method :validate_input!
112
+
113
+ private
114
+
115
+ def method_missing(name, *args, &block)
116
+ return super unless args.empty? && !block && @answer_keys&.key?(name.to_s)
117
+
118
+ self[name]
119
+ end
120
+
121
+ def respond_to_missing?(name, include_private = nil)
122
+ @answer_keys&.key?(name.to_s) || super
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # Reads what the MCP Apps extension keeps in a tool's _meta: the UI that
6
+ # renders the tool's results, and who may call the tool. Servers on
7
+ # older SDKs name the UI with the flat ui/resourceUri key the spec
8
+ # deprecated.
9
+ module Apps # :nodoc:
10
+ EXTENSION = 'io.modelcontextprotocol/ui'
11
+ MIME_TYPE = 'text/html;profile=mcp-app'
12
+ VISIBILITY = %w[model app].freeze
13
+
14
+ module_function
15
+
16
+ def uri(meta)
17
+ meta.dig('ui', 'resourceUri') || meta['ui/resourceUri']
18
+ end
19
+
20
+ def visibility(meta)
21
+ Array(meta.dig('ui', 'visibility') || VISIBILITY).map(&:to_sym)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # Speaks JSON-RPC to one MCP server over a transport. It speaks the
6
+ # 2026-07-28 revision and falls back to the initialize handshake for
7
+ # servers that predate it, declaring only its extensions so those
8
+ # servers never call back. A server that rejects 2026-07-28 while
9
+ # listing it gets the request once more; one that answers the handshake
10
+ # with a revision missing from LEGACY_VERSIONS is disconnected.
11
+ #
12
+ # Servers that predate 2026-07-28 may announce that their tools,
13
+ # prompts, or resources changed on the stream of any request. Those
14
+ # notifications reach the block given to ::new once the request is
15
+ # answered, so the block can make requests of its own. #listen opens a
16
+ # subscription for them instead: subscriptions/listen on newer servers,
17
+ # resources/subscribe and the session's own stream on older ones, which
18
+ # never announce the status of tasks. Their subscription counts as
19
+ # acknowledged once a ping confirms the session, starting a new one if
20
+ # it ended.
21
+ class Client # :nodoc:
22
+ VERSION = '2026-07-28'
23
+ LEGACY_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
24
+ UNSUPPORTED_VERSION = -32_022
25
+ MODERN_ERRORS = [-32_020, -32_021, UNSUPPORTED_VERSION].freeze
26
+ METHOD_NOT_FOUND = -32_601
27
+ DISCOVERY_TIMEOUT = 10
28
+ ACKNOWLEDGED = 'notifications/subscriptions/acknowledged'
29
+ CHANGES = %w[
30
+ notifications/tools/list_changed notifications/prompts/list_changed
31
+ notifications/resources/list_changed notifications/resources/updated
32
+ ].freeze
33
+
34
+ # The answer to a request the server sends: RubyLLM only answers pings.
35
+ def self.reply(request)
36
+ if request['method'] == 'ping'
37
+ { jsonrpc: '2.0', id: request['id'], result: {} }
38
+ else
39
+ { jsonrpc: '2.0', id: request['id'], error: { code: METHOD_NOT_FOUND, message: 'Method not found' } }
40
+ end
41
+ end
42
+
43
+ attr_reader :version
44
+
45
+ def initialize(transport, capabilities: {}, &on_change)
46
+ @transport = transport
47
+ @capabilities = capabilities
48
+ @on_change = on_change
49
+ @connecting = Mutex.new
50
+ @subscribed = []
51
+ end
52
+
53
+ def server
54
+ @connecting.synchronize { @server ||= discover || handshake }
55
+ end
56
+
57
+ def request(method, params = {}, headers: {}, &)
58
+ server
59
+ call(method, params, headers:, &)
60
+ rescue SessionExpired
61
+ @connecting.synchronize { @server = handshake }
62
+ call(method, params, headers:, &)
63
+ end
64
+
65
+ def list(method, key)
66
+ page = request(method)
67
+ items = page.fetch(key, [])
68
+ while (cursor = page['nextCursor'])
69
+ page = request(method, { cursor: })
70
+ items += page.fetch(key, [])
71
+ end
72
+ items
73
+ end
74
+
75
+ # Subscribes to +changes+, a subscriptions/listen filter, and yields
76
+ # each notification of the subscription, starting with the server's
77
+ # acknowledgment. Returns when the server ends the subscription and
78
+ # raises Error when it refuses one or the stream breaks.
79
+ def listen(changes, &)
80
+ server
81
+ raise ConfigurationError, 'The MCP transport does not respond to listen' unless @transport.respond_to?(:listen)
82
+
83
+ modern? ? subscribe(changes, &) : listen_to_session(changes, &)
84
+ end
85
+
86
+ def modern?
87
+ version == VERSION
88
+ end
89
+
90
+ def close
91
+ @connecting.synchronize do
92
+ @transport.close
93
+ @server = nil
94
+ @version = nil
95
+ @subscribed = []
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def discover
102
+ @version = VERSION
103
+ result = call('server/discover', timeout: DISCOVERY_TIMEOUT)
104
+ result if Array(result['supportedVersions']).include?(VERSION)
105
+ rescue Error => e
106
+ raise if MODERN_ERRORS.include?(e.code)
107
+ end
108
+
109
+ def handshake
110
+ @version = nil
111
+ params = { protocolVersion: LEGACY_VERSIONS.first, capabilities: @capabilities.slice(:extensions),
112
+ clientInfo: client_info }
113
+ result = call('initialize', params)
114
+ unless LEGACY_VERSIONS.include?(result['protocolVersion'])
115
+ @transport.close
116
+ raise Error, "The server answered with protocol version #{result['protocolVersion']}, " \
117
+ 'which RubyLLM does not speak'
118
+ end
119
+
120
+ @version = result['protocolVersion']
121
+ @subscribed = []
122
+ @transport.notify(message('notifications/initialized'), version:)
123
+ result
124
+ end
125
+
126
+ def call(method, params = {}, timeout: nil, headers: {}, retried: false, &on_notification)
127
+ request = message(method, params, id: SecureRandom.uuid)
128
+ answer(exchange(request, timeout:, headers:, &on_notification))
129
+ rescue Error => e
130
+ raise if retried || !offers_version?(e)
131
+
132
+ call(method, params, timeout:, headers:, retried: true, &on_notification)
133
+ end
134
+
135
+ def exchange(request, timeout:, headers:, &on_notification)
136
+ changes = []
137
+ @transport.request(request, version:, timeout:, headers:) do |notification|
138
+ CHANGES.include?(notification['method']) ? changes << notification : on_notification&.call(notification)
139
+ end
140
+ rescue CancelledError
141
+ cancel(request)
142
+ raise
143
+ ensure
144
+ changes.each { |change| @on_change&.call(change) }
145
+ end
146
+
147
+ def subscribe(changes)
148
+ request = message('subscriptions/listen', { notifications: changes }, id: SecureRandom.uuid)
149
+ ending = catch(request) do
150
+ @transport.listen(request, version:) do |notification|
151
+ throw request, notification if cancels?(notification, request)
152
+ yield notification
153
+ end
154
+ end
155
+ answer(ending)
156
+ rescue CancelledError
157
+ cancel(request)
158
+ raise
159
+ end
160
+
161
+ def listen_to_session(changes, &)
162
+ uris = Array(changes[:resourceSubscriptions])
163
+ (@subscribed - uris).each { |uri| request('resources/unsubscribe', { uri: }) }
164
+ uris.each { |uri| request('resources/subscribe', { uri: }) }
165
+ @subscribed = uris
166
+ request('ping')
167
+ listened = changes.except(:taskIds).transform_keys(&:to_s)
168
+ yield({ 'method' => ACKNOWLEDGED, 'params' => { 'notifications' => listened } })
169
+ @transport.listen(nil, version:, &)
170
+ rescue SessionExpired
171
+ @connecting.synchronize { @server = handshake }
172
+ raise
173
+ end
174
+
175
+ def cancels?(notification, request)
176
+ notification['method'] == 'notifications/cancelled' && notification.dig('params', 'requestId') == request[:id]
177
+ end
178
+
179
+ def cancel(request)
180
+ @transport.cancel(message('notifications/cancelled', { requestId: request[:id] }), version:)
181
+ end
182
+
183
+ def answer(response)
184
+ error = response['error']
185
+ raise Error.new(error['message'], code: error['code'], data: error['data']) if error
186
+
187
+ response['result']
188
+ end
189
+
190
+ def offers_version?(error)
191
+ supported = error.data['supported'] if error.data.is_a?(Hash)
192
+ modern? && error.code == UNSUPPORTED_VERSION && Array(supported).include?(VERSION)
193
+ end
194
+
195
+ def message(method, params = {}, id: nil)
196
+ params = params.merge(_meta: meta.merge(params.fetch(:_meta, {}))) if modern?
197
+ { jsonrpc: '2.0', id:, method:, params: }.compact
198
+ end
199
+
200
+ def meta
201
+ {
202
+ 'io.modelcontextprotocol/protocolVersion' => VERSION,
203
+ 'io.modelcontextprotocol/clientInfo' => client_info,
204
+ 'io.modelcontextprotocol/clientCapabilities' => @capabilities
205
+ }
206
+ end
207
+
208
+ def client_info
209
+ { name: 'ruby_llm', version: RubyLLM::VERSION }
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # The MCP servers connected to a chat, readable by name.
6
+ #
7
+ # chat.with_mcp(Linear.new(user: current_user))
8
+ # chat.mcp.linear # => #<Linear ...>
9
+ # chat.mcp[:linear] # => #<Linear ...>
10
+ # chat.mcp.map(&:name) # => ["linear"]
11
+ #
12
+ class Collection
13
+ include Enumerable
14
+
15
+ def initialize # :nodoc:
16
+ @servers = {}
17
+ end
18
+
19
+ def <<(server) # :nodoc:
20
+ @servers[server.name.to_sym] = server
21
+ self
22
+ end
23
+
24
+ # Returns the server named +name+, or +nil+.
25
+ def [](name)
26
+ @servers[name.to_sym]
27
+ end
28
+
29
+ # Yields each server.
30
+ def each(&)
31
+ @servers.each_value(&)
32
+ end
33
+
34
+ # Returns whether no servers are connected.
35
+ def empty?
36
+ @servers.empty?
37
+ end
38
+
39
+ def inspect # :nodoc:
40
+ "#<#{self.class.name} #{@servers.keys.join(', ')}>"
41
+ end
42
+
43
+ private
44
+
45
+ def method_missing(name, *arguments)
46
+ return super unless arguments.empty? && @servers.key?(name)
47
+
48
+ @servers[name]
49
+ end
50
+
51
+ def respond_to_missing?(name, include_private = nil)
52
+ @servers.key?(name) || super
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # Reads MCP content blocks into text and attachments. Tool results and
6
+ # prompt messages share the format.
7
+ module Content # :nodoc:
8
+ module_function
9
+
10
+ def read(blocks)
11
+ texts, attachments = Array(blocks).filter_map { |block| part(block) }.partition { |part| part.is_a?(String) }
12
+ [texts.join("\n\n"), attachments]
13
+ end
14
+
15
+ def part(block)
16
+ case block['type']
17
+ when 'text' then block['text']
18
+ when 'image', 'audio' then attachment(block['data'], block['mimeType'], block['type'])
19
+ when 'resource' then embedded(block['resource'] || {})
20
+ when 'resource_link' then [block['title'] || block['name'], block['uri']].compact.join(': ')
21
+ end
22
+ end
23
+
24
+ def embedded(resource)
25
+ return resource['text'] if resource['text']
26
+ return unless resource['blob']
27
+
28
+ attachment(resource['blob'], resource['mimeType'], filename(resource['uri']))
29
+ end
30
+
31
+ def attachment(data, mime_type, name)
32
+ 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)
35
+ end
36
+
37
+ def filename(uri)
38
+ File.basename(URI(uri.to_s).path.to_s)
39
+ rescue URI::InvalidURIError
40
+ 'resource'
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class MCP
5
+ # Raised when an MCP server answers with a JSON-RPC error, an unexpected
6
+ # HTTP status, or not at all.
7
+ #
8
+ # begin
9
+ # github.search_issues(query: "flaky")
10
+ # rescue RubyLLM::MCP::Error => e
11
+ # e.code # => -32602
12
+ # end
13
+ class Error < RubyLLM::Error
14
+ # The JSON-RPC error code, or +nil+ when the failure had none.
15
+ attr_reader :code
16
+
17
+ # Additional information the server attached to the error, or +nil+.
18
+ attr_reader :data
19
+
20
+ def initialize(message = nil, code: nil, data: nil, response: nil) # :nodoc:
21
+ @code = code
22
+ @data = data
23
+ super(message, response:)
24
+ end
25
+ end
26
+ end
27
+ end