diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 6b6ee7a9b67..1cccc6b2cfb 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -18,6 +18,7 @@ from litellm.litellm_core_utils.get_blog_posts import ( GetBlogPosts, get_blog_posts, ) +from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap from litellm.proxy._types import ( CommonProxyErrors, ) @@ -453,15 +454,16 @@ async def get_public_fuse_presets() -> FusePresetCatalog: "/public/litellm_model_cost_map", tags=["public", "model management"], ) -async def get_litellm_model_cost_map(): +async def get_litellm_model_cost_map(catalog_only: bool = False): """ Public endpoint to get the LiteLLM model cost map. Returns pricing information for all supported models. + With catalog_only=true, returns the catalog as loaded, without entries registered at runtime for proxy deployments. """ import litellm try: - _model_cost_map: Final = litellm.model_cost + _model_cost_map: Final = GetModelCostMap.loaded_model_cost_map() if catalog_only else litellm.model_cost return _model_cost_map except Exception as e: raise HTTPException( diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 7e20b5f8c3c..b720ba24c3e 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -76,6 +76,32 @@ def test_get_provider_create_fields(): ), "Expected at least one provider to have detailed credential fields" +def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import litellm + from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + + monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost)) + runtime_key: Final = "lit-9263-deployment-alias" + litellm.register_model({runtime_key: {"litellm_provider": "openai", "mode": "chat"}}, persist_across_reloads=False) + app: Final = FastAPI() + app.include_router(router) + client: Final = TestClient(app) + + live_response: Final = client.get("/public/litellm_model_cost_map") + catalog_response: Final = client.get("/public/litellm_model_cost_map", params={"catalog_only": "true"}) + + assert live_response.status_code == 200 + assert live_response.json()[runtime_key]["litellm_provider"] == "openai" + assert catalog_response.status_code == 200 + catalog_payload: Final = catalog_response.json() + assert runtime_key not in catalog_payload + assert catalog_payload == json.loads( + json.dumps({key: dict(entry) for key, entry in GetModelCostMap.loaded_model_cost_map().items()}) + ) + + def test_get_litellm_model_cost_map_returns_cost_map(): app = FastAPI() app.include_router(router) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.test.ts index f79ca33bc5d..68983f5146e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.test.ts @@ -141,4 +141,23 @@ describe("useModelCostMap", () => { expect(result.current).toHaveProperty("isSuccess"); expect(result.current).toHaveProperty("error"); }); + + it("fetches the catalog-only map under its own cache entry when catalogOnly is set", async () => { + (modelCostMap as any).mockImplementation(async (catalogOnly: boolean) => + catalogOnly ? { catalog: { litellm_provider: "bedrock" } } : mockModelCostData, + ); + + const { result: live } = renderHook(() => useModelCostMap(), { wrapper }); + const { result: catalog } = renderHook(() => useModelCostMap(true, true), { wrapper }); + + await waitFor(() => { + expect(live.current.isSuccess).toBe(true); + expect(catalog.current.isSuccess).toBe(true); + }); + + expect(live.current.data).toEqual(mockModelCostData); + expect(catalog.current.data).toEqual({ catalog: { litellm_provider: "bedrock" } }); + expect(modelCostMap).toHaveBeenCalledWith(false); + expect(modelCostMap).toHaveBeenCalledWith(true); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.ts index d9824b4753e..cf97a504e1e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModelCostMap.ts @@ -2,13 +2,13 @@ import { modelCostMap } from "@/components/networking"; import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; -const modelCostMapKeys = createQueryKeys("modelCostMap"); +export const modelCostMapKeys = createQueryKeys("modelCostMap"); -export const useModelCostMap = (enabled = true) => { +export const useModelCostMap = (enabled = true, catalogOnly = false) => { return useQuery>({ enabled, - queryKey: modelCostMapKeys.list({}), - queryFn: async () => await modelCostMap(), + queryKey: modelCostMapKeys.list(catalogOnly ? { filters: { catalog_only: "true" } } : {}), + queryFn: async () => await modelCostMap(catalogOnly), staleTime: 60 * 1000, // 1 minute gcTime: 60 * 1000, // 1 minute }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx index 7cd418bc176..b2beb5beeed 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx @@ -1,17 +1,39 @@ /* @vitest-environment jsdom */ -import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { modelCostMapKeys } from "../../hooks/models/useModelCostMap"; import PriceDataManagementTab from "./PriceDataManagementTab"; -vi.mock("@/components/price_data_reload", () => ({ default: () =>
reload
})); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) })); -vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ - useModelCostMap: () => ({ refetch: vi.fn() }), +vi.mock("@/components/price_data_reload", () => ({ + default: ({ onReloadSuccess }: { onReloadSuccess: () => void }) => , })); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) })); + +const renderTab = (queryClient: QueryClient) => + render( + + + , + ); describe("PriceDataManagementTab", () => { it("renders its content standalone, without a tab-panel ancestor", () => { - render(); + renderTab(new QueryClient()); expect(screen.getByText("Price Data Management")).toBeInTheDocument(); }); + + it("invalidates both the live and the catalog-only cost map after a reload", () => { + const queryClient = new QueryClient(); + const liveKey = modelCostMapKeys.list({}); + const catalogKey = modelCostMapKeys.list({ filters: { catalog_only: "true" } }); + queryClient.setQueryData(liveKey, {}); + queryClient.setQueryData(catalogKey, {}); + renderTab(queryClient); + + fireEvent.click(screen.getByRole("button", { name: "reload" })); + + expect(queryClient.getQueryState(liveKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(catalogKey)?.isInvalidated).toBe(true); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx index 126f6970c2e..ce6f8e77d38 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx @@ -1,11 +1,12 @@ import PriceDataReload from "@/components/price_data_reload"; import React from "react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { useModelCostMap } from "../../hooks/models/useModelCostMap"; +import { useQueryClient } from "@tanstack/react-query"; +import { modelCostMapKeys } from "../../hooks/models/useModelCostMap"; const PriceDataManagementTab = () => { const { accessToken } = useAuthorized(); - const { refetch: refetchModelCostMap } = useModelCostMap(); + const queryClient = useQueryClient(); return (
@@ -19,7 +20,7 @@ const PriceDataManagementTab = () => { { - refetchModelCostMap(); + queryClient.invalidateQueries({ queryKey: modelCostMapKeys.all }); }} buttonText="Reload Price Data" size="middle" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx index efba26734ff..410758491fe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx @@ -22,7 +22,11 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled", () => usePtuCostAttributionEnabled: () => mockPtuEnabled(), })); -vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) })); +const mockUseModelCostMap = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ + useModelCostMap: (...args: unknown[]) => mockUseModelCostMap(...args), +})); vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ useCredentials: () => ({ data: { credentials: [] } }), @@ -138,10 +142,29 @@ const setup = async () => { describe("AddModelPanel submit payload contract", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseModelCostMap.mockReturnValue({ data: {} }); mockPtuEnabled.mockReturnValue(false); mockAuthorized.mockReturnValue(PROXY_ADMIN); }); + it("lists catalog models in the model picker, not entries registered at runtime for deployments", async () => { + mockUseModelCostMap.mockImplementation((_enabled: boolean, catalogOnly: boolean) => ({ + data: catalogOnly + ? { "gpt-4o-2024-08-06": { litellm_provider: "openai" } } + : { + "gpt-4o-2024-08-06": { litellm_provider: "openai" }, + "openai-gpt-4o-deployment-id": { litellm_provider: "openai" }, + }, + })); + const { user } = await setup(); + await user.click(screen.getByRole("combobox", { name: /provider/i })); + await user.click(await screen.findByText("OpenAI")); + await user.click(await screen.findByPlaceholderText("Select models")); + + expect(await screen.findByText("gpt-4o-2024-08-06")).toBeInTheDocument(); + expect(screen.queryByText("openai-gpt-4o-deployment-id")).not.toBeInTheDocument(); + }); + it("sends only the always-mounted fields while Advanced Settings stays closed", async () => { const { fillRequired, submit } = await setup(); await fillRequired(); @@ -295,6 +318,7 @@ describe("AddModelPanel submit payload contract", () => { describe("AddModelPanel empty-string skip", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseModelCostMap.mockReturnValue({ data: {} }); mockPtuEnabled.mockReturnValue(false); mockAuthorized.mockReturnValue(PROXY_ADMIN); }); @@ -376,6 +400,7 @@ describe("AddModelPanel validation gates", () => { describe("AddModelPanel behaviours the removed Advanced Settings form instance never drove", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseModelCostMap.mockReturnValue({ data: {} }); mockPtuEnabled.mockReturnValue(false); mockAuthorized.mockReturnValue(PROXY_ADMIN); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 1cdce04d07c..9dacabe506b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -23,7 +23,7 @@ export default function AddModelPanel() { const form = useForm({ mode: "onChange", defaultValues: INITIAL_VALUES }); const registry = useMountRegistry(); const queryClient = useQueryClient(); - const { data: modelCostMapData } = useModelCostMap(); + const { data: modelCostMapData } = useModelCostMap(true, true); const { data: credentialsResponse } = useCredentials(); const { data: teams } = useTeams(); const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 62670467827..0fdf7a4799d 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -871,3 +871,20 @@ describe("schema-bound dashboard responses", () => { expect(result.users[0]).toEqual(user); }); }); + +describe("modelCostMap", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests the catalog-only map when catalogOnly is set and the full map otherwise", async () => { + const mockFetch = vi.fn().mockImplementation(async () => new Response(JSON.stringify({}))); + vi.stubGlobal("fetch", mockFetch); + + await Networking.modelCostMap(true); + await Networking.modelCostMap(); + + expect(mockFetch.mock.calls[0][0]).toMatch(/\/public\/litellm_model_cost_map\?catalog_only=true$/); + expect(mockFetch.mock.calls[1][0]).toMatch(/\/public\/litellm_model_cost_map$/); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index bf35e22f228..a874f58f868 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -553,9 +553,10 @@ export const getOpenAPISchema = async () => { return jsonData; }; -export const modelCostMap = async () => { +export const modelCostMap = async (catalogOnly = false) => { try { - const url = proxyBaseUrl ? `${proxyBaseUrl}/public/litellm_model_cost_map` : `/public/litellm_model_cost_map`; + const path = catalogOnly ? "/public/litellm_model_cost_map?catalog_only=true" : "/public/litellm_model_cost_map"; + const url = proxyBaseUrl ? `${proxyBaseUrl}${path}` : path; const response = await fetch(url, { method: "GET", headers: { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bf863323e77..769798b5134 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -13959,6 +13959,7 @@ export interface paths { * Get Litellm Model Cost Map * @description Public endpoint to get the LiteLLM model cost map. * Returns pricing information for all supported models. + * With catalog_only=true, returns the catalog as loaded, without entries registered at runtime for proxy deployments. */ get: operations["get_litellm_model_cost_map_public_litellm_model_cost_map_get"]; put?: never; @@ -70662,7 +70663,9 @@ export interface operations { }; get_litellm_model_cost_map_public_litellm_model_cost_map_get: { parameters: { - query?: never; + query?: { + catalog_only?: boolean; + }; header?: never; path?: never; cookie?: never; @@ -70678,6 +70681,15 @@ export interface operations { "application/json": unknown; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; get_mcp_servers_public_mcp_hub_get: {