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:
parent
66d6708f59
commit
d45e2c9ae3
8 changed files with 85 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:"):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue