brute 6.1.0 → 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: a640b2c8ee7c0ca3d79e5618b3026494e48115c4bc38799e2d1ba1ca1acc968f
4
- data.tar.gz: 66fed78b88d735cc528669e08dca7edbebfd1e6ba712f079fde45d268c9a5ec2
3
+ metadata.gz: a29376e6820e2ef4e6233073f2012ff95541027b64307956e141af222ece973e
4
+ data.tar.gz: 94c6351c5526249d3a62d200050180d4880f92bfbe259f3c97fabe5e5663160a
5
5
  SHA512:
6
- metadata.gz: a5374a6f3194a224069e812c3d49ef0748ca1e6b03d13ea0ef5e645103e6d67ec08f92f4702a86bce78733844c59472a571b9012268759e1a92693ce61b12b83
7
- data.tar.gz: aab38ce15e5fec92937cdef94a9350e880cf0ab52bce6e1706ed586fc720c204b94a8ca98e4021dce069a0ae058447194e615d6c8c29990debcd30f88a0a6907
6
+ metadata.gz: a156cb8659d03cf076b359a2e14e7d462d456433aa5b3a169ec1fcef97b775132ede29b7b838246a2237f32cc5c7e927df513391df61c8e726d14f4101195a5c
7
+ data.tar.gz: '008a7f1f998bd58467fa13769318d32d27e1d6e92808619dd3a7baed8c3444b263e935a3a435a87405849bab3a7c2ae691a7f0866475b544e6210a61b4215a19'
@@ -28,6 +28,10 @@ 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
+
31
35
  # Whose signatures these are. OpenRouter names the same format when it
32
36
  # proxies Claude, so thinking that came back through it goes home.
33
37
  FORMAT = "anthropic-claude-v1"
@@ -54,69 +58,86 @@ module Brute
54
58
  def self.dump(message, model: nil)
55
59
  case message.role
56
60
  when :tool
57
- { role: "user", content: [tool_result_block(message)] }
61
+ { role: "user", content: [ tool_result_block(message) ] }
58
62
  when :assistant
59
- if message.tool_call?
60
- blocks = thinking_blocks(message)
61
- unless message.content.to_s.empty?
62
- blocks << { type: "text", text: message.content }
63
- end
64
- blocks += message.tool_calls.map { |tc| { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments } }
65
- { role: "assistant", content: blocks }
66
- else
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
79
- end
63
+ { role: "assistant", content: assistant_content(message) }
80
64
  else
81
65
  { role: message.role.to_s, content: message.content }
82
66
  end
83
67
  end
84
68
 
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.
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.
94
105
  def self.expressible?(reasoning)
95
- reasoning.blocks.all? { |block| block.type == :text || block.opaque? }
106
+ reasoning.blocks.any? && reasoning.blocks.all? do |block|
107
+ block.format == FORMAT && block.signed? && (block.type == :text || block.opaque?)
108
+ end
96
109
  end
97
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.
98
114
  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
115
+ if message.reasoning && expressible?(message.reasoning)
116
+ message.reasoning.blocks.map { |block| thinking_param(block) }
107
117
  else
108
118
  []
109
119
  end
110
120
  end
111
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
+
112
133
  # A thinking block, or the redacted one that stands in for it when the
113
134
  # provider will not show its working. Both go back whole.
114
135
  def self.thinking_block(block)
115
136
  case block.type
116
137
  when :thinking
117
- Brute::Reasoning::Block.new(type: :text, text: block.thinking, signature: block.signature, format: FORMAT)
138
+ Brute::ReasoningBlock.new(type: :text, text: block.thinking, signature: block.signature, format: FORMAT)
118
139
  when :redacted_thinking
119
- Brute::Reasoning::Block.new(type: :encrypted, signature: block.data, format: FORMAT)
140
+ Brute::ReasoningBlock.new(type: :encrypted, signature: block.data, format: FORMAT)
120
141
  end
121
142
  end
122
143
 
@@ -245,6 +266,27 @@ describe "brute/message_transport/anthropic" do
245
266
  end
246
267
  end
247
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
+
248
290
  it "wraps text and tool_use blocks into one assistant message" do
249
291
  response = fake_response.new(content: [
250
292
  fake_text_block.new(type: :text, text: "running ls"),
@@ -20,63 +20,34 @@ module Brute
20
20
  def self.usage_metrics(response)
21
21
  Brute::UsageDetection::OpenRouter.detect(response)
22
22
  end
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.
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.
26
31
  #
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.
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.
31
35
  def self.dump(message, model: nil)
32
36
  message.to_h.tap do |hash|
33
37
  # What Message#to_h holds is brute's own shape, which is not the
34
38
  # wire's: whatever goes out under these keys is put there here.
35
39
  hash.delete(:reasoning)
36
40
 
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
41
+ reasoning = message.reasoning
43
42
 
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
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
57
47
  end
58
48
  end
59
49
  end
60
50
 
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
51
  # Each type names its own payload: reasoning text carries `text` and the
81
52
  # `signature` that verifies it, a summary carries `summary`, and an
82
53
  # encrypted item carries `data`. Written under the wrong key the payload
@@ -161,24 +132,19 @@ module Brute
161
132
  end
162
133
  end
163
134
 
164
- def answering_model
165
- if @result.respond_to?(:model)
166
- @result.model
167
- end
168
- end
169
-
170
135
  # The type names the payload's key, so it says which one to read.
171
136
  def block(detail)
172
137
  type = detail[:type].to_s.split(".").last.to_s
173
138
 
174
- Brute::Reasoning::Block.new(
139
+ Brute::ReasoningBlock.new(
175
140
  type: type.empty? ? :text : type.to_sym,
176
141
  text: detail[:text] || detail[:summary],
177
142
  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,
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],
182
148
  id: detail[:id],
183
149
  index: detail[:index],
184
150
  )
@@ -237,19 +203,19 @@ describe "brute/message_transport/open_router" do
237
203
  reasoned.reasoning.text.should == "thought about it"
238
204
  Brute::MessageTransport::OpenRouter.dump(reasoned)[:reasoning].should == "thought about it"
239
205
 
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.
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.
244
210
  signed = Struct.new(:choices, :model).new([{ "message" => {
245
211
  "role" => "assistant", "content" => "",
246
212
  "reasoning_details" => [{ "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1" }],
247
213
  } }], "anthropic/claude-sonnet-4")
248
214
 
249
215
  back = Brute::MessageTransport::OpenRouter.dump(Brute::MessageTransport::OpenRouter.new(signed).wrap_each.to_a.first)
250
- back[:reasoning].should == "step by step"
216
+ back.key?(:reasoning).should.be.false
251
217
  back[:reasoning_details].should == [
252
- { type: "reasoning.text", format: "anthropic/claude-sonnet-4", text: "step by step", signature: "sig-1" },
218
+ { type: "reasoning.text", text: "step by step", signature: "sig-1" },
253
219
  ]
254
220
 
255
221
  # A tool call is an answer, even with nothing said alongside it.
@@ -310,23 +276,54 @@ describe "brute/message_transport/open_router" do
310
276
  end
311
277
  end
312
278
 
313
- it "sends the details sequence whole, or not at all" do
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
314
306
  # "The entire sequence of consecutive reasoning blocks must match the
315
307
  # 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.
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.
318
310
  response = Struct.new(:choices).new([
319
311
  { "message" => { "role" => "assistant", "content" => "done", "reasoning_details" => [
320
312
  { "type" => "reasoning.text", "text" => "step by step", "signature" => "sig-1",
321
- "format" => "anthropic-claude-v1" },
313
+ "format" => "anthropic-claude-v1", "index" => 0 },
314
+ { "type" => "reasoning.encrypted", "data" => "b64blob",
315
+ "format" => "anthropic-claude-v1", "index" => 1 },
322
316
  ] } },
323
317
  ])
324
318
 
325
319
  wrapped = Brute::MessageTransport::OpenRouter.new(response).wrap_each.to_a.first
326
320
 
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
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
+ ]
330
327
  end
331
328
 
332
329
  it "does not invent a details sequence around plaintext reasoning" do
@@ -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,80 +57,200 @@ 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, model: nil)
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) ]
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # RubyLLM::Message -> Brute::Message.
70
+ def wrap(message)
71
+ raw_calls = message.tool_calls
72
+ if raw_calls.respond_to?(:values)
73
+ calls_list = raw_calls.values
74
+ else
75
+ calls_list = raw_calls
76
+ end
77
+
78
+ tool_calls = calls_list&.map do |tc|
79
+ Brute::ToolCall.new(id: tc.id, name: tc.name, arguments: tc.arguments)
80
+ end
81
+
82
+ Brute::Message.new(
83
+ role: message.role,
84
+ content: message.content&.to_s, # Preserves nil safely
85
+ tool_calls: tool_calls,
86
+ tool_call_id: message.tool_call_id,
87
+ reasoning: reasoning(message),
88
+ )
21
89
  end
22
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)
23
111
  ::RubyLLM::Message.new(
24
112
  role: message.role,
25
113
  content: message.content,
26
- tool_calls: tool_calls,
114
+ tool_calls: tool_calls(message),
27
115
  tool_call_id: message.tool_call_id,
28
116
  thinking: thinking(message),
29
117
  )
30
118
  end
31
119
 
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.
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.
35
124
  def self.thinking(message)
36
- if message.reasoning
125
+ reasoning = message.reasoning
126
+
127
+ if reasoning&.blocks&.one?
128
+ block = reasoning.blocks.first
129
+
37
130
  ::RubyLLM::Thinking.build(
38
- text: message.reasoning.text,
39
- signature: message.reasoning.blocks.find(&:signed?)&.signature,
131
+ text: block.text.to_s.empty? ? nil : block.text,
132
+ signature: block.signature,
40
133
  )
41
134
  end
42
135
  end
43
136
 
44
137
  private
45
138
 
46
- # RubyLLM::Message -> Brute::Message.
47
- def wrap(message)
48
- raw_calls = message.tool_calls
49
- if raw_calls.respond_to?(:values)
50
- calls_list = raw_calls.values
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
51
195
  else
52
- calls_list = raw_calls
196
+ named.merge(text: block.text, signature: block.signature).compact
53
197
  end
198
+ end
54
199
 
55
- tool_calls = calls_list&.map do |tc|
56
- Brute::ToolCall.new(
57
- id: tc.id,
58
- name: tc.name,
59
- arguments: tc.arguments,
60
- )
61
- end
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
62
205
 
63
- Brute::Message.new(
64
- role: message.role,
65
- content: message.content&.to_s, # Preserves nil safely
66
- tool_calls: tool_calls,
67
- tool_call_id: message.tool_call_id,
68
- reasoning: reasoning(message),
206
+ if reasoning
207
+ ::RubyLLM::Thinking.build(
208
+ text: reasoning.text.empty? ? nil : reasoning.text,
209
+ signature: reasoning.blocks.find(&:signed?)&.signature,
69
210
  )
70
211
  end
212
+ end
213
+
214
+ private
71
215
 
216
+ # 2.0 names it #model.
72
217
  def answering_model(message)
73
218
  if message.respond_to?(:model)
74
219
  message.model
75
220
  end
76
221
  end
77
222
 
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.
223
+ # The reasoning_details sequence where the adapter kept one, and
224
+ # ruby_llm's flattened view where it did not.
82
225
  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
- )
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) })
89
235
  end
90
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
+
252
+ )
253
+ end
91
254
  end
92
255
  end
93
256
  end
@@ -96,52 +259,144 @@ __END__
96
259
 
97
260
  describe "brute/message_transport/ruby_llm" do
98
261
  require "brute/messages"
262
+ require "ruby_llm"
99
263
 
100
264
  # Duck-typed stand-ins for RubyLLM::Message / RubyLLM::ToolCall so these
101
- # 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.
102
267
  fake_tool_call = Struct.new(:id, :name, :arguments, keyword_init: true)
103
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
104
285
 
105
286
  it "wraps a plain assistant message" do
106
287
  m = fake_message.new(role: :assistant, content: "hi")
107
- out = Brute::MessageTransport::RubyLLM.new(m).wrap_each.to_a
288
+ out = Brute::MessageTransport::RubyLLM::V1.new(m).wrap_each.to_a
108
289
  out.first.should.be.kind_of?(Brute::Message)
109
290
  out.first.content.should == "hi"
110
291
  end
111
292
 
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"
293
+ it "wraps ruby_llm's id-keyed tool_calls hash into a flat list" do
294
+ tc = fake_tool_call.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
295
+ m = fake_message.new(role: :assistant, content: "", tool_calls: { "tc1" => tc })
296
+
297
+ out = Brute::MessageTransport::RubyLLM::V1.new(m).wrap_each.to_a.first
298
+ out.tool_calls.first.should == Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
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" }
116
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
117
315
  m = Brute::Message.new(role: :assistant, content: "done", reasoning: {
118
316
  blocks: [{ type: :text, text: "thought it through", signature: "sig-1" }],
119
317
  })
120
318
 
121
- thinking = Brute::MessageTransport::RubyLLM.dump(m).thinking
319
+ thinking = Brute::MessageTransport::RubyLLM::V1.dump(m).thinking
122
320
  thinking.text.should == "thought it through"
123
321
  thinking.signature.should == "sig-1"
124
322
  end
125
323
 
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)
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
+
131
362
  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" }
363
+ m.define_singleton_method(:raw_reasoning) { details }
134
364
 
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"
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
+ ]
138
377
  end
139
378
 
140
- it "wraps ruby_llm's id-keyed tool_calls hash into a flat list" do
141
- tc = fake_tool_call.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
142
- m = fake_message.new(role: :assistant, content: "", tool_calls: { "tc1" => tc })
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")
143
384
 
144
- out = Brute::MessageTransport::RubyLLM.new(m).wrap_each.to_a.first
145
- out.tool_calls.first.should == Brute::ToolCall.new(id: "tc1", name: "shell", arguments: { "command" => "ls" })
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"
146
401
  end
147
402
  end
@@ -22,6 +22,44 @@ 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
+
25
63
  # What the model thought before it answered, and the provider's proof that
26
64
  # it did.
27
65
  #
@@ -40,10 +78,10 @@ module Brute
40
78
  # blocks must match the outputs generated by the model" (OpenRouter).
41
79
  Reasoning = Data.define(:blocks) do
42
80
  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)) }
81
+ blocks = Array(blocks).map { |block| block.is_a?(ReasoningBlock) ? block : ReasoningBlock.new(**block.to_h.transform_keys(&:to_sym)) }
44
82
 
45
83
  if blocks.empty? && !(text.to_s.empty? && signature.to_s.empty?)
46
- blocks = [ Reasoning::Block.new(text: text, signature: signature, format: format) ]
84
+ blocks = [ ReasoningBlock.new(text: text, signature: signature, format: format) ]
47
85
  end
48
86
 
49
87
  unless blocks.empty?
@@ -85,46 +123,6 @@ module Brute
85
123
  def to_h(...) = { blocks: blocks.map(&:to_h) }
86
124
  end
87
125
 
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
-
128
126
  Message = Data.define(
129
127
  :role,
130
128
  :content,
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.1.0"
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.1.0
4
+ version: 6.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brute Contributors