Files
gott/Endpoints/ApiKeysEndpoint.cs
T
Meleki 8dc52c1337 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
2026-03-12 12:40:23 +01:00

299 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
using Gott.Models;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
namespace Gott.Endpoints;
public static class ApiKeysEndpoint
{
public static void MapApiKeysEndpoints(this WebApplication app)
{
app.MapGet("/api/admin/api-keys", HandleGetAll);
app.MapPost("/api/admin/api-keys", HandleCreate);
app.MapPut("/api/admin/api-keys/{keyId}", HandleUpdate);
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)
{
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
var keys = await db.GetApiKeys();
var result = keys.Select(k => new
{
k.KeyId,
k.Provider,
k.Name,
k.ApiKey,
k.IsActive,
k.CreatedAt,
k.UpdatedAt
});
await ctx.Response.WriteAsJsonAsync(result);
}
private static async Task HandleCreate(HttpContext ctx, DatabaseService db)
{
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var provider = body.GetProperty("provider").GetString()?.Trim().ToLower() ?? "";
var name = body.GetProperty("name").GetString()?.Trim() ?? "";
var apiKey = body.GetProperty("apiKey").GetString()?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(apiKey))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Provider, Name und API-Key sind erforderlich" });
return;
}
var now = DateTime.UtcNow.ToString("o");
var record = new ApiKeyRecord
{
KeyId = Guid.NewGuid().ToString("N"),
Provider = provider,
Name = name,
ApiKey = apiKey,
IsActive = false,
CreatedAt = now,
UpdatedAt = now
};
await db.CreateApiKey(record);
await ctx.Response.WriteAsJsonAsync(new
{
record.KeyId,
record.Provider,
record.Name,
record.ApiKey,
record.IsActive,
record.CreatedAt
});
}
private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, 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;
}
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
var name = body.TryGetProperty("name", out var n) ? n.GetString()?.Trim() : null;
var apiKey = body.TryGetProperty("apiKey", out var k) ? k.GetString()?.Trim() : null;
if (string.IsNullOrWhiteSpace(name))
name = existing.Name;
await db.UpdateApiKey(keyId, name, apiKey);
// Wenn aktiver Key geändert wird → Token im Service aktualisieren
if (existing.IsActive && existing.Provider == "anthropic" && !string.IsNullOrWhiteSpace(apiKey))
anthropic.UpdateToken(apiKey);
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}
private static async Task HandleActivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, 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;
}
await db.SetActiveApiKey(keyId, existing.Provider);
// Token im laufenden Service aktualisieren
if (existing.Provider == "anthropic")
anthropic.UpdateToken(existing.ApiKey);
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}
private static async Task HandleDeactivate(HttpContext ctx, DatabaseService db, AnthropicService anthropic, 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;
}
await db.DeactivateApiKey(keyId);
// Token im laufenden Service leeren
if (existing.Provider == "anthropic")
anthropic.UpdateToken("");
await ctx.Response.WriteAsJsonAsync(new { ok = true });
}
private static async Task HandleDelete(HttpContext ctx, DatabaseService db, AnthropicService anthropic, 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;
}
// Wenn aktiver Key gelöscht wird → Token leeren
if (existing.IsActive && existing.Provider == "anthropic")
anthropic.UpdateToken("");
await db.DeleteApiKey(keyId);
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 });
}
}
}