llm.rb 4.10.0 → 4.11.1
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 +152 -0
- data/README.md +265 -113
- data/data/anthropic.json +209 -242
- data/data/deepseek.json +15 -15
- data/data/google.json +553 -403
- data/data/openai.json +740 -535
- data/data/xai.json +250 -253
- data/data/zai.json +157 -90
- data/lib/llm/context/deserializer.rb +2 -1
- data/lib/llm/context.rb +58 -2
- data/lib/llm/contract/completion.rb +7 -0
- data/lib/llm/error.rb +4 -0
- data/lib/llm/eventhandler.rb +7 -0
- data/lib/llm/function/registry.rb +106 -0
- data/lib/llm/function/task.rb +39 -0
- data/lib/llm/function.rb +12 -7
- data/lib/llm/mcp/transport/http.rb +40 -6
- data/lib/llm/mcp/transport/stdio.rb +7 -0
- data/lib/llm/mcp.rb +54 -24
- data/lib/llm/message.rb +9 -2
- data/lib/llm/provider.rb +10 -0
- data/lib/llm/providers/anthropic/response_adapter/completion.rb +6 -0
- data/lib/llm/providers/anthropic/stream_parser.rb +37 -4
- data/lib/llm/providers/anthropic.rb +1 -1
- data/lib/llm/providers/google/response_adapter/completion.rb +12 -5
- data/lib/llm/providers/google/stream_parser.rb +54 -11
- data/lib/llm/providers/google/utils.rb +30 -0
- data/lib/llm/providers/google.rb +2 -0
- data/lib/llm/providers/ollama/response_adapter/completion.rb +6 -0
- data/lib/llm/providers/ollama/stream_parser.rb +10 -4
- data/lib/llm/providers/ollama.rb +1 -1
- data/lib/llm/providers/openai/response_adapter/completion.rb +7 -0
- data/lib/llm/providers/openai/response_adapter/responds.rb +84 -10
- data/lib/llm/providers/openai/responses/stream_parser.rb +63 -4
- data/lib/llm/providers/openai/responses.rb +1 -1
- data/lib/llm/providers/openai/stream_parser.rb +68 -4
- data/lib/llm/providers/openai.rb +1 -1
- data/lib/llm/stream/queue.rb +51 -0
- data/lib/llm/stream.rb +102 -0
- data/lib/llm/tool.rb +50 -45
- data/lib/llm/tracer/telemetry.rb +2 -2
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +3 -2
- data/llm.gemspec +2 -2
- metadata +7 -1
data/lib/llm/providers/google.rb
CHANGED
|
@@ -18,6 +18,7 @@ module LLM
|
|
|
18
18
|
# ctx.talk ["Tell me about this photo", ctx.local_file("/images/photo.png")]
|
|
19
19
|
# ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
|
|
20
20
|
class Google < Provider
|
|
21
|
+
require_relative "google/utils"
|
|
21
22
|
require_relative "google/error_handler"
|
|
22
23
|
require_relative "google/request_adapter"
|
|
23
24
|
require_relative "google/response_adapter"
|
|
@@ -28,6 +29,7 @@ module LLM
|
|
|
28
29
|
require_relative "google/files"
|
|
29
30
|
|
|
30
31
|
include RequestAdapter
|
|
32
|
+
extend Utils
|
|
31
33
|
|
|
32
34
|
HOST = "generativelanguage.googleapis.com"
|
|
33
35
|
|
|
@@ -11,9 +11,9 @@ class LLM::Ollama
|
|
|
11
11
|
|
|
12
12
|
##
|
|
13
13
|
# @return [LLM::OpenAI::Chunk]
|
|
14
|
-
def initialize(
|
|
14
|
+
def initialize(stream)
|
|
15
15
|
@body = {}
|
|
16
|
-
@
|
|
16
|
+
@stream = stream
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
##
|
|
@@ -23,6 +23,12 @@ class LLM::Ollama
|
|
|
23
23
|
tap { merge!(chunk) }
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
##
|
|
27
|
+
# Frees internal parser state used during streaming.
|
|
28
|
+
# @return [void]
|
|
29
|
+
def free
|
|
30
|
+
end
|
|
31
|
+
|
|
26
32
|
private
|
|
27
33
|
|
|
28
34
|
def merge!(chunk)
|
|
@@ -30,10 +36,10 @@ class LLM::Ollama
|
|
|
30
36
|
if key == "message"
|
|
31
37
|
if @body[key]
|
|
32
38
|
@body[key]["content"] << value["content"]
|
|
33
|
-
@
|
|
39
|
+
@stream << value["content"] if @stream.respond_to?(:<<)
|
|
34
40
|
else
|
|
35
41
|
@body[key] = value
|
|
36
|
-
@
|
|
42
|
+
@stream << value["content"] if @stream.respond_to?(:<<)
|
|
37
43
|
end
|
|
38
44
|
else
|
|
39
45
|
@body[key] = value
|
data/lib/llm/providers/ollama.rb
CHANGED
|
@@ -122,7 +122,7 @@ module LLM
|
|
|
122
122
|
tools = resolve_tools(params.delete(:tools))
|
|
123
123
|
params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
|
|
124
124
|
role, stream = params.delete(:role), params.delete(:stream)
|
|
125
|
-
params[:stream] = true if
|
|
125
|
+
params[:stream] = true if streamable?(stream) || stream == true
|
|
126
126
|
[params, stream, tools, role]
|
|
127
127
|
end
|
|
128
128
|
|
|
@@ -10,6 +10,7 @@ module LLM::OpenAI::ResponseAdapter
|
|
|
10
10
|
extra = {
|
|
11
11
|
index:, response: self,
|
|
12
12
|
logprobs: choice.logprobs,
|
|
13
|
+
reasoning_content: message.reasoning_content,
|
|
13
14
|
tool_calls: adapt_tool_calls(message.tool_calls),
|
|
14
15
|
original_tool_calls: message.tool_calls
|
|
15
16
|
}
|
|
@@ -63,6 +64,12 @@ module LLM::OpenAI::ResponseAdapter
|
|
|
63
64
|
super
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
##
|
|
68
|
+
# (see LLM::Contract::Completion#reasoning_content)
|
|
69
|
+
def reasoning_content
|
|
70
|
+
super
|
|
71
|
+
end
|
|
72
|
+
|
|
66
73
|
##
|
|
67
74
|
# (see LLM::Contract::Completion#content!)
|
|
68
75
|
def content!
|
|
@@ -2,29 +2,101 @@
|
|
|
2
2
|
|
|
3
3
|
module LLM::OpenAI::ResponseAdapter
|
|
4
4
|
module Responds
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def
|
|
8
|
-
|
|
5
|
+
##
|
|
6
|
+
# (see LLM::Contract::Completion#messages)
|
|
7
|
+
def messages
|
|
8
|
+
[adapt_message]
|
|
9
|
+
end
|
|
10
|
+
alias_method :choices, :messages
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @return [String]
|
|
14
|
+
def response_id
|
|
15
|
+
respond_to?(:response) ? response["id"] : id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
##
|
|
19
|
+
# @return [Array<Hash>]
|
|
20
|
+
def annotations = messages[0].annotations
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# (see LLM::Contract::Completion#input_tokens)
|
|
24
|
+
def input_tokens
|
|
25
|
+
body.usage&.input_tokens || 0
|
|
26
|
+
end
|
|
27
|
+
alias_method :prompt_tokens, :input_tokens
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# (see LLM::Contract::Completion#output_tokens)
|
|
31
|
+
def output_tokens
|
|
32
|
+
body.usage&.output_tokens || 0
|
|
33
|
+
end
|
|
34
|
+
alias_method :completion_tokens, :output_tokens
|
|
35
|
+
|
|
36
|
+
##
|
|
37
|
+
# (see LLM::Contract::Completion#reasoning_tokens)
|
|
38
|
+
def reasoning_tokens
|
|
39
|
+
body
|
|
40
|
+
.usage
|
|
41
|
+
&.output_tokens_details
|
|
42
|
+
&.reasoning_tokens || 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
##
|
|
46
|
+
# (see LLM::Contract::Completion#total_tokens)
|
|
47
|
+
def total_tokens
|
|
48
|
+
body.usage&.total_tokens || 0
|
|
49
|
+
end
|
|
9
50
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def
|
|
51
|
+
##
|
|
52
|
+
# (see LLM::Contract::Completion#usage)
|
|
53
|
+
def usage
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# (see LLM::Contract::Completion#model)
|
|
59
|
+
def model
|
|
60
|
+
body.model
|
|
61
|
+
end
|
|
13
62
|
|
|
14
63
|
##
|
|
15
64
|
# Returns the aggregated text content from the response outputs.
|
|
16
65
|
# @return [String]
|
|
17
66
|
def output_text
|
|
18
|
-
|
|
67
|
+
content
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
##
|
|
71
|
+
# (see LLM::Contract::Completion#content)
|
|
72
|
+
def content
|
|
73
|
+
super || ""
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
##
|
|
77
|
+
# (see LLM::Contract::Completion#content!)
|
|
78
|
+
def content!
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
##
|
|
83
|
+
# (see LLM::Contract::Completion#reasoning_content)
|
|
84
|
+
def reasoning_content
|
|
85
|
+
super
|
|
19
86
|
end
|
|
20
87
|
|
|
21
88
|
private
|
|
22
89
|
|
|
23
90
|
def adapt_message
|
|
24
|
-
message = LLM::Message.new("assistant", +"", {response: self, tool_calls: []})
|
|
25
|
-
output.each
|
|
91
|
+
message = LLM::Message.new("assistant", +"", {response: self, tool_calls: [], reasoning_content: +""})
|
|
92
|
+
output.each do |choice|
|
|
26
93
|
if choice.type == "function_call"
|
|
27
94
|
message.extra[:tool_calls] << adapt_tool(choice)
|
|
95
|
+
elsif choice.type == "reasoning"
|
|
96
|
+
(choice.summary || []).each do |summary|
|
|
97
|
+
next unless summary["type"] == "summary_text"
|
|
98
|
+
message.extra["reasoning_content"] << summary["text"]
|
|
99
|
+
end
|
|
28
100
|
elsif choice.content
|
|
29
101
|
choice.content.each do |c|
|
|
30
102
|
next unless c["type"] == "output_text"
|
|
@@ -48,5 +120,7 @@ module LLM::OpenAI::ResponseAdapter
|
|
|
48
120
|
rescue *LLM.json.parser_error
|
|
49
121
|
{}
|
|
50
122
|
end
|
|
123
|
+
|
|
124
|
+
include LLM::Contract::Completion
|
|
51
125
|
end
|
|
52
126
|
end
|
|
@@ -10,11 +10,13 @@ class LLM::OpenAI
|
|
|
10
10
|
attr_reader :body
|
|
11
11
|
|
|
12
12
|
##
|
|
13
|
-
# @param [
|
|
13
|
+
# @param [#<<, LLM::Stream] stream
|
|
14
|
+
# A stream sink that implements {#<<} or the {LLM::Stream} interface
|
|
14
15
|
# @return [LLM::OpenAI::Responses::StreamParser]
|
|
15
|
-
def initialize(
|
|
16
|
+
def initialize(stream)
|
|
16
17
|
@body = {"output" => []}
|
|
17
|
-
@
|
|
18
|
+
@stream = stream
|
|
19
|
+
@emits = {tools: []}
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
##
|
|
@@ -24,6 +26,13 @@ class LLM::OpenAI
|
|
|
24
26
|
tap { handle_event(chunk) }
|
|
25
27
|
end
|
|
26
28
|
|
|
29
|
+
##
|
|
30
|
+
# Frees internal parser state used during streaming.
|
|
31
|
+
# @return [void]
|
|
32
|
+
def free
|
|
33
|
+
@emits.clear
|
|
34
|
+
end
|
|
35
|
+
|
|
27
36
|
private
|
|
28
37
|
|
|
29
38
|
def handle_event(chunk)
|
|
@@ -56,9 +65,21 @@ class LLM::OpenAI
|
|
|
56
65
|
if content_part && content_part["type"] == "output_text"
|
|
57
66
|
content_part["text"] ||= ""
|
|
58
67
|
content_part["text"] << delta_text
|
|
59
|
-
|
|
68
|
+
emit_content(delta_text)
|
|
60
69
|
end
|
|
61
70
|
end
|
|
71
|
+
when "response.function_call_arguments.delta"
|
|
72
|
+
output_item = @body["output"][chunk["output_index"]]
|
|
73
|
+
if output_item && output_item["type"] == "function_call"
|
|
74
|
+
output_item["arguments"] ||= +""
|
|
75
|
+
output_item["arguments"] << chunk["delta"]
|
|
76
|
+
end
|
|
77
|
+
when "response.function_call_arguments.done"
|
|
78
|
+
output_item = @body["output"][chunk["output_index"]]
|
|
79
|
+
if output_item && output_item["type"] == "function_call"
|
|
80
|
+
output_item["arguments"] = chunk["arguments"]
|
|
81
|
+
emit_tool(chunk["output_index"], output_item)
|
|
82
|
+
end
|
|
62
83
|
when "response.output_item.done"
|
|
63
84
|
output_index = chunk["output_index"]
|
|
64
85
|
item = chunk["item"]
|
|
@@ -72,5 +93,43 @@ class LLM::OpenAI
|
|
|
72
93
|
@body["output"][output_index]["content"][content_index] = part
|
|
73
94
|
end
|
|
74
95
|
end
|
|
96
|
+
|
|
97
|
+
def emit_content(value)
|
|
98
|
+
if @stream.respond_to?(:on_content)
|
|
99
|
+
@stream.on_content(value)
|
|
100
|
+
elsif @stream.respond_to?(:<<)
|
|
101
|
+
@stream << value
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def emit_tool(index, tool)
|
|
106
|
+
return unless @stream.respond_to?(:on_tool_call)
|
|
107
|
+
return unless complete_tool?(tool)
|
|
108
|
+
return if @emits[:tools].include?(index)
|
|
109
|
+
function, error = resolve_tool(tool)
|
|
110
|
+
@emits[:tools] << index
|
|
111
|
+
@stream.on_tool_call(function, error)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def complete_tool?(tool)
|
|
115
|
+
tool["call_id"] && tool["name"] && parse_arguments(tool["arguments"])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def resolve_tool(tool)
|
|
119
|
+
registered = LLM::Function.find_by_name(tool["name"])
|
|
120
|
+
fn = (registered || LLM::Function.new(tool["name"])).dup.tap do |fn|
|
|
121
|
+
fn.id = tool["call_id"]
|
|
122
|
+
fn.arguments = parse_arguments(tool["arguments"])
|
|
123
|
+
end
|
|
124
|
+
[fn, (registered ? nil : @stream.tool_not_found(fn))]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def parse_arguments(arguments)
|
|
128
|
+
return nil if arguments.to_s.empty?
|
|
129
|
+
parsed = LLM.json.load(arguments)
|
|
130
|
+
Hash === parsed ? parsed : nil
|
|
131
|
+
rescue *LLM.json.parser_error
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
75
134
|
end
|
|
76
135
|
end
|
|
@@ -39,7 +39,7 @@ class LLM::OpenAI
|
|
|
39
39
|
tools = resolve_tools(params.delete(:tools))
|
|
40
40
|
params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
|
|
41
41
|
role, stream = params.delete(:role), params.delete(:stream)
|
|
42
|
-
params[:stream] = true if
|
|
42
|
+
params[:stream] = true if @provider.streamable?(stream) || stream == true
|
|
43
43
|
req = Net::HTTP::Post.new("/v1/responses", headers)
|
|
44
44
|
messages = build_complete_messages(prompt, params, role)
|
|
45
45
|
@provider.tracer.set_request_metadata(user_input: extract_user_input(messages, fallback: prompt))
|
|
@@ -11,9 +11,10 @@ class LLM::OpenAI
|
|
|
11
11
|
|
|
12
12
|
##
|
|
13
13
|
# @return [LLM::OpenAI::Chunk]
|
|
14
|
-
def initialize(
|
|
14
|
+
def initialize(stream)
|
|
15
15
|
@body = {}
|
|
16
|
-
@
|
|
16
|
+
@stream = stream
|
|
17
|
+
@emits = {tools: []}
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
##
|
|
@@ -23,6 +24,13 @@ class LLM::OpenAI
|
|
|
23
24
|
tap { merge!(chunk) }
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
##
|
|
28
|
+
# Frees internal parser state used during streaming.
|
|
29
|
+
# @return [void]
|
|
30
|
+
def free
|
|
31
|
+
@emits.clear
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
private
|
|
27
35
|
|
|
28
36
|
def merge!(chunk)
|
|
@@ -47,7 +55,11 @@ class LLM::OpenAI
|
|
|
47
55
|
if key == "content"
|
|
48
56
|
target_message[key] ||= +""
|
|
49
57
|
target_message[key] << value
|
|
50
|
-
|
|
58
|
+
emit_content(value)
|
|
59
|
+
elsif key == "reasoning_content"
|
|
60
|
+
target_message[key] ||= +""
|
|
61
|
+
target_message[key] << value
|
|
62
|
+
emit_reasoning_content(value)
|
|
51
63
|
elsif key == "tool_calls"
|
|
52
64
|
merge_tools!(target_message, value)
|
|
53
65
|
else
|
|
@@ -60,8 +72,13 @@ class LLM::OpenAI
|
|
|
60
72
|
(choice["delta"] || {}).each do |key, value|
|
|
61
73
|
next if value.nil?
|
|
62
74
|
if key == "content"
|
|
63
|
-
|
|
75
|
+
emit_content(value)
|
|
76
|
+
message_hash[key] = value
|
|
77
|
+
elsif key == "reasoning_content"
|
|
78
|
+
emit_reasoning_content(value)
|
|
64
79
|
message_hash[key] = value
|
|
80
|
+
elsif key == "tool_calls"
|
|
81
|
+
merge_tools!(message_hash, value)
|
|
65
82
|
else
|
|
66
83
|
message_hash[key] = value
|
|
67
84
|
end
|
|
@@ -85,7 +102,54 @@ class LLM::OpenAI
|
|
|
85
102
|
else
|
|
86
103
|
target["tool_calls"][tindex] = toola
|
|
87
104
|
end
|
|
105
|
+
emit_tool(target["tool_calls"][tindex], tindex)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def emit_content(value)
|
|
110
|
+
if @stream.respond_to?(:on_content)
|
|
111
|
+
@stream.on_content(value)
|
|
112
|
+
elsif @stream.respond_to?(:<<)
|
|
113
|
+
@stream << value
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def emit_reasoning_content(value)
|
|
118
|
+
if @stream.respond_to?(:on_reasoning_content)
|
|
119
|
+
@stream.on_reasoning_content(value)
|
|
88
120
|
end
|
|
89
121
|
end
|
|
122
|
+
|
|
123
|
+
def emit_tool(tool, tindex)
|
|
124
|
+
return unless @stream.respond_to?(:on_tool_call)
|
|
125
|
+
return unless complete_tool?(tool)
|
|
126
|
+
return if @emits[:tools].include?(tindex)
|
|
127
|
+
function, error = resolve_tool(tool)
|
|
128
|
+
@emits[:tools] << tindex
|
|
129
|
+
@stream.on_tool_call(function, error)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def complete_tool?(tool)
|
|
133
|
+
function = tool["function"]
|
|
134
|
+
function && tool["id"] && function["name"] && parse_arguments(function["arguments"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def resolve_tool(tool)
|
|
138
|
+
function = tool["function"]
|
|
139
|
+
registered = LLM::Function.find_by_name(function["name"])
|
|
140
|
+
fn = (registered || LLM::Function.new(function["name"])).dup.tap do |fn|
|
|
141
|
+
fn.id = tool["id"]
|
|
142
|
+
fn.arguments = parse_arguments(function["arguments"])
|
|
143
|
+
end
|
|
144
|
+
[fn, (registered ? nil : @stream.tool_not_found(fn))]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_arguments(arguments)
|
|
148
|
+
return nil if arguments.to_s.empty?
|
|
149
|
+
parsed = LLM.json.load(arguments)
|
|
150
|
+
Hash === parsed ? parsed : nil
|
|
151
|
+
rescue *LLM.json.parser_error
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
90
154
|
end
|
|
91
155
|
end
|
data/lib/llm/providers/openai.rb
CHANGED
|
@@ -212,7 +212,7 @@ module LLM
|
|
|
212
212
|
tools = resolve_tools(params.delete(:tools))
|
|
213
213
|
params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
|
|
214
214
|
role, stream = params.delete(:role), params.delete(:stream)
|
|
215
|
-
params[:stream] = true if
|
|
215
|
+
params[:stream] = true if streamable?(stream) || stream == true
|
|
216
216
|
if params[:stream]
|
|
217
217
|
params[:stream_options] = {include_usage: true}.merge!(params[:stream_options] || {})
|
|
218
218
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Stream
|
|
4
|
+
##
|
|
5
|
+
# A small queue for collecting streamed tool work. Values can be immediate
|
|
6
|
+
# {LLM::Function::Return} objects or concurrent handles returned by
|
|
7
|
+
# {LLM::Function#spawn}. Calling {#wait(strategy)} resolves queued work and
|
|
8
|
+
# returns an array of {LLM::Function::Return} values.
|
|
9
|
+
class Queue
|
|
10
|
+
##
|
|
11
|
+
# @return [LLM::Stream::Queue]
|
|
12
|
+
def initialize
|
|
13
|
+
@items = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
##
|
|
17
|
+
# Enqueue a function return or spawned task.
|
|
18
|
+
# @param [LLM::Function::Return, Thread, Async::Task, Fiber] item
|
|
19
|
+
# @return [LLM::Stream::Queue]
|
|
20
|
+
def <<(item)
|
|
21
|
+
@items << item
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# Returns true when the queue is empty.
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def empty?
|
|
29
|
+
@items.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Waits for queued work to finish and returns function results.
|
|
34
|
+
# @param [Symbol] strategy
|
|
35
|
+
# Controls concurrency strategy:
|
|
36
|
+
# - `:thread`: Use threads
|
|
37
|
+
# - `:task`: Use async tasks (requires async gem)
|
|
38
|
+
# - `:fiber`: Use raw fibers
|
|
39
|
+
# @return [Array<LLM::Function::Return>]
|
|
40
|
+
def wait(strategy)
|
|
41
|
+
returns, tasks = @items.shift(@items.length).partition { LLM::Function::Return === _1 }
|
|
42
|
+
returns.concat case strategy
|
|
43
|
+
when :thread then LLM::Function::ThreadGroup.new(tasks).wait
|
|
44
|
+
when :task then LLM::Function::TaskGroup.new(tasks).wait
|
|
45
|
+
when :fiber then LLM::Function::FiberGroup.new(tasks).wait
|
|
46
|
+
else raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, or :fiber"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
alias_method :value, :wait
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/llm/stream.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Stream LLM::Stream} class provides the callback interface for
|
|
6
|
+
# streamed model output in llm.rb.
|
|
7
|
+
#
|
|
8
|
+
# A stream object can be an instance of {LLM::Stream LLM::Stream}, a
|
|
9
|
+
# subclass that overrides the callbacks it needs, or any other object that
|
|
10
|
+
# implements some or all of the same interface. {#queue} provides a small
|
|
11
|
+
# helper for collecting asynchronous tool work started from a callback, and
|
|
12
|
+
# {#tool_not_found} returns an in-band tool error when a streamed tool
|
|
13
|
+
# cannot be resolved.
|
|
14
|
+
#
|
|
15
|
+
# @note The `on_*` callbacks run inline with the streaming parser. They
|
|
16
|
+
# therefore block streaming progress and should generally return as
|
|
17
|
+
# quickly as possible.
|
|
18
|
+
#
|
|
19
|
+
# The most common callback is {#on_content}, which also maps to {#<<} for
|
|
20
|
+
# compatibility with `StringIO`-style objects. Providers may also call
|
|
21
|
+
# {#on_reasoning_content} and {#on_tool_call} when that data is available.
|
|
22
|
+
class Stream
|
|
23
|
+
require_relative "stream/queue"
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# Returns a lazily-initialized queue for tool results or spawned work.
|
|
27
|
+
# @return [LLM::Stream::Queue]
|
|
28
|
+
def queue
|
|
29
|
+
@queue ||= Queue.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
##
|
|
33
|
+
# Waits for queued tool work to finish and returns function results.
|
|
34
|
+
# @param [Symbol] strategy
|
|
35
|
+
# The concurrency strategy to use
|
|
36
|
+
# @return [Array<LLM::Function::Return>]
|
|
37
|
+
def wait(strategy)
|
|
38
|
+
queue.wait(strategy)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @group Public callbacks
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Called when visible assistant output is streamed.
|
|
45
|
+
# @param [String] content
|
|
46
|
+
# A chunk of assistant-visible text.
|
|
47
|
+
# @return [nil]
|
|
48
|
+
def on_content(content)
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
alias_method :<<, :on_content
|
|
52
|
+
|
|
53
|
+
##
|
|
54
|
+
# Called when reasoning output is streamed separately from visible content.
|
|
55
|
+
# @param [String] content
|
|
56
|
+
# A chunk of reasoning text.
|
|
57
|
+
# @return [nil]
|
|
58
|
+
def on_reasoning_content(content)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
##
|
|
63
|
+
# Called when a streamed tool call has been fully constructed.
|
|
64
|
+
# @note A stream implementation may start tool execution here, for
|
|
65
|
+
# example by pushing `tool.spawn(:thread)`, `tool.spawn(:fiber)`, or
|
|
66
|
+
# `tool.spawn(:task)` onto {#queue}. When a streamed tool cannot be
|
|
67
|
+
# resolved, `error` is passed as an {LLM::Function::Return}. It can be
|
|
68
|
+
# sent back to the model, allowing the tool-call path to recover and the
|
|
69
|
+
# session to continue. Tool resolution depends on
|
|
70
|
+
# {LLM::Function.registry}, which includes {LLM::Tool LLM::Tool}
|
|
71
|
+
# subclasses, including MCP tools, but not functions defined with
|
|
72
|
+
# {LLM.function}.
|
|
73
|
+
# @param [LLM::Function] tool
|
|
74
|
+
# The parsed tool call.
|
|
75
|
+
# @param [LLM::Function::Return, nil] error
|
|
76
|
+
# An in-band tool error for unresolved tool calls.
|
|
77
|
+
# @return [nil]
|
|
78
|
+
def on_tool_call(tool, error)
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @endgroup
|
|
83
|
+
|
|
84
|
+
# @group Error handlers
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Returns a function return describing a streamed tool that could not
|
|
88
|
+
# be resolved.
|
|
89
|
+
# @note This is mainly useful as a fallback from {#on_tool_call}. It
|
|
90
|
+
# should be uncommon in normal use, since streamed tool callbacks only
|
|
91
|
+
# run for tools already defined in the context.
|
|
92
|
+
# @param [LLM::Function] tool
|
|
93
|
+
# @return [LLM::Function::Return]
|
|
94
|
+
def tool_not_found(tool)
|
|
95
|
+
LLM::Function::Return.new(tool.id, tool.name, {
|
|
96
|
+
error: true, type: LLM::NoSuchToolError.name, message: "tool not found"
|
|
97
|
+
})
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @endgroup
|
|
101
|
+
end
|
|
102
|
+
end
|