brute 5.0.1 → 5.0.3
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/lib/brute/completion/lang_chain.rb +17 -2
- data/lib/brute/completion/llmrb.rb +17 -0
- data/lib/brute/completion/open_router.rb +114 -3
- data/lib/brute/completion/ruby_llm.rb +54 -3
- data/lib/brute/version.rb +1 -1
- data/lib/brute.rb +0 -1
- metadata +1 -16
- data/lib/brute/completion/async_faraday.rb +0 -38
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b1c034900a1b4cd0fca86749632d4bba871872a01a530e545fc5eeae62290ed
|
|
4
|
+
data.tar.gz: 157222f25f8afd4f3f44dcdd6589b4dd29a9f26349851360a4a6dadc158ab9fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 59a96300b0a9db1d5d9348e6155e8f2977da15fcaf3834aa357cc663ff37cff011ec2ac1e11e32eb4a2de65baf08159cff0d9ad95895936c3f22be80e5b90e50
|
|
7
|
+
data.tar.gz: 073a72fd94f1af3eaf590794634610f856b34b30bb76508fb5574e418d275020cdd4dc3f2bb0ae4524488fd1d794947ae6e95b117ac34f5a0c1ef8117f2ab2f7
|
|
@@ -35,8 +35,6 @@ module Brute
|
|
|
35
35
|
raise LoadError, "#{self.class} needs the 'langchainrb' gem — add `gem \"langchainrb\"` to your Gemfile."
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
Brute::Completion.async_faraday!
|
|
39
|
-
|
|
40
38
|
@llm = llm || client or
|
|
41
39
|
raise ArgumentError, "#{self.class} needs an llm: option, e.g. Langchain::LLM::OpenAI.new(api_key: ...)"
|
|
42
40
|
|
|
@@ -157,6 +155,23 @@ describe "brute/completion/lang_chain" do
|
|
|
157
155
|
llm.calls.first[:temperature].should == 0.7
|
|
158
156
|
llm.calls.first[:messages].first[:role].should == "user"
|
|
159
157
|
|
|
158
|
+
# env[:tools] — what the ToolPipeline put there — is advertised as-is.
|
|
159
|
+
tooled = FakeLangChainLLM.new(FakeLangChainResponse.new("ok", []))
|
|
160
|
+
tool = {
|
|
161
|
+
name: "echo",
|
|
162
|
+
description: "Echo the input back",
|
|
163
|
+
params: { msg: { type: "string", desc: "what to echo", required: true } },
|
|
164
|
+
execute: ->(msg:) { msg },
|
|
165
|
+
}
|
|
166
|
+
tools_env = { messages: Brute.log, tools: [tool], events: [] }
|
|
167
|
+
tools_env[:messages].user("hi")
|
|
168
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::LangChain.new(llm: tooled) }.call(tools_env)
|
|
169
|
+
|
|
170
|
+
advertised = tooled.calls.first[:tools]
|
|
171
|
+
advertised.first[:type].should == "function"
|
|
172
|
+
advertised.first[:function][:name].should == "echo"
|
|
173
|
+
advertised.first[:function][:parameters][:required].should == ["msg"]
|
|
174
|
+
|
|
160
175
|
# A tool call comes back in the OpenAI shape and lands as a ToolCall.
|
|
161
176
|
calling = FakeLangChainLLM.new(FakeLangChainResponse.new(nil, [
|
|
162
177
|
{ "id" => "tc1", "type" => "function", "function" => { "name" => "echo", "arguments" => { "text" => "hi" } } },
|
|
@@ -168,6 +168,23 @@ describe "brute/completion/llmrb" do
|
|
|
168
168
|
call[:params][:temperature].should == 0.1
|
|
169
169
|
call[:params][:model].should == "env-model" # falls back to env
|
|
170
170
|
|
|
171
|
+
# env[:tools] — what the ToolPipeline put there — becomes llm.rb functions.
|
|
172
|
+
tooled = FakeLLMrbClient.new
|
|
173
|
+
tool = {
|
|
174
|
+
name: "echo",
|
|
175
|
+
description: "Echo the input back",
|
|
176
|
+
params: { msg: { type: "string", desc: "what to echo", required: true } },
|
|
177
|
+
execute: ->(msg:) { msg },
|
|
178
|
+
}
|
|
179
|
+
tools_env = { messages: Brute.log, provider: :stub, model: "m", tools: [tool], events: [] }
|
|
180
|
+
tools_env[:messages].user("hi")
|
|
181
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::LLMrb.new(client: tooled) }.call(tools_env)
|
|
182
|
+
|
|
183
|
+
functions = tooled.calls.first[:params][:tools]
|
|
184
|
+
functions.size.should == 1
|
|
185
|
+
functions.first.name.should == "echo"
|
|
186
|
+
functions.first.description.should == "Echo the input back"
|
|
187
|
+
|
|
171
188
|
# Without a client, a provider is required to build one.
|
|
172
189
|
no_provider = Brute::Turn::Pipeline.new
|
|
173
190
|
no_provider.run Brute::Completion::LLMrb.new
|
|
@@ -15,6 +15,12 @@ module Brute
|
|
|
15
15
|
class OpenRouter
|
|
16
16
|
include Brute::Hooks
|
|
17
17
|
|
|
18
|
+
# Anything not given here falls back to the turn env, the way the other
|
|
19
|
+
# completions do it: tools from env[:tools] — the list the ToolPipeline
|
|
20
|
+
# middleware puts there and executes, so a pipeline declares its tools
|
|
21
|
+
# once — and the model from env[:model], which lets a middleware route a
|
|
22
|
+
# turn to a different one. Options given at point of use always win.
|
|
23
|
+
#
|
|
18
24
|
# config: keyword arguments for OpenRouter::Client.new
|
|
19
25
|
# (access_token:, request_timeout:, uri_base:, extra_headers:).
|
|
20
26
|
# Defaults to OpenRouter.configuration's global settings.
|
|
@@ -29,9 +35,10 @@ module Brute
|
|
|
29
35
|
raise LoadError, "#{self.class} needs the 'open_router_enhanced' gem — add `gem \"open_router_enhanced\"` to your Gemfile."
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
Brute::Completion.async_faraday!
|
|
33
|
-
|
|
34
38
|
@config = config
|
|
39
|
+
# CompletionOptions defaults the model to "openrouter/auto", so what
|
|
40
|
+
# was actually asked for is remembered before that default hides it.
|
|
41
|
+
@model_given = options.key?(:model)
|
|
35
42
|
@options = ::OpenRouter::CompletionOptions.new(**options)
|
|
36
43
|
end
|
|
37
44
|
|
|
@@ -42,7 +49,7 @@ module Brute
|
|
|
42
49
|
|
|
43
50
|
::OpenRouter::Client.new(**@config).then do |client|
|
|
44
51
|
response = nil
|
|
45
|
-
emit(LLM_DURATION_EVENT, env) { response = client.complete(messages,
|
|
52
|
+
emit(LLM_DURATION_EVENT, env) { response = client.complete(messages, options(env)) }
|
|
46
53
|
|
|
47
54
|
response.then do |response|
|
|
48
55
|
|
|
@@ -84,6 +91,32 @@ module Brute
|
|
|
84
91
|
|
|
85
92
|
env
|
|
86
93
|
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Point-of-use options win; the rest fall back to the env.
|
|
98
|
+
def options(env)
|
|
99
|
+
overrides = {}
|
|
100
|
+
|
|
101
|
+
unless @options.tools?
|
|
102
|
+
tools = tool_definitions(env)
|
|
103
|
+
overrides[:tools] = tools if tools.any?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if !@model_given && (model = env[:model])
|
|
107
|
+
overrides[:model] = model
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
overrides.any? ? @options.merge(**overrides) : @options
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# open_router_enhanced serializes an OpenRouter::Tool or a plain Hash;
|
|
114
|
+
# Adapter#to_h is already the function half of that shape.
|
|
115
|
+
def tool_definitions(env)
|
|
116
|
+
Brute::Tools::Adapter.wrap_all(env[:tools] || []).values.map do |adapter|
|
|
117
|
+
{ type: "function", function: adapter.to_h }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
87
120
|
end
|
|
88
121
|
end
|
|
89
122
|
end
|
|
@@ -141,6 +174,84 @@ describe "brute/completion/open_router" do
|
|
|
141
174
|
env.key?(:metadata).should.be.false
|
|
142
175
|
end
|
|
143
176
|
|
|
177
|
+
it "advertises env[:tools] to the provider, and lets a tools: option win" do
|
|
178
|
+
captured = []
|
|
179
|
+
fake_client = Object.new
|
|
180
|
+
fake_client.define_singleton_method(:complete) { |_messages, options| captured << options; FakeUsageResponse.new(nil) }
|
|
181
|
+
original = OpenRouter::Client.method(:new)
|
|
182
|
+
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
183
|
+
|
|
184
|
+
tool = {
|
|
185
|
+
name: "echo",
|
|
186
|
+
description: "Echo the input back",
|
|
187
|
+
params: { msg: { type: "string", desc: "what to echo", required: true } },
|
|
188
|
+
execute: ->(msg:) { msg },
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
begin
|
|
192
|
+
# The ToolPipeline puts its tools in env[:tools]; the completion
|
|
193
|
+
# advertises them without being told twice.
|
|
194
|
+
env = { messages: Brute.log, tools: [tool] }
|
|
195
|
+
env[:messages].user("hi")
|
|
196
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::OpenRouter.new }.call(env)
|
|
197
|
+
|
|
198
|
+
advertised = captured.last.tools
|
|
199
|
+
advertised.size.should == 1
|
|
200
|
+
advertised.first[:type].should == "function"
|
|
201
|
+
advertised.first[:function][:name].should == "echo"
|
|
202
|
+
advertised.first[:function][:parameters][:required].should == ["msg"]
|
|
203
|
+
|
|
204
|
+
# An empty (or absent) env[:tools] advertises nothing.
|
|
205
|
+
bare = { messages: Brute.log, tools: [] }
|
|
206
|
+
bare[:messages].user("hi")
|
|
207
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::OpenRouter.new }.call(bare)
|
|
208
|
+
captured.last.tools?.should.be.false
|
|
209
|
+
|
|
210
|
+
# Tools given at point of use win over env[:tools].
|
|
211
|
+
explicit = { type: "function", function: { name: "explicit", description: "d", parameters: { type: "object" } } }
|
|
212
|
+
env2 = { messages: Brute.log, tools: [tool] }
|
|
213
|
+
env2[:messages].user("hi")
|
|
214
|
+
Brute::Turn::Pipeline.new.tap { |p|
|
|
215
|
+
p.run Brute::Completion::OpenRouter.new(tools: [explicit])
|
|
216
|
+
}.call(env2)
|
|
217
|
+
captured.last.tools.should == [explicit]
|
|
218
|
+
ensure
|
|
219
|
+
OpenRouter::Client.define_singleton_method(:new, original)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it "takes the model from env[:model], and lets a model: option win" do
|
|
224
|
+
captured = []
|
|
225
|
+
fake_client = Object.new
|
|
226
|
+
fake_client.define_singleton_method(:complete) { |_messages, options| captured << options; FakeUsageResponse.new(nil) }
|
|
227
|
+
original = OpenRouter::Client.method(:new)
|
|
228
|
+
OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
|
|
229
|
+
|
|
230
|
+
begin
|
|
231
|
+
# A middleware that routes a turn elsewhere sets env[:model].
|
|
232
|
+
env = { messages: Brute.log, model: "anthropic/claude-sonnet-4" }
|
|
233
|
+
env[:messages].user("hi")
|
|
234
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::OpenRouter.new }.call(env)
|
|
235
|
+
captured.last.model.should == "anthropic/claude-sonnet-4"
|
|
236
|
+
|
|
237
|
+
# Given at point of use, it wins.
|
|
238
|
+
env2 = { messages: Brute.log, model: "from/env" }
|
|
239
|
+
env2[:messages].user("hi")
|
|
240
|
+
Brute::Turn::Pipeline.new.tap { |p|
|
|
241
|
+
p.run Brute::Completion::OpenRouter.new(model: "use/this")
|
|
242
|
+
}.call(env2)
|
|
243
|
+
captured.last.model.should == "use/this"
|
|
244
|
+
|
|
245
|
+
# With neither, the gem's own default stands.
|
|
246
|
+
bare = { messages: Brute.log }
|
|
247
|
+
bare[:messages].user("hi")
|
|
248
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::OpenRouter.new }.call(bare)
|
|
249
|
+
captured.last.model.should == "openrouter/auto"
|
|
250
|
+
ensure
|
|
251
|
+
OpenRouter::Client.define_singleton_method(:new, original)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
144
255
|
it "reports a failed provider call through the hooks instead of raising" do
|
|
145
256
|
seen = []
|
|
146
257
|
|
|
@@ -27,6 +27,25 @@ module Brute
|
|
|
27
27
|
|
|
28
28
|
DEFAULT_TEMPERATURE = 0.7
|
|
29
29
|
|
|
30
|
+
# A Brute tool adapter wearing the interface ruby_llm's providers read
|
|
31
|
+
# off a RubyLLM::Tool: name, description, and a params_schema (they fall
|
|
32
|
+
# back to #parameters only when that is nil), plus #provider_params to
|
|
33
|
+
# deep-merge into the declaration. Brute's own ToolPipeline runs the
|
|
34
|
+
# tool, so this mostly has to describe it — #call is here for a caller
|
|
35
|
+
# that hands the same list to ruby_llm's dispatch.
|
|
36
|
+
class Tool
|
|
37
|
+
def initialize(adapter)
|
|
38
|
+
@adapter = adapter
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def name = @adapter.name
|
|
42
|
+
def description = @adapter.description
|
|
43
|
+
def params_schema = @adapter.to_h[:parameters]
|
|
44
|
+
def parameters = {}
|
|
45
|
+
def provider_params = {}
|
|
46
|
+
def call(args) = @adapter.call(args)
|
|
47
|
+
end
|
|
48
|
+
|
|
30
49
|
def initialize(**options)
|
|
31
50
|
# Brute depends on no LLM library: the provider gem is required here,
|
|
32
51
|
# at point of use, and only for this completion.
|
|
@@ -36,8 +55,6 @@ module Brute
|
|
|
36
55
|
raise LoadError, "#{self.class} needs the 'ruby_llm' gem — add `gem \"ruby_llm\"` to your Gemfile."
|
|
37
56
|
end
|
|
38
57
|
|
|
39
|
-
Brute::Completion.async_faraday!
|
|
40
|
-
|
|
41
58
|
@options = options
|
|
42
59
|
end
|
|
43
60
|
|
|
@@ -81,7 +98,7 @@ module Brute
|
|
|
81
98
|
def complete(env, messages)
|
|
82
99
|
kwargs = {
|
|
83
100
|
model: resolve_model(option(env, :model), option(env, :provider)),
|
|
84
|
-
tools:
|
|
101
|
+
tools: ruby_llm_tools(env),
|
|
85
102
|
temperature: temperature(env),
|
|
86
103
|
}
|
|
87
104
|
|
|
@@ -127,6 +144,15 @@ module Brute
|
|
|
127
144
|
|
|
128
145
|
def tool_adapters(env) = Brute::Tools::Adapter.wrap_all(option(env, :tools) || [])
|
|
129
146
|
|
|
147
|
+
# ruby_llm wants { name => tool }, which is the shape wrap_all already
|
|
148
|
+
# returns; only the values need presenting as ruby_llm tools. A tool
|
|
149
|
+
# written against ruby_llm goes through untouched.
|
|
150
|
+
def ruby_llm_tools(env)
|
|
151
|
+
tool_adapters(env).transform_values do |adapter|
|
|
152
|
+
adapter.original.is_a?(::RubyLLM::Tool) ? adapter.original : Tool.new(adapter)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
130
156
|
def streaming?(env) = options.fetch(:streaming) { env[:streaming] } == true
|
|
131
157
|
end
|
|
132
158
|
end
|
|
@@ -156,6 +182,31 @@ describe "brute/completion/ruby_llm" do
|
|
|
156
182
|
Brute::Turn::Pipeline.new.tap { |pipeline| pipeline.run completion }
|
|
157
183
|
end
|
|
158
184
|
|
|
185
|
+
it "advertises env[:tools] as ruby_llm tools" do
|
|
186
|
+
client = FakeRubyLLMClient.new
|
|
187
|
+
tool = {
|
|
188
|
+
name: "echo",
|
|
189
|
+
description: "Echo the input back",
|
|
190
|
+
params: { msg: { type: "string", desc: "what to echo", required: true } },
|
|
191
|
+
execute: ->(msg:) { msg },
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
env = { messages: Brute.log, provider: :stub, model: "m", tools: [tool], events: [] }
|
|
195
|
+
env[:messages].user("hi")
|
|
196
|
+
Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::RubyLLM.new(client: client) }.call(env)
|
|
197
|
+
|
|
198
|
+
tools = client.calls.first[:kwargs][:tools]
|
|
199
|
+
tools.keys.should == [:echo]
|
|
200
|
+
|
|
201
|
+
advertised = tools[:echo]
|
|
202
|
+
advertised.name.should == "echo"
|
|
203
|
+
advertised.description.should == "Echo the input back"
|
|
204
|
+
advertised.params_schema[:properties][:msg][:type].should == "string"
|
|
205
|
+
advertised.params_schema[:required].should == ["msg"]
|
|
206
|
+
advertised.provider_params.should == {}
|
|
207
|
+
advertised.call(msg: "back").should == "back"
|
|
208
|
+
end
|
|
209
|
+
|
|
159
210
|
it "completes a turn, prefers point-of-use options over env, and reports failure through the hooks" do
|
|
160
211
|
client = FakeRubyLLMClient.new("hello there")
|
|
161
212
|
env = { messages: Brute.log, provider: :stub, model: "env-model", tools: [], events: [] }
|
data/lib/brute/version.rb
CHANGED
data/lib/brute.rb
CHANGED
|
@@ -94,7 +94,6 @@ require_relative "brute/version"
|
|
|
94
94
|
require_relative "brute/hooks"
|
|
95
95
|
require_relative "brute/messages"
|
|
96
96
|
require_relative "brute/middleware/000_base"
|
|
97
|
-
require_relative "brute/completion/async_faraday"
|
|
98
97
|
require_relative "brute/completion/open_router"
|
|
99
98
|
require_relative "brute/completion/lang_chain"
|
|
100
99
|
require_relative "brute/completion/llmrb"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brute
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.0.
|
|
4
|
+
version: 5.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brute Contributors
|
|
@@ -107,20 +107,6 @@ dependencies:
|
|
|
107
107
|
- - ">="
|
|
108
108
|
- !ruby/object:Gem::Version
|
|
109
109
|
version: '0'
|
|
110
|
-
- !ruby/object:Gem::Dependency
|
|
111
|
-
name: async-http-faraday
|
|
112
|
-
requirement: !ruby/object:Gem::Requirement
|
|
113
|
-
requirements:
|
|
114
|
-
- - ">="
|
|
115
|
-
- !ruby/object:Gem::Version
|
|
116
|
-
version: '0'
|
|
117
|
-
type: :runtime
|
|
118
|
-
prerelease: false
|
|
119
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
-
requirements:
|
|
121
|
-
- - ">="
|
|
122
|
-
- !ruby/object:Gem::Version
|
|
123
|
-
version: '0'
|
|
124
110
|
- !ruby/object:Gem::Dependency
|
|
125
111
|
name: json_schemer
|
|
126
112
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -282,7 +268,6 @@ extensions: []
|
|
|
282
268
|
extra_rdoc_files: []
|
|
283
269
|
files:
|
|
284
270
|
- lib/brute.rb
|
|
285
|
-
- lib/brute/completion/async_faraday.rb
|
|
286
271
|
- lib/brute/completion/lang_chain.rb
|
|
287
272
|
- lib/brute/completion/llmrb.rb
|
|
288
273
|
- lib/brute/completion/open_router.rb
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/setup"
|
|
4
|
-
require "brute"
|
|
5
|
-
|
|
6
|
-
module Brute
|
|
7
|
-
module Completion
|
|
8
|
-
# Faraday's own default adapter is Net::HTTP, which works under Async but
|
|
9
|
-
# opens a fresh connection for every request. Provider gems build their
|
|
10
|
-
# own Faraday connections and give no seam to configure them, so the
|
|
11
|
-
# adapter is swapped at the only place it can be: Faraday's default.
|
|
12
|
-
#
|
|
13
|
-
# Called by each completion whose provider gem rides on Faraday, after
|
|
14
|
-
# that gem is required — so it only fires when Faraday is genuinely in
|
|
15
|
-
# play, and an app that uses a completion which doesn't (llm.rb speaks
|
|
16
|
-
# Net::HTTP directly) never loads any of it.
|
|
17
|
-
def self.async_faraday!
|
|
18
|
-
return false unless defined?(::Faraday)
|
|
19
|
-
|
|
20
|
-
require "async/http/faraday/default"
|
|
21
|
-
true
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
__END__
|
|
27
|
-
|
|
28
|
-
describe "brute/completion/async_faraday" do
|
|
29
|
-
it "points Faraday's default adapter at async-http, and does nothing without Faraday" do
|
|
30
|
-
require "faraday"
|
|
31
|
-
Brute::Completion.async_faraday!.should.be.true
|
|
32
|
-
::Faraday.default_adapter.should == :async_http
|
|
33
|
-
|
|
34
|
-
# Idempotent: a second completion constructing does not undo the first.
|
|
35
|
-
Brute::Completion.async_faraday!.should.be.true
|
|
36
|
-
::Faraday.default_adapter.should == :async_http
|
|
37
|
-
end
|
|
38
|
-
end
|