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; 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; 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; ignoredSecretFields: string[]; } { const data: Record = {}; 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): Record { const result: Record = {}; 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; 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, bodyOverrides: Record = {}, ): 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) }; } }