mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
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:
parent
4086d5ccaa
commit
1bcd11706b
13 changed files with 321 additions and 115 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2267,7 +2267,7 @@
|
|||
},
|
||||
{
|
||||
"provider": "LITELLM_PROXY",
|
||||
"provider_display_name": "Litellm Proxy",
|
||||
"provider_display_name": "LiteLLM Gateway",
|
||||
"litellm_provider": "litellm_proxy",
|
||||
"credential_fields": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue