brute 6.0.2 → 6.1.1

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: a29376e6820e2ef4e6233073f2012ff95541027b64307956e141af222ece973e
4
+ data.tar.gz: 94c6351c5526249d3a62d200050180d4880f92bfbe259f3c97fabe5e5663160a
5
5
  SHA512:
6
- metadata.gz: bcb85717124bd3b7f4272eb7809a51d240adbcb1ad27aff3e5b67de53b4b1088ba14d7becdeb658b349f350f5247c82119c6e024799ab36ffc38f7fd74630ddf
7
- data.tar.gz: 46dbd653dbde8e6edea254440b6b793d99766567402164181e7c4b9b780ed28aa85602a6ae8033febedb5858a75659278b69878782beab0a10d30ee9d8ec7668
6
+ metadata.gz: a156cb8659d03cf076b359a2e14e7d462d456433aa5b3a169ec1fcef97b775132ede29b7b838246a2237f32cc5c7e927df513391df61c8e726d14f4101195a5c
7
+ data.tar.gz: '008a7f1f998bd58467fa13769318d32d27e1d6e92808619dd3a7baed8c3444b263e935a3a435a87405849bab3a7c2ae691a7f0866475b544e6210a61b4215a19'
@@ -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,13 @@ 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
+ # An assistant message this cannot put on the wire, because Anthropic
32
+ # takes no empty content and there is nothing in it to send.
33
+ EmptyMessage = Class.new(StandardError)
34
+
35
+ # Whose signatures these are. OpenRouter names the same format when it
36
+ # proxies Claude, so thinking that came back through it goes home.
37
+ FORMAT = "anthropic-claude-v1"
31
38
  # The :system messages' text, for the top-level `system_:` parameter.
32
39
  def self.system_text(messages)
33
40
  messages.select { |m| m.role == :system }.map(&:content).join("\n\n")
@@ -35,7 +42,7 @@ module Brute
35
42
 
36
43
  # Brute log -> the `messages:` array. Drops :system messages (see
37
44
  # .system_text) and folds consecutive :tool results into one user turn.
38
- def self.dump_all(messages)
45
+ def self.dump_all(messages, model: nil)
39
46
  chat = messages.reject { |m| m.role == :system }
40
47
 
41
48
  chat.chunk_while { |a, b| a.role == :tool && b.role == :tool }.map do |group|
@@ -48,26 +55,92 @@ module Brute
48
55
  end
49
56
 
50
57
  # Brute::Message -> an Anthropic message param hash.
51
- def self.dump(message)
58
+ def self.dump(message, model: nil)
52
59
  case message.role
53
60
  when :tool
54
- { role: "user", content: [tool_result_block(message)] }
61
+ { role: "user", content: [ tool_result_block(message) ] }
55
62
  when :assistant
56
- if message.tool_call?
57
- blocks = []
58
- unless message.content.to_s.empty?
59
- blocks << { type: "text", text: message.content }
60
- end
61
- blocks += message.tool_calls.map { |tc| { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments } }
62
- { role: "assistant", content: blocks }
63
- else
64
- { role: "assistant", content: message.content }
65
- end
63
+ { role: "assistant", content: assistant_content(message) }
66
64
  else
67
65
  { role: message.role.to_s, content: message.content }
68
66
  end
69
67
  end
70
68
 
69
+ # The assistant turn's content blocks, in the order the wire wants
70
+ # them: thinking first, because the sequence is part of what Anthropic
71
+ # verifies, then what was said, then what was called.
72
+ #
73
+ # Anthropic takes no empty content -- not an empty string, not an empty
74
+ # block, not an empty array -- so a message with nothing in it is not
75
+ # one this can express. It says so rather than sending a request that
76
+ # is already a 400.
77
+ def self.assistant_content(message)
78
+ blocks = thinking_blocks(message)
79
+
80
+ unless message.content.to_s.empty?
81
+ blocks << { type: "text", text: message.content }
82
+ end
83
+
84
+ if message.tool_call?
85
+ blocks += message.tool_calls.map do |tc|
86
+ { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments }
87
+ end
88
+ end
89
+
90
+ if blocks.empty?
91
+ raise EmptyMessage, "an assistant message with no content, no tool calls and no thinking Anthropic can take: #{message.inspect}"
92
+ end
93
+
94
+ blocks
95
+ end
96
+
97
+ # Whether every stored block has an Anthropic form. There are two of
98
+ # them and no third -- `thinking` and `redacted_thinking` -- and both
99
+ # carry a payload Anthropic issued and checks on the way back. So a
100
+ # block another provider signed, one carrying no signature at all, and
101
+ # a summary (which is a precis, and has no block of its own here) each
102
+ # have no form to take. The sequence goes whole or not at all, because
103
+ # what Anthropic verifies is the sequence, so one such block answers
104
+ # for the rest.
105
+ def self.expressible?(reasoning)
106
+ reasoning.blocks.any? && reasoning.blocks.all? do |block|
107
+ block.format == FORMAT && block.signed? && (block.type == :text || block.opaque?)
108
+ end
109
+ end
110
+
111
+ # The stored blocks as Anthropic content blocks, in order. Where the
112
+ # sequence has no Anthropic form there is nothing to send -- an
113
+ # unverifiable block is a 400, and no thinking beats a failed request.
114
+ def self.thinking_blocks(message)
115
+ if message.reasoning && expressible?(message.reasoning)
116
+ message.reasoning.blocks.map { |block| thinking_param(block) }
117
+ else
118
+ []
119
+ end
120
+ end
121
+
122
+ # One stored block as the content block Anthropic named it. The
123
+ # opaque payload rides under `data`, the readable one under `thinking`
124
+ # with the signature that verifies it.
125
+ def self.thinking_param(block)
126
+ if block.opaque?
127
+ { type: "redacted_thinking", data: block.signature }
128
+ else
129
+ { type: "thinking", thinking: block.text.to_s, signature: block.signature }
130
+ end
131
+ end
132
+
133
+ # A thinking block, or the redacted one that stands in for it when the
134
+ # provider will not show its working. Both go back whole.
135
+ def self.thinking_block(block)
136
+ case block.type
137
+ when :thinking
138
+ Brute::ReasoningBlock.new(type: :text, text: block.thinking, signature: block.signature, format: FORMAT)
139
+ when :redacted_thinking
140
+ Brute::ReasoningBlock.new(type: :encrypted, signature: block.data, format: FORMAT)
141
+ end
142
+ end
143
+
71
144
  def self.tool_result_block(message)
72
145
  { type: "tool_result", tool_use_id: message.tool_call_id, content: message.content.to_s }
73
146
  end
@@ -80,6 +153,12 @@ module Brute
80
153
  blocks = message.content
81
154
 
82
155
  text = blocks.select { |b| b.type == :text }.map(&:text).join
156
+
157
+ # Thinking is its own block, its signature is what the API checks
158
+ # when it comes back, and redacted thinking is a block whose payload
159
+ # is not ours to read. All of them are kept, in order: what goes back
160
+ # has to match what the model produced.
161
+ thinking = blocks.filter_map { |b| self.class.thinking_block(b) }
83
162
  tool_calls = blocks.select { |b| b.type == :tool_use }.map do |b|
84
163
  if b.input.respond_to?(:to_h)
85
164
  arguments = b.input.to_h
@@ -97,6 +176,7 @@ module Brute
97
176
  role: :assistant,
98
177
  content: text,
99
178
  tool_calls: tool_calls,
179
+ reasoning: Brute::Reasoning.build(blocks: thinking),
100
180
  )
101
181
  end
102
182
  end
@@ -140,6 +220,73 @@ describe "brute/message_transport/anthropic" do
140
220
  dumped.last[:content].map { |b| b[:tool_use_id] }.should == %w[tc1 tc2]
141
221
  end
142
222
 
223
+ it "sends signed thinking back first, ahead of the text" do
224
+ # The sequence is part of what is verified, so thinking leads.
225
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
226
+ blocks: [{ type: :text, text: "let me think", signature: "sig-1", format: "anthropic-claude-v1" }],
227
+ })
228
+
229
+ Brute::MessageTransport::Anthropic.dump(m)[:content].should == [
230
+ { type: "thinking", thinking: "let me think", signature: "sig-1" },
231
+ { type: "text", text: "done" },
232
+ ]
233
+ end
234
+
235
+ it "sends an opaque payload home as redacted_thinking, under either name" do
236
+ # Anthropic calls it redacted_thinking; OpenRouter calls the same Claude
237
+ # payload reasoning.encrypted. A conversation logged through either one
238
+ # goes back the same way.
239
+ [:redacted, :encrypted].each do |type|
240
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
241
+ blocks: [{ type: type, signature: "blob", format: "anthropic-claude-v1" }],
242
+ })
243
+
244
+ Brute::MessageTransport::Anthropic.dump(m)[:content].first
245
+ .should == { type: "redacted_thinking", data: "blob" }
246
+ end
247
+ end
248
+
249
+ it "sends no thinking at all rather than a block it cannot vouch for" do
250
+ # An unverifiable block is a 400, and no thinking beats a failed request:
251
+ # a signature Anthropic did not issue, and a block carrying none where it
252
+ # requires one, are both refused -- and refused whole.
253
+ foreign = Brute::Message.new(role: :assistant, content: "done", reasoning: {
254
+ blocks: [{ type: :text, text: "thought", signature: "sig-1", format: "openai-responses-v1" }],
255
+ })
256
+
257
+ partly_signed = Brute::Message.new(role: :assistant, content: "done", reasoning: {
258
+ blocks: [
259
+ { type: :text, text: "step one", signature: "sig-1", format: "anthropic-claude-v1" },
260
+ { type: :text, text: "step two", format: "anthropic-claude-v1" },
261
+ ],
262
+ })
263
+
264
+ [foreign, partly_signed].each do |m|
265
+ Brute::MessageTransport::Anthropic.dump(m)[:content].should == [{ type: "text", text: "done" }]
266
+ end
267
+ end
268
+
269
+ it "says so rather than sending content Anthropic will not take" do
270
+ # Anthropic takes no empty content -- not an empty string, not an empty
271
+ # block, not an empty array. An assistant message with nothing said,
272
+ # nothing called and no thinking it can express has no form here, and
273
+ # went out as `content: ""`: a 400, and one that poisons every later
274
+ # request in the same conversation.
275
+ nothing = Brute::Message.new(role: :assistant, content: "")
276
+
277
+ lambda { Brute::MessageTransport::Anthropic.dump(nothing) }
278
+ .should.raise(Brute::MessageTransport::Anthropic::EmptyMessage)
279
+
280
+ # Thinking alone is something said, so it goes as itself.
281
+ thought = Brute::Message.new(role: :assistant, content: "", reasoning: {
282
+ blocks: [{ type: :text, text: "let me think", signature: "sig-1", format: "anthropic-claude-v1" }],
283
+ })
284
+
285
+ Brute::MessageTransport::Anthropic.dump(thought)[:content].should == [
286
+ { type: "thinking", thinking: "let me think", signature: "sig-1" },
287
+ ]
288
+ end
289
+
143
290
  it "wraps text and tool_use blocks into one assistant message" do
144
291
  response = fake_response.new(content: [
145
292
  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,61 @@ 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
+ # OpenRouter takes reasoning back either way: `reasoning_details` is
24
+ # the sequence the model produced, each entry carrying its own type,
25
+ # payload and signature, and `reasoning` is the same thinking as plain
26
+ # prose. The details are the complete form, so they are what goes when
27
+ # there are details to send; prose that was only ever prose -- read off
28
+ # a `reasoning` field, or off a library that reports nothing else --
29
+ # goes as prose, because a details array built around it would be a
30
+ # sequence the model never produced.
31
+ #
32
+ # Which of the two is a property of what was stored, not a judgement
33
+ # about where the turn is headed. Where it is headed is the caller's to
34
+ # decide; this only turns one shape into the other.
35
+ def self.dump(message, model: nil)
36
+ message.to_h.tap do |hash|
37
+ # What Message#to_h holds is brute's own shape, which is not the
38
+ # wire's: whatever goes out under these keys is put there here.
39
+ hash.delete(:reasoning)
40
+
41
+ reasoning = message.reasoning
42
+
43
+ if reasoning&.detailed?
44
+ hash[:reasoning_details] = reasoning.blocks.map { |block| detail(block) }
45
+ elsif reasoning && !reasoning.text.empty?
46
+ hash[:reasoning] = reasoning.text
47
+ end
48
+ end
49
+ end
50
+
51
+ # Each type names its own payload: reasoning text carries `text` and the
52
+ # `signature` that verifies it, a summary carries `summary`, and an
53
+ # encrypted item carries `data`. Written under the wrong key the payload
54
+ # is simply lost, which for an encrypted item is the whole of it.
55
+ def self.detail(block)
56
+ named = {
57
+ type: "reasoning.#{block.type}",
58
+ format: block.format,
59
+ id: block.id,
60
+ index: block.index,
61
+ }
62
+
63
+ case block.type
64
+ when :encrypted
65
+ named.merge(data: block.signature).compact
66
+ when :summary
67
+ named.merge(summary: block.text).compact
68
+ else
69
+ named.merge(text: block.text, signature: block.signature).compact
70
+ end
24
71
  end
25
72
 
26
73
  # An OpenRouter::Response's messages (one per choice; in practice
@@ -47,8 +94,18 @@ module Brute
47
94
 
48
95
  case hash
49
96
  in { role: (:system | :user | :assistant | :tool) }
50
- # Slice away provider extras (refusal, reasoning, model, ...)
51
- # that Brute::Message doesn't know.
97
+ # A completion that answered with nothing is not an answer. The
98
+ # provider was paid for it and something came back -- reasoning,
99
+ # a refusal, a field this does not know -- and appending it as an
100
+ # empty assistant message loses the turn quietly: the loop stops
101
+ # because it is not a tool result, and there is no reply in it.
102
+ if hash[:role] == :assistant && hash[:content].to_s.strip.empty? && Array(hash[:tool_calls]).empty? && reasoning(hash).nil?
103
+ raise EmptyCompletion, "the provider answered with no content and no tool calls: #{message.inspect}"
104
+ end
105
+
106
+ # Slice away provider extras (refusal, model, ...) that
107
+ # Brute::Message doesn't know. Reasoning is not one of them: it is
108
+ # the model's own thinking and it goes back up with the message.
52
109
  Brute::Message.new(
53
110
  **hash.slice(
54
111
  :role,
@@ -56,12 +113,43 @@ module Brute
56
113
  :tool_calls,
57
114
  :tool_call_id,
58
115
  ),
116
+ reasoning: reasoning(hash),
59
117
  )
60
118
  else
61
119
  raise "Unrecognised message format #{message.inspect}"
62
120
  end
63
121
  end
64
122
 
123
+ # reasoning_details is the sequence the model produced, each detail
124
+ # naming its own format; `reasoning` is the same thing as plaintext.
125
+ def reasoning(hash)
126
+ details = Array(hash[:reasoning_details]).map { |detail| detail.to_h.transform_keys(&:to_sym) }
127
+
128
+ if details.empty?
129
+ Brute::Reasoning.build(text: hash[:reasoning])
130
+ else
131
+ Brute::Reasoning.build(blocks: details.map { |detail| block(detail) })
132
+ end
133
+ end
134
+
135
+ # The type names the payload's key, so it says which one to read.
136
+ def block(detail)
137
+ type = detail[:type].to_s.split(".").last.to_s
138
+
139
+ Brute::ReasoningBlock.new(
140
+ type: type.empty? ? :text : type.to_sym,
141
+ text: detail[:text] || detail[:summary],
142
+ signature: detail[:signature] || detail[:data],
143
+ # Straight off the wire. `format` names a structure OpenRouter
144
+ # documents a closed set of ("anthropic-claude-v1",
145
+ # "openai-responses-v1", ... "unknown"); a model id is not one of
146
+ # them, so where the wire named none, none is stored.
147
+ format: detail[:format],
148
+ id: detail[:id],
149
+ index: detail[:index],
150
+ )
151
+ end
152
+
65
153
  # An OpenAI-wire tool call ({ id:, type:, function: { name:, arguments: JSON } })
66
154
  # -> the flat { id:, name:, arguments: Hash } Brute::Message understands.
67
155
  def wrap_tool_call(tool_call)
@@ -105,6 +193,148 @@ describe "brute/message_transport/open_router" do
105
193
  out.first.content.should == "hi there"
106
194
  end
107
195
 
196
+ it "keeps the model's reasoning, and refuses a completion that answered with nothing at all" do
197
+ # Reasoning is the model's own thinking, and it goes back up with the
198
+ # message: a reasoning model that called a tool has to see it on the next
199
+ # pass. Dropping it left an empty assistant message and a lost turn.
200
+ thought = Struct.new(:choices).new([{ "message" => { "role" => "assistant", "content" => "", "reasoning" => "thought about it" } }])
201
+
202
+ reasoned = Brute::MessageTransport::OpenRouter.new(thought).wrap_each.to_a.first
203
+ reasoned.reasoning.text.should == "thought about it"
204
+ Brute::MessageTransport::OpenRouter.dump(reasoned)[:reasoning].should == "thought about it"
205
+
206
+ # Signed thinking goes back as the details array, entry for entry. The
207
+ # details are the complete form, so they carry the turn on their own --
208
+ # `reasoning` is the same thinking as prose, and sending both says it
209
+ # twice.
210
+ signed = Struct.new(:choices, :model).new([{ "message" => {
211
+ "role" => "assistant", "content" => "",
212
+ "reasoning_details" => [{ "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1" }],
213
+ } }], "anthropic/claude-sonnet-4")
214
+
215
+ back = Brute::MessageTransport::OpenRouter.dump(Brute::MessageTransport::OpenRouter.new(signed).wrap_each.to_a.first)
216
+ back.key?(:reasoning).should.be.false
217
+ back[:reasoning_details].should == [
218
+ { type: "reasoning.text", text: "step by step", signature: "sig-1" },
219
+ ]
220
+
221
+ # A tool call is an answer, even with nothing said alongside it.
222
+ calling = Struct.new(:choices).new([
223
+ { "message" => {
224
+ "role" => "assistant", "content" => nil,
225
+ "tool_calls" => [{ "id" => "tc1", "type" => "function", "function" => { "name" => "shell", "arguments" => "{}" } }],
226
+ } },
227
+ ])
228
+
229
+ Brute::MessageTransport::OpenRouter.new(calling).wrap_each.to_a.first.tool_call?.should.be.true
230
+
231
+ # Nothing said, nothing thought, nothing called: the provider was paid for
232
+ # an answer and there is none, which is an error rather than a message.
233
+ empty = Struct.new(:choices).new([{ "message" => { "role" => "assistant", "content" => "", "refusal" => nil } }])
234
+
235
+ lambda { Brute::MessageTransport::OpenRouter.new(empty).wrap_each.to_a }
236
+ .should.raise(Brute::MessageTransport::OpenRouter::EmptyCompletion)
237
+ end
238
+
239
+ it "round-trips every detail type under its own payload key" do
240
+ # OpenRouter names each payload after its type: a summary's prose is
241
+ # `summary`, an encrypted item's payload is `data`, and only reasoning
242
+ # text carries a `signature`. Read or written under the wrong key, the
243
+ # payload is simply lost -- and an encrypted item is the case where the
244
+ # plaintext alone is not enough.
245
+ details = [
246
+ { "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1",
247
+ "format" => "anthropic-claude-v1", "id" => "r-1", "index" => 0 },
248
+ { "type" => "reasoning.summary", "summary" => "weighed the constraints",
249
+ "format" => "anthropic-claude-v1", "id" => "r-2", "index" => 1 },
250
+ { "type" => "reasoning.encrypted", "data" => "b64blob",
251
+ "format" => "anthropic-claude-v1", "id" => "r-3", "index" => 2 },
252
+ ]
253
+
254
+ response = Struct.new(:choices).new([
255
+ { "message" => { "role" => "assistant", "content" => "", "reasoning_details" => details } },
256
+ ])
257
+
258
+ wrapped = Brute::MessageTransport::OpenRouter.new(response).wrap_each.to_a.first
259
+ wrapped.reasoning.text.should == "step by step\nweighed the constraints"
260
+
261
+ back = Brute::MessageTransport::OpenRouter.dump(wrapped, model: "anthropic/claude-sonnet-4")
262
+ back[:reasoning_details].should == details.map { |detail| detail.transform_keys(&:to_sym) }
263
+ end
264
+
265
+ it "sends an opaque payload out as reasoning.encrypted, under either name" do
266
+ # The same Claude payload is :redacted when Anthropic logged it and
267
+ # :encrypted when OpenRouter did. Either way it is not text, and its
268
+ # payload belongs under data.
269
+ [:redacted, :encrypted].each do |type|
270
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
271
+ blocks: [{ type: type, signature: "b64blob", format: "anthropic-claude-v1" }],
272
+ })
273
+
274
+ Brute::MessageTransport::OpenRouter.dump(m, model: "anthropic/claude-sonnet-4")[:reasoning_details]
275
+ .should == [{ type: "reasoning.encrypted", data: "b64blob", format: "anthropic-claude-v1" }]
276
+ end
277
+ end
278
+
279
+ it "stores the format the wire named, and no other" do
280
+ # `format` names one of a closed set of structures OpenRouter documents
281
+ # -- "anthropic-claude-v1", "openai-responses-v1", ... "unknown". A model
282
+ # id is not one of them, so where the wire named no format, none is
283
+ # stored and none is sent: an invented one is a value the API does not
284
+ # take, and a value nothing else in here can read back.
285
+ named = Struct.new(:choices, :model).new([
286
+ { "message" => { "role" => "assistant", "content" => "done", "reasoning_details" => [
287
+ { "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1",
288
+ "format" => "anthropic-claude-v1" },
289
+ ] } },
290
+ ], "anthropic/claude-sonnet-4")
291
+
292
+ unnamed = Struct.new(:choices, :model).new([
293
+ { "message" => { "role" => "assistant", "content" => "done", "reasoning_details" => [
294
+ { "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1" },
295
+ ] } },
296
+ ], "anthropic/claude-sonnet-4")
297
+
298
+ Brute::MessageTransport::OpenRouter.new(named).wrap_each.to_a.first
299
+ .reasoning.blocks.first.format.should == "anthropic-claude-v1"
300
+
301
+ Brute::MessageTransport::OpenRouter.new(unnamed).wrap_each.to_a.first
302
+ .reasoning.blocks.first.format.should.be.nil
303
+ end
304
+
305
+ it "sends the details sequence whole, entry for entry" do
306
+ # "The entire sequence of consecutive reasoning blocks must match the
307
+ # outputs generated by the model; you cannot rearrange or modify the
308
+ # sequence." So every entry goes, in order, as it was stored -- what the
309
+ # turn is then sent to is the caller's to decide, not this.
310
+ response = Struct.new(:choices).new([
311
+ { "message" => { "role" => "assistant", "content" => "done", "reasoning_details" => [
312
+ { "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1",
313
+ "format" => "anthropic-claude-v1", "index" => 0 },
314
+ { "type" => "reasoning.encrypted", "data" => "b64blob",
315
+ "format" => "anthropic-claude-v1", "index" => 1 },
316
+ ] } },
317
+ ])
318
+
319
+ wrapped = Brute::MessageTransport::OpenRouter.new(response).wrap_each.to_a.first
320
+
321
+ Brute::MessageTransport::OpenRouter.dump(wrapped)[:reasoning_details].should == [
322
+ { type: "reasoning.text", format: "anthropic-claude-v1", index: 0,
323
+ text: "step by step", signature: "sig-1" },
324
+ { type: "reasoning.encrypted", format: "anthropic-claude-v1", index: 1,
325
+ data: "b64blob" },
326
+ ]
327
+ end
328
+
329
+ it "does not invent a details sequence around plaintext reasoning" do
330
+ # A sequence Brute made up is not one the model produced.
331
+ plain = Brute::Message.new(role: :assistant, content: "done", reasoning: "just thinking")
332
+
333
+ back = Brute::MessageTransport::OpenRouter.dump(plain, model: "anthropic/claude-sonnet-4")
334
+ back[:reasoning].should == "just thinking"
335
+ back.key?(:reasoning_details).should.be.false
336
+ end
337
+
108
338
  it "unwraps OpenAI-wire tool calls and parses their JSON arguments" do
109
339
  fake_response = Struct.new(:choices).new([
110
340
  { "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 }
@@ -6,7 +6,50 @@ require "brute/message_transport"
6
6
 
7
7
  module Brute
8
8
  class MessageTransport
9
+ # MessageTransport for the ruby_llm gem (https://github.com/crmne/ruby_llm).
10
+ # Brute does not require it — you do:
11
+ #
12
+ # require "ruby_llm"
13
+ #
14
+ # The gem changed shape at 2.0, and it changed in exactly the place that
15
+ # decides what this transport can promise. Through 1.x a message models
16
+ # its reasoning as a RubyLLM::Thinking: one text, one signature, and
17
+ # nowhere to put a sequence -- so a sequence cannot come back out of it
18
+ # the way it went in. 2.0 adds #raw_reasoning, which holds the whole
19
+ # reasoning_details sequence and which the provider adapters send on, so
20
+ # every entry goes back with the type, payload and signature it had. The
21
+ # model id moved too: #model_id in 1.x, #model in 2.0.
22
+ #
23
+ # Two shapes, so two subclasses, and .for is the only place that asks
24
+ # which one is installed. Nothing else branches on a version.
9
25
  class RubyLLM < MessageTransport
26
+ # The release that added somewhere for a whole sequence to live.
27
+ VERBATIM = Gem::Version.new("2.0")
28
+
29
+ # Which subclass the bundled gem calls for. Read lazily: brute does not
30
+ # require ruby_llm, so ::RubyLLM only exists once the caller has.
31
+ def self.for(version = ::RubyLLM::VERSION)
32
+ if Gem::Version.new(version) >= VERBATIM
33
+ V2
34
+ else
35
+ V1
36
+ end
37
+ end
38
+
39
+ # Brute::MessageTransport::RubyLLM.new(response) answers whichever
40
+ # subclass fits; asking a subclass for one builds it as usual.
41
+ def self.new(result)
42
+ if self == RubyLLM
43
+ self.for.new(result)
44
+ else
45
+ super
46
+ end
47
+ end
48
+
49
+ # Dispatchers. Both subclasses override these.
50
+ def self.dump(message, model: nil) = self.for.dump(message, model: model)
51
+
52
+ def self.thinking(message) = self.for.thinking(message)
10
53
 
11
54
  # What the provider reported about this call — the transport knows its
12
55
  # own library's shape, so it knows which detector to ask.
@@ -14,37 +57,26 @@ module Brute
14
57
  Brute::UsageDetection::RubyLLM.detect(message)
15
58
  end
16
59
 
17
- # Brute::Message -> RubyLLM::Message (tool calls as ruby_llm's id-keyed hash).
18
- def self.dump(message)
19
- tool_calls = message.tool_calls&.to_h do |tc|
20
- [tc.id, ::RubyLLM::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)]
60
+ # ruby_llm keys tool calls by id; brute keeps a flat list.
61
+ def self.tool_calls(message)
62
+ message.tool_calls&.to_h do |tc|
63
+ [ tc.id, ::RubyLLM::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments) ]
21
64
  end
22
-
23
- ::RubyLLM::Message.new(
24
- role: message.role,
25
- content: message.content,
26
- tool_calls: tool_calls,
27
- tool_call_id: message.tool_call_id,
28
- )
29
65
  end
30
66
 
31
67
  private
32
68
 
33
- # RubyLLM::Message -> Brute::Message.
69
+ # RubyLLM::Message -> Brute::Message.
34
70
  def wrap(message)
35
71
  raw_calls = message.tool_calls
36
72
  if raw_calls.respond_to?(:values)
37
73
  calls_list = raw_calls.values
38
- else
39
- calls_list = raw_calls
40
- end
74
+ else
75
+ calls_list = raw_calls
76
+ end
41
77
 
42
78
  tool_calls = calls_list&.map do |tc|
43
- Brute::ToolCall.new(
44
- id: tc.id,
45
- name: tc.name,
46
- arguments: tc.arguments,
47
- )
79
+ Brute::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)
48
80
  end
49
81
 
50
82
  Brute::Message.new(
@@ -52,6 +84,171 @@ module Brute
52
84
  content: message.content&.to_s, # Preserves nil safely
53
85
  tool_calls: tool_calls,
54
86
  tool_call_id: message.tool_call_id,
87
+ reasoning: reasoning(message),
88
+ )
89
+ end
90
+
91
+ # The thinking ruby_llm flattened: one text and one signature, which
92
+ # is all 1.x ever has, and all 2.0 has when the provider adapter
93
+ # kept no reasoning_details of its own.
94
+ def flattened(message)
95
+ if message.respond_to?(:thinking) && message.thinking
96
+ Brute::Reasoning.build(
97
+ text: message.thinking.text,
98
+ signature: message.thinking.signature,
99
+ format: answering_model(message),
100
+ )
101
+ end
102
+ end
103
+ end
104
+
105
+ # ruby_llm 1.x. A message carries RubyLLM::Thinking and nothing else, so
106
+ # a sequence of more than one block has nowhere to live: it would go back
107
+ # as one block holding the joined text and the first signature, which is
108
+ # a modified sequence and a 400. Nothing goes instead.
109
+ class RubyLLM::V1 < RubyLLM
110
+ def self.dump(message, model: nil)
111
+ ::RubyLLM::Message.new(
112
+ role: message.role,
113
+ content: message.content,
114
+ tool_calls: tool_calls(message),
115
+ tool_call_id: message.tool_call_id,
116
+ thinking: thinking(message),
117
+ )
118
+ end
119
+
120
+ # One block or none. RubyLLM::Thinking.build answers nil when there is
121
+ # nothing to say; empty text is passed as nil rather than "" so an
122
+ # encrypted payload renders as redacted_thinking rather than as a
123
+ # thinking block with an empty body.
124
+ def self.thinking(message)
125
+ reasoning = message.reasoning
126
+
127
+ if reasoning&.blocks&.one?
128
+ block = reasoning.blocks.first
129
+
130
+ ::RubyLLM::Thinking.build(
131
+ text: block.text.to_s.empty? ? nil : block.text,
132
+ signature: block.signature,
133
+ )
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ # 1.x names it #model_id.
140
+ def answering_model(message)
141
+ if message.respond_to?(:model_id)
142
+ message.model_id
143
+ end
144
+ end
145
+
146
+ def reasoning(message) = flattened(message)
147
+ end
148
+
149
+ # ruby_llm 2.0 and later. #raw_reasoning holds the reasoning_details
150
+ # sequence and the provider adapters send it on, so a sequence goes home
151
+ # entry for entry. Where the adapter kept none there is still #thinking,
152
+ # which carries one text and one signature.
153
+ class RubyLLM::V2 < RubyLLM
154
+ def self.dump(message, model: nil)
155
+ ::RubyLLM::Message.new(
156
+ role: message.role,
157
+ content: message.content,
158
+ tool_calls: tool_calls(message),
159
+ tool_call_id: message.tool_call_id,
160
+ thinking: thinking(message),
161
+ raw_reasoning: raw_reasoning(message),
162
+ )
163
+ end
164
+
165
+ # 2.0 hands #raw_reasoning to the provider as the reasoning_details
166
+ # array, so the stored sequence goes out in that shape: each block
167
+ # under the type that names it, and its payload under the key that
168
+ # type gives it. Prose that was only ever prose has no sequence to
169
+ # make of itself, and travels on #thinking instead.
170
+ def self.raw_reasoning(message)
171
+ reasoning = message.reasoning
172
+
173
+ if reasoning&.detailed?
174
+ reasoning.blocks.map { |block| detail(block) }
175
+ end
176
+ end
177
+
178
+ # One stored block as a reasoning detail. Reasoning text carries
179
+ # `text` and the `signature` that verifies it, a summary carries
180
+ # `summary`, and an encrypted item carries `data` -- written under the
181
+ # wrong key the payload is simply lost.
182
+ def self.detail(block)
183
+ named = {
184
+ type: "reasoning.#{block.type}",
185
+ format: block.format,
186
+ id: block.id,
187
+ index: block.index,
188
+ }
189
+
190
+ case block.type
191
+ when :encrypted
192
+ named.merge(data: block.signature).compact
193
+ when :summary
194
+ named.merge(summary: block.text).compact
195
+ else
196
+ named.merge(text: block.text, signature: block.signature).compact
197
+ end
198
+ end
199
+
200
+ # The readable view. #raw_reasoning is what actually goes back out;
201
+ # this is what a human sees, and what a provider adapter that kept no
202
+ # payload falls back on.
203
+ def self.thinking(message)
204
+ reasoning = message.reasoning
205
+
206
+ if reasoning
207
+ ::RubyLLM::Thinking.build(
208
+ text: reasoning.text.empty? ? nil : reasoning.text,
209
+ signature: reasoning.blocks.find(&:signed?)&.signature,
210
+ )
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ # 2.0 names it #model.
217
+ def answering_model(message)
218
+ if message.respond_to?(:model)
219
+ message.model
220
+ end
221
+ end
222
+
223
+ # The reasoning_details sequence where the adapter kept one, and
224
+ # ruby_llm's flattened view where it did not.
225
+ def reasoning(message)
226
+ details = []
227
+ if message.respond_to?(:raw_reasoning)
228
+ details = Array(message.raw_reasoning)
229
+ end
230
+
231
+ if details.empty?
232
+ flattened(message)
233
+ else
234
+ Brute::Reasoning.build(blocks: details.map { |detail| block(detail) })
235
+ end
236
+ end
237
+
238
+ # One reasoning detail as a stored block. The type names which key
239
+ # the payload arrived under.
240
+ def block(detail)
241
+ hash = detail.to_h.transform_keys(&:to_sym)
242
+ type = hash[:type].to_s.split(".").last.to_s
243
+
244
+ Brute::ReasoningBlock.new(
245
+ type: type.empty? ? :text : type.to_sym,
246
+ text: hash[:text] || hash[:summary],
247
+ signature: hash[:signature] || hash[:data],
248
+ format: hash[:format],
249
+ id: hash[:id],
250
+ index: hash[:index],
251
+
55
252
  )
56
253
  end
57
254
  end
@@ -62,15 +259,33 @@ __END__
62
259
 
63
260
  describe "brute/message_transport/ruby_llm" do
64
261
  require "brute/messages"
262
+ require "ruby_llm"
65
263
 
66
264
  # Duck-typed stand-ins for RubyLLM::Message / RubyLLM::ToolCall so these
67
- # specs don't need the gem loaded.
265
+ # specs don't need a particular version of the gem loaded. The 2.0 shape is
266
+ # not installable alongside the 1.x one, so it is stood in for.
68
267
  fake_tool_call = Struct.new(:id, :name, :arguments, keyword_init: true)
69
268
  fake_message = Struct.new(:role, :content, :tool_calls, :tool_call_id, keyword_init: true)
269
+ fake_thinking = Struct.new(:text, :signature, keyword_init: true)
270
+
271
+ it "picks the subclass its bundled gem calls for" do
272
+ # 1.x models thinking as one text and one signature; 2.0 keeps the
273
+ # whole reasoning_details sequence. Two shapes, two subclasses.
274
+ Brute::MessageTransport::RubyLLM.for("1.14.1").should == Brute::MessageTransport::RubyLLM::V1
275
+ Brute::MessageTransport::RubyLLM.for("1.16.0").should == Brute::MessageTransport::RubyLLM::V1
276
+ Brute::MessageTransport::RubyLLM.for("2.0.0").should == Brute::MessageTransport::RubyLLM::V2
277
+ Brute::MessageTransport::RubyLLM.for("2.4.0").should == Brute::MessageTransport::RubyLLM::V2
278
+ end
279
+
280
+ it "builds the subclass, not the dispatcher" do
281
+ m = fake_message.new(role: :assistant, content: "hi")
282
+ Brute::MessageTransport::RubyLLM.new(m).should.be.kind_of?(Brute::MessageTransport::RubyLLM)
283
+ Brute::MessageTransport::RubyLLM::V1.new(m).should.be.kind_of?(Brute::MessageTransport::RubyLLM::V1)
284
+ end
70
285
 
71
286
  it "wraps a plain assistant message" do
72
287
  m = fake_message.new(role: :assistant, content: "hi")
73
- out = Brute::MessageTransport::RubyLLM.new(m).wrap_each.to_a
288
+ out = Brute::MessageTransport::RubyLLM::V1.new(m).wrap_each.to_a
74
289
  out.first.should.be.kind_of?(Brute::Message)
75
290
  out.first.content.should == "hi"
76
291
  end
@@ -79,7 +294,109 @@ describe "brute/message_transport/ruby_llm" do
79
294
  tc = fake_tool_call.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
80
295
  m = fake_message.new(role: :assistant, content: "", tool_calls: { "tc1" => tc })
81
296
 
82
- out = Brute::MessageTransport::RubyLLM.new(m).wrap_each.to_a.first
297
+ out = Brute::MessageTransport::RubyLLM::V1.new(m).wrap_each.to_a.first
83
298
  out.tool_calls.first.should == Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
84
299
  end
300
+
301
+ it "stamps the model that answered, under the name 1.x gives it" do
302
+ # RubyLLM::Message names it #model_id through 1.x -- there is no #model,
303
+ # so asking for one stamped nothing and the signature went out
304
+ # unattributed.
305
+ m = fake_message.new(role: :assistant, content: "done")
306
+ m.define_singleton_method(:thinking) { fake_thinking.new(text: "thought", signature: "sig-1") }
307
+ m.define_singleton_method(:model_id) { "claude-sonnet-4" }
308
+
309
+ block = Brute::MessageTransport::RubyLLM::V1.new(m).wrap_each.to_a.first.reasoning.blocks.first
310
+ block.signature.should == "sig-1"
311
+ block.format.should == "claude-sonnet-4"
312
+ end
313
+
314
+ it "sends one block back with the signature it carried" do
315
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
316
+ blocks: [{ type: :text, text: "thought it through", signature: "sig-1" }],
317
+ })
318
+
319
+ thinking = Brute::MessageTransport::RubyLLM::V1.dump(m).thinking
320
+ thinking.text.should == "thought it through"
321
+ thinking.signature.should == "sig-1"
322
+ end
323
+
324
+ it "sends nothing rather than a sequence 1.x cannot hold" do
325
+ # RubyLLM::Thinking has room for one text and one signature. A sequence
326
+ # put through it comes out as the joined text under the first block's
327
+ # signature, which is a modified sequence -- a 400, not a near miss. So
328
+ # it does not go.
329
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
330
+ blocks: [
331
+ { type: :text, text: "step one", signature: "sig-1", format: "anthropic-claude-v1" },
332
+ { type: :text, text: "step two", signature: "sig-2", format: "anthropic-claude-v1" },
333
+ ],
334
+ })
335
+
336
+ Brute::MessageTransport::RubyLLM::V1.dump(m).thinking.should.be.nil
337
+ end
338
+
339
+ it "hands an encrypted payload over as a payload, not as empty thinking" do
340
+ # An encrypted block has no prose. Passed as "" rather than nil it renders
341
+ # as a thinking block with an empty body; passed as nil ruby_llm renders
342
+ # the redacted_thinking block the payload actually belongs in.
343
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
344
+ blocks: [{ type: :encrypted, signature: "b64blob", format: "anthropic-claude-v1" }],
345
+ })
346
+
347
+ thinking = Brute::MessageTransport::RubyLLM::V1.dump(m).thinking
348
+ thinking.text.should.be.nil
349
+ thinking.signature.should == "b64blob"
350
+ end
351
+
352
+ it "round-trips a reasoning_details sequence, entry for entry" do
353
+ # 2.0 hands #raw_reasoning on to the provider, so each entry goes back
354
+ # under the type that named it with the payload that type gives it.
355
+ details = [
356
+ { "type" => "reasoning.text", "text" => "step one", "signature" => "sig-1",
357
+ "format" => "anthropic-claude-v1", "index" => 0 },
358
+ { "type" => "reasoning.encrypted", "data" => "b64blob",
359
+ "format" => "anthropic-claude-v1", "index" => 1 },
360
+ ]
361
+
362
+ m = fake_message.new(role: :assistant, content: "done")
363
+ m.define_singleton_method(:raw_reasoning) { details }
364
+
365
+ wrapped = Brute::MessageTransport::RubyLLM::V2.new(m).wrap_each.to_a.first
366
+
367
+ wrapped.reasoning.text.should == "step one"
368
+ wrapped.reasoning.blocks.map(&:type).should == [:text, :encrypted]
369
+
370
+ # ...and back out in the shape it came in, entry for entry.
371
+ Brute::MessageTransport::RubyLLM::V2.raw_reasoning(wrapped).should == [
372
+ { type: "reasoning.text", format: "anthropic-claude-v1", index: 0,
373
+ text: "step one", signature: "sig-1" },
374
+ { type: "reasoning.encrypted", format: "anthropic-claude-v1", index: 1,
375
+ data: "b64blob" },
376
+ ]
377
+ end
378
+
379
+ it "makes no details sequence out of prose that was only ever prose" do
380
+ # A sequence brute invented is not one the model produced, so plaintext
381
+ # travels as plaintext -- on #thinking, where a reader finds it -- and no
382
+ # reasoning_details array is built around it.
383
+ plain = Brute::Message.new(role: :assistant, content: "done", reasoning: "just thinking")
384
+
385
+ Brute::MessageTransport::RubyLLM::V2.raw_reasoning(plain).should.be.nil
386
+ Brute::MessageTransport::RubyLLM::V2.dump(plain).thinking.text.should == "just thinking"
387
+ end
388
+
389
+ it "falls back to the flattened view when the adapter kept no details" do
390
+ # Not every 2.0 provider adapter keeps a reasoning_details array of its
391
+ # own. Where none was kept there is still #thinking, and 2.0 names the
392
+ # model #model where 1.x named it #model_id.
393
+ m = fake_message.new(role: :assistant, content: "done")
394
+ m.define_singleton_method(:thinking) { fake_thinking.new(text: "thought", signature: "sig-1") }
395
+ m.define_singleton_method(:model) { "anthropic/claude-sonnet-4" }
396
+ m.define_singleton_method(:raw_reasoning) { nil }
397
+
398
+ reasoning = Brute::MessageTransport::RubyLLM::V2.new(m).wrap_each.to_a.first.reasoning
399
+ reasoning.text.should == "thought"
400
+ reasoning.blocks.first.format.should == "anthropic/claude-sonnet-4"
401
+ end
85
402
  end
@@ -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,115 @@ 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
+ # One block of thinking. :text is what the model thought and :summary is
26
+ # the provider's precis of it; :encrypted is a payload that is not ours to
27
+ # read and matters only in that it goes back whole.
28
+ #
29
+ # Anthropic calls that payload redacted_thinking and OpenRouter calls the
30
+ # same thing reasoning.encrypted, so :redacted is taken as a spelling of
31
+ # :encrypted rather than a second kind -- a conversation logged through
32
+ # one and replayed to the other has to arrive as what it is.
33
+ ReasoningBlock = Data.define(
34
+ :type,
35
+ :text,
36
+ :signature,
37
+ :format,
38
+ :id,
39
+ :index,
40
+ ) do
41
+ TYPES = { redacted: :encrypted }.freeze
42
+
43
+ def initialize(type: :text, text: nil, signature: nil, format: nil, id: nil, index: nil)
44
+ type = type.to_sym
45
+
46
+ super(
47
+ type: TYPES.fetch(type, type),
48
+ text: text,
49
+ signature: signature,
50
+ format: format,
51
+ id: id,
52
+ index: index,
53
+ )
54
+ end
55
+
56
+ def opaque? = type == :encrypted
57
+
58
+ def signed? = !signature.to_s.empty?
59
+
60
+ def to_h(...) = super.compact
61
+ end
62
+
63
+ # What the model thought before it answered, and the provider's proof that
64
+ # it did.
65
+ #
66
+ # A signature is only meaningful to the provider that issued it: Anthropic
67
+ # verifies its own and rejects a modified block with a 400, and OpenRouter
68
+ # names the format ("anthropic-claude-v1", "openai-responses-v1") because
69
+ # the details only mean anything against the provider that produced them.
70
+ # The log is provider-agnostic and a conversation can be replayed against
71
+ # anyone, so what was signed carries who signed it -- a transport sends the
72
+ # blocks back only when they are its own, and sends the text alone when they
73
+ # are not.
74
+ #
75
+ # The blocks are a sequence, in the order the model produced them, because
76
+ # that is what both providers ask for: "passed back unmodified and in their
77
+ # original order" (Anthropic), "the entire sequence of consecutive reasoning
78
+ # blocks must match the outputs generated by the model" (OpenRouter).
79
+ Reasoning = Data.define(:blocks) do
80
+ def self.build(text: nil, signature: nil, format: nil, blocks: nil)
81
+ blocks = Array(blocks).map { |block| block.is_a?(ReasoningBlock) ? block : ReasoningBlock.new(**block.to_h.transform_keys(&:to_sym)) }
82
+
83
+ if blocks.empty? && !(text.to_s.empty? && signature.to_s.empty?)
84
+ blocks = [ ReasoningBlock.new(text: text, signature: signature, format: format) ]
85
+ end
86
+
87
+ unless blocks.empty?
88
+ new(blocks: blocks)
89
+ end
90
+ end
91
+
92
+ # What the model thought, for anyone reading rather than replaying.
93
+ def text = blocks.filter_map(&:text).join("\n")
94
+
95
+ # Whether these blocks may be sent back to this provider as they are. A
96
+ # signature the provider did not issue is worse than no signature: it is
97
+ # a rejected request.
98
+ def signed_by?(format) = !format.nil? && blocks.any? && issued_by?(format) && blocks.all?(&:signed?)
99
+
100
+ # Whether this is the sequence the model emitted rather than one built
101
+ # around a plaintext string. Only the former may be replayed: a provider
102
+ # checks what comes back against what it produced, and prose lifted out of
103
+ # a `reasoning` field was never a sequence at all.
104
+ def detailed? = blocks.any? { |block| block.signed? || block.format || block.id }
105
+
106
+ # Whether every block came from this provider. The sequence goes back
107
+ # whole or not at all, so one foreign block disqualifies all of them.
108
+ #
109
+ # A block that names no provider is nobody's to vouch for: unsigned there
110
+ # is nothing to vouch for and it passes, but signed it is a signature that
111
+ # cannot be attributed, and offering one the provider did not issue is a
112
+ # rejected request rather than an unverified one.
113
+ def issued_by?(format)
114
+ blocks.all? do |block|
115
+ if block.format.nil?
116
+ !block.signed?
117
+ else
118
+ format.nil? || block.format == format
119
+ end
120
+ end
121
+ end
122
+
123
+ def to_h(...) = { blocks: blocks.map(&:to_h) }
124
+ end
125
+
25
126
  Message = Data.define(
26
127
  :role,
27
128
  :content,
28
129
  :tool_calls,
29
130
  :tool_call_id,
131
+ :reasoning,
30
132
  ) do
31
- def initialize(role:, content: nil, tool_calls: nil, tool_call_id: nil)
133
+ def initialize(role:, content: nil, tool_calls: nil, tool_call_id: nil, reasoning: nil)
32
134
  formatted_calls = tool_calls&.map do |tc|
33
135
  tc.is_a?(ToolCall) ? tc : ToolCall.new(**tc.to_h.transform_keys(&:to_sym))
34
136
  end
@@ -37,7 +139,8 @@ module Brute
37
139
  role: role.to_sym,
38
140
  content: content,
39
141
  tool_calls: formatted_calls,
40
- tool_call_id: tool_call_id
142
+ tool_call_id: tool_call_id,
143
+ reasoning: coerce(reasoning)
41
144
  )
42
145
  end
43
146
 
@@ -50,8 +153,23 @@ module Brute
50
153
  if tool_calls
51
154
  hash[:tool_calls] = tool_calls.map(&:to_h)
52
155
  end
156
+ if reasoning
157
+ hash[:reasoning] = reasoning.to_h
158
+ end
53
159
  hash.compact
54
160
  end
161
+
162
+ private
163
+
164
+ # Reasoning read back off disk is a hash, and a provider that reports
165
+ # nothing but the text is still reporting reasoning.
166
+ def coerce(reasoning)
167
+ case reasoning
168
+ in nil | Reasoning then reasoning
169
+ in String => text then Reasoning.build(text: text)
170
+ else Reasoning.build(**reasoning.to_h.transform_keys(&:to_sym))
171
+ end
172
+ end
55
173
  end
56
174
 
57
175
  # The in-memory conversation log is just a plain Array of Brute::Message.
@@ -129,4 +247,47 @@ describe "brute/messages" do
129
247
  it "to_h drops nil fields" do
130
248
  Brute::Message.new(role: :user, content: "hi").to_h.should == { role: :user, content: "hi" }
131
249
  end
250
+
251
+ it "round-trips reasoning through to_h" do
252
+ m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
253
+ blocks: [{ type: :text, text: "thought", signature: "sig-1", format: "anthropic-claude-v1" }],
254
+ })
255
+
256
+ Brute::Message.new(**m.to_h).should == m
257
+ end
258
+
259
+ it "will not vouch for a sequence in which any block is unsigned" do
260
+ # A provider that verifies signatures rejects a thinking block without
261
+ # one, so one signed block among several does not make the sequence
262
+ # sendable: every block has to carry its own signature, or none goes.
263
+ mixed = Brute::Reasoning.build(blocks: [
264
+ { type: :text, text: "step one", signature: "sig-1", format: "anthropic-claude-v1" },
265
+ { type: :text, text: "step two", format: "anthropic-claude-v1" },
266
+ ])
267
+
268
+ mixed.signed_by?("anthropic-claude-v1").should.be.false
269
+ end
270
+
271
+ it "will not vouch for a signature that names no provider" do
272
+ # A block with a signature and no format cannot be attributed, and a
273
+ # signature the provider did not issue is a rejected request rather than
274
+ # an unverified one. Unsigned and unstamped is nobody's claim to make, so
275
+ # it passes.
276
+ unattributed = Brute::Reasoning.build(text: "thought", signature: "sig-1")
277
+
278
+ unattributed.issued_by?("anthropic-claude-v1").should.be.false
279
+ unattributed.signed_by?("anthropic-claude-v1").should.be.false
280
+
281
+ Brute::Reasoning.build(text: "just prose").issued_by?("anthropic-claude-v1").should.be.true
282
+ end
283
+
284
+ it "knows a sequence the model produced from one built around a string" do
285
+ # Only what the model actually emitted may be sent back as a sequence, so
286
+ # reasoning coerced from plaintext is not one to be replayed.
287
+ Brute::Reasoning.build(text: "just thinking").detailed?.should.be.false
288
+
289
+ Brute::Reasoning.build(blocks: [
290
+ { type: :summary, text: "considered it", format: "anthropic-claude-v1", id: "r-1" },
291
+ ]).detailed?.should.be.true
292
+ end
132
293
  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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors