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 || [])
+16 -1
View File
@@ -2,7 +2,7 @@ import express from 'express';
import { requireRole, ROLES } from '../middleware/auth.js';
import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js';
import { ssoConfigured, testConnection } from '../lib/sso.js';
import { answerText, askModel } from '../lib/ai.js';
import { answerText, askModel, listModels } from '../lib/ai.js';
import { sendDocument } from '../export/render.js';
const router = express.Router();
@@ -178,6 +178,21 @@ router.post('/ai/test', requireRole('admin'), async (req, res) => {
}
});
/*
* Verfuegbare Modelle fuer das Auswahlfeld. POST statt GET, weil ein gerade
* eingetippter, noch nicht gespeicherter Schluessel mitkommen darf - der
* gehoert nicht in eine URL. Ohne ihn gilt der hinterlegte.
*/
router.post('/ai/models', requireRole('admin'), async (req, res) => {
try {
const typed = typeof req.body?.api_key === 'string' ? req.body.api_key.trim().slice(0, 300) : '';
const apiKey = typed || (await getSettings()).ai.api_key;
res.json({ models: await listModels(apiKey) });
} catch (err) {
res.status(err.status || 502).json({ error: err.message });
}
});
router.post('/sso/test', requireRole('admin'), async (req, res) => {
try {
const { sso } = await getSettings();