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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +178 -31
  4. data/data/anthropic.json +209 -242
  5. data/data/deepseek.json +15 -15
  6. data/data/google.json +553 -403
  7. data/data/openai.json +740 -535
  8. data/data/xai.json +250 -253
  9. data/data/zai.json +157 -90
  10. data/lib/llm/context/deserializer.rb +2 -1
  11. data/lib/llm/context.rb +58 -2
  12. data/lib/llm/contract/completion.rb +7 -0
  13. data/lib/llm/error.rb +4 -0
  14. data/lib/llm/eventhandler.rb +7 -0
  15. data/lib/llm/function/registry.rb +106 -0
  16. data/lib/llm/function/task.rb +39 -0
  17. data/lib/llm/function.rb +12 -7
  18. data/lib/llm/mcp/transport/http/event_handler.rb +66 -0
  19. data/lib/llm/mcp/transport/http.rb +156 -0
  20. data/lib/llm/mcp/transport/stdio.rb +7 -0
  21. data/lib/llm/mcp.rb +74 -30
  22. data/lib/llm/message.rb +9 -2
  23. data/lib/llm/provider.rb +10 -0
  24. data/lib/llm/providers/anthropic/response_adapter/completion.rb +6 -0
  25. data/lib/llm/providers/anthropic/stream_parser.rb +37 -4
  26. data/lib/llm/providers/anthropic.rb +1 -1
  27. data/lib/llm/providers/google/response_adapter/completion.rb +12 -5
  28. data/lib/llm/providers/google/stream_parser.rb +54 -11
  29. data/lib/llm/providers/google/utils.rb +30 -0
  30. data/lib/llm/providers/google.rb +2 -0
  31. data/lib/llm/providers/ollama/response_adapter/completion.rb +6 -0
  32. data/lib/llm/providers/ollama/stream_parser.rb +10 -4
  33. data/lib/llm/providers/ollama.rb +1 -1
  34. data/lib/llm/providers/openai/response_adapter/completion.rb +7 -0
  35. data/lib/llm/providers/openai/response_adapter/responds.rb +84 -10
  36. data/lib/llm/providers/openai/responses/stream_parser.rb +63 -4
  37. data/lib/llm/providers/openai/responses.rb +1 -1
  38. data/lib/llm/providers/openai/stream_parser.rb +68 -4
  39. data/lib/llm/providers/openai.rb +1 -1
  40. data/lib/llm/schema/all_of.rb +31 -0
  41. data/lib/llm/schema/any_of.rb +31 -0
  42. data/lib/llm/schema/one_of.rb +31 -0
  43. data/lib/llm/schema/parser.rb +36 -0
  44. data/lib/llm/schema.rb +45 -8
  45. data/lib/llm/stream/queue.rb +51 -0
  46. data/lib/llm/stream.rb +102 -0
  47. data/lib/llm/tool.rb +53 -47
  48. data/lib/llm/version.rb +1 -1
  49. data/lib/llm.rb +3 -2
  50. data/llm.gemspec +2 -2
  51. 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, index|
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:, response: self, tool_calls: adapt_tool_calls(tools), original_tool_calls: tools}
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 || []).map do |part|
80
+ def adapt_tool_calls(parts, cindex)
81
+ (parts || []).each_with_index.filter_map do |part, pindex|
76
82
  tool = part["functionCall"]
77
- {name: tool.name, arguments: tool.args}
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 [#<<] io An IO-like object
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(io)
16
+ def initialize(stream)
16
17
  @body = {"candidates" => []}
17
- @io = io
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
- @io << text if @io.respond_to?(:<<)
105
+ emit_content(text)
97
106
  else
98
107
  parts << delta
99
- @io << text if @io.respond_to?(:<<)
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
@@ -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
 
@@ -51,6 +51,12 @@ module LLM::Ollama::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!
@@ -11,9 +11,9 @@ class LLM::Ollama
11
11
 
12
12
  ##
13
13
  # @return [LLM::OpenAI::Chunk]
14
- def initialize(io)
14
+ def initialize(stream)
15
15
  @body = {}
16
- @io = io
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
- @io << value["content"] if @io.respond_to?(:<<)
39
+ @stream << value["content"] if @stream.respond_to?(:<<)
34
40
  else
35
41
  @body[key] = value
36
- @io << value["content"] if @io.respond_to?(:<<)
42
+ @stream << value["content"] if @stream.respond_to?(:<<)
37
43
  end
38
44
  else
39
45
  @body[key] = value
@@ -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 stream.respond_to?(:<<) || stream == true
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
- def model = body.model
6
- def response_id = respond_to?(:response) ? response["id"] : id
7
- def choices = [adapt_message]
8
- def annotations = choices[0].annotations
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
- def prompt_tokens = body.usage&.input_tokens
11
- def completion_tokens = body.usage&.output_tokens
12
- def total_tokens = body.usage&.total_tokens
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
- choices.find(&:assistant?).content || ""
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.with_index do |choice, index|
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 [#<<] io An IO-like object
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(io)
16
+ def initialize(stream)
16
17
  @body = {"output" => []}
17
- @io = io
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
- @io << delta_text if @io.respond_to?(:<<)
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 stream.respond_to?(:<<) || stream == true
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(io)
14
+ def initialize(stream)
15
15
  @body = {}
16
- @io = io
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
- @io << value if @io.respond_to?(:<<)
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
- @io << value if @io.respond_to?(:<<)
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
@@ -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 stream.respond_to?(:<<) || stream == true
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