KI: Vorgabemodell auf gpt-6-luna umgestellt

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>
This commit is contained in:
schakal00783
2026-09-29 10:10:41 +02:00
co-authored by Claude Opus 5.5
parent 4adf48f336
commit 6fe5b18c11
6 changed files with 524 additions and 524 deletions
+238 -238
View File
@@ -1,238 +1,238 @@
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-5.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: gpt-5.6-luna laesst nur den
* Standardwert zu und weist 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 };
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 };