feat: litellm plugin architecture v2#30688
Conversation
…ugins
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
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.
- 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)
- 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
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
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.
…ma.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.
…m 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.
…sition 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.
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.
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.
register_plugins_from_config now replaces the registry instead of merging, so plugins removed from config are unreachable immediately without requiring a process restart.
feat: plugin architecture — toggle between AI Gateway and external plugins
|
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 19c03f647d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Greptile SummaryThis PR introduces a plugin architecture v2 for litellm, allowing external services to register as embeddable UI panels alongside the AI Gateway — with an authenticated reverse proxy (
Confidence Score: 5/5Safe to merge; the security-sensitive path (credential header stripping, plugin_key injection, iframe auth claim derivation) is well-guarded and covered by targeted tests. All credential-forwarding concerns raised in prior rounds are addressed: the canonical SpecialHeaders set plus the runtime-configured custom key header are all stripped before forwarding, the httpx client uses the correct PassThroughEndpoint provider, response wire-encoding/cookie headers are sanitised, and the iframe auth race condition is resolved by sending the claim on arrival and on each subsequent load event. Remaining feedback is cosmetic or a documentation-placement rule. docs/plugin_architecture.md (should move to litellm-docs repo); PluginSettings.tsx plugin_key display state after edit
|
| Filename | Overview |
|---|---|
| litellm/proxy/plugin_routes.py | New plugin proxy module: all litellm credential headers (canonical + configured custom header) stripped before forwarding, plugin_key injected from registry, hop-by-hop/cookie headers stripped, response wire-encoding/set-cookie headers stripped with CSP sandbox applied, follow_redirects=False guards against SSRF, correct httpxSpecialProvider.PassThroughEndpoint used |
| litellm/proxy/_types.py | Adds PluginConfig model, plugins field to ConfigGeneralSettings, and litellm_credential_header_names() classmethod to SpecialHeaders — all clean |
| litellm/proxy/proxy_server.py | Integrates plugin router, calls register_plugins_from_config at startup and on live config update, adds _preserve_redacted_plugin_keys to prevent "***" placeholder from overwriting real credentials on edit |
| ui/litellm-dashboard/src/contexts/PluginModeContext.tsx | Plugin mode context with live fetch, fallback to ai-gateway on empty/failed fetch, accessToken-keyed re-fetch on auth changes; Plugin interface includes plugin_key field that the API never populates (dead field) |
| ui/litellm-dashboard/src/app/(dashboard)/layout.tsx | AgentControlPlaneView: auth claim fetched per-plugin and per-token-change, postMessage sent on claim availability AND on iframe load event to cover both orderings (race fixed), targetOrigin set to plugin URL, no token in iframe src |
| ui/litellm-dashboard/src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx | Plugin admin UI: openEdit clears plugin_key to empty (not "***") so backend treats it as "keep existing"; after save, local state holds plugin_key="" causing table to briefly show no-key until page reload |
| ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx | Dynamic switcher hides itself when no plugins registered, shows AI Gateway + each plugin by name, correct setMode call on selection |
| backend/routes/allowlist.py | Adds /api/plugins and /plugin-proxy/ prefixes to backend path allowlist so the reverse proxy routes traffic to the litellm backend correctly |
| tests/test_litellm/proxy/test_plugin_routes.py | New mock tests covering plugin registry, plugin_key omission from list response, DB-loaded plugins, response header sanitisation, and comprehensive credential-header stripping |
| docs/plugin_architecture.md | Documentation file that violates the team rule requiring docs to live in the litellm-docs repo |
Reviews (13): Last reviewed commit: "fix(security): strip every litellm auth ..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 11 · PR risk: 0/10 |
…tellm 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.
|
Security fix pushed (commit b81736b): The credential exposure issue from Veria's review is now addressed. The plugin iframe no longer receives the raw litellm bearer token:
A postMessage intercept now yields only useless ciphertext. The raw litellm credential never leaves the proxy process in plaintext. @veria-ai please re-review |
…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
|
@greptile please re-review |
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.
|
@greptile review |
…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.
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.
|
@greptile review |
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.
|
@greptile review |
|
@greptileai review please — fetch-error fallback addressed in ba1976c |
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.
|
@greptileai review — plugin_key removed from /api/plugins in f6f7adc |
…lugins 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.
|
@greptileai review — High finding addressed in f203878: every litellm auth header (incl. configured custom header) now stripped before forwarding, sourced from a canonical SpecialHeaders set |
Correct the plugin architecture doc to match the shipped implementation: /api/plugins never returns plugin_key, the auth-token route is plugin-scoped and needs LITELLM_SALT_KEY, and the reverse proxy strips every litellm credential header, injects only plugin_key, forwards identity headers, and sandboxes responses.
accbd7e
into
litellm_internal_staging
* docs(proxy): add plugin architecture v2 page
Documents the v1.89.3+ plugin system: config.yaml registration, the
manifest and auth endpoints a plugin must expose, the per-plugin
HMAC-derived key, the encrypted identity claim flow, and the credential
stripping the reverse proxy performs at /plugin-proxy/{name}/{path}.
Linked from the proxy Admin UI > Setup & SSO sidebar group.
Refs BerriAI/litellm#30688
* docs(proxy): add plugins UI screenshots
Show the sidebar mode dropdown and the Admin Settings > Plugins add
modal so readers see what the feature looks like before reading the
config and HTTP details.
* docs(proxy): document plugin user context and add loaded view
Adds a 'What the plugin receives about the user' section spelling out
the four claim fields (plugin, user_id, user_role, exp) and the
matching x-litellm-user-id / x-litellm-user-role headers forwarded on
every reverse-proxied request. Calls out empty-string defaults for
unresolved callers and notes that headers are informational and not a
substitute for verifying the session_claim.
Also wires the third screenshot showing an Agent Control Plane plugin
loaded inside the LiteLLM dashboard frame so readers see the
end-to-end result of selecting a plugin from the dropdown.
---------
Co-authored-by: Krrish Dholakia <[email protected]>
In get_config_field_info, compose both protections: run _redact_general_setting_value first (PR #30587: redact secret config values for non-full-admin viewers, recursing into nested secrets), then unconditionally redact plugins[*].plugin_key to '***' (PR #30688: shared plugin credential is never returned, and the '***' placeholder round-trips through /config/field/update via _preserve_redacted_plugin_keys). Test file: keep both blocks of new tests (config_field_info redaction behavior + _preserve_redacted_plugin_keys behavior); they cover disjoint concerns and the previous conflict was purely a co-located insertion point.
* 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.
Relevant issues
Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
make test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
CI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Screenshots / Proof of Fix
Type
🆕 New Feature
🐛 Bug Fix
🧹 Refactoring
📖 Documentation
🚄 Infrastructure
✅ Test
Changes