refactor(api): extract settings router support
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
isAiConfigured,
|
||||||
|
parseAiError,
|
||||||
|
sanitizeDiagnosticError,
|
||||||
|
} from "../ai-client.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 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 ?? "gpt-4o-mini";
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { adminProcedure, createTRPCRouter } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter } from "../trpc.js";
|
||||||
import { isAiConfigured, parseAiError, sanitizeDiagnosticError } from "../ai-client.js";
|
import { isAiConfigured } from "../ai-client.js";
|
||||||
import { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
|
import { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
|
||||||
import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
|
|
||||||
import { testSmtpConnection } from "../lib/email.js";
|
import { testSmtpConnection } from "../lib/email.js";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
import { logger } from "../lib/logger.js";
|
|
||||||
import { getRuntimeSecretStatuses, RUNTIME_SECRET_FIELDS, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
import { getRuntimeSecretStatuses, RUNTIME_SECRET_FIELDS, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
|
||||||
|
import {
|
||||||
/** Fields that must never appear in audit log values */
|
buildSettingsUpdatePayload,
|
||||||
const SENSITIVE_FIELDS = new Set([
|
getDefaultScoreWeights,
|
||||||
"azureOpenAiApiKey",
|
sanitizeSettingsAuditSnapshot,
|
||||||
"smtpPassword",
|
settingsUpdateInputSchema,
|
||||||
"azureDalleApiKey",
|
testRuntimeAiConnection,
|
||||||
"anonymizationSeed",
|
} from "./settings-support.js";
|
||||||
"geminiApiKey",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const settingsRouter = createTRPCRouter({
|
export const settingsRouter = createTRPCRouter({
|
||||||
getSystemSettings: adminProcedure.query(async ({ ctx }) => {
|
getSystemSettings: adminProcedure.query(async ({ ctx }) => {
|
||||||
@@ -28,13 +23,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
(field) => runtimeSecrets[field].hasStoredValue,
|
(field) => runtimeSecrets[field].hasStoredValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultWeights = {
|
const defaultWeights = getDefaultScoreWeights();
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
aiProvider: settings?.aiProvider ?? "openai",
|
aiProvider: settings?.aiProvider ?? "openai",
|
||||||
@@ -78,113 +67,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
updateSystemSettings: adminProcedure
|
updateSystemSettings: adminProcedure
|
||||||
.input(
|
.input(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(
|
|
||||||
(w) => {
|
|
||||||
const sum = w.skillDepth + w.skillBreadth + w.costEfficiency + w.chargeability + w.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(),
|
|
||||||
// SMTP
|
|
||||||
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(),
|
|
||||||
// Global anonymization
|
|
||||||
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(),
|
|
||||||
// DALL-E image generation
|
|
||||||
azureDalleDeployment: z.string().optional(),
|
|
||||||
azureDalleEndpoint: z.string().url().optional().or(z.literal("")),
|
|
||||||
azureDalleApiKey: z.string().optional(),
|
|
||||||
// Gemini image generation
|
|
||||||
geminiApiKey: z.string().optional(),
|
|
||||||
geminiModel: z.string().optional(),
|
|
||||||
// Image provider selection
|
|
||||||
imageProvider: z.enum(["dalle", "gemini"]).optional(),
|
|
||||||
// Vacation
|
|
||||||
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
|
|
||||||
// Timeline
|
|
||||||
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const data: Record<string, unknown> = {};
|
const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input);
|
||||||
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;
|
|
||||||
// SMTP
|
|
||||||
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;
|
|
||||||
// Global anonymization
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// DALL-E
|
|
||||||
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");
|
|
||||||
// Gemini
|
|
||||||
if (input.geminiApiKey !== undefined)
|
|
||||||
ignoredSecretFields.push("geminiApiKey");
|
|
||||||
if (input.geminiModel !== undefined)
|
|
||||||
data.geminiModel = input.geminiModel || null;
|
|
||||||
// Image provider
|
|
||||||
if (input.imageProvider !== undefined)
|
|
||||||
data.imageProvider = input.imageProvider;
|
|
||||||
// Vacation
|
|
||||||
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
|
|
||||||
// Timeline
|
|
||||||
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
|
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -203,17 +88,8 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
update: data,
|
update: data,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build sanitized snapshots — redact sensitive fields
|
const sanitizedBefore = before ? sanitizeSettingsAuditSnapshot(before as unknown as Record<string, unknown>) : undefined;
|
||||||
const sanitize = (obj: Record<string, unknown>): Record<string, unknown> => {
|
const sanitizedAfter = sanitizeSettingsAuditSnapshot(data);
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
result[key] = SENSITIVE_FIELDS.has(key) ? (value ? "***" : null) : value;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sanitizedBefore = before ? sanitize(before as unknown as Record<string, unknown>) : undefined;
|
|
||||||
const sanitizedAfter = sanitize(data);
|
|
||||||
|
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
@@ -272,98 +148,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
|
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
|
||||||
where: { id: "singleton" },
|
where: { id: "singleton" },
|
||||||
}));
|
}));
|
||||||
|
return testRuntimeAiConnection(settings);
|
||||||
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 provider = settings.aiProvider ?? "openai";
|
|
||||||
const apiKey = settings.azureOpenAiApiKey!;
|
|
||||||
|
|
||||||
let url: string;
|
|
||||||
let headers: Record<string, string>;
|
|
||||||
|
|
||||||
if (provider === "azure") {
|
|
||||||
const endpoint = settings.azureOpenAiEndpoint!.replace(/\/$/, "");
|
|
||||||
const deployment = settings.azureOpenAiDeployment!;
|
|
||||||
const apiVersion = settings.azureApiVersion ?? "2025-01-01-preview";
|
|
||||||
url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
|
|
||||||
headers = { "Content-Type": "application/json", "api-key": apiKey };
|
|
||||||
} else {
|
|
||||||
// Standard OpenAI API — deployment field holds the model name (e.g. "gpt-4o")
|
|
||||||
const model = settings.azureOpenAiDeployment ?? "gpt-4o-mini";
|
|
||||||
url = "https://api.openai.com/v1/chat/completions";
|
|
||||||
headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
|
||||||
// Override body to include model field for OpenAI
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
|
||||||
messages: [{ role: "user", content: "ping" }],
|
|
||||||
max_completion_tokens: 5,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const body = await resp.text();
|
|
||||||
if (resp.ok) return { ok: true };
|
|
||||||
let msg = body;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body) as { error?: { message?: string } };
|
|
||||||
if (parsed.error?.message) msg = parsed.error.message;
|
|
||||||
} catch { /* keep raw */ }
|
|
||||||
const raw = `HTTP ${resp.status}: ${msg}`;
|
|
||||||
logger.warn(
|
|
||||||
{ provider, diagnostic: sanitizeDiagnosticError(raw) },
|
|
||||||
"AI connection test failed",
|
|
||||||
);
|
|
||||||
return { ok: false, error: parseAiError(new Error(raw)) };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ provider, diagnostic: sanitizeDiagnosticError(err) },
|
|
||||||
"AI connection test failed",
|
|
||||||
);
|
|
||||||
return { ok: false, error: parseAiError(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
messages: [{ role: "user", content: "ping" }],
|
|
||||||
max_completion_tokens: 5,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = await resp.text();
|
|
||||||
|
|
||||||
if (resp.ok) return { ok: true };
|
|
||||||
|
|
||||||
let azureMessage = body;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body) as { error?: { message?: string; code?: string } };
|
|
||||||
if (parsed.error?.message) azureMessage = parsed.error.message;
|
|
||||||
} catch { /* leave as raw text */ }
|
|
||||||
|
|
||||||
const raw = `HTTP ${resp.status}: ${azureMessage}`;
|
|
||||||
logger.warn(
|
|
||||||
{ provider, diagnostic: sanitizeDiagnosticError(raw) },
|
|
||||||
"AI connection test failed",
|
|
||||||
);
|
|
||||||
return { ok: false, error: parseAiError(new Error(raw)) };
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{ provider, diagnostic: sanitizeDiagnosticError(err) },
|
|
||||||
"AI connection test failed",
|
|
||||||
);
|
|
||||||
return { ok: false, error: parseAiError(err) };
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
testSmtpConnection: adminProcedure.mutation(async ({ ctx }) => {
|
testSmtpConnection: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user