feat: implement AI management system with service layer, model definitions, and administrative UI modules
This commit is contained in:
parent
23c32409ed
commit
1144f83f3c
@ -77,6 +77,7 @@ const superAdminSystemMenu: MenuItem[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ label: "SMTP Config", path: "/settings/smtp" },
|
{ label: "SMTP Config", path: "/settings/smtp" },
|
||||||
{ label: "Failed Emails", path: "/settings/failed-emails" },
|
{ label: "Failed Emails", path: "/settings/failed-emails" },
|
||||||
|
{ label: "AI Fallback Monitoring", path: "/settings/ai-fallbacks" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export const ViewAIProviderModal = ({
|
|||||||
Default Embedding Model
|
Default Embedding Model
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-800 font-medium">
|
<span className="text-slate-800 font-medium">
|
||||||
{(config as any).default_embedding_model || "—"}
|
{config.default_embedding_model || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -113,9 +113,9 @@ export const ViewAIProviderModal = ({
|
|||||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
||||||
Custom Embedding Models
|
Custom Embedding Models
|
||||||
</span>
|
</span>
|
||||||
{parseArray((config as any).custom_embedding_models).length > 0 ? (
|
{parseArray(config.custom_embedding_models).length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
{parseArray((config as any).custom_embedding_models).map(
|
{parseArray(config.custom_embedding_models).map(
|
||||||
(m: any, idx: any) => (
|
(m: any, idx: any) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|||||||
592
src/pages/superadmin/AIFallbackHistory.tsx
Normal file
592
src/pages/superadmin/AIFallbackHistory.tsx
Normal file
@ -0,0 +1,592 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
PrimaryButton,
|
||||||
|
type Column,
|
||||||
|
Modal,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { aiService } from "@/services/ai-service";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
import {
|
||||||
|
ShieldAlert,
|
||||||
|
Settings2,
|
||||||
|
History,
|
||||||
|
Eye,
|
||||||
|
RefreshCw,
|
||||||
|
Info,
|
||||||
|
ArrowRight,
|
||||||
|
Save,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
import { TenantAIProviders } from "../tenant/TenantAIProviders";
|
||||||
|
import type { AIProviderInfo } from "@/types/ai";
|
||||||
|
|
||||||
|
interface FallbackEvent {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string | null;
|
||||||
|
tenant_name: string | null;
|
||||||
|
failed_provider: string;
|
||||||
|
failed_model: string | null;
|
||||||
|
fallback_provider: string;
|
||||||
|
fallback_model: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
config_details: any;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known built-in providers that always exist in the system
|
||||||
|
const BUILT_IN_PROVIDERS = [
|
||||||
|
{ name: "openai", displayName: "OpenAI" },
|
||||||
|
{ name: "anthropic", displayName: "Anthropic Claude" },
|
||||||
|
{ name: "gemini", displayName: "Google Gemini" },
|
||||||
|
{ name: "open-source", displayName: "Open Source" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NONE_OPTION = { value: "__none__", label: "— No fallback —" };
|
||||||
|
|
||||||
|
export const AIFallbackHistory = () => {
|
||||||
|
const [fallbacks, setFallbacks] = useState<FallbackEvent[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<FallbackEvent | null>(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<Record<string, string>>({});
|
||||||
|
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<FallbackEvent>[] = [
|
||||||
|
{
|
||||||
|
key: "scope",
|
||||||
|
label: "Tenant Scope",
|
||||||
|
render: (event) => (
|
||||||
|
<span className="font-semibold text-slate-800">
|
||||||
|
{event.tenant_name || "Global / System"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failed_provider",
|
||||||
|
label: "Failed Provider",
|
||||||
|
render: (event) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="uppercase text-xs font-bold text-red-600 tracking-wider">
|
||||||
|
{event.failed_provider}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono select-all">
|
||||||
|
{event.failed_model || "unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fallback_provider",
|
||||||
|
label: "Fallback Utilized",
|
||||||
|
render: (event) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="uppercase text-xs font-bold text-emerald-600 tracking-wider">
|
||||||
|
{event.fallback_provider}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono select-all">
|
||||||
|
{event.fallback_model || "unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "error_message",
|
||||||
|
label: "Error Details",
|
||||||
|
render: (event) => {
|
||||||
|
const errorText = event.error_message || "Unknown rate-limit/network error";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-xs text-slate-600 block max-w-xs truncate"
|
||||||
|
title={errorText}
|
||||||
|
>
|
||||||
|
{errorText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at",
|
||||||
|
label: "Timestamp",
|
||||||
|
render: (event) => (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{formatDate(event.created_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
|
render: (event) => (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
Inspect Config
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Layout
|
||||||
|
currentPage="Settings"
|
||||||
|
pageHeader={{
|
||||||
|
title: "AI Gateway Fallback Settings & Monitoring",
|
||||||
|
description:
|
||||||
|
"Configure automatic gateway fallback routing logic, review errors, and inspect failed provider configs.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick info panel & incident summary */}
|
||||||
|
<div className="bg-white border border-slate-100 rounded-xl p-5 shadow-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<ShieldAlert className="w-5 h-5 text-amber-500" />
|
||||||
|
<h3 className="text-sm font-bold text-slate-800">
|
||||||
|
How Fallbacks Protect Client Operations
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 bg-amber-50 border border-amber-100 p-3 rounded-lg text-xs text-amber-800 leading-relaxed flex-1">
|
||||||
|
<Info className="w-4 h-4 shrink-0 text-amber-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="font-bold">Automatic Redundancy:</span> 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{" "}
|
||||||
|
<span className="font-semibold">Provider Fallback Mapping</span> tab to define exactly which provider acts as the fallback for each primary provider.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-4">
|
||||||
|
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">
|
||||||
|
Total Fallbacks Logged
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-bold text-slate-800 block mt-1">
|
||||||
|
{totalItems}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">
|
||||||
|
Gateway Status
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-bold text-emerald-600 block mt-1">
|
||||||
|
Healthy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-400 block tracking-wider">
|
||||||
|
Last Incident
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-slate-700 block mt-2">
|
||||||
|
{fallbacks.length > 0
|
||||||
|
? formatDate(fallbacks[0].created_at)
|
||||||
|
: "No incidents logged"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<History className="w-4 h-4" />
|
||||||
|
Fallback History Logs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
Provider Fallback Mapping
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => 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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "history" && (
|
||||||
|
<button
|
||||||
|
onClick={fetchFallbackHistory}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-800 font-semibold cursor-pointer border border-slate-200 px-3 py-1.5 rounded-lg hover:bg-slate-50 transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HISTORY TAB */}
|
||||||
|
{activeTab === "history" && (
|
||||||
|
<>
|
||||||
|
<div className="border border-slate-50 rounded-xl overflow-hidden">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={fallbacks}
|
||||||
|
isLoading={isLoading}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
emptyMessage="No fallback incidents have been recorded."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalItems > limit && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={Math.ceil(totalItems / limit)}
|
||||||
|
totalItems={totalItems}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PROVIDER FALLBACK MAPPING TAB */}
|
||||||
|
{activeTab === "mapping" && (
|
||||||
|
<div className="space-y-4 pt-1">
|
||||||
|
<div className="flex items-start gap-3 bg-blue-50 border border-blue-100 rounded-lg p-3 text-xs text-blue-800">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0 text-blue-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="font-bold block mb-1">Global Fallback Mapping</span>
|
||||||
|
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{" "}
|
||||||
|
<span className="font-semibold">Dynamic Fallback Models & API Keys</span> tab will automatically appear here.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMappingLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-[#112868]" />
|
||||||
|
<span className="ml-2 text-sm text-slate-500">Loading provider mappings...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{availableProviders.map((provider) => {
|
||||||
|
const currentFallback = fallbackMapping[provider.name] || "__none__";
|
||||||
|
const options = getFallbackOptions(provider.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider.name}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center gap-3 p-4 bg-slate-50 border border-slate-100 rounded-xl hover:border-slate-200 transition-all"
|
||||||
|
>
|
||||||
|
{/* Primary provider label */}
|
||||||
|
<div className="flex items-center gap-2 min-w-[180px]">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#112868] shrink-0" />
|
||||||
|
<span className="text-sm font-semibold text-slate-800">
|
||||||
|
{provider.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono bg-slate-100 px-1.5 py-0.5 rounded">
|
||||||
|
{provider.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<ArrowRight className="w-4 h-4 text-slate-300" />
|
||||||
|
<span className="text-xs text-slate-400 font-medium">fallback to</span>
|
||||||
|
<ArrowRight className="w-4 h-4 text-slate-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fallback dropdown */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<select
|
||||||
|
value={currentFallback}
|
||||||
|
onChange={(e) => 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) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current mapping status badge */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
{currentFallback && currentFallback !== "__none__" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-100 px-2 py-1 rounded-full uppercase tracking-wider">
|
||||||
|
✓ Mapped
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-slate-400 bg-slate-100 border border-slate-200 px-2 py-1 rounded-full uppercase tracking-wider">
|
||||||
|
Not set
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{availableProviders.length} provider{availableProviders.length !== 1 ? "s" : ""} configured.
|
||||||
|
Add more via the <span className="font-semibold text-slate-500">Dynamic Fallback Models & API Keys</span> tab.
|
||||||
|
</p>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleSaveMapping}
|
||||||
|
disabled={isSavingMapping}
|
||||||
|
className="h-9 px-5 text-xs flex items-center gap-1.5 rounded-lg"
|
||||||
|
>
|
||||||
|
{isSavingMapping ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
Save Fallback Mapping
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DYNAMIC FALLBACK MODELS TAB */}
|
||||||
|
{activeTab === "configs" && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<TenantAIProviders
|
||||||
|
customTenantId="00000000-0000-0000-0000-000000000001"
|
||||||
|
hideLayout={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagnostic Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!selectedEvent}
|
||||||
|
onClose={() => setSelectedEvent(null)}
|
||||||
|
title="Failed Endpoint Diagnostics"
|
||||||
|
description={`Tenant Scope: ${selectedEvent?.tenant_name || "Global / System"}`}
|
||||||
|
maxWidth="md"
|
||||||
|
footer={
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => setSelectedEvent(null)}
|
||||||
|
className="h-9 px-4 text-xs rounded-lg"
|
||||||
|
>
|
||||||
|
Close Diagnostics
|
||||||
|
</PrimaryButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedEvent && (
|
||||||
|
<div className="space-y-4 pt-1">
|
||||||
|
{/* Incident Header Details */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs border-b border-slate-100 pb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block font-medium">Failed Provider</span>
|
||||||
|
<span className="uppercase font-bold text-red-600 block mt-0.5">
|
||||||
|
{selectedEvent.failed_provider}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block font-medium">Fallback Provider</span>
|
||||||
|
<span className="uppercase font-bold text-emerald-600 block mt-0.5">
|
||||||
|
{selectedEvent.fallback_provider}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exact Error Log */}
|
||||||
|
<div className="space-y-1 bg-red-50/50 border border-red-100 p-3 rounded-lg text-xs">
|
||||||
|
<span className="font-bold text-red-800 block">Failed Provider Error Message:</span>
|
||||||
|
<p className="font-mono text-red-700 select-all leading-relaxed whitespace-pre-wrap">
|
||||||
|
{selectedEvent.error_message || "No specific API error details captured."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration parameters details */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-xs font-bold text-slate-800 border-b border-slate-50 pb-1">
|
||||||
|
Failed Provider Endpoint & Security Config
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<table className="w-full text-xs text-slate-700">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-slate-50">
|
||||||
|
<td className="py-2 text-slate-400 font-medium">Config Type</td>
|
||||||
|
<td className="py-2 font-semibold capitalize">
|
||||||
|
{selectedEvent.config_details?.config_type || "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-50">
|
||||||
|
<td className="py-2 text-slate-400 font-medium">Endpoint URL</td>
|
||||||
|
<td className="py-2 font-mono select-all">
|
||||||
|
{selectedEvent.config_details?.endpoint || "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-50">
|
||||||
|
<td className="py-2 text-slate-400 font-medium">Deployment / Alias</td>
|
||||||
|
<td className="py-2 font-semibold">
|
||||||
|
{selectedEvent.config_details?.deployment || "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-slate-50">
|
||||||
|
<td className="py-2 text-slate-400 font-medium">API Version</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{selectedEvent.config_details?.api_version || "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-2 text-slate-400 font-medium">API Key (Masked)</td>
|
||||||
|
<td className="py-2 font-mono">
|
||||||
|
{selectedEvent.config_details?.apiKeyMasked || "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIFallbackHistory;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import {
|
import {
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
FormTagInput,
|
FormTagInput,
|
||||||
|
FormSelect,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
// import { ArrowLeft } from "lucide-react";
|
// import { ArrowLeft } from "lucide-react";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
@ -13,6 +14,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { aiService } from "@/services/ai-service";
|
import { aiService } from "@/services/ai-service";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
|
import type { AIProviderInfo } from "@/types/ai";
|
||||||
|
|
||||||
const createConfigSchema = z.object({
|
const createConfigSchema = z.object({
|
||||||
provider: z.string().min(1, "Provider vendor is required"),
|
provider: z.string().min(1, "Provider vendor is required"),
|
||||||
@ -27,6 +29,7 @@ const createConfigSchema = z.object({
|
|||||||
custom_models: z.array(z.string()).default([]),
|
custom_models: z.array(z.string()).default([]),
|
||||||
default_embedding_model: z.string().optional(),
|
default_embedding_model: z.string().optional(),
|
||||||
custom_embedding_models: z.array(z.string()).default([]),
|
custom_embedding_models: z.array(z.string()).default([]),
|
||||||
|
fallback_provider: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TenantAIProviderCreateProps {
|
interface TenantAIProviderCreateProps {
|
||||||
@ -44,6 +47,19 @@ export const TenantAIProviderCreate = ({
|
|||||||
}: TenantAIProviderCreateProps = {}): ReactElement => {
|
}: TenantAIProviderCreateProps = {}): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
const [availableProviders, setAvailableProviders] = useState<AIProviderInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProviders = async () => {
|
||||||
|
try {
|
||||||
|
const data = await aiService.getProviders();
|
||||||
|
setAvailableProviders(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load providers:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void fetchProviders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -66,6 +82,7 @@ export const TenantAIProviderCreate = ({
|
|||||||
custom_models: [],
|
custom_models: [],
|
||||||
default_embedding_model: "",
|
default_embedding_model: "",
|
||||||
custom_embedding_models: [],
|
custom_embedding_models: [],
|
||||||
|
fallback_provider: "openai",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -87,6 +104,7 @@ export const TenantAIProviderCreate = ({
|
|||||||
custom_models: data.custom_models || [],
|
custom_models: data.custom_models || [],
|
||||||
default_embedding_model: data.default_embedding_model || undefined,
|
default_embedding_model: data.default_embedding_model || undefined,
|
||||||
custom_embedding_models: data.custom_embedding_models || [],
|
custom_embedding_models: data.custom_embedding_models || [],
|
||||||
|
fallback_provider: data.fallback_provider || undefined,
|
||||||
} as any, customTenantId);
|
} as any, customTenantId);
|
||||||
|
|
||||||
showToast.success("AI Provider configuration created successfully!");
|
showToast.success("AI Provider configuration created successfully!");
|
||||||
@ -322,11 +340,35 @@ export const TenantAIProviderCreate = ({
|
|||||||
required
|
required
|
||||||
placeholder="e.g. gpt-4"
|
placeholder="e.g. gpt-4"
|
||||||
error={errors.default_model?.message as any}
|
error={errors.default_model?.message as any}
|
||||||
helperText="Fallback model for this provider."
|
helperText="Default model for this provider."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="fallback_provider"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => {
|
||||||
|
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 (
|
||||||
|
<FormSelect
|
||||||
|
{...field}
|
||||||
|
label="Fallback Provider"
|
||||||
|
options={options}
|
||||||
|
error={errors.fallback_provider?.message as any}
|
||||||
|
helperText="The fallback provider to use if this provider encounters errors (default: openai)."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="custom_models"
|
name="custom_models"
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMas
|
|||||||
const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster"));
|
const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster"));
|
||||||
const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig"));
|
const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig"));
|
||||||
const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
|
const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
|
||||||
|
const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -105,4 +106,8 @@ export const superAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/settings/failed-emails",
|
path: "/settings/failed-emails",
|
||||||
element: <LazyRoute component={FailedEmails} />,
|
element: <LazyRoute component={FailedEmails} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/ai-fallbacks",
|
||||||
|
element: <LazyRoute component={AIFallbackHistory} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -310,6 +310,42 @@ class AIService {
|
|||||||
const response = await apiClient.get(`/ai/prompts/test-cases/${testCaseId}/results`, { params: { limit } });
|
const response = await apiClient.get(`/ai/prompts/test-cases/${testCaseId}/results`, { params: { limit } });
|
||||||
return unwrap<any[]>(response);
|
return unwrap<any[]>(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<string[]> {
|
||||||
|
const headers = tenantId ? { "x-tenant-id": tenantId } : undefined;
|
||||||
|
const response = await apiClient.get("/ai/fallback-order", { headers });
|
||||||
|
return unwrap<string[]>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFallbackOrder(fallbackOrder: string[], tenantId?: string): Promise<string[]> {
|
||||||
|
const headers = tenantId ? { "x-tenant-id": tenantId } : undefined;
|
||||||
|
const response = await apiClient.post("/ai/fallback-order", { fallback_order: fallbackOrder }, { headers });
|
||||||
|
return unwrap<string[]>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFallbackMapping(): Promise<Record<string, string>> {
|
||||||
|
const response = await apiClient.get("/ai/fallback-mapping");
|
||||||
|
return unwrap<Record<string, string>>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFallbackMapping(mapping: Record<string, string>): Promise<Record<string, string>> {
|
||||||
|
const response = await apiClient.post("/ai/fallback-mapping", { mapping });
|
||||||
|
return unwrap<Record<string, string>>(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const aiService = new AIService();
|
export const aiService = new AIService();
|
||||||
|
|||||||
@ -86,8 +86,11 @@ export interface TenantAIConfig {
|
|||||||
deployment?: string | null;
|
deployment?: string | null;
|
||||||
api_version?: string | null;
|
api_version?: string | null;
|
||||||
custom_models?: string[];
|
custom_models?: string[];
|
||||||
|
custom_embedding_models?: string[];
|
||||||
custom_pricing?: Record<string, { input: number; output: number }>;
|
custom_pricing?: Record<string, { input: number; output: number }>;
|
||||||
default_model?: string | null;
|
default_model?: string | null;
|
||||||
|
default_embedding_model?: string | null;
|
||||||
|
fallback_provider?: string | null;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
last_verified_at?: string | null;
|
last_verified_at?: string | null;
|
||||||
last_error?: string | null;
|
last_error?: string | null;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user