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:
schakal00783
2026-09-23 10:26:30 +02:00
co-authored by Claude Opus 5.5
parent 3f25355a6d
commit cbac02a96a
40 changed files with 1476 additions and 80 deletions
+43
View File
@@ -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);