feat(costs): Stufe 2 — geschätzte Quellen (lokaler Strom, Cartesia, Notruf)

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-30 13:27:50 +02:00
commit d45e2c9ae3
8 changed files with 85 additions and 5 deletions

View file

@ -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: (39040) × 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

View file

@ -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)

View file

@ -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:"):

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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]

View file

@ -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() {
<td class="px-4 py-2.5 font-medium">${escHtml(u.display_name)}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_requests ?? 0}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_web_searches ?? 0}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${fmtUsd(u.month_cost_usd)}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs" title="davon geschätzt (TTS/Notruf/Strom): ${fmtUsd(u.month_cost_estimated_usd)}">${fmtUsd(u.month_cost_usd)}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.month_requests ? fmtUsd((u.month_cost_usd || 0) / u.month_requests) : "—"}</td>
<td class="px-4 py-2.5 text-right font-mono text-xs">${u.total_units ?? 0}</td>
<td class="px-4 py-2.5 text-right text-xs text-slate-400">${u.last_active || "—"}</td>
@ -1906,6 +1907,8 @@ async function loadMetrics() {
</table>
</div>
<p class="text-xs text-slate-400 mb-4">Kosten = laufender Monat (USD). Exakt via OpenRouter (abgleichbar): <span class="font-mono">${fmtUsd(totalCost - totalEstimated)}</span> · geschätzt (TTS/Notruf/Strom): <span class="font-mono">${fmtUsd(totalEstimated)}</span>.</p>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<h3 class="font-semibold text-sm mb-4">Anfragen je Nutzer</h3>
<div class="space-y-2.5">${bars}</div>