mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
232 lines
7.8 KiB
Python
232 lines
7.8 KiB
Python
"""Regression tests for UI-registered embed plugins.
|
|
|
|
Covers three bugs:
|
|
1. `general_settings.plugins` was not a field on ConfigGeneralSettings, so the
|
|
admin UI's POST /config/field/update with field_name="plugins" was rejected
|
|
with "Invalid field=plugins passed in."
|
|
2. The in-memory plugin registry only refreshed at startup, so a plugin added
|
|
via the UI did not appear in /api/plugins until a restart.
|
|
3. Plugins persisted to DB general_settings were not loaded on startup (the
|
|
registry only initialised from the YAML config), so UI-added plugins vanished
|
|
after a restart.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from litellm.proxy._types import (
|
|
ConfigGeneralSettings,
|
|
LitellmUserRoles,
|
|
PluginConfig,
|
|
UserAPIKeyAuth,
|
|
)
|
|
from litellm.proxy.plugin_routes import list_plugins, register_plugins_from_config
|
|
|
|
|
|
def _admin() -> UserAPIKeyAuth:
|
|
return UserAPIKeyAuth(api_key="sk-admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
|
|
|
|
|
def _non_admin() -> UserAPIKeyAuth:
|
|
return UserAPIKeyAuth(api_key="sk-user", user_role=LitellmUserRoles.INTERNAL_USER)
|
|
|
|
|
|
def test_plugins_is_a_valid_general_setting() -> None:
|
|
"""The config-update endpoint gates on this exact membership check."""
|
|
assert "plugins" in ConfigGeneralSettings.model_fields
|
|
|
|
|
|
def test_config_general_settings_parses_plugin_list() -> None:
|
|
"""A list of plugin dicts (what the UI sends) coerces into PluginConfig."""
|
|
settings = ConfigGeneralSettings.model_validate(
|
|
{
|
|
"plugins": [
|
|
{
|
|
"name": "chat-ui",
|
|
"display_name": "Chat UI",
|
|
"url": "http://localhost:3300",
|
|
},
|
|
{
|
|
"name": "agent-builder",
|
|
"url": "http://127.0.0.1:4010",
|
|
"plugin_key": "sk-secret",
|
|
},
|
|
]
|
|
}
|
|
)
|
|
plugins = settings.plugins
|
|
assert plugins is not None
|
|
assert [p.name for p in plugins] == ["chat-ui", "agent-builder"]
|
|
assert isinstance(plugins[0], PluginConfig)
|
|
assert plugins[1].display_name is None
|
|
assert plugins[1].plugin_key == "sk-secret"
|
|
|
|
|
|
def test_registered_plugins_appear_in_list_without_restart() -> None:
|
|
"""register_plugins_from_config makes UI-added plugins visible immediately,
|
|
and replaces (not merges) so removed plugins disappear."""
|
|
register_plugins_from_config(
|
|
{
|
|
"plugins": [
|
|
{
|
|
"name": "chat-ui",
|
|
"display_name": "Chat UI",
|
|
"url": "http://localhost:3300",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
names = [p["name"] for p in asyncio.run(list_plugins(user_api_key_dict=_admin()))]
|
|
assert names == ["chat-ui"]
|
|
|
|
register_plugins_from_config(
|
|
{
|
|
"plugins": [
|
|
{
|
|
"name": "chat-ui",
|
|
"display_name": "Chat UI",
|
|
"url": "http://localhost:3300",
|
|
},
|
|
{
|
|
"name": "agent-builder",
|
|
"display_name": "Agent Builder",
|
|
"url": "http://127.0.0.1:4010",
|
|
},
|
|
]
|
|
}
|
|
)
|
|
names = sorted(
|
|
p["name"] for p in asyncio.run(list_plugins(user_api_key_dict=_admin()))
|
|
)
|
|
assert names == ["agent-builder", "chat-ui"]
|
|
|
|
# Removing a plugin from config drops it from the live list.
|
|
register_plugins_from_config({})
|
|
assert asyncio.run(list_plugins(user_api_key_dict=_admin())) == []
|
|
|
|
|
|
def test_plugin_key_is_never_returned_to_the_browser() -> None:
|
|
"""plugin_key is a credential the UI never needs; /api/plugins must omit it
|
|
for every caller, admin included, so it never lands in browser state."""
|
|
register_plugins_from_config(
|
|
{
|
|
"plugins": [
|
|
{
|
|
"name": "p",
|
|
"display_name": "P",
|
|
"url": "http://localhost:9",
|
|
"plugin_key": "sk-secret",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
admin_entry = asyncio.run(list_plugins(user_api_key_dict=_admin()))[0]
|
|
user_entry = asyncio.run(list_plugins(user_api_key_dict=_non_admin()))[0]
|
|
|
|
assert "plugin_key" not in admin_entry
|
|
assert "plugin_key" not in user_entry
|
|
assert admin_entry["url"] == "http://localhost:9"
|
|
|
|
register_plugins_from_config({})
|
|
|
|
|
|
def test_db_persisted_plugins_load_on_startup(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.proxy_server import ProxyConfig
|
|
|
|
register_plugins_from_config({})
|
|
monkeypatch.setattr(proxy_server, "general_settings", {})
|
|
|
|
asyncio.run(
|
|
ProxyConfig()._update_general_settings(
|
|
{
|
|
"plugins": [
|
|
{
|
|
"name": "db-plugin",
|
|
"display_name": "DB Plugin",
|
|
"url": "http://localhost:5000",
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
|
|
names = [p["name"] for p in asyncio.run(list_plugins(user_api_key_dict=_admin()))]
|
|
assert names == ["db-plugin"]
|
|
|
|
register_plugins_from_config({})
|
|
|
|
|
|
def test_safe_response_headers_sandbox_and_strips_wire_headers() -> None:
|
|
"""Proxied plugin responses must be inert and shed wire/cookie headers."""
|
|
from litellm.proxy.plugin_routes import _safe_response_headers
|
|
|
|
out = _safe_response_headers(
|
|
{
|
|
"content-type": "text/html",
|
|
"content-encoding": "gzip",
|
|
"content-length": "123",
|
|
"set-cookie": "session=abc",
|
|
"content-security-policy": "default-src *",
|
|
}
|
|
)
|
|
|
|
assert out["content-security-policy"] == "sandbox"
|
|
assert out["x-content-type-options"] == "nosniff"
|
|
assert out["content-type"] == "text/html"
|
|
for stripped in ("content-encoding", "content-length", "set-cookie"):
|
|
assert stripped not in out
|
|
|
|
|
|
def test_litellm_credential_header_names_covers_every_auth_header() -> None:
|
|
"""The canonical strip set must list every header user_api_key_auth accepts
|
|
as a litellm key, so a new auth header can't silently start leaking."""
|
|
from litellm.proxy._types import SpecialHeaders
|
|
|
|
assert SpecialHeaders.litellm_credential_header_names() == {
|
|
"authorization",
|
|
"api-key",
|
|
"x-api-key",
|
|
"x-goog-api-key",
|
|
"ocp-apim-subscription-key",
|
|
"x-litellm-api-key",
|
|
}
|
|
|
|
|
|
def test_every_litellm_auth_header_is_stripped_before_forwarding() -> None:
|
|
"""A plugin must never receive any header that authenticates against litellm,
|
|
only the hop-by-hop set and benign headers are forwarded."""
|
|
from litellm.proxy.plugin_routes import _request_strip_headers
|
|
|
|
strip = _request_strip_headers()
|
|
incoming = {
|
|
"Authorization": "Bearer sk-litellm",
|
|
"API-Key": "sk-litellm",
|
|
"X-Api-Key": "sk-litellm",
|
|
"X-Goog-Api-Key": "sk-litellm",
|
|
"Ocp-Apim-Subscription-Key": "sk-litellm",
|
|
"X-Litellm-Api-Key": "sk-litellm",
|
|
"Cookie": "litellm_session=abc",
|
|
"Accept": "application/json",
|
|
"X-Trace-Id": "t-1",
|
|
}
|
|
forwarded = {k: v for k, v in incoming.items() if k.lower() not in strip}
|
|
|
|
assert forwarded == {"Accept": "application/json", "X-Trace-Id": "t-1"}
|
|
|
|
|
|
def test_configured_custom_key_header_is_stripped() -> None:
|
|
"""A custom general_settings.litellm_key_header_name must also be stripped,
|
|
read live so config changes are honoured without a restart."""
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy.plugin_routes import _request_strip_headers
|
|
|
|
original = getattr(proxy_server, "general_settings", None)
|
|
proxy_server.general_settings = {"litellm_key_header_name": "X-My-Tenant-Key"}
|
|
try:
|
|
assert "x-my-tenant-key" in _request_strip_headers()
|
|
finally:
|
|
proxy_server.general_settings = original
|