From 8dc52c133782f31745779cdfd7a15dd03c6c5d57 Mon Sep 17 00:00:00 2001 From: Meleki Date: Thu, 12 Mar 2026 12:40:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20API-Key=20Health-Check=20f=C3=BCr=20Ant?= =?UTF-8?q?hropic-Keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/admin/api-keys/{keyId}/check Endpoint - Sendet minimale Anfrage an Anthropic API, liest Rate-Limit-Headers - Plan-Erkennung: Max (Overage > 0), Pro (kein Fallback-Header), Pro/Max (sonst), API Console - Budget-Auslastung: 5h + 7d + Overage mit Status-Warnings - Frontend: Heartbeat-Button zeigt Ergebnis inline unter dem Key-Eintrag - apiCheckApiKey() API-Funktion --- Endpoints/ApiKeysEndpoint.cs | 124 ++++++++++++++++++++++++++++++ wwwroot/js/api.js | 9 +++ wwwroot/js/pages/api-keys-page.js | 54 ++++++++++++- 3 files changed, 186 insertions(+), 1 deletion(-) diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index c2dde47..3b79f06 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -1,5 +1,8 @@ using System; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; using System.Text.Json; using System.Threading.Tasks; using Gott.Extensions; @@ -20,6 +23,7 @@ public static class ApiKeysEndpoint app.MapPut("/api/admin/api-keys/{keyId}/activate", HandleActivate); app.MapPut("/api/admin/api-keys/{keyId}/deactivate", HandleDeactivate); app.MapDelete("/api/admin/api-keys/{keyId}", HandleDelete); + app.MapPost("/api/admin/api-keys/{keyId}/check", HandleCheck); } private static async Task HandleGetAll(HttpContext ctx, DatabaseService db) @@ -170,5 +174,125 @@ public static class ApiKeysEndpoint await ctx.Response.WriteAsJsonAsync(new { ok = true }); } + private static async Task HandleCheck(HttpContext ctx, IHttpClientFactory httpFactory, DatabaseService db, string keyId) + { + if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; } + var existing = await db.GetApiKey(keyId); + if (existing == null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsJsonAsync(new { error = "Key nicht gefunden" }); + return; + } + + if (existing.Provider != "anthropic") + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync(new { error = "Check nur für Anthropic-Keys verfügbar" }); + return; + } + + try + { + var client = httpFactory.CreateClient(); + client.Timeout = TimeSpan.FromSeconds(30); + + var payload = JsonSerializer.Serialize(new + { + model = "claude-sonnet-4-20250514", + max_tokens = 1, + messages = new[] { new { role = "user", content = "hi" } } + }); + + var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages"); + request.Headers.Add("x-api-key", existing.ApiKey); + request.Headers.Add("anthropic-version", "2023-06-01"); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + + var resp = await client.SendAsync(request); + var headers = resp.Headers; + + string GetHeader(string name) + { + if (headers.TryGetValues("anthropic-ratelimit-" + name, out var v1)) + return string.Join("", v1); + if (headers.TryGetValues(name, out var v2)) + return string.Join("", v2); + return null; + } + + if (resp.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Ungültig oder abgelaufen (401)" }); + return; + } + if (resp.StatusCode == (System.Net.HttpStatusCode)429) + { + await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Rate Limited (429)" }); + return; + } + if (resp.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "Insufficient Quota (403)" }); + return; + } + if ((int)resp.StatusCode >= 400) + { + await ctx.Response.WriteAsJsonAsync(new { status = "error", message = "API-Fehler (" + (int)resp.StatusCode + ")" }); + return; + } + + // Key ist gültig – Header auslesen + var d7Util = GetHeader("unified-7d-utilization"); + var d5hUtil = GetHeader("unified-5h-utilization"); + var overageUtil = GetHeader("unified-overage-utilization"); + var d7Status = GetHeader("unified-7d-status"); + var d5hStatus = GetHeader("unified-5h-status"); + var overageStatus = GetHeader("unified-overage-status"); + + // Subscription-Keys haben unified-* Headers, API-Keys nicht + var isSubscription = d7Util != null || d5hUtil != null; + var fallbackPct = GetHeader("unified-fallback-percentage"); + + // Plan-Erkennung: + // - overage > 0 → definitiv MAX (Extra Usage aktiv, nur bei Max sinnvoll nutzbar) + // - kein fallback-percentage Header → PRO + // - sonst → Pro/Max (nicht unterscheidbar) + string keyType; + if (!isSubscription) + keyType = "API Console"; + else if (overageUtil != null && double.TryParse(overageUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ovCheck) && ovCheck > 0) + keyType = "Max"; + else if (fallbackPct == null) + keyType = "Pro"; + else + keyType = "Pro/Max"; + + double? usage7d = null, usage5h = null, overage = null; + if (d7Util != null && double.TryParse(d7Util, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d7)) + usage7d = Math.Round(d7 * 100, 1); + if (d5hUtil != null && double.TryParse(d5hUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var d5)) + usage5h = Math.Round(d5 * 100, 1); + if (overageUtil != null && double.TryParse(overageUtil, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var ov)) + overage = Math.Round(ov * 100, 1); + + await ctx.Response.WriteAsJsonAsync(new + { + status = "ok", + keyType, + isSubscription, + usage7d, + usage5h, + overage, + status7d = d7Status, + status5h = d5hStatus, + statusOverage = overageStatus + }); + } + catch (Exception ex) + { + await ctx.Response.WriteAsJsonAsync(new { status = "error", message = ex.Message }); + } + } } diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 16697c9..9263b06 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -645,6 +645,15 @@ async function apiDeleteApiKey(keyId) { return resp.json(); } +async function apiCheckApiKey(keyId) { + const resp = await fetch('/api/admin/api-keys/' + keyId + '/check', { method: 'POST' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || 'Fehler beim Prüfen'); + } + return resp.json(); +} + // ── Krea Image API ── async function apiKreaStatus() { diff --git a/wwwroot/js/pages/api-keys-page.js b/wwwroot/js/pages/api-keys-page.js index 81b489d..c7ded68 100644 --- a/wwwroot/js/pages/api-keys-page.js +++ b/wwwroot/js/pages/api-keys-page.js @@ -32,6 +32,8 @@ registerPage('api-keys', { } keys.forEach(function(key) { + var wrapper = document.createElement('div'); + var row = document.createElement('div'); row.className = 'admin-user-row'; @@ -54,6 +56,21 @@ registerPage('api-keys', { var actions = document.createElement('div'); actions.className = 'admin-user-actions'; + // Status-Zeile (für Check-Ergebnis) + var statusLine = document.createElement('div'); + statusLine.className = 'admin-user-meta'; + statusLine.style.cssText = 'padding:0 var(--box-padding) .5rem;display:none;font-size:.85em'; + + // Check (nur Anthropic) + if (key.provider === 'anthropic') { + var checkBtn = document.createElement('button'); + checkBtn.className = 'icon-btn'; + checkBtn.title = 'Key prüfen'; + checkBtn.innerHTML = ''; + checkBtn.addEventListener('click', function() { checkKey(key, checkBtn, statusLine); }); + actions.appendChild(checkBtn); + } + // Bearbeiten var editBtn = document.createElement('button'); editBtn.className = 'icon-btn'; @@ -89,7 +106,9 @@ registerPage('api-keys', { row.appendChild(info); row.appendChild(actions); - list.appendChild(row); + wrapper.appendChild(row); + wrapper.appendChild(statusLine); + list.appendChild(wrapper); }); } @@ -102,6 +121,39 @@ registerPage('api-keys', { } } + async function checkKey(key, btn, statusLine) { + var origHtml = btn.innerHTML; + btn.innerHTML = ''; + btn.disabled = true; + statusLine.style.display = 'block'; + statusLine.textContent = 'Prüfe…'; + statusLine.style.opacity = '0.5'; + try { + var result = await apiCheckApiKey(key.keyId); + statusLine.style.opacity = '1'; + if (result.status === 'ok') { + var parts = ['✅ Gültig', result.keyType]; + if (result.usage5h != null) parts.push('5h: ' + result.usage5h + '%'); + if (result.usage7d != null) { + var s7d = '7d: ' + result.usage7d + '%'; + if (result.status7d === 'allowed_warning') s7d += ' ⚠️'; + else if (result.status7d === 'exceeded') s7d += ' 🛑'; + parts.push(s7d); + } + if (result.overage != null && result.overage > 0) parts.push('Overage: ' + result.overage + '%'); + statusLine.textContent = parts.join(' · '); + } else { + statusLine.textContent = '❌ ' + (result.message || 'Unbekannter Fehler'); + } + } catch (err) { + statusLine.style.opacity = '1'; + statusLine.textContent = '❌ ' + err.message; + } finally { + btn.innerHTML = origHtml; + btn.disabled = false; + } + } + function startEdit(key) { editingKeyId = key.keyId; formTitle.textContent = 'API-Key bearbeiten';