llm.rb 4.13.0 → 4.15.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.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::MCP
4
+ ##
5
+ # Coordinates shared access to a transport by routing JSON-RPC
6
+ # responses to the mailbox waiting on the matching request id.
7
+ class Router
8
+ def initialize
9
+ @request_id = -1
10
+ @pending = {}
11
+ @lock = Monitor.new
12
+ @writer = Monitor.new
13
+ @reader = Monitor.new
14
+ end
15
+
16
+ def register
17
+ @lock.synchronize do
18
+ @request_id += 1
19
+ mailbox = LLM::MCP::Mailbox.new
20
+ @pending[@request_id] = mailbox
21
+ [@request_id, mailbox]
22
+ end
23
+ end
24
+
25
+ def clear(id)
26
+ @lock.synchronize { @pending.delete(id) }
27
+ end
28
+
29
+ def read(transport)
30
+ @reader.synchronize { transport.read_nonblock }
31
+ end
32
+
33
+ def write(transport, message)
34
+ @writer.synchronize { transport.write(message) }
35
+ end
36
+
37
+ def route(response)
38
+ mailbox = @lock.synchronize { @pending[response["id"]] }
39
+ raise LLM::MCP::MismatchError.new(expected_id: nil, actual_id: response["id"]) unless mailbox
40
+ mailbox << response
41
+ nil
42
+ end
43
+ end
44
+ end
data/lib/llm/mcp/rpc.rb CHANGED
@@ -27,13 +27,15 @@ class LLM::MCP
27
27
  def call(transport, method, params = {})
28
28
  message = {jsonrpc: "2.0", method:, params: default_params(method).merge(params)}
29
29
  if notification?(method)
30
- transport.write(message)
31
- nil
32
- else
33
- @request_id = (@request_id || -1) + 1
34
- id = @request_id
35
- transport.write(message.merge(id:))
36
- recv(transport, id)
30
+ router.write(transport, message)
31
+ return nil
32
+ end
33
+ id, mailbox = router.register
34
+ begin
35
+ router.write(transport, message.merge(id:))
36
+ recv(transport, id, mailbox)
37
+ ensure
38
+ router.clear(id)
37
39
  end
38
40
  end
39
41
 
@@ -49,19 +51,12 @@ class LLM::MCP
49
51
  # When the MCP process returns an error
50
52
  # @return [Object, nil]
51
53
  # The result returned by the MCP process
52
- def recv(transport, id)
54
+ def recv(transport, id, mailbox)
53
55
  poll(timeout:, ex: [IO::WaitReadable]) do
54
56
  loop do
55
- res = transport.read_nonblock
56
- if res["id"] == id && res["error"]
57
- raise LLM::MCP::Error.from(response: res)
58
- elsif res["id"] == id
59
- break res["result"]
60
- elsif res["method"]
61
- next
62
- elsif res.key?("id")
63
- raise LLM::MCP::MismatchError.new(expected_id: id, actual_id: res["id"])
64
- end
57
+ res = mailbox.pop
58
+ return handle_response(id, res) if res
59
+ route_response(router.read(transport), id)
65
60
  end
66
61
  end
67
62
  end
@@ -119,5 +114,21 @@ class LLM::MCP
119
114
  sleep 0.05
120
115
  end
121
116
  end
117
+
118
+ def handle_response(id, res)
119
+ raise LLM::MCP::Error.from(response: res) if res["error"]
120
+ return res["result"] if res["id"] == id
121
+ raise LLM::MCP::MismatchError.new(expected_id: id, actual_id: res["id"])
122
+ end
123
+
124
+ def route_response(res, id)
125
+ return nil if res["method"]
126
+ return router.route(res) if res.key?("id")
127
+ raise LLM::MCP::MismatchError.new(expected_id: id, actual_id: nil)
128
+ end
129
+
130
+ def router
131
+ @router ||= LLM::MCP::Router.new
132
+ end
122
133
  end
123
134
  end
@@ -21,29 +21,31 @@ module LLM::MCP::Transport
21
21
 
22
22
  ##
23
23
  # Receives the SSE event name.
24
- # @param [LLM::EventStream::Event] event
24
+ # @param [LLM::EventStream::Event, String, nil] event
25
+ # @param [String, nil] chunk
25
26
  # The event stream event
26
27
  # @return [void]
27
- def on_event(event)
28
- @event = event.value
28
+ def on_event(event, chunk = nil)
29
+ @event = chunk ? event : event.value
29
30
  end
30
31
 
31
32
  ##
32
33
  # Receives one line of SSE data.
33
- # @param [LLM::EventStream::Event] event
34
+ # @param [LLM::EventStream::Event, String, nil] event
35
+ # @param [String, nil] chunk
34
36
  # The event stream event
35
37
  # @return [void]
36
- def on_data(event)
37
- @data << event.value.to_s
38
+ def on_data(event, chunk = nil)
39
+ @data << (chunk ? event : event.value).to_s
38
40
  end
39
41
 
40
42
  # The generic event stream parser dispatches one line at a time.
41
43
  # A blank line terminates the current SSE event.
42
- # @param [LLM::EventStream::Event] event
44
+ # @param [LLM::EventStream::Event, String] event
43
45
  # The event stream event
44
46
  # @return [void]
45
- def on_chunk(event)
46
- flush if event.chunk == "\n"
47
+ def on_chunk(event, chunk = nil)
48
+ flush if (chunk || event&.chunk || event) == "\n"
47
49
  end
48
50
 
49
51
  private
@@ -82,13 +82,13 @@ module LLM::MCP::Transport
82
82
  # Reads the next queued message without blocking.
83
83
  # @raise [LLM::MCP::Error]
84
84
  # When the transport is not running
85
- # @raise [IO::WaitReadable]
85
+ # @raise [IO::EAGAINWaitReadable]
86
86
  # When no complete message is available to read
87
87
  # @return [Hash]
88
88
  def read_nonblock
89
89
  lock do
90
90
  raise LLM::MCP::Error, "MCP transport is not running" unless running?
91
- raise IO::WaitReadable if @queue.empty?
91
+ raise IO::EAGAINWaitReadable, "no complete message available" if @queue.empty?
92
92
  @queue.shift
93
93
  end
94
94
  end
@@ -57,7 +57,7 @@ module LLM::MCP::Transport
57
57
  # Reads a message from the MCP process without blocking.
58
58
  # @raise [LLM::Error]
59
59
  # When the transport is not running
60
- # @raise [IO::WaitReadable]
60
+ # @raise [IO::EAGAINWaitReadable]
61
61
  # When no complete message is available to read
62
62
  # @return [Hash]
63
63
  # The next message from the MCP process
data/lib/llm/mcp.rb CHANGED
@@ -10,11 +10,14 @@
10
10
  # transports and focuses on discovering tools that can be used through
11
11
  # {LLM::Context LLM::Context} and {LLM::Agent LLM::Agent}.
12
12
  #
13
- # Like {LLM::Context LLM::Context}, an MCP client is stateful and is
14
- # expected to remain isolated to a single thread.
13
+ # An MCP client is stateful. Coordinate lifecycle operations such as
14
+ # {#start} and {#stop}; request methods can be issued concurrently and
15
+ # responses are matched by JSON-RPC id.
15
16
  class LLM::MCP
16
17
  require_relative "mcp/error"
17
18
  require_relative "mcp/command"
19
+ require_relative "mcp/mailbox"
20
+ require_relative "mcp/router"
18
21
  require_relative "mcp/rpc"
19
22
  require_relative "mcp/pipe"
20
23
  require_relative "mcp/transport/http"
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Provider::Transport
4
+ class HTTP
5
+ ##
6
+ # Internal HTTP request execution methods for {LLM::Provider}.
7
+ #
8
+ # This module handles provider-side HTTP execution, response parsing,
9
+ # streaming, and request body setup through
10
+ # {LLM::Provider::Transport::HTTP}.
11
+ #
12
+ # @api private
13
+ module HTTP::Execution
14
+ private
15
+
16
+ ##
17
+ # Executes a HTTP request
18
+ # @param [Net::HTTPRequest] request
19
+ # The request to send
20
+ # @param [Proc] b
21
+ # A block to yield the response to (optional)
22
+ # @return [Net::HTTPResponse]
23
+ # The response from the server
24
+ # @raise [LLM::Error::Unauthorized]
25
+ # When authentication fails
26
+ # @raise [LLM::Error::RateLimit]
27
+ # When the rate limit is exceeded
28
+ # @raise [LLM::Error]
29
+ # When any other unsuccessful status code is returned
30
+ # @raise [SystemCallError]
31
+ # When there is a network error at the operating system level
32
+ # @return [Net::HTTPResponse]
33
+ def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, inputs: nil, &b)
34
+ owner = transport.request_owner
35
+ tracer = self.tracer
36
+ span = tracer.on_request_start(operation:, model:, inputs:)
37
+ res = transport.request(request, owner:) do |http|
38
+ perform_request(http, request, stream, stream_parser, &b)
39
+ end
40
+ [handle_response(res, tracer, span), span, tracer]
41
+ rescue *LLM::Provider::Transport::HTTP::Interruptible::INTERRUPT_ERRORS
42
+ raise LLM::Interrupt, "request interrupted" if transport.interrupted?(owner)
43
+ raise
44
+ end
45
+
46
+ ##
47
+ # Handles the response from a request
48
+ # @param [Net::HTTPResponse] res
49
+ # The response to handle
50
+ # @param [Object, nil] span
51
+ # The span
52
+ # @return [Net::HTTPResponse]
53
+ def handle_response(res, tracer, span)
54
+ case res
55
+ when Net::HTTPOK then res.body = parse_response(res)
56
+ else error_handler.new(tracer, span, res).raise_error!
57
+ end
58
+ res
59
+ end
60
+
61
+ ##
62
+ # Parse a HTTP response
63
+ # @param [Net::HTTPResponse] res
64
+ # @return [LLM::Object, String]
65
+ def parse_response(res)
66
+ case res["content-type"]
67
+ when %r{\Aapplication/json\s*} then LLM::Object.from(LLM.json.load(res.body))
68
+ else res.body
69
+ end
70
+ end
71
+
72
+ ##
73
+ # @param [Net::HTTPRequest] req
74
+ # The request to set the body stream for
75
+ # @param [IO] io
76
+ # The IO object to set as the body stream
77
+ # @return [void]
78
+ def set_body_stream(req, io)
79
+ req.body_stream = io
80
+ req["transfer-encoding"] = "chunked" unless req["content-length"]
81
+ end
82
+
83
+ ##
84
+ # Performs the request on the given HTTP connection.
85
+ # @param [Net::HTTP] http
86
+ # @param [Net::HTTPRequest] request
87
+ # @param [Object, nil] stream
88
+ # @param [Class] stream_parser
89
+ # @param [Proc, nil] b
90
+ # @return [Net::HTTPResponse]
91
+ def perform_request(http, request, stream, stream_parser, &b)
92
+ if stream
93
+ http.request(request) do |res|
94
+ if Net::HTTPSuccess === res
95
+ parser = StreamDecoder.new(stream_parser.new(stream))
96
+ res.read_body(parser)
97
+ body = parser.body
98
+ res.body = (Hash === body || Array === body) ? LLM::Object.from(body) : body
99
+ else
100
+ body = +""
101
+ res.read_body { body << _1 }
102
+ res.body = body
103
+ end
104
+ ensure
105
+ parser&.free
106
+ end
107
+ elsif b
108
+ http.request(request) { (Net::HTTPSuccess === _1) ? b.call(_1) : _1 }
109
+ else
110
+ http.request(request)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Provider
4
+ module Transport
5
+ class HTTP
6
+ ##
7
+ # Internal request interruption methods for
8
+ # {LLM::Provider::Transport::HTTP}.
9
+ #
10
+ # This module tracks active requests by execution owner and provides
11
+ # the logic used to interrupt an in-flight request by closing the
12
+ # active HTTP connection.
13
+ #
14
+ # @api private
15
+ module Interruptible
16
+ INTERRUPT_ERRORS = [::IOError, ::EOFError, Errno::EBADF].freeze
17
+ Request = Struct.new(:http, :connection, keyword_init: true)
18
+
19
+ ##
20
+ # Interrupt an active request, if any.
21
+ # @param [Fiber] owner
22
+ # The execution owner whose request should be interrupted
23
+ # @return [nil]
24
+ def interrupt!(owner)
25
+ req = request_for(owner) or return
26
+ lock { (@interrupts ||= {})[owner] = true }
27
+ if persistent_http?(req.http)
28
+ close_socket(req.connection&.http)
29
+ req.http.finish(req.connection)
30
+ elsif transient_http?(req.http)
31
+ close_socket(req.http)
32
+ req.http.finish if req.http.active?
33
+ end
34
+ rescue *INTERRUPT_ERRORS
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ ##
41
+ # Closes the active socket for a request, if present.
42
+ # @param [Net::HTTP, nil] http
43
+ # @return [nil]
44
+ def close_socket(http)
45
+ socket = http&.instance_variable_get(:@socket) or return
46
+ socket = socket.io if socket.respond_to?(:io)
47
+ socket.close
48
+ rescue *INTERRUPT_ERRORS
49
+ nil
50
+ end
51
+
52
+ ##
53
+ # Returns whether the active request is using a transient HTTP client.
54
+ # @param [Object, nil] http
55
+ # @return [Boolean]
56
+ def transient_http?(http)
57
+ Net::HTTP === http
58
+ end
59
+
60
+ ##
61
+ # Returns whether the active request is using a persistent HTTP client.
62
+ # @param [Object, nil] http
63
+ # @return [Boolean]
64
+ def persistent_http?(http)
65
+ defined?(Net::HTTP::Persistent) && Net::HTTP::Persistent === http
66
+ end
67
+
68
+ ##
69
+ # Returns the active request for an execution owner.
70
+ # @param [Fiber] owner
71
+ # @return [Request, nil]
72
+ def request_for(owner)
73
+ lock do
74
+ @requests ||= {}
75
+ @requests[owner]
76
+ end
77
+ end
78
+
79
+ ##
80
+ # Records an active request for an execution owner.
81
+ # @param [Request] req
82
+ # @param [Fiber] owner
83
+ # @return [Request]
84
+ def set_request(req, owner)
85
+ lock do
86
+ @requests ||= {}
87
+ @requests[owner] = req
88
+ end
89
+ end
90
+
91
+ ##
92
+ # Clears the active request for an execution owner.
93
+ # @param [Fiber] owner
94
+ # @return [Request, nil]
95
+ def clear_request(owner)
96
+ lock { @requests&.delete(owner) }
97
+ end
98
+
99
+ ##
100
+ # Returns whether an execution owner was interrupted.
101
+ # @param [Fiber] owner
102
+ # @return [Boolean, nil]
103
+ def interrupted?(owner)
104
+ lock { @interrupts&.delete(owner) }
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Provider::Transport
4
+ ##
5
+ # @private
6
+ class HTTP::StreamDecoder
7
+ ##
8
+ # @return [Object]
9
+ attr_reader :parser
10
+
11
+ ##
12
+ # @param [#parse!, #body] parser
13
+ # @return [LLM::Provider::Transport::HTTP::StreamDecoder]
14
+ def initialize(parser)
15
+ @buffer = +""
16
+ @cursor = 0
17
+ @data = []
18
+ @parser = parser
19
+ end
20
+
21
+ ##
22
+ # @param [String] chunk
23
+ # @return [void]
24
+ def <<(chunk)
25
+ @buffer << chunk
26
+ each_line { handle_line(_1) }
27
+ end
28
+
29
+ ##
30
+ # @return [Object]
31
+ def body
32
+ parser.body
33
+ end
34
+
35
+ ##
36
+ # @return [void]
37
+ def free
38
+ @buffer.clear
39
+ @cursor = 0
40
+ @data.clear
41
+ parser.free if parser.respond_to?(:free)
42
+ end
43
+
44
+ private
45
+
46
+ def handle_line(line)
47
+ if line == "\n" || line == "\r\n"
48
+ flush_sse_event
49
+ elsif line.start_with?("data:")
50
+ @data << field_value(line)
51
+ elsif line.start_with?("event:", "id:", "retry:", ":")
52
+ else
53
+ decode!(strip_newline(line))
54
+ end
55
+ end
56
+
57
+ def flush_sse_event
58
+ return if @data.empty?
59
+ decode!(@data.join("\n"))
60
+ @data.clear
61
+ end
62
+
63
+ def field_value(line)
64
+ value_start = line.getbyte(5) == 32 ? 6 : 5
65
+ strip_newline(line.byteslice(value_start..))
66
+ end
67
+
68
+ def strip_newline(line)
69
+ line = line.byteslice(0, line.bytesize - 1) if line.end_with?("\n")
70
+ line = line.byteslice(0, line.bytesize - 1) if line.end_with?("\r")
71
+ line
72
+ end
73
+
74
+ def decode!(payload)
75
+ return if payload.empty? || payload == "[DONE]"
76
+ chunk = LLM.json.load(payload)
77
+ parser.parse!(chunk) if chunk
78
+ rescue *LLM.json.parser_error
79
+ end
80
+
81
+ def each_line
82
+ while (newline = @buffer.index("\n", @cursor))
83
+ line = @buffer[@cursor..newline]
84
+ @cursor = newline + 1
85
+ yield(line)
86
+ end
87
+ return if @cursor.zero?
88
+ @buffer = @buffer[@cursor..] || +""
89
+ @cursor = 0
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Provider
4
+ module Transport
5
+ ##
6
+ # The {LLM::Provider::Transport::HTTP LLM::Provider::Transport::HTTP}
7
+ # class manages HTTP connections for {LLM::Provider}. It handles
8
+ # transient and persistent clients, tracks active requests by owner,
9
+ # and interrupts in-flight requests when needed.
10
+ #
11
+ # @api private
12
+ class HTTP
13
+ require_relative "http/stream_decoder"
14
+ require_relative "http/interruptible"
15
+
16
+ include Interruptible
17
+
18
+ ##
19
+ # @param [String] host
20
+ # @param [Integer] port
21
+ # @param [Integer] timeout
22
+ # @param [Boolean] ssl
23
+ # @param [Boolean] persistent
24
+ # @return [LLM::Provider::Transport::HTTP]
25
+ def initialize(host:, port:, timeout:, ssl:, persistent: false)
26
+ @host = host
27
+ @port = port
28
+ @timeout = timeout
29
+ @ssl = ssl
30
+ @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
31
+ @persistent_client = persistent ? persistent_client : nil
32
+ @monitor = Monitor.new
33
+ end
34
+
35
+ ##
36
+ # Interrupt an active request, if any.
37
+ # @param [Fiber] owner
38
+ # @return [nil]
39
+ def interrupt!(owner)
40
+ super
41
+ end
42
+
43
+ ##
44
+ # Returns whether an execution owner was interrupted.
45
+ # @param [Fiber] owner
46
+ # @return [Boolean, nil]
47
+ def interrupted?(owner)
48
+ super
49
+ end
50
+
51
+ ##
52
+ # Returns the current request owner.
53
+ # @return [Fiber]
54
+ def request_owner
55
+ Fiber.current
56
+ end
57
+
58
+ ##
59
+ # Configures the transport to use a persistent HTTP connection pool.
60
+ # @return [LLM::Provider::Transport::HTTP]
61
+ def persist!
62
+ client = persistent_client
63
+ lock do
64
+ @persistent_client = client
65
+ self
66
+ end
67
+ end
68
+ alias_method :persistent, :persist!
69
+
70
+ ##
71
+ # @return [Boolean]
72
+ def persistent?
73
+ !persistent_client.nil?
74
+ end
75
+
76
+ ##
77
+ # Performs a request on the current HTTP transport.
78
+ # @param [Net::HTTPRequest] request
79
+ # @param [Fiber] owner
80
+ # @yieldparam [Net::HTTP] http
81
+ # @return [Object]
82
+ def request(request, owner:, &)
83
+ if persistent?
84
+ request_persistent(request, owner, &)
85
+ else
86
+ request_transient(request, owner, &)
87
+ end
88
+ ensure
89
+ clear_request(owner)
90
+ end
91
+
92
+ ##
93
+ # @return [String]
94
+ def inspect
95
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @persistent=#{persistent?}>"
96
+ end
97
+
98
+ private
99
+
100
+ attr_reader :host, :port, :timeout, :ssl, :base_uri
101
+
102
+ def request_transient(request, owner, &)
103
+ http = transient_client
104
+ set_request(Request.new(http:), owner)
105
+ yield http
106
+ end
107
+
108
+ def request_persistent(request, owner, &)
109
+ persistent_client.connection_for(URI.join(base_uri, request.path)) do |connection|
110
+ set_request(Request.new(http: persistent_client, connection:), owner)
111
+ yield connection.http
112
+ end
113
+ end
114
+
115
+ def persistent_client
116
+ LLM.lock(:clients) do
117
+ if LLM.clients[client_id]
118
+ LLM.clients[client_id]
119
+ else
120
+ require "net/http/persistent" unless defined?(Net::HTTP::Persistent)
121
+ client = Net::HTTP::Persistent.new(name: self.class.name)
122
+ client.read_timeout = timeout
123
+ LLM.clients[client_id] = client
124
+ end
125
+ end
126
+ end
127
+
128
+ def transient_client
129
+ client = Net::HTTP.new(host, port)
130
+ client.read_timeout = timeout
131
+ client.use_ssl = ssl
132
+ client
133
+ end
134
+
135
+ def client_id
136
+ "#{host}:#{port}:#{timeout}:#{ssl}"
137
+ end
138
+
139
+ def lock(&)
140
+ @monitor.synchronize(&)
141
+ end
142
+ end
143
+ end
144
+ end