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 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 5 committed 2026-08-15 11:55:22 +02:00
1 parent f38f3a42d1
commit 7305a5a50f
8 files changed
+136 -31

No files matched your search

+10 -2
View File
@@ -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
+5 -3
View File
@@ -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;
}
+15 -2
View File
@@ -70,7 +70,20 @@ public static class ModelsEndpoint
private static async Task<IResult> 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()
});
}
}
+5 -4
View File
@@ -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).
+87 -16
View File
@@ -149,37 +149,99 @@ public class OpenAiService
}
/// <summary>
/// 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.
/// </summary>
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 ""
}
/// <summary>
/// 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.
/// </summary>
public bool HasServerOpenAiKey() => !string.IsNullOrEmpty(_settings.Get("OpenAiApiKey"));
public async Task<string> ResolveOpenAiKey()
{
var fromSettings = _settings.Get("OpenAiApiKey");
if (!string.IsNullOrEmpty(fromSettings)) return fromSettings;
return await _db.GetFirstActiveKeyByProvider("openai");
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Lädt Modelle von allen Provider-APIs und speichert sie im Cache.
/// Gibt pro Provider zurück, ob geladen oder übersprungen wurde.
/// </summary>
public async Task<List<ProviderRefreshResult>> RefreshModelsFromApis()
{
var report = new List<ProviderRefreshResult>();
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<List<ProviderModel>> 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);
}
+1
View File
@@ -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" ),
};
+4 -2
View File
@@ -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).
+9 -2
View File
@@ -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 {