mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
* feat: plugin architecture — toggle between AI Gateway and external plugins
Adds a generic plugin system so any external service can register with
litellm and appear as a mode in the UI alongside the AI Gateway.
Backend (litellm/proxy/plugin_routes.py — new):
- GET /api/plugins: returns registered plugins from config; returns
plugin_key only to authenticated requests
- ANY /plugin-proxy/{name}/{path}: reverse proxies API calls to plugin
Config:
general_settings:
plugins:
- name: my-plugin
display_name: My Plugin
url: https://my-plugin.example.com
plugin_key: sk-... # plugin auth key, passed to iframe
UI:
- PluginModeContext.tsx: fetches /api/plugins, persists mode to localStorage
- leftnav.tsx: mode switcher dropdown at top of sidebar; plugin mode shows
plugin-specific nav items
- layout.tsx: renders iframe to plugin URL in plugin mode; passes plugin_key
as ?token= for auto sign-in
Plugin contract: expose GET /api/plugin-manifest returning
{ name, display_name, nav_items[], capabilities[] }. No litellm changes
needed to add new plugins — config only.
Reference implementation: LiteLLM-Labs/litellm-agent-control-plane
* feat: add Plugins tab to Admin Settings UI
Allows admins to add/edit/delete plugin registrations directly in the
litellm UI under Admin Settings > Plugins, instead of editing config.yaml.
Uses existing /config/field/update API to persist to general_settings.plugins.
Each plugin entry has: name (identifier), display_name, url, plugin_key.
* fix(ci): black, prettier, eslint, async-client violations
- Black: format plugin_routes.py and proxy_server.py
- Prettier: format PluginModeContext.tsx and PluginSettings.tsx
- ESLint: replace raw fetch() with createApiClient in PluginModeContext
- ESLint: use lazy useState initializer to read localStorage instead of
calling setModeState inside useEffect (react-hooks/set-state-in-effect)
- code-quality: replace httpx.AsyncClient per-request with
get_async_httpx_client() shared client (avoids +500ms overhead)
* fix(ci): schema.d.ts regen, Black proxy_server.py, ApiClientConfig fix
- Regenerate schema.d.ts for new /api/plugins routes
- Re-run Black 26.3.1 on proxy_server.py (matches CI version)
- Fix PluginModeContext: createApiClient requires getBaseUrl field
* fix: security hardening + CI fixes
Security (Greptile 1/5 → addressing all 3 findings):
- plugin_routes.py: add Depends(user_api_key_auth) to both /api/plugins
and /plugin-proxy/{name}/{path} — was an unauthenticated open relay
- plugin_routes.py: /api/plugins now returns plugin_key only to callers
with a valid litellm token (enforced by user_api_key_auth), not just
any header presence
- layout.tsx: replace ?token= URL param with postMessage(targetOrigin)
— token no longer exposed in browser history / logs / Referer headers
CI:
- backend/routes/allowlist.py: add /api/plugins and /plugin-proxy/ to
fix test_gateway_plus_backend_covers_full_app
- schema.d.ts: regenerated with enterprise routes included
- Black + Prettier formatting
* fix: regenerate schema.d.ts with enterprise routes included
Install litellm-enterprise workspace member before gen:api so audit and
other enterprise routes appear in the generated types, matching what CI
produces with uv sync --extra proxy.
* fix: exclude plugin routes from OpenAPI schema, restore upstream schema.d.ts
Both /api/plugins and /plugin-proxy/ are internal infrastructure routes,
not part of the public litellm API surface. Marking include_in_schema=False
prevents Python-version-dependent schema diffs from breaking the schema
sync check across different environments.
* fix: schema.d.ts - passing schema base + exact plugin route types from openapi-typescript
Use the CI-correct schema from a recently passing branch as base, then
inject plugin route entries (paths + operations) generated by
openapi-typescript from the plugin routes' OpenAPI spec. This avoids
Python-version-dependent formatting differences that made local gen:api
produce incorrect output.
* fix: schema.d.ts - insert plugin ops at correct route registration position
Plugin operations belong after delete_memory_v1_memory__key__delete
(memory_router is included immediately before plugin_router in proxy_server.py),
not after list_organization which is alphabetically but not registration-order.
* fix: schema.d.ts - correct op positions from hunk analysis
list_plugins_api_plugins_get goes after event_logging_batch op (hunk 1: line 33583).
plugin_proxy ops go after create_policy_policies_post (hunk 2: line 44634).
Previous location after delete_memory_v1_memory__key__delete was wrong.
* fix: schema.d.ts - proxy ops go before create_policy (after otel_spans)
* fix(security): restrict plugin_key to proxy_admin role only
Veria finding: plugin_key was returned to any authenticated caller.
Now only proxy_admin users receive plugin credentials in /api/plugins
response — regular internal users see plugin name/url but not the key.
* fix: update schema.d.ts docstring for list_plugins
* fix: clear plugin registry on config reload (Greptile medium)
register_plugins_from_config now replaces the registry instead of
merging, so plugins removed from config are unreachable immediately
without requiring a process restart.
* fix(security): encrypted token exchange for plugin iframe — no raw litellm credential exposure
The dashboard was sending the user's litellm bearer token to the plugin
iframe via postMessage, allowing a compromised plugin to act as that user.
Fix:
- GET /api/plugins/auth-token: proxy encrypts caller token with Fernet
keyed from LITELLM_SALT_KEY, returns ciphertext only
- UI postMessages the ciphertext (not raw token) to the iframe
- Plugin decrypts server-side with same LITELLM_SALT_KEY via POST /api/plugin-auth
- Raw litellm credential never leaves the proxy in plaintext
Additional hardening already in place:
- /plugin-proxy/* strips Authorization header, injects plugin_key instead
- plugin_key only returned to proxy_admin role via /api/plugins
- Plugin registry cleared (not merged) on config reload
Adds docs/plugin_architecture.md with plugin integration guide.
* fix(code-quality): use get_async_httpx_client in plugin_proxy
* fix: add /api/plugins/auth-token to schema.d.ts
* fix: use apiClient for auth-token fetch, copy correct layout.tsx and PluginModeContext
- Replace raw fetch() with createApiClient (fixes no-restricted-syntax ESLint rule)
- Copy correct layout.tsx with encrypted token + postMessage approach
- Copy correct PluginModeContext.tsx with accessToken prop injection
- Update schema.d.ts with auth-token path and operation entries
* fix: add plugin_auth_token operation to schema.d.ts
* fix(security): strip cookie/set-cookie + fix compressed response headers
Veria High: cookie header was forwarded to plugin backends allowing
capture of litellm JWT session cookies. Strip cookie on requests.
Strip set-cookie from responses so plugins cannot overwrite litellm
session cookies.
Greptile P1: httpx decompresses responses but resp.headers still
contained Content-Encoding/Transfer-Encoding/Content-Length from the
wire. Forwarding these caused double-decompression and length errors.
Now filtered via _RESPONSE_STRIP before returning to the browser.
* fix: update plugin_key help text — no more ?token= reference
* fix(security): disable follow_redirects to prevent SSRF
follow_redirects=True allowed a plugin backend to return a 3xx to an
internal URL, causing the proxy to fetch that internal service and relay
the response. Disabled: clients handle their own redirects.
* fix: forward user identity headers to plugin to address confused deputy
Plugins receive X-LiteLLM-User-Id and X-LiteLLM-User-Role so they can
enforce their own per-user access control before acting on requests that
arrive with the shared plugin_key credential.
* fix(security): restrict /plugin-proxy/* to proxy_admin role
Closes the confused deputy gap: regular users could invoke any plugin
endpoint using the shared plugin_key as a bearer credential. Now only
proxy_admin callers can use the plugin proxy route.
Plugin UIs communicate with the plugin service directly via the iframe
(using the encrypted token exchange); this proxy route is for
administrative/server-to-server access only.
* fix: update schema.d.ts for admin-only proxy route docstring
* fix(bug): use PassThroughEndpoint instead of None for get_async_httpx_client
get_async_httpx_client(llm_provider=None) raises TypeError — the function
concatenates the provider string and None is not a str. Use
httpxSpecialProvider.PassThroughEndpoint, the enum value used by other
internal proxy pass-through routes.
* fix(security): add 30s TTL to encrypted plugin auth tokens
Veria medium: encrypted tokens had no expiry, allowing indefinite replay.
Fernet embeds a timestamp; decrypt_token now passes ttl=30 so tokens
older than 30 seconds are rejected even with a valid HMAC.
Plugin's /api/plugin-auth must call litellm within 30s of the iframe
receiving the postMessage — normal browser behavior, tight enough to
close the replay window.
* feat(ui): topnav plugin switcher, embed plugins at their root
Builds on the plugin architecture already on this branch (encrypted-token
postMessage handshake, /api/plugins, PluginSettings) and removes the parts of the
embed that assumed a specific plugin's shape.
The mode switcher moves out of the sidebar into the topnav and lists AI Gateway
plus each registered plugin by its display_name. Selecting a plugin hides
litellm's sidebar entirely and renders the plugin full-bleed at its root url; the
plugin draws its own navigation inside the iframe. This drops the hardcoded
"Agent Control Plane" label and the hardcoded Sessions/Agents/Routines/... nav
groups (agentControlPlaneMenuGroups / acpPagePaths) that only matched the agent
platform and 404'd for a plugin that serves only / (e.g. the chat UI). The
encrypted-token postMessage flow is unchanged.
Note: embedding at root means a plugin must route internally from /; plugins that
previously relied on the /sessions entrypoint should redirect from their root.
* fix(security): audience-scoped identity claim replaces litellm token
Veria: shared LITELLM_SALT_KEY with plugins + encrypting user bearer token
created delegation/impersonation risk.
Architecture change:
- /api/plugins/auth-token now issues a plugin-scoped identity CLAIM
{user_id, user_role, plugin, exp} encrypted with HMAC(LITELLM_SALT_KEY, plugin_name)
- Each plugin holds only its own HMAC-derived key; cannot forge claims for
other plugins or recover LITELLM_SALT_KEY
- Claim contains NO litellm bearer token — compromised plugin learns caller
identity only, cannot act as that user against the proxy
- 30s TTL enforced in both Fernet header and explicit exp field
- LAP /api/plugin-auth verifies claim, returns its own master key to browser
(LAP key never exposed without valid claim)
* fix(plugins): allow registering plugins from the admin UI
Adding a plugin in the UI POSTs general_settings.plugins to /config/field/update,
which rejected it with "Invalid field=plugins passed in." because `plugins` was
not a field on ConfigGeneralSettings. Add a typed PluginConfig model and a
`plugins` field so the update validates and persists.
The in-memory plugin registry only refreshed at startup, so a plugin added via
the UI did not appear in /api/plugins (the view switcher) until a restart. Refresh
the registry from the new general_settings whenever the plugins field is updated.
While here, type the registry as dict[str, PluginConfig] instead of raw dicts so
list_plugins and plugin_proxy access typed attributes.
Fix the Plugin Key field copy: it is optional and only used to authenticate
litellm's server-side reverse proxy to a plugin's own backend
(/plugin-proxy/<name>/*). It is not involved in iframe auth, which forwards the
user's litellm token. Plugins that use the forwarded token leave it blank.
* fix: regenerate schema.d.ts with PluginConfig type and updated auth-token endpoint
* fix: use CI-compatible schema base for plugin entries
* fix(plugins): load DB-persisted plugins on startup
Plugins added through the admin UI are saved to DB general_settings, but the
registry only initialised from the YAML config at boot, so UI-added plugins
disappeared from the view switcher after a restart (the Plugins table still
listed them since it reads the DB directly). Refresh the registry from the DB
general_settings when it is merged in at startup.
* fix: add PluginConfig schema, plugins field, fix list_plugins return type
* fix: correct PluginConfig and plugins field positions in schema
* fix: correct plugins field position in schema (after pass_through_endpoints)
* fix: update PluginConfig.plugin_key description to match _types.py source
* fix: move plugins field after pass_through_request_timeout (correct alphabetical position)
* fix: redact plugin_key in config/field/info response
Veria medium: proxy_admin_viewer could read plugin_key via
GET /config/field/info?field_name=plugins. Now plugin_key is
replaced with *** in the response regardless of caller role.
The credential is only usable server-side.
* fix(security): correct plugin docs salt-key guidance, drop iframe clipboard-read
Address the two open Veria findings on the plugin architecture.
The plugin docs told external services to decrypt the iframe auth payload
with the proxy's LITELLM_SALT_KEY directly. That is both insecure and wrong:
the running code derives a per-plugin key as HMAC-SHA256(LITELLM_SALT_KEY,
plugin_name) and ships only a short-lived identity claim with no litellm
bearer token. Sharing the master salt would let a compromised plugin decrypt
any litellm secret recovered from a dump or backup. Rewrite the doc to match
the implementation: the proxy computes the per-plugin key once and provisions
it as a dedicated secret, the plugin validates the claim's audience and 30s
TTL, and LITELLM_SALT_KEY never leaves the proxy. Also refresh the now-stale
module and UI comments that still described the old shared-key token flow.
Drop clipboard-read from the plugin iframe's allow attribute so an untrusted
plugin can no longer read the user's clipboard; clipboard-write is retained.
* fix(ci): modernize PluginConfig typing, refresh budget baselines via merge
* fix(plugins): close iframe auth race and empty-plugins mode fallback
Address the two open Greptile behavioral findings.
The iframe auth handshake only posted the encrypted claim on the iframe's
`load` event. When the auth-token fetch resolved after the iframe had already
loaded, that listener never fired again and the plugin never received the
claim. Send the claim immediately as well as on subsequent loads so both
orderings are covered.
The plugin mode fallback guarded on a non-empty plugins list, so removing all
plugins left a user stranded on a stale mode with a blank iframe instead of
returning to the AI Gateway. Track a loaded flag and fall back to ai-gateway
once plugins have loaded whenever the stored mode is no longer registered,
including the empty-list case.
Add a PluginModeContext regression test covering the empty-list fallback and
the still-registered path.
* chore: re-trigger CI (GH Actions missed the prior head; re-run flaky live-API suites)
* fix(plugins): scope iframe auth claim to the active plugin
The iframe auth-token fetch omitted plugin_name, so the proxy always issued a
claim encrypted under the default plugin's per-plugin key. For any other active
plugin the iframe received a claim it could not decrypt and sign-in silently
broke, and because the cached claim was posted to whichever plugin was mounted,
a compromised iframe could replay the default plugin's claim. The active
plugin's name was also missing from the fetch effect's dependencies, so
switching plugins never refreshed the claim.
Request the claim with the active plugin's name, re-fetch when the active
plugin changes, and only deliver a claim while it still matches the mounted
plugin so one plugin's claim is never replayed to another.
* fix(plugins): never overwrite a stored plugin_key with its redaction placeholder
/config/field/info redacts every plugin_key to "***", so an admin editing a
plugin in the settings UI posted that placeholder straight back and the update
handler persisted "***" as the real credential, permanently destroying the key.
Preserve the stored credential on update: a blank or redacted plugin_key now
sources the existing key from the saved config, only a real value replaces it,
and a placeholder with no stored key is dropped rather than written. The edit
modal also starts the key field blank so an untouched save keeps the current
key, with the field labelled accordingly.
* fix(security): sandbox proxied plugin responses on the dashboard origin
The /plugin-proxy reverse proxy returned the plugin's body and content-type on
the litellm dashboard origin, so a compromised plugin could serve an HTML/JS
document that a proxy_admin navigates to and have it execute with the admin's
session against same-origin management APIs.
Force every proxied response inert: set Content-Security-Policy: sandbox (opaque
origin, scripts disabled) and X-Content-Type-Options: nosniff, applied after the
plugin's own headers so they cannot be overridden. The header construction moves
to a pure helper with a unit test covering the sandbox enforcement and the
existing wire/cookie header stripping.
* fix(plugins): recover to ai-gateway when the plugins fetch fails
The loaded flag was only set on a successful /api/plugins response, so when the
fetch failed a user with a plugin mode stored in localStorage stayed on the
blank plugin placeholder with no switcher to escape. Mark loaded in a finally
so the stored mode still falls back to ai-gateway on failure, and add a
regression test for the failed-fetch path.
* fix(security): never return plugin_key from /api/plugins
The plugin list endpoint returned the plaintext plugin_key to proxy_admin
callers, and the dashboard fetches /api/plugins on every load into React state,
so the credential was exposed to DevTools, memory snapshots, and any same-origin
script. The browser never uses the key; the proxy injects it server-side from
the registry and admin key management runs through the redacted
/config/field/info path. Drop plugin_key from the response for every caller and
update the regression test to assert it is never returned.
* chore(ui): regenerate schema.d.ts for updated list_plugins docstring
* fix(security): strip every litellm auth header before forwarding to plugins
The plugin reverse proxy only removed Authorization and x-api-key, but
user_api_key_auth also authenticates a caller via API-Key, x-goog-api-key,
Ocp-Apim-Subscription-Key, x-litellm-api-key, and any configured custom key
header. A malicious plugin could lure a proxy_admin into calling
/plugin-proxy/... with the litellm key in one of those headers; the request
authenticated locally and then forwarded the same key to the plugin, letting it
impersonate the admin.
Add a canonical SpecialHeaders.litellm_credential_header_names() that the auth
header enum is the single source for, and strip that whole set plus the live
general_settings.litellm_key_header_name from every forwarded request. New auth
headers added to SpecialHeaders are now stripped automatically. Regression tests
cover each credential header, the custom configured header, and the canonical
list's contents.
232 lines
7.9 KiB
Python
232 lines
7.9 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
|
|
|
|
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() -> None:
|
|
"""Plugins saved to DB general_settings must register when the DB config is
|
|
merged at startup, not just when present in the YAML file."""
|
|
from litellm.proxy.proxy_server import ProxyConfig
|
|
|
|
register_plugins_from_config({}) # start empty (as if YAML had no plugins)
|
|
|
|
ProxyConfig()._add_general_settings_from_db_config(
|
|
config_data={
|
|
"general_settings": {
|
|
"plugins": [
|
|
{
|
|
"name": "db-plugin",
|
|
"display_name": "DB Plugin",
|
|
"url": "http://localhost:5000",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
general_settings={},
|
|
proxy_logging_obj=MagicMock(),
|
|
)
|
|
|
|
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
|