/* * 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'); }