feat(costs): Pro-Nutzer-Kostenerfassung Stufe 1 (OpenRouter-Ist-Kosten)

CostMeter via ContextVars (app/core/costs.py, multitool-ready). Alle
OpenRouter-Calls (LLM, Sonar, Koreferenz, Fallback) senden usage:{include:true}
und buchen die Ist-Kosten je Kategorie (llm/web_search) — inkl. finalem
Streaming-usage-Chunk. cost_usage-Tabelle (user/day/category) + Store-Methoden;
ws.py/chat.py start_meter pro Turn + persist_costs. Statistik: Spalten
"Kosten (Monat)" + "Ø/Anfrage" + Gesamt. Live: 1 Such-Turn ~ $0,0052
(web_search dominiert, LLM ~ $0,00007). Tests: 318.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 11:08:45 +02:00
commit d87f0ce424
9 changed files with 171 additions and 10 deletions

View file

@ -128,6 +128,14 @@ class Store(ABC):
def add_web_search_usage(self, user_id: str, n: int = 1, day: str | None = None) -> int:
"""Zaehlt n Web-Suchen dazu und liefert die neue Tages-Zahl."""
@abstractmethod
def add_cost_usage(self, user_id: str, category: str, usd: float, day: str | None = None) -> None:
"""Bucht Kosten (USD) auf Nutzer/Tag/Kategorie."""
@abstractmethod
def cost_for_day(self, user_id: str, day: str | None = None) -> float:
"""Summe der Kosten (USD) des Nutzers am Tag (Default: heute, UTC)."""
@abstractmethod
def delete_user(self, user_id: str) -> bool:
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
@ -243,6 +251,13 @@ class SQLiteStore(Store):
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day)
);
CREATE TABLE IF NOT EXISTS cost_usage (
user_id TEXT NOT NULL,
day TEXT NOT NULL,
category TEXT NOT NULL,
amount_usd REAL NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, day, category)
);
CREATE TABLE IF NOT EXISTS emergency_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@ -517,6 +532,29 @@ class SQLiteStore(Store):
).fetchone()
return int(row["count"])
def add_cost_usage(self, user_id: str, category: str, usd: float,
day: str | None = None) -> None:
if not usd or usd <= 0:
return
day = day or self._today()
with self._connect() as conn:
conn.execute(
"INSERT INTO cost_usage (user_id, day, category, amount_usd) VALUES (?, ?, ?, ?)"
" ON CONFLICT(user_id, day, category) DO UPDATE SET"
" amount_usd = amount_usd + excluded.amount_usd",
(user_id, day, category, float(usd)),
)
def cost_for_day(self, user_id: str, day: str | None = None) -> float:
day = day or self._today()
with self._connect() as conn:
row = conn.execute(
"SELECT COALESCE(SUM(amount_usd), 0) AS s FROM cost_usage"
" WHERE user_id = ? AND day = ?",
(user_id, day),
).fetchone()
return float(row["s"]) if row else 0.0
def delete_user(self, user_id: str) -> bool:
if user_id == ANONYMOUS_USER_ID:
raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.")
@ -532,6 +570,7 @@ class SQLiteStore(Store):
conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM web_search_usage WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM cost_usage WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
return True
@ -639,15 +678,21 @@ class SQLiteStore(Store):
return [dict(r) for r in rows]
def get_all_usage(self) -> list[dict]:
month = datetime.now(timezone.utc).strftime("%Y-%m") + "-%"
with self._connect() as conn:
rows = conn.execute(
"SELECT u.user_id, COALESCE(usr.display_name, u.user_id) AS display_name,"
" SUM(u.requests) AS total_requests, SUM(u.units) AS total_units,"
" MAX(u.day) AS last_active,"
" COALESCE((SELECT SUM(w.count) FROM web_search_usage w"
" WHERE w.user_id = u.user_id), 0) AS total_web_searches"
" WHERE w.user_id = u.user_id), 0) AS total_web_searches,"
" COALESCE((SELECT SUM(c.amount_usd) FROM cost_usage c"
" WHERE c.user_id = u.user_id AND c.day LIKE ?), 0) AS month_cost_usd,"
" COALESCE((SELECT SUM(uu.requests) FROM usage uu"
" WHERE uu.user_id = u.user_id AND uu.day LIKE ?), 0) AS month_requests"
" FROM usage u LEFT JOIN users usr ON usr.id = u.user_id"
" GROUP BY u.user_id ORDER BY total_requests DESC",
(month, month),
).fetchall()
return [dict(r) for r in rows]