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
@@ -1,322 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "date"
4
-
5
- require "gem_kit"
6
-
7
- module Brute
8
- # A parser and linter for CHANGELOG.md, which follows
9
- # [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
10
- #
11
- # The changelog is the one release artefact nothing else can regenerate, so
12
- # it is the one most easily forgotten. Making it machine-checkable turns
13
- # "did anyone write the changelog?" into a gate: `bin/lint-changelog`
14
- # validates the format, and `bin/release-gem` refuses to push a version that
15
- # has no section of its own.
16
- #
17
- # changelog = Brute::Changelog.load
18
- # changelog.problems # => [] when the format is clean
19
- # changelog.release_problems("4.1.0")
20
- #
21
- # The shape it expects:
22
- #
23
- # # Changelog
24
- #
25
- # ## [Unreleased]
26
- #
27
- # ## [4.1.0] - 2026-08-20
28
- #
29
- # ### Added
30
- #
31
- # - Something that happened.
32
- #
33
- class Changelog
34
- # Deprecated. This moved out to the gem_kit-release gem, where the rest of
35
- # the release toolchain now lives — `gem kit changelog` is the CLI over it.
36
- # The implementation stays here, working, until the deadline.
37
- extend GemKit::Deprecate
38
- superseded_by "GemKit::Release::Changelog", "5.0"
39
-
40
- # The six change types Keep a Changelog defines. Anything else under a
41
- # version is a typo or an invention, and both are worth catching.
42
- SECTIONS = %w[Added Changed Deprecated Removed Fixed Security].freeze
43
-
44
- UNRELEASED = "Unreleased"
45
- HEADING = /\A##\s+\[([^\]]+)\](?:\s+-\s+(.*))?\s*\z/
46
- SUBHEADING = /\A###\s+(.*?)\s*\z/
47
- BULLET = /\A[-*]\s+\S/
48
- DATE = /\A\d{4}-\d{2}-\d{2}\z/
49
-
50
- # One `## [...]` section of the file.
51
- Release = Struct.new(:version, :date, :line, :subsections, keyword_init: true) do
52
- def unreleased? = version == UNRELEASED
53
- def empty? = subsections.empty? || subsections.values.all?(&:empty?)
54
- def to_s = unreleased? ? "[#{version}]" : "[#{version}] - #{date}"
55
- end
56
-
57
- # Load the changelog sitting beside the gem (../../CHANGELOG.md).
58
- def self.load(path = File.expand_path("../../CHANGELOG.md", __dir__))
59
- new(File.exist?(path) ? File.read(path) : nil, path: path)
60
- end
61
-
62
- attr_reader :path, :releases
63
-
64
- # @parameter text [String, nil] the file's contents; nil means "no file".
65
- def initialize(text, path: "CHANGELOG.md")
66
- @path = path
67
- @text = text
68
- @releases = text ? parse(text) : []
69
- end
70
-
71
- def missing? = @text.nil?
72
-
73
- def unreleased = releases.find(&:unreleased?)
74
-
75
- def released = releases.reject(&:unreleased?)
76
-
77
- def find(version)
78
- target = Gem::Version.new(version.to_s)
79
- released.find { |release| Gem::Version.new(release.version) == target rescue false }
80
- end
81
-
82
- # Everything wrong with the file's *format*, as a list of human-readable
83
- # problems. Empty means it lints clean.
84
- def problems
85
- return ["#{path} does not exist"] if missing?
86
-
87
- [*header_problems, *heading_problems, *ordering_problems, *content_problems]
88
- end
89
-
90
- # Everything standing between this changelog and releasing `version`.
91
- # Format problems count: a file nobody can parse is not documentation.
92
- def release_problems(version)
93
- return problems unless problems.empty?
94
-
95
- release = find(version)
96
- return ["#{path} has no section for #{version} — run bin/update-changelog"] if release.nil?
97
- return ["#{path} section for #{version} is empty"] if release.empty?
98
-
99
- newest = released.first
100
- if newest && newest.version != release.version
101
- return ["#{path} lists #{newest.version} above #{version}; the release being cut must come first"]
102
- end
103
-
104
- []
105
- end
106
-
107
- private
108
-
109
- # Split the file into `## [...]` sections, recording each one's
110
- # `### Type` subsections and their top-level bullets.
111
- def parse(text)
112
- found = []
113
- current = nil
114
- heading = nil
115
-
116
- text.each_line.with_index(1) do |line, number|
117
- case line
118
- when HEADING
119
- heading = nil
120
- current = Release.new(version: $1, date: $2&.strip, line: number, subsections: {})
121
- found << current
122
- when SUBHEADING
123
- next unless current
124
-
125
- heading = $1
126
- (current.subsections[heading] ||= [])
127
- when BULLET
128
- current.subsections[heading] << line.strip if current && heading
129
- end
130
- end
131
-
132
- found
133
- end
134
-
135
- def header_problems
136
- first = @text.each_line.find { |line| !line.strip.empty? }
137
- return [] if first&.strip == "# Changelog"
138
-
139
- ["#{path}:1 must start with the title `# Changelog`"]
140
- end
141
-
142
- def heading_problems
143
- problems = []
144
-
145
- @text.each_line.with_index(1) do |line, number|
146
- next unless line.start_with?("## ") && !line.start_with?("###")
147
-
148
- unless line =~ HEADING
149
- problems << "#{path}:#{number} malformed version heading: #{line.strip.inspect} " \
150
- "(expected `## [Unreleased]` or `## [1.2.3] - YYYY-MM-DD`)"
151
- next
152
- end
153
-
154
- version, date = $1, $2&.strip
155
- next if version == UNRELEASED
156
-
157
- problems << "#{path}:#{number} #{version} has no date (expected `## [#{version}] - YYYY-MM-DD`)" if date.nil? || date.empty?
158
- problems << "#{path}:#{number} #{version} has a malformed date: #{date.inspect}" if date && !date.empty? && date !~ DATE
159
- problems << "#{path}:#{number} #{version} is not a valid version number" unless valid_version?(version)
160
- end
161
-
162
- problems + subheading_problems
163
- end
164
-
165
- def subheading_problems
166
- @text.each_line.with_index(1).filter_map do |line, number|
167
- next unless line.start_with?("### ")
168
- next if line =~ SUBHEADING && SECTIONS.include?($1)
169
-
170
- "#{path}:#{number} unknown section #{line.sub("###", "").strip.inspect} " \
171
- "(expected one of: #{SECTIONS.join(", ")})"
172
- end
173
- end
174
-
175
- def ordering_problems
176
- problems = []
177
-
178
- if unreleased && releases.first&.unreleased? == false
179
- problems << "#{path}:#{unreleased.line} [Unreleased] must be the first section"
180
- end
181
-
182
- seen = {}
183
- released.each do |release|
184
- if (first = seen[release.version])
185
- problems << "#{path}:#{release.line} duplicate section for #{release.version} (also at line #{first})"
186
- end
187
- seen[release.version] ||= release.line
188
- end
189
-
190
- versions = released.select { |release| valid_version?(release.version) }
191
- versions.each_cons(2) do |newer, older|
192
- next if Gem::Version.new(newer.version) > Gem::Version.new(older.version)
193
-
194
- problems << "#{path}:#{older.line} #{older.version} is listed below #{newer.version}; " \
195
- "releases must run newest to oldest"
196
- end
197
-
198
- problems
199
- end
200
-
201
- def content_problems
202
- released.flat_map do |release|
203
- if release.subsections.empty?
204
- ["#{path}:#{release.line} #{release} has no #{SECTIONS.join("/")} section"]
205
- else
206
- release.subsections.filter_map do |heading, bullets|
207
- "#{path}:#{release.line} #{release} has an empty `### #{heading}` section" if bullets.empty?
208
- end
209
- end
210
- end
211
- end
212
-
213
- def valid_version?(version)
214
- Gem::Version.correct?(version)
215
- end
216
- end
217
- end
218
-
219
- __END__
220
-
221
- describe "brute/changelog" do
222
- # These specs exercise a deprecated class on purpose; skip_during keeps the
223
- # warning out of the suite's output (see DEPRECATIONS.md).
224
- changelog = lambda do |body|
225
- Gem::Deprecate.skip_during { Brute::Changelog.new("# Changelog\n\n#{body}") }
226
- end
227
-
228
- good = <<~MD
229
- ## [Unreleased]
230
-
231
- ## [4.1.0] - 2026-08-20
232
-
233
- ### Added
234
-
235
- - A thing.
236
-
237
- ## [4.0.0] - 2026-08-01
238
-
239
- ### Removed
240
-
241
- - An older thing.
242
- MD
243
-
244
- it "parses sections, dates and bullets" do
245
- log = changelog.call(good)
246
-
247
- log.releases.map(&:version).should == ["Unreleased", "4.1.0", "4.0.0"]
248
- log.unreleased.should.be.kind_of?(Brute::Changelog::Release)
249
- log.released.first.date.should == "2026-08-20"
250
- log.released.first.subsections["Added"].should == ["- A thing."]
251
- log.find("4.0.0").subsections["Removed"].size.should == 1
252
- end
253
-
254
- it "lints a well-formed file clean and clears it for release" do
255
- log = changelog.call(good)
256
-
257
- log.problems.should == []
258
- log.release_problems("4.1.0").should == []
259
- end
260
-
261
- it "requires the `# Changelog` title" do
262
- Gem::Deprecate.skip_during do
263
- Brute::Changelog.new("## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n")
264
- .problems.first.should.match(/must start with the title/)
265
- end
266
- end
267
-
268
- it "reports a missing file" do
269
- log = Gem::Deprecate.skip_during { Brute::Changelog.new(nil, path: "nope.md") }
270
- log.missing?.should.be.true
271
- log.problems.first.should.match(/does not exist/)
272
- end
273
-
274
- it "rejects a malformed version heading" do
275
- changelog.call("## 1.0.0\n").problems.first.should.match(/malformed version heading/)
276
- end
277
-
278
- it "rejects an undated or badly dated release" do
279
- changelog.call("## [1.0.0]\n\n### Added\n\n- x\n").problems.first.should.match(/has no date/)
280
- changelog.call("## [1.0.0] - 20260101\n\n### Added\n\n- x\n").problems.first.should.match(/malformed date/)
281
- end
282
-
283
- it "rejects an unknown section type" do
284
- changelog.call("## [1.0.0] - 2026-01-01\n\n### Improved\n\n- x\n")
285
- .problems.first.should.match(/unknown section "Improved"/)
286
- end
287
-
288
- it "rejects an empty release and an empty subsection" do
289
- changelog.call("## [1.0.0] - 2026-01-01\n").problems.first.should.match(/has no Added/)
290
- changelog.call("## [1.0.0] - 2026-01-01\n\n### Added\n\n## [0.9.0] - 2026-01-01\n\n### Added\n\n- x\n")
291
- .problems.first.should.match(/empty `### Added` section/)
292
- end
293
-
294
- it "rejects duplicates and out-of-order releases" do
295
- body = "## [1.0.0] - 2026-01-02\n\n### Added\n\n- x\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- y\n"
296
- changelog.call(body).problems.first.should.match(/duplicate section for 1\.0\.0/)
297
-
298
- body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [2.0.0] - 2026-01-02\n\n### Added\n\n- y\n"
299
- changelog.call(body).problems.first.should.match(/must run newest to oldest/)
300
- end
301
-
302
- it "requires [Unreleased] to come first" do
303
- body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [Unreleased]\n"
304
- changelog.call(body).problems.first.should.match(/\[Unreleased\] must be the first section/)
305
- end
306
-
307
- it "blocks a release with no section of its own" do
308
- changelog.call(good).release_problems("9.9.9").first.should.match(/no section for 9\.9\.9/)
309
- end
310
-
311
- it "blocks a release that is not the newest section" do
312
- changelog.call(good).release_problems("4.0.0").first.should.match(/lists 4\.1\.0 above 4\.0\.0/)
313
- end
314
-
315
- it "reports format problems ahead of release problems" do
316
- changelog.call("## nonsense\n").release_problems("4.1.0").first.should.match(/malformed version heading/)
317
- end
318
-
319
- it "loads the repo's own CHANGELOG.md" do
320
- Gem::Deprecate.skip_during { Brute::Changelog.load.missing?.should.be.false }
321
- end
322
- end
@@ -1,132 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "gem_kit"
4
-
5
- module Brute
6
- # Deprecated. This is [`GemKit::Deprecate`](https://rubygems.org/gems/gem_kit)
7
- # now — the same code, extracted so that other gems could use it, and so that
8
- # `gem kit deprecations` could read one registry rather than two.
9
- #
10
- # extend Brute::Deprecate -> extend GemKit::Deprecate
11
- # brute_deprecate -> deprecate
12
- # brute_deprecate_constant -> superseded_by
13
- #
14
- # Extending this module still works and still registers: it warns, then
15
- # extends GemKit::Deprecate for you and aliases the old method names onto the
16
- # new ones. It will stop working in 5.0.
17
- module Deprecate
18
- REPLACEMENT = "GemKit::Deprecate"
19
- REMOVED_IN = "5.0"
20
-
21
- # The names Brute used before the extraction. `deprecate` collides with
22
- # Gem::Deprecate's own, which is why Brute's carried a prefix; GemKit's
23
- # namespace does that job instead.
24
- ALIASES = { brute_deprecate: :deprecate, brute_deprecate_constant: :superseded_by }.freeze
25
-
26
- def self.extended(base)
27
- origin = Gem.location_of_caller.join(":")
28
- GemKit::Deprecate.warn(
29
- GemKit::Deprecate.message("Brute::Deprecate", REPLACEMENT, REMOVED_IN, origin),
30
- )
31
-
32
- base.extend(GemKit::Deprecate)
33
- ALIASES.each { |old, new| base.singleton_class.alias_method(old, new) }
34
- end
35
-
36
- # The registry moved with the DSL. Delegated rather than mirrored: two
37
- # registries meant two answers to "what is still outstanding".
38
- class << self
39
- def registry = GemKit::Deprecate.registry
40
- def pending(version) = GemKit::Deprecate.pending(version)
41
- def upcoming(version) = GemKit::Deprecate.upcoming(version)
42
- def register(...) = GemKit::Deprecate.register(...)
43
- def warn(message) = GemKit::Deprecate.warn(message)
44
- end
45
- end
46
- end
47
-
48
- # The module itself is deprecated, and a module cannot announce that the way a
49
- # class can — there is no `new` to wrap. Registering it by hand is what puts it
50
- # in `gem kit deprecations` alongside everything else.
51
- GemKit::Deprecate.register(
52
- name: "Brute::Deprecate",
53
- replacement: Brute::Deprecate::REPLACEMENT,
54
- removed_in: Brute::Deprecate::REMOVED_IN,
55
- declared_at: "#{__FILE__}:#{__LINE__ - 5}",
56
- )
57
-
58
- __END__
59
-
60
- describe "brute/deprecate" do
61
- captured = []
62
- # Capture what the shim emits, and keep the shared registry clean.
63
- isolated = lambda do |&block|
64
- saved = GemKit::Deprecate.registry.dup
65
- original = GemKit::Deprecate.method(:warn)
66
- captured.clear
67
- GemKit::Deprecate.define_singleton_method(:warn) { |message| captured << message }
68
- begin
69
- block.call
70
- ensure
71
- GemKit::Deprecate.define_singleton_method(:warn, original)
72
- GemKit::Deprecate.registry.replace(saved)
73
- end
74
- end
75
-
76
- it "warns when extended, naming GemKit::Deprecate" do
77
- isolated.call do
78
- Class.new { extend Brute::Deprecate }
79
-
80
- captured.size.should == 1
81
- captured.first.should.match(/Brute::Deprecate is deprecated/)
82
- captured.first.should.match(/use GemKit::Deprecate instead/)
83
- captured.first.should.match(/removed in 5\.0/)
84
- end
85
- end
86
-
87
- it "still declares a method deprecation under the old name" do
88
- isolated.call do
89
- klass = Class.new do
90
- extend Brute::Deprecate
91
- def new_name = :result
92
- def old_name = new_name
93
- brute_deprecate :old_name, "Thing#new_name", "9.0"
94
- end
95
-
96
- klass.new.old_name.should == :result
97
- GemKit::Deprecate.registry.last.replacement.should == "Thing#new_name"
98
- end
99
- end
100
-
101
- it "still declares a constant deprecation under the old name" do
102
- isolated.call do
103
- modern = Class.new { def initialize(x); @x = x; end; attr_reader :x }
104
- legacy = Class.new(modern) do
105
- extend Brute::Deprecate
106
- def self.name = "Old::Name"
107
- brute_deprecate_constant "New::Name", "9.0"
108
- end
109
-
110
- legacy.new(42).x.should == 42
111
- GemKit::Deprecate.registry.last.name.should == "Old::Name"
112
- end
113
- end
114
-
115
- it "delegates the registry rather than keeping one of its own" do
116
- isolated.call do
117
- Brute::Deprecate.registry.should.equal?(GemKit::Deprecate.registry)
118
-
119
- GemKit::Deprecate.register(name: "A", replacement: "A2", removed_in: "5.0")
120
- Brute::Deprecate.pending("5.0.0").map(&:name).should.include?("A")
121
- Brute::Deprecate.upcoming("4.0.0").map(&:name).should.include?("A")
122
- end
123
- end
124
-
125
- it "registers itself, so `gem kit deprecations` lists it" do
126
- entry = GemKit::Deprecate.registry.find { |e| e.name == "Brute::Deprecate" }
127
-
128
- entry.should.not.be.nil
129
- entry.replacement.should == "GemKit::Deprecate"
130
- entry.removed_in.should == Gem::Version.new("5.0")
131
- end
132
- end
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- # Outermost OTel middleware. Creates a span per LLM stack call
9
- # and passes it through env[:span] for inner OTel middlewares to
10
- # decorate with events and attributes.
11
- #
12
- # When opentelemetry-sdk is not loaded, this is a pure pass-through.
13
- #
14
- # Stack position: outermost (wraps everything including retries).
15
- #
16
- # use Brute::Middleware::OTel::Span
17
- # use Brute::Middleware::OTel::ToolResultLoop
18
- # use Brute::Middleware::OTel::ToolCalls
19
- # use Brute::Middleware::OTel::TokenUsage
20
- # # ... existing middleware ...
21
- # run ->(env) { ... } # inline LLM call proc (see Brute.agent)
22
- #
23
- class OtelSpan
24
- def initialize(app)
25
- @app = app
26
- end
27
-
28
- def call(env)
29
- #return @app.call(env) unless defined?(::OpenTelemetry::SDK)
30
-
31
- #provider_name = provider_type(env[:provider])
32
- #model = env[:model] || (env[:provider].default_model rescue nil)
33
- #span_name = model ? "llm.call #{model}" : "llm.call"
34
-
35
- #attributes = {
36
- # "brute.provider" => provider_name,
37
- # "brute.streaming" => !!env[:streaming],
38
- # "brute.context_messages" => env[:messages].size,
39
- #}
40
- #attributes["brute.model"] = model.to_s if model
41
- #attributes["brute.session_id"] = env[:metadata][:session_id].to_s if env.dig(:metadata, :session_id)
42
-
43
- #tracer.in_span(span_name, attributes: attributes, kind: :internal) do |span|
44
- # env[:span] = span
45
- # response = @app.call(env)
46
-
47
- # # Record response model if it differs from request model
48
- # resp_model = begin; response.model; rescue; nil; end
49
- # span.set_attribute("brute.response_model", resp_model.to_s) if resp_model && resp_model != model
50
-
51
- # response
52
- #rescue ::StandardError => e
53
- # span.record_exception(e)
54
- # span.status = ::OpenTelemetry::Trace::Status.error(e.message)
55
- # raise
56
- #ensure
57
- # env.delete(:span)
58
- #end
59
- @app.all(env)
60
- end
61
-
62
- private
63
-
64
- def tracer
65
- @tracer ||= ::OpenTelemetry.tracer_provider.tracer("brute", Brute::VERSION)
66
- end
67
-
68
- def provider_type(provider)
69
- provider.name.to_s
70
- end
71
- end
72
- end
73
- end
74
-
75
- __END__
76
-
77
- describe "brute/middleware/001_otel_span" do
78
- # not implemented
79
- end
@@ -1,44 +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
15
- def initialize(app)
16
- @app = app
17
- end
18
-
19
- def call(env)
20
- #response = @app.call(env)
21
-
22
- #span = env[:span]
23
- #if span && response.respond_to?(:usage) && (usage = response.usage)
24
- # span.set_attribute("gen_ai.usage.input_tokens", usage.input_tokens.to_i)
25
- # span.set_attribute("gen_ai.usage.output_tokens", usage.output_tokens.to_i)
26
- # span.set_attribute("gen_ai.usage.total_tokens", usage.total_tokens.to_i)
27
-
28
- # reasoning = usage.reasoning_tokens.to_i
29
- # span.set_attribute("gen_ai.usage.reasoning_tokens", reasoning) if reasoning > 0
30
- #end
31
-
32
- #response
33
- @app.call(env)
34
- end
35
- end
36
- end
37
- end
38
- end
39
-
40
- __END__
41
-
42
- describe "brute/middleware/015_otel_token_usage" do
43
- # not implemented
44
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- # Records tool calls the LLM requested as span events.
9
- #
10
- # Runs POST-call: after the LLM responds, inspects ctx.functions
11
- # for any tool calls the model wants to make, and adds a span event
12
- # for each one with the tool name, call ID, and arguments.
13
- #
14
- class OtelToolCalls
15
- def initialize(app)
16
- @app = app
17
- end
18
-
19
- def call(env)
20
- #response = @app.call(env)
21
-
22
- #span = env[:span]
23
- #if span
24
- # functions = env[:pending_functions]
25
- # if functions && !functions.empty?
26
- # span.set_attribute("brute.tool_calls.count", functions.size)
27
-
28
- # functions.each do |fn|
29
- # attrs = {
30
- # "tool.name" => fn.name.to_s,
31
- # "tool.id" => fn.id.to_s,
32
- # }
33
- # args = fn.arguments
34
- # attrs["tool.arguments"] = args.to_json if args
35
- # span.add_event("tool_call", attributes: attrs)
36
- # end
37
- # end
38
- #end
39
-
40
- #response
41
- @app.call(env)
42
- end
43
- end
44
- end
45
- end
46
-
47
- __END__
48
-
49
- describe "brute/middleware/073_otel_tool_call" do
50
- # not implemented
51
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/setup"
4
- require "brute"
5
-
6
- module Brute
7
- module Middleware
8
- # Records tool results as span events.
9
- #
10
- # Tool results are now appended directly to env[:messages] as :tool
11
- # role messages. This middleware can inspect the last messages to
12
- # record them as span events.
13
- #
14
- class OtelToolResults
15
- def initialize(app)
16
- @app = app
17
- end
18
-
19
- def call(env)
20
- #span = env[:span]
21
-
22
- #if span && (results = env[:tool_results])
23
- # span.set_attribute("brute.tool_results.count", results.size)
24
-
25
- # results.each do |name, value|
26
- # error = value.is_a?(Hash) && value[:error]
27
- # attrs = { "tool.name" => name.to_s }
28
- # if error
29
- # attrs["tool.status"] = "error"
30
- # attrs["tool.error"] = value[:error].to_s
31
- # else
32
- # attrs["tool.status"] = "ok"
33
- # end
34
- # span.add_event("tool_result", attributes: attrs)
35
- # end
36
- #end
37
-
38
- @app.call(env)
39
- end
40
- end
41
- end
42
- end
43
-
44
- __END__
45
-
46
- describe "brute/middleware/075_otel_tool_results" do
47
- # not implemented
48
- end