llm.rb 4.10.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +157 -36
  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.rb +40 -6
  19. data/lib/llm/mcp/transport/stdio.rb +7 -0
  20. data/lib/llm/mcp.rb +54 -24
  21. data/lib/llm/message.rb +9 -2
  22. data/lib/llm/provider.rb +10 -0
  23. data/lib/llm/providers/anthropic/response_adapter/completion.rb +6 -0
  24. data/lib/llm/providers/anthropic/stream_parser.rb +37 -4
  25. data/lib/llm/providers/anthropic.rb +1 -1
  26. data/lib/llm/providers/google/response_adapter/completion.rb +12 -5
  27. data/lib/llm/providers/google/stream_parser.rb +54 -11
  28. data/lib/llm/providers/google/utils.rb +30 -0
  29. data/lib/llm/providers/google.rb +2 -0
  30. data/lib/llm/providers/ollama/response_adapter/completion.rb +6 -0
  31. data/lib/llm/providers/ollama/stream_parser.rb +10 -4
  32. data/lib/llm/providers/ollama.rb +1 -1
  33. data/lib/llm/providers/openai/response_adapter/completion.rb +7 -0
  34. data/lib/llm/providers/openai/response_adapter/responds.rb +84 -10
  35. data/lib/llm/providers/openai/responses/stream_parser.rb +63 -4
  36. data/lib/llm/providers/openai/responses.rb +1 -1
  37. data/lib/llm/providers/openai/stream_parser.rb +68 -4
  38. data/lib/llm/providers/openai.rb +1 -1
  39. data/lib/llm/stream/queue.rb +51 -0
  40. data/lib/llm/stream.rb +102 -0
  41. data/lib/llm/tool.rb +50 -45
  42. data/lib/llm/version.rb +1 -1
  43. data/lib/llm.rb +3 -2
  44. data/llm.gemspec +2 -2
  45. metadata +7 -1
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Function
4
+ ##
5
+ # The {LLM::Function::Task} class wraps a single concurrent function call and
6
+ # provides a small, uniform interface across threads, fibers, and async tasks.
7
+ class Task
8
+ ##
9
+ # @return [Object]
10
+ attr_reader :task
11
+
12
+ ##
13
+ # @param [Thread, Fiber, Async::Task] task
14
+ # @return [LLM::Function::Task]
15
+ def initialize(task)
16
+ @task = task
17
+ end
18
+
19
+ ##
20
+ # @return [Boolean]
21
+ def alive?
22
+ task.alive?
23
+ end
24
+
25
+ ##
26
+ # @return [LLM::Function::Return]
27
+ def wait
28
+ if Thread === task
29
+ task.value
30
+ elsif Fiber === task
31
+ task.resume if task.alive?
32
+ task.value
33
+ else
34
+ task.wait
35
+ end
36
+ end
37
+ alias_method :value, :wait
38
+ end
39
+ end
data/lib/llm/function.rb CHANGED
@@ -29,12 +29,15 @@
29
29
  # end
30
30
  # end
31
31
  class LLM::Function
32
+ require_relative "function/registry"
32
33
  require_relative "function/tracing"
33
34
  require_relative "function/array"
35
+ require_relative "function/task"
34
36
  require_relative "function/thread_group"
35
37
  require_relative "function/fiber_group"
36
38
  require_relative "function/task_group"
37
39
 
40
+ extend LLM::Function::Registry
38
41
  prepend LLM::Function::Tracing
39
42
 
40
43
  Return = Struct.new(:id, :name, :value) do
@@ -144,7 +147,7 @@ class LLM::Function
144
147
  end
145
148
 
146
149
  ##
147
- # Calls the function in a separate thread.
150
+ # Calls the function concurrently.
148
151
  #
149
152
  # This is the low-level method that powers concurrent tool execution.
150
153
  # Prefer the collection methods on {LLM::Context#functions} for most
@@ -156,8 +159,8 @@ class LLM::Function
156
159
  # ctx.talk(ctx.functions.wait)
157
160
  #
158
161
  # # Direct usage (uncommon)
159
- # thread = tool.spawn
160
- # result = thread.value
162
+ # task = tool.spawn(:thread)
163
+ # result = task.value
161
164
  #
162
165
  # @param [Symbol] strategy
163
166
  # Controls concurrency strategy:
@@ -165,10 +168,10 @@ class LLM::Function
165
168
  # - `:task`: Use async tasks (requires async gem)
166
169
  # - `:fiber`: Use raw fibers
167
170
  #
168
- # @return [Thread, Async::Task, Fiber]
169
- # Returns a thread, async task, or fiber whose `#value` is an {LLM::Function::Return}.
171
+ # @return [LLM::Function::Task]
172
+ # Returns a task whose `#value` is an {LLM::Function::Return}.
170
173
  def spawn(strategy)
171
- case strategy
174
+ task = case strategy
172
175
  when :task
173
176
  require "async" unless defined?(::Async)
174
177
  Async { call_function }
@@ -183,6 +186,7 @@ class LLM::Function
183
186
  else
184
187
  raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, or :fiber"
185
188
  end
189
+ Task.new(task)
186
190
  ensure
187
191
  @called = true
188
192
  end
@@ -260,7 +264,8 @@ class LLM::Function
260
264
  # Returns a Return object with either the function result or error information.
261
265
  def call_function
262
266
  runner = ((Class === @runner) ? @runner.new : @runner)
263
- Return.new(id, name, runner.call(**arguments))
267
+ kwargs = Hash === arguments ? arguments.transform_keys(&:to_sym) : arguments
268
+ Return.new(id, name, runner.call(**kwargs))
264
269
  rescue => ex
265
270
  Return.new(id, name, {error: true, type: ex.class.name, message: ex.message})
266
271
  end
@@ -61,10 +61,16 @@ module LLM::MCP::Transport
61
61
  # @return [void]
62
62
  def write(message)
63
63
  raise LLM::MCP::Error, "MCP transport is not running" unless running?
64
- http = Net::HTTP.start(uri.host, uri.port, use_ssl:, open_timeout: timeout, read_timeout: timeout)
65
64
  req = Net::HTTP::Post.new(uri.path, headers.merge("content-type" => "application/json"))
66
65
  req.body = LLM.json.dump(message)
67
- http.request(req) do |res|
66
+ if persistent_client.nil?
67
+ http = Net::HTTP.start(uri.host, uri.port, use_ssl:, open_timeout: timeout, read_timeout: timeout)
68
+ args = [req]
69
+ else
70
+ http = persistent_client
71
+ args = [uri, req]
72
+ end
73
+ http.request(*args) do |res|
68
74
  unless Net::HTTPSuccess === res
69
75
  raise LLM::MCP::Error, "MCP transport write failed with HTTP #{res.code}"
70
76
  end
@@ -94,14 +100,30 @@ module LLM::MCP::Transport
94
100
  @running
95
101
  end
96
102
 
103
+ ##
104
+ # Configures the transport to use a persistent HTTP connection pool
105
+ # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
106
+ # @example
107
+ # mcp = LLM.mcp(http: {url: "https://example.com/mcp"}).persist!
108
+ # # do something with 'mcp'
109
+ # @return [LLM::MCP::Transport::HTTP]
110
+ def persist!
111
+ LLM.lock(:mcp) do
112
+ require "net/http/persistent" unless defined?(Net::HTTP::Persistent)
113
+ unless LLM::MCP.clients.key?(key)
114
+ http = Net::HTTP::Persistent.new(name: self.class.name)
115
+ http.read_timeout = timeout
116
+ http.open_timeout = timeout
117
+ LLM::MCP.clients[key] ||= http
118
+ end
119
+ end
120
+ self
121
+ end
122
+
97
123
  private
98
124
 
99
125
  attr_reader :uri, :use_ssl, :headers, :timeout
100
126
 
101
- def enqueue(message)
102
- lock { @queue << message }
103
- end
104
-
105
127
  def read(res)
106
128
  if res["content-type"].to_s.include?("text/event-stream")
107
129
  parser = LLM::EventStream::Parser.new
@@ -115,6 +137,18 @@ module LLM::MCP::Transport
115
137
  end
116
138
  end
117
139
 
140
+ def enqueue(message)
141
+ lock { @queue << message }
142
+ end
143
+
144
+ def persistent_client
145
+ LLM::MCP.clients[key]
146
+ end
147
+
148
+ def key
149
+ "#{uri.scheme}:#{uri.host}:#{uri.port}:#{timeout}"
150
+ end
151
+
118
152
  def lock(&)
119
153
  @monitor.synchronize(&)
120
154
  end
@@ -78,6 +78,13 @@ module LLM::MCP::Transport
78
78
  command.wait
79
79
  end
80
80
 
81
+ ##
82
+ # This method is a no-op for stdio transports
83
+ # @return [LLM::MCP::Transport::Stdio]
84
+ def persist!
85
+ self
86
+ end
87
+
81
88
  private
82
89
 
83
90
  attr_reader :command, :stdin, :stdout, :stderr
data/lib/llm/mcp.rb CHANGED
@@ -9,8 +9,10 @@
9
9
  # In llm.rb, {LLM::MCP LLM::MCP} currently supports stdio and HTTP
10
10
  # transports and focuses on discovering tools that can be used through
11
11
  # {LLM::Context LLM::Context} and {LLM::Agent LLM::Agent}.
12
+ #
13
+ # Like {LLM::Context LLM::Context}, an MCP client is stateful and is
14
+ # expected to remain isolated to a single thread.
12
15
  class LLM::MCP
13
- require "monitor"
14
16
  require_relative "mcp/error"
15
17
  require_relative "mcp/command"
16
18
  require_relative "mcp/rpc"
@@ -20,6 +22,34 @@ class LLM::MCP
20
22
 
21
23
  include RPC
22
24
 
25
+ @@clients = {}
26
+
27
+ ##
28
+ # @api private
29
+ def self.clients = @@clients
30
+
31
+ ##
32
+ # Builds an MCP client that uses the stdio transport.
33
+ # @param [LLM::Provider, nil] llm
34
+ # An instance of LLM::Provider. Optional.
35
+ # @param [Hash] stdio
36
+ # The stdio transport configuration
37
+ # @return [LLM::MCP]
38
+ def self.stdio(llm = nil, **stdio)
39
+ new(llm, stdio:)
40
+ end
41
+
42
+ ##
43
+ # Builds an MCP client that uses the HTTP transport.
44
+ # @param [LLM::Provider, nil] llm
45
+ # An instance of LLM::Provider. Optional.
46
+ # @param [Hash] http
47
+ # The HTTP transport configuration
48
+ # @return [LLM::MCP]
49
+ def self.http(llm = nil, **http)
50
+ new(llm, http:)
51
+ end
52
+
23
53
  ##
24
54
  # @param [LLM::Provider, nil] llm
25
55
  # The provider to use for MCP transports that need one
@@ -35,11 +65,11 @@ class LLM::MCP
35
65
  # The URL for the MCP HTTP endpoint
36
66
  # @option http [Hash] :headers
37
67
  # Extra headers for requests
38
- # @param [Integer] timeout The maximum amount of time to wait when reading from an MCP process
68
+ # @param [Integer] timeout
69
+ # The maximum amount of time to wait when reading from an MCP process
39
70
  # @return [LLM::MCP] A new MCP instance
40
71
  def initialize(llm = nil, stdio: nil, http: nil, timeout: 30)
41
72
  @llm = llm
42
- @monitor = Monitor.new
43
73
  @timeout = timeout
44
74
  if stdio && http
45
75
  raise ArgumentError, "stdio and http are mutually exclusive"
@@ -57,31 +87,37 @@ class LLM::MCP
57
87
  # Starts the MCP process.
58
88
  # @return [void]
59
89
  def start
60
- lock do
61
- transport.start
62
- call(transport, "initialize", {clientInfo: {name: "llm.rb", version: LLM::VERSION}})
63
- call(transport, "notifications/initialized")
64
- end
90
+ transport.start
91
+ call(transport, "initialize", {clientInfo: {name: "llm.rb", version: LLM::VERSION}})
92
+ call(transport, "notifications/initialized")
65
93
  end
66
94
 
67
95
  ##
68
96
  # Stops the MCP process.
69
97
  # @return [void]
70
98
  def stop
71
- lock do
72
- transport.stop
73
- nil
74
- end
99
+ transport.stop
100
+ nil
101
+ end
102
+
103
+ ##
104
+ # Configures an HTTP MCP transport to use a persistent connection pool
105
+ # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
106
+ # @example
107
+ # mcp = LLM.mcp(http: {url: "https://example.com/mcp"}).persist!
108
+ # # do something with 'mcp'
109
+ # @return [LLM::MCP]
110
+ def persist!
111
+ transport.persist!
112
+ self
75
113
  end
76
114
 
77
115
  ##
78
116
  # Returns the tools provided by the MCP process.
79
117
  # @return [Array<Class<LLM::Tool>>]
80
118
  def tools
81
- lock do
82
- res = call(transport, "tools/list")
83
- res["tools"].map { LLM::Tool.mcp(self, _1) }
84
- end
119
+ res = call(transport, "tools/list")
120
+ res["tools"].map { LLM::Tool.mcp(self, _1) }
85
121
  end
86
122
 
87
123
  ##
@@ -90,10 +126,8 @@ class LLM::MCP
90
126
  # @param [Hash] arguments The arguments to pass to the tool
91
127
  # @return [Object] The result of the tool call
92
128
  def call_tool(name, arguments = {})
93
- lock do
94
- res = call(transport, "tools/call", {name:, arguments:})
95
- adapt_tool_result(res)
96
- end
129
+ res = call(transport, "tools/call", {name:, arguments:})
130
+ adapt_tool_result(res)
97
131
  end
98
132
 
99
133
  private
@@ -109,8 +143,4 @@ class LLM::MCP
109
143
  result
110
144
  end
111
145
  end
112
-
113
- def lock(&)
114
- @monitor.synchronize(&)
115
- end
116
146
  end
data/lib/llm/message.rb CHANGED
@@ -33,7 +33,7 @@ module LLM
33
33
  # Returns a Hash representation of the message.
34
34
  # @return [Hash]
35
35
  def to_h
36
- {role:, content:,
36
+ {role:, content:, reasoning_content:,
37
37
  tools: extra.tool_calls,
38
38
  usage:,
39
39
  original_tool_calls: extra.original_tool_calls}.compact
@@ -67,6 +67,13 @@ module LLM
67
67
  LLM.json.load(content)
68
68
  end
69
69
 
70
+ ##
71
+ # Returns reasoning content associated with the message
72
+ # @return [String, nil]
73
+ def reasoning_content
74
+ extra.reasoning_content
75
+ end
76
+
70
77
  ##
71
78
  # @return [Array<LLM::Function>]
72
79
  def functions
@@ -158,7 +165,7 @@ module LLM
158
165
  def inspect
159
166
  "#<#{self.class.name}:0x#{object_id.to_s(16)} " \
160
167
  "tool_call=#{tool_calls.any?} role=#{role.inspect} " \
161
- "content=#{content.inspect}>"
168
+ "content=#{content.inspect} reasoning_content=#{reasoning_content.inspect}>"
162
169
  end
163
170
 
164
171
  private
data/lib/llm/provider.rb CHANGED
@@ -318,6 +318,15 @@ class LLM::Provider
318
318
  end
319
319
  end
320
320
 
321
+ ##
322
+ # @param [Object] stream
323
+ # @return [Boolean]
324
+ def streamable?(stream)
325
+ stream.respond_to?(:on_content) ||
326
+ stream.respond_to?(:on_reasoning_content) ||
327
+ stream.respond_to?(:<<)
328
+ end
329
+
321
330
  private
322
331
 
323
332
  attr_reader :client, :base_uri, :host, :port, :timeout, :ssl
@@ -393,6 +402,7 @@ class LLM::Provider
393
402
  res.body = body
394
403
  end
395
404
  ensure
405
+ handler&.free
396
406
  parser&.free
397
407
  end
398
408
  else
@@ -51,6 +51,12 @@ module LLM::Anthropic::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!
@@ -10,11 +10,12 @@ class LLM::Anthropic
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::Anthropic::StreamParser]
15
- def initialize(io)
16
+ def initialize(stream)
16
17
  @body = {"role" => "assistant", "content" => []}
17
- @io = io
18
+ @stream = stream
18
19
  end
19
20
 
20
21
  ##
@@ -24,6 +25,12 @@ class LLM::Anthropic
24
25
  tap { merge!(chunk) }
25
26
  end
26
27
 
28
+ ##
29
+ # Frees internal parser state used during streaming.
30
+ # @return [void]
31
+ def free
32
+ end
33
+
27
34
  private
28
35
 
29
36
  def merge!(chunk)
@@ -34,7 +41,7 @@ class LLM::Anthropic
34
41
  elsif chunk["type"] == "content_block_delta"
35
42
  if chunk["delta"]["type"] == "text_delta"
36
43
  @body["content"][chunk["index"]]["text"] << chunk["delta"]["text"]
37
- @io << chunk["delta"]["text"] if @io.respond_to?(:<<)
44
+ emit_content(chunk["delta"]["text"])
38
45
  elsif chunk["delta"]["type"] == "input_json_delta"
39
46
  content = @body["content"][chunk["index"]]
40
47
  if Hash === content["input"]
@@ -53,6 +60,9 @@ class LLM::Anthropic
53
60
  if content["input"]
54
61
  content["input"] = LLM.json.load(content["input"])
55
62
  end
63
+ if content["type"] == "tool_use"
64
+ emit_tool(content)
65
+ end
56
66
  end
57
67
  end
58
68
 
@@ -76,5 +86,28 @@ class LLM::Anthropic
76
86
  end
77
87
  end
78
88
  end
89
+
90
+ def emit_content(value)
91
+ if @stream.respond_to?(:on_content)
92
+ @stream.on_content(value)
93
+ elsif @stream.respond_to?(:<<)
94
+ @stream << value
95
+ end
96
+ end
97
+
98
+ def emit_tool(tool)
99
+ return unless @stream.respond_to?(:on_tool_call)
100
+ function, error = resolve_tool(tool)
101
+ @stream.on_tool_call(function, error)
102
+ end
103
+
104
+ def resolve_tool(tool)
105
+ registered = LLM::Function.find_by_name(tool["name"])
106
+ fn = (registered || LLM::Function.new(tool["name"])).dup.tap do |fn|
107
+ fn.id = tool["id"]
108
+ fn.arguments = tool["input"]
109
+ end
110
+ [fn, (registered ? nil : @stream.tool_not_found(fn))]
111
+ end
79
112
  end
80
113
  end
@@ -141,7 +141,7 @@ module LLM
141
141
  tools = resolve_tools(params.delete(:tools))
142
142
  params = [params, adapt_tools(tools)].inject({}, &:merge!).compact
143
143
  role, stream = params.delete(:role), params.delete(:stream)
144
- params[:stream] = true if stream.respond_to?(:<<) || stream == true
144
+ params[:stream] = true if streamable?(stream) || stream == true
145
145
  [params, stream, tools, role]
146
146
  end
147
147
 
@@ -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