Zwei Verbesserungen auf Nutzer-Rückmeldung. - Max Nutzer war nach dem Anlegen einer Lizenz nirgends mehr änderbar. In der aktiven Lizenz-Zeile ist es jetzt ein Zahlenfeld mit Sofort-Speicherung (der generische Lizenz-Updater akzeptiert max_users mit Prüfung 1 bis 1000), die Limit-Anzeige über der Nutzerliste zieht mit. - "Status" (Lizenz) direkt neben "Org-Status" war verwirrend. Der Org-Status sitzt jetzt als Umschalter in der Kopfzeile der Organisation (an der Stelle des bisherigen Abzeichens), die Tabelle behält nur noch Lizenz-Status und Pipeline-Sprache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 Zeilen
12 KiB
Python
259 Zeilen
12 KiB
Python
"""Token-Usage & Budget-Verwaltung."""
|
|
import logging
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from auth import get_current_admin
|
|
from database import get_db
|
|
from audit import log_action, get_client_ip, row_to_dict
|
|
|
|
logger = logging.getLogger("verwaltung.token_usage")
|
|
router = APIRouter(prefix="/api/token-usage", tags=["Token-Usage"])
|
|
|
|
|
|
@router.get("/overview")
|
|
async def get_usage_overview(admin=Depends(get_current_admin)):
|
|
"""Token-Verbrauch aller Organisationen."""
|
|
db = await get_db()
|
|
try:
|
|
cursor = await db.execute("""
|
|
SELECT
|
|
o.id, o.name, o.slug,
|
|
l.credits_total, l.credits_used, l.cost_per_credit,
|
|
l.token_budget_usd, l.budget_warning_percent, l.unlimited_budget,
|
|
l.credits_period, l.credits_rollover, l.credits_carried, l.credits_period_start,
|
|
COALESCE(SUM(r.total_cost_usd), 0) as total_cost,
|
|
COALESCE(SUM(r.input_tokens), 0) as total_input_tokens,
|
|
COALESCE(SUM(r.output_tokens), 0) as total_output_tokens,
|
|
COALESCE(SUM(r.api_calls), 0) as total_api_calls,
|
|
COUNT(r.id) as total_refreshes
|
|
FROM organizations o
|
|
LEFT JOIN licenses l ON l.organization_id = o.id AND l.status = 'active'
|
|
LEFT JOIN refresh_log r ON r.tenant_id = o.id AND r.status = 'completed'
|
|
GROUP BY o.id
|
|
""")
|
|
rows = await cursor.fetchall()
|
|
|
|
result = []
|
|
for row in rows:
|
|
credits_total = row["credits_total"] or 0
|
|
credits_used = row["credits_used"] or 0
|
|
credits_carried = row["credits_carried"] or 0
|
|
unlimited = bool(row["unlimited_budget"])
|
|
# Verfuegbar = Kontingent plus Uebertrag. Der Monitor gewaehrt beim
|
|
# Hard-Stop inklusive Uebertrag, die Anzeige muss dieselbe
|
|
# Bezugsgroesse nutzen, sonst zeigt sie zu wenig Rest an.
|
|
credits_available = credits_total + credits_carried
|
|
credits_remaining = None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None)
|
|
percent_used = None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None)
|
|
budget_usd = row["token_budget_usd"]
|
|
cost = row["total_cost"]
|
|
budget_percent = None if unlimited else (round((cost / budget_usd) * 100, 1) if budget_usd and budget_usd > 0 else None)
|
|
|
|
result.append({
|
|
"org_id": row["id"],
|
|
"org_name": row["name"],
|
|
"org_slug": row["slug"],
|
|
"credits_total": credits_total,
|
|
"credits_used": round(credits_used, 1),
|
|
"credits_remaining": credits_remaining,
|
|
"credits_percent_used": percent_used,
|
|
"credits_available": credits_available,
|
|
"credits_carried": credits_carried,
|
|
"credits_period": row["credits_period"] or "monthly",
|
|
"credits_rollover": bool(row["credits_rollover"]),
|
|
"credits_period_start": row["credits_period_start"],
|
|
"token_budget_usd": budget_usd,
|
|
"total_cost_usd": round(cost, 2),
|
|
"budget_percent_used": budget_percent,
|
|
"budget_warning_percent": row["budget_warning_percent"] or 80,
|
|
"unlimited_budget": unlimited,
|
|
"total_input_tokens": row["total_input_tokens"],
|
|
"total_output_tokens": row["total_output_tokens"],
|
|
"total_api_calls": row["total_api_calls"],
|
|
"total_refreshes": row["total_refreshes"],
|
|
"cost_per_credit": row["cost_per_credit"],
|
|
})
|
|
return result
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
@router.get("/{org_id}")
|
|
async def get_org_usage(org_id: int, admin=Depends(get_current_admin)):
|
|
"""Monatliche Token-Nutzung einer Organisation."""
|
|
db = await get_db()
|
|
try:
|
|
cursor = await db.execute(
|
|
"SELECT * FROM token_usage_monthly WHERE organization_id = ? ORDER BY year_month DESC",
|
|
(org_id,))
|
|
rows = await cursor.fetchall()
|
|
|
|
return [{
|
|
"year_month": row["year_month"],
|
|
"source": row["source"] if "source" in row.keys() else "monitor",
|
|
"input_tokens": row["input_tokens"],
|
|
"output_tokens": row["output_tokens"],
|
|
"cache_creation_tokens": row["cache_creation_tokens"],
|
|
"cache_read_tokens": row["cache_read_tokens"],
|
|
"total_cost_usd": round(row["total_cost_usd"], 2),
|
|
"api_calls": row["api_calls"],
|
|
"refresh_count": row["refresh_count"],
|
|
} for row in rows]
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
@router.get("/{org_id}/current")
|
|
async def get_org_current_usage(org_id: int, admin=Depends(get_current_admin)):
|
|
"""Aktueller Monat + Budget-Auslastung."""
|
|
db = await get_db()
|
|
try:
|
|
year_month = datetime.now().strftime("%Y-%m")
|
|
|
|
cursor = await db.execute(
|
|
"SELECT * FROM token_usage_monthly WHERE organization_id = ? AND year_month = ?",
|
|
(org_id, year_month))
|
|
usage_rows = await cursor.fetchall()
|
|
usage = {
|
|
"input_tokens": sum(r["input_tokens"] for r in usage_rows),
|
|
"output_tokens": sum(r["output_tokens"] for r in usage_rows),
|
|
"total_cost_usd": sum(r["total_cost_usd"] for r in usage_rows),
|
|
"api_calls": sum(r["api_calls"] for r in usage_rows),
|
|
"refresh_count": sum(r["refresh_count"] for r in usage_rows),
|
|
}
|
|
usage_by_source = {}
|
|
for r in usage_rows:
|
|
src = r["source"] if "source" in r.keys() else "monitor"
|
|
usage_by_source[src] = {
|
|
"input_tokens": r["input_tokens"],
|
|
"output_tokens": r["output_tokens"],
|
|
"total_cost_usd": round(r["total_cost_usd"], 2),
|
|
"api_calls": r["api_calls"],
|
|
"refresh_count": r["refresh_count"],
|
|
}
|
|
|
|
cursor = await db.execute(
|
|
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent, unlimited_budget, "
|
|
"credits_period, credits_rollover, credits_carried, credits_period_start "
|
|
"FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
|
(org_id,))
|
|
lic = await cursor.fetchone()
|
|
|
|
unlimited = bool(lic["unlimited_budget"]) if lic else False
|
|
credits_total = lic["credits_total"] if lic else None
|
|
credits_used = lic["credits_used"] if lic else 0
|
|
credits_carried = (lic["credits_carried"] or 0) if lic else 0
|
|
# Verfuegbar = Kontingent plus Uebertrag, dieselbe Bezugsgroesse wie
|
|
# der Hard-Stop im Monitor.
|
|
credits_available = (credits_total or 0) + credits_carried
|
|
|
|
return {
|
|
"year_month": year_month,
|
|
"usage": {
|
|
"input_tokens": usage["input_tokens"],
|
|
"output_tokens": usage["output_tokens"],
|
|
"total_cost_usd": round(usage["total_cost_usd"], 2),
|
|
"api_calls": usage["api_calls"],
|
|
"refresh_count": usage["refresh_count"],
|
|
},
|
|
"usage_by_source": usage_by_source,
|
|
"budget": {
|
|
"unlimited_budget": unlimited,
|
|
"credits_total": credits_total,
|
|
"credits_used": round(credits_used, 1) if credits_used else 0,
|
|
"credits_remaining": None if unlimited else (max(0, int(credits_available - credits_used)) if credits_available else None),
|
|
"credits_percent_used": None if unlimited else (round((credits_used / credits_available) * 100, 1) if credits_available > 0 else None),
|
|
"credits_available": credits_available,
|
|
"credits_carried": credits_carried,
|
|
"credits_period": (lic["credits_period"] or "monthly") if lic else "monthly",
|
|
"credits_rollover": bool(lic["credits_rollover"]) if lic else False,
|
|
"credits_period_start": lic["credits_period_start"] if lic else None,
|
|
"token_budget_usd": lic["token_budget_usd"] if lic else None,
|
|
"cost_per_credit": lic["cost_per_credit"] if lic else None,
|
|
"budget_warning_percent": lic["budget_warning_percent"] if lic else 80,
|
|
},
|
|
}
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
@router.put("/budget/{license_id}")
|
|
async def update_budget(license_id: int, data: dict, request: Request, admin=Depends(get_current_admin)):
|
|
"""Budget einer Lizenz setzen/aendern."""
|
|
db = await get_db()
|
|
try:
|
|
before = await row_to_dict(db, "licenses", license_id)
|
|
if not before:
|
|
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
|
|
|
|
# Vorab-Validierung, der Endpoint nimmt ein rohes dict entgegen.
|
|
# Ein Tippfehler wie 'weekly' wuerde sonst still in der DB landen und
|
|
# der Monitor behandelte die Lizenz wie 'total' (kein Monatsreset).
|
|
if "credits_period" in data and data["credits_period"] not in ("monthly", "total"):
|
|
raise HTTPException(status_code=400, detail="credits_period muss 'monthly' oder 'total' sein")
|
|
if "budget_warning_percent" in data:
|
|
try:
|
|
pct = int(data["budget_warning_percent"])
|
|
except (TypeError, ValueError):
|
|
raise HTTPException(status_code=400, detail="budget_warning_percent muss eine Zahl sein")
|
|
if not 1 <= pct <= 100:
|
|
raise HTTPException(status_code=400, detail="budget_warning_percent muss zwischen 1 und 100 liegen")
|
|
data["budget_warning_percent"] = pct
|
|
if "max_users" in data:
|
|
try:
|
|
mu = int(data["max_users"])
|
|
except (TypeError, ValueError):
|
|
raise HTTPException(status_code=400, detail="max_users muss eine Zahl sein")
|
|
if not 1 <= mu <= 1000:
|
|
raise HTTPException(status_code=400, detail="max_users muss zwischen 1 und 1000 liegen")
|
|
data["max_users"] = mu
|
|
|
|
fields = []
|
|
values = []
|
|
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent", "credits_period", "max_users"):
|
|
if key in data:
|
|
fields.append(f"{key} = ?")
|
|
values.append(data[key])
|
|
|
|
if "credits_used" in data:
|
|
fields.append("credits_used = ?")
|
|
values.append(data["credits_used"])
|
|
|
|
if "unlimited_budget" in data:
|
|
fields.append("unlimited_budget = ?")
|
|
values.append(1 if data["unlimited_budget"] else 0)
|
|
|
|
if "credits_rollover" in data:
|
|
fields.append("credits_rollover = ?")
|
|
values.append(1 if data["credits_rollover"] else 0)
|
|
|
|
# Bewusst NICHT aenderbar: credits_carried, credits_period_start und
|
|
# budget_warning_sent sind Laufzeitfelder, die verwaltet der Monitor.
|
|
|
|
if not fields:
|
|
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
|
|
|
values.append(license_id)
|
|
await db.execute(f"UPDATE licenses SET {', '.join(fields)} WHERE id = ?", values)
|
|
await db.commit()
|
|
|
|
after = await row_to_dict(db, "licenses", license_id)
|
|
await log_action(
|
|
db, admin, get_client_ip(request),
|
|
action="update", resource_type="license", resource_id=license_id,
|
|
before=before, after=after,
|
|
)
|
|
|
|
# Konsistenz-Hinweis
|
|
warning = None
|
|
if after and not after.get("unlimited_budget"):
|
|
cpc = after.get("cost_per_credit")
|
|
ct = after.get("credits_total")
|
|
if (not cpc or cpc == 0) and (not ct or ct == 0):
|
|
warning = "Achtung: cost_per_credit und credits_total sind leer/0 - Budget wird nicht getrackt. Bitte 'Unbegrenzt' aktivieren oder gueltige Werte eintragen."
|
|
|
|
logger.info(f"Budget fuer Lizenz {license_id} aktualisiert: {data}")
|
|
return {"ok": True, "warning": warning}
|
|
finally:
|
|
await db.close()
|