From d45e2c9ae3d191df69116e81981593638a004647 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Tue, 30 Jun 2026 13:27:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(costs):=20Stufe=202=20=E2=80=94=20gesch?= =?UTF-8?q?=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}