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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  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/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
4
5
 
5
6
  module Mistri
6
7
  module Providers
@@ -26,10 +27,17 @@ module Mistri
26
27
 
27
28
  def initialize(turns: [], chunk_size: 12)
28
29
  @turns = turns.map { |turn| turn.transform_keys(&:to_sym) }
30
+ @prices_usage = @turns.all? do |turn|
31
+ !turn[:usage] || turn[:usage].cost.known?
32
+ end
29
33
  @chunk_size = [chunk_size, 1].max
30
34
  @requests = []
35
+ @tool_call_prefix = SecureRandom.uuid
36
+ @tool_call_sequence = 0
31
37
  end
32
38
 
39
+ def prices_usage? = @prices_usage
40
+
33
41
  def stream(messages: [], **options, &emit)
34
42
  # Snapshot the array: the loop appends replies to it in place.
35
43
  @requests << { messages: messages.dup, options: }
@@ -67,18 +75,40 @@ module Mistri
67
75
  kind == :text ? Content::Text.new(text:) : Content::Thinking.new(thinking: text)
68
76
  end
69
77
 
70
- def stream_tool_call(spec, position, blocks, emit)
78
+ # Small scripted arguments parse eagerly on every chunk for precise UI
79
+ # tests. Real assemblers cap their refresh schedule under hostile fragmentation.
80
+ def stream_tool_call(spec, _position, blocks, emit)
71
81
  spec = spec.transform_keys(&:to_sym)
72
- call = ToolCall.new(id: spec[:id] || "call_#{position + 1}", name: spec[:name],
73
- arguments: (spec[:arguments] || {}).transform_keys(&:to_s))
82
+ arguments = spec.key?(:arguments) ? spec[:arguments] : {}
83
+ id = spec.key?(:id) ? spec[:id] : next_tool_call_id
84
+ call = ToolCall.new(id:, name: spec[:name], arguments:, signature: spec[:signature],
85
+ arguments_error: spec[:arguments_error],
86
+ provider_call_id: spec[:provider_call_id])
74
87
  index = blocks.size
75
88
  emit_event(emit, :toolcall_start, blocks, content_index: index)
76
- emit_event(emit, :toolcall_delta, blocks, content_index: index,
77
- delta: JSON.generate(call.arguments))
89
+ built = +""
90
+ JSON.generate(call.arguments).scan(/.{1,#{@chunk_size}}/m) do |chunk|
91
+ built << chunk
92
+ emit_event(emit, :toolcall_delta, blocks + [in_progress(call, built)],
93
+ content_index: index, delta: chunk)
94
+ end
78
95
  blocks << call
79
96
  emit_event(emit, :toolcall_end, blocks, content_index: index, tool_call: call)
80
97
  end
81
98
 
99
+ def in_progress(call, json)
100
+ parsed = ToolArguments.freeze_partial(PartialJson.parse(json))
101
+ ToolCall.new(id: call.id, name: call.name,
102
+ arguments: parsed, signature: call.signature,
103
+ provider_call_id: call.provider_call_id,
104
+ canonicalize: false)
105
+ end
106
+
107
+ def next_tool_call_id
108
+ @tool_call_sequence += 1
109
+ "fake_#{@tool_call_prefix}_#{@tool_call_sequence}"
110
+ end
111
+
82
112
  def finish(turn, blocks, emit)
83
113
  reason = turn[:stop_reason] ||
84
114
  (blocks.any?(ToolCall) ? StopReason::TOOL_USE : StopReason::STOP)
@@ -90,7 +120,7 @@ module Mistri
90
120
  def finish_error(turn, blocks, emit)
91
121
  error = { "type" => turn.fetch(:error_type, "Error") }
92
122
  error["status"] = turn[:status] if turn[:status]
93
- message = assemble(blocks, usage: Usage.zero, stop_reason: StopReason::ERROR,
123
+ message = assemble(blocks, usage: turn[:usage] || Usage.zero, stop_reason: StopReason::ERROR,
94
124
  error_message: turn[:error], error: error)
95
125
  emit&.call(Event.new(type: :error, reason: StopReason::ERROR, message:,
96
126
  error_message: turn[:error]))
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
4
5
 
5
6
  module Mistri
6
7
  module Providers
@@ -13,46 +14,104 @@ module Mistri
13
14
  # Thought signatures ride on individual parts and are captured onto the
14
15
  # block they arrived with, verbatim, for replay.
15
16
  class Assembler
16
- def initialize(model:)
17
+ def initialize(model:, catalog_pricing: true, service_tier: nil)
17
18
  @model = model
19
+ @catalog_pricing = catalog_pricing
20
+ @service_tier = service_tier
21
+ @pricing_at = Time.now
18
22
  @blocks = []
19
23
  @current = nil
20
- @usage = Usage.zero
24
+ @usage = Usage.new
21
25
  @finish_reason = nil
26
+ @terminal = false
27
+ end
28
+
29
+ # Finish reasons that are the provider's verdict on the content
30
+ # itself: a retry of the same input meets the same filter, and a
31
+ # harness that re-rolls against a policy verdict is machinery for
32
+ # evading it, so these fail fast.
33
+ VERDICTS = %w[SAFETY RECITATION LANGUAGE BLOCKLIST PROHIBITED_CONTENT SPII
34
+ IMAGE_SAFETY IMAGE_PROHIBITED_CONTENT IMAGE_RECITATION].freeze
35
+ # Missing replay state accuses the request rather than the generated
36
+ # content, but retrying the same history is equally deterministic.
37
+ INPUT_ERRORS = %w[MISSING_THOUGHT_SIGNATURE].freeze
38
+ # The model fumbled its own output (an invalid or runaway tool call,
39
+ # a missing image), or the API stopped for a reason it cannot name
40
+ # (OTHER is documented as "Unknown reason"). The input stands accused
41
+ # of nothing, so these error retryably; a regeneration usually lands.
42
+ FUMBLES = %w[MALFORMED_FUNCTION_CALL UNEXPECTED_TOOL_CALL TOO_MANY_TOOL_CALLS
43
+ NO_IMAGE OTHER IMAGE_OTHER MALFORMED_RESPONSE
44
+ FINISH_REASON_UNSPECIFIED].freeze
45
+
46
+ def start(&emit)
47
+ emit&.call(Event.new(type: :start, partial: assemble))
22
48
  end
23
49
 
24
50
  def feed(record, &)
25
- if (error = record["error"])
26
- @error = ProviderError.new(error["message"] || "provider error",
27
- status: error["code"])
28
- return
29
- end
51
+ return after_terminal(record, &) if @terminal
52
+ return record_error(record["error"]) if record["error"]
53
+
54
+ fold_record(record, &)
55
+ end
56
+
57
+ def after_terminal(record, &)
58
+ return ingest_usage(record) unless terminal_content?(record)
59
+
60
+ protocol_error("stream continued after its terminal record", &)
61
+ end
62
+
63
+ def record_error(error)
64
+ @error = ProviderError.new(error["message"] || "provider error",
65
+ status: error["code"])
66
+ @terminal = true
67
+ end
30
68
 
69
+ def fold_record(record, &)
70
+ block = record.dig("promptFeedback", "blockReason").to_s
71
+ @block_reason = block unless block.empty? || block == "BLOCK_REASON_UNSPECIFIED"
31
72
  candidate = record.dig("candidates", 0) || {}
32
- Array(candidate.dig("content", "parts")).each { |part| fold_part(part, &) }
73
+ Array(candidate.dig("content", "parts")).each_with_index do |part, index|
74
+ fold_part(part, part_boundary: index.positive?, &)
75
+ end
33
76
  @finish_reason = candidate["finishReason"] if candidate["finishReason"]
34
- @usage = parse_usage(record["usageMetadata"]) if record["usageMetadata"]
77
+ @terminal = true if @finish_reason || @block_reason
78
+ close_current(&) if @terminal
79
+ ingest_usage(record)
35
80
  end
81
+ private :after_terminal, :record_error, :fold_record
36
82
 
37
83
  # A stream that ended without a finishReason was truncated, not
38
84
  # cancelled: fail it so the loop can treat it as retryable.
39
85
  def finish(&emit)
40
86
  return fail_stream(@error, &emit) if @error
87
+ if (refused = blocked)
88
+ return fail_stream(refused, usage_known: @usage_authoritative, &emit)
89
+ end
41
90
  return fail_stream("stream ended without a finish reason", &emit) unless @finish_reason
42
91
 
92
+ if @blocks.any?(ToolCall) && !%w[STOP MAX_TOKENS].include?(@finish_reason)
93
+ invalidate_tool_calls
94
+ reason = ProviderError.new("generation ended before its tool calls were confirmed: " \
95
+ "#{@finish_reason}")
96
+ return fail_stream(reason, usage_known: @usage_authoritative, &emit)
97
+ end
98
+
43
99
  close_current(&emit)
100
+ invalidate_cost unless @usage_authoritative
44
101
  @message = assemble(stop_reason: stop_reason)
45
102
  emit&.call(Event.new(type: :done, reason: @message.stop_reason, message: @message))
46
103
  @message
47
104
  end
48
105
 
49
106
  def abort(&)
50
- close_current
107
+ close_current(&)
108
+ invalidate_cost
51
109
  terminal(StopReason::ABORTED, "aborted", &)
52
110
  end
53
111
 
54
- def fail_stream(reason, &)
55
- close_current
112
+ def fail_stream(reason, usage_known: false, &)
113
+ close_current(&)
114
+ invalidate_cost unless usage_known
56
115
  text = case reason
57
116
  when ProviderError then "#{reason.class}: #{reason.describe}"
58
117
  when Exception then "#{reason.class}: #{reason.message}"
@@ -67,16 +126,52 @@ module Mistri
67
126
 
68
127
  private
69
128
 
70
- def fold_part(part, &)
129
+ def fold_part(part, part_boundary: false, &)
71
130
  if part.key?("functionCall")
72
131
  fold_function_call(part, &)
73
132
  elsif part.key?("text")
74
- fold_text(part, part["thought"] ? :thinking : :text, &)
133
+ fold_text(part, part["thought"] ? :thinking : :text, part_boundary:, &)
134
+ end
135
+ end
136
+
137
+ def terminal_content?(record)
138
+ return true if record["error"]
139
+ return true unless record.dig("promptFeedback", "blockReason").to_s.empty?
140
+
141
+ candidate = record.dig("candidates", 0)
142
+ candidate && (candidate["finishReason"] ||
143
+ !Array(candidate.dig("content", "parts")).empty?)
144
+ end
145
+
146
+ def ingest_usage(record)
147
+ raw = record["usageMetadata"]
148
+ return unless raw
149
+
150
+ @service_tier = raw["serviceTier"] if raw.key?("serviceTier")
151
+ @usage = priced(parse_usage(raw))
152
+ @usage_authoritative = true if @terminal
153
+ end
154
+
155
+ def protocol_error(message, &)
156
+ @error ||= ProviderError.new(message)
157
+ close_current(&)
158
+ invalidate_tool_calls
159
+ nil
160
+ end
161
+
162
+ def invalidate_tool_calls
163
+ @blocks.map! do |block|
164
+ if block.is_a?(ToolCall)
165
+ block.with(arguments: nil, arguments_error: "incomplete")
166
+ else
167
+ block
168
+ end
75
169
  end
76
170
  end
77
171
 
78
- def fold_text(part, kind, &)
79
- close_current(&) if @current && @current.kind != kind
172
+ def fold_text(part, kind, part_boundary:, &)
173
+ close_current(&) if @current && (@current.kind != kind || part_boundary ||
174
+ @current.signature)
80
175
  unless @current
81
176
  @current = Builder.new(kind, @blocks.size, +"", nil)
82
177
  emit_event(:"#{kind}_start", content_index: @current.index, &)
@@ -92,14 +187,15 @@ module Mistri
92
187
  def fold_function_call(part, &)
93
188
  close_current(&)
94
189
  call_spec = part["functionCall"] || {}
95
- arguments = call_spec["args"].is_a?(Hash) ? call_spec["args"] : {}
96
- call = ToolCall.new(id: call_spec["id"] || "call_#{@blocks.size + 1}",
190
+ arguments = call_spec.key?("args") ? call_spec["args"] : {}
191
+ wire_id = call_spec["id"]
192
+ call = ToolCall.new(id: wire_id || "gemini_#{SecureRandom.uuid}",
97
193
  name: call_spec["name"], arguments: arguments,
98
- signature: part["thoughtSignature"])
194
+ signature: part["thoughtSignature"], provider_call_id: wire_id)
99
195
  index = @blocks.size
100
196
  emit_event(:toolcall_start, content_index: index, &)
101
197
  emit_event(:toolcall_delta, content_index: index,
102
- delta: JSON.generate(arguments), &)
198
+ delta: JSON.generate(call.arguments), &)
103
199
  @blocks << call
104
200
  emit_event(:toolcall_end, content_index: index, tool_call: call, &)
105
201
  end
@@ -124,9 +220,26 @@ module Mistri
124
220
  end
125
221
  end
126
222
 
223
+ # A blocked prompt arrives as promptFeedback with no candidates; a
224
+ # blocked candidate arrives as a verdict finishReason. Either way the
225
+ # error carries the wire word, so hosts tell SAFETY from RECITATION
226
+ # without parsing prose.
227
+ def blocked
228
+ if @block_reason
229
+ return InvalidRequestError.new("the prompt was blocked: #{@block_reason}")
230
+ end
231
+ return unless @finish_reason
232
+ if VERDICTS.include?(@finish_reason) || INPUT_ERRORS.include?(@finish_reason)
233
+ return InvalidRequestError.new("generation stopped: #{@finish_reason}")
234
+ end
235
+ return unless FUMBLES.include?(@finish_reason)
236
+
237
+ ProviderError.new("generation stopped: #{@finish_reason}")
238
+ end
239
+
127
240
  def stop_reason
128
- return StopReason::TOOL_USE if @blocks.any?(ToolCall)
129
241
  return StopReason::LENGTH if @finish_reason == "MAX_TOKENS"
242
+ return StopReason::TOOL_USE if @blocks.any?(ToolCall)
130
243
 
131
244
  StopReason::STOP
132
245
  end
@@ -157,6 +270,20 @@ module Mistri
157
270
  output: raw["candidatesTokenCount"].to_i + reasoning,
158
271
  cache_read: cache_read, reasoning: reasoning)
159
272
  end
273
+
274
+ def priced(usage)
275
+ return usage unless @catalog_pricing
276
+
277
+ standard = @service_tier.nil? || %w[unspecified standard].include?(@service_tier.to_s)
278
+ return usage unless standard
279
+
280
+ rates = Models.rates(@model, usage:, at: @pricing_at)
281
+ rates ? usage.with_cost(rates) : usage
282
+ end
283
+
284
+ def invalidate_cost
285
+ @usage = @usage.with(cost: @usage.cost.with(known: false))
286
+ end
160
287
  end
161
288
  end
162
289
  end
@@ -12,14 +12,28 @@ module Mistri
12
12
  # signature would be rejected. Thinking summaries are output-only and
13
13
  # never replay. Consecutive user turns stay separate: Gemini accepts
14
14
  # them, and mixing a text part into a functionResponse turn makes it
15
- # answer an empty candidate.
15
+ # answer an empty candidate. Completed tool exchanges from another
16
+ # provider become ordinary text: Gemini rejects foreign functionCall
17
+ # history because it has no Gemini thought signature.
16
18
  module Serializer
17
19
  module_function
18
20
 
19
21
  def contents(history)
20
- groups = history.reject(&:system?).chunk_while { |a, b| a.tool? && b.tool? }
22
+ result_metadata = tool_result_metadata(history)
23
+ groups = history.reject(&:system?).chunk_while do |a, b|
24
+ a.tool? && b.tool? && native_tool_result?(a, result_metadata) ==
25
+ native_tool_result?(b, result_metadata)
26
+ end
21
27
  groups.filter_map do |group|
22
- group.first.tool? ? tool_turn(group) : turn(group.first)
28
+ if group.first.tool?
29
+ if native_tool_result?(group.first, result_metadata)
30
+ tool_turn(group, result_metadata)
31
+ else
32
+ foreign_tool_turn(group)
33
+ end
34
+ else
35
+ turn(group.first)
36
+ end
23
37
  end
24
38
  end
25
39
 
@@ -33,7 +47,7 @@ module Mistri
33
47
  declarations = definitions.map do |tool|
34
48
  spec = tool.transform_keys(&:to_sym)
35
49
  { name: spec[:name], description: spec[:description],
36
- parameters: spec[:input_schema] }
50
+ parametersJsonSchema: spec[:input_schema] }
37
51
  end
38
52
  [{ functionDeclarations: declarations }]
39
53
  end
@@ -47,14 +61,28 @@ module Mistri
47
61
 
48
62
  # Gemini pairs a functionResponse to its call by NAME; a wrong name
49
63
  # silently mismatches, so a missing one fails loudly instead.
50
- def tool_turn(group)
64
+ def tool_turn(group, result_metadata = {}.compare_by_identity)
51
65
  { role: "user", parts: group.map do |msg|
52
66
  unless msg.tool_name
53
67
  raise SchemaError, "Gemini tool results need tool_name to pair with their call"
54
68
  end
55
69
 
56
- { functionResponse: { name: msg.tool_name,
57
- response: { "result" => result_text(msg) } } }
70
+ key = msg.tool_error? ? "error" : "result"
71
+ response = { name: msg.tool_name, response: { key => result_text(msg) } }
72
+ wire_id = result_metadata.dig(msg, :provider_call_id)
73
+ response[:id] = wire_id if wire_id
74
+ { functionResponse: response }
75
+ end }
76
+ end
77
+
78
+ def foreign_tool_turn(group)
79
+ { role: "user", parts: group.map do |msg|
80
+ raise SchemaError, "Gemini tool results need tool_name" unless msg.tool_name
81
+
82
+ label = msg.tool_error? ? "error" : "result"
83
+ text = "Tool #{JSON.generate(msg.tool_name)} #{label} " \
84
+ "(call #{JSON.generate(msg.tool_call_id)}): #{result_text(msg)}"
85
+ { text: text }
58
86
  end }
59
87
  end
60
88
 
@@ -84,10 +112,51 @@ module Mistri
84
112
  case block
85
113
  when Content::Text then signed({ text: block.text }, block.signature, own)
86
114
  when ToolCall
87
- signed({ functionCall: { name: block.name, args: block.arguments } },
88
- block.signature, own)
115
+ own ? native_function_call(block) : foreign_function_call(block)
116
+ end
117
+ end
118
+ end
119
+
120
+ def native_function_call(block)
121
+ arguments = ToolArguments.replay_object(block)
122
+ signature = block.signature if arguments.equal?(block.arguments)
123
+ call = { name: block.name, args: arguments }
124
+ call[:id] = block.provider_call_id if block.provider_call_id
125
+ signed({ functionCall: call }, signature, true)
126
+ end
127
+
128
+ def foreign_function_call(block)
129
+ arguments = if block.arguments_error
130
+ "unavailable (#{block.arguments_error})"
131
+ else
132
+ JSON.generate(block.arguments)
133
+ end
134
+ { text: "Tool call #{JSON.generate(block.name)} " \
135
+ "(call #{JSON.generate(block.id)}) with arguments: #{arguments}" }
136
+ end
137
+
138
+ def native_tool_result?(message, result_metadata)
139
+ result_metadata.dig(message, :provider) == :gemini
140
+ end
141
+
142
+ # Legacy histories may reuse call_N across turns. Correlating each
143
+ # result to the nearest preceding occurrence keeps a later Gemini call
144
+ # from rewriting an earlier foreign result's wire semantics.
145
+ def tool_result_metadata(history)
146
+ pending = Hash.new { |calls, id| calls[id] = [] }
147
+ metadata = {}.compare_by_identity
148
+ history.each do |message|
149
+ if message.assistant?
150
+ message.tool_calls.each do |call|
151
+ pending[call.id] << { provider: message.provider,
152
+ provider_call_id: call.provider_call_id }.freeze
153
+ end
154
+ elsif message.tool?
155
+ matched = pending[message.tool_call_id].pop
156
+ metadata[message] = matched if matched
89
157
  end
90
158
  end
159
+ metadata
91
160
  end
92
161
 
93
162
  def signed(part, signature, own)
@@ -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 Gemini API (v1beta generateContent), streamed over SSE and
@@ -11,30 +13,50 @@ module Mistri
11
13
  # maxOutputTokens is omitted for the same reason: the API defaults to the
12
14
  # model's ceiling.
13
15
  class Gemini
16
+ DEFAULT_ORIGIN = "https://generativelanguage.googleapis.com"
14
17
  DEFAULT_THINKING = { includeThoughts: true }.freeze
15
18
 
16
19
  def initialize(api_key:, model: "gemini-2.5-flash",
17
- origin: "https://generativelanguage.googleapis.com",
18
- thinking: DEFAULT_THINKING, **transport_options)
20
+ origin: DEFAULT_ORIGIN, thinking: DEFAULT_THINKING,
21
+ service_tier: nil, catalog_pricing: nil, **transport_options)
19
22
  @api_key = api_key
20
23
  @model = model
21
24
  @thinking = thinking
25
+ @service_tier = service_tier
26
+ @catalog_pricing = catalog_pricing.nil? ? official_origin?(origin) : catalog_pricing
22
27
  @transport = Transport.new(origin: origin, **transport_options)
23
28
  end
24
29
 
25
30
  attr_reader :model
26
31
 
32
+ def prices_usage?
33
+ tier_known = @service_tier.nil? || %w[unspecified standard].include?(@service_tier.to_s)
34
+ @catalog_pricing && Models.priced?(model) && tier_known
35
+ end
36
+
37
+ def native_output_schema(schema)
38
+ return unless Models.find(model)&.provider == :gemini
39
+
40
+ SchemaCapabilities.derive(schema, :gemini)
41
+ end
42
+
27
43
  def stream(messages:, system: nil, tools: [], signal: nil, **overrides, &emit)
44
+ delivery = EventDelivery.wrap(emit)
28
45
  model = overrides.fetch(:model, @model)
29
- assembler = Gemini::Assembler.new(model: model)
46
+ service_tier = overrides.fetch(:service_tier, @service_tier)
47
+ assembler = Gemini::Assembler.new(model: model, catalog_pricing: @catalog_pricing,
48
+ service_tier:)
49
+ assembler.start(&delivery)
30
50
  body = build_body(messages, system, tools, overrides)
31
51
  path = "/v1beta/models/#{model}:streamGenerateContent?alt=sse"
32
52
  outcome = @transport.stream_post(path, body: body, headers: headers,
33
53
  signal: signal) do |record|
34
54
  assembler.feed(record,
35
- &emit)
55
+ &delivery)
36
56
  end
37
- outcome == :aborted ? assembler.abort(&emit) : assembler.finish(&emit)
57
+ outcome == :aborted ? assembler.abort(&delivery) : assembler.finish(&delivery)
58
+ rescue EventDelivery::Failure => e
59
+ raise EventDelivery.unwrap(e, delivery)
38
60
  rescue Error => e
39
61
  assembler.fail_stream(e, &emit)
40
62
  end
@@ -43,11 +65,15 @@ module Mistri
43
65
 
44
66
  private
45
67
 
68
+ def official_origin?(origin) = origin.to_s.delete_suffix("/") == DEFAULT_ORIGIN
69
+
46
70
  def build_body(messages, system, tools, overrides)
47
71
  body = { contents: Serializer.contents(messages) }
48
72
  instruction = Serializer.system_instruction(system)
49
73
  body[:systemInstruction] = instruction if instruction
50
74
  body[:tools] = Serializer.tools(tools) if tools.any?
75
+ service_tier = overrides.fetch(:service_tier, @service_tier)
76
+ body[:serviceTier] = service_tier if service_tier
51
77
  config = {}
52
78
  thinking = overrides.fetch(:thinking, @thinking)
53
79
  config[:thinkingConfig] = thinking if thinking