Skip to content

feat(v1.87.0): port PublicReqMiddleware + small UI fixes (Wave 3) - #48

Merged
songkuan-zheng merged 6 commits into
ship/v1.87.0from
fix/v1.87.0-wave-3-middleware-ui
Jun 4, 2026
Merged

feat(v1.87.0): port PublicReqMiddleware + small UI fixes (Wave 3)#48
songkuan-zheng merged 6 commits into
ship/v1.87.0from
fix/v1.87.0-wave-3-middleware-ui

Conversation

@songkuan-zheng

Copy link
Copy Markdown
Collaborator

Tier classification

  • A — Company-specific logic (litellm_extras/ only)
  • B — Internal infra / branding (CI, Dockerfile, e2e, internal navbar version)
  • C — Universal bug fix in litellm/ core
  • D — Universal mechanism + company opinion in litellm/ core

If Tier C or D, did you try upstream first?

  • N/A — PublicReqMiddleware is Tier A. UI fixes are Tier B/UI-only.

Summary

Third wave of v1.87.0 bump. Mix of Tier A + B fixes — internal
middleware ( litellm_extras/) and small UI tweaks. Low risk.

Cherry-picks (chronological, 6 commits)

SHA Subject Tier
414a032865 feat(proxy): add PublicReqMiddleware for X-Public-Req gated safeguards A
4293d940f6 fix(deploy): install PublicReqMiddleware at the Docker entrypoint B
767909d4d4 fix(ui): expose api_base field on Google AI Studio credential form B
d6e6b0f452 fix(ui): reset credential form state when switching providers B
895b8fd983 fix(ui): logging callbacks table reads backend type for Mode badge (#35) B
ede8b23edd test(e2e): add case 22 — Gemini provider with custom api_base B

Conflict resolutions

e2e/cases/README.md (PublicReqMiddleware): original commit added
both case 17 + 18; kept only case 18 (case 17 is deferred to a
later wave).

e2e/tools/run-all-cases (PublicReqMiddleware): same — kept the
case_18 invocation, dropped the case_17 call.

docker/prod_entrypoint.sh (Docker entrypoint): upstream removed
the SEPARATE_HEALTH_APP supervisord block between v1.83.10 and
v1.87.0. Accepted upstream's removal; kept our wrapper invocation
( python -m litellm_extras.entrypoint) and explanatory comment.

tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py:
trivial — added new test_google_ai_studio_provider_fields_expose_api_base
at the end of the file (HEAD had no content there).

e2e/tools/proxy (case 22): original commit added comment lines
for both case 20 (returned_model_name) and case 22 (gemini-custom-base);
kept only case 22 (case 20 is deferred).

New file inventory

  • litellm_extras/__init__.py
  • litellm_extras/entrypoint.py — wraps upstream Click CLI
  • litellm_extras/public_req_middleware.py — X-Public-Req gating
  • e2e/cases/18_public_req_middleware.md + data fixture
  • e2e/cases/22_gemini_credential_custom_api_base.md + data fixture
  • ui/litellm-dashboard/src/components/model_add/credential_form_helpers.{ts,test.ts}
  • 2 test files for PublicReqMiddleware + Google AI Studio api_base
  • LoggingCallbacks/LoggingCallbacksTable.test.tsx

Verification

```bash

PublicReqMiddleware loads

python -c "from litellm_extras.public_req_middleware import PublicReqMiddleware"

Provider fields endpoint exposes Gemini api_base

uv run pytest tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py -v

Docker entrypoint targets the wrapper

grep 'litellm_extras.entrypoint' docker/prod_entrypoint.sh
```

Deferred to later waves

  • Case 17 ( /v1/model/info user.models filter) — Wave 7 (hot-path family)
  • Case 20 ( returned_model_name) — Wave 6 (U-class architectural)

Pre-Submission checklist

Type

🆕 New Feature / 🐛 Bug Fix / 🚄 Infrastructure

Internal extension that lets a single LiteLLM proxy serve both external
users (through a public Nginx ingress) and internal services (direct
ClusterIP) from one process. The public ingress injects
`X-Public-Req: 1`; this middleware keys off that header to apply two
safeguards on external requests while leaving internal traffic
untouched.

When `X-Public-Req: 1` is present:

- Strip every inbound `x-litellm-*` request header. These let callers
  override internal proxy behavior (`x-litellm-api-key`,
  `x-litellm-num-retries`, `x-litellm-spend-logs-metadata`,
  `x-litellm-mock-response`, etc.) and must never be honored from
  untrusted sources.
- Silently drop forbidden query parameters on `/v1/models` and
  `/models` (`include_metadata`, `fallback_type`,
  `include_model_access_groups`, `only_model_access_groups`). These
  expose router fallback chains and access-group naming. Other query
  params are preserved byte-for-byte.
- Strip every outbound `x-litellm-*` response header (model deployment
  IDs, cache-hit flags, cost/budget annotations, ratelimit-remaining
  fields) before they leave the proxy.

When the header is absent (internal callers) the middleware is a
no-op: scope passes through, response is unchanged, internal observability
headers and the `x-litellm-spend-logs-metadata` write path keep working.

Implementation:

- Pure ASGI middleware (not `BaseHTTPMiddleware`) — does not buffer
  response bodies. Modifies headers only on the single
  `http.response.start` ASGI message; `http.response.body` chunks
  pass through verbatim. Streaming TTFT is unaffected.
- Installed via a wrapper entrypoint, `litellm_extras.entrypoint`,
  invoked in place of the upstream `litellm` console script. The
  wrapper imports `litellm.proxy.proxy_server` first (constructing
  the FastAPI `app`), calls `add_middleware`, then delegates to the
  existing Click `run_server` CLI. Python's module cache means
  uvicorn's `from .proxy_server import app` returns the same mutated
  instance.

Coverage:

- 12 unit tests in `tests/test_litellm/test_public_req_middleware.py`
  drive the middleware directly via fake ASGI scope/send, covering
  internal passthrough, inbound + outbound strip, streaming chunk
  pass-through (regression guard against accidental BaseHTTPMiddleware
  switch), query-strip variations, marker case-insensitivity, and
  non-HTTP scope routing.

- E2E case 18 (`e2e/cases/18_public_req_middleware.md` + fixture)
  exercises the running proxy:
    A1 SSE stream produces >=3 chunks
    A2 streaming_phase (wall - ttfb) > 200ms (proves no buffering)
    A3 public response: 0 x-litellm-* headers
    A4 internal response: >=1 x-litellm-* header (control)
    A5 public /v1/models?include_metadata=true returns 200 with the
       same body as bare /v1/models (silent query strip)
    A6 internal /v1/models?include_metadata=true returns 200 with a
       different body (metadata expansion still works for internal)
    A7 internal x-litellm-spend-logs-metadata reaches spend_logs
       (control — proves the header would otherwise be honored)
    A8 public x-litellm-spend-logs-metadata absent from spend_logs
       (inbound strip verified at the DB layer)

  All 8 assertions pass; the full e2e suite remains 18/18 PASS.
…t docker-compose

The previous commit installed PublicReqMiddleware by overriding the
`entrypoint:` in `e2e/_config/docker-compose.yml`. That works for the
e2e harness, but for production it means every Kubernetes manifest /
Helm chart / docker run command has to remember to swap the entrypoint
too. A missed swap silently disables every X-Public-Req safeguard —
including the inbound header strip — and there is no runtime error to
flag the regression.

Move the wrapper invocation into `docker/prod_entrypoint.sh`, the
image's baked-in ENTRYPOINT. The upstream script previously dispatched
to `litellm "$@"`; it now dispatches to
`python -m litellm_extras.entrypoint "$@"` under both the plain and
the `USE_DDTRACE=true` paths. Every deployment of this image now
loads PublicReqMiddleware automatically with no extra configuration.

Drop the corresponding override in `e2e/_config/docker-compose.yml` so
e2e exercises the same entrypoint path that production does. If the
wrapper breaks in either, the other surface catches it.

Verified:
- `docker inspect litellm-e2e` shows entrypoint `docker/prod_entrypoint.sh`
  (no docker-compose override), confirming the image's default path
  is what runs.
- E2E case 18: 8/8 assertions PASS — middleware still strips inbound
  + outbound `x-litellm-*`, strips `/v1/models` forbidden query, and
  the spend_logs marker check confirms the inbound header never
  reached the proxy core.
- Full e2e suite: 18/18 PASS, no regressions.

Upstream-rebase note: `docker/prod_entrypoint.sh` is a small (14-line)
infrastructure file that rarely changes upstream. Future merge
conflicts on this file resolve by keeping our `python -m
litellm_extras.entrypoint` substitution in both branches of the
USE_DDTRACE conditional.
The Google_AI_Studio entry in provider_create_fields.json only exposed
api_key, so admins using a Gemini-compatible gateway (anything that
hosts the /v1beta/models/{model}:generateContent path on a custom host)
had no way to set a custom api_base via the UI. Forced workarounds:
either edit deployment-level litellm_params via raw API, or mislabel
the credential as "OpenAI" to borrow that form's api_base field.

The runtime gemini provider already handles custom api_base correctly
via vertex_llm_base._check_custom_proxy:415 — it builds
{api_base}/models/{model}:generateContent and uses x-goog-api-key. The
gap was UI-only.

Add an optional api_base field to the Google AI Studio credential form,
with a default value of the canonical Google AI Studio endpoint so
leaving the default behaves identically to leaving the field blank.
Place api_base before api_key in the field order to match Anthropic /
OpenAI / AI21 conventions (admin sees URL override before the key).

Add a regression test mirroring test_anthropic_provider_fields_support_byok
that asserts: api_base exists, optional, text type, default value
matches the canonical Google endpoint, and orders before api_key.

Test plan:
- python3 -m pytest tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py -v
  (25 passed including new test_google_ai_studio_provider_fields_expose_api_base)
- black tests/.../test_public_endpoints.py (no changes after first run)
The credential modal's provider Select wired onChange to only update
`selectedProvider` + write the new value to `custom_llm_provider`. It
left every other field's Antd Form state untouched, so values seeded
by the previous provider's `default_value` carried over.

Most visibly: PR #24 added an `api_base` field to Google AI Studio with
a default of `https://generativelanguage.googleapis.com/v1beta`, but
because OpenAI is the modal's initial provider, OpenAI's `api_base`
default (`https://api.openai.com/v1`) populated Antd Form's state on
mount. Switching to Google AI Studio re-rendered ProviderSpecificFields
with the Google default, but Antd Form's controlled value still pointed
at the OpenAI URL — the user saw OpenAI's URL in the api_base field
when adding a Google AI Studio credential.

Fix: extract a `resetCredentialFormOnProviderChange` helper that
resetFields then restores the provider-agnostic fields (credential_name
+ custom_llm_provider) so the newly rendered ProviderSpecificFields can
apply its own defaults from a clean slate. Same call wires both modals
(Add + Edit) since they had the same bug.

Test plan:
- New unit test exercises the helper with 4 scenarios: (1) clears
  provider-specific fields, (2) preserves credential_name, (3) writes
  custom_llm_provider + invokes setSelectedProvider, (4) does NOT touch
  credential_name when it was unset (avoids spurious "required"
  validation on a brand-new modal).
- Existing AddCredentialModal + EditCredentialModal smoke tests still
  pass (4 tests).
- vitest run src/components/model_add/credential_form_helpers.test.ts
  AddCredentialModal.test.tsx EditCredentialModal.test.tsx -> 8 passed

Note: a direct end-to-end "render modal, click Provider Select, switch
to Google AI Studio, assert api_base" test would be more comprehensive
but Antd Select's portal/dropdown behavior is unreliable in jsdom —
attempted patterns (fireEvent.mouseDown, getByRole combobox,
document.body portal query) all timed out finding the dropdown
options. Testing the extracted helper directly is the more reliable
unit-level surrogate.
…35)

The `/get_callbacks` proxy endpoint returns each callback registration
as `{name, type, variables}` where `type` is `"success"` or `"failure"`.
The same callback name (e.g. `generic_api`) can appear twice — once
per event class — and the two entries fire on disjoint events, not
double-fire on a single event.

`LoggingCallbacksTable` ignored the `type` field and read
`record.mode`, which was always `undefined`, so every row fell back
to the "Success" badge. A `generic_api` callback registered for both
success and failure events showed up as two identical "Success"
rows, plus React emitted a duplicate-key warning because `rowKey` was
derived from `name` alone.

Changes
-------
* `types.ts`: `AlertingObject` gains an optional `type` field with a
  comment explaining the success vs failure registration distinction.
* `LoggingCallbacksTable.tsx`:
  - Mode column reads `record.type` first, falling back to
    `record.mode` for newly-added (not-yet-server-acknowledged) rows.
  - Composite rowKey ``${name}-${type ?? mode ?? 'success'}`` prevents
    the duplicate-key warning when the same name appears twice.
  - Removed leftover `console.log("availableCallbacks", ...)` debug
    line that was firing on every render.
* `LoggingCallbacksTable.test.tsx`: adds a regression test that
  renders two `generic_api` rows (one success, one failure) and
  asserts both the "Success" and "Failure" badges are visible AND
  that the shared display name appears twice.

Backend callback firing behaviour was already correct — this is a
display-only fix. Verified locally with the in-network mock provider
(separate PR): the mock's `/api/hooks/spend-log` receives exactly one
POST per success event and one per failure event, never both, even
when the UI was showing duplicate "Success" badges.

Test plan:
  * `vitest run src/components/Settings/LoggingAndAlerts/LoggingCallbacks/`
    → 4 passed (3 existing + new regression).
  * `prettier --check` clean.
Adds the runtime half of PR #24's coverage: end-to-end validation that a
deployment with `litellm_params.model = gemini/...` plus a custom
`api_base` correctly routes through the gemini provider and surfaces
the custom URL in `x-litellm-model-api-base`. Guards the path that the
new UI api_base field on the Google_AI_Studio credential form will
exercise once admins start using it.

- e2e/tools/proxy: render a `gemini-custom-base` deployment when
  GEMINI_API_KEY is set in .env (gated so other cases don't fail with
  missing key). Reuses MODEL_GEMINI for the upstream model id with a
  gemini/gemini-3.1-pro-preview default.
- e2e/cases/22_gemini_credential_custom_api_base.md: runbook with
  goal/preconditions/expected/failure-modes.
- e2e/cases/data/22_gemini_credential_custom_api_base.sh: fixture
  asserts HTTP 200, x-litellm-model-api-base matches GEMINI_API_BASE
  from .env, x-litellm-model-group matches deployment name, and the
  response body shape. Exits 77 (SKIP) when GEMINI_API_KEY is unset.

Test plan:
- GEMINI_API_KEY + GEMINI_API_BASE set in e2e/.env pointing at a
  Gemini-compatible gateway:
  - e2e/tools/proxy restart
  - bash e2e/cases/data/22_gemini_credential_custom_api_base.sh
    → 3 PASS lines, exit 0
- Without GEMINI_API_KEY: deployment is omitted from rendered config;
  the case detects this via /v1/models and SKIPs (exit 77).
@songkuan-zheng
songkuan-zheng merged commit 073885a into ship/v1.87.0 Jun 4, 2026
@songkuan-zheng
songkuan-zheng deleted the fix/v1.87.0-wave-3-middleware-ui branch June 4, 2026 10:16
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.

1 participant