Usage metering
Every Agent exposes agent.usage, a thread-safe UsageMeter. It records each
actual chat-model request, including retries, tool continuations, streams and
approval resumes. It excludes embeddings, transcription, speech and image generation.
OpenRouter’s usage.cost is separate from estimates. Each ModelCall may have
reported_cost_usd; meter totals include reported_cost_usd and
cost_reported_calls. The final response’s metadata["reported_cost"] aggregates
charges with amount, currency="USD" and complete. Missing reports do not
become zero-priced calls. PriceTable estimates remain separate. Reported-cost
totals survive bounded record eviction and persisted approval history.
from praval import Agent
from praval.metering import Price, PriceTable, UsageMeter, correlation
meter = UsageMeter(max_records=1000)
agent = Agent("writer", provider="openai", model="gpt-5.4-mini")
with meter.track(), correlation("application-request-42"):
response = agent.generate("Explain coral reefs briefly.")
print(meter.totals)
prices = PriceTable({("openai", "gpt-5.4-mini"): Price(1.0, 2.0)})
print(meter.cost(prices)) # Illustrative caller-supplied prices per million tokens.
track() includes calls from multiple agents in the current context and its
Praval worker threads. Nested tracking of the same meter records each request
once. agent.usage covers the agent lifetime. reset() clears its totals and
history while retaining subscribers. subscribe(callback) provides each new
ModelCall and returns an unsubscribe function. A failing subscriber is removed;
its failure does not interrupt model execution.
Records and totals
ModelCall contains request identities, operation, attempt, tool round, status,
duration and provider-reported usage. It contains no prompts, responses or tool
arguments. calls retains at most max_records recent records. Lifetime totals
and totals by provider/model remain exact after records are evicted.
ModelResponse.usage sums reported usage across the complete logical invocation.
metadata["model_calls"] holds its request records and
metadata["usage_complete"] reports whether every request succeeded and supplied
usage. Missing usage stays unknown; zero usage is a reported value.
UsageTotals.failed_calls counts errors, while unreported_calls counts successful
requests without usage. complete is false for either condition.
Streaming adds a model_call event for each completed request before final.
Provider usage snapshots are not added repeatedly. A subscriber receives records
when requests finish, including an interrupted stream with no final response.
ExecutionObservation.model_calls and its usage reflect actual requests without
counting the final aggregate again.
Approval state retains request records across restart and failed resumes. Restored records contribute to the resumed response, but are not emitted again to lifetime meters or subscribers. Tool replay protection applies after the result is committed to SQLite; applications still need idempotency for a crash between an external effect and that commit.
Token normalization and cost
Input includes cached tokens. Output includes reasoning tokens. Cache read/write and reasoning fields are subsets, so pricing does not charge them twice. Anthropic cache counts are added to its ordinary input count; Gemini thought tokens are added to candidate output. Cohere uses billed units when available. Local providers that omit usage produce incomplete totals.
Prices belong to the caller and are never fetched automatically. Cache prices
default to the input price. Missing model prices or usage produce an incomplete
CostEstimate with explicit priced/unpriced request counts. Tables cannot mix
currencies. Estimates describe reported token charges, excluding other fees.
Provider field references: OpenAI, Anthropic, Gemini, Cohere.
API
.. py:module:: praval.metering
Bounded, exact chat-model usage counters and caller-supplied cost estimates.
Records contain identities and usage only, never prompts or response content. Provider requests are counted even when usage is unavailable.
.. py:class:: ModelCall(provider, model, operation, round_index, attempt, status, usage, duration_ms, started_at, agent_name, call_id, run_id, parent_run_id, correlation_id, response_id, reported_cost_usd=None) :module: praval.metering
Bases: :py:class:object
One actual provider request, including failed and interrupted attempts.
.. py:attribute:: ModelCall.provider :module: praval.metering :type: str
.. py:attribute:: ModelCall.model :module: praval.metering :type: str
.. py:attribute:: ModelCall.operation :module: praval.metering :type: ~typing.Literal[‘invoke’, ‘stream’, ‘continue’, ‘resume’]
.. py:attribute:: ModelCall.round_index :module: praval.metering :type: int | None
.. py:attribute:: ModelCall.attempt :module: praval.metering :type: int
.. py:attribute:: ModelCall.status :module: praval.metering :type: ~typing.Literal[‘ok’, ‘error’]
.. py:attribute:: ModelCall.usage :module: praval.metering :type: ~praval.models.Usage | None
.. py:attribute:: ModelCall.duration_ms :module: praval.metering :type: float
.. py:attribute:: ModelCall.started_at :module: praval.metering :type: ~datetime.datetime
.. py:attribute:: ModelCall.agent_name :module: praval.metering :type: str | None
.. py:attribute:: ModelCall.call_id :module: praval.metering :type: str
.. py:attribute:: ModelCall.run_id :module: praval.metering :type: str | None
.. py:attribute:: ModelCall.parent_run_id :module: praval.metering :type: str | None
.. py:attribute:: ModelCall.correlation_id :module: praval.metering :type: str | None
.. py:attribute:: ModelCall.response_id :module: praval.metering :type: str | None
.. py:attribute:: ModelCall.reported_cost_usd :module: praval.metering :type: float | None :value: None
.. py:method:: ModelCall.init(provider, model, operation, round_index, attempt, status, usage, duration_ms, started_at, agent_name, call_id, run_id, parent_run_id, correlation_id, response_id, reported_cost_usd=None) :module: praval.metering
.. py:class:: UsageMeter(max_records=10000) :module: praval.metering
Bases: :py:class:object
Thread-safe counters with bounded records and isolated subscribers.
.. py:method:: UsageMeter.init(max_records=10000) :module: praval.metering
.. py:method:: UsageMeter.record(call) :module: praval.metering
Record a request; invoke subscribers without holding the meter lock.
:rtype: :sphinx_autodoc_typehints_type:`\:py\:obj\:\`None\``
.. py:property:: UsageMeter.totals :module: praval.metering :type: ~praval.metering.UsageTotals
Return an immutable snapshot of all recorded usage.
.. py:method:: UsageMeter.totals_by_model() :module: praval.metering
Return independent totals for each provider/model pair.
:rtype: :sphinx_autodoc_typehints_type:`\:py\:class\:\`\~typing.Dict\`\\ \\\[\:py\:data\:\`\~typing.Tuple\`\\ \\\[\:py\:class\:\`str\`\, \:py\:class\:\`str\`\]\, \:py\:class\:\`\~praval.metering.UsageTotals\`\]`
.. py:property:: UsageMeter.calls :module: praval.metering :type: ~typing.Tuple[~praval.metering.ModelCall, …]
Return defensive snapshots of the most recent bounded records.
.. py:method:: UsageMeter.subscribe(callback) :module: praval.metering
Subscribe to new requests; return an idempotent unsubscribe function.
:rtype: :sphinx_autodoc_typehints_type:`\:py\:data\:\`\~typing.Callable\`\\ \\\[\\\[\]\, \:py\:obj\:\`None\`\]`
.. py:method:: UsageMeter.cost(prices) :module: praval.metering
Price exact lifetime totals, without charging cache/reasoning twice.
:rtype: :sphinx_autodoc_typehints_type:`\:py\:class\:\`\~praval.metering.CostEstimate\``
.. py:method:: UsageMeter.reset() :module: praval.metering
Clear records and totals, keeping existing subscriptions.
:rtype: :sphinx_autodoc_typehints_type:`\:py\:obj\:\`None\``
.. py:method:: UsageMeter.track() :module: praval.metering
Track calls from all agents/runtimes in this context, including workers.
:rtype: :sphinx_autodoc_typehints_type:`\:py\:class\:\`\~typing.Iterator\`\\ \\\[\:py\:class\:\`\~praval.metering.UsageMeter\`\]`
.. py:class:: UsageTotals(calls=0, failed_calls=0, unreported_calls=0, input_tokens=0, output_tokens=0, reasoning_tokens=0, cache_read_tokens=0, cache_write_tokens=0, total_tokens=0, complete=True, reported_cost_usd=0.0, cost_reported_calls=0) :module: praval.metering
Bases: :py:class:object
Exact totals, including records evicted from the bounded call history.
.. py:attribute:: UsageTotals.calls :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.failed_calls :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.unreported_calls :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.input_tokens :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.output_tokens :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.reasoning_tokens :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.cache_read_tokens :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.cache_write_tokens :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.total_tokens :module: praval.metering :type: int :value: 0
.. py:attribute:: UsageTotals.complete :module: praval.metering :type: bool :value: True
.. py:attribute:: UsageTotals.reported_cost_usd :module: praval.metering :type: float :value: 0.0
.. py:attribute:: UsageTotals.cost_reported_calls :module: praval.metering :type: int :value: 0
.. py:method:: UsageTotals.init(calls=0, failed_calls=0, unreported_calls=0, input_tokens=0, output_tokens=0, reasoning_tokens=0, cache_read_tokens=0, cache_write_tokens=0, total_tokens=0, complete=True, reported_cost_usd=0.0, cost_reported_calls=0) :module: praval.metering
.. py:class:: Price(input_per_million, output_per_million, cache_read_per_million=None, cache_write_per_million=None, currency=’USD’) :module: praval.metering
Bases: :py:class:object
Prices per million tokens; cache prices default to the input price.
.. py:attribute:: Price.input_per_million :module: praval.metering :type: float
.. py:attribute:: Price.output_per_million :module: praval.metering :type: float
.. py:attribute:: Price.cache_read_per_million :module: praval.metering :type: float | None :value: None
.. py:attribute:: Price.cache_write_per_million :module: praval.metering :type: float | None :value: None
.. py:attribute:: Price.currency :module: praval.metering :type: str :value: ‘USD’
.. py:method:: Price.init(input_per_million, output_per_million, cache_read_per_million=None, cache_write_per_million=None, currency=’USD’) :module: praval.metering
.. py:class:: PriceTable(prices) :module: praval.metering
Bases: :py:class:object
Caller-owned prices indexed by (provider, model) in one currency.
.. py:method:: PriceTable.init(prices) :module: praval.metering
.. py:class:: CostEstimate(amount, currency, priced_calls, unpriced_calls, complete) :module: praval.metering
Bases: :py:class:object
Cost of reported usage, with explicit missing-price/usage accounting.
.. py:attribute:: CostEstimate.amount :module: praval.metering :type: float
.. py:attribute:: CostEstimate.currency :module: praval.metering :type: str
.. py:attribute:: CostEstimate.priced_calls :module: praval.metering :type: int
.. py:attribute:: CostEstimate.unpriced_calls :module: praval.metering :type: int
.. py:attribute:: CostEstimate.complete :module: praval.metering :type: bool
.. py:method:: CostEstimate.init(amount, currency, priced_calls, unpriced_calls, complete) :module: praval.metering
.. py:function:: correlation(identifier) :module: praval.metering
Attach an application correlation ID to calls in the current context.
:rtype: :sphinx_autodoc_typehints_type:\:py\:class\:\~typing.Iterator`\ \[:py:obj:`None`]`