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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -0
- data/README.md +82 -32
- data/lib/llm/context.rb +25 -10
- data/lib/llm/error.rb +4 -0
- data/lib/llm/eventhandler.rb +16 -12
- data/lib/llm/eventstream/event.rb +15 -5
- data/lib/llm/eventstream/parser.rb +64 -17
- data/lib/llm/mcp/command.rb +1 -1
- data/lib/llm/mcp/mailbox.rb +23 -0
- data/lib/llm/mcp/pipe.rb +1 -1
- data/lib/llm/mcp/router.rb +44 -0
- data/lib/llm/mcp/rpc.rb +29 -18
- data/lib/llm/mcp/transport/http/event_handler.rb +11 -9
- data/lib/llm/mcp/transport/http.rb +2 -2
- data/lib/llm/mcp/transport/stdio.rb +1 -1
- data/lib/llm/mcp.rb +5 -2
- data/lib/llm/provider/transport/http/execution.rb +115 -0
- data/lib/llm/provider/transport/http/interruptible.rb +109 -0
- data/lib/llm/provider/transport/http/stream_decoder.rb +92 -0
- data/lib/llm/provider/transport/http.rb +144 -0
- data/lib/llm/provider.rb +17 -103
- data/lib/llm/providers/anthropic/stream_parser.rb +6 -3
- data/lib/llm/providers/google/stream_parser.rb +6 -3
- data/lib/llm/providers/ollama/stream_parser.rb +3 -2
- data/lib/llm/providers/openai/responses/stream_parser.rb +216 -91
- data/lib/llm/providers/openai/stream_parser.rb +111 -57
- data/lib/llm/response.rb +12 -4
- data/lib/llm/sequel/plugin.rb +252 -0
- data/lib/llm/stream/queue.rb +2 -2
- data/lib/llm/stream.rb +2 -2
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +8 -0
- data/lib/sequel/plugins/llm.rb +8 -0
- metadata +9 -2
- data/lib/llm/client.rb +0 -36
|
@@ -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
|
-
|
|
31
|
-
nil
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 =
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
-
#
|
|
14
|
-
#
|
|
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
|