202 lines
9.9 KiB
Markdown
202 lines
9.9 KiB
Markdown
|
|
# 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:<name>
|
|||
|
|
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:<name>`-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)
|