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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -1
  3. data/README.md +58 -18
  4. data/lib/llm/a2a/transport/http.rb +9 -8
  5. data/lib/llm/a2a.rb +14 -7
  6. data/lib/llm/agent.rb +6 -3
  7. data/lib/llm/context.rb +41 -6
  8. data/lib/llm/function/array.rb +6 -0
  9. data/lib/llm/function.rb +38 -4
  10. data/lib/llm/json_adapter.rb +8 -2
  11. data/lib/llm/mcp/transport/http.rb +7 -5
  12. data/lib/llm/mcp.rb +6 -7
  13. data/lib/llm/object/builder.rb +1 -0
  14. data/lib/llm/object.rb +9 -0
  15. data/lib/llm/provider.rb +1 -18
  16. data/lib/llm/providers/anthropic/files.rb +6 -6
  17. data/lib/llm/providers/anthropic/models.rb +1 -1
  18. data/lib/llm/providers/anthropic.rb +1 -1
  19. data/lib/llm/providers/bedrock/models.rb +4 -4
  20. data/lib/llm/providers/bedrock/signature.rb +3 -3
  21. data/lib/llm/providers/bedrock.rb +1 -1
  22. data/lib/llm/providers/google/files.rb +5 -5
  23. data/lib/llm/providers/google/images.rb +1 -1
  24. data/lib/llm/providers/google/models.rb +1 -1
  25. data/lib/llm/providers/google.rb +2 -2
  26. data/lib/llm/providers/ollama/models.rb +1 -1
  27. data/lib/llm/providers/ollama.rb +2 -2
  28. data/lib/llm/providers/openai/audio.rb +3 -3
  29. data/lib/llm/providers/openai/files.rb +5 -5
  30. data/lib/llm/providers/openai/images.rb +3 -3
  31. data/lib/llm/providers/openai/models.rb +1 -1
  32. data/lib/llm/providers/openai/moderations.rb +1 -1
  33. data/lib/llm/providers/openai/responses.rb +3 -3
  34. data/lib/llm/providers/openai/vector_stores.rb +11 -11
  35. data/lib/llm/providers/openai.rb +2 -2
  36. data/lib/llm/schema.rb +23 -5
  37. data/lib/llm/skill.rb +44 -14
  38. data/lib/llm/tool.rb +21 -0
  39. data/lib/llm/tracer/telemetry.rb +3 -1
  40. data/lib/llm/transport/curb.rb +246 -0
  41. data/lib/llm/transport/execution.rb +1 -1
  42. data/lib/llm/transport/http.rb +9 -4
  43. data/lib/llm/transport/net_http_adapter.rb +61 -0
  44. data/lib/llm/transport/persistent_http.rb +10 -5
  45. data/lib/llm/transport/request.rb +121 -0
  46. data/lib/llm/transport/response/curb.rb +112 -0
  47. data/lib/llm/transport/response.rb +1 -0
  48. data/lib/llm/transport/utils.rb +42 -17
  49. data/lib/llm/transport.rb +17 -45
  50. data/lib/llm/version.rb +1 -1
  51. data/llm.gemspec +3 -3
  52. 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", required: true
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", required: true
28
- # property :age, Integer, "Person's age", required: true
29
- # property :hobbies, Array[String], "Person's hobbies", required: true
30
- # property :address, Address, "Person's address", required: true
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
- instructions, tools, tracer = self.instructions, self.tools, ctx.llm.tracer
78
- params = ctx.params.merge(mode: ctx.mode).reject { [:tools, :schema].include?(_1) }
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.
@@ -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
@@ -13,7 +13,7 @@ class LLM::Transport
13
13
 
14
14
  ##
15
15
  # Executes a HTTP request
16
- # @param [Net::HTTPRequest] request
16
+ # @param [LLM::Transport::Request] request
17
17
  # The request to send
18
18
  # @param [Proc] b
19
19
  # A block to yield the response to (optional)
@@ -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
- Request = Struct.new(:client, keyword_init: true)
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
- # @param [Net::HTTPRequest] request
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(Request.new(client:), owner)
78
- perform_request(client, request, stream, &b)
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
- Request = Struct.new(:client, :connection, keyword_init: true)
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
- # @param [Net::HTTPRequest] request
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
- client.connection_for(URI.join(base_uri, request.path)) do |connection|
89
- set_request(Request.new(client:, connection:), owner)
90
- perform_request(connection.http, request, stream, &b)
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