llm.rb 8.1.0 → 10.0.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -6
  3. data/README.md +233 -518
  4. data/data/anthropic.json +278 -258
  5. data/data/bedrock.json +1288 -1561
  6. data/data/deepseek.json +38 -38
  7. data/data/google.json +656 -579
  8. data/data/openai.json +860 -818
  9. data/data/xai.json +243 -552
  10. data/data/zai.json +168 -168
  11. data/lib/llm/active_record/acts_as_agent.rb +5 -0
  12. data/lib/llm/active_record/acts_as_llm.rb +7 -8
  13. data/lib/llm/active_record.rb +1 -6
  14. data/lib/llm/agent.rb +121 -82
  15. data/lib/llm/context.rb +79 -74
  16. data/lib/llm/contract/completion.rb +45 -0
  17. data/lib/llm/cost.rb +81 -4
  18. data/lib/llm/error.rb +1 -1
  19. data/lib/llm/function/array.rb +8 -5
  20. data/lib/llm/function/call_group.rb +39 -0
  21. data/lib/llm/function/call_task.rb +46 -0
  22. data/lib/llm/function/fork/task.rb +6 -0
  23. data/lib/llm/function/ractor/task.rb +6 -0
  24. data/lib/llm/function/task.rb +10 -0
  25. data/lib/llm/function.rb +28 -1
  26. data/lib/llm/mcp/transport/http.rb +26 -46
  27. data/lib/llm/mcp/transport/stdio.rb +0 -8
  28. data/lib/llm/mcp.rb +6 -23
  29. data/lib/llm/provider.rb +30 -20
  30. data/lib/llm/providers/anthropic/error_handler.rb +6 -7
  31. data/lib/llm/providers/anthropic/files.rb +2 -2
  32. data/lib/llm/providers/anthropic/response_adapter/completion.rb +30 -0
  33. data/lib/llm/providers/anthropic/stream_parser.rb +2 -2
  34. data/lib/llm/providers/anthropic.rb +1 -1
  35. data/lib/llm/providers/bedrock/error_handler.rb +8 -9
  36. data/lib/llm/providers/bedrock/models.rb +13 -13
  37. data/lib/llm/providers/bedrock/response_adapter/completion.rb +30 -0
  38. data/lib/llm/providers/bedrock/stream_parser.rb +2 -2
  39. data/lib/llm/providers/bedrock.rb +1 -1
  40. data/lib/llm/providers/google/error_handler.rb +6 -7
  41. data/lib/llm/providers/google/files.rb +2 -4
  42. data/lib/llm/providers/google/images.rb +1 -1
  43. data/lib/llm/providers/google/models.rb +0 -2
  44. data/lib/llm/providers/google/response_adapter/completion.rb +30 -0
  45. data/lib/llm/providers/google/stream_parser.rb +2 -2
  46. data/lib/llm/providers/google.rb +1 -1
  47. data/lib/llm/providers/ollama/error_handler.rb +6 -7
  48. data/lib/llm/providers/ollama/models.rb +0 -2
  49. data/lib/llm/providers/ollama/response_adapter/completion.rb +30 -0
  50. data/lib/llm/providers/ollama.rb +1 -1
  51. data/lib/llm/providers/openai/audio.rb +3 -3
  52. data/lib/llm/providers/openai/error_handler.rb +6 -7
  53. data/lib/llm/providers/openai/files.rb +2 -2
  54. data/lib/llm/providers/openai/images.rb +3 -3
  55. data/lib/llm/providers/openai/models.rb +1 -1
  56. data/lib/llm/providers/openai/response_adapter/completion.rb +42 -0
  57. data/lib/llm/providers/openai/response_adapter/responds.rb +39 -0
  58. data/lib/llm/providers/openai/responses/stream_parser.rb +2 -2
  59. data/lib/llm/providers/openai/responses.rb +2 -2
  60. data/lib/llm/providers/openai/stream_parser.rb +2 -2
  61. data/lib/llm/providers/openai/vector_stores.rb +1 -1
  62. data/lib/llm/providers/openai.rb +1 -1
  63. data/lib/llm/response.rb +10 -8
  64. data/lib/llm/schema.rb +11 -0
  65. data/lib/llm/sequel/agent.rb +5 -0
  66. data/lib/llm/sequel/plugin.rb +8 -14
  67. data/lib/llm/stream/queue.rb +15 -42
  68. data/lib/llm/stream.rb +15 -40
  69. data/lib/llm/tool/param.rb +1 -8
  70. data/lib/llm/transport/execution.rb +67 -0
  71. data/lib/llm/transport/http.rb +134 -0
  72. data/lib/llm/transport/persistent_http.rb +152 -0
  73. data/lib/llm/transport/response/http.rb +113 -0
  74. data/lib/llm/transport/response.rb +112 -0
  75. data/lib/llm/{provider/transport/http → transport}/stream_decoder.rb +8 -4
  76. data/lib/llm/transport.rb +139 -0
  77. data/lib/llm/usage.rb +14 -5
  78. data/lib/llm/utils.rb +24 -14
  79. data/lib/llm/version.rb +1 -1
  80. data/lib/llm.rb +3 -12
  81. data/llm.gemspec +2 -16
  82. metadata +13 -20
  83. data/lib/llm/bot.rb +0 -3
  84. data/lib/llm/provider/transport/http/execution.rb +0 -115
  85. data/lib/llm/provider/transport/http/interruptible.rb +0 -114
  86. data/lib/llm/provider/transport/http.rb +0 -145
data/lib/llm/cost.rb CHANGED
@@ -2,19 +2,96 @@
2
2
 
3
3
  ##
4
4
  # The {LLM::Cost LLM::Cost} class represents an approximate
5
- # cost breakdown for a provider request. It stores the input
6
- # and output costs separately and can return the total.
5
+ # cost breakdown for a provider request. It stores input,
6
+ # output, input audio, output audio, input image, cache read, cache write,
7
+ # and reasoning costs separately and can return the total.
7
8
  #
8
9
  # @attr [Float] input_costs
9
10
  # Returns the input cost
10
11
  # @attr [Float] output_costs
11
12
  # Returns the output cost
12
- class LLM::Cost < Struct.new(:input_costs, :output_costs)
13
+ # @attr [Float, nil] input_audio_costs
14
+ # Returns the input audio cost, or nil when no input audio tokens
15
+ # were used
16
+ # @attr [Float, nil] output_audio_costs
17
+ # Returns the output audio cost, or nil when no output audio tokens
18
+ # were used
19
+ # @attr [Float, nil] input_image_costs
20
+ # Returns the input image cost, or nil when no input image tokens
21
+ # were used
22
+ # @attr [Float, nil] cache_read_costs
23
+ # Returns the cache read cost, or nil when no cache tokens
24
+ # were used
25
+ # @attr [Float, nil] cache_write_costs
26
+ # Returns the cache write cost, or nil when no cache creation
27
+ # tokens were used
28
+ # @attr [Float, nil] reasoning_costs
29
+ # Returns the reasoning cost, or nil when no reasoning tokens
30
+ # were used
31
+ class LLM::Cost < Struct.new(
32
+ :input_costs, :output_costs,
33
+ :input_audio_costs, :output_audio_costs,
34
+ :cache_read_costs, :cache_write_costs,
35
+ :input_image_costs, :reasoning_costs,
36
+ keyword_init: true
37
+ )
38
+ ##
39
+ # Build a cost breakdown from token usage and model pricing.
40
+ # @param [LLM::Context]
41
+ # Context used to resolve provider, model, and token usage
42
+ # @return [LLM::Cost]
43
+ def self.from(ctx)
44
+ pricing = LLM.registry_for(ctx.llm).cost(model: ctx.model)
45
+ new(
46
+ input_costs: price(pricing.input, ctx.usage.input_tokens),
47
+ output_costs: price(pricing.output, ctx.usage.output_tokens),
48
+ input_audio_costs: price(pricing.input_audio, ctx.usage.input_audio_tokens),
49
+ output_audio_costs: price(pricing.output_audio, ctx.usage.output_audio_tokens),
50
+ input_image_costs: price(pricing.input, ctx.usage.input_image_tokens),
51
+ cache_read_costs: price(pricing.cache_read, ctx.usage.cache_read_tokens),
52
+ cache_write_costs: price(pricing.cache_write, ctx.usage.cache_write_tokens),
53
+ reasoning_costs: price(pricing.output, ctx.usage.reasoning_tokens)
54
+ )
55
+ rescue LLM::NoSuchModelError, LLM::NoSuchRegistryError
56
+ new
57
+ end
58
+
59
+ ##
60
+ # @api private
61
+ def self.price(rate, tokens)
62
+ return if tokens.nil? || tokens.to_i.zero?
63
+ return if rate.nil? || rate.to_f.zero?
64
+ ((rate.to_f / 1_000_000.0) * tokens.to_i).round(12)
65
+ end
66
+ private_class_method :price
67
+
13
68
  ##
14
69
  # @return [Float]
15
70
  # Returns the total cost
16
71
  def total
17
- input_costs + output_costs
72
+ [
73
+ input_costs, output_costs,
74
+ input_audio_costs, output_audio_costs,
75
+ cache_read_costs, cache_write_costs,
76
+ input_image_costs, reasoning_costs
77
+ ].compact.sum.round(12)
78
+ end
79
+
80
+ ##
81
+ # @return [Hash]
82
+ # Returns a hash with the non-nil cost components and the total
83
+ def to_h
84
+ {
85
+ input: input_costs,
86
+ output: output_costs,
87
+ input_audio: input_audio_costs,
88
+ output_audio: output_audio_costs,
89
+ input_image: input_image_costs,
90
+ cache_read: cache_read_costs,
91
+ cache_write: cache_write_costs,
92
+ reasoning: reasoning_costs,
93
+ total: total
94
+ }.compact
18
95
  end
19
96
 
20
97
  ##
data/lib/llm/error.rb CHANGED
@@ -5,7 +5,7 @@ module LLM
5
5
  # The superclass of all LLM errors
6
6
  class Error < RuntimeError
7
7
  ##
8
- # @return [Net::HTTPResponse, nil]
8
+ # @return [LLM::Transport::Response, nil]
9
9
  # Returns the response associated with an error, or nil
10
10
  attr_accessor :response
11
11
 
@@ -18,21 +18,23 @@ class LLM::Function
18
18
 
19
19
  ##
20
20
  # Calls all functions in a collection concurrently.
21
- # This method returns an {LLM::Function::ThreadGroup},
22
- # {LLM::Function::TaskGroup}, or {LLM::Function::FiberGroup}
23
- # that can be waited on to access the return values.
21
+ # This method returns an execution group that can be
22
+ # waited on to access the return values.
24
23
  #
25
24
  # @param [Symbol] strategy
26
25
  # Controls concurrency strategy:
26
+ # - `:call`: Call functions sequentially without spawning
27
27
  # - `:thread`: Use threads
28
28
  # - `:task`: Use async tasks (requires async gem)
29
29
  # - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
30
30
  # - `:fork`: Use forked child processes
31
31
  # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
32
32
  #
33
- # @return [LLM::Function::ThreadGroup, LLM::Function::TaskGroup, LLM::Function::FiberGroup, LLM::Function::Ractor::Group]
33
+ # @return [LLM::Function::CallGroup, LLM::Function::ThreadGroup, LLM::Function::TaskGroup, LLM::Function::FiberGroup, LLM::Function::Ractor::Group]
34
34
  def spawn(strategy)
35
35
  case strategy
36
+ when :call
37
+ CallGroup.new(self)
36
38
  when :task
37
39
  TaskGroup.new(map { |fn| fn.spawn(:task) })
38
40
  when :thread
@@ -44,7 +46,7 @@ class LLM::Function
44
46
  when :ractor
45
47
  Ractor::Group.new(map { |fn| fn.spawn(:ractor) })
46
48
  else
47
- raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, :fiber, :fork, or :ractor"
49
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :call, :thread, :task, :fiber, :fork, or :ractor"
48
50
  end
49
51
  end
50
52
 
@@ -54,6 +56,7 @@ class LLM::Function
54
56
  #
55
57
  # @param [Symbol] strategy
56
58
  # Controls concurrency strategy:
59
+ # - `:call`: Call each function sequentially through a call group
57
60
  # - `:thread`: Use threads
58
61
  # - `:task`: Use async tasks (requires async gem)
59
62
  # - `:fiber`: Use scheduler-backed fibers (requires Fiber.scheduler)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Function
4
+ ##
5
+ # The {LLM::Function::CallGroup} class wraps an array of
6
+ # {LLM::Function} objects for sequential execution.
7
+ #
8
+ # It provides the same basic interface as the concurrent group
9
+ # wrappers so callers can flow through `spawn(strategy).wait`
10
+ # uniformly, even when the selected strategy is direct calls.
11
+ class CallGroup
12
+ ##
13
+ # @param [Array<LLM::Function>] functions
14
+ # @return [LLM::Function::CallGroup]
15
+ def initialize(functions)
16
+ @functions = functions
17
+ end
18
+
19
+ ##
20
+ # @return [Boolean]
21
+ def alive?
22
+ false
23
+ end
24
+
25
+ ##
26
+ # @return [nil]
27
+ def interrupt!
28
+ nil
29
+ end
30
+ alias_method :cancel!, :interrupt!
31
+
32
+ ##
33
+ # @return [Array<LLM::Function::Return>]
34
+ def wait
35
+ @functions.map(&:call)
36
+ end
37
+ alias_method :value, :wait
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Function
4
+ ##
5
+ # The {LLM::Function::CallTask} class wraps a single direct function call
6
+ # behind the same task-like interface used by spawned concurrency modes.
7
+ class CallTask
8
+ ##
9
+ # @return [LLM::Function]
10
+ attr_reader :function
11
+
12
+ ##
13
+ # @param [LLM::Function] function
14
+ # @return [LLM::Function::CallTask]
15
+ def initialize(function)
16
+ @function = function
17
+ end
18
+
19
+ ##
20
+ # @return [Boolean]
21
+ def alive?
22
+ false
23
+ end
24
+
25
+ ##
26
+ # @return [nil]
27
+ def interrupt!
28
+ function.interrupt!
29
+ nil
30
+ end
31
+ alias_method :cancel!, :interrupt!
32
+
33
+ ##
34
+ # @return [LLM::Function::Return]
35
+ def wait
36
+ function.call
37
+ end
38
+ alias_method :value, :wait
39
+
40
+ ##
41
+ # @return [Class]
42
+ def group_class
43
+ LLM::Function::TaskGroup
44
+ end
45
+ end
46
+ end
@@ -63,6 +63,12 @@ class LLM::Function
63
63
  end
64
64
  alias_method :value, :wait
65
65
 
66
+ ##
67
+ # @return [Class]
68
+ def group_class
69
+ LLM::Function::Fork::Group
70
+ end
71
+
66
72
  private
67
73
 
68
74
  def reap
@@ -57,6 +57,12 @@ class LLM::Function
57
57
  end
58
58
  alias_method :value, :wait
59
59
 
60
+ ##
61
+ # @return [Class]
62
+ def group_class
63
+ LLM::Function::Ractor::Group
64
+ end
65
+
60
66
  private
61
67
 
62
68
  def build_task
@@ -53,6 +53,16 @@ class LLM::Function
53
53
  end
54
54
  alias_method :value, :wait
55
55
 
56
+ ##
57
+ # @return [Class]
58
+ def group_class
59
+ case task
60
+ when Thread then LLM::Function::ThreadGroup
61
+ when Fiber then LLM::Function::FiberGroup
62
+ else LLM::Function::TaskGroup
63
+ end
64
+ end
65
+
56
66
  private
57
67
 
58
68
  def scheduler
data/lib/llm/function.rb CHANGED
@@ -32,6 +32,8 @@ class LLM::Function
32
32
  require_relative "function/registry"
33
33
  require_relative "function/tracing"
34
34
  require_relative "function/array"
35
+ require_relative "function/call_group"
36
+ require_relative "function/call_task"
35
37
  require_relative "function/task"
36
38
  require_relative "function/thread_group"
37
39
  require_relative "function/fiber_group"
@@ -209,6 +211,7 @@ class LLM::Function
209
211
  #
210
212
  # @param [Symbol] strategy
211
213
  # Controls concurrency strategy:
214
+ # - `:call`: Call the function sequentially without spawning
212
215
  # - `:thread`: Use threads
213
216
  # - `:task`: Use async tasks (requires async gem)
214
217
  # - `:fork`: Use a forked child process (requires xchan.rb support)
@@ -220,6 +223,8 @@ class LLM::Function
220
223
  # Returns a task whose `#value` is an {LLM::Function::Return}.
221
224
  def spawn(strategy)
222
225
  task = case strategy
226
+ when :call
227
+ CallTask.new(self)
223
228
  when :task
224
229
  LLM.require "async" unless defined?(::Async)
225
230
  Async { call! }
@@ -240,7 +245,7 @@ class LLM::Function
240
245
  span = @tracer&.on_tool_start(id:, name:, arguments:, model:)
241
246
  Ractor::Task.new(@runner, id, name, arguments, tracer: @tracer, span:).spawn
242
247
  else
243
- raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, :fiber, :fork, or :ractor"
248
+ raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :call, :thread, :task, :fiber, :fork, or :ractor"
244
249
  end
245
250
  Task.new(task, self)
246
251
  ensure
@@ -294,6 +299,28 @@ class LLM::Function
294
299
  !@called && !@cancelled
295
300
  end
296
301
 
302
+ ##
303
+ # Returns an in-band error for an unresolved function call.
304
+ # @return [LLM::Function::Return]
305
+ def unavailable
306
+ Return.new(id, name, {
307
+ error: true,
308
+ type: LLM::NoSuchToolError.name,
309
+ message: "tool not found"
310
+ })
311
+ end
312
+
313
+ ##
314
+ # Returns an in-band error for a tool loop rate limit.
315
+ # @return [LLM::Function::Return]
316
+ def rate_limit
317
+ LLM::Function::Return.new(id, name, {
318
+ error: true,
319
+ type: LLM::ToolLoopError.name,
320
+ message: "tool loop rate limit reached"
321
+ })
322
+ end
323
+
297
324
  ##
298
325
  # @return [Hash]
299
326
  def adapt(provider)
@@ -16,12 +16,13 @@ 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
21
  # @return [LLM::MCP::Transport::HTTP]
20
- def initialize(url:, headers: {}, timeout: nil)
22
+ def initialize(url:, headers: {}, timeout: nil, transport: nil)
21
23
  @uri = URI.parse(url)
22
- @use_ssl = @uri.scheme == "https"
23
24
  @headers = headers
24
- @timeout = timeout
25
+ @transport = resolve_transport(transport, timeout:)
25
26
  @queue = []
26
27
  @monitor = Monitor.new
27
28
  @running = false
@@ -61,21 +62,11 @@ module LLM::MCP::Transport
61
62
  # @return [void]
62
63
  def write(message)
63
64
  raise LLM::MCP::Error, "MCP transport is not running" unless running?
64
- req = Net::HTTP::Post.new(uri.path, headers.merge("content-type" => "application/json"))
65
+ req = Net::HTTP::Post.new(uri.request_uri, headers.merge("content-type" => "application/json"))
65
66
  req.body = LLM.json.dump(message)
66
- if persistent_client.nil?
67
- http = Net::HTTP.start(uri.host, uri.port, use_ssl:, open_timeout: timeout, read_timeout: timeout)
68
- args = [req]
69
- else
70
- http = persistent_client
71
- args = [uri, req]
72
- end
73
- http.request(*args) do |res|
74
- unless Net::HTTPSuccess === res
75
- raise LLM::MCP::Error, "MCP transport write failed with HTTP #{res.code}"
76
- end
77
- read(res)
78
- end
67
+ res = transport.request(req, owner: self) { consume(_1) }
68
+ res = LLM::Transport::Response.from(res)
69
+ raise LLM::MCP::Error, "MCP transport write failed with HTTP #{res.code}" unless res.success?
79
70
  end
80
71
 
81
72
  ##
@@ -100,30 +91,27 @@ module LLM::MCP::Transport
100
91
  @running
101
92
  end
102
93
 
103
- ##
104
- # Configures the transport to use a persistent HTTP connection pool
105
- # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
106
- # @example
107
- # mcp = LLM::MCP.http(url: "https://example.com/mcp", persistent: true)
108
- # # do something with 'mcp'
109
- # @return [LLM::MCP::Transport::HTTP]
110
- def persist!
111
- LLM.lock(:mcp) do
112
- LLM.require "net/http/persistent" unless defined?(Net::HTTP::Persistent)
113
- unless LLM::MCP.clients.key?(key)
114
- http = Net::HTTP::Persistent.new(name: self.class.name)
115
- http.read_timeout = timeout
116
- http.open_timeout = timeout
117
- LLM::MCP.clients[key] ||= http
118
- end
119
- end
120
- self
94
+ private
95
+
96
+ attr_reader :uri, :headers, :transport
97
+
98
+ def consume(res)
99
+ res = LLM::Transport::Response.from(res)
100
+ read(res)
101
+ res
121
102
  end
122
- alias_method :persistent, :persist!
123
103
 
124
- private
104
+ def resolve_transport(transport, timeout:)
105
+ return default_transport(timeout:) if transport.nil?
106
+ if Class === transport && transport <= LLM::Transport
107
+ return transport.new(host: uri.host, port: uri.port, timeout:, ssl: uri.scheme == "https")
108
+ end
109
+ transport
110
+ end
125
111
 
126
- attr_reader :uri, :use_ssl, :headers, :timeout
112
+ def default_transport(timeout:)
113
+ LLM::Transport::HTTP.new(host: uri.host, port: uri.port, timeout:, ssl: uri.scheme == "https")
114
+ end
127
115
 
128
116
  def read(res)
129
117
  if res["content-type"].to_s.include?("text/event-stream")
@@ -142,14 +130,6 @@ module LLM::MCP::Transport
142
130
  lock { @queue << message }
143
131
  end
144
132
 
145
- def persistent_client
146
- LLM::MCP.clients[key]
147
- end
148
-
149
- def key
150
- "#{uri.scheme}:#{uri.host}:#{uri.port}:#{timeout}"
151
- end
152
-
153
133
  def lock(&)
154
134
  @monitor.synchronize(&)
155
135
  end
@@ -78,14 +78,6 @@ module LLM::MCP::Transport
78
78
  command.wait
79
79
  end
80
80
 
81
- ##
82
- # This method is a no-op for stdio transports
83
- # @return [LLM::MCP::Transport::Stdio]
84
- def persist!
85
- self
86
- end
87
- alias_method :persistent, :persist!
88
-
89
81
  private
90
82
 
91
83
  attr_reader :command, :stdin, :stdout, :stderr
data/lib/llm/mcp.rb CHANGED
@@ -24,14 +24,6 @@ class LLM::MCP
24
24
 
25
25
  include RPC
26
26
 
27
- @clients = {}
28
-
29
- ##
30
- # @api private
31
- def self.clients
32
- @clients
33
- end
34
-
35
27
  ##
36
28
  # Builds an MCP client that uses the stdio transport.
37
29
  # @param [LLM::Provider, nil] llm
@@ -69,6 +61,9 @@ class LLM::MCP
69
61
  # The URL for the MCP HTTP endpoint
70
62
  # @option http [Hash] :headers
71
63
  # Extra headers for requests
64
+ # @option http [LLM::Transport, Class] :transport
65
+ # Optional override with any {LLM::Transport} instance or subclass,
66
+ # similar to {LLM::Provider}
72
67
  # @param [Integer] timeout
73
68
  # The maximum amount of time to wait when reading from an MCP process
74
69
  # @return [LLM::MCP] A new MCP instance
@@ -82,8 +77,9 @@ class LLM::MCP
82
77
  @transport = Transport::Stdio.new(command:)
83
78
  elsif http
84
79
  persistent = http.delete(:persistent)
85
- @transport = Transport::HTTP.new(**http, timeout:)
86
- @transport.persistent if persistent
80
+ transport = http.delete(:transport)
81
+ transport ||= LLM::Transport::PersistentHTTP if persistent
82
+ @transport = Transport::HTTP.new(**http, timeout:, transport:)
87
83
  else
88
84
  raise ArgumentError, "stdio or http is required"
89
85
  end
@@ -121,19 +117,6 @@ class LLM::MCP
121
117
  stop
122
118
  end
123
119
 
124
- ##
125
- # Configures an HTTP MCP transport to use a persistent connection pool
126
- # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
127
- # @example
128
- # mcp = LLM::MCP.http(url: "https://example.com/mcp", persistent: true)
129
- # # do something with 'mcp'
130
- # @return [LLM::MCP]
131
- def persist!
132
- transport.persist!
133
- self
134
- end
135
- alias_method :persistent, :persist!
136
-
137
120
  ##
138
121
  # Returns the tools provided by the MCP process.
139
122
  # @return [Array<Class<LLM::Tool>>]
data/lib/llm/provider.rb CHANGED
@@ -6,10 +6,7 @@
6
6
  #
7
7
  # @abstract
8
8
  class LLM::Provider
9
- require "net/http"
10
- require_relative "provider/transport/http"
11
- require_relative "provider/transport/http/execution"
12
- include Transport::HTTP::Execution
9
+ include LLM::Transport::Execution
13
10
 
14
11
  ##
15
12
  # @param [String, nil] key
@@ -27,7 +24,9 @@ class LLM::Provider
27
24
  # @param [Boolean] persistent
28
25
  # Whether to use a persistent connection.
29
26
  # Requires the net-http-persistent gem.
30
- def initialize(key:, host:, port: 443, timeout: 60, ssl: true, base_path: "", persistent: false)
27
+ # @param [LLM::Transport, Class, nil] transport
28
+ # Optional override with any {LLM::Transport} instance or subclass.
29
+ def initialize(key:, host:, port: 443, timeout: 60, ssl: true, base_path: "", persistent: false, transport: nil)
31
30
  @key = key
32
31
  @host = host
33
32
  @port = port
@@ -36,7 +35,7 @@ class LLM::Provider
36
35
  @base_path = normalize_base_path(base_path)
37
36
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
38
37
  @headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
39
- @transport = Transport::HTTP.new(host:, port:, timeout:, ssl:, persistent:)
38
+ @transport = resolve_transport(transport, persistent:)
40
39
  @monitor = Monitor.new
41
40
  end
42
41
 
@@ -316,19 +315,6 @@ class LLM::Provider
316
315
  end
317
316
  end
318
317
 
319
- ##
320
- # This method configures a provider to use a persistent connection pool
321
- # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
322
- # @example
323
- # llm = LLM.openai(key: ENV["KEY"]).persistent
324
- # # do something with 'llm'
325
- # @return [LLM::Provider]
326
- def persist!
327
- transport.persist!
328
- self
329
- end
330
- alias_method :persistent, :persist!
331
-
332
318
  ##
333
319
  # Interrupt the active request, if any.
334
320
  # @param [Fiber] owner
@@ -353,6 +339,13 @@ class LLM::Provider
353
339
  LLM::Stream === stream || stream.respond_to?(:<<)
354
340
  end
355
341
 
342
+ ##
343
+ # @return [Boolean]
344
+ # Returns true when an API key is configured
345
+ def key?
346
+ @key != nil && @key.to_s.strip.size > 0
347
+ end
348
+
356
349
  private
357
350
 
358
351
  def path(suffix)
@@ -403,7 +396,7 @@ class LLM::Provider
403
396
  # @return [Class]
404
397
  # Returns the class responsible for decoding streamed response bodies
405
398
  def stream_decoder
406
- LLM::Provider::Transport::HTTP::StreamDecoder
399
+ LLM::Transport::StreamDecoder
407
400
  end
408
401
 
409
402
  ##
@@ -431,6 +424,23 @@ class LLM::Provider
431
424
  @monitor.synchronize(&)
432
425
  end
433
426
 
427
+ ##
428
+ # @api private
429
+ def default_transport(persistent:)
430
+ transport_class = persistent ? LLM::Transport::PersistentHTTP : LLM::Transport::HTTP
431
+ transport_class.new(host:, port:, timeout:, ssl:)
432
+ end
433
+
434
+ ##
435
+ # @api private
436
+ def resolve_transport(transport, persistent:)
437
+ return default_transport(persistent:) if transport.nil?
438
+ if Class === transport && transport <= LLM::Transport
439
+ return transport.new(host:, port:, timeout:, ssl:)
440
+ end
441
+ transport
442
+ end
443
+
434
444
  ##
435
445
  # @api private
436
446
  def thread
@@ -5,7 +5,7 @@ class LLM::Anthropic
5
5
  # @private
6
6
  class ErrorHandler
7
7
  ##
8
- # @return [Net::HTTPResponse]
8
+ # @return [LLM::Transport::Response]
9
9
  # Non-2XX response from the server
10
10
  attr_reader :res
11
11
 
@@ -19,13 +19,13 @@ class LLM::Anthropic
19
19
  # The tracer
20
20
  # @param [Object, nil] span
21
21
  # The span
22
- # @param [Net::HTTPResponse] res
22
+ # @param [LLM::Transport::Response, Net::HTTPResponse] res
23
23
  # The response from the server
24
24
  # @return [LLM::Anthropic::ErrorHandler]
25
25
  def initialize(tracer, span, res)
26
26
  @tracer = tracer
27
27
  @span = span
28
- @res = res
28
+ @res = LLM::Transport::Response.from(res)
29
29
  end
30
30
 
31
31
  ##
@@ -43,12 +43,11 @@ class LLM::Anthropic
43
43
  ##
44
44
  # @return [LLM::Error]
45
45
  def error
46
- case res
47
- when Net::HTTPServerError
46
+ if res.server_error?
48
47
  LLM::ServerError.new("Server error").tap { _1.response = res }
49
- when Net::HTTPUnauthorized
48
+ elsif res.unauthorized?
50
49
  LLM::UnauthorizedError.new("Authentication error").tap { _1.response = res }
51
- when Net::HTTPTooManyRequests
50
+ elsif res.rate_limited?
52
51
  LLM::RateLimitError.new("Too many requests").tap { _1.response = res }
53
52
  else
54
53
  LLM::Error.new("Unexpected response").tap { _1.response = res }
@@ -58,7 +58,7 @@ class LLM::Anthropic
58
58
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file)))
59
59
  req = Net::HTTP::Post.new("/v1/files", headers)
60
60
  req["content-type"] = multi.content_type
61
- set_body_stream(req, multi.body)
61
+ transport.set_body_stream(req, multi.body)
62
62
  res, span, tracer = execute(request: req, operation: "request")
63
63
  res = ResponseAdapter.adapt(res, type: :file)
64
64
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -159,7 +159,7 @@ class LLM::Anthropic
159
159
  @provider.instance_variable_get(:@key)
160
160
  end
161
161
 
162
- [:headers, :execute, :set_body_stream].each do |m|
162
+ [:headers, :execute, :transport].each do |m|
163
163
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
164
164
  end
165
165
  end