Skip to content

feat: litellm plugin architecture v2#30688

Merged
krrish-berri-2 merged 53 commits into
litellm_internal_stagingfrom
litellm_plugin_architecture_v2
Jun 21, 2026
Merged

feat: litellm plugin architecture v2#30688
krrish-berri-2 merged 53 commits into
litellm_internal_stagingfrom
litellm_plugin_architecture_v2

Conversation

@krrish-berri-2

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays 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)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • 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

…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
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@krrish-berri-2 krrish-berri-2 changed the title Litellm plugin architecture v2 feat: litellm plugin architecture v2 Jun 17, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread ui/litellm-dashboard/src/contexts/PluginModeContext.tsx Outdated
Comment thread litellm/proxy/plugin_routes.py
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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 (/plugin-proxy/{name}/*), a short-lived per-plugin identity claim for iframe auth, and an admin settings page to manage plugins at runtime.

  • Backend (plugin_routes.py, proxy_server.py, _types.py): Every litellm credential header (canonical set + runtime-configured litellm_key_header_name) is stripped before forwarding to the plugin; the plugin's own plugin_key is injected instead. Wire-encoding and set-cookie headers are sanitised on responses, with a CSP sandbox forced on all proxied content. Plugins updated via the UI take effect immediately without a restart, DB-persisted plugins load on startup, and "***"-placeholder saves no longer overwrite real credentials.
  • Frontend (PluginModeContext, ViewSwitcher, layout.tsx, PluginSettings): Plugin list drives the view switcher dynamically; PluginModeProvider receives accessToken from the auth context rather than reading stale cookies, so plugin data refreshes on login/logout. The iframe auth claim is fetched per active plugin and delivered via postMessage in a way that handles both orderings (claim arrives before or after the iframe load event).
  • Tests: New mock-only tests cover credential-header stripping, registry refresh, plugin_key omission from the list endpoint, response-header sanitisation, and DB-startup loading.

Confidence Score: 5/5

Safe 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

Important Files Changed

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

Comment thread litellm/proxy/plugin_routes.py
Comment thread ui/litellm-dashboard/src/contexts/PluginModeContext.tsx Outdated
@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 58.27338% with 58 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/plugin_routes.py 45.91% 53 Missing ⚠️
litellm/proxy/proxy_server.py 84.37% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread ui/litellm-dashboard/src/app/(dashboard)/layout.tsx Outdated
@veria-ai

veria-ai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No 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.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

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:

  1. New GET /api/plugins/auth-token — proxy encrypts the caller's token with Fernet (key derived from LITELLM_SALT_KEY) and returns ciphertext
  2. UI layout.tsx — fetches the ciphertext and sends it via postMessage (not the raw token)
  3. Plugin decrypts server-side using the same LITELLM_SALT_KEY via POST /api/plugin-auth

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
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptile please re-review

Comment thread ui/litellm-dashboard/src/app/(dashboard)/layout.tsx Outdated
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.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@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.
Comment thread litellm/proxy/plugin_routes.py
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.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptile review

Comment thread ui/litellm-dashboard/src/contexts/PluginModeContext.tsx
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.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptile review

@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review please — fetch-error fallback addressed in ba1976c

Comment thread litellm/proxy/plugin_routes.py Outdated
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.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review — plugin_key removed from /api/plugins in f6f7adc

Comment thread litellm/proxy/plugin_routes.py
…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.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@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

@krrish-berri-2 krrish-berri-2 enabled auto-merge (squash) June 20, 2026 22:55
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.

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saw demo of this working irl. LGTM; thanks!

@krrish-berri-2 krrish-berri-2 merged commit accbd7e into litellm_internal_staging Jun 21, 2026
116 of 121 checks passed
@krrish-berri-2 krrish-berri-2 deleted the litellm_plugin_architecture_v2 branch June 21, 2026 03:37
krrish-berri-2 added a commit to BerriAI/litellm-docs that referenced this pull request Jun 21, 2026
* 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]>
yucheng-berri added a commit that referenced this pull request Jun 23, 2026
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.
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants