From 7305a5a50f5064290c078733342eef63fe07de9f Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 15 Aug 2026 11:55:22 +0200 Subject: [PATCH] OpenAI: Server-Key-Setting, Filter-Fix fuer o-Reihe, ehrlicher Modell-Refresh Der Modell-Refresh war fuer OpenAI faktisch ein No-Op: der Key wurde nur aus der api_keys-Tabelle geholt, und ohne Key blieb die alte Liste im Speicher stehen und wurde direkt wieder in models_cache.json geschrieben - waehrend der Endpoint pauschal Erfolg meldete. - ServerSettingsService: neues Setting OpenAiApiKey (OpenAI war der einzige Provider ohne Server-Key, analog Gemini/MiniMax/z.ai) - OpenAiService: ResolveOpenAiKey() (Setting -> api_keys), HasServerOpenAiKey(), ResolveProviderConfig zieht den Setting-Key auch im Chat - OpenAiService: IsOpenAiChatModel() erfasst die o-Reihe inkl. bare IDs (o1, o3, kuenftig o5) - StartsWith("o3-") hat die verschluckt - RefreshModelsFromApis() liefert ProviderRefreshResult[] pro Provider; ModelsEndpoint gibt providers[] zurueck, agent-page.js meldet uebersprungene Provider als Fehler-Toast statt pauschalem Erfolg - ChatEndpoint/TelegramService/CronjobService: OpenAI ohne Agent-Key faellt auf den Server-Key zurueck statt hart abzubrechen - ARCHITECTURE.md nachgezogen Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 12 +++- Endpoints/ChatEndpoint.cs | 8 ++- Endpoints/ModelsEndpoint.cs | 17 ++++- Services/CronjobService.cs | 9 +-- Services/OpenAiService.cs | 103 +++++++++++++++++++++++++----- Services/ServerSettingsService.cs | 1 + Services/TelegramService.cs | 6 +- wwwroot/js/pages/agent-page.js | 11 +++- 8 files changed, 136 insertions(+), 31 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b6b65f5..18bd7d1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -624,6 +624,8 @@ Neben Anthropic Claude unterstützt Gott drei **OpenAI-kompatible Provider**. De | `nvidia:` | Nvidia (g4f.space Proxy) | `g4f.space/api/nvidia/v1` | keiner | | `pollinations:` | Pollinations | `text.pollinations.ai/openai` | keiner | | `gemini:` | Google Gemini | `generativelanguage.googleapis.com/v1beta/openai` | `GeminiApiKey` | +| `openai:` | OpenAI | `api.openai.com/v1` | `agent_api_keys`, sonst Server-Setting `OpenAiApiKey` | +| `minimax:` | MiniMax | `api.minimax.io/v1` | `MinimaxApiKey` | ### Model-String-Format @@ -2042,10 +2044,16 @@ Wenn ein **Sub-Agent** (Preparer, Review, Finalizer) keinem API-Key zugewiesen i Dasselbe Muster gilt für **Cronjobs** (`CronjobService.ExecuteCronjob` + `SendCronjobNotification`) und **Telegram** (`TelegramService.HandleMessage`) – exakt die **gleiche Provider-/Key-Dreiteilung** wie im `ChatEndpoint`: - **Anthropic ohne zugewiesenen Key** → `ChatNonStreamingViaCli()` (CLI-Fallback), sofern der User `CanUseCli`/`IsAdmin` hat und die CLI verfügbar ist -- **`openai` ohne Key** → harter Fehler (kein Server-Key, kein CLI-Äquivalent) +- **`openai` ohne Agent-Key** → Server-Setting `OpenAiApiKey` (in `ResolveProviderConfig` aufgelöst, analog Gemini/MiniMax); fehlt auch das → harter Fehler (kein CLI-Äquivalent) - **keyless Provider (nvidia/gemini/minimax/pollinations)** → Request wird ohne User-Key durchgelassen; der **Server-seitige** Key wird in `ResolveProviderConfig` aufgelöst -Der frühere harte Abbruch („Kein API-Key zugewiesen") blockte irrtümlich **jeden** nicht-leeren Provider – jetzt blockiert nur noch `openai` ohne Key. +Der frühere harte Abbruch („Kein API-Key zugewiesen") blockte irrtümlich **jeden** nicht-leeren Provider – jetzt blockiert nur noch `openai` ganz ohne Key (weder Agent-Key noch `OpenAiApiKey`). Die Prüfung erfolgt im `CronjobService` über `OpenAiService.HasServerOpenAiKey()`, in `ChatEndpoint`/`TelegramService` direkt über `settings.Get("OpenAiApiKey")`. + +### Modell-Refresh (`POST /api/models/refresh`) + +`RefreshModelsFromApis()` gibt pro Provider ein `ProviderRefreshResult` (`provider`, `loaded`, `count`, `note`) zurück; der Endpoint liefert das als `providers[]` mit. Wird ein Provider **übersprungen** (kein Key, API leer), bleibt seine **alte Liste im Cache stehen** – das wird jetzt explizit gemeldet (Log + Toast im Agent-Formular), statt pauschal „Modelle neu geladen" zu behaupten. + +Der OpenAI-Key für die Modell-Liste kommt aus `OpenAiApiKey` (Setting), sonst aus dem ersten aktiven `openai`-Eintrag der `api_keys`-Tabelle (`ResolveOpenAiKey()`). Der Chat-Modell-Filter (`IsOpenAiChatModel`) erfasst `gpt-*`, `chatgpt-*` und die komplette o-Reihe **inklusive bare IDs** (`o1`, `o3`, künftig `o5`) – der frühere `StartsWith("o3-")`-Filter hat die verschluckt. ### Python-Detection für MCP-Config diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 32d316c..3fb65f3 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -263,10 +263,12 @@ public static class ChatEndpoint // OpenAI-kompatibler Provider – Key des Users für diesen Agent verwenden string openAiKeyOverride = userApiKey?.ApiKey; - // OpenAI-Provider braucht zwingend einen Key - if (ctx.Provider == "openai" && string.IsNullOrEmpty(openAiKeyOverride)) + // OpenAI-Provider braucht zwingend einen Key: Agent-Key, sonst Server-Setting + // 'OpenAiApiKey' (analog Gemini/MiniMax – wird in ResolveProviderConfig gezogen). + if (ctx.Provider == "openai" && string.IsNullOrEmpty(openAiKeyOverride) + && string.IsNullOrEmpty(settings.Get("OpenAiApiKey"))) { - await SseHelper.TryWriteSse(res, "error", new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte einen OpenAI-Key zuweisen." }, sseCt); + await SseHelper.TryWriteSse(res, "error", new { error = "Kein OpenAI-Key vorhanden: weder dem Agent zugewiesen noch als Server-Setting 'OpenAiApiKey' gesetzt." }, sseCt); await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt); return; } diff --git a/Endpoints/ModelsEndpoint.cs b/Endpoints/ModelsEndpoint.cs index 2631642..ce6f0e5 100644 --- a/Endpoints/ModelsEndpoint.cs +++ b/Endpoints/ModelsEndpoint.cs @@ -70,7 +70,20 @@ public static class ModelsEndpoint private static async Task HandleRefresh(OpenAiService openAiService) { - await openAiService.RefreshModelsFromApis(); - return Results.Json(new { success = true, message = "Modelle neu geladen und gecacht." }); + var report = await openAiService.RefreshModelsFromApis(); + var loaded = report.Where(r => r.Loaded).ToList(); + var skipped = report.Where(r => !r.Loaded).ToList(); + + var message = $"{loaded.Count} Provider neu geladen" + + (skipped.Count > 0 + ? $", {skipped.Count} übersprungen: {string.Join(", ", skipped.Select(r => $"{r.Provider} ({r.Note})"))}" + : "."); + + return Results.Json(new + { + success = true, + message, + providers = report.Select(r => new { provider = r.Provider, loaded = r.Loaded, count = r.Count, note = r.Note }).ToList() + }); } } diff --git a/Services/CronjobService.cs b/Services/CronjobService.cs index 398fea9..433d82e 100644 --- a/Services/CronjobService.cs +++ b/Services/CronjobService.cs @@ -111,9 +111,10 @@ public class CronjobService : BackgroundService useCliFallback = true; _logger.LogInformation("Cronjob '{Name}': Kein API-Key für Agent {AgentId}, verwende CLI-Fallback", job.Name, ctx.AgentId); } - else if (ctx.Provider == "openai") + else if (ctx.Provider == "openai" && !_openAiService.HasServerOpenAiKey()) { - throw new Exception($"Kein API-Key für Agent {ctx.AgentId} zugewiesen (User {job.UserId}). OpenAI benötigt zwingend einen zugewiesenen API-Key."); + // Ohne Agent-Key greift der Server-Key aus dem Setting 'OpenAiApiKey' (analog Gemini/MiniMax). + throw new Exception($"Kein OpenAI-Key für Agent {ctx.AgentId} (User {job.UserId}): weder zugewiesen noch als Server-Setting 'OpenAiApiKey' gesetzt."); } // sonst: nvidia/gemini/minimax/pollinations → ctx.ApiToken bleibt leer, // der Server-seitige Provider-Key wird in ResolveProviderConfig aufgelöst. @@ -233,9 +234,9 @@ public class CronjobService : BackgroundService notifyUseCliFallback = true; } } - else if (notifyCtx.Provider == "openai") + else if (notifyCtx.Provider == "openai" && !_openAiService.HasServerOpenAiKey()) { - _logger.LogWarning("Notify-Agent-Reaktion für Cronjob '{Name}' übersprungen: OpenAI benötigt zwingend einen Key", job.Name); + _logger.LogWarning("Notify-Agent-Reaktion für Cronjob '{Name}' übersprungen: kein OpenAI-Key (weder zugewiesen noch Server-Setting 'OpenAiApiKey')", job.Name); canRunNotifyAgent = false; } // sonst: nvidia/gemini/minimax/pollinations → notifyCtx.ApiToken bleibt leer (Server-Key-Auflösung). diff --git a/Services/OpenAiService.cs b/Services/OpenAiService.cs index b7c17a0..45c4869 100644 --- a/Services/OpenAiService.cs +++ b/Services/OpenAiService.cs @@ -149,37 +149,99 @@ public class OpenAiService } /// - /// Lädt Modelle von allen Provider-APIs und speichert sie im Cache. + /// Ergebnis eines Modell-Refreshs pro Provider – damit der Aufrufer (und die UI) + /// sieht, was tatsächlich geladen und was übersprungen wurde. /// - public async Task RefreshModelsFromApis() + public class ProviderRefreshResult { + public string Provider { get; set; } + public bool Loaded { get; set; } // true = frisch von der API geladen + public int Count { get; set; } // Anzahl Modelle nach dem Refresh + public string Note { get; set; } // Grund bei Skip/Fehler, sonst "" + } + + /// + /// Löst den OpenAI-Key auf: bevorzugt das Server-Setting (OpenAiApiKey, analog + /// Gemini/MiniMax), sonst der erste aktive 'openai'-Key aus der api_keys-Tabelle. + /// + public bool HasServerOpenAiKey() => !string.IsNullOrEmpty(_settings.Get("OpenAiApiKey")); + + public async Task ResolveOpenAiKey() + { + var fromSettings = _settings.Get("OpenAiApiKey"); + if (!string.IsNullOrEmpty(fromSettings)) return fromSettings; + return await _db.GetFirstActiveKeyByProvider("openai"); + } + + /// + /// Chat-Modelle von OpenAI filtern: gpt-*, chatgpt-* und die o-Reihe. + /// Wichtig: auch bare IDs ohne Bindestrich (o1, o3, künftig o5) – der alte + /// Filter (StartsWith("o3-")) hat die verschluckt. + /// + private static bool IsOpenAiChatModel(string id) + { + if (string.IsNullOrEmpty(id)) return false; + if (id.StartsWith("gpt-", StringComparison.OrdinalIgnoreCase)) return true; + if (id.StartsWith("chatgpt-", StringComparison.OrdinalIgnoreCase)) return true; + // o-Reihe: 'o' + Ziffer, danach Ende oder '-'/'.' (o1, o3, o3-mini, o4-mini, o5.1) + if (id.Length >= 2 && (id[0] == 'o' || id[0] == 'O') && char.IsDigit(id[1])) + return true; + return false; + } + + /// + /// Lädt Modelle von allen Provider-APIs und speichert sie im Cache. + /// Gibt pro Provider zurück, ob geladen oder übersprungen wurde. + /// + public async Task> RefreshModelsFromApis() + { + var report = new List(); + void Report(string provider, bool loaded, int count, string note = "") + { + report.Add(new ProviderRefreshResult { Provider = provider, Loaded = loaded, Count = count, Note = note }); + Console.WriteLine($"[OpenAiService] {provider}: {(loaded ? "geladen" : "übersprungen")} – {count} Modelle{(string.IsNullOrEmpty(note) ? "" : $" ({note})")}"); + } + var nvidiaApiKey = _settings.Get("NvidiaApiKey"); if (!string.IsNullOrEmpty(nvidiaApiKey)) _nvidiaModels = await FetchModels("https://integrate.api.nvidia.com/v1/models", "Nvidia", nvidiaApiKey); else _nvidiaModels = await FetchModels("https://g4f.space/api/nvidia/models", "Nvidia"); + Report("nvidia", _nvidiaModels.Count > 0, _nvidiaModels.Count, + _nvidiaModels.Count == 0 ? "API lieferte keine Modelle" : ""); + _pollinationsModels = await FetchModels("https://text.pollinations.ai/models", "Pollinations"); + Report("pollinations", _pollinationsModels.Count > 0, _pollinationsModels.Count, + _pollinationsModels.Count == 0 ? "API lieferte keine Modelle" : ""); var geminiApiKey = _settings.Get("GeminiApiKey"); if (!string.IsNullOrEmpty(geminiApiKey)) + { _geminiModels = await FetchModels("https://generativelanguage.googleapis.com/v1beta/openai/models", "Gemini", geminiApiKey); + Report("gemini", _geminiModels.Count > 0, _geminiModels.Count, + _geminiModels.Count == 0 ? "Key gesetzt, aber API lieferte nichts" : ""); + } else - Console.WriteLine("[OpenAiService] GeminiApiKey nicht konfiguriert – Gemini-Modelle nicht geladen."); + Report("gemini", false, _geminiModels.Count, "GeminiApiKey nicht konfiguriert – alte Liste bleibt"); - // OpenAI: Key aus DB holen (erster aktiver OpenAI-Key) - var openaiKey = await _db.GetFirstActiveKeyByProvider("openai"); + // OpenAI: Key aus Settings (OpenAiApiKey), sonst aus der api_keys-Tabelle. + var openaiKey = await ResolveOpenAiKey(); 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)."); + if (allOpenAi.Count > 0) + { + _openaiModels = allOpenAi + .Where(m => IsOpenAiChatModel(m.Id)) + .OrderBy(m => m.Id) + .ToList(); + Report("openai", true, _openaiModels.Count, $"gefiltert aus {allOpenAi.Count} total"); + } + else + Report("openai", false, _openaiModels.Count, "Key gesetzt, aber /v1/models lieferte nichts (Key ungültig?) – alte Liste bleibt"); } else - Console.WriteLine("[OpenAiService] Kein aktiver OpenAI API-Key in DB – OpenAI-Modelle nicht geladen."); + Report("openai", false, _openaiModels.Count, "kein OpenAI-Key (Setting 'OpenAiApiKey' oder api_keys-Tabelle) – alte Liste bleibt"); // MiniMax: Key aus Settings (MinimaxApiKey). Versuche /v1/models, sonst statische Fallback-Liste. var minimaxKey = _settings.Get("MinimaxApiKey"); @@ -187,22 +249,26 @@ public class OpenAiService { var fetched = await FetchModels("https://api.minimax.io/v1/models", "MiniMax", minimaxKey); _minimaxModels = fetched.Count > 0 ? fetched : MinimaxFallbackModels; - if (fetched.Count == 0) - Console.WriteLine($"[OpenAiService] MiniMax /v1/models leer/nicht erreichbar – nutze Fallback-Liste ({_minimaxModels.Count} Modelle)."); + Report("minimax", fetched.Count > 0, _minimaxModels.Count, + fetched.Count == 0 ? "/v1/models leer – Fallback-Liste" : ""); } else { // Ohne Key: statische Fallback-Liste anbieten, damit das Dropdown nicht leer ist. _minimaxModels = MinimaxFallbackModels; - Console.WriteLine($"[OpenAiService] MinimaxApiKey nicht konfiguriert – nutze Fallback-Liste ({_minimaxModels.Count} Modelle)."); + Report("minimax", false, _minimaxModels.Count, "MinimaxApiKey nicht konfiguriert – Fallback-Liste"); } // z.ai / GLM: Key aus Settings (ZaiApiKey). OpenAI-kompatibler /models-Endpoint von z.ai. var zaiKey = _settings.Get("ZaiApiKey"); if (!string.IsNullOrEmpty(zaiKey)) + { _glmModels = await FetchModels("https://api.z.ai/api/paas/v4/models", "GLM (z.ai)", zaiKey); + Report("glm", _glmModels.Count > 0, _glmModels.Count, + _glmModels.Count == 0 ? "Key gesetzt, aber API lieferte nichts" : ""); + } else - Console.WriteLine("[OpenAiService] ZaiApiKey nicht konfiguriert – GLM-Modelle nicht geladen."); + Report("glm", false, _glmModels.Count, "ZaiApiKey nicht konfiguriert – alte Liste bleibt"); // Cache speichern try @@ -224,6 +290,8 @@ public class OpenAiService { Console.WriteLine($"[OpenAiService] Fehler beim Speichern des Modelle-Cache: {ex.Message}"); } + + return report; } private async Task> FetchModels(string url, string providerName, string apiKey = "") @@ -1530,6 +1598,9 @@ public class OpenAiService if (provider == "minimax" && string.IsNullOrEmpty(providerConfig.ApiKey)) providerConfig = (providerConfig.BaseUrl, _settings.Get("MinimaxApiKey")); + if (provider == "openai" && string.IsNullOrEmpty(providerConfig.ApiKey)) + providerConfig = (providerConfig.BaseUrl, _settings.Get("OpenAiApiKey")); + return (provider, modelId, providerConfig); } diff --git a/Services/ServerSettingsService.cs b/Services/ServerSettingsService.cs index 17bfd76..e95344c 100644 --- a/Services/ServerSettingsService.cs +++ b/Services/ServerSettingsService.cs @@ -38,6 +38,7 @@ public class ServerSettingsService new SettingDefinition( "HuggingFaceToken", "string", "", "HuggingFace Token (optional, erhöht Rate-Limits)", "password" ), new SettingDefinition( "NvidiaApiKey", "string", "", "Nvidia API-Key (direkt statt g4f.space Proxy)", "password" ), new SettingDefinition( "MinimaxApiKey", "string", "", "MiniMax API-Key (für minimax-Provider im Chat)", "password" ), + new SettingDefinition( "OpenAiApiKey", "string", "", "OpenAI API-Key (für openai-Provider im Chat + Modell-Liste)", "password" ), new SettingDefinition( "ZaiApiKey", "string", "", "z.ai API-Key (für GLM-Modelle über die Claude CLI)", "password" ), new SettingDefinition( "ScannerKeyTypes", "string_array", "anthropic_oauth,krea_ai", "Key-Scanner: Aktive Key-Typen", "checkboxes" ), }; diff --git a/Services/TelegramService.cs b/Services/TelegramService.cs index ab6237d..41fae9a 100644 --- a/Services/TelegramService.cs +++ b/Services/TelegramService.cs @@ -201,9 +201,11 @@ public class TelegramService useCliFallback = true; } - else if (ctx.Provider == "openai") + else if (ctx.Provider == "openai" && string.IsNullOrEmpty(_settings.Get("OpenAiApiKey"))) { - await SendMessage(chatId, botToken, "❌ Kein API-Key für diesen Agent zugewiesen. OpenAI benötigt zwingend einen Key – der Bot-Besitzer muss im Web-UI einen Key zuweisen."); + // Ohne Agent-Key greift der Server-Key aus dem Setting 'OpenAiApiKey' + // (analog Gemini/MiniMax). Fehlt auch der, ist OpenAI nicht nutzbar. + await SendMessage(chatId, botToken, "❌ Kein OpenAI-Key vorhanden: weder dem Agent zugewiesen noch als Server-Setting 'OpenAiApiKey' gesetzt."); return; } // sonst: nvidia/gemini/minimax/pollinations → ctx.ApiToken bleibt leer (Server-Key-Auflösung). diff --git a/wwwroot/js/pages/agent-page.js b/wwwroot/js/pages/agent-page.js index f34f2f1..04f5694 100644 --- a/wwwroot/js/pages/agent-page.js +++ b/wwwroot/js/pages/agent-page.js @@ -66,10 +66,17 @@ registerPage('agent', { refreshModelsBtn.disabled = true; const prevValue = modelEl.value; try { - await apiRefreshModels(); + const res = await apiRefreshModels(); await loadAllModels(); modelEl.value = prevValue; // vorherige Auswahl wiederherstellen (falls noch vorhanden) - showToast('Modell-Listen neu geladen'); + // Ehrlich melden, was tatsächlich geladen wurde – ein übersprungener Provider + // (z.B. OpenAI ohne Key) darf nicht als Erfolg durchgehen. + const skipped = (res && res.providers || []).filter(p => !p.loaded); + if (skipped.length) { + showToast('Neu geladen, übersprungen: ' + skipped.map(p => p.provider + ' – ' + p.note).join('; '), 'error'); + } else { + showToast('Modell-Listen neu geladen'); + } } catch (err) { showToast(err.message || 'Fehler beim Neu-Laden der Modelle', 'error'); } finally {