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`]`