feat: implement AI management system with service layer, model definitions, and administrative UI modules

This commit is contained in:
sibarchannayak 2026-05-27 13:24:48 +05:30
parent 23c32409ed
commit 1144f83f3c
7 changed files with 684 additions and 5 deletions

View File

@ -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" },
],
},
];

View File

@ -74,7 +74,7 @@ export const ViewAIProviderModal = ({
Default Embedding Model
</span>
<span className="text-slate-800 font-medium">
{(config as any).default_embedding_model || "—"}
{config.default_embedding_model || "—"}
</span>
</div>
@ -113,9 +113,9 @@ export const ViewAIProviderModal = ({
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-1">
Custom Embedding Models
</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">
{parseArray((config as any).custom_embedding_models).map(
{parseArray(config.custom_embedding_models).map(
(m: any, idx: any) => (
<span
key={idx}

View 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;

View File

@ -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<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 {
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."
/>
)}
/>
<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
name="custom_models"
control={control}

View File

@ -19,6 +19,7 @@ const NotificationMaster = lazy(() => 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: <LazyRoute component={FailedEmails} />,
},
{
path: "/settings/ai-fallbacks",
element: <LazyRoute component={AIFallbackHistory} />,
},
];

View File

@ -310,6 +310,42 @@ class AIService {
const response = await apiClient.get(`/ai/prompts/test-cases/${testCaseId}/results`, { params: { limit } });
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();

View File

@ -86,8 +86,11 @@ export interface TenantAIConfig {
deployment?: string | null;
api_version?: string | null;
custom_models?: string[];
custom_embedding_models?: string[];
custom_pricing?: Record<string, { input: number; output: number }>;
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;