llm.rb 4.12.0 → 4.14.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 +84 -0
- data/README.md +126 -736
- data/lib/llm/context.rb +12 -2
- 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 +29 -14
- data/lib/llm/function.rb +1 -1
- data/lib/llm/mcp/command.rb +1 -1
- data/lib/llm/mcp/error.rb +31 -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 +31 -15
- 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 +46 -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/openai/request_adapter/respond.rb +11 -5
- data/lib/llm/providers/openai/response_adapter/responds.rb +13 -1
- data/lib/llm/providers/openai/responses/stream_parser.rb +31 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +8 -0
- data/llm.gemspec +16 -6
- metadata +23 -8
- data/lib/llm/client.rb +0 -36
|
@@ -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
|
data/lib/llm/provider.rb
CHANGED
|
@@ -7,14 +7,9 @@
|
|
|
7
7
|
# @abstract
|
|
8
8
|
class LLM::Provider
|
|
9
9
|
require "net/http"
|
|
10
|
-
require_relative "
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@@clients = {}
|
|
14
|
-
|
|
15
|
-
##
|
|
16
|
-
# @api private
|
|
17
|
-
def self.clients = @@clients
|
|
10
|
+
require_relative "provider/transport/http"
|
|
11
|
+
require_relative "provider/transport/http/execution"
|
|
12
|
+
include Transport::HTTP::Execution
|
|
18
13
|
|
|
19
14
|
##
|
|
20
15
|
# @param [String, nil] key
|
|
@@ -36,9 +31,9 @@ class LLM::Provider
|
|
|
36
31
|
@port = port
|
|
37
32
|
@timeout = timeout
|
|
38
33
|
@ssl = ssl
|
|
39
|
-
@client = persistent ? persistent_client : nil
|
|
40
34
|
@base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
|
|
41
35
|
@headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
|
|
36
|
+
@transport = Transport::HTTP.new(host:, port:, timeout:, ssl:, persistent:)
|
|
42
37
|
@monitor = Monitor.new
|
|
43
38
|
end
|
|
44
39
|
|
|
@@ -47,7 +42,7 @@ class LLM::Provider
|
|
|
47
42
|
# @return [String]
|
|
48
43
|
# @note The secret key is redacted in inspect for security reasons
|
|
49
44
|
def inspect
|
|
50
|
-
"#<#{self.class.name}:0x#{object_id.to_s(16)} @key=[REDACTED] @
|
|
45
|
+
"#<#{self.class.name}:0x#{object_id.to_s(16)} @key=[REDACTED] @transport=#{transport.inspect} @tracer=#{tracer.inspect}>"
|
|
51
46
|
end
|
|
52
47
|
|
|
53
48
|
##
|
|
@@ -312,13 +307,20 @@ class LLM::Provider
|
|
|
312
307
|
# # do something with 'llm'
|
|
313
308
|
# @return [LLM::Provider]
|
|
314
309
|
def persist!
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
tap { @client = client }
|
|
318
|
-
end
|
|
310
|
+
transport.persist!
|
|
311
|
+
self
|
|
319
312
|
end
|
|
320
313
|
alias_method :persistent, :persist!
|
|
321
314
|
|
|
315
|
+
##
|
|
316
|
+
# Interrupt the active request, if any.
|
|
317
|
+
# @param [Fiber] owner
|
|
318
|
+
# @return [nil]
|
|
319
|
+
def interrupt!(owner)
|
|
320
|
+
transport.interrupt!(owner)
|
|
321
|
+
end
|
|
322
|
+
alias_method :cancel!, :interrupt!
|
|
323
|
+
|
|
322
324
|
##
|
|
323
325
|
# @param [Object] stream
|
|
324
326
|
# @return [Boolean]
|
|
@@ -328,7 +330,7 @@ class LLM::Provider
|
|
|
328
330
|
|
|
329
331
|
private
|
|
330
332
|
|
|
331
|
-
attr_reader :
|
|
333
|
+
attr_reader :base_uri, :host, :port, :timeout, :ssl, :transport
|
|
332
334
|
|
|
333
335
|
##
|
|
334
336
|
# The headers to include with a request
|
|
@@ -360,94 +362,6 @@ class LLM::Provider
|
|
|
360
362
|
raise NotImplementedError
|
|
361
363
|
end
|
|
362
364
|
|
|
363
|
-
##
|
|
364
|
-
# Executes a HTTP request
|
|
365
|
-
# @param [Net::HTTPRequest] request
|
|
366
|
-
# The request to send
|
|
367
|
-
# @param [Proc] b
|
|
368
|
-
# A block to yield the response to (optional)
|
|
369
|
-
# @return [Net::HTTPResponse]
|
|
370
|
-
# The response from the server
|
|
371
|
-
# @raise [LLM::Error::Unauthorized]
|
|
372
|
-
# When authentication fails
|
|
373
|
-
# @raise [LLM::Error::RateLimit]
|
|
374
|
-
# When the rate limit is exceeded
|
|
375
|
-
# @raise [LLM::Error]
|
|
376
|
-
# When any other unsuccessful status code is returned
|
|
377
|
-
# @raise [SystemCallError]
|
|
378
|
-
# When there is a network error at the operating system level
|
|
379
|
-
# @return [Net::HTTPResponse]
|
|
380
|
-
def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, inputs: nil, &b)
|
|
381
|
-
tracer = self.tracer
|
|
382
|
-
span = tracer.on_request_start(operation:, model:, inputs:)
|
|
383
|
-
http = client || transient_client
|
|
384
|
-
args = (Net::HTTP === http) ? [request] : [URI.join(base_uri, request.path), request]
|
|
385
|
-
res = if stream
|
|
386
|
-
http.request(*args) do |res|
|
|
387
|
-
if Net::HTTPSuccess === res
|
|
388
|
-
handler = event_handler.new stream_parser.new(stream)
|
|
389
|
-
parser = LLM::EventStream::Parser.new
|
|
390
|
-
parser.register(handler)
|
|
391
|
-
res.read_body(parser)
|
|
392
|
-
# If the handler body is empty, the response was
|
|
393
|
-
# most likely not streamed or parsing failed.
|
|
394
|
-
# Preserve the raw body in that case so standard
|
|
395
|
-
# JSON/error handling can parse it later.
|
|
396
|
-
body = handler.body.empty? ? parser.body : handler.body
|
|
397
|
-
res.body = Hash === body || Array === body ? LLM::Object.from(body) : body
|
|
398
|
-
else
|
|
399
|
-
body = +""
|
|
400
|
-
res.read_body { body << _1 }
|
|
401
|
-
res.body = body
|
|
402
|
-
end
|
|
403
|
-
ensure
|
|
404
|
-
handler&.free
|
|
405
|
-
parser&.free
|
|
406
|
-
end
|
|
407
|
-
else
|
|
408
|
-
b ? http.request(*args) { (Net::HTTPSuccess === _1) ? b.call(_1) : _1 } :
|
|
409
|
-
http.request(*args)
|
|
410
|
-
end
|
|
411
|
-
[handle_response(res, tracer, span), span, tracer]
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
##
|
|
415
|
-
# Handles the response from a request
|
|
416
|
-
# @param [Net::HTTPResponse] res
|
|
417
|
-
# The response to handle
|
|
418
|
-
# @param [Object, nil] span
|
|
419
|
-
# The span
|
|
420
|
-
# @return [Net::HTTPResponse]
|
|
421
|
-
def handle_response(res, tracer, span)
|
|
422
|
-
case res
|
|
423
|
-
when Net::HTTPOK then res.body = parse_response(res)
|
|
424
|
-
else error_handler.new(tracer, span, res).raise_error!
|
|
425
|
-
end
|
|
426
|
-
res
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
##
|
|
430
|
-
# Parse a HTTP response
|
|
431
|
-
# @param [Net::HTTPResponse] res
|
|
432
|
-
# @return [LLM::Object, String]
|
|
433
|
-
def parse_response(res)
|
|
434
|
-
case res["content-type"]
|
|
435
|
-
when %r|\Aapplication/json\s*| then LLM::Object.from(LLM.json.load(res.body))
|
|
436
|
-
else res.body
|
|
437
|
-
end
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
##
|
|
441
|
-
# @param [Net::HTTPRequest] req
|
|
442
|
-
# The request to set the body stream for
|
|
443
|
-
# @param [IO] io
|
|
444
|
-
# The IO object to set as the body stream
|
|
445
|
-
# @return [void]
|
|
446
|
-
def set_body_stream(req, io)
|
|
447
|
-
req.body_stream = io
|
|
448
|
-
req["transfer-encoding"] = "chunked" unless req["content-length"]
|
|
449
|
-
end
|
|
450
|
-
|
|
451
365
|
##
|
|
452
366
|
# Resolves tools to their function representations
|
|
453
367
|
# @param [Array<LLM::Function, LLM::Tool>] tools
|
|
@@ -15,6 +15,8 @@ module LLM::OpenAI::RequestAdapter
|
|
|
15
15
|
catch(:abort) do
|
|
16
16
|
if Hash === message
|
|
17
17
|
{role: message[:role], content: adapt_content(message[:content])}
|
|
18
|
+
elsif message.tool_call?
|
|
19
|
+
message.extra[:original_tool_calls]
|
|
18
20
|
else
|
|
19
21
|
adapt_message
|
|
20
22
|
end
|
|
@@ -23,12 +25,12 @@ module LLM::OpenAI::RequestAdapter
|
|
|
23
25
|
|
|
24
26
|
private
|
|
25
27
|
|
|
26
|
-
def adapt_content(content)
|
|
28
|
+
def adapt_content(content, role: message.role)
|
|
27
29
|
case content
|
|
28
30
|
when String
|
|
29
|
-
[{type:
|
|
31
|
+
[{type: text_content_type(role), text: content.to_s}]
|
|
30
32
|
when LLM::Response then adapt_remote_file(content)
|
|
31
|
-
when LLM::Message then adapt_content(content.content)
|
|
33
|
+
when LLM::Message then adapt_content(content.content, role: content.role)
|
|
32
34
|
when LLM::Object
|
|
33
35
|
case content.kind
|
|
34
36
|
when :image_url then [{type: :image_url, image_url: {url: content.value.to_s}}]
|
|
@@ -46,7 +48,7 @@ module LLM::OpenAI::RequestAdapter
|
|
|
46
48
|
when Array
|
|
47
49
|
adapt_array
|
|
48
50
|
else
|
|
49
|
-
{role: message.role, content: adapt_content(content)}
|
|
51
|
+
{role: message.role, content: adapt_content(content, role: message.role)}
|
|
50
52
|
end
|
|
51
53
|
end
|
|
52
54
|
|
|
@@ -56,7 +58,7 @@ module LLM::OpenAI::RequestAdapter
|
|
|
56
58
|
elsif returns.any?
|
|
57
59
|
returns.map { {type: "function_call_output", call_id: _1.id, output: LLM.json.dump(_1.value)} }
|
|
58
60
|
else
|
|
59
|
-
{role: message.role, content: content.flat_map { adapt_content(_1) }}
|
|
61
|
+
{role: message.role, content: content.flat_map { adapt_content(_1, role: message.role) }}
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
|
|
@@ -83,5 +85,9 @@ module LLM::OpenAI::RequestAdapter
|
|
|
83
85
|
def message = @message
|
|
84
86
|
def content = message.content
|
|
85
87
|
def returns = content.grep(LLM::Function::Return)
|
|
88
|
+
|
|
89
|
+
def text_content_type(role)
|
|
90
|
+
role.to_s == "assistant" ? :output_text : :input_text
|
|
91
|
+
end
|
|
86
92
|
end
|
|
87
93
|
end
|
|
@@ -60,6 +60,13 @@ module LLM::OpenAI::ResponseAdapter
|
|
|
60
60
|
body.model
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
##
|
|
64
|
+
# OpenAI's Responses API does not expose a system fingerprint.
|
|
65
|
+
# @return [nil]
|
|
66
|
+
def system_fingerprint
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
63
70
|
##
|
|
64
71
|
# Returns the aggregated text content from the response outputs.
|
|
65
72
|
# @return [String]
|
|
@@ -88,10 +95,15 @@ module LLM::OpenAI::ResponseAdapter
|
|
|
88
95
|
private
|
|
89
96
|
|
|
90
97
|
def adapt_message
|
|
91
|
-
message = LLM::Message.new(
|
|
98
|
+
message = LLM::Message.new(
|
|
99
|
+
"assistant",
|
|
100
|
+
+"",
|
|
101
|
+
{response: self, tool_calls: [], original_tool_calls: [], reasoning_content: +""}
|
|
102
|
+
)
|
|
92
103
|
output.each do |choice|
|
|
93
104
|
if choice.type == "function_call"
|
|
94
105
|
message.extra[:tool_calls] << adapt_tool(choice)
|
|
106
|
+
message.extra[:original_tool_calls] << choice
|
|
95
107
|
elsif choice.type == "reasoning"
|
|
96
108
|
(choice.summary || []).each do |summary|
|
|
97
109
|
next unless summary["type"] == "summary_text"
|
|
@@ -43,11 +43,19 @@ class LLM::OpenAI
|
|
|
43
43
|
@body[k] = v
|
|
44
44
|
end
|
|
45
45
|
@body["output"] ||= []
|
|
46
|
+
when "response.in_progress", "response.completed"
|
|
47
|
+
response = chunk["response"] || {}
|
|
48
|
+
response.each do |k, v|
|
|
49
|
+
next if k == "output" && @body["output"].is_a?(Array) && @body["output"].any?
|
|
50
|
+
@body[k] = v
|
|
51
|
+
end
|
|
52
|
+
@body["output"] ||= response["output"] || []
|
|
46
53
|
when "response.output_item.added"
|
|
47
54
|
output_index = chunk["output_index"]
|
|
48
55
|
item = chunk["item"]
|
|
49
56
|
@body["output"][output_index] = item
|
|
50
57
|
@body["output"][output_index]["content"] ||= []
|
|
58
|
+
@body["output"][output_index]["summary"] ||= [] if item["type"] == "reasoning"
|
|
51
59
|
when "response.content_part.added"
|
|
52
60
|
output_index = chunk["output_index"]
|
|
53
61
|
content_index = chunk["content_index"]
|
|
@@ -55,6 +63,25 @@ class LLM::OpenAI
|
|
|
55
63
|
@body["output"][output_index] ||= {"content" => []}
|
|
56
64
|
@body["output"][output_index]["content"] ||= []
|
|
57
65
|
@body["output"][output_index]["content"][content_index] = part
|
|
66
|
+
when "response.reasoning_summary_text.delta"
|
|
67
|
+
output_item = @body["output"][chunk["output_index"]]
|
|
68
|
+
if output_item && output_item["type"] == "reasoning"
|
|
69
|
+
summary_index = chunk["summary_index"] || 0
|
|
70
|
+
output_item["summary"] ||= []
|
|
71
|
+
output_item["summary"][summary_index] ||= {"type" => "summary_text", "text" => +""}
|
|
72
|
+
output_item["summary"][summary_index]["text"] << chunk["delta"]
|
|
73
|
+
emit_reasoning_content(chunk["delta"])
|
|
74
|
+
end
|
|
75
|
+
when "response.reasoning_summary_text.done"
|
|
76
|
+
output_item = @body["output"][chunk["output_index"]]
|
|
77
|
+
if output_item && output_item["type"] == "reasoning"
|
|
78
|
+
summary_index = chunk["summary_index"] || 0
|
|
79
|
+
output_item["summary"] ||= []
|
|
80
|
+
output_item["summary"][summary_index] = {
|
|
81
|
+
"type" => "summary_text",
|
|
82
|
+
"text" => chunk["text"]
|
|
83
|
+
}
|
|
84
|
+
end
|
|
58
85
|
when "response.output_text.delta"
|
|
59
86
|
output_index = chunk["output_index"]
|
|
60
87
|
content_index = chunk["content_index"]
|
|
@@ -102,6 +129,10 @@ class LLM::OpenAI
|
|
|
102
129
|
end
|
|
103
130
|
end
|
|
104
131
|
|
|
132
|
+
def emit_reasoning_content(value)
|
|
133
|
+
@stream.on_reasoning_content(value) if @stream.respond_to?(:on_reasoning_content)
|
|
134
|
+
end
|
|
135
|
+
|
|
105
136
|
def emit_tool(index, tool)
|
|
106
137
|
return unless @stream.respond_to?(:on_tool_call)
|
|
107
138
|
return unless complete_tool?(tool)
|
data/lib/llm/version.rb
CHANGED
data/lib/llm.rb
CHANGED
|
@@ -40,6 +40,14 @@ module LLM
|
|
|
40
40
|
# Model registry
|
|
41
41
|
@registry = {}
|
|
42
42
|
|
|
43
|
+
##
|
|
44
|
+
# Shared HTTP clients used by providers.
|
|
45
|
+
@clients = {}
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# @api private
|
|
49
|
+
def self.clients = @clients
|
|
50
|
+
|
|
43
51
|
##
|
|
44
52
|
# @param [Symbol, LLM::Provider] llm
|
|
45
53
|
# The name of a provider, or an instance of LLM::Provider
|