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
@@ -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
- def initialize(url:, token:, headers:, open_timeout:, read_timeout:)
17
- uri = URI(url)
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: "#{uri.scheme}://#{uri.host}:#{uri.port}",
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 = @session_id = nil
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
- def initialize(command:, env: {}, read_timeout: 120)
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
- return unless @pid
109
-
110
- [@stdin, @stdout].each { |io| io.close unless io.closed? }
111
- terminate
112
- @pid = nil
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 Error, "the MCP server closed its input"
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
- ready = @stdout.wait_readable(@read_timeout)
137
- raise Error, "timed out waiting for the MCP server" unless ready
186
+ line = complete_record
187
+ return JSON.parse(line) if line
138
188
 
139
- line = @stdout.gets
140
- raise Error, "the MCP server exited" if line.nil?
141
- next if line.strip.empty?
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
- return JSON.parse(line)
193
+ @read_buffer << chunk
144
194
  end
145
195
  rescue JSON::ParserError
146
- raise Error, "the MCP server wrote non-protocol output on stdout"
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
@@ -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: spec["inputSchema"] || Tool::EMPTY_SCHEMA,
55
- needs_approval: gate) do |args|
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 answers in band so the model can react.
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
- text = blocks.grep(String).join("\n")
66
- return "MCP tool error: #{text.empty? ? "unknown error" : text}"
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
@@ -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: it persists with the message and
14
- # rides its :tool_result event, but no serializer ever sends it to a model.
15
- # error is the machine-readable failure on an errored turn (ErrorData
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: }.compact
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 only improves defaults (output ceilings, provider inference),
7
- # so a brand-new model is usable the day it ships.
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
- Model = Data.define(:id, :provider, :max_output, :context_window, :thinking)
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, 200_000, :adaptive],
16
- ["claude-opus-4-8", :anthropic, 128_000, 200_000, :adaptive],
17
- ["claude-opus-4-7", :anthropic, 128_000, 200_000, :adaptive],
18
- ["claude-opus-4-6", :anthropic, 128_000, 200_000, :adaptive],
19
- ["claude-sonnet-5", :anthropic, 128_000, 200_000, :adaptive],
20
- ["claude-sonnet-4-6", :anthropic, 128_000, 200_000, :adaptive],
21
- ["claude-haiku-4-5", :anthropic, 64_000, 200_000, :budget],
22
- ["gpt-5.5", :openai, 128_000, 400_000, :effort],
23
- ["gpt-5.4", :openai, 128_000, 400_000, :effort],
24
- ["gpt-5-nano", :openai, 128_000, 400_000, :effort],
25
- ["gemini-3.5-flash", :gemini, 65_536, 1_048_576, :level],
26
- ["gemini-3.1-pro-preview", :gemini, 65_536, 1_048_576, :level],
27
- ["gemini-2.5-pro", :gemini, 65_536, 1_048_576, :budget],
28
- ["gemini-2.5-flash", :gemini, 65_536, 1_048_576, :budget]
29
- ].to_h do |id, provider, max_output, context_window, thinking|
30
- [id, Model.new(id:, provider:, max_output:, context_window:, thinking:)]
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
- # Dated aliases resolve to their base entry: claude-opus-4-8-20260115 and
34
- # gpt-5.4-2025-04-14 both match their base ids.
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
- CATALOG[id] || CATALOG[id.to_s.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")]
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