llm.rb 11.1.0 → 11.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -12
  3. data/README.md +104 -69
  4. data/lib/llm/a2a/transport/http.rb +9 -8
  5. data/lib/llm/a2a.rb +14 -7
  6. data/lib/llm/agent.rb +31 -7
  7. data/lib/llm/context.rb +20 -6
  8. data/lib/llm/error.rb +4 -0
  9. data/lib/llm/function/array.rb +6 -0
  10. data/lib/llm/function.rb +26 -0
  11. data/lib/llm/json_adapter.rb +8 -2
  12. data/lib/llm/mcp/transport/http.rb +7 -5
  13. data/lib/llm/mcp.rb +6 -7
  14. data/lib/llm/provider.rb +1 -18
  15. data/lib/llm/providers/anthropic/error_handler.rb +2 -0
  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/error_handler.rb +1 -1
  20. data/lib/llm/providers/bedrock/models.rb +4 -4
  21. data/lib/llm/providers/bedrock/signature.rb +3 -3
  22. data/lib/llm/providers/bedrock.rb +1 -1
  23. data/lib/llm/providers/google/error_handler.rb +2 -0
  24. data/lib/llm/providers/google/files.rb +5 -5
  25. data/lib/llm/providers/google/images.rb +1 -1
  26. data/lib/llm/providers/google/models.rb +1 -1
  27. data/lib/llm/providers/google.rb +2 -2
  28. data/lib/llm/providers/ollama/error_handler.rb +2 -0
  29. data/lib/llm/providers/ollama/models.rb +1 -1
  30. data/lib/llm/providers/ollama.rb +2 -2
  31. data/lib/llm/providers/openai/audio.rb +3 -3
  32. data/lib/llm/providers/openai/error_handler.rb +2 -0
  33. data/lib/llm/providers/openai/files.rb +5 -5
  34. data/lib/llm/providers/openai/images.rb +3 -3
  35. data/lib/llm/providers/openai/models.rb +1 -1
  36. data/lib/llm/providers/openai/moderations.rb +1 -1
  37. data/lib/llm/providers/openai/responses.rb +3 -3
  38. data/lib/llm/providers/openai/vector_stores.rb +11 -11
  39. data/lib/llm/providers/openai.rb +2 -2
  40. data/lib/llm/skill.rb +1 -1
  41. data/lib/llm/tool.rb +21 -0
  42. data/lib/llm/transport/curb.rb +246 -0
  43. data/lib/llm/transport/execution.rb +1 -1
  44. data/lib/llm/transport/http.rb +9 -4
  45. data/lib/llm/transport/net_http_adapter.rb +61 -0
  46. data/lib/llm/transport/persistent_http.rb +10 -5
  47. data/lib/llm/transport/request.rb +121 -0
  48. data/lib/llm/transport/response/curb.rb +112 -0
  49. data/lib/llm/transport/response.rb +1 -0
  50. data/lib/llm/transport/utils.rb +42 -17
  51. data/lib/llm/transport.rb +17 -45
  52. data/lib/llm/version.rb +1 -1
  53. data/llm.gemspec +6 -5
  54. metadata +25 -8
@@ -17,13 +17,14 @@ class LLM::A2A
17
17
  # @param [String] url The base URL of the A2A agent
18
18
  # @param [Hash<String, String>] headers Extra HTTP headers
19
19
  # @param [Integer, nil] timeout The timeout in seconds
20
- # @param [LLM::Transport, Class, nil] transport Override transport
20
+ # @param [Boolean] persistent Whether to use persistent HTTP connections
21
+ # @param [LLM::Transport, Class, Symbol, nil] transport Override transport
21
22
  # @param [String] protocol_version The A2A protocol version header
22
- def initialize(url:, headers: {}, timeout: nil, transport: nil, protocol_version: "1.0")
23
+ def initialize(url:, headers: {}, timeout: nil, persistent: false, transport: nil, protocol_version: "1.0")
23
24
  @uri = URI.parse(url)
24
25
  @headers = headers
25
26
  @protocol_version = protocol_version
26
- @transport = resolve_transport(@uri, transport, timeout)
27
+ @transport = resolve_transport(host: @uri.host, port: uri.port, ssl: @uri.scheme == "https", timeout:, persistent:, transport:)
27
28
  end
28
29
 
29
30
  ##
@@ -31,7 +32,7 @@ class LLM::A2A
31
32
  # @param [String] path The URL path
32
33
  # @return [Hash]
33
34
  def get(path, accept: "application/json")
34
- req = Net::HTTP::Get.new(request_path(path), headers(accept:))
35
+ req = LLM::Transport::Request.get(request_path(path), headers(accept:))
35
36
  res = transport.request(req, owner: self)
36
37
  parse_response(res)
37
38
  end
@@ -42,7 +43,7 @@ class LLM::A2A
42
43
  # @param [Hash] body The JSON body
43
44
  # @return [Hash]
44
45
  def post(path, body, content_type: "application/json", accept: "application/json")
45
- req = Net::HTTP::Post.new(request_path(path), headers(content_type:, accept:))
46
+ req = LLM::Transport::Request.post(request_path(path), headers(content_type:, accept:))
46
47
  req.body = LLM.json.dump(body)
47
48
  res = transport.request(req, owner: self)
48
49
  parse_response(res)
@@ -53,7 +54,7 @@ class LLM::A2A
53
54
  # @param [String] path The URL path
54
55
  # @return [Hash]
55
56
  def delete(path, accept: "application/json")
56
- req = Net::HTTP::Delete.new(request_path(path), headers(accept:))
57
+ req = LLM::Transport::Request.delete(request_path(path), headers(accept:))
57
58
  res = transport.request(req, owner: self)
58
59
  parse_response(res)
59
60
  end
@@ -66,7 +67,7 @@ class LLM::A2A
66
67
  # @yieldparam [LLM::Object] event A stream event
67
68
  # @return [void]
68
69
  def get_stream(path, &on_event)
69
- req = Net::HTTP::Get.new(request_path(path), headers(accept: "text/event-stream"))
70
+ req = LLM::Transport::Request.get(request_path(path), headers(accept: "text/event-stream"))
70
71
  stream(req, &on_event)
71
72
  end
72
73
 
@@ -79,7 +80,7 @@ class LLM::A2A
79
80
  # @yieldparam [LLM::Object] event A stream event
80
81
  # @return [void]
81
82
  def post_stream(path, body, content_type: "application/json", &on_event)
82
- req = Net::HTTP::Post.new(request_path(path), headers(content_type:, accept: "text/event-stream"))
83
+ req = LLM::Transport::Request.post(request_path(path), headers(content_type:, accept: "text/event-stream"))
83
84
  req.body = LLM.json.dump(body)
84
85
  stream(req, &on_event)
85
86
  end
data/lib/llm/a2a.rb CHANGED
@@ -61,8 +61,10 @@ class LLM::A2A
61
61
  # Extra HTTP headers to include in requests (e.g., Authorization)
62
62
  # @param [Integer, nil] timeout
63
63
  # The timeout in seconds for HTTP requests
64
- # @param [LLM::Transport, Class, nil] transport
65
- # Optional override with any {LLM::Transport} instance or subclass
64
+ # @param [Boolean] persistent
65
+ # Whether to use persistent HTTP connections
66
+ # @param [LLM::Transport, Class, Symbol, nil] transport
67
+ # Optional override with any {LLM::Transport} instance, subclass, or shortcut
66
68
  # @param [Symbol] binding
67
69
  # The protocol binding to use. One of `:rest` or `:jsonrpc`
68
70
  # @param [String] base_path
@@ -70,7 +72,7 @@ class LLM::A2A
70
72
  # @param [String] protocol_version
71
73
  # The expected A2A protocol version. Defaults to `"1.0"`.
72
74
  # @return [LLM::A2A]
73
- def self.http(url:, headers: {}, timeout: 30, transport: nil, binding: :rest, base_path: "", protocol_version: "1.0")
75
+ def self.http(url:, headers: {}, timeout: 30, persistent: false, transport: nil, binding: :rest, base_path: "", protocol_version: "1.0")
74
76
  new(
75
77
  binding:,
76
78
  base_path:,
@@ -79,6 +81,7 @@ class LLM::A2A
79
81
  url:,
80
82
  headers:,
81
83
  timeout:,
84
+ persistent:,
82
85
  transport:,
83
86
  protocol_version:
84
87
  )
@@ -90,13 +93,15 @@ class LLM::A2A
90
93
  # @param [String] url
91
94
  # @param [Hash<String, String>] headers
92
95
  # @param [Integer, nil] timeout
93
- # @param [LLM::Transport, Class, nil] transport
96
+ # @param [Boolean] persistent
97
+ # @param [LLM::Transport, Class, Symbol, nil] transport
94
98
  # @return [LLM::A2A]
95
- def self.rest(url:, headers: {}, timeout: 30, transport: nil, base_path: "", protocol_version: "1.0")
99
+ def self.rest(url:, headers: {}, timeout: 30, persistent: false, transport: nil, base_path: "", protocol_version: "1.0")
96
100
  http(
97
101
  url:,
98
102
  headers:,
99
103
  timeout:,
104
+ persistent:,
100
105
  transport:,
101
106
  binding: :rest,
102
107
  base_path:,
@@ -109,13 +114,15 @@ class LLM::A2A
109
114
  # @param [String] url
110
115
  # @param [Hash<String, String>] headers
111
116
  # @param [Integer, nil] timeout
112
- # @param [LLM::Transport, Class, nil] transport
117
+ # @param [Boolean] persistent
118
+ # @param [LLM::Transport, Class, Symbol, nil] transport
113
119
  # @return [LLM::A2A]
114
- def self.jsonrpc(url:, headers: {}, timeout: 30, transport: nil, base_path: "", protocol_version: "1.0")
120
+ def self.jsonrpc(url:, headers: {}, timeout: 30, persistent: false, transport: nil, base_path: "", protocol_version: "1.0")
115
121
  http(
116
122
  url:,
117
123
  headers:,
118
124
  timeout:,
125
+ persistent:,
119
126
  transport:,
120
127
  binding: :jsonrpc,
121
128
  base_path:,
data/lib/llm/agent.rb CHANGED
@@ -72,7 +72,11 @@ module LLM
72
72
  # Returns the current skills when no argument is provided
73
73
  def self.skills(*skills, &block)
74
74
  return @skills if skills.empty? && !block
75
- @skills = block || skills.flatten
75
+ if skills.size == 1 and skills.grep(Symbol).any?
76
+ @skills = skills.first
77
+ else
78
+ @skills = block || skills.flatten
79
+ end
76
80
  end
77
81
 
78
82
  ##
@@ -160,14 +164,31 @@ module LLM
160
164
  ##
161
165
  # Set or get the tool names that require confirmation before they can run.
162
166
  #
167
+ # When a single Symbol is given, it is stored as-is and resolved at
168
+ # initialization time by calling the method with that name on the agent
169
+ # instance. This allows dynamic tool confirmation lists.
170
+ #
171
+ # @example
172
+ # class MyAgent < LLM::Agent
173
+ # confirm :tools_that_need_confirmation
174
+ #
175
+ # def tools_that_need_confirmation
176
+ # some_condition ? %w[delete destroy] : %w[delete]
177
+ # end
178
+ # end
179
+ #
163
180
  # @param [String, Symbol, Array<String, Symbol>, Proc] tool_names
164
181
  # One or more tool names.
165
182
  # @param [Proc] block
166
183
  # An optional, lazy-evaluated Proc
167
- # @return [Array<String>, Proc, nil]
184
+ # @return [Array<String>, Proc, Symbol, nil]
168
185
  def self.confirm(*tool_names, &block)
169
186
  return @confirm if tool_names.empty? && !block
170
- @confirm = block || tool_names.flatten.map(&:to_s)
187
+ if tool_names.size == 1 && tool_names.grep(Symbol).any?
188
+ @confirm = tool_names.first
189
+ else
190
+ @confirm = block || tool_names.flatten.map(&:to_s)
191
+ end
171
192
  end
172
193
 
173
194
  ##
@@ -190,7 +211,7 @@ module LLM
190
211
  fields_ivar = %i[tracer concurrency instructions confirm]
191
212
  fields.each do |field|
192
213
  resolvable = params.key?(field) ? params.delete(field) : self.class.public_send(field)
193
- resolve_symbol = !%i[concurrency confirm].include?(field)
214
+ resolve_symbol = !%i[concurrency].include?(field)
194
215
  resolved = resolvable != nil ? resolve_option(self, resolvable, resolve_symbol:) : resolvable
195
216
  resolved = [*resolved].map(&:to_s) if field == :confirm && resolved
196
217
  if field == :model
@@ -447,10 +468,13 @@ module LLM
447
468
  strategy = concurrency || :call
448
469
  return wait(strategy) unless @confirm&.any?
449
470
  confirmables = @ctx.functions.select { @confirm.include?(_1.name.to_s) }
450
- results = confirmables.map do |tool|
451
- send(:on_tool_confirmation, tool, strategy)
471
+ results = confirmables.map { method(:on_tool_confirmation).call(_1, strategy) }
472
+ @ctx.method(:emit_tool_returns).call(confirmables, results)
473
+ if (@ctx.functions - confirmables).any?
474
+ [*results, *wait(strategy, except: confirmables)]
475
+ else
476
+ results
452
477
  end
453
- @ctx.functions? ? [*results, *wait(strategy)] : results
454
478
  end
455
479
 
456
480
  ##
data/lib/llm/context.rb CHANGED
@@ -303,15 +303,21 @@ module LLM
303
303
  # without using this argument.
304
304
  # Otherwise, this controls how pending functions are resolved directly.
305
305
  # Use `:call` for sequential execution without spawning.
306
+ # @param [Array<LLM::Function>] except
307
+ # A list of functions to exclude from the wait
306
308
  # @return [Array<LLM::Function::Return>]
307
- def wait(strategy)
309
+ def wait(strategy, except: [])
308
310
  if LLM::Stream === stream && !stream.queue.empty?
309
311
  @queue = stream.queue
310
312
  @queue.wait
311
313
  else
312
- return guarded_returns if guarded_returns
313
- @queue = functions.spawn(strategy)
314
- @queue.wait
314
+ tools = except.empty? ? functions : functions - except
315
+ guards = guarded_returns(tools:)
316
+ return guards if guards
317
+ @queue = tools.spawn(strategy)
318
+ returns = @queue.wait
319
+ emit_tool_returns(tools, returns)
320
+ returns
315
321
  end
316
322
  ensure
317
323
  @queue = nil
@@ -516,10 +522,10 @@ module LLM
516
522
  ##
517
523
  # Builds in-band guarded returns when the guard blocks tool work.
518
524
  # @api private
519
- def guarded_returns
525
+ def guarded_returns(tools:)
520
526
  warning = guard&.call(self)
521
527
  return unless warning
522
- functions.map { guarded_return_for(_1, warning) }
528
+ tools.map { guarded_return_for(_1, warning) }
523
529
  end
524
530
 
525
531
  ##
@@ -568,6 +574,14 @@ module LLM
568
574
  })
569
575
  end
570
576
 
577
+ ##
578
+ # Emits tool return callbacks for directly waited function work.
579
+ # @api private
580
+ def emit_tool_returns(tools, returns)
581
+ return unless LLM::Stream === stream
582
+ returns.each_with_index { |result, index| stream.on_tool_return(tools[index], result) }
583
+ end
584
+
571
585
  ##
572
586
  # Closes assistant tool-call messages that do not have matching tool
573
587
  # responses. This can happen when a turn is interrupted while a tool
data/lib/llm/error.rb CHANGED
@@ -35,6 +35,10 @@ module LLM
35
35
  # HTTPServerError
36
36
  ServerError = Class.new(Error)
37
37
 
38
+ ##
39
+ # HTTPNotFound
40
+ NotFoundError = Class.new(Error)
41
+
38
42
  ##
39
43
  # When an given an input object that is not understood
40
44
  FormatError = Class.new(Error)
@@ -68,5 +68,11 @@ class LLM::Function
68
68
  def wait(strategy)
69
69
  spawn(strategy).wait
70
70
  end
71
+
72
+ ##
73
+ # @return [LLM::Function::Array]
74
+ def -(other)
75
+ super.extend(Array)
76
+ end
71
77
  end
72
78
  end
data/lib/llm/function.rb CHANGED
@@ -120,6 +120,25 @@ class LLM::Function
120
120
  @arguments = LLM::Object.from(other)
121
121
  end
122
122
 
123
+ ##
124
+ # Compares functions by tool call ID when both sides have one.
125
+ # @param [LLM::Function] other
126
+ # @return [Boolean]
127
+ def ==(other)
128
+ return true if equal?(other)
129
+ return false unless self.class === other
130
+ return false unless id && other.id
131
+ id == other.id
132
+ end
133
+ alias_method :eql?, :==
134
+
135
+ ##
136
+ # Returns a hash value compatible with {#==}.
137
+ # @return [Integer]
138
+ def hash
139
+ id ? id.hash : object_id.hash
140
+ end
141
+
123
142
  ##
124
143
  # Returns a tracer, or nil
125
144
  # @return [LLM::Tracer, nil]
@@ -300,6 +319,13 @@ class LLM::Function
300
319
  @cancelled
301
320
  end
302
321
 
322
+ ##
323
+ # Returns true when this function is backed by a skill tool.
324
+ # @return [Boolean]
325
+ def skill?
326
+ @runner.respond_to?(:skill?) and @runner.skill?
327
+ end
328
+
303
329
  ##
304
330
  # Returns true when a function has neither been called nor cancelled
305
331
  # @return [Boolean]
@@ -35,9 +35,15 @@ module LLM
35
35
  class JSONAdapter::JSON < JSONAdapter
36
36
  ##
37
37
  # @return (see JSONAdapter#dump)
38
- def self.dump(obj, ...)
38
+ def self.dump(obj, state = nil, **options)
39
39
  require "json" unless defined?(::JSON)
40
- ::JSON.dump(obj, ...)
40
+ if ::JSON::State === state
41
+ ::JSON.generate(obj, state)
42
+ elsif state
43
+ ::JSON.dump(obj, state, **options)
44
+ else
45
+ ::JSON.dump(obj, **options)
46
+ end
41
47
  end
42
48
 
43
49
  ##
@@ -16,16 +16,18 @@ module LLM::MCP::Transport
16
16
  # Extra headers to send with requests
17
17
  # @param [Integer, nil] timeout
18
18
  # The timeout in seconds. Defaults to nil
19
- # @param [LLM::Transport, Class, nil] transport
20
- # Optional override with any {LLM::Transport} instance or subclass
19
+ # @param [Boolean] persistent
20
+ # Whether to use persistent HTTP connections
21
+ # @param [LLM::Transport, Class, Symbol, nil] transport
22
+ # Optional override with any {LLM::Transport} instance, subclass, or shortcut
21
23
  # @return [LLM::MCP::Transport::HTTP]
22
- def initialize(url:, headers: {}, timeout: nil, transport: nil)
24
+ def initialize(url:, headers: {}, timeout: nil, persistent: false, transport: nil)
23
25
  @uri = URI.parse(url)
24
26
  @headers = headers
25
- @transport = resolve_transport(uri, transport, timeout)
26
27
  @queue = []
27
28
  @monitor = Monitor.new
28
29
  @running = false
30
+ @transport = resolve_transport(host: uri.host, port: uri.port, ssl: uri.scheme == "https", timeout:, persistent:, transport:)
29
31
  end
30
32
 
31
33
  ##
@@ -62,7 +64,7 @@ module LLM::MCP::Transport
62
64
  # @return [void]
63
65
  def write(message)
64
66
  raise LLM::MCP::Error, "MCP transport is not running" unless running?
65
- req = Net::HTTP::Post.new(uri.request_uri, headers.merge("content-type" => "application/json"))
67
+ req = LLM::Transport::Request.post(uri.request_uri, headers.merge("content-type" => "application/json"))
66
68
  req.body = LLM.json.dump(message)
67
69
  res = transport.request(req, owner: self) { consume(_1) }
68
70
  res = LLM::Transport::Response.from(res)
data/lib/llm/mcp.rb CHANGED
@@ -55,9 +55,11 @@ class LLM::MCP
55
55
  # The URL for the MCP HTTP endpoint
56
56
  # @option http [Hash] :headers
57
57
  # Extra headers for requests
58
- # @option http [LLM::Transport, Class] :transport
59
- # Optional override with any {LLM::Transport} instance or subclass,
60
- # similar to {LLM::Provider}
58
+ # @option http [Boolean] :persistent
59
+ # Whether to use persistent HTTP connections
60
+ # @option http [LLM::Transport, Class, Symbol] :transport
61
+ # Optional override with any {LLM::Transport} instance, subclass, or
62
+ # shortcut, similar to {LLM::Provider}
61
63
  # @param [Integer] timeout
62
64
  # The maximum amount of time to wait when reading from an MCP process
63
65
  # @return [LLM::MCP] A new MCP instance
@@ -69,10 +71,7 @@ class LLM::MCP
69
71
  @command = Command.new(**stdio)
70
72
  @transport = Transport::Stdio.new(command:)
71
73
  elsif http
72
- persistent = http.delete(:persistent)
73
- transport = http.delete(:transport)
74
- transport ||= LLM::Transport::PersistentHTTP if persistent
75
- @transport = Transport::HTTP.new(**http, timeout:, transport:)
74
+ @transport = Transport::HTTP.new(**http, timeout:)
76
75
  else
77
76
  raise ArgumentError, "stdio or http is required"
78
77
  end
data/lib/llm/provider.rb CHANGED
@@ -35,7 +35,7 @@ class LLM::Provider
35
35
  @base_path = LLM::Utils.normalize_base_path(base_path)
36
36
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
37
37
  @headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
38
- @transport = resolve_transport(transport, persistent:)
38
+ @transport = LLM::Transport::Utils.resolve_transport(host:, port:, timeout:, ssl:, transport:, persistent:)
39
39
  @monitor = Monitor.new
40
40
  end
41
41
 
@@ -417,23 +417,6 @@ class LLM::Provider
417
417
  @monitor.synchronize(&)
418
418
  end
419
419
 
420
- ##
421
- # @api private
422
- def default_transport(persistent:)
423
- transport_class = persistent ? LLM::Transport::PersistentHTTP : LLM::Transport::HTTP
424
- transport_class.new(host:, port:, timeout:, ssl:)
425
- end
426
-
427
- ##
428
- # @api private
429
- def resolve_transport(transport, persistent:)
430
- return default_transport(persistent:) if transport.nil?
431
- if Class === transport && transport <= LLM::Transport
432
- return transport.new(host:, port:, timeout:, ssl:)
433
- end
434
- transport
435
- end
436
-
437
420
  ##
438
421
  # @api private
439
422
  def thread
@@ -49,6 +49,8 @@ class LLM::Anthropic
49
49
  LLM::UnauthorizedError.new("Authentication error").tap { _1.response = res }
50
50
  elsif res.rate_limited?
51
51
  LLM::RateLimitError.new("Too many requests").tap { _1.response = res }
52
+ elsif res.not_found?
53
+ LLM::NotFoundError.new("Server response: not found (404)").tap { _1.response = res }
52
54
  else
53
55
  LLM::Error.new("Unexpected response").tap { _1.response = res }
54
56
  end
@@ -37,7 +37,7 @@ class LLM::Anthropic
37
37
  # @return [LLM::Response]
38
38
  def all(**params)
39
39
  query = URI.encode_www_form(params)
40
- req = Net::HTTP::Get.new("/v1/files?#{query}", headers)
40
+ req = LLM::Transport::Request.get("/v1/files?#{query}", headers)
41
41
  res, span, tracer = execute(request: req, operation: "request")
42
42
  res = ResponseAdapter.adapt(res, type: :enumerable)
43
43
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -56,7 +56,7 @@ class LLM::Anthropic
56
56
  # @return [LLM::Response]
57
57
  def create(file:, **params)
58
58
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file)))
59
- req = Net::HTTP::Post.new("/v1/files", headers)
59
+ req = LLM::Transport::Request.post("/v1/files", headers)
60
60
  req["content-type"] = multi.content_type
61
61
  transport.set_body_stream(req, multi.body)
62
62
  res, span, tracer = execute(request: req, operation: "request")
@@ -79,7 +79,7 @@ class LLM::Anthropic
79
79
  def get(file:, **params)
80
80
  file_id = file.respond_to?(:id) ? file.id : file
81
81
  query = URI.encode_www_form(params)
82
- req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
82
+ req = LLM::Transport::Request.get("/v1/files/#{file_id}?#{query}", headers)
83
83
  res, span, tracer = execute(request: req, operation: "request")
84
84
  res = ResponseAdapter.adapt(res, type: :file)
85
85
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -100,7 +100,7 @@ class LLM::Anthropic
100
100
  def get_metadata(file:, **params)
101
101
  query = URI.encode_www_form(params)
102
102
  file_id = file.respond_to?(:id) ? file.id : file
103
- req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
103
+ req = LLM::Transport::Request.get("/v1/files/#{file_id}?#{query}", headers)
104
104
  res, span, tracer = execute(request: req, operation: "request")
105
105
  res = ResponseAdapter.adapt(res, type: :file)
106
106
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -120,7 +120,7 @@ class LLM::Anthropic
120
120
  # @return [LLM::Response]
121
121
  def delete(file:)
122
122
  file_id = file.respond_to?(:id) ? file.id : file
123
- req = Net::HTTP::Delete.new("/v1/files/#{file_id}", headers)
123
+ req = LLM::Transport::Request.delete("/v1/files/#{file_id}", headers)
124
124
  res, span, tracer = execute(request: req, operation: "request")
125
125
  res = LLM::Response.new(res)
126
126
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -145,7 +145,7 @@ class LLM::Anthropic
145
145
  def download(file:, **params)
146
146
  query = URI.encode_www_form(params)
147
147
  file_id = file.respond_to?(:id) ? file.id : file
148
- req = Net::HTTP::Get.new("/v1/files/#{file_id}/content?#{query}", headers)
148
+ req = LLM::Transport::Request.get("/v1/files/#{file_id}/content?#{query}", headers)
149
149
  io = StringIO.new("".b)
150
150
  res, span, tracer = execute(request: req, operation: "request") { |res| res.read_body { |chunk| io << chunk } }
151
151
  res = LLM::Response.new(res).tap { _1.define_singleton_method(:file) { io } }
@@ -39,7 +39,7 @@ class LLM::Anthropic
39
39
  # @return [LLM::Response]
40
40
  def all(**params)
41
41
  query = URI.encode_www_form(params)
42
- req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
42
+ req = LLM::Transport::Request.get("/v1/models?#{query}", headers)
43
43
  res, span, tracer = execute(request: req, operation: "request")
44
44
  res = ResponseAdapter.adapt(res, type: :models)
45
45
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -160,7 +160,7 @@ module LLM
160
160
  messages = build_complete_messages(prompt, params, role)
161
161
  payload = adapt(messages)
162
162
  body = LLM.json.dump(payload.merge!(params))
163
- req = Net::HTTP::Post.new("/v1/messages", headers)
163
+ req = LLM::Transport::Request.post("/v1/messages", headers)
164
164
  transport.set_body_stream(req, StringIO.new(body))
165
165
  req
166
166
  end
@@ -53,7 +53,7 @@ class LLM::Bedrock
53
53
  elsif res.rate_limited?
54
54
  LLM::RateLimitError.new(message).tap { _1.response = res }
55
55
  elsif res.not_found?
56
- LLM::Error.new("Bedrock model not found: #{message}").tap { _1.response = res }
56
+ LLM::NotFoundError.new("Server response: not found (404)").tap { _1.response = res }
57
57
  else
58
58
  LLM::Error.new(message).tap { _1.response = res }
59
59
  end
@@ -57,13 +57,13 @@ class LLM::Bedrock
57
57
  ##
58
58
  # @param [String] host
59
59
  # @param [Hash] params
60
- # @return [Net::HTTP::Get]
60
+ # @return [LLM::Transport::Request]
61
61
  def build_request(host, params)
62
62
  path = "/foundation-models"
63
63
  query = URI.encode_www_form(params) unless params.empty?
64
64
  path = "#{path}?#{query}" if query && !query.empty?
65
65
  body = ""
66
- req = Net::HTTP::Get.new(path, {"Content-Type" => "application/json", "Accept" => "application/json"})
66
+ req = LLM::Transport::Request.get(path, {"Content-Type" => "application/json", "Accept" => "application/json"})
67
67
  req.tap { sign!(req, body, host, query) }
68
68
  end
69
69
 
@@ -84,11 +84,11 @@ class LLM::Bedrock
84
84
  end
85
85
 
86
86
  ##
87
- # @param [Net::HTTPRequest] req
87
+ # @param [LLM::Transport::Request] req
88
88
  # @param [String] body
89
89
  # @param [String] host
90
90
  # @param [String, nil] query
91
- # @return [Net::HTTPRequest]
91
+ # @return [LLM::Transport::Request]
92
92
  def sign!(req, body, host = credentials.host, query = nil)
93
93
  creds = credentials.tap { _1.host = host }
94
94
  Signature.new(credentials: creds, method: "GET", path: "/foundation-models", query:, body:).sign!(req)
@@ -8,7 +8,7 @@ class LLM::Bedrock
8
8
  # Signs HTTP requests and headers with AWS Signature V4.
9
9
  #
10
10
  # Returns the signed headers as a Hash through #to_h, ready to merge
11
- # into a Net::HTTPRequest or other HTTP client. Everything else is
11
+ # into an {LLM::Transport::Request} or other HTTP client. Everything else is
12
12
  # private.
13
13
  #
14
14
  # Uses only Ruby's stdlib (openssl, digest) with no external deps.
@@ -89,8 +89,8 @@ class LLM::Bedrock
89
89
  end
90
90
 
91
91
  ##
92
- # @param [Net::HTTPRequest] req
93
- # @return [Net::HTTPRequest]
92
+ # @param [LLM::Transport::Request] req
93
+ # @return [LLM::Transport::Request]
94
94
  def sign!(req)
95
95
  to_h.each { |k, v| req[k] = v }
96
96
  req
@@ -217,7 +217,7 @@ module LLM
217
217
  body = LLM.json.dump(payload)
218
218
  path = stream ? "/model/#{model_id}/converse-stream" \
219
219
  : "/model/#{model_id}/converse"
220
- req = Net::HTTP::Post.new(path, headers)
220
+ req = LLM::Transport::Request.post(path, headers)
221
221
  transport.set_body_stream(req, StringIO.new(body))
222
222
  [req, messages, body]
223
223
  end
@@ -60,6 +60,8 @@ class LLM::Google
60
60
  end
61
61
  elsif res.rate_limited?
62
62
  LLM::RateLimitError.new("Too many requests").tap { _1.response = res }
63
+ elsif res.not_found?
64
+ LLM::NotFoundError.new("Server response: not found (404)").tap { _1.response = res }
63
65
  else
64
66
  LLM::Error.new("Unexpected response").tap { _1.response = res }
65
67
  end
@@ -45,7 +45,7 @@ class LLM::Google
45
45
  # @return [LLM::Response]
46
46
  def all(**params)
47
47
  query = URI.encode_www_form(params.merge!(key: key))
48
- req = Net::HTTP::Get.new("/v1beta/files?#{query}", headers)
48
+ req = LLM::Transport::Request.get("/v1beta/files?#{query}", headers)
49
49
  res, span, tracer = execute(request: req, operation: "request")
50
50
  res = ResponseAdapter.adapt(res, type: :files)
51
51
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -64,7 +64,7 @@ class LLM::Google
64
64
  # @return [LLM::Response]
65
65
  def create(file:, **params)
66
66
  file = LLM.File(file)
67
- req = Net::HTTP::Post.new(request_upload_url(file:), {})
67
+ req = LLM::Transport::Request.post(request_upload_url(file:), {})
68
68
  req["content-length"] = file.bytesize
69
69
  req["X-Goog-Upload-Offset"] = 0
70
70
  req["X-Goog-Upload-Command"] = "upload, finalize"
@@ -91,7 +91,7 @@ class LLM::Google
91
91
  def get(file:, **params)
92
92
  file_id = file.respond_to?(:name) ? file.name : file.to_s
93
93
  query = URI.encode_www_form(params.merge!(key: key))
94
- req = Net::HTTP::Get.new("/v1beta/#{file_id}?#{query}", headers)
94
+ req = LLM::Transport::Request.get("/v1beta/#{file_id}?#{query}", headers)
95
95
  res, span, tracer = execute(request: req, operation: "request")
96
96
  res = ResponseAdapter.adapt(res, type: :file)
97
97
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -111,7 +111,7 @@ class LLM::Google
111
111
  def delete(file:, **params)
112
112
  file_id = file.respond_to?(:name) ? file.name : file.to_s
113
113
  query = URI.encode_www_form(params.merge!(key: key))
114
- req = Net::HTTP::Delete.new("/v1beta/#{file_id}?#{query}", headers)
114
+ req = LLM::Transport::Request.delete("/v1beta/#{file_id}?#{query}", headers)
115
115
  res, span, tracer = execute(request: req, operation: "request")
116
116
  res = LLM::Response.new(res)
117
117
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -128,7 +128,7 @@ class LLM::Google
128
128
  private
129
129
 
130
130
  def request_upload_url(file:)
131
- req = Net::HTTP::Post.new("/upload/v1beta/files?key=#{key}", headers)
131
+ req = LLM::Transport::Request.post("/upload/v1beta/files?key=#{key}", headers)
132
132
  req["X-Goog-Upload-Protocol"] = "resumable"
133
133
  req["X-Goog-Upload-Command"] = "start"
134
134
  req["X-Goog-Upload-Header-Content-Length"] = file.bytesize
@@ -40,7 +40,7 @@ class LLM::Google
40
40
  # @raise (see LLM::Provider#request)
41
41
  # @return [LLM::Response]
42
42
  def create(prompt:, n: 1, image_size: nil, aspect_ratio: nil, person_generation: nil, model: "imagen-4.0-generate-001", **params)
43
- req = Net::HTTP::Post.new("/v1beta/models/#{model}:predict?key=#{key}", headers)
43
+ req = LLM::Transport::Request.post("/v1beta/models/#{model}:predict?key=#{key}", headers)
44
44
  body = LLM.json.dump({
45
45
  parameters: {
46
46
  sampleCount: n,