llm.rb 4.12.0 → 4.14.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.
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Provider
4
+ module Transport
5
+ class HTTP
6
+ ##
7
+ # Internal request interruption methods for
8
+ # {LLM::Provider::Transport::HTTP}.
9
+ #
10
+ # This module tracks active requests by execution owner and provides
11
+ # the logic used to interrupt an in-flight request by closing the
12
+ # active HTTP connection.
13
+ #
14
+ # @api private
15
+ module Interruptible
16
+ INTERRUPT_ERRORS = [::IOError, ::EOFError, Errno::EBADF].freeze
17
+ Request = Struct.new(:http, :connection, keyword_init: true)
18
+
19
+ ##
20
+ # Interrupt an active request, if any.
21
+ # @param [Fiber] owner
22
+ # The execution owner whose request should be interrupted
23
+ # @return [nil]
24
+ def interrupt!(owner)
25
+ req = request_for(owner) or return
26
+ lock { (@interrupts ||= {})[owner] = true }
27
+ if persistent_http?(req.http)
28
+ close_socket(req.connection&.http)
29
+ req.http.finish(req.connection)
30
+ elsif transient_http?(req.http)
31
+ close_socket(req.http)
32
+ req.http.finish if req.http.active?
33
+ end
34
+ rescue *INTERRUPT_ERRORS
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ ##
41
+ # Closes the active socket for a request, if present.
42
+ # @param [Net::HTTP, nil] http
43
+ # @return [nil]
44
+ def close_socket(http)
45
+ socket = http&.instance_variable_get(:@socket) or return
46
+ socket = socket.io if socket.respond_to?(:io)
47
+ socket.close
48
+ rescue *INTERRUPT_ERRORS
49
+ nil
50
+ end
51
+
52
+ ##
53
+ # Returns whether the active request is using a transient HTTP client.
54
+ # @param [Object, nil] http
55
+ # @return [Boolean]
56
+ def transient_http?(http)
57
+ Net::HTTP === http
58
+ end
59
+
60
+ ##
61
+ # Returns whether the active request is using a persistent HTTP client.
62
+ # @param [Object, nil] http
63
+ # @return [Boolean]
64
+ def persistent_http?(http)
65
+ defined?(Net::HTTP::Persistent) && Net::HTTP::Persistent === http
66
+ end
67
+
68
+ ##
69
+ # Returns the active request for an execution owner.
70
+ # @param [Fiber] owner
71
+ # @return [Request, nil]
72
+ def request_for(owner)
73
+ lock do
74
+ @requests ||= {}
75
+ @requests[owner]
76
+ end
77
+ end
78
+
79
+ ##
80
+ # Records an active request for an execution owner.
81
+ # @param [Request] req
82
+ # @param [Fiber] owner
83
+ # @return [Request]
84
+ def set_request(req, owner)
85
+ lock do
86
+ @requests ||= {}
87
+ @requests[owner] = req
88
+ end
89
+ end
90
+
91
+ ##
92
+ # Clears the active request for an execution owner.
93
+ # @param [Fiber] owner
94
+ # @return [Request, nil]
95
+ def clear_request(owner)
96
+ lock { @requests&.delete(owner) }
97
+ end
98
+
99
+ ##
100
+ # Returns whether an execution owner was interrupted.
101
+ # @param [Fiber] owner
102
+ # @return [Boolean, nil]
103
+ def interrupted?(owner)
104
+ lock { @interrupts&.delete(owner) }
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Provider::Transport
4
+ ##
5
+ # @private
6
+ class HTTP::StreamDecoder
7
+ ##
8
+ # @return [Object]
9
+ attr_reader :parser
10
+
11
+ ##
12
+ # @param [#parse!, #body] parser
13
+ # @return [LLM::Provider::Transport::HTTP::StreamDecoder]
14
+ def initialize(parser)
15
+ @buffer = +""
16
+ @cursor = 0
17
+ @data = []
18
+ @parser = parser
19
+ end
20
+
21
+ ##
22
+ # @param [String] chunk
23
+ # @return [void]
24
+ def <<(chunk)
25
+ @buffer << chunk
26
+ each_line { handle_line(_1) }
27
+ end
28
+
29
+ ##
30
+ # @return [Object]
31
+ def body
32
+ parser.body
33
+ end
34
+
35
+ ##
36
+ # @return [void]
37
+ def free
38
+ @buffer.clear
39
+ @cursor = 0
40
+ @data.clear
41
+ parser.free if parser.respond_to?(:free)
42
+ end
43
+
44
+ private
45
+
46
+ def handle_line(line)
47
+ if line == "\n" || line == "\r\n"
48
+ flush_sse_event
49
+ elsif line.start_with?("data:")
50
+ @data << field_value(line)
51
+ elsif line.start_with?("event:", "id:", "retry:", ":")
52
+ else
53
+ decode!(strip_newline(line))
54
+ end
55
+ end
56
+
57
+ def flush_sse_event
58
+ return if @data.empty?
59
+ decode!(@data.join("\n"))
60
+ @data.clear
61
+ end
62
+
63
+ def field_value(line)
64
+ value_start = line.getbyte(5) == 32 ? 6 : 5
65
+ strip_newline(line.byteslice(value_start..))
66
+ end
67
+
68
+ def strip_newline(line)
69
+ line = line.byteslice(0, line.bytesize - 1) if line.end_with?("\n")
70
+ line = line.byteslice(0, line.bytesize - 1) if line.end_with?("\r")
71
+ line
72
+ end
73
+
74
+ def decode!(payload)
75
+ return if payload.empty? || payload == "[DONE]"
76
+ chunk = LLM.json.load(payload)
77
+ parser.parse!(chunk) if chunk
78
+ rescue *LLM.json.parser_error
79
+ end
80
+
81
+ def each_line
82
+ while (newline = @buffer.index("\n", @cursor))
83
+ line = @buffer[@cursor..newline]
84
+ @cursor = newline + 1
85
+ yield(line)
86
+ end
87
+ return if @cursor.zero?
88
+ @buffer = @buffer[@cursor..] || +""
89
+ @cursor = 0
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Provider
4
+ module Transport
5
+ ##
6
+ # The {LLM::Provider::Transport::HTTP LLM::Provider::Transport::HTTP}
7
+ # class manages HTTP connections for {LLM::Provider}. It handles
8
+ # transient and persistent clients, tracks active requests by owner,
9
+ # and interrupts in-flight requests when needed.
10
+ #
11
+ # @api private
12
+ class HTTP
13
+ require_relative "http/stream_decoder"
14
+ require_relative "http/interruptible"
15
+
16
+ include Interruptible
17
+
18
+ ##
19
+ # @param [String] host
20
+ # @param [Integer] port
21
+ # @param [Integer] timeout
22
+ # @param [Boolean] ssl
23
+ # @param [Boolean] persistent
24
+ # @return [LLM::Provider::Transport::HTTP]
25
+ def initialize(host:, port:, timeout:, ssl:, persistent: false)
26
+ @host = host
27
+ @port = port
28
+ @timeout = timeout
29
+ @ssl = ssl
30
+ @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
31
+ @persistent_client = persistent ? persistent_client : nil
32
+ @monitor = Monitor.new
33
+ end
34
+
35
+ ##
36
+ # Interrupt an active request, if any.
37
+ # @param [Fiber] owner
38
+ # @return [nil]
39
+ def interrupt!(owner)
40
+ super
41
+ end
42
+
43
+ ##
44
+ # Returns whether an execution owner was interrupted.
45
+ # @param [Fiber] owner
46
+ # @return [Boolean, nil]
47
+ def interrupted?(owner)
48
+ super
49
+ end
50
+
51
+ ##
52
+ # Returns the current request owner.
53
+ # @return [Fiber]
54
+ def request_owner
55
+ Fiber.current
56
+ end
57
+
58
+ ##
59
+ # Configures the transport to use a persistent HTTP connection pool.
60
+ # @return [LLM::Provider::Transport::HTTP]
61
+ def persist!
62
+ client = persistent_client
63
+ lock do
64
+ @persistent_client = client
65
+ self
66
+ end
67
+ end
68
+ alias_method :persistent, :persist!
69
+
70
+ ##
71
+ # @return [Boolean]
72
+ def persistent?
73
+ !persistent_client.nil?
74
+ end
75
+
76
+ ##
77
+ # Performs a request on the current HTTP transport.
78
+ # @param [Net::HTTPRequest] request
79
+ # @param [Fiber] owner
80
+ # @yieldparam [Net::HTTP] http
81
+ # @return [Object]
82
+ def request(request, owner:, &)
83
+ if persistent?
84
+ request_persistent(request, owner, &)
85
+ else
86
+ request_transient(request, owner, &)
87
+ end
88
+ ensure
89
+ clear_request(owner)
90
+ end
91
+
92
+ ##
93
+ # @return [String]
94
+ def inspect
95
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @persistent=#{persistent?}>"
96
+ end
97
+
98
+ private
99
+
100
+ attr_reader :host, :port, :timeout, :ssl, :base_uri
101
+
102
+ def request_transient(request, owner, &)
103
+ http = transient_client
104
+ set_request(Request.new(http:), owner)
105
+ yield http
106
+ end
107
+
108
+ def request_persistent(request, owner, &)
109
+ persistent_client.connection_for(URI.join(base_uri, request.path)) do |connection|
110
+ set_request(Request.new(http: persistent_client, connection:), owner)
111
+ yield connection.http
112
+ end
113
+ end
114
+
115
+ def persistent_client
116
+ LLM.lock(:clients) do
117
+ if LLM.clients[client_id]
118
+ LLM.clients[client_id]
119
+ else
120
+ require "net/http/persistent" unless defined?(Net::HTTP::Persistent)
121
+ client = Net::HTTP::Persistent.new(name: self.class.name)
122
+ client.read_timeout = timeout
123
+ LLM.clients[client_id] = client
124
+ end
125
+ end
126
+ end
127
+
128
+ def transient_client
129
+ client = Net::HTTP.new(host, port)
130
+ client.read_timeout = timeout
131
+ client.use_ssl = ssl
132
+ client
133
+ end
134
+
135
+ def client_id
136
+ "#{host}:#{port}:#{timeout}:#{ssl}"
137
+ end
138
+
139
+ def lock(&)
140
+ @monitor.synchronize(&)
141
+ end
142
+ end
143
+ end
144
+ end
data/lib/llm/provider.rb CHANGED
@@ -7,14 +7,9 @@
7
7
  # @abstract
8
8
  class LLM::Provider
9
9
  require "net/http"
10
- require_relative "client"
11
- include LLM::Client
12
-
13
- @@clients = {}
14
-
15
- ##
16
- # @api private
17
- def self.clients = @@clients
10
+ require_relative "provider/transport/http"
11
+ require_relative "provider/transport/http/execution"
12
+ include Transport::HTTP::Execution
18
13
 
19
14
  ##
20
15
  # @param [String, nil] key
@@ -36,9 +31,9 @@ class LLM::Provider
36
31
  @port = port
37
32
  @timeout = timeout
38
33
  @ssl = ssl
39
- @client = persistent ? persistent_client : nil
40
34
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
41
35
  @headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
36
+ @transport = Transport::HTTP.new(host:, port:, timeout:, ssl:, persistent:)
42
37
  @monitor = Monitor.new
43
38
  end
44
39
 
@@ -47,7 +42,7 @@ class LLM::Provider
47
42
  # @return [String]
48
43
  # @note The secret key is redacted in inspect for security reasons
49
44
  def inspect
50
- "#<#{self.class.name}:0x#{object_id.to_s(16)} @key=[REDACTED] @client=#{@client.inspect} @tracer=#{tracer.inspect}>"
45
+ "#<#{self.class.name}:0x#{object_id.to_s(16)} @key=[REDACTED] @transport=#{transport.inspect} @tracer=#{tracer.inspect}>"
51
46
  end
52
47
 
53
48
  ##
@@ -312,13 +307,20 @@ class LLM::Provider
312
307
  # # do something with 'llm'
313
308
  # @return [LLM::Provider]
314
309
  def persist!
315
- client = persistent_client
316
- lock do
317
- tap { @client = client }
318
- end
310
+ transport.persist!
311
+ self
319
312
  end
320
313
  alias_method :persistent, :persist!
321
314
 
315
+ ##
316
+ # Interrupt the active request, if any.
317
+ # @param [Fiber] owner
318
+ # @return [nil]
319
+ def interrupt!(owner)
320
+ transport.interrupt!(owner)
321
+ end
322
+ alias_method :cancel!, :interrupt!
323
+
322
324
  ##
323
325
  # @param [Object] stream
324
326
  # @return [Boolean]
@@ -328,7 +330,7 @@ class LLM::Provider
328
330
 
329
331
  private
330
332
 
331
- attr_reader :client, :base_uri, :host, :port, :timeout, :ssl
333
+ attr_reader :base_uri, :host, :port, :timeout, :ssl, :transport
332
334
 
333
335
  ##
334
336
  # The headers to include with a request
@@ -360,94 +362,6 @@ class LLM::Provider
360
362
  raise NotImplementedError
361
363
  end
362
364
 
363
- ##
364
- # Executes a HTTP request
365
- # @param [Net::HTTPRequest] request
366
- # The request to send
367
- # @param [Proc] b
368
- # A block to yield the response to (optional)
369
- # @return [Net::HTTPResponse]
370
- # The response from the server
371
- # @raise [LLM::Error::Unauthorized]
372
- # When authentication fails
373
- # @raise [LLM::Error::RateLimit]
374
- # When the rate limit is exceeded
375
- # @raise [LLM::Error]
376
- # When any other unsuccessful status code is returned
377
- # @raise [SystemCallError]
378
- # When there is a network error at the operating system level
379
- # @return [Net::HTTPResponse]
380
- def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, inputs: nil, &b)
381
- tracer = self.tracer
382
- span = tracer.on_request_start(operation:, model:, inputs:)
383
- http = client || transient_client
384
- args = (Net::HTTP === http) ? [request] : [URI.join(base_uri, request.path), request]
385
- res = if stream
386
- http.request(*args) do |res|
387
- if Net::HTTPSuccess === res
388
- handler = event_handler.new stream_parser.new(stream)
389
- parser = LLM::EventStream::Parser.new
390
- parser.register(handler)
391
- res.read_body(parser)
392
- # If the handler body is empty, the response was
393
- # most likely not streamed or parsing failed.
394
- # Preserve the raw body in that case so standard
395
- # JSON/error handling can parse it later.
396
- body = handler.body.empty? ? parser.body : handler.body
397
- res.body = Hash === body || Array === body ? LLM::Object.from(body) : body
398
- else
399
- body = +""
400
- res.read_body { body << _1 }
401
- res.body = body
402
- end
403
- ensure
404
- handler&.free
405
- parser&.free
406
- end
407
- else
408
- b ? http.request(*args) { (Net::HTTPSuccess === _1) ? b.call(_1) : _1 } :
409
- http.request(*args)
410
- end
411
- [handle_response(res, tracer, span), span, tracer]
412
- end
413
-
414
- ##
415
- # Handles the response from a request
416
- # @param [Net::HTTPResponse] res
417
- # The response to handle
418
- # @param [Object, nil] span
419
- # The span
420
- # @return [Net::HTTPResponse]
421
- def handle_response(res, tracer, span)
422
- case res
423
- when Net::HTTPOK then res.body = parse_response(res)
424
- else error_handler.new(tracer, span, res).raise_error!
425
- end
426
- res
427
- end
428
-
429
- ##
430
- # Parse a HTTP response
431
- # @param [Net::HTTPResponse] res
432
- # @return [LLM::Object, String]
433
- def parse_response(res)
434
- case res["content-type"]
435
- when %r|\Aapplication/json\s*| then LLM::Object.from(LLM.json.load(res.body))
436
- else res.body
437
- end
438
- end
439
-
440
- ##
441
- # @param [Net::HTTPRequest] req
442
- # The request to set the body stream for
443
- # @param [IO] io
444
- # The IO object to set as the body stream
445
- # @return [void]
446
- def set_body_stream(req, io)
447
- req.body_stream = io
448
- req["transfer-encoding"] = "chunked" unless req["content-length"]
449
- end
450
-
451
365
  ##
452
366
  # Resolves tools to their function representations
453
367
  # @param [Array<LLM::Function, LLM::Tool>] tools
@@ -15,6 +15,8 @@ module LLM::OpenAI::RequestAdapter
15
15
  catch(:abort) do
16
16
  if Hash === message
17
17
  {role: message[:role], content: adapt_content(message[:content])}
18
+ elsif message.tool_call?
19
+ message.extra[:original_tool_calls]
18
20
  else
19
21
  adapt_message
20
22
  end
@@ -23,12 +25,12 @@ module LLM::OpenAI::RequestAdapter
23
25
 
24
26
  private
25
27
 
26
- def adapt_content(content)
28
+ def adapt_content(content, role: message.role)
27
29
  case content
28
30
  when String
29
- [{type: :input_text, text: content.to_s}]
31
+ [{type: text_content_type(role), text: content.to_s}]
30
32
  when LLM::Response then adapt_remote_file(content)
31
- when LLM::Message then adapt_content(content.content)
33
+ when LLM::Message then adapt_content(content.content, role: content.role)
32
34
  when LLM::Object
33
35
  case content.kind
34
36
  when :image_url then [{type: :image_url, image_url: {url: content.value.to_s}}]
@@ -46,7 +48,7 @@ module LLM::OpenAI::RequestAdapter
46
48
  when Array
47
49
  adapt_array
48
50
  else
49
- {role: message.role, content: adapt_content(content)}
51
+ {role: message.role, content: adapt_content(content, role: message.role)}
50
52
  end
51
53
  end
52
54
 
@@ -56,7 +58,7 @@ module LLM::OpenAI::RequestAdapter
56
58
  elsif returns.any?
57
59
  returns.map { {type: "function_call_output", call_id: _1.id, output: LLM.json.dump(_1.value)} }
58
60
  else
59
- {role: message.role, content: content.flat_map { adapt_content(_1) }}
61
+ {role: message.role, content: content.flat_map { adapt_content(_1, role: message.role) }}
60
62
  end
61
63
  end
62
64
 
@@ -83,5 +85,9 @@ module LLM::OpenAI::RequestAdapter
83
85
  def message = @message
84
86
  def content = message.content
85
87
  def returns = content.grep(LLM::Function::Return)
88
+
89
+ def text_content_type(role)
90
+ role.to_s == "assistant" ? :output_text : :input_text
91
+ end
86
92
  end
87
93
  end
@@ -60,6 +60,13 @@ module LLM::OpenAI::ResponseAdapter
60
60
  body.model
61
61
  end
62
62
 
63
+ ##
64
+ # OpenAI's Responses API does not expose a system fingerprint.
65
+ # @return [nil]
66
+ def system_fingerprint
67
+ nil
68
+ end
69
+
63
70
  ##
64
71
  # Returns the aggregated text content from the response outputs.
65
72
  # @return [String]
@@ -88,10 +95,15 @@ module LLM::OpenAI::ResponseAdapter
88
95
  private
89
96
 
90
97
  def adapt_message
91
- message = LLM::Message.new("assistant", +"", {response: self, tool_calls: [], reasoning_content: +""})
98
+ message = LLM::Message.new(
99
+ "assistant",
100
+ +"",
101
+ {response: self, tool_calls: [], original_tool_calls: [], reasoning_content: +""}
102
+ )
92
103
  output.each do |choice|
93
104
  if choice.type == "function_call"
94
105
  message.extra[:tool_calls] << adapt_tool(choice)
106
+ message.extra[:original_tool_calls] << choice
95
107
  elsif choice.type == "reasoning"
96
108
  (choice.summary || []).each do |summary|
97
109
  next unless summary["type"] == "summary_text"
@@ -43,11 +43,19 @@ class LLM::OpenAI
43
43
  @body[k] = v
44
44
  end
45
45
  @body["output"] ||= []
46
+ when "response.in_progress", "response.completed"
47
+ response = chunk["response"] || {}
48
+ response.each do |k, v|
49
+ next if k == "output" && @body["output"].is_a?(Array) && @body["output"].any?
50
+ @body[k] = v
51
+ end
52
+ @body["output"] ||= response["output"] || []
46
53
  when "response.output_item.added"
47
54
  output_index = chunk["output_index"]
48
55
  item = chunk["item"]
49
56
  @body["output"][output_index] = item
50
57
  @body["output"][output_index]["content"] ||= []
58
+ @body["output"][output_index]["summary"] ||= [] if item["type"] == "reasoning"
51
59
  when "response.content_part.added"
52
60
  output_index = chunk["output_index"]
53
61
  content_index = chunk["content_index"]
@@ -55,6 +63,25 @@ class LLM::OpenAI
55
63
  @body["output"][output_index] ||= {"content" => []}
56
64
  @body["output"][output_index]["content"] ||= []
57
65
  @body["output"][output_index]["content"][content_index] = part
66
+ when "response.reasoning_summary_text.delta"
67
+ output_item = @body["output"][chunk["output_index"]]
68
+ if output_item && output_item["type"] == "reasoning"
69
+ summary_index = chunk["summary_index"] || 0
70
+ output_item["summary"] ||= []
71
+ output_item["summary"][summary_index] ||= {"type" => "summary_text", "text" => +""}
72
+ output_item["summary"][summary_index]["text"] << chunk["delta"]
73
+ emit_reasoning_content(chunk["delta"])
74
+ end
75
+ when "response.reasoning_summary_text.done"
76
+ output_item = @body["output"][chunk["output_index"]]
77
+ if output_item && output_item["type"] == "reasoning"
78
+ summary_index = chunk["summary_index"] || 0
79
+ output_item["summary"] ||= []
80
+ output_item["summary"][summary_index] = {
81
+ "type" => "summary_text",
82
+ "text" => chunk["text"]
83
+ }
84
+ end
58
85
  when "response.output_text.delta"
59
86
  output_index = chunk["output_index"]
60
87
  content_index = chunk["content_index"]
@@ -102,6 +129,10 @@ class LLM::OpenAI
102
129
  end
103
130
  end
104
131
 
132
+ def emit_reasoning_content(value)
133
+ @stream.on_reasoning_content(value) if @stream.respond_to?(:on_reasoning_content)
134
+ end
135
+
105
136
  def emit_tool(index, tool)
106
137
  return unless @stream.respond_to?(:on_tool_call)
107
138
  return unless complete_tool?(tool)
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "4.12.0"
4
+ VERSION = "4.14.0"
5
5
  end
data/lib/llm.rb CHANGED
@@ -40,6 +40,14 @@ module LLM
40
40
  # Model registry
41
41
  @registry = {}
42
42
 
43
+ ##
44
+ # Shared HTTP clients used by providers.
45
+ @clients = {}
46
+
47
+ ##
48
+ # @api private
49
+ def self.clients = @clients
50
+
43
51
  ##
44
52
  # @param [Symbol, LLM::Provider] llm
45
53
  # The name of a provider, or an instance of LLM::Provider