Files
DocsGPT/docsgpt/storage/db/repositories/token_usage.py
T
Alex 6c42139224 fix(usage): stop double counting scheduled runs; attribute workflow node usage
sum_tokens_in_range now skips the scheduler's per-run rollup rows, whose
tokens are already on the run's per-call rows, so the per-agent 24h limit
and the admin total no longer count scheduled spend twice.

Workflow node LLMs carry the workflow agent's id, so their usage rows are
attributed to the agent instead of landing with a user id only.

usage_totals returns a user's tokens and cost since a window start, split
by interactive and agent-key traffic.
2026-09-21 12:11:11 +01:00

346 lines
14 KiB
Python

"""Repository for the ``token_usage`` table.
Covers every operation the legacy Mongo code performs on
``token_usage_collection`` / ``usage_collection``:
1. ``insert_one`` in usage.py (record per-call token counts)
2. ``aggregate`` in analytics/routes.py (time-bucketed totals)
3. ``aggregate`` in answer/routes/base.py (24h sum for rate limiting)
4. ``count_documents`` in answer/routes/base.py (24h request count)
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import Connection, text
class TokenUsageRepository:
"""Postgres-backed replacement for Mongo ``token_usage_collection``."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
def insert(
self,
*,
user_id: Optional[str] = None,
api_key: Optional[str] = None,
agent_id: Optional[str] = None,
prompt_tokens: int = 0,
generated_tokens: int = 0,
source: str = "agent_stream",
request_id: Optional[str] = None,
model_id: Optional[str] = None,
timestamp: Optional[datetime] = None,
cached_tokens: Optional[int] = None,
cache_write_tokens: Optional[int] = None,
cost: float = 0.0,
) -> None:
# Attribution guard: the ``token_usage_attribution_chk`` CHECK
# constraint requires at least one of ``user_id`` / ``api_key``
# to be non-null. Raise here for a clear error rather than
# relying on the DB to reject the row.
if not user_id and not api_key:
raise ValueError("token_usage insert requires user_id or api_key")
# ``agent_id`` is a UUID column. Legacy callers occasionally pass
# a Mongo ObjectId string (24 hex chars) — those would make
# psycopg raise at CAST time. Coerce anything that isn't shaped
# like a UUID (36 chars with hyphens) to NULL so a stray legacy
# id never breaks token accounting.
agent_id_uuid: Optional[str] = None
if agent_id:
s = str(agent_id)
if len(s) == 36 and "-" in s:
agent_id_uuid = s
self._conn.execute(
text(
"""
INSERT INTO token_usage (
user_id, api_key, agent_id,
prompt_tokens, generated_tokens,
cached_tokens, cache_write_tokens, cost,
source, request_id, model_id, timestamp
)
VALUES (
:user_id, :api_key,
CAST(:agent_id AS uuid),
:prompt_tokens, :generated_tokens,
:cached_tokens, :cache_write_tokens, :cost,
:source, :request_id, :model_id, COALESCE(:timestamp, now())
)
"""
),
{
"user_id": user_id,
"api_key": api_key,
"agent_id": agent_id_uuid,
"prompt_tokens": prompt_tokens,
"generated_tokens": generated_tokens,
"cached_tokens": cached_tokens,
"cache_write_tokens": cache_write_tokens,
"cost": cost,
"source": source,
"request_id": request_id,
"model_id": model_id,
"timestamp": timestamp,
},
)
def reassign_api_key(self, *, old_key: str, new_key: str) -> int:
"""Re-point historical rows from ``old_key`` to ``new_key``.
Called when an agent's key is rotated. Although per-agent analytics
also match on ``agent_id``, the 24h rate-limit window
(``sum_tokens_in_range`` / ``count_in_range``) and any legacy rows with
a NULL ``agent_id`` are keyed by the api-key string only. Rewriting the
key here preserves the running rate-limit window across a rotation (so
rotating cannot be used to reset it) and keeps legacy rows attributed.
Returns the number of rows updated.
"""
if not old_key or not new_key:
return 0
result = self._conn.execute(
text("UPDATE token_usage SET api_key = :new_key WHERE api_key = :old_key"),
{"old_key": old_key, "new_key": new_key},
)
return result.rowcount
def sum_tokens_in_range(
self,
*,
start: datetime,
end: datetime,
user_id: Optional[str] = None,
api_key: Optional[str] = None,
) -> int:
"""Total (prompt + generated) tokens in the given time range.
Run-level rollup rows (``ROLLUP_SOURCES``) are excluded: their tokens
are already counted on the run's per-call rows.
"""
clauses = ["timestamp >= :start", "timestamp <= :end", "source <> ALL(:rollup_sources)"]
params: dict = {"start": start, "end": end, "rollup_sources": list(self.ROLLUP_SOURCES)}
if user_id is not None:
clauses.append("user_id = :user_id")
params["user_id"] = user_id
if api_key is not None:
clauses.append("api_key = :api_key")
params["api_key"] = api_key
where = " AND ".join(clauses)
result = self._conn.execute(
text(f"SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0) FROM token_usage WHERE {where}"),
params,
)
return result.scalar()
def usage_totals(self, *, user_id: str, start: datetime, bucket: str = "all") -> tuple[int, float]:
"""Return ``(tokens, cost_usd)`` a user has consumed since ``start``.
Args:
user_id: The billable user (auth ``sub``).
start: Inclusive window start.
bucket: ``all``, ``direct`` (rows without an agent key) or
``agent`` (rows with one).
Rollup rows are excluded; side-channel calls count, they are real spend.
"""
clauses = ["user_id = :user_id", "timestamp >= :start", "source <> ALL(:rollup_sources)"]
if bucket == "direct":
clauses.append("api_key IS NULL")
elif bucket == "agent":
clauses.append("api_key IS NOT NULL")
elif bucket != "all":
raise ValueError(f"unknown usage bucket: {bucket!r}")
row = self._conn.execute(
text(
"SELECT COALESCE(SUM(prompt_tokens + generated_tokens), 0), COALESCE(SUM(cost), 0) "
f"FROM token_usage WHERE {' AND '.join(clauses)}"
),
{"user_id": user_id, "start": start, "rollup_sources": list(self.ROLLUP_SOURCES)},
).one()
return int(row[0]), float(row[1])
# Token usage written outside a user-initiated request (conversation
# title generation, history compression, RAG question condensing,
# provider fallback). Mirrors the exclusion list in ``count_in_range``.
SIDE_CHANNEL_SOURCES = ("title", "compression", "rag_condense", "fallback")
# Run-level roll-ups that duplicate per-call rows. The scheduler worker
# inserts one ``source='schedule'`` row summing a run's tokens, but the
# run's individual LLM calls were already persisted as ``agent_stream``
# rows by the usage decorators — counting both doubles scheduled spend.
# The rollup is never used as a fallback: if a per-call insert failed
# (logged in usage.py), that call's tokens go uncounted, the same loss
# mode as any other traffic whose insert fails.
ROLLUP_SOURCES = ("schedule",)
# Allowed ``group_by`` values → the SQL expression producing the
# group key. ``agent`` resolves to the agent's display name so the
# dashboard never has to map UUIDs client-side.
_GROUP_KEY_EXPRS = {
"model": "COALESCE(tu.model_id, 'unknown')",
"agent": "COALESCE(a.name, 'No agent')",
"source": "COALESCE(tu.source, 'agent_stream')",
}
def bucketed_totals(
self,
*,
bucket_unit: str,
user_id: Optional[str] = None,
api_key: Optional[str] = None,
agent_id: Optional[str] = None,
timestamp_gte: Optional[datetime] = None,
timestamp_lt: Optional[datetime] = None,
group_by: Optional[str] = None,
include_side_channel: bool = True,
) -> list[dict]:
"""Sum ``prompt_tokens`` / ``generated_tokens`` bucketed by time.
Replacement for the legacy Mongo ``$dateToString`` aggregation
used by the analytics dashboard. The ``bucket`` format string
mirrors Mongo's output so the route layer doesn't reshape:
``"YYYY-MM-DD HH:MM:00"`` (minute), ``"YYYY-MM-DD HH:00"``
(hour), ``"YYYY-MM-DD"`` (day). Rows are ordered by bucket ASC.
``group_by`` (``"model"`` / ``"agent"`` / ``"source"``) adds a
second grouping dimension; each returned row then carries a
``group_key``. ``include_side_channel=False`` drops rows whose
``source`` is a side-channel call (title generation etc.).
"""
formats = {
"minute": "YYYY-MM-DD HH24:MI:00",
"hour": "YYYY-MM-DD HH24:00",
"day": "YYYY-MM-DD",
}
if bucket_unit not in formats:
raise ValueError(f"unsupported bucket_unit: {bucket_unit!r}")
if group_by is not None and group_by not in self._GROUP_KEY_EXPRS:
raise ValueError(f"unsupported group_by: {group_by!r}")
fmt = formats[bucket_unit]
clauses: list[str] = []
params: dict = {"fmt": fmt}
if user_id is not None:
clauses.append("tu.user_id = :user_id")
params["user_id"] = user_id
# Rows stamp ``api_key`` (external traffic) or ``agent_id``
# (owner chats / headless runs), so a per-agent filter must
# match either shape.
agent_clauses: list[str] = []
if api_key is not None:
agent_clauses.append("tu.api_key = :api_key")
params["api_key"] = api_key
if agent_id is not None:
agent_clauses.append("tu.agent_id = CAST(:agent_id AS uuid)")
params["agent_id"] = agent_id
if agent_clauses:
clauses.append(f"({' OR '.join(agent_clauses)})")
if timestamp_gte is not None:
clauses.append("tu.timestamp >= :timestamp_gte")
params["timestamp_gte"] = timestamp_gte
if timestamp_lt is not None:
clauses.append("tu.timestamp < :timestamp_lt")
params["timestamp_lt"] = timestamp_lt
excluded_sources = list(self.ROLLUP_SOURCES)
if not include_side_channel:
excluded_sources.extend(self.SIDE_CHANNEL_SOURCES)
placeholders = []
for i, src in enumerate(excluded_sources):
key = f"excl_src_{i}"
placeholders.append(f":{key}")
params[key] = src
clauses.append(
f"COALESCE(tu.source, 'agent_stream') NOT IN ({', '.join(placeholders)})"
)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
group_select = ""
group_clause = ""
join = ""
if group_by is not None:
group_select = f", {self._GROUP_KEY_EXPRS[group_by]} AS group_key"
group_clause = ", group_key"
if group_by == "agent":
join = "LEFT JOIN agents a ON a.id = tu.agent_id"
result = self._conn.execute(
text(
f"""
SELECT to_char(tu.timestamp AT TIME ZONE 'UTC', :fmt) AS bucket,
COALESCE(SUM(tu.prompt_tokens), 0) AS prompt_tokens,
COALESCE(SUM(tu.generated_tokens), 0) AS generated_tokens
{group_select}
FROM token_usage tu
{join}
{where}
GROUP BY bucket{group_clause}
ORDER BY bucket ASC
"""
),
params,
)
return [
{
"bucket": row._mapping["bucket"],
"prompt_tokens": int(row._mapping["prompt_tokens"]),
"generated_tokens": int(row._mapping["generated_tokens"]),
**(
{"group_key": row._mapping["group_key"]}
if group_by is not None
else {}
),
}
for row in result.fetchall()
]
def count_in_range(
self,
*,
start: datetime,
end: datetime,
user_id: Optional[str] = None,
api_key: Optional[str] = None,
) -> int:
"""Count user-initiated requests in the given time range.
A request = one ``agent_stream`` invocation. Multi-tool agent
runs produce multiple rows (one per LLM call) tagged with the
same ``request_id``; we DISTINCT on that to count the request
once. Pre-migration rows have ``request_id=NULL`` and are
counted one-per-row via the second branch (back-compat).
Side-channel sources (``title`` / ``compression`` /
``rag_condense`` / ``fallback``) are excluded — they aren't
user-initiated and shouldn't tick the request limit.
"""
clauses = [
"timestamp >= :start",
"timestamp <= :end",
"source = 'agent_stream'",
]
params: dict = {"start": start, "end": end}
if user_id is not None:
clauses.append("user_id = :user_id")
params["user_id"] = user_id
if api_key is not None:
clauses.append("api_key = :api_key")
params["api_key"] = api_key
where = " AND ".join(clauses)
result = self._conn.execute(
text(
f"""
SELECT
COUNT(DISTINCT request_id) FILTER (WHERE request_id IS NOT NULL)
+ COUNT(*) FILTER (WHERE request_id IS NULL)
FROM token_usage
WHERE {where}
"""
),
params,
)
return result.scalar()