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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +152 -0
- data/README.md +157 -36
- 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/version.rb +1 -1
- data/lib/llm.rb +3 -2
- data/llm.gemspec +2 -2
- 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
|
|
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
|
-
#
|
|
160
|
-
# result =
|
|
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 [
|
|
169
|
-
# Returns a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
@@ -10,11 +10,12 @@ class LLM::Anthropic
|
|
|
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::Anthropic::StreamParser]
|
|
15
|
-
def initialize(
|
|
16
|
+
def initialize(stream)
|
|
16
17
|
@body = {"role" => "assistant", "content" => []}
|
|
17
|
-
@
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|