From 1144f83f3c478e684447c4868b403cf9a065399b Mon Sep 17 00:00:00 2001 From: sibarchannayak Date: Wed, 27 May 2026 13:24:48 +0530 Subject: [PATCH] feat: implement AI management system with service layer, model definitions, and administrative UI modules --- src/components/layout/Sidebar.tsx | 1 + src/components/tenant/ViewAIProviderModal.tsx | 6 +- src/pages/superadmin/AIFallbackHistory.tsx | 592 ++++++++++++++++++ src/pages/tenant/TenantAIProviderCreate.tsx | 46 +- src/routes/super-admin-routes.tsx | 5 + src/services/ai-service.ts | 36 ++ src/types/ai.ts | 3 + 7 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 src/pages/superadmin/AIFallbackHistory.tsx diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 61628ab..ed447da 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -77,6 +77,7 @@ const superAdminSystemMenu: MenuItem[] = [ children: [ { label: "SMTP Config", path: "/settings/smtp" }, { label: "Failed Emails", path: "/settings/failed-emails" }, + { label: "AI Fallback Monitoring", path: "/settings/ai-fallbacks" }, ], }, ]; diff --git a/src/components/tenant/ViewAIProviderModal.tsx b/src/components/tenant/ViewAIProviderModal.tsx index 56d0d2d..5e231f4 100644 --- a/src/components/tenant/ViewAIProviderModal.tsx +++ b/src/components/tenant/ViewAIProviderModal.tsx @@ -74,7 +74,7 @@ export const ViewAIProviderModal = ({ Default Embedding Model - {(config as any).default_embedding_model || "—"} + {config.default_embedding_model || "—"} @@ -113,9 +113,9 @@ export const ViewAIProviderModal = ({ Custom Embedding Models - {parseArray((config as any).custom_embedding_models).length > 0 ? ( + {parseArray(config.custom_embedding_models).length > 0 ? (
- {parseArray((config as any).custom_embedding_models).map( + {parseArray(config.custom_embedding_models).map( (m: any, idx: any) => ( { + const [fallbacks, setFallbacks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedEvent, setSelectedEvent] = useState(null); + const [activeTab, setActiveTab] = useState<"history" | "mapping" | "configs">("history"); + + // Pagination + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(10); + const [totalItems, setTotalItems] = useState(0); + + // Per-provider fallback mapping state + const [availableProviders, setAvailableProviders] = useState<{ name: string; displayName: string }[]>([]); + const [fallbackMapping, setFallbackMapping] = useState>({}); + const [isMappingLoading, setIsMappingLoading] = useState(false); + const [isSavingMapping, setIsSavingMapping] = useState(false); + + const fetchFallbackHistory = async () => { + setIsLoading(true); + try { + const res = await aiService.listFallbacks({ + page: currentPage, + limit: limit, + }); + setFallbacks(res.data || []); + setTotalItems(res.pagination?.total || (res.data || []).length); + } catch (err) { + showToast.error("Failed to load fallback history logs"); + } finally { + setIsLoading(false); + } + }; + + const fetchMappingData = useCallback(async () => { + setIsMappingLoading(true); + try { + // Load system providers + global dynamic providers + const [systemProviders, mapping] = await Promise.all([ + aiService.getProviders().catch(() => [] as AIProviderInfo[]), + aiService.getFallbackMapping().catch(() => ({})), + ]); + + // Build unified list: built-ins + any extra dynamic providers from the API + const allProviders = [...BUILT_IN_PROVIDERS]; + for (const sp of systemProviders) { + const exists = allProviders.some((p) => p.name === sp.name); + if (!exists) { + allProviders.push({ name: sp.name, displayName: sp.displayName || sp.name }); + } + } + + setAvailableProviders(allProviders); + setFallbackMapping(mapping || {}); + } catch (err) { + console.error("Failed to load mapping data:", err); + } finally { + setIsMappingLoading(false); + } + }, []); + + useEffect(() => { + void fetchFallbackHistory(); + }, [currentPage, limit]); + + useEffect(() => { + if (activeTab === "mapping") { + void fetchMappingData(); + } + }, [activeTab, fetchMappingData]); + + const handleMappingChange = (providerName: string, fallbackName: string) => { + setFallbackMapping((prev) => ({ + ...prev, + [providerName]: fallbackName === "__none__" ? "" : fallbackName, + })); + }; + + const handleSaveMapping = async () => { + setIsSavingMapping(true); + try { + // Strip empty entries before saving + const cleanMapping = Object.fromEntries( + Object.entries(fallbackMapping).filter(([, v]) => v && v !== "__none__") + ); + await aiService.updateFallbackMapping(cleanMapping); + showToast.success("Per-provider fallback mapping saved successfully"); + } catch (err) { + showToast.error("Failed to save fallback mapping"); + } finally { + setIsSavingMapping(false); + } + }; + + const columns: Column[] = [ + { + key: "scope", + label: "Tenant Scope", + render: (event) => ( + + {event.tenant_name || "Global / System"} + + ), + }, + { + key: "failed_provider", + label: "Failed Provider", + render: (event) => ( +
+ + {event.failed_provider} + + + {event.failed_model || "unknown"} + +
+ ), + }, + { + key: "fallback_provider", + label: "Fallback Utilized", + render: (event) => ( +
+ + {event.fallback_provider} + + + {event.fallback_model || "unknown"} + +
+ ), + }, + { + key: "error_message", + label: "Error Details", + render: (event) => { + const errorText = event.error_message || "Unknown rate-limit/network error"; + return ( + + {errorText} + + ); + }, + }, + { + key: "created_at", + label: "Timestamp", + render: (event) => ( + + {formatDate(event.created_at)} + + ), + }, + { + key: "actions", + label: "Actions", + align: "right", + render: (event) => ( + + ), + }, + ]; + + // Build select options for a given provider (exclude itself) + const getFallbackOptions = (currentProviderName: string) => { + const options = [ + NONE_OPTION, + ...availableProviders + .filter((p) => p.name !== currentProviderName) + .map((p) => ({ value: p.name, label: p.displayName })), + ]; + return options; + }; + + return ( + +
+ {/* Quick info panel & incident summary */} +
+
+
+ +

+ How Fallbacks Protect Client Operations +

+
+
+ +
+ Automatic Redundancy: If a configured endpoint fails (e.g., Azure OpenAI rate-limits or encounters network timeouts), the gateway silently forwards requests to the configured fallback provider. Use the{" "} + Provider Fallback Mapping tab to define exactly which provider acts as the fallback for each primary provider. +
+
+
+ +
+
+ + Total Fallbacks Logged + + + {totalItems} + +
+
+ + Gateway Status + + + Healthy + +
+
+ + Last Incident + + + {fallbacks.length > 0 + ? formatDate(fallbacks[0].created_at) + : "No incidents logged"} + +
+
+
+ + {/* Tabs */} +
+
+
+ + + +
+ + {activeTab === "history" && ( + + )} +
+ + {/* HISTORY TAB */} + {activeTab === "history" && ( + <> +
+ item.id} + emptyMessage="No fallback incidents have been recorded." + /> +
+ + {totalItems > limit && ( +
+ +
+ )} + + )} + + {/* PROVIDER FALLBACK MAPPING TAB */} + {activeTab === "mapping" && ( +
+
+ +
+ Global Fallback Mapping + Configure which provider the gateway should automatically switch to when each primary provider fails. + These mappings apply globally to all tenants. Tenant-level custom fallback settings (set per provider config) take higher priority over these mappings. + Custom open-source models (e.g. Mistral-7B, Mistral-24B) added via the{" "} + Dynamic Fallback Models & API Keys tab will automatically appear here. +
+
+ + {isMappingLoading ? ( +
+ + Loading provider mappings... +
+ ) : ( + <> +
+ {availableProviders.map((provider) => { + const currentFallback = fallbackMapping[provider.name] || "__none__"; + const options = getFallbackOptions(provider.name); + + return ( +
+ {/* Primary provider label */} +
+
+ + {provider.displayName} + + + {provider.name} + +
+ + {/* Arrow */} +
+ + fallback to + +
+ + {/* Fallback dropdown */} +
+ +
+ + {/* Current mapping status badge */} +
+ {currentFallback && currentFallback !== "__none__" ? ( + + ✓ Mapped + + ) : ( + + Not set + + )} +
+
+ ); + })} +
+ +
+

+ {availableProviders.length} provider{availableProviders.length !== 1 ? "s" : ""} configured. + Add more via the Dynamic Fallback Models & API Keys tab. +

+ + {isSavingMapping ? ( + <> + + Saving... + + ) : ( + <> + + Save Fallback Mapping + + )} + +
+ + )} +
+ )} + + {/* DYNAMIC FALLBACK MODELS TAB */} + {activeTab === "configs" && ( +
+ +
+ )} +
+
+ + {/* Diagnostic Modal */} + setSelectedEvent(null)} + title="Failed Endpoint Diagnostics" + description={`Tenant Scope: ${selectedEvent?.tenant_name || "Global / System"}`} + maxWidth="md" + footer={ + setSelectedEvent(null)} + className="h-9 px-4 text-xs rounded-lg" + > + Close Diagnostics + + } + > + {selectedEvent && ( +
+ {/* Incident Header Details */} +
+
+ Failed Provider + + {selectedEvent.failed_provider} + +
+
+ Fallback Provider + + {selectedEvent.fallback_provider} + +
+
+ + {/* Exact Error Log */} +
+ Failed Provider Error Message: +

+ {selectedEvent.error_message || "No specific API error details captured."} +

+
+ + {/* Configuration parameters details */} +
+

+ Failed Provider Endpoint & Security Config +

+ + + + + + + + + + + + + + + + + + + + + + + + +
Config Type + {selectedEvent.config_details?.config_type || "N/A"} +
Endpoint URL + {selectedEvent.config_details?.endpoint || "N/A"} +
Deployment / Alias + {selectedEvent.config_details?.deployment || "N/A"} +
API Version + {selectedEvent.config_details?.api_version || "N/A"} +
API Key (Masked) + {selectedEvent.config_details?.apiKeyMasked || "N/A"} +
+
+
+ )} +
+ + ); +}; + +export default AIFallbackHistory; diff --git a/src/pages/tenant/TenantAIProviderCreate.tsx b/src/pages/tenant/TenantAIProviderCreate.tsx index 497f77d..87223c4 100644 --- a/src/pages/tenant/TenantAIProviderCreate.tsx +++ b/src/pages/tenant/TenantAIProviderCreate.tsx @@ -1,4 +1,4 @@ -import { type ReactElement, useState } from "react"; +import { type ReactElement, useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { Layout } from "@/components/layout/Layout"; import { @@ -6,6 +6,7 @@ import { PrimaryButton, SecondaryButton, FormTagInput, + FormSelect, } from "@/components/shared"; // import { ArrowLeft } from "lucide-react"; import { useForm, Controller } from "react-hook-form"; @@ -13,6 +14,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { aiService } from "@/services/ai-service"; import { showToast } from "@/utils/toast"; +import type { AIProviderInfo } from "@/types/ai"; const createConfigSchema = z.object({ provider: z.string().min(1, "Provider vendor is required"), @@ -27,6 +29,7 @@ const createConfigSchema = z.object({ custom_models: z.array(z.string()).default([]), default_embedding_model: z.string().optional(), custom_embedding_models: z.array(z.string()).default([]), + fallback_provider: z.string().optional().nullable(), }); interface TenantAIProviderCreateProps { @@ -44,6 +47,19 @@ export const TenantAIProviderCreate = ({ }: TenantAIProviderCreateProps = {}): ReactElement => { const navigate = useNavigate(); const [isSubmitting, setIsSubmitting] = useState(false); + const [availableProviders, setAvailableProviders] = useState([]); + + useEffect(() => { + const fetchProviders = async () => { + try { + const data = await aiService.getProviders(); + setAvailableProviders(data || []); + } catch (err) { + console.error("Failed to load providers:", err); + } + }; + void fetchProviders(); + }, []); const { control, @@ -66,6 +82,7 @@ export const TenantAIProviderCreate = ({ custom_models: [], default_embedding_model: "", custom_embedding_models: [], + fallback_provider: "openai", }, }); @@ -87,6 +104,7 @@ export const TenantAIProviderCreate = ({ custom_models: data.custom_models || [], default_embedding_model: data.default_embedding_model || undefined, custom_embedding_models: data.custom_embedding_models || [], + fallback_provider: data.fallback_provider || undefined, } as any, customTenantId); showToast.success("AI Provider configuration created successfully!"); @@ -322,11 +340,35 @@ export const TenantAIProviderCreate = ({ required placeholder="e.g. gpt-4" error={errors.default_model?.message as any} - helperText="Fallback model for this provider." + helperText="Default model for this provider." /> )} /> + { + const options = availableProviders.map(p => ({ + value: p.name, + label: p.displayName || p.name + })); + // Fallback option in case API fails or is loading + if (options.length === 0) { + options.push({ value: "openai", label: "OpenAI" }); + } + return ( + + ); + }} + /> + import("@/pages/superadmin/NotificationMas const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster")); const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig")); const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails")); +const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory")); // Loading fallback component const RouteLoader = (): ReactElement => ( @@ -105,4 +106,8 @@ export const superAdminRoutes: RouteConfig[] = [ path: "/settings/failed-emails", element: , }, + { + path: "/settings/ai-fallbacks", + element: , + }, ]; diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts index cfff38f..fac228c 100644 --- a/src/services/ai-service.ts +++ b/src/services/ai-service.ts @@ -310,6 +310,42 @@ class AIService { const response = await apiClient.get(`/ai/prompts/test-cases/${testCaseId}/results`, { params: { limit } }); return unwrap(response); } + + async listFallbacks(params: { + page?: number; + limit?: number; + }): Promise<{ + data: any[]; + pagination?: { page: number; limit: number; total: number; totalPages: number }; + }> { + const response = await apiClient.get("/ai/fallbacks", { params }); + if (response?.data?.data && response?.data?.pagination) { + return { data: response.data.data, pagination: response.data.pagination }; + } + return unwrap(response); + } + + async getFallbackOrder(tenantId?: string): Promise { + const headers = tenantId ? { "x-tenant-id": tenantId } : undefined; + const response = await apiClient.get("/ai/fallback-order", { headers }); + return unwrap(response); + } + + async updateFallbackOrder(fallbackOrder: string[], tenantId?: string): Promise { + const headers = tenantId ? { "x-tenant-id": tenantId } : undefined; + const response = await apiClient.post("/ai/fallback-order", { fallback_order: fallbackOrder }, { headers }); + return unwrap(response); + } + + async getFallbackMapping(): Promise> { + const response = await apiClient.get("/ai/fallback-mapping"); + return unwrap>(response); + } + + async updateFallbackMapping(mapping: Record): Promise> { + const response = await apiClient.post("/ai/fallback-mapping", { mapping }); + return unwrap>(response); + } } export const aiService = new AIService(); diff --git a/src/types/ai.ts b/src/types/ai.ts index f3089e3..fa6daae 100644 --- a/src/types/ai.ts +++ b/src/types/ai.ts @@ -86,8 +86,11 @@ export interface TenantAIConfig { deployment?: string | null; api_version?: string | null; custom_models?: string[]; + custom_embedding_models?: string[]; custom_pricing?: Record; default_model?: string | null; + default_embedding_model?: string | null; + fallback_provider?: string | null; is_active?: boolean; last_verified_at?: string | null; last_error?: string | null;