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:
co-authored by
Claude Opus 5.5
parent
4adf48f336
commit
6fe5b18c11
+2
-2
@@ -36,10 +36,10 @@ UPLOAD_DIR=/var/www/icon_CRM/uploads
|
||||
# Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
|
||||
# OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus.
|
||||
#
|
||||
# Hinweis zum Modell: gpt-5.6-luna akzeptiert KEIN eigenes "temperature";
|
||||
# Hinweis zum Modell: die Luna-Modelle akzeptieren KEIN eigenes "temperature";
|
||||
# ai.js schickt deshalb nur den Standardwert.
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-5.6-luna
|
||||
OPENAI_MODEL=gpt-6-luna
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lesender Zugriff auf die Kontakte des perinco CRM
|
||||
|
||||
+238
-238
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user