mistri 0.4.1 → 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 +596 -3
- data/CONTRIBUTING.md +52 -0
- data/README.md +291 -306
- 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/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +635 -108
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +186 -0
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +35 -12
- data/lib/mistri/console.rb +209 -0
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +49 -0
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +30 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- 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 +43 -9
- 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 +36 -6
- 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 +8 -3
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +649 -47
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +316 -0
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +22 -7
- data/lib/mistri/stores/jsonl.rb +3 -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 +238 -103
- data/lib/mistri/task_output.rb +58 -0
- data/lib/mistri/tool.rb +102 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +7 -5
- 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 +17 -1
- data/mistri.gemspec +34 -0
- metadata +38 -3
data/lib/mistri/mcp/wires.rb
CHANGED
|
@@ -13,22 +13,34 @@ module Mistri
|
|
|
13
13
|
# Streamable HTTP: requests POST to one endpoint, responses arrive as
|
|
14
14
|
# JSON or an SSE stream. Sessions and bearer auth live here.
|
|
15
15
|
class Http
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
HEADER_NAME = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
17
|
+
INVALID_HEADER_VALUE = /[\x00-\x1F\x7F]/n
|
|
18
|
+
RESERVED_HEADERS = %w[host content-length transfer-encoding connection keep-alive
|
|
19
|
+
proxy-authenticate proxy-authorization proxy-connection te trailer
|
|
20
|
+
upgrade accept content-type accept-encoding mcp-session-id
|
|
21
|
+
mcp-protocol-version].freeze
|
|
22
|
+
private_constant :HEADER_NAME, :INVALID_HEADER_VALUE, :RESERVED_HEADERS
|
|
23
|
+
|
|
24
|
+
def initialize(url:, token:, headers:, open_timeout:, read_timeout:, allow_non_public:,
|
|
25
|
+
max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
|
|
26
|
+
uri, resolver = Egress.resolver(url, allow_non_public:, label: "MCP URL",
|
|
27
|
+
timeout: open_timeout)
|
|
18
28
|
@path = uri.path.empty? ? "/" : uri.path
|
|
19
29
|
@path = "#{@path}?#{uri.query}" if uri.query
|
|
20
|
-
@transport = Transport.new(origin:
|
|
21
|
-
open_timeout: open_timeout, read_timeout: read_timeout
|
|
30
|
+
@transport = Transport.new(origin: Egress.origin(uri), address_resolver: resolver,
|
|
31
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
32
|
+
max_record_bytes: max_record_bytes)
|
|
22
33
|
@token = token
|
|
23
|
-
@headers = headers
|
|
34
|
+
@headers = normalized_headers(headers)
|
|
24
35
|
@session_id = nil
|
|
25
36
|
@protocol_version = nil
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
attr_writer :protocol_version
|
|
29
40
|
|
|
30
|
-
def call(payload, &)
|
|
31
|
-
meta = @transport.post_either(@path, body: payload, headers: request_headers,
|
|
41
|
+
def call(payload, replayable:, &)
|
|
42
|
+
meta = @transport.post_either(@path, body: payload, headers: request_headers,
|
|
43
|
+
replayable: replayable, &)
|
|
32
44
|
capture_session(meta)
|
|
33
45
|
nil
|
|
34
46
|
end
|
|
@@ -43,12 +55,33 @@ module Mistri
|
|
|
43
55
|
|
|
44
56
|
def refreshable? = @token.respond_to?(:call)
|
|
45
57
|
|
|
46
|
-
def reset_session
|
|
58
|
+
def reset_session
|
|
59
|
+
@session_id = nil
|
|
60
|
+
@protocol_version = nil
|
|
61
|
+
end
|
|
47
62
|
|
|
48
63
|
def close = @transport.close
|
|
49
64
|
|
|
50
65
|
private
|
|
51
66
|
|
|
67
|
+
def normalized_headers(headers)
|
|
68
|
+
headers.each_with_object({}) do |(key, value), copy|
|
|
69
|
+
name = key.to_s
|
|
70
|
+
content = value.to_s
|
|
71
|
+
unless name.ascii_only? && HEADER_NAME.match?(name)
|
|
72
|
+
raise ConfigurationError, "#{name.inspect} is not a valid HTTP header name"
|
|
73
|
+
end
|
|
74
|
+
if RESERVED_HEADERS.include?(name.downcase)
|
|
75
|
+
raise ConfigurationError, "#{name} is managed by the MCP transport"
|
|
76
|
+
end
|
|
77
|
+
if INVALID_HEADER_VALUE.match?(content.b)
|
|
78
|
+
raise ConfigurationError, "#{name} contains invalid control characters"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
copy[name.dup.freeze] = content.dup.freeze
|
|
82
|
+
end.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
52
85
|
def request_headers
|
|
53
86
|
headers = { "Accept" => "application/json, text/event-stream" }
|
|
54
87
|
headers.merge!(@headers)
|
|
@@ -72,14 +105,24 @@ module Mistri
|
|
|
72
105
|
# credentials in its environment, as the spec prescribes for local
|
|
73
106
|
# servers. Its stderr stays attached for honest local debugging.
|
|
74
107
|
class Stdio
|
|
75
|
-
|
|
108
|
+
READ_SIZE = 16 * 1024
|
|
109
|
+
BLANK_RECORD = /\A[[:space:]]*\z/
|
|
110
|
+
private_constant :READ_SIZE, :BLANK_RECORD
|
|
111
|
+
|
|
112
|
+
def initialize(command:, env: {}, read_timeout: 120,
|
|
113
|
+
max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
|
|
114
|
+
unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
|
|
115
|
+
raise ConfigurationError, "max_record_bytes: must be a positive integer"
|
|
116
|
+
end
|
|
117
|
+
|
|
76
118
|
@command = Array(command).map(&:to_s)
|
|
77
119
|
@env = env.transform_keys(&:to_s).transform_values(&:to_s)
|
|
78
120
|
@read_timeout = read_timeout
|
|
121
|
+
@max_record_bytes = max_record_bytes
|
|
79
122
|
@pid = nil
|
|
80
123
|
end
|
|
81
124
|
|
|
82
|
-
def call(payload)
|
|
125
|
+
def call(payload, **)
|
|
83
126
|
spawn_server unless @pid
|
|
84
127
|
write(payload)
|
|
85
128
|
loop do
|
|
@@ -105,11 +148,13 @@ module Mistri
|
|
|
105
148
|
def protocol_version=(_version); end
|
|
106
149
|
|
|
107
150
|
def close
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
if @pid
|
|
152
|
+
[@stdin, @stdout].each { |io| io.close unless io.closed? }
|
|
153
|
+
terminate
|
|
154
|
+
@pid = nil
|
|
155
|
+
end
|
|
156
|
+
@read_buffer&.clear
|
|
157
|
+
nil
|
|
113
158
|
end
|
|
114
159
|
|
|
115
160
|
private
|
|
@@ -118,6 +163,8 @@ module Mistri
|
|
|
118
163
|
child_in, @stdin = IO.pipe
|
|
119
164
|
@stdout, child_out = IO.pipe
|
|
120
165
|
@pid = Process.spawn(@env, *@command, in: child_in, out: child_out)
|
|
166
|
+
@read_buffer = +"".b
|
|
167
|
+
@newline_search_offset = 0
|
|
121
168
|
child_in.close
|
|
122
169
|
child_out.close
|
|
123
170
|
end
|
|
@@ -126,24 +173,69 @@ module Mistri
|
|
|
126
173
|
@stdin.write("#{JSON.generate(payload)}\n")
|
|
127
174
|
@stdin.flush
|
|
128
175
|
rescue Errno::EPIPE
|
|
129
|
-
raise
|
|
176
|
+
raise WireError, "the MCP server closed its input"
|
|
130
177
|
end
|
|
131
178
|
|
|
132
179
|
# The spec requires stdout to carry only protocol messages, so a
|
|
133
180
|
# line that is not one is corruption worth failing loudly on.
|
|
134
181
|
def read_record
|
|
182
|
+
deadline = if @read_timeout
|
|
183
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
|
|
184
|
+
end
|
|
135
185
|
loop do
|
|
136
|
-
|
|
137
|
-
|
|
186
|
+
line = complete_record
|
|
187
|
+
return JSON.parse(line) if line
|
|
138
188
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
189
|
+
oversized_record! if @read_buffer.bytesize > @max_record_bytes
|
|
190
|
+
chunk = read_chunk(deadline)
|
|
191
|
+
return JSON.parse(eof_record) unless chunk
|
|
142
192
|
|
|
143
|
-
|
|
193
|
+
@read_buffer << chunk
|
|
144
194
|
end
|
|
145
195
|
rescue JSON::ParserError
|
|
146
|
-
raise
|
|
196
|
+
raise WireError, "the MCP server wrote non-protocol output on stdout"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def complete_record
|
|
200
|
+
while (newline = @read_buffer.index("\n", @newline_search_offset))
|
|
201
|
+
oversized_record! if newline > @max_record_bytes
|
|
202
|
+
line = @read_buffer.slice!(0, newline + 1)
|
|
203
|
+
@newline_search_offset = 0
|
|
204
|
+
return line unless BLANK_RECORD.match?(line)
|
|
205
|
+
end
|
|
206
|
+
@newline_search_offset = @read_buffer.bytesize
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def read_chunk(deadline)
|
|
211
|
+
loop do
|
|
212
|
+
remaining = deadline && (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
213
|
+
if remaining && !remaining.positive?
|
|
214
|
+
raise WireError, "timed out waiting for the MCP server"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
ready = @stdout.wait_readable(remaining)
|
|
218
|
+
raise WireError, "timed out waiting for the MCP server" unless ready
|
|
219
|
+
|
|
220
|
+
room = @max_record_bytes - @read_buffer.bytesize + 1
|
|
221
|
+
chunk = @stdout.read_nonblock([READ_SIZE, room].min, exception: false)
|
|
222
|
+
return chunk unless chunk == :wait_readable
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def eof_record
|
|
227
|
+
raise WireError, "the MCP server exited" if @read_buffer.empty?
|
|
228
|
+
|
|
229
|
+
line = @read_buffer.slice!(0, @read_buffer.bytesize)
|
|
230
|
+
raise WireError, "the MCP server exited" if BLANK_RECORD.match?(line)
|
|
231
|
+
|
|
232
|
+
line
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def oversized_record!
|
|
236
|
+
error = ResponseTooLargeError.new(kind: :stdio_record, limit: @max_record_bytes)
|
|
237
|
+
close
|
|
238
|
+
raise error
|
|
147
239
|
end
|
|
148
240
|
|
|
149
241
|
def terminate
|
data/lib/mistri/mcp.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "json"
|
|
|
5
5
|
module Mistri
|
|
6
6
|
# Bridge Model Context Protocol servers into Mistri tools: list a server's
|
|
7
7
|
# tools, hand them to an agent, and everything the harness already does
|
|
8
|
-
# composes
|
|
8
|
+
# composes: approval gates on third-party write tools, retries, sub-agent
|
|
9
9
|
# pools, the ui channel.
|
|
10
10
|
#
|
|
11
11
|
# client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
|
|
@@ -17,6 +17,10 @@ module Mistri
|
|
|
17
17
|
# {"name", "description", "inputSchema"} hashes) and call_tool(name, args)
|
|
18
18
|
# bridges the same way, so the official mcp gem's client plugs in too.
|
|
19
19
|
module MCP
|
|
20
|
+
EMPTY_INPUT_SCHEMA = {
|
|
21
|
+
"type" => "object", "properties" => {}.freeze, "additionalProperties" => false
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
20
24
|
# A protocol-level failure: a JSON-RPC error, a missing response, an
|
|
21
25
|
# unsupported negotiation.
|
|
22
26
|
class Error < Mistri::Error
|
|
@@ -28,42 +32,69 @@ module Mistri
|
|
|
28
32
|
end
|
|
29
33
|
end
|
|
30
34
|
|
|
35
|
+
# The stdio peer failed while a protocol message was in flight.
|
|
36
|
+
class WireError < Error; end
|
|
37
|
+
|
|
31
38
|
# The server expired this client's session (a 404 with a session
|
|
32
39
|
# attached); the spec says start a fresh one, and Client does.
|
|
33
40
|
class SessionExpired < Error; end
|
|
34
41
|
|
|
42
|
+
# A remote URL cannot be used without weakening the MCP network boundary.
|
|
43
|
+
class UnsafeURLError < ConfigurationError; end
|
|
44
|
+
|
|
35
45
|
module_function
|
|
36
46
|
|
|
37
47
|
# The server's tools as Mistri tools. allow/deny filter by remote name,
|
|
38
48
|
# prefix namespaces local names ("linear__create_issue") against
|
|
39
49
|
# collisions, and gates marks tools needing human approval
|
|
40
50
|
# (gates: { "create_issue" => true }, or needs_approval: for all).
|
|
41
|
-
def tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {}
|
|
51
|
+
def tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {},
|
|
52
|
+
complete_argument_validator: nil)
|
|
42
53
|
listed = client.tools
|
|
43
54
|
listed = listed.select { |tool| allow.include?(tool["name"]) } if allow
|
|
44
55
|
listed = listed.reject { |tool| deny.include?(tool["name"]) }
|
|
45
56
|
listed.map do |tool|
|
|
46
|
-
bridge(client, tool, prefix: prefix, gate: gates.fetch(tool["name"], needs_approval)
|
|
57
|
+
bridge(client, tool, prefix: prefix, gate: gates.fetch(tool["name"], needs_approval),
|
|
58
|
+
complete_argument_validator: complete_argument_validator)
|
|
47
59
|
end
|
|
48
60
|
end
|
|
49
61
|
|
|
50
|
-
def bridge(client, spec, prefix: nil, gate: false)
|
|
62
|
+
def bridge(client, spec, prefix: nil, gate: false, complete_argument_validator: nil)
|
|
51
63
|
remote = spec.fetch("name")
|
|
52
64
|
local = prefix ? "#{prefix}__#{remote}" : remote
|
|
65
|
+
if spec.key?("inputSchema") && spec["inputSchema"].nil?
|
|
66
|
+
raise ConfigurationError, "inputSchema must be an object, not null"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
raw_schema = spec.fetch("inputSchema", EMPTY_INPUT_SCHEMA)
|
|
70
|
+
input_schema = Schema.validate_mcp!(
|
|
71
|
+
raw_schema, complete: !complete_argument_validator.nil?
|
|
72
|
+
)
|
|
53
73
|
Tool.define(local, spec["description"].to_s,
|
|
54
|
-
input_schema:
|
|
55
|
-
needs_approval: gate
|
|
74
|
+
input_schema: input_schema,
|
|
75
|
+
needs_approval: gate,
|
|
76
|
+
complete_argument_validator: complete_argument_validator) do |args|
|
|
56
77
|
answer(client.call_tool(remote, args || {}))
|
|
57
78
|
end
|
|
79
|
+
rescue ConfigurationError => e
|
|
80
|
+
raise ConfigurationError, "MCP tool #{remote.inspect}: #{e.message}"
|
|
58
81
|
end
|
|
59
82
|
|
|
60
83
|
# An MCP result becomes model-readable content: text joins, images ride
|
|
61
|
-
# as image blocks, and isError
|
|
84
|
+
# as image blocks, and isError remains structured failure data end to end.
|
|
62
85
|
def answer(result)
|
|
63
86
|
blocks = Array(result["content"]).map { |block| convert(block) }
|
|
64
87
|
if result["isError"]
|
|
65
|
-
|
|
66
|
-
|
|
88
|
+
text_blocks = blocks.grep(String)
|
|
89
|
+
if result["structuredContent"]
|
|
90
|
+
structured = JSON.generate(result["structuredContent"])
|
|
91
|
+
text_blocks << structured unless text_blocks.include?(structured)
|
|
92
|
+
end
|
|
93
|
+
text = text_blocks.join("\n")
|
|
94
|
+
content = "MCP tool error: #{text.empty? ? "unknown error" : text}"
|
|
95
|
+
non_text = blocks.grep_v(String)
|
|
96
|
+
content = [content, *non_text] unless non_text.empty?
|
|
97
|
+
return ToolResult.new(content:, error: true)
|
|
67
98
|
end
|
|
68
99
|
if blocks.empty? && result["structuredContent"]
|
|
69
100
|
return JSON.generate(result["structuredContent"])
|
|
@@ -91,6 +122,9 @@ module Mistri
|
|
|
91
122
|
end
|
|
92
123
|
end
|
|
93
124
|
|
|
125
|
+
require_relative "mcp/egress"
|
|
94
126
|
require_relative "mcp/wires"
|
|
95
127
|
require_relative "mcp/client"
|
|
96
128
|
require_relative "mcp/oauth"
|
|
129
|
+
|
|
130
|
+
Mistri::MCP.private_constant :Egress, :WireError, :EMPTY_INPUT_SCHEMA
|
data/lib/mistri/message.rb
CHANGED
|
@@ -10,26 +10,33 @@ module Mistri
|
|
|
10
10
|
# them, which is what lets a later turn replay history across models, plus
|
|
11
11
|
# usage and the stop reason.
|
|
12
12
|
#
|
|
13
|
-
# ui is a tool result's host-only channel
|
|
14
|
-
#
|
|
15
|
-
# error is the machine-readable failure on an errored turn
|
|
16
|
-
# shape); error_message stays the human story.
|
|
13
|
+
# ui is a tool result's host-only channel. tool_error is true or false on
|
|
14
|
+
# newly produced tool results and nil on legacy records that predate the
|
|
15
|
+
# field. error is the machine-readable failure on an errored provider turn
|
|
16
|
+
# (ErrorData shape); error_message stays the human story.
|
|
17
17
|
class Message < Data.define(:role, :content, :tool_call_id, :tool_name,
|
|
18
18
|
:model, :provider, :usage, :stop_reason, :error_message, :ui,
|
|
19
|
-
:error)
|
|
19
|
+
:error, :tool_error)
|
|
20
20
|
ROLES = %i[system user assistant tool].freeze
|
|
21
21
|
|
|
22
22
|
def initialize(role:, content: nil, tool_call_id: nil, tool_name: nil, model: nil,
|
|
23
23
|
provider: nil, usage: nil, stop_reason: nil, error_message: nil, ui: nil,
|
|
24
|
-
error: nil)
|
|
24
|
+
error: nil, tool_error: nil)
|
|
25
25
|
role = role.to_sym
|
|
26
26
|
raise ArgumentError, "unknown role #{role.inspect}" unless ROLES.include?(role)
|
|
27
27
|
if stop_reason && !StopReason.valid?(stop_reason)
|
|
28
28
|
raise ArgumentError, "unknown stop reason #{stop_reason.inspect}"
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
unless tool_error.nil?
|
|
32
|
+
unless [true, false].include?(tool_error)
|
|
33
|
+
raise ArgumentError, "tool_error must be true, false, or nil"
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "tool_error is only valid on tool messages" if role != :tool
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
super(role:, content: Content.wrap(content).freeze, tool_call_id:, tool_name:,
|
|
32
|
-
model:, provider:, usage:, stop_reason:, error_message:, ui:, error:)
|
|
39
|
+
model:, provider:, usage:, stop_reason:, error_message:, ui:, error:, tool_error:)
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
def self.system(content) = new(role: :system, content:)
|
|
@@ -50,8 +57,8 @@ module Mistri
|
|
|
50
57
|
new(role: :assistant, content: [*Content.wrap(content), *tool_calls], **meta)
|
|
51
58
|
end
|
|
52
59
|
|
|
53
|
-
def self.tool(content:, tool_call_id:, tool_name: nil, ui: nil)
|
|
54
|
-
new(role: :tool, content:, tool_call_id:, tool_name:, ui:)
|
|
60
|
+
def self.tool(content:, tool_call_id:, tool_name: nil, ui: nil, tool_error: false)
|
|
61
|
+
new(role: :tool, content:, tool_call_id:, tool_name:, ui:, tool_error:)
|
|
55
62
|
end
|
|
56
63
|
|
|
57
64
|
def self.from_h(hash)
|
|
@@ -62,13 +69,15 @@ module Mistri
|
|
|
62
69
|
model: h["model"], provider: h["provider"]&.to_sym,
|
|
63
70
|
usage: h["usage"] && Usage.from_h(h["usage"]),
|
|
64
71
|
stop_reason: h["stop_reason"]&.to_sym, error_message: h["error_message"],
|
|
65
|
-
ui: h["ui"], error: h["error"])
|
|
72
|
+
ui: h["ui"], error: h["error"], tool_error: h["tool_error"])
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
def system? = role == :system
|
|
69
76
|
def user? = role == :user
|
|
70
77
|
def assistant? = role == :assistant
|
|
71
78
|
def tool? = role == :tool
|
|
79
|
+
def tool_error? = tool_error == true
|
|
80
|
+
def tool_error_known? = !tool_error.nil?
|
|
72
81
|
|
|
73
82
|
# Every Text block joined, or nil when the turn carried no text.
|
|
74
83
|
def text
|
|
@@ -84,7 +93,8 @@ module Mistri
|
|
|
84
93
|
# never with new(**to_h).
|
|
85
94
|
def to_h
|
|
86
95
|
{ role:, content: content.map(&:to_h), tool_call_id:, tool_name:, model:,
|
|
87
|
-
provider:, usage: usage&.to_h, stop_reason:, error_message:, ui:, error
|
|
96
|
+
provider:, usage: usage&.to_h, stop_reason:, error_message:, ui:, error:,
|
|
97
|
+
tool_error: }.compact
|
|
88
98
|
end
|
|
89
99
|
end
|
|
90
100
|
end
|
data/lib/mistri/models.rb
CHANGED
|
@@ -1,43 +1,181 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Mistri
|
|
4
6
|
# The model catalog: capability data for known models, with graceful
|
|
5
7
|
# passthrough for unknown ones. An id missing here still works everywhere;
|
|
6
|
-
# the catalog
|
|
7
|
-
#
|
|
8
|
+
# the catalog supplies capability defaults and paid direct-API pricing, so a
|
|
9
|
+
# brand-new model remains usable unless the host requests a cost ceiling.
|
|
8
10
|
module Models
|
|
11
|
+
EMPTY_PRICING = [].freeze
|
|
12
|
+
|
|
13
|
+
# One request's standard rates and optional prompt-size tier.
|
|
14
|
+
RateCard = Data.define(:rates, :above, :higher) do
|
|
15
|
+
def initialize(rates:, above: nil, higher: nil)
|
|
16
|
+
if above.nil? != higher.nil?
|
|
17
|
+
raise ArgumentError, "a pricing threshold needs both above and higher"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
super(rates: rates.freeze, above:, higher: higher&.freeze)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def rates_for(usage)
|
|
24
|
+
usage && above && usage.prompt_tokens > above ? higher : rates
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# A rate card that becomes active at one absolute instant.
|
|
29
|
+
Schedule = Data.define(:effective_at, :card)
|
|
30
|
+
|
|
9
31
|
# thinking is how the model accepts a reasoning request: :adaptive (the
|
|
10
32
|
# model decides), :budget (a token budget), or :effort. It is what keeps
|
|
11
33
|
# a provider from sending an unsupported thinking shape that 400s.
|
|
12
|
-
|
|
34
|
+
#
|
|
35
|
+
# context_window is the provider's published context or input limit.
|
|
36
|
+
# Anthropic and OpenAI share it with the next output; Gemini publishes a
|
|
37
|
+
# separate inputTokenLimit and outputTokenLimit.
|
|
38
|
+
#
|
|
39
|
+
# Pricing is selected per request from its prompt size. Rates are paid
|
|
40
|
+
# standard direct-API list prices. cache_write is the provider's baseline
|
|
41
|
+
# write rate; Usage prices a reported longer-retention slice separately.
|
|
42
|
+
Model = Data.define(:id, :provider, :max_output, :context_window, :thinking) do
|
|
43
|
+
def initialize(id:, provider:, max_output:, context_window:, thinking:, pricing: [])
|
|
44
|
+
@pricing = pricing.sort_by(&:effective_at).freeze
|
|
45
|
+
super(id:, provider:, max_output:, context_window:, thinking:)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rates(usage: nil, at: Time.now)
|
|
49
|
+
pricing.reverse_each.find { |entry| entry.effective_at <= at }&.card&.rates_for(usage)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def priced? = !pricing.empty?
|
|
53
|
+
|
|
54
|
+
def with(**changes)
|
|
55
|
+
updated_pricing = changes.delete(:pricing) { pricing }
|
|
56
|
+
self.class.new(**to_h, **changes, pricing: updated_pricing)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ==(other) = super && pricing == other.send(:pricing)
|
|
60
|
+
|
|
61
|
+
alias_method :eql?, :==
|
|
62
|
+
|
|
63
|
+
def hash = [super, pricing].hash
|
|
64
|
+
|
|
65
|
+
def _dump(_level)
|
|
66
|
+
serialized = pricing.map do |entry|
|
|
67
|
+
{ effective_at: entry.effective_at.to_i, rates: entry.card.rates,
|
|
68
|
+
above: entry.card.above, higher: entry.card.higher }
|
|
69
|
+
end
|
|
70
|
+
JSON.generate(to_h.merge(pricing: serialized))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self._load(payload)
|
|
74
|
+
attributes = JSON.parse(payload, symbolize_names: true)
|
|
75
|
+
serialized = attributes.delete(:pricing)
|
|
76
|
+
pricing = serialized.map do |entry|
|
|
77
|
+
card = RateCard.new(rates: entry[:rates], above: entry[:above],
|
|
78
|
+
higher: entry[:higher])
|
|
79
|
+
Schedule.new(effective_at: Time.at(entry[:effective_at]).utc, card:)
|
|
80
|
+
end
|
|
81
|
+
attributes[:provider] = attributes[:provider].to_sym
|
|
82
|
+
attributes[:thinking] = attributes[:thinking].to_sym
|
|
83
|
+
new(**attributes, pricing:)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def pricing = @pricing || EMPTY_PRICING
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
EPOCH = Time.at(0).utc.freeze
|
|
92
|
+
|
|
93
|
+
def self.price(rates = nil, from: EPOCH, above: nil, higher: nil, **flat_rates)
|
|
94
|
+
rates ||= flat_rates
|
|
95
|
+
Schedule.new(effective_at: from,
|
|
96
|
+
card: RateCard.new(rates:, above:, higher:))
|
|
97
|
+
end
|
|
98
|
+
private_class_method :price
|
|
13
99
|
|
|
14
100
|
CATALOG = [
|
|
15
|
-
["claude-fable-5", :anthropic, 128_000,
|
|
16
|
-
|
|
17
|
-
["claude-opus-4-
|
|
18
|
-
|
|
19
|
-
["claude-
|
|
20
|
-
|
|
21
|
-
["claude-
|
|
22
|
-
|
|
23
|
-
["
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
["
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
101
|
+
["claude-fable-5", :anthropic, 128_000, 1_000_000, :adaptive,
|
|
102
|
+
[price(input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5)]],
|
|
103
|
+
["claude-opus-4-8", :anthropic, 128_000, 1_000_000, :adaptive,
|
|
104
|
+
[price(input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25)]],
|
|
105
|
+
["claude-opus-4-7", :anthropic, 128_000, 1_000_000, :adaptive,
|
|
106
|
+
[price(input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25)]],
|
|
107
|
+
["claude-opus-4-6", :anthropic, 128_000, 1_000_000, :adaptive,
|
|
108
|
+
[price(input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25)]],
|
|
109
|
+
["claude-sonnet-5", :anthropic, 128_000, 1_000_000, :adaptive,
|
|
110
|
+
[price(input: 2.0, output: 10.0, cache_read: 0.2, cache_write: 2.5),
|
|
111
|
+
price({ input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
|
|
112
|
+
from: Time.utc(2026, 9, 1))]],
|
|
113
|
+
["claude-sonnet-4-6", :anthropic, 128_000, 1_000_000, :adaptive,
|
|
114
|
+
[price(input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75)]],
|
|
115
|
+
["claude-haiku-4-5", :anthropic, 64_000, 200_000, :budget,
|
|
116
|
+
[price(input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25)]],
|
|
117
|
+
["gpt-5.6-sol", :openai, 128_000, 1_050_000, :effort,
|
|
118
|
+
[price({ input: 5.0, output: 30.0, cache_read: 0.5, cache_write: 6.25 },
|
|
119
|
+
above: 272_000,
|
|
120
|
+
higher: { input: 10.0, output: 45.0, cache_read: 1.0,
|
|
121
|
+
cache_write: 12.5 })]],
|
|
122
|
+
["gpt-5.6-terra", :openai, 128_000, 1_050_000, :effort,
|
|
123
|
+
[price({ input: 2.5, output: 15.0, cache_read: 0.25, cache_write: 3.125 },
|
|
124
|
+
above: 272_000,
|
|
125
|
+
higher: { input: 5.0, output: 22.5, cache_read: 0.5,
|
|
126
|
+
cache_write: 6.25 })]],
|
|
127
|
+
["gpt-5.6-luna", :openai, 128_000, 1_050_000, :effort,
|
|
128
|
+
[price({ input: 1.0, output: 6.0, cache_read: 0.1, cache_write: 1.25 },
|
|
129
|
+
above: 272_000,
|
|
130
|
+
higher: { input: 2.0, output: 9.0, cache_read: 0.2,
|
|
131
|
+
cache_write: 2.5 })]],
|
|
132
|
+
["gpt-5.5", :openai, 128_000, 1_050_000, :effort,
|
|
133
|
+
[price({ input: 5.0, output: 30.0, cache_read: 0.5 },
|
|
134
|
+
above: 272_000, higher: { input: 10.0, output: 45.0, cache_read: 1.0 })]],
|
|
135
|
+
["gpt-5.4", :openai, 128_000, 1_050_000, :effort,
|
|
136
|
+
[price({ input: 2.5, output: 15.0, cache_read: 0.25 },
|
|
137
|
+
above: 272_000, higher: { input: 5.0, output: 22.5, cache_read: 0.5 })]],
|
|
138
|
+
["gpt-5-nano", :openai, 128_000, 400_000, :effort,
|
|
139
|
+
[price(input: 0.05, output: 0.4, cache_read: 0.005)]],
|
|
140
|
+
["gemini-3.5-flash", :gemini, 65_536, 1_048_576, :level,
|
|
141
|
+
[price(input: 1.5, output: 9.0, cache_read: 0.15)]],
|
|
142
|
+
["gemini-3.1-pro-preview", :gemini, 65_536, 1_048_576, :level,
|
|
143
|
+
[price({ input: 2.0, output: 12.0, cache_read: 0.2 },
|
|
144
|
+
above: 200_000, higher: { input: 4.0, output: 18.0, cache_read: 0.4 })]],
|
|
145
|
+
["gemini-2.5-pro", :gemini, 65_536, 1_048_576, :budget,
|
|
146
|
+
[price({ input: 1.25, output: 10.0, cache_read: 0.125 },
|
|
147
|
+
above: 200_000, higher: { input: 2.5, output: 15.0, cache_read: 0.25 })]],
|
|
148
|
+
["gemini-2.5-flash", :gemini, 65_536, 1_048_576, :budget,
|
|
149
|
+
[price(input: 0.3, output: 2.5, cache_read: 0.03)]]
|
|
150
|
+
].to_h do |row|
|
|
151
|
+
id, provider, max_output, context_window, thinking, pricing = row
|
|
152
|
+
[id, Model.new(id:, provider:, max_output:, context_window:, thinking:, pricing:)]
|
|
31
153
|
end.freeze
|
|
32
154
|
|
|
33
|
-
|
|
34
|
-
|
|
155
|
+
ALIASES = { "gpt-5.6" => "gpt-5.6-sol" }.freeze
|
|
156
|
+
|
|
157
|
+
# Published family aliases and dated ids resolve to their canonical entry.
|
|
35
158
|
def self.find(id)
|
|
36
|
-
|
|
159
|
+
exact = CATALOG[id]
|
|
160
|
+
return exact if exact
|
|
161
|
+
|
|
162
|
+
base = id.to_s.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")
|
|
163
|
+
CATALOG[ALIASES.fetch(base, base)]
|
|
37
164
|
end
|
|
38
165
|
|
|
39
166
|
def self.max_output(id) = find(id)&.max_output
|
|
40
167
|
|
|
168
|
+
# Output capacity that occupies the published context limit. Gemini's
|
|
169
|
+
# output limit is independent, so its compaction headroom is input-only.
|
|
170
|
+
def self.shared_output(id)
|
|
171
|
+
model = find(id)
|
|
172
|
+
model&.max_output unless model&.provider == :gemini
|
|
173
|
+
end
|
|
174
|
+
|
|
41
175
|
def self.thinking(id) = find(id)&.thinking
|
|
176
|
+
|
|
177
|
+
def self.rates(id, usage: nil, at: Time.now) = find(id)&.rates(usage:, at:)
|
|
178
|
+
|
|
179
|
+
def self.priced?(id) = find(id)&.priced? || false
|
|
42
180
|
end
|
|
43
181
|
end
|