healthcheck-model_id-fix: There was quite a bit of code that needed to be changed since health checks were entirely keyed by model name. This includes, proxy logic, the dashboard, and even networking, because model name was the identifier everywhere. All changed files had tests added to them, which are passing with no regressions.

This commit is contained in:
Alejandro Tapia
2026-02-12 14:47:09 -08:00
parent d47b7b763f
commit 82f6d0fe43
11 changed files with 469 additions and 137 deletions
+11 -1
View File
@@ -190,10 +190,15 @@ async def perform_health_check(
model: Optional[str] = None,
cli_model: Optional[str] = None,
details: Optional[bool] = True,
model_id: Optional[str] = None,
):
"""
Perform a health check on the system.
When model_id is provided, only the deployment with that id is checked
(so models that share the same name but have different ids are checked separately).
When model (name) is provided, all deployments matching that name are checked.
Returns:
(bool): True if the health check passes, False otherwise.
"""
@@ -205,7 +210,12 @@ async def perform_health_check(
else:
return [], []
if model is not None:
# Filter by model_id first so a single deployment is checked when id is specified
if model_id is not None:
_by_id = [x for x in model_list if (x.get("model_info") or {}).get("id") == model_id]
if _by_id:
model_list = _by_id
elif model is not None:
_new_model_list = [
x for x in model_list if x["litellm_params"]["model"] == model
]
@@ -238,7 +238,7 @@ async def health_services_endpoint( # noqa: PLR0915
service_in_success_callbacks = True
else:
for cb in litellm.success_callback:
if hasattr(cb, 'callback_name') and cb.callback_name == service:
if getattr(cb, "callback_name", None) == service:
service_in_success_callbacks = True
break
cb_id = get_callback_identifier(cb)
@@ -732,7 +732,11 @@ async def _perform_health_check_and_save(
):
"""Helper function to perform health check and save results to database"""
healthy_endpoints, unhealthy_endpoints = await perform_health_check(
model_list=model_list, cli_model=cli_model, model=target_model, details=details
model_list=model_list,
cli_model=cli_model,
model=target_model,
details=details,
model_id=model_id,
)
# Optionally save health check result to database (non-blocking)
@@ -471,6 +471,50 @@ def test_update_litellm_params_for_health_check():
)
@pytest.mark.asyncio
async def test_perform_health_check_filters_by_model_id():
"""
When model_id is passed, only that deployment is checked (not all deployments
that share the same model name).
"""
from litellm.proxy.health_check import perform_health_check
# Two deployments with same model_name but different ids
model_list = [
{
"model_name": "gpt-4",
"model_info": {"id": "deployment-id-1"},
"litellm_params": {"model": "gpt-4", "api_key": "fake-key-1"},
},
{
"model_name": "gpt-4",
"model_info": {"id": "deployment-id-2"},
"litellm_params": {"model": "gpt-4", "api_key": "fake-key-2"},
},
]
captured_list = []
async def mock_perform_health_check(m_list, details=True):
captured_list.append(m_list)
return [{"model": "gpt-4", "api_key": m_list[0]["litellm_params"]["api_key"]}], []
with patch(
"litellm.proxy.health_check._perform_health_check",
side_effect=mock_perform_health_check,
):
healthy_endpoints, unhealthy_endpoints = await perform_health_check(
model_list=model_list, model_id="deployment-id-2", details=True
)
# Only one deployment (deployment-id-2) should have been passed to _perform_health_check
assert len(captured_list) == 1
assert len(captured_list[0]) == 1
assert (captured_list[0][0].get("model_info") or {}).get("id") == "deployment-id-2"
assert len(healthy_endpoints) == 1
assert healthy_endpoints[0]["api_key"] == "fake-key-2"
@pytest.mark.asyncio
async def test_perform_health_check_with_health_check_model():
"""
@@ -12,6 +12,7 @@ sys.path.insert(0, os.path.abspath("../../.."))
from litellm.proxy.health_endpoints._health_endpoints import (
_aggregate_health_check_results,
_build_model_param_to_info_mapping,
_perform_health_check_and_save,
_save_background_health_checks_to_db,
_save_health_check_results_if_changed,
_save_health_check_to_db,
@@ -466,5 +467,43 @@ async def test_get_all_latest_health_checks_without_model_id(mock_prisma):
assert result[0].checked_at == mock_check2.checked_at # Latest
@pytest.mark.asyncio
async def test_perform_health_check_and_save_passes_model_id_to_perform_health_check():
"""Test that _perform_health_check_and_save passes model_id to perform_health_check so health checks run by model id."""
model_list = [
{
"model_name": "gpt-4",
"model_info": {"id": "deployment-abc"},
"litellm_params": {"model": "gpt-4"},
},
]
healthy = [{"model": "gpt-4"}]
unhealthy = []
async def mock_perform_health_check(model_list, model=None, cli_model=None, details=True, model_id=None):
return healthy, unhealthy
with patch(
"litellm.proxy.health_endpoints._health_endpoints.perform_health_check",
side_effect=mock_perform_health_check,
) as mock_perform:
result = await _perform_health_check_and_save(
model_list=model_list,
target_model=None,
cli_model=None,
details=True,
prisma_client=None,
start_time=0.0,
user_id="user-1",
model_id="deployment-abc",
)
mock_perform.assert_called_once()
call_kwargs = mock_perform.call_args[1]
assert call_kwargs["model_id"] == "deployment-abc"
assert result["healthy_count"] == 1
assert result["unhealthy_count"] == 0
if __name__ == "__main__":
pytest.main([__file__])
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { act, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelsAndEndpointsView from "./ModelsAndEndpointsView";
@@ -13,6 +13,8 @@ vi.mock("@/components/networking", () => ({
getCallbacksCall: vi.fn().mockResolvedValue({ router_settings: {} }),
setCallbacksCall: vi.fn().mockResolvedValue(undefined),
getUiSettings: vi.fn().mockResolvedValue({ values: {} }),
latestHealthChecksCall: vi.fn().mockResolvedValue({ latest_health_checks: {} }),
getModelCostMapReloadStatus: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/app/(dashboard)/models-and-endpoints/components/ModelAnalyticsTab/ModelAnalyticsTab", () => ({
@@ -27,6 +29,14 @@ vi.mock("@/components/add_model/AddModelForm", () => ({
default: () => null,
}));
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
default: (props: { all_models_on_proxy?: string[] }) => {
mockHealthCheckComponent(props);
return null;
},
}));
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
default: () => ({
teams: [],
@@ -104,4 +114,43 @@ describe("ModelsAndEndpointsView", () => {
);
expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument();
}, 15000);
it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => {
mockHealthCheckComponent.mockClear();
const modelDataWithIds = {
data: [
{ model_name: "gpt-4", model_info: { id: "deployment-id-1" } },
{ model_name: "gpt-4", model_info: { id: "deployment-id-2" } },
],
};
mockUseModelsInfo.mockReturnValue({
data: { data: modelDataWithIds.data },
isLoading: false,
refetch: vi.fn(),
});
const queryClient = createQueryClient();
const { getByRole } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView
token="123"
modelData={{ data: modelDataWithIds.data }}
keys={[]}
setModelData={() => {}}
premiumUser={false}
teams={[]}
/>
</QueryClientProvider>,
);
const healthStatusTab = getByRole("tab", { name: "Health Status" });
await act(async () => {
healthStatusTab.click();
});
expect(mockHealthCheckComponent).toHaveBeenCalled();
const healthCheckProps = mockHealthCheckComponent.mock.calls[0][0];
expect(healthCheckProps.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]);
expect(healthCheckProps.all_models_on_proxy).not.toContain("gpt-4");
});
});
@@ -98,6 +98,13 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
return modelDataResponse.data.map((model: any) => model.model_name);
}, [modelDataResponse?.data]);
const allModelIdsOnProxy = useMemo<string[]>(() => {
if (!modelDataResponse?.data) return [];
return modelDataResponse.data
.map((model: any) => model.model_info?.id)
.filter((id: string | undefined): id is string => Boolean(id));
}, [modelDataResponse?.data]);
const getProviderFromModel = (model: string) => {
if (modelCostMapData !== null && modelCostMapData !== undefined) {
if (typeof modelCostMapData == "object" && model in modelCostMapData) {
@@ -397,7 +404,7 @@ const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, te
<HealthCheckComponent
accessToken={accessToken}
modelData={processedModelData}
all_models_on_proxy={allModelsOnProxy}
all_models_on_proxy={allModelIdsOnProxy}
getDisplayModelName={getDisplayModelName}
setSelectedModelId={setSelectedModelId}
teams={teams}
@@ -0,0 +1,147 @@
/* @vitest-environment jsdom */
import { act, render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import HealthCheckComponent from "./HealthCheckComponent";
const mockIndividualModelHealthCheckCall = vi.fn();
const mockLatestHealthChecksCall = vi.fn();
vi.mock("../networking", () => ({
individualModelHealthCheckCall: (...args: unknown[]) => mockIndividualModelHealthCheckCall(...args),
latestHealthChecksCall: (...args: unknown[]) => mockLatestHealthChecksCall(...args),
}));
describe("HealthCheckComponent", () => {
const getDisplayModelName = (model: { model_name?: string }) => model.model_name ?? "";
beforeEach(() => {
vi.clearAllMocks();
mockLatestHealthChecksCall.mockResolvedValue({ latest_health_checks: {} });
mockIndividualModelHealthCheckCall.mockResolvedValue({
healthy_count: 1,
unhealthy_count: 0,
healthy_endpoints: [],
unhealthy_endpoints: [],
});
});
it("should render the health check section", async () => {
const modelData = {
data: [
{
model_name: "gpt-4",
model_info: { id: "deployment-1" },
litellm_model_name: "gpt-4",
},
],
};
await act(async () => {
render(
<HealthCheckComponent
accessToken="token"
modelData={modelData}
all_models_on_proxy={["deployment-1"]}
getDisplayModelName={getDisplayModelName}
/>,
);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(screen.getByText("Model Health Status")).toBeInTheDocument();
expect(
screen.getByText("Run health checks on individual models to verify they are working correctly"),
).toBeInTheDocument();
});
it("should call individualModelHealthCheckCall with model id when run health check is triggered", async () => {
const modelData = {
data: [
{
model_name: "gpt-4",
model_info: { id: "deployment-abc-123" },
litellm_model_name: "gpt-4",
},
],
};
render(
<HealthCheckComponent
accessToken="token-123"
modelData={modelData}
all_models_on_proxy={["deployment-abc-123"]}
getDisplayModelName={getDisplayModelName}
/>,
);
const runButtons = screen.getAllByTestId("run-health-check-btn");
expect(runButtons.length).toBeGreaterThanOrEqual(1);
const runButton = runButtons[0];
await act(async () => {
runButton.click();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 50));
});
expect(mockIndividualModelHealthCheckCall).toHaveBeenCalledWith("token-123", "deployment-abc-123");
expect(mockIndividualModelHealthCheckCall).not.toHaveBeenCalledWith("token-123", "gpt-4");
});
it("should key health status by model id and show status from latest_health_checks by model_id", async () => {
const modelData = {
data: [
{
model_name: "gpt-4",
model_info: { id: "id-alpha" },
litellm_model_name: "gpt-4",
},
{
model_name: "gpt-4",
model_info: { id: "id-beta" },
litellm_model_name: "gpt-4",
},
],
};
mockLatestHealthChecksCall.mockResolvedValue({
latest_health_checks: {
"id-alpha": {
status: "healthy",
checked_at: "2024-01-15T10:00:00Z",
error_message: null,
},
"id-beta": {
status: "unhealthy",
checked_at: "2024-01-15T10:05:00Z",
error_message: "Connection failed",
},
},
});
await act(async () => {
render(
<HealthCheckComponent
accessToken="token"
modelData={modelData}
all_models_on_proxy={["id-alpha", "id-beta"]}
getDisplayModelName={getDisplayModelName}
/>,
);
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
expect(mockLatestHealthChecksCall).toHaveBeenCalledWith("token");
const healthyBadges = screen.getAllByText("healthy");
const unhealthyBadges = screen.getAllByText("unhealthy");
expect(healthyBadges.length).toBeGreaterThanOrEqual(1);
expect(unhealthyBadges.length).toBeGreaterThanOrEqual(1);
});
});
@@ -53,31 +53,33 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
const healthTableRef = useRef<TableInstance<any>>(null);
// Initialize health statuses on component mount
// Initialize health statuses on component mount (keyed by model id)
useEffect(() => {
if (!accessToken || !modelData?.data) return;
const initializeHealthStatuses = async () => {
const healthStatusMap: { [key: string]: HealthStatus } = {};
// Initialize all models with default state using model names
// Initialize all models with default state using model ids
modelData.data.forEach((model: any) => {
const modelName = model.model_name;
healthStatusMap[modelName] = {
status: "none",
lastCheck: "None",
lastSuccess: "None",
loading: false,
error: undefined,
fullError: undefined,
successResponse: undefined,
};
const modelId = model.model_info?.id;
if (modelId) {
healthStatusMap[modelId] = {
status: "none",
lastCheck: "None",
lastSuccess: "None",
loading: false,
error: undefined,
fullError: undefined,
successResponse: undefined,
};
}
});
try {
const latestHealthChecks = await latestHealthChecksCall(accessToken);
// Override with actual database data if it exists
// Override with actual database data if it exists (latest_health_checks is keyed by model_id)
if (
latestHealthChecks &&
latestHealthChecks.latest_health_checks &&
@@ -86,32 +88,28 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
Object.entries(latestHealthChecks.latest_health_checks).forEach(([key, checkData]: [string, any]) => {
if (!checkData) return;
let targetModelName: string | null = null;
let targetModelId: string | null = null;
// The key could be either model_id or model_name, try both approaches
const directModelMatch = modelData.data.find((m: any) => m.model_name === key);
if (directModelMatch) {
targetModelName = directModelMatch.model_name;
// The key is model_id from the backend; fallback to matching by model_name for legacy data
const modelByIdMatch = modelData.data.find((m: any) => m.model_info && m.model_info.id === key);
if (modelByIdMatch) {
targetModelId = modelByIdMatch.model_info.id;
} else {
// If not a direct match, treat as model_id and find the corresponding model
const modelByIdMatch = modelData.data.find((m: any) => m.model_info && m.model_info.id === key);
if (modelByIdMatch) {
targetModelName = modelByIdMatch.model_name;
} else {
// Check if checkData contains model_name and use that
if (checkData.model_name) {
const modelByNameInData = modelData.data.find((m: any) => m.model_name === checkData.model_name);
if (modelByNameInData) {
targetModelName = modelByNameInData.model_name;
}
const directModelMatch = modelData.data.find((m: any) => m.model_name === key);
if (directModelMatch?.model_info?.id) {
targetModelId = directModelMatch.model_info.id;
} else if (checkData.model_name) {
const modelByNameInData = modelData.data.find((m: any) => m.model_name === checkData.model_name);
if (modelByNameInData?.model_info?.id) {
targetModelId = modelByNameInData.model_info.id;
}
}
}
if (targetModelName) {
if (targetModelId) {
const fullError = checkData.error_message || undefined;
healthStatusMap[targetModelName] = {
healthStatusMap[targetModelId] = {
status: checkData.status || "unknown",
lastCheck: checkData.checked_at ? new Date(checkData.checked_at).toLocaleString() : "None",
lastSuccess:
@@ -246,33 +244,31 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
return cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned;
};
const runIndividualHealthCheck = async (modelName: string) => {
const runIndividualHealthCheck = async (modelId: string) => {
if (!accessToken) return;
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
...prev[modelName],
[modelId]: {
...prev[modelId],
loading: true,
status: "checking",
},
}));
try {
// Run the health check and process the response directly
const response = await individualModelHealthCheckCall(accessToken, modelName);
const response = await individualModelHealthCheckCall(accessToken, modelId);
const currentTime = new Date().toLocaleString();
// Check if there are any unhealthy endpoints (which means this specific model failed)
if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) {
const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed";
const errorMessage = extractMeaningfulError(rawError);
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
[modelId]: {
status: "unhealthy",
lastCheck: currentTime,
lastSuccess: prev[modelName]?.lastSuccess || "None",
lastSuccess: prev[modelId]?.lastSuccess || "None",
loading: false,
error: errorMessage,
fullError: rawError,
@@ -281,7 +277,7 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
} else {
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
[modelId]: {
status: "healthy",
lastCheck: currentTime,
lastSuccess: currentTime,
@@ -291,41 +287,33 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
}));
}
// Refresh health status from database to get the saved check data including timestamp
try {
const latestHealthChecks = await latestHealthChecksCall(accessToken);
const checkData = latestHealthChecks.latest_health_checks?.[modelId];
// Find the model ID for this model name to look up database data
const model = modelData.data.find((m: any) => m.model_name === modelName);
if (model) {
const modelId = model.model_info.id;
const checkData = latestHealthChecks.latest_health_checks?.[modelId];
if (checkData) {
const fullError = checkData.error_message || undefined;
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
status: checkData.status || prev[modelName]?.status || "unknown",
lastCheck: checkData.checked_at
? new Date(checkData.checked_at).toLocaleString()
: prev[modelName]?.lastCheck || "None",
lastSuccess:
checkData.status === "healthy"
? checkData.checked_at
? new Date(checkData.checked_at).toLocaleString()
: prev[modelName]?.lastSuccess || "None"
: prev[modelName]?.lastSuccess || "None",
loading: false,
error: fullError ? extractMeaningfulError(fullError) : prev[modelName]?.error,
fullError: fullError || prev[modelName]?.fullError,
successResponse: checkData.status === "healthy" ? checkData : prev[modelName]?.successResponse,
},
}));
}
if (checkData) {
const fullError = checkData.error_message || undefined;
setModelHealthStatuses((prev) => ({
...prev,
[modelId]: {
status: checkData.status || prev[modelId]?.status || "unknown",
lastCheck: checkData.checked_at
? new Date(checkData.checked_at).toLocaleString()
: prev[modelId]?.lastCheck || "None",
lastSuccess:
checkData.status === "healthy"
? checkData.checked_at
? new Date(checkData.checked_at).toLocaleString()
: prev[modelId]?.lastSuccess || "None"
: prev[modelId]?.lastSuccess || "None",
loading: false,
error: fullError ? extractMeaningfulError(fullError) : prev[modelId]?.error,
fullError: fullError || prev[modelId]?.fullError,
successResponse: checkData.status === "healthy" ? checkData : prev[modelId]?.successResponse,
},
}));
}
} catch (dbError) {
// Ignore database errors - we already have the health check result from the API call
console.debug("Could not fetch updated status from database (non-critical):", dbError);
}
} catch (error) {
@@ -334,10 +322,10 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
const errorMessage = extractMeaningfulError(rawError);
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
[modelId]: {
status: "unhealthy",
lastCheck: currentTime,
lastSuccess: prev[modelName]?.lastSuccess || "None",
lastSuccess: prev[modelId]?.lastSuccess || "None",
loading: false,
error: errorMessage,
fullError: rawError,
@@ -349,11 +337,10 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
const runAllHealthChecks = async () => {
const modelsToCheck = selectedModelsForHealth.length > 0 ? selectedModelsForHealth : all_models_on_proxy;
// Set all models to loading state
const loadingStatuses = modelsToCheck.reduce(
(acc, modelName) => {
acc[modelName] = {
...modelHealthStatuses[modelName],
(acc, modelId) => {
acc[modelId] = {
...modelHealthStatuses[modelId],
loading: true,
status: "checking",
};
@@ -364,30 +351,25 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
setModelHealthStatuses((prev) => ({ ...prev, ...loadingStatuses }));
// Store results from individual health checks
const healthCheckResults: { [key: string]: any } = {};
// Run all health checks in parallel and collect results
const healthCheckPromises = modelsToCheck.map(async (modelName) => {
const healthCheckPromises = modelsToCheck.map(async (modelId) => {
if (!accessToken) return;
try {
// Run the health check and store the result
const response = await individualModelHealthCheckCall(accessToken, modelName);
healthCheckResults[modelName] = response;
const response = await individualModelHealthCheckCall(accessToken, modelId);
healthCheckResults[modelId] = response;
// Update status immediately based on response
const currentTime = new Date().toLocaleString();
// Check if there are any unhealthy endpoints (which means this specific model failed)
if (response.unhealthy_count > 0 && response.unhealthy_endpoints && response.unhealthy_endpoints.length > 0) {
const rawError = response.unhealthy_endpoints[0]?.error || "Health check failed";
const errorMessage = extractMeaningfulError(rawError);
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
[modelId]: {
status: "unhealthy",
lastCheck: currentTime,
lastSuccess: prev[modelName]?.lastSuccess || "None",
lastSuccess: prev[modelId]?.lastSuccess || "None",
loading: false,
error: errorMessage,
fullError: rawError,
@@ -396,7 +378,7 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
} else {
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
[modelId]: {
status: "healthy",
lastCheck: currentTime,
lastSuccess: currentTime,
@@ -406,17 +388,16 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
}));
}
} catch (error) {
console.error(`Health check failed for ${modelName}:`, error);
// Set error status for failed health checks
console.error(`Health check failed for model id ${modelId}:`, error);
const currentTime = new Date().toLocaleString();
const rawError = error instanceof Error ? error.message : String(error);
const errorMessage = extractMeaningfulError(rawError);
setModelHealthStatuses((prev) => ({
...prev,
[modelName]: {
[modelId]: {
status: "unhealthy",
lastCheck: currentTime,
lastSuccess: prev[modelName]?.lastSuccess || "None",
lastSuccess: prev[modelId]?.lastSuccess || "None",
loading: false,
error: errorMessage,
fullError: rawError,
@@ -425,27 +406,21 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
}
});
// Wait for all health checks to complete
await Promise.allSettled(healthCheckPromises);
// Refresh health statuses from database to get the saved check data including timestamps
try {
if (!accessToken) return;
const latestHealthChecks = await latestHealthChecksCall(accessToken);
if (latestHealthChecks.latest_health_checks) {
// Update health statuses from database, which should have the most accurate saved data
Object.entries(latestHealthChecks.latest_health_checks).forEach(([modelId, checkData]: [string, any]) => {
// Find the model name for this model ID
const model = modelData.data.find((m: any) => m.model_info.id === modelId);
if (model && modelsToCheck.includes(model.model_name) && checkData) {
const modelName = model.model_name;
if (modelsToCheck.includes(modelId) && checkData) {
const fullError = checkData.error_message || undefined;
setModelHealthStatuses((prev) => {
const currentStatus = prev[modelName];
const currentStatus = prev[modelId];
return {
...prev,
[modelName]: {
[modelId]: {
status: checkData.status || currentStatus?.status || "unknown",
lastCheck: checkData.checked_at
? new Date(checkData.checked_at).toLocaleString()
@@ -468,15 +443,14 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
}
} catch (dbError) {
console.warn("Failed to fetch updated health statuses from database (non-critical):", dbError);
// This is non-critical - we already have the health check results from the API calls
}
};
const handleModelSelection = (modelName: string, checked: boolean) => {
const handleModelSelection = (modelId: string, checked: boolean) => {
if (checked) {
setSelectedModelsForHealth((prev) => [...prev, modelName]);
setSelectedModelsForHealth((prev) => [...prev, modelId]);
} else {
setSelectedModelsForHealth((prev) => prev.filter((name) => name !== modelName));
setSelectedModelsForHealth((prev) => prev.filter((id) => id !== modelId));
setAllModelsSelected(false);
}
};
@@ -580,8 +554,9 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
teams,
)}
data={modelData.data.map((model: any) => {
const modelName = model.model_name;
const healthStatus = modelHealthStatuses[modelName] || {
const modelId = model.model_info?.id;
const healthStatus = modelId ? modelHealthStatuses[modelId] : null;
const status = healthStatus || {
status: "none",
lastCheck: "None",
loading: false,
@@ -591,12 +566,12 @@ const HealthCheckComponent: React.FC<HealthCheckComponentProps> = ({
model_info: model.model_info,
provider: model.provider,
litellm_model_name: model.litellm_model_name,
health_status: healthStatus.status,
last_check: healthStatus.lastCheck,
last_success: healthStatus.lastSuccess || "None",
health_loading: healthStatus.loading,
health_error: healthStatus.error,
health_full_error: healthStatus.fullError,
health_status: status.status,
last_check: status.lastCheck,
last_success: status.lastSuccess || "None",
health_loading: status.loading,
health_error: status.error,
health_full_error: status.fullError,
};
})}
isLoading={false}
@@ -35,9 +35,9 @@ export const healthCheckColumns = (
modelHealthStatuses: { [key: string]: HealthStatus },
selectedModelsForHealth: string[],
allModelsSelected: boolean,
handleModelSelection: (modelName: string, checked: boolean) => void,
handleModelSelection: (modelId: string, checked: boolean) => void,
handleSelectAll: (checked: boolean) => void,
runIndividualHealthCheck: (modelName: string) => void,
runIndividualHealthCheck: (modelId: string) => void,
getStatusBadge: (status: string) => JSX.Element,
getDisplayModelName: (model: any) => string,
showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void,
@@ -62,14 +62,14 @@ export const healthCheckColumns = (
sortingFn: "alphanumeric",
cell: ({ row }) => {
const model = row.original;
const modelName = model.model_name;
const isSelected = selectedModelsForHealth.includes(modelName);
const modelId = model.model_info?.id ?? "";
const isSelected = selectedModelsForHealth.includes(modelId);
return (
<div className="flex items-center gap-2">
<Checkbox
checked={isSelected}
onChange={(e) => handleModelSelection(modelName, e.target.checked)}
onChange={(e) => handleModelSelection(modelId, e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
<Tooltip title={model.model_info.id}>
@@ -169,8 +169,9 @@ export const healthCheckColumns = (
);
}
const modelName = model.model_name;
const hasSuccessResponse = healthStatus.status === "healthy" && modelHealthStatuses[modelName]?.successResponse;
const modelId = model.model_info?.id ?? "";
const displayName = getDisplayModelName(model) || model.model_name;
const hasSuccessResponse = healthStatus.status === "healthy" && modelHealthStatuses[modelId]?.successResponse;
return (
<div className="flex items-center space-x-2">
@@ -178,7 +179,7 @@ export const healthCheckColumns = (
{hasSuccessResponse && showSuccessModal && (
<Tooltip title="View response details" placement="top">
<button
onClick={() => showSuccessModal(modelName, modelHealthStatuses[modelName]?.successResponse)}
onClick={() => showSuccessModal(displayName, modelHealthStatuses[modelId]?.successResponse)}
className="p-1 text-green-600 hover:text-green-800 hover:bg-green-50 rounded cursor-pointer transition-colors"
>
<InformationCircleIcon className="h-4 w-4" />
@@ -195,8 +196,9 @@ export const healthCheckColumns = (
enableSorting: false,
cell: ({ row }) => {
const model = row.original;
const modelName = model.model_name;
const healthStatus = modelHealthStatuses[modelName];
const modelId = model.model_info?.id ?? "";
const displayName = getDisplayModelName(model) || model.model_name;
const healthStatus = modelHealthStatuses[modelId];
if (!healthStatus?.error) {
return <Text className="text-gray-400 text-sm">No errors</Text>;
@@ -215,7 +217,7 @@ export const healthCheckColumns = (
{showErrorModal && fullError !== cleanedError && (
<Tooltip title="View full error details" placement="top">
<button
onClick={() => showErrorModal(modelName, cleanedError, fullError)}
onClick={() => showErrorModal(displayName, cleanedError, fullError)}
className="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded cursor-pointer transition-colors"
>
<InformationCircleIcon className="h-4 w-4" />
@@ -294,8 +296,8 @@ export const healthCheckColumns = (
},
cell: ({ row }) => {
const model = row.original;
const modelName = model.model_name;
const healthStatus = modelHealthStatuses[modelName];
const modelId = model.model_info?.id ?? "";
const healthStatus = modelHealthStatuses[modelId];
const lastSuccess = healthStatus?.lastSuccess || "None";
return <Text className="text-gray-600 text-sm">{lastSuccess}</Text>;
@@ -306,7 +308,7 @@ export const healthCheckColumns = (
id: "actions",
cell: ({ row }) => {
const model = row.original;
const modelName = model.model_name;
const modelId = model.model_info?.id ?? "";
const hasExistingStatus = model.health_status && model.health_status !== "none";
const tooltipText = model.health_loading
@@ -318,6 +320,7 @@ export const healthCheckColumns = (
return (
<Tooltip title={tooltipText} placement="top">
<button
data-testid="run-health-check-btn"
className={`p-2 rounded-md transition-colors ${
model.health_loading
? "text-gray-400 cursor-not-allowed bg-gray-100"
@@ -325,7 +328,7 @@ export const healthCheckColumns = (
}`}
onClick={() => {
if (!model.health_loading) {
runIndividualHealthCheck(modelName);
runIndividualHealthCheck(modelId);
}
}}
disabled={model.health_loading}
@@ -317,3 +317,57 @@ describe("UI config and public endpoints", () => {
expect(configCall).toBeDefined();
});
});
describe("individualModelHealthCheckCall", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
it("should call /health with model_id query param so health checks run by deployment id", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
healthy_count: 1,
unhealthy_count: 0,
healthy_endpoints: [],
unhealthy_endpoints: [],
}),
} as any);
global.fetch = mockFetch as any;
await Networking.individualModelHealthCheckCall("token-123", "deployment-abc-456");
expect(mockFetch).toHaveBeenCalledOnce();
const [url] = mockFetch.mock.calls[0];
const urlStr = typeof url === "string" ? url : (url as Request).url;
expect(urlStr).toContain("health");
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
expect(parsed.searchParams.get("model_id")).toBe("deployment-abc-456");
expect(parsed.searchParams.has("model")).toBe(false);
});
it("should encode model_id in URL", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
healthy_count: 0,
unhealthy_count: 0,
healthy_endpoints: [],
unhealthy_endpoints: [],
}),
} as any);
global.fetch = mockFetch as any;
await Networking.individualModelHealthCheckCall("token", "id/with/slashes");
const [url] = mockFetch.mock.calls[0];
const parsed = typeof url === "string" ? new URL(url, "http://example.com") : new URL((url as Request).url);
expect(parsed.searchParams.get("model_id")).toBe("id/with/slashes");
});
});
@@ -5001,14 +5001,14 @@ export const healthCheckCall = async (accessToken: string) => {
}
};
export const individualModelHealthCheckCall = async (accessToken: string, modelName: string) => {
export const individualModelHealthCheckCall = async (accessToken: string, modelId: string) => {
/**
* Run health check for a specific model using model name
* Run health check for a specific model using model ID (so each deployment is checked separately).
*/
try {
let url = proxyBaseUrl
? `${proxyBaseUrl}/health?model=${encodeURIComponent(modelName)}`
: `/health?model=${encodeURIComponent(modelName)}`;
? `${proxyBaseUrl}/health?model_id=${encodeURIComponent(modelId)}`
: `/health?model_id=${encodeURIComponent(modelId)}`;
const response = await fetch(url, {
method: "GET",
@@ -5028,7 +5028,7 @@ export const individualModelHealthCheckCall = async (accessToken: string, modelN
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to call /health for model ${modelName}:`, error);
console.error(`Failed to call /health for model id ${modelId}:`, error);
throw error;
}
};