mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
98465fe2ba
commit
82a5d9842a
11 changed files with 145 additions and 20 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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$/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue