The agent cost ledger records the model id, tokens, cost and latency of each LLM call. The usage report sums it per workflow type and per tenant.

The ledger is a separate table, harvest_llm_ledger. It is not part of the workflow history, so replay does not change. The payload codec does not encrypt it, so SQL can sum it without a key. The model id and the counts are in clear. See the security posture.

1. Record a call

Call ActivityContext::record_llm_call once for each model call:

Rust
use std::time::Instant;
use autumn_harvest::llm_ledger::LlmCall;
use autumn_harvest::prelude::*;

#[activity(start_to_close = "5m")]
async fn summarise(ctx: &ActivityContext, prompt: String) -> Result<String, String> {
    let started = Instant::now();
    let reply = call_my_model(&prompt).await?;
    ctx.record_llm_call(
        LlmCall::new("claude-sonnet-5-5", reply.input_tokens, reply.output_tokens)
            .with_cost_usd_micros(reply.cost_usd_micros)
            .with_latency(started.elapsed()),
    )?;
    Ok(reply.text)
}
  • LlmCall::new(model, input_tokens, output_tokens) makes the call.
  • with_cost_usd_micros sets the cost in millionths of a US dollar. A call with no cost counts as unpriced.
  • with_latency sets the latency. When it is not set, the context records the time since the previous call, or since the attempt started. Set it when an attempt does other slow work between calls.
  • An activity can record up to 256 calls in one attempt.
  • ActivityContext::llm_calls returns the calls recorded so far. Use it in a unit test.
  • An interceptor gets the same context, so it can record the calls of many activities in one place.

record_llm_call checks each call:

CheckLimit
Model id1 to 200 bytes of ASCII letters, digits and ._:/@+-
Input or output tokensAt most 10^12
CostAt most 10^15 millionths of a US dollar
LatencySaturates at 10^10 ms

A refused call returns LlmCallError. The error converts to a non-retryable activity failure. So ? fails the attempt and does not run the model again.

2. When the engine writes the rows

The engine writes one row per call when the attempt completes. The write is in the transaction that appends the completion event, so the rows and the event commit together. Each row holds the event_id of that event and the call_index of the call in the attempt.

PathCompletion event
A worker activityActivityCompleted
A local activityLocalActivityCompleted
run_transactionalActivityCompleted, in the user transaction

In a run_transactional activity, record the calls before the commit. The commit writes the calls that exist at that time. The engine logs a warning for a call recorded after the commit and drops it.

On the SQLite backend

autumn-harvest-sqlite writes the same rows to its own harvest_llm_ledger table, in the transaction that appends the ActivityCompleted or LocalActivityCompleted event. A failed or timed-out attempt writes no row. An activity gives its calls in one of two ways:

  • An #[activity] body that register_activity registers calls ctx.record_llm_call, as on Postgres.
  • A closure that register_activity_with_ledger registers returns Ok((output, calls)). The closure gets no context, so set each latency. An unset latency is the run time of the attempt for the first call, and zero for a later one.

The table names the run exec_id and the completion event event_seq, the seq of the event in harvest_events. recorded_at is the drive clock at the completion, in epoch milliseconds. Read the rows with SqliteRuntime::llm_ledger(exec) or llm_ledger_between(from, until). SqliteReader has both reads. Retention removes the rows with the run.

3. Read the ledger

GET /admin/usage adds six fields to each group:

FieldMeaning
llm_callsLedger rows in the window.
llm_input_tokensSum of the input tokens.
llm_output_tokensSum of the output tokens.
llm_cost_usd_microsSum of the priced costs, in millionths of a US dollar.
llm_unpriced_callsRows with no cost.
llm_latency_msSum of the latencies, in milliseconds.

The window applies to recorded_at. recorded_at is the time of the completion transaction. It equals the completion event's timestamp, so a row and its event fall in the same window.

Use group_by=workflow_name for the cost per workflow type. Use group_by=search_attr:tenant_id for the cost per tenant. See the usage report.

harvest usage shows LLM_CALLS, LLM_IN, LLM_OUT, LLM_COST_USD and LLM_UNPRICED columns. LLM_COST_USD sums the priced calls only, so read it with LLM_UNPRICED. --json prints every field.

The table is plain SQL, so a custom report can read it. A shard move leaves a copy of the rows on two shards. Filter as the usage report does, so the fleet counts each run once:

Sql
SELECT l.model, SUM(l.input_tokens), SUM(l.output_tokens), SUM(l.cost_usd_micros)
  FROM harvest_llm_ledger l
  JOIN harvest_workflow_executions w ON w.id = l.workflow_exec_id
 WHERE l.recorded_at >= NOW() - INTERVAL '30 days'
   AND (w.state NOT IN ('MIGRATING', 'MIGRATED')
        OR (w.state = 'MIGRATED' AND EXISTS (
            SELECT 1 FROM harvest_shard_migrations m
             WHERE m.execution_id = w.id AND m.phase = 'COMMITTED')))
 GROUP BY l.model;

4. The agent adapter

The agent_model_turn activity of autumn-harvest-agent records one call per model call, on both backends. Implement AgentModel::model_id to name the model, and AgentModel::cost_usd_micros to price a call. The defaults are no id and unpriced. The ledger records no id, or an id that it refuses, as "unknown", so the tokens still count.

The response cache also keys on model_id. A cache hit calls no model and spends no tokens, so it records no call. See the agent adapter.

5. Lifecycle

  • Retention. The rows cascade with the execution row. The history archive does not hold them, so retention deletes the cost data too.
  • Shard moves. The rows move with the run. The report skips the staged target copy. It counts the sealed source until the move is done. So one shard reports each run, with one exception. Activation commits the target first, then marks the source done on its own database. Between the two commits, both shards count the run. If the second commit fails, the window lasts until migration recovery ends it.
  • Reset. A fork copies events, not ledger rows. The cost counts once.
  • Erasure. Erasure deletes the rows of the run. It reports the count in ledger_rows_deleted. A row names the run and its models. The spend of an erased run leaves the budget sums. The model id is the only free-form text, and it must not hold PII.
  • Cross-region DR. The FOR ALL TABLES publication copies the table to the standby.

6. Limits

  • A failed attempt writes no row. So does an attempt that loses its lease, or that returns a result over the result cap. Tokens that such an attempt spent are not counted.
  • The ledger has no columns for cached prompt tokens.
  • The report has no per-model dimension. Query the table for that.
  • Quota checks do not read the ledger.