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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +480 -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 +11 -4
  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 +190 -5
  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
@@ -10,38 +10,61 @@ module Mistri
10
10
  # output_item.done closes it with the complete item, whose ids and
11
11
  # encrypted reasoning land in the signature slots for replay.
12
12
  #
13
- # Unknown event and item types are skipped by contract.
14
- class Assembler
15
- def initialize(model:)
13
+ # Argument deltas expose a bounded, cached preview; the completed item
14
+ # stays authoritative. Unknown event and item types are skipped by contract.
15
+ class Assembler # rubocop:disable Metrics/ClassLength -- one stream owns item order
16
+ def initialize(model:, catalog_pricing: true)
16
17
  @model = model
18
+ @catalog_pricing = catalog_pricing
19
+ @pricing_at = Time.now
17
20
  @blocks = []
18
21
  @current = nil
19
- @usage = Usage.zero
22
+ @usage = Usage.new
20
23
  @status = nil
21
24
  @incomplete_reason = nil
22
25
  end
23
26
 
24
27
  def feed(record, &)
25
- case record["type"]
26
- when "response.output_item.added" then start_item(record["item"], &)
27
- when "response.output_text.delta" then text_delta(record["delta"], &)
28
+ type = record["type"]
29
+ if @status && STREAM_EVENTS.include?(type)
30
+ return protocol_error("response continued after its terminal event", &)
31
+ end
32
+
33
+ dispatch_record(record, &)
34
+ end
35
+
36
+ def dispatch_record(record, &)
37
+ type = record["type"]
38
+ case type
39
+ when "response.output_item.added" then start_item(record, &)
40
+ when "response.output_text.delta" then text_delta(record, &)
28
41
  when "response.reasoning_summary_text.delta"
29
- thinking_delta(record["delta"], record["summary_index"], &)
30
- when "response.function_call_arguments.delta" then arguments_delta(record["delta"], &)
31
- when "response.output_item.done" then finish_item(record["item"], &)
42
+ thinking_delta(record, &)
43
+ when "response.refusal.delta" then refusal_delta(record, &)
44
+ when "response.function_call_arguments.delta" then arguments_delta(record, &)
45
+ when "response.output_item.done" then finish_item(record, &)
32
46
  when "response.completed", "response.incomplete", "response.failed"
33
- finish_response(record["response"] || {})
34
- when "error" then @error = wire_error(record)
47
+ finish_response(record, &)
48
+ when "error" then @error ||= wire_error(record)
35
49
  end
36
50
  end
51
+ private :dispatch_record
37
52
 
38
53
  # A stream that ended without a terminal response event was truncated,
39
54
  # not cancelled: fail it so the loop can treat it as retryable.
40
55
  def finish(&emit)
41
56
  return fail_stream(@error, &emit) if @error
57
+ return fail_stream(@refusal_error, &emit) if @refusal_error
58
+ return fail_stream(filter_error, &emit) if @incomplete_reason == "content_filter"
59
+
60
+ if @status == "incomplete" && @incomplete_reason != "max_output_tokens"
61
+ detail = @incomplete_reason || "unspecified reason"
62
+ return fail_stream("response was incomplete: #{detail}", &emit)
63
+ end
42
64
  return fail_stream("stream ended without a terminal event", &emit) unless @status
65
+ return fail_stream("output item ended without output_item.done", &emit) if @current
43
66
 
44
- @message = assemble(stop_reason: stop_reason)
67
+ @message = assemble(final: true, stop_reason: stop_reason)
45
68
  emit&.call(Event.new(type: :done, reason: @message.stop_reason, message: @message))
46
69
  @message
47
70
  end
@@ -55,11 +78,7 @@ module Mistri
55
78
  def wire_error(record)
56
79
  message = record["message"] || "provider error"
57
80
  code = record["code"].to_s
58
- klass = if code.include?("rate_limit") then RateLimitError
59
- elsif code.include?("server") then ServerError
60
- else ProviderError
61
- end
62
- klass.new(message)
81
+ classify_error(code, message, unknown: ProviderError)
63
82
  end
64
83
 
65
84
  def fail_stream(reason, &)
@@ -73,58 +92,174 @@ module Mistri
73
92
 
74
93
  def message = @message ||= finish
75
94
 
76
- Builder = Struct.new(:kind, :index, :text, :json)
95
+ Builder = Struct.new(:kind, :index, :output_index, :text, :json,
96
+ :argument_bytes, :argument_error,
97
+ :argument_preview, :preview_bytes, :item_id, :call_id, :name)
77
98
  KINDS = { "message" => :text, "reasoning" => :thinking,
78
99
  "function_call" => :toolcall }.freeze
100
+ STREAM_EVENTS = %w[
101
+ response.output_item.added response.output_text.delta
102
+ response.reasoning_summary_text.delta response.refusal.delta
103
+ response.function_call_arguments.delta response.output_item.done
104
+ response.completed response.incomplete response.failed error
105
+ ].freeze
106
+ TERMINAL_STATUSES = {
107
+ "response.completed" => "completed",
108
+ "response.incomplete" => "incomplete",
109
+ "response.failed" => "failed"
110
+ }.freeze
111
+ PREVIEW_EAGER_BYTES = 4 * 1024
112
+ PREVIEW_STEP_BYTES = 4 * 1024
113
+ PREVIEW_MAX_BYTES = 64 * 1024
114
+ MAX_REFUSAL_BYTES = 2048
115
+
116
+ def start(&emit)
117
+ emit&.call(Event.new(type: :start, partial: assemble))
118
+ end
79
119
 
80
120
  private
81
121
 
82
- def start_item(item, &)
122
+ def start_item(record, &)
123
+ return if @error
124
+ if @current
125
+ return protocol_error("response opened an output item before closing the prior item", &)
126
+ end
127
+
128
+ item = record["item"]
83
129
  kind = KINDS[item&.fetch("type", nil)]
84
130
  return unless kind
85
131
 
86
- @current = Builder.new(kind, @blocks.size, +"", +"")
132
+ @current = Builder.new(kind, @blocks.size, record["output_index"], +"", +"", 0, nil,
133
+ ToolArguments::EMPTY_OBJECT, 0,
134
+ item["id"], item["call_id"], item["name"])
87
135
  @summary_part = nil
88
136
  emit_event(:"#{kind}_start", content_index: @current.index, &)
89
137
  end
90
138
 
91
- def text_delta(delta, &)
92
- return unless @current
139
+ def text_delta(record, &)
140
+ return unless matching_current?(:text, "output text delta", record, &)
93
141
 
94
- @current.text << delta.to_s
95
- emit_event(:text_delta, content_index: @current.index, delta: delta, &)
142
+ @current.text << record["delta"].to_s
143
+ emit_event(:text_delta, content_index: @current.index, delta: record["delta"], &)
96
144
  end
97
145
 
98
146
  # A reasoning item carries one or more summary parts, each its own
99
147
  # paragraph; the boundary streams as a delta so live views keep the
100
148
  # break the finished text will have.
101
- def thinking_delta(delta, part, &)
102
- return unless @current
149
+ def thinking_delta(record, &)
150
+ return unless matching_current?(:thinking, "reasoning summary delta", record, &)
103
151
 
152
+ part = record["summary_index"]
104
153
  if @summary_part && part && part != @summary_part
105
154
  @current.text << "\n\n"
106
155
  emit_event(:thinking_delta, content_index: @current.index, delta: "\n\n", &)
107
156
  end
108
157
  @summary_part = part if part
109
- @current.text << delta.to_s
110
- emit_event(:thinking_delta, content_index: @current.index, delta: delta, &)
158
+ @current.text << record["delta"].to_s
159
+ emit_event(:thinking_delta, content_index: @current.index, delta: record["delta"], &)
111
160
  end
112
161
 
113
- def arguments_delta(delta, &)
114
- return unless @current
162
+ def arguments_delta(record, &)
163
+ return unless matching_current?(:toolcall, "function arguments delta", record, &)
164
+
165
+ append_arguments(@current, record["delta"].to_s)
166
+ emit_event(:toolcall_delta, content_index: @current.index, delta: record["delta"], &)
167
+ end
168
+
169
+ def refusal_delta(record, &)
170
+ return unless matching_current?(:text, "refusal delta", record, &)
171
+
172
+ available = MAX_REFUSAL_BYTES - (@refusal_preview&.bytesize || 0)
173
+ accepted = available.positive? ? bounded_utf8(record["delta"], available) : ""
174
+ @refusal_preview ||= +""
175
+ @refusal_preview << accepted
176
+ unless accepted.empty?
177
+ @current.text << accepted
178
+ emit_event(:text_delta, content_index: @current.index, delta: accepted, &)
179
+ end
180
+ @refusal_error = refusal_from(@refusal_preview)
181
+ end
182
+
183
+ def append_arguments(builder, fragment)
184
+ return if builder.argument_error
185
+
186
+ if fragment.bytesize > ToolArguments::MAX_BYTES - builder.argument_bytes
187
+ builder.argument_error = "too_large"
188
+ # String#clear may retain its backing allocation.
189
+ builder.json = +""
190
+ return
191
+ end
115
192
 
116
- @current.json << delta.to_s
117
- emit_event(:toolcall_delta, content_index: @current.index, delta: delta, &)
193
+ builder.json << fragment
194
+ builder.argument_bytes += fragment.bytesize
195
+ refresh_argument_preview(builder)
196
+ end
197
+
198
+ def refresh_argument_preview(builder)
199
+ target = [builder.argument_bytes, PREVIEW_MAX_BYTES].min
200
+ eager = target <= PREVIEW_EAGER_BYTES
201
+ milestone = target - builder.preview_bytes >= PREVIEW_STEP_BYTES
202
+ return unless eager || milestone || target == PREVIEW_MAX_BYTES
203
+ return if target == builder.preview_bytes
204
+
205
+ source = if target == builder.json.bytesize
206
+ builder.json
207
+ else
208
+ builder.json.byteslice(0, target)
209
+ end
210
+ builder.argument_preview = ToolArguments.freeze_partial(PartialJson.parse(source))
211
+ builder.preview_bytes = target
118
212
  end
119
213
 
120
214
  # The done item is authoritative: its text, arguments, ids, and
121
215
  # encrypted content replace whatever the deltas accumulated.
122
- def finish_item(item, &)
216
+ def finish_item(record, &)
217
+ return if @error
218
+
219
+ item = record["item"]
123
220
  kind = KINDS[item&.fetch("type", nil)]
124
- return unless kind
221
+ return unless kind || @current
222
+
223
+ problem = completed_item_problem(record, item, kind)
224
+ return protocol_error(problem, &) if problem
225
+ return if finish_refusal?(item, kind, &)
125
226
 
126
- index = @current&.index || @blocks.size
127
- block = build_block(kind, item)
227
+ commit_item(item, kind, &)
228
+ end
229
+
230
+ def completed_item_problem(record, item, kind)
231
+ return "response closed an output item that was never opened" unless @current
232
+ unless kind == @current.kind
233
+ return "response changed an output item's type before closing it"
234
+ end
235
+ if @current.item_id && item["id"] && item["id"] != @current.item_id
236
+ return "response closed a different output item than it opened"
237
+ end
238
+ if @current.output_index && record["output_index"] &&
239
+ record["output_index"] != @current.output_index
240
+ return "response closed an unexpected output index"
241
+ end
242
+
243
+ nil
244
+ end
245
+
246
+ def finish_refusal?(item, kind, &)
247
+ return false unless kind == :text && (error = refusal_error(item))
248
+
249
+ index = @current.index
250
+ @refusal_error = error
251
+ if @current.text.empty?
252
+ @current = nil
253
+ emit_event(:text_end, content_index: index, content: "", &)
254
+ else
255
+ close_current(&)
256
+ end
257
+ true
258
+ end
259
+
260
+ def commit_item(item, kind, &)
261
+ index = @current.index
262
+ block = build_block(kind, item, streamed: @current)
128
263
  @blocks << block
129
264
  @current = nil
130
265
  fields = { content_index: index }
@@ -135,7 +270,35 @@ module Mistri
135
270
  emit_event(:"#{kind}_end", **fields.compact, &)
136
271
  end
137
272
 
138
- def build_block(kind, item)
273
+ # A refusal is an explicit provider verdict outside the requested
274
+ # schema, not an empty successful answer to send through a fix loop.
275
+ def refusal_error(item)
276
+ part = Array(item["content"]).find { |candidate| candidate["type"] == "refusal" }
277
+ return unless part
278
+
279
+ detail = bounded_refusal(part["refusal"])
280
+ refusal_from(detail)
281
+ end
282
+
283
+ def refusal_from(detail)
284
+ message = "OpenAI refused the request"
285
+ message = "#{message}: #{detail}" unless detail.empty?
286
+ InvalidRequestError.new(message)
287
+ end
288
+
289
+ def bounded_refusal(value)
290
+ bounded_utf8(value, MAX_REFUSAL_BYTES)
291
+ end
292
+
293
+ def bounded_utf8(value, limit)
294
+ return "" unless value.is_a?(String)
295
+
296
+ prefix = value.byteslice(0, limit).dup.force_encoding(value.encoding)
297
+ prefix.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
298
+ .scrub.byteslice(0, limit).to_s.force_encoding(Encoding::UTF_8).scrub
299
+ end
300
+
301
+ def build_block(kind, item, streamed: nil)
139
302
  case kind
140
303
  when :text
141
304
  text = Array(item["content"]).filter_map { |part| part["text"] }.join
@@ -144,25 +307,57 @@ module Mistri
144
307
  summary = Array(item["summary"]).filter_map { |part| part["text"] }.join("\n\n")
145
308
  Content::Thinking.new(thinking: summary, signature: JSON.generate(item))
146
309
  when :toolcall
310
+ arguments, error = if item.key?("arguments")
311
+ ToolArguments.parse_json(item["arguments"])
312
+ elsif streamed&.argument_error
313
+ [nil, streamed.argument_error]
314
+ else
315
+ [nil, "invalid_json"]
316
+ end
147
317
  ToolCall.new(id: item["call_id"], name: item["name"],
148
- arguments: parse_arguments(item["arguments"]), signature: item["id"])
318
+ arguments:, signature: item["id"], arguments_error: error)
149
319
  end
150
320
  end
151
321
 
152
- def parse_arguments(raw)
153
- parsed = raw.to_s.strip.empty? ? {} : JSON.parse(raw)
154
- parsed.is_a?(Hash) ? parsed : {}
155
- rescue JSON::ParserError
156
- fallback = PartialJson.parse(raw)
157
- fallback.is_a?(Hash) ? fallback : {}
158
- end
159
-
160
- def finish_response(response)
161
- @status = response["status"] || "completed"
322
+ def finish_response(record, &)
323
+ response = record["response"] || {}
324
+ if @current
325
+ protocol_error("response ended before output_item.done closed its output item",
326
+ &)
327
+ end
328
+ expected = TERMINAL_STATUSES.fetch(record["type"])
329
+ if response["status"] && response["status"] != expected
330
+ protocol_error("response terminal event disagreed with its status", &)
331
+ end
332
+ @status = expected
162
333
  @incomplete_reason = response.dig("incomplete_details", "reason")
163
- @error = response.dig("error", "message") if @status == "failed"
334
+ @error ||= failure_error(response["error"]) if @status == "failed"
335
+ @service_tier = response["service_tier"]
164
336
  usage = response["usage"]
165
- @usage = parse_usage(usage) if usage
337
+ @usage = priced(parse_usage(usage)) if usage
338
+ end
339
+
340
+ # A failed response is the provider's verdict on this input, not a
341
+ # transport accident: only its documented transient codes retry
342
+ # (rate limits, server errors, timeouts). Everything else, like
343
+ # invalid_prompt and the image family, cannot succeed on a retry.
344
+ def failure_error(error)
345
+ return ProviderError.new("the response failed without an error") unless error
346
+
347
+ code = error["code"].to_s
348
+ message = [code, error["message"] || "the response failed"]
349
+ .reject(&:empty?).join(": ")
350
+ classify_error(code, message, unknown: InvalidRequestError)
351
+ end
352
+
353
+ def classify_error(code, message, unknown:)
354
+ klass = if code.include?("rate_limit") then RateLimitError
355
+ elsif code.include?("server") then ServerError
356
+ elsif code.include?("timeout") then ProviderError
357
+ elsif code.empty? then unknown
358
+ else InvalidRequestError
359
+ end
360
+ klass.new(message)
166
361
  end
167
362
 
168
363
  def stop_reason
@@ -172,8 +367,14 @@ module Mistri
172
367
  StopReason::STOP
173
368
  end
174
369
 
370
+ # The spec's only other incomplete reason: a filter cut is a verdict
371
+ # on the content, not a truncation, so it never reads as a clean stop
372
+ # and never retries.
373
+ def filter_error = InvalidRequestError.new("the response was cut by the content filter")
374
+
175
375
  def terminal(reason, text, error: nil, &emit)
176
- @message = assemble(stop_reason: reason, error_message: text, error: error)
376
+ close_current(interrupted: true, &emit)
377
+ @message = assemble(final: true, stop_reason: reason, error_message: text, error: error)
177
378
  emit&.call(Event.new(type: :error, reason: reason, message: @message,
178
379
  error_message: text))
179
380
  @message
@@ -183,21 +384,87 @@ module Mistri
183
384
  emit&.call(Event.new(type:, partial: assemble, **fields))
184
385
  end
185
386
 
186
- def assemble(**meta)
387
+ def matching_current?(kind, label, record, &)
388
+ return false if @error
389
+
390
+ unless @current&.kind == kind
391
+ protocol_error("response sent a #{label} outside its matching output item", &)
392
+ return false
393
+ end
394
+ if @current.item_id && record["item_id"] && record["item_id"] != @current.item_id
395
+ protocol_error("response sent a #{label} for a different output item", &)
396
+ return false
397
+ end
398
+ if @current.output_index && record["output_index"] &&
399
+ record["output_index"] != @current.output_index
400
+ protocol_error("response sent a #{label} for an unexpected output index", &)
401
+ return false
402
+ end
403
+
404
+ true
405
+ end
406
+
407
+ def protocol_error(message, &)
408
+ @error ||= ProviderError.new(message)
409
+ close_current(interrupted: true, &)
410
+ invalidate_tool_calls
411
+ nil
412
+ end
413
+
414
+ def invalidate_tool_calls
415
+ @blocks.map! do |block|
416
+ if block.is_a?(ToolCall)
417
+ block.with(arguments: nil, arguments_error: "incomplete")
418
+ else
419
+ block
420
+ end
421
+ end
422
+ end
423
+
424
+ def close_current(interrupted: false, &)
425
+ return unless @current
426
+
427
+ builder = @current
428
+ block = interrupted ? interrupted_block(builder) : partial_block(builder, final: true)
429
+ @blocks << block
430
+ @current = nil
431
+ fields = { content_index: builder.index }
432
+ fields[:tool_call] = block if block.is_a?(ToolCall)
433
+ unless block.is_a?(ToolCall)
434
+ fields[:content] = block.respond_to?(:text) ? block.text : block.thinking
435
+ end
436
+ emit_event(:"#{builder.kind}_end", **fields, &)
437
+ block
438
+ end
439
+
440
+ def interrupted_block(builder)
441
+ case builder.kind
442
+ when :text then Content::Text.new(text: builder.text)
443
+ when :thinking then Content::Thinking.new(thinking: builder.text)
444
+ when :toolcall
445
+ ToolCall.new(id: builder.call_id, name: builder.name, arguments: nil,
446
+ signature: builder.item_id, arguments_error: "incomplete")
447
+ end
448
+ end
449
+
450
+ def assemble(final: false, **meta)
187
451
  blocks = @blocks.dup
188
- blocks << partial_block(@current) if @current
452
+ if @current && (block = partial_block(@current, final:))
453
+ blocks << block
454
+ end
189
455
  Message.assistant(content: blocks, model: @model, provider: :openai,
190
456
  usage: @usage, **meta)
191
457
  end
192
458
 
193
- def partial_block(builder)
459
+ def partial_block(builder, final: false)
194
460
  case builder.kind
195
461
  when :text then Content::Text.new(text: builder.text)
196
462
  when :thinking then Content::Thinking.new(thinking: builder.text)
197
463
  when :toolcall
198
- args = PartialJson.parse(builder.json)
464
+ return nil if final
465
+
199
466
  ToolCall.new(id: "pending", name: "pending",
200
- arguments: args.is_a?(Hash) ? args : {})
467
+ arguments: builder.argument_preview, canonicalize: false)
201
468
  end
202
469
  end
203
470
 
@@ -205,11 +472,21 @@ module Mistri
205
472
  details = raw["input_tokens_details"] || {}
206
473
  output_details = raw["output_tokens_details"] || {}
207
474
  cache_read = details["cached_tokens"].to_i
208
- Usage.new(input: [raw["input_tokens"].to_i - cache_read, 0].max,
209
- output: raw["output_tokens"].to_i, cache_read: cache_read,
475
+ cache_write = details["cache_write_tokens"].to_i
476
+ input = [raw["input_tokens"].to_i - cache_read - cache_write, 0].max
477
+ Usage.new(input:, output: raw["output_tokens"].to_i, cache_read:,
478
+ cache_write:,
210
479
  reasoning: output_details["reasoning_tokens"].to_i)
211
480
  end
212
- end
481
+
482
+ def priced(usage)
483
+ return usage unless @catalog_pricing
484
+ return usage unless @service_tier == "default"
485
+
486
+ rates = Models.rates(@model, usage:, at: @pricing_at)
487
+ rates ? usage.with_cost(rates) : usage
488
+ end
489
+ end # rubocop:enable Metrics/ClassLength
213
490
  end
214
491
  end
215
492
  end
@@ -62,22 +62,23 @@ module Mistri
62
62
  end
63
63
 
64
64
  def assistant_items(msg)
65
- msg.content.filter_map { |block| assistant_item(block) }
65
+ own = msg.provider == :openai
66
+ msg.content.filter_map { |block| assistant_item(block, own:) }
66
67
  end
67
68
 
68
- def assistant_item(block)
69
+ def assistant_item(block, own: true)
69
70
  case block
70
- when Content::Thinking then reasoning_item(block)
71
- when Content::Text then message_item(block)
72
- when ToolCall then function_call_item(block)
71
+ when Content::Thinking then reasoning_item(block, own:)
72
+ when Content::Text then message_item(block, own:)
73
+ when ToolCall then function_call_item(block, own:)
73
74
  end
74
75
  end
75
76
 
76
77
  # The reasoning item replays exactly as it arrived or not at all. An
77
78
  # item without encrypted_content triggers a server-side id lookup that
78
79
  # cannot succeed under store: false, so it drops rather than 404s.
79
- def reasoning_item(block)
80
- return nil unless block.signature
80
+ def reasoning_item(block, own: true)
81
+ return nil unless own && block.signature
81
82
 
82
83
  item = JSON.parse(block.signature)
83
84
  item.is_a?(Hash) && item["encrypted_content"] ? item : nil
@@ -85,17 +86,17 @@ module Mistri
85
86
  nil
86
87
  end
87
88
 
88
- def message_item(block)
89
+ def message_item(block, own: true)
89
90
  item = { type: "message", role: "assistant",
90
91
  content: [{ type: "output_text", text: block.text }] }
91
- item[:id] = block.signature if block.signature
92
+ item[:id] = block.signature if own && block.signature
92
93
  item
93
94
  end
94
95
 
95
- def function_call_item(block)
96
+ def function_call_item(block, own: true)
96
97
  item = { type: "function_call", call_id: block.id, name: block.name,
97
- arguments: JSON.generate(block.arguments) }
98
- item[:id] = block.signature if block.signature
98
+ arguments: JSON.generate(ToolArguments.replay_value(block)) }
99
+ item[:id] = block.signature if own && block.signature && !block.arguments_error
99
100
  item
100
101
  end
101
102
  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 OpenAI Responses API, streamed and stateless: store is always false,
@@ -11,29 +13,47 @@ module Mistri
11
13
  # Provider failures fold into the stream as an error turn rather than
12
14
  # raising, matching the Anthropic provider's contract.
13
15
  class OpenAI
16
+ DEFAULT_ORIGIN = "https://api.openai.com"
14
17
  DEFAULT_REASONING = { summary: "auto" }.freeze
15
18
 
16
- def initialize(api_key:, model: "gpt-5.5", origin: "https://api.openai.com",
17
- reasoning: DEFAULT_REASONING, **transport_options)
19
+ def initialize(api_key:, model: "gpt-5.5", origin: DEFAULT_ORIGIN,
20
+ reasoning: DEFAULT_REASONING, service_tier: nil,
21
+ catalog_pricing: nil, **transport_options)
18
22
  @api_key = api_key
19
23
  @model = model
20
24
  @reasoning = reasoning
25
+ @service_tier = service_tier
26
+ @catalog_pricing = catalog_pricing.nil? ? official_origin?(origin) : catalog_pricing
21
27
  @transport = Transport.new(origin: origin, **transport_options)
22
28
  end
23
29
 
24
30
  attr_reader :model
25
31
 
32
+ def prices_usage?
33
+ @catalog_pricing && Models.priced?(model) && @service_tier.to_s == "default"
34
+ end
35
+
36
+ def native_output_schema(schema)
37
+ return unless Models.find(model)&.provider == :openai
38
+
39
+ SchemaCapabilities.derive(schema, :openai)
40
+ end
41
+
26
42
  def stream(messages:, system: nil, tools: [], signal: nil, **overrides, &emit)
43
+ delivery = EventDelivery.wrap(emit)
27
44
  model = overrides.fetch(:model, @model)
28
- assembler = OpenAI::Assembler.new(model: model)
45
+ assembler = OpenAI::Assembler.new(model: model, catalog_pricing: @catalog_pricing)
46
+ assembler.start(&delivery)
29
47
  body = build_body(model, messages, system, tools, overrides)
30
48
  outcome = @transport.stream_post("/v1/responses", body: body, headers: headers,
31
49
  signal: signal) do |record|
32
50
  assembler.feed(
33
- record, &emit
51
+ record, &delivery
34
52
  )
35
53
  end
36
- outcome == :aborted ? assembler.abort(&emit) : assembler.finish(&emit)
54
+ outcome == :aborted ? assembler.abort(&delivery) : assembler.finish(&delivery)
55
+ rescue EventDelivery::Failure => e
56
+ raise EventDelivery.unwrap(e, delivery)
37
57
  rescue Error => e
38
58
  assembler.fail_stream(e, &emit)
39
59
  end
@@ -42,6 +62,8 @@ module Mistri
42
62
 
43
63
  private
44
64
 
65
+ def official_origin?(origin) = origin.to_s.delete_suffix("/") == DEFAULT_ORIGIN
66
+
45
67
  def build_body(model, messages, system, tools, overrides)
46
68
  body = {
47
69
  model: model,
@@ -52,6 +74,8 @@ module Mistri
52
74
  }
53
75
  body[:instructions] = system if system && !system.empty?
54
76
  body[:tools] = Serializer.tools(tools) if tools.any?
77
+ service_tier = overrides.fetch(:service_tier, @service_tier)
78
+ body[:service_tier] = service_tier if service_tier
55
79
  reasoning = overrides.fetch(:reasoning, @reasoning)
56
80
  body[:reasoning] = reasoning if reasoning
57
81
  if (schema = overrides[:output_schema])