- 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
299 lines
11 KiB
C#
299 lines
11 KiB
C#
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 });
|
||
}
|
||
}
|
||
}
|