activeagent 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module ActiveAgent
6
+ module Delegation
7
+ # Executes one delegated call: budget check, sub-agent generation, result
8
+ # validation, accounting.
9
+ #
10
+ # The runner is what makes a sub-agent a *tool* rather than a method call.
11
+ # It answers to the calling model in the model's own terms — a refused call
12
+ # comes back as a structured result the model can reason about, not as an
13
+ # exception that ends the conversation.
14
+ #
15
+ # @api private
16
+ class Runner
17
+ # @return [Definition]
18
+ attr_reader :definition
19
+ # @return [ActiveAgent::Base] the delegating agent instance
20
+ attr_reader :owner
21
+
22
+ # @param definition [Definition]
23
+ # @param owner [ActiveAgent::Base]
24
+ def initialize(definition, owner:)
25
+ @definition = definition
26
+ @owner = owner
27
+ end
28
+
29
+ # Runs the delegated call.
30
+ #
31
+ # @param arguments [Hash] arguments supplied by the calling model
32
+ # @return [Object] the sub-agent's result, or a structured error the
33
+ # calling model can act on
34
+ # @raise [BudgetExceededError] when the budget policy is +:raise+
35
+ # @raise [InvalidResultError] when the returns policy is +:raise+
36
+ def call(**arguments)
37
+ if (violation = budget_violation)
38
+ return refuse(violation)
39
+ end
40
+
41
+ arguments = coerce_arguments(arguments)
42
+
43
+ instrument(arguments) do |payload|
44
+ started = clock
45
+ response = nil
46
+
47
+ begin
48
+ response = with_timeout { generate(arguments) }
49
+ rescue Timeout::Error
50
+ duration = clock - started
51
+ record(duration: duration)
52
+ payload[:status] = :timed_out
53
+ payload[:duration_ms] = (duration * 1_000).round
54
+
55
+ next timed_out(duration)
56
+ end
57
+
58
+ duration = clock - started
59
+ usage = response.usage
60
+ model = response.model.presence || generation_model(response)
61
+ cost = Pricing.cost_for(usage: usage, model: model, rates: budget.rates)
62
+
63
+ record(tokens: usage&.total_tokens, cost: cost, duration: duration)
64
+
65
+ payload[:status] = :ok
66
+ payload[:model] = model
67
+ payload[:duration_ms] = (duration * 1_000).round
68
+ payload[:usage] = usage
69
+ payload[:cost] = cost
70
+ payload[:ledger] = ledgers.last.to_h
71
+
72
+ result(response, payload)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # @return [Budget]
79
+ def budget = definition.budget
80
+
81
+ # Agent-wide ledger first, then this delegation's own — a call has to
82
+ # clear both.
83
+ #
84
+ # @return [Array<Ledger>]
85
+ def ledgers
86
+ @ledgers ||= [ owner.delegation_ledger, owner.delegation_ledger_for(definition.tool_name) ]
87
+ end
88
+
89
+ # @return [Budget::Violation, nil]
90
+ def budget_violation
91
+ owner.class.delegation_budget.violation_for(ledgers.first) || budget.violation_for(ledgers.last)
92
+ end
93
+
94
+ # @param tokens [Integer, nil]
95
+ # @param cost [Float, nil]
96
+ # @param duration [Float]
97
+ # @return [void]
98
+ def record(tokens: 0, cost: nil, duration: 0.0)
99
+ ledgers.each { |ledger| ledger.record(tokens: tokens, cost: cost, duration: duration) }
100
+ end
101
+
102
+ # Drops arguments the contract never declared, so a model that
103
+ # hallucinates an extra key gets a working call instead of an
104
+ # +ArgumentError+ from the sub-agent's method signature.
105
+ #
106
+ # @param arguments [Hash]
107
+ # @return [Hash]
108
+ def coerce_arguments(arguments)
109
+ arguments = arguments.symbolize_keys
110
+ return arguments if definition.schema.empty?
111
+
112
+ arguments.slice(*definition.schema.keys)
113
+ end
114
+
115
+ # @param arguments [Hash]
116
+ # @return [ActiveAgent::Providers::Common::PromptResponse]
117
+ def generate(arguments)
118
+ agent = definition.resolved_agent_class.new
119
+ agent.params = resolved_params(arguments)
120
+ agent.process(definition.action, **arguments)
121
+
122
+ definition.backend.apply(agent)
123
+ apply_returns_format(agent)
124
+ inherit_trace_id(agent)
125
+
126
+ agent.process_prompt
127
+ end
128
+
129
+ # A delegated generation is part of its parent's work, so it carries the
130
+ # parent's trace id — otherwise the sub-agent's tokens and latency land
131
+ # in a separate trace and the budget you set has nothing to show for it.
132
+ #
133
+ # @param agent [ActiveAgent::Base]
134
+ # @return [void]
135
+ def inherit_trace_id(agent)
136
+ trace_id = owner.prompt_options[:trace_id]
137
+ agent.prompt_options[:trace_id] ||= trace_id if trace_id
138
+ end
139
+
140
+ # A declared +returns+ schema *is* the response format for the delegated
141
+ # call; the sub-agent does not have to restate it.
142
+ #
143
+ # @param agent [ActiveAgent::Base]
144
+ # @return [void]
145
+ def apply_returns_format(agent)
146
+ return unless definition.structured?
147
+
148
+ agent.prompt_options[:response_format] = {
149
+ type: :json_schema,
150
+ json_schema: definition.returns.to_response_format(name: "#{definition.tool_name}_result")
151
+ }
152
+ end
153
+
154
+ # @param arguments [Hash]
155
+ # @return [Hash]
156
+ def resolved_params(arguments)
157
+ case (params = definition.params)
158
+ when nil then {}
159
+ when Hash then params
160
+ when Symbol then owner.send(params)
161
+ when Proc then params.arity == 1 ? owner.instance_exec(arguments, &params) : owner.instance_exec(&params)
162
+ else
163
+ raise ArgumentError, "Delegation params must be a Hash, Symbol or Proc, got #{params.inspect}"
164
+ end.to_h.symbolize_keys
165
+ end
166
+
167
+ # @yield the delegated generation
168
+ # @return [Object]
169
+ def with_timeout
170
+ return yield unless budget.timeout
171
+
172
+ Timeout.timeout(budget.timeout) { yield }
173
+ end
174
+
175
+ # @param response [ActiveAgent::Providers::Common::PromptResponse]
176
+ # @param payload [Hash] instrumentation payload
177
+ # @return [Object]
178
+ def result(response, payload)
179
+ message = response.message
180
+ return nil if message.nil?
181
+
182
+ return text_of(message) unless definition.structured?
183
+
184
+ parsed = message.respond_to?(:parsed_json) ? message.parsed_json : nil
185
+ missing = definition.returns.missing_keys(parsed)
186
+ return parsed if missing.empty?
187
+
188
+ payload[:status] = :invalid_result
189
+ payload[:missing] = missing
190
+
191
+ invalid(missing, text_of(message))
192
+ end
193
+
194
+ # @param message [Object]
195
+ # @return [String]
196
+ def text_of(message)
197
+ message.respond_to?(:text) ? message.text : message.content.to_s
198
+ end
199
+
200
+ # @param response [ActiveAgent::Providers::Common::PromptResponse]
201
+ # @return [String, nil]
202
+ def generation_model(response)
203
+ response.context.is_a?(Hash) ? response.context[:model] : nil
204
+ end
205
+
206
+ # @param violation [Budget::Violation]
207
+ # @return [Hash]
208
+ def refuse(violation)
209
+ ActiveSupport::Notifications.instrument("delegation_refused.active_agent", instrument_payload.merge(
210
+ status: :budget_exceeded, limit: violation.limit, allowed: violation.allowed, used: violation.used
211
+ ))
212
+
213
+ if budget.policy == :raise
214
+ raise BudgetExceededError.new(violation, definition: definition)
215
+ end
216
+
217
+ { error: "budget_exceeded", limit: violation.limit.to_s, allowed: violation.allowed, used: violation.used, message: violation.message }
218
+ end
219
+
220
+ # @param duration [Float]
221
+ # @return [Hash]
222
+ def timed_out(duration)
223
+ if budget.policy == :raise
224
+ raise TimeoutError, "#{definition.agent_class}##{definition.action} exceeded its #{budget.timeout}s delegation timeout"
225
+ end
226
+
227
+ {
228
+ error: "timeout",
229
+ limit: "timeout",
230
+ allowed: budget.timeout,
231
+ used: duration.round(3),
232
+ message: "The #{definition.tool_name} delegation timed out after #{budget.timeout}s. " \
233
+ "Answer with the information you already have, or call it with a smaller request."
234
+ }
235
+ end
236
+
237
+ # @param missing [Array<Symbol>]
238
+ # @param content [String]
239
+ # @return [Hash]
240
+ def invalid(missing, content)
241
+ if definition.on_invalid == :raise
242
+ raise InvalidResultError, "#{definition.agent_class}##{definition.action} returned a result missing " \
243
+ "required #{"key".pluralize(missing.size)}: #{missing.join(", ")}"
244
+ end
245
+
246
+ {
247
+ error: "invalid_result",
248
+ missing: missing.map(&:to_s),
249
+ message: "The #{definition.tool_name} delegation returned a result missing required " \
250
+ "#{"key".pluralize(missing.size)}: #{missing.join(", ")}.",
251
+ content: content
252
+ }
253
+ end
254
+
255
+ # @param arguments [Hash]
256
+ # @yield [Hash] instrumentation payload
257
+ def instrument(arguments, &block)
258
+ ActiveSupport::Notifications.instrument(
259
+ "delegate.active_agent", instrument_payload.merge(arguments: arguments), &block
260
+ )
261
+ end
262
+
263
+ # @return [Hash]
264
+ def instrument_payload
265
+ {
266
+ agent: owner.class.name,
267
+ delegate: definition.agent_class.name,
268
+ action: definition.action,
269
+ tool: definition.tool_name.to_s,
270
+ provider: definition.backend.provider,
271
+ budget: budget.to_h.except(:rates)
272
+ }.compact
273
+ end
274
+
275
+ # Monotonic so a clock adjustment mid-generation can't corrupt a latency budget.
276
+ #
277
+ # @return [Float]
278
+ def clock
279
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
280
+ end
281
+ end
282
+ end
283
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Delegation
5
+ # Declarative JSON Schema for a delegated agent's inputs and outputs.
6
+ #
7
+ # A delegation is only as good as its contract: the calling model needs to
8
+ # know exactly what a sub-agent accepts, and the calling *agent* needs to
9
+ # know what shape comes back. Schema builds both from a small DSL, from a
10
+ # plain JSON Schema hash, or from any class that responds to
11
+ # +to_json_schema+ (see {ActiveAgent::SchemaGenerator}).
12
+ #
13
+ # @example DSL
14
+ # Schema.build do
15
+ # string :text, required: true, description: "Document to summarize"
16
+ # integer :max_points, description: "How many bullets to return"
17
+ # array :tags, of: :string, description: "Topic tags"
18
+ # end
19
+ #
20
+ # @example Plain JSON Schema
21
+ # Schema.build(type: "object", properties: { text: { type: "string" } }, required: [ "text" ])
22
+ #
23
+ # @example ActiveModel / ActiveRecord
24
+ # Schema.build(ContactForm) # ContactForm includes ActiveAgent::SchemaGenerator
25
+ class Schema
26
+ # Scalar types that get a one-line DSL helper.
27
+ SCALAR_TYPES = %i[string integer number boolean].freeze
28
+
29
+ class << self
30
+ # Coerces any supported schema source into a Schema.
31
+ #
32
+ # @param source [Schema, Hash, Class, nil] existing schema, raw JSON Schema,
33
+ # or a class responding to +to_json_schema+
34
+ # @yield DSL block (evaluated against a new Schema, or yielded when arity is 1)
35
+ # @return [Schema]
36
+ # @raise [ArgumentError] when the source cannot be interpreted
37
+ def build(source = nil, &block)
38
+ schema =
39
+ case source
40
+ when Schema then source
41
+ when Hash then from_hash(source)
42
+ when nil then new
43
+ else
44
+ if source.respond_to?(:to_json_schema)
45
+ from_hash(source.to_json_schema)
46
+ else
47
+ raise ArgumentError, "Cannot build a delegation schema from #{source.inspect}. " \
48
+ "Pass a Hash, a class responding to #to_json_schema, or use the block DSL."
49
+ end
50
+ end
51
+
52
+ if block
53
+ block.arity == 1 ? block.call(schema) : schema.instance_eval(&block)
54
+ end
55
+
56
+ schema
57
+ end
58
+
59
+ # @param hash [Hash] JSON Schema object
60
+ # @return [Schema]
61
+ def from_hash(hash)
62
+ hash = hash.deep_symbolize_keys
63
+
64
+ new.tap do |schema|
65
+ (hash[:properties] || {}).each { |name, definition| schema.property(name, definition) }
66
+ schema.required(*Array(hash[:required]))
67
+ schema.additional_properties(hash.fetch(:additionalProperties, hash.fetch(:additional_properties, false)))
68
+ end
69
+ end
70
+ end
71
+
72
+ def initialize
73
+ @properties = {}
74
+ @required = []
75
+ @additional_properties = false
76
+ end
77
+
78
+ # @return [Boolean] true when nothing has been declared
79
+ def empty?
80
+ @properties.empty?
81
+ end
82
+
83
+ # Declares a property from an already-built JSON Schema fragment.
84
+ #
85
+ # @param name [Symbol, String]
86
+ # @param definition [Hash] JSON Schema fragment (e.g. +{ type: "string" }+)
87
+ # @return [void]
88
+ def property(name, definition)
89
+ @properties[name.to_sym] = definition.deep_symbolize_keys
90
+ end
91
+
92
+ # Declares a property.
93
+ #
94
+ # @param name [Symbol, String]
95
+ # @param type [Symbol, String] JSON Schema type
96
+ # @param required [Boolean] whether the calling model must supply it
97
+ # @param description [String, nil] shown to the calling model — write it for a reader who
98
+ # has never seen your code
99
+ # @param options [Hash] any other JSON Schema keyword (+enum+, +format+, +minimum+, ...)
100
+ # @yield nested DSL for object properties
101
+ # @return [void]
102
+ def param(name, type = :string, required: false, description: nil, **options, &block)
103
+ definition = { type: type.to_s, description: description }.compact.merge(options)
104
+
105
+ if block
106
+ nested = self.class.build(&block)
107
+ definition = definition.merge(nested.to_json_schema.except(:type))
108
+ definition[:type] = "object"
109
+ end
110
+
111
+ property(name, definition)
112
+ self.required(name) if required
113
+ end
114
+
115
+ SCALAR_TYPES.each do |type|
116
+ define_method(type) do |name, **options|
117
+ param(name, type, **options)
118
+ end
119
+ end
120
+
121
+ # Declares an object property.
122
+ #
123
+ # @param name [Symbol, String]
124
+ # @yield nested DSL
125
+ def object(name, **options, &block)
126
+ param(name, :object, **options, &block)
127
+ end
128
+
129
+ # Declares an array property.
130
+ #
131
+ # @param name [Symbol, String]
132
+ # @param of [Symbol, String, Hash, nil] item type, or a JSON Schema fragment for items
133
+ # @yield nested DSL describing an object item type
134
+ def array(name, of: nil, **options, &block)
135
+ items =
136
+ if block
137
+ self.class.build(&block).to_json_schema
138
+ elsif of.is_a?(Hash)
139
+ of.deep_symbolize_keys
140
+ elsif of
141
+ { type: of.to_s }
142
+ end
143
+
144
+ param(name, :array, **options.merge({ items: items }.compact))
145
+ end
146
+
147
+ # Marks properties as required.
148
+ #
149
+ # @param names [Array<Symbol, String>]
150
+ # @return [Array<Symbol>]
151
+ def required(*names)
152
+ names.flatten.each { |name| @required |= [ name.to_sym ] }
153
+ @required
154
+ end
155
+
156
+ # @param value [Boolean]
157
+ def additional_properties(value = true)
158
+ @additional_properties = value
159
+ end
160
+
161
+ # @return [Array<Symbol>] declared property names
162
+ def keys
163
+ @properties.keys
164
+ end
165
+
166
+ # @return [Array<Symbol>] required property names
167
+ def required_keys
168
+ @required.dup
169
+ end
170
+
171
+ # @return [Hash] JSON Schema object
172
+ #
173
+ # @note Deep-duplicated: providers normalize tool definitions in place,
174
+ # and a schema is shared by every generation that exposes it.
175
+ def to_json_schema
176
+ {
177
+ type: "object",
178
+ properties: @properties.deep_dup,
179
+ required: @required.map(&:to_s),
180
+ additionalProperties: @additional_properties
181
+ }
182
+ end
183
+ alias_method :to_h, :to_json_schema
184
+
185
+ # JSON Schema for a +response_format+ payload.
186
+ #
187
+ # ActiveAgent camelizes response-format schema *keys* on the way to the
188
+ # provider, but the +required+ array holds string *values* — so they are
189
+ # camelized here to keep the emitted schema internally consistent.
190
+ # {Assistant#parsed_json} underscores the keys again on the way back, so
191
+ # agent code only ever sees snake_case.
192
+ #
193
+ # @param name [String] schema name reported to the provider
194
+ # @param strict [Boolean]
195
+ # @return [Hash]
196
+ def to_response_format(name:, strict: true)
197
+ schema = to_json_schema
198
+ schema[:required] = schema[:required].map { |key| key.camelize(:lower) }
199
+
200
+ { name: name, schema: schema, strict: strict }
201
+ end
202
+
203
+ # Validates a parsed payload against the declared required properties.
204
+ #
205
+ # This is a deliberately shallow check: it catches the failure that
206
+ # actually happens in practice (a model omitting a required key) without
207
+ # pulling a full JSON Schema validator into the gem's dependencies.
208
+ #
209
+ # @param payload [Hash, nil]
210
+ # @return [Array<Symbol>] missing required keys
211
+ def missing_keys(payload)
212
+ return required_keys if payload.nil?
213
+ return [] unless payload.is_a?(Hash)
214
+
215
+ keys = payload.keys.map { |key| key.to_s.underscore.to_sym }
216
+ required_keys.reject { |key| keys.include?(key) }
217
+ end
218
+ end
219
+ end
220
+ end
@@ -54,8 +54,11 @@ module ActiveAgent
54
54
  attr_internal :options, :context, :trace_id, # Setup
55
55
  :request, :message_stack, # Runtime
56
56
  :stream_broadcaster, :streaming, # Callback (Streams)
57
+ :stream_completion_pending, # Callback (Streams)
58
+ :stream_completion_result, # Callback (Streams)
57
59
  :tools_function, # Callback (Tools)
58
60
  :usage_stack, # Usage Tracking
61
+ :stream_usage_index, # Usage Tracking (Streams)
59
62
  :max_tool_turns, :tool_turns # Tool-loop safety
60
63
 
61
64
  # Upper bound on tool-calling round-trips within one generation. A
@@ -118,6 +121,9 @@ module ActiveAgent
118
121
  self.context = kwargs
119
122
  self.message_stack = []
120
123
  self.usage_stack = []
124
+ self.stream_completion_pending = false
125
+ self.stream_completion_result = nil
126
+ self.stream_usage_index = nil
121
127
  end
122
128
 
123
129
  # Generates prompt preview without executing the API call.
@@ -179,6 +185,9 @@ module ActiveAgent
179
185
  #
180
186
  # @return [ActiveAgent::Providers::Common::PromptResponse]
181
187
  def resolve_prompt
188
+ # Each turn streams its own usage; see record_stream_usage.
189
+ self.stream_usage_index = nil
190
+
182
191
  api_parameters = api_request_build(prepare_prompt_request, prompt_request_type)
183
192
  api_response = instrument("prompt.provider.active_agent") do |payload|
184
193
  raw_response = with_exception_handling { api_prompt_execute(api_parameters) }
@@ -193,6 +202,12 @@ module ActiveAgent
193
202
  raw_response
194
203
  end
195
204
 
205
+ # A stream that deferred its completion has already run
206
+ # process_prompt_finished (including any tool-call recursion) from
207
+ # stream_finished!, and holds the response it produced. Calling it a
208
+ # second time here would re-run that work.
209
+ return stream_completion_result if stream_completion_result
210
+
196
211
  process_prompt_finished(api_response)
197
212
  end
198
213
 
@@ -231,7 +246,11 @@ module ActiveAgent
231
246
  # @return [Hash] API request parameters
232
247
  def api_request_build(request, request_type)
233
248
  parameters = request_type.serialize(request)
234
- parameters[:stream] = process_stream if request.try(:stream)
249
+
250
+ if request.try(:stream)
251
+ parameters[:stream] = process_stream
252
+ parameters.deep_merge!(api_stream_usage_parameters)
253
+ end
235
254
 
236
255
  if options.extra_headers.present?
237
256
  parameters[:request_options] = { extra_headers: options.extra_headers }.deep_merge(parameters[:request_options] || {})
@@ -240,6 +259,20 @@ module ActiveAgent
240
259
  parameters
241
260
  end
242
261
 
262
+ # Extra request parameters needed to make the provider report token usage
263
+ # while streaming.
264
+ #
265
+ # A streaming request returns its usage on a final chunk rather than in a
266
+ # response body, and several providers only send that chunk when the
267
+ # request asks for it. Providers that need such a flag override this;
268
+ # the default asks for nothing, so a provider that reports usage
269
+ # unconditionally — or not at all — is unaffected.
270
+ #
271
+ # @return [Hash]
272
+ def api_stream_usage_parameters
273
+ {}
274
+ end
275
+
243
276
  # @return [Proc] for each response chunk
244
277
  def process_stream
245
278
  proc do |api_response_chunk|
@@ -258,10 +291,27 @@ module ActiveAgent
258
291
  api_prompt_executer.create(**parameters)
259
292
  else
260
293
  api_prompt_executer.stream(**parameters.except(:stream)).each(&parameters[:stream])
294
+ stream_finished!
261
295
  nil
262
296
  end
263
297
  end
264
298
 
299
+ # Runs the deferred end-of-generation work once a stream has drained.
300
+ #
301
+ # A chunk handler that completed the message marks the generation
302
+ # pending rather than finishing inline, because providers emit their
303
+ # usage chunk after the content is done — finishing inline would build
304
+ # the response before usage is recorded. Handlers that finish inline
305
+ # leave the flag unset and this is a no-op.
306
+ #
307
+ # @return [Object, nil] result of process_prompt_finished, if deferred
308
+ def stream_finished!
309
+ return unless stream_completion_pending
310
+
311
+ self.stream_completion_pending = false
312
+ self.stream_completion_result = process_prompt_finished
313
+ end
314
+
265
315
  # Returns provider-specific API executer for prompt requests.
266
316
  #
267
317
  # Since all currently implemented providers use stainless gems, subclasses
@@ -305,6 +355,52 @@ module ActiveAgent
305
355
  fail NotImplementedError, "Subclass expected to implement"
306
356
  end
307
357
 
358
+ # Records token usage carried on a streaming chunk.
359
+ #
360
+ # The non-streaming path pushes onto usage_stack in resolve_prompt, from
361
+ # the response body. A streaming request has no response body — the
362
+ # provider streams chunks and api_prompt_execute returns nil — so usage
363
+ # would otherwise be lost, and every streamed generation reports zero
364
+ # tokens and zero cost. Chunk handlers call this when they see usage so
365
+ # the two paths converge on the same usage_stack.
366
+ #
367
+ # Ignores blank and all-zero payloads: providers send `usage: null` on
368
+ # ordinary content chunks, and pushing those would add empty entries to
369
+ # a stack that is summed with reduce(:+).
370
+ #
371
+ # A streamed usage payload is a running total for the turn, not a delta,
372
+ # so the turn keeps a single entry that later payloads replace. Chat
373
+ # Completions sends exactly one, on a final chunk, but Gemini's
374
+ # OpenAI-compatible endpoint repeats a cumulative usage on every chunk —
375
+ # summing those would report a turn's tokens many times over. Tool
376
+ # calling still accumulates across turns: each turn enters resolve_prompt
377
+ # and starts a fresh entry.
378
+ #
379
+ # @param raw_usage [Hash, Object, nil] provider-shaped usage payload
380
+ # @return [void]
381
+ def record_stream_usage(raw_usage)
382
+ return if raw_usage.blank?
383
+
384
+ # from_provider_usage only reads hashes, and the stainless gems hand
385
+ # back model objects (e.g. OpenAI::Models::CompletionUsage), so an
386
+ # unconverted object is silently dropped.
387
+ raw_usage = raw_usage.deep_to_h if raw_usage.respond_to?(:deep_to_h)
388
+ raw_usage = raw_usage.to_h if !raw_usage.is_a?(Hash) && raw_usage.respond_to?(:to_h)
389
+
390
+ usage = Common::Usage.from_provider_usage(raw_usage)
391
+ return if usage.blank?
392
+ return if usage.total_tokens.to_i.zero? &&
393
+ usage.input_tokens.to_i.zero? &&
394
+ usage.output_tokens.to_i.zero?
395
+
396
+ if stream_usage_index
397
+ usage_stack[stream_usage_index] = usage
398
+ else
399
+ self.stream_usage_index = usage_stack.length
400
+ usage_stack.push(usage)
401
+ end
402
+ end
403
+
308
404
  # Broadcasts stream open event.
309
405
  #
310
406
  # Fires once per request cycle, even during multi-turn tool calling.