diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 159d357c2a..2c5a800a90 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -116,3 +116,54 @@ async def get_provider_fields() -> List[ProviderCreateInfo]: """ return get_provider_create_metadata() + + +@router.get( + "/public/model_provider_map", + tags=["public", "model management"], +) +async def get_model_provider_map(): + """ + Return a mapping of model names to their litellm_provider and mode. + This is a public endpoint that provides the same structure as /get/litellm_model_cost_map + but without cost information, making it accessible to non-admin users. + + Returns: + dict: A dictionary mapping model names to their provider information: + { + "model_name": { + "litellm_provider": "provider_name", + "mode": "chat" | "completion" | "embedding" | "image_generation" | "audio_transcription" | ... + }, + ... + } + """ + import litellm + + try: + _model_cost_map = litellm.model_cost + if not _model_cost_map: + return {} + + # Extract the litellm_provider and mode fields from each model entry + model_provider_map = {} + for model_name, model_info in _model_cost_map.items(): + if isinstance(model_info, dict) and "litellm_provider" in model_info: + litellm_provider = model_info["litellm_provider"] + # Only include if litellm_provider is not None/empty + if litellm_provider: + model_entry = { + "litellm_provider": litellm_provider + } + # Include mode if it exists + if "mode" in model_info and model_info["mode"]: + model_entry["mode"] = model_info["mode"] + + model_provider_map[model_name] = model_entry + + return model_provider_map + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Internal Server Error ({str(e)})", + ) diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 8456cf5538..8c76e64509 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -64,3 +64,47 @@ def test_get_provider_fields_returns_metadata(): } assert {"api_base", "api_key"}.issubset(runway_credential_keys) + +def test_get_model_provider_map_returns_correct_structure(): + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.get("/public/model_provider_map") + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, dict) + + # Verify structure: each entry should have litellm_provider, optionally mode + for model_name, model_info in payload.items(): + assert isinstance(model_name, str) + assert isinstance(model_info, dict) + assert "litellm_provider" in model_info + assert isinstance(model_info["litellm_provider"], str) + assert len(model_info["litellm_provider"]) > 0 + + # If mode exists, it should be a valid string + if "mode" in model_info: + assert isinstance(model_info["mode"], str) + assert len(model_info["mode"]) > 0 + + # Verify some common models exist (if model_cost is populated) + if len(payload) > 0: + # Check for at least one OpenAI model + openai_models = [ + model for model, info in payload.items() + if info.get("litellm_provider") == "openai" + ] + # If OpenAI models exist, verify structure + if openai_models: + sample_model = openai_models[0] + assert "litellm_provider" in payload[sample_model] + assert payload[sample_model]["litellm_provider"] == "openai" + # Most OpenAI models should have mode="chat" + if "mode" in payload[sample_model]: + assert payload[sample_model]["mode"] in [ + "chat", "completion", "embedding", + "image_generation", "audio_transcription" + ] + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx new file mode 100644 index 0000000000..5027907653 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx @@ -0,0 +1,155 @@ +import { render, waitFor, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import ModelsAndEndpointsView from "./ModelsAndEndpointsView"; +import * as useAuthorizedModule from "@/app/(dashboard)/hooks/useAuthorized"; +import * as useTeamsModule from "@/app/(dashboard)/hooks/useTeams"; + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +const mockUseAuthorized = { + token: "mock-token", + accessToken: "mock-access-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Admin", + premiumUser: true, + disabledPersonalKeyCreation: false, + showSSOBanner: false, +}; + +beforeAll(() => { + vi.spyOn(useAuthorizedModule, "default").mockReturnValue(mockUseAuthorized); + vi.spyOn(useTeamsModule, "default").mockReturnValue({ + teams: [], + setTeams: vi.fn(), + }); +}); + +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + modelInfoCall: vi.fn().mockResolvedValue({ + data: [ + { + model_name: "gpt-4", + litellm_params: { + model: "gpt-4", + custom_llm_provider: "openai", + }, + model_info: { + id: "model-1", + access_groups: [], + }, + }, + ], + }), + modelProviderMap: vi.fn().mockResolvedValue({ + "gpt-4": { + litellm_provider: "openai", + }, + }), + modelSettingsCall: vi.fn().mockResolvedValue([]), + credentialListCall: vi.fn().mockResolvedValue({ + credentials: [], + }), + modelMetricsCall: vi.fn().mockResolvedValue({ + data: [], + all_api_bases: [], + }), + streamingModelMetricsCall: vi.fn().mockResolvedValue({ + data: [], + all_api_bases: [], + }), + modelExceptionsCall: vi.fn().mockResolvedValue({ + data: [], + exception_types: [], + }), + modelMetricsSlowResponsesCall: vi.fn().mockResolvedValue([]), + getCallbacksCall: vi.fn().mockResolvedValue({ + router_settings: { + model_group_retry_policy: {}, + retry_policy: {}, + num_retries: 0, + model_group_alias: {}, + }, + }), + setCallbacksCall: vi.fn().mockResolvedValue({}), + adminGlobalActivityExceptions: vi.fn().mockResolvedValue({ + sum_num_rate_limit_exceptions: 0, + daily_data: [], + }), + adminGlobalActivityExceptionsPerDeployment: vi.fn().mockResolvedValue([]), + allEndUsersCall: vi.fn().mockResolvedValue([]), + modelAvailableCall: vi.fn().mockResolvedValue({ + data: [], + }), + getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ + data: [], + }), + }; +}); + +describe("ModelsAndEndpointsView", () => { + const defaultProps = { + accessToken: "test-access-token", + token: "test-token", + userRole: "Admin", + userID: "test-user-id", + modelData: { + data: [ + { + model_name: "gpt-4", + litellm_params: { + model: "gpt-4", + custom_llm_provider: "openai", + }, + model_info: { + id: "model-1", + access_groups: [], + }, + }, + ], + }, + keys: [], + setModelData: vi.fn(), + premiumUser: true, + teams: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the component successfully", async () => { + const { container } = render(); + + await waitFor(() => { + expect(container).toBeTruthy(); + }); + + expect(screen.getByText("Model Management")).toBeInTheDocument(); + }); + + it("should render tabs", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("Model Management")).toBeInTheDocument(); + }); + + const allModelsTabs = screen.getAllByRole("tab", { name: /All Models/i }); + expect(allModelsTabs.length).toBeGreaterThan(0); + + const addModelTabs = screen.getAllByRole("tab", { name: /Add Model/i }); + expect(addModelTabs.length).toBeGreaterThan(0); + + expect(screen.getByRole("tab", { name: /LLM Credentials/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Pass-Through Endpoints/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Health Status/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Model Analytics/i })).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx index 8d65c0e170..6967576a1a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx @@ -10,7 +10,7 @@ import { TabPanel, TabPanels, TabGroup, TabList, Tab, Icon } from "@tremor/react import { DateRangePickerValue } from "@tremor/react"; import { modelInfoCall, - modelCostMap, + modelProviderMap, modelMetricsCall, streamingModelMetricsCall, modelExceptionsCall, @@ -418,13 +418,17 @@ const ModelsAndEndpointsView: React.FC = ({ fetchData(); } - const fetchModelMap = async () => { - const data = await modelCostMap(accessToken); - console.log(`received model cost map data: ${Object.keys(data)}`); - setModelMap(data); + const fetchModelProviderMap = async () => { + try { + const data = await modelProviderMap(); + console.log(`received model provider map data: ${Object.keys(data).length} models`); + setModelMap(data); + } catch (error) { + console.error("Failed to fetch model provider map:", error); + } }; if (modelMap == null) { - fetchModelMap(); + fetchModelProviderMap(); } handleRefreshClick(); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index ddb95294e9..05416975f9 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -306,6 +306,31 @@ export const getOpenAPISchema = async () => { return jsonData; }; +export const modelProviderMap = async () => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/public/model_provider_map` : `/public/model_provider_map`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Failed to fetch model provider map:", response.status, errorText); + throw new Error("Failed to load model provider mapping"); + } + + const jsonData = await response.json(); + console.log(`received model provider map data: ${Object.keys(jsonData).length} models`); + return jsonData; + } catch (error) { + console.error("Failed to get model provider map:", error); + throw error; + } +}; + export const modelCostMap = async (accessToken: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/get/litellm_model_cost_map` : `/get/litellm_model_cost_map`; @@ -6677,7 +6702,6 @@ export const getGuardrailProviderSpecificParams = async (accessToken: string) => } }; - export const getAgentsList = async (accessToken: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/agents` : `/v1/agents`; @@ -6795,7 +6819,6 @@ export const patchAgentCall = async ( } }; - export const updateGuardrailCall = async ( accessToken: string, guardrailId: string,