- 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)
207 lines
9.1 KiB
C#
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 };
|
|
}
|
|
}
|
|
}
|
|
}
|