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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +469 -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 +1 -1
- 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 +183 -3
- 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
|
@@ -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
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
22
|
+
@usage = Usage.new
|
|
20
23
|
@status = nil
|
|
21
24
|
@incomplete_reason = nil
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def feed(record, &)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
30
|
-
when "response.
|
|
31
|
-
when "response.
|
|
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
|
|
34
|
-
when "error" then @error
|
|
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
|
-
|
|
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(
|
|
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(
|
|
92
|
-
return unless
|
|
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(
|
|
102
|
-
return unless
|
|
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(
|
|
114
|
-
return unless
|
|
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
|
-
|
|
117
|
-
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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:
|
|
318
|
+
arguments:, signature: item["id"], arguments_error: error)
|
|
149
319
|
end
|
|
150
320
|
end
|
|
151
321
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
464
|
+
return nil if final
|
|
465
|
+
|
|
199
466
|
ToolCall.new(id: "pending", name: "pending",
|
|
200
|
-
arguments:
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
17
|
-
reasoning: DEFAULT_REASONING,
|
|
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, &
|
|
51
|
+
record, &delivery
|
|
34
52
|
)
|
|
35
53
|
end
|
|
36
|
-
outcome == :aborted ? assembler.abort(&
|
|
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])
|