/** * Platform multi-AI role configs - agreed with backend AI schema agent. * * Roles: processing | vectorization | docs_api | support * Fields: provider, base_url, api_key (secret), model, enabled, optional extras * * API (nested under platform settings): * GET /api/admin/settings -> { ..., ai_roles: Record } * PUT /api/admin/settings -> { ai_roles?: Partial> } * POST /api/admin/settings/ai-roles/{role}/test -> probe (ok|failed|skipped; 404 on older APIs) * * Secrets: never echo GET into password fields; blank keep; clear_api_key clears. * Legacy openai maps to processing when ai_roles.processing is absent. */ import { api, ApiError } from "./api"; import { PLATFORM_SETTINGS_PATH, maskHint, savePlatformAdminSettings, type PlatformAdminSettings, type PlatformOpenAIPublic } from "./admin-platform-settings"; export const AI_ROLES = ["processing", "vectorization", "docs_api", "support"] as const; export type AIRole = (typeof AI_ROLES)[number]; export const AI_ROLE_META: Record< AIRole, { label: string; description: string; modelPlaceholder: string } > = { processing: { label: "Processing", description: "Product pipeline chat/completions (titles, descriptions, enhance).", modelPlaceholder: "gpt-5.6-luna" }, vectorization: { label: "Vectorization", description: "Embeddings for search / Pinecone indexing (match index dimensions).", modelPlaceholder: "text-embedding-3-small" }, docs_api: { label: "Docs / API", description: "Future docs/API assistant slot. /docs Ask stays rule-based and must never call this role.", modelPlaceholder: "gpt-5.6-luna" }, support: { label: "Support", description: "Ticket auto-reply AI fallback. Configure the provider/key/model here; enable delivery in Support knowledge → Auto-reply.", modelPlaceholder: "gpt-5.6-luna" } }; /** Common OpenAI-compatible provider labels for the admin select. */ export const AI_PROVIDER_OPTIONS = [ { value: "openai", label: "OpenAI" }, { value: "openrouter", label: "OpenRouter" }, { value: "azure", label: "Azure OpenAI" }, { value: "ollama", label: "Ollama" }, { value: "custom", label: "Custom / other" } ] as const; export type PlatformAIRolePublic = { role?: AIRole | string; provider?: string; base_url?: string; model?: string; enabled?: boolean; configured?: boolean; has_api_key?: boolean; api_key_last4?: string; api_key_masked?: string; source?: "db" | "env" | "none" | string; /** Optional free-form string bag (dimensions, timeout, …). */ extras?: Record; }; export type PlatformAIRoleUpdate = { provider?: string; base_url?: string; model?: string; enabled?: boolean; api_key?: string; clear_api_key?: boolean; extras?: Record; }; export type PlatformAIRolesMap = Partial>; export type PlatformAIRolesUpdate = Partial>; export type AIRoleFormState = { provider: string; baseURL: string; model: string; enabled: boolean; apiKey: string; hasKey: boolean; keyMasked: string; clearKey: boolean; source: string; /** Embeddings dimensions (vectorization extras.dimensions). */ dimensions: string; }; export function emptyAIRoleForm(): AIRoleFormState { return { provider: "openai", baseURL: "", model: "", enabled: false, apiKey: "", hasKey: false, keyMasked: "", clearKey: false, source: "", dimensions: "" }; } export function roleFromPublic(pub: PlatformAIRolePublic | undefined): AIRoleFormState { const form = emptyAIRoleForm(); if (!pub) return form; form.provider = pub.provider?.trim() || "openai"; form.baseURL = pub.base_url ?? ""; form.model = pub.model ?? ""; form.enabled = Boolean(pub.enabled ?? pub.configured ?? pub.has_api_key); form.hasKey = Boolean(pub.has_api_key); form.keyMasked = maskHint(form.hasKey, pub.api_key_masked, pub.api_key_last4); form.source = pub.source ?? ""; form.apiKey = ""; form.clearKey = false; form.dimensions = pub.extras?.dimensions ?? ""; return form; } /** Map legacy platform openai section into the processing role form. */ export function roleFromLegacyOpenAI(openai: PlatformOpenAIPublic | undefined): AIRoleFormState { const form = emptyAIRoleForm(); if (!openai) return form; form.provider = "openai"; form.baseURL = openai.base_url ?? ""; form.model = openai.model ?? ""; form.enabled = Boolean(openai.configured || openai.has_api_key); form.hasKey = Boolean(openai.has_api_key); form.keyMasked = maskHint(form.hasKey, openai.api_key_masked, openai.api_key_last4); form.source = openai.source ?? ""; form.apiKey = ""; form.clearKey = false; return form; } export function extractAIRoles(settings: PlatformAdminSettings): PlatformAIRolesMap { const raw = settings.ai_roles; if (!raw || typeof raw !== "object") return {}; const out: PlatformAIRolesMap = {}; for (const role of AI_ROLES) { const entry = raw[role]; if (entry && typeof entry === "object") out[role] = entry; } return out; } export function buildRoleForms(settings: PlatformAdminSettings): Record { const roles = extractAIRoles(settings); const forms = {} as Record; for (const role of AI_ROLES) { if (roles[role]) { forms[role] = roleFromPublic(roles[role]); } else if (role === "processing") { forms[role] = roleFromLegacyOpenAI(settings.openai); } else { forms[role] = emptyAIRoleForm(); } } return forms; } export function formToUpdate(form: AIRoleFormState, role: AIRole): PlatformAIRoleUpdate { const update: PlatformAIRoleUpdate = { provider: form.provider.trim() || "custom", base_url: form.baseURL.trim(), model: form.model.trim(), enabled: form.enabled, api_key: form.apiKey.trim() || undefined, clear_api_key: form.clearKey }; if (role === "vectorization") { const dim = form.dimensions.trim(); update.extras = { dimensions: dim ? dim : null }; } return update; } /** * Save one or more AI roles via PUT /api/admin/settings { ai_roles }. * When only processing is sent and the API ignores ai_roles, also mirror to openai * so legacy backends keep working during cutover. */ export async function saveAIRoles( roles: PlatformAIRolesUpdate, opts?: { mirrorProcessingToOpenAI?: boolean } ): Promise { const body: { ai_roles: PlatformAIRolesUpdate; openai?: { base_url?: string; model?: string; api_key?: string; clear_api_key?: boolean; }; } = { ai_roles: roles }; if (opts?.mirrorProcessingToOpenAI !== false && roles.processing) { const p = roles.processing; body.openai = { base_url: p.base_url, model: p.model, api_key: p.api_key, clear_api_key: p.clear_api_key }; } return savePlatformAdminSettings(body); } export const AI_ROLE_TEST_PATH = (role: AIRole) => `${PLATFORM_SETTINGS_PATH}/ai-roles/${encodeURIComponent(role)}/test`; export type PlatformAIRoleTestResult = { status: "ok" | "failed" | "skipped" | string; message: string; role?: string; }; export async function testAIRole(role: AIRole): Promise { try { return await api(AI_ROLE_TEST_PATH(role), { method: "POST", body: {} }); } catch (err) { if (err instanceof ApiError && (err.status === 404 || err.status === 501)) { return { status: "skipped", message: "Connection test is not available on this API build.", role }; } throw err; } } export function roleConfigured(form: AIRoleFormState): boolean { return form.hasKey || Boolean(form.baseURL.trim() && form.model.trim()); }