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
This commit is contained in:
ishaan-j-berri 2026-11-06 17:51:45 +08:00 • committed by GitHub
parent 4086d5ccaa
commit 1bcd11706b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 321 additions and 115 deletions

View file

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

View file

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

View file

@ -2267,7 +2267,7 @@
},
{
"provider": "LITELLM_PROXY",
"provider_display_name": "Litellm Proxy",
"provider_display_name": "LiteLLM Gateway",
"litellm_provider": "litellm_proxy",
"credential_fields": [
{

View file

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

View file

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

View file

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

View file

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

View file

@ -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(<AddModelForm {...props} />);
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));

View file

@ -23,6 +23,7 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
const form = useFormContext<MountedFormValues>();
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<LiteLLMModelNameFieldProps> = ({
}
}}
/>
) : providerModels.length > 0 && !(typeof control.value === "string" && control.value !== "") ? (
) : (providerModels.length > 0 || isLiteLLMGateway) &&
!(typeof control.value === "string" && control.value !== "") ? (
<MultiSelect
id={control.id}
placeholder={t("addModel.litellmModelName.selectModels")}
@ -159,7 +161,9 @@ const LiteLLMModelNameField: React.FC<LiteLLMModelNameFieldProps> = ({
},
{
label: t("addModel.litellmModelName.wildcardOption", {
provider: selectedProvider ?? t("addModel.litellmModelName.providerFallback"),
provider: isLiteLLMGateway
? Providers.LITELLM_PROXY
: selectedProvider ?? t("addModel.litellmModelName.providerFallback"),
}),
value: "all-wildcard",
},

View file

@ -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<string, { litellm_provider?: string }> = 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");

View file

@ -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<string, string> = {
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<Record<Providers, string>> = {
[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<Record<Providers, string>> = {
[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",

View file

@ -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: <ProviderLogo provider="litellm_proxy" className="w-5 h-5" />,
},
];
function Controlled() {
const [value, setValue] = useState<string | null>(null);
return <SearchSelect options={options} value={value} onValueChange={setValue} aria-label="Provider" />;
}
render(<Controlled />);
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(<SearchSelect options={[]} value="team-2" onValueChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveValue("team-2");

View file

@ -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 <InputGroupAddon align="inline-start">{option.icon}</InputGroupAddon>;
};
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 ?? ""}`}
/>
>
<SelectedOptionIcon option={selected} />
</ComboboxInput>
<ComboboxContent side="bottom" collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>