metrics: break out the co-op's own (operator) spend in the daily series
metrics_daily.spend_usd is the co-op-wide total and deliberately spans every team, operators included — the numbers were already right. What was missing was a way to tell how much of that total is the co-op's OWN usage, so a member-facing chart could not be misread as member-only activity. Adds metrics_daily.operator_spend_usd: the per-day portion of spend_usd that belongs to the accounts in OPERATOR_EMAILS (teams aliased member:<email>, like anyone else). NULL = not measured (never a confident 0), mirroring spend_usd. - metrics_snapshot.py: operator_spend_by_day() + write it on snapshot/backfill - app/main.py: expose it in metrics_series (so /broker/metrics and /admin/metrics/series carry it), and add operator_spend / operator_all_time_spend to /admin/overview totals as the explicit breakout of total_spend / total_all_time_spend. spend_usd and every existing column are untouched.
This commit is contained in:
1 parent
ddeefdebe0
commit
5f8bed33e6
2 files changed
+99
-3
No files matched your search
+82
-2
@@ -57,6 +57,7 @@ def connect() -> sqlite3.Connection:
|
||||
newsletter_subs INTEGER,
|
||||
matrix_space_members INTEGER,
|
||||
spend_usd REAL,
|
||||
operator_spend_usd REAL,
|
||||
requests INTEGER,
|
||||
out_tokens INTEGER,
|
||||
energy_wh REAL,
|
||||
@@ -68,6 +69,12 @@ def connect() -> sqlite3.Connection:
|
||||
)
|
||||
# CREATE IF NOT EXISTS never adds columns to an existing table, so migrate.
|
||||
for col, decl in (
|
||||
# Per-day share of spend_usd that belongs to the co-op's OWN operator
|
||||
# accounts (OPERATOR_EMAILS). spend_usd stays the co-op-wide total
|
||||
# (it deliberately spans every team, operators included) — this column
|
||||
# is the breakout of it, so member-facing surfaces can say how much of
|
||||
# the co-op total is the co-op's own usage. NULL = not measured.
|
||||
("operator_spend_usd", "REAL"),
|
||||
("out_tokens", "INTEGER"),
|
||||
("energy_wh", "REAL"),
|
||||
("co2e_g", "REAL"),
|
||||
@@ -312,6 +319,61 @@ def spend_by_day(since: dt.date | None = None) -> dict[str, tuple[float, int]]:
|
||||
return out
|
||||
|
||||
|
||||
def operator_spend_by_day(since: dt.date | None = None) -> dict[str, float] | None:
|
||||
"""{day: operator_spend_usd} — the co-op's OWN accounts' share of spend.
|
||||
|
||||
spend_usd (spend_by_day) is the co-op-wide total and deliberately spans
|
||||
every team, operators included. This is the breakout: the portion of it
|
||||
that belongs to the operator accounts named by OPERATOR_EMAILS, whose
|
||||
teams are aliased `member:<email>` like anyone else. The member dashboard
|
||||
reads this through /broker/metrics so a member can see how much of the
|
||||
co-op total is the co-op's own usage.
|
||||
|
||||
Returns None when the LiteLLM DB could not be read (the caller then leaves
|
||||
the column NULL — "not measured", never a confident 0), mirroring
|
||||
spend_by_day's fail-closed behaviour.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"metrics: psycopg2 unavailable ({exc})")
|
||||
return None
|
||||
aliases = [f"member:{e}" for e in OPERATOR_EMAILS]
|
||||
if not aliases:
|
||||
return {}
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=os.environ.get("LITELLM_DB_HOST"),
|
||||
port=os.environ.get("LITELLM_DB_PORT"),
|
||||
user=os.environ.get("LITELLM_DB_USER"),
|
||||
password=os.environ.get("LITELLM_DB_PASSWORD"),
|
||||
dbname=os.environ.get("LITELLM_DB_NAME"),
|
||||
connect_timeout=10,
|
||||
sslmode="prefer",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"metrics: LiteLLM DB connect failed ({exc}) — operator spend left NULL")
|
||||
return None
|
||||
try:
|
||||
where, params = "", [aliases]
|
||||
if since:
|
||||
where, params = 'AND s."startTime" >= %s', [aliases, since.isoformat()]
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"""SELECT to_char(s."startTime", 'YYYY-MM-DD') AS day,
|
||||
COALESCE(SUM(s.spend), 0)
|
||||
FROM "LiteLLM_SpendLogs" s
|
||||
JOIN "LiteLLM_TeamTable" t ON t.team_id = s."team_id"
|
||||
WHERE t.team_alias = ANY(%s) {where}
|
||||
GROUP BY 1 ORDER BY 1""",
|
||||
params,
|
||||
)
|
||||
out = {row[0]: round(float(row[1]), 6) for row in cur.fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
return out
|
||||
|
||||
|
||||
def tokens_by_day_model(since: dt.date | None = None) -> dict[str, dict[str, int]]:
|
||||
"""{day: {model: output_tokens}} from LiteLLM's spend logs.
|
||||
|
||||
@@ -459,9 +521,10 @@ def upsert(day: str, row: dict) -> None:
|
||||
conn.execute(
|
||||
"""INSERT INTO metrics_daily
|
||||
(day, members_active, members_total, members_new, newsletter_subs,
|
||||
matrix_space_members, spend_usd, requests, out_tokens, energy_wh,
|
||||
matrix_space_members, spend_usd, operator_spend_usd, requests,
|
||||
out_tokens, energy_wh,
|
||||
co2e_g, tokens_measured, tokens_estimated, factor_revision, recorded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(day) DO UPDATE SET
|
||||
members_active=excluded.members_active,
|
||||
members_total=excluded.members_total,
|
||||
@@ -469,6 +532,7 @@ def upsert(day: str, row: dict) -> None:
|
||||
newsletter_subs=excluded.newsletter_subs,
|
||||
matrix_space_members=excluded.matrix_space_members,
|
||||
spend_usd=excluded.spend_usd,
|
||||
operator_spend_usd=excluded.operator_spend_usd,
|
||||
requests=excluded.requests,
|
||||
out_tokens=excluded.out_tokens,
|
||||
energy_wh=excluded.energy_wh,
|
||||
@@ -485,6 +549,7 @@ def upsert(day: str, row: dict) -> None:
|
||||
row.get("newsletter_subs"),
|
||||
row.get("matrix_space_members"),
|
||||
row.get("spend_usd"),
|
||||
row.get("operator_spend_usd"),
|
||||
row.get("requests"),
|
||||
row.get("out_tokens"),
|
||||
row.get("energy_wh"),
|
||||
@@ -509,6 +574,15 @@ def snapshot(day: str | None = None) -> dict:
|
||||
since = dt.date.fromisoformat(day) - dt.timedelta(days=1)
|
||||
spend = spend_by_day(since=since)
|
||||
today_spend = spend.get(day, (None, None))
|
||||
op_spend = operator_spend_by_day(since=since)
|
||||
# The co-op's own share of today's spend. If the LiteLLM DB could not be
|
||||
# read, both spend and operator_spend are NULL (not measured). If it was
|
||||
# read and operators simply used nothing, that is a measured 0.0 — but only
|
||||
# when there WAS spend to attribute that day.
|
||||
if op_spend is None or today_spend[0] is None:
|
||||
op_today = None
|
||||
else:
|
||||
op_today = op_spend.get(day, 0.0)
|
||||
day_models = (tokens_by_day_model(since=since).get(day)) or {}
|
||||
tot = sum(day_models.values())
|
||||
out_tokens = tot or None
|
||||
@@ -520,6 +594,7 @@ def snapshot(day: str | None = None) -> dict:
|
||||
"newsletter_subs": subs,
|
||||
"matrix_space_members": space,
|
||||
"spend_usd": today_spend[0],
|
||||
"operator_spend_usd": op_today,
|
||||
"requests": today_spend[1],
|
||||
"out_tokens": out_tokens,
|
||||
"energy_wh": impact["wh"],
|
||||
@@ -545,6 +620,7 @@ def backfill() -> None:
|
||||
m = from_sqlite()
|
||||
joins = sorted(listmonk_joins() or [])
|
||||
spend = spend_by_day()
|
||||
op_spend = operator_spend_by_day()
|
||||
tokens = tokens_by_day_model()
|
||||
if not joins:
|
||||
print("metrics: no membership history (listmonk unavailable) — past days left NULL for members")
|
||||
@@ -571,6 +647,10 @@ def backfill() -> None:
|
||||
"members_total": members_total,
|
||||
"members_new": sum(1 for j in joins if j == key),
|
||||
"spend_usd": spend.get(key, (None, None))[0],
|
||||
"operator_spend_usd": (
|
||||
None if op_spend is None
|
||||
else (op_spend.get(key, 0.0) if key in spend else None)
|
||||
),
|
||||
"requests": spend.get(key, (None, None))[1],
|
||||
}
|
||||
day_models = tokens.get(key) or {}
|
||||
|
||||
Reference in new issue
Block a user