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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -0
- data/README.md +82 -32
- data/lib/llm/context.rb +25 -10
- data/lib/llm/error.rb +4 -0
- data/lib/llm/eventhandler.rb +16 -12
- data/lib/llm/eventstream/event.rb +15 -5
- data/lib/llm/eventstream/parser.rb +64 -17
- data/lib/llm/mcp/command.rb +1 -1
- data/lib/llm/mcp/mailbox.rb +23 -0
- data/lib/llm/mcp/pipe.rb +1 -1
- data/lib/llm/mcp/router.rb +44 -0
- data/lib/llm/mcp/rpc.rb +29 -18
- data/lib/llm/mcp/transport/http/event_handler.rb +11 -9
- data/lib/llm/mcp/transport/http.rb +2 -2
- data/lib/llm/mcp/transport/stdio.rb +1 -1
- data/lib/llm/mcp.rb +5 -2
- data/lib/llm/provider/transport/http/execution.rb +115 -0
- data/lib/llm/provider/transport/http/interruptible.rb +109 -0
- data/lib/llm/provider/transport/http/stream_decoder.rb +92 -0
- data/lib/llm/provider/transport/http.rb +144 -0
- data/lib/llm/provider.rb +17 -103
- data/lib/llm/providers/anthropic/stream_parser.rb +6 -3
- data/lib/llm/providers/google/stream_parser.rb +6 -3
- data/lib/llm/providers/ollama/stream_parser.rb +3 -2
- data/lib/llm/providers/openai/responses/stream_parser.rb +216 -91
- data/lib/llm/providers/openai/stream_parser.rb +111 -57
- data/lib/llm/response.rb +12 -4
- data/lib/llm/sequel/plugin.rb +252 -0
- data/lib/llm/stream/queue.rb +2 -2
- data/lib/llm/stream.rb +2 -2
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +8 -0
- data/lib/sequel/plugins/llm.rb +8 -0
- metadata +9 -2
- data/lib/llm/client.rb +0 -36
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 "
|
|
11
|
-
|
|
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] @
|
|
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
|
-
|
|
316
|
-
|
|
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 :
|
|
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 @
|
|
94
|
+
if @can_emit_content
|
|
92
95
|
@stream.on_content(value)
|
|
93
|
-
elsif @
|
|
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 @
|
|
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 @
|
|
132
|
+
if @can_emit_content
|
|
130
133
|
@stream.on_content(value)
|
|
131
|
-
elsif @
|
|
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 @
|
|
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 @
|
|
40
|
+
@stream << value["content"] if @can_push_content
|
|
40
41
|
else
|
|
41
42
|
@body[key] = value
|
|
42
|
-
@stream << value["content"] if @
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@body[
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 @
|
|
243
|
+
if @can_emit_content
|
|
126
244
|
@stream.on_content(value)
|
|
127
|
-
elsif @
|
|
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 @
|
|
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 @
|
|
138
|
-
return
|
|
139
|
-
return
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
end
|
|
265
|
+
##
|
|
266
|
+
# @endgroup
|
|
148
267
|
|
|
149
|
-
|
|
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 =
|
|
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
|