feat: Credits-System — Token-Usage-Router, Budget-Verwaltung, Frontend-Übersicht

- Neuer Router /api/token-usage mit Overview, Org-Detail, Monatsstatistik
- Budget-Felder (credits_total, cost_per_credit, token_budget_usd) bei Lizenz-Erstellung
- Token-Nutzung Sub-Tab in Org-Detail mit Verbrauchsbalken und Monatstabelle
- Dashboard Stat-Card für API-Kosten gesamt
- CSS Dark-Theme Styling für Token-Komponenten
Dieser Commit ist enthalten in:
Claude Dev
2026-03-17 23:59:49 +01:00
Ursprung 3828d7c8bf
Commit 7cd36959b0
7 geänderte Dateien mit 333 neuen und 4 gelöschten Zeilen

Datei anzeigen

@@ -10,7 +10,7 @@ from config import STATIC_DIR, PORT
from database import db_dependency from database import db_dependency
from auth import verify_password, create_token from auth import verify_password, create_token
from models import LoginRequest, TokenResponse from models import LoginRequest, TokenResponse
from routers import organizations, licenses, users, dashboard, sources from routers import organizations, licenses, users, dashboard, sources, token_usage
import aiosqlite import aiosqlite
@@ -40,6 +40,7 @@ app.include_router(licenses.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(sources.router) app.include_router(sources.router)
app.include_router(token_usage.router)
# --- Login --- # --- Login ---

Datei anzeigen

@@ -40,6 +40,10 @@ class LicenseCreate(BaseModel):
license_type: str = Field(pattern="^(trial|annual|permanent)$") license_type: str = Field(pattern="^(trial|annual|permanent)$")
max_users: int = Field(default=5, ge=1, le=1000) max_users: int = Field(default=5, ge=1, le=1000)
duration_days: Optional[int] = Field(default=None, ge=1, le=3650) duration_days: Optional[int] = Field(default=None, ge=1, le=3650)
token_budget_usd: Optional[float] = None
credits_total: Optional[int] = None
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = Field(default=80, ge=1, le=100)
class LicenseResponse(BaseModel): class LicenseResponse(BaseModel):
@@ -51,6 +55,11 @@ class LicenseResponse(BaseModel):
valid_until: Optional[str] valid_until: Optional[str]
status: str status: str
notes: Optional[str] notes: Optional[str]
token_budget_usd: Optional[float] = None
credits_total: Optional[int] = None
credits_used: Optional[float] = None
cost_per_credit: Optional[float] = None
budget_warning_percent: Optional[int] = None
created_at: str created_at: str

Datei anzeigen

@@ -58,9 +58,11 @@ async def create_license(
valid_until = (now + timedelta(days=365)).isoformat() valid_until = (now + timedelta(days=365)).isoformat()
cursor = await db.execute( cursor = await db.execute(
"""INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status) """INSERT INTO licenses (organization_id, license_type, max_users, valid_from, valid_until, status,
VALUES (?, ?, ?, ?, ?, 'active')""", token_budget_usd, credits_total, cost_per_credit, budget_warning_percent)
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until), VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)""",
(data.organization_id, data.license_type, data.max_users, valid_from, valid_until,
data.token_budget_usd, data.credits_total, data.cost_per_credit, data.budget_warning_percent),
) )
await db.commit() await db.commit()

164
src/routers/token_usage.py Normale Datei
Datei anzeigen

@@ -0,0 +1,164 @@
"""Token-Usage & Budget-Verwaltung."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from auth import get_current_admin
from database import get_db
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,
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_remaining = max(0, int(credits_total - credits_used)) if credits_total else None
percent_used = round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 else None
budget_usd = row["token_budget_usd"]
cost = row["total_cost"]
budget_percent = 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,
"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,
"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"],
"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 = await cursor.fetchone()
cursor = await db.execute(
"SELECT credits_total, credits_used, cost_per_credit, token_budget_usd, budget_warning_percent FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
(org_id,))
lic = await cursor.fetchone()
credits_total = lic["credits_total"] if lic else None
credits_used = lic["credits_used"] if lic else 0
return {
"year_month": year_month,
"usage": {
"input_tokens": usage["input_tokens"] if usage else 0,
"output_tokens": usage["output_tokens"] if usage else 0,
"total_cost_usd": round(usage["total_cost_usd"], 2) if usage else 0,
"api_calls": usage["api_calls"] if usage else 0,
"refresh_count": usage["refresh_count"] if usage else 0,
},
"budget": {
"credits_total": credits_total,
"credits_used": round(credits_used, 1) if credits_used else 0,
"credits_remaining": max(0, int(credits_total - credits_used)) if credits_total else None,
"credits_percent_used": round((credits_used / credits_total) * 100, 1) if credits_total and credits_total > 0 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, admin=Depends(get_current_admin)):
"""Budget einer Lizenz setzen/ändern."""
db = await get_db()
try:
cursor = await db.execute("SELECT id FROM licenses WHERE id = ?", (license_id,))
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Lizenz nicht gefunden")
fields = []
values = []
for key in ("token_budget_usd", "credits_total", "cost_per_credit", "budget_warning_percent"):
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 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()
logger.info(f"Budget für Lizenz {license_id} aktualisiert: {data}")
return {"ok": True}
finally:
await db.close()

Datei anzeigen

@@ -763,3 +763,17 @@ tr:hover td {
.cat-header-count::after { .cat-header-count::after {
content: ")"; content: ")";
} }
/* ===== Token-Nutzung Tab ===== */
.token-overview { padding: 4px 0; }
.token-stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
.token-stat-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
.token-stat-label { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.token-stat-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.token-budget-bar-section { margin-bottom: 16px; }
.token-budget-label { display: flex; justify-content: space-between; font-size: 13px; color: var(--text-secondary); margin-bottom: 6px; }
.token-budget-bar-container { width: 100%; height: 10px; background: var(--bg-tertiary); border-radius: 5px; overflow: hidden; }
.token-budget-bar { height: 100%; border-radius: 5px; background: var(--accent); transition: width 0.6s ease, background-color 0.3s ease; }
.token-budget-bar.warning { background: #e67e22; }
.token-budget-bar.critical { background: #e74c3c; }
@media (max-width: 768px) { .token-stats-row { grid-template-columns: repeat(2, 1fr); } }

Datei anzeigen

@@ -88,6 +88,7 @@
<button class="nav-tab active" data-subtab="users">Nutzer</button> <button class="nav-tab active" data-subtab="users">Nutzer</button>
<button class="nav-tab" data-subtab="org-licenses">Lizenzen</button> <button class="nav-tab" data-subtab="org-licenses">Lizenzen</button>
<button class="nav-tab" data-subtab="settings">Einstellungen</button> <button class="nav-tab" data-subtab="settings">Einstellungen</button>
<button class="nav-tab" data-subtab="tokens">Token-Nutzung</button>
</div> </div>
<!-- Users Sub-Tab --> <!-- Users Sub-Tab -->
@@ -161,6 +162,52 @@
</form> </form>
</div> </div>
</div> </div>
<div class="section" id="sub-tokens" style="display:none;">
<div class="token-overview">
<div class="token-stats-row" id="tokenStatsRow">
<div class="token-stat-card">
<div class="token-stat-label">Verbrauchte Credits</div>
<div class="token-stat-value" id="tokenCreditsUsed">-</div>
</div>
<div class="token-stat-card">
<div class="token-stat-label">Verbleibend</div>
<div class="token-stat-value" id="tokenCreditsRemaining">-</div>
</div>
<div class="token-stat-card">
<div class="token-stat-label">Budget (USD)</div>
<div class="token-stat-value" id="tokenBudgetUsd">-</div>
</div>
<div class="token-stat-card">
<div class="token-stat-label">Kosten (USD)</div>
<div class="token-stat-value" id="tokenCostUsd">-</div>
</div>
</div>
<div class="token-budget-bar-section">
<div class="token-budget-label">
<span>Budget-Auslastung</span>
<span id="tokenBudgetPercent">0%</span>
</div>
<div class="token-budget-bar-container">
<div class="token-budget-bar" id="tokenBudgetBar"></div>
</div>
</div>
<h3 style="margin-top:24px;">Monatlicher Verlauf</h3>
<table class="data-table">
<thead>
<tr>
<th>Monat</th>
<th>Refreshes</th>
<th>API-Calls</th>
<th>Input-Tokens</th>
<th>Output-Tokens</th>
<th>Kosten (USD)</th>
</tr>
</thead>
<tbody id="tokenMonthlyTable"></tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -388,6 +435,19 @@
<input type="number" id="newLicDuration" value="14" min="1" max="3650"> <input type="number" id="newLicDuration" value="14" min="1" max="3650">
<div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div> <div class="text-muted mt-8" style="font-size: 12px;">Trial: Standard 14 Tage, Jahreslizenz: Standard 365 Tage</div>
</div> </div>
<div class="form-group">
<label for="newLicCreditsTotal">Credits-Kontingent</label>
<input type="number" id="newLicCreditsTotal" placeholder="z.B. 600000">
</div>
<div class="form-group">
<label for="newLicCostPerCredit">Kosten pro Credit (USD)</label>
<input type="number" id="newLicCostPerCredit" step="0.0001" placeholder="z.B. 0.0033">
</div>
<div class="form-group">
<label for="newLicBudgetUsd">Budget-Limit (USD)</label>
<input type="number" id="newLicBudgetUsd" step="0.01" placeholder="z.B. 2000">
</div>
<div id="newLicError" class="error-msg" style="display:none"></div> <div id="newLicError" class="error-msg" style="display:none"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

Datei anzeigen

@@ -43,6 +43,7 @@ document.addEventListener("DOMContentLoaded", () => {
setupOrgDetailTabs(); setupOrgDetailTabs();
setupForms(); setupForms();
loadDashboard(); loadDashboard();
loadDashboardTokenStats();
loadOrgs(); loadOrgs();
}); });
@@ -75,6 +76,7 @@ function setupOrgDetailTabs() {
tab.classList.add("active"); tab.classList.add("active");
document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active")); document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active"));
document.getElementById(`sub-${subtab}`).classList.add("active"); document.getElementById(`sub-${subtab}`).classList.add("active");
if (subtab === 'tokens' && currentOrgId) loadOrgTokenUsage(currentOrgId);
}); });
}); });
@@ -465,6 +467,12 @@ function setupForms() {
if (licType !== "permanent") { if (licType !== "permanent") {
body.duration_days = parseInt(document.getElementById("newLicDuration").value); body.duration_days = parseInt(document.getElementById("newLicDuration").value);
} }
const creditsTotal = document.getElementById('newLicCreditsTotal');
const costPerCredit = document.getElementById('newLicCostPerCredit');
const budgetUsd = document.getElementById('newLicBudgetUsd');
if (creditsTotal && creditsTotal.value) body.credits_total = parseInt(creditsTotal.value);
if (costPerCredit && costPerCredit.value) body.cost_per_credit = parseFloat(costPerCredit.value);
if (budgetUsd && budgetUsd.value) body.token_budget_usd = parseFloat(budgetUsd.value);
try { try {
await API.post("/api/licenses", body); await API.post("/api/licenses", body);
closeModal("modalNewLicense"); closeModal("modalNewLicense");
@@ -557,3 +565,74 @@ function formatDate(iso) {
return iso; return iso;
} }
} }
// ===== Token-Nutzung =====
async function loadOrgTokenUsage(orgId) {
try {
const [current, monthly] = await Promise.all([
API.get('/api/token-usage/' + orgId + '/current'),
API.get('/api/token-usage/' + orgId),
]);
const budget = current.budget || {};
const usage = current.usage || {};
const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
el('tokenCreditsUsed', budget.credits_used != null ? Math.round(budget.credits_used).toLocaleString('de-DE') : '-');
el('tokenCreditsRemaining', budget.credits_remaining != null ? budget.credits_remaining.toLocaleString('de-DE') : '-');
el('tokenBudgetUsd', budget.token_budget_usd != null ? '$' + Number(budget.token_budget_usd).toFixed(2) : '-');
el('tokenCostUsd', usage.total_cost_usd != null ? '$' + Number(usage.total_cost_usd).toFixed(2) : '-');
const bar = document.getElementById('tokenBudgetBar');
const percentEl = document.getElementById('tokenBudgetPercent');
const percent = budget.credits_percent_used || 0;
if (bar) {
bar.style.width = Math.min(100, percent) + '%';
bar.classList.remove('warning', 'critical');
if (percent > 80) bar.classList.add('critical');
else if (percent > 50) bar.classList.add('warning');
}
if (percentEl) percentEl.textContent = percent.toFixed(1) + '%';
const tbody = document.getElementById('tokenMonthlyTable');
if (tbody) {
tbody.innerHTML = monthly.map(function(m) {
return '<tr>' +
'<td>' + esc(m.year_month) + '</td>' +
'<td>' + m.refresh_count + '</td>' +
'<td>' + m.api_calls.toLocaleString('de-DE') + '</td>' +
'<td>' + m.input_tokens.toLocaleString('de-DE') + '</td>' +
'<td>' + m.output_tokens.toLocaleString('de-DE') + '</td>' +
'<td>$' + Number(m.total_cost_usd).toFixed(2) + '</td>' +
'</tr>';
}).join('');
}
} catch (err) {
console.error('Token-Usage laden fehlgeschlagen:', err);
}
}
async function loadDashboardTokenStats() {
try {
const data = await API.get('/api/token-usage/overview');
const grid = document.getElementById('statsGrid');
if (grid && data.length > 0) {
const totals = data.reduce(function(acc, org) {
return {
cost: acc.cost + (org.total_cost_usd || 0),
refreshes: acc.refreshes + (org.total_refreshes || 0),
};
}, { cost: 0, refreshes: 0 });
const card = document.createElement('div');
card.className = 'stat-card';
card.innerHTML = '<div class="stat-label">API-Kosten gesamt</div>' +
'<div class="stat-value">$' + totals.cost.toFixed(2) + '</div>' +
'<div class="stat-sub">' + totals.refreshes + ' Refreshes</div>';
grid.appendChild(card);
}
} catch (err) {
console.error('Token-Overview laden fehlgeschlagen:', err);
}
}