feat: API-Key Health-Check für Anthropic-Keys

- 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
This commit is contained in:
Meleki committed 2026-03-12 12:40:23 +01:00
1 parent fa8deb15b6
commit 8dc52c1337
3 files changed
+186 -1

No files matched your search

+124
View File
@@ -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 });
}
}
}