mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
* feat(ui): source Home What's new from day 0 support blog posts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(ui): Home cards read card_title and card_summary from blog tags with provider logo Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1536 lines
56 KiB
Python
1536 lines
56 KiB
Python
import json
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from typing import Final
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import TypeAdapter
|
|
|
|
import litellm
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.public_endpoints import router
|
|
from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_presets
|
|
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
|
ModelGroupInfoProxy,
|
|
)
|
|
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo
|
|
from litellm.types.utils import LlmProviders
|
|
|
|
|
|
def test_fuse_presets_route_serves_the_shared_catalog_without_authentication() -> None:
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
client: Final = TestClient(app)
|
|
response: Final = client.get("/public/complexity_router/fuse_presets")
|
|
assert response.status_code == 200
|
|
assert response.json() == get_fuse_presets().model_dump(mode="json")
|
|
assert client.get("/public/complexity_router/fuse_presets").json() == response.json()
|
|
|
|
|
|
def test_get_supported_providers_returns_enum_values():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
client = TestClient(app_instance)
|
|
|
|
response = client.get("/public/providers")
|
|
|
|
assert response.status_code == 200
|
|
expected_providers = sorted(provider.value for provider in LlmProviders)
|
|
assert response.json() == expected_providers
|
|
|
|
|
|
def test_get_provider_create_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
client = TestClient(app_instance)
|
|
|
|
response = client.get("/public/providers/fields")
|
|
|
|
assert response.status_code == 200
|
|
|
|
response_data = response.json()
|
|
|
|
assert isinstance(response_data, list)
|
|
|
|
assert len(response_data) > 0
|
|
|
|
first_provider = response_data[0]
|
|
assert "provider" in first_provider
|
|
assert "provider_display_name" in first_provider
|
|
assert "litellm_provider" in first_provider
|
|
assert "credential_fields" in first_provider
|
|
|
|
assert isinstance(first_provider["credential_fields"], list)
|
|
|
|
has_detailed_fields = any(
|
|
provider.get("credential_fields") and len(provider.get("credential_fields", [])) > 0
|
|
for provider in response_data
|
|
)
|
|
assert has_detailed_fields, "Expected at least one provider to have detailed credential fields"
|
|
|
|
|
|
def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
|
|
|
monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost))
|
|
runtime_key: Final = "lit-9263-deployment-alias"
|
|
litellm.register_model({runtime_key: {"litellm_provider": "openai", "mode": "chat"}}, persist_across_reloads=False)
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
client: Final = TestClient(app)
|
|
|
|
live_response: Final = client.get("/public/litellm_model_cost_map")
|
|
catalog_response: Final = client.get("/public/litellm_model_cost_map", params={"catalog_only": "true"})
|
|
|
|
assert live_response.status_code == 200
|
|
assert live_response.json()[runtime_key]["litellm_provider"] == "openai"
|
|
assert catalog_response.status_code == 200
|
|
catalog_payload: Final = catalog_response.json()
|
|
assert runtime_key not in catalog_payload
|
|
assert catalog_payload == json.loads(
|
|
json.dumps({key: dict(entry) for key, entry in GetModelCostMap.loaded_model_cost_map().items()})
|
|
)
|
|
|
|
|
|
def test_get_litellm_model_cost_map_returns_cost_map():
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/litellm_model_cost_map")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert isinstance(payload, dict)
|
|
assert len(payload) > 0, "Expected model cost map to contain at least one model"
|
|
|
|
# Verify the structure contains expected keys for at least one model
|
|
# Check for a common model like gpt-4 or gpt-3.5-turbo
|
|
model_keys = list(payload.keys())
|
|
assert len(model_keys) > 0
|
|
|
|
# Verify at least one model has expected cost fields
|
|
sample_model = model_keys[0]
|
|
sample_model_data = payload[sample_model]
|
|
assert isinstance(sample_model_data, dict)
|
|
# Check for common cost fields that should be present
|
|
assert "input_cost_per_token" in sample_model_data or "output_cost_per_token" in sample_model_data
|
|
|
|
|
|
def test_public_ai_hub_info_is_public_by_default(monkeypatch):
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-master")
|
|
|
|
response = client.get("/public/model_hub/info")
|
|
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
def test_watsonx_provider_fields():
|
|
"""Test that Watsonx provider has all required credential fields including multiple auth options."""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/providers/fields")
|
|
providers = response.json()
|
|
|
|
watsonx = next((p for p in providers if p["provider"] == "WATSONX"), None)
|
|
assert watsonx is not None
|
|
|
|
field_keys = [f["key"] for f in watsonx["credential_fields"]]
|
|
# Core fields
|
|
assert "api_base" in field_keys
|
|
assert "project_id" in field_keys
|
|
assert "space_id" in field_keys
|
|
# Multiple auth methods supported
|
|
assert "api_key" in field_keys
|
|
assert "token" in field_keys
|
|
assert "zen_api_key" in field_keys
|
|
|
|
|
|
def test_azure_provider_fields_include_entra_id():
|
|
"""Azure provider must expose Entra ID (Service Principal) credential fields so
|
|
the UI can input tenant_id / client_id / client_secret as an alternative to api_key.
|
|
"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/providers/fields")
|
|
providers = response.json()
|
|
|
|
azure = next((p for p in providers if p["provider"] == "Azure"), None)
|
|
assert azure is not None
|
|
|
|
fields_by_key = {f["key"]: f for f in azure["credential_fields"]}
|
|
# API-key auth still supported
|
|
assert "api_key" in fields_by_key
|
|
# Entra ID fields
|
|
assert "tenant_id" in fields_by_key
|
|
assert "client_id" in fields_by_key
|
|
assert "client_secret" in fields_by_key
|
|
# client_secret must be masked in the UI
|
|
assert fields_by_key["client_secret"]["field_type"] == "password"
|
|
# Entra ID is an alternative to api_key, so none of these are individually required
|
|
assert fields_by_key["tenant_id"]["required"] is False
|
|
assert fields_by_key["client_id"]["required"] is False
|
|
assert fields_by_key["client_secret"]["required"] is False
|
|
|
|
|
|
def test_anthropic_provider_fields_support_byok():
|
|
"""
|
|
The Anthropic provider form must allow BYOK:
|
|
- api_key is optional (not required) so admins can create models without a key
|
|
- api_key has a non-null tooltip explaining the BYOK use case
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
anthropic = next((p for p in providers if p["provider"] == "Anthropic"), None)
|
|
assert anthropic is not None, "Anthropic provider entry not found"
|
|
|
|
fields_by_key = {f["key"]: f for f in anthropic["credential_fields"]}
|
|
assert "api_key" in fields_by_key
|
|
assert fields_by_key["api_key"]["required"] is False, (
|
|
"Anthropic api_key must be optional so admins can configure BYOK models "
|
|
"without entering a key. See BYOK tutorial."
|
|
)
|
|
assert fields_by_key["api_key"].get("tooltip"), (
|
|
"Anthropic api_key must have a tooltip explaining the BYOK use case."
|
|
)
|
|
assert "api_base" in fields_by_key, (
|
|
"Anthropic provider form must expose api_base so cloud customers "
|
|
"can override the upstream URL without env var access."
|
|
)
|
|
api_base_field = fields_by_key["api_base"]
|
|
assert api_base_field["required"] is False
|
|
assert api_base_field["field_type"] == "text"
|
|
assert api_base_field.get("tooltip"), "api_base should have a tooltip explaining it is optional."
|
|
|
|
# UI forms render fields in credential_fields order; api_base should come first
|
|
# so an admin sees the URL override before the key field.
|
|
field_order = [f["key"] for f in anthropic["credential_fields"]]
|
|
assert field_order.index("api_base") < field_order.index("api_key"), (
|
|
"api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
|
|
)
|
|
|
|
|
|
def test_bedrock_mantle_provider_fields():
|
|
"""Amazon Bedrock Mantle must be a selectable provider in the Add Model flow.
|
|
|
|
The dropdown is driven entirely by /public/providers/fields, so a missing
|
|
entry means Mantle cannot be added through the UI at all (regression guard
|
|
for LIT-3885). The credential fields must match what the backend actually
|
|
honors: an optional bearer api_key (BYOK), the AWS SigV4 chain, a region,
|
|
and an api_base override.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
mantle = next((p for p in providers if p["provider"] == "BedrockMantle"), None)
|
|
assert mantle is not None, "Bedrock Mantle provider entry not found"
|
|
|
|
# provider must equal the UI provider_map key so the model dropdown resolves
|
|
# bedrock_mantle models; litellm_provider must be the backend slug.
|
|
assert mantle["provider_display_name"] == "Amazon Bedrock Mantle"
|
|
assert mantle["litellm_provider"] == "bedrock_mantle"
|
|
assert mantle["default_model_placeholder"].startswith("bedrock_mantle/")
|
|
|
|
fields_by_key = {f["key"]: f for f in mantle["credential_fields"]}
|
|
|
|
# Bearer-token auth is BYOK: optional and masked.
|
|
assert "api_key" in fields_by_key
|
|
assert fields_by_key["api_key"]["required"] is False
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
# AWS SigV4 fallback credentials.
|
|
assert fields_by_key["aws_access_key_id"]["field_type"] == "password"
|
|
assert fields_by_key["aws_secret_access_key"]["field_type"] == "password"
|
|
assert "aws_region_name" in fields_by_key
|
|
|
|
# api_base override so admins can target a custom Mantle host without env access.
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
|
|
|
|
def test_vllm_provider_display_names_are_distinct():
|
|
"""Hosted and local vLLM must not share a dropdown label.
|
|
|
|
The Add Model provider dropdown is driven by /public/providers/fields.
|
|
Both entries previously rendered as near-identical "vllm"/"Vllm" rows
|
|
with the same logo, so admins could not tell them apart.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
hosted = next((p for p in providers if p["provider"] == "Hosted_Vllm"), None)
|
|
local = next((p for p in providers if p["provider"] == "VLLM"), None)
|
|
assert hosted is not None, "Hosted vLLM provider entry not found"
|
|
assert local is not None, "Local vLLM provider entry not found"
|
|
|
|
assert hosted["provider_display_name"] == "Hosted vLLM"
|
|
assert local["provider_display_name"] == "Local vLLM"
|
|
assert hosted["provider_display_name"].casefold() != local["provider_display_name"].casefold()
|
|
assert hosted["litellm_provider"] == "hosted_vllm"
|
|
assert local["litellm_provider"] == "vllm"
|
|
|
|
|
|
def test_nvidia_riva_provider_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
riva = next((p for p in providers if p["provider"] == "NVIDIA_RIVA"), None)
|
|
assert riva is not None, "NVIDIA Riva provider entry not found"
|
|
|
|
assert riva["provider_display_name"] == "Nvidia Riva"
|
|
assert riva["litellm_provider"] == LlmProviders.NVIDIA_RIVA.value
|
|
assert riva["default_model_placeholder"].startswith("nvidia_riva/")
|
|
|
|
fields_by_key = {f["key"]: f for f in riva["credential_fields"]}
|
|
|
|
assert fields_by_key["api_base"]["required"] is True
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
|
|
assert fields_by_key["api_key"]["required"] is False
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
assert "nvcf_function_id" in fields_by_key
|
|
assert fields_by_key["nvcf_function_id"]["required"] is False
|
|
|
|
|
|
def test_cognition_provider_fields():
|
|
"""Cognition must be selectable in the Add Model flow (LIT-5348).
|
|
|
|
The dropdown is driven entirely by /public/providers/fields, so without an
|
|
entry here admins have to fall back to the generic OpenAI-compatible route,
|
|
which is exactly the provider identity mix-up this feature removes.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
cognition = next((p for p in providers if p["provider"] == "Cognition"), None)
|
|
assert cognition is not None, "Cognition provider entry not found"
|
|
|
|
assert cognition["provider_display_name"] == "Cognition"
|
|
assert cognition["litellm_provider"] == "cognition"
|
|
assert cognition["default_model_placeholder"].startswith("cognition/")
|
|
|
|
fields_by_key = {f["key"]: f for f in cognition["credential_fields"]}
|
|
|
|
assert fields_by_key["api_key"]["required"] is True
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
assert fields_by_key["api_base"]["required"] is False
|
|
|
|
|
|
def test_qwen_mainland_provider_fields_carry_the_qianwen_brand():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
providers = test_client.get("/public/providers/fields").json()
|
|
|
|
mainland = next(p for p in providers if p["litellm_provider"] == "qwen_ai_platform")
|
|
international = next(p for p in providers if p["litellm_provider"] == "qwencloud")
|
|
|
|
assert mainland["provider_display_name"] == "Qianwen AI Platform"
|
|
assert international["provider_display_name"] == "QwenCloud"
|
|
|
|
mainland_fields = {f["key"]: f for f in mainland["credential_fields"]}
|
|
assert mainland_fields["api_key"]["label"] == "Qianwen AI Platform API Key"
|
|
assert "Qianwen AI Platform" in mainland_fields["api_base"]["tooltip"]
|
|
assert "Qwen AI Platform" not in json.dumps(mainland)
|
|
|
|
|
|
def test_chatgpt_provider_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
chatgpt = next((p for p in providers if p["provider"] == "CHATGPT"), None)
|
|
assert chatgpt is not None, "ChatGPT provider entry not found"
|
|
|
|
assert chatgpt["provider_display_name"] == "ChatGPT Subscription"
|
|
assert chatgpt["litellm_provider"] == LlmProviders.CHATGPT.value
|
|
assert chatgpt["default_model_placeholder"].startswith("chatgpt/")
|
|
assert chatgpt["credential_fields"] == []
|
|
|
|
|
|
def test_tencent_provider_fields():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
tencent = next((p for p in providers if p["provider"] == "Tencent"), None)
|
|
assert tencent is not None, "Tencent provider entry not found"
|
|
|
|
assert tencent["provider_display_name"] == "Tencent"
|
|
assert tencent["litellm_provider"] == LlmProviders.TENCENT.value
|
|
assert tencent["default_model_placeholder"].startswith("tencent/")
|
|
|
|
fields_by_key = {f["key"]: f for f in tencent["credential_fields"]}
|
|
|
|
assert fields_by_key["api_key"]["required"] is True
|
|
assert fields_by_key["api_key"]["field_type"] == "password"
|
|
|
|
assert fields_by_key["api_base"]["field_type"] == "text"
|
|
assert fields_by_key["api_base"]["required"] is False
|
|
|
|
|
|
def _decisions_provider_entry(provider: str) -> ProviderCreateInfo:
|
|
app_instance: Final = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client: Final = TestClient(app_instance)
|
|
|
|
response: Final = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers: Final = TypeAdapter(list[ProviderCreateInfo]).validate_python(response.json())
|
|
entry: Final = next((p for p in providers if p.provider == provider), None)
|
|
assert entry is not None, f"{provider} provider entry not found"
|
|
return entry
|
|
|
|
|
|
def test_typesafe_provider_fields():
|
|
typesafe: Final = _decisions_provider_entry("TypeSafe")
|
|
|
|
assert typesafe.provider_display_name == "TypeSafe"
|
|
assert typesafe.litellm_provider == LlmProviders.TYPESAFE.value
|
|
assert typesafe.default_model_placeholder is not None
|
|
assert typesafe.default_model_placeholder.startswith("typesafe/")
|
|
|
|
fields_by_key: Final = {f.key: f for f in typesafe.credential_fields}
|
|
|
|
assert fields_by_key["api_key"].required is True
|
|
assert fields_by_key["api_key"].field_type == "password"
|
|
|
|
assert fields_by_key["api_base"].required is False
|
|
assert fields_by_key["api_base"].field_type == "text"
|
|
|
|
|
|
def test_strands_decider_provider_fields():
|
|
strands: Final = _decisions_provider_entry("StrandsDecider")
|
|
|
|
assert strands.provider_display_name == "Strands Decider"
|
|
assert strands.litellm_provider == LlmProviders.STRANDS_DECIDER.value
|
|
assert strands.default_model_placeholder is not None
|
|
assert strands.default_model_placeholder.startswith("strands_decider/")
|
|
|
|
fields_by_key: Final = {f.key: f for f in strands.credential_fields}
|
|
|
|
assert fields_by_key["api_base"].required is True
|
|
assert fields_by_key["api_base"].field_type == "text"
|
|
|
|
assert fields_by_key["api_key"].required is False
|
|
assert fields_by_key["api_key"].field_type == "password"
|
|
|
|
|
|
ADD_MODEL_UNLISTED_PROVIDERS: Final = frozenset(
|
|
{
|
|
"a2a",
|
|
"a2a_agent",
|
|
"amazon_nova",
|
|
"apertis",
|
|
"aws_polly",
|
|
"black_forest_labs",
|
|
"charity_engine",
|
|
"chutes",
|
|
"darkbloom",
|
|
"gdc",
|
|
"helicone",
|
|
"inception",
|
|
"langflow",
|
|
"langgraph",
|
|
"libertai",
|
|
"litellm_agent",
|
|
"manus",
|
|
"meta",
|
|
"modelscope",
|
|
"mongodb",
|
|
"nano-gpt",
|
|
"neosantara",
|
|
"parasail",
|
|
"pinstripes",
|
|
"poe",
|
|
"publicai",
|
|
"ragflow",
|
|
"reducto",
|
|
"s3_vectors",
|
|
"sagemaker_nova",
|
|
"scaleway",
|
|
"stability",
|
|
"synthetic",
|
|
"tensormesh",
|
|
"text-completion-inception",
|
|
"transcribe",
|
|
"valkey",
|
|
"xiaomi_mimo",
|
|
"zai",
|
|
}
|
|
)
|
|
|
|
|
|
def test_every_backend_provider_is_listed_in_add_model_or_frozen_as_unlisted():
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
listed = {p["litellm_provider"] for p in response.json()}
|
|
|
|
unlisted = {provider.value for provider in LlmProviders} - listed
|
|
assert unlisted == ADD_MODEL_UNLISTED_PROVIDERS, (
|
|
"Add Model dropdown drift: give the new provider an entry in provider_create_fields.json "
|
|
"rather than adding it to ADD_MODEL_UNLISTED_PROVIDERS"
|
|
)
|
|
|
|
|
|
def test_google_ai_studio_provider_fields_expose_api_base():
|
|
"""The Google AI Studio (gemini) credential form must let admins set a custom
|
|
api_base so they can point at a Gemini-compatible gateway (e.g. a self-hosted
|
|
proxy at /v1beta) without env var access.
|
|
|
|
The runtime gemini provider already supports custom api_base via
|
|
`vertex_llm_base._check_custom_proxy`; the UI just needs to expose the field.
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/providers/fields")
|
|
assert response.status_code == 200
|
|
providers = response.json()
|
|
|
|
google_ai = next((p for p in providers if p["provider"] == "Google_AI_Studio"), None)
|
|
assert google_ai is not None, "Google_AI_Studio provider entry not found"
|
|
assert google_ai["litellm_provider"] == "gemini"
|
|
|
|
fields_by_key = {f["key"]: f for f in google_ai["credential_fields"]}
|
|
assert "api_key" in fields_by_key
|
|
assert "api_base" in fields_by_key, (
|
|
"Google_AI_Studio provider form must expose api_base so admins can "
|
|
"point at a Gemini-compatible gateway without env var access."
|
|
)
|
|
|
|
api_base_field = fields_by_key["api_base"]
|
|
assert api_base_field["required"] is False
|
|
assert api_base_field["field_type"] == "text"
|
|
# default_value MUST be null (not the canonical URL): saving it as the
|
|
# default would persist v1beta into every credential record and bypass
|
|
# `get_gemini_url`'s automatic v1alpha routing for Gemini 3+ models. The
|
|
# placeholder shows the canonical URL so users still get the visual hint.
|
|
# (See greptileai threads on PR #30419.)
|
|
assert api_base_field["default_value"] is None
|
|
assert api_base_field["placeholder"] == "https://generativelanguage.googleapis.com/v1beta"
|
|
|
|
# UI forms render fields in credential_fields order; api_base should come
|
|
# first so an admin sees the URL override before the key field (matches
|
|
# OpenAI and Anthropic conventions).
|
|
field_order = [f["key"] for f in google_ai["credential_fields"]]
|
|
assert field_order.index("api_base") < field_order.index("api_key"), (
|
|
"api_base must appear before api_key in credential_fields."
|
|
)
|
|
|
|
|
|
def test_public_model_hub_with_healthy_model():
|
|
"""Test that health information is populated for a healthy model"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
# Override auth dependency
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
# Create mock model groups
|
|
mock_model_group = ModelGroupInfoProxy(
|
|
model_group="gpt-3.5-turbo",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
# Create mock health check
|
|
mock_health_check = MagicMock()
|
|
mock_health_check.model_id = None
|
|
mock_health_check.model_name = "gpt-3.5-turbo"
|
|
mock_health_check.status = "healthy"
|
|
mock_health_check.response_time_ms = 150.5
|
|
mock_health_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["gpt-3.5-turbo"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
|
):
|
|
mock_get_info.return_value = [mock_model_group]
|
|
mock_convert.return_value = {
|
|
"status": "healthy",
|
|
"response_time_ms": 150.5,
|
|
"checked_at": mock_health_check.checked_at.isoformat(),
|
|
}
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
assert data[0]["model_group"] == "gpt-3.5-turbo"
|
|
assert data[0]["health_status"] == "healthy"
|
|
assert data[0]["health_response_time"] == 150.5
|
|
assert data[0]["health_checked_at"] is not None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_model_hub_with_unhealthy_model():
|
|
"""Test that health information is populated for an unhealthy model"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
mock_model_group = ModelGroupInfoProxy(
|
|
model_group="gpt-4",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
mock_health_check = MagicMock()
|
|
mock_health_check.model_id = None
|
|
mock_health_check.model_name = "gpt-4"
|
|
mock_health_check.status = "unhealthy"
|
|
mock_health_check.response_time_ms = None
|
|
mock_health_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["gpt-4"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
|
):
|
|
mock_get_info.return_value = [mock_model_group]
|
|
mock_convert.return_value = {
|
|
"status": "unhealthy",
|
|
"response_time_ms": None,
|
|
"checked_at": mock_health_check.checked_at.isoformat(),
|
|
}
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
assert data[0]["model_group"] == "gpt-4"
|
|
assert data[0]["health_status"] == "unhealthy"
|
|
assert data[0]["health_response_time"] is None
|
|
assert data[0]["health_checked_at"] is not None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_model_hub_without_health_check():
|
|
"""Test that health information is null when no health check exists"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
mock_model_group = ModelGroupInfoProxy(
|
|
model_group="claude-3",
|
|
providers=["anthropic"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[])
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["claude-3"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
):
|
|
mock_get_info.return_value = [mock_model_group]
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 1
|
|
assert data[0]["model_group"] == "claude-3"
|
|
assert data[0]["health_status"] is None
|
|
assert data[0]["health_response_time"] is None
|
|
assert data[0]["health_checked_at"] is None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_model_hub_mixed_health_statuses():
|
|
"""Test multiple models with different health statuses"""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
healthy_model = ModelGroupInfoProxy(
|
|
model_group="gpt-3.5-turbo",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
unhealthy_model = ModelGroupInfoProxy(
|
|
model_group="gpt-4",
|
|
providers=["openai"],
|
|
is_public_model_group=True,
|
|
)
|
|
no_health_model = ModelGroupInfoProxy(
|
|
model_group="claude-3",
|
|
providers=["anthropic"],
|
|
is_public_model_group=True,
|
|
)
|
|
|
|
healthy_check = MagicMock()
|
|
healthy_check.model_id = None
|
|
healthy_check.model_name = "gpt-3.5-turbo"
|
|
healthy_check.status = "healthy"
|
|
healthy_check.response_time_ms = 120.0
|
|
healthy_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
unhealthy_check = MagicMock()
|
|
unhealthy_check.model_id = None
|
|
unhealthy_check.model_name = "gpt-4"
|
|
unhealthy_check.status = "unhealthy"
|
|
unhealthy_check.response_time_ms = None
|
|
unhealthy_check.checked_at = datetime.now(timezone.utc)
|
|
|
|
mock_llm_router = MagicMock()
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[healthy_check, unhealthy_check])
|
|
|
|
def convert_side_effect(check):
|
|
if check.model_name == "gpt-3.5-turbo":
|
|
return {
|
|
"status": "healthy",
|
|
"response_time_ms": 120.0,
|
|
"checked_at": check.checked_at.isoformat(),
|
|
}
|
|
elif check.model_name == "gpt-4":
|
|
return {
|
|
"status": "unhealthy",
|
|
"response_time_ms": None,
|
|
"checked_at": check.checked_at.isoformat(),
|
|
}
|
|
return {}
|
|
|
|
with (
|
|
patch("litellm.public_model_groups", ["gpt-3.5-turbo", "gpt-4", "claude-3"]),
|
|
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
|
|
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
|
|
):
|
|
mock_get_info.return_value = [
|
|
healthy_model,
|
|
unhealthy_model,
|
|
no_health_model,
|
|
]
|
|
mock_convert.side_effect = convert_side_effect
|
|
|
|
response = client.get(
|
|
"/public/model_hub",
|
|
headers={"Authorization": "Bearer test-key"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data) == 3
|
|
|
|
# Find each model and verify health status
|
|
gpt35 = next(m for m in data if m["model_group"] == "gpt-3.5-turbo")
|
|
assert gpt35["health_status"] == "healthy"
|
|
assert gpt35["health_response_time"] == 120.0
|
|
assert gpt35["health_checked_at"] is not None
|
|
|
|
gpt4 = next(m for m in data if m["model_group"] == "gpt-4")
|
|
assert gpt4["health_status"] == "unhealthy"
|
|
assert gpt4["health_response_time"] is None
|
|
assert gpt4["health_checked_at"] is not None
|
|
|
|
claude = next(m for m in data if m["model_group"] == "claude-3")
|
|
assert claude["health_status"] is None
|
|
assert claude["health_response_time"] is None
|
|
assert claude["health_checked_at"] is None
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/agent_hub
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_public_agent_hub_rewrites_upstream_url_to_proxy():
|
|
"""Public agent hub must not leak the upstream backend URL retained on the
|
|
stored card. The ``url`` field has to be overwritten with the proxy
|
|
``/a2a/{agent_id}`` entrypoint, matching the well-known card endpoint, so
|
|
an unauthenticated client cannot call the backend directly."""
|
|
from litellm.types.agents import AgentResponse
|
|
|
|
upstream_url = "https://upstream.internal.example.com/a2a"
|
|
agent = AgentResponse(
|
|
agent_id="agent-123",
|
|
agent_name="public-agent",
|
|
agent_card_params={"name": "public-agent", "url": upstream_url},
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.get_public_agent_list.return_value = [agent]
|
|
mock_registry.ids_for_agent = MagicMock(side_effect=lambda agent_id: frozenset({agent_id}))
|
|
|
|
with (
|
|
patch("litellm.public_agent_groups", ["agent-123"]),
|
|
patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry",
|
|
mock_registry,
|
|
),
|
|
):
|
|
response = client.get("/public/agent_hub")
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert len(payload) == 1
|
|
card = payload[0]
|
|
assert upstream_url not in card.get("url", "")
|
|
assert card["url"].endswith("/a2a/agent-123")
|
|
|
|
|
|
def test_public_agent_hub_serializes_http_security_scheme_without_bearer_format():
|
|
"""Regression: agents created through the UI carry an auto-generated
|
|
``securitySchemes.LiteLLMKey`` of ``{"type": "http", "scheme": "bearer"}``
|
|
with no ``bearerFormat``. The endpoint response_model must accept this
|
|
optional-field-omitted scheme; otherwise response validation raises and
|
|
/public/agent_hub returns 500, which the frontend swallows into an empty
|
|
list and hides the Agent Hub tab."""
|
|
from litellm.types.agents import AgentResponse
|
|
|
|
agent = AgentResponse(
|
|
agent_id="agent-123",
|
|
agent_name="public-agent",
|
|
agent_card_params={
|
|
"name": "public-agent",
|
|
"url": "https://upstream.internal.example.com/a2a",
|
|
"securitySchemes": {
|
|
"LiteLLMKey": {
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"description": "LiteLLM virtual key",
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.get_public_agent_list.return_value = [agent]
|
|
mock_registry.ids_for_agent = MagicMock(side_effect=lambda agent_id: frozenset({agent_id}))
|
|
|
|
with (
|
|
patch("litellm.public_agent_groups", ["agent-123"]),
|
|
patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry",
|
|
mock_registry,
|
|
),
|
|
):
|
|
response = client.get("/public/agent_hub")
|
|
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert len(payload) == 1
|
|
scheme = payload[0]["securitySchemes"]["LiteLLMKey"]
|
|
assert scheme["type"] == "http"
|
|
assert scheme["scheme"] == "bearer"
|
|
assert "bearerFormat" not in scheme
|
|
|
|
|
|
def test_public_agent_hub_returns_empty_when_no_public_groups():
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
mock_registry = MagicMock()
|
|
mock_registry.get_public_agent_list.return_value = []
|
|
|
|
with (
|
|
patch("litellm.public_agent_groups", None),
|
|
patch(
|
|
"litellm.proxy.agent_endpoints.agent_registry.global_agent_registry",
|
|
mock_registry,
|
|
),
|
|
):
|
|
response = client.get("/public/agent_hub")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/agents/fields
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bedrock_agentcore_runtime_arn_validation_pattern_accepts_full_resource_path():
|
|
"""Regression for LIT-6737: the AgentCore agent_runtime_arn field's
|
|
validation_pattern must accept a complete runtime ARN whose resource part
|
|
is itself multi-segment (``runtime/<runtime-id>``), and reject the exact
|
|
truncated shape a naive split("/")-by-position parse used to produce (the
|
|
ARN cut off right after the ``runtime`` resource type).
|
|
"""
|
|
app_instance = FastAPI()
|
|
app_instance.include_router(router)
|
|
test_client = TestClient(app_instance)
|
|
|
|
response = test_client.get("/public/agents/fields")
|
|
assert response.status_code == 200
|
|
agents = response.json()
|
|
|
|
bedrock_agentcore = next((a for a in agents if a["agent_type"] == "bedrock_agentcore"), None)
|
|
assert bedrock_agentcore is not None, "bedrock_agentcore agent type not found"
|
|
assert bedrock_agentcore["model_template"] == "bedrock/agentcore/{agent_runtime_arn}"
|
|
|
|
fields_by_key = {f["key"]: f for f in bedrock_agentcore["credential_fields"]}
|
|
arn_field = fields_by_key["agent_runtime_arn"]
|
|
assert arn_field["required"] is True
|
|
assert arn_field["include_in_litellm_params"] is False
|
|
|
|
pattern = arn_field.get("validation_pattern")
|
|
assert pattern, "agent_runtime_arn must ship a validation_pattern so the UI can reject a truncated ARN"
|
|
|
|
full_arn = "arn:aws:bedrock-agentcore:eu-central-1:123456789012:runtime/hosted_agent_4vm3i-BaTdfOELAs"
|
|
truncated_arn = "arn:aws:bedrock-agentcore:eu-central-1:123456789012:runtime"
|
|
|
|
assert re.match(pattern, full_arn), "the validator must accept a complete runtime ARN"
|
|
assert not re.match(pattern, truncated_arn), "the validator must reject the truncated ARN"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import litellm.proxy.public_endpoints.public_endpoints as _pe_module
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_build_endpoints,
|
|
_clean_display_name,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=False)
|
|
def reset_endpoints_cache():
|
|
"""Reset the module-level cache before and after each cache-related test."""
|
|
original = _pe_module._cached_endpoints
|
|
_pe_module._cached_endpoints = None
|
|
yield
|
|
_pe_module._cached_endpoints = original
|
|
|
|
|
|
def _make_client():
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_get_supported_endpoints_returns_200(reset_endpoints_cache):
|
|
response = _make_client().get("/public/endpoints")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_get_supported_endpoints_response_shape(reset_endpoints_cache):
|
|
data = _make_client().get("/public/endpoints").json()
|
|
assert "endpoints" in data
|
|
assert isinstance(data["endpoints"], list)
|
|
assert len(data["endpoints"]) > 0
|
|
|
|
|
|
def test_get_supported_endpoints_item_fields(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
assert "key" in item
|
|
assert "label" in item
|
|
assert "endpoint" in item
|
|
assert "providers" in item
|
|
assert isinstance(item["providers"], list)
|
|
|
|
|
|
def test_get_supported_endpoints_provider_fields(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
for provider in item["providers"]:
|
|
assert "slug" in provider
|
|
assert "display_name" in provider
|
|
|
|
|
|
def test_get_supported_endpoints_paths_start_with_slash(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
assert item["endpoint"].startswith("/"), f"Expected path starting with /, got: {item['endpoint']}"
|
|
|
|
|
|
def test_get_supported_endpoints_chat_completions_present(reset_endpoints_cache):
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
keys = [item["key"] for item in endpoints]
|
|
assert "chat_completions" in keys
|
|
|
|
chat = next(item for item in endpoints if item["key"] == "chat_completions")
|
|
assert chat["endpoint"] == "/chat/completions"
|
|
assert chat["label"] == "Chat Completions"
|
|
assert len(chat["providers"]) > 0
|
|
|
|
|
|
def test_get_supported_endpoints_display_names_have_no_slug_suffix(
|
|
reset_endpoints_cache,
|
|
):
|
|
"""Provider display_names must not contain the raw `` (`slug`) `` suffix."""
|
|
import re
|
|
|
|
suffix_re = re.compile(r"\(`[^`]+`\)")
|
|
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
|
|
for item in endpoints:
|
|
for provider in item["providers"]:
|
|
assert not suffix_re.search(provider["display_name"]), (
|
|
f"display_name still contains slug suffix: {provider['display_name']!r}"
|
|
)
|
|
|
|
|
|
def test_get_supported_endpoints_is_cached(reset_endpoints_cache):
|
|
"""`_load_endpoints` is called only once; subsequent requests use the cache."""
|
|
client = _make_client()
|
|
with patch(
|
|
"litellm.proxy.public_endpoints.public_endpoints._load_endpoints",
|
|
wraps=_pe_module._load_endpoints,
|
|
) as mock_load:
|
|
client.get("/public/endpoints")
|
|
client.get("/public/endpoints")
|
|
client.get("/public/endpoints")
|
|
|
|
mock_load.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_endpoints unit tests (transformation logic)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_MINIMAL_RAW = {
|
|
"providers": {
|
|
"openai": {
|
|
"display_name": "OpenAI (`openai`)",
|
|
"url": "https://example.com",
|
|
"endpoints": {
|
|
"chat_completions": True,
|
|
"embeddings": True,
|
|
"images": False,
|
|
},
|
|
},
|
|
"anthropic": {
|
|
"display_name": "Anthropic (`anthropic`)",
|
|
"url": "https://example.com",
|
|
"endpoints": {
|
|
"chat_completions": True,
|
|
"embeddings": False,
|
|
"images": False,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
|
|
def test_build_endpoints_known_key_uses_metadata():
|
|
result = _build_endpoints(_MINIMAL_RAW)
|
|
chat = next(e for e in result if e["key"] == "chat_completions")
|
|
assert chat["label"] == "Chat Completions"
|
|
assert chat["endpoint"] == "/chat/completions"
|
|
|
|
|
|
def test_build_endpoints_only_includes_supporting_providers():
|
|
result = _build_endpoints(_MINIMAL_RAW)
|
|
embeddings = next(e for e in result if e["key"] == "embeddings")
|
|
slugs = [p["slug"] for p in embeddings["providers"]]
|
|
assert slugs == ["openai"]
|
|
|
|
|
|
def test_build_endpoints_unknown_key_derives_label_and_path():
|
|
raw = {
|
|
"providers": {
|
|
"someprovider": {
|
|
"display_name": "Some Provider (`someprovider`)",
|
|
"endpoints": {"my_custom_endpoint": True},
|
|
}
|
|
}
|
|
}
|
|
result = _build_endpoints(raw)
|
|
item = result[0]
|
|
assert item["key"] == "my_custom_endpoint"
|
|
assert item["label"] == "My Custom Endpoint"
|
|
assert item["endpoint"].startswith("/")
|
|
|
|
|
|
def test_build_endpoints_empty_providers_returns_empty():
|
|
result = _build_endpoints({"providers": {}})
|
|
assert result == []
|
|
|
|
|
|
def test_clean_display_name_strips_suffix():
|
|
assert _clean_display_name("OpenAI (`openai`)") == "OpenAI"
|
|
assert _clean_display_name("AI/ML API (`aiml`)") == "AI/ML API"
|
|
assert _clean_display_name("A2A (Agent-to-Agent) (`a2a`)") == "A2A (Agent-to-Agent)"
|
|
|
|
|
|
def test_clean_display_name_passthrough_when_no_suffix():
|
|
assert _clean_display_name("OpenAI") == "OpenAI"
|
|
assert _clean_display_name("") == ""
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"strict,explicit,expected_listed",
|
|
((True, True, True), (True, False, False), (False, True, True), (False, False, True)),
|
|
)
|
|
@pytest.mark.parametrize("stored_public", (None, False, True))
|
|
def test_public_mcp_hub_derives_publication_metadata_without_mutating_registry(
|
|
strict: bool,
|
|
explicit: bool,
|
|
expected_listed: bool,
|
|
stored_public: bool | None,
|
|
) -> None:
|
|
from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
client: Final = TestClient(app)
|
|
|
|
server: Final = MCPServer(
|
|
server_id="listed",
|
|
name="listed",
|
|
server_name="listed",
|
|
transport=MCPTransport.http,
|
|
available_on_public_internet=True,
|
|
mcp_info=(
|
|
{
|
|
"is_public": stored_public,
|
|
"is_public_explicit": not explicit,
|
|
"description": "Preserve custom metadata",
|
|
}
|
|
if stored_public is not None
|
|
else None
|
|
),
|
|
)
|
|
unlisted: Final = MCPServer(
|
|
server_id="unlisted",
|
|
name="unlisted",
|
|
transport=MCPTransport.http,
|
|
available_on_public_internet=False,
|
|
mcp_info={"is_public": True, "is_public_explicit": True},
|
|
)
|
|
manager: Final = MCPServerManager()
|
|
manager.config_mcp_servers = {server.server_id: server}
|
|
manager.registry = {unlisted.server_id: unlisted}
|
|
original_registry: Final = {key: value.model_dump() for key, value in manager.get_registry().items()}
|
|
|
|
with (
|
|
patch("litellm.public_mcp_servers", [server.server_id] if explicit else []),
|
|
patch("litellm.public_mcp_hub_strict_whitelist", strict),
|
|
patch(
|
|
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
|
manager,
|
|
),
|
|
):
|
|
response: Final = client.get("/public/mcp_hub")
|
|
|
|
assert response.status_code == 200
|
|
data: Final = response.json()
|
|
assert [item["server_id"] for item in data] == ([server.server_id] if expected_listed else [])
|
|
if expected_listed:
|
|
assert data[0]["mcp_info"] == {
|
|
**({"description": "Preserve custom metadata"} if stored_public is not None else {}),
|
|
"is_public": True,
|
|
"is_public_explicit": explicit,
|
|
}
|
|
assert {key: value.model_dump() for key, value in manager.get_registry().items()} == original_registry
|
|
|
|
|
|
def test_public_mcp_hub_returns_empty_when_whitelist_unset():
|
|
"""When no servers have been published via /v1/mcp/make_public, the
|
|
hub returns an empty list (matches /public/agent_hub behavior)."""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_public_mcp_servers.return_value = []
|
|
|
|
with (
|
|
patch("litellm.public_mcp_servers", None),
|
|
patch(
|
|
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
|
mock_manager,
|
|
),
|
|
):
|
|
response = client.get("/public/mcp_hub")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_public_mcp_hub_does_not_expose_upstream_url():
|
|
"""Regression: /public/mcp_hub is unauthenticated, so the gateway-internal
|
|
upstream url must never appear in its response even when the server has one."""
|
|
from litellm.proxy._types import MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
|
|
secret_url = "https://internal-only.example.com/mcp"
|
|
server = MCPServer(
|
|
server_id="listed",
|
|
name="listed",
|
|
server_name="listed",
|
|
url=secret_url,
|
|
transport=MCPTransport.http,
|
|
available_on_public_internet=True,
|
|
)
|
|
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_public_mcp_servers.return_value = [server]
|
|
|
|
with (
|
|
patch("litellm.public_mcp_servers", ["listed"]),
|
|
patch(
|
|
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
|
mock_manager,
|
|
),
|
|
):
|
|
response = client.get("/public/mcp_hub")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert [item["server_id"] for item in data] == ["listed"]
|
|
assert all("url" not in item for item in data)
|
|
assert secret_url not in response.text
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def reset_autorouter_presets_cache():
|
|
from litellm.proxy.public_endpoints.public_endpoints import _AutoRouterPresetsCache
|
|
|
|
_AutoRouterPresetsCache.presets = None
|
|
_AutoRouterPresetsCache.lock = None
|
|
yield
|
|
_AutoRouterPresetsCache.presets = None
|
|
_AutoRouterPresetsCache.lock = None
|
|
|
|
|
|
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(monkeypatch, reset_autorouter_presets_cache):
|
|
monkeypatch.setenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "True")
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/autorouter_presets")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert "anthropic_family" in payload
|
|
assert payload["1m_context"]["complexity_router_config"]["classifier_type"] == "heuristic_v2"
|
|
assert payload["1m_context"]["complexity_router_config"]["tiers"] == {
|
|
"SIMPLE": ["gpt-6-luna"],
|
|
"MEDIUM": ["gpt-5.6-terra"],
|
|
"COMPLEX": ["gpt-6-sol"],
|
|
"REASONING": ["claude-opus-5-5"],
|
|
}
|
|
assert payload["1m_context"]["complexity_router_config"]["tier_model_configs"] == {
|
|
"REASONING": [{"model_name": "claude-opus-5-5", "litellm_params": {"reasoning_effort": "high"}}]
|
|
}
|
|
for preset in payload.values():
|
|
assert isinstance(preset["label"], str)
|
|
assert isinstance(preset["description"], str)
|
|
assert "tiers" in preset["complexity_router_config"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_autorouter_presets_fetches_once_per_process(monkeypatch, reset_autorouter_presets_cache):
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_AUTOROUTER_PRESETS_ADAPTER,
|
|
get_autorouter_presets,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
remote = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"remote_only": {
|
|
"label": "Remote Only",
|
|
"description": "from the remote catalog",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
|
},
|
|
}
|
|
}
|
|
)
|
|
calls = []
|
|
|
|
async def fake_fetch(url):
|
|
calls.append(url)
|
|
return remote
|
|
|
|
first = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
|
second = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
|
|
|
assert first == remote
|
|
assert second == remote
|
|
assert calls == ["https://example.test/presets.json"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_autorouter_presets_single_flight_on_concurrent_cold_start(
|
|
monkeypatch, reset_autorouter_presets_cache
|
|
):
|
|
import asyncio
|
|
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_AUTOROUTER_PRESETS_ADAPTER,
|
|
get_autorouter_presets,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
remote = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"remote_only": {
|
|
"label": "Remote Only",
|
|
"description": "from the remote catalog",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
|
},
|
|
}
|
|
}
|
|
)
|
|
calls = []
|
|
|
|
async def slow_fetch(url):
|
|
calls.append(url)
|
|
await asyncio.sleep(0.05)
|
|
return remote
|
|
|
|
results = await asyncio.gather(
|
|
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
|
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
|
get_autorouter_presets(url="https://example.test/presets.json", fetch=slow_fetch),
|
|
)
|
|
|
|
assert all(result == remote for result in results)
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_autorouter_presets_caches_bundled_fallback_on_remote_failure(
|
|
monkeypatch, reset_autorouter_presets_cache
|
|
):
|
|
from litellm.proxy.public_endpoints.public_endpoints import get_autorouter_presets
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
calls = []
|
|
|
|
async def broken_fetch(url):
|
|
calls.append(url)
|
|
raise ValueError("remote catalog unavailable")
|
|
|
|
first = await get_autorouter_presets(url="https://example.test/presets.json", fetch=broken_fetch)
|
|
second = await get_autorouter_presets(url="https://example.test/presets.json", fetch=broken_fetch)
|
|
|
|
assert "anthropic_family" in first
|
|
assert second == first
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_autorouter_presets_adapter_rejects_wrong_shapes():
|
|
from pydantic import ValidationError
|
|
|
|
from litellm.proxy.public_endpoints.public_endpoints import _AUTOROUTER_PRESETS_ADAPTER
|
|
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python({"bad": {"label": "no description or config"}})
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(["not", "a", "mapping"])
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{"no_tiers": {"label": "L", "description": "D", "complexity_router_config": {}}}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"missing_builtin_tier": {
|
|
"label": "L",
|
|
"description": "D",
|
|
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"]}},
|
|
}
|
|
}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"unknown_tier_name": {
|
|
"label": "L",
|
|
"description": "D",
|
|
"complexity_router_config": {
|
|
"tiers": {
|
|
"SIMPLE": ["m1"],
|
|
"MEDIUM": ["m2"],
|
|
"COMPLEX": ["m3"],
|
|
"REASONING": ["m4"],
|
|
"ULTRA": ["m5"],
|
|
}
|
|
},
|
|
}
|
|
}
|
|
)
|
|
with pytest.raises(ValidationError):
|
|
_AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"bad_tiers": {
|
|
"label": "L",
|
|
"description": "D",
|
|
"complexity_router_config": {"tiers": "not-a-mapping"},
|
|
}
|
|
}
|
|
)
|
|
|
|
|
|
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(monkeypatch, reset_autorouter_presets_cache):
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_AUTOROUTER_PRESETS_ADAPTER,
|
|
_AutoRouterPresetsCache,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
_AutoRouterPresetsCache.presets = _AUTOROUTER_PRESETS_ADAPTER.validate_python(
|
|
{
|
|
"future_preset": {
|
|
"label": "Future",
|
|
"description": "carries fields this proxy version does not know",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]},
|
|
"future_config_knob": 3,
|
|
},
|
|
"icon": "sparkles",
|
|
}
|
|
}
|
|
)
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/public/autorouter_presets")
|
|
|
|
assert response.status_code == 200
|
|
served = response.json()["future_preset"]
|
|
assert served["icon"] == "sparkles"
|
|
assert served["complexity_router_config"]["future_config_knob"] == 3
|
|
assert served["complexity_router_config"]["tiers"]["SIMPLE"] == ["m1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypatch):
|
|
import litellm.llms.custom_httpx.http_handler as http_handler_module
|
|
from litellm.proxy.public_endpoints.public_endpoints import _fetch_remote_autorouter_presets
|
|
|
|
catalog = {
|
|
"remote_only": {
|
|
"label": "Remote Only",
|
|
"description": "from the remote catalog",
|
|
"complexity_router_config": {
|
|
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
|
|
},
|
|
}
|
|
}
|
|
response = MagicMock()
|
|
response.raise_for_status = MagicMock()
|
|
response.content = json.dumps(catalog).encode()
|
|
client = MagicMock()
|
|
client.get = AsyncMock(return_value=response)
|
|
monkeypatch.setattr(http_handler_module, "get_async_httpx_client", lambda llm_provider: client)
|
|
|
|
presets = await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|
|
assert presets["remote_only"].label == "Remote Only"
|
|
response.raise_for_status.assert_called_once()
|
|
|
|
response.content = b"{}"
|
|
with pytest.raises(ValueError, match="empty"):
|
|
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|