From d74e94e6a463291fafa688c04f734876d8a86d16 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 09:51:29 +0200 Subject: [PATCH 1/6] feat(admin): Nutzungsstatistik um "mit Websuche" je Nutzer get_all_usage liefert total_web_searches (Summe aus web_search_usage); Statistik-Tabelle + Gesamtzeile zeigen die neue Spalte "mit Websuche". Co-Authored-By: Claude Opus 4.8 --- app/store.py | 4 +++- app/web/app.js | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/store.py b/app/store.py index 4a6b35d..4653dab 100644 --- a/app/store.py +++ b/app/store.py @@ -643,7 +643,9 @@ class SQLiteStore(Store): 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" + " 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" " FROM usage u LEFT JOIN users usr ON usr.id = u.user_id" " GROUP BY u.user_id ORDER BY total_requests DESC", ).fetchall() diff --git a/app/web/app.js b/app/web/app.js index eff7acd..e8d2362 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1826,6 +1826,7 @@ async function loadMetrics() { if (!data.length) { container.innerHTML = '

Noch keine Nutzungsdaten vorhanden.

'; return; } const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); + const totalWS = data.reduce((s, u) => s + (u.total_web_searches || 0), 0); const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1); const bars = data.map((u) => { @@ -1847,6 +1848,7 @@ async function loadMetrics() { Nutzer Anfragen gesamt + mit Websuche Einheiten Letzte Aktivität @@ -1856,6 +1858,7 @@ async function loadMetrics() { ${escHtml(u.display_name)} ${u.total_requests ?? 0} + ${u.total_web_searches ?? 0} ${u.total_units ?? 0} ${u.last_active || "—"} `).join("")} @@ -1864,6 +1867,7 @@ async function loadMetrics() { Gesamt ${totalReq} + ${totalWS} From 21a5250282d0d4df7fd106c9be4168b143154164 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 10:57:20 +0200 Subject: [PATCH 2/6] docs: Konzept Pro-Nutzer-Kostenerfassung (USD) CostMeter via ContextVars (multitool-ready), OpenRouter-Ist-Kosten inline, geschaetzte Quellen (Cartesia/Notruf), cost_usage-Tabelle, Darstellung (laufender Monat) + $-Tagesbudget. Docs/kosten-erfassung.md. Co-Authored-By: Claude Opus 4.8 --- Docs/kosten-erfassung.md | 202 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 Docs/kosten-erfassung.md diff --git a/Docs/kosten-erfassung.md b/Docs/kosten-erfassung.md new file mode 100644 index 0000000..e6e1acc --- /dev/null +++ b/Docs/kosten-erfassung.md @@ -0,0 +1,202 @@ +# Konzept: Pro-Nutzer-Kostenerfassung (USD) + +> Stand: Entwurf. Dokumentiert, wie die tatsächlichen Kosten je Runde erfasst, +> pro Nutzer/Tag akkumuliert und im Admin angezeigt werden. Bewusst so geschnitten, +> dass der kommende **Multitool-Ausbau** ([[plan-multitool-ausbau]], `MULTITOOL_AUSBAU_PLAN.md`) +> weitere Kostenverursacher ohne Umbau anhängen kann. + +## 1. Ziel & Leitidee + +Dokumentieren, **wie viele Dollar jeder Nutzer verbraucht** — zur Aktivitäts- *und* +Ressourcendokumentation. Zwei Klassen von Kosten: + +- **Exakt gemessen:** Alles, was über **OpenRouter** läuft (LLM cloud, Web-Suche + via perplexity/sonar, OpenRouter-TTS). OpenRouter liefert mit `usage:{include:true}` + die **Ist-Kosten** jeder Generierung inline (geprüft: non-stream, **Streaming im + finalen `usage`-Chunk**, Sonar — inkl. `cost_details` und gecachten Tokens). +- **Geschätzt:** Kosten außerhalb OpenRouter (Cartesia-TTS, Notruf-SMS/-Anruf, + künftige externe Tool-APIs) — aus Menge × konfiguriertem Preis. + +Währung: **USD** (nativer OpenRouter-Wert; geschätzte Quellen ebenfalls in USD +konfiguriert). €-Umrechnung später optional. + +## 2. Kostenquellen + +| Quelle | Erfassung | Genauigkeit | +|---|---|---| +| LLM cloud (OpenRouter) | inline `usage.cost` | **exakt** | +| Web-Suche (Sonar via OpenRouter) | inline `usage.cost` (~0,005 $/Suche!) | **exakt** | +| OpenRouter-TTS (gpt-audio) | inline `usage.cost` | **exakt** | +| **Lokales LLM** (Ollama/llama.cpp) | — | **0 $** (nicht über OpenRouter; Strom = amortisierte Infrastruktur, NICHT pro Anfrage) | +| **Cartesia-TTS** | synth. Zeichen × Preis (100k Credits ≈ 100.000 Zeichen → **0,00005 $/Zeichen**) | geschätzt | +| piper / Gerät-TTS | — | **0 $** | +| **Notruf** (SMS+Voice) | pro `emergency_event` × Preis (≈ 0,22 $) | geschätzt | +| **Multitool (künftig)** | je nach Tool: OpenRouter-geroutet → exakt; externe API → Tool meldet eigene Kosten; lokal/DB → 0 $ | gemischt | + +Die Web-Suche ist der mit Abstand größte Pro-Turn-Treiber (~0,005 $ gegen +Bruchteile eines Cents fürs LLM). + +## 3. Keystone: request-weiter Kosten-Zähler über ContextVars + +Kosten entstehen in **vielen, nicht zusammenhängenden** Schichten (LLM-Provider, +Tool, TTS-Provider, Koreferenz-Vorstufe, Notruf-Modul). Callbacks durch alle +Signaturen zu fädeln wäre invasiv und brüchig. Stattdessen ein **request-scoped +`CostMeter`** (`app/core/costs.py`) via `contextvars`: + +```python +# app/core/costs.py +import contextvars +_meter = contextvars.ContextVar("cost_meter", default=None) + +class CostMeter: + def __init__(self): self.total = 0.0; self.by_cat: dict[str, float] = {} + def add(self, usd: float, category: str): + if not usd: return + self.total += usd + self.by_cat[category] = self.by_cat.get(category, 0.0) + usd + +def start_meter() -> CostMeter: + m = CostMeter(); _meter.set(m); return m + +def add_cost(usd: float, category: str): + m = _meter.get() + if m is not None: m.add(usd, category) + +def current_meter() -> "CostMeter | None": + return _meter.get() +``` + +- **Jede kostenverursachende Stelle ruft `add_cost(betrag, kategorie)`** — egal in + welcher Schicht, ohne Signatur-Änderung. +- Die **API-Schicht** (ws.py / chat.py) öffnet pro Turn `start_meter()`, fährt den + Turn, liest danach `current_meter()` und persistiert. +- `asyncio.create_task` **kopiert den Kontext** und referenziert dasselbe (mutable) + `CostMeter`-Objekt → auch Hintergrund-Tasks (Filler-/Geduld-Schleife) tragen + korrekt ein. Voraussetzung: das Objekt **einmal** pro Turn setzen, nicht neu setzen. + +Das ist die multitool-sichere Grundlage: ein neues Tool muss nur `add_cost(...)` +rufen (oder seine Kosten im `ToolResult` melden, s. §5). + +## 4. Erfassung je Quelle + +- **OpenRouter-Calls** (LLM in `tool_calling.py`/`openrouter.py`/`local_*`-Cloud, + Sonar in `web_search.py`, Koreferenz in `decontextualizer.py`, OpenRouter-TTS): + in jeden Payload `"usage": {"include": True}`; nach dem Parsen + `add_cost(data["usage"]["cost"], kategorie)`. Beim **Streaming** den finalen + `usage`-Chunk auslesen (unser `_stream_chat` ignoriert ihn heute). Ein gemeinsamer + Helfer `record_openrouter_cost(usage, category)` vermeidet Duplikate. +- **Cartesia-TTS** (`cartesia.py`): `add_cost(len(text) * cfg.cartesia_usd_per_char, "tts")`. +- **Lokales LLM:** kein `cost`-Feld → trägt 0 bei (fällt automatisch heraus). +- **Notruf** (`safety/emergency.py`): pro tatsächlich gesendetem Alarm + `add_cost(cfg.emergency_alert_usd, "alert")` — **nur Dokumentation, nie Sperre** + (sicherheitskritisch). Notrufe laufen über einen eigenen Endpunkt → dort eigenes + `start_meter()`/Persist, da nicht Teil eines Chat-Turns. + +**Doppelzählung vermeiden:** Jede Kostenquelle meldet **genau einmal**, an der +Schicht, an der sie anfällt. Sonars Kosten kommen aus seinem OpenRouter-Call (§4, +Kategorie `web_search`) — `SonarTool` meldet **keine** zusätzlichen Kosten im Result. + +## 5. Multitool-Vorsorge + +Künftige Tools (`MULTITOOL_AUSBAU_PLAN.md`) fügen sich ohne Umbau ein: + +- **OpenRouter-geroutetes Tool** (z. B. eine LLM-Klassifikation, Medizin-Safety): + Kosten werden am OpenRouter-Call automatisch erfasst (Kategorie z. B. `tool:medsafety`). +- **Externe-API-Tool** (z. B. Wetter, Karten, Kalender mit Bezahl-API): Das Tool + kennt seine Kosten selbst → es meldet sie. Dafür bekommt `ToolResult` ein optionales + Feld `cost_usd: float | None`; `ToolCallingLLM._run_tool` ruft bei `cost_usd` + `add_cost(result.cost_usd, f"tool:{name}")`. (OpenRouter-Tools lassen `cost_usd=None`.) +- **Lokales/DB-Tool** (Erinnerungen, Profil): 0 $. + +→ **Kategorien sind frei** (Strings), die Speicherung (§6) ist zeilenbasiert → +neue Kostenarten erscheinen automatisch als neue Kategorie, ohne Schema-Änderung. + +## 6. Speicherung + +Eine **kategorie-zeilenbasierte** Tages-Tabelle (extensibel, kein Spalten-ALTER bei +neuen Kostenarten): + +```sql +CREATE TABLE IF NOT EXISTS cost_usage ( + user_id TEXT NOT NULL, + day TEXT NOT NULL, -- UTC, wie usage/web_search_usage + category TEXT NOT NULL, -- llm | web_search | tts | alert | tool: + amount_usd REAL NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, day, category) +); +``` + +Store-Methoden: `add_cost(user_id, category, usd, day=None)` (UPSERT), +`get_cost(user_id, day=None)` / Aggregation über Fenster; kaskadiert in `delete_user` +und wird von `delete_user_history` **nicht** angetastet (Kosten bleiben). In der +API-Schicht nach dem Turn: `meter.by_cat` zeilenweise in `cost_usage` buchen. +Aggregation für die Statistik = SUM über Kategorien/Fenster. + +## 7. Konfiguration (neue Settings) + +- `cost_tracking_enabled: bool = True` +- `cartesia_usd_per_char: float = 0.00005` (100k Credits ≈ 100.000 Zeichen) +- `emergency_alert_usd: float = 0.22` (SMS+Voice ≈ 20 €-Cent) +- (später) `usd_per_eur` für optionale €-Anzeige. + +`usage:{include:true}` wird in allen OpenRouter-Payloads gesetzt (minimaler +Mehraufwand, nur größerer Response). + +## 8. Darstellung (Admin → Nutzungsstatistik) + +Pro Nutzer: +- **Kosten (laufender Monat)** — passt zur OpenRouter-Abrechnung; umschaltbar + *Heute · 30 Tage · Monat · Gesamt (seit Erfassungsbeginn)*. +- **Ø Kosten/Anfrage** = Monatskosten / Anfragen — Verhaltens-Indikator. +- optional **Aufschlüsselung** je Kategorie (LLM / Web-Suche / TTS / Notruf / Tools). +- **Gesamt-Zeile** (alle Nutzer) = geschätzte Monatsrechnung; davon **„exakt + (OpenRouter): $X"** zum Abgleich mit dem OpenRouter-Dashboard. + +**Exakt vs. geschätzt sichtbar** kennzeichnen (OpenRouter-Kategorien = exakt; +cartesia/alert/externe Tools = Schätzung). „Kosten erst ab Einführung der Erfassung; +lokale Modelle = 0 $." + +## 9. Optional: €-/$-Budget statt nur Web-Such-Budget + +Das vorhandene Pro-Nutzer-Tagesbudget (`web_search_daily_limit`) lässt sich zu einem +**`cost_daily_limit_usd`** erweitern: ist das Tagesbudget aufgebraucht, greift dieselbe +Gate-/Hinweis-Mechanik (Web-Suche aus + freundliche Systemnachricht). Deckt dann alle +Kostenarten statt nur Suchen. Notrufe bleiben ausgenommen. + +## 10. Harte Stellen (bewusst benannt) + +- **Streaming-`usage`-Chunk** auslesen (heute ignoriert) — sonst werden gestreamte + Antworten (der Normalfall!) mit 0 $ gezählt. +- **Doppelzählung** vermeiden (§4): genau eine Meldung pro Quelle. +- **ContextVar + Tasks:** Meter einmal pro Turn setzen; Tasks kopieren den Kontext + und mutieren dasselbe Objekt. +- **Cartesia ist ein Abo** ($5/100k): der Pro-Zeichen-Preis ist der *implizite* + Stückpreis (Anteil am Credit-Pool), nicht streng marginal — als „anteilig" framen. +- **Notruf nie blocken**, nur dokumentieren. +- **Reconciliation:** nur der OpenRouter-Anteil ist 1:1 gegen die OpenRouter-Rechnung + abgleichbar; Cartesia/Notruf gegen deren eigene Rechnungen. + +## 11. Phasenplan + +- **Stufe 1 (80 %, exakt):** `CostMeter` + OpenRouter-`usage.cost` (LLM, Sonar, + OpenRouter-TTS) inkl. Streaming-Chunk; `cost_usage`-Tabelle; Statistik-Spalte + „Kosten (Monat)" + Ø/Anfrage + Gesamt. +- **Stufe 2 (Schätzungen):** Cartesia (Zeichen×Preis) + Notruf (pro Event); + exakt/geschätzt-Kennzeichnung; Aufschlüsselung. +- **Stufe 3 (optional):** `cost_daily_limit_usd`-Budget + Hinweis; €-Anzeige. +- **Multitool:** `ToolResult.cost_usd` + `tool:`-Kategorien — greift dann + automatisch (keine weitere Statistik-Änderung nötig). + +## 12. Berührte Dateien (Landkarte) + +- neu: `app/core/costs.py` (`CostMeter`, `add_cost`, `start_meter`) +- `app/providers/llm/{tool_calling,openrouter,local_openai_compatible}.py`, + `app/tools/web_search.py`, `app/pipeline/decontextualizer.py` — `usage:{include:true}` + + `record_openrouter_cost(...)`; Streaming-`usage`-Chunk lesen +- `app/providers/tts/{cartesia,openrouter}.py` — TTS-Kosten +- `app/safety/emergency.py` — Notruf-Kosten (pro Event) +- `app/api/ws.py` / `app/api/chat.py` — `start_meter()` pro Turn + Persist +- `app/store.py` — `cost_usage`-Tabelle + `add_cost`/`get_cost`; `delete_user`-Kaskade +- `app/config.py` — neue Settings +- `app/api/admin.py` (`get_all_usage` um Kosten/Kategorien) + `app/web/app.js` + (Spalten/Aufschlüsselung) From d87f0ce42454864f18b64d03f4fa4dc17a9cdf00 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 11:08:45 +0200 Subject: [PATCH 3/6] feat(costs): Pro-Nutzer-Kostenerfassung Stufe 1 (OpenRouter-Ist-Kosten) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/chat.py | 6 ++- app/api/ws.py | 3 ++ app/core/costs.py | 80 +++++++++++++++++++++++++++++++ app/pipeline/decontextualizer.py | 8 +++- app/providers/llm/openrouter.py | 5 ++ app/providers/llm/tool_calling.py | 19 ++++++-- app/store.py | 47 +++++++++++++++++- app/tools/web_search.py | 5 +- app/web/app.js | 8 ++++ 9 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 app/core/costs.py diff --git a/app/api/chat.py b/app/api/chat.py index a83fd61..84a2d56 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -16,7 +16,8 @@ from app.dependencies import ( voice_for_route, ) from app.core.memory_extractor import maybe_schedule_extraction -from app.quota import enforce_quota, record_usage, QuotaExceededError +from app.core.costs import start_meter, persist_costs +from app.quota import enforce_quota, record_usage, QuotaExceededError, record_web_search from app.runtime_config import runtime_settings from app.safety.emergency import ( record_manual_emergency, @@ -97,6 +98,7 @@ async def chat( # Explizit angeforderte Stimme gewinnt; sonst folgt sie der Routensprache. voice = payload.voice or voice_for_route(route.tts_provider, route.language, route.voice_gender) + start_meter() # Kosten dieses Turns sammeln try: trace, audio = await orchestrator.chat_text( payload.text, @@ -110,6 +112,8 @@ async def chat( raise HTTPException(status_code=502, detail=str(exc)) record_usage(user, store, len(payload.text) + len(trace.semantic_response or "")) + record_web_search(user, store, trace.web_searches) + persist_costs(user, store) # Turn persistieren (User-Eingabe + semantische Antwort) fuer das Gedaechtnis. if session_id: diff --git a/app/api/ws.py b/app/api/ws.py index 20a8720..9aa3338 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -34,6 +34,7 @@ from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction from app.quota import (enforce_quota, record_usage, QuotaExceededError, web_search_within_budget, record_web_search) +from app.core.costs import start_meter, persist_costs from app.pipeline.search_backstop import should_force_search # Freundliche Hinweise (Senioren), wenn die Web-Suche bei einer offensichtlich @@ -106,6 +107,7 @@ async def _run_turn( effective_voice: str | None = None, ): """Faehrt einen Antwort-Turn und streamt die Events an den Client.""" + start_meter() # Kosten dieses Turns sammeln (LLM/Sonar via OpenRouter usage.cost) conversation = ( store.get_recent_messages(session_id, settings.history_max_messages) if session_id @@ -199,6 +201,7 @@ async def _run_turn( record_usage(user, store, len(text) + len(trace.semantic_response or "")) record_web_search(user, store, trace.web_searches) + persist_costs(user, store) if session_id: store.append_message(session_id, user.id, "user", text) diff --git a/app/core/costs.py b/app/core/costs.py new file mode 100644 index 0000000..b457e08 --- /dev/null +++ b/app/core/costs.py @@ -0,0 +1,80 @@ +"""Request-weiter Kosten-Zähler (USD) über ContextVars. + +Kosten entstehen in vielen unverbundenen Schichten (LLM-Provider, Tool, TTS, +Koreferenz-Vorstufe, Notruf). Statt einen Callback durch alle Signaturen zu +fädeln, hält ein contextvar-basierter `CostMeter` die Kosten eines Requests +zusammen: jede Stelle ruft `add_cost(betrag_usd, kategorie)`. Die API-Schicht +öffnet pro Turn `start_meter()` und persistiert danach `current_meter()`. + +Multitool-ready: neue Kostenarten = neue (freie) Kategorie-Strings. +Siehe Docs/kosten-erfassung.md. +""" +import contextvars +import json + +_meter: contextvars.ContextVar = contextvars.ContextVar("cost_meter", default=None) + + +class CostMeter: + __slots__ = ("total", "by_cat") + + def __init__(self): + self.total: float = 0.0 + self.by_cat: dict[str, float] = {} + + def add(self, usd, category: str) -> None: + try: + usd = float(usd or 0.0) + except (TypeError, ValueError): + return + if usd <= 0: + return + self.total += usd + self.by_cat[category] = self.by_cat.get(category, 0.0) + usd + + +def start_meter() -> CostMeter: + """Setzt einen frischen Zähler für den aktuellen (Request-)Kontext.""" + meter = CostMeter() + _meter.set(meter) + return meter + + +def current_meter() -> "CostMeter | None": + return _meter.get() + + +def add_cost(usd, category: str) -> None: + """Bucht Kosten auf den aktuellen Zähler (no-op, wenn keiner aktiv ist).""" + meter = _meter.get() + if meter is not None: + meter.add(usd, category) + + +def record_openrouter_cost(usage, category: str) -> None: + """Liest `cost` aus einem OpenRouter-`usage`-Objekt und bucht es (USD).""" + if isinstance(usage, dict): + add_cost(usage.get("cost"), category) + + +def persist_costs(user, store) -> None: + """Schreibt die im aktuellen Meter gesammelten Kosten pro Kategorie in den Store.""" + meter = _meter.get() + if meter is None or user is None: + return + for category, amount in meter.by_cat.items(): + store.add_cost_usage(user.id, category, amount) + + +def record_sse_usage(line: str, category: str) -> None: + """Bucht die Kosten aus einer OpenRouter-SSE-Zeile (finaler `usage`-Chunk).""" + if not line.startswith("data:"): + return + body = line[len("data:"):].strip() + if not body or body == "[DONE]": + return + try: + obj = json.loads(body) + except ValueError: + return + record_openrouter_cost(obj.get("usage"), category) diff --git a/app/pipeline/decontextualizer.py b/app/pipeline/decontextualizer.py index e507f1f..948e999 100644 --- a/app/pipeline/decontextualizer.py +++ b/app/pipeline/decontextualizer.py @@ -9,6 +9,8 @@ import logging import httpx +from app.core.costs import record_openrouter_cost + logger = logging.getLogger(__name__) ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" @@ -66,7 +68,7 @@ class Decontextualizer: return text user = (f"Conversation:\n{self._render(history)}\n\n" f"Latest message: {text}\n\nRewritten self-contained message:") - payload = {"model": self.model, "temperature": 0.0, + payload = {"model": self.model, "temperature": 0.0, "usage": {"include": True}, "messages": [{"role": "system", "content": _SYSTEM}, {"role": "user", "content": user}]} headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} @@ -74,7 +76,9 @@ class Decontextualizer: async with httpx.AsyncClient(timeout=httpx.Timeout(self.timeout)) as client: resp = await client.post(ENDPOINT, json=payload, headers=headers) resp.raise_for_status() - out = (resp.json()["choices"][0]["message"]["content"] or "").strip() + data = resp.json() + record_openrouter_cost(data.get("usage"), "llm") + out = (data["choices"][0]["message"]["content"] or "").strip() except Exception as exc: # noqa: BLE001 — best effort, bei Fehler Original behalten logger.warning("Decontextualizer fehlgeschlagen: %s", exc) return text diff --git a/app/providers/llm/openrouter.py b/app/providers/llm/openrouter.py index 722f788..a20eae5 100644 --- a/app/providers/llm/openrouter.py +++ b/app/providers/llm/openrouter.py @@ -5,6 +5,7 @@ from collections.abc import AsyncIterator import httpx from app.core import clock +from app.core.costs import record_openrouter_cost, record_sse_usage from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -122,6 +123,7 @@ class OpenRouterLLMProvider(LLMProvider): payload = { "model": self.model, "messages": self._build_messages(text, history, language=language), + "usage": {"include": True}, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) last_error: Exception | None = None @@ -158,6 +160,7 @@ class OpenRouterLLMProvider(LLMProvider): raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc data = response.json() + record_openrouter_cost(data.get("usage"), "llm") try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError) as exc: @@ -182,6 +185,7 @@ class OpenRouterLLMProvider(LLMProvider): "model": self.model, "messages": self._build_messages(text, history, language=language), "stream": True, + "usage": {"include": True}, } timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0) last_error: Exception | None = None @@ -213,6 +217,7 @@ class OpenRouterLLMProvider(LLMProvider): f"{body.decode(errors='replace')}" ) async for line in response.aiter_lines(): + record_sse_usage(line, "llm") delta = sse_delta(line) if delta: yield delta diff --git a/app/providers/llm/tool_calling.py b/app/providers/llm/tool_calling.py index 97e8667..8340f24 100644 --- a/app/providers/llm/tool_calling.py +++ b/app/providers/llm/tool_calling.py @@ -17,6 +17,7 @@ from collections.abc import AsyncIterator import httpx from app.core import clock +from app.core.costs import record_openrouter_cost from app.metrics import metrics from app.pipeline.search_backstop import should_force_search from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder @@ -180,7 +181,8 @@ class ToolCallingLLM(LLMProvider): async def _chat(self, messages: list[dict], allow_tools: bool = True) -> dict: """Ein Modell-Aufruf; liefert die Assistant-Message (mit/ohne tool_calls).""" - payload = {"model": self.model, "messages": messages, "temperature": self.temperature} + payload = {"model": self.model, "messages": messages, "temperature": self.temperature, + "usage": {"include": True}} if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" @@ -215,6 +217,7 @@ class ToolCallingLLM(LLMProvider): await asyncio.sleep(min(2.0 * attempt, 30.0)) continue raise RuntimeError(f"Ungültige OpenRouter-Antwort: {resp.text[:300]}") + record_openrouter_cost(data.get("usage"), "llm") return data["choices"][0]["message"] raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen") @@ -338,8 +341,8 @@ class ToolCallingLLM(LLMProvider): async def _stream_chat(self, messages: list[dict], allow_tools: bool, acc: _StreamAcc) -> AsyncIterator[str]: """Ein gestreamter Modell-Call. Yieldet Text-Deltas; füllt acc (Text + tool_calls).""" - payload = {"model": self.model, "messages": messages, - "temperature": self.temperature, "stream": True} + payload = {"model": self.model, "messages": messages, "temperature": self.temperature, + "stream": True, "usage": {"include": True}} if allow_tools and not self._tools_unsupported: payload["tools"] = [WEB_SEARCH_TOOL] payload["tool_choice"] = "auto" @@ -362,9 +365,15 @@ class ToolCallingLLM(LLMProvider): if not data or data == "[DONE]": continue try: - delta = json.loads(data)["choices"][0]["delta"] - except (ValueError, KeyError, IndexError, TypeError): + obj = json.loads(data) + except ValueError: continue + if obj.get("usage"): # finaler Chunk -> Ist-Kosten + record_openrouter_cost(obj["usage"], "llm") + choices = obj.get("choices") or [] + if not choices: + continue + delta = choices[0].get("delta") or {} content = delta.get("content") if content: acc.add_text(content) diff --git a/app/store.py b/app/store.py index 4653dab..3e00c6e 100644 --- a/app/store.py +++ b/app/store.py @@ -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] diff --git a/app/tools/web_search.py b/app/tools/web_search.py index fa1ca67..8b949ba 100644 --- a/app/tools/web_search.py +++ b/app/tools/web_search.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field import httpx +from app.core.costs import record_openrouter_cost from app.metrics import metrics from app.providers.llm.base import lang_instruction @@ -70,7 +71,8 @@ class SonarTool: logger.warning("SonarTool ohne API-Key") return ToolResult(text=_NO_DATA, ok=False) - payload = {"model": self.model, "messages": self._messages(query, language)} + payload = {"model": self.model, "messages": self._messages(query, language), + "usage": {"include": True}} headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} timeout = httpx.Timeout(self.timeout) @@ -90,6 +92,7 @@ class SonarTool: return ToolResult(text=_NO_DATA, ok=False) data = resp.json() + record_openrouter_cost(data.get("usage"), "web_search") try: content = data["choices"][0]["message"]["content"] except (KeyError, IndexError, TypeError): diff --git a/app/web/app.js b/app/web/app.js index e8d2362..3ff4cd6 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1827,6 +1827,8 @@ async function loadMetrics() { const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); const totalWS = data.reduce((s, u) => s + (u.total_web_searches || 0), 0); + const totalCost = data.reduce((s, u) => s + (u.month_cost_usd || 0), 0); + const fmtUsd = (n) => "$" + (Number(n) || 0).toFixed(4); const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1); const bars = data.map((u) => { @@ -1849,6 +1851,8 @@ async function loadMetrics() { Nutzer Anfragen gesamt mit Websuche + Kosten (Monat) + Ø/Anfrage Einheiten Letzte Aktivität @@ -1859,6 +1863,8 @@ async function loadMetrics() { ${escHtml(u.display_name)} ${u.total_requests ?? 0} ${u.total_web_searches ?? 0} + ${fmtUsd(u.month_cost_usd)} + ${u.month_requests ? fmtUsd((u.month_cost_usd || 0) / u.month_requests) : "—"} ${u.total_units ?? 0} ${u.last_active || "—"} `).join("")} @@ -1868,6 +1874,8 @@ async function loadMetrics() { Gesamt ${totalReq} ${totalWS} + ${fmtUsd(totalCost)} + From 66d6708f595386d1b49a06649a34e7a0a6f35731 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 13:17:54 +0200 Subject: [PATCH 4/6] feat(costs): $-Tagesbudget pro Nutzer (Stufe 3) cost_daily_limit_usd (Pref > global; 0 = unbegrenzt) in quota.py (cost_within_budget, cost_limit). ws.py-Gate: Web-Such- ODER Kostenbudget aufgebraucht -> Web-Suche fuer den Turn aus + freundlicher Hinweis. Admin: Schema-Feld + list_users + UI-Eingabe ($/Tag) + globales Live-Setting. Co-Authored-By: Claude Opus 4.8 --- app/api/admin.py | 1 + app/api/ws.py | 11 +++++++---- app/config.py | 1 + app/quota.py | 22 ++++++++++++++++++++++ app/runtime_config.py | 1 + app/schemas.py | 3 ++- app/web/app.js | 23 +++++++++++++++++++++++ 7 files changed, 57 insertions(+), 5 deletions(-) diff --git a/app/api/admin.py b/app/api/admin.py index 8f4f944..95a47f6 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -239,6 +239,7 @@ async def list_users(): "location_consent": bool((u.prefs or {}).get("location_consent", False)), "web_search_enabled": bool((u.prefs or {}).get("web_search_enabled", runtime_settings.web_search_enabled)), "web_search_daily_limit": (u.prefs or {}).get("web_search_daily_limit", ""), + "cost_daily_limit_usd": (u.prefs or {}).get("cost_daily_limit_usd", ""), "is_admin": is_admin_user(u), } for u in get_store().list_users() diff --git a/app/api/ws.py b/app/api/ws.py index 9aa3338..d98f6d5 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -33,7 +33,7 @@ from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction from app.quota import (enforce_quota, record_usage, QuotaExceededError, - web_search_within_budget, record_web_search) + web_search_within_budget, record_web_search, cost_within_budget) from app.core.costs import start_meter, persist_costs from app.pipeline.search_backstop import should_force_search @@ -93,8 +93,9 @@ def _authenticate(websocket: WebSocket, token: str | None): async def _resolve(user, session_id, options, store=None): """Loest Route + Orchestrator + Output-Endpunkt auf (kann RoutingError/Ownership werfen).""" overrides = {key: options.get(key) for key in _OVERRIDE_KEYS} - # Web-Such-Tagesbudget aufgebraucht -> Web-Suche fuer diesen Turn aus. - if store is not None and not web_search_within_budget(user, store): + # Web-Such- oder Kosten-Tagesbudget aufgebraucht -> Web-Suche fuer den Turn aus. + if store is not None and (not web_search_within_budget(user, store) + or not cost_within_budget(user, store)): overrides["web_search_enabled"] = False route = resolve_route(user, session_id, overrides) orchestrator = build_orchestrator(route) @@ -215,7 +216,9 @@ async def _run_turn( # Freundlicher Hinweis, wenn die Web-Suche bei einer offensichtlich aktuellen # Frage nicht verfuegbar war (abgeschaltet oder Tagesbudget aufgebraucht). if not route.web_search_enabled and should_force_search(text): - kind = "budget" if not web_search_within_budget(user, store) else "off" + over_budget = (not web_search_within_budget(user, store) + or not cost_within_budget(user, store)) + kind = "budget" if over_budget else "off" await websocket.send_json({"type": "notice", "text": _ws_notice(route.language, kind)}) # Im text_only-Modus kommt kein Audio (Gerät spricht selbst). if not audio_stream and not text_only: diff --git a/app/config.py b/app/config.py index c38cedc..32a9121 100644 --- a/app/config.py +++ b/app/config.py @@ -190,6 +190,7 @@ class Settings(BaseSettings): audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten) web_search_enabled: bool = True # Web-Suche via Tool-Calling (Weg 2); global an, pro Nutzer abschaltbar web_search_daily_limit: int = 0 # max. Web-Suchen pro Nutzer/Tag (0 = unbegrenzt); pro Nutzer überschreibbar + cost_daily_limit_usd: float = 0 # max. Kosten pro Nutzer/Tag in USD (0 = unbegrenzt); pro Nutzer überschreibbar # TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light. tts_normalize_level: str = "auto" stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) diff --git a/app/quota.py b/app/quota.py index 5733e84..4b0cc2c 100644 --- a/app/quota.py +++ b/app/quota.py @@ -68,3 +68,25 @@ def record_web_search(user, store, n: int = 1) -> None: """Zaehlt n tatsaechlich ausgefuehrte Web-Suchen fuer den Nutzer.""" if n and n > 0 and user is not None: store.add_web_search_usage(user.id, n) + + +def cost_limit(user, cfg=None) -> float: + """Max. Kosten/Tag (USD) fuer diesen Nutzer (Pref > global; 0 = unbegrenzt).""" + if cfg is None: + from app.runtime_config import runtime_settings + cfg = runtime_settings + pref = user.prefs.get("cost_daily_limit_usd") if user and user.prefs else None + if pref is not None: + try: + return float(pref) + except (TypeError, ValueError): + pass + return cfg.cost_daily_limit_usd + + +def cost_within_budget(user, store, cfg=None) -> bool: + """Darf der Nutzer heute noch Kosten verursachen? (Limit 0 = unbegrenzt).""" + limit = cost_limit(user, cfg) + if not limit or limit <= 0: + return True + return store.cost_for_day(user.id) < limit diff --git a/app/runtime_config.py b/app/runtime_config.py index ef99a53..4e76e74 100644 --- a/app/runtime_config.py +++ b/app/runtime_config.py @@ -34,6 +34,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = { "memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"), "memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"), "daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"), + "cost_daily_limit_usd": ("Kostenbudget/Tag (global, $)", "float", "0 = unbegrenzt — pro Nutzer überschreibbar"), "emergency_cooldown_minutes": ("Notruf-Sperre (Minuten)", "int", "Erneuter Alarm für N Minuten blockiert (Standard 5, 0 = aus)"), } diff --git a/app/schemas.py b/app/schemas.py index 6a9b0f8..34a0a4b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -165,8 +165,9 @@ class AdminUserPrefsUpdate(BaseModel): Format geprüft (serverseitige Härtung zusätzlich zur Frontend-Validierung). """ allowed_languages: str | None = None - web_search_enabled: bool | None = None # Web-Suche (Weg 2) pro Nutzer an/aus + web_search_enabled: bool | None = None # Web-Suche (Weg 2) pro Nutzer an/aus web_search_daily_limit: int | None = None # max. Web-Suchen/Tag (0 = unbegrenzt) + cost_daily_limit_usd: float | None = None # max. Kosten/Tag in USD (0 = unbegrenzt) # Notfall-Profil (in user.prefs gespeichert, fließt in die Notruf-Meldungen ein) full_name: str | None = None street: str | None = None diff --git a/app/web/app.js b/app/web/app.js index 3ff4cd6..202a531 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1235,6 +1235,11 @@ function buildUserCard(u) { +
+ max. Kosten pro Tag ($): + + +
@@ -1358,6 +1363,24 @@ function buildUserCard(u) { } }); } + + // Kosten-Tagesbudget in $ (leer/0 = unbegrenzt) — sofort speichern + const costLimit = card.querySelector(".cost-limit"); + const costLimitStatus = card.querySelector(".cost-limit-status"); + if (costLimit) { + const lim = Number(u.cost_daily_limit_usd); + costLimit.value = Number.isFinite(lim) && lim > 0 ? lim : ""; + costLimit.addEventListener("change", async () => { + const n = parseFloat(costLimit.value); + const val = Number.isFinite(n) && n > 0 ? n : 0; // leer/0 = unbegrenzt + if (costLimitStatus) costLimitStatus.textContent = "speichere …"; + const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT", { cost_daily_limit_usd: val }); + if (costLimitStatus) { + costLimitStatus.textContent = res ? "✓" : "Fehler"; + setTimeout(() => { costLimitStatus.textContent = ""; }, 2000); + } + }); + } const profBtn = card.querySelector(".btn-prof"); const profSection = card.querySelector(".prof-section"); const profArrow = card.querySelector(".prof-arrow"); From d45e2c9ae3d191df69116e81981593638a004647 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 13:27:50 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(costs):=20Stufe=202=20=E2=80=94=20gesc?= =?UTF-8?q?h=C3=A4tzte=20Quellen=20(lokaler=20Strom,=20Cartesia,=20Notruf)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lokales LLM: Call-Dauer gemessen -> Stromkosten je Job ((max-idle)*load*n/ 3.6e6*Preis(EUR/kWh)*usd_per_eur), Kategorie llm_local. Cartesia-TTS: Zeichen x cartesia_usd_per_char (tts). Notruf: pro Webhook-Alarm 0,20€*usd_per_eur (alert), nur Doku, nie Sperre. Helfer in costs.py; neue Settings (GPU-Watt/Last/ Strompreis/usd_per_eur/cartesia/alert). Statistik: "exakt vs. geschätzt" (Tooltip + Summenzeile, Kategorien tts/alert/llm_local). Tests: 318. Co-Authored-By: Claude Opus 4.8 --- Docs/kosten-erfassung.md | 10 +++- app/config.py | 9 ++++ app/core/costs.py | 48 ++++++++++++++++++++ app/providers/llm/local_openai_compatible.py | 8 +++- app/providers/tts/cartesia.py | 2 + app/safety/emergency.py | 3 ++ app/store.py | 5 +- app/web/app.js | 5 +- 8 files changed, 85 insertions(+), 5 deletions(-) diff --git a/Docs/kosten-erfassung.md b/Docs/kosten-erfassung.md index e6e1acc..9a69a25 100644 --- a/Docs/kosten-erfassung.md +++ b/Docs/kosten-erfassung.md @@ -27,10 +27,16 @@ konfiguriert). €-Umrechnung später optional. | LLM cloud (OpenRouter) | inline `usage.cost` | **exakt** | | Web-Suche (Sonar via OpenRouter) | inline `usage.cost` (~0,005 $/Suche!) | **exakt** | | OpenRouter-TTS (gpt-audio) | inline `usage.cost` | **exakt** | -| **Lokales LLM** (Ollama/llama.cpp) | — | **0 $** (nicht über OpenRouter; Strom = amortisierte Infrastruktur, NICHT pro Anfrage) | +| **Lokales LLM** (Ollama/llama.cpp, 2× RTX 3090) | Call-Dauer n × Strom-Formel (Kategorie `llm_local`) | geschätzt | | **Cartesia-TTS** | synth. Zeichen × Preis (100k Credits ≈ 100.000 Zeichen → **0,00005 $/Zeichen**) | geschätzt | | piper / Gerät-TTS | — | **0 $** | -| **Notruf** (SMS+Voice) | pro `emergency_event` × Preis (≈ 0,22 $) | geschätzt | +| **Notruf** (SMS+Voice) | pro Webhook-Alarm × Preis (0,20 € × `usd_per_eur`) | geschätzt | + +**Lokaler-Strom-Formel** (marginaler Verbrauch über Leerlauf, je aktivem GPU-Job): +`Kosten = (gpu_max_w − gpu_idle_w) × gpu_avg_load × n_s / 3.600.000 kWh × Preis(EUR/kWh) × usd_per_eur`. +Default: (390−40) × 0,85 × n / 3,6e6 × 0,33 × 1,08. Der Leerlauf (40 W/GPU) bleibt +Infrastruktur; nur der Delta-Verbrauch eines Jobs zählt. Dauer n wird im lokalen +Provider gemessen. | **Multitool (künftig)** | je nach Tool: OpenRouter-geroutet → exakt; externe API → Tool meldet eigene Kosten; lokal/DB → 0 $ | gemischt | Die Web-Suche ist der mit Abstand größte Pro-Turn-Treiber (~0,005 $ gegen diff --git a/app/config.py b/app/config.py index 32a9121..3abea11 100644 --- a/app/config.py +++ b/app/config.py @@ -191,6 +191,15 @@ class Settings(BaseSettings): web_search_enabled: bool = True # Web-Suche via Tool-Calling (Weg 2); global an, pro Nutzer abschaltbar web_search_daily_limit: int = 0 # max. Web-Suchen pro Nutzer/Tag (0 = unbegrenzt); pro Nutzer überschreibbar cost_daily_limit_usd: float = 0 # max. Kosten pro Nutzer/Tag in USD (0 = unbegrenzt); pro Nutzer überschreibbar + # Kostenerfassung Stufe 2 (geschätzte Quellen). Siehe Docs/kosten-erfassung.md. + usd_per_eur: float = 1.08 # Umrechnung EUR-naher Schätzungen in die USD-Anzeige + cartesia_usd_per_char: float = 0.00005 # Cartesia: 100k Credits ≈ 100.000 Zeichen → $5 + emergency_alert_eur: float = 0.20 # SMS+Voice pro Alarm (Schätzung) + # Lokales LLM: Stromkosten pro Anfrage (marginal über Leerlauf), 2× RTX 3090. + gpu_idle_watts: float = 40.0 # Leerlauf je GPU = Infrastruktur (nicht pro Anfrage) + gpu_max_watts: float = 390.0 # Volllast je GPU + gpu_avg_load: float = 0.85 # mittlere Auslastung während einer Anfrage + electricity_price_eur_per_kwh: float = 0.33 # TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light. tts_normalize_level: str = "auto" stt_fallback: str = "" # kommaseparierte Provider-Namen (Fallback-Kette) diff --git a/app/core/costs.py b/app/core/costs.py index b457e08..350d1ba 100644 --- a/app/core/costs.py +++ b/app/core/costs.py @@ -66,6 +66,54 @@ def persist_costs(user, store) -> None: store.add_cost_usage(user.id, category, amount) +def _cfg(cfg): + if cfg is None: + from app.runtime_config import runtime_settings + return runtime_settings + return cfg + + +def electricity_usd(seconds, cfg=None) -> float: + """Stromkosten (USD) eines lokalen GPU-Jobs der Dauer `seconds`. + + (max_w − idle_w) × avg_load × s / 3.600.000 kWh × Preis(EUR/kWh) × usd_per_eur. + Der Leerlauf-Verbrauch zählt als Infrastruktur (nicht pro Anfrage); nur der + marginale Delta-Verbrauch eines aktiven Jobs wird berechnet. + """ + cfg = _cfg(cfg) + try: + s = float(seconds) + except (TypeError, ValueError): + return 0.0 + if s <= 0: + return 0.0 + delta_w = max(0.0, cfg.gpu_max_watts - cfg.gpu_idle_watts) * cfg.gpu_avg_load + kwh = delta_w * s / 3_600_000.0 + return kwh * cfg.electricity_price_eur_per_kwh * cfg.usd_per_eur + + +def cartesia_usd(n_chars, cfg=None) -> float: + """Cartesia-TTS-Kosten (USD) für `n_chars` synthetisierte Zeichen (Schätzung).""" + cfg = _cfg(cfg) + try: + n = int(n_chars or 0) + except (TypeError, ValueError): + return 0.0 + return max(0, n) * cfg.cartesia_usd_per_char + + +def eur_to_usd(eur, cfg=None) -> float: + cfg = _cfg(cfg) + try: + return float(eur or 0.0) * cfg.usd_per_eur + except (TypeError, ValueError): + return 0.0 + + +# Kategorien, die geschätzt (nicht von OpenRouter exakt gemeldet) sind. +ESTIMATED_CATEGORIES = ("tts", "alert", "llm_local") + + def record_sse_usage(line: str, category: str) -> None: """Bucht die Kosten aus einer OpenRouter-SSE-Zeile (finaler `usage`-Chunk).""" if not line.startswith("data:"): diff --git a/app/providers/llm/local_openai_compatible.py b/app/providers/llm/local_openai_compatible.py index af0e9db..22ab079 100644 --- a/app/providers/llm/local_openai_compatible.py +++ b/app/providers/llm/local_openai_compatible.py @@ -1,8 +1,10 @@ +import time from collections.abc import AsyncIterator import httpx from app.core import clock +from app.core.costs import add_cost, electricity_usd from app.providers.llm.base import ( LLMProvider, lang_instruction, @@ -88,6 +90,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): session_id: str | None = None, language: str | None = None, ) -> str: + t0 = time.monotonic() async with httpx.AsyncClient(timeout=120) as client: response = await client.post( f"{self.base_url}/chat/completions", @@ -96,7 +99,8 @@ class LocalOpenAICompatibleLLM(LLMProvider): ) response.raise_for_status() data = response.json() - return data["choices"][0]["message"]["content"] + add_cost(electricity_usd(time.monotonic() - t0), "llm_local") + return data["choices"][0]["message"]["content"] async def stream( self, @@ -106,6 +110,7 @@ class LocalOpenAICompatibleLLM(LLMProvider): language: str | None = None, **kwargs, # z. B. on_tool_start — hier ignoriert (kein Tool-Calling) ) -> AsyncIterator[str]: + t0 = time.monotonic() async with httpx.AsyncClient(timeout=120) as client: async with client.stream( "POST", @@ -123,3 +128,4 @@ class LocalOpenAICompatibleLLM(LLMProvider): delta = sse_delta(line) if delta: yield delta + add_cost(electricity_usd(time.monotonic() - t0), "llm_local") diff --git a/app/providers/tts/cartesia.py b/app/providers/tts/cartesia.py index fbcbe21..5a97541 100644 --- a/app/providers/tts/cartesia.py +++ b/app/providers/tts/cartesia.py @@ -1,5 +1,6 @@ import httpx +from app.core.costs import add_cost, cartesia_usd from app.providers.tts.base import TTSProvider _API_URL = "https://api.cartesia.ai/tts/bytes" @@ -139,4 +140,5 @@ class CartesiaTTSProvider(TTSProvider): if not response.content: raise RuntimeError("Cartesia TTS returned empty audio") + add_cost(cartesia_usd(len(text.strip())), "tts") # geschätzt (Zeichen × Preis) return response.content diff --git a/app/safety/emergency.py b/app/safety/emergency.py index 0dfa255..d8b9a1d 100644 --- a/app/safety/emergency.py +++ b/app/safety/emergency.py @@ -24,6 +24,7 @@ from email.message import EmailMessage import httpx from app.config import settings +from app.core.costs import eur_to_usd from app.metrics import metrics logger = logging.getLogger(__name__) @@ -422,6 +423,8 @@ async def record_manual_emergency(user, store, language: str | None = None, webhook_sent = await _post_webhook(cfg.emergency_webhook_url, payload, cfg) if webhook_sent: metrics.inc("emergency_notify_total", {"channel": "webhook"}) + # Geschätzte Alarm-Kosten (SMS+Anruf) dokumentieren — nie blockieren. + store.add_cost_usage(user.id, "alert", eur_to_usd(cfg.emergency_alert_eur, cfg)) notified = email_sent or webhook_sent ready = notify_readiness(user, cfg) diff --git a/app/store.py b/app/store.py index 3e00c6e..8629938 100644 --- a/app/store.py +++ b/app/store.py @@ -688,11 +688,14 @@ class SQLiteStore(Store): " 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(ce.amount_usd) FROM cost_usage ce" + " WHERE ce.user_id = u.user_id AND ce.day LIKE ?" + " AND ce.category IN ('tts','alert','llm_local')), 0) AS month_cost_estimated_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), + (month, month, month), ).fetchall() return [dict(r) for r in rows] diff --git a/app/web/app.js b/app/web/app.js index 202a531..9496dc0 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -1851,6 +1851,7 @@ async function loadMetrics() { const totalReq = data.reduce((s, u) => s + (u.total_requests || 0), 0); const totalWS = data.reduce((s, u) => s + (u.total_web_searches || 0), 0); const totalCost = data.reduce((s, u) => s + (u.month_cost_usd || 0), 0); + const totalEstimated = data.reduce((s, u) => s + (u.month_cost_estimated_usd || 0), 0); const fmtUsd = (n) => "$" + (Number(n) || 0).toFixed(4); const maxReq = Math.max(...data.map((u) => u.total_requests || 0), 1); @@ -1886,7 +1887,7 @@ async function loadMetrics() { ${escHtml(u.display_name)} ${u.total_requests ?? 0} ${u.total_web_searches ?? 0} - ${fmtUsd(u.month_cost_usd)} + ${fmtUsd(u.month_cost_usd)} ${u.month_requests ? fmtUsd((u.month_cost_usd || 0) / u.month_requests) : "—"} ${u.total_units ?? 0} ${u.last_active || "—"} @@ -1906,6 +1907,8 @@ async function loadMetrics() { +

Kosten = laufender Monat (USD). Exakt via OpenRouter (abgleichbar): ${fmtUsd(totalCost - totalEstimated)} · geschätzt (TTS/Notruf/Strom): ${fmtUsd(totalEstimated)}.

+

Anfragen je Nutzer

${bars}
From 363d0ab3c939da43317b255787b1dc969da3a355 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 13:31:31 +0200 Subject: [PATCH 6/6] docs: CHANGELOG 0.3.2 (Pro-Nutzer-Kostenerfassung) + Version 0.3.2 Kostenerfassung (OpenRouter-Ist-Kosten + geschaetzte Quellen Strom/Cartesia/ Notruf), $-Tagesbudget und Statistik-Spalte "mit Websuche" dokumentiert. Version 0.3.1 -> 0.3.2. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 19 +++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0aa844..5dcb5ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ Alle nennenswerten Änderungen an **Alexis**. Format lose nach [Keep a Changelog](https://keepachangelog.com/de/); Versionierung nach [SemVer](https://semver.org/lang/de/) (prä-1.0: `0.x`). +## [0.3.2] — 2026-06-30 + +### Hinzugefügt +- **Pro-Nutzer-Kostenerfassung (USD).** Zentraler `CostMeter` (ContextVars, + multitool-ready, `app/core/costs.py`); Konzept in `Docs/kosten-erfassung.md`. + - *Exakt:* Alle OpenRouter-Calls (LLM, Web-Suche/Sonar, Koreferenz, Fallback) + senden `usage:{include:true}` und buchen die Ist-Kosten je Kategorie + (`llm`/`web_search`) — inkl. finalem Streaming-`usage`-Chunk. + - *Geschätzt:* lokales LLM (Call-Dauer × Strom-Formel, marginaler GPU-Verbrauch, + `llm_local`), Cartesia-TTS (Zeichen × Preis, `tts`), Notruf (pro Webhook-Alarm, + `alert`, nur Doku — nie Sperre). Sätze konfigurierbar (GPU-Watt/Last, Strompreis, + `usd_per_eur`, Cartesia-Preis, Alarm-Preis). + - `cost_usage`-Tabelle (Nutzer/Tag/Kategorie); Statistik-Spalten **„Kosten (Monat)"** + + **„Ø/Anfrage"**, Tooltip „davon geschätzt" und Summenzeile **„exakt vs. geschätzt"**. +- **$-Tagesbudget pro Nutzer** (`cost_daily_limit_usd`, Pref > global, 0 = unbegrenzt): + ist das Web-Such- oder Kostenbudget aufgebraucht, wird die Web-Suche für den Turn + ausgesetzt (+ freundlicher Hinweis). Admin-Eingabe + globales Live-Setting. +- **Nutzungsstatistik: Spalte „mit Websuche"** je Nutzer (`total_web_searches`). + ## [0.3.1] — 2026-06-30 ### Hinzugefügt diff --git a/pyproject.toml b/pyproject.toml index 68b674a..282c506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "voice-assistant-gateway" -version = "0.3.1" +version = "0.3.2" description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters" readme = "README.md" requires-python = ">=3.11"