Files
CapaKraken/packages/api/src/router/settings-support.ts
T

299 lines
12 KiB
TypeScript

import { DEFAULT_OPENAI_MODEL, VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
import { z } from "zod";
import {
isAiConfigured,
parseAiError,
sanitizeDiagnosticError,
} from "../ai-client.js";
import {
RUNTIME_SECRET_FIELDS,
} from "../lib/system-settings-runtime.js";
import type { RuntimeSecretField, RuntimeSecretStatus } from "../lib/system-settings-runtime.js";
import { logger } from "../lib/logger.js";
/** Fields that must never appear in audit log values */
const SENSITIVE_FIELDS = new Set([
"azureOpenAiApiKey",
"smtpPassword",
"azureDalleApiKey",
"anonymizationSeed",
"geminiApiKey",
]);
export const settingsUpdateInputSchema = z.object({
aiProvider: z.enum(["openai", "azure"]).optional(),
azureOpenAiEndpoint: z.string().url().optional().or(z.literal("")),
azureOpenAiDeployment: z.string().optional(),
azureOpenAiApiKey: z.string().optional(),
azureApiVersion: z.string().optional(),
aiMaxCompletionTokens: z.number().int().min(50).max(4000).optional(),
aiTemperature: z.number().min(0).max(2).optional(),
aiSummaryPrompt: z.string().optional(),
scoreWeights: z.object({
skillDepth: z.number().min(0).max(1),
skillBreadth: z.number().min(0).max(1),
costEfficiency: z.number().min(0).max(1),
chargeability: z.number().min(0).max(1),
experience: z.number().min(0).max(1),
}).refine(
(weights) => {
const sum = weights.skillDepth
+ weights.skillBreadth
+ weights.costEfficiency
+ weights.chargeability
+ weights.experience;
return Math.abs(sum - 1.0) < 0.01;
},
{ message: "Score weights must sum to 1.0" },
).optional(),
scoreVisibleRoles: z.array(z.enum(["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"])).optional(),
smtpHost: z.string().optional(),
smtpPort: z.number().int().min(1).max(65535).optional(),
smtpUser: z.string().optional(),
smtpPassword: z.string().optional(),
smtpFrom: z.string().email().optional().or(z.literal("")),
smtpTls: z.boolean().optional(),
anonymizationEnabled: z.boolean().optional(),
anonymizationDomain: z.string().trim().min(1).optional(),
anonymizationSeed: z.string().trim().min(1).optional().or(z.literal("")),
anonymizationMode: z.enum(["global"]).optional(),
azureDalleDeployment: z.string().optional(),
azureDalleEndpoint: z.string().url().optional().or(z.literal("")),
azureDalleApiKey: z.string().optional(),
geminiApiKey: z.string().optional(),
geminiModel: z.string().optional(),
imageProvider: z.enum(["dalle", "gemini"]).optional(),
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),
});
export type SettingsUpdateInput = z.infer<typeof settingsUpdateInputSchema>;
export function getDefaultScoreWeights() {
return {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
};
}
export function buildSystemSettingsViewModel(input: {
settings: {
aiProvider?: string | null;
azureOpenAiEndpoint?: string | null;
azureOpenAiDeployment?: string | null;
azureApiVersion?: string | null;
aiMaxCompletionTokens?: number | null;
aiTemperature?: number | null;
aiSummaryPrompt?: string | null;
scoreWeights?: unknown;
scoreVisibleRoles?: unknown;
smtpHost?: string | null;
smtpPort?: number | null;
smtpUser?: string | null;
smtpFrom?: string | null;
smtpTls?: boolean | null;
anonymizationEnabled?: boolean | null;
anonymizationDomain?: string | null;
anonymizationMode?: string | null;
azureDalleDeployment?: string | null;
azureDalleEndpoint?: string | null;
geminiModel?: string | null;
imageProvider?: string | null;
vacationDefaultDays?: number | null;
timelineUndoMaxSteps?: number | null;
} | null | undefined;
runtimeSettings: {
azureOpenAiApiKey?: string | null;
smtpPassword?: string | null;
azureDalleApiKey?: string | null;
geminiApiKey?: string | null;
} | null | undefined;
runtimeSecrets: Record<RuntimeSecretField, RuntimeSecretStatus>;
defaultSummaryPrompt: string;
}) {
const defaultWeights = getDefaultScoreWeights();
const settings = input.settings;
return {
aiProvider: settings?.aiProvider ?? "openai",
azureOpenAiEndpoint: settings?.azureOpenAiEndpoint ?? null,
azureOpenAiDeployment: settings?.azureOpenAiDeployment ?? null,
azureApiVersion: settings?.azureApiVersion ?? "2025-01-01-preview",
aiMaxCompletionTokens: settings?.aiMaxCompletionTokens ?? 300,
aiTemperature: settings?.aiTemperature ?? 1,
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
defaultSummaryPrompt: input.defaultSummaryPrompt,
hasApiKey: !!input.runtimeSettings?.azureOpenAiApiKey,
runtimeSecrets: input.runtimeSecrets,
legacyStoredSecretFields: RUNTIME_SECRET_FIELDS.filter(
(field) => input.runtimeSecrets[field].hasStoredValue,
),
scoreWeights: (settings?.scoreWeights as typeof defaultWeights | null | undefined) ?? defaultWeights,
scoreVisibleRoles: (settings?.scoreVisibleRoles as string[] | null | undefined) ?? ["ADMIN", "MANAGER"],
smtpHost: settings?.smtpHost ?? null,
smtpPort: settings?.smtpPort ?? 587,
smtpUser: settings?.smtpUser ?? null,
smtpFrom: settings?.smtpFrom ?? null,
smtpTls: settings?.smtpTls ?? true,
hasSmtpPassword: !!input.runtimeSettings?.smtpPassword,
anonymizationEnabled: settings?.anonymizationEnabled ?? false,
anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de",
anonymizationMode: settings?.anonymizationMode ?? "global",
azureDalleDeployment: settings?.azureDalleDeployment ?? null,
azureDalleEndpoint: settings?.azureDalleEndpoint ?? null,
hasDalleApiKey: !!input.runtimeSettings?.azureDalleApiKey,
geminiModel: settings?.geminiModel ?? "gemini-2.5-flash-image",
hasGeminiApiKey: !!input.runtimeSettings?.geminiApiKey,
imageProvider: settings?.imageProvider ?? "dalle",
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50,
};
}
export function buildSettingsUpdatePayload(input: SettingsUpdateInput): {
data: Record<string, unknown>;
ignoredSecretFields: string[];
} {
const data: Record<string, unknown> = {};
const ignoredSecretFields: string[] = [];
if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider;
if (input.azureOpenAiEndpoint !== undefined) data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null;
if (input.azureOpenAiDeployment !== undefined) data.azureOpenAiDeployment = input.azureOpenAiDeployment || null;
if (input.azureOpenAiApiKey !== undefined) ignoredSecretFields.push("azureOpenAiApiKey");
if (input.azureApiVersion !== undefined) data.azureApiVersion = input.azureApiVersion || null;
if (input.aiMaxCompletionTokens !== undefined) data.aiMaxCompletionTokens = input.aiMaxCompletionTokens;
if (input.aiTemperature !== undefined) data.aiTemperature = input.aiTemperature;
if (input.aiSummaryPrompt !== undefined) data.aiSummaryPrompt = input.aiSummaryPrompt || null;
if (input.scoreWeights !== undefined) data.scoreWeights = input.scoreWeights;
if (input.scoreVisibleRoles !== undefined) data.scoreVisibleRoles = input.scoreVisibleRoles;
if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null;
if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort;
if (input.smtpUser !== undefined) data.smtpUser = input.smtpUser || null;
if (input.smtpPassword !== undefined) ignoredSecretFields.push("smtpPassword");
if (input.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null;
if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls;
if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled;
if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de";
if (input.anonymizationSeed !== undefined) ignoredSecretFields.push("anonymizationSeed");
if (input.anonymizationMode !== undefined) {
data.anonymizationMode = input.anonymizationMode;
data.anonymizationAliases = null;
}
if (input.azureDalleDeployment !== undefined) data.azureDalleDeployment = input.azureDalleDeployment || null;
if (input.azureDalleEndpoint !== undefined) data.azureDalleEndpoint = input.azureDalleEndpoint || null;
if (input.azureDalleApiKey !== undefined) ignoredSecretFields.push("azureDalleApiKey");
if (input.geminiApiKey !== undefined) ignoredSecretFields.push("geminiApiKey");
if (input.geminiModel !== undefined) data.geminiModel = input.geminiModel || null;
if (input.imageProvider !== undefined) data.imageProvider = input.imageProvider;
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
return { data, ignoredSecretFields };
}
export function sanitizeSettingsAuditSnapshot(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = SENSITIVE_FIELDS.has(key) ? (value ? "***" : null) : value;
}
return result;
}
export async function testRuntimeAiConnection(settings: {
aiProvider?: string | null;
azureOpenAiEndpoint?: string | null;
azureOpenAiDeployment?: string | null;
azureOpenAiApiKey?: string | null;
azureApiVersion?: string | null;
} | null | undefined): Promise<{ ok: boolean; error?: string }> {
if (!isAiConfigured(settings)) {
const provider = settings?.aiProvider ?? "openai";
if (provider === "azure") {
return { ok: false, error: "Missing required fields: endpoint, deployment name, and API key are all required for Azure OpenAI." };
}
return { ok: false, error: "Missing required fields: model name and API key are required." };
}
const configuredSettings = settings as NonNullable<typeof settings>;
const provider = configuredSettings.aiProvider ?? "openai";
const apiKey = configuredSettings.azureOpenAiApiKey!;
if (provider === "azure") {
const endpoint = configuredSettings.azureOpenAiEndpoint!.replace(/\/$/, "");
const deployment = configuredSettings.azureOpenAiDeployment!;
const apiVersion = configuredSettings.azureApiVersion ?? "2025-01-01-preview";
const url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
return performAiConnectionFetch(provider, url, {
"Content-Type": "application/json",
"api-key": apiKey,
});
}
const model = configuredSettings.azureOpenAiDeployment ?? DEFAULT_OPENAI_MODEL;
return performAiConnectionFetch(
provider,
"https://api.openai.com/v1/chat/completions",
{
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
{ model },
);
}
async function performAiConnectionFetch(
provider: string,
url: string,
headers: Record<string, string>,
bodyOverrides: Record<string, unknown> = {},
): Promise<{ ok: boolean; error?: string }> {
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
messages: [{ role: "user", content: "ping" }],
max_completion_tokens: 5,
...bodyOverrides,
}),
});
const body = await response.text();
if (response.ok) {
return { ok: true };
}
let message = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string } };
if (parsed.error?.message) {
message = parsed.error.message;
}
} catch {
message = body;
}
const raw = `HTTP ${response.status}: ${message}`;
logger.warn(
{ provider, diagnostic: sanitizeDiagnosticError(raw) },
"AI connection test failed",
);
return { ok: false, error: parseAiError(new Error(raw)) };
} catch (error) {
logger.warn(
{ provider, diagnostic: sanitizeDiagnosticError(error) },
"AI connection test failed",
);
return { ok: false, error: parseAiError(error) };
}
}