llm.rb 4.9.0 → 4.11.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 +152 -0
- data/README.md +178 -31
- 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/event_handler.rb +66 -0
- data/lib/llm/mcp/transport/http.rb +156 -0
- data/lib/llm/mcp/transport/stdio.rb +7 -0
- data/lib/llm/mcp.rb +74 -30
- 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/schema/all_of.rb +31 -0
- data/lib/llm/schema/any_of.rb +31 -0
- data/lib/llm/schema/one_of.rb +31 -0
- data/lib/llm/schema/parser.rb +36 -0
- data/lib/llm/schema.rb +45 -8
- data/lib/llm/stream/queue.rb +51 -0
- data/lib/llm/stream.rb +102 -0
- data/lib/llm/tool.rb +53 -47
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +3 -2
- data/llm.gemspec +2 -2
- metadata +12 -1
|
@@ -51,6 +51,12 @@ module LLM::Google::ResponseAdapter
|
|
|
51
51
|
super
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
##
|
|
55
|
+
# (see LLM::Contract::Completion#reasoning_content)
|
|
56
|
+
def reasoning_content
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
54
60
|
##
|
|
55
61
|
# (see LLM::Contract::Completion#content!)
|
|
56
62
|
def content!
|
|
@@ -60,21 +66,22 @@ module LLM::Google::ResponseAdapter
|
|
|
60
66
|
private
|
|
61
67
|
|
|
62
68
|
def adapt_choices
|
|
63
|
-
candidates.map.with_index do |choice,
|
|
69
|
+
candidates.map.with_index do |choice, cindex|
|
|
64
70
|
content = choice.content || LLM::Object.new
|
|
65
71
|
role = content.role || "model"
|
|
66
72
|
parts = content.parts || [{"text" => choice.finishReason}]
|
|
67
73
|
text = parts.filter_map { _1["text"] }.join
|
|
68
74
|
tools = parts.select { _1["functionCall"] }
|
|
69
|
-
extra = {index
|
|
75
|
+
extra = {index: cindex, response: self, tool_calls: adapt_tool_calls(parts, cindex), original_tool_calls: tools}
|
|
70
76
|
LLM::Message.new(role, text, extra)
|
|
71
77
|
end
|
|
72
78
|
end
|
|
73
79
|
|
|
74
|
-
def adapt_tool_calls(parts)
|
|
75
|
-
(parts || []).
|
|
80
|
+
def adapt_tool_calls(parts, cindex)
|
|
81
|
+
(parts || []).each_with_index.filter_map do |part, pindex|
|
|
76
82
|
tool = part["functionCall"]
|
|
77
|
-
|
|
83
|
+
next unless tool
|
|
84
|
+
{id: LLM::Google.tool_id(part:, cindex:, pindex:), name: tool.name, arguments: tool.args}
|
|
78
85
|
end
|
|
79
86
|
end
|
|
80
87
|
|
|
@@ -10,11 +10,13 @@ class LLM::Google
|
|
|
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::Google::StreamParser]
|
|
15
|
-
def initialize(
|
|
16
|
+
def initialize(stream)
|
|
16
17
|
@body = {"candidates" => []}
|
|
17
|
-
@
|
|
18
|
+
@stream = stream
|
|
19
|
+
@emits = {tools: []}
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
##
|
|
@@ -24,6 +26,13 @@ class LLM::Google
|
|
|
24
26
|
tap { merge_chunk!(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 merge_chunk!(chunk)
|
|
@@ -49,7 +58,7 @@ class LLM::Google
|
|
|
49
58
|
delta.each do |key, value|
|
|
50
59
|
k = key.to_s
|
|
51
60
|
if k == "content"
|
|
52
|
-
merge_candidate_content!(candidate["content"], value) if value
|
|
61
|
+
merge_candidate_content!(candidate["content"], value, index) if value
|
|
53
62
|
else
|
|
54
63
|
candidate[k] = value # Overwrite other fields
|
|
55
64
|
end
|
|
@@ -57,24 +66,24 @@ class LLM::Google
|
|
|
57
66
|
end
|
|
58
67
|
end
|
|
59
68
|
|
|
60
|
-
def merge_candidate_content!(content, delta)
|
|
69
|
+
def merge_candidate_content!(content, delta, cindex)
|
|
61
70
|
delta.each do |key, value|
|
|
62
71
|
k = key.to_s
|
|
63
72
|
if k == "parts"
|
|
64
73
|
content["parts"] ||= []
|
|
65
|
-
merge_content_parts!(content["parts"], value) if value
|
|
74
|
+
merge_content_parts!(content["parts"], value, cindex) if value
|
|
66
75
|
else
|
|
67
76
|
content[k] = value
|
|
68
77
|
end
|
|
69
78
|
end
|
|
70
79
|
end
|
|
71
80
|
|
|
72
|
-
def merge_content_parts!(parts, deltas)
|
|
81
|
+
def merge_content_parts!(parts, deltas, cindex)
|
|
73
82
|
deltas.each do |delta|
|
|
74
83
|
if delta["text"]
|
|
75
84
|
merge_text!(parts, delta)
|
|
76
85
|
elsif delta["functionCall"]
|
|
77
|
-
merge_function_call!(parts, delta)
|
|
86
|
+
merge_function_call!(parts, delta, cindex)
|
|
78
87
|
elsif delta["inlineData"]
|
|
79
88
|
parts << delta
|
|
80
89
|
elsif delta["functionResponse"]
|
|
@@ -93,14 +102,14 @@ class LLM::Google
|
|
|
93
102
|
if last_existing_part.is_a?(Hash) && last_existing_part["text"]
|
|
94
103
|
last_existing_part["text"] ||= +""
|
|
95
104
|
last_existing_part["text"] << text
|
|
96
|
-
|
|
105
|
+
emit_content(text)
|
|
97
106
|
else
|
|
98
107
|
parts << delta
|
|
99
|
-
|
|
108
|
+
emit_content(text)
|
|
100
109
|
end
|
|
101
110
|
end
|
|
102
111
|
|
|
103
|
-
def merge_function_call!(parts, delta)
|
|
112
|
+
def merge_function_call!(parts, delta, cindex)
|
|
104
113
|
last_existing_part = parts.last
|
|
105
114
|
last_call = last_existing_part.is_a?(Hash) ? last_existing_part["functionCall"] : nil
|
|
106
115
|
delta_call = delta["functionCall"]
|
|
@@ -113,6 +122,40 @@ class LLM::Google
|
|
|
113
122
|
else
|
|
114
123
|
parts << delta
|
|
115
124
|
end
|
|
125
|
+
emit_tool(parts.length - 1, cindex, parts.last || delta)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def emit_content(value)
|
|
129
|
+
if @stream.respond_to?(:on_content)
|
|
130
|
+
@stream.on_content(value)
|
|
131
|
+
elsif @stream.respond_to?(:<<)
|
|
132
|
+
@stream << value
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def emit_tool(pindex, cindex, part)
|
|
137
|
+
return unless @stream.respond_to?(:on_tool_call)
|
|
138
|
+
return unless complete_tool?(part)
|
|
139
|
+
key = [cindex, pindex]
|
|
140
|
+
return if @emits[:tools].include?(key)
|
|
141
|
+
function, error = resolve_tool(part, cindex, pindex)
|
|
142
|
+
@emits[:tools] << key
|
|
143
|
+
@stream.on_tool_call(function, error)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def complete_tool?(part)
|
|
147
|
+
call = part["functionCall"]
|
|
148
|
+
call && call["name"] && Hash === call["args"]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def resolve_tool(part, cindex, pindex)
|
|
152
|
+
call = part["functionCall"]
|
|
153
|
+
registered = LLM::Function.find_by_name(call["name"])
|
|
154
|
+
fn = (registered || LLM::Function.new(call["name"])).dup.tap do |fn|
|
|
155
|
+
fn.id = LLM::Google.tool_id(part:, cindex:, pindex:)
|
|
156
|
+
fn.arguments = call["args"]
|
|
157
|
+
end
|
|
158
|
+
[fn, (registered ? nil : @stream.tool_not_found(fn))]
|
|
116
159
|
end
|
|
117
160
|
end
|
|
118
161
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Google
|
|
4
|
+
module Utils
|
|
5
|
+
##
|
|
6
|
+
# Returns a stable internal tool-call ID for Gemini function calls.
|
|
7
|
+
#
|
|
8
|
+
# Gemini responses may omit a direct tool-call ID, but llm.rb expects one
|
|
9
|
+
# for matching pending tool calls with tool returns across streaming and
|
|
10
|
+
# normal completion flows.
|
|
11
|
+
#
|
|
12
|
+
# When Gemini provides a `thoughtSignature`, that value is used as the
|
|
13
|
+
# basis for the ID. Otherwise the ID falls back to the candidate and part
|
|
14
|
+
# indexes, which are stable within the response.
|
|
15
|
+
#
|
|
16
|
+
# @param part [Hash]
|
|
17
|
+
# A Gemini content part containing a `functionCall`.
|
|
18
|
+
# @param cindex [Integer]
|
|
19
|
+
# The candidate index for the tool call.
|
|
20
|
+
# @param pindex [Integer]
|
|
21
|
+
# The part index for the tool call within the candidate.
|
|
22
|
+
# @return [String]
|
|
23
|
+
# Returns a stable internal tool-call ID.
|
|
24
|
+
def tool_id(part:, cindex:, pindex:)
|
|
25
|
+
signature = part["thoughtSignature"].to_s
|
|
26
|
+
return "google_#{signature}" unless signature.empty?
|
|
27
|
+
"google_call_#{cindex}_#{pindex}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
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,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Schema
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Schema::AllOf LLM::Schema::AllOf} class represents an
|
|
6
|
+
# allOf union in a JSON schema. It is a subclass of
|
|
7
|
+
# {LLM::Schema::Leaf LLM::Schema::Leaf}.
|
|
8
|
+
class AllOf < Leaf
|
|
9
|
+
##
|
|
10
|
+
# Returns an allOf union for the given types.
|
|
11
|
+
# @return [LLM::Schema::AllOf]
|
|
12
|
+
def self.[](*types)
|
|
13
|
+
schema = LLM::Schema.new
|
|
14
|
+
new(types.map { LLM::Schema::Utils.resolve(schema, _1) })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @param [Array<LLM::Schema::Leaf>] values
|
|
19
|
+
# The values required by the union
|
|
20
|
+
# @return [LLM::Schema::AllOf]
|
|
21
|
+
def initialize(values)
|
|
22
|
+
@values = values
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
##
|
|
26
|
+
# @return [Hash]
|
|
27
|
+
def to_h
|
|
28
|
+
super.merge!(allOf: @values)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|