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.
- checksums.yaml +4 -4
- data/README.md +65 -8
- data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
- data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
- data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
- data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
- data/lib/ruby_llm/accounting/usage.rb +86 -49
- data/lib/ruby_llm/active_record/acts_as.rb +3 -0
- data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
- data/lib/ruby_llm/active_record/batch.rb +1 -1
- data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
- data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
- data/lib/ruby_llm/active_record/message_methods.rb +32 -7
- data/lib/ruby_llm/active_record/model.rb +1 -1
- data/lib/ruby_llm/active_record/provider_file.rb +64 -0
- data/lib/ruby_llm/active_record/record.rb +19 -0
- data/lib/ruby_llm/active_record/tool_call.rb +1 -1
- data/lib/ruby_llm/active_record/usage.rb +53 -3
- data/lib/ruby_llm/agent.rb +198 -33
- data/lib/ruby_llm/aliases.json +66 -8
- data/lib/ruby_llm/attachment.rb +54 -10
- data/lib/ruby_llm/batch.rb +26 -3
- data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
- data/lib/ruby_llm/chat/tool_search.rb +26 -0
- data/lib/ruby_llm/chat.rb +488 -82
- data/lib/ruby_llm/choice.rb +38 -0
- data/lib/ruby_llm/configuration.rb +36 -11
- data/lib/ruby_llm/context.rb +45 -32
- data/lib/ruby_llm/cost.rb +42 -1
- data/lib/ruby_llm/embedding.rb +9 -2
- data/lib/ruby_llm/error.rb +34 -2
- data/lib/ruby_llm/evaluation/assertions.rb +40 -0
- data/lib/ruby_llm/evaluation/case.rb +52 -0
- data/lib/ruby_llm/evaluation/dataset.rb +65 -0
- data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
- data/lib/ruby_llm/evaluation/evidence.rb +125 -0
- data/lib/ruby_llm/evaluation/minitest.rb +26 -0
- data/lib/ruby_llm/evaluation/report.rb +103 -0
- data/lib/ruby_llm/evaluation/result.rb +75 -0
- data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
- data/lib/ruby_llm/evaluation/rspec.rb +23 -0
- data/lib/ruby_llm/evaluation/runner.rb +93 -0
- data/lib/ruby_llm/evaluation/trial.rb +101 -0
- data/lib/ruby_llm/evaluation.rb +289 -0
- data/lib/ruby_llm/image.rb +12 -4
- data/lib/ruby_llm/judge/builder.rb +51 -0
- data/lib/ruby_llm/judge/data.rb +43 -0
- data/lib/ruby_llm/judge/question.rb +101 -0
- data/lib/ruby_llm/judge.rb +179 -0
- data/lib/ruby_llm/judgment.rb +125 -0
- data/lib/ruby_llm/mcp/apps.rb +25 -0
- data/lib/ruby_llm/mcp/client.rb +213 -0
- data/lib/ruby_llm/mcp/collection.rb +56 -0
- data/lib/ruby_llm/mcp/content.rb +44 -0
- data/lib/ruby_llm/mcp/error.rb +27 -0
- data/lib/ruby_llm/mcp/http.rb +379 -0
- data/lib/ruby_llm/mcp/input_request.rb +118 -0
- data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
- data/lib/ruby_llm/mcp/listener.rb +168 -0
- data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
- data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
- data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
- data/lib/ruby_llm/mcp/oauth.rb +653 -0
- data/lib/ruby_llm/mcp/param_headers.rb +83 -0
- data/lib/ruby_llm/mcp/prompt.rb +71 -0
- data/lib/ruby_llm/mcp/resource.rb +84 -0
- data/lib/ruby_llm/mcp/resource_template.rb +99 -0
- data/lib/ruby_llm/mcp/result.rb +76 -0
- data/lib/ruby_llm/mcp/session_expired.rb +10 -0
- data/lib/ruby_llm/mcp/stdio.rb +250 -0
- data/lib/ruby_llm/mcp/task.rb +151 -0
- data/lib/ruby_llm/mcp/tool.rb +130 -0
- data/lib/ruby_llm/mcp.rb +1101 -0
- data/lib/ruby_llm/message.rb +41 -6
- data/lib/ruby_llm/model.rb +2 -1
- data/lib/ruby_llm/models/aliases.rb +5 -2
- data/lib/ruby_llm/models/lookup.rb +80 -0
- data/lib/ruby_llm/models/registry.rb +2 -2
- data/lib/ruby_llm/models/schema.rb +1 -1
- data/lib/ruby_llm/models.json +23082 -10158
- data/lib/ruby_llm/models.rb +20 -69
- data/lib/ruby_llm/moderation.rb +7 -2
- data/lib/ruby_llm/ocr.rb +7 -2
- data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
- data/lib/ruby_llm/open_telemetry.rb +82 -0
- data/lib/ruby_llm/probability.rb +30 -0
- data/lib/ruby_llm/progress.rb +44 -0
- data/lib/ruby_llm/prompt/context.rb +55 -0
- data/lib/ruby_llm/prompt.rb +1 -1
- data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
- data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
- data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
- data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
- data/lib/ruby_llm/protocol/streaming.rb +38 -31
- data/lib/ruby_llm/protocol.rb +192 -40
- data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
- data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
- data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
- data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
- data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
- data/lib/ruby_llm/protocols/anthropic.rb +20 -1
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
- data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
- data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
- data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
- data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
- data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
- data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
- data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
- data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
- data/lib/ruby_llm/protocols/cohere.rb +1 -0
- data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
- data/lib/ruby_llm/protocols/converse/media.rb +16 -2
- data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
- data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
- data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
- data/lib/ruby_llm/protocols/converse.rb +3 -5
- data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
- data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
- data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
- data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
- data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
- data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
- data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
- data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
- data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
- data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
- data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
- data/lib/ruby_llm/protocols/gemini.rb +1 -0
- data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
- data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
- data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
- data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
- data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
- data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
- data/lib/ruby_llm/protocols/interactions.rb +1 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
- data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
- data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
- data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
- data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
- data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
- data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
- data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
- data/lib/ruby_llm/protocols/openai/files.rb +0 -7
- data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
- data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
- data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
- data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
- data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
- data/lib/ruby_llm/protocols/responses/media.rb +11 -7
- data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
- data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
- data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
- data/lib/ruby_llm/protocols/responses.rb +1 -0
- data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
- data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
- data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
- data/lib/ruby_llm/protocols/system_one.rb +27 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
- data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
- data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
- data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
- data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
- data/lib/ruby_llm/provider.rb +87 -15
- data/lib/ruby_llm/providers/anthropic.rb +23 -0
- data/lib/ruby_llm/providers/azure/chat.rb +4 -0
- data/lib/ruby_llm/providers/azure/images.rb +2 -2
- data/lib/ruby_llm/providers/azure/media.rb +8 -3
- data/lib/ruby_llm/providers/azure/videos.rb +7 -2
- data/lib/ruby_llm/providers/azure.rb +24 -4
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
- data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
- data/lib/ruby_llm/providers/bedrock.rb +38 -6
- data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
- data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
- data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
- data/lib/ruby_llm/providers/hetzner.rb +41 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
- data/lib/ruby_llm/providers/mistral/media.rb +8 -4
- data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
- data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
- data/lib/ruby_llm/providers/ollama/media.rb +6 -2
- data/lib/ruby_llm/providers/ollama/models.rb +2 -0
- data/lib/ruby_llm/providers/ollama.rb +11 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
- data/lib/ruby_llm/providers/openai.rb +14 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
- data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
- data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
- data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
- data/lib/ruby_llm/providers/perplexity.rb +11 -0
- data/lib/ruby_llm/providers/typesafe.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
- data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
- data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/vertexai.rb +27 -28
- data/lib/ruby_llm/providers/xai/chat.rb +6 -0
- data/lib/ruby_llm/providers/xai/images.rb +9 -10
- data/lib/ruby_llm/providers/xai/responses.rb +10 -8
- data/lib/ruby_llm/providers/xai/videos.rb +11 -9
- data/lib/ruby_llm/providers/xai.rb +4 -0
- data/lib/ruby_llm/railtie.rb +11 -0
- data/lib/ruby_llm/request_shape/part.rb +117 -0
- data/lib/ruby_llm/request_shape/problem.rb +63 -0
- data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
- data/lib/ruby_llm/request_shape/turn.rb +54 -0
- data/lib/ruby_llm/request_shape.rb +160 -0
- data/lib/ruby_llm/rerank.rb +9 -2
- data/lib/ruby_llm/research_job.rb +21 -5
- data/lib/ruby_llm/score.rb +42 -0
- data/lib/ruby_llm/search_results.rb +2 -2
- data/lib/ruby_llm/server_tool_call.rb +10 -2
- data/lib/ruby_llm/speech.rb +8 -1
- data/lib/ruby_llm/support/cancellation.rb +39 -0
- data/lib/ruby_llm/support/inspectable.rb +3 -2
- data/lib/ruby_llm/support/instrumentation.rb +38 -1
- data/lib/ruby_llm/support/process_cache.rb +60 -0
- data/lib/ruby_llm/support/progress_reporter.rb +33 -0
- data/lib/ruby_llm/thinking.rb +2 -1
- data/lib/ruby_llm/tokens.rb +57 -14
- data/lib/ruby_llm/tool.rb +50 -3
- data/lib/ruby_llm/tool_call.rb +17 -2
- data/lib/ruby_llm/transcription.rb +10 -3
- data/lib/ruby_llm/transport/connection.rb +127 -44
- data/lib/ruby_llm/transport/error_middleware.rb +90 -24
- data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
- data/lib/ruby_llm/transport/json_response.rb +33 -0
- data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm/video.rb +5 -3
- data/lib/ruby_llm/video_job.rb +48 -5
- data/lib/ruby_llm.rb +106 -5
- data/lib/tasks/ruby_llm/evaluations.rb +43 -0
- data/lib/tasks/ruby_llm.rake +6 -9
- data/skills/rubyllm/SKILL.md +33 -12
- metadata +95 -37
- data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
- data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
- 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
|