brute 5.0.5 → 6.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/compaction/middleware/sliding_window.rb +170 -0
  3. data/lib/brute/compaction/middleware/strategy.rb +137 -0
  4. data/lib/brute/compaction/middleware/tool_results.rb +146 -0
  5. data/lib/brute/compaction/summarize.rb +349 -0
  6. data/lib/brute/compaction/transcript.rb +166 -0
  7. data/lib/brute/compaction.rb +57 -0
  8. data/lib/brute/completion/lang_chain.rb +34 -31
  9. data/lib/brute/completion/llmrb.rb +31 -29
  10. data/lib/brute/completion/open_router.rb +43 -40
  11. data/lib/brute/completion/ruby_llm.rb +28 -27
  12. data/lib/brute/contrib/otel.rb +83 -51
  13. data/lib/brute/env.rb +54 -0
  14. data/lib/brute/eval/case.rb +254 -0
  15. data/lib/brute/eval/suite.rb +181 -0
  16. data/lib/brute/eval/transcript.rb +147 -0
  17. data/lib/brute/eval/world.rb +106 -0
  18. data/lib/brute/eval.rb +48 -0
  19. data/lib/brute/events/handler.rb +3 -1
  20. data/lib/brute/events/prefixed_terminal_output.rb +3 -1
  21. data/lib/brute/events/terminal_output_handler.rb +1 -1
  22. data/lib/brute/hooks.rb +89 -102
  23. data/lib/brute/message_transport/anthropic.rb +13 -3
  24. data/lib/brute/message_transport/llm.rb +8 -4
  25. data/lib/brute/message_transport/open_router.rb +30 -16
  26. data/lib/brute/message_transport/openai.rb +14 -7
  27. data/lib/brute/message_transport/ruby_llm.rb +21 -17
  28. data/lib/brute/message_transport/ruby_open_ai.rb +59 -57
  29. data/lib/brute/messages.rb +15 -8
  30. data/lib/brute/middleware/000_base.rb +6 -6
  31. data/lib/brute/middleware/002_session_log.rb +12 -4
  32. data/lib/brute/middleware/008_checkpoint.rb +27 -19
  33. data/lib/brute/middleware/010_max_iterations.rb +1 -1
  34. data/lib/brute/middleware/020_system_prompt.rb +1 -1
  35. data/lib/brute/middleware/040_default_compaction_pipeline.rb +356 -0
  36. data/lib/brute/middleware/{070_tool_pipeline.rb → 070_default_tool_pipeline.rb} +49 -58
  37. data/lib/brute/prompt_template.rb +19 -15
  38. data/lib/brute/prompts/base.rb +19 -10
  39. data/lib/brute/prompts/environment.rb +3 -1
  40. data/lib/brute/prompts/instructions.rb +9 -7
  41. data/lib/brute/prompts/skills.rb +5 -3
  42. data/lib/brute/rack/adapter.rb +40 -23
  43. data/lib/brute/skill.rb +136 -89
  44. data/lib/brute/system_prompt.rb +9 -9
  45. data/lib/brute/token_counter/approximate.rb +54 -0
  46. data/lib/brute/token_counter/tiktoken.rb +80 -0
  47. data/lib/brute/token_counter.rb +150 -0
  48. data/lib/brute/tool.rb +10 -6
  49. data/lib/brute/tools/adapter.rb +57 -47
  50. data/lib/brute/tools/fs/snapshot_store.rb +5 -1
  51. data/lib/brute/tools/fs_patch.rb +16 -8
  52. data/lib/brute/tools/fs_read.rb +107 -80
  53. data/lib/brute/tools/fs_remove.rb +6 -2
  54. data/lib/brute/tools/fs_search.rb +14 -4
  55. data/lib/brute/tools/fs_undo.rb +6 -2
  56. data/lib/brute/tools/fs_write.rb +5 -1
  57. data/lib/brute/tools/net_fetch.rb +6 -2
  58. data/lib/brute/tools/question.rb +42 -39
  59. data/lib/brute/tools/shell.rb +20 -5
  60. data/lib/brute/tools/skill_load.rb +46 -41
  61. data/lib/brute/tools/sub_agent.rb +2 -2
  62. data/lib/brute/tools/todo_write.rb +19 -15
  63. data/lib/brute/truncation.rb +68 -43
  64. data/lib/brute/turn/agent_pipeline.rb +23 -13
  65. data/lib/brute/turn/compaction_pipeline.rb +123 -0
  66. data/lib/brute/turn/pipeline.rb +87 -96
  67. data/lib/brute/turn/tool_pipeline.rb +4 -3
  68. data/lib/brute/usage_detection/llmrb.rb +18 -14
  69. data/lib/brute/usage_detection/open_router.rb +20 -17
  70. data/lib/brute/usage_detection/ruby_llm.rb +18 -14
  71. data/lib/brute/usage_detection/usage.rb +10 -1
  72. data/lib/brute/utils/diff.rb +18 -10
  73. data/lib/brute/version.rb +1 -1
  74. data/lib/brute.rb +25 -12
  75. data/lib/brute_cli/providers/shell.rb +32 -29
  76. data/lib/brute_cli/providers/shell_response.rb +20 -18
  77. metadata +48 -5
  78. data/lib/brute/middleware/040_compaction_check.rb +0 -157
  79. data/lib/brute/middleware/event_handler.rb +0 -27
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "brute"
5
+ require "brute/turn/pipeline"
6
+
7
+ module Brute
8
+ module Turn
9
+ # A compactor built out of middleware.
10
+ #
11
+ # Like ToolPipeline it *composes* a Pipeline rather than inheriting: the
12
+ # definition block is instance_eval'd into the internal Pipeline, so `use`
13
+ # / `run` inside it are the builder's methods.
14
+ #
15
+ # The stack order is the policy. A layer that got the conversation under
16
+ # target does not call the next one, so the strategies that cost nothing
17
+ # sit at the top and the terminal app -- the summariser, the only thing
18
+ # here that spends money -- is reached only when they could not get there.
19
+ # That is `run` meaning what it means everywhere else in Brute: the model
20
+ # call, at the bottom, with the layers deciding what reaches it.
21
+ #
22
+ # Brute::Turn::CompactionPipeline.new do
23
+ # use Brute::Compaction::Middleware::ToolResults, keep_steps: 1
24
+ # use Brute::Compaction::Middleware::SlidingWindow, keep_steps: 2
25
+ #
26
+ # run Brute::Compaction::Summarize.new(
27
+ # Brute::Completion::OpenRouter.new(config: { access_token: key }),
28
+ # )
29
+ # end
30
+ #
31
+ # A pipeline that must never spend a call is the first two layers and
32
+ # `run ->(env) { env }` -- a policy said out loud rather than configured.
33
+ #
34
+ # The conversation being compacted rides on `env[:conversation]`, leaving
35
+ # `env[:messages]` to mean what it means to every other terminal app in
36
+ # Brute: the prompt going down, the reply coming back.
37
+ #
38
+ # It answers #compact, so what comes out drops into
39
+ # Brute::Middleware::DefaultCompactionPipeline as that turn's compactor.
40
+ class CompactionPipeline
41
+ def initialize(&block)
42
+ @pipeline = Pipeline.new
43
+ if block
44
+ @pipeline.instance_eval(&block)
45
+ end
46
+ end
47
+
48
+ # Answer a smaller conversation, or nil when nothing was given up.
49
+ def compact(messages, target:, token_counter: nil)
50
+ env = {
51
+ conversation: messages,
52
+ target: target,
53
+ token_counter: token_counter,
54
+ applied: [],
55
+ messages: Brute.log,
56
+ metadata: {},
57
+ }
58
+ @pipeline.call(env)
59
+
60
+ unless env[:applied].empty?
61
+ env[:conversation]
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ __END__
69
+
70
+ describe "brute/turn/compaction_pipeline" do
71
+ def said(content) = Brute::Message.new(role: :user, content: content)
72
+
73
+ def layer(name, &block)
74
+ Class.new(Brute::Compaction::Middleware::Strategy) do
75
+ define_method(:strategy) { name }
76
+ define_method(:rewrite) { |conversation, target:| block.call(conversation, target) }
77
+ end
78
+ end
79
+
80
+ it "composes strategies into one compactor, and only reaches run when they could not" do
81
+ reached = []
82
+ halves = layer("halves") { |c, _t| reached << "halves"; c.first(c.length / 2) }
83
+ never = layer("never") { |_c, _t| reached << "never"; nil }
84
+ # A terminal belongs to one pipeline: the builder binds an emit onto it
85
+ # and refuses to bind a second, so each pipeline gets its own.
86
+ terminal = lambda do
87
+ Object.new.tap { |it| it.define_singleton_method(:call) { |env| reached << "run"; env } }
88
+ end
89
+
90
+ four = [said("a" * 4_000), said("b" * 4_000), said("c" * 4_000), said("d" * 4_000)]
91
+
92
+ # Halving is enough, so the terminal -- the one that costs money -- is
93
+ # never reached. The stack order is the policy.
94
+ pipeline = Brute::Turn::CompactionPipeline.new do
95
+ use halves
96
+ run terminal.call
97
+ end
98
+ pipeline.compact(four, target: 3_000).map { |m| m.content[0, 1] }.should == ["a", "b"]
99
+ reached.should == ["halves"]
100
+
101
+ # A strategy that declines lets the turn fall through to it.
102
+ reached.clear
103
+ Brute::Turn::CompactionPipeline.new do
104
+ use never
105
+ run terminal.call
106
+ end.compact(four, target: 3_000).should.be.nil
107
+ reached.should == ["never", "run"]
108
+
109
+ # Nothing given up at all is nil, not the conversation it was handed --
110
+ # the caller applies whatever else comes back.
111
+ reached.clear
112
+ pipeline.compact(four, target: 500_000).should.be.nil
113
+ reached.should == []
114
+
115
+ # The conversation rides on :conversation, leaving :messages to mean what
116
+ # it means to every other terminal app -- the prompt down, the reply back.
117
+ seen = nil
118
+ Brute::Turn::CompactionPipeline.new do
119
+ run ->(env) { seen = [env[:conversation].length, env[:messages], env[:target]] }
120
+ end.compact(four, target: 10)
121
+ seen.should == [4, [], 10]
122
+ end
123
+ end
@@ -21,32 +21,46 @@ module Brute
21
21
  # }
22
22
  #
23
23
  # Every layer announces itself: :middleware_added when it goes on the
24
- # stack, then :enter and :exit around its own work on each turn. Both
25
- # of those carry the middleware instance as self, and :exit fires from
24
+ # stack, then :middleware_start and :middleware_end around its own work on each turn. Both
25
+ # of those carry the middleware instance as self, and :middleware_end fires from
26
26
  # an ensure so a layer that raises is still reported.
27
27
  def use(middleware, *args, &block)
28
28
  tap do
29
29
  super
30
- hooks.emit(MIDDLEWARE_ADDED_EVENT, {}, middleware, *args)
31
-
32
- builder = @use.pop
33
- @use << lambda do |app|
34
- bind_emitter(builder.call(app)).tap do |layer|
35
- layer.define_singleton_method(:call) do |env|
36
- emit(ENTER_EVENT, env, self)
37
-
38
- # The layer's work is :duration's block, so its subscribers
39
- # are handed how long this layer took — its own work plus
40
- # everything below it. :exit then marks the layer done,
41
- # from an ensure so it lands even when the work raised.
42
- result = nil
43
- begin
44
- emit(DURATION_EVENT, env, self) { result = super(env) }
45
- ensure
46
- emit(EXIT_EVENT, env, self)
30
+ hooks.emit(
31
+ MIDDLEWARE_ADDED_EVENT,
32
+ {},
33
+ middleware,
34
+ *args,
35
+ )
36
+
37
+ @use.pop.then do |builder|
38
+ @use << lambda do |app|
39
+ builder.call(app).tap do |layer|
40
+ layer.define_singleton_method(:call) do |env|
41
+ result = nil
42
+
43
+ # A layer is one call: what it emits, and what the layers
44
+ # below it emit, belong to this trace.
45
+ env.emit_trace do |env|
46
+ env.emit(MIDDLEWARE_START_EVENT, self)
47
+
48
+ # The layer's work is :middleware_duration's block, so its subscribers
49
+ # are handed how long this layer took — its own work plus
50
+ # everything below it. :middleware_end then marks the layer done,
51
+ # from an ensure so it lands even when the work raised.
52
+ begin
53
+ env.emit(MIDDLEWARE_DURATION_EVENT, self) { result = super(env) }
54
+ rescue => error
55
+ env.emit(MIDDLEWARE_FAILURE_EVENT, self, error)
56
+ raise
57
+ ensure
58
+ env.emit(MIDDLEWARE_END_EVENT, self)
59
+ end
60
+ end
61
+
62
+ result
47
63
  end
48
-
49
- result
50
64
  end
51
65
  end
52
66
  end
@@ -55,11 +69,12 @@ module Brute
55
69
  # Keeps a trailing hash flagged as keywords through `super`, the way
56
70
  # Rack::Builder#use does — without it `use Mw, label: "x"` arrives at
57
71
  # the middleware's constructor as a positional Hash.
58
- ruby2_keywords(:use) if respond_to?(:ruby2_keywords, true)
72
+ if respond_to?(:ruby2_keywords, true)
73
+ ruby2_keywords(:use)
74
+ end
59
75
 
60
76
  def run(app = nil, &block)
61
77
  tap do
62
- bind_emitter(app) unless app.nil?
63
78
  super
64
79
  end
65
80
  end
@@ -69,62 +84,40 @@ module Brute
69
84
  # Brute.agent
70
85
  # .use(MaxProfit)
71
86
  # .run(->(env) { ... })
72
- # .on(:before_llm) { |env| ... }
73
- # .on(:approve_tool) { |call| call[:name] != "exec" }
87
+ # .on(:llm_start) { |env| ... }
88
+ # .on(:tool_approve) { |call| call[:name] != "exec" }
74
89
  #
75
90
  def on(...) = tap { hooks.on(...) }
76
91
 
77
92
  private
78
93
 
79
- # Bind an emit() to this builder's store, so a layer fires events at
80
- # the pipeline it belongs to rather than fishing them out of env.
81
- def bind_emitter(object)
82
- if object.is_a?(Proc)
83
- warn("brute: #{self.class} was given a lambda, which cannot be given an emit — its events will not fire")
84
- return object
85
- end
86
94
 
87
- if object.respond_to?(:emit)
88
- raise ArgumentError, "#{object.class} already defines #emit, so the pipeline will not bind its own over it"
89
- end
90
-
91
- store = hooks
92
- object.define_singleton_method(:emit) do |event, env, *extras, &work|
93
- store.emit(event, env, *extras, &work)
94
- end
95
- object
96
- end
97
-
98
- public
95
+ public
99
96
  end
100
97
 
101
98
  include Chainable
102
99
 
103
- # Rack's `new_from_string` evaluates the script then returns `to_app` —
104
- # the built callable. An agent, though, is the *builder*: you `.start` it,
105
- # and it may be re-`use`d or served through the Rack adapter. So evaluate
106
- # the script against a fresh builder and hand back the builder itself.
107
- # This also backs `parse_file` (→ `load_file` → here), so
108
- # `AgentPipeline.parse_file("agent.ru").start(prompt)` works as documented.
109
100
  def self.new_from_string(builder_script, path = "(rackup)", **options)
110
- builder = new(**options)
111
- eval(builder_script, ::Rack::BUILDER_TOPLEVEL_BINDING.call(builder), path) # rubocop:disable Security/Eval
112
- builder
101
+ new(**options).tap do |builder|
102
+ eval(builder_script, ::Rack::BUILDER_TOPLEVEL_BINDING.call(builder), path) # rubocop:disable Security/Eval
103
+ end
113
104
  end
114
105
 
115
- # Brute's name for Rack's `to_app`: nest the middleware around the
116
- # terminal app and return the runnable callable. Raises (via `to_app`)
117
- # when `run` was never called.
106
+ # Brute's name for Rack's `to_app`
118
107
  alias_method :build, :to_app
119
108
 
120
- # The lifecycle-hook registry for this pipeline (see Brute::Hooks).
121
- def hooks
122
- @hooks ||= Brute::Hooks.new
109
+ def initialize(default_app = nil, hooks: Brute::Hooks::Registry.new, **options, &block)
110
+ @hooks = hooks
111
+ super(default_app, **options, &block)
123
112
  end
124
113
 
125
- # Default null sink for env[:events] — swallows anything pushed to it.
126
- class NullSink
127
- def <<(_event); self; end
114
+ attr_reader :hooks
115
+
116
+ # Every way into a pipeline arrives at a Trace: what a caller hands over
117
+ # is wrapped once, and a pipeline entered from inside another keeps the
118
+ # trace it was already in.
119
+ def call(env)
120
+ env.is_a?(Brute::Hooks::Trace) ? super : super(Brute::Hooks::Trace.new(env, hooks: hooks))
128
121
  end
129
122
  end
130
123
  end
@@ -135,11 +128,11 @@ __END__
135
128
  describe "brute/turn/pipeline" do
136
129
  it "announces a layer when it is added, then on the way in and out of every turn" do
137
130
  seen = []
138
- hooks = Brute::Hooks.new
131
+ hooks = Brute::Hooks::Registry.new
139
132
  hooks.on(Brute::Hooks::MIDDLEWARE_ADDED_EVENT) { |env, mw, *args| seen << [:added, env, mw, args] }
140
- hooks.on(Brute::Hooks::ENTER_EVENT) { |_env, layer| seen << [:enter, layer.class] }
141
- hooks.on(Brute::Hooks::DURATION_EVENT) { |_env, started, finished, layer| seen << [:duration, layer.class, finished >= started] }
142
- hooks.on(Brute::Hooks::EXIT_EVENT) { |_env, layer| seen << [:exit, layer.class] }
133
+ hooks.on(Brute::Hooks::MIDDLEWARE_START_EVENT) { |_env, layer| seen << [:middleware_start, layer.class] }
134
+ hooks.on(Brute::Hooks::MIDDLEWARE_DURATION_EVENT) { |_env, started, finished, layer| seen << [:middleware_duration, layer.class, finished >= started] }
135
+ hooks.on(Brute::Hooks::MIDDLEWARE_END_EVENT) { |_env, layer| seen << [:middleware_end, layer.class] }
143
136
 
144
137
  labeller = Class.new do
145
138
  def initialize(app, label:); @app = app; @label = label; end
@@ -153,12 +146,12 @@ describe "brute/turn/pipeline" do
153
146
  pipeline.call({ hooks: hooks })
154
147
 
155
148
  seen.first.should == [:added, {}, labeller, [{ label: "outer" }]]
156
- seen[1].should == [:enter, labeller]
157
- seen[2].should == [:duration, labeller, true]
158
- seen[3].should == [:exit, labeller]
149
+ seen[1].should == [:middleware_start, labeller]
150
+ seen[2].should == [:middleware_duration, labeller, true]
151
+ seen[3].should == [:middleware_end, labeller]
159
152
  end
160
153
 
161
- it "reports a layer that raises through :exit anyway" do
154
+ it "reports a layer that raises through :middleware_end anyway" do
162
155
  seen = []
163
156
 
164
157
  boom = Class.new do
@@ -167,7 +160,7 @@ describe "brute/turn/pipeline" do
167
160
  end
168
161
 
169
162
  pipeline = Brute::Turn::Pipeline.new
170
- pipeline.on(Brute::Hooks::EXIT_EVENT) { |_env, layer| seen << layer.class }
163
+ pipeline.on(Brute::Hooks::MIDDLEWARE_END_EVENT) { |_env, layer| seen << layer.class }
171
164
  pipeline.use boom
172
165
  pipeline.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
173
166
  begin
@@ -179,42 +172,40 @@ describe "brute/turn/pipeline" do
179
172
  seen.should == [boom]
180
173
  end
181
174
 
182
- it "binds emit to its own store, warns for a lambda, and refuses to shadow an existing emit" do
175
+ it "wraps what it is called with in a trace, so a layer emits without being given anything" do
183
176
  seen = []
184
177
  quiet = Class.new do
185
178
  def initialize(app); @app = app; end
186
- def call(env); emit(:ping, env, :from_layer); @app.call(env); end
179
+ def call(env); env.emit(:ping, :from_layer); @app.call(env); end
187
180
  end
188
181
 
189
182
  pipeline = Brute::Turn::Pipeline.new
190
- pipeline.on(:ping) { |env, extra| seen << [env, extra] }
183
+ pipeline.on(:ping) { |env, extra, id| seen << [env[:said], extra, id] }
191
184
  pipeline.use quiet
192
185
  pipeline.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
193
- pipeline.call({})
194
- seen.should == [[{}, :from_layer]]
195
-
196
- # A lambda cannot reach the bound method, so it is warned about, not patched.
197
- warned = []
198
- lambda_pipeline = Brute::Turn::Pipeline.new
199
- lambda_pipeline.define_singleton_method(:warn) { |message| warned << message }
200
- lambda_pipeline.run ->(env) { env }
201
- warned.size.should == 1
202
- warned.first.should.match(/lambda/)
203
-
204
- # An object that already answers to emit is left alone, loudly.
205
- emitter = Class.new do
206
- def initialize(app); @app = app; end
207
- def emit(*); end
208
- def call(env); env; end
186
+ pipeline.call({ said: "hello" })
187
+
188
+ said, extra, id = seen.first
189
+ said.should == "hello"
190
+ extra.should == :from_layer
191
+ id.should.not.be.nil
192
+
193
+ # A lambda is a layer like any other now: it is handed the trace as its env.
194
+ answered = []
195
+ Brute::Turn::Pipeline.new.tap do |lambdas|
196
+ lambdas.on(:ping) { |_env, extra| answered << extra }
197
+ lambdas.run ->(env) { env.emit(:ping, :from_lambda); env }
198
+ lambdas.call({})
209
199
  end
210
- # The layer is constructed when the stack is built, so that is when the
211
- # collision is discovered.
212
- shadowing = Brute::Turn::Pipeline.new
213
- shadowing.use emitter
214
- shadowing.run Object.new.tap { |o| o.define_singleton_method(:call) { |env| env } }
215
- should.raise(ArgumentError) { shadowing.call({}) }
200
+ answered.should == [:from_lambda]
201
+
202
+ # Given a registry it uses that one rather than building its own, so a
203
+ # nested pipeline reports to the agent that owns it.
204
+ shared = Brute::Hooks::Registry.new
205
+ Brute::Turn::Pipeline.new(hooks: shared).hooks.equal?(shared).should.be.true
216
206
  end
217
207
 
208
+
218
209
  it "builds and calls a chain" do
219
210
  seen = []
220
211
  inc = Class.new do
@@ -38,15 +38,16 @@ module Brute
38
38
  @description = description
39
39
  @params = params
40
40
  @pipeline = Pipeline.new
41
- @pipeline.instance_eval(&block) if block
41
+ if block
42
+ @pipeline.instance_eval(&block)
43
+ end
42
44
  end
43
45
 
44
- def call(events: Pipeline::NullSink.new, **arguments)
46
+ def call(**arguments)
45
47
  env = {
46
48
  name: @name,
47
49
  arguments: arguments,
48
50
  result: nil,
49
- events: events,
50
51
  metadata: {},
51
52
  }
52
53
  @pipeline.call(env)
@@ -11,20 +11,24 @@ module Brute
11
11
  # reading of the response and it is reported as given.
12
12
  module LLMrb
13
13
  def self.detect(response)
14
- return nil unless response.respond_to?(:usage)
15
-
16
- usage = response.usage
17
- return nil if usage.nil?
18
-
19
- Usage.new(
20
- input: read(usage, :input_tokens),
21
- output: read(usage, :output_tokens),
22
- total: read(usage, :total_tokens),
23
- reasoning: read(usage, :reasoning_tokens),
24
- cache_read: read(usage, :cache_read_tokens),
25
- cache_write: read(usage, :cache_write_tokens),
26
- raw: usage,
27
- )
14
+ if response.respond_to?(:usage)
15
+ usage = response.usage
16
+ if usage.nil?
17
+ nil
18
+ else
19
+ Usage.new(
20
+ input: read(usage, :input_tokens),
21
+ output: read(usage, :output_tokens),
22
+ total: read(usage, :total_tokens),
23
+ reasoning: read(usage, :reasoning_tokens),
24
+ cache_read: read(usage, :cache_read_tokens),
25
+ cache_write: read(usage, :cache_write_tokens),
26
+ raw: usage,
27
+ )
28
+ end
29
+ else
30
+ nil
31
+ end
28
32
  end
29
33
 
30
34
  def self.read(usage, name) = usage.respond_to?(name) ? usage.public_send(name) : nil
@@ -11,23 +11,26 @@ module Brute
11
11
  # arrive nested under completion_tokens_details.
12
12
  module OpenRouter
13
13
  def self.detect(response)
14
- return nil unless response.respond_to?(:usage)
15
-
16
- usage = response.usage
17
- return nil if usage.nil?
18
-
19
- usage = usage.to_h
20
- details = (usage["completion_tokens_details"] || usage[:completion_tokens_details] || {}).to_h
21
-
22
- Usage.new(
23
- input: field(usage, "prompt_tokens"),
24
- output: field(usage, "completion_tokens"),
25
- total: field(usage, "total_tokens"),
26
- reasoning: field(details, "reasoning_tokens"),
27
- cache_read: field(usage, "cached_tokens"),
28
- cost: field(usage, "cost"),
29
- raw: usage,
30
- )
14
+ if response.respond_to?(:usage)
15
+ usage = response.usage
16
+ if usage.nil?
17
+ nil
18
+ else
19
+ usage = usage.to_h
20
+ details = (usage["completion_tokens_details"] || usage[:completion_tokens_details] || {}).to_h
21
+ Usage.new(
22
+ input: field(usage, "prompt_tokens"),
23
+ output: field(usage, "completion_tokens"),
24
+ total: field(usage, "total_tokens"),
25
+ reasoning: field(details, "reasoning_tokens"),
26
+ cache_read: field(usage, "cached_tokens"),
27
+ cost: field(usage, "cost"),
28
+ raw: usage,
29
+ )
30
+ end
31
+ else
32
+ nil
33
+ end
31
34
  end
32
35
 
33
36
  # OpenRouter hands back string keys; a hand-built response may not.
@@ -11,20 +11,24 @@ module Brute
11
11
  # provider's own reported_cost when it gives one.
12
12
  module RubyLLM
13
13
  def self.detect(message)
14
- return nil unless message.respond_to?(:tokens)
15
-
16
- tokens = message.tokens
17
- return nil if tokens.nil?
18
-
19
- Usage.new(
20
- input: read(tokens, :input),
21
- output: read(tokens, :output),
22
- reasoning: read(tokens, :thinking),
23
- cache_read: read(tokens, :cache_read),
24
- cache_write: read(tokens, :cache_write),
25
- cost: read(tokens, :reported_cost),
26
- raw: tokens,
27
- )
14
+ if message.respond_to?(:tokens)
15
+ tokens = message.tokens
16
+ if tokens.nil?
17
+ nil
18
+ else
19
+ Usage.new(
20
+ input: read(tokens, :input),
21
+ output: read(tokens, :output),
22
+ reasoning: read(tokens, :thinking),
23
+ cache_read: read(tokens, :cache_read),
24
+ cache_write: read(tokens, :cache_write),
25
+ cost: read(tokens, :reported_cost),
26
+ raw: tokens,
27
+ )
28
+ end
29
+ else
30
+ nil
31
+ end
28
32
  end
29
33
 
30
34
  def self.read(tokens, name) = tokens.respond_to?(name) ? tokens.public_send(name) : nil
@@ -19,7 +19,16 @@ module Brute
19
19
  # turn are the env's business, not the reader's. A strategy answers nil when
20
20
  # the provider reported nothing at all.
21
21
  module UsageDetection
22
- Usage = Data.define(:input, :output, :total, :reasoning, :cache_read, :cache_write, :cost, :raw) do
22
+ Usage = Data.define(
23
+ :input,
24
+ :output,
25
+ :total,
26
+ :reasoning,
27
+ :cache_read,
28
+ :cache_write,
29
+ :cost,
30
+ :raw,
31
+ ) do
23
32
  def initialize(input: nil, output: nil, total: nil, reasoning: nil,
24
33
  cache_read: nil, cache_write: nil, cost: nil, raw: nil)
25
34
  super
@@ -12,17 +12,25 @@ module Brute
12
12
  old_lines = old_text.lines
13
13
  new_lines = new_text.lines
14
14
  diffs = ::Diff::LCS.diff(old_lines, new_lines)
15
- return '' if diffs.empty?
16
-
17
- output = +''
18
- file_length_difference = 0
19
- diffs.each do |piece|
20
- hunk = ::Diff::LCS::Hunk.new(old_lines, new_lines, piece, context, file_length_difference)
21
- file_length_difference = hunk.file_length_difference
22
- output << hunk.diff(:unified)
23
- output << "\n"
15
+ if diffs.empty?
16
+ ''
17
+ else
18
+ output = +''
19
+ file_length_difference = 0
20
+ diffs.each do |piece|
21
+ hunk = ::Diff::LCS::Hunk.new(
22
+ old_lines,
23
+ new_lines,
24
+ piece,
25
+ context,
26
+ file_length_difference,
27
+ )
28
+ file_length_difference = hunk.file_length_difference
29
+ output << hunk.diff(:unified)
30
+ output << "\n"
31
+ end
32
+ output
24
33
  end
25
- output
26
34
  end
27
35
  end
28
36
  end
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.5"
4
+ VERSION = "6.0.0"
5
5
  end