llm.rb 11.1.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/README.md +27 -4
  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 +20 -6
  8. data/lib/llm/function/array.rb +6 -0
  9. data/lib/llm/function.rb +26 -0
  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/provider.rb +1 -18
  14. data/lib/llm/providers/anthropic/files.rb +6 -6
  15. data/lib/llm/providers/anthropic/models.rb +1 -1
  16. data/lib/llm/providers/anthropic.rb +1 -1
  17. data/lib/llm/providers/bedrock/models.rb +4 -4
  18. data/lib/llm/providers/bedrock/signature.rb +3 -3
  19. data/lib/llm/providers/bedrock.rb +1 -1
  20. data/lib/llm/providers/google/files.rb +5 -5
  21. data/lib/llm/providers/google/images.rb +1 -1
  22. data/lib/llm/providers/google/models.rb +1 -1
  23. data/lib/llm/providers/google.rb +2 -2
  24. data/lib/llm/providers/ollama/models.rb +1 -1
  25. data/lib/llm/providers/ollama.rb +2 -2
  26. data/lib/llm/providers/openai/audio.rb +3 -3
  27. data/lib/llm/providers/openai/files.rb +5 -5
  28. data/lib/llm/providers/openai/images.rb +3 -3
  29. data/lib/llm/providers/openai/models.rb +1 -1
  30. data/lib/llm/providers/openai/moderations.rb +1 -1
  31. data/lib/llm/providers/openai/responses.rb +3 -3
  32. data/lib/llm/providers/openai/vector_stores.rb +11 -11
  33. data/lib/llm/providers/openai.rb +2 -2
  34. data/lib/llm/skill.rb +1 -1
  35. data/lib/llm/tool.rb +21 -0
  36. data/lib/llm/transport/curb.rb +246 -0
  37. data/lib/llm/transport/execution.rb +1 -1
  38. data/lib/llm/transport/http.rb +9 -4
  39. data/lib/llm/transport/net_http_adapter.rb +61 -0
  40. data/lib/llm/transport/persistent_http.rb +10 -5
  41. data/lib/llm/transport/request.rb +121 -0
  42. data/lib/llm/transport/response/curb.rb +112 -0
  43. data/lib/llm/transport/response.rb +1 -0
  44. data/lib/llm/transport/utils.rb +42 -17
  45. data/lib/llm/transport.rb +17 -45
  46. data/lib/llm/version.rb +1 -1
  47. data/llm.gemspec +3 -3
  48. metadata +8 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bb91948d8cfa006f7512dd0a4fa62f90b42360e3f11a57074870470fdc70d3f
4
- data.tar.gz: 64b49f633318bc0439252cebca4c3886db4c7676f3cb9a78f4945eefe58b4356
3
+ metadata.gz: bb1ffd1e0ecb17422014ec8f75c8b729f74d0a7cef4fd4e12681ef254411b24a
4
+ data.tar.gz: e5a7815d52c6fa99a38dec111c6d71aef9782272b97bfbad81e8f5bee913f918
5
5
  SHA512:
6
- metadata.gz: c56b48185604b22c44f7b4697da56a5fda8359a69e110392abf2510b47a4dc9aedbe5c6a9e64e733fafb5672c34d4a0833448fab5ca9de52c453c8a906080174
7
- data.tar.gz: a2a9241da0e8749569111573451c99c92ed3569fa7cee0c9178b8aa884a72b27a24f18e19566cef8a16c2e460420a392b83d441708da67a571a9e2648d4d81e2
6
+ metadata.gz: d7c2d1dac8ef97a5be2540896828b523ce1491e43f2fd8c78e53b8fbab34432bf6dedaee066afd43d261fb8f4e2fb9f7c3d8c112de83e60ee809d6ef77f41feb
7
+ data.tar.gz: d5073e8c5c156739cff4c68c65c4efaf88403229841ada3cdd1adb4920dfb58c00017ae7dbd73d12ef17b6c191b0b71ab4f15fd0c0ae29991dcffb1b840381d0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,89 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v11.2.0
6
+
7
+ Changes since `v11.1.0`.
8
+
9
+ This release adds `LLM::Function#skill?` and `LLM::Tool#skill?` so
10
+ callers can inspect whether a function or tool is backed by a skill.
11
+
12
+ It introduces `LLM::Transport::Request` as a transport-agnostic request
13
+ object so providers no longer depend directly on `Net::HTTP` request
14
+ classes, and adds an optional Curb (libcurl) backend alongside symbolic
15
+ transport shortcuts such as `transport: :curb`.
16
+
17
+ MCP and A2A clients now accept `persistent: true` matching provider configuration.
18
+ Several fixes land for tool return callback emission, function comparison by
19
+ tool call ID, function array filtering, skill tool inheritance, and JSON generator
20
+ state compatibility on Ruby 4.
21
+
22
+ ### Add
23
+
24
+ * **Add `LLM::Function#skill?`** <br>
25
+ Add `skill?` to `LLM::Function` so callers can check whether a
26
+ function is backed by a skill tool.
27
+
28
+ * **Add `LLM::Tool.skill?` and `LLM::Tool#skill?`** <br>
29
+ Add class-level `skill?` and instance-level `skill?` to
30
+ `LLM::Tool`, matching the existing `mcp?` and `a2a?` pattern.
31
+
32
+ * **Add `LLM::Transport::Request`** <br>
33
+ Add `LLM::Transport::Request` as a transport-agnostic request object
34
+ and update providers to build requests without depending directly on
35
+ Net::HTTP request classes. The built-in Net::HTTP transports still
36
+ accept existing Net::HTTP request objects through a compatibility
37
+ bridge, while alternative transports can handle the generic request
38
+ shape directly.
39
+
40
+ * **Add optional Curb transport support** <br>
41
+ Add `LLM::Transport::Curb`, an optional libcurl-backed transport
42
+ that can be selected with `transport: :curb`. Providers already
43
+ emit `LLM::Transport::Request` objects, so the Curb backend can
44
+ execute requests without routing through Net::HTTP.
45
+
46
+ * **Add symbolic transport shortcuts** <br>
47
+ Allow providers, MCP HTTP clients, and A2A HTTP clients to accept
48
+ transport shortcuts such as `transport: :curb` and
49
+ `transport: :net_http_persistent`.
50
+
51
+ * **Add persistent HTTP selection to MCP and A2A clients** <br>
52
+ Allow MCP and A2A HTTP clients to accept `persistent: true`, matching
53
+ provider configuration and selecting the persistent Net::HTTP
54
+ transport by default.
55
+
56
+ ### Fix
57
+
58
+ * **Support JSON generation state on Ruby 4** <br>
59
+ Handle JSON generator state objects in the standard JSON adapter so
60
+ schema objects serialize correctly when Ruby 4 calls custom `to_json`
61
+ methods during provider request generation.
62
+
63
+ * **Emit tool return callbacks for direct context waits** <br>
64
+ Emit `LLM::Stream#on_tool_return` when `LLM::Context#wait` executes
65
+ pending tool work directly instead of draining `LLM::Stream::Queue`.
66
+
67
+ * **Emit confirmed tool return callbacks once** <br>
68
+ Emit `LLM::Stream#on_tool_return` for confirmed and cancelled tool
69
+ calls, and exclude confirmed functions from later waits so mixed
70
+ confirmed and unconfirmed tool batches do not execute confirmed tools
71
+ twice.
72
+
73
+ * **Compare functions by tool call ID** <br>
74
+ Add `LLM::Function#==`, `#eql?`, and `#hash` so pending function
75
+ collections can compare tool calls by provider-assigned ID instead of
76
+ object identity.
77
+
78
+ * **Preserve function array behavior after filtering** <br>
79
+ Preserve `LLM::Function::Array` behavior when subtracting function
80
+ arrays so filtered tool batches can still spawn through the normal
81
+ function array API.
82
+
83
+ * **Prevent skills from inheriting skill-backed tools** <br>
84
+ Exclude skill-backed tools when a skill sub-agent uses `tools:
85
+ inherit`, preventing skills loaded through a parent context from
86
+ being recursively exposed to nested skill agents.
87
+
5
88
  ## v11.1.0
6
89
 
7
90
  Changes since `v11.0.0`.
@@ -133,7 +216,7 @@ requests outside `#session`, `LLM::Function#def` as a short alias for
133
216
 
134
217
  * **Fix context and agent JSON serialization through `LLM.json`** <br>
135
218
  Fix `LLM::Context#to_json` and `LLM::Agent#to_json` to serialize
136
- through `LLM.json.dump(...)` instead of plain `to_json`.
219
+ through `LLM.json.dump(...)` instead of plain `to_json`.
137
220
 
138
221
  * **Fix block-form ORM agent DSL forwarding** <br>
139
222
  Fix block-form `model { ... }`, `tools { ... }`, and
data/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  <img src="https://img.shields.io/badge/License-0BSD-orange.svg?" alt="License">
12
12
  </a>
13
13
  <a href="https://github.com/llmrb/llm.rb/tags">
14
- <img src="https://img.shields.io/badge/version-11.1.0-green.svg?" alt="Version">
14
+ <img src="https://img.shields.io/badge/version-11.2.0-green.svg?" alt="Version">
15
15
  </a>
16
16
  </p>
17
17
 
@@ -175,7 +175,9 @@ ctx.talk(ctx.wait(:call)) while ctx.functions?
175
175
  The HTTP transport can be used with or without the `session` method,
176
176
  and unlike the stdio transport it can remain efficient without the
177
177
  `session` method through a persistent connection pool that is available
178
- through the [LLM::Transport.net_http_persistent](https://0x1eef.github.io/x/llm.rb/LLM/Transport.html#method-c-net_http_persistent) transport:
178
+ through the
179
+ [LLM::Transport.net_http_persistent](https://0x1eef.github.io/x/llm.rb/LLM/Transport.html#method-c-net_http_persistent)
180
+ transport:
179
181
 
180
182
  ```ruby
181
183
  require "llm"
@@ -183,7 +185,7 @@ require "llm"
183
185
  llm = LLM.openai(key: ENV["KEY"])
184
186
  mcp = LLM::MCP.http(
185
187
  url: "https://remote-mcp.example.com",
186
- transport: LLM::Transport.net_http_persistent
188
+ transport: :net_http_persistent
187
189
  )
188
190
 
189
191
  ctx = LLM::Context.new(llm, tools: mcp.tools)
@@ -224,7 +226,7 @@ require "llm"
224
226
 
225
227
  a2a = LLM::A2A.rest(
226
228
  url: "https://remote-agent.example.com",
227
- transport: LLM::Transport.net_http_persistent
229
+ transport: :net_http_persistent
228
230
  )
229
231
  ```
230
232
 
@@ -232,6 +234,27 @@ For more on direct messaging, task operations, push notification
232
234
  configs, and JSON-RPC, see the
233
235
  [LLM::A2A API docs](https://0x1eef.github.io/x/llm.rb/LLM/A2A.html).
234
236
 
237
+ #### Transports
238
+
239
+ Providers use Ruby's standard library Net::HTTP transport by default.
240
+ You can opt into persistent Net::HTTP connections with `persistent: true`,
241
+ or provide a transport shortcut when you want a different backend.
242
+ `transport: :curb` uses libcurl through the optional `curb` gem.
243
+
244
+ Custom transports can implement the
245
+ [LLM::Transport](https://0x1eef.github.io/x/llm.rb/LLM/Transport.html)
246
+ interface and receive transport-agnostic
247
+ [LLM::Transport::Request](https://0x1eef.github.io/x/llm.rb/LLM/Transport/Request.html)
248
+ objects from providers.
249
+
250
+ ```ruby
251
+ require "llm"
252
+
253
+ llm = LLM.openai(key: ENV["KEY"], persistent: true)
254
+ llm = LLM.openai(key: ENV["KEY"], transport: :net_http_persistent)
255
+ llm = LLM.openai(key: ENV["KEY"], transport: :curb)
256
+ ```
257
+
235
258
  #### Skills
236
259
 
237
260
  Skills are reusable instructions loaded from a `SKILL.md` directory. They let
@@ -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
@@ -447,10 +447,13 @@ module LLM
447
447
  strategy = concurrency || :call
448
448
  return wait(strategy) unless @confirm&.any?
449
449
  confirmables = @ctx.functions.select { @confirm.include?(_1.name.to_s) }
450
- results = confirmables.map do |tool|
451
- send(:on_tool_confirmation, tool, strategy)
450
+ results = confirmables.map { method(:on_tool_confirmation).call(_1, strategy) }
451
+ @ctx.method(:emit_tool_returns).call(confirmables, results)
452
+ if (@ctx.functions - confirmables).any?
453
+ [*results, *wait(strategy, except: confirmables)]
454
+ else
455
+ results
452
456
  end
453
- @ctx.functions? ? [*results, *wait(strategy)] : results
454
457
  end
455
458
 
456
459
  ##
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
@@ -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
@@ -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
@@ -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)