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 +4 -4
- data/lib/brute/message_transport/anthropic.rb +84 -42
- data/lib/brute/message_transport/open_router.rb +66 -69
- data/lib/brute/message_transport/ruby_llm.rb +317 -62
- data/lib/brute/messages.rb +40 -42
- data/lib/brute/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a29376e6820e2ef4e6233073f2012ff95541027b64307956e141af222ece973e
|
|
4
|
+
data.tar.gz: 94c6351c5526249d3a62d200050180d4880f92bfbe259f3c97fabe5e5663160a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
# Anthropic
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
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?
|
|
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
|
|
100
|
-
message.reasoning.blocks.map
|
|
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::
|
|
138
|
+
Brute::ReasoningBlock.new(type: :text, text: block.thinking, signature: block.signature, format: FORMAT)
|
|
118
139
|
when :redacted_thinking
|
|
119
|
-
Brute::
|
|
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
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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::
|
|
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
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
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,
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
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
|
|
216
|
+
back.key?(:reasoning).should.be.false
|
|
251
217
|
back[:reasoning_details].should == [
|
|
252
|
-
{ type: "reasoning.text",
|
|
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 "
|
|
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."
|
|
317
|
-
#
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
#
|
|
18
|
-
def self.
|
|
19
|
-
|
|
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
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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
|
-
|
|
125
|
+
reasoning = message.reasoning
|
|
126
|
+
|
|
127
|
+
if reasoning&.blocks&.one?
|
|
128
|
+
block = reasoning.blocks.first
|
|
129
|
+
|
|
37
130
|
::RubyLLM::Thinking.build(
|
|
38
|
-
text:
|
|
39
|
-
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
|
-
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
196
|
+
named.merge(text: block.text, signature: block.signature).compact
|
|
53
197
|
end
|
|
198
|
+
end
|
|
54
199
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
#
|
|
79
|
-
#
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 "
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 "
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
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(:
|
|
133
|
-
m.define_singleton_method(:model) { "claude-sonnet-4" }
|
|
363
|
+
m.define_singleton_method(:raw_reasoning) { details }
|
|
134
364
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 "
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
data/lib/brute/messages.rb
CHANGED
|
@@ -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?(
|
|
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 = [
|
|
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