llm.rb 4.13.0 → 4.15.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.
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
@@ -16,6 +16,9 @@ class LLM::Anthropic
16
16
  def initialize(stream)
17
17
  @body = {"role" => "assistant", "content" => []}
18
18
  @stream = stream
19
+ @can_emit_content = stream.respond_to?(:on_content)
20
+ @can_emit_tool_call = stream.respond_to?(:on_tool_call)
21
+ @can_push_content = stream.respond_to?(:<<)
19
22
  end
20
23
 
21
24
  ##
@@ -88,15 +91,15 @@ class LLM::Anthropic
88
91
  end
89
92
 
90
93
  def emit_content(value)
91
- if @stream.respond_to?(:on_content)
94
+ if @can_emit_content
92
95
  @stream.on_content(value)
93
- elsif @stream.respond_to?(:<<)
96
+ elsif @can_push_content
94
97
  @stream << value
95
98
  end
96
99
  end
97
100
 
98
101
  def emit_tool(tool)
99
- return unless @stream.respond_to?(:on_tool_call)
102
+ return unless @can_emit_tool_call
100
103
  function, error = resolve_tool(tool)
101
104
  @stream.on_tool_call(function, error)
102
105
  end
@@ -17,6 +17,9 @@ class LLM::Google
17
17
  @body = {"candidates" => []}
18
18
  @stream = stream
19
19
  @emits = {tools: []}
20
+ @can_emit_content = stream.respond_to?(:on_content)
21
+ @can_emit_tool_call = stream.respond_to?(:on_tool_call)
22
+ @can_push_content = stream.respond_to?(:<<)
20
23
  end
21
24
 
22
25
  ##
@@ -126,15 +129,15 @@ class LLM::Google
126
129
  end
127
130
 
128
131
  def emit_content(value)
129
- if @stream.respond_to?(:on_content)
132
+ if @can_emit_content
130
133
  @stream.on_content(value)
131
- elsif @stream.respond_to?(:<<)
134
+ elsif @can_push_content
132
135
  @stream << value
133
136
  end
134
137
  end
135
138
 
136
139
  def emit_tool(pindex, cindex, part)
137
- return unless @stream.respond_to?(:on_tool_call)
140
+ return unless @can_emit_tool_call
138
141
  return unless complete_tool?(part)
139
142
  key = [cindex, pindex]
140
143
  return if @emits[:tools].include?(key)
@@ -14,6 +14,7 @@ class LLM::Ollama
14
14
  def initialize(stream)
15
15
  @body = {}
16
16
  @stream = stream
17
+ @can_push_content = stream.respond_to?(:<<)
17
18
  end
18
19
 
19
20
  ##
@@ -36,10 +37,10 @@ class LLM::Ollama
36
37
  if key == "message"
37
38
  if @body[key]
38
39
  @body[key]["content"] << value["content"]
39
- @stream << value["content"] if @stream.respond_to?(:<<)
40
+ @stream << value["content"] if @can_push_content
40
41
  else
41
42
  @body[key] = value
42
- @stream << value["content"] if @stream.respond_to?(:<<)
43
+ @stream << value["content"] if @can_push_content
43
44
  end
44
45
  else
45
46
  @body[key] = value
@@ -4,6 +4,8 @@ class LLM::OpenAI
4
4
  ##
5
5
  # @private
6
6
  class Responses::StreamParser
7
+ EMPTY_HASH = {}.freeze
8
+
7
9
  ##
8
10
  # Returns the fully constructed response body
9
11
  # @return [Hash]
@@ -16,7 +18,15 @@ class LLM::OpenAI
16
18
  def initialize(stream)
17
19
  @body = {"output" => []}
18
20
  @stream = stream
19
- @emits = {tools: []}
21
+ @emits = {tools: {}}
22
+ @can_emit_content = stream.respond_to?(:on_content)
23
+ @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
24
+ @can_emit_tool_call = stream.respond_to?(:on_tool_call)
25
+ @can_push_content = stream.respond_to?(:<<)
26
+ @cached_output_index = nil
27
+ @cached_output_item = nil
28
+ @cached_content_index = nil
29
+ @cached_content_part = nil
20
30
  end
21
31
 
22
32
  ##
@@ -31,126 +41,238 @@ class LLM::OpenAI
31
41
  # @return [void]
32
42
  def free
33
43
  @emits.clear
44
+ clear_cache!
34
45
  end
35
46
 
36
47
  private
37
48
 
49
+ ##
50
+ # @group Dispatchers
51
+
38
52
  def handle_event(chunk)
39
- case chunk["type"]
40
- when "response.created"
41
- chunk.each do |k, v|
42
- next if k == "type"
43
- @body[k] = v
44
- end
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
53
+ output = @body["output"]
54
+ type = chunk["type"]
55
+ if type == "response.output_text.delta"
56
+ merge_output_text_delta!(output, chunk)
57
+ elsif type == "response.content_part.added"
58
+ merge_content_part!(output, chunk)
59
+ elsif type == "response.output_item.added"
60
+ merge_output_item!(output, chunk)
61
+ elsif type == "response.function_call_arguments.delta"
62
+ merge_function_call_arguments_delta!(output, chunk)
63
+ elsif type == "response.function_call_arguments.done"
64
+ merge_function_call_arguments_done!(output, chunk)
65
+ elsif type == "response.output_item.done"
66
+ merge_output_item!(output, chunk)
67
+ elsif type == "response.content_part.done"
68
+ merge_content_part!(output, chunk, part_key: "part")
69
+ else
70
+ case type
71
+ when "response.created"
72
+ merge_response_created!(chunk)
73
+ when "response.in_progress", "response.completed"
74
+ merge_response_state!(output, chunk)
75
+ when "response.reasoning_summary_text.delta"
76
+ merge_reasoning_summary_text_delta!(output, chunk)
77
+ when "response.reasoning_summary_text.done"
78
+ merge_reasoning_summary_text_done!(output, chunk)
51
79
  end
52
- @body["output"] ||= response["output"] || []
53
- when "response.output_item.added"
54
- output_index = chunk["output_index"]
55
- item = chunk["item"]
56
- @body["output"][output_index] = item
57
- @body["output"][output_index]["content"] ||= []
58
- @body["output"][output_index]["summary"] ||= [] if item["type"] == "reasoning"
59
- when "response.content_part.added"
60
- output_index = chunk["output_index"]
61
- content_index = chunk["content_index"]
62
- part = chunk["part"]
63
- @body["output"][output_index] ||= {"content" => []}
64
- @body["output"][output_index]["content"] ||= []
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
85
- when "response.output_text.delta"
86
- output_index = chunk["output_index"]
87
- content_index = chunk["content_index"]
80
+ end
81
+ end
82
+
83
+ ##
84
+ # @endgroup
85
+
86
+ ##
87
+ # @group Mergers
88
+
89
+ def merge_response_created!(chunk)
90
+ clear_cache!
91
+ chunk.each do |k, v|
92
+ next if k == "type"
93
+ @body[k] = v
94
+ end
95
+ @body["output"] ||= []
96
+ end
97
+
98
+ def merge_response_state!(output, chunk)
99
+ clear_cache!
100
+ response = chunk["response"] || EMPTY_HASH
101
+ response.each do |k, v|
102
+ next if k == "output" && Array === output && output.any?
103
+ @body[k] = v
104
+ end
105
+ @body["output"] ||= response["output"] || []
106
+ end
107
+
108
+ def merge_output_item!(output, chunk)
109
+ output_index = chunk["output_index"]
110
+ item = chunk["item"]
111
+ output[output_index] = item
112
+ item["content"] ||= [] if item["type"] == "message" || item.key?("content")
113
+ item["summary"] ||= [] if item["type"] == "reasoning"
114
+ cache_output_item!(output_index, item)
115
+ end
116
+
117
+ def merge_content_part!(output, chunk, part_key: "part")
118
+ output_index = chunk["output_index"]
119
+ content_index = chunk["content_index"]
120
+ part = chunk[part_key]
121
+ output_item = output_item_at(output, output_index)
122
+ unless output_item
123
+ output_item = {"content" => []}
124
+ output[output_index] = output_item
125
+ cache_output_item!(output_index, output_item)
126
+ end
127
+ content = output_item["content"] ||= []
128
+ content[content_index] = part
129
+ cache_content_part!(content_index, part)
130
+ end
131
+
132
+ def merge_output_text_delta!(output, chunk)
133
+ content_part = content_part_at(output, chunk["output_index"], chunk["content_index"])
134
+ if content_part && content_part["type"] == "output_text"
88
135
  delta_text = chunk["delta"]
89
- output_item = @body["output"][output_index]
90
- if output_item && output_item["content"]
91
- content_part = output_item["content"][content_index]
92
- if content_part && content_part["type"] == "output_text"
93
- content_part["text"] ||= ""
94
- content_part["text"] << delta_text
95
- emit_content(delta_text)
96
- end
136
+ if text = content_part["text"]
137
+ text << delta_text
138
+ else
139
+ content_part["text"] = delta_text
97
140
  end
98
- when "response.function_call_arguments.delta"
99
- output_item = @body["output"][chunk["output_index"]]
100
- if output_item && output_item["type"] == "function_call"
101
- output_item["arguments"] ||= +""
102
- output_item["arguments"] << chunk["delta"]
141
+ emit_content(delta_text)
142
+ end
143
+ end
144
+
145
+ def merge_reasoning_summary_text_delta!(output, chunk)
146
+ output_item = output_item_at(output, chunk["output_index"])
147
+ if output_item && output_item["type"] == "reasoning"
148
+ summary_index = chunk["summary_index"] || 0
149
+ delta = chunk["delta"]
150
+ summary = output_item["summary"] ||= []
151
+ if summary_item = summary[summary_index]
152
+ summary_item["text"] << delta
153
+ else
154
+ summary[summary_index] = {"type" => "summary_text", "text" => delta}
103
155
  end
104
- when "response.function_call_arguments.done"
105
- output_item = @body["output"][chunk["output_index"]]
106
- if output_item && output_item["type"] == "function_call"
107
- output_item["arguments"] = chunk["arguments"]
108
- emit_tool(chunk["output_index"], output_item)
156
+ emit_reasoning_content(delta)
157
+ end
158
+ end
159
+
160
+ def merge_reasoning_summary_text_done!(output, chunk)
161
+ output_item = output_item_at(output, chunk["output_index"])
162
+ if output_item && output_item["type"] == "reasoning"
163
+ summary_index = chunk["summary_index"] || 0
164
+ output_item["summary"] ||= []
165
+ output_item["summary"][summary_index] = {
166
+ "type" => "summary_text",
167
+ "text" => chunk["text"]
168
+ }
169
+ end
170
+ end
171
+
172
+ def merge_function_call_arguments_delta!(output, chunk)
173
+ output_item = output_item_at(output, chunk["output_index"])
174
+ if output_item && output_item["type"] == "function_call"
175
+ if arguments = output_item["arguments"]
176
+ arguments << chunk["delta"]
177
+ else
178
+ output_item["arguments"] = chunk["delta"]
109
179
  end
110
- when "response.output_item.done"
111
- output_index = chunk["output_index"]
112
- item = chunk["item"]
113
- @body["output"][output_index] = item
114
- when "response.content_part.done"
115
- output_index = chunk["output_index"]
116
- content_index = chunk["content_index"]
117
- part = chunk["part"]
118
- @body["output"][output_index] ||= {"content" => []}
119
- @body["output"][output_index]["content"] ||= []
120
- @body["output"][output_index]["content"][content_index] = part
121
180
  end
122
181
  end
123
182
 
183
+ def merge_function_call_arguments_done!(output, chunk)
184
+ output_item = output_item_at(output, chunk["output_index"])
185
+ if output_item && output_item["type"] == "function_call"
186
+ output_item["arguments"] = chunk["arguments"]
187
+ emit_tool(chunk["output_index"], output_item)
188
+ end
189
+ end
190
+
191
+ ##
192
+ # @endgroup
193
+
194
+ ##
195
+ # @group Cache
196
+
197
+ def output_item_at(output, output_index)
198
+ if @cached_output_index == output_index
199
+ @cached_output_item
200
+ else
201
+ cache_output_item!(output_index, output[output_index])
202
+ end
203
+ end
204
+
205
+ def content_part_at(output, output_index, content_index)
206
+ if @cached_output_index == output_index && @cached_content_index == content_index
207
+ @cached_content_part
208
+ else
209
+ output_item = output_item_at(output, output_index)
210
+ content = output_item && output_item["content"]
211
+ cache_content_part!(content_index, content && content[content_index])
212
+ end
213
+ end
214
+
215
+ def cache_output_item!(output_index, output_item)
216
+ @cached_output_index = output_index
217
+ @cached_output_item = output_item
218
+ @cached_content_index = nil
219
+ @cached_content_part = nil
220
+ output_item
221
+ end
222
+
223
+ def cache_content_part!(content_index, content_part)
224
+ @cached_content_index = content_index
225
+ @cached_content_part = content_part
226
+ content_part
227
+ end
228
+
229
+ def clear_cache!
230
+ @cached_output_index = nil
231
+ @cached_output_item = nil
232
+ @cached_content_index = nil
233
+ @cached_content_part = nil
234
+ end
235
+
236
+ ##
237
+ # @endgroup
238
+
239
+ ##
240
+ # @group Emitters
241
+
124
242
  def emit_content(value)
125
- if @stream.respond_to?(:on_content)
243
+ if @can_emit_content
126
244
  @stream.on_content(value)
127
- elsif @stream.respond_to?(:<<)
245
+ elsif @can_push_content
128
246
  @stream << value
129
247
  end
130
248
  end
131
249
 
132
250
  def emit_reasoning_content(value)
133
- @stream.on_reasoning_content(value) if @stream.respond_to?(:on_reasoning_content)
251
+ @stream.on_reasoning_content(value) if @can_emit_reasoning_content
134
252
  end
135
253
 
136
254
  def emit_tool(index, tool)
137
- return unless @stream.respond_to?(:on_tool_call)
138
- return unless complete_tool?(tool)
139
- return if @emits[:tools].include?(index)
140
- function, error = resolve_tool(tool)
141
- @emits[:tools] << index
255
+ return unless @can_emit_tool_call
256
+ return if @emits[:tools][index]
257
+ return unless tool["call_id"] && tool["name"]
258
+ arguments = parse_arguments(tool["arguments"])
259
+ return unless arguments
260
+ function, error = resolve_tool(tool, arguments)
261
+ @emits[:tools][index] = true
142
262
  @stream.on_tool_call(function, error)
143
263
  end
144
264
 
145
- def complete_tool?(tool)
146
- tool["call_id"] && tool["name"] && parse_arguments(tool["arguments"])
147
- end
265
+ ##
266
+ # @endgroup
148
267
 
149
- def resolve_tool(tool)
268
+ ##
269
+ # @group Resolvers
270
+
271
+ def resolve_tool(tool, arguments)
150
272
  registered = LLM::Function.find_by_name(tool["name"])
151
273
  fn = (registered || LLM::Function.new(tool["name"])).dup.tap do |fn|
152
274
  fn.id = tool["call_id"]
153
- fn.arguments = parse_arguments(tool["arguments"])
275
+ fn.arguments = arguments
154
276
  end
155
277
  [fn, (registered ? nil : @stream.tool_not_found(fn))]
156
278
  end
@@ -162,5 +284,8 @@ class LLM::OpenAI
162
284
  rescue *LLM.json.parser_error
163
285
  nil
164
286
  end
287
+
288
+ ##
289
+ # @endgroup
165
290
  end
166
291
  end