feat(ki): Modell aus Liste der verfuegbaren OpenAI-Modelle waehlen

Bisher musste der Administrator den Modellnamen abtippen - ein Tippfehler
fiel erst beim Test oder bei der ersten Frage auf, und welche Modelle der
Schluessel ueberhaupt darf, war in Pulse nicht zu sehen.

Einstellungen -> KI-Assistent zeigt jetzt ein Auswahlfeld. Die Liste kommt
ueber POST /settings/ai/models live von OpenAI (/v1/models), neueste
zuerst; Embedding-, Bild-, Audio-, Realtime-, Such- und
Moderationsmodelle fliegen raus. "Liste laden" nimmt einen gerade
eingetippten, noch nicht gespeicherten Schluessel mit. Das eingestellte
Modell bleibt waehlbar, auch wenn OpenAI es nicht mehr listet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-23 10:25:21 +02:00
co-authored by Claude Opus 5.5
parent 8a8e433397
commit 2f9c58f202
5 changed files with 132 additions and 4 deletions
+37
View File
@@ -15,6 +15,7 @@
*/
const ENDPOINT = 'https://api.openai.com/v1/responses';
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
const TIMEOUT_MS = 120_000;
export const AI_DEFAULT_MODEL = 'gpt-5.6-luna';
@@ -73,6 +74,42 @@ export async function askModel(ai, { instructions, input, tools, maxOutputTokens
return data;
}
/*
* Modelle, die fuer den Assistenten in Frage kommen. /v1/models liefert alles,
* was der Schluessel darf - auch Embeddings, Bild-, Audio- und Moderationsmodelle.
* Die koennen keine Textantwort mit Werkzeugen und fliegen raus.
*/
const CHAT_MODEL = /^(gpt-|o\d|chatgpt-)/;
const NOT_CHAT = /(embedding|tts|whisper|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|codex)/;
/** Liste der Modelle fuer die Auswahl in den Einstellungen, neueste zuerst. */
export async function listModels(apiKey) {
if (!apiKey) throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt.', 400);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
let response;
try {
response = await fetch(MODELS_ENDPOINT, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});
} catch (err) {
if (err.name === 'AbortError') throw new AiError('OpenAI hat nicht rechtzeitig geantwortet.', 504);
throw new AiError(`OpenAI ist nicht erreichbar: ${err.message}`);
} finally {
clearTimeout(timer);
}
const data = await response.json().catch(() => ({}));
if (response.status === 401) throw new AiError('OpenAI hat den Schlüssel abgelehnt.', 502);
if (!response.ok) throw new AiError(`OpenAI: ${data?.error?.message || `HTTP ${response.status}`}`);
return (data.data || [])
.filter((m) => CHAT_MODEL.test(m.id) && !NOT_CHAT.test(m.id))
.sort((a, b) => (b.created || 0) - (a.created || 0) || a.id.localeCompare(b.id))
.map((m) => ({ id: m.id, created: m.created ? new Date(m.created * 1000).toISOString().slice(0, 10) : null }));
}
/** Text aller Nachrichten einer Antwort. */
export function answerText(response) {
return (response.output || [])