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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e01ccfbca88337e90f041dc548bb2ced64a83bcffc1f76e302bd8fa4825b902
4
- data.tar.gz: 6bd8054dacdf68ac5d3f773d53fd6a4093be59985350ba51cc78c10227fe38ef
3
+ metadata.gz: 8b1c034900a1b4cd0fca86749632d4bba871872a01a530e545fc5eeae62290ed
4
+ data.tar.gz: 157222f25f8afd4f3f44dcdd6589b4dd29a9f26349851360a4a6dadc158ab9fe
5
5
  SHA512:
6
- metadata.gz: 4eae8566ab16b8f48ab62db551efe0878569727a4a5a5069e8a7c8de533858a84707538dd6015f4651f2ef276b9c17fc9b6ef615535e76301a425039fbf8deb0
7
- data.tar.gz: fb6bf08902d2917ec98089403a64f1424216ac8f2d81374a426b372ff24e2a90e32adf4c52571d24cce166ddcbd2f1f13c34180c1fee01302f9cc69753c38c87
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, @options) }
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: tool_adapters(env).transform_values(&:to_ruby_llm),
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Brute
4
- VERSION = "5.0.1"
4
+ VERSION = "5.0.3"
5
5
  end
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.1
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