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