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