mistri 0.5.0 → 0.6.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/CHANGELOG.md +480 -4
- data/CONTRIBUTING.md +52 -0
- data/README.md +289 -385
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/agent.rb +575 -55
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +72 -16
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +34 -11
- data/lib/mistri/console.rb +28 -7
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +14 -12
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +24 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks.rb +3 -3
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +42 -8
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +26 -10
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +1 -1
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +560 -48
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/spawner.rb +111 -61
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +11 -4
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +124 -87
- data/lib/mistri/task_output.rb +24 -6
- data/lib/mistri/tool.rb +93 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +4 -2
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +190 -5
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +6 -1
- data/mistri.gemspec +34 -0
- metadata +31 -3
|
@@ -6,29 +6,45 @@ module Mistri
|
|
|
6
6
|
# Folds the Messages API stream into the event union, building the
|
|
7
7
|
# assistant message block by block. Every emitted event carries an
|
|
8
8
|
# immutable snapshot of the message so far; in-flight tool arguments
|
|
9
|
-
#
|
|
9
|
+
# expose a bounded, cached PartialJson preview so hostile fragmentation
|
|
10
|
+
# cannot make snapshot construction quadratic.
|
|
10
11
|
#
|
|
11
12
|
# Unknown event and block types are skipped by contract: the API adds
|
|
12
13
|
# types over time and a live stream must survive them.
|
|
13
|
-
class Assembler
|
|
14
|
-
def initialize(model:)
|
|
14
|
+
class Assembler # rubocop:disable Metrics/ClassLength -- one stream owns block order
|
|
15
|
+
def initialize(model:, catalog_pricing: true)
|
|
15
16
|
@model = model
|
|
17
|
+
@catalog_pricing = catalog_pricing
|
|
18
|
+
@pricing_at = Time.now
|
|
16
19
|
@blocks = []
|
|
17
20
|
@current = nil
|
|
18
|
-
@usage = Usage.
|
|
21
|
+
@usage = Usage.new
|
|
19
22
|
@stop_reason = nil
|
|
20
23
|
@done = false
|
|
24
|
+
@next_wire_index = 0
|
|
25
|
+
@open_wire_index = nil
|
|
26
|
+
@message_phase = false
|
|
21
27
|
end
|
|
22
28
|
|
|
29
|
+
STREAM_EVENTS = %w[
|
|
30
|
+
message_start content_block_start content_block_delta content_block_stop
|
|
31
|
+
message_delta message_stop error
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
23
34
|
def feed(record, &)
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
type = record["type"]
|
|
36
|
+
if @done && STREAM_EVENTS.include?(type)
|
|
37
|
+
return protocol_error("stream continued after message_stop", &)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
case type
|
|
41
|
+
when "message_start" then message_start(record)
|
|
26
42
|
when "content_block_start" then start_block(record, &)
|
|
27
43
|
when "content_block_delta" then delta_block(record, &)
|
|
28
44
|
when "content_block_stop" then stop_block(record, &)
|
|
29
|
-
when "message_delta" then message_delta(record)
|
|
30
|
-
when "message_stop" then
|
|
31
|
-
when "error" then @error
|
|
45
|
+
when "message_delta" then message_delta(record, &)
|
|
46
|
+
when "message_stop" then stop_message(&)
|
|
47
|
+
when "error" then @error ||= wire_error(record["error"])
|
|
32
48
|
end
|
|
33
49
|
end
|
|
34
50
|
|
|
@@ -38,15 +54,24 @@ module Mistri
|
|
|
38
54
|
# reading as a cancellation.
|
|
39
55
|
def finish(&emit)
|
|
40
56
|
return fail_stream(@error, &emit) if @error
|
|
57
|
+
if @refused
|
|
58
|
+
return fail_stream(refusal_error,
|
|
59
|
+
usage_known: @done && @usage_authoritative, &emit)
|
|
60
|
+
end
|
|
41
61
|
return fail_stream("stream ended without message_stop", &emit) unless @done
|
|
62
|
+
if @open_wire_index
|
|
63
|
+
return fail_stream("content block ended without content_block_stop", &emit)
|
|
64
|
+
end
|
|
42
65
|
|
|
66
|
+
invalidate_cost unless @usage_authoritative
|
|
43
67
|
@message = assemble(stop_reason: @stop_reason || StopReason::STOP)
|
|
44
68
|
emit&.call(Event.new(type: :done, reason: @message.stop_reason, message: @message))
|
|
45
69
|
@message
|
|
46
70
|
end
|
|
47
71
|
|
|
48
72
|
def abort(&emit)
|
|
49
|
-
|
|
73
|
+
close_current(interrupted: true, &emit)
|
|
74
|
+
invalidate_cost
|
|
50
75
|
@message = assemble(stop_reason: StopReason::ABORTED, error_message: "aborted")
|
|
51
76
|
emit&.call(Event.new(type: :error, reason: StopReason::ABORTED, message: @message,
|
|
52
77
|
error_message: "aborted"))
|
|
@@ -57,12 +82,22 @@ module Mistri
|
|
|
57
82
|
# as retryable, not fold into prose.
|
|
58
83
|
def wire_error(payload)
|
|
59
84
|
message = payload&.dig("message") || "provider error"
|
|
60
|
-
|
|
61
|
-
klass
|
|
85
|
+
type = payload&.dig("type").to_s
|
|
86
|
+
klass = case type
|
|
87
|
+
when "authentication_error" then AuthenticationError
|
|
88
|
+
when "rate_limit_error" then RateLimitError
|
|
89
|
+
when "overloaded_error" then OverloadedError
|
|
90
|
+
when "api_error" then ServerError
|
|
91
|
+
when "timeout_error" then ProviderError
|
|
92
|
+
else InvalidRequestError
|
|
93
|
+
end
|
|
94
|
+
detail = type.empty? ? message : "#{type}: #{message}"
|
|
95
|
+
klass.new(detail)
|
|
62
96
|
end
|
|
63
97
|
|
|
64
|
-
def fail_stream(reason, &emit)
|
|
65
|
-
|
|
98
|
+
def fail_stream(reason, usage_known: false, &emit)
|
|
99
|
+
close_current(interrupted: true, &emit)
|
|
100
|
+
invalidate_cost unless usage_known
|
|
66
101
|
text = case reason
|
|
67
102
|
when ProviderError then "#{reason.class}: #{reason.describe}"
|
|
68
103
|
when Exception then "#{reason.class}: #{reason.message}"
|
|
@@ -77,35 +112,110 @@ module Mistri
|
|
|
77
112
|
|
|
78
113
|
def message = @message ||= finish
|
|
79
114
|
|
|
80
|
-
Builder = Struct.new(:kind, :index, :text, :json, :signature, :id, :name, :redacted
|
|
115
|
+
Builder = Struct.new(:kind, :index, :text, :json, :signature, :id, :name, :redacted,
|
|
116
|
+
:argument_bytes, :argument_error, :argument_preview, :preview_bytes)
|
|
117
|
+
DELTA_KINDS = {
|
|
118
|
+
"text_delta" => :text,
|
|
119
|
+
"thinking_delta" => :thinking,
|
|
120
|
+
"signature_delta" => :thinking,
|
|
121
|
+
"input_json_delta" => :toolcall
|
|
122
|
+
}.freeze
|
|
123
|
+
|
|
124
|
+
PREVIEW_EAGER_BYTES = 4 * 1024
|
|
125
|
+
PREVIEW_STEP_BYTES = 4 * 1024
|
|
126
|
+
PREVIEW_MAX_BYTES = 64 * 1024
|
|
127
|
+
MAX_SIGNATURE_BYTES = ToolArguments::MAX_BYTES
|
|
128
|
+
MAX_REFUSAL_BYTES = 2048
|
|
129
|
+
|
|
130
|
+
def start(&emit)
|
|
131
|
+
emit&.call(Event.new(type: :start, partial: assemble))
|
|
132
|
+
end
|
|
81
133
|
|
|
82
134
|
private
|
|
83
135
|
|
|
136
|
+
def message_start(record)
|
|
137
|
+
raw = record.dig("message", "usage")
|
|
138
|
+
@service_tier = raw&.fetch("service_tier", nil)
|
|
139
|
+
@usage = priced(parse_usage(raw))
|
|
140
|
+
end
|
|
141
|
+
|
|
84
142
|
def start_block(record, &)
|
|
143
|
+
return if @error
|
|
144
|
+
return protocol_error("stream opened content after the message delta phase", &) \
|
|
145
|
+
if @message_phase
|
|
146
|
+
if @open_wire_index
|
|
147
|
+
return protocol_error("stream opened a content block before closing the prior block", &)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
index = record["index"]
|
|
151
|
+
unless index == @next_wire_index
|
|
152
|
+
return protocol_error("stream opened a content block at an unexpected index", &)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
@open_wire_index = index
|
|
156
|
+
@next_wire_index += 1
|
|
157
|
+
|
|
85
158
|
block = record["content_block"] || {}
|
|
159
|
+
if block["type"] == "tool_use" && block.key?("input") &&
|
|
160
|
+
(!block["input"].is_a?(Hash) || !block["input"].empty?)
|
|
161
|
+
return protocol_error("tool_use started with nonempty input before its deltas", &)
|
|
162
|
+
end
|
|
163
|
+
|
|
86
164
|
kind = { "text" => :text, "thinking" => :thinking, "redacted_thinking" => :thinking,
|
|
87
165
|
"tool_use" => :toolcall }[block["type"]]
|
|
88
166
|
return unless kind
|
|
89
167
|
|
|
90
168
|
@current = Builder.new(kind, @blocks.size, +"", +"", nil,
|
|
91
|
-
block["id"], block["name"], block["type"] == "redacted_thinking"
|
|
169
|
+
block["id"], block["name"], block["type"] == "redacted_thinking",
|
|
170
|
+
0, nil, ToolArguments::EMPTY_OBJECT, 0)
|
|
92
171
|
@current.signature = block["data"] if @current.redacted
|
|
93
172
|
emit_event(:"#{kind}_start", content_index: @current.index, &)
|
|
94
173
|
end
|
|
95
174
|
|
|
96
175
|
def delta_block(record, &)
|
|
176
|
+
return if @error
|
|
177
|
+
return protocol_error("stream sent content after the message delta phase", &) \
|
|
178
|
+
if @message_phase
|
|
179
|
+
unless @open_wire_index && record["index"] == @open_wire_index
|
|
180
|
+
return protocol_error("stream sent a delta for a different content block", &)
|
|
181
|
+
end
|
|
97
182
|
return unless @current
|
|
98
183
|
|
|
99
184
|
delta = record["delta"] || {}
|
|
185
|
+
expected = DELTA_KINDS[delta["type"]]
|
|
186
|
+
return unless expected
|
|
187
|
+
unless @current.kind == expected
|
|
188
|
+
return protocol_error("stream sent a delta outside its matching content block", &)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
route_delta(delta, &)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def route_delta(delta, &)
|
|
100
195
|
case delta["type"]
|
|
101
|
-
when "text_delta"
|
|
102
|
-
|
|
196
|
+
when "text_delta"
|
|
197
|
+
text_delta(delta["text"], &)
|
|
198
|
+
when "thinking_delta"
|
|
199
|
+
thinking_delta(delta["thinking"], &)
|
|
103
200
|
when "signature_delta"
|
|
104
|
-
|
|
105
|
-
when "input_json_delta"
|
|
201
|
+
signature_delta(delta["signature"])
|
|
202
|
+
when "input_json_delta"
|
|
203
|
+
input_delta(delta["partial_json"], &)
|
|
106
204
|
end
|
|
107
205
|
end
|
|
108
206
|
|
|
207
|
+
def signature_delta(fragment)
|
|
208
|
+
fragment = fragment.to_s
|
|
209
|
+
current = @current.signature || +""
|
|
210
|
+
if fragment.bytesize > MAX_SIGNATURE_BYTES - current.bytesize
|
|
211
|
+
raise ResponseTooLargeError.new(kind: :thinking_signature,
|
|
212
|
+
limit: MAX_SIGNATURE_BYTES)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
@current.signature = current
|
|
216
|
+
current << fragment
|
|
217
|
+
end
|
|
218
|
+
|
|
109
219
|
def text_delta(text, &)
|
|
110
220
|
@current.text << text.to_s
|
|
111
221
|
emit_event(:text_delta, content_index: @current.index, delta: text, &)
|
|
@@ -117,54 +227,122 @@ module Mistri
|
|
|
117
227
|
end
|
|
118
228
|
|
|
119
229
|
def input_delta(fragment, &)
|
|
120
|
-
@current
|
|
230
|
+
append_arguments(@current, fragment.to_s)
|
|
121
231
|
emit_event(:toolcall_delta, content_index: @current.index, delta: fragment, &)
|
|
122
232
|
end
|
|
123
233
|
|
|
124
|
-
def
|
|
125
|
-
return
|
|
234
|
+
def append_arguments(builder, fragment)
|
|
235
|
+
return if builder.argument_error
|
|
126
236
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
237
|
+
if fragment.bytesize > ToolArguments::MAX_BYTES - builder.argument_bytes
|
|
238
|
+
builder.argument_error = "too_large"
|
|
239
|
+
# String#clear may retain its backing allocation.
|
|
240
|
+
builder.json = +""
|
|
241
|
+
return
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
builder.json << fragment
|
|
245
|
+
builder.argument_bytes += fragment.bytesize
|
|
246
|
+
refresh_argument_preview(builder)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def refresh_argument_preview(builder)
|
|
250
|
+
target = [builder.argument_bytes, PREVIEW_MAX_BYTES].min
|
|
251
|
+
eager = target <= PREVIEW_EAGER_BYTES
|
|
252
|
+
milestone = target - builder.preview_bytes >= PREVIEW_STEP_BYTES
|
|
253
|
+
return unless eager || milestone || target == PREVIEW_MAX_BYTES
|
|
254
|
+
return if target == builder.preview_bytes
|
|
255
|
+
|
|
256
|
+
source = if target == builder.json.bytesize
|
|
257
|
+
builder.json
|
|
258
|
+
else
|
|
259
|
+
builder.json.byteslice(0, target)
|
|
260
|
+
end
|
|
261
|
+
builder.argument_preview = ToolArguments.freeze_partial(PartialJson.parse(source))
|
|
262
|
+
builder.preview_bytes = target
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def stop_block(record, &)
|
|
266
|
+
return if @error
|
|
267
|
+
return protocol_error("stream stopped content after the message delta phase", &) \
|
|
268
|
+
if @message_phase
|
|
269
|
+
unless @open_wire_index && record["index"] == @open_wire_index
|
|
270
|
+
return protocol_error("stream stopped a different content block", &)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
close_current(&) if @current
|
|
274
|
+
@open_wire_index = nil
|
|
133
275
|
end
|
|
134
276
|
|
|
135
|
-
def message_delta(record)
|
|
277
|
+
def message_delta(record, &)
|
|
278
|
+
@message_phase = true
|
|
279
|
+
if @open_wire_index
|
|
280
|
+
return protocol_error("message delta arrived before the content block stopped", &)
|
|
281
|
+
end
|
|
282
|
+
|
|
136
283
|
reason = record.dig("delta", "stop_reason")
|
|
137
284
|
@stop_reason = map_stop_reason(reason) if reason
|
|
285
|
+
if reason == "refusal"
|
|
286
|
+
@refused = true
|
|
287
|
+
@refusal = record.dig("delta", "stop_details")
|
|
288
|
+
end
|
|
138
289
|
# message_delta usage is cumulative; merge output counts over the
|
|
139
290
|
# opening snapshot rather than summing.
|
|
140
291
|
output = record.dig("usage", "output_tokens")
|
|
141
|
-
|
|
292
|
+
return unless output
|
|
293
|
+
|
|
294
|
+
@usage = priced(@usage.with(output: output.to_i))
|
|
295
|
+
@usage_authoritative = true
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def stop_message(&)
|
|
299
|
+
protocol_error("message stopped before the content block stopped", &) if @open_wire_index
|
|
300
|
+
@done = true
|
|
142
301
|
end
|
|
143
302
|
|
|
144
|
-
def
|
|
303
|
+
def close_current(interrupted: false, &)
|
|
145
304
|
return unless @current
|
|
146
305
|
|
|
147
|
-
built = build_block(@current)
|
|
306
|
+
built = build_block(@current, interrupted:)
|
|
148
307
|
@blocks << built
|
|
149
308
|
@current = nil
|
|
309
|
+
kind = built.is_a?(ToolCall) ? :toolcall : built.type
|
|
310
|
+
fields = { content_index: @blocks.size - 1 }
|
|
311
|
+
fields[:tool_call] = built if built.is_a?(ToolCall)
|
|
312
|
+
fields[:content] = builder_text(built) unless built.is_a?(ToolCall)
|
|
313
|
+
emit_event(:"#{kind}_end", **fields, &)
|
|
150
314
|
built
|
|
151
315
|
end
|
|
152
316
|
|
|
153
|
-
def build_block(builder)
|
|
317
|
+
def build_block(builder, final: true, interrupted: false)
|
|
154
318
|
case builder.kind
|
|
155
319
|
when :text then Content::Text.new(text: builder.text)
|
|
156
320
|
when :thinking
|
|
157
|
-
|
|
158
|
-
|
|
321
|
+
signature = interrupted ? nil : builder.signature
|
|
322
|
+
redacted = interrupted ? false : builder.redacted
|
|
323
|
+
Content::Thinking.new(thinking: builder.text, signature:, redacted:)
|
|
159
324
|
when :toolcall
|
|
325
|
+
arguments, error = if interrupted
|
|
326
|
+
[nil, "incomplete"]
|
|
327
|
+
elsif final
|
|
328
|
+
if builder.argument_error
|
|
329
|
+
[nil, builder.argument_error]
|
|
330
|
+
else
|
|
331
|
+
parsed_arguments(builder.json)
|
|
332
|
+
end
|
|
333
|
+
else
|
|
334
|
+
[builder.argument_preview, nil]
|
|
335
|
+
end
|
|
160
336
|
ToolCall.new(id: builder.id, name: builder.name,
|
|
161
|
-
arguments:
|
|
337
|
+
arguments:, signature: nil, arguments_error: error,
|
|
338
|
+
canonicalize: final)
|
|
162
339
|
end
|
|
163
340
|
end
|
|
164
341
|
|
|
165
342
|
def parsed_arguments(json)
|
|
166
|
-
|
|
167
|
-
|
|
343
|
+
return [{}, nil] if json.empty?
|
|
344
|
+
|
|
345
|
+
ToolArguments.parse_json(json)
|
|
168
346
|
end
|
|
169
347
|
|
|
170
348
|
def builder_text(block)
|
|
@@ -175,23 +353,71 @@ module Mistri
|
|
|
175
353
|
emit&.call(Event.new(type:, partial: assemble, **fields))
|
|
176
354
|
end
|
|
177
355
|
|
|
356
|
+
def protocol_error(message, &)
|
|
357
|
+
@error ||= ProviderError.new(message)
|
|
358
|
+
close_current(interrupted: true, &)
|
|
359
|
+
@blocks.map! do |block|
|
|
360
|
+
if block.is_a?(ToolCall)
|
|
361
|
+
block.with(arguments: nil,
|
|
362
|
+
arguments_error: "incomplete")
|
|
363
|
+
else
|
|
364
|
+
block
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
@open_wire_index = nil
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
178
371
|
def assemble(**meta)
|
|
179
372
|
blocks = @blocks.dup
|
|
180
|
-
blocks << build_block(@current) if @current
|
|
373
|
+
blocks << build_block(@current, final: false) if @current
|
|
181
374
|
Message.assistant(content: blocks, model: @model, provider: :anthropic,
|
|
182
375
|
usage: @usage, **meta)
|
|
183
376
|
end
|
|
184
377
|
|
|
185
|
-
# pause_turn (a server tool paused a long turn) maps to tool_use so
|
|
186
|
-
# loop continues the turn rather than ending it
|
|
378
|
+
# pause_turn (a server tool paused a long turn) maps to tool_use so
|
|
379
|
+
# the loop continues the turn rather than ending it; a filled context
|
|
380
|
+
# window is a truncation, per the API's own guidance.
|
|
187
381
|
def map_stop_reason(reason)
|
|
188
382
|
{ "end_turn" => StopReason::STOP, "stop_sequence" => StopReason::STOP,
|
|
189
383
|
"max_tokens" => StopReason::LENGTH, "tool_use" => StopReason::TOOL_USE,
|
|
384
|
+
"model_context_window_exceeded" => StopReason::LENGTH,
|
|
190
385
|
"pause_turn" => StopReason::TOOL_USE }.fetch(reason, StopReason::STOP)
|
|
191
386
|
end
|
|
192
387
|
|
|
388
|
+
# The API's guidance for a refusal is a different model, never a
|
|
389
|
+
# retry of this one, so it fails fast; stop_details names the policy
|
|
390
|
+
# category when the API provides one.
|
|
391
|
+
def refusal_error
|
|
392
|
+
details = bounded_refusal(@refusal&.dig("category"),
|
|
393
|
+
@refusal&.dig("explanation"))
|
|
394
|
+
text = +"the model refused to respond"
|
|
395
|
+
text << " (#{details})" unless details.empty?
|
|
396
|
+
InvalidRequestError.new(text)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def bounded_refusal(*values)
|
|
400
|
+
output = +""
|
|
401
|
+
values.each do |value|
|
|
402
|
+
next unless value.is_a?(String)
|
|
403
|
+
|
|
404
|
+
separator = output.empty? ? "" : ": "
|
|
405
|
+
remaining = MAX_REFUSAL_BYTES - output.bytesize - separator.bytesize
|
|
406
|
+
break unless remaining.positive?
|
|
407
|
+
|
|
408
|
+
output << separator << utf8_prefix(value, remaining)
|
|
409
|
+
end
|
|
410
|
+
output
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def utf8_prefix(value, limit)
|
|
414
|
+
prefix = value.byteslice(0, limit).dup.force_encoding(value.encoding)
|
|
415
|
+
prefix.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
416
|
+
.scrub.byteslice(0, limit).to_s.force_encoding(Encoding::UTF_8).scrub
|
|
417
|
+
end
|
|
418
|
+
|
|
193
419
|
def parse_usage(raw)
|
|
194
|
-
return Usage.
|
|
420
|
+
return Usage.new unless raw
|
|
195
421
|
|
|
196
422
|
cache_creation = raw["cache_creation"] || {}
|
|
197
423
|
Usage.new(input: raw["input_tokens"].to_i, output: raw["output_tokens"].to_i,
|
|
@@ -199,7 +425,19 @@ module Mistri
|
|
|
199
425
|
cache_write: raw["cache_creation_input_tokens"].to_i,
|
|
200
426
|
cache_write_1h: cache_creation["ephemeral_1h_input_tokens"].to_i)
|
|
201
427
|
end
|
|
202
|
-
|
|
428
|
+
|
|
429
|
+
def priced(usage)
|
|
430
|
+
return usage unless @catalog_pricing
|
|
431
|
+
return usage unless @service_tier == "standard"
|
|
432
|
+
|
|
433
|
+
rates = Models.rates(@model, usage:, at: @pricing_at)
|
|
434
|
+
rates ? usage.with_cost(rates) : usage
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def invalidate_cost
|
|
438
|
+
@usage = @usage.with(cost: @usage.cost.with(known: false))
|
|
439
|
+
end
|
|
440
|
+
end # rubocop:enable Metrics/ClassLength
|
|
203
441
|
end
|
|
204
442
|
end
|
|
205
443
|
end
|
|
@@ -42,30 +42,35 @@ module Mistri
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def message(msg)
|
|
45
|
-
|
|
45
|
+
own = msg.provider == :anthropic
|
|
46
|
+
{ role: msg.role.to_s,
|
|
47
|
+
content: msg.content.filter_map { |content| block(content, own:) } }
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def tool_results(group)
|
|
49
51
|
{ role: "user", content: group.map do |msg|
|
|
50
|
-
blocks = msg.content.filter_map { |
|
|
52
|
+
blocks = msg.content.filter_map { |content| block(content, own: false) }
|
|
51
53
|
# The API rejects an empty tool_result; a space stands in for a
|
|
52
54
|
# tool that returned nothing.
|
|
53
55
|
blocks = [{ type: "text", text: " " }] if blocks.empty?
|
|
54
|
-
{ type: "tool_result", tool_use_id: msg.tool_call_id, content: blocks }
|
|
56
|
+
result = { type: "tool_result", tool_use_id: msg.tool_call_id, content: blocks }
|
|
57
|
+
result[:is_error] = true if msg.tool_error?
|
|
58
|
+
result
|
|
55
59
|
end }
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
# Returns nil for a block the API would reject (empty text, unusable
|
|
59
63
|
# thinking), so callers filter_map it out.
|
|
60
|
-
def block(block)
|
|
64
|
+
def block(block, own: true)
|
|
61
65
|
case block
|
|
62
66
|
when Content::Text then text_block(block)
|
|
63
|
-
when Content::Thinking then thinking_block(block)
|
|
67
|
+
when Content::Thinking then thinking_block(block, own:)
|
|
64
68
|
when Content::Image
|
|
65
69
|
{ type: "image",
|
|
66
70
|
source: { type: "base64", media_type: block.mime_type, data: block.data } }
|
|
67
71
|
when ToolCall
|
|
68
|
-
{ type: "tool_use", id: block.id, name: block.name,
|
|
72
|
+
{ type: "tool_use", id: block.id, name: block.name,
|
|
73
|
+
input: ToolArguments.replay_object(block) }
|
|
69
74
|
else
|
|
70
75
|
raise SchemaError, "cannot serialize #{block.class} for Anthropic"
|
|
71
76
|
end
|
|
@@ -80,9 +85,9 @@ module Mistri
|
|
|
80
85
|
# its opaque payload; a normal thinking block missing its signature
|
|
81
86
|
# (an aborted turn cut before signature_delta) cannot replay, so it
|
|
82
87
|
# degrades to its text, or drops when even that is empty.
|
|
83
|
-
def thinking_block(block)
|
|
84
|
-
return { type: "redacted_thinking", data: block.signature } if block.redacted?
|
|
85
|
-
if block.signature
|
|
88
|
+
def thinking_block(block, own: true)
|
|
89
|
+
return { type: "redacted_thinking", data: block.signature } if own && block.redacted?
|
|
90
|
+
if own && block.signature
|
|
86
91
|
return { type: "thinking", thinking: block.thinking,
|
|
87
92
|
signature: block.signature }
|
|
88
93
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "schema_capabilities"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
module Providers
|
|
5
7
|
# The Anthropic Messages API, streamed. Defaults target the current model
|
|
@@ -10,41 +12,58 @@ module Mistri
|
|
|
10
12
|
# raising: the loop decides whether to retry, and the host always gets a
|
|
11
13
|
# message back.
|
|
12
14
|
class Anthropic
|
|
15
|
+
DEFAULT_ORIGIN = "https://api.anthropic.com"
|
|
13
16
|
VERSION_HEADER = "2023-06-01"
|
|
14
17
|
DEFAULT_THINKING = { type: "adaptive", display: "summarized" }.freeze
|
|
15
18
|
|
|
16
19
|
# Messages API parameters passed through verbatim from a stream override.
|
|
17
|
-
PASSTHROUGH = %i[temperature top_p top_k stop_sequences metadata
|
|
18
|
-
tool_choice service_tier].freeze
|
|
20
|
+
PASSTHROUGH = %i[temperature top_p top_k stop_sequences metadata tool_choice].freeze
|
|
19
21
|
# The ceiling for an uncatalogued model: high enough for headroom, low
|
|
20
22
|
# enough that every current model accepts it. Catalog a model to unlock
|
|
21
23
|
# its real output limit.
|
|
22
24
|
UNKNOWN_MODEL_MAX_TOKENS = 64_000
|
|
23
25
|
|
|
24
|
-
def initialize(api_key:, model: "claude-opus-4-8", origin:
|
|
26
|
+
def initialize(api_key:, model: "claude-opus-4-8", origin: DEFAULT_ORIGIN,
|
|
25
27
|
max_tokens: nil, thinking: DEFAULT_THINKING, cache: true,
|
|
28
|
+
service_tier: nil, catalog_pricing: nil,
|
|
26
29
|
**transport_options)
|
|
27
30
|
@api_key = api_key
|
|
28
31
|
@model = model
|
|
29
32
|
@max_tokens = max_tokens
|
|
30
33
|
@thinking = thinking
|
|
31
34
|
@cache = cache
|
|
35
|
+
@service_tier = service_tier
|
|
36
|
+
@catalog_pricing = catalog_pricing.nil? ? official_origin?(origin) : catalog_pricing
|
|
32
37
|
@transport = Transport.new(origin: origin, **transport_options)
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
attr_reader :model
|
|
36
41
|
|
|
42
|
+
def prices_usage?
|
|
43
|
+
@catalog_pricing && Models.priced?(model) && @service_tier.to_s == "standard_only"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def native_output_schema(schema)
|
|
47
|
+
return unless Models.find(model)&.provider == :anthropic
|
|
48
|
+
|
|
49
|
+
SchemaCapabilities.derive(schema, :anthropic)
|
|
50
|
+
end
|
|
51
|
+
|
|
37
52
|
def stream(messages:, system: nil, tools: [], signal: nil, **overrides, &emit)
|
|
53
|
+
delivery = EventDelivery.wrap(emit)
|
|
38
54
|
model = overrides.fetch(:model, @model)
|
|
39
|
-
assembler = Anthropic::Assembler.new(model: model)
|
|
55
|
+
assembler = Anthropic::Assembler.new(model: model, catalog_pricing: @catalog_pricing)
|
|
56
|
+
assembler.start(&delivery)
|
|
40
57
|
body = build_body(model, messages, system, tools, overrides)
|
|
41
58
|
outcome = @transport.stream_post("/v1/messages", body: body, headers: headers,
|
|
42
59
|
signal: signal) do |record|
|
|
43
60
|
assembler.feed(
|
|
44
|
-
record, &
|
|
61
|
+
record, &delivery
|
|
45
62
|
)
|
|
46
63
|
end
|
|
47
|
-
outcome == :aborted ? assembler.abort(&
|
|
64
|
+
outcome == :aborted ? assembler.abort(&delivery) : assembler.finish(&delivery)
|
|
65
|
+
rescue EventDelivery::Failure => e
|
|
66
|
+
raise EventDelivery.unwrap(e, delivery)
|
|
48
67
|
rescue Error => e
|
|
49
68
|
assembler.fail_stream(e, &emit)
|
|
50
69
|
end
|
|
@@ -53,6 +72,8 @@ module Mistri
|
|
|
53
72
|
|
|
54
73
|
private
|
|
55
74
|
|
|
75
|
+
def official_origin?(origin) = origin.to_s.delete_suffix("/") == DEFAULT_ORIGIN
|
|
76
|
+
|
|
56
77
|
def build_body(model, messages, system, tools, overrides)
|
|
57
78
|
body = {
|
|
58
79
|
model: model,
|
|
@@ -63,6 +84,8 @@ module Mistri
|
|
|
63
84
|
system_blocks = Serializer.system_blocks(system, cache: @cache)
|
|
64
85
|
body[:system] = system_blocks if system_blocks
|
|
65
86
|
body[:tools] = Serializer.tools(tools) if tools.any?
|
|
87
|
+
service_tier = overrides.fetch(:service_tier, @service_tier)
|
|
88
|
+
body[:service_tier] = service_tier if service_tier
|
|
66
89
|
thinking = thinking_for(model, overrides)
|
|
67
90
|
body[:thinking] = thinking if thinking
|
|
68
91
|
if (schema = overrides[:output_schema])
|