Usage Ledger Reference

The records struckdown emits for every provider request, the functions that control them, and the Django tables, settings and commands that store them. For step-by-step use see Record LLM Usage and Record Usage in Django; for how costs are worked out see Cost Tracking.

Core API

Importable from struckdown unless another module is given.

Function Description
register_usage_handler(handler) Call handler for every record in this process. Registering the same handler twice has no effect.
unregister_usage_handler(handler) Remove a process-wide handler.
usage_tracking(handler) Context manager: call handler for records made inside the block, in this context only. One per context; a nested block replaces the outer handler until it ends.
set_model_pricing(input_cost_per_mtok, output_cost_per_mtok, cache_read_cost_per_mtok=None, cache_write_cost_per_mtok=None) Stored pricing (USD per million tokens) for calls that follow in this context. Needs both input and output rates; None for either clears it.
struckdown.llm.get_model_pricing() The current stored pricing as a StoredPricing, or None.
set_model_ref(ref) Your own identifier for the model, copied onto records that follow in this context. None clears it.
struckdown.ledger.get_model_ref() The current model_ref.
set_audio_pricing(cost_per_minute) Per-minute rate for transcriptions that follow in this context. None clears it.
held_usage() Context manager. Records made inside the block, including on threads running in a copy of this context, are held back and dispatched in this thread when the block exits, whether or not it raised. Inside an enclosing block, they are passed up to it instead.
struckdown.ledger.aheld_usage() The same as an async context manager; the records are dispatched on this loop, shielded from cancellation.
deferred_usage() Lower level: a context manager yielding the list of held records, which it does not dispatch. Flush it with flush_usage in a finally.
flush_usage(pending) Dispatch held records from sync code, in this thread. Inside an enclosing deferred_usage block, passes them up to it instead.
struckdown.ledger.flush_usage_async(pending) The same from async code; awaits handlers that return an awaitable.

set_model_pricing, set_model_ref and set_audio_pricing set context variables. They stay set until replaced, and apply to every call in the context whatever its model. complete(spec=...) and complete(registry=...) set pricing and model_ref from the resolved ModelSpec.

Handlers

Handler = Callable[[UsageRecord], None | Awaitable[None]]

From async code, an awaitable a handler returns is awaited. From sync code it is run to completion, or scheduled on the running loop if the thread has one. A handler that raises is logged and does not fail the call.

A handler with a truthy wants_payload attribute (or property) receives record.payload; otherwise payload is None and the bodies are never built.

UsageRecord

struckdown.UsageRecord, a dataclass. One per provider request.

Field Type Default Description
kind str   chat, embedding or transcription
model_name str   Model as called, e.g. openai:gpt-4.1-mini
provider str "" From the response, or the provider: prefix of the name
base_url_host str "" Host of the credentials’ base_url; never the key
model_ref str or None None The model_ref set when the record was made
input_tokens int 0 All prompt tokens, cached included
output_tokens int 0 All completion tokens, reasoning included
cache_read_tokens int 0 Prompt tokens read from the provider’s cache
cache_write_tokens int 0 Prompt tokens written to the provider’s cache
reasoning_tokens int 0 Part of output_tokens
audio_seconds float or None None Transcription only
cost CostBreakdown or None None None when the call could not be priced, and for failed requests
duration_ms float or None None For a tool loop, set on the last round’s record only
started_at datetime or None None UTC
cache_hit bool False Served from struckdown’s response cache; cost 0
ok bool True False for a failed request
error_class str "" Exception class name of a failed request
slot str or None None Template slot the call was made for
provider_request_id str "" The provider’s response id, where given
finish_reason str "" Where given
payload UsagePayload or None None Bodies, when a handler asked
extra dict {} Unused by struckdown; free for handlers

record.total_cost is cost.total_cost, or None.

CostBreakdown

struckdown.CostBreakdown, a dataclass.

Field Type Description
input_cost float USD: uncached input, cache reads and cache writes at their own rates; the whole cost of an embedding or transcription
output_cost float USD: completion tokens, reasoning included
input_price, output_price float or None USD per million tokens, as used; set for stored pricing only
cache_read_price, cache_write_price float or None As used; the input rate when no cache rate was set
source str stored, pydantic_ai, genai_prices, audio_rate, or cache for a response-cache hit

total_cost is input_cost + output_cost.

StoredPricing and cost_from_stored

struckdown.ledger.StoredPricing(input_per_mtok, output_per_mtok, cache_read_per_mtok=None, cache_write_per_mtok=None) holds caller-supplied rates.

struckdown.ledger.cost_from_stored(pricing, *, input_tokens, output_tokens, cache_read_tokens=0, cache_write_tokens=0) returns the CostBreakdown for those counts. input_tokens is the whole prompt; cache reads and writes are subtracted from it and charged at their own rates, or at the input rate where none is set.

UsagePayload

struckdown.UsagePayload(request=None, response=None).

Call request response
Completion {"messages": [...]} {"output": ...}
Tool loop round {"messages": [...]} on the first round, else None {"parts": [...]}
Embedding batch {"texts": <count>} None
Transcription no payload  

OpenTelemetry names

struckdown.ledger.SEMCONV maps record fields to the OpenTelemetry gen_ai semantic conventions:

Field Attribute
kind gen_ai.operation.name
model_name gen_ai.request.model
provider gen_ai.provider.name
input_tokens gen_ai.usage.input_tokens
output_tokens gen_ai.usage.output_tokens
cache_read_tokens gen_ai.usage.cache_read.input_tokens
cache_write_tokens gen_ai.usage.cache_creation.input_tokens
provider_request_id gen_ai.response.id
finish_reason gen_ai.response.finish_reasons
error_class error.type

Django

struckdown.contrib.django, app label sd_models. Requires Django 5.0+, django.contrib.contenttypes and django.contrib.auth.

Settings

Setting Default Description
STRUCKDOWN_LEDGER_ENABLED True Write rows at all
STRUCKDOWN_LEDGER_CAPTURE_PAYLOADS False Keep every call’s bodies, not only those in a capture=True span
STRUCKDOWN_LEDGER_PAYLOAD_DAYS 30 Payload retention for sd_prune_ledger; 0 keeps them
STRUCKDOWN_LEDGER_CALL_DAYS 400 Call and span retention for sd_prune_ledger; 0 keeps them

Django tables

All three tables are written by the ledger; the admin shows them read-only.

LLMSpan (llm_span)

What a call belonged to: a request, a task, or a block a caller named.

Field Type Description
id UUID  
name char(160) http:<view name>, celery:<task name>, or the name given to llm_span
parent FK LLMSpan, null Enclosing span
user FK user, null  
content_type, object_id, obj generic FK The record that holds the text of the exchange
started_at, ended_at datetime For a streaming response’s root span, ended_at is when the view returned, before the body streamed
attributes JSON Extra keyword arguments to the span; method and path for a request
capture_payloads bool Opened with capture=True

root_name (property) is the outermost span’s name.

LLMCall (llm_call)

One row per provider request. The model’s name, prices and residency are copied onto the row as they were at call time; available_model is a link for filtering, not a source of facts.

Field Type Description
created_at datetime When the row was written; the costs page and pruning use this
started_at datetime, null When the request started
duration_ms int, null  
span FK LLMSpan, null Innermost span; null when unattributed
root_name char(160) Outermost span’s name, copied for grouping; "" when unattributed
slot char(120)  
kind char(20) chat, embedding, transcription
model_name, provider, base_url_host char  
data_residency char(10) From the linked AvailableModel
available_model FK AvailableModel, null See linking
input_price, output_price, cache_read_price, cache_write_price decimal(12,6), null USD per million tokens, as used; null unless priced from stored rates
price_source char(20) As CostBreakdown.source; "" when unpriced
input_tokens int All input, cached included
cache_read_tokens, cache_write_tokens int  
output_tokens int  
reasoning_tokens int Part of output
audio_seconds float, null  
input_cost, output_cost decimal(14,8), null USD. Null means unpriced, never 0; set together or not at all
total_cost generated decimal(14,8), null input_cost + output_cost, stored
cache_hit bool Served from struckdown’s response cache
ok bool  
error_class char(120)  
provider_request_id char(120)  
finish_reason char(40)  

unpriced (property) is true when input_cost is null. The costs page counts a row as unpriced when its cost is null, ok is true and it is not a cache hit.

LLMCallPayload (llm_call_payload)

Field Type Description
call one-to-one LLMCall, cascade  
request, response JSON, null As UsagePayload
created_at datetime Used by pruning

Written only when STRUCKDOWN_LEDGER_CAPTURE_PAYLOADS is on or the call’s innermost span has capture_payloads.

LLMCosts

A proxy of LLMCall that stores nothing. Its admin changelist is the costs page, at /admin/sd_models/llmcosts/, and its view_llmcosts permission gates it.

AvailableModel additions

Member Description
cache_read_cost_per_mtok decimal, null. Blank means cache reads are charged at the input rate
cache_write_cost_per_mtok decimal, null. Blank means cache writes are charged at the input rate
stored_pricing() The row’s rates as a StoredPricing, or None if the input or output rate is blank
to_spec(default_credential=None) Now includes the cache rates and model_ref=<row id>
get_llm_and_credentials() Also sets the cache rates, model_ref and the audio rate for the calls that follow

sd_update_prices and update_prices() fill the cache rates where the price source publishes them.

How a call is linked to an AvailableModel

  1. The row whose id is the record’s model_ref, if it exists.
  2. Otherwise the only row with that model_name.
  3. Otherwise, of the rows with that name, the only one whose credential’s base_url contains the record’s base_url_host.
  4. Otherwise no link.

When struckdown could not price a successful, non-cached chat or embedding call and the linked row has stored rates, the row is written priced from those rates with price_source stored.

Spans

struckdown.contrib.django.spans.

Function Description
llm_span(name, *, user=None, obj=None, capture=False, **attributes) Context manager; opens a span, writes its row, yields the SpanHandle, closes it on exit
open_span(name, *, user=None, obj=None, capture=False, eager=True, root=False, name_fn=None, **attributes) Opens a span in this context and returns its handle. eager writes the row now; root ignores any enclosing span; name_fn is a callable that supplies the final name later
close_span(handle) Sets ended_at and restores the enclosing span
aopen_span(name, *, user=None, obj=None, capture=False, **attributes) open_span from async code; the row is written on a thread
aclose_span(handle) close_span from async code
current_span() The current SpanHandle, or None
install_celery_hooks() Connects task_prerun / task_postrun so each task runs in a root span named celery:<task name>

user is a user instance, a primary key, or a callable returning either; an anonymous user is stored as null. obj is any model instance.

SpanMiddleware opens a lazy root span per request – the row is written only if a call is made – named http:<view name>, or http:<path> when the URL did not resolve. It is sync- and async-capable, and must come after AuthenticationMiddleware. For a streaming response it runs the body under the request’s span, setting it around each next() or __anext__() while the body computes a chunk, and never leaving it in the consumer’s context. A span the body opens stays current across its chunks. It resets the span context when the view returns, discarding any span the view opened and left open; open spans for streamed work inside the body.

Ledger writer

struckdown.contrib.django.ledger.

Function Description
record_call(*, model_name, input_tokens=0, output_tokens=0, cache_read_tokens=0, cache_write_tokens=0, kind="chat", available_model=None, duration_ms=None, started_at=None, ok=True, error_class="", provider_request_id="", finish_reason="", request=None, response=None) Write an LLMCall for a call that did not go through struckdown, priced from available_model’s stored rates (or a row matched by name). Returns the row, or None if the ledger is disabled or the write failed
record_openai_response(response, *, model_name, available_model=None, **kwargs) record_call from an OpenAI SDK chat completion or final stream chunk: reads prompt_tokens, completion_tokens, prompt_tokens_details.cached_tokens, id and the first choice’s finish_reason. There is no cache-write count in this shape
write_record(record) Write a UsageRecord. Logs and returns None on failure
resolve_model(record) The linked AvailableModel, as above, or None
handler The registered handler instance. Writes inline from sync code; from async code returns a sync_to_async(thread_sensitive=True) coroutine for struckdown to await

A failed write is logged and never fails the LLM call.

Costs aggregations

struckdown.contrib.django.costs returns plain dicts, for a dashboard tile or a report. days is one of the windows in WINDOWS (1, 7, 30, 365, or 0 for all time).

Function Returns
dashboard(days) All of the below in one dict
headline(days) Totals: calls, users, input_tokens, cache_read_tokens, output_tokens, cost_in, cost_out, cost_total, unpriced, cache_hits, failed, per_call, per_user, cache_share
by_feature(days) The same per root_name ("unattributed" for none)
by_model(days) The same per model name, provider and kind
by_month(months=12) The same per calendar month
per_user(days) users, mean, median, max; no names

per_call is cost_total divided by calls minus unpriced calls and cache hits. Failed calls are not subtracted.

Admin

struckdown.contrib.django.admin_ledger defines LLMCallAdmin, LLMSpanAdmin, LLMCallPayloadAdmin (read-only; deleting is allowed) and LLMCostsAdmin (the costs page). LLMCostsAdmin.extra_sections(request, days) returns extra tables for the page as a list of {"title", "columns", "rows", "note"} dicts, rows being a list of lists.

register(site=admin.site) registers the four admins on site, skipping any model already registered. The app calls it for the default site when its admin module loads.

Management commands

Command Description
sd_prune_ledger [--dry-run] Delete payloads older than STRUCKDOWN_LEDGER_PAYLOAD_DAYS, calls older than STRUCKDOWN_LEDGER_CALL_DAYS, and spans older than that with no calls left. A payload also goes with its call. --dry-run counts the same rows and deletes none
sd_update_prices [--force] [--dry-run] Refresh stored prices, cache rates included, for active AvailableModels from their credential’s pricing source. Skips rows with prices_updated_manually unless --force

This site uses Just the Docs, a documentation theme for Jekyll.