feat(abrechnung): Monatskontingent in Credits mit festen Sätzen, Hard-Stop und Warnschwelle

Portierung aus dem Lokal-Fork (AegisSight-Monitor-Local, Commit d2b9168,
per Cherry-Pick übernommen und an den Server-Stand angepasst).

- Feste Sätze je Aktion (BILLING_MODE=flat) statt echter Kosten geteilt
  durch cost_per_credit. Sätze auf die am 23.07.2026 beschlossenen
  Verkaufswerte gesetzt. Live-Lauf 45, Recherche je Durchlauf 40,
  Studio-Bausteine 12, Chat/Beschreibung/Globe 1. Per ENV überschreibbar,
  der alte Modus bleibt als BILLING_MODE=actual erhalten.
- Abrechnungsperiode. licenses.credits_period trennt monthly von total,
  träger Monatsreset bei der nächsten Lizenzprüfung, optionaler Übertrag
  (credits_rollover, gedeckelt auf ein Monatskontingent). Bestandslizenzen
  ohne Periodenmarke behalten ihren Verbrauch.
- Hard-Stop gegen das verfügbare Monatskontingent (Kontingent plus
  Übertrag) statt gegen das Lebenszeit-Total.
- Warnschwelle budget_warning_percent (Default 80 Prozent) wird erstmals
  ausgewertet, einmalige Meldung an alle aktiven Nutzer der Organisation.
- DB-Migration additiv und idempotent (credits_period, credits_period_start,
  credits_rollover, credits_carried, budget_warning_sent, unlimited_budget).
- /api/auth/me liefert credits_available als Bezugsgröße plus
  credits_period, das Credits-Widget zeigt "Credits diesen Monat".
- Wortlaut überall Credits (nicht Guthaben/Einheiten), docs/ABRECHNUNG.md
  auf den Online-Stand gebracht.

Abweichungen zur Fork-Vorlage. Die Takt-Änderungen (Untergrenze 30 Min,
Kostenvorschau im Anlege-Dialog, Fork-Commit 5b0b578) sind bewusst NICHT
enthalten, die Sätze stehen auf 45/40 statt der Fork-Defaults 24/33.

Getestet gegen eine Kopie der Staging-DB, 20 Prüfungen bestanden.
Migration, Flat-Buchung adhoc/research/chat, Warnschwelle einmalig,
Hard-Stop, Monatsreset, Übertrag gedeckelt, Bestandslizenz ohne Marke.
Die vier Live-Lizenzen stehen auf unlimited_budget und sind unbeeinflusst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 12:26:54 +00:00
Ursprung ecfc208f22
Commit 1bbbd891ef
13 geänderte Dateien mit 534 neuen und 50 gelöschten Zeilen

149
docs/ABRECHNUNG.md Normale Datei
Datei anzeigen

@@ -0,0 +1,149 @@
# Credits und Abrechnung
Wie das Kontingent eines Kunden funktioniert, welche Stellschrauben es gibt und
was die Verwaltung setzen muss. Stand 2026-07-25 (Portierung aus dem Lokal-Fork,
Sätze auf die am 23.07.2026 beschlossenen Verkaufswerte gesetzt).
## Begriffe
Der Kunde sieht **Credits**, nicht Token und nicht Dollar. Das ist Absicht.
"Token" ist ein KI-Fachbegriff mit einer festen Bedeutung, und 10.000 echte
Token wären etwa 7.500 Wörter, also ein einziger längerer Artikel. Wer den
Begriff kennt, hält so eine Angabe für einen Fehler. Intern heißen die Felder
`credits_*`, nach außen steht überall "Credits" (Entscheidung vom 25.07.2026,
einheitlich in Monitor und Verwaltungsportal).
## Was eine Aktion kostet
Im Modus `flat` (Voreinstellung) kostet jede Aktion einen festen Satz aus
`CREDIT_TARIFF` in `src/config.py`, unabhängig davon, was sie uns tatsächlich
verursacht hat. 1 Credit entspricht 0,20 USD.
| Aktion | Credits | Herkunft des Werts |
|---|---|---|
| Live-Refresh (adhoc) | 45 | beschlossen 23.07.2026 (gemessener Median 24 Credits = 4,76 USD) |
| Recherche je Durchlauf | 40 | beschlossen 23.07.2026 (gemessener Median 33 Credits = 6,54 USD) |
| Analyse-Baustein (Studio) | 12 | geschätzt, rund ein Viertel eines Live-Laufs |
| Faktencheck-Baustein (Studio) | 12 | geschätzt |
| Chat, Beschreibungs-Assistent, Globe | 1 | real 0,02 bis 0,03 USD je Aufruf |
Die erste Aktualisierung einer Recherche-Lage fährt drei Durchläufe, kostet also
120 Credits. Die Verkaufssätze liegen bewusst über den gemessenen Medianen.
Messgrundlage sind 1.341 abgeschlossene Refreshes des Live-Systems aus dem
Zeitraum 28.02.2026 bis 21.06.2026 (`refresh_log`). Der Verbrauchsrechner im
Verwaltungsportal rechnet mit denselben Sätzen.
### Warum feste Sätze und nicht die echten Kosten
Vorher wurde `echte_kosten / cost_per_credit` abgebucht. Das hatte zwei Nachteile.
Der Kunde konnte nicht planen, weil ein Refresh einer großen Lage ein Vielfaches
eines Refreshs einer frischen kostet, ohne dass er den Unterschied sieht. Und
sobald das Modell-Backend billiger wird, etwa beim Wechsel von Anthropic auf eine
EU-Cloud, hätte derselbe Kunde plötzlich ein Vielfaches an Aktionen bekommen,
oder wir hätten `cost_per_credit` nachziehen müssen, was nach einer heimlichen
Preiserhöhung aussieht.
Der alte Modus lebt weiter unter `BILLING_MODE=actual` als Rückfallebene. Die
echten Kosten wandern in beiden Modi unverändert nach `token_usage_monthly`, die
interne Kostenkontrolle bleibt also vollständig erhalten.
## Abrechnungsperiode
`licenses.credits_period` steuert den Bezugszeitraum.
- `monthly` (Voreinstellung) füllt die Credits zum Monatswechsel neu auf.
- `total` lässt das Kontingent für die gesamte Lizenzlaufzeit gelten.
Der Wechsel passiert träge bei der nächsten Lizenzprüfung, nicht über einen
Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff
nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht.
`credits_rollover` überträgt ungenutzte Credits in den Folgemonat, gedeckelt
auf ein Monatskontingent. Der Übertrag steht in `credits_carried` und zählt beim
Hard-Stop mit. Das ist empfehlenswert, weil OSINT-Nutzung ereignisgetrieben ist.
Bricht eine Krise aus, braucht der Kunde in einer Woche mehr als sonst im
Quartal, und genau dann darf das System nicht dichtmachen.
Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle
Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr
Credits geschenkt, die im laufenden Monat bereits verbraucht wurden.
## Warnung und Sperre
Bei Erreichen von `budget_warning_percent` (Voreinstellung 80) bekommen alle
aktiven Nutzer der Organisation eine Meldung im Benachrichtigungsbereich. Das
Flag `budget_warning_sent` verhindert, dass sich die Warnung bei jeder weiteren
Buchung wiederholt, und wird beim Periodenwechsel zurückgesetzt.
Sind die Credits aufgebraucht, wechselt die Organisation in den Nur-Lese-Modus.
Bestehende Lagen bleiben vollständig lesbar, es lassen sich nur keine neuen
Aktualisierungen mehr starten. Abgeschaltet wird nichts.
**Offen.** Die Warnung geht bisher nur in die Oberfläche, nicht per E-Mail. Wer
sich nicht anmeldet, sieht sie nicht. Ein Versand über `email_utils` wäre der
nächste Schritt.
## Was die Verwaltung je Lizenz setzen muss
| Spalte | Bedeutung | Beispiel |
|---|---|---|
| `credits_total` | Kontingent je Periode | 10000 |
| `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 |
| `credits_period` | `monthly` oder `total` | monthly |
| `credits_rollover` | Übertrag erlauben | 1 |
| `budget_warning_percent` | Warnschwelle in Prozent | 80 |
| `unlimited_budget` | Kontingent aushebeln | 0 |
Ohne `credits_total` läuft die Organisation ohne Kontingent, dann wird nichts
belastet. So stehen aktuell alle vier Live-Lizenzen (`unlimited_budget=1`), die
Umstellung ändert für sie nichts, bis ihnen ein Kontingent gesetzt wird.
Das Verwaltungsportal hat für diese Stellwerte noch keine Eingabefelder, das ist
ein eigenes, offenes Arbeitspaket auf der Portal-Seite.
## Einordnung der Größenordnung
Bei einem Satz von 45 Credits je Live-Lauf entspricht ein Monatskontingent von
10.000 Credits rund 222 Live-Refreshes oder rund 83 neu angelegten Recherchen.
Zum Vergleich, das gesamte Live-System mit 46 aktiven Lagen verbrauchte im Mai
2026 nach den gemessenen Medianen rund 11.700 Credits, mit den Verkaufssätzen
bewertet wären es grob 20.000 bis 22.000.
Ein Hinweis zur Fortschreibung. Die Ist-Kosten je Refresh sind zwischen März und
Mai 2026 von 3,54 auf 7,80 Dollar gestiegen, vermutlich weil ein Lagebild mit
wachsendem Materialbestand auf mehr Kontext aufsetzt. Falls sich das bestätigt,
verbraucht derselbe Kunde im zweiten Jahr mehr als im ersten. Die Sätze in
`CREDIT_TARIFF` sollten deshalb regelmäßig gegen `token_usage_monthly` geprüft
werden.
## Auto-Refresh-Takt
Der Takt ist die wichtigste Stellschraube am Verbrauch. Achtung, im Online-Monitor
liegt die Untergrenze derzeit noch bei 10 Minuten und die Voreinstellung bei
15 Minuten. Ein Live-Refresh dauert gemessen im Median aber 12,7 Minuten, jeder
zehnte länger als 22,9 Minuten, und im 15-Minuten-Takt verbraucht eine einzige
Lage bei 45 Credits je Lauf rund 130.000 Credits im Monat. Der Lokal-Fork hat
dafür bereits eine Lösung (Untergrenze 30 Minuten, Voreinstellung 12 Stunden,
Kostenvorschau im Anlege-Dialog, Commit 5b0b578), deren Portierung noch offen ist.
Zur Orientierung bei einem Kontingent von 10.000 Credits und 45 Credits je Lauf.
| Takt | Refreshes je Monat | Credits | Anteil |
|---|---|---|---|
| 30 Min | 1.440 | 64.800 | 648 Prozent |
| 1 Stunde | 720 | 32.400 | 324 Prozent |
| 6 Stunden | 120 | 5.400 | 54 Prozent |
| 12 Stunden | 60 | 2.700 | 27 Prozent |
| 24 Stunden | 30 | 1.350 | 14 Prozent |
## Offene Punkte
- E-Mail-Versand der Budget-Warnung.
- Eingabefelder für die Stellwerte im Verwaltungsportal (heute erzeugt das
Anlegen einer Organisation dort nicht einmal eine Lizenz).
- Takt-Untergrenze und Kostenvorschau aus dem Lokal-Fork portieren (siehe oben).
- Nachkaufpakete. Ohne sie ist der Deckel eine Sackgasse, mit ihnen eine
Umsatzquelle. Der Verkauf gehört ins Verwaltungsportal, im Monitor müsste nur
der Hinweistext bei aufgebrauchten Credits darauf zeigen.
- Sätze für die Studio-Bausteine sind geschätzt und sollten nachgemessen werden,
sobald `token_usage_monthly` Zeilen mit `source='analysis'` und `'factcheck'`
enthält.

Datei anzeigen

@@ -2170,10 +2170,15 @@ class AgentOrchestrator:
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)" f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
) )
# Credits-Tracking: Monatliche Aggregation + Credits abziehen # Guthaben belasten + monatliche Statistik fortschreiben. Der Lagentyp
if tenant_id and usage_acc.total_cost_usd > 0: # entscheidet ueber den Tarif, ein Research-Durchlauf kostet mehr als
# ein Live-Refresh. Nicht mehr an total_cost_usd > 0 gebunden, weil im
# Pauschalmodus die Aktion zaehlt und lokale Modelle 0 kosten.
if tenant_id:
from services.license_service import charge_usage_to_tenant from services.license_service import charge_usage_to_tenant
await charge_usage_to_tenant(db, tenant_id, usage_acc, source="monitor") await charge_usage_to_tenant(
db, tenant_id, usage_acc, source="monitor", incident_type=incident_type
)
await db.commit() await db.commit()
# Quellen-Discovery im Background starten # Quellen-Discovery im Background starten

Datei anzeigen

@@ -34,6 +34,51 @@ CLAUDE_MODEL_FAST = "claude-haiku-4-5-20251001" # Für einfache Aufgaben (Feed-
CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse) CLAUDE_MODEL_MEDIUM = "claude-sonnet-4-6" # Für qualitätskritische Aufgaben (Netzwerkanalyse)
CLAUDE_MODEL_STANDARD = "claude-opus-4-7" # Standard-Opus für Recherche, Analyse, Faktencheck CLAUDE_MODEL_STANDARD = "claude-opus-4-7" # Standard-Opus für Recherche, Analyse, Faktencheck
# --- Verbrauchssaetze (Credits je Aktion) -------------------------------------
# Beschlossen 2026-07-23, der Verbrauchsrechner im Verwaltungsportal nutzt
# dieselben Werte: Live-Lauf 45, Recherche je Durchlauf 40 (das Anlegen faehrt
# drei Durchlaeufe, also 120), 1 Credit = 0,20 USD. Per ENV ueberschreibbar.
# (Die gemessenen Mediane aus refresh_log lagen bei 24 bzw. 33 Credits, die
# Verkaufssaetze sind bewusst hoeher angesetzt.)
CREDITS_PER_ADHOC_REFRESH = float(os.environ.get("CREDITS_PER_ADHOC_REFRESH", "45"))
CREDITS_PER_RESEARCH_PASS = float(os.environ.get("CREDITS_PER_RESEARCH_PASS", "40"))
# --- Abrechnungsmodus ---------------------------------------------------------
# 'flat' = feste Credits je Aktion nach CREDIT_TARIFF. Der Kunde kann seinen
# Verbrauch vorher ausrechnen, und eine spaetere Verbilligung des
# Modell-Backends veraendert sein Kontingent nicht.
# 'actual' = bisheriges Verhalten, echte Kosten geteilt durch cost_per_credit.
# Damit haengt das Kundenerlebnis am Einkaufspreis, ein Refresh einer
# grossen Lage zieht ein Vielfaches eines Refreshs einer frischen.
# Die echten Kosten wandern in beiden Modi unveraendert nach token_usage_monthly,
# die interne Kostenkontrolle bleibt also erhalten.
BILLING_MODE = os.environ.get("BILLING_MODE", "flat").lower()
# Credits je Aktion im Modus 'flat'. Schluessel ist die Abrechnungsquelle,
# beim Refresh zusaetzlich nach Lagentyp getrennt.
#
# monitor_* sind die beschlossenen Verkaufssaetze (siehe oben).
# analysis/factcheck sind Einzelbausteine aus dem Studio und liegen mangels
# eigener Messreihe bei rund einem Viertel eines Live-Laufs. Sobald
# token_usage_monthly dafuer Zahlen hat, hier nachziehen.
# chat/enhance/globe kosten real 0,02 bis 0,03 USD je Aufruf, also unter einem
# halben Credit. Der Satz 1 verhindert Missbrauch, ohne echte Nutzung spuerbar
# zu belasten.
CREDIT_TARIFF = {
"monitor_adhoc": CREDITS_PER_ADHOC_REFRESH,
"monitor_research": CREDITS_PER_RESEARCH_PASS,
"analysis": float(os.environ.get("CREDITS_PER_ANALYSIS", "12")),
"factcheck": float(os.environ.get("CREDITS_PER_FACTCHECK", "12")),
"chat": 1.0,
"enhance": 1.0,
"globe": 1.0,
}
# Voreinstellung fuer neue Lizenzen, wenn die Verwaltung nichts anderes setzt.
# 'monthly' = Kontingent gilt je Kalendermonat und wird zum Monatswechsel neu
# gefuellt. 'total' = Kontingent gilt fuer die gesamte Lizenzlaufzeit.
CREDITS_PERIOD_DEFAULT = os.environ.get("CREDITS_PERIOD_DEFAULT", "monthly")
# Ausgabesprache wird pro Organisation gesteuert -- siehe services/org_settings.py # Ausgabesprache wird pro Organisation gesteuert -- siehe services/org_settings.py
# (organization_settings-Tabelle, Key 'output_language', Werte 'de' | 'en'). # (organization_settings-Tabelle, Key 'output_language', Werte 'de' | 'en').
# Default-Fallback in den Agent-Methoden ist 'Deutsch', sodass Calls ohne # Default-Fallback in den Agent-Methoden ist 'Deutsch', sodass Calls ohne

Datei anzeigen

@@ -863,6 +863,39 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt") logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt")
# Migration: Credits-Periode. Bis hierher war credits_total ein
# Gesamtwert ueber die ganze Lizenzlaufzeit, credits_used wurde nur
# hochgezaehlt und nie zurueckgesetzt. Ein als "monatlich" verkauftes
# Kontingent haette den Kunden nach dem ersten starken Monat dauerhaft
# in den Nur-Lese-Modus gestellt.
cursor = await db.execute("PRAGMA table_info(licenses)")
lic_columns = [row[1] for row in await cursor.fetchall()]
if "credits_period" not in lic_columns:
await db.execute(
"ALTER TABLE licenses ADD COLUMN credits_period TEXT DEFAULT 'monthly'"
)
# Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten
# Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen.
await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT")
# Ungenutzte Credits in die Folgeperiode uebertragen. Aus = harter
# Monatsdeckel wie verkauft, An = faengt Krisenspitzen ab.
await db.execute("ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0")
await db.execute("ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0")
# Verhindert, dass die Warnschwelle bei jeder Buchung erneut meldet.
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0")
await db.commit()
logger.info("Migration: Credits-Periode zu Lizenzen hinzugefuegt")
# Migration: unlimited_budget nachziehen. Auf dem Live-Stand existiert die
# Spalte, im Schema fehlte sie -- check_license() las sie defensiv per
# .get() aus und bekam auf frischen Datenbanken immer None.
if "unlimited_budget" not in lic_columns:
await db.execute(
"ALTER TABLE licenses ADD COLUMN unlimited_budget INTEGER DEFAULT 0"
)
await db.commit()
logger.info("Migration: unlimited_budget zu Lizenzen hinzugefuegt")
# Migration: Token-Usage-Monatstabelle # Migration: Token-Usage-Monatstabelle
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'") cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
if not await cursor.fetchone(): if not await cursor.fetchone():

Datei anzeigen

@@ -42,6 +42,9 @@ class UserMeResponse(BaseModel):
credits_total: Optional[int] = None credits_total: Optional[int] = None
credits_remaining: Optional[int] = None credits_remaining: Optional[int] = None
credits_percent_used: Optional[float] = None credits_percent_used: Optional[float] = None
# 'monthly' = Kontingent wird zum Monatswechsel neu gefuellt, 'total' = gilt
# fuer die ganze Lizenzlaufzeit. Steuert nur die Beschriftung im Frontend.
credits_period: Optional[str] = None
is_global_admin: bool = False is_global_admin: bool = False
output_language: str = "de" output_language: str = "de"

Datei anzeigen

@@ -195,21 +195,22 @@ async def get_me(
from services.license_service import check_license from services.license_service import check_license
license_info = await check_license(db, current_user["tenant_id"]) license_info = await check_license(db, current_user["tenant_id"])
# Credits-Daten laden (echte Prozente, nicht gekappt) # Guthaben-Daten aus der Lizenzpruefung uebernehmen. check_license() hat den
# Periodenwechsel bereits nachgeholt, ein zweiter Griff in die Tabelle wuerde
# nur dieselben Werte noch einmal lesen.
credits_total = None credits_total = None
credits_remaining = None credits_remaining = None
credits_percent_used = None credits_percent_used = None
credits_period = None
unlimited_budget = bool(license_info.get("unlimited_budget", False)) unlimited_budget = bool(license_info.get("unlimited_budget", False))
if current_user.get("tenant_id"): if current_user.get("tenant_id") and license_info.get("credits_available"):
lic_cursor = await db.execute( # Verfuegbar ist Kontingent plus Uebertrag, danach richtet sich auch der
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", # Hard-Stop. Die Anzeige muss dieselbe Bezugsgroesse nutzen.
(current_user["tenant_id"],)) credits_total = int(license_info["credits_available"])
lic_row = await lic_cursor.fetchone() credits_used = license_info.get("credits_used") or 0
if lic_row and lic_row["credits_total"]: credits_period = license_info.get("credits_period")
credits_total = lic_row["credits_total"] credits_remaining = max(0, int(credits_total - credits_used))
credits_used = lic_row["credits_used"] or 0 credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
credits_remaining = max(0, int(credits_total - credits_used))
credits_percent_used = round((credits_used / credits_total) * 100, 1) if credits_total > 0 else 0
# Org-Switcher fuer Global-Admins -- auch auf Staging aktiv, damit eng_demo # Org-Switcher fuer Global-Admins -- auch auf Staging aktiv, damit eng_demo
# und andere Sprach-/Demo-Mandanten via Dropdown erreichbar sind. (Vorherige # und andere Sprach-/Demo-Mandanten via Dropdown erreichbar sind. (Vorherige
@@ -240,6 +241,7 @@ async def get_me(
unlimited_budget=unlimited_budget, unlimited_budget=unlimited_budget,
is_global_admin=is_global_admin_response, is_global_admin=is_global_admin_response,
output_language=output_language_iso, output_language=output_language_iso,
credits_period=credits_period,
) )

Datei anzeigen

@@ -2,7 +2,7 @@
import logging import logging
import os import os
from datetime import datetime from datetime import datetime
from config import TIMEZONE from config import TIMEZONE, BILLING_MODE, CREDIT_TARIFF
import aiosqlite import aiosqlite
logger = logging.getLogger("osint.license") logger = logging.getLogger("osint.license")
@@ -17,6 +17,126 @@ def _staging_mode() -> bool:
return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes") return os.environ.get("STAGING_MODE", "").lower() in ("1", "true", "yes")
def _current_period() -> str:
"""Kennung der laufenden Abrechnungsperiode (Kalendermonat)."""
return datetime.now(TIMEZONE).strftime("%Y-%m")
def _tariff_key(source: str, incident_type: str | None) -> str:
"""Abrechnungsquelle auf einen Tarifschluessel abbilden.
Ein Refresh kostet je nach Lagentyp unterschiedlich viel, deshalb wird
'monitor' anhand des Typs aufgeteilt. Alle anderen Quellen entsprechen
direkt einem Schluessel in CREDIT_TARIFF.
"""
if source == "monitor":
return "monitor_research" if incident_type == "research" else "monitor_adhoc"
return source
async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
"""Setzt das Guthaben zurueck, wenn eine neue Abrechnungsperiode begonnen hat.
Wird traege bei jeder Lizenzpruefung aufgerufen statt ueber einen Zeitplan.
Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff
ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird.
Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode,
gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst.
Returns:
Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt).
"""
if (lic.get("credits_period") or "monthly") != "monthly":
return lic
if not lic.get("credits_total"):
return lic
period = _current_period()
started = lic.get("credits_period_start")
if not started:
# Bestandslizenz ohne Periodenmarke. Marke setzen, Verbrauch stehen
# lassen -- ein Reset wuerde dem Kunden hier Guthaben schenken, das er
# in diesem Monat schon verbraucht hat.
await db.execute(
"UPDATE licenses SET credits_period_start = ? WHERE id = ?",
(period, lic["id"]),
)
await db.commit()
lic["credits_period_start"] = period
return lic
if started == period:
return lic
total = lic.get("credits_total") or 0
carried_old = lic.get("credits_carried") or 0
used = lic.get("credits_used") or 0
if lic.get("credits_rollover"):
carried_new = max(0.0, (total + carried_old) - used)
carried_new = min(carried_new, float(total)) # hoechstens ein Monat
else:
carried_new = 0.0
await db.execute(
"""UPDATE licenses
SET credits_used = 0, credits_carried = ?, credits_period_start = ?,
budget_warning_sent = 0
WHERE id = ?""",
(round(carried_new, 2), period, lic["id"]),
)
await db.commit()
lic["credits_used"] = 0
lic["credits_carried"] = carried_new
lic["credits_period_start"] = period
lic["budget_warning_sent"] = 0
logger.info(
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt "
f"(Uebertrag {round(carried_new, 2)} Einheiten)"
)
return lic
async def _notify_budget_warning(
db: aiosqlite.Connection, organization_id: int, percent: float, remaining: float
) -> None:
"""Legt eine Warnung an, sobald die Schwelle des Guthabens erreicht ist.
Die Meldung geht an alle aktiven Nutzer der Organisation, die sich schon
einmal angemeldet haben. Ein E-Mail-Versand haengt hier bewusst nicht dran,
das waere ein eigener Schritt ueber email_utils.
"""
cursor = await db.execute(
"SELECT id FROM users WHERE organization_id = ? AND is_active = 1 AND last_login_at IS NOT NULL",
(organization_id,),
)
user_ids = [row["id"] for row in await cursor.fetchall()]
now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M:%S")
title = "Credits zu {p} Prozent verbraucht".format(p=int(percent))
text = (
f"Es sind noch rund {int(remaining)} Credits in dieser Abrechnungsperiode "
f"verfuegbar. Sind die Credits aufgebraucht, bleiben die Lagen lesbar, es "
f"lassen sich aber keine neuen Aktualisierungen mehr starten."
)
for user_id in user_ids:
await db.execute(
"""INSERT INTO notifications (user_id, incident_id, type, title, text, icon, tenant_id, created_at)
VALUES (?, NULL, 'budget_warning', ?, ?, 'warning', ?, ?)""",
(user_id, title, text, organization_id, now),
)
logger.info(
f"Budget-Warnung fuer Org {organization_id} an {len(user_ids)} Nutzer "
f"({int(percent)} Prozent verbraucht)"
)
async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict: async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"""Prueft den Lizenzstatus einer Organisation. """Prueft den Lizenzstatus einer Organisation.
@@ -56,9 +176,19 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
# Felder zur weiteren Verwendung extrahieren # Felder zur weiteren Verwendung extrahieren
lic_dict = dict(license_row) lic_dict = dict(license_row)
# Periodenwechsel nachholen, bevor irgendetwas geprueft wird. Sonst haengt
# ein Kunde mit monatlichem Kontingent im Nur-Lese-Modus fest, obwohl der
# neue Monat laengst begonnen hat.
lic_dict = await roll_credit_period(db, lic_dict)
unlimited_budget = bool(lic_dict.get("unlimited_budget")) unlimited_budget = bool(lic_dict.get("unlimited_budget"))
credits_total = lic_dict.get("credits_total") credits_total = lic_dict.get("credits_total")
credits_used = lic_dict.get("credits_used") or 0 credits_used = lic_dict.get("credits_used") or 0
credits_carried = lic_dict.get("credits_carried") or 0
credits_period = lic_dict.get("credits_period") or "monthly"
# Verfuegbar ist das Kontingent plus ein etwaiger Uebertrag aus dem Vormonat.
credits_available = (credits_total or 0) + credits_carried
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited # STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
if _staging_mode(): if _staging_mode():
@@ -88,10 +218,11 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
# Budget-Check (Hard-Stop bei aufgebrauchten Credits, ausser unlimited) # Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
# Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag.
budget_exceeded = False budget_exceeded = False
if not unlimited_budget and credits_total and credits_total > 0: if not unlimited_budget and credits_available > 0:
if credits_used >= credits_total: if credits_used >= credits_available:
budget_exceeded = True budget_exceeded = True
# Nutzerzahl pruefen # Nutzerzahl pruefen
@@ -110,10 +241,13 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"current_users": current_users, "current_users": current_users,
"read_only": True, "read_only": True,
"read_only_reason": "budget_exceeded", "read_only_reason": "budget_exceeded",
"message": "Token-Budget aufgebraucht", "message": "Credits aufgebraucht",
"unlimited_budget": False, "unlimited_budget": False,
"credits_total": credits_total, "credits_total": credits_total,
"credits_used": credits_used, "credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available,
"credits_period": credits_period,
} }
return { return {
@@ -128,6 +262,9 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"unlimited_budget": unlimited_budget, "unlimited_budget": unlimited_budget,
"credits_total": credits_total, "credits_total": credits_total,
"credits_used": credits_used, "credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available,
"credits_period": credits_period,
} }
@@ -152,21 +289,30 @@ async def charge_usage_to_tenant(
tenant_id: int | None, tenant_id: int | None,
usage, usage,
source: str, source: str,
incident_type: str | None = None,
) -> None: ) -> None:
"""Verbucht Token-Verbrauch auf einen Tenant. """Verbucht eine Aktion auf einen Tenant.
Aktualisiert `token_usage_monthly` (UPSERT pro organization_id+year_month+source) Zwei getrennte Vorgaenge. `token_usage_monthly` bekommt immer die echten
und zieht Credits von der aktiven Lizenz ab (wenn cost_per_credit gesetzt). Tokenmengen und Kosten, das ist die interne Kostenkontrolle. Das Guthaben
der Lizenz wird je nach BILLING_MODE belastet.
'flat' zieht den festen Satz aus CREDIT_TARIFF ab. Der Kunde kann seinen
Verbrauch damit vorher ausrechnen, und eine spaetere Verbilligung
des Modell-Backends veraendert sein Kontingent nicht.
'actual' zieht die echten Kosten geteilt durch cost_per_credit ab, also das
bisherige Verhalten.
Args: Args:
db: offene aiosqlite.Connection db: offene aiosqlite.Connection
tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung) tenant_id: Organisations-ID oder None (dann nur geloggt, keine DB-Buchung)
usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/ usage: ClaudeUsage oder UsageAccumulator mit input_tokens/output_tokens/
cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count cache_creation_tokens/cache_read_tokens/total_cost_usd/call_count
source: 'monitor' | 'enhance' | 'chat' source: 'monitor' | 'analysis' | 'factcheck' | 'chat' | 'enhance' | 'globe'
incident_type: 'adhoc' | 'research', nur bei source='monitor' relevant
Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller. Der Helper ruft KEIN db.commit() auf — die Transaktionsgrenzen bestimmt der Caller.
Ohne Verbrauch (total_cost_usd == 0) oder ohne tenant_id wird nichts gebucht. Ausnahme ist der Periodenwechsel, der eine eigene Transaktion braucht.
""" """
total_cost = getattr(usage, "total_cost_usd", None) total_cost = getattr(usage, "total_cost_usd", None)
if total_cost is None: if total_cost is None:
@@ -179,9 +325,19 @@ async def charge_usage_to_tenant(
) )
return return
if total_cost <= 0: # Ohne echte Kosten gibt es nichts zu statistisch erfassen. Die Guthaben-
return # Buchung laeuft im Pauschalmodus trotzdem, weil der Kunde die Aktion
# bezahlt und nicht unseren Einkauf. Auf lokalen Modellen ist total_cost 0.
if total_cost > 0:
await _record_usage_statistics(db, tenant_id, usage, source, total_cost)
await _charge_credits(db, tenant_id, source, incident_type, total_cost)
async def _record_usage_statistics(
db: aiosqlite.Connection, tenant_id: int, usage, source: str, total_cost: float
) -> None:
"""Schreibt die echten Tokenmengen und Kosten nach token_usage_monthly."""
input_tokens = getattr(usage, "input_tokens", 0) input_tokens = getattr(usage, "input_tokens", 0)
output_tokens = getattr(usage, "output_tokens", 0) output_tokens = getattr(usage, "output_tokens", 0)
cache_creation = getattr(usage, "cache_creation_tokens", 0) cache_creation = getattr(usage, "cache_creation_tokens", 0)
@@ -214,24 +370,99 @@ async def charge_usage_to_tenant(
), ),
) )
async def _charge_credits(
db: aiosqlite.Connection,
tenant_id: int,
source: str,
incident_type: str | None,
total_cost: float,
) -> None:
"""Belastet das Guthaben der aktiven Lizenz und prueft die Warnschwelle."""
lic_cursor = await db.execute( lic_cursor = await db.execute(
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", "SELECT * FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(tenant_id,), (tenant_id,),
) )
lic = await lic_cursor.fetchone() lic_row = await lic_cursor.fetchone()
credits_consumed = 0.0 if not lic_row:
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0: return
credits_consumed = total_cost / lic["cost_per_credit"]
await db.execute( lic = dict(lic_row)
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'", if not lic.get("credits_total"):
(round(credits_consumed, 2), tenant_id), return # Lizenz ohne Kontingent, nichts zu belasten
)
# Periodenwechsel nachholen, bevor gebucht wird. Sonst landet der erste
# Verbrauch des neuen Monats noch auf dem alten Zaehler.
lic = await roll_credit_period(db, lic)
key = _tariff_key(source, incident_type)
if BILLING_MODE == "flat":
credits_consumed = CREDIT_TARIFF.get(key)
if credits_consumed is None:
# Unbekannte Quelle. Lieber auf die echte Rechnung zurueckfallen als
# stillschweigend gratis abzugeben.
logger.warning(
f"Kein Tarif fuer '{key}', falle auf tatsaechliche Kosten zurueck"
)
credits_consumed = _actual_credits(lic, total_cost)
else:
credits_consumed = _actual_credits(lic, total_cost)
if credits_consumed <= 0:
return
await db.execute(
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE id = ?",
(round(credits_consumed, 2), lic["id"]),
)
used_new = (lic.get("credits_used") or 0) + credits_consumed
available = (lic.get("credits_total") or 0) + (lic.get("credits_carried") or 0)
logger.info( logger.info(
f"charge_usage_to_tenant[{source}] Tenant {tenant_id}: " f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: "
f"${total_cost:.4f} -> {round(credits_consumed, 2)} Credits" f"${total_cost:.4f} -> {round(credits_consumed, 2)} Einheiten "
f"({round(used_new, 1)}/{round(available, 1)})"
) )
await _check_budget_warning(db, tenant_id, lic, used_new, available)
def _actual_credits(lic: dict, total_cost: float) -> float:
"""Echte Kosten in Einheiten umrechnen (Modus 'actual' und Rueckfallebene)."""
cost_per_credit = lic.get("cost_per_credit")
if not cost_per_credit or cost_per_credit <= 0:
return 0.0
return total_cost / cost_per_credit
async def _check_budget_warning(
db: aiosqlite.Connection, tenant_id: int, lic: dict, used: float, available: float
) -> None:
"""Meldet einmal je Periode, wenn die Warnschwelle erreicht ist.
Die Schwelle steht als budget_warning_percent auf der Lizenz und war bisher
zwar als Spalte vorhanden, wurde aber nirgends ausgewertet. Ohne sie liefen
Kunden ohne Vorwarnung in den Nur-Lese-Modus.
"""
if available <= 0 or lic.get("budget_warning_sent"):
return
threshold = lic.get("budget_warning_percent") or 80
percent = (used / available) * 100
if percent < threshold:
return
await db.execute(
"UPDATE licenses SET budget_warning_sent = 1 WHERE id = ?", (lic["id"],)
)
try:
await _notify_budget_warning(db, tenant_id, percent, max(0.0, available - used))
except Exception as e:
# Eine fehlgeschlagene Benachrichtigung darf die Buchung nicht kippen.
logger.warning(f"Budget-Warnung konnte nicht zugestellt werden: {e}")
async def expire_licenses(db: aiosqlite.Connection): async def expire_licenses(db: aiosqlite.Connection):
"""Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen.""" """Setzt abgelaufene Lizenzen auf 'expired'. Taeglich aufrufen."""

Datei anzeigen

@@ -63,12 +63,12 @@
</div> </div>
<div id="credits-section" class="credits-section" style="display: none;"> <div id="credits-section" class="credits-section" style="display: none;">
<div class="credits-divider"></div> <div class="credits-divider"></div>
<div class="credits-label">Credits</div> <div class="credits-label" id="credits-label" data-i18n="credits.label">Credits</div>
<div class="credits-bar-container"> <div class="credits-bar-container">
<div id="credits-bar" class="credits-bar"></div> <div id="credits-bar" class="credits-bar"></div>
</div> </div>
<div class="credits-info"> <div class="credits-info">
<span><span id="credits-remaining">0</span> von <span id="credits-total">0</span></span> <span><span id="credits-remaining">0</span> <span data-i18n="credits.of">von</span> <span id="credits-total">0</span></span>
<span class="credits-percent" id="credits-percent"></span> <span class="credits-percent" id="credits-percent"></span>
</div> </div>
</div> </div>
@@ -807,13 +807,13 @@
<script src="/static/vendor/leaflet.js"></script> <script src="/static/vendor/leaflet.js"></script>
<script src="/static/vendor/leaflet.markercluster.js"></script> <script src="/static/vendor/leaflet.markercluster.js"></script>
<script src="/static/js/i18n.js?v=20260513a"></script> <script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260522f"></script> <script src="/static/js/api.js?v=20260725b"></script>
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260723a"></script> <script src="/static/js/components.js?v=20260723a"></script>
<script src="/static/js/layout.js?v=20260513f"></script> <script src="/static/js/layout.js?v=20260513f"></script>
<script src="/static/js/pipeline.js?v=20260513d"></script> <script src="/static/js/pipeline.js?v=20260513d"></script>
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>
<script src="/static/js/app.js?v=20260725a"></script> <script src="/static/js/app.js?v=20260725b"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script> <script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script> <script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260514e"></script> <script src="/static/js/chat.js?v=20260514e"></script>

Datei anzeigen

@@ -21,7 +21,7 @@
"action.restore": "Wiederherstellen", "action.restore": "Wiederherstellen",
"action.budget_exceeded": "Budget aufgebraucht", "action.budget_exceeded": "Budget aufgebraucht",
"action.read_only": "Nur Lesezugriff", "action.read_only": "Nur Lesezugriff",
"action.budget_exceeded_title": "Token-Budget aufgebraucht. Bitte Verwaltung kontaktieren.", "action.budget_exceeded_title": "Credits aufgebraucht. Für weitere Aktualisierungen bitte die Verwaltung kontaktieren.",
"action.read_only_title": "Lizenz erlaubt keinen Schreibzugriff", "action.read_only_title": "Lizenz erlaubt keinen Schreibzugriff",
"sidebar.empty": "Keine Lagen vorhanden", "sidebar.empty": "Keine Lagen vorhanden",
"header.logout": "Abmelden", "header.logout": "Abmelden",
@@ -262,5 +262,8 @@
"chat.send_title": "Senden", "chat.send_title": "Senden",
"chat.send_aria": "Nachricht senden", "chat.send_aria": "Nachricht senden",
"chat.greeting": "Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.", "chat.greeting": "Hallo! Ich bin der AegisSight Assistent. Stell mir gerne jede Frage rund um die Bedienung des Monitors, ich helfe dir weiter.",
"stats.articles_total": "Artikel gesamt" "stats.articles_total": "Artikel gesamt",
"credits.label": "Credits",
"credits.label_monthly": "Credits diesen Monat",
"credits.of": "von"
} }

Datei anzeigen

@@ -21,7 +21,7 @@
"action.restore": "Restore", "action.restore": "Restore",
"action.budget_exceeded": "Budget exhausted", "action.budget_exceeded": "Budget exhausted",
"action.read_only": "Read-only", "action.read_only": "Read-only",
"action.budget_exceeded_title": "Token budget exhausted. Please contact administration.", "action.budget_exceeded_title": "Credits used up. Please contact your administrator to continue updating.",
"action.read_only_title": "License does not permit write access", "action.read_only_title": "License does not permit write access",
"sidebar.empty": "No situations yet", "sidebar.empty": "No situations yet",
"header.logout": "Sign out", "header.logout": "Sign out",
@@ -262,5 +262,8 @@
"chat.send_title": "Send", "chat.send_title": "Send",
"chat.send_aria": "Send message", "chat.send_aria": "Send message",
"chat.greeting": "Hi! I'm the AegisSight Assistant. Ask me anything about how to use the monitor and I'll guide you through.", "chat.greeting": "Hi! I'm the AegisSight Assistant. Ask me anything about how to use the monitor and I'll guide you through.",
"stats.articles_total": "Articles total" "stats.articles_total": "Articles total",
"credits.label": "Credits",
"credits.label_monthly": "Credits this month",
"credits.of": "of"
} }

Datei anzeigen

@@ -102,10 +102,10 @@ const API = {
const warningEl = document.getElementById('header-license-warning'); const warningEl = document.getElementById('header-license-warning');
if (warningEl) { if (warningEl) {
let text = 'Nur Lesezugriff'; let text = 'Nur Lesezugriff';
if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht nur Lesezugriff. Bitte Verwaltung kontaktieren.'; if (licStatus === 'budget_exceeded') text = 'Credits aufgebraucht, nur Lesezugriff. Für weitere Aktualisierungen bitte die Verwaltung kontaktieren.';
else if (licStatus === 'expired') text = 'Lizenz abgelaufen nur Lesezugriff'; else if (licStatus === 'expired') text = 'Lizenz abgelaufen, nur Lesezugriff';
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz nur Lesezugriff'; else if (licStatus === 'no_license') text = 'Keine aktive Lizenz, nur Lesezugriff';
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert nur Lesezugriff'; else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert, nur Lesezugriff';
warningEl.textContent = text; warningEl.textContent = text;
warningEl.classList.add('visible'); warningEl.classList.add('visible');
} }

Datei anzeigen

@@ -358,6 +358,16 @@ const App = {
} }
const percentEl = document.getElementById("credits-percent"); const percentEl = document.getElementById("credits-percent");
if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend"; if (percentEl) percentEl.textContent = percentRemaining.toFixed(0) + "% verbleibend";
// Bezugszeitraum benennen. Ohne den Zusatz liest sich ein
// monatliches Kontingent wie ein Gesamtvorrat, der nie wiederkommt.
const labelEl = document.getElementById('credits-label');
if (labelEl) {
const _tt = (k, fb) => (typeof T === 'function') ? T(k, fb) : fb;
labelEl.textContent = user.credits_period === 'monthly'
? _tt('credits.label_monthly', 'Credits diesen Monat')
: _tt('credits.label', 'Credits');
}
} }
// Dropdown Toggle // Dropdown Toggle

Datei anzeigen

@@ -561,7 +561,7 @@
<script src="/static/vendor/leaflet.js"></script> <script src="/static/vendor/leaflet.js"></script>
<script src="/static/vendor/leaflet.markercluster.js"></script> <script src="/static/vendor/leaflet.markercluster.js"></script>
<script src="/static/js/i18n.js?v=20260513a"></script> <script src="/static/js/i18n.js?v=20260513a"></script>
<script src="/static/js/api.js?v=20260714a"></script> <script src="/static/js/api.js?v=20260725b"></script>
<script src="/static/js/ws.js?v=20260316b"></script> <script src="/static/js/ws.js?v=20260316b"></script>
<script src="/static/js/components.js?v=20260514e"></script> <script src="/static/js/components.js?v=20260514e"></script>
<script src="/static/js/a11y.js?v=20260725a"></script> <script src="/static/js/a11y.js?v=20260725a"></script>