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,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# Mirrors tool arguments marked with +x-mcp-header+ into Mcp-Param-*
|
|
6
|
+
# HTTP headers, as 2026-07-28 requires, so intermediaries can route on
|
|
7
|
+
# them. Tools that declare invalid headers are left out entirely.
|
|
8
|
+
module ParamHeaders # :nodoc:
|
|
9
|
+
TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
10
|
+
TYPES = %w[string integer boolean].freeze
|
|
11
|
+
SCHEMA_MAPS = %w[$defs definitions patternProperties dependentSchemas dependencies].freeze
|
|
12
|
+
SCHEMA_VALUES = %w[
|
|
13
|
+
items prefixItems additionalItems contains additionalProperties unevaluatedItems unevaluatedProperties
|
|
14
|
+
propertyNames allOf anyOf oneOf not if then else contentSchema
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def valid?(definition)
|
|
20
|
+
declared = declarations(definition)
|
|
21
|
+
names = declared.map { |_, header, _| header.to_s.downcase }
|
|
22
|
+
valid = declared.all? { |path, header, schema| valid_declaration?(path, header, schema) }
|
|
23
|
+
return true if valid && names.uniq.size == names.size
|
|
24
|
+
|
|
25
|
+
RubyLLM.logger.warn { "Ignoring MCP tool #{definition['name']}: it declares invalid x-mcp-header values" }
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def for(definition, arguments)
|
|
30
|
+
declarations(definition).each_with_object({}) do |(path, header, _), headers|
|
|
31
|
+
value = argument_at(arguments, path)
|
|
32
|
+
headers[header] = value.to_s unless value.nil?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def declarations(definition)
|
|
37
|
+
schema_declarations(definition['inputSchema'], [])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def valid_declaration?(path, header, schema)
|
|
41
|
+
path&.any? && header.is_a?(String) && header.match?(TOKEN) && TYPES.include?(schema['type'])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def schema_declarations(schema, path)
|
|
45
|
+
return [] unless schema.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
declared = schema.key?('x-mcp-header') ? [[path, schema['x-mcp-header'], schema]] : []
|
|
48
|
+
declared + property_declarations(schema['properties'], path) +
|
|
49
|
+
unreachable_schemas(schema).flat_map { |child| schema_declarations(child, nil) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def property_declarations(properties, path)
|
|
53
|
+
return [] unless properties.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
properties.flat_map do |property, child|
|
|
56
|
+
schema_declarations(child, path && [*path, property])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unreachable_schemas(schema)
|
|
61
|
+
schema.flat_map do |keyword, children|
|
|
62
|
+
case keyword
|
|
63
|
+
when *SCHEMA_MAPS
|
|
64
|
+
children.is_a?(Hash) ? children.values : []
|
|
65
|
+
when *SCHEMA_VALUES
|
|
66
|
+
children.is_a?(Array) ? children : [children]
|
|
67
|
+
else
|
|
68
|
+
[]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def argument_at(arguments, path)
|
|
74
|
+
path.reduce(arguments) do |value, property|
|
|
75
|
+
break unless value.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
argument = value[property.to_sym]
|
|
78
|
+
argument.nil? ? value[property] : argument
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# A prompt an MCP server offers: messages written by the server and
|
|
6
|
+
# filled in with your arguments. Ask a chat with it like a question.
|
|
7
|
+
#
|
|
8
|
+
# github.prompts # => [#<RubyLLM::MCP::Prompt name: "code_review", ...>]
|
|
9
|
+
# review = github.prompt(:code_review, code: diff)
|
|
10
|
+
# review.messages # => [#<RubyLLM::Message role: :user, ...>]
|
|
11
|
+
# chat.ask review
|
|
12
|
+
#
|
|
13
|
+
class Prompt
|
|
14
|
+
include Support::Inspectable
|
|
15
|
+
|
|
16
|
+
# An argument a prompt takes.
|
|
17
|
+
Argument = Struct.new(:name, :description, :required, keyword_init: true) do
|
|
18
|
+
# Returns whether the prompt needs this argument.
|
|
19
|
+
def required?
|
|
20
|
+
required == true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The prompt's name.
|
|
25
|
+
attr_reader :name
|
|
26
|
+
|
|
27
|
+
# A human-readable title, or +nil+.
|
|
28
|
+
attr_reader :title
|
|
29
|
+
|
|
30
|
+
# What the prompt does, or +nil+.
|
|
31
|
+
attr_reader :description
|
|
32
|
+
|
|
33
|
+
# The arguments the prompt takes, as Argument objects.
|
|
34
|
+
attr_reader :arguments
|
|
35
|
+
|
|
36
|
+
def initialize(mcp, data, messages: nil) # :nodoc:
|
|
37
|
+
@mcp = mcp
|
|
38
|
+
@name = data['name']
|
|
39
|
+
@title = data['title']
|
|
40
|
+
@description = data['description']
|
|
41
|
+
@arguments = Array(data['arguments']).map do |argument|
|
|
42
|
+
Argument.new(name: argument['name'].to_sym, description: argument['description'],
|
|
43
|
+
required: argument['required'])
|
|
44
|
+
end
|
|
45
|
+
@messages = messages
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the prompt's messages as Message objects. A prompt from
|
|
49
|
+
# MCP#prompts has none until you fill it in with MCP#prompt.
|
|
50
|
+
def messages
|
|
51
|
+
@messages || []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Asks the server to complete an argument's partial value. Pass one
|
|
55
|
+
# argument to complete and any others that are already filled in.
|
|
56
|
+
# Returns an Array of suggested values.
|
|
57
|
+
#
|
|
58
|
+
# github.prompts.first.suggest(language: "ru") # => ["ruby", "rust"]
|
|
59
|
+
#
|
|
60
|
+
def suggest(**arguments)
|
|
61
|
+
@mcp.suggest({ type: 'ref/prompt', name: }, arguments)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def inspect_attributes
|
|
67
|
+
{ name:, arguments: arguments.map(&:name), messages: messages.size.nonzero? }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# A resource on an MCP server: a file, a record, or any data the server
|
|
6
|
+
# names with a URI. Pass one to a chat like any attachment, or save it.
|
|
7
|
+
#
|
|
8
|
+
# readme = files.resource("file:///project/README.md")
|
|
9
|
+
# readme.content # => "# My Project..."
|
|
10
|
+
# readme.save("README.md")
|
|
11
|
+
# chat.ask "Summarize this", with: readme
|
|
12
|
+
#
|
|
13
|
+
# Resources from MCP#resources are read from the server the first time
|
|
14
|
+
# their content is needed.
|
|
15
|
+
class Resource
|
|
16
|
+
include Support::Inspectable
|
|
17
|
+
|
|
18
|
+
# The URI that identifies the resource on its server.
|
|
19
|
+
attr_reader :uri
|
|
20
|
+
|
|
21
|
+
# The resource's name.
|
|
22
|
+
attr_reader :name
|
|
23
|
+
|
|
24
|
+
# A human-readable title, or +nil+.
|
|
25
|
+
attr_reader :title
|
|
26
|
+
|
|
27
|
+
# What the resource is, or +nil+.
|
|
28
|
+
attr_reader :description
|
|
29
|
+
|
|
30
|
+
# The MIME type the server reports, or +nil+.
|
|
31
|
+
attr_reader :mime_type
|
|
32
|
+
|
|
33
|
+
# The +_meta+ the server sent with the resource, as a Hash with String
|
|
34
|
+
# keys, empty when there is none. A resource read with MCP#resource
|
|
35
|
+
# has the +_meta+ of its contents; one from MCP#resources has the
|
|
36
|
+
# +_meta+ of the listing.
|
|
37
|
+
attr_reader :meta
|
|
38
|
+
|
|
39
|
+
def initialize(mcp, data) # :nodoc:
|
|
40
|
+
@mcp = mcp
|
|
41
|
+
@uri = data['uri']
|
|
42
|
+
@name = data['name'] || Content.filename(uri)
|
|
43
|
+
@title = data['title']
|
|
44
|
+
@description = data['description']
|
|
45
|
+
@mime_type = data['mimeType']
|
|
46
|
+
@meta = data['_meta'] || {}
|
|
47
|
+
@data = data
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the resource's content: text for text resources, bytes for
|
|
51
|
+
# binary ones.
|
|
52
|
+
def content
|
|
53
|
+
@content ||= if @data.key?('text') then @data['text']
|
|
54
|
+
elsif @data.key?('blob') then Base64.decode64(@data['blob'])
|
|
55
|
+
else @mcp.resource(uri).content
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the content as a String of bytes.
|
|
60
|
+
def to_blob
|
|
61
|
+
content.b
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Writes the content to +path+, expanding it first. Returns +path+.
|
|
65
|
+
def save(path)
|
|
66
|
+
File.binwrite(File.expand_path(path), to_blob)
|
|
67
|
+
path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the resource as an Attachment, which is how chats take it
|
|
71
|
+
# through +with:+.
|
|
72
|
+
def to_attachment
|
|
73
|
+
filename = Content.filename(uri)
|
|
74
|
+
Attachment.new(StringIO.new(to_blob), filename: filename.empty? ? name : filename)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def inspect_attributes
|
|
80
|
+
{ uri:, name:, mime_type: }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# A family of resources on an MCP server, named by a URI template.
|
|
6
|
+
#
|
|
7
|
+
# template = files.resource_templates.first
|
|
8
|
+
# template.uri # => "file:///{path}"
|
|
9
|
+
# template.suggest(path: "app/mo") # => ["app/models/"]
|
|
10
|
+
# files.resource(template.uri, path: "Gemfile")
|
|
11
|
+
#
|
|
12
|
+
class ResourceTemplate
|
|
13
|
+
include Support::Inspectable
|
|
14
|
+
|
|
15
|
+
OPERATORS = {
|
|
16
|
+
'' => [',', false], '+' => [',', true], '#' => [',', true], '/' => ['/', false],
|
|
17
|
+
'.' => ['.', false], ';' => [';', false], '?' => ['&', false], '&' => ['&', false]
|
|
18
|
+
}.freeze
|
|
19
|
+
NAMED_OPERATORS = %w[? & ;].freeze
|
|
20
|
+
private_constant :OPERATORS, :NAMED_OPERATORS
|
|
21
|
+
|
|
22
|
+
# The URI template, such as <tt>"file:///{path}"</tt>.
|
|
23
|
+
attr_reader :uri
|
|
24
|
+
|
|
25
|
+
# The template's name.
|
|
26
|
+
attr_reader :name
|
|
27
|
+
|
|
28
|
+
# A human-readable title, or +nil+.
|
|
29
|
+
attr_reader :title
|
|
30
|
+
|
|
31
|
+
# What the resources are, or +nil+.
|
|
32
|
+
attr_reader :description
|
|
33
|
+
|
|
34
|
+
# The MIME type of the resources, or +nil+.
|
|
35
|
+
attr_reader :mime_type
|
|
36
|
+
|
|
37
|
+
def self.expand(template, variables) # :nodoc:
|
|
38
|
+
variables = variables.transform_keys(&:to_s)
|
|
39
|
+
template.gsub(%r{\{([+#./;?&]?)([^{}]+)\}}) do
|
|
40
|
+
expand_expression(Regexp.last_match(1), Regexp.last_match(2).split(','), variables)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.expand_expression(operator, specs, variables) # :nodoc:
|
|
45
|
+
separator, reserved = OPERATORS.fetch(operator)
|
|
46
|
+
values = specs.filter_map { |spec| expand_variable(spec, operator, separator, reserved, variables) }
|
|
47
|
+
return '' if values.empty?
|
|
48
|
+
|
|
49
|
+
prefix = ['', '+'].include?(operator) ? '' : operator
|
|
50
|
+
"#{prefix}#{values.join(separator)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Expands one variable with its prefix (+:3+) and explode (+*+)
|
|
54
|
+
# modifiers. Array values join with the operator's separator when
|
|
55
|
+
# exploded and with commas otherwise.
|
|
56
|
+
def self.expand_variable(spec, operator, separator, reserved, variables) # :nodoc:
|
|
57
|
+
explode = spec.end_with?('*')
|
|
58
|
+
name, length = spec.delete_suffix('*').split(':')
|
|
59
|
+
return unless variables.key?(name)
|
|
60
|
+
|
|
61
|
+
parts = Array(variables[name]).map { |part| encode(length ? part.to_s[0, length.to_i] : part.to_s, reserved) }
|
|
62
|
+
named = NAMED_OPERATORS.include?(operator)
|
|
63
|
+
return parts.map { |part| named ? "#{name}=#{part}" : part }.join(separator) if explode
|
|
64
|
+
|
|
65
|
+
named ? "#{name}=#{parts.join(',')}" : parts.join(',')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.encode(value, reserved) # :nodoc:
|
|
69
|
+
pattern = reserved ? %r{[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]} : /[^A-Za-z0-9\-._~]/
|
|
70
|
+
value.gsub(pattern) { |char| char.bytes.map { |byte| format('%%%02X', byte) }.join }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(mcp, data) # :nodoc:
|
|
74
|
+
@mcp = mcp
|
|
75
|
+
@uri = data['uriTemplate']
|
|
76
|
+
@name = data['name']
|
|
77
|
+
@title = data['title']
|
|
78
|
+
@description = data['description']
|
|
79
|
+
@mime_type = data['mimeType']
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Asks the server to complete a variable's partial value. Pass one
|
|
83
|
+
# variable to complete and any others that are already filled in.
|
|
84
|
+
# Returns an Array of suggested values.
|
|
85
|
+
#
|
|
86
|
+
# template.suggest(path: "app/mo") # => ["app/models/"]
|
|
87
|
+
#
|
|
88
|
+
def suggest(**variables)
|
|
89
|
+
@mcp.suggest({ type: 'ref/resource', uri: }, variables)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def inspect_attributes
|
|
95
|
+
{ uri:, name: }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# The result of calling an MCP tool. Text, files, and structured data
|
|
6
|
+
# each have their own reader.
|
|
7
|
+
#
|
|
8
|
+
# result = linear.list_issues(query: "bug")
|
|
9
|
+
# result.text # => "Found 3 issues..."
|
|
10
|
+
# result.structured # => { "issues" => [...] }
|
|
11
|
+
# result.attachments # => [#<RubyLLM::Attachment ...>]
|
|
12
|
+
# result.meta # => { "com.linear/request_id" => "..." }
|
|
13
|
+
#
|
|
14
|
+
# In a chat, the result of a tool with a UI stays on its tool result
|
|
15
|
+
# message as Message#mcp_result, so your app can render the UI again.
|
|
16
|
+
class Result
|
|
17
|
+
include Support::Inspectable
|
|
18
|
+
|
|
19
|
+
# The text of the result, with text blocks joined by blank lines.
|
|
20
|
+
attr_reader :text
|
|
21
|
+
|
|
22
|
+
# Images, audio, and embedded files, as Attachment objects.
|
|
23
|
+
attr_reader :attachments
|
|
24
|
+
|
|
25
|
+
# The structured content, parsed from JSON, or +nil+.
|
|
26
|
+
attr_reader :structured
|
|
27
|
+
|
|
28
|
+
# The result's +_meta+ as the server sent it: a Hash with String
|
|
29
|
+
# keys, empty when there is none.
|
|
30
|
+
attr_reader :meta
|
|
31
|
+
|
|
32
|
+
# The URI of the UI that renders the result, from its tool's
|
|
33
|
+
# MCP::Tool#ui_uri, or +nil+ for a tool without one.
|
|
34
|
+
attr_reader :ui_uri
|
|
35
|
+
|
|
36
|
+
def self.load(data) # :nodoc:
|
|
37
|
+
new(data['result'], data['ui_uri'])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(data, ui_uri = nil) # :nodoc:
|
|
41
|
+
@data = data
|
|
42
|
+
@ui_uri = ui_uri
|
|
43
|
+
@structured = data['structuredContent']
|
|
44
|
+
@meta = data['_meta'] || {}
|
|
45
|
+
@text, @attachments = Content.read(data['content'])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns whether the tool reported a failure.
|
|
49
|
+
def error?
|
|
50
|
+
@data['isError'] == true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns what a chat sends to the model: the text, or the structured
|
|
54
|
+
# content as JSON when there is no text, followed by any attachments.
|
|
55
|
+
def content
|
|
56
|
+
body = text.empty? && structured ? JSON.generate(structured) : text
|
|
57
|
+
attachments.empty? ? body : [body, *attachments]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the result as the server sent it.
|
|
61
|
+
def to_h
|
|
62
|
+
@data
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def dump # :nodoc:
|
|
66
|
+
{ 'ui_uri' => ui_uri, 'result' => to_h }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def inspect_attributes
|
|
72
|
+
{ text:, structured:, attachments: attachments.size.nonzero?, error: error? || nil, ui_uri: }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class MCP
|
|
5
|
+
# Raised by a transport when the session of a server that predates
|
|
6
|
+
# 2026-07-28 has ended, so the client starts a new one: the server
|
|
7
|
+
# answered 404, or its stdio process exited.
|
|
8
|
+
class SessionExpired < Error; end # :nodoc:
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/wait'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
class MCP
|
|
8
|
+
# stdio. The server is a child process that reads one JSON-RPC message
|
|
9
|
+
# per line on stdin and writes one per line on stdout. Reads never block
|
|
10
|
+
# past the deadline, even on a partial line. It starts on the
|
|
11
|
+
# first request, restarts after it exits, and handles one request at a
|
|
12
|
+
# time. Its stderr is the parent's. A server that predates 2026-07-28
|
|
13
|
+
# keeps its session for the life of its process, so after a restart its
|
|
14
|
+
# requests raise SessionExpired until the client initializes it again.
|
|
15
|
+
#
|
|
16
|
+
# Subscriptions share the channel, so whichever thread reads a message
|
|
17
|
+
# that belongs to one hands it to the subscription's listener: a
|
|
18
|
+
# request waiting for its answer, or the listener itself while no
|
|
19
|
+
# request reads. A listener stops when CancelledError is raised in its
|
|
20
|
+
# thread, so it reads and writes with that deferred.
|
|
21
|
+
class Stdio # :nodoc:
|
|
22
|
+
SHUTDOWN_GRACE = 2
|
|
23
|
+
CHECK_INTERVAL = 0.5
|
|
24
|
+
SUBSCRIPTION_ID = 'io.modelcontextprotocol/subscriptionId'
|
|
25
|
+
|
|
26
|
+
# The messages of the subscription opened by request +id+, or with no
|
|
27
|
+
# +id+, the changes a server that predates subscriptions announces.
|
|
28
|
+
Subscription = Struct.new(:id, :messages) do
|
|
29
|
+
def claims?(message)
|
|
30
|
+
return !message.key?('id') && Client::CHANGES.include?(message['method']) if id.nil?
|
|
31
|
+
|
|
32
|
+
params = message['params'].is_a?(Hash) ? message['params'] : {}
|
|
33
|
+
params.dig('_meta', SUBSCRIPTION_ID) == id || answer?(message) ||
|
|
34
|
+
(message['method'] == 'notifications/cancelled' && params['requestId'] == id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def answer?(message)
|
|
38
|
+
!id.nil? && message['id'] == id && !message.key?('method')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(command, env: {}, directory: nil, timeout: nil, config: RubyLLM.config)
|
|
43
|
+
@command = Array(command).map(&:to_s)
|
|
44
|
+
@env = env.to_h { |key, value| [key.to_s, value.to_s] }
|
|
45
|
+
@directory = directory&.to_s
|
|
46
|
+
@timeout = timeout || config.request_timeout
|
|
47
|
+
@lock = Mutex.new
|
|
48
|
+
@subscriptions = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def request(message, version: nil, timeout: nil, **, &)
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
check_session(version)
|
|
54
|
+
write(message)
|
|
55
|
+
@session = @process if message[:method] == 'initialize'
|
|
56
|
+
await(message[:id], timeout || @timeout, &)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def notify(message, **)
|
|
61
|
+
@lock.synchronize { write(message) }
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cancel(notification, **)
|
|
66
|
+
notify(notification)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def listen(message, **)
|
|
70
|
+
subscription = Subscription.new(message&.dig(:id), Queue.new)
|
|
71
|
+
process = uninterrupted { @lock.synchronize { subscribe(subscription, message) } }
|
|
72
|
+
loop do
|
|
73
|
+
until subscription.messages.empty?
|
|
74
|
+
reply = subscription.messages.pop
|
|
75
|
+
return reply if subscription.answer?(reply)
|
|
76
|
+
|
|
77
|
+
yield reply
|
|
78
|
+
end
|
|
79
|
+
raise Error, "#{name} exited" unless @process.equal?(process)
|
|
80
|
+
|
|
81
|
+
receive(subscription)
|
|
82
|
+
end
|
|
83
|
+
rescue IOError
|
|
84
|
+
raise Error, "#{name} exited"
|
|
85
|
+
ensure
|
|
86
|
+
uninterrupted { @lock.synchronize { @subscriptions.delete(subscription) } }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def close
|
|
90
|
+
@lock.synchronize { stop }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def check_session(version)
|
|
96
|
+
return if version.nil? || version == Client::VERSION || (@process&.alive? && @process.equal?(@session))
|
|
97
|
+
|
|
98
|
+
raise SessionExpired, "#{name} exited"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def subscribe(subscription, message)
|
|
102
|
+
raise Error, "#{name} exited" unless message || @process&.alive?
|
|
103
|
+
|
|
104
|
+
@subscriptions << subscription
|
|
105
|
+
write(message) if message
|
|
106
|
+
@process
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def receive(subscription)
|
|
110
|
+
read = uninterrupted do
|
|
111
|
+
next false unless @lock.try_lock
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
drain
|
|
115
|
+
ensure
|
|
116
|
+
@lock.unlock
|
|
117
|
+
end
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
return sleep(CHECK_INTERVAL) unless read
|
|
121
|
+
|
|
122
|
+
@stdout.wait_readable(CHECK_INTERVAL) if subscription.messages.empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def drain
|
|
126
|
+
loop do
|
|
127
|
+
while (line = @buffer.slice!(/\A[^\n]*\n/))
|
|
128
|
+
message = parse(line)
|
|
129
|
+
handled?(message) if message
|
|
130
|
+
end
|
|
131
|
+
chunk = @stdout.read_nonblock(65_536, exception: false)
|
|
132
|
+
return if chunk == :wait_readable
|
|
133
|
+
|
|
134
|
+
exited unless chunk
|
|
135
|
+
@buffer << chunk
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def handled?(message)
|
|
140
|
+
return true if route(message)
|
|
141
|
+
return false unless message.key?('method') && message.key?('id')
|
|
142
|
+
|
|
143
|
+
answer(message)
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def route(message)
|
|
148
|
+
@subscriptions.find { |subscription| subscription.claims?(message) }&.messages&.push(message)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def uninterrupted(&)
|
|
152
|
+
Thread.handle_interrupt(CancelledError => :never, &)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def await(id, timeout)
|
|
156
|
+
deadline = monotonic_now + timeout
|
|
157
|
+
loop do
|
|
158
|
+
reply = read(deadline)
|
|
159
|
+
return reply if reply['id'] == id && !reply.key?('method')
|
|
160
|
+
|
|
161
|
+
yield reply if !handled?(reply) && reply.key?('method') && block_given?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def write(message)
|
|
166
|
+
start unless @process&.alive?
|
|
167
|
+
@stdin.puts JSON.generate(message)
|
|
168
|
+
@stdin.flush
|
|
169
|
+
rescue Errno::EPIPE, IOError
|
|
170
|
+
stop
|
|
171
|
+
raise Error, "#{name} exited"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def read(deadline)
|
|
175
|
+
loop do
|
|
176
|
+
message = parse(next_line(deadline))
|
|
177
|
+
return message if message
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse(line)
|
|
182
|
+
return if line.strip.empty?
|
|
183
|
+
|
|
184
|
+
message = JSON.parse(line)
|
|
185
|
+
message if message.is_a?(Hash)
|
|
186
|
+
rescue JSON::ParserError
|
|
187
|
+
RubyLLM.logger.debug { "#{name} wrote a line that is not JSON" }
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def next_line(deadline)
|
|
192
|
+
loop do
|
|
193
|
+
line = @buffer.slice!(/\A[^\n]*\n/)
|
|
194
|
+
return line if line
|
|
195
|
+
|
|
196
|
+
remaining = deadline - monotonic_now
|
|
197
|
+
raise Error, "#{name} did not answer in time" unless remaining.positive?
|
|
198
|
+
|
|
199
|
+
Support::Cancellation.check
|
|
200
|
+
next unless @stdout.wait_readable([remaining, CHECK_INTERVAL].min)
|
|
201
|
+
|
|
202
|
+
chunk = @stdout.read_nonblock(65_536, exception: false)
|
|
203
|
+
next if chunk == :wait_readable
|
|
204
|
+
|
|
205
|
+
exited unless chunk
|
|
206
|
+
@buffer << chunk
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def exited
|
|
211
|
+
stop
|
|
212
|
+
raise Error, "#{name} exited"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def answer(request)
|
|
216
|
+
write(Client.reply(request))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def start
|
|
220
|
+
options = @directory ? { chdir: @directory } : {}
|
|
221
|
+
@stdin, @stdout, @process = Open3.popen2(@env, *@command, **options)
|
|
222
|
+
@buffer = +''
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def stop
|
|
226
|
+
return unless @process
|
|
227
|
+
|
|
228
|
+
@stdin.close unless @stdin.closed?
|
|
229
|
+
@stdout.close unless @stdout.closed?
|
|
230
|
+
terminate unless @process.join(SHUTDOWN_GRACE)
|
|
231
|
+
@process = nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def terminate
|
|
235
|
+
Process.kill('TERM', @process.pid)
|
|
236
|
+
Process.kill('KILL', @process.pid) unless @process.join(SHUTDOWN_GRACE)
|
|
237
|
+
rescue Errno::ESRCH
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def name
|
|
242
|
+
File.basename(@command.first.to_s)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def monotonic_now
|
|
246
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|