KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand
Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf. - Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu) - Neuer Endpunkt GET /api/ai/models (admin) - Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet; Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist - Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen, Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3f25355a6d
commit
cbac02a96a
@@ -111,6 +111,49 @@ export async function saveAiModel(model) {
|
||||
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Modelle, die der hinterlegte Schluessel nutzen darf.
|
||||
*
|
||||
* /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations-
|
||||
* modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen
|
||||
* ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht
|
||||
* jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher.
|
||||
*/
|
||||
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
|
||||
const MODELS_TTL_MS = 10 * 60_000;
|
||||
const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i;
|
||||
let modelCache = { key: '', at: 0, list: [] };
|
||||
|
||||
export async function listAiModels() {
|
||||
if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503);
|
||||
if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) {
|
||||
return modelCache.list;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(MODELS_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${config.key}` },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch {
|
||||
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
||||
}
|
||||
|
||||
const list = (payload?.data ?? [])
|
||||
.map((m) => ({ id: String(m.id), created: Number(m.created) || 0 }))
|
||||
.filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id))
|
||||
// Neueste zuerst - die will man meistens.
|
||||
.sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
|
||||
|
||||
modelCache = { key: config.key, at: Date.now(), list };
|
||||
return list;
|
||||
}
|
||||
|
||||
class AiError extends Error {
|
||||
constructor(message, status = 502) {
|
||||
super(message);
|
||||
|
||||
Reference in New Issue
Block a user