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
@@ -39,6 +39,9 @@ public static class ApiKeysEndpoint
|
||||
app.MapGet("/api/admin/api-keys/event-scanner/status", HandleEventScannerStatus);
|
||||
app.MapGet("/api/admin/api-keys/event-scanner/log", HandleEventScannerLog);
|
||||
|
||||
// Scanner Logs (persistiert)
|
||||
app.MapGet("/api/admin/scanner/logs", HandleGetScannerLogs);
|
||||
|
||||
// User-Endpoints (für alle authentifizierten User)
|
||||
app.MapGet("/api/api-keys/available", HandleGetAvailable);
|
||||
app.MapPut("/api/api-keys/select/{keyId}", HandleSelectKey);
|
||||
@@ -179,32 +182,51 @@ public static class ApiKeysEndpoint
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing.Provider != "anthropic")
|
||||
switch (existing.Provider)
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Check nur für Anthropic-Keys verfügbar" });
|
||||
return;
|
||||
case "anthropic":
|
||||
{
|
||||
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
|
||||
var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
|
||||
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h);
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
status = dbStatus,
|
||||
message = result.Message,
|
||||
plan = result.Plan,
|
||||
result.IsSubscription,
|
||||
result.Usage7d,
|
||||
result.Usage5h,
|
||||
result.Overage,
|
||||
status7d = result.Status7d,
|
||||
status5h = result.Status5h,
|
||||
statusOverage = result.StatusOverage
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "openai":
|
||||
{
|
||||
var result = await OpenAiKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
|
||||
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
|
||||
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Message, null, null);
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = dbStatus, message = result.Message, plan = result.Message });
|
||||
break;
|
||||
}
|
||||
case "google":
|
||||
{
|
||||
var result = await GoogleKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
|
||||
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
|
||||
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Message, null, null);
|
||||
await ctx.Response.WriteAsJsonAsync(new { status = dbStatus, message = result.Message, plan = result.Message });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
ctx.Response.StatusCode = 400;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = $"Check für Provider '{existing.Provider}' nicht unterstützt" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
|
||||
var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
|
||||
|
||||
// Metadaten in DB speichern
|
||||
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
status = dbStatus,
|
||||
message = result.Message,
|
||||
plan = result.Plan,
|
||||
result.IsSubscription,
|
||||
result.Usage7d,
|
||||
result.Usage5h,
|
||||
result.Overage,
|
||||
status7d = result.Status7d,
|
||||
status5h = result.Status5h,
|
||||
statusOverage = result.StatusOverage
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task HandleCheckAll(HttpContext ctx, IHttpClientFactory httpFactory, DatabaseService db)
|
||||
@@ -212,15 +234,37 @@ public static class ApiKeysEndpoint
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var allKeys = await db.GetApiKeys();
|
||||
var anthropicKeys = allKeys.Where(k => k.Provider == "anthropic").ToList();
|
||||
|
||||
var results = new List<object>();
|
||||
foreach (var key in anthropicKeys)
|
||||
|
||||
foreach (var key in allKeys)
|
||||
{
|
||||
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
|
||||
var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
|
||||
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h);
|
||||
results.Add(new { key.KeyId, key.Name, status = dbStatus, result.Plan, result.Usage5h, result.Usage7d });
|
||||
switch (key.Provider)
|
||||
{
|
||||
case "anthropic":
|
||||
{
|
||||
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
|
||||
var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
|
||||
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h);
|
||||
results.Add(new { key.KeyId, key.Name, status = dbStatus, result.Plan, result.Usage5h, result.Usage7d });
|
||||
break;
|
||||
}
|
||||
case "openai":
|
||||
{
|
||||
var result = await OpenAiKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
|
||||
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
|
||||
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Message, null, null);
|
||||
results.Add(new { key.KeyId, key.Name, status = dbStatus, Plan = result.Message, Usage5h = (double?)null, Usage7d = (double?)null });
|
||||
break;
|
||||
}
|
||||
case "google":
|
||||
{
|
||||
var result = await GoogleKeyChecker.CheckKeyAsync(httpFactory, key.ApiKey);
|
||||
var dbStatus = result.Status == "ok" ? "valid" : result.Status;
|
||||
await db.UpdateApiKeyMeta(key.KeyId, dbStatus, result.Message, null, null);
|
||||
results.Add(new { key.KeyId, key.Name, status = dbStatus, Plan = result.Message, Usage5h = (double?)null, Usage7d = (double?)null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new { checked_ = results.Count, results });
|
||||
@@ -377,10 +421,17 @@ public static class ApiKeysEndpoint
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
// Provider anhand des Key-Typs bestimmen
|
||||
var keyType = KeyPatterns.DetectType(sk.ApiKey);
|
||||
string provider;
|
||||
if (keyType.StartsWith("openai")) provider = "openai";
|
||||
else if (keyType == "google_api") provider = "google";
|
||||
else provider = "anthropic";
|
||||
|
||||
var record = new ApiKeyRecord
|
||||
{
|
||||
KeyId = Guid.NewGuid().ToString("N"),
|
||||
Provider = "anthropic",
|
||||
Provider = provider,
|
||||
Name = title,
|
||||
ApiKey = sk.ApiKey,
|
||||
IsPublic = false,
|
||||
@@ -507,4 +558,19 @@ public static class ApiKeysEndpoint
|
||||
await ctx.Response.WriteAsJsonAsync(new { entries = log.Select(e => new { e.Time, e.Type, e.Message }) });
|
||||
}
|
||||
|
||||
private static async Task HandleGetScannerLogs(HttpContext ctx, DatabaseService db)
|
||||
{
|
||||
if (!ctx.IsAdmin()) { ctx.Response.StatusCode = 403; return; }
|
||||
|
||||
var limit = int.TryParse(ctx.Request.Query["limit"], out var l) ? Math.Min(l, 1000) : 200;
|
||||
var offset = int.TryParse(ctx.Request.Query["offset"], out var o) ? o : 0;
|
||||
var type = ctx.Request.Query["type"].FirstOrDefault();
|
||||
var scanner = ctx.Request.Query["scanner"].FirstOrDefault();
|
||||
|
||||
var logs = await db.GetScannerLogs(limit, offset, type, scanner);
|
||||
var total = await db.GetScannerLogCount(type, scanner);
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new { logs, total });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -220,8 +220,28 @@ public static class ChatEndpoint
|
||||
{
|
||||
if (!string.IsNullOrEmpty(ctx.Provider))
|
||||
{
|
||||
// OpenAI-kompatibler Provider (z.B. Nvidia)
|
||||
await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, workCt: workCt, sseCt: sseCt);
|
||||
// OpenAI-kompatibler Provider – für "openai" den User-Key aus DB verwenden
|
||||
string openAiKeyOverride = null;
|
||||
if (ctx.Provider == "openai")
|
||||
{
|
||||
// Zuerst prüfen ob der ausgewählte Key ein OpenAI-Key ist
|
||||
if (userApiKey.Provider == "openai")
|
||||
openAiKeyOverride = userApiKey.ApiKey;
|
||||
else
|
||||
{
|
||||
// Fallback: ersten aktiven OpenAI-Key aus DB holen
|
||||
openAiKeyOverride = await db.GetFirstActiveKeyByProvider("openai");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(openAiKeyOverride))
|
||||
{
|
||||
await SseHelper.TryWriteSse(res, "error", new { error = "Kein OpenAI API-Key vorhanden. Bitte einen OpenAI-Key in den API-Keys hinzufügen." }, sseCt);
|
||||
await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await openAi.StreamChat(res, ctx.ModelId, ctx.SystemPromptText, messages, ctx.ChainId, workspace: ctx.Workspace, sessionId: sessionId, agentId: ctx.AgentId, apiKeyOverride: openAiKeyOverride, workCt: workCt, sseCt: sseCt);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ public static class ModelsEndpoint
|
||||
app.MapGet("/api/models/nvidia", HandleNvidiaModels);
|
||||
app.MapGet("/api/models/pollinations", HandlePollinationsModels);
|
||||
app.MapGet("/api/models/gemini", HandleGeminiModels);
|
||||
app.MapGet("/api/models/openai", HandleOpenAiModels);
|
||||
app.MapPost("/api/models/refresh", HandleRefresh);
|
||||
}
|
||||
|
||||
@@ -41,6 +42,14 @@ public static class ModelsEndpoint
|
||||
return Results.Json(new { models });
|
||||
}
|
||||
|
||||
private static IResult HandleOpenAiModels(OpenAiService openAiService)
|
||||
{
|
||||
var models = openAiService.GetOpenAiModels()
|
||||
.Select(m => new { id = m.Id, displayName = m.DisplayName })
|
||||
.ToList();
|
||||
return Results.Json(new { models });
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleRefresh(OpenAiService openAiService)
|
||||
{
|
||||
await openAiService.RefreshModelsFromApis();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -192,6 +192,9 @@
|
||||
<option value="opus-4-5">opus-4-5</option>
|
||||
<option value="opus-4-6" selected>opus-4-6</option>
|
||||
<option value="haiku-4-5">haiku-4-5</option>
|
||||
<optgroup label="OpenAI" id="openaiOptgroup">
|
||||
<option value="" disabled>Wird geladen...</option>
|
||||
</optgroup>
|
||||
<optgroup label="Google Gemini" id="geminiOptgroup">
|
||||
<option value="" disabled>Wird geladen...</option>
|
||||
</optgroup>
|
||||
@@ -572,6 +575,8 @@
|
||||
<label>Provider</label>
|
||||
<select id="apiKeysProvider">
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="google">Google / Gemini</option>
|
||||
</select>
|
||||
<label>Name</label>
|
||||
<input type="text" id="apiKeysName" placeholder="z.B. Mein Pro Key" autocomplete="off">
|
||||
|
||||
@@ -21,6 +21,7 @@ registerPage('agent', {
|
||||
const nvidiaOptgroup = document.getElementById('nvidiaOptgroup');
|
||||
const pollinationsOptgroup = document.getElementById('pollinationsOptgroup');
|
||||
const geminiOptgroup = document.getElementById('geminiOptgroup');
|
||||
const openaiOptgroup = document.getElementById('openaiOptgroup');
|
||||
|
||||
// Modelle dynamisch laden
|
||||
const loadProviderModels = async (url, prefix, optgroup) => {
|
||||
@@ -35,13 +36,16 @@ registerPage('agent', {
|
||||
opt.textContent = m.displayName;
|
||||
optgroup.appendChild(opt);
|
||||
}
|
||||
} else if (optgroup) {
|
||||
optgroup.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
if (optgroup) optgroup.innerHTML = '<option value="" disabled>Fehler beim Laden</option>';
|
||||
if (optgroup) optgroup.remove();
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
loadProviderModels('/api/models/openai', 'openai', openaiOptgroup),
|
||||
loadProviderModels('/api/models/nvidia', 'nvidia', nvidiaOptgroup),
|
||||
loadProviderModels('/api/models/pollinations', 'pollinations', pollinationsOptgroup),
|
||||
loadProviderModels('/api/models/gemini', 'gemini', geminiOptgroup)
|
||||
|
||||
@@ -137,8 +137,8 @@ registerPage('api-keys', {
|
||||
statusLine.className = 'admin-user-meta';
|
||||
statusLine.style.cssText = 'padding:0 var(--box-padding) .5rem;display:none;font-size:.85em';
|
||||
|
||||
// Check (nur Anthropic) – rechts neben Usage-Badge
|
||||
if (key.provider === 'anthropic') {
|
||||
// Check – rechts neben Usage-Badge
|
||||
if (key.provider === 'anthropic' || key.provider === 'openai' || key.provider === 'google') {
|
||||
var checkBtn = document.createElement('button');
|
||||
checkBtn.className = 'icon-btn';
|
||||
checkBtn.title = 'Key prüfen';
|
||||
@@ -324,6 +324,7 @@ registerPage('api-keys', {
|
||||
var scannedList = document.getElementById('scannedKeysList');
|
||||
var scannedKeys = [];
|
||||
var selectedType = null;
|
||||
var checkUncheckedAbort = false;
|
||||
var pollTimer = null;
|
||||
|
||||
var typeNames = {
|
||||
@@ -405,10 +406,71 @@ registerPage('api-keys', {
|
||||
backName.innerHTML = '<i class="fa fa-arrow-left"></i> Zurück zur Übersicht';
|
||||
backInfo.appendChild(backName);
|
||||
backRow.appendChild(backInfo);
|
||||
backRow.addEventListener('click', function() { selectedType = null; loadSummary(); });
|
||||
backRow.addEventListener('click', function() { selectedType = null; checkUncheckedAbort = true; loadSummary(); });
|
||||
scannedList.appendChild(backRow);
|
||||
|
||||
// Toolbar: "Ungeprüfte prüfen"-Button
|
||||
var visibleKeys = scannedKeys.filter(function(sk) { return sk.adoptedApiKeyId == null; });
|
||||
var uncheckedKeys = visibleKeys.filter(function(sk) { return sk.status === 'unchecked'; });
|
||||
if (uncheckedKeys.length > 0) {
|
||||
var toolbar = document.createElement('div');
|
||||
toolbar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px var(--box-padding);border-bottom:1px solid var(--border)';
|
||||
|
||||
var checkUncheckedBtn = document.createElement('button');
|
||||
checkUncheckedBtn.className = 'section-add-btn';
|
||||
checkUncheckedBtn.innerHTML = '<i class="fa fa-search"></i> ' + uncheckedKeys.length + ' Ungeprüfte prüfen';
|
||||
|
||||
var progressSpan = document.createElement('span');
|
||||
progressSpan.className = 'admin-user-meta';
|
||||
progressSpan.style.marginLeft = '8px';
|
||||
|
||||
toolbar.appendChild(checkUncheckedBtn);
|
||||
toolbar.appendChild(progressSpan);
|
||||
scannedList.appendChild(toolbar);
|
||||
|
||||
checkUncheckedBtn.addEventListener('click', async function() {
|
||||
checkUncheckedAbort = false;
|
||||
checkUncheckedBtn.disabled = true;
|
||||
|
||||
var stopBtn = document.createElement('button');
|
||||
stopBtn.className = 'icon-btn';
|
||||
stopBtn.title = 'Abbrechen';
|
||||
stopBtn.innerHTML = '<i class="fa fa-stop" style="color:var(--danger)"></i>';
|
||||
stopBtn.addEventListener('click', function() { checkUncheckedAbort = true; });
|
||||
toolbar.appendChild(stopBtn);
|
||||
|
||||
var checked = 0;
|
||||
var found = 0;
|
||||
for (var i = 0; i < uncheckedKeys.length; i++) {
|
||||
if (checkUncheckedAbort) {
|
||||
progressSpan.textContent = 'Abgebrochen bei ' + checked + '/' + uncheckedKeys.length;
|
||||
break;
|
||||
}
|
||||
var sk = uncheckedKeys[i];
|
||||
progressSpan.textContent = (i + 1) + '/' + uncheckedKeys.length + ' prüfe…';
|
||||
checkUncheckedBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ' + (i + 1) + '/' + uncheckedKeys.length;
|
||||
try {
|
||||
var result = await apiRecheckScannedKey(sk.scannedKeyId);
|
||||
checked++;
|
||||
if (result && result.status === 'valid') {
|
||||
found++;
|
||||
progressSpan.textContent = '✅ Gültigen Key gefunden! Stoppe.';
|
||||
showToast('Gültigen Key gefunden!', 'success');
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
if (!checkUncheckedAbort && found === 0) {
|
||||
progressSpan.textContent = checked + ' geprüft, keinen gültigen gefunden.';
|
||||
}
|
||||
checkUncheckedBtn.disabled = false;
|
||||
checkUncheckedBtn.innerHTML = '<i class="fa fa-search"></i> Ungeprüfte prüfen';
|
||||
if (stopBtn.parentNode) stopBtn.parentNode.removeChild(stopBtn);
|
||||
await loadScannedKeysByType(selectedType);
|
||||
});
|
||||
}
|
||||
if (visibleKeys.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'admin-user-meta';
|
||||
|
||||
@@ -168,6 +168,8 @@ async function _showKeySwitchModal() {
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'key-switch-meta';
|
||||
var parts = [];
|
||||
var providerLabels = { anthropic: '🟤 Anthropic', openai: '🟢 OpenAI', google: '🔵 Google' };
|
||||
parts.push(providerLabels[key.provider] || key.provider || 'Anthropic');
|
||||
if (key.plan) parts.push(key.plan);
|
||||
if (key.status) parts.push(key.status === 'valid' ? '✅' : '⚠️');
|
||||
meta.textContent = parts.join(' · ');
|
||||
|
||||
Reference in new issue
Block a user