llm.rb 11.0.0 → 11.2.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 +126 -1
- data/README.md +58 -18
- data/lib/llm/a2a/transport/http.rb +9 -8
- data/lib/llm/a2a.rb +14 -7
- data/lib/llm/agent.rb +6 -3
- data/lib/llm/context.rb +41 -6
- data/lib/llm/function/array.rb +6 -0
- data/lib/llm/function.rb +38 -4
- data/lib/llm/json_adapter.rb +8 -2
- data/lib/llm/mcp/transport/http.rb +7 -5
- data/lib/llm/mcp.rb +6 -7
- data/lib/llm/object/builder.rb +1 -0
- data/lib/llm/object.rb +9 -0
- data/lib/llm/provider.rb +1 -18
- data/lib/llm/providers/anthropic/files.rb +6 -6
- data/lib/llm/providers/anthropic/models.rb +1 -1
- data/lib/llm/providers/anthropic.rb +1 -1
- data/lib/llm/providers/bedrock/models.rb +4 -4
- data/lib/llm/providers/bedrock/signature.rb +3 -3
- data/lib/llm/providers/bedrock.rb +1 -1
- data/lib/llm/providers/google/files.rb +5 -5
- data/lib/llm/providers/google/images.rb +1 -1
- data/lib/llm/providers/google/models.rb +1 -1
- data/lib/llm/providers/google.rb +2 -2
- data/lib/llm/providers/ollama/models.rb +1 -1
- data/lib/llm/providers/ollama.rb +2 -2
- data/lib/llm/providers/openai/audio.rb +3 -3
- data/lib/llm/providers/openai/files.rb +5 -5
- data/lib/llm/providers/openai/images.rb +3 -3
- data/lib/llm/providers/openai/models.rb +1 -1
- data/lib/llm/providers/openai/moderations.rb +1 -1
- data/lib/llm/providers/openai/responses.rb +3 -3
- data/lib/llm/providers/openai/vector_stores.rb +11 -11
- data/lib/llm/providers/openai.rb +2 -2
- data/lib/llm/schema.rb +23 -5
- data/lib/llm/skill.rb +44 -14
- data/lib/llm/tool.rb +21 -0
- data/lib/llm/tracer/telemetry.rb +3 -1
- data/lib/llm/transport/curb.rb +246 -0
- data/lib/llm/transport/execution.rb +1 -1
- data/lib/llm/transport/http.rb +9 -4
- data/lib/llm/transport/net_http_adapter.rb +61 -0
- data/lib/llm/transport/persistent_http.rb +10 -5
- data/lib/llm/transport/request.rb +121 -0
- data/lib/llm/transport/response/curb.rb +112 -0
- data/lib/llm/transport/response.rb +1 -0
- data/lib/llm/transport/utils.rb +42 -17
- data/lib/llm/transport.rb +17 -45
- data/lib/llm/version.rb +1 -1
- data/llm.gemspec +3 -3
- metadata +8 -4
data/lib/llm/schema.rb
CHANGED
|
@@ -20,14 +20,16 @@
|
|
|
20
20
|
#
|
|
21
21
|
# @example Ruby-style
|
|
22
22
|
# class Address < LLM::Schema
|
|
23
|
-
# property :street, String, "Street address"
|
|
23
|
+
# property :street, String, "Street address"
|
|
24
|
+
# required %i[street]
|
|
24
25
|
# end
|
|
25
26
|
#
|
|
26
27
|
# class Person < LLM::Schema
|
|
27
|
-
# property :name, String, "Person's name"
|
|
28
|
-
# property :age, Integer, "Person's age"
|
|
29
|
-
# property :hobbies, Array[String], "Person's hobbies"
|
|
30
|
-
# property :address, Address, "Person's address"
|
|
28
|
+
# property :name, String, "Person's name"
|
|
29
|
+
# property :age, Integer, "Person's age"
|
|
30
|
+
# property :hobbies, Array[String], "Person's hobbies"
|
|
31
|
+
# property :address, Address, "Person's address"
|
|
32
|
+
# required %i[name age hobbies address]
|
|
31
33
|
# end
|
|
32
34
|
class LLM::Schema
|
|
33
35
|
require_relative "schema/version"
|
|
@@ -74,6 +76,10 @@ class LLM::Schema
|
|
|
74
76
|
end
|
|
75
77
|
schema.array(item)
|
|
76
78
|
end
|
|
79
|
+
|
|
80
|
+
def fetch(properties, name)
|
|
81
|
+
properties[name] || properties.fetch(name.to_s)
|
|
82
|
+
end
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
##
|
|
@@ -103,6 +109,18 @@ class LLM::Schema
|
|
|
103
109
|
end
|
|
104
110
|
end
|
|
105
111
|
|
|
112
|
+
##
|
|
113
|
+
# Mark existing properties as required.
|
|
114
|
+
# @param names [Array<Symbol,String>]
|
|
115
|
+
# @return [LLM::Schema::Object]
|
|
116
|
+
def self.required(names)
|
|
117
|
+
lock do
|
|
118
|
+
object.tap do |schema|
|
|
119
|
+
[*names].each { Utils.fetch(schema.properties, _1).required }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
106
124
|
##
|
|
107
125
|
# @api private
|
|
108
126
|
# @return [LLM::Schema]
|
data/lib/llm/skill.rb
CHANGED
|
@@ -56,6 +56,7 @@ module LLM
|
|
|
56
56
|
@instructions = ""
|
|
57
57
|
@frontmatter = LLM::Object.from({})
|
|
58
58
|
@tools = []
|
|
59
|
+
@inherit_tools = false
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
##
|
|
@@ -74,18 +75,8 @@ module LLM
|
|
|
74
75
|
# @param [LLM::Context] ctx
|
|
75
76
|
# @return [Hash]
|
|
76
77
|
def call(ctx)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
concurrency = params[:stream].extra[:concurrency] if LLM::Stream === params[:stream]
|
|
80
|
-
params[:concurrency] = concurrency if concurrency
|
|
81
|
-
agent = Class.new(LLM::Agent) do
|
|
82
|
-
instructions(instructions)
|
|
83
|
-
tools(*tools)
|
|
84
|
-
tracer(tracer)
|
|
85
|
-
end.new(ctx.llm, params)
|
|
86
|
-
agent.messages.concat(messages_for(ctx))
|
|
87
|
-
res = agent.talk("Solve the user's query.")
|
|
88
|
-
{content: res.content}
|
|
78
|
+
content = agent(ctx).talk("Solve the user's query.").content
|
|
79
|
+
{content:}
|
|
89
80
|
end
|
|
90
81
|
|
|
91
82
|
##
|
|
@@ -96,9 +87,10 @@ module LLM
|
|
|
96
87
|
def to_tool(ctx)
|
|
97
88
|
skill = self
|
|
98
89
|
Class.new(LLM::Tool) do
|
|
90
|
+
attr_accessor :tracer
|
|
91
|
+
|
|
99
92
|
name skill.name
|
|
100
93
|
description skill.description
|
|
101
|
-
attr_accessor :tracer
|
|
102
94
|
|
|
103
95
|
define_singleton_method(:skill?) do
|
|
104
96
|
true
|
|
@@ -110,6 +102,13 @@ module LLM
|
|
|
110
102
|
end
|
|
111
103
|
end
|
|
112
104
|
|
|
105
|
+
##
|
|
106
|
+
# Returns true when a skill should inherit tools from its parent
|
|
107
|
+
# @return [Boolean]
|
|
108
|
+
def inherit_tools?
|
|
109
|
+
@inherit_tools
|
|
110
|
+
end
|
|
111
|
+
|
|
113
112
|
private
|
|
114
113
|
|
|
115
114
|
def messages_for(ctx)
|
|
@@ -132,8 +131,39 @@ module LLM
|
|
|
132
131
|
@frontmatter = LLM::Object.from(YAML.safe_load(match[1]) || {})
|
|
133
132
|
@name = @frontmatter.name || @name
|
|
134
133
|
@description = @frontmatter.description || @description
|
|
135
|
-
@tools = [*@frontmatter.tools].map { LLM::Tool.find_by_name!(_1) }
|
|
136
134
|
@instructions = match[2]
|
|
135
|
+
@inherit_tools, @tools = parse_tools(@frontmatter.tools)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_tools(tools)
|
|
139
|
+
case tools
|
|
140
|
+
when String
|
|
141
|
+
tools == "inherit" ? [true, []] : raise_invalid_error!(tools)
|
|
142
|
+
when Array
|
|
143
|
+
[false, [*@frontmatter.tools].map { LLM::Tool.find_by_name!(_1) }]
|
|
144
|
+
when NilClass
|
|
145
|
+
[false, []]
|
|
146
|
+
else
|
|
147
|
+
raise_invalid_error!(tools)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def raise_invalid_error!(tools)
|
|
152
|
+
raise LLM::Error, "invalid value for tools key: '#{tools}'"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def agent(ctx)
|
|
156
|
+
instructions, tools, tracer, inherit_tools = self.instructions, self.tools, ctx.llm.tracer, inherit_tools?
|
|
157
|
+
params = ctx.params.merge(mode: ctx.mode).reject { [:tools, :schema].include?(_1) }
|
|
158
|
+
concurrency = params[:stream].extra[:concurrency] if LLM::Stream === params[:stream]
|
|
159
|
+
params[:concurrency] = concurrency if concurrency
|
|
160
|
+
agent = Class.new(LLM::Agent) do
|
|
161
|
+
instructions(instructions)
|
|
162
|
+
tools(inherit_tools ? [*ctx.params[:tools]].reject(&:skill?) : tools)
|
|
163
|
+
tracer(tracer)
|
|
164
|
+
end.new(ctx.llm, params)
|
|
165
|
+
agent.messages.concat(messages_for(ctx))
|
|
166
|
+
agent
|
|
137
167
|
end
|
|
138
168
|
end
|
|
139
169
|
end
|
data/lib/llm/tool.rb
CHANGED
|
@@ -202,6 +202,13 @@ class LLM::Tool
|
|
|
202
202
|
false
|
|
203
203
|
end
|
|
204
204
|
|
|
205
|
+
##
|
|
206
|
+
# Returns true if the tool is a skill
|
|
207
|
+
# @return [Boolean]
|
|
208
|
+
def self.skill?
|
|
209
|
+
false
|
|
210
|
+
end
|
|
211
|
+
|
|
205
212
|
##
|
|
206
213
|
# Returns a function bound to this tool instance.
|
|
207
214
|
# @return [LLM::Function]
|
|
@@ -216,6 +223,20 @@ class LLM::Tool
|
|
|
216
223
|
self.class.mcp?
|
|
217
224
|
end
|
|
218
225
|
|
|
226
|
+
##
|
|
227
|
+
# Returns true if the tool is an A2A tool
|
|
228
|
+
# @return [Boolean]
|
|
229
|
+
def a2a?
|
|
230
|
+
self.class.a2a?
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
##
|
|
234
|
+
# Returns true if the tool is a skill
|
|
235
|
+
# @return [Boolean]
|
|
236
|
+
def skill?
|
|
237
|
+
self.class.skill?
|
|
238
|
+
end
|
|
239
|
+
|
|
219
240
|
##
|
|
220
241
|
# Called when an in-flight tool run is interrupted.
|
|
221
242
|
# Tools can override this to implement cooperative cleanup.
|
data/lib/llm/tracer/telemetry.rb
CHANGED
|
@@ -223,7 +223,9 @@ module LLM
|
|
|
223
223
|
require "opentelemetry/sdk" unless defined?(OpenTelemetry)
|
|
224
224
|
@exporter ||= OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new
|
|
225
225
|
processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(@exporter)
|
|
226
|
-
@tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
|
|
226
|
+
@tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
|
|
227
|
+
sampler: OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON
|
|
228
|
+
)
|
|
227
229
|
@tracer_provider.add_span_processor(processor)
|
|
228
230
|
@tracer = @tracer_provider.tracer("llm.rb", LLM::VERSION)
|
|
229
231
|
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Transport
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Transport::Curb LLM::Transport::Curb} transport is an
|
|
6
|
+
# optional adapter for libcurl via the
|
|
7
|
+
# [curb](https://github.com/taf2/curb) gem.
|
|
8
|
+
#
|
|
9
|
+
# Curb is a C extension around libcurl. It releases the GVL during
|
|
10
|
+
# I/O so other Ruby threads can run while requests are in flight. Its
|
|
11
|
+
# timeout handling is built into libcurl itself — no thread-based
|
|
12
|
+
# timeout library required. It supports HTTP/2, connection reuse, and
|
|
13
|
+
# a wider range of network protocols out of the box.
|
|
14
|
+
#
|
|
15
|
+
# Unlike the built-in Net::HTTP transports, this transport does not
|
|
16
|
+
# require any Ruby standard library HTTP client and can be used on
|
|
17
|
+
# platforms where Net::HTTP is not available or desired.
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# LLM.openai(key: ENV["KEY"], transport: :curb)
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class Curb < self
|
|
24
|
+
INTERRUPT_ERRORS = [::IOError, ::EOFError, Errno::EBADF].freeze
|
|
25
|
+
ActiveRequest = Struct.new(:easy, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# @param [String] host
|
|
29
|
+
# @param [Integer] port
|
|
30
|
+
# @param [Integer] timeout
|
|
31
|
+
# @param [Boolean] ssl
|
|
32
|
+
# @return [LLM::Transport::Curb]
|
|
33
|
+
def initialize(host:, port:, timeout:, ssl:)
|
|
34
|
+
@host = host
|
|
35
|
+
@port = port
|
|
36
|
+
@timeout = timeout
|
|
37
|
+
@ssl = ssl
|
|
38
|
+
@base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
|
|
39
|
+
@monitor = Monitor.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Returns the current request owner.
|
|
44
|
+
# @return [Object]
|
|
45
|
+
def request_owner
|
|
46
|
+
return Fiber.current unless defined?(::Async)
|
|
47
|
+
Async::Task.current? ? Async::Task.current : Fiber.current
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# @return [Array<Class<Exception>>]
|
|
52
|
+
def interrupt_errors
|
|
53
|
+
[*INTERRUPT_ERRORS, *optional_interrupt_errors]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Interrupt an active request, if any.
|
|
58
|
+
#
|
|
59
|
+
# Sets the interrupt flag so the on_body callback can raise
|
|
60
|
+
# LLM::Interrupt on the next chunk.
|
|
61
|
+
#
|
|
62
|
+
# @param [Fiber] owner
|
|
63
|
+
# @return [nil]
|
|
64
|
+
def interrupt!(owner)
|
|
65
|
+
request_for(owner) or return
|
|
66
|
+
lock { (@interrupts ||= {})[owner] = true }
|
|
67
|
+
rescue *interrupt_errors
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
##
|
|
72
|
+
# Returns whether an execution owner was interrupted.
|
|
73
|
+
# @param [Fiber] owner
|
|
74
|
+
# @return [Boolean, nil]
|
|
75
|
+
def interrupted?(owner)
|
|
76
|
+
lock { @interrupts&.delete(owner) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# Performs a request through curb and returns a transport response
|
|
81
|
+
# wrapper so the provider layer can stay transport-agnostic.
|
|
82
|
+
#
|
|
83
|
+
# @param [LLM::Transport::Request] request
|
|
84
|
+
# @param [Fiber] owner
|
|
85
|
+
# @param [LLM::Object, nil] stream
|
|
86
|
+
# @yieldparam [LLM::Transport::Response] response
|
|
87
|
+
# @return [Object]
|
|
88
|
+
def request(request, owner:, stream: nil, &b)
|
|
89
|
+
easy = build_easy(request)
|
|
90
|
+
set_request(ActiveRequest.new(easy:), owner)
|
|
91
|
+
if stream
|
|
92
|
+
perform_streaming(easy, owner, stream)
|
|
93
|
+
elsif b
|
|
94
|
+
res = perform_blocking(easy, owner)
|
|
95
|
+
if LLM::Transport::Response === res
|
|
96
|
+
res.success? ? b.call(res) : res
|
|
97
|
+
else
|
|
98
|
+
res
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
perform_blocking(easy, owner)
|
|
102
|
+
end
|
|
103
|
+
ensure
|
|
104
|
+
clear_request(owner)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# @return [String]
|
|
109
|
+
def inspect
|
|
110
|
+
"#<#{LLM::Utils.object_id(self)}>"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
attr_reader :host, :port, :timeout, :ssl, :base_uri
|
|
116
|
+
|
|
117
|
+
def build_easy(request)
|
|
118
|
+
LLM.require "curb" unless defined?(::Curl)
|
|
119
|
+
easy = ::Curl::Easy.new(request_url(request))
|
|
120
|
+
easy.timeout = timeout
|
|
121
|
+
easy.connect_timeout = timeout
|
|
122
|
+
request.headers.each { |k, v| easy.headers[k] = v }
|
|
123
|
+
easy.follow_location = true
|
|
124
|
+
easy.ssl_verify_peer = false if !ssl
|
|
125
|
+
set_body(easy, request)
|
|
126
|
+
easy
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def request_url(request)
|
|
130
|
+
path = request.path
|
|
131
|
+
return path if path.start_with?("http://", "https://")
|
|
132
|
+
scheme = ssl ? "https" : "http"
|
|
133
|
+
default_port = ssl ? 443 : 80
|
|
134
|
+
authority = port && port.to_i > 0 && port.to_i != default_port \
|
|
135
|
+
? "#{host}:#{port}" : host
|
|
136
|
+
"#{scheme}://#{authority}#{path}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def set_body(easy, request)
|
|
140
|
+
case request.method
|
|
141
|
+
when "POST"
|
|
142
|
+
easy.post_body = request.body if request.body
|
|
143
|
+
when "PUT"
|
|
144
|
+
easy.put_data = request.body if request.body
|
|
145
|
+
when "DELETE"
|
|
146
|
+
easy.delete = true
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def perform_blocking(easy, owner)
|
|
151
|
+
check_interrupted(owner)
|
|
152
|
+
easy.on_body { |chunk|
|
|
153
|
+
check_interrupted(owner)
|
|
154
|
+
chunk.bytesize
|
|
155
|
+
}
|
|
156
|
+
easy.perform
|
|
157
|
+
build_response(easy)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def perform_streaming(easy, owner, stream)
|
|
161
|
+
res = nil
|
|
162
|
+
raw_body = +""
|
|
163
|
+
decoder = stream.decoder.new(stream.parser.new(stream.streamer))
|
|
164
|
+
easy.on_body do |chunk|
|
|
165
|
+
raise LLM::Interrupt, "request interrupted" if interrupted?(owner)
|
|
166
|
+
if (res ||= build_response_from_headers(easy))&.success? \
|
|
167
|
+
&& res["content-type"].to_s.include?("text/event-stream")
|
|
168
|
+
decoder << chunk
|
|
169
|
+
else
|
|
170
|
+
raw_body << chunk
|
|
171
|
+
end
|
|
172
|
+
chunk.bytesize
|
|
173
|
+
end
|
|
174
|
+
easy.perform
|
|
175
|
+
res ||= build_response(easy)
|
|
176
|
+
if raw_body.empty?
|
|
177
|
+
body = decoder.body
|
|
178
|
+
res.body = (Hash === body || Array === body) \
|
|
179
|
+
? LLM::Object.from(body) : body
|
|
180
|
+
else
|
|
181
|
+
res.body = raw_body
|
|
182
|
+
end
|
|
183
|
+
res
|
|
184
|
+
ensure
|
|
185
|
+
decoder&.free
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_response(easy)
|
|
189
|
+
LLM::Transport::Response::Curb.new(
|
|
190
|
+
easy.response_code.to_i,
|
|
191
|
+
parse_headers(easy.header_str.to_s),
|
|
192
|
+
easy.body_str.to_s
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_response_from_headers(easy)
|
|
197
|
+
return nil if easy.header_str.to_s.empty?
|
|
198
|
+
LLM::Transport::Response::Curb.new(
|
|
199
|
+
easy.response_code.to_i,
|
|
200
|
+
parse_headers(easy.header_str.to_s),
|
|
201
|
+
+""
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def parse_headers(header_str)
|
|
206
|
+
headers = {}
|
|
207
|
+
header_str.each_line do |line|
|
|
208
|
+
line = line.strip
|
|
209
|
+
next if line.empty? || line.start_with?("HTTP/")
|
|
210
|
+
key, value = line.split(/: \s*/, 2)
|
|
211
|
+
headers[key.downcase] = value if key && value
|
|
212
|
+
end
|
|
213
|
+
headers
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def check_interrupted(owner)
|
|
217
|
+
raise LLM::Interrupt, "request interrupted" if interrupted?(owner)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def request_for(owner)
|
|
221
|
+
lock do
|
|
222
|
+
@requests ||= {}
|
|
223
|
+
@requests[owner]
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def set_request(req, owner)
|
|
228
|
+
lock do
|
|
229
|
+
@requests ||= {}
|
|
230
|
+
@requests[owner] = req
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def clear_request(owner)
|
|
235
|
+
lock { @requests&.delete(owner) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def lock(&)
|
|
239
|
+
@monitor.synchronize(&)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def optional_interrupt_errors
|
|
243
|
+
defined?(::Async::Stop) ? [Async::Stop] : []
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
data/lib/llm/transport/http.rb
CHANGED
|
@@ -11,8 +11,10 @@ class LLM::Transport
|
|
|
11
11
|
#
|
|
12
12
|
# @api private
|
|
13
13
|
class HTTP < self
|
|
14
|
+
include NetHTTPAdapter
|
|
15
|
+
|
|
14
16
|
INTERRUPT_ERRORS = [::IOError, ::EOFError, Errno::EBADF].freeze
|
|
15
|
-
|
|
17
|
+
ActiveRequest = Struct.new(:client, keyword_init: true)
|
|
16
18
|
|
|
17
19
|
##
|
|
18
20
|
# @param [String] host
|
|
@@ -67,15 +69,18 @@ class LLM::Transport
|
|
|
67
69
|
|
|
68
70
|
##
|
|
69
71
|
# Performs a request on the current HTTP transport.
|
|
70
|
-
#
|
|
72
|
+
# Accepts both {Net::HTTPRequest} and {LLM::Transport::Request}.
|
|
73
|
+
#
|
|
74
|
+
# @param [Net::HTTPRequest, LLM::Transport::Request] request
|
|
71
75
|
# @param [Fiber] owner
|
|
72
76
|
# @param [LLM::Object, nil] stream
|
|
73
77
|
# @yieldparam [LLM::Transport::Response] response
|
|
74
78
|
# @return [Object]
|
|
75
79
|
def request(request, owner:, stream: nil, &b)
|
|
80
|
+
http_req = resolve_request(request)
|
|
76
81
|
client = client()
|
|
77
|
-
set_request(
|
|
78
|
-
perform_request(client,
|
|
82
|
+
set_request(ActiveRequest.new(client:), owner)
|
|
83
|
+
perform_request(client, http_req, stream, &b)
|
|
79
84
|
ensure
|
|
80
85
|
clear_request(owner)
|
|
81
86
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Transport
|
|
4
|
+
##
|
|
5
|
+
# @api private
|
|
6
|
+
module NetHTTPAdapter
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def resolve_request(request)
|
|
10
|
+
return request if ::Net::HTTPRequest === request
|
|
11
|
+
build_net_http_request(request)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build_net_http_request(req)
|
|
15
|
+
method = req.method.downcase.to_sym
|
|
16
|
+
path = req.path
|
|
17
|
+
headers = req.headers
|
|
18
|
+
http_req = case method
|
|
19
|
+
when :get then ::Net::HTTP::Get.new(path, headers)
|
|
20
|
+
when :post then ::Net::HTTP::Post.new(path, headers)
|
|
21
|
+
when :put then ::Net::HTTP::Put.new(path, headers)
|
|
22
|
+
when :patch then ::Net::HTTP::Patch.new(path, headers)
|
|
23
|
+
when :delete then ::Net::HTTP::Delete.new(path, headers)
|
|
24
|
+
else ::Net::HTTP::GenericRequest.new(method, path, nil, headers)
|
|
25
|
+
end
|
|
26
|
+
if req.body
|
|
27
|
+
http_req.body = req.body
|
|
28
|
+
elsif req.body_stream
|
|
29
|
+
http_req.body_stream = req.body_stream
|
|
30
|
+
end
|
|
31
|
+
http_req
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def perform_request(client, request, stream, &b)
|
|
35
|
+
if stream
|
|
36
|
+
client.request(request) do |raw|
|
|
37
|
+
res = LLM::Transport::Response.from(raw)
|
|
38
|
+
if res.success?
|
|
39
|
+
parser = stream.decoder.new(stream.parser.new(stream.streamer))
|
|
40
|
+
res.read_body(parser)
|
|
41
|
+
body = parser.body
|
|
42
|
+
res.body = (Hash === body || Array === body) ? LLM::Object.from(body) : body
|
|
43
|
+
else
|
|
44
|
+
body = +""
|
|
45
|
+
res.read_body { body << _1 }
|
|
46
|
+
res.body = body
|
|
47
|
+
end
|
|
48
|
+
ensure
|
|
49
|
+
parser&.free
|
|
50
|
+
end
|
|
51
|
+
elsif b
|
|
52
|
+
client.request(request) do |raw|
|
|
53
|
+
res = LLM::Transport::Response.from(raw)
|
|
54
|
+
res.success? ? b.call(res) : res
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
LLM::Transport::Response.from(client.request(request))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -10,8 +10,10 @@ class LLM::Transport
|
|
|
10
10
|
#
|
|
11
11
|
# @api private
|
|
12
12
|
class PersistentHTTP < self
|
|
13
|
+
include NetHTTPAdapter
|
|
14
|
+
|
|
13
15
|
INTERRUPT_ERRORS = [::IOError, ::EOFError, Errno::EBADF].freeze
|
|
14
|
-
|
|
16
|
+
ActiveRequest = Struct.new(:client, :connection, keyword_init: true)
|
|
15
17
|
@registry = {}
|
|
16
18
|
@monitor = Monitor.new
|
|
17
19
|
|
|
@@ -79,15 +81,18 @@ class LLM::Transport
|
|
|
79
81
|
|
|
80
82
|
##
|
|
81
83
|
# Performs a request on the current HTTP transport.
|
|
82
|
-
#
|
|
84
|
+
# Accepts both {Net::HTTPRequest} and {LLM::Transport::Request}.
|
|
85
|
+
#
|
|
86
|
+
# @param [Net::HTTPRequest, LLM::Transport::Request] request
|
|
83
87
|
# @param [Fiber] owner
|
|
84
88
|
# @param [LLM::Object, nil] stream
|
|
85
89
|
# @yieldparam [LLM::Transport::Response] response
|
|
86
90
|
# @return [Object]
|
|
87
91
|
def request(request, owner:, stream: nil, &b)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
http_req = resolve_request(request)
|
|
93
|
+
client.connection_for(URI.join(base_uri, http_req.path)) do |connection|
|
|
94
|
+
set_request(ActiveRequest.new(client:, connection:), owner)
|
|
95
|
+
perform_request(connection.http, http_req, stream, &b)
|
|
91
96
|
end
|
|
92
97
|
ensure
|
|
93
98
|
clear_request(owner)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Transport
|
|
4
|
+
##
|
|
5
|
+
# {LLM::Transport::Request LLM::Transport::Request} defines the
|
|
6
|
+
# normalized request interface expected by transports.
|
|
7
|
+
#
|
|
8
|
+
# Providers build request objects through this class, then hand them
|
|
9
|
+
# to a transport for execution without depending on any specific HTTP
|
|
10
|
+
# client library.
|
|
11
|
+
class Request
|
|
12
|
+
##
|
|
13
|
+
# @return [Object, nil]
|
|
14
|
+
attr_accessor :body
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# @return [IO, nil]
|
|
18
|
+
attr_accessor :body_stream
|
|
19
|
+
|
|
20
|
+
##
|
|
21
|
+
# @return [String]
|
|
22
|
+
attr_reader :method
|
|
23
|
+
|
|
24
|
+
##
|
|
25
|
+
# @return [String]
|
|
26
|
+
attr_reader :path
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# @return [Hash]
|
|
30
|
+
attr_reader :headers
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# @param [String] path
|
|
34
|
+
# @param [Hash, nil] headers
|
|
35
|
+
# @return [LLM::Transport::Request]
|
|
36
|
+
def self.get(path, headers = nil)
|
|
37
|
+
new("GET", path, headers)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
##
|
|
41
|
+
# @param [String] path
|
|
42
|
+
# @param [Hash, nil] headers
|
|
43
|
+
# @return [LLM::Transport::Request]
|
|
44
|
+
def self.post(path, headers = nil)
|
|
45
|
+
new("POST", path, headers)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# @param [String] path
|
|
50
|
+
# @param [Hash, nil] headers
|
|
51
|
+
# @return [LLM::Transport::Request]
|
|
52
|
+
def self.put(path, headers = nil)
|
|
53
|
+
new("PUT", path, headers)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# @param [String] path
|
|
58
|
+
# @param [Hash, nil] headers
|
|
59
|
+
# @return [LLM::Transport::Request]
|
|
60
|
+
def self.patch(path, headers = nil)
|
|
61
|
+
new("PATCH", path, headers)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# @param [String] path
|
|
66
|
+
# @param [Hash, nil] headers
|
|
67
|
+
# @return [LLM::Transport::Request]
|
|
68
|
+
def self.delete(path, headers = nil)
|
|
69
|
+
new("DELETE", path, headers)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
##
|
|
73
|
+
# @param [String] method
|
|
74
|
+
# @param [String] path
|
|
75
|
+
# @param [Hash, nil] headers
|
|
76
|
+
# @return [LLM::Transport::Request]
|
|
77
|
+
def initialize(method, path, headers = nil)
|
|
78
|
+
@method = method.to_s.upcase
|
|
79
|
+
@path = path.to_s
|
|
80
|
+
@headers = {}
|
|
81
|
+
(headers || {}).each { self[_1] = _2 }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# @param [String] key
|
|
86
|
+
# @return [String, nil]
|
|
87
|
+
def [](key)
|
|
88
|
+
@headers[normalize_header(key)]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
##
|
|
92
|
+
# @param [String] key
|
|
93
|
+
# @param [Object] value
|
|
94
|
+
# @return [String]
|
|
95
|
+
def []=(key, value)
|
|
96
|
+
@headers[normalize_header(key)] = value.to_s
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
##
|
|
100
|
+
# @yieldparam [String] key
|
|
101
|
+
# @yieldparam [String] value
|
|
102
|
+
# @return [Hash]
|
|
103
|
+
def each_header(&block)
|
|
104
|
+
@headers.each(&block)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# @return [String]
|
|
109
|
+
def inspect
|
|
110
|
+
"#<#{self.class.name}:0x#{object_id.to_s(16)}" \
|
|
111
|
+
" @method=#{@method} @path=#{@path}" \
|
|
112
|
+
" @headers=#{@headers.inspect}>"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def normalize_header(key)
|
|
118
|
+
key.to_s.downcase
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|