Skip to content

fix(ui): expose api_base on Google AI Studio credential form - #24

Merged
songkuan-zheng merged 2 commits into
ship/v1.83.10from
fix/ui-gemini-credential-api-base
May 28, 2026
Merged

fix(ui): expose api_base on Google AI Studio credential form#24
songkuan-zheng merged 2 commits into
ship/v1.83.10from
fix/ui-gemini-credential-api-base

Conversation

@songkuan-zheng

Copy link
Copy Markdown
Collaborator

Summary

The Google AI Studio credential form in the dashboard UI only exposed api_key, so admins using a Gemini-compatible gateway (any host serving /v1beta/models/{model}:generateContent with x-goog-api-key auth — e.g. self-hosted Raven Router, anispark router, etc.) had no way to set a custom api_base via the UI.

Forced workarounds today:

  1. PATCH the deployment's litellm_params.api_base via raw admin API
  2. Mislabel the credential as OpenAI to borrow that form's api_base field, leaving Model.litellm_params.model with the gemini/ prefix so the runtime still routes through the gemini provider

Both work because the runtime gemini provider already handles custom api_base correctly via litellm/llms/vertex_ai/vertex_llm_base.py:415 — it builds {api_base}/models/{model}:generateContent and authenticates with x-goog-api-key. The gap was UI-only.

Change

litellm/proxy/public_endpoints/provider_create_fields.json — add an optional api_base field to the Google_AI_Studio entry, with default_value = "https://generativelanguage.googleapis.com/v1beta" (the canonical Google AI Studio endpoint LiteLLM talks to when api_base is unset, so leaving the default behaves identically to leaving the field blank).

Field order is api_base then api_key, matching Anthropic / OpenAI / AI21 conventions (admin sees the URL override before the key field).

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_litellm/proxy/public_endpoints/test_public_endpoints.py — clean after one normalization pass
  • Verified live against gateway exposing /v1beta/models/{model}:generateContent: a Credential with provider=Google AI Studio + api_base=<gw>/v1beta + api_key=<key> paired with Model.litellm_params.model = gemini/gemini-3.1-pro-preview returns 200 from the gateway (gemini provider path, x-goog-api-key header)

Note

This is a candidate upstream PR — the same gap exists on BerriAI/litellm main. Happy to mirror to upstream after this lands.

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)
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 c0bd05a into ship/v1.83.10 May 28, 2026
1 check passed
songkuan-zheng added a commit that referenced this pull request Jun 4, 2026
* feat(proxy): add PublicReqMiddleware for X-Public-Req gated safeguards

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.

* fix(deploy): install PublicReqMiddleware at the Docker entrypoint, not 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.

* fix(ui): expose api_base field on Google AI Studio credential form

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)

* fix(ui): reset credential form state when switching providers

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.

* fix(ui): logging callbacks table reads backend `type` for Mode badge (#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.

* test(e2e): add case 22 — Gemini provider with custom api_base

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