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>
127 lines
4.9 KiB
JavaScript
127 lines
4.9 KiB
JavaScript
/*
|
|
* Anbindung an OpenAI fuer den Assistenten im Kopf der Anwendung.
|
|
*
|
|
* Bewusst ohne SDK: gebraucht wird ein einziger Endpunkt, und `fetch` ist in
|
|
* Node eingebaut. Der Schluessel liegt verschluesselt in den Einstellungen und
|
|
* bleibt auf dem Server - der Browser spricht nie mit OpenAI.
|
|
*
|
|
* Benutzt wird /v1/responses, nicht /v1/chat/completions: die neueren Modelle
|
|
* (gpt-5.6-luna und Geschwister) nehmen Werkzeuge dort nur an, wenn man das
|
|
* Nachdenken abschaltet. Ueber die Responses-API geht beides zusammen.
|
|
*
|
|
* `store: false` - die Frage und die Daten aus Pulse bleiben nicht bei OpenAI
|
|
* liegen. Der Verlauf eines Gespraechs wird deshalb bei jeder Runde komplett
|
|
* mitgeschickt.
|
|
*/
|
|
|
|
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';
|
|
|
|
export class AiError extends Error {
|
|
constructor(message, status = 502) {
|
|
super(message);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
export function aiConfigured(ai) {
|
|
return Boolean(ai?.api_key);
|
|
}
|
|
|
|
/** Ein Aufruf. `input` ist der ganze bisherige Verlauf inklusive Werkzeugergebnisse. */
|
|
export async function askModel(ai, { instructions, input, tools, maxOutputTokens = 2000, effort = 'low' }) {
|
|
if (!aiConfigured(ai)) throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI-Assistent).', 503);
|
|
|
|
const body = {
|
|
model: ai.model || AI_DEFAULT_MODEL,
|
|
instructions,
|
|
input,
|
|
max_output_tokens: maxOutputTokens,
|
|
store: false,
|
|
};
|
|
if (tools?.length) body.tools = tools;
|
|
// Nicht jedes Modell kennt die Stufe; ohne Angabe waehlt OpenAI selbst.
|
|
if (effort) body.reasoning = { effort };
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
let response;
|
|
try {
|
|
response = await fetch(ENDPOINT, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ai.api_key}` },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal,
|
|
});
|
|
} catch (err) {
|
|
clearTimeout(timer);
|
|
if (err.name === 'AbortError') throw new AiError('Das Modell hat nicht rechtzeitig geantwortet.', 504);
|
|
throw new AiError(`OpenAI ist nicht erreichbar: ${err.message}`);
|
|
}
|
|
clearTimeout(timer);
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const message = data?.error?.message || `HTTP ${response.status}`;
|
|
if (response.status === 401) throw new AiError('OpenAI hat den Schlüssel abgelehnt.', 502);
|
|
if (response.status === 429) throw new AiError('OpenAI drosselt gerade oder das Guthaben ist aufgebraucht.', 502);
|
|
if (response.status === 404) throw new AiError(`Das Modell „${body.model}“ steht für diesen Schlüssel nicht bereit.`, 502);
|
|
throw new AiError(`OpenAI: ${message}`);
|
|
}
|
|
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 || [])
|
|
.filter((item) => item.type === 'message')
|
|
.flatMap((item) => item.content || [])
|
|
.map((part) => part.text || '')
|
|
.join('')
|
|
.trim();
|
|
}
|
|
|
|
/** Werkzeugaufrufe einer Antwort. */
|
|
export function toolCalls(response) {
|
|
return (response.output || []).filter((item) => item.type === 'function_call');
|
|
}
|