brute 6.0.2 → 6.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4197c46dd001a197da4f187e2729c21dc462355def0d458deb3a6341196fec67
4
- data.tar.gz: 27453e69fbeff9fa2958502ea366843466c9347335127f593b76f2cd60ea1629
3
+ metadata.gz: a640b2c8ee7c0ca3d79e5618b3026494e48115c4bc38799e2d1ba1ca1acc968f
4
+ data.tar.gz: 66fed78b88d735cc528669e08dca7edbebfd1e6ba712f079fde45d268c9a5ec2
5
5
  SHA512:
6
- metadata.gz: bcb85717124bd3b7f4272eb7809a51d240adbcb1ad27aff3e5b67de53b4b1088ba14d7becdeb658b349f350f5247c82119c6e024799ab36ffc38f7fd74630ddf
7
- data.tar.gz: 46dbd653dbde8e6edea254440b6b793d99766567402164181e7c4b9b780ed28aa85602a6ae8033febedb5858a75659278b69878782beab0a10d30ee9d8ec7668
6
+ metadata.gz: a5374a6f3194a224069e812c3d49ef0748ca1e6b03d13ea0ef5e645103e6d67ec08f92f4702a86bce78733844c59472a571b9012268759e1a92693ce61b12b83
7
+ data.tar.gz: aab38ce15e5fec92937cdef94a9350e880cf0ab52bce6e1706ed586fc720c204b94a8ca98e4021dce069a0ae058447194e615d6c8c29990debcd30f88a0a6907
@@ -46,7 +46,7 @@ module Brute
46
46
  env.emit_trace do |env|
47
47
  env.emit(LLM_START_EVENT)
48
48
 
49
- messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages])
49
+ messages = Brute::MessageTransport::OpenRouter.dump_all(env[:messages], model: options(env).model)
50
50
 
51
51
  ::OpenRouter::Client.new(**@config).then do |client|
52
52
  response = nil
data/lib/brute/hooks.rb CHANGED
@@ -70,7 +70,9 @@ module Brute
70
70
  # The trace this one was opened inside, if any: emit_trace wraps, so the
71
71
  # thing wrapped is the call that was already running.
72
72
  def parent
73
- __getobj__ if __getobj__.is_a?(Trace)
73
+ if __getobj__.is_a?(Trace)
74
+ __getobj__
75
+ end
74
76
  end
75
77
 
76
78
  def emit(event, *extras, &work) = @hooks.emit(
@@ -28,6 +28,9 @@ module Brute
28
28
  # a user message — consecutive tool results are folded into one user
29
29
  # turn so roles keep alternating.
30
30
  class Anthropic < MessageTransport
31
+ # Whose signatures these are. OpenRouter names the same format when it
32
+ # proxies Claude, so thinking that came back through it goes home.
33
+ FORMAT = "anthropic-claude-v1"
31
34
  # The :system messages' text, for the top-level `system_:` parameter.
32
35
  def self.system_text(messages)
33
36
  messages.select { |m| m.role == :system }.map(&:content).join("\n\n")
@@ -35,7 +38,7 @@ module Brute
35
38
 
36
39
  # Brute log -> the `messages:` array. Drops :system messages (see
37
40
  # .system_text) and folds consecutive :tool results into one user turn.
38
- def self.dump_all(messages)
41
+ def self.dump_all(messages, model: nil)
39
42
  chat = messages.reject { |m| m.role == :system }
40
43
 
41
44
  chat.chunk_while { |a, b| a.role == :tool && b.role == :tool }.map do |group|
@@ -48,26 +51,75 @@ module Brute
48
51
  end
49
52
 
50
53
  # Brute::Message -> an Anthropic message param hash.
51
- def self.dump(message)
54
+ def self.dump(message, model: nil)
52
55
  case message.role
53
56
  when :tool
54
57
  { role: "user", content: [tool_result_block(message)] }
55
58
  when :assistant
56
59
  if message.tool_call?
57
- blocks = []
60
+ blocks = thinking_blocks(message)
58
61
  unless message.content.to_s.empty?
59
62
  blocks << { type: "text", text: message.content }
60
63
  end
61
64
  blocks += message.tool_calls.map { |tc| { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments } }
62
65
  { role: "assistant", content: blocks }
63
66
  else
64
- { role: "assistant", content: message.content }
67
+ blocks = thinking_blocks(message)
68
+ unless message.content.to_s.empty?
69
+ blocks << { type: "text", text: message.content }
70
+ end
71
+
72
+ # Anthropic rejects an empty text block, so a message with nothing
73
+ # left to send goes as plain content rather than an empty one.
74
+ if blocks.empty?
75
+ { role: "assistant", content: message.content }
76
+ else
77
+ { role: "assistant", content: blocks }
78
+ end
65
79
  end
66
80
  else
67
81
  { role: message.role.to_s, content: message.content }
68
82
  end
69
83
  end
70
84
 
85
+ # Thinking goes back first and unmodified. A signature Anthropic did
86
+ # not issue cannot be verified -- a conversation replayed from another
87
+ # provider carries thinking that is not Anthropic's -- and an
88
+ # unverifiable block is a 400, so it sends none rather than a bad one.
89
+ # Anthropic has two kinds of thinking block and no third: a summary is
90
+ # a precis of thinking the provider would not show, and the signature
91
+ # was issued over the thinking rather than the precis. Sent as a
92
+ # thinking block it is a block that does not verify, so a sequence
93
+ # carrying one is refused whole -- the same as a foreign format.
94
+ def self.expressible?(reasoning)
95
+ reasoning.blocks.all? { |block| block.type == :text || block.opaque? }
96
+ end
97
+
98
+ def self.thinking_blocks(message)
99
+ if message.reasoning&.signed_by?(FORMAT) && expressible?(message.reasoning)
100
+ message.reasoning.blocks.map do |block|
101
+ if block.opaque?
102
+ { type: "redacted_thinking", data: block.signature }
103
+ else
104
+ { type: "thinking", thinking: block.text.to_s, signature: block.signature }
105
+ end
106
+ end
107
+ else
108
+ []
109
+ end
110
+ end
111
+
112
+ # A thinking block, or the redacted one that stands in for it when the
113
+ # provider will not show its working. Both go back whole.
114
+ def self.thinking_block(block)
115
+ case block.type
116
+ when :thinking
117
+ Brute::Reasoning::Block.new(type: :text, text: block.thinking, signature: block.signature, format: FORMAT)
118
+ when :redacted_thinking
119
+ Brute::Reasoning::Block.new(type: :encrypted, signature: block.data, format: FORMAT)
120
+ end
121
+ end
122
+
71
123
  def self.tool_result_block(message)
72
124
  { type: "tool_result", tool_use_id: message.tool_call_id, content: message.content.to_s }
73
125
  end
@@ -80,6 +132,12 @@ module Brute
80
132
  blocks = message.content
81
133
 
82
134
  text = blocks.select { |b| b.type == :text }.map(&:text).join
135
+
136
+ # Thinking is its own block, its signature is what the API checks
137
+ # when it comes back, and redacted thinking is a block whose payload
138
+ # is not ours to read. All of them are kept, in order: what goes back
139
+ # has to match what the model produced.
140
+ thinking = blocks.filter_map { |b| self.class.thinking_block(b) }
83
141
  tool_calls = blocks.select { |b| b.type == :tool_use }.map do |b|
84
142
  if b.input.respond_to?(:to_h)
85
143
  arguments = b.input.to_h
@@ -97,6 +155,7 @@ module Brute
97
155
  role: :assistant,
98
156
  content: text,
99
157
  tool_calls: tool_calls,
158
+ reasoning: Brute::Reasoning.build(blocks: thinking),
100
159
  )
101
160
  end
102
161
  end
@@ -140,6 +199,52 @@ describe "brute/message_transport/anthropic" do
140
199
  dumped.last[:content].map { |b| b[:tool_use_id] }.should == %w[tc1 tc2]
141
200
  end
142
201
 
202
+ it "sends signed thinking back first, ahead of the text" do
203
+ # The sequence is part of what is verified, so thinking leads.
204
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
205
+ blocks: [{ type: :text, text: "let me think", signature: "sig-1", format: "anthropic-claude-v1" }],
206
+ })
207
+
208
+ Brute::MessageTransport::Anthropic.dump(m)[:content].should == [
209
+ { type: "thinking", thinking: "let me think", signature: "sig-1" },
210
+ { type: "text", text: "done" },
211
+ ]
212
+ end
213
+
214
+ it "sends an opaque payload home as redacted_thinking, under either name" do
215
+ # Anthropic calls it redacted_thinking; OpenRouter calls the same Claude
216
+ # payload reasoning.encrypted. A conversation logged through either one
217
+ # goes back the same way.
218
+ [:redacted, :encrypted].each do |type|
219
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
220
+ blocks: [{ type: type, signature: "blob", format: "anthropic-claude-v1" }],
221
+ })
222
+
223
+ Brute::MessageTransport::Anthropic.dump(m)[:content].first
224
+ .should == { type: "redacted_thinking", data: "blob" }
225
+ end
226
+ end
227
+
228
+ it "sends no thinking at all rather than a block it cannot vouch for" do
229
+ # An unverifiable block is a 400, and no thinking beats a failed request:
230
+ # a signature Anthropic did not issue, and a block carrying none where it
231
+ # requires one, are both refused -- and refused whole.
232
+ foreign = Brute::Message.new(role: :assistant, content: "done", reasoning: {
233
+ blocks: [{ type: :text, text: "thought", signature: "sig-1", format: "openai-responses-v1" }],
234
+ })
235
+
236
+ partly_signed = Brute::Message.new(role: :assistant, content: "done", reasoning: {
237
+ blocks: [
238
+ { type: :text, text: "step one", signature: "sig-1", format: "anthropic-claude-v1" },
239
+ { type: :text, text: "step two", format: "anthropic-claude-v1" },
240
+ ],
241
+ })
242
+
243
+ [foreign, partly_signed].each do |m|
244
+ Brute::MessageTransport::Anthropic.dump(m)[:content].should == [{ type: "text", text: "done" }]
245
+ end
246
+ end
247
+
143
248
  it "wraps text and tool_use blocks into one assistant message" do
144
249
  response = fake_response.new(content: [
145
250
  fake_text_block.new(type: :text, text: "running ls"),
@@ -27,7 +27,7 @@ module Brute
27
27
  # `original_tool_calls` extra (the provider wire format); tool results
28
28
  # become an LLM::Function::Return so llm.rb's request adapters emit
29
29
  # them correctly.
30
- def self.dump(message)
30
+ def self.dump(message, model: nil)
31
31
  case message.role
32
32
  when :tool
33
33
  ::LLM::Message.new(
@@ -67,8 +67,16 @@ module Brute
67
67
  role: message.role,
68
68
  content: message.content.to_s,
69
69
  tool_calls: tool_calls,
70
+ reasoning: reasoning(message),
70
71
  )
71
72
  end
73
+
74
+ # llm.rb hangs it off the message's extras as reasoning_content.
75
+ def reasoning(message)
76
+ if message.respond_to?(:reasoning_content)
77
+ Brute::Reasoning.build(text: message.reasoning_content)
78
+ end
79
+ end
72
80
  end
73
81
  end
74
82
  end
@@ -13,14 +13,90 @@ module Brute
13
13
  #
14
14
  # require "open_router"
15
15
  class OpenRouter < MessageTransport
16
+ EmptyCompletion = Class.new(StandardError)
16
17
 
17
18
  # What the provider reported about this call — the transport knows its
18
19
  # own library's shape, so it knows which detector to ask.
19
20
  def self.usage_metrics(response)
20
21
  Brute::UsageDetection::OpenRouter.detect(response)
21
22
  end
22
- def self.dump(message)
23
- message.to_h
23
+ # Reasoning goes back up with the message it belongs to: a reasoning
24
+ # model that called a tool has to see its own thinking on the next pass,
25
+ # or it answers the tool call without the thinking that asked for it.
26
+ #
27
+ # OpenRouter takes it either as the plain `reasoning` string or as the
28
+ # whole `reasoning_details` array, which is what carries the signature.
29
+ # The sequence has to match what the model produced -- it may not be
30
+ # rearranged or modified -- so what came back is what goes out.
31
+ def self.dump(message, model: nil)
32
+ message.to_h.tap do |hash|
33
+ # What Message#to_h holds is brute's own shape, which is not the
34
+ # wire's: whatever goes out under these keys is put there here.
35
+ hash.delete(:reasoning)
36
+
37
+ if message.reasoning
38
+ # An encrypted payload has no prose in it, and an empty string is
39
+ # not reasoning: the details carry it, or nothing does.
40
+ unless message.reasoning.text.empty?
41
+ hash[:reasoning] = message.reasoning.text
42
+ end
43
+
44
+ # The details go back whole, in order, or not at all -- the
45
+ # sequence has to match what the model produced. OpenRouter routes
46
+ # to many providers, so "signed" is not enough: a Claude signature
47
+ # is only good while the turn is still going to Claude. Switch
48
+ # model mid-conversation and the plaintext survives while the
49
+ # signatures, which the new provider cannot verify, do not.
50
+ # Whole or not at all: dropping an entry out of the middle is
51
+ # modifying the sequence, which is what the provider forbids. A
52
+ # plaintext-only reasoning has nothing signed in it, so no array
53
+ # is invented around it.
54
+ if message.reasoning.detailed? && message.reasoning.blocks.all? { |block| issued_by?(block, model) }
55
+ hash[:reasoning_details] = message.reasoning.blocks.map { |block| detail(block) }
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # OpenRouter names both sides after the provider: a model id reads
62
+ # "anthropic/claude-sonnet-4", a format "anthropic-claude-v1". Asked
63
+ # about no model in particular, the format is taken at its word.
64
+ def self.issued_by?(block, model)
65
+ vendor = model.to_s.split("/").first
66
+
67
+ if block.format.nil?
68
+ # A signature that names no provider cannot be attributed to this
69
+ # one; unsigned there is nothing to attribute and it passes.
70
+ !block.signed?
71
+ elsif vendor.nil? || vendor == model.to_s
72
+ # No model, or an id that names no vendor: nothing to contradict the
73
+ # format, so it is taken at its word rather than dropped silently.
74
+ true
75
+ else
76
+ block.format.split("-").first == vendor
77
+ end
78
+ end
79
+
80
+ # Each type names its own payload: reasoning text carries `text` and the
81
+ # `signature` that verifies it, a summary carries `summary`, and an
82
+ # encrypted item carries `data`. Written under the wrong key the payload
83
+ # is simply lost, which for an encrypted item is the whole of it.
84
+ def self.detail(block)
85
+ named = {
86
+ type: "reasoning.#{block.type}",
87
+ format: block.format,
88
+ id: block.id,
89
+ index: block.index,
90
+ }
91
+
92
+ case block.type
93
+ when :encrypted
94
+ named.merge(data: block.signature).compact
95
+ when :summary
96
+ named.merge(summary: block.text).compact
97
+ else
98
+ named.merge(text: block.text, signature: block.signature).compact
99
+ end
24
100
  end
25
101
 
26
102
  # An OpenRouter::Response's messages (one per choice; in practice
@@ -47,8 +123,18 @@ module Brute
47
123
 
48
124
  case hash
49
125
  in { role: (:system | :user | :assistant | :tool) }
50
- # Slice away provider extras (refusal, reasoning, model, ...)
51
- # that Brute::Message doesn't know.
126
+ # A completion that answered with nothing is not an answer. The
127
+ # provider was paid for it and something came back -- reasoning,
128
+ # a refusal, a field this does not know -- and appending it as an
129
+ # empty assistant message loses the turn quietly: the loop stops
130
+ # because it is not a tool result, and there is no reply in it.
131
+ if hash[:role] == :assistant && hash[:content].to_s.strip.empty? && Array(hash[:tool_calls]).empty? && reasoning(hash).nil?
132
+ raise EmptyCompletion, "the provider answered with no content and no tool calls: #{message.inspect}"
133
+ end
134
+
135
+ # Slice away provider extras (refusal, model, ...) that
136
+ # Brute::Message doesn't know. Reasoning is not one of them: it is
137
+ # the model's own thinking and it goes back up with the message.
52
138
  Brute::Message.new(
53
139
  **hash.slice(
54
140
  :role,
@@ -56,12 +142,48 @@ module Brute
56
142
  :tool_calls,
57
143
  :tool_call_id,
58
144
  ),
145
+ reasoning: reasoning(hash),
59
146
  )
60
147
  else
61
148
  raise "Unrecognised message format #{message.inspect}"
62
149
  end
63
150
  end
64
151
 
152
+ # reasoning_details is the sequence the model produced, each detail
153
+ # naming its own format; `reasoning` is the same thing as plaintext.
154
+ def reasoning(hash)
155
+ details = Array(hash[:reasoning_details]).map { |detail| detail.to_h.transform_keys(&:to_sym) }
156
+
157
+ if details.empty?
158
+ Brute::Reasoning.build(text: hash[:reasoning])
159
+ else
160
+ Brute::Reasoning.build(blocks: details.map { |detail| block(detail) })
161
+ end
162
+ end
163
+
164
+ def answering_model
165
+ if @result.respond_to?(:model)
166
+ @result.model
167
+ end
168
+ end
169
+
170
+ # The type names the payload's key, so it says which one to read.
171
+ def block(detail)
172
+ type = detail[:type].to_s.split(".").last.to_s
173
+
174
+ Brute::Reasoning::Block.new(
175
+ type: type.empty? ? :text : type.to_sym,
176
+ text: detail[:text] || detail[:summary],
177
+ signature: detail[:signature] || detail[:data],
178
+ # A provider that names no format still answered as some model,
179
+ # and an unattributed signature is one nobody may replay -- so the
180
+ # model that produced it stands in for the format it did not send.
181
+ format: detail[:format] || answering_model,
182
+ id: detail[:id],
183
+ index: detail[:index],
184
+ )
185
+ end
186
+
65
187
  # An OpenAI-wire tool call ({ id:, type:, function: { name:, arguments: JSON } })
66
188
  # -> the flat { id:, name:, arguments: Hash } Brute::Message understands.
67
189
  def wrap_tool_call(tool_call)
@@ -105,6 +227,117 @@ describe "brute/message_transport/open_router" do
105
227
  out.first.content.should == "hi there"
106
228
  end
107
229
 
230
+ it "keeps the model's reasoning, and refuses a completion that answered with nothing at all" do
231
+ # Reasoning is the model's own thinking, and it goes back up with the
232
+ # message: a reasoning model that called a tool has to see it on the next
233
+ # pass. Dropping it left an empty assistant message and a lost turn.
234
+ thought = Struct.new(:choices).new([{ "message" => { "role" => "assistant", "content" => "", "reasoning" => "thought about it" } }])
235
+
236
+ reasoned = Brute::MessageTransport::OpenRouter.new(thought).wrap_each.to_a.first
237
+ reasoned.reasoning.text.should == "thought about it"
238
+ Brute::MessageTransport::OpenRouter.dump(reasoned)[:reasoning].should == "thought about it"
239
+
240
+ # Signed thinking goes back as the details array, unmodified, which is
241
+ # what carries the signature the provider checks.
242
+ # A detail that names no format is stamped with the model that answered,
243
+ # because a signature nobody claims is one nobody may replay.
244
+ signed = Struct.new(:choices, :model).new([{ "message" => {
245
+ "role" => "assistant", "content" => "",
246
+ "reasoning_details" => [{ "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1" }],
247
+ } }], "anthropic/claude-sonnet-4")
248
+
249
+ back = Brute::MessageTransport::OpenRouter.dump(Brute::MessageTransport::OpenRouter.new(signed).wrap_each.to_a.first)
250
+ back[:reasoning].should == "step by step"
251
+ back[:reasoning_details].should == [
252
+ { type: "reasoning.text", format: "anthropic/claude-sonnet-4", text: "step by step", signature: "sig-1" },
253
+ ]
254
+
255
+ # A tool call is an answer, even with nothing said alongside it.
256
+ calling = Struct.new(:choices).new([
257
+ { "message" => {
258
+ "role" => "assistant", "content" => nil,
259
+ "tool_calls" => [{ "id" => "tc1", "type" => "function", "function" => { "name" => "shell", "arguments" => "{}" } }],
260
+ } },
261
+ ])
262
+
263
+ Brute::MessageTransport::OpenRouter.new(calling).wrap_each.to_a.first.tool_call?.should.be.true
264
+
265
+ # Nothing said, nothing thought, nothing called: the provider was paid for
266
+ # an answer and there is none, which is an error rather than a message.
267
+ empty = Struct.new(:choices).new([{ "message" => { "role" => "assistant", "content" => "", "refusal" => nil } }])
268
+
269
+ lambda { Brute::MessageTransport::OpenRouter.new(empty).wrap_each.to_a }
270
+ .should.raise(Brute::MessageTransport::OpenRouter::EmptyCompletion)
271
+ end
272
+
273
+ it "round-trips every detail type under its own payload key" do
274
+ # OpenRouter names each payload after its type: a summary's prose is
275
+ # `summary`, an encrypted item's payload is `data`, and only reasoning
276
+ # text carries a `signature`. Read or written under the wrong key, the
277
+ # payload is simply lost -- and an encrypted item is the case where the
278
+ # plaintext alone is not enough.
279
+ details = [
280
+ { "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1",
281
+ "format" => "anthropic-claude-v1", "id" => "r-1", "index" => 0 },
282
+ { "type" => "reasoning.summary", "summary" => "weighed the constraints",
283
+ "format" => "anthropic-claude-v1", "id" => "r-2", "index" => 1 },
284
+ { "type" => "reasoning.encrypted", "data" => "b64blob",
285
+ "format" => "anthropic-claude-v1", "id" => "r-3", "index" => 2 },
286
+ ]
287
+
288
+ response = Struct.new(:choices).new([
289
+ { "message" => { "role" => "assistant", "content" => "", "reasoning_details" => details } },
290
+ ])
291
+
292
+ wrapped = Brute::MessageTransport::OpenRouter.new(response).wrap_each.to_a.first
293
+ wrapped.reasoning.text.should == "step by step\nweighed the constraints"
294
+
295
+ back = Brute::MessageTransport::OpenRouter.dump(wrapped, model: "anthropic/claude-sonnet-4")
296
+ back[:reasoning_details].should == details.map { |detail| detail.transform_keys(&:to_sym) }
297
+ end
298
+
299
+ it "sends an opaque payload out as reasoning.encrypted, under either name" do
300
+ # The same Claude payload is :redacted when Anthropic logged it and
301
+ # :encrypted when OpenRouter did. Either way it is not text, and its
302
+ # payload belongs under data.
303
+ [:redacted, :encrypted].each do |type|
304
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
305
+ blocks: [{ type: type, signature: "b64blob", format: "anthropic-claude-v1" }],
306
+ })
307
+
308
+ Brute::MessageTransport::OpenRouter.dump(m, model: "anthropic/claude-sonnet-4")[:reasoning_details]
309
+ .should == [{ type: "reasoning.encrypted", data: "b64blob", format: "anthropic-claude-v1" }]
310
+ end
311
+ end
312
+
313
+ it "sends the details sequence whole, or not at all" do
314
+ # "The entire sequence of consecutive reasoning blocks must match the
315
+ # outputs generated by the model; you cannot rearrange or modify the
316
+ # sequence." A subset of it is a modified sequence, so a turn going to
317
+ # another provider keeps the plaintext and drops the array entire.
318
+ response = Struct.new(:choices).new([
319
+ { "message" => { "role" => "assistant", "content" => "done", "reasoning_details" => [
320
+ { "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1",
321
+ "format" => "anthropic-claude-v1" },
322
+ ] } },
323
+ ])
324
+
325
+ wrapped = Brute::MessageTransport::OpenRouter.new(response).wrap_each.to_a.first
326
+
327
+ elsewhere = Brute::MessageTransport::OpenRouter.dump(wrapped, model: "openai/gpt-5")
328
+ elsewhere[:reasoning].should == "step by step"
329
+ elsewhere.key?(:reasoning_details).should.be.false
330
+ end
331
+
332
+ it "does not invent a details sequence around plaintext reasoning" do
333
+ # A sequence Brute made up is not one the model produced.
334
+ plain = Brute::Message.new(role: :assistant, content: "done", reasoning: "just thinking")
335
+
336
+ back = Brute::MessageTransport::OpenRouter.dump(plain, model: "anthropic/claude-sonnet-4")
337
+ back[:reasoning].should == "just thinking"
338
+ back.key?(:reasoning_details).should.be.false
339
+ end
340
+
108
341
  it "unwraps OpenAI-wire tool calls and parses their JSON arguments" do
109
342
  fake_response = Struct.new(:choices).new([
110
343
  { "message" => {
@@ -22,7 +22,11 @@ module Brute
22
22
  # Brute::MessageTransport::OpenAI.wrap_each(response) { |m| env[:messages] << m }
23
23
  class OpenAI < MessageTransport
24
24
  # Brute::Message -> a chat.completions message param hash.
25
- def self.dump(message)
25
+ # Chat completions has no field for reasoning -- neither
26
+ # ChatCompletionMessage nor the assistant message param carries one --
27
+ # so nothing is sent back here. Reasoning items belong to the Responses
28
+ # API, which this transport does not speak.
29
+ def self.dump(message, model: nil)
26
30
  case message.role
27
31
  when :tool
28
32
  { role: "tool", tool_call_id: message.tool_call_id, content: message.content.to_s }
@@ -15,7 +15,7 @@ module Brute
15
15
  end
16
16
 
17
17
  # Brute::Message -> RubyLLM::Message (tool calls as ruby_llm's id-keyed hash).
18
- def self.dump(message)
18
+ def self.dump(message, model: nil)
19
19
  tool_calls = message.tool_calls&.to_h do |tc|
20
20
  [tc.id, ::RubyLLM::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)]
21
21
  end
@@ -25,9 +25,22 @@ module Brute
25
25
  content: message.content,
26
26
  tool_calls: tool_calls,
27
27
  tool_call_id: message.tool_call_id,
28
+ thinking: thinking(message),
28
29
  )
29
30
  end
30
31
 
32
+ # RubyLLM::Thinking.build answers nil when there is nothing to say, and
33
+ # takes the signature back exactly as it came. The signature lives on
34
+ # the block that was signed, not on the sequence.
35
+ def self.thinking(message)
36
+ if message.reasoning
37
+ ::RubyLLM::Thinking.build(
38
+ text: message.reasoning.text,
39
+ signature: message.reasoning.blocks.find(&:signed?)&.signature,
40
+ )
41
+ end
42
+ end
43
+
31
44
  private
32
45
 
33
46
  # RubyLLM::Message -> Brute::Message.
@@ -52,8 +65,29 @@ module Brute
52
65
  content: message.content&.to_s, # Preserves nil safely
53
66
  tool_calls: tool_calls,
54
67
  tool_call_id: message.tool_call_id,
68
+ reasoning: reasoning(message),
55
69
  )
56
70
  end
71
+
72
+ def answering_model(message)
73
+ if message.respond_to?(:model)
74
+ message.model
75
+ end
76
+ end
77
+
78
+ # RubyLLM models it as a Thinking, carrying the text and the
79
+ # provider's signature for it. It speaks to many providers, so the
80
+ # model that issued the signature is what stamps the block: unstamped,
81
+ # it would be offered to whichever provider asked next.
82
+ def reasoning(message)
83
+ if message.respond_to?(:thinking) && message.thinking
84
+ Brute::Reasoning.build(
85
+ text: message.thinking.text,
86
+ signature: message.thinking.signature,
87
+ format: answering_model(message),
88
+ )
89
+ end
90
+ end
57
91
  end
58
92
  end
59
93
  end
@@ -75,6 +109,34 @@ describe "brute/message_transport/ruby_llm" do
75
109
  out.first.content.should == "hi"
76
110
  end
77
111
 
112
+ it "sends thinking back with the signature its block carried" do
113
+ # ruby_llm models thinking as one text and one signature, so a sequence of
114
+ # blocks flattens to its text and the signature of the block carrying one.
115
+ require "ruby_llm"
116
+
117
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
118
+ blocks: [{ type: :text, text: "thought it through", signature: "sig-1" }],
119
+ })
120
+
121
+ thinking = Brute::MessageTransport::RubyLLM.dump(m).thinking
122
+ thinking.text.should == "thought it through"
123
+ thinking.signature.should == "sig-1"
124
+ end
125
+
126
+ it "stamps the model that signed the thinking it wrapped" do
127
+ # ruby_llm speaks to many providers, so a signature it hands back is only
128
+ # good against the model that issued it. Unstamped, it would be offered to
129
+ # whichever provider asked next.
130
+ thinking = Struct.new(:text, :signature, keyword_init: true)
131
+ m = fake_message.new(role: :assistant, content: "done")
132
+ m.define_singleton_method(:thinking) { thinking.new(text: "thought", signature: "sig-1") }
133
+ m.define_singleton_method(:model) { "claude-sonnet-4" }
134
+
135
+ block = Brute::MessageTransport::RubyLLM.new(m).wrap_each.to_a.first.reasoning.blocks.first
136
+ block.signature.should == "sig-1"
137
+ block.format.should == "claude-sonnet-4"
138
+ end
139
+
78
140
  it "wraps ruby_llm's id-keyed tool_calls hash into a flat list" do
79
141
  tc = fake_tool_call.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
80
142
  m = fake_message.new(role: :assistant, content: "", tool_calls: { "tc1" => tc })
@@ -3,7 +3,9 @@ module Brute
3
3
  class RubyOpenAI < MessageTransport
4
4
 
5
5
  # Brute::Message -> ruby-openai Hash payload
6
- def self.dump(message)
6
+ # An OpenAI-compatible proxy may report reasoning, and it is read on the
7
+ # way in, but the chat completions wire has no field to send it back on.
8
+ def self.dump(message, model: nil)
7
9
  {
8
10
  role: message.role.to_s,
9
11
  }.tap do |payload|
@@ -67,13 +69,15 @@ module Brute
67
69
  role: :assistant,
68
70
  content: hash[:content],
69
71
  tool_calls: tool_calls,
72
+ reasoning: hash[:reasoning],
70
73
  )
71
74
 
72
75
  # Branch 3: Standard Assistant text message
73
76
  in { role: "assistant" }
74
77
  Brute::Message.new(
75
- role: :assistant,
76
- content: hash[:content],
78
+ role: :assistant,
79
+ content: hash[:content],
80
+ reasoning: hash[:reasoning],
77
81
  )
78
82
 
79
83
  else
@@ -12,13 +12,17 @@ module Brute
12
12
  end
13
13
 
14
14
  # Outbound: one Brute::Message in the library's format. Identity here.
15
- def self.dump(message)
15
+ #
16
+ # `model:` is what the turn is about to be sent to. A transport that puts
17
+ # signed reasoning back on the wire needs it: a signature is only valid
18
+ # against the provider that issued it, and the log outlives any one model.
19
+ def self.dump(message, model: nil)
16
20
  message
17
21
  end
18
22
 
19
23
  # Outbound: the whole log in the library's format.
20
- def self.dump_all(messages)
21
- messages.map { |message| dump(message) }
24
+ def self.dump_all(messages, model: nil)
25
+ messages.map { |message| dump(message, model: model) }
22
26
  end
23
27
 
24
28
  # Inbound: what the provider reported about this call, as a
@@ -22,13 +22,117 @@ module Brute
22
22
  # Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" }),
23
23
  # ])
24
24
  # Brute::Message.new(role: :tool, content: "result", tool_call_id: "tc1")
25
+ # What the model thought before it answered, and the provider's proof that
26
+ # it did.
27
+ #
28
+ # A signature is only meaningful to the provider that issued it: Anthropic
29
+ # verifies its own and rejects a modified block with a 400, and OpenRouter
30
+ # names the format ("anthropic-claude-v1", "openai-responses-v1") because
31
+ # the details only mean anything against the provider that produced them.
32
+ # The log is provider-agnostic and a conversation can be replayed against
33
+ # anyone, so what was signed carries who signed it -- a transport sends the
34
+ # blocks back only when they are its own, and sends the text alone when they
35
+ # are not.
36
+ #
37
+ # The blocks are a sequence, in the order the model produced them, because
38
+ # that is what both providers ask for: "passed back unmodified and in their
39
+ # original order" (Anthropic), "the entire sequence of consecutive reasoning
40
+ # blocks must match the outputs generated by the model" (OpenRouter).
41
+ Reasoning = Data.define(:blocks) do
42
+ def self.build(text: nil, signature: nil, format: nil, blocks: nil)
43
+ blocks = Array(blocks).map { |block| block.is_a?(Reasoning::Block) ? block : Reasoning::Block.new(**block.to_h.transform_keys(&:to_sym)) }
44
+
45
+ if blocks.empty? && !(text.to_s.empty? && signature.to_s.empty?)
46
+ blocks = [ Reasoning::Block.new(text: text, signature: signature, format: format) ]
47
+ end
48
+
49
+ unless blocks.empty?
50
+ new(blocks: blocks)
51
+ end
52
+ end
53
+
54
+ # What the model thought, for anyone reading rather than replaying.
55
+ def text = blocks.filter_map(&:text).join("\n")
56
+
57
+ # Whether these blocks may be sent back to this provider as they are. A
58
+ # signature the provider did not issue is worse than no signature: it is
59
+ # a rejected request.
60
+ def signed_by?(format) = !format.nil? && blocks.any? && issued_by?(format) && blocks.all?(&:signed?)
61
+
62
+ # Whether this is the sequence the model emitted rather than one built
63
+ # around a plaintext string. Only the former may be replayed: a provider
64
+ # checks what comes back against what it produced, and prose lifted out of
65
+ # a `reasoning` field was never a sequence at all.
66
+ def detailed? = blocks.any? { |block| block.signed? || block.format || block.id }
67
+
68
+ # Whether every block came from this provider. The sequence goes back
69
+ # whole or not at all, so one foreign block disqualifies all of them.
70
+ #
71
+ # A block that names no provider is nobody's to vouch for: unsigned there
72
+ # is nothing to vouch for and it passes, but signed it is a signature that
73
+ # cannot be attributed, and offering one the provider did not issue is a
74
+ # rejected request rather than an unverified one.
75
+ def issued_by?(format)
76
+ blocks.all? do |block|
77
+ if block.format.nil?
78
+ !block.signed?
79
+ else
80
+ format.nil? || block.format == format
81
+ end
82
+ end
83
+ end
84
+
85
+ def to_h(...) = { blocks: blocks.map(&:to_h) }
86
+ end
87
+
88
+ class Reasoning
89
+ # One block of thinking. :text is what the model thought and :summary is
90
+ # the provider's precis of it; :encrypted is a payload that is not ours to
91
+ # read and matters only in that it goes back whole.
92
+ #
93
+ # Anthropic calls that payload redacted_thinking and OpenRouter calls the
94
+ # same thing reasoning.encrypted, so :redacted is taken as a spelling of
95
+ # :encrypted rather than a second kind -- a conversation logged through
96
+ # one and replayed to the other has to arrive as what it is.
97
+ Block = Data.define(
98
+ :type,
99
+ :text,
100
+ :signature,
101
+ :format,
102
+ :id,
103
+ :index,
104
+ ) do
105
+ TYPES = { redacted: :encrypted }.freeze
106
+
107
+ def initialize(type: :text, text: nil, signature: nil, format: nil, id: nil, index: nil)
108
+ type = type.to_sym
109
+
110
+ super(
111
+ type: TYPES.fetch(type, type),
112
+ text: text,
113
+ signature: signature,
114
+ format: format,
115
+ id: id,
116
+ index: index,
117
+ )
118
+ end
119
+
120
+ def opaque? = type == :encrypted
121
+
122
+ def signed? = !signature.to_s.empty?
123
+
124
+ def to_h(...) = super.compact
125
+ end
126
+ end
127
+
25
128
  Message = Data.define(
26
129
  :role,
27
130
  :content,
28
131
  :tool_calls,
29
132
  :tool_call_id,
133
+ :reasoning,
30
134
  ) do
31
- def initialize(role:, content: nil, tool_calls: nil, tool_call_id: nil)
135
+ def initialize(role:, content: nil, tool_calls: nil, tool_call_id: nil, reasoning: nil)
32
136
  formatted_calls = tool_calls&.map do |tc|
33
137
  tc.is_a?(ToolCall) ? tc : ToolCall.new(**tc.to_h.transform_keys(&:to_sym))
34
138
  end
@@ -37,7 +141,8 @@ module Brute
37
141
  role: role.to_sym,
38
142
  content: content,
39
143
  tool_calls: formatted_calls,
40
- tool_call_id: tool_call_id
144
+ tool_call_id: tool_call_id,
145
+ reasoning: coerce(reasoning)
41
146
  )
42
147
  end
43
148
 
@@ -50,8 +155,23 @@ module Brute
50
155
  if tool_calls
51
156
  hash[:tool_calls] = tool_calls.map(&:to_h)
52
157
  end
158
+ if reasoning
159
+ hash[:reasoning] = reasoning.to_h
160
+ end
53
161
  hash.compact
54
162
  end
163
+
164
+ private
165
+
166
+ # Reasoning read back off disk is a hash, and a provider that reports
167
+ # nothing but the text is still reporting reasoning.
168
+ def coerce(reasoning)
169
+ case reasoning
170
+ in nil | Reasoning then reasoning
171
+ in String => text then Reasoning.build(text: text)
172
+ else Reasoning.build(**reasoning.to_h.transform_keys(&:to_sym))
173
+ end
174
+ end
55
175
  end
56
176
 
57
177
  # The in-memory conversation log is just a plain Array of Brute::Message.
@@ -129,4 +249,47 @@ describe "brute/messages" do
129
249
  it "to_h drops nil fields" do
130
250
  Brute::Message.new(role: :user, content: "hi").to_h.should == { role: :user, content: "hi" }
131
251
  end
252
+
253
+ it "round-trips reasoning through to_h" do
254
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
255
+ blocks: [{ type: :text, text: "thought", signature: "sig-1", format: "anthropic-claude-v1" }],
256
+ })
257
+
258
+ Brute::Message.new(**m.to_h).should == m
259
+ end
260
+
261
+ it "will not vouch for a sequence in which any block is unsigned" do
262
+ # A provider that verifies signatures rejects a thinking block without
263
+ # one, so one signed block among several does not make the sequence
264
+ # sendable: every block has to carry its own signature, or none goes.
265
+ mixed = Brute::Reasoning.build(blocks: [
266
+ { type: :text, text: "step one", signature: "sig-1", format: "anthropic-claude-v1" },
267
+ { type: :text, text: "step two", format: "anthropic-claude-v1" },
268
+ ])
269
+
270
+ mixed.signed_by?("anthropic-claude-v1").should.be.false
271
+ end
272
+
273
+ it "will not vouch for a signature that names no provider" do
274
+ # A block with a signature and no format cannot be attributed, and a
275
+ # signature the provider did not issue is a rejected request rather than
276
+ # an unverified one. Unsigned and unstamped is nobody's claim to make, so
277
+ # it passes.
278
+ unattributed = Brute::Reasoning.build(text: "thought", signature: "sig-1")
279
+
280
+ unattributed.issued_by?("anthropic-claude-v1").should.be.false
281
+ unattributed.signed_by?("anthropic-claude-v1").should.be.false
282
+
283
+ Brute::Reasoning.build(text: "just prose").issued_by?("anthropic-claude-v1").should.be.true
284
+ end
285
+
286
+ it "knows a sequence the model produced from one built around a string" do
287
+ # Only what the model actually emitted may be sent back as a sequence, so
288
+ # reasoning coerced from plaintext is not one to be replayed.
289
+ Brute::Reasoning.build(text: "just thinking").detailed?.should.be.false
290
+
291
+ Brute::Reasoning.build(blocks: [
292
+ { type: :summary, text: "considered it", format: "anthropic-claude-v1", id: "r-1" },
293
+ ]).detailed?.should.be.true
294
+ end
132
295
  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 = "6.0.2"
4
+ VERSION = "6.1.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brute
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.2
4
+ version: 6.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors