feat: Credits-System mit Verbrauchsanzeige im User-Dropdown
- DB-Migration: credits_total/credits_used/cost_per_credit auf licenses, token_usage_monthly Tabelle - Orchestrator: Monatliche Token-Aggregation + Credits-Abzug nach Refresh - Auth: Credits-Daten im /me Endpoint + Bugfix fehlende Klammer in get() - Frontend: Credits-Balken im User-Dropdown mit Farbwechsel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -1316,6 +1316,41 @@ class AgentOrchestrator:
|
||||
f"${usage_acc.total_cost_usd:.4f} ({usage_acc.call_count} Calls)"
|
||||
)
|
||||
|
||||
# Credits-Tracking: Monatliche Aggregation + Credits abziehen
|
||||
if tenant_id and usage_acc.total_cost_usd > 0:
|
||||
year_month = datetime.now(TIMEZONE).strftime('%Y-%m')
|
||||
await db.execute("""
|
||||
INSERT INTO token_usage_monthly
|
||||
(organization_id, year_month, input_tokens, output_tokens,
|
||||
cache_creation_tokens, cache_read_tokens, total_cost_usd, api_calls, refresh_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(organization_id, year_month) DO UPDATE SET
|
||||
input_tokens = input_tokens + excluded.input_tokens,
|
||||
output_tokens = output_tokens + excluded.output_tokens,
|
||||
cache_creation_tokens = cache_creation_tokens + excluded.cache_creation_tokens,
|
||||
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
||||
total_cost_usd = total_cost_usd + excluded.total_cost_usd,
|
||||
api_calls = api_calls + excluded.api_calls,
|
||||
refresh_count = refresh_count + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (tenant_id, year_month,
|
||||
usage_acc.input_tokens, usage_acc.output_tokens,
|
||||
usage_acc.cache_creation_tokens, usage_acc.cache_read_tokens,
|
||||
round(usage_acc.total_cost_usd, 7), usage_acc.call_count))
|
||||
|
||||
# Credits auf Lizenz abziehen
|
||||
lic_cursor = await db.execute(
|
||||
"SELECT cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||
(tenant_id,))
|
||||
lic = await lic_cursor.fetchone()
|
||||
if lic and lic["cost_per_credit"] and lic["cost_per_credit"] > 0:
|
||||
credits_consumed = usage_acc.total_cost_usd / lic["cost_per_credit"]
|
||||
await db.execute(
|
||||
"UPDATE licenses SET credits_used = COALESCE(credits_used, 0) + ? WHERE organization_id = ? AND status = 'active'",
|
||||
(round(credits_consumed, 2), tenant_id))
|
||||
await db.commit()
|
||||
logger.info(f"Credits: {round(credits_consumed, 1) if lic and lic['cost_per_credit'] else 0} abgezogen für Tenant {tenant_id}")
|
||||
|
||||
# Quellen-Discovery im Background starten
|
||||
if unique_results:
|
||||
asyncio.create_task(_background_discover_sources(unique_results))
|
||||
|
||||
@@ -582,7 +582,42 @@ async def init_db():
|
||||
await db.commit()
|
||||
logger.info("Migration: article_locations-Tabelle erstellt")
|
||||
|
||||
# Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
|
||||
|
||||
# Migration: Credits-System fuer Lizenzen
|
||||
cursor = await db.execute("PRAGMA table_info(licenses)")
|
||||
columns = [row[1] for row in await cursor.fetchall()]
|
||||
if "token_budget_usd" not in columns:
|
||||
await db.execute("ALTER TABLE licenses ADD COLUMN token_budget_usd REAL")
|
||||
await db.execute("ALTER TABLE licenses ADD COLUMN credits_total INTEGER")
|
||||
await db.execute("ALTER TABLE licenses ADD COLUMN credits_used REAL DEFAULT 0")
|
||||
await db.execute("ALTER TABLE licenses ADD COLUMN cost_per_credit REAL")
|
||||
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_percent INTEGER DEFAULT 80")
|
||||
await db.commit()
|
||||
logger.info("Migration: Credits-System zu Lizenzen hinzugefuegt")
|
||||
|
||||
# Migration: Token-Usage-Monatstabelle
|
||||
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage_monthly'")
|
||||
if not await cursor.fetchone():
|
||||
await db.execute("""
|
||||
CREATE TABLE token_usage_monthly (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
organization_id INTEGER REFERENCES organizations(id),
|
||||
year_month TEXT NOT NULL,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_creation_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
total_cost_usd REAL DEFAULT 0.0,
|
||||
api_calls INTEGER DEFAULT 0,
|
||||
refresh_count INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(organization_id, year_month)
|
||||
)
|
||||
""")
|
||||
await db.commit()
|
||||
logger.info("Migration: token_usage_monthly Tabelle erstellt")
|
||||
|
||||
# Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
|
||||
await db.execute(
|
||||
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
|
||||
@@ -41,6 +41,9 @@ class UserMeResponse(BaseModel):
|
||||
license_status: str = "unknown"
|
||||
license_type: str = ""
|
||||
read_only: bool = False
|
||||
credits_total: Optional[int] = None
|
||||
credits_remaining: Optional[int] = None
|
||||
credits_percent_used: Optional[float] = None
|
||||
|
||||
|
||||
# Incidents (Lagen)
|
||||
|
||||
@@ -261,10 +261,28 @@ async def get_me(
|
||||
from services.license_service import check_license
|
||||
license_info = await check_license(db, current_user["tenant_id"])
|
||||
|
||||
# Credits-Daten laden
|
||||
credits_total = None
|
||||
credits_remaining = None
|
||||
credits_percent_used = None
|
||||
if current_user.get("tenant_id"):
|
||||
lic_cursor = await db.execute(
|
||||
"SELECT credits_total, credits_used, cost_per_credit FROM licenses WHERE organization_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1",
|
||||
(current_user["tenant_id"],))
|
||||
lic_row = await lic_cursor.fetchone()
|
||||
if lic_row and lic_row["credits_total"]:
|
||||
credits_total = lic_row["credits_total"]
|
||||
credits_used = lic_row["credits_used"] or 0
|
||||
credits_remaining = max(0, int(credits_total - credits_used))
|
||||
credits_percent_used = round(min(100, (credits_used / credits_total) * 100), 1) if credits_total > 0 else 0
|
||||
|
||||
return UserMeResponse(
|
||||
id=current_user["id"],
|
||||
username=current_user["username"],
|
||||
email=current_user.get("email", ""),
|
||||
credits_total=credits_total,
|
||||
credits_remaining=credits_remaining,
|
||||
credits_percent_used=credits_percent_used,
|
||||
role=current_user["role"],
|
||||
org_name=org_name,
|
||||
org_slug=current_user.get("org_slug", ""),
|
||||
|
||||
@@ -5335,3 +5335,61 @@ body.tutorial-active .tutorial-cursor {
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 0 0 2px rgba(150, 121, 26, 0.25);
|
||||
}
|
||||
|
||||
/* ===== Credits-Anzeige im User-Dropdown ===== */
|
||||
.credits-section {
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
|
||||
.credits-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.credits-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.credits-bar-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.credits-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
transition: width 0.6s ease, background-color 0.3s ease;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.credits-bar.warning {
|
||||
background: #e67e22;
|
||||
}
|
||||
|
||||
.credits-bar.critical {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.credits-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.credits-info span {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,16 @@
|
||||
<span class="header-dropdown-label">Lizenz</span>
|
||||
<span class="header-dropdown-value" id="header-license-info">-</span>
|
||||
</div>
|
||||
<div id="credits-section" class="credits-section" style="display: none;">
|
||||
<div class="credits-divider"></div>
|
||||
<div class="credits-label">Credits</div>
|
||||
<div class="credits-bar-container">
|
||||
<div id="credits-bar" class="credits-bar"></div>
|
||||
</div>
|
||||
<div class="credits-info">
|
||||
<span id="credits-remaining">0</span> von <span id="credits-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-license-warning" id="header-license-warning"></div>
|
||||
|
||||
@@ -466,6 +466,32 @@ const App = {
|
||||
licInfoEl.textContent = label;
|
||||
}
|
||||
|
||||
// Credits-Anzeige im Dropdown
|
||||
const creditsSection = document.getElementById('credits-section');
|
||||
if (creditsSection && user.credits_total) {
|
||||
creditsSection.style.display = 'block';
|
||||
const bar = document.getElementById('credits-bar');
|
||||
const remainingEl = document.getElementById('credits-remaining');
|
||||
const totalEl = document.getElementById('credits-total');
|
||||
|
||||
const remaining = user.credits_remaining || 0;
|
||||
const total = user.credits_total || 1;
|
||||
const percentUsed = user.credits_percent_used || 0;
|
||||
const percentRemaining = Math.max(0, 100 - percentUsed);
|
||||
|
||||
remainingEl.textContent = remaining.toLocaleString('de-DE');
|
||||
totalEl.textContent = total.toLocaleString('de-DE');
|
||||
bar.style.width = percentRemaining + '%';
|
||||
|
||||
// Farbwechsel je nach Verbrauch
|
||||
bar.classList.remove('warning', 'critical');
|
||||
if (percentUsed > 80) {
|
||||
bar.classList.add('critical');
|
||||
} else if (percentUsed > 50) {
|
||||
bar.classList.add('warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown Toggle
|
||||
const userBtn = document.getElementById('header-user-btn');
|
||||
const userDropdown = document.getElementById('header-user-dropdown');
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren