Compare commits
No commits in common. "363d0ab3c939da43317b255787b1dc969da3a355" and "d738921d25a033bf47c862dd98e35a66c9c38f0b" have entirely different histories.
363d0ab3c9
...
d738921d25
20 changed files with 17 additions and 537 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -3,25 +3,6 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
# 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, 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 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
|
||||
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)
|
||||
|
|
@ -239,7 +239,6 @@ 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()
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ from app.dependencies import (
|
|||
voice_for_route,
|
||||
)
|
||||
from app.core.memory_extractor import maybe_schedule_extraction
|
||||
from app.core.costs import start_meter, persist_costs
|
||||
from app.quota import enforce_quota, record_usage, QuotaExceededError, record_web_search
|
||||
from app.quota import enforce_quota, record_usage, QuotaExceededError
|
||||
from app.runtime_config import runtime_settings
|
||||
from app.safety.emergency import (
|
||||
record_manual_emergency,
|
||||
|
|
@ -98,7 +97,6 @@ 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,
|
||||
|
|
@ -112,8 +110,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -33,8 +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, cost_within_budget)
|
||||
from app.core.costs import start_meter, persist_costs
|
||||
web_search_within_budget, record_web_search)
|
||||
from app.pipeline.search_backstop import should_force_search
|
||||
|
||||
# Freundliche Hinweise (Senioren), wenn die Web-Suche bei einer offensichtlich
|
||||
|
|
@ -93,9 +92,8 @@ 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- 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)):
|
||||
# Web-Such-Tagesbudget aufgebraucht -> Web-Suche fuer diesen Turn aus.
|
||||
if store is not None and not web_search_within_budget(user, store):
|
||||
overrides["web_search_enabled"] = False
|
||||
route = resolve_route(user, session_id, overrides)
|
||||
orchestrator = build_orchestrator(route)
|
||||
|
|
@ -108,7 +106,6 @@ 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
|
||||
|
|
@ -202,7 +199,6 @@ 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)
|
||||
|
|
@ -216,9 +212,7 @@ 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):
|
||||
over_budget = (not web_search_within_budget(user, store)
|
||||
or not cost_within_budget(user, store))
|
||||
kind = "budget" if over_budget else "off"
|
||||
kind = "budget" if not web_search_within_budget(user, store) 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:
|
||||
|
|
|
|||
|
|
@ -190,16 +190,6 @@ 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
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
"""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 _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:"):
|
||||
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)
|
||||
|
|
@ -9,8 +9,6 @@ import logging
|
|||
|
||||
import httpx
|
||||
|
||||
from app.core.costs import record_openrouter_cost
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
|
@ -68,7 +66,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, "usage": {"include": True},
|
||||
payload = {"model": self.model, "temperature": 0.0,
|
||||
"messages": [{"role": "system", "content": _SYSTEM},
|
||||
{"role": "user", "content": user}]}
|
||||
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
||||
|
|
@ -76,9 +74,7 @@ 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()
|
||||
data = resp.json()
|
||||
record_openrouter_cost(data.get("usage"), "llm")
|
||||
out = (data["choices"][0]["message"]["content"] or "").strip()
|
||||
out = (resp.json()["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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
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,
|
||||
|
|
@ -90,7 +88,6 @@ 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",
|
||||
|
|
@ -99,8 +96,7 @@ class LocalOpenAICompatibleLLM(LLMProvider):
|
|||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
add_cost(electricity_usd(time.monotonic() - t0), "llm_local")
|
||||
return data["choices"][0]["message"]["content"]
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
|
|
@ -110,7 +106,6 @@ 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",
|
||||
|
|
@ -128,4 +123,3 @@ class LocalOpenAICompatibleLLM(LLMProvider):
|
|||
delta = sse_delta(line)
|
||||
if delta:
|
||||
yield delta
|
||||
add_cost(electricity_usd(time.monotonic() - t0), "llm_local")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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,
|
||||
|
|
@ -123,7 +122,6 @@ 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
|
||||
|
|
@ -160,7 +158,6 @@ 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:
|
||||
|
|
@ -185,7 +182,6 @@ 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
|
||||
|
|
@ -217,7 +213,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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
|
||||
|
|
@ -181,8 +180,7 @@ 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,
|
||||
"usage": {"include": True}}
|
||||
payload = {"model": self.model, "messages": messages, "temperature": self.temperature}
|
||||
if allow_tools and not self._tools_unsupported:
|
||||
payload["tools"] = [WEB_SEARCH_TOOL]
|
||||
payload["tool_choice"] = "auto"
|
||||
|
|
@ -217,7 +215,6 @@ 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")
|
||||
|
|
@ -341,8 +338,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, "usage": {"include": True}}
|
||||
payload = {"model": self.model, "messages": messages,
|
||||
"temperature": self.temperature, "stream": True}
|
||||
if allow_tools and not self._tools_unsupported:
|
||||
payload["tools"] = [WEB_SEARCH_TOOL]
|
||||
payload["tool_choice"] = "auto"
|
||||
|
|
@ -365,15 +362,9 @@ class ToolCallingLLM(LLMProvider):
|
|||
if not data or data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except ValueError:
|
||||
delta = json.loads(data)["choices"][0]["delta"]
|
||||
except (ValueError, KeyError, IndexError, TypeError):
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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"
|
||||
|
|
@ -140,5 +139,4 @@ 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
|
||||
|
|
|
|||
22
app/quota.py
22
app/quota.py
|
|
@ -68,25 +68,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ 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)"),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ 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__)
|
||||
|
|
@ -423,8 +422,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -165,9 +165,8 @@ 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
|
||||
|
|
|
|||
52
app/store.py
52
app/store.py
|
|
@ -128,14 +128,6 @@ 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,
|
||||
|
|
@ -251,13 +243,6 @@ 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,
|
||||
|
|
@ -532,29 +517,6 @@ 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.")
|
||||
|
|
@ -570,7 +532,6 @@ 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
|
||||
|
||||
|
|
@ -678,24 +639,13 @@ 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,"
|
||||
" 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"
|
||||
" MAX(u.day) AS last_active"
|
||||
" 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),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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
|
||||
|
||||
|
|
@ -71,8 +70,7 @@ class SonarTool:
|
|||
logger.warning("SonarTool ohne API-Key")
|
||||
return ToolResult(text=_NO_DATA, ok=False)
|
||||
|
||||
payload = {"model": self.model, "messages": self._messages(query, language),
|
||||
"usage": {"include": True}}
|
||||
payload = {"model": self.model, "messages": self._messages(query, language)}
|
||||
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
||||
timeout = httpx.Timeout(self.timeout)
|
||||
|
||||
|
|
@ -92,7 +90,6 @@ 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):
|
||||
|
|
|
|||
|
|
@ -1235,11 +1235,6 @@ function buildUserCard(u) {
|
|||
<input type="number" min="0" class="ws-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
|
||||
<span class="ws-limit-status text-xs text-slate-400"></span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">max. Kosten pro Tag ($):</span>
|
||||
<input type="number" min="0" step="0.01" class="cost-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="∞" />
|
||||
<span class="cost-limit-status text-xs text-slate-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notfall-Profil -->
|
||||
|
|
@ -1363,24 +1358,6 @@ 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");
|
||||
|
|
@ -1849,10 +1826,6 @@ async function loadMetrics() {
|
|||
if (!data.length) { container.innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>'; 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 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);
|
||||
|
||||
const bars = data.map((u) => {
|
||||
|
|
@ -1874,9 +1847,6 @@ async function loadMetrics() {
|
|||
<tr class="text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
|
||||
<th class="text-left px-4 py-2.5">Nutzer</th>
|
||||
<th class="text-right px-4 py-2.5">Anfragen gesamt</th>
|
||||
<th class="text-right px-4 py-2.5">mit Websuche</th>
|
||||
<th class="text-right px-4 py-2.5">Kosten (Monat)</th>
|
||||
<th class="text-right px-4 py-2.5">Ø/Anfrage</th>
|
||||
<th class="text-right px-4 py-2.5">Einheiten</th>
|
||||
<th class="text-right px-4 py-2.5">Letzte Aktivität</th>
|
||||
</tr>
|
||||
|
|
@ -1886,9 +1856,6 @@ async function loadMetrics() {
|
|||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors">
|
||||
<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" 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>
|
||||
</tr>`).join("")}
|
||||
|
|
@ -1897,9 +1864,6 @@ async function loadMetrics() {
|
|||
<tr class="text-xs font-semibold text-slate-600 dark:text-slate-300">
|
||||
<td class="px-4 py-2">Gesamt</td>
|
||||
<td class="px-4 py-2 text-right font-mono">${totalReq}</td>
|
||||
<td class="px-4 py-2 text-right font-mono">${totalWS}</td>
|
||||
<td class="px-4 py-2 text-right font-mono">${fmtUsd(totalCost)}</td>
|
||||
<td class="px-4 py-2"></td>
|
||||
<td class="px-4 py-2"></td>
|
||||
<td class="px-4 py-2"></td>
|
||||
</tr>
|
||||
|
|
@ -1907,8 +1871,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "voice-assistant-gateway"
|
||||
version = "0.3.2"
|
||||
version = "0.3.1"
|
||||
description = "Modular voice assistant gateway with pluggable audio endpoints and provider adapters"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue