oq-ai-usage
Per-user and per-tenant AI token and cost usage recording and analytics, for Python. A small, framework-agnostic core with two optional extras, built so any project can adopt it without pulling in a pricing library or an ORM it does not already use.
Extracted for reuse alongside oq-ai-router
(provider-agnostic LLM routing) rather than designed in the abstract:
this package answers the question that library deliberately leaves open
-- once a call has been routed and answered, who used how much, at what
token counts, and at what cost.
What this is, and what it deliberately is not
| Concern | Owner |
|---|---|
| Which model to call, in what order, for what kind of work | oq-ai-router, or your own routing layer |
| The actual HTTP call | LiteLLM, your own transport, anything |
| The record of one completed call: who, which tenant, which model, how many tokens | this package (events.py) |
| What that call cost, or what a local call avoided spending | this package, via LiteLLM's price map (pricing.py, optional) |
| Aggregating many records into per-user, per-tenant, per-purpose, per-day totals | this package (analytics.py, pure Python; storage.py, pushed into SQL) |
| Persisting records | your own database and your own model (storage.py supplies the column set only) |
This package ships no table of its own and no default pricing model. A shared library owning a table in a multi-tenant host is exactly the kind of coupling that breaks the host's own tenancy and row-level-security story; a library inventing a "reasonable default" comparison model for cost-avoided figures would be reporting a number nobody actually decided was correct. Both are refused structurally, not just by convention -- see the honesty rules below.
Install
pip install oq-ai-usage # core only: events + analytics
pip install oq-ai-usage[pricing] # + cost estimation via litellm
pip install oq-ai-usage[sqlalchemy] # + persistence helpers
pip install "oq-ai-usage[pricing,sqlalchemy]" # both
The core (UsageEvent, analytics.py) has zero hard dependencies.
pricing.py and storage.py both import their extra lazily --
importing this package, or either of those two modules, never requires
litellm or sqlalchemy to be installed. Only calling a function that
genuinely needs one does, and it raises a named error
(PricingUnavailable, or a plain RuntimeError from storage.py) if
the extra is missing, rather than degrading silently.
The honesty rules
These are enforced in code, not just documented, and every one of them has a test:
- No reference model, no cost-avoided figure. A call served by a local model has no cloud invoice of its own. The only honest "what did running this locally save" figure is "what the cloud-equivalent call would have cost", and that number does not exist until the caller names which cloud model is the equivalent. There is no default reference model anywhere in this package.
- An unknown cost is
None, never zero. A model absent from the price map, or no reference model supplied, both mean "not known", not "free".analytics.pyandstorage.py's SQL aggregates both count such events separately (cost_known_count,cost_unknown_count) and never fold an unknown cost into a sum as if it were0.0. A group that is genuinely, verifiably free (every event priced at exactly$0.00) is a different, real fact from a group nobody could price at all, and the two render differently:cost_usd=0.0versuscost_usd=None. - The price basis date is surfaced honestly, which today means
None. Seepricing.py's own docstring for the measured finding: LiteLLM's bundled price map, as actually shipped, carries no per-model "as of" date in its schema. This package still looks for one (in case a future LiteLLM release adds one) rather than hardcodingNoneoutright, but every caller should expectNonetoday. left_the_buildingisOptional[bool], notbool.Nonemeans the host never determined whether the call left the building, which is a different fact fromFalse(determined, and it did not). Nothing in this package defaults a missing value toFalse.
Quickstart
from datetime import datetime, timezone
from oq_ai_usage import UsageEvent, pricing, analytics
# A call your own inference layer already made and answered.
estimate = pricing.cost_of_call("openai/gpt-4o-mini", prompt_tokens=812, completion_tokens=140)
event = UsageEvent(
user_id="u_42",
tenant_id="t_9",
purpose="tender-extraction",
provider="openai",
model="openai/gpt-4o-mini",
prompt_tokens=812,
completion_tokens=140,
lane="openai_compatible_cloud",
left_the_building=True,
created_at=datetime.now(timezone.utc),
cost_usd=estimate.cost_usd,
price_basis_date=estimate.price_basis_date,
cost_reason=estimate.reason,
)
by_tenant = analytics.totals_by_tenant([event])
A call served locally has no cost of its own; report what it AVOIDED spending, against a reference model you name explicitly:
avoided = pricing.cloud_equivalent_cost_avoided(
reference_model="anthropic/claude-3-5-haiku-latest",
prompt_tokens=812,
completion_tokens=140,
)
# avoided.cost_usd is None if you omit reference_model. It is never guessed.
FastAPI + SQLAlchemy hosts
Declare your own model on your own Base, splicing in the shared column
set (or inheriting the shared mixin) so it has the same shape every
adopter's table has, then add whatever tenancy and row-level-security
columns and policies your own application already uses:
from sqlalchemy import Integer
from sqlalchemy.orm import DeclarativeBase, mapped_column
from oq_ai_usage import storage
class Base(DeclarativeBase):
pass
class AiUsageEvent(Base):
__tablename__ = "ai_usage_events"
id = mapped_column(Integer, primary_key=True)
locals().update(storage.usage_event_columns())
# ... your own tenant_id ForeignKey, RLS policy, indexes, etc.
Then, inside a request handler that already has a Session:
from oq_ai_usage import storage
async def record(session, event: UsageEvent):
storage.record_event(session, AiUsageEvent, event)
await session.commit()
totals = storage.query_totals_by_tenant(session, AiUsageEvent)
totals_today = storage.query_totals_by_day(session, AiUsageEvent, tenant_id="t_9")
storage.py asserts nothing about row-level security or tenant
isolation -- that is entirely your model and your database's policies.
Its own tests run against in-memory SQLite, which has no RLS
implementation at all; they prove column shape and aggregation SQL, never
a tenancy guarantee.
API surface
oq_ai_usage.UsageEvent-- the one fact this package works over. Frozen dataclass:user_id,tenant_id,purpose,provider,model,prompt_tokens,completion_tokens,lane,left_the_building,created_at, plus optionalcost_usd/price_basis_date/cost_reason.oq_ai_usage.pricing--cost_of_call(),cloud_equivalent_cost_avoided(),CostEstimate,PricingUnavailable. Requires thepricingextra.oq_ai_usage.analytics--totals(),totals_by(),totals_by_user(),totals_by_tenant(),totals_by_purpose(),totals_by_day(),UsageTotals. Pure Python, no dependencies, works over any iterable ofUsageEvent.oq_ai_usage.storage--usage_event_columns(),get_usage_event_mixin(),record_event(),query_totals_by_user(),query_totals_by_tenant(),query_totals_by_purpose(),query_totals_by_day(). Requires thesqlalchemyextra.
Testing
pip install -e ".[dev]"
pytest -q
The dev extra installs sqlalchemy (so storage.py is fully tested)
and deliberately not litellm, so the suite also proves
pricing.PricingUnavailable fires for real when the pricing extra is
absent, rather than that path being untested.
Licence
Dual licensed: AGPL v3 or later for community use, plus a proprietary commercial licence for anyone who needs to keep modifications private or embed this in a closed-source product. See LICENSING.md.
Note the Affero clause: running a modified version as a network service obliges you to offer its source to that service's users.
A CLA must be in place before any outside contribution is accepted, because dual licensing only works while the copyright is wholly owned. Until then this repository does not accept contributions.
Release files for oq-ai-usage 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| oq_ai_usage-0.1.1.tar.gz | 36.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| oq_ai_usage-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 67.9 kB
Release files / oq_ai_usage-0.1.1.tar.gz
| Download URL | oq_ai_usage-0.1.1.tar.gz |
|---|---|
| Size | 36.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e1da2e813dd7e874c6eb49af40656dd85a92fb6d98d9c15e327951b3344fdd43
|
|
BLAKE2b-256 checksum How to use checksums |
492beeedd448afb59019544c97ac2e9ef2807943bede289951c01cd7fbc82cc1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.4
|
Release files / oq_ai_usage-0.1.1-py3-none-any.whl
| Download URL | oq_ai_usage-0.1.1-py3-none-any.whl |
|---|---|
| Size | 31.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b79c12c2c1861ea192e66c740c6b721610397256d8a26d5dfe09bedaa29cb345
|
|
BLAKE2b-256 checksum How to use checksums |
0c0f07b26fbc7cf81cd136fc552d707b0f0ab602efca891825dac4061adddef7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.4
|