Display Error from Backend on the UI - Notification (#13427)

* fix sso logout

- add a new login page with sso button

* lint fix

* lint fix

* lint fix

* fix tests

* fix test

* Revert "fix test"

This reverts commit 74eb7345710892d5a9d02baec0ef389b98d0dde3.

* Reapply "fix test"

This reverts commit 72d0b2d4c62f6bb9351a7656ff88efc2ba91aef7.

* add host to add modal

* close modal after save is clicked. and auto-refresh

* show old values in edit modal

* send the whole payload on edit

* Update settings.tsx

* resolve conflict

* fix conflict

* merge main

* first draft of notifications added to settings

* add error compatibility by taking errors from the backend

- db errors
- auth errors

* add support for different types of errors

* minor

* name change

* email alerts page notifications modified

* remove unused code
This commit is contained in:
tanjiro
2025-08-09 04:34:16 +09:00
committed by GitHub
parent 51c2ff7c15
commit 4fdc866fcb
6 changed files with 435 additions and 61 deletions
+1
View File
@@ -2164,6 +2164,7 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
litellm_callback_params=[
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_HOST",
],
)
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { Card, Text, Grid, Button } from "@tremor/react";
import { Typography, message, Divider, Spin, Checkbox } from "antd";
import { Typography, Divider, Spin, Checkbox } from "antd";
import NotificationsManager from "../molecules/notifications_manager";
import { getEmailEventSettings, updateEmailEventSettings, resetEmailEventSettings } from "../networking";
import { EmailEvent } from "../../types";
import { EmailEventSetting } from "./types";
@@ -31,7 +32,7 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({
setEventSettings(response.settings);
} catch (error) {
console.error("Failed to fetch email event settings:", error);
message.error("Failed to fetch email event settings");
NotificationsManager.fromBackend(error);
} finally {
setLoading(false);
}
@@ -49,10 +50,10 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({
try {
await updateEmailEventSettings(accessToken, { settings: eventSettings });
message.success("Email event settings updated successfully");
NotificationsManager.success("Email event settings updated successfully");
} catch (error) {
console.error("Failed to update email event settings:", error);
message.error("Failed to update email event settings");
NotificationsManager.fromBackend(error);
}
};
@@ -61,12 +62,12 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({
try {
await resetEmailEventSettings(accessToken);
message.success("Email event settings reset to defaults");
NotificationsManager.success("Email event settings reset to defaults");
// Refresh settings after reset
fetchEventSettings();
} catch (error) {
console.error("Failed to reset email event settings:", error);
message.error("Failed to reset email event settings");
NotificationsManager.fromBackend(error);
}
};
@@ -7,7 +7,8 @@ import {
TextInput,
TableCell,
} from "@tremor/react";
import { Typography, message, Divider } from "antd";
import { Typography } from "antd";
import NotificationsManager from "./molecules/notifications_manager";
import { serviceHealthCheck, setCallbacksCall } from "./networking";
import { EmailEventSettings } from "./email_events";
@@ -24,7 +25,7 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({
premiumUser,
alerts,
}) => {
const handleSaveEmailSettings = () => {
const handleSaveEmailSettings = async () => {
if (!accessToken) {
return;
}
@@ -52,12 +53,11 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({
environment_variables: updatedVariables,
};
try {
setCallbacksCall(accessToken, payload);
await setCallbacksCall(accessToken, payload);
NotificationsManager.success("Email settings updated successfully");
} catch (error) {
message.error("Failed to update alerts: " + error, 20);
NotificationsManager.fromBackend(error);
}
message.success("Email settings updated successfully");
}
return (
@@ -191,9 +191,15 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({
Save Changes
</Button>
<Button
onClick={() =>
accessToken && serviceHealthCheck(accessToken, "email")
}
onClick={async () => {
if (!accessToken) return;
try {
await serviceHealthCheck(accessToken, "email");
NotificationsManager.success("Email test triggered. Check your configured email inbox/logs.");
} catch (error) {
NotificationsManager.fromBackend(error);
}
}}
className="mx-2"
>
Test Email Alerts
@@ -0,0 +1,318 @@
import { notification } from "antd"
import { parseErrorMessage } from "../shared/errorUtils"
type Placement = "top" | "topLeft" | "topRight" | "bottom" | "bottomLeft" | "bottomRight"
type NotificationConfig = {
message?: string
description?: string
duration?: number
placement?: Placement
key?: string
}
type NotificationConfigResolved = Omit<NotificationConfig, "message"> & { message: string }
function defaultPlacement(): Placement {
return "topRight"
}
function normalize(input: string | NotificationConfig, fallbackTitle: string): NotificationConfigResolved {
if (typeof input === "string") return { message: fallbackTitle, description: input }
return { message: input.message ?? fallbackTitle, ...input }
}
function toIntMaybe(val: any): number | undefined {
if (typeof val === "number") return val
if (typeof val === "string" && /^\d+$/.test(val)) return parseInt(val, 10)
return undefined
}
const AUTH_MATCH = [
"invalid api key",
"invalid authorization header format",
"authentication error",
"invalid proxy server token",
"invalid jwt token",
"invalid jwt submitted",
"unauthorized access to metrics endpoint",
];
const FORBIDDEN_MATCH = [
"admin-only endpoint",
"not allowed to access model",
"user does not have permission",
"access forbidden",
"invalid credentials used to access ui",
"user not allowed to access proxy",
];
const DB_MATCH = [
"db not connected",
"database not initialized",
"no db connected",
"prisma client not initialized",
"service unhealthy",
];
const ROUTER_MATCH = [
"no models configured on proxy",
"llm router not initialized",
"no deployments available",
"no healthy deployment available",
"not allowed to access model due to tags configuration",
"invalid model name passed in",
];
const RATE_LIMIT_EXTRA = [
"deployment over user-defined ratelimit",
"crossed tpm / rpm / max parallel request limit",
"max parallel request limit",
];
const BUDGET_MATCH = [
"budget exceeded",
"crossed budget",
"provider budget",
];
const ENTERPRISE_MATCH = [
"must be a litellm enterprise user",
"only be available for liteLLM enterprise users",
"missing litellm-enterprise package",
"only available on the docker image",
"enterprise feature",
"premium user",
];
const VALIDATION_MATCH = [
"invalid json payload",
"invalid request type",
"invalid key format",
"invalid hash key",
"invalid sort column",
"invalid sort order",
"invalid limit",
"invalid file type",
"invalid field",
"invalid date format",
];
const NOT_FOUND_MATCH = [
"model not found",
"model with id",
"credential not found",
"user not found",
"team not found",
"organization not found",
"mcp server with id",
"tool '", // will combine with “not found” in message
];
const EXISTS_MATCH = [
"already exists",
"team member is already in team",
"user already exists",
];
const GUARDRAIL_MATCH = [
"violated openai moderation policy",
"violated jailbreak threshold",
"violated prompt_injection threshold",
"violated content safety policy",
"violated lasso guardrail policy",
"blocked by pillar security guardrail",
"violated azure prompt shield guardrail policy",
"content blocked by model armor",
"response blocked by model armor",
"streaming response blocked by model armor",
"guardrail",
"moderation",
];
const FILE_UPLOAD_MATCH = [
"invalid purpose",
"service must be specified",
"invalid response - response.response is none",
];
const CLOUDZERO_MATCH = [
"cloudzero settings not configured",
"failed to decrypt cloudzero api key",
"cloudzero settings not found",
];
function titleFor(status?: number, desc?: string): string {
const d = (desc || "").toLowerCase();
if (AUTH_MATCH.some(s => d.includes(s))) return "Authentication Error";
if (FORBIDDEN_MATCH.some(s => d.includes(s))) return "Access Denied";
if (DB_MATCH?.some?.((s:string)=>d.includes(s)) || status === 503) return "Service Unavailable";
if (BUDGET_MATCH?.some?.((s:string)=>d.includes(s))) return "Budget Exceeded";
if (ENTERPRISE_MATCH?.some?.((s:string)=>d.includes(s))) return "Feature Unavailable";
if (ROUTER_MATCH?.some?.((s:string)=>d.includes(s))) return "Routing Error";
if (EXISTS_MATCH.some(s => d.includes(s))) return "Already Exists";
if (GUARDRAIL_MATCH.some(s => d.includes(s))) return "Content Blocked";
if (FILE_UPLOAD_MATCH.some(s => d.includes(s))) return "Validation Error";
if (CLOUDZERO_MATCH.some(s => d.includes(s))) return "Integration Error";
if (VALIDATION_MATCH.some(s => d.includes(s))) return "Validation Error";
if (status === 404 || d.includes("not found") || NOT_FOUND_MATCH.some(s => d.includes(s))) return "Not Found";
if (status === 429 || d.includes("rate limit") || d.includes("tpm") || d.includes("rpm") || RATE_LIMIT_EXTRA?.some?.((s:string)=>d.includes(s))) return "Rate Limit Exceeded";
if (status && status >= 500) return "Server Error";
if (status === 401) return "Authentication Error";
if (status === 403) return "Access Denied";
if (d.includes("enterprise") || d.includes("premium")) return "Info";
if (status && status >= 400) return "Request Error";
return "Error";
}
const SUCCESS_MATCH = [
"created successfully",
"updated successfully",
"deleted successfully",
"credential created successfully",
"model added successfully",
"team created successfully",
"user created successfully",
"organization created successfully",
"cloudzero settings initialized successfully",
"cloudzero settings updated successfully",
"cloudzero export completed successfully",
"mock llm request made",
"mock slack alert sent",
"mock email alert sent",
"spend for all api keys and teams reset successfully",
"monthlyglobalspend view refreshed",
"cache cleared successfully",
"cache set successfully",
"ip ",
"deleted successfully"
];
const INFO_MATCH = [
"rate limit reached for deployment",
"deployment cooldown period active",
];
const DEPRECATION_FEATURE_WARN_MATCH = [
"this feature is only available for litellm enterprise users",
"enterprise features are not available",
"regenerating virtual keys is an enterprise feature",
"trying to set allowed_routes. this is an enterprise feature",
];
const CONFIG_WARN_MATCH = [
"invalid maximum_spend_logs_retention_interval value",
"error has invalid or non-convertible code",
"failed to save health check to database",
];
function classifyGeneralMessage(desc?: string): { kind: "success" | "info" | "warning"; title: string } | null {
const d = (desc || "").toLowerCase();
if (SUCCESS_MATCH.some(s => d.includes(s))) return { kind: "success", title: "Success" };
if (DEPRECATION_FEATURE_WARN_MATCH.some(s => d.includes(s))) return { kind: "warning", title: "Feature Notice" };
if (CONFIG_WARN_MATCH.some(s => d.includes(s))) return { kind: "warning", title: "Configuration Warning" };
if (INFO_MATCH.some(s => d.includes(s))) return { kind: "warning", title: "Rate Limit" }; // show as warning for visibility
return null;
}
function extractStatus(input: any): number | undefined {
return toIntMaybe(input?.response?.status) ?? toIntMaybe(input?.status_code) ?? toIntMaybe(input?.code);
}
function extractDescription(input: any): string {
if (typeof input === "string") return input; // raw error string
const backendMsg =
input?.response?.data?.error?.message ??
input?.response?.data?.message ??
input?.response?.data?.error ??
input?.detail ??
input?.message ??
input;
return parseErrorMessage(backendMsg);
}
function looksErrorPayload(input: any, status?: number): boolean {
if (status !== undefined) return true;
if (input instanceof Error) return true;
if (typeof input === "string") return true; // treat raw strings passed to fromBackend as errors
if (input && typeof input === "object" && ("error" in input || "detail" in input)) return true;
return false;
}
const NotificationManager = {
error(input: string | NotificationConfig) {
const cfg = normalize(input, "Error")
notification.error({
...cfg,
placement: cfg.placement ?? defaultPlacement(),
duration: cfg.duration ?? 6,
})
},
warning(input: string | NotificationConfig) {
const cfg = normalize(input, "Warning")
notification.warning({
...cfg,
placement: cfg.placement ?? defaultPlacement(),
duration: cfg.duration ?? 5,
})
},
info(input: string | NotificationConfig) {
const cfg = normalize(input, "Info")
notification.info({
...cfg,
placement: cfg.placement ?? defaultPlacement(),
duration: cfg.duration ?? 4,
})
},
success(input: string | NotificationConfig) {
const cfg = normalize(input, "Success")
notification.success({
...cfg,
placement: cfg.placement ?? defaultPlacement(),
duration: cfg.duration ?? 3.5,
})
},
fromBackend(input: any, extra?: Omit<NotificationConfig, "message" | "description">) {
const status = extractStatus(input);
const description = extractDescription(input);
const base = { ...(extra ?? {}), description, placement: extra?.placement ?? defaultPlacement() };
if (looksErrorPayload(input, status)) {
const title = titleFor(status, description);
const payload = { ...base, message: title };
if (title === "Rate Limit Exceeded" || title === "Info" || title === "Budget Exceeded" || title === "Feature Unavailable" || title === "Content Blocked" || title === "Integration Error") {
notification.warning({ ...payload, duration: extra?.duration ?? 7 }); return;
}
if (title === "Server Error") { notification.error({ ...payload, duration: extra?.duration ?? 8 }); return; }
if (title === "Request Error" || title === "Authentication Error" || title === "Access Denied" || title === "Not Found" || title === "Error") {
notification.error({ ...payload, duration: extra?.duration ?? 6 }); return;
}
notification.info({ ...payload, duration: extra?.duration ?? 4 }); return;
}
// Non-error: success/info/warning classifier
const cls = classifyGeneralMessage(description);
const payload = { ...base, message: cls?.title ?? "Info" };
if (cls?.kind === "success") { notification.success({ ...payload, duration: extra?.duration ?? 3.5 }); return; }
if (cls?.kind === "warning") { notification.warning({ ...payload, duration: extra?.duration ?? 6 }); return; }
notification.info({ ...payload, duration: extra?.duration ?? 4 });
},
clear() {
notification.destroy()
},
}
export default NotificationManager
@@ -4264,9 +4264,6 @@ export const serviceHealthCheck = async (
}
const data = await response.json();
message.success(
`Test request to ${service} made - check logs/alerts on ${service} to verify`
);
// You can add additional logic here based on the response if needed
return data;
} catch (error) {
@@ -30,8 +30,8 @@ import {
Input,
Select,
Button as Button2,
message,
} from "antd";
import NotificationsManager from "./molecules/notifications_manager";
import EmailSettings from "./email_settings";
const { Title, Paragraph } = Typography;
@@ -49,6 +49,7 @@ import {
callbackInfo,
Callbacks,
} from "./callback_info_helpers";
import { parseErrorMessage } from "./shared/errorUtils";
interface SettingsPageProps {
accessToken: string | null;
userRole: string | null;
@@ -84,7 +85,8 @@ const Settings: React.FC<SettingsPageProps> = ({
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
const [alerts, setAlerts] = useState<any[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const [form] = Form.useForm();
const [addForm] = Form.useForm();
const [editForm] = Form.useForm();
const [selectedCallback, setSelectedCallback] = useState<string | null>(null);
const [catchAllWebhookURL, setCatchAllWebhookURL] = useState<string>("");
const [alertToWebhooks, setAlertToWebhooks] = useState<
@@ -106,6 +108,15 @@ const Settings: React.FC<SettingsPageProps> = ({
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [callbackToDelete, setCallbackToDelete] = useState<string | null>(null);
useEffect(() => {
if (showEditCallback && selectedEditCallback) {
const normalized = Object.fromEntries(
Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""])
);
editForm.setFieldsValue(normalized)
}
}, [showEditCallback, selectedEditCallback, editForm]);
const handleSwitchChange = (alertName: string) => {
if (activeAlerts.includes(alertName)) {
setActiveAlerts(activeAlerts.filter((alert) => alert !== alertName));
@@ -155,7 +166,7 @@ const Settings: React.FC<SettingsPageProps> = ({
};
const updateCallbackCall = async (formValues: Record<string, any>) => {
if (!accessToken) {
if (!accessToken || !selectedEditCallback) {
return;
}
@@ -167,17 +178,27 @@ const Settings: React.FC<SettingsPageProps> = ({
}
});
let payload = {
environment_variables: env_vars,
};
environment_variables: formValues,
litellm_settings: {
"success_callback": [selectedEditCallback.name]
}
}
try {
await setCallbacksCall(accessToken, payload);
message.success(`Callback added successfully`);
setIsModalVisible(false);
form.resetFields();
setSelectedCallback(null);
NotificationsManager.success("Callback updated successfully");
setShowEditCallback(false);
editForm.resetFields();
setSelectedEditCallback(null);
// Refresh the callbacks list
if (userID && userRole) {
const updatedData = await getCallbacksCall(accessToken, userID, userRole);
setCallbacks(updatedData.callbacks);
}
} catch (error) {
message.error("Failed to add callback: " + error, 20);
NotificationsManager.fromBackend(error);
}
};
@@ -196,7 +217,7 @@ const Settings: React.FC<SettingsPageProps> = ({
});
let payload = {
environment_variables: env_vars,
environment_variables: formValues,
litellm_settings: {
success_callback: [new_callback],
},
@@ -204,12 +225,17 @@ const Settings: React.FC<SettingsPageProps> = ({
try {
await setCallbacksCall(accessToken, payload);
message.success(`Callback ${new_callback} added successfully`);
setIsModalVisible(false);
form.resetFields();
NotificationsManager.success(`Callback ${new_callback} added successfully`);
setShowAddCallbacksModal(false);
addForm.resetFields();
setSelectedCallback(null);
setSelectedCallbackParams([]);
// Refresh the callbacks list
const updatedData = await getCallbacksCall(accessToken, userID || "", userRole || "");
setCallbacks(updatedData.callbacks);
} catch (error) {
message.error("Failed to add callback: " + error, 20);
NotificationsManager.fromBackend(error);
}
};
@@ -225,7 +251,7 @@ const Settings: React.FC<SettingsPageProps> = ({
}
};
const handleSaveAlerts = () => {
const handleSaveAlerts = async () => {
if (!accessToken) {
return;
}
@@ -247,12 +273,11 @@ const Settings: React.FC<SettingsPageProps> = ({
};
try {
setCallbacksCall(accessToken, payload);
await setCallbacksCall(accessToken, payload);
} catch (error) {
message.error("Failed to update alerts: " + error, 20);
NotificationsManager.fromBackend(error);
}
message.success("Alerts updated successfully");
NotificationsManager.success("Alerts updated successfully");
};
const handleSaveChanges = (callback: any) => {
if (!accessToken) {
@@ -277,10 +302,9 @@ const Settings: React.FC<SettingsPageProps> = ({
try {
setCallbacksCall(accessToken, payload);
} catch (error) {
message.error("Failed to update callback: " + error, 20);
NotificationsManager.fromBackend(error);
}
message.success("Callback updated successfully");
NotificationsManager.success("Callback updated successfully");
};
const handleOk = () => {
@@ -288,7 +312,7 @@ const Settings: React.FC<SettingsPageProps> = ({
return;
}
// Handle form submission
form.validateFields().then((values) => {
addForm.validateFields().then((values) => {
// Call API to add the callback
let payload;
if (values.callback === "langfuse") {
@@ -365,7 +389,7 @@ const Settings: React.FC<SettingsPageProps> = ({
};
}
setIsModalVisible(false);
form.resetFields();
addForm.resetFields();
setSelectedCallback(null);
});
};
@@ -382,7 +406,7 @@ const Settings: React.FC<SettingsPageProps> = ({
try {
await deleteCallback(accessToken, callbackToDelete);
message.success(`Callback ${callbackToDelete} deleted successfully`);
NotificationsManager.success(`Callback ${callbackToDelete} deleted successfully`);
// Refresh the callbacks list
if (userID && userRole) {
@@ -394,7 +418,7 @@ const Settings: React.FC<SettingsPageProps> = ({
setCallbackToDelete(null);
} catch (error) {
console.error("Failed to delete callback:", error);
message.error(`Failed to delete callback: ${error}`);
NotificationsManager.fromBackend(error);
}
};
@@ -450,9 +474,14 @@ const Settings: React.FC<SettingsPageProps> = ({
className="text-red-500 hover:text-red-700 cursor-pointer"
/>
<Button
onClick={() =>
serviceHealthCheck(accessToken, callback.name)
}
onClick={async () => {
try {
await serviceHealthCheck(accessToken, callback.name);
NotificationsManager.success("Health check triggered");
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
className="ml-2"
variant="secondary"
>
@@ -551,7 +580,14 @@ const Settings: React.FC<SettingsPageProps> = ({
</Button>
<Button
onClick={() => serviceHealthCheck(accessToken, "slack")}
onClick={async () => {
try {
await serviceHealthCheck(accessToken, "slack");
NotificationsManager.success("Alert test triggered. Test request to slack made - check logs/alerts on slack to verify");
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
className="mx-2"
>
Test Alerts
@@ -579,7 +615,11 @@ const Settings: React.FC<SettingsPageProps> = ({
title="Add Logging Callback"
visible={showAddCallbacksModal}
width={800}
onCancel={() => setShowAddCallbacksModal(false)}
onCancel= {() => {
setShowAddCallbacksModal(false)
setSelectedCallback(null);
setSelectedCallbackParams([]);
}}
footer={null}
>
<a
@@ -593,7 +633,7 @@ const Settings: React.FC<SettingsPageProps> = ({
</a>
<Form
form={form}
form={addForm}
onFinish={addNewCallbackCall}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
@@ -659,7 +699,7 @@ const Settings: React.FC<SettingsPageProps> = ({
},
]}
>
<TextInput type="password" />
<Input.Password />
</FormItem>
))}
@@ -674,11 +714,14 @@ const Settings: React.FC<SettingsPageProps> = ({
visible={showEditCallback}
width={800}
title={`Edit ${selectedEditCallback?.name} Settings`}
onCancel={() => setShowEditCallback(false)}
onCancel={() => {
setShowEditCallback(false)
setSelectedEditCallback(null);
}}
footer={null}
>
<Form
form={form}
form={editForm}
onFinish={updateCallbackCall}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
@@ -687,13 +730,21 @@ const Settings: React.FC<SettingsPageProps> = ({
<>
{selectedEditCallback &&
selectedEditCallback.variables &&
Object.entries(selectedEditCallback.variables).map(
([param, value]) => (
<FormItem label={param} name={param} key={param}>
<TextInput type="password" defaultValue={value as string} />
</FormItem>
)
)}
Object.entries(selectedEditCallback.variables).map(([param]) => (
<FormItem
label={param}
name={param}
key={param}
rules={[
{
required: true,
message: `Please enter the value for ${param}`,
},
]}
>
<Input.Password />
</FormItem>
))}
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>