fix(ui): list only catalog models in the Add Model picker (#44877)

The Add Model selector read the merged runtime cost map, so deployment
ids and provider-prefixed backend keys registered for proxy deployments
showed up as duplicate model choices. The public cost map endpoint now
accepts catalog_only=true to return the catalog as loaded, and the Add
Model panel requests that view. The default response is unchanged.

Resolves LIT-9263

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-08 11:18:29 -05:00 • committed by GitHub
parent 98465fe2ba
commit 82a5d9842a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 145 additions and 20 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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);
});
});

View file

@ -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<Record<string, any>>({
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
});

View file

@ -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: () => <div>reload</div> }));
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 }) => <button onClick={onReloadSuccess}>reload</button>,
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
const renderTab = (queryClient: QueryClient) =>
render(
<QueryClientProvider client={queryClient}>
<PriceDataManagementTab />
</QueryClientProvider>,
);
describe("PriceDataManagementTab", () => {
it("renders its content standalone, without a tab-panel ancestor", () => {
render(<PriceDataManagementTab />);
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);
});
});

View file

@ -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 (
<div>
@ -19,7 +20,7 @@ const PriceDataManagementTab = () => {
<PriceDataReload
accessToken={accessToken}
onReloadSuccess={() => {
refetchModelCostMap();
queryClient.invalidateQueries({ queryKey: modelCostMapKeys.all });
}}
buttonText="Reload Price Data"
size="middle"

View file

@ -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);
});

View file

@ -23,7 +23,7 @@ export default function AddModelPanel() {
const form = useForm<MountedFormValues>({ 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<string | null>(Providers.Anthropic);

View file

@ -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$/);
});
});

View file

@ -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: {

View file

@ -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: {