gpt-6-luna kostet rund 50 % weniger als gpt-5.6-luna. Das Modell in icon_settings (Einstellungen -> KI) hat weiter Vorrang vor der Vorgabe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
239 lines
7.7 KiB
JavaScript
239 lines
7.7 KiB
JavaScript
import { getSetting, setSetting } from './db.js';
|
|
|
|
/*
|
|
* Anbindung an OpenAI.
|
|
*
|
|
* Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und
|
|
* `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss.
|
|
*
|
|
* Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage
|
|
* laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt
|
|
* ("sk-proj-...a1b2").
|
|
*
|
|
* Er kann an zwei Stellen stehen:
|
|
*
|
|
* 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI
|
|
* 2. in server/.env als OPENAI_API_KEY
|
|
*
|
|
* Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt,
|
|
* und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen
|
|
* Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das
|
|
* System ohne Oberflaeche in Gang zu bringen.
|
|
*/
|
|
|
|
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
|
|
|
|
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
|
|
const TIMEOUT_MS = 120_000;
|
|
|
|
export const DEFAULT_MODEL = 'gpt-6-luna';
|
|
|
|
const KEY_SETTING = 'openai_api_key';
|
|
const MODEL_SETTING = 'openai_model';
|
|
|
|
/*
|
|
* Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt
|
|
* beim Start (loadAiConfig) und bei jeder Aenderung.
|
|
*/
|
|
const config = {
|
|
key: process.env.OPENAI_API_KEY || '',
|
|
model: process.env.OPENAI_MODEL || DEFAULT_MODEL,
|
|
/** Woher der gueltige Schluessel stammt - fuer die Anzeige. */
|
|
source: process.env.OPENAI_API_KEY ? 'env' : 'none',
|
|
};
|
|
|
|
/** Beim Start aufrufen, sobald die Datenbank steht. */
|
|
export async function loadAiConfig() {
|
|
try {
|
|
const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]);
|
|
if (key) {
|
|
config.key = key;
|
|
config.source = 'db';
|
|
}
|
|
if (model) config.model = model;
|
|
} catch (err) {
|
|
// Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten.
|
|
console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message);
|
|
}
|
|
}
|
|
|
|
export function aiConfigured() {
|
|
return Boolean(config.key);
|
|
}
|
|
|
|
export function aiModel() {
|
|
return config.model;
|
|
}
|
|
|
|
/**
|
|
* Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt -
|
|
* genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen.
|
|
*/
|
|
export function aiStatus() {
|
|
return {
|
|
configured: aiConfigured(),
|
|
model: config.model,
|
|
defaultModel: DEFAULT_MODEL,
|
|
source: config.source,
|
|
masked: maskKey(config.key),
|
|
/** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */
|
|
envAvailable: Boolean(process.env.OPENAI_API_KEY),
|
|
};
|
|
}
|
|
|
|
function maskKey(key) {
|
|
if (!key) return null;
|
|
return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`;
|
|
}
|
|
|
|
/**
|
|
* Schluessel setzen oder entfernen.
|
|
*
|
|
* Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env
|
|
* einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall.
|
|
*/
|
|
export async function saveAiKey(key) {
|
|
const value = String(key ?? '').trim();
|
|
await setSetting(KEY_SETTING, value || null);
|
|
if (value) {
|
|
config.key = value;
|
|
config.source = 'db';
|
|
} else {
|
|
config.key = process.env.OPENAI_API_KEY || '';
|
|
config.source = config.key ? 'env' : 'none';
|
|
}
|
|
}
|
|
|
|
/** Modell setzen; leer heisst zurueck zur Vorgabe. */
|
|
export async function saveAiModel(model) {
|
|
const value = String(model ?? '').trim();
|
|
await setSetting(MODEL_SETTING, value || null);
|
|
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);
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ein Aufruf an das Modell.
|
|
*
|
|
* `temperature` wird bewusst NICHT mitgeschickt: die Luna-Modelle lassen nur den
|
|
* Standardwert zu und weisen jeden anderen mit 400 ab.
|
|
*
|
|
* @param {object} options
|
|
* @param {string} options.system Rollenanweisung
|
|
* @param {string} options.user Eigentliche Anfrage
|
|
* @param {boolean} [options.json] true erzwingt eine JSON-Antwort
|
|
* @param {number} [options.maxTokens]
|
|
* @returns {Promise<string>} der Antworttext
|
|
*/
|
|
export async function askModel({ system, user, json = false, maxTokens }) {
|
|
if (!aiConfigured()) {
|
|
throw new AiError(
|
|
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).',
|
|
503
|
|
);
|
|
}
|
|
|
|
const body = {
|
|
model: config.model,
|
|
messages: [
|
|
...(system ? [{ role: 'system', content: system }] : []),
|
|
{ role: 'user', content: user },
|
|
],
|
|
};
|
|
if (json) body.response_format = { type: 'json_object' };
|
|
if (maxTokens) body.max_completion_tokens = maxTokens;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${config.key}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal,
|
|
});
|
|
} catch (err) {
|
|
if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504);
|
|
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
// Die Meldung von OpenAI ist meist praezise ("model not found", "quota") -
|
|
// sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel.
|
|
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
|
}
|
|
|
|
const text = payload?.choices?.[0]?.message?.content;
|
|
if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502);
|
|
return text;
|
|
}
|
|
|
|
/** Wie askModel, gibt aber das geparste JSON zurueck. */
|
|
export async function askModelJson(options) {
|
|
const text = await askModel({ ...options, json: true });
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
|
|
}
|
|
}
|
|
|
|
export { AiError };
|