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) => (
+ {
+ let parsedConfig = event.config_details;
+ if (typeof parsedConfig === "string") {
+ try {
+ parsedConfig = JSON.parse(parsedConfig);
+ } catch (e) {
+ parsedConfig = {};
+ }
+ }
+ setSelectedEvent({
+ ...event,
+ config_details: parsedConfig,
+ });
+ }}
+ className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 font-semibold cursor-pointer bg-slate-50 hover:bg-blue-50 px-2 py-1.5 rounded transition-all"
+ >
+
+ Inspect Config
+
+ ),
+ },
+ ];
+
+ // 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 */}
+
+
+
+ setActiveTab("history")}
+ className={`flex items-center gap-1.5 text-sm font-bold pb-2 transition-all cursor-pointer ${
+ activeTab === "history"
+ ? "text-[#112868] border-b-2 border-[#112868]"
+ : "text-slate-400 hover:text-slate-600"
+ }`}
+ >
+
+ Fallback History Logs
+
+ setActiveTab("mapping")}
+ className={`flex items-center gap-1.5 text-sm font-bold pb-2 transition-all cursor-pointer ${
+ activeTab === "mapping"
+ ? "text-[#112868] border-b-2 border-[#112868]"
+ : "text-slate-400 hover:text-slate-600"
+ }`}
+ >
+
+ Provider Fallback Mapping
+
+ setActiveTab("configs")}
+ className={`flex items-center gap-1.5 text-sm font-bold pb-2 transition-all cursor-pointer ${
+ activeTab === "configs"
+ ? "text-[#112868] border-b-2 border-[#112868]"
+ : "text-slate-400 hover:text-slate-600"
+ }`}
+ >
+ Dynamic Fallback Models & API Keys
+
+
+
+ {activeTab === "history" && (
+
+
+ Refresh
+
+ )}
+
+
+ {/* 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 dropdown */}
+
+ handleMappingChange(provider.name, e.target.value)}
+ className="w-full h-9 px-3 text-sm border border-slate-200 rounded-lg bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-[#112868]/20 focus:border-[#112868] transition-all cursor-pointer"
+ >
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ {/* 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;