brute 1.0.1 → 2.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +72 -6
  3. data/lib/brute/events/handler.rb +69 -0
  4. data/lib/brute/events/prefixed_terminal_output.rb +72 -0
  5. data/lib/brute/events/terminal_output_handler.rb +68 -0
  6. data/lib/brute/middleware/001_otel_span.rb +77 -0
  7. data/lib/brute/middleware/003_tool_result_loop.rb +103 -0
  8. data/lib/brute/middleware/004_summarize.rb +139 -0
  9. data/lib/brute/middleware/005_tracing.rb +86 -0
  10. data/lib/brute/middleware/010_max_iterations.rb +73 -0
  11. data/lib/brute/middleware/015_otel_token_usage.rb +42 -0
  12. data/lib/brute/middleware/020_system_prompt.rb +128 -0
  13. data/lib/brute/middleware/040_compaction_check.rb +155 -0
  14. data/lib/brute/middleware/060_questions.rb +41 -0
  15. data/lib/brute/middleware/070_tool_call.rb +247 -0
  16. data/lib/brute/middleware/073_otel_tool_call.rb +49 -0
  17. data/lib/brute/middleware/075_otel_tool_results.rb +46 -0
  18. data/lib/brute/middleware/100_llm_call.rb +62 -0
  19. data/lib/brute/middleware/event_handler.rb +25 -0
  20. data/lib/brute/middleware/user_queue.rb +35 -0
  21. data/lib/brute/pipeline.rb +44 -107
  22. data/lib/brute/prompts/skills.rb +2 -2
  23. data/lib/brute/prompts.rb +23 -23
  24. data/lib/brute/providers/shell.rb +6 -19
  25. data/lib/brute/providers/shell_response.rb +22 -30
  26. data/lib/brute/session.rb +52 -0
  27. data/lib/brute/store/snapshot_store.rb +21 -37
  28. data/lib/brute/sub_agent.rb +106 -0
  29. data/lib/brute/system_prompt.rb +1 -83
  30. data/lib/brute/tool.rb +107 -0
  31. data/lib/brute/tools/delegate.rb +61 -70
  32. data/lib/brute/tools/fs_patch.rb +9 -7
  33. data/lib/brute/tools/fs_read.rb +233 -20
  34. data/lib/brute/tools/fs_remove.rb +8 -9
  35. data/lib/brute/tools/fs_search.rb +98 -16
  36. data/lib/brute/tools/fs_undo.rb +8 -8
  37. data/lib/brute/tools/fs_write.rb +7 -5
  38. data/lib/brute/tools/net_fetch.rb +8 -8
  39. data/lib/brute/tools/question.rb +36 -24
  40. data/lib/brute/tools/shell.rb +74 -16
  41. data/lib/brute/tools/todo_read.rb +8 -8
  42. data/lib/brute/tools/todo_write.rb +25 -18
  43. data/lib/brute/tools.rb +8 -12
  44. data/lib/brute/truncation.rb +219 -0
  45. data/lib/brute/version.rb +1 -1
  46. data/lib/brute.rb +82 -45
  47. metadata +59 -46
  48. data/lib/brute/loop/agent_stream.rb +0 -118
  49. data/lib/brute/loop/agent_turn.rb +0 -520
  50. data/lib/brute/loop/compactor.rb +0 -107
  51. data/lib/brute/loop/doom_loop.rb +0 -86
  52. data/lib/brute/loop/step.rb +0 -332
  53. data/lib/brute/loop/tool_call_step.rb +0 -90
  54. data/lib/brute/middleware/base.rb +0 -27
  55. data/lib/brute/middleware/compaction_check.rb +0 -106
  56. data/lib/brute/middleware/doom_loop_detection.rb +0 -136
  57. data/lib/brute/middleware/llm_call.rb +0 -128
  58. data/lib/brute/middleware/message_tracking.rb +0 -339
  59. data/lib/brute/middleware/otel/span.rb +0 -105
  60. data/lib/brute/middleware/otel/token_usage.rb +0 -68
  61. data/lib/brute/middleware/otel/tool_calls.rb +0 -68
  62. data/lib/brute/middleware/otel/tool_results.rb +0 -65
  63. data/lib/brute/middleware/otel.rb +0 -34
  64. data/lib/brute/middleware/reasoning_normalizer.rb +0 -192
  65. data/lib/brute/middleware/retry.rb +0 -157
  66. data/lib/brute/middleware/session_persistence.rb +0 -72
  67. data/lib/brute/middleware/token_tracking.rb +0 -124
  68. data/lib/brute/middleware/tool_error_tracking.rb +0 -179
  69. data/lib/brute/middleware/tool_use_guard.rb +0 -133
  70. data/lib/brute/middleware/tracing.rb +0 -124
  71. data/lib/brute/middleware.rb +0 -18
  72. data/lib/brute/orchestrator/turn.rb +0 -105
  73. data/lib/brute/patches/anthropic_tool_role.rb +0 -35
  74. data/lib/brute/patches/buffer_nil_guard.rb +0 -26
  75. data/lib/brute/providers/models_dev.rb +0 -111
  76. data/lib/brute/providers/ollama.rb +0 -135
  77. data/lib/brute/providers/opencode_go.rb +0 -43
  78. data/lib/brute/providers/opencode_zen.rb +0 -87
  79. data/lib/brute/providers.rb +0 -62
  80. data/lib/brute/queue/base_queue.rb +0 -222
  81. data/lib/brute/queue/parallel_queue.rb +0 -66
  82. data/lib/brute/queue/sequential_queue.rb +0 -63
  83. data/lib/brute/store/message_store.rb +0 -362
  84. data/lib/brute/store/session.rb +0 -106
  85. /data/lib/brute/{diff.rb → utils/diff.rb} +0 -0
@@ -1,128 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- # The terminal "app" in the pipeline — performs the actual LLM call.
9
- #
10
- # Builds a fresh LLM::Context per call from env[:messages], makes the
11
- # call, extracts new messages back into env[:messages], and stashes
12
- # pending functions in env[:pending_functions].
13
- #
14
- # When streaming, on_content fires incrementally via AgentStream.
15
- # When not streaming, fires on_content post-hoc with the full text.
16
- #
17
- class LLMCall
18
- def call(env)
19
- ctx = build_context(env)
20
-
21
- # Load existing conversation history into the ephemeral context
22
- ctx.messages.concat(env[:messages])
23
-
24
- response = ctx.talk(env[:input])
25
-
26
- # Extract new messages appended by talk() and store them
27
- new_messages = ctx.messages.to_a.drop(env[:messages].size)
28
- env[:messages].concat(new_messages)
29
-
30
- # Stash pending functions for the agent loop
31
- env[:pending_functions] = ctx.functions.to_a
32
-
33
- # Only fire on_content post-hoc when NOT streaming
34
- # (streaming delivers chunks incrementally via AgentStream)
35
- unless env[:streaming]
36
- if (cb = env.dig(:callbacks, :on_content)) && response
37
- text = safe_content(response)
38
- cb.call(text) if text
39
- end
40
- end
41
-
42
- response
43
- end
44
-
45
- private
46
-
47
- def build_context(env)
48
- params = {}
49
- params[:tools] = env[:tools] if env[:tools]&.any?
50
- params[:stream] = env[:stream] if env[:stream]
51
- params[:model] = env[:model] if env[:model]
52
- LLM::Context.new(env[:provider], **params)
53
- end
54
-
55
- # Safely extract text content from an LLM response.
56
- # Returns nil when the response contains only tool calls (no assistant text),
57
- # which causes LLM::Contract::Completion#content to raise NoMethodError
58
- # because messages.find(&:assistant?) returns nil.
59
- def safe_content(response)
60
- return nil unless response.respond_to?(:content)
61
- response.content
62
- rescue NoMethodError
63
- nil
64
- end
65
- end
66
- end
67
- end
68
-
69
- test do
70
- require_relative "../../../spec/support/mock_provider"
71
- require_relative "../../../spec/support/mock_response"
72
-
73
- def build_env(**overrides)
74
- { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
75
- messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
76
- tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
77
- end
78
-
79
- it "calls the provider and returns a response" do
80
- provider = MockProvider.new
81
- middleware = Brute::Middleware::LLMCall.new
82
- env = build_env(provider: provider, input: "hello", streaming: false)
83
- response = middleware.call(env)
84
- response.should.not.be.nil
85
- end
86
-
87
- it "records a call on the provider" do
88
- provider = MockProvider.new
89
- middleware = Brute::Middleware::LLMCall.new
90
- env = build_env(provider: provider, input: "hello", streaming: false)
91
- middleware.call(env)
92
- provider.calls.size.should == 1
93
- end
94
-
95
- it "appends new messages to env[:messages]" do
96
- provider = MockProvider.new
97
- middleware = Brute::Middleware::LLMCall.new
98
- env = build_env(provider: provider, input: "hello", streaming: false)
99
- middleware.call(env)
100
- env[:messages].should.not.be.empty
101
- end
102
-
103
- it "populates env[:pending_functions] as an Array" do
104
- provider = MockProvider.new
105
- middleware = Brute::Middleware::LLMCall.new
106
- env = build_env(provider: provider, input: "hello", streaming: false)
107
- middleware.call(env)
108
- env[:pending_functions].should.be.kind_of(Array)
109
- end
110
-
111
- it "does not fire on_content callback when streaming" do
112
- provider = MockProvider.new
113
- middleware = Brute::Middleware::LLMCall.new
114
- called = false
115
- env = build_env(provider: provider, input: "hi", streaming: true, callbacks: { on_content: ->(_) { called = true } })
116
- middleware.call(env)
117
- called.should.be.false
118
- end
119
-
120
- it "preserves existing messages across calls" do
121
- provider = MockProvider.new
122
- middleware = Brute::Middleware::LLMCall.new
123
- existing = LLM::Message.new(:user, "previous")
124
- env = build_env(provider: provider, input: "hello", streaming: false, messages: [existing])
125
- middleware.call(env)
126
- env[:messages].first.should == existing
127
- end
128
- end
@@ -1,339 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- # Records every LLM exchange into a MessageStore in the OpenCode
9
- # {info, parts} format so sessions can be viewed later.
10
- #
11
- # Lifecycle per pipeline call:
12
- #
13
- # 1. PRE-CALL — if this is the first call of a turn (env[:tool_results]
14
- # is nil), record the user message.
15
- # 2. POST-CALL — record the assistant message: text content as a "text"
16
- # part, each tool call as a "tool" part in "running" state.
17
- # 3. When the pipeline is called again with tool results, update the
18
- # corresponding tool parts to "completed" (or "error").
19
- #
20
- # The middleware also stores itself in env[:message_tracking] so the
21
- # agent loop can access the current assistant message ID for callbacks.
22
- #
23
- class MessageTracking < Base
24
- attr_reader :store
25
-
26
- def initialize(app, store:)
27
- super(app)
28
- @store = store
29
- @current_user_id = nil
30
- @current_assistant_id = nil
31
- end
32
-
33
- def call(env)
34
- env[:message_tracking] = self
35
-
36
- # ── Pre-call: record user message or update tool results ──
37
- if env[:tool_results].nil?
38
- # New turn — record the user message
39
- record_user_message(env)
40
- else
41
- # Tool results coming back — complete the tool parts
42
- complete_tool_parts(env)
43
- end
44
-
45
- # ── LLM call ──
46
- response = @app.call(env)
47
-
48
- # ── Post-call: record assistant message ──
49
- record_assistant_message(env, response)
50
-
51
- response
52
- end
53
-
54
- # The current assistant message ID (used by external callbacks).
55
- def current_assistant_id
56
- @current_assistant_id
57
- end
58
-
59
- private
60
-
61
- # ── User message ───────────────────────────────────────────────
62
-
63
- def record_user_message(env)
64
- text = extract_user_text(env)
65
- return unless text
66
-
67
- @current_user_id = @store.append_user(text: text)
68
- end
69
-
70
- def extract_user_text(env)
71
- input = env[:input]
72
- case input
73
- when String
74
- input
75
- when Array
76
- # llm.rb prompt format: array of message hashes
77
- user_msg = input.reverse_each.find { |m| m.respond_to?(:role) && m.role.to_s == "user" }
78
- user_msg&.content.to_s if user_msg
79
- else
80
- # Could be a prompt object — try to extract user content
81
- if input.respond_to?(:messages)
82
- msgs = input.messages.to_a
83
- user_msg = msgs.reverse_each.find { |m| m.role.to_s == "user" }
84
- user_msg&.content.to_s if user_msg
85
- end
86
- end
87
- end
88
-
89
- # ── Assistant message ──────────────────────────────────────────
90
-
91
- def record_assistant_message(env, response)
92
- provider_name = env[:provider]&.class&.name&.split("::")&.last&.downcase
93
- model_name = resolve_model_name(env)
94
-
95
- @current_assistant_id = @store.append_assistant(
96
- parent_id: @current_user_id,
97
- model_id: model_name,
98
- provider_id: provider_name,
99
- )
100
-
101
- # Text content
102
- text = safe_content(response)
103
- @store.add_text_part(message_id: @current_assistant_id, text: text) if text && !text.empty?
104
-
105
- # Tool calls
106
- record_tool_calls(env)
107
-
108
- # Token usage
109
- tokens = extract_tokens(env, response)
110
- @store.complete_assistant(message_id: @current_assistant_id, tokens: tokens) if tokens
111
-
112
- # Step finish
113
- @store.add_step_finish(message_id: @current_assistant_id, tokens: tokens)
114
- end
115
-
116
- def record_tool_calls(env)
117
- functions = env[:pending_functions]
118
- return if functions.nil? || functions.empty?
119
-
120
- functions.each do |fn|
121
- @store.add_tool_part(
122
- message_id: @current_assistant_id,
123
- tool: fn.name,
124
- call_id: fn.id,
125
- input: fn.arguments,
126
- )
127
- end
128
- end
129
-
130
- # ── Tool results ───────────────────────────────────────────────
131
-
132
- def complete_tool_parts(env)
133
- return unless @current_assistant_id
134
-
135
- results = env[:tool_results]
136
- return unless results.is_a?(Array)
137
-
138
- results.each do |name, value|
139
- # Find the tool part by name (tool results come as [name, value] pairs)
140
- msg = @store.message(@current_assistant_id)
141
- next unless msg
142
-
143
- # Match by tool name — find the first running tool part with this name
144
- part = msg[:parts]&.find do |p|
145
- p[:type] == "tool" && p[:tool] == name && p.dig(:state, :status) == "running"
146
- end
147
- next unless part
148
-
149
- call_id = part[:callID]
150
- if value.is_a?(Hash) && value[:error]
151
- @store.error_tool_part(
152
- message_id: @current_assistant_id,
153
- call_id: call_id,
154
- error: value[:error],
155
- )
156
- else
157
- output = value.is_a?(String) ? value : value.to_s
158
- @store.complete_tool_part(
159
- message_id: @current_assistant_id,
160
- call_id: call_id,
161
- output: output,
162
- )
163
- end
164
- end
165
- end
166
-
167
- # ── Helpers ────────────────────────────────────────────────────
168
-
169
- # Resolve the actual model used for the request.
170
- # Prefers env[:model] (set by AgentTurn) and falls back to the
171
- # provider's default_model.
172
- def resolve_model_name(env)
173
- model = env[:model]
174
- return model.to_s if model
175
-
176
- # Fall back to provider default
177
- env[:provider]&.respond_to?(:default_model) ? env[:provider].default_model.to_s : nil
178
- end
179
-
180
- def safe_content(response)
181
- return nil unless response.respond_to?(:content)
182
- response.content
183
- rescue NoMethodError
184
- nil
185
- end
186
-
187
- def extract_tokens(env, response)
188
- # Prefer the metadata accumulated by TokenTracking middleware
189
- meta_tokens = env.dig(:metadata, :tokens, :last_call)
190
- if meta_tokens
191
- {
192
- input: meta_tokens[:input] || 0,
193
- output: meta_tokens[:output] || 0,
194
- reasoning: 0,
195
- cache: { read: 0, write: 0 },
196
- }
197
- elsif response.respond_to?(:usage) && (usage = response.usage)
198
- {
199
- input: usage.input_tokens.to_i,
200
- output: usage.output_tokens.to_i,
201
- reasoning: usage.reasoning_tokens.to_i,
202
- cache: { read: 0, write: 0 },
203
- }
204
- end
205
- end
206
- end
207
- end
208
- end
209
-
210
- test do
211
- require_relative "../../../spec/support/mock_provider"
212
- require_relative "../../../spec/support/mock_response"
213
- require "tmpdir"
214
- require "fileutils"
215
-
216
- def build_env(**overrides)
217
- { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
218
- messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
219
- tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
220
- end
221
-
222
- def with_tracking
223
- tmpdir = Dir.mktmpdir("brute_test_")
224
- store = Brute::Store::MessageStore.new(session_id: "test-session", dir: tmpdir)
225
- response = MockResponse.new(content: "Hello from the LLM")
226
- inner_app = ->(_env) { response }
227
- middleware = Brute::Middleware::MessageTracking.new(inner_app, store: store)
228
- yield middleware, store, response
229
- ensure
230
- FileUtils.rm_rf(tmpdir)
231
- end
232
-
233
- it "records a user message on first call of a turn" do
234
- with_tracking do |mw, store, _|
235
- mw.call(build_env(input: "What is Ruby?", tool_results: nil))
236
- user_msg = store.messages.find { |m| m[:info][:role] == "user" }
237
- user_msg[:parts][0][:text].should == "What is Ruby?"
238
- end
239
- end
240
-
241
- it "records only one user message per turn" do
242
- with_tracking do |mw, store, _|
243
- env = build_env(input: "Hello", tool_results: nil)
244
- mw.call(env)
245
- env[:tool_results] = [["read", "contents"]]
246
- mw.call(env)
247
- store.messages.select { |m| m[:info][:role] == "user" }.size.should == 1
248
- end
249
- end
250
-
251
- it "records an assistant message after LLM call" do
252
- with_tracking do |mw, store, _|
253
- mw.call(build_env(input: "Hello", tool_results: nil))
254
- asst = store.messages.find { |m| m[:info][:role] == "assistant" }
255
- asst.should.not.be.nil
256
- end
257
- end
258
-
259
- it "captures text content as a text part" do
260
- with_tracking do |mw, store, _|
261
- mw.call(build_env(input: "Hello", tool_results: nil))
262
- asst = store.messages.find { |m| m[:info][:role] == "assistant" }
263
- text_parts = asst[:parts].select { |p| p[:type] == "text" }
264
- text_parts[0][:text].should == "Hello from the LLM"
265
- end
266
- end
267
-
268
- it "captures token usage from response" do
269
- with_tracking do |mw, store, _|
270
- mw.call(build_env(input: "Hello", tool_results: nil))
271
- asst = store.messages.find { |m| m[:info][:role] == "assistant" }
272
- asst[:info][:tokens][:input].should == 100
273
- end
274
- end
275
-
276
- it "records tool calls as tool parts in running state" do
277
- with_tracking do |mw, store, _|
278
- fn = Struct.new(:id, :name, :arguments, keyword_init: true).new(id: "call_001", name: "read", arguments: { file_path: "/test" })
279
- mw.call(build_env(input: "Read the file", tool_results: nil, pending_functions: [fn]))
280
- asst = store.messages.find { |m| m[:info][:role] == "assistant" }
281
- tool_parts = asst[:parts].select { |p| p[:type] == "tool" }
282
- tool_parts[0][:state][:status].should == "running"
283
- end
284
- end
285
-
286
- it "updates tool parts when results arrive" do
287
- with_tracking do |mw, store, _|
288
- fn = Struct.new(:id, :name, :arguments, keyword_init: true).new(id: "call_001", name: "read", arguments: { file_path: "/test" })
289
- env = build_env(input: "Read the file", tool_results: nil, pending_functions: [fn])
290
- mw.call(env)
291
- env[:pending_functions] = []
292
- env[:tool_results] = [["read", "file contents here"]]
293
- mw.call(env)
294
- first_asst = store.messages.find { |m| m[:info][:role] == "assistant" }
295
- tool_part = first_asst[:parts].find { |p| p[:type] == "tool" }
296
- tool_part[:state][:status].should == "completed"
297
- end
298
- end
299
-
300
- it "records provider default_model when no override" do
301
- with_tracking do |mw, store, _|
302
- mw.call(build_env(input: "Hello", tool_results: nil))
303
- asst = store.messages.find { |m| m[:info][:role] == "assistant" }
304
- asst[:info][:modelID].should == "mock-model"
305
- end
306
- end
307
-
308
- it "records overridden model when env[:model] is set" do
309
- with_tracking do |mw, store, _|
310
- mw.call(build_env(input: "Hello", tool_results: nil, model: "custom-haiku"))
311
- asst = store.messages.find { |m| m[:info][:role] == "assistant" }
312
- asst[:info][:modelID].should == "custom-haiku"
313
- end
314
- end
315
-
316
- it "stores itself in env[:message_tracking]" do
317
- with_tracking do |mw, _, _|
318
- env = build_env(input: "Hello", tool_results: nil)
319
- mw.call(env)
320
- env[:message_tracking].should == mw
321
- end
322
- end
323
-
324
- it "returns the inner app response unchanged" do
325
- with_tracking do |mw, _, response|
326
- result = mw.call(build_env(input: "Hello", tool_results: nil))
327
- result.should == response
328
- end
329
- end
330
-
331
- it "adds a step-finish part to assistant messages" do
332
- with_tracking do |mw, store, _|
333
- mw.call(build_env(input: "Hello", tool_results: nil))
334
- asst = store.messages.find { |m| m[:info][:role] == "assistant" }
335
- step_finish = asst[:parts].find { |p| p[:type] == "step-finish" }
336
- step_finish[:reason].should == "stop"
337
- end
338
- end
339
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- module OTel
9
- # Outermost OTel middleware. Creates a span per LLM pipeline call
10
- # and passes it through env[:span] for inner OTel middlewares to
11
- # decorate with events and attributes.
12
- #
13
- # When opentelemetry-sdk is not loaded, this is a pure pass-through.
14
- #
15
- # Pipeline position: outermost (wraps everything including retries).
16
- #
17
- # use Brute::Middleware::OTel::Span
18
- # use Brute::Middleware::OTel::ToolResults
19
- # use Brute::Middleware::OTel::ToolCalls
20
- # use Brute::Middleware::OTel::TokenUsage
21
- # # ... existing middleware ...
22
- # run Brute::Middleware::LLMCall.new
23
- #
24
- class Span < Base
25
- def call(env)
26
- return @app.call(env) unless defined?(::OpenTelemetry::SDK)
27
-
28
- provider_name = provider_type(env[:provider])
29
- model = env[:model] || (env[:provider].default_model rescue nil)
30
- span_name = model ? "llm.call #{model}" : "llm.call"
31
-
32
- attributes = {
33
- "brute.provider" => provider_name,
34
- "brute.streaming" => !!env[:streaming],
35
- "brute.context_messages" => env[:messages].size,
36
- }
37
- attributes["brute.model"] = model.to_s if model
38
- attributes["brute.session_id"] = env[:metadata][:session_id].to_s if env.dig(:metadata, :session_id)
39
-
40
- tracer.in_span(span_name, attributes: attributes, kind: :internal) do |span|
41
- env[:span] = span
42
- response = @app.call(env)
43
-
44
- # Record response model if it differs from request model
45
- resp_model = begin; response.model; rescue; nil; end
46
- span.set_attribute("brute.response_model", resp_model.to_s) if resp_model && resp_model != model
47
-
48
- response
49
- rescue ::StandardError => e
50
- span.record_exception(e)
51
- span.status = ::OpenTelemetry::Trace::Status.error(e.message)
52
- raise
53
- ensure
54
- env.delete(:span)
55
- end
56
- end
57
-
58
- private
59
-
60
- def tracer
61
- @tracer ||= ::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
62
- end
63
-
64
- def provider_type(provider)
65
- name = provider.class.name.to_s.downcase
66
- if name.include?("anthropic") then "anthropic"
67
- elsif name.include?("openai") then "openai"
68
- elsif name.include?("google") || name.include?("gemini") then "google"
69
- elsif name.include?("deepseek") then "deepseek"
70
- elsif name.include?("ollama") then "ollama"
71
- elsif name.include?("xai") then "xai"
72
- else "unknown"
73
- end
74
- end
75
- end
76
- end
77
- end
78
- end
79
-
80
- test do
81
- require_relative "../../../../spec/support/mock_provider"
82
- require_relative "../../../../spec/support/mock_response"
83
-
84
- def build_env(**overrides)
85
- { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
86
- messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
87
- tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
88
- end
89
-
90
- it "passes through when OpenTelemetry::SDK is not defined" do
91
- response = MockResponse.new(content: "hello from LLM")
92
- middleware = Brute::Middleware::OTel::Span.new(->(_env) { response })
93
- env = build_env
94
- result = middleware.call(env)
95
- result.should == response
96
- end
97
-
98
- it "env[:span] is nil when OTel is not defined" do
99
- response = MockResponse.new(content: "hello from LLM")
100
- middleware = Brute::Middleware::OTel::Span.new(->(_env) { response })
101
- env = build_env
102
- middleware.call(env)
103
- env[:span].should.be.nil
104
- end
105
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- module OTel
9
- # Records token usage from the LLM response as span attributes.
10
- #
11
- # Runs POST-call: reads token counts from the response usage object
12
- # and sets them as attributes on the span.
13
- #
14
- class TokenUsage < Base
15
- def call(env)
16
- response = @app.call(env)
17
-
18
- span = env[:span]
19
- if span && response.respond_to?(:usage) && (usage = response.usage)
20
- span.set_attribute("gen_ai.usage.input_tokens", usage.input_tokens.to_i)
21
- span.set_attribute("gen_ai.usage.output_tokens", usage.output_tokens.to_i)
22
- span.set_attribute("gen_ai.usage.total_tokens", usage.total_tokens.to_i)
23
-
24
- reasoning = usage.reasoning_tokens.to_i
25
- span.set_attribute("gen_ai.usage.reasoning_tokens", reasoning) if reasoning > 0
26
- end
27
-
28
- response
29
- end
30
- end
31
- end
32
- end
33
- end
34
-
35
- test do
36
- require_relative "../../../../spec/support/mock_provider"
37
- require_relative "../../../../spec/support/mock_response"
38
-
39
- def build_env(**overrides)
40
- { provider: MockProvider.new, model: nil, input: "test prompt", tools: [],
41
- messages: [], stream: nil, params: {}, metadata: {}, callbacks: {},
42
- tool_results: nil, streaming: false, should_exit: nil, pending_functions: [] }.merge(overrides)
43
- end
44
-
45
- def make_response
46
- MockResponse.new(content: "hello",
47
- usage: LLM::Usage.new(input_tokens: 100, output_tokens: 50, reasoning_tokens: 10, total_tokens: 160))
48
- end
49
-
50
- it "passes the response through unchanged" do
51
- response = make_response
52
- middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { response })
53
- result = middleware.call(build_env)
54
- result.should == response
55
- end
56
-
57
- it "passes through without error when span is nil" do
58
- response = make_response
59
- middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { response })
60
- lambda { middleware.call(build_env) }.should.not.raise
61
- end
62
-
63
- it "handles a response without usage gracefully" do
64
- no_usage = Object.new
65
- middleware = Brute::Middleware::OTel::TokenUsage.new(->(_env) { no_usage })
66
- lambda { middleware.call(build_env) }.should.not.raise
67
- end
68
- end