brute 4.3.1 → 5.0.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/completion/async_faraday.rb +38 -0
  3. data/lib/brute/completion/lang_chain.rb +185 -0
  4. data/lib/brute/completion/llmrb.rb +182 -0
  5. data/lib/brute/completion/open_router.rb +55 -44
  6. data/lib/brute/completion/ruby_llm.rb +198 -0
  7. data/lib/brute/contrib/otel.rb +208 -0
  8. data/lib/brute/hooks.rb +149 -31
  9. data/lib/brute/message_transport/lang_chain.rb +36 -0
  10. data/lib/brute/message_transport/llm.rb +6 -0
  11. data/lib/brute/message_transport/open_router.rb +6 -0
  12. data/lib/brute/message_transport/ruby_llm.rb +6 -0
  13. data/lib/brute/message_transport.rb +8 -0
  14. data/lib/brute/middleware/000_base.rb +61 -0
  15. data/lib/brute/middleware/002_session_log.rb +1 -1
  16. data/lib/brute/middleware/004_summarize.rb +1 -1
  17. data/lib/brute/middleware/005_tracing.rb +1 -1
  18. data/lib/brute/middleware/006_loop.rb +1 -1
  19. data/lib/brute/middleware/008_checkpoint.rb +1 -1
  20. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  21. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  22. data/lib/brute/middleware/025_skills.rb +1 -1
  23. data/lib/brute/middleware/040_compaction_check.rb +1 -1
  24. data/lib/brute/middleware/060_questions.rb +1 -1
  25. data/lib/brute/middleware/070_tool_pipeline.rb +60 -47
  26. data/lib/brute/middleware/event_handler.rb +1 -1
  27. data/lib/brute/middleware/user_queue.rb +1 -1
  28. data/lib/brute/turn/agent_pipeline.rb +3 -4
  29. data/lib/brute/turn/pipeline.rb +151 -2
  30. data/lib/brute/usage_detection/lang_chain.rb +44 -0
  31. data/lib/brute/usage_detection/llmrb.rb +53 -0
  32. data/lib/brute/usage_detection/open_router.rb +62 -0
  33. data/lib/brute/usage_detection/ruby_llm.rb +55 -0
  34. data/lib/brute/usage_detection/usage.rb +51 -0
  35. data/lib/brute/version.rb +1 -1
  36. data/lib/brute.rb +95 -3
  37. metadata +83 -8
  38. data/lib/brute/changelog.rb +0 -322
  39. data/lib/brute/deprecate.rb +0 -132
  40. data/lib/brute/middleware/001_otel_span.rb +0 -79
  41. data/lib/brute/middleware/015_otel_token_usage.rb +0 -44
  42. data/lib/brute/middleware/073_otel_tool_call.rb +0 -51
  43. data/lib/brute/middleware/075_otel_tool_results.rb +0 -48
  44. data/lib/brute/middleware/open_router.rb +0 -56
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8264f0e2d23e001a3c980d3edd0f88ac3ceb8a3f167359c56391e3fe9702e59
4
- data.tar.gz: 7439466f57b0f3e18aae290a723f8cb4077970b200f1a8e0f7970fd7d863aee6
3
+ metadata.gz: b9b592266887d7f4099f89d132ae3fb0af1b7b360f8b4a819c10b300b8e0484e
4
+ data.tar.gz: d761c4ab24c37596acf52235e5e640177ec4b4d6ba528c5c9c0936452fdffc88
5
5
  SHA512:
6
- metadata.gz: 5fa71cbfe00b6069e2bdb90872cff9fe2f88eb2f48c7ff614feb59ead8f03890739c4772c863b09edf27fd7c2c122ac486fc5c3d84f582f477527f5f69d2dee4
7
- data.tar.gz: 866794dc4f87f5433508b1e883db27788b00bb57157211ea2942fe64ccaa46969c3cfdd36fe917b9717fe7bdc385f4fd9881b1a5ce47c9763e859d7e00fbf356
6
+ metadata.gz: 98083c184fcea3e2213fe6b244a151aabdb4d136ef11bd130d35ecf4a07881925b961df267e75de5574927b5e8cf18c2fda9f9fea1ec10e57740c76e8c0a2c23
7
+ data.tar.gz: 41f4df4d86383fb6eb3cddaeb4d64e27dd37f3bccd09e5e80d2f5b98eaac7f1b7df886c16cb77a714b8d1888becf3cca5495723d8a6e9539771b36623bb413c0
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/hooks"
6
+
7
+ module Brute
8
+ module Completion
9
+ # Completion backed by the langchainrb gem
10
+ # (https://github.com/patterns-ai-core/langchainrb). Its LLM classes wrap
11
+ # many providers; build one and hand it over:
12
+ #
13
+ # Brute.agent
14
+ # .use(Brute::Middleware::SystemPrompt)
15
+ # .run(Brute::Completion::LangChain.new(
16
+ # llm: Langchain::LLM::OpenAI.new(api_key: ENV["OPENAI_API_KEY"]),
17
+ # ))
18
+ #
19
+ # llm: a Langchain::LLM instance (required; client: is an alias)
20
+ # model: model id override (falls back to env[:model], then the
21
+ # llm instance's own default)
22
+ # tools: tools list, any shape Tools::Adapter accepts
23
+ # temperature: sampling temperature (default 0.7)
24
+ class LangChain
25
+ include Brute::Hooks
26
+
27
+ DEFAULT_TEMPERATURE = 0.7
28
+
29
+ def initialize(llm: nil, client: nil, **options)
30
+ # Brute depends on no LLM library: the provider gem is required here,
31
+ # at point of use, and only for this completion.
32
+ begin
33
+ require "langchain"
34
+ rescue LoadError
35
+ raise LoadError, "#{self.class} needs the 'langchainrb' gem — add `gem \"langchainrb\"` to your Gemfile."
36
+ end
37
+
38
+ Brute::Completion.async_faraday!
39
+
40
+ @llm = llm || client or
41
+ raise ArgumentError, "#{self.class} needs an llm: option, e.g. Langchain::LLM::OpenAI.new(api_key: ...)"
42
+
43
+ @options = options
44
+ end
45
+
46
+ def call(env)
47
+ emit(BEFORE_LLM_EVENT, env)
48
+
49
+ response = nil
50
+ emit(LLM_DURATION_EVENT, env) { response = @llm.chat(**params(env)) }
51
+
52
+ if (usage = Brute::MessageTransport::LangChain.usage_metrics(response))
53
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
54
+ end
55
+
56
+ # langchainrb speaks the OpenAI-style wire format both ways.
57
+ Brute::MessageTransport::LangChain.wrap_each(reply(response)) do |message|
58
+ env[:messages] << message
59
+ end
60
+
61
+ emit(AFTER_LLM_EVENT, env)
62
+ env
63
+ rescue => error
64
+ emit(LLM_FAILURE_EVENT, env)
65
+
66
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
67
+ emit(FARADAY_ERROR_EVENT, env, error)
68
+ else
69
+ emit(STANDARD_ERROR_EVENT, env, error)
70
+ end
71
+
72
+ env
73
+ end
74
+
75
+ private
76
+
77
+ attr_reader :options
78
+
79
+ def params(env)
80
+ params = {
81
+ messages: Brute::MessageTransport::LangChain.dump_all(env[:messages]),
82
+ temperature: temperature(env),
83
+ }
84
+
85
+ tools = tool_definitions(env)
86
+ params[:tools] = tools if tools.any?
87
+
88
+ model = option(env, :model)
89
+ params[:model] = model if model
90
+
91
+ params
92
+ end
93
+
94
+ def reply(response)
95
+ {
96
+ "role" => "assistant",
97
+ "content" => response.chat_completion,
98
+ "tool_calls" => Array(response.tool_calls),
99
+ }
100
+ end
101
+
102
+ def tool_definitions(env)
103
+ Brute::Tools::Adapter.wrap_all(option(env, :tools) || []).values.map do |adapter|
104
+ { type: "function", function: adapter.to_h }
105
+ end
106
+ end
107
+
108
+ def option(env, key) = options.fetch(key) { env[key] }
109
+
110
+ def temperature(env) = options.fetch(:temperature) { env.fetch(:temperature, DEFAULT_TEMPERATURE) }
111
+ end
112
+ end
113
+ end
114
+
115
+ __END__
116
+
117
+ describe "brute/completion/lang_chain" do
118
+ require "brute/messages"
119
+
120
+ # langchainrb's own suite fakes the LLM with `receive(:chat)` returning a
121
+ # response that answers chat_completion / tool_calls; this is that shape.
122
+ FakeLangChainResponse = Struct.new(:chat_completion, :tool_calls) unless defined?(FakeLangChainResponse)
123
+
124
+ FakeLangChainLLM = Class.new do
125
+ attr_reader :calls
126
+
127
+ def initialize(response)
128
+ @response = response
129
+ @calls = []
130
+ end
131
+
132
+ def chat(**params)
133
+ @calls << params
134
+ @response
135
+ end
136
+ end unless defined?(FakeLangChainLLM)
137
+
138
+ it "sends the log as OpenAI-style messages and appends the reply, tool calls included" do
139
+ response = FakeLangChainResponse.new("via langchain", [])
140
+ llm = FakeLangChainLLM.new(response)
141
+
142
+ env = { messages: Brute.log, tools: [], events: [] }
143
+ env[:messages].user("hi")
144
+
145
+ seen = []
146
+ pipeline = Brute::Turn::Pipeline.new
147
+ pipeline.run Brute::Completion::LangChain.new(llm: llm, model: "gpt-4o-mini")
148
+ pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
149
+ pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
150
+ pipeline.call(env)
151
+
152
+ env[:messages].last.role.should == :assistant
153
+ env[:messages].last.content.should == "via langchain"
154
+ seen.should == [:before, :after]
155
+
156
+ llm.calls.first[:model].should == "gpt-4o-mini"
157
+ llm.calls.first[:temperature].should == 0.7
158
+ llm.calls.first[:messages].first[:role].should == "user"
159
+
160
+ # A tool call comes back in the OpenAI shape and lands as a ToolCall.
161
+ calling = FakeLangChainLLM.new(FakeLangChainResponse.new(nil, [
162
+ { "id" => "tc1", "type" => "function", "function" => { "name" => "echo", "arguments" => { "text" => "hi" } } },
163
+ ]))
164
+ tool_env = { messages: Brute.log, tools: [], events: [] }
165
+ tool_env[:messages].user("call it")
166
+ Brute::Turn::Pipeline.new.tap { |p| p.run Brute::Completion::LangChain.new(llm: calling) }.call(tool_env)
167
+ tool_env[:messages].last.tool_calls.first.name.should == "echo"
168
+
169
+ # A raising provider is reported through the hooks, not up the stack.
170
+ boom = Object.new
171
+ boom.define_singleton_method(:chat) { |**_params| raise "no route to host" }
172
+ failed = []
173
+ failing = Brute::Turn::Pipeline.new
174
+ failing.run Brute::Completion::LangChain.new(llm: boom)
175
+ failing.on(Brute::Hooks::STANDARD_ERROR_EVENT) { |_env, error| failed << error.message }
176
+
177
+ error_env = { messages: Brute.log, tools: [], events: [] }
178
+ error_env[:messages].user("hi")
179
+ failing.call(error_env)
180
+ failed.should == ["no route to host"]
181
+
182
+ # An llm is not optional.
183
+ should.raise(ArgumentError) { Brute::Completion::LangChain.new }
184
+ end
185
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/hooks"
6
+
7
+ module Brute
8
+ module Completion
9
+ # Completion backed by the llm.rb gem (https://github.com/llmrb/llm.rb).
10
+ #
11
+ # Brute.agent
12
+ # .use(Brute::Middleware::SystemPrompt)
13
+ # .run(Brute::Completion::LLMrb.new(
14
+ # provider: :openai,
15
+ # provider_options: { key: ENV["OPENAI_API_KEY"] },
16
+ # model: "gpt-4o-mini",
17
+ # ))
18
+ #
19
+ # # or hand over a provider llm.rb has already built:
20
+ # run Brute::Completion::LLMrb.new(client: LLM.ollama(key: nil), model: "llama3.2:latest")
21
+ #
22
+ # client: an LLM::Provider instance (takes precedence)
23
+ # provider: llm.rb constructor name (:openai, :anthropic,
24
+ # :ollama, ...); falls back to env[:provider]
25
+ # provider_options: kwargs for that constructor, e.g. { key: "..." }
26
+ # model: model id (falls back to env[:model])
27
+ # tools: tools list, any shape Tools::Adapter accepts
28
+ # temperature: sampling temperature (default 0.7)
29
+ class LLMrb
30
+ include Brute::Hooks
31
+
32
+ DEFAULT_TEMPERATURE = 0.7
33
+
34
+ def initialize(**options)
35
+ # Brute depends on no LLM library: the provider gem is required here,
36
+ # at point of use, and only for this completion.
37
+ begin
38
+ require "llm"
39
+ rescue LoadError
40
+ raise LoadError, "#{self.class} needs the 'llm.rb' gem — add `gem \"llm.rb\"` to your Gemfile."
41
+ end
42
+
43
+ @options = options
44
+ end
45
+
46
+ def call(env)
47
+ emit(BEFORE_LLM_EVENT, env)
48
+
49
+ # llm.rb takes the last message as the prompt and the rest as history.
50
+ messages = Brute::MessageTransport::LLM.dump_all(env[:messages])
51
+ prompt = messages.pop
52
+
53
+ response = nil
54
+ emit(LLM_DURATION_EVENT, env) { response = client(env).complete(prompt, **params(env, messages)) }
55
+
56
+ if (usage = Brute::MessageTransport::LLM.usage_metrics(response))
57
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
58
+ end
59
+
60
+ Brute::MessageTransport::LLM.wrap_each(response) do |message|
61
+ env[:messages] << message
62
+ end
63
+
64
+ emit(AFTER_LLM_EVENT, env)
65
+ env
66
+ rescue => error
67
+ emit(LLM_FAILURE_EVENT, env)
68
+
69
+ if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
70
+ emit(FARADAY_ERROR_EVENT, env, error)
71
+ else
72
+ emit(STANDARD_ERROR_EVENT, env, error)
73
+ end
74
+
75
+ env
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :options
81
+
82
+ def params(env, history)
83
+ params = {
84
+ role: nil,
85
+ model: option(env, :model),
86
+ messages: history,
87
+ temperature: temperature(env),
88
+ }.compact
89
+
90
+ functions = functions(env)
91
+ params[:tools] = functions if functions.any?
92
+ params
93
+ end
94
+
95
+ def client(env)
96
+ options[:client] || begin
97
+ provider = option(env, :provider) or
98
+ raise ArgumentError, "#{self.class} needs a client: or provider: option"
99
+
100
+ ::LLM.public_send(provider, **(options[:provider_options] || {}))
101
+ end
102
+ end
103
+
104
+ # Brute tools as llm.rb functions, executed by llm.rb's own dispatch.
105
+ def functions(env)
106
+ Brute::Tools::Adapter.wrap_all(option(env, :tools) || []).values.map do |adapter|
107
+ schema = adapter.to_h[:parameters]
108
+
109
+ ::LLM.function(adapter.name) do |fn|
110
+ fn.description adapter.description
111
+ fn.params { schema }
112
+ fn.define { |**args| adapter.call(args) }
113
+ end
114
+ end
115
+ end
116
+
117
+ def option(env, key) = options.fetch(key) { env[key] }
118
+
119
+ def temperature(env) = options.fetch(:temperature) { env.fetch(:temperature, DEFAULT_TEMPERATURE) }
120
+ end
121
+ end
122
+ end
123
+
124
+ __END__
125
+
126
+ describe "brute/completion/llmrb" do
127
+ require "brute/messages"
128
+
129
+ # llm.rb's own suite drives a real provider and stubs the HTTP; the seam it
130
+ # leaves for a caller is the provider object's #complete, which is what a
131
+ # client: option replaces.
132
+ FakeLLMrbClient = Class.new do
133
+ attr_reader :calls
134
+
135
+ def initialize(content = "via llm.rb")
136
+ @content = content
137
+ @calls = []
138
+ end
139
+
140
+ def complete(prompt, **params)
141
+ @calls << { prompt: prompt, params: params }
142
+ ::LLM::Message.new(:assistant, @content)
143
+ end
144
+ end unless defined?(FakeLLMrbClient)
145
+
146
+ it "sends the last message as the prompt, the rest as history, and appends the reply" do
147
+ client = FakeLLMrbClient.new("hello from llm.rb")
148
+
149
+ env = { messages: Brute.log, provider: :stub, model: "env-model", tools: [], events: [] }
150
+ env[:messages].user("first")
151
+ env[:messages] << Brute::Message.new(role: :assistant, content: "earlier")
152
+ env[:messages].user("latest")
153
+
154
+ seen = []
155
+ pipeline = Brute::Turn::Pipeline.new
156
+ pipeline.run Brute::Completion::LLMrb.new(client: client, temperature: 0.1)
157
+ pipeline.on(Brute::Hooks::BEFORE_LLM_EVENT) { |_env| seen << :before }
158
+ pipeline.on(Brute::Hooks::AFTER_LLM_EVENT) { |_env| seen << :after }
159
+ pipeline.call(env)
160
+
161
+ env[:messages].last.role.should == :assistant
162
+ env[:messages].last.content.should == "hello from llm.rb"
163
+ seen.should == [:before, :after]
164
+
165
+ call = client.calls.first
166
+ call[:prompt].content.should == "latest"
167
+ call[:params][:messages].size.should == 2
168
+ call[:params][:temperature].should == 0.1
169
+ call[:params][:model].should == "env-model" # falls back to env
170
+
171
+ # Without a client, a provider is required to build one.
172
+ no_provider = Brute::Turn::Pipeline.new
173
+ no_provider.run Brute::Completion::LLMrb.new
174
+ failed = []
175
+ no_provider.on(Brute::Hooks::STANDARD_ERROR_EVENT) { |_env, error| failed << error.class }
176
+
177
+ bare = { messages: Brute.log, tools: [], events: [] }
178
+ bare[:messages].user("hi")
179
+ no_provider.call(bare)
180
+ failed.should == [ArgumentError]
181
+ end
182
+ end
@@ -13,30 +13,45 @@ module Brute
13
13
  # its own provider, and only when you use it.
14
14
  module Completion
15
15
  class OpenRouter
16
+ include Brute::Hooks
17
+
16
18
  # config: keyword arguments for OpenRouter::Client.new
17
19
  # (access_token:, request_timeout:, uri_base:, extra_headers:).
18
20
  # Defaults to OpenRouter.configuration's global settings.
19
21
  # options: keyword arguments for OpenRouter::CompletionOptions.new
20
22
  # (model:, temperature:, tools:, ...).
21
- def initialize(app, config: {}, **options)
22
- @app = app
23
+ def initialize(config: {}, **options)
24
+ # Brute depends on no LLM library: the provider gem is required here,
25
+ # at point of use, and only for this completion.
26
+ begin
27
+ require "open_router"
28
+ rescue LoadError
29
+ raise LoadError, "#{self.class} needs the 'open_router_enhanced' gem — add `gem \"open_router_enhanced\"` to your Gemfile."
30
+ end
31
+
32
+ Brute::Completion.async_faraday!
33
+
23
34
  @config = config
24
35
  @options = ::OpenRouter::CompletionOptions.new(**options)
25
36
  end
26
37
 
27
38
  def call(env)
28
- env[:hooks]&.emit(:before_llm, env)
39
+ emit(BEFORE_LLM_EVENT, env)
29
40
 
30
41
  messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
31
42
 
32
43
  ::OpenRouter::Client.new(**@config).then do |client|
33
- client.complete(messages, @options).then do |response|
44
+ response = nil
45
+ emit(LLM_DURATION_EVENT, env) { response = client.complete(messages, @options) }
46
+
47
+ response.then do |response|
34
48
 
35
49
  # Expose the provider's usage for downstream accounting
36
- # middleware (goal budgets, autonomous limits, compaction
37
- # thresholds, usage attribution) — additive metadata only.
38
- if response.respond_to?(:usage) && response.usage
39
- (env[:metadata] ||= {})[:last_llm_usage] = response.usage
50
+ # (goal budgets, autonomous limits, compaction thresholds, usage
51
+ # attribution) — additive metadata only, normalised so a reader
52
+ # does not have to know which provider answered.
53
+ if (usage = Brute::MessageTransport::OpenRouter.usage_metrics(response))
54
+ (env[:metadata] ||= {})[:last_llm_usage] = usage
40
55
  end
41
56
 
42
57
  # OpenRouter in fact only returns a single message...
@@ -47,23 +62,23 @@ module Brute
47
62
  end
48
63
  end
49
64
 
50
- env[:hooks]&.emit(:after_llm, env)
65
+ emit(AFTER_LLM_EVENT, env)
51
66
  env
52
67
  # A provider call that raises is reported through the hooks rather than
53
68
  # up the stack: :llm_failure with the turn env, then one hook naming the
54
69
  # kind of failure. The classes are looked up defensively — the provider
55
70
  # gem is only a dependency of the app that uses this middleware.
56
71
  rescue => error
57
- env[:hooks]&.emit(:llm_failure, env)
72
+ emit(LLM_FAILURE_EVENT, env)
58
73
 
59
74
  if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
60
- env[:hooks]&.emit(:faraday_error, error)
75
+ emit(FARADAY_ERROR_EVENT, env, error)
61
76
 
62
77
  elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
63
- env[:hooks]&.emit(:open_router_server_error, error)
78
+ emit(OPEN_ROUTER_SERVER_ERROR_EVENT, env, error)
64
79
 
65
80
  else
66
- env[:hooks]&.emit(:standard_error, error)
81
+ emit(STANDARD_ERROR_EVENT, env, error)
67
82
 
68
83
  end
69
84
 
@@ -78,25 +93,23 @@ __END__
78
93
  describe "brute/completion/open_router" do
79
94
  require "brute/messages"
80
95
 
81
- # The repo suite has no open_router gem; stub the two constants the
82
- # middleware touches (the transport wraps duck-typed responses fine).
83
- begin
84
- require "open_router"
85
- rescue LoadError
86
- module OpenRouter
87
- CompletionOptions = Class.new { def initialize(**_opts); end }
88
- Client = Class.new
89
- end
90
- end
91
-
92
96
  FakeUsageResponse = Struct.new(:usage) do
93
97
  def choices
94
98
  [{ "message" => { "role" => "assistant", "content" => "hello" } }]
95
99
  end
96
100
  end unless defined?(FakeUsageResponse)
97
101
 
102
+ # A completion only gets its emit from the pipeline that runs it, so every
103
+ # turn here goes through a builder rather than calling the object directly.
104
+ running = lambda do |completion, &block|
105
+ pipeline = Brute::Turn::Pipeline.new
106
+ pipeline.run completion
107
+ block&.call(pipeline)
108
+ pipeline
109
+ end
110
+
98
111
  # Run one turn against a stubbed OpenRouter::Client and hand back the env.
99
- with_fake_client = lambda do |middleware, response|
112
+ with_fake_client = lambda do |completion, response|
100
113
  fake_client = Object.new
101
114
  fake_client.define_singleton_method(:complete) { |_messages, _options| response }
102
115
  original = OpenRouter::Client.method(:new)
@@ -104,7 +117,7 @@ describe "brute/completion/open_router" do
104
117
  begin
105
118
  env = { messages: Brute.log }
106
119
  env[:messages].user("hi")
107
- middleware.call(env)
120
+ running.call(completion).call(env)
108
121
  env
109
122
  ensure
110
123
  OpenRouter::Client.define_singleton_method(:new, original)
@@ -113,24 +126,23 @@ describe "brute/completion/open_router" do
113
126
 
114
127
  it "records the provider usage into env metadata and appends the message" do
115
128
  response = FakeUsageResponse.new({ "prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15 })
116
- env = with_fake_client.call(Brute::Completion::OpenRouter.new(->(e) { e }), response)
129
+ env = with_fake_client.call(Brute::Completion::OpenRouter.new, response)
117
130
 
118
131
  env[:messages].last.role.should == :assistant
119
- env[:metadata][:last_llm_usage]["total_tokens"].should == 15
132
+ # Normalised, not the provider's raw hash.
133
+ env[:metadata][:last_llm_usage].total.should == 15
134
+ env[:metadata][:last_llm_usage].input.should == 10
135
+ env[:metadata][:last_llm_usage].output.should == 5
120
136
  end
121
137
 
122
138
  it "leaves metadata alone when the response has no usage" do
123
- env = with_fake_client.call(Brute::Completion::OpenRouter.new(->(e) { e }), FakeUsageResponse.new(nil))
139
+ env = with_fake_client.call(Brute::Completion::OpenRouter.new, FakeUsageResponse.new(nil))
124
140
 
125
141
  env.key?(:metadata).should.be.false
126
142
  end
127
143
 
128
144
  it "reports a failed provider call through the hooks instead of raising" do
129
145
  seen = []
130
- hooks = Brute::Hooks.new
131
- %i[llm_failure standard_error after_llm].each do |event|
132
- hooks.on(event) { |payload| seen << [event, payload] }
133
- end
134
146
 
135
147
  boom = RuntimeError.new("no route to host")
136
148
  fake_client = Object.new
@@ -139,24 +151,23 @@ describe "brute/completion/open_router" do
139
151
  OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
140
152
 
141
153
  begin
142
- env = { messages: Brute.log, hooks: hooks }
154
+ env = { messages: Brute.log }
143
155
  env[:messages].user("hi")
144
- returned = Brute::Completion::OpenRouter.new(->(e) { e }).call(env)
156
+ pipeline = Brute::Turn::Pipeline.new
157
+ pipeline.run Brute::Completion::OpenRouter.new
158
+ %i[llm_failure standard_error after_llm].each do |event|
159
+ pipeline.on(event) { |hook_env, extra| seen << [event, hook_env, extra] }
160
+ end
161
+ returned = pipeline.call(env)
145
162
 
146
163
  returned.should.be.identical_to env
147
164
  seen.map(&:first).should == [:llm_failure, :standard_error]
148
- seen.first.last.should.be.identical_to env
149
- seen.last.last.should.be.identical_to boom
165
+ seen.first[1].should.be.identical_to env
166
+ seen.last[1].should.be.identical_to env
167
+ seen.last[2].should.be.identical_to boom
150
168
  ensure
151
169
  OpenRouter::Client.define_singleton_method(:new, original)
152
170
  end
153
171
  end
154
172
 
155
- it "still answers to the deprecated Middleware::OpenRouter::Completion name" do
156
- deprecated = Brute::Middleware::OpenRouter::Completion.new(->(e) { e })
157
- deprecated.should.be.kind_of?(Brute::Completion::OpenRouter)
158
-
159
- env = with_fake_client.call(deprecated, FakeUsageResponse.new(nil))
160
- env[:messages].last.content.should == "hello"
161
- end
162
173
  end