Read usage aggregations from LiteLLM Postgres directly (HTTP fallback kept)

This commit is contained in:
inference-bot committed 2026-09-26 16:39:23 -06:00
1 parent 4600a69cac
commit c7ed245939
3 files changed
+165 -6

No files matched your search

Binary file not shown.
+164 -6
View File
@@ -167,6 +167,123 @@ LISTMONK_API_USER = os.environ.get("LISTMONK_API_USER", "portal-sync")
LISTMONK_API_TOKEN = os.environ.get("LISTMONK_API_TOKEN", "") LISTMONK_API_TOKEN = os.environ.get("LISTMONK_API_TOKEN", "")
LISTMONK_LIST_ID = int(os.environ.get("LISTMONK_LIST_ID", "3")) # "Members" list LISTMONK_LIST_ID = int(os.environ.get("LISTMONK_LIST_ID", "3")) # "Members" list
# Direct read access to LiteLLM's Postgres (for usage aggregations).
# READ-ONLY purpose: never write to LiteLLM's tables — its HTTP API remains the
# only interface for writes/management. Falls back to the HTTP path when unset.
LITELLM_DB_HOST = os.environ.get("LITELLM_DB_HOST", "")
LITELLM_DB_PORT = int(os.environ.get("LITELLM_DB_PORT", "5432"))
LITELLM_DB_USER = os.environ.get("LITELLM_DB_USER", "")
LITELLM_DB_PASSWORD = os.environ.get("LITELLM_DB_PASSWORD", "")
LITELLM_DB_NAME = os.environ.get("LITELLM_DB_NAME", "")
def litellm_db_ready() -> bool:
"""True when LiteLLM DB read credentials are configured."""
return bool(LITELLM_DB_HOST and LITELLM_DB_USER and LITELLM_DB_NAME)
def _pg_connect():
"""Open a short-lived connection to LiteLLM's Postgres (read-only use)."""
import psycopg2
return psycopg2.connect(
host=LITELLM_DB_HOST, port=LITELLM_DB_PORT, user=LITELLM_DB_USER,
password=LITELLM_DB_PASSWORD, dbname=LITELLM_DB_NAME,
connect_timeout=10, sslmode="prefer",
)
def aggregate_model_usage(days: int = 0, team_id: str | None = None) -> dict:
"""Aggregate LiteLLM spend logs per model, straight from Postgres.
Returns {"models": [...], "totals": {...}} in the same shape the HTTP path
produced, so callers don't change. `days` bounds the window (0 = all time);
`team_id` restricts to one member's team (used by the dashboard path).
Prefers the `model_group` column, which carries our provider/model alias
(e.g. greenpt/green-r); falls back to `model` for pre-alias rows — matching
the logic the HTTP path used, expressed in SQL.
"""
if not litellm_db_ready():
raise RuntimeError("LiteLLM DB credentials not configured")
where = []
params: list = []
if days and days > 0:
from datetime import datetime, timedelta, timezone
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%dT%H:%M:%S"
)
where.append("COALESCE(\"startTime\", '') >= %s")
params.append(cutoff)
if team_id:
where.append("team_id = %s")
params.append(team_id)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
sql = f"""
SELECT
COALESCE(NULLIF(model_group, ''), NULLIF(model, ''), 'unknown') AS model,
COALESCE(SUM(spend), 0) AS spend,
COALESCE(SUM(total_tokens), 0) AS tokens,
COUNT(*) AS requests
FROM "LiteLLM_SpendLogs"
{where_sql}
GROUP BY 1
ORDER BY 2 DESC
"""
conn = _pg_connect()
try:
with conn.cursor() as cur:
cur.execute(sql, params)
rows = cur.fetchall()
finally:
conn.close()
models = [
{"model": r[0], "spend": float(r[1] or 0.0), "tokens": int(r[2] or 0),
"requests": int(r[3] or 0)}
for r in rows
]
return {
"models": models,
"totals": {
"total_spend": sum(m["spend"] for m in models),
"total_tokens": sum(m["tokens"] for m in models),
"total_requests": sum(m["requests"] for m in models),
},
}
def get_team_budget_from_db(team_id: str) -> dict | None:
"""Fetch a team's spend/budget/reset directly from LiteLLM's Postgres.
Same data as the /team/list HTTP path (LiteLLM computes `spend`), without
downloading the whole team list. Returns None when the team is unknown.
"""
if not litellm_db_ready():
return None
sql = """
SELECT max_budget, spend, budget_reset_at
FROM "LiteLLM_TeamTable"
WHERE team_id = %s
LIMIT 1
"""
conn = _pg_connect()
try:
with conn.cursor() as cur:
cur.execute(sql, (team_id,))
row = cur.fetchone()
finally:
conn.close()
if not row:
return None
return {
"max_budget": float(row[0]) if row[0] is not None else None,
"spend": float(row[1] or 0.0),
"budget_reset_at": row[2],
}
async def listmonk_sync(email: str, subscribe: bool) -> None: async def listmonk_sync(email: str, subscribe: bool) -> None:
"""Subscribe or unsubscribe a member to the newsletter list. """Subscribe or unsubscribe a member to the newsletter list.
@@ -1330,9 +1447,20 @@ async def admin_model_usage(request: Request, days: int = 0):
sorted by spend descending. Optional ?days=N limits to the last N days sorted by spend descending. Optional ?days=N limits to the last N days
(0 or omitted = all-time). No per-member breakdown — this is co-op-level (0 or omitted = all-time). No per-member breakdown — this is co-op-level
so admins can see which models the membership actually uses. so admins can see which models the membership actually uses.
Reads go straight to LiteLLM's Postgres when configured (fast, bounded);
otherwise falls back to the HTTP API path.
""" """
_verify_admin_token(request) _verify_admin_token(request)
# Fast path: direct Postgres aggregation.
if litellm_db_ready():
try:
return aggregate_model_usage(days=days)
except Exception as e:
logger.warning("admin model-usage: DB path failed (%s); falling back to HTTP", e)
# Fallback path: LiteLLM HTTP API.
cutoff = None cutoff = None
if days and days > 0: if days and days > 0:
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -1479,12 +1607,14 @@ async def broker_revoke(name: str, request: Request):
return {"status": "revoked", "name": name} return {"status": "revoked", "name": name}
async def broker_get_usage(email: str) -> dict: async def broker_get_usage(email: str, days: int = 0) -> dict:
"""Return the member's spend vs. balance, plus per-model and token detail. """Return the member's spend vs. balance, plus per-model and token detail.
Reads the member's team (spend + max_budget) and aggregates their recent This is the read-only primitive the member dashboard uses (no master key on
spend logs by model for token counts. This is the read-only primitive the the client). Reads go straight to LiteLLM's Postgres when configured (fast,
member dashboard uses (no master key on the client). bounded); otherwise falls back to the HTTP API path. `days` bounds the
per-model breakdown window (0 = all time); balance/spend always come from
the team's live totals.
""" """
team_id = await litellm_get_or_create_team(email, get_member_balance(email)) team_id = await litellm_get_or_create_team(email, get_member_balance(email))
@@ -1492,6 +1622,34 @@ async def broker_get_usage(email: str) -> dict:
spend = 0.0 spend = 0.0
reset_at = None reset_at = None
# Fast path: direct Postgres reads.
if litellm_db_ready():
try:
team = get_team_budget_from_db(team_id)
if team:
if team["max_budget"] is not None:
balance = float(team["max_budget"])
spend = float(team["spend"] or 0.0)
reset_at = team["budget_reset_at"]
usage = aggregate_model_usage(days=days, team_id=team_id)
model_list = [
{"model": m["model"], "spend": m["spend"], "tokens": m["tokens"],
"calls": m["requests"]}
for m in usage["models"]
]
return {
"email": email,
"balance": balance,
"spend": spend,
"remaining": max(balance - spend, 0.0),
"total_tokens": usage["totals"]["total_tokens"],
"reset_at": reset_at,
"models": model_list,
}
except Exception as e:
logger.warning("broker_get_usage: DB path failed (%s); falling back to HTTP", e)
# Fallback path: LiteLLM HTTP API.
# /team/list and /spend/logs have grown slow as usage accumulates; the # /team/list and /spend/logs have grown slow as usage accumulates; the
# httpx default 5s read timeout was too short and made /broker/usage 500. # httpx default 5s read timeout was too short and made /broker/usage 500.
client_timeout = httpx.Timeout(connect=15.0, read=60.0, write=15.0, pool=15.0) client_timeout = httpx.Timeout(connect=15.0, read=60.0, write=15.0, pool=15.0)
@@ -1558,9 +1716,9 @@ async def broker_get_usage(email: str) -> dict:
@app.get("/broker/usage") @app.get("/broker/usage")
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def broker_usage(request: Request): async def broker_usage(request: Request, days: int = 0):
email = _verify_broker_secret(request) email = _verify_broker_secret(request)
return await broker_get_usage(email) return await broker_get_usage(email, days=days)
async def reconcile_memberships() -> dict: async def reconcile_memberships() -> dict:
+1
View File
@@ -4,3 +4,4 @@ httpx==0.27.2
pydantic==2.9.2 pydantic==2.9.2
PyJWT==2.9.0 PyJWT==2.9.0
slowapi==0.1.9 slowapi==0.1.9
psycopg2-binary==2.9.9