From 1bcd11706b0b4ab38e1649fe158c985b17fc8d4a Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Fri, 6 Nov 2026 17:51:45 +0800 Subject: [PATCH] fix(proxy): discover models for LiteLLM Gateway wildcard providers (#50374) * fix(proxy): discover upstream LiteLLM Gateway wildcard models * fix(proxy): use the typed shared client for gateway discovery * fix(ui): show the selected LiteLLM Gateway logo --- .../llms/litellm_proxy/chat/transformation.py | 27 +++- litellm/proxy/auth/model_checks.py | 8 +- .../provider_create_fields.json | 2 +- .../test_litellm_proxy_chat_transformation.py | 26 ++++ tests/unit/proxy/auth/test_model_checks.py | 123 +++++++++++++----- .../core/test_parity_regress5_errors.py | 5 +- tests/unit/test_utils.py | 114 +++++++--------- .../AddModelForm.integration.test.tsx | 57 ++++++++ .../add_model/litellm_model_name.tsx | 8 +- .../components/provider_info_helpers.test.tsx | 18 ++- .../src/components/provider_info_helpers.tsx | 5 + .../shared/SearchSelect.integration.test.tsx | 33 +++++ .../src/components/shared/SearchSelect.tsx | 10 +- 13 files changed, 321 insertions(+), 115 deletions(-) diff --git a/litellm/llms/litellm_proxy/chat/transformation.py b/litellm/llms/litellm_proxy/chat/transformation.py index 4183f900e26..6f55e251f47 100644 --- a/litellm/llms/litellm_proxy/chat/transformation.py +++ b/litellm/llms/litellm_proxy/chat/transformation.py @@ -4,9 +4,13 @@ Translate from OpenAI's `/v1/chat/completions` to VLLM's `/v1/chat/completions` from typing import TYPE_CHECKING, Final -from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS +from pydantic import BaseModel, ConfigDict + +import litellm +from litellm.constants import NON_LLM_CONNECTION_TIMEOUT, OPENAI_CHAT_COMPLETION_PARAMS from litellm.secret_managers.main import get_secret_bool, get_secret_str from litellm.types.router import LiteLLM_Params +from litellm.utils import add_path_to_api_base from ...openai.chat.gpt_transformation import OpenAIGPTConfig @@ -14,6 +18,18 @@ if TYPE_CHECKING: from litellm.types.llms.openai import AllMessageValues +class _ProxyModel(BaseModel): + model_config = ConfigDict(frozen=True) + + id: str + + +class _ProxyModelList(BaseModel): + model_config = ConfigDict(frozen=True) + + data: tuple[_ProxyModel, ...] + + class LiteLLMProxyChatConfig(OpenAIGPTConfig): def get_supported_openai_params(self, model: str) -> list: params_list: Final = super().get_supported_openai_params(model) @@ -53,8 +69,13 @@ class LiteLLMProxyChatConfig(OpenAIGPTConfig): api_base, api_key = self._get_openai_compatible_provider_info(api_base, api_key) if api_base is None: raise ValueError("api_base not set for LiteLLM Proxy route. Set in env via `LITELLM_PROXY_API_BASE`") - models: Final = super().get_models(api_key=api_key, api_base=api_base) - return [f"litellm_proxy/{model}" for model in models] + response: Final = litellm.module_level_client.client.get( + url=add_path_to_api_base(api_base, "/v1/models"), + headers={"Authorization": f"Bearer {api_key}"}, + timeout=NON_LLM_CONNECTION_TIMEOUT, + ) + response.raise_for_status() + return [f"litellm_proxy/{model.id}" for model in _ProxyModelList.model_validate_json(response.content).data] @staticmethod def get_api_key(api_key: str | None = None) -> str | None: diff --git a/litellm/proxy/auth/model_checks.py b/litellm/proxy/auth/model_checks.py index 73edcd66bd7..d57aa5c9df1 100644 --- a/litellm/proxy/auth/model_checks.py +++ b/litellm/proxy/auth/model_checks.py @@ -41,7 +41,7 @@ _CREDENTIAL_LITELLM_PARAM_FIELDS = set(CredentialLiteLLMParams.model_fields) _MODEL_LISTING_POOL: Final = ThreadPoolExecutor(thread_name_prefix="litellm-model-listing") _PROVIDERS_LISTED_ONLY_THROUGH_A_DEPLOYMENT: Final = frozenset( - {LlmProviders.OPENROUTER.value, LlmProviders.HOSTED_VLLM.value} + {LlmProviders.OPENROUTER.value, LlmProviders.HOSTED_VLLM.value, LlmProviders.LITELLM_PROXY.value} ) _Listed: Final = TypeVar("_Listed") @@ -83,7 +83,11 @@ def get_provider_models(provider: str, litellm_params: LiteLLM_Params | None = N return None if ProviderConfigManager.get_provider_model_info(model=None, provider=llm_provider) is None: return None - return get_valid_models(custom_llm_provider=provider, litellm_params=litellm_params) + return get_valid_models( + check_provider_endpoint=llm_provider == LlmProviders.LITELLM_PROXY, + custom_llm_provider=provider, + litellm_params=litellm_params, + ) def _get_models_from_access_groups( diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 5ded2fa5f5e..5afc3908820 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2267,7 +2267,7 @@ }, { "provider": "LITELLM_PROXY", - "provider_display_name": "Litellm Proxy", + "provider_display_name": "LiteLLM Gateway", "litellm_provider": "litellm_proxy", "credential_fields": [ { diff --git a/tests/unit/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py b/tests/unit/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py index 8a690a70a97..f516d03c499 100644 --- a/tests/unit/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py +++ b/tests/unit/llms/litellm_proxy/chat/test_litellm_proxy_chat_transformation.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest +import respx from openai import AsyncOpenAI, OpenAI from openai._legacy_response import LegacyAPIResponse from openai.types import CreateEmbeddingResponse, Embedding @@ -27,6 +28,31 @@ _GATEWAY_EMBEDDING_RESPONSE: Final = CreateEmbeddingResponse( ) +@pytest.mark.parametrize( + ("base_path", "models_path"), + [ + ("", "/v1/models"), + ("/", "/v1/models"), + ("/v1", "/v1/models"), + ("/v1/", "/v1/models"), + ("/gateway/v1", "/gateway/v1/models"), + ("/gateway/v1/", "/gateway/v1/models"), + ], +) +@respx.mock +def test_proxy_model_discovery_preserves_the_api_base_path_and_upstream_namespaces( + base_path: str, models_path: str +) -> None: + respx.get( + f"http://upstream-listing.test:4000{models_path}", + headers={"Authorization": "Bearer test-proxy-key"}, + ).mock(return_value=httpx.Response(200, json={"data": [{"id": "chat-model"}, {"id": "vendor/chat-model"}]})) + + assert LiteLLMProxyChatConfig().get_models( + api_base=f"http://upstream-listing.test:4000{base_path}", api_key="test-proxy-key" + ) == ["litellm_proxy/chat-model", "litellm_proxy/vendor/chat-model"] + + async def _gateway_embedding_via_injected_client( is_async: bool, ) -> tuple[CapturingTransport, litellm.EmbeddingResponse]: diff --git a/tests/unit/proxy/auth/test_model_checks.py b/tests/unit/proxy/auth/test_model_checks.py index bc9366aa607..250c83fa61a 100644 --- a/tests/unit/proxy/auth/test_model_checks.py +++ b/tests/unit/proxy/auth/test_model_checks.py @@ -1411,24 +1411,21 @@ def test_get_complete_model_list_sentinel_only_grants_nothing(): assert result == [] -def test_get_provider_models_admits_providers_without_a_static_catalog(): - """Providers without a static model list are no longer rejected up front. +@pytest.fixture +def fresh_available_models_cache(monkeypatch: pytest.MonkeyPatch) -> None: + import litellm.utils as litellm_utils - With endpoint discovery off, get_valid_models falls back to the (empty) - static list, so the result is [] rather than None. Before the fix this - returned None and the wildcard was never expanded. - """ - import litellm + monkeypatch.setattr(litellm_utils, "_model_cache", litellm_utils.AvailableModelsCache()) + + +def test_get_provider_models_admits_providers_without_a_static_catalog(): from litellm.proxy.auth.model_checks import get_provider_models from litellm.types.router import LiteLLM_Params - assert "litellm_proxy" not in litellm.models_by_provider - assert "hosted_vllm" not in litellm.models_by_provider - result = get_provider_models( - "litellm_proxy", + "hosted_vllm", litellm_params=LiteLLM_Params( - model="litellm_proxy/*", + model="hosted_vllm/*", api_base="http://upstream:4000", api_key="sk-upstream", ), @@ -1437,47 +1434,103 @@ def test_get_provider_models_admits_providers_without_a_static_catalog(): assert result == [] -def test_get_complete_model_list_discovers_litellm_proxy_wildcard_models(monkeypatch): - """A litellm_proxy/* deployment lists the upstream proxy's models when endpoint discovery is on.""" +@pytest.mark.parametrize("wildcard", ["litellm_proxy/*", "team-proxy/*"]) +@pytest.mark.usefixtures("fresh_available_models_cache") +@respx.mock +def test_get_complete_model_list_discovers_litellm_proxy_wildcard_models_by_default( + monkeypatch: pytest.MonkeyPatch, wildcard: str +) -> None: import litellm - from litellm import Router - from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.proxy.auth.model_checks import get_complete_model_list - monkeypatch.setattr(litellm, "check_provider_endpoint", True) - captured = {} - - def fake_get_models(self, api_key=None, api_base=None): - captured["api_key"] = api_key - captured["api_base"] = api_base - return ["gpt-4o", "claude-sonnet"] - - monkeypatch.setattr(OpenAIGPTConfig, "get_models", fake_get_models) - - router = Router( + monkeypatch.setattr(litellm, "check_provider_endpoint", False) + catalog: Final = respx.get( + "http://upstream-discovery.test:4000/gateway/v1/models", + headers={"Authorization": "Bearer test-upstream-key"}, + ).mock(return_value=httpx.Response(200, json={"data": [{"id": "chat-model"}, {"id": "vendor/chat-model"}]})) + router: Final = Router( model_list=[ { - "model_name": "litellm_proxy/*", + "model_name": wildcard, "litellm_params": { "model": "litellm_proxy/*", - "api_base": "http://upstream:4000", - "api_key": "sk-upstream", + "api_base": "http://upstream-discovery.test:4000/gateway/v1", + "api_key": "test-upstream-key", }, } ] ) - result = get_complete_model_list( + result: Final = get_complete_model_list( key_models=[], team_models=[], - proxy_model_list=["litellm_proxy/*"], + proxy_model_list=list(router.registry().names(kind="group_set")), user_model=None, infer_model_from_keys=False, llm_router=router, ) - assert captured == {"api_key": "sk-upstream", "api_base": "http://upstream:4000"} - assert "litellm_proxy/gpt-4o" in result - assert "litellm_proxy/claude-sonnet" in result + assert result == [wildcard.replace("*", "chat-model"), wildcard.replace("*", "vendor/chat-model")] + assert catalog.call_count == 1 + + +@pytest.mark.usefixtures("fresh_available_models_cache") +@respx.mock +def test_litellm_proxy_discovery_cache_is_scoped_to_the_upstream_key_and_gateway( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import litellm + from litellm.proxy.auth.model_checks import get_known_models_from_wildcard + from litellm.types.router import LiteLLM_Params + + monkeypatch.setattr(litellm, "check_provider_endpoint", False) + first: Final = respx.get( + "http://first-proxy.test:4000/v1/models", headers={"Authorization": "Bearer test-first-key"} + ).mock(return_value=httpx.Response(200, json={"data": [{"id": "first-key-model"}]})) + second: Final = respx.get( + "http://first-proxy.test:4000/v1/models", headers={"Authorization": "Bearer test-second-key"} + ).mock(return_value=httpx.Response(200, json={"data": [{"id": "second-key-model"}]})) + other_gateway: Final = respx.get( + "http://other-proxy.test:4000/v1/models", headers={"Authorization": "Bearer test-first-key"} + ).mock(return_value=httpx.Response(200, json={"data": [{"id": "other-gateway-model"}]})) + deployment: Final = LiteLLM_Params( + model="litellm_proxy/*", api_base="http://first-proxy.test:4000/v1", api_key="test-first-key" + ) + + assert get_known_models_from_wildcard("litellm_proxy/*", deployment) == ["litellm_proxy/first-key-model"] + assert get_known_models_from_wildcard( + "litellm_proxy/*", deployment.model_copy(update={"api_key": "test-second-key"}) + ) == ["litellm_proxy/second-key-model"] + assert get_known_models_from_wildcard( + "litellm_proxy/*", deployment.model_copy(update={"api_base": "http://other-proxy.test:4000/v1"}) + ) == ["litellm_proxy/other-gateway-model"] + assert get_known_models_from_wildcard("litellm_proxy/*", deployment) == ["litellm_proxy/first-key-model"] + assert (first.call_count, second.call_count, other_gateway.call_count) == (1, 1, 1) + + +@pytest.mark.usefixtures("fresh_available_models_cache") +@respx.mock +def test_undeployed_litellm_proxy_wildcard_does_not_list_the_environment_gateway( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import litellm + from litellm.proxy.auth.model_checks import get_complete_model_list + + monkeypatch.setattr(litellm, "check_provider_endpoint", False) + monkeypatch.setenv("LITELLM_PROXY_API_BASE", "http://environment-proxy.test:4000") + monkeypatch.setenv("LITELLM_PROXY_API_KEY", "test-environment-key") + catalog: Final = respx.get("http://environment-proxy.test:4000/v1/models").mock( + return_value=httpx.Response(200, json={"data": [{"id": "environment-only-model"}]}) + ) + listed: Final = get_complete_model_list( + key_models=["litellm_proxy/*"], + team_models=[], + proxy_model_list=[], + user_model=None, + infer_model_from_keys=False, + llm_router=Router(model_list=[]), + ) + + assert (listed, catalog.call_count) == ([], 0) def test_get_provider_models_returns_none_for_an_unknown_provider(): diff --git a/tests/unit/test_router/core/test_parity_regress5_errors.py b/tests/unit/test_router/core/test_parity_regress5_errors.py index a0122f8eda6..3e8537af878 100644 --- a/tests/unit/test_router/core/test_parity_regress5_errors.py +++ b/tests/unit/test_router/core/test_parity_regress5_errors.py @@ -69,8 +69,9 @@ def cooled(core: RouterCore, *ids: str) -> Mapping[str, Mapping[str, object]]: async def settle() -> None: """Let the tasks a failed stream scheduled run.""" - for _ in range(20): - await asyncio.sleep(0) + while pending := asyncio.all_tasks() - {asyncio.current_task()}: + _, still_running = await asyncio.wait(pending, timeout=5) + assert not still_running, still_running async def failed_with(core: RouterCore, **request: object) -> BaseException: # kwargs-ok: request kwargs diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index c41c4db2c02..f8ec0e56e4a 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -3011,52 +3011,37 @@ if __name__ == "__main__": class TestGetValidModelsWithCLI: """Test get_valid_models function as used in CLI token usage""" - def test_get_valid_models_with_cli_pattern(self): + @respx.mock + def test_get_valid_models_with_cli_pattern(self, reset_mock_cache: None) -> None: """Test get_valid_models with litellm_proxy provider and CLI token pattern""" - # Mock the HTTP request that get_valid_models makes to the proxy - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "data": [ - {"id": "gpt-3.5-turbo", "object": "model"}, - {"id": "gpt-4", "object": "model"}, - {"id": "litellm_proxy/gemini/gemini-2.5-flash", "object": "model"}, - {"id": "claude-3-sonnet", "object": "model"}, - ] - } + route: Final = respx.get("https://gateway-cli.example.test/v1/models").respond( + 200, + json={ + "data": [ + {"id": "gpt-3.5-turbo", "object": "model"}, + {"id": "gpt-4", "object": "model"}, + {"id": "litellm_proxy/gemini/gemini-2.5-flash", "object": "model"}, + {"id": "claude-3-sonnet", "object": "model"}, + ] + }, + ) - with patch.object(litellm.module_level_client, "get", return_value=mock_response) as mock_get: - # Test the exact pattern used in cli_token_usage.py - result = litellm.get_valid_models( - check_provider_endpoint=True, - custom_llm_provider="litellm_proxy", - api_key="sk-test-cli-key-123", - api_base="http://localhost:4000/", - ) + result: Final = litellm.get_valid_models( + check_provider_endpoint=True, + custom_llm_provider="litellm_proxy", + api_key="sk-test-cli-key-123", + api_base="https://gateway-cli.example.test/", + ) - # Verify the function returns a list of model names - assert isinstance(result, list) - assert len(result) == 4 - # All models get prefixed with "litellm_proxy/" by the get_models method - assert "litellm_proxy/gpt-3.5-turbo" in result - assert "litellm_proxy/gpt-4" in result - # Note: This model already had the prefix, so it gets double-prefixed - assert "litellm_proxy/litellm_proxy/gemini/gemini-2.5-flash" in result - assert "litellm_proxy/claude-3-sonnet" in result - - # Verify the HTTP request was made with correct parameters - mock_get.assert_called_once() - _, call_kwargs = mock_get.call_args - - # Check that the request was made to the correct endpoint - assert call_kwargs["url"].startswith("http://localhost:4000/") - assert call_kwargs["url"].endswith("/v1/models") - - # Check that the API key was included in headers - assert "headers" in call_kwargs - headers = call_kwargs["headers"] - assert headers.get("Authorization") == "Bearer sk-test-cli-key-123" + assert result == [ + "litellm_proxy/gpt-3.5-turbo", + "litellm_proxy/gpt-4", + "litellm_proxy/litellm_proxy/gemini/gemini-2.5-flash", + "litellm_proxy/claude-3-sonnet", + ] + assert route.call_count == 1 + assert route.calls.last.request.headers["Authorization"] == "Bearer sk-test-cli-key-123" @respx.mock @@ -10307,36 +10292,33 @@ def test_logprobs_type(): assert logprobs.top_logprobs is None -def test_get_valid_models_openai_proxy(monkeypatch): - from litellm.utils import get_valid_models - import litellm - - litellm.turn_on_debug() - +@respx.mock +def test_get_valid_models_openai_proxy(monkeypatch: pytest.MonkeyPatch, reset_mock_cache: None) -> None: monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-9876") - monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://litellm-api.up.railway.app/") + monkeypatch.setenv("LITELLM_PROXY_API_BASE", "https://gateway-env.example.test/") monkeypatch.delenv("FIREWORKS_AI_ACCOUNT_ID", None) monkeypatch.delenv("FIREWORKS_AI_API_KEY", None) - mock_response_data = { - "object": "list", - "data": [ - { - "id": "gpt-5.5", - "object": "model", - "created": 1686935002, - "owned_by": "organization-owner", - }, - ], - } + route: Final = respx.get("https://gateway-env.example.test/v1/models").respond( + 200, + json={ + "object": "list", + "data": [ + { + "id": "gpt-5.5", + "object": "model", + "created": 1686935002, + "owned_by": "organization-owner", + }, + ], + }, + ) - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = mock_response_data + valid_models: Final = litellm.get_valid_models(check_provider_endpoint=True) - with patch.object(litellm.module_level_client, "get", return_value=mock_response) as mock_post: - valid_models = get_valid_models(check_provider_endpoint=True) - assert "litellm_proxy/gpt-5.5" in valid_models + assert "litellm_proxy/gpt-5.5" in valid_models + assert route.call_count == 1 + assert route.calls.last.request.headers["Authorization"] == "Bearer sk-9876" def test_pick_cheapest_chat_model_from_llm_provider(): diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx index a95d9ffbfb8..7fab7219f7a 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx @@ -1,5 +1,6 @@ import { chooseSelectOption, + act, fireEvent, renderHook, screen, @@ -48,6 +49,15 @@ vi.mock("../networking", async () => { credentialCreateCall: vi.fn().mockResolvedValue({ success: true }), modelCreateCall: vi.fn().mockResolvedValue({ success: true }), getProviderCreateMetadata: vi.fn().mockResolvedValue([ + { + provider: "LITELLM_PROXY", + provider_display_name: "LiteLLM Gateway", + litellm_provider: "litellm_proxy", + credential_fields: [ + { key: "api_base", label: "API Base", field_type: "text" }, + { key: "api_key", label: "API Key", field_type: "password" }, + ], + }, { provider: "OpenAI", provider_display_name: "OpenAI", @@ -103,6 +113,15 @@ vi.mock("../networking", async () => { vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({ useProviderFields: vi.fn().mockReturnValue({ data: [ + { + provider: "LITELLM_PROXY", + provider_display_name: "LiteLLM Gateway", + litellm_provider: "litellm_proxy", + credential_fields: [ + { key: "api_base", label: "API Base", field_type: "text" }, + { key: "api_key", label: "API Key", field_type: "password" }, + ], + }, { provider: "OpenAI", provider_display_name: "OpenAI", @@ -300,6 +319,44 @@ describe("AddModelForm", () => { expect(await screen.findByRole("heading", { name: "Add Model" })).toBeInTheDocument(); }); + it("should add a LiteLLM Gateway wildcard with its upstream URL and key when no catalog models are known", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); + vi.mocked(modelCreateCall).mockClear(); + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + const props = { ...createTestProps(), selectedProvider: "LITELLM_PROXY", providerModels: [] }; + props.handleOk.mockImplementation(async () => { + await handleAddModelSubmit(props.mountedValues(), "test-access-token", { resetFields: vi.fn() }); + return true; + }); + renderWithProviders(); + + await pickProvider(user, /LiteLLM Gateway/); + await pickModel(user, "All LiteLLM Gateway Models (Wildcard)"); + act(() => { + fireEvent.change(screen.getByLabelText("API Base"), { target: { value: "https://gateway.example.test/v1" } }); + fireEvent.change(screen.getByLabelText("API Key"), { target: { value: "upstream-test-key" } }); + }); + await user.click(screen.getByTestId("add-model-btn")); + + const expectedParams = { + model: "litellm_proxy/*", + custom_llm_provider: "litellm_proxy", + api_base: "https://gateway.example.test/v1", + api_key: "upstream-test-key", + }; + await waitFor(() => + expect(modelCreateCall).toHaveBeenCalledWith( + "test-access-token", + expect.objectContaining({ + model_name: "litellm_proxy/*", + litellm_params: expect.objectContaining(expectedParams), + }), + ), + ); + expect(modelCreateCall).toHaveBeenCalledOnce(); + }); + it("should show proxy admin only (not team admin) - should not see Select Team dropdown unless switch is toggled", async () => { const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); diff --git a/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx b/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx index edb02da5d0d..97c6769db54 100644 --- a/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx +++ b/ui/litellm-dashboard/src/components/add_model/litellm_model_name.tsx @@ -23,6 +23,7 @@ const LiteLLMModelNameField: React.FC = ({ const form = useFormContext(); const modelValue = useWatch({ control: form.control, name: "model" }); const selectedModels = Array.isArray(modelValue) ? modelValue : [modelValue]; + const isLiteLLMGateway = selectedProvider === "LITELLM_PROXY" || selectedProvider === Providers.LITELLM_PROXY; const handleModelChange = (value: string | string[]) => { // Ensure value is always treated as an array @@ -142,7 +143,8 @@ const LiteLLMModelNameField: React.FC = ({ } }} /> - ) : providerModels.length > 0 && !(typeof control.value === "string" && control.value !== "") ? ( + ) : (providerModels.length > 0 || isLiteLLMGateway) && + !(typeof control.value === "string" && control.value !== "") ? ( = ({ }, { label: t("addModel.litellmModelName.wildcardOption", { - provider: selectedProvider ?? t("addModel.litellmModelName.providerFallback"), + provider: isLiteLLMGateway + ? Providers.LITELLM_PROXY + : selectedProvider ?? t("addModel.litellmModelName.providerFallback"), }), value: "all-wildcard", }, diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 604d5923caa..6a78fe9441a 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -11,15 +11,27 @@ import { resolveLitellmProviderSlug, } from "./provider_info_helpers"; -const PROVIDER_CREATE_FIELDS: { provider: string; litellm_provider: string }[] = JSON.parse( - readFileSync(resolve(__dirname, "../../../../litellm/proxy/public_endpoints/provider_create_fields.json"), "utf8"), -); +const PROVIDER_CREATE_FIELDS: { provider: string; provider_display_name: string; litellm_provider: string }[] = + JSON.parse( + readFileSync(resolve(__dirname, "../../../../litellm/proxy/public_endpoints/provider_create_fields.json"), "utf8"), + ); const BUNDLED_MODEL_MAP: Record = JSON.parse( readFileSync(resolve(__dirname, "../../../../model_prices_and_context_window.json"), "utf8"), ); describe("provider_info_helpers", () => { + it("should identify the upstream LiteLLM Gateway consistently with provider metadata", () => { + const metadata = PROVIDER_CREATE_FIELDS.find((provider) => provider.provider === "LITELLM_PROXY"); + expect(resolveLitellmProviderSlug("LITELLM_PROXY")).toBe(metadata?.litellm_provider); + expect(Providers.LITELLM_PROXY).toBe(metadata?.provider_display_name); + expect(getProviderLogoAndName("litellm_proxy")).toEqual({ + displayName: Providers.LITELLM_PROXY, + logo: providerLogoMap[Providers.LITELLM_PROXY], + }); + expect(providerLogoMap[Providers.LITELLM_PROXY]).toContain("litellm_monogram"); + }); + it("resolves Meta Model API separately from Meta Llama and isolates their bundled models", () => { expect(resolveLitellmProviderSlug("META")).toBe("meta"); expect(getPlaceholder(Providers.META)).toBe("meta/muse-spark-1.1"); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 167343fc05e..8efd42f52e5 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -34,6 +34,7 @@ import hostingerLogo from "../../public/assets/logos/hostinger.png"; import infinityLogo from "../../public/assets/logos/infinity.png"; import jinaLogo from "../../public/assets/logos/jina.png"; import lambdaLogo from "../../public/assets/logos/lambda.svg"; +import litellmLogo from "../../public/assets/logos/litellm_monogram.svg"; import lmstudioLogo from "../../public/assets/logos/lmstudio.svg"; import metaLlamaLogo from "../../public/assets/logos/meta_llama.svg"; import microsoft365CopilotLogo from "../../public/assets/logos/microsoft_365_copilot.svg"; @@ -141,6 +142,7 @@ export enum Providers { LAMBDA_AI = "Lambda Ai", Laya = "Laya", LEMONADE = "Lemonade", + LITELLM_PROXY = "LiteLLM Gateway", LLAMAFILE = "Llamafile", LM_STUDIO = "Lm Studio", LLAMA = "Meta Llama", @@ -276,6 +278,7 @@ export const provider_map: Record = { LAMBDA_AI: "lambda_ai", Laya: "laya", LEMONADE: "lemonade", + LITELLM_PROXY: "litellm_proxy", LLAMAFILE: "llamafile", LLAMA: "meta_llama", META: "meta", @@ -402,6 +405,7 @@ export const providerLogoMap: Partial> = { [Providers.JinaAI]: jinaLogo.src, [Providers.OPENCODE_GO]: opencodeLogo.src, [Providers.LAMBDA_AI]: lambdaLogo.src, + [Providers.LITELLM_PROXY]: litellmLogo.src, [Providers.LM_STUDIO]: lmstudioLogo.src, [Providers.LLAMA]: metaLlamaLogo.src, [Providers.META]: metaLlamaLogo.src, @@ -506,6 +510,7 @@ const providerPlaceholderMap: Partial> = { [Providers.Google_AI_Studio]: "gemini-pro", [Providers.HOSTINGER_ROUTER]: "hostinger_router/claude-sonnet-5", [Providers.JinaAI]: "jina_ai/", + [Providers.LITELLM_PROXY]: "litellm_proxy/*", [Providers.META]: "meta/muse-spark-1.1", [Providers.MICROSOFT_365_COPILOT]: "microsoft_365_copilot/chat", [Providers.NVIDIA_RIVA]: "nvidia_riva/nvidia/parakeet-ctc-1_1b-asr", diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.integration.test.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.integration.test.tsx index ecb8dd161f3..75ba453c501 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.integration.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { SearchSelect } from "./SearchSelect"; import { chooseSelectOption } from "../../../tests/test-utils"; +import { ProviderLogo } from "../molecules/models/ProviderLogo"; const OPTIONS = [ { label: "Acme Prod", value: "team-1" }, @@ -30,6 +31,38 @@ describe("SearchSelect", () => { expect(screen.getByRole("combobox")).toHaveValue("Growth"); }); + it("should keep the LiteLLM Gateway logo visible after selection and remove it when cleared", async () => { + const user = userEvent.setup(); + const options = [ + { + label: "LiteLLM Gateway", + value: "LITELLM_PROXY", + icon: , + }, + ]; + function Controlled() { + const [value, setValue] = useState(null); + return ; + } + render(); + const input = screen.getByRole("combobox", { name: "Provider" }); + + await chooseSelectOption(user, input, /LiteLLM Gateway/); + await user.tab(); + + expect(input).toHaveValue("LiteLLM Gateway"); + expect(screen.getByRole("img", { name: "litellm_proxy logo" })).toHaveAttribute( + "src", + expect.stringContaining("litellm_monogram"), + ); + + await user.click(screen.getByRole("button", { name: "Clear" })); + await user.tab(); + + expect(input).toHaveValue(""); + expect(screen.queryByRole("img", { name: "litellm_proxy logo" })).not.toBeInTheDocument(); + }); + it("shows a value the options do not carry yet instead of blanking the field", () => { const { rerender } = render(); expect(screen.getByRole("combobox")).toHaveValue("team-2"); diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx index 897782b6c7f..b2188267001 100644 --- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx @@ -9,6 +9,7 @@ import { ComboboxItem, ComboboxList, } from "@/components/ui/combobox"; +import { InputGroupAddon } from "@/components/ui/input-group"; import i18n from "@/i18n/i18n"; export interface SearchSelectOption { @@ -47,6 +48,11 @@ const matchesQuery = (option: SearchSelectOption, query: string): boolean => { return option.label.toLowerCase().includes(q) || (option.sublabel?.toLowerCase().includes(q) ?? false); }; +const SelectedOptionIcon = ({ option }: { option: SearchSelectOption | null }) => { + if (!option?.icon) return null; + return {option.icon}; +}; + export function SearchSelect({ options, value, @@ -91,7 +97,9 @@ export function SearchSelect({ placeholder={placeholder} showClear={allowClear && value != null && value !== ""} className={`h-8 w-full text-sm ${className ?? ""}`} - /> + > + + {emptyText}