mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
* refactor(mcp): drop the experimental label from the MCP server and client paths Move litellm/proxy/_experimental/mcp_server to litellm/proxy/mcp_server and litellm/experimental_mcp_client to litellm/mcp_client, with the mirrored tests moved under tests/unit/proxy/mcp_server and tests/unit/mcp_client. Both old packages stay as shims that warn with DeprecationWarning, re-export the new package's public names and alias every submodule in sys.modules so deep imports keep resolving to the same objects. The OpenAPI docs gate now puts the merge base's enterprise package on PYTHONPATH so its base counts do not mix the checked-out enterprise code with the base litellm tree Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(mcp): keep tests/unit/proxy/_experimental as a sharded root for the shim tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): alias namespace subpackages and bind attributes in the deprecated MCP shims pkgutil.walk_packages skipped mcp_server/auth because it has no __init__.py, so old imports of litellm.proxy._experimental.mcp_server.auth.* failed, and sys.modules entries alone left litellm.experimental_mcp_client.tools unreachable as an attribute. Walk the package on disk and setattr each alias on its old parent. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1725 lines
64 KiB
Python
1725 lines
64 KiB
Python
import asyncio
|
|
import json
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from importlib.resources import files
|
|
from itertools import chain
|
|
from types import SimpleNamespace
|
|
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.db.health_check_latest import LatestHealthCheckRow
|
|
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, ProviderCredentialField
|
|
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
|
|
|
|
|
|
@pytest.mark.parametrize("display_name,provider", (("TypeSafe", LlmProviders.TYPESAFE), ("Nace", LlmProviders.NACE)))
|
|
def test_decisions_provider_fields(display_name: str, provider: LlmProviders) -> None:
|
|
entry: Final = _decisions_provider_entry(display_name)
|
|
|
|
assert entry.provider_display_name == display_name
|
|
assert entry.litellm_provider == provider.value
|
|
assert entry.default_model_placeholder is not None
|
|
assert entry.default_model_placeholder.startswith(f"{provider.value}/")
|
|
|
|
fields_by_key: Final = {f.key: f for f in entry.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"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("provider", "display_name", "litellm_provider", "model_placeholder"),
|
|
(
|
|
("Laya", "Laya", LlmProviders.LAYA.value, "laya/english"),
|
|
("Bespoke", "Bespoke Nimble", LlmProviders.BESPOKE.value, "bespoke/nimble"),
|
|
),
|
|
)
|
|
def test_oss_decisions_provider_fields(
|
|
provider: str, display_name: str, litellm_provider: str, model_placeholder: str
|
|
) -> None:
|
|
entry: Final = _decisions_provider_entry(provider)
|
|
|
|
assert entry.provider_display_name == display_name
|
|
assert entry.litellm_provider == litellm_provider
|
|
assert entry.default_model_placeholder == model_placeholder
|
|
|
|
fields_by_key: Final = {f.key: f for f in entry.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",
|
|
"databricks_agent",
|
|
"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()
|
|
|
|
|
|
def _stored_health_check(model_id: str | None, status: str, checked_at: datetime) -> LatestHealthCheckRow:
|
|
return LatestHealthCheckRow(
|
|
health_check_id=f"hc-{model_id}",
|
|
model_name="gpt-4",
|
|
model_id=model_id,
|
|
status=status,
|
|
checked_at=checked_at,
|
|
created_at=checked_at,
|
|
updated_at=checked_at,
|
|
)
|
|
|
|
|
|
def test_public_model_hub_reports_the_newest_check_of_a_group_not_the_last_row_read():
|
|
"""Rows come back in model_id order with NULL last, so a stale name-probe row used to overwrite the fresh one."""
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
|
client = TestClient(app)
|
|
fresh = _stored_health_check("deployment-a", "healthy", datetime(2026, 10, 16, 11, 10, tzinfo=timezone.utc))
|
|
stale = _stored_health_check(None, "unhealthy", datetime(2026, 9, 4, 11, 20, tzinfo=timezone.utc))
|
|
mock_prisma = MagicMock()
|
|
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=(fresh, stale))
|
|
|
|
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", MagicMock()),
|
|
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
|
):
|
|
mock_get_info.return_value = [
|
|
ModelGroupInfoProxy(model_group="gpt-4", providers=["openai"], is_public_model_group=True)
|
|
]
|
|
response = client.get("/public/model_hub", headers={"Authorization": "Bearer test-key"})
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()[0]["health_status"] == "healthy"
|
|
assert response.json()[0]["health_checked_at"] == "2026-10-16T11:10:00+00:00"
|
|
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._types import MCPTransport
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
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.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.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.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
|
|
@pytest.mark.parametrize("flag", ["LITELLM_LOCAL_AUTOROUTER_PRESETS", "LITELLM_OFFLINE_MODE"])
|
|
async def test_get_autorouter_presets_local_flags_serve_bundled_without_fetching(
|
|
monkeypatch, reset_autorouter_presets_cache, flag
|
|
):
|
|
from litellm.proxy.public_endpoints.public_endpoints import (
|
|
_load_bundled_autorouter_presets,
|
|
get_autorouter_presets,
|
|
)
|
|
|
|
monkeypatch.delenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", raising=False)
|
|
monkeypatch.setenv(flag, "True")
|
|
calls = []
|
|
|
|
async def fake_fetch(url):
|
|
calls.append(url)
|
|
raise AssertionError("no fetch when the bundled catalog is forced")
|
|
|
|
presets = await get_autorouter_presets(url="https://example.test/presets.json", fetch=fake_fetch)
|
|
|
|
assert presets == _load_bundled_autorouter_presets()
|
|
assert calls == []
|
|
|
|
|
|
@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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /public/agents/fields inherited provider credentials
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_agent_fields_merge_does_not_compound_across_requests():
|
|
"""The inherited-credentials merge runs once, so credential_fields cannot grow per request.
|
|
|
|
Caching the raw file contents instead of the merged result would let the merge
|
|
append inherited fields again on every request.
|
|
"""
|
|
client = _make_client()
|
|
|
|
def counts():
|
|
return {
|
|
agent["agent_type"]: len(agent.get("credential_fields") or [])
|
|
for agent in client.get("/public/agents/fields").json()
|
|
}
|
|
|
|
first = counts()
|
|
assert first, "expected at least one agent type"
|
|
assert first == counts() == counts()
|
|
|
|
|
|
def test_get_agent_fields_appends_inherited_provider_credential_fields():
|
|
client: Final = _make_client()
|
|
providers: Final = {p["provider"]: p for p in client.get("/public/providers/fields").json()}
|
|
agents: Final = client.get("/public/agents/fields").json()
|
|
bundled: Final = json.loads(
|
|
files("litellm.proxy.public_endpoints").joinpath("agent_create_fields.json").read_text(encoding="utf-8")
|
|
)
|
|
own_counts: Final = {agent["agent_type"]: len(agent["credential_fields"]) for agent in bundled}
|
|
expected_inherited: Final = {
|
|
agent["agent_type"]: providers.get(agent.get("inherit_credentials_from_provider"), {}).get(
|
|
"credential_fields", []
|
|
)
|
|
for agent in bundled
|
|
}
|
|
inherited: Final = {
|
|
agent["agent_type"]: agent["credential_fields"][own_counts[agent["agent_type"]] :] for agent in agents
|
|
}
|
|
|
|
assert any(expected_inherited.values()), "expected at least one agent to inherit provider credential fields"
|
|
assert all("inherit_credentials_from_provider" not in agent for agent in agents)
|
|
assert {
|
|
agent_type: [
|
|
{key: field[key] for key in field if key in ProviderCredentialField.model_fields} for field in fields
|
|
]
|
|
for agent_type, fields in inherited.items()
|
|
} == expected_inherited
|
|
assert all(field["include_in_litellm_params"] is True for field in chain.from_iterable(inherited.values()))
|
|
|
|
|
|
@pytest.mark.parametrize("preference", ["auto_install", None])
|
|
def test_public_skill_hub_echoes_registered_installation_preference(preference):
|
|
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.anthropic_endpoints.claude_code_endpoints.claude_code_marketplace import register_plugin
|
|
from litellm.types.proxy.claude_code_endpoints import RegisterPluginRequest
|
|
|
|
table: Final = MagicMock()
|
|
table.find_unique = AsyncMock(return_value=None)
|
|
table.create = AsyncMock(side_effect=lambda data: SimpleNamespace(id="plugin-id", **data))
|
|
table.find_many = AsyncMock(
|
|
side_effect=lambda where: [SimpleNamespace(id="plugin-id", **table.create.call_args.kwargs["data"])]
|
|
)
|
|
prisma: Final = MagicMock()
|
|
prisma.db.litellm_claudecodeplugintable = table
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
|
|
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
|
|
asyncio.run(
|
|
register_plugin(
|
|
request=RegisterPluginRequest(
|
|
name="security-review",
|
|
source={
|
|
"source": "archive",
|
|
"url": "https://plugins.example.com/security-review-1.0.0.zip",
|
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
},
|
|
installation_preference=preference,
|
|
),
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
|
|
)
|
|
)
|
|
response: Final = TestClient(app).get("/public/skill_hub")
|
|
|
|
assert response.status_code == 200
|
|
assert [(p["name"], p["installation_preference"]) for p in response.json()["plugins"]] == [
|
|
("security-review", preference)
|
|
]
|