From 7bfa1d29cf2ca37cf8eb10b7fb60e59b8f69c5fe Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Tue, 17 Mar 2026 23:53:19 +0100 Subject: [PATCH] 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) --- src/agents/orchestrator.py | 35 +++++++++++++++++++++++ src/database.py | 37 +++++++++++++++++++++++- src/models.py | 3 ++ src/routers/auth.py | 18 ++++++++++++ src/static/css/style.css | 58 ++++++++++++++++++++++++++++++++++++++ src/static/dashboard.html | 10 +++++++ src/static/js/app.js | 26 +++++++++++++++++ 7 files changed, 186 insertions(+), 1 deletion(-) diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py index feae838..b382db1 100644 --- a/src/agents/orchestrator.py +++ b/src/agents/orchestrator.py @@ -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)) diff --git a/src/database.py b/src/database.py index cc99d12..db6b1a8 100644 --- a/src/database.py +++ b/src/database.py @@ -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 diff --git a/src/models.py b/src/models.py index 0333321..f16b53b 100644 --- a/src/models.py +++ b/src/models.py @@ -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) diff --git a/src/routers/auth.py b/src/routers/auth.py index 6116346..8145ee0 100644 --- a/src/routers/auth.py +++ b/src/routers/auth.py @@ -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", ""), diff --git a/src/static/css/style.css b/src/static/css/style.css index 92f7c7c..35e032e 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -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); +} diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 9d780b3..42151d7 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -50,6 +50,16 @@ Lizenz - +
diff --git a/src/static/js/app.js b/src/static/js/app.js index 5e920c9..ad48f52 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -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');