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}