brute 4.3.2 → 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 +54 -42
  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: 88ca4bf8ec3ec3a24eb7f0ebfdc50601d00065c879b9a492b83a45a6765813a9
4
- data.tar.gz: ba526db6766f46199259f75fb409d0629c14aa31c97ee4e0fe2b2f017d0a632b
3
+ metadata.gz: b9b592266887d7f4099f89d132ae3fb0af1b7b360f8b4a819c10b300b8e0484e
4
+ data.tar.gz: d761c4ab24c37596acf52235e5e640177ec4b4d6ba528c5c9c0936452fdffc88
5
5
  SHA512:
6
- metadata.gz: ec146be8bdbf9d8f60b31ac0b694181e3cbf28784e67a25cc1de013666b8b2ef10a621191505daa9047026931f9a8db472b3072afef9c42e4ba2ebb0f4c6934e
7
- data.tar.gz: cb0317f631c112664871818a976a18abd7ac050a30f14e053db08d3d31509fdff3fa52057460b97e736be71e8b27dd6b2265f343be3c8c24d9d9304f5c7ef0cc
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,29 +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
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
+
22
34
  @config = config
23
35
  @options = ::OpenRouter::CompletionOptions.new(**options)
24
36
  end
25
37
 
26
38
  def call(env)
27
- env[:hooks]&.emit(:before_llm, env)
39
+ emit(BEFORE_LLM_EVENT, env)
28
40
 
29
41
  messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
30
42
 
31
43
  ::OpenRouter::Client.new(**@config).then do |client|
32
- 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|
33
48
 
34
49
  # Expose the provider's usage for downstream accounting
35
- # middleware (goal budgets, autonomous limits, compaction
36
- # thresholds, usage attribution) — additive metadata only.
37
- if response.respond_to?(:usage) && response.usage
38
- (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
39
55
  end
40
56
 
41
57
  # OpenRouter in fact only returns a single message...
@@ -46,23 +62,23 @@ module Brute
46
62
  end
47
63
  end
48
64
 
49
- env[:hooks]&.emit(:after_llm, env)
65
+ emit(AFTER_LLM_EVENT, env)
50
66
  env
51
67
  # A provider call that raises is reported through the hooks rather than
52
68
  # up the stack: :llm_failure with the turn env, then one hook naming the
53
69
  # kind of failure. The classes are looked up defensively — the provider
54
70
  # gem is only a dependency of the app that uses this middleware.
55
71
  rescue => error
56
- env[:hooks]&.emit(:llm_failure, env)
72
+ emit(LLM_FAILURE_EVENT, env)
57
73
 
58
74
  if defined?(::Faraday::Error) && error.is_a?(::Faraday::Error)
59
- env[:hooks]&.emit(:faraday_error, error)
75
+ emit(FARADAY_ERROR_EVENT, env, error)
60
76
 
61
77
  elsif defined?(::OpenRouter::ServerError) && error.is_a?(::OpenRouter::ServerError)
62
- env[:hooks]&.emit(:open_router_server_error, error)
78
+ emit(OPEN_ROUTER_SERVER_ERROR_EVENT, env, error)
63
79
 
64
80
  else
65
- env[:hooks]&.emit(:standard_error, error)
81
+ emit(STANDARD_ERROR_EVENT, env, error)
66
82
 
67
83
  end
68
84
 
@@ -77,25 +93,23 @@ __END__
77
93
  describe "brute/completion/open_router" do
78
94
  require "brute/messages"
79
95
 
80
- # The repo suite has no open_router gem; stub the two constants the
81
- # middleware touches (the transport wraps duck-typed responses fine).
82
- begin
83
- require "open_router"
84
- rescue LoadError
85
- module OpenRouter
86
- CompletionOptions = Class.new { def initialize(**_opts); end }
87
- Client = Class.new
88
- end
89
- end
90
-
91
96
  FakeUsageResponse = Struct.new(:usage) do
92
97
  def choices
93
98
  [{ "message" => { "role" => "assistant", "content" => "hello" } }]
94
99
  end
95
100
  end unless defined?(FakeUsageResponse)
96
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
+
97
111
  # Run one turn against a stubbed OpenRouter::Client and hand back the env.
98
- with_fake_client = lambda do |middleware, response|
112
+ with_fake_client = lambda do |completion, response|
99
113
  fake_client = Object.new
100
114
  fake_client.define_singleton_method(:complete) { |_messages, _options| response }
101
115
  original = OpenRouter::Client.method(:new)
@@ -103,7 +117,7 @@ describe "brute/completion/open_router" do
103
117
  begin
104
118
  env = { messages: Brute.log }
105
119
  env[:messages].user("hi")
106
- middleware.call(env)
120
+ running.call(completion).call(env)
107
121
  env
108
122
  ensure
109
123
  OpenRouter::Client.define_singleton_method(:new, original)
@@ -112,24 +126,23 @@ describe "brute/completion/open_router" do
112
126
 
113
127
  it "records the provider usage into env metadata and appends the message" do
114
128
  response = FakeUsageResponse.new({ "prompt_tokens" => 10, "completion_tokens" => 5, "total_tokens" => 15 })
115
- env = with_fake_client.call(Brute::Completion::OpenRouter.new(->(e) { e }), response)
129
+ env = with_fake_client.call(Brute::Completion::OpenRouter.new, response)
116
130
 
117
131
  env[:messages].last.role.should == :assistant
118
- 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
119
136
  end
120
137
 
121
138
  it "leaves metadata alone when the response has no usage" do
122
- 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))
123
140
 
124
141
  env.key?(:metadata).should.be.false
125
142
  end
126
143
 
127
144
  it "reports a failed provider call through the hooks instead of raising" do
128
145
  seen = []
129
- hooks = Brute::Hooks.new
130
- %i[llm_failure standard_error after_llm].each do |event|
131
- hooks.on(event) { |payload| seen << [event, payload] }
132
- end
133
146
 
134
147
  boom = RuntimeError.new("no route to host")
135
148
  fake_client = Object.new
@@ -138,24 +151,23 @@ describe "brute/completion/open_router" do
138
151
  OpenRouter::Client.define_singleton_method(:new) { |**_config| fake_client }
139
152
 
140
153
  begin
141
- env = { messages: Brute.log, hooks: hooks }
154
+ env = { messages: Brute.log }
142
155
  env[:messages].user("hi")
143
- 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)
144
162
 
145
163
  returned.should.be.identical_to env
146
164
  seen.map(&:first).should == [:llm_failure, :standard_error]
147
- seen.first.last.should.be.identical_to env
148
- 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
149
168
  ensure
150
169
  OpenRouter::Client.define_singleton_method(:new, original)
151
170
  end
152
171
  end
153
172
 
154
- it "still answers to the deprecated Middleware::OpenRouter::Completion name" do
155
- deprecated = Brute::Middleware::OpenRouter::Completion.new(->(e) { e })
156
- deprecated.should.be.kind_of?(Brute::Completion::OpenRouter)
157
-
158
- env = with_fake_client.call(deprecated, FakeUsageResponse.new(nil))
159
- env[:messages].last.content.should == "hello"
160
- end
161
173
  end