mistri 0.5.0 → 0.6.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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -4
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +289 -385
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/agent.rb +575 -55
  28. data/lib/mistri/budget.rb +26 -1
  29. data/lib/mistri/child.rb +72 -16
  30. data/lib/mistri/compaction.rb +26 -10
  31. data/lib/mistri/compactor.rb +34 -11
  32. data/lib/mistri/console.rb +28 -7
  33. data/lib/mistri/content.rb +9 -3
  34. data/lib/mistri/dispatchers.rb +14 -12
  35. data/lib/mistri/errors.rb +83 -4
  36. data/lib/mistri/event.rb +24 -8
  37. data/lib/mistri/event_delivery.rb +60 -0
  38. data/lib/mistri/locks.rb +3 -3
  39. data/lib/mistri/mcp/client.rb +74 -19
  40. data/lib/mistri/mcp/egress.rb +216 -0
  41. data/lib/mistri/mcp/oauth.rb +476 -127
  42. data/lib/mistri/mcp/wires.rb +115 -23
  43. data/lib/mistri/mcp.rb +42 -8
  44. data/lib/mistri/message.rb +21 -11
  45. data/lib/mistri/models.rb +160 -22
  46. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  47. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  48. data/lib/mistri/providers/anthropic.rb +29 -6
  49. data/lib/mistri/providers/fake.rb +26 -10
  50. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  51. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  52. data/lib/mistri/providers/gemini.rb +31 -5
  53. data/lib/mistri/providers/openai/assembler.rb +337 -60
  54. data/lib/mistri/providers/openai/serializer.rb +13 -12
  55. data/lib/mistri/providers/openai.rb +29 -5
  56. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  57. data/lib/mistri/result.rb +1 -1
  58. data/lib/mistri/schema.rb +893 -75
  59. data/lib/mistri/session.rb +560 -48
  60. data/lib/mistri/sinks/coalesced.rb +17 -10
  61. data/lib/mistri/spawner.rb +111 -61
  62. data/lib/mistri/sse.rb +57 -14
  63. data/lib/mistri/stores/active_record.rb +1 -1
  64. data/lib/mistri/stores/memory.rb +21 -2
  65. data/lib/mistri/sub_agent/execution.rb +81 -0
  66. data/lib/mistri/sub_agent/runtime.rb +297 -0
  67. data/lib/mistri/sub_agent.rb +124 -87
  68. data/lib/mistri/task_output.rb +24 -6
  69. data/lib/mistri/tool.rb +93 -13
  70. data/lib/mistri/tool_arguments.rb +377 -0
  71. data/lib/mistri/tool_call.rb +43 -9
  72. data/lib/mistri/tool_context.rb +4 -2
  73. data/lib/mistri/tool_executor.rb +117 -26
  74. data/lib/mistri/tool_result.rb +15 -10
  75. data/lib/mistri/tools/edit_file.rb +62 -8
  76. data/lib/mistri/tools.rb +41 -4
  77. data/lib/mistri/transport.rb +149 -44
  78. data/lib/mistri/usage.rb +65 -13
  79. data/lib/mistri/version.rb +1 -1
  80. data/lib/mistri/workspace/active_record.rb +183 -3
  81. data/lib/mistri/workspace/directory.rb +28 -8
  82. data/lib/mistri/workspace/memory.rb +34 -9
  83. data/lib/mistri/workspace/single.rb +62 -5
  84. data/lib/mistri/workspace.rb +39 -0
  85. data/lib/mistri.rb +6 -1
  86. data/mistri.gemspec +34 -0
  87. 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
- # parse via PartialJson so consumers can read them mid-stream.
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.zero
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
- case record["type"]
25
- when "message_start" then @usage = parse_usage(record.dig("message", "usage"))
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 @done = true
31
- when "error" then @error = wire_error(record["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
- finalize_current
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
- klass = payload&.dig("type").to_s.include?("overloaded") ? OverloadedError : ProviderError
61
- klass.new(message)
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
- finalize_current
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" then text_delta(delta["text"], &)
102
- when "thinking_delta" then thinking_delta(delta["thinking"], &)
196
+ when "text_delta"
197
+ text_delta(delta["text"], &)
198
+ when "thinking_delta"
199
+ thinking_delta(delta["thinking"], &)
103
200
  when "signature_delta"
104
- @current.signature = "#{@current.signature}#{delta["signature"]}"
105
- when "input_json_delta" then input_delta(delta["partial_json"], &)
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.json << fragment.to_s
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 stop_block(_record, &)
125
- return unless @current
234
+ def append_arguments(builder, fragment)
235
+ return if builder.argument_error
126
236
 
127
- block = finalize_current
128
- kind = block.is_a?(ToolCall) ? :toolcall : block.type
129
- fields = { content_index: @blocks.size - 1 }
130
- fields[:tool_call] = block if block.is_a?(ToolCall)
131
- fields[:content] = @blocks.last.is_a?(ToolCall) ? nil : builder_text(block)
132
- emit_event(:"#{kind}_end", **fields.compact, &)
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
- @usage = @usage.with(output: output.to_i) if output
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 finalize_current
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
- Content::Thinking.new(thinking: builder.text, signature: builder.signature,
158
- redacted: builder.redacted)
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: parsed_arguments(builder.json), signature: nil)
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
- parsed = json.strip.empty? ? {} : PartialJson.parse(json)
167
- parsed.is_a?(Hash) ? parsed : {}
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 the
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.zero unless raw
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
- end
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
- { role: msg.role.to_s, content: msg.content.filter_map { |block| block(block) } }
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 { |block| block(block) }
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, input: block.arguments }
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: "https://api.anthropic.com",
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, &emit
61
+ record, &delivery
45
62
  )
46
63
  end
47
- outcome == :aborted ? assembler.abort(&emit) : assembler.finish(&emit)
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])