feat(helm): support a separate hostname per component in the componentized deploy (#46360)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-14 14:56:36 -07:00 • committed by GitHub
parent 461cbea6e6
commit f76f7e1368
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 1347 additions and 153 deletions

View file

@ -5,3 +5,21 @@ This chart deploys separate gateway, backend and UI services. Configure Lens thr
Bundled mode generates separate service and identity-signing credentials. External mode requires `lensWorker.gateway.secretName` and `lensWorker.serviceTokenSecret.name`; their keys default to `gateway-secret` and `service-token`. Provision distinct values matching the external Lens deployment
Follow the [Lens Helm connection guide](https://github.com/BerriAI/lens/blob/main/helm/lens/README.md#connect-a-gateway) for the complete values and verification flow. It also links the GitOps credential requirements and existing-data migration. Source-chart installation requires a built Lens image until the first signed Lens release is published
For an ingress with separate component hostnames, set all three `ingress.componentHosts` values, leave `ingress.host` and `ingress.extraPaths` empty, configure `ui.backendUrl` as the backend's public origin and `backend.gatewayUrl` as the gateway's public origin, and set `ui.publicUrl` to the browser-facing UI origin. The UI Deployment receives `LITELLM_BACKEND_URL` from `ui.backendUrl` only when `ingress.componentHosts` is configured.
```yaml
ingress:
enabled: true
host: ""
componentHosts:
ui: ui.example.com
backend: backend.example.com
gateway: gateway.example.com
extraPaths: []
ui:
publicUrl: https://ui.example.com
backendUrl: https://backend.example.com
backend:
gatewayUrl: https://gateway.example.com
```

View file

@ -27,6 +27,30 @@ Common naming + label helpers shared by gateway, backend, and ui templates.
{{- printf "%s-ui" (include "litellm.fullname" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "litellm.ui.publicOrigin" -}}
{{- $origin := .Values.ui.publicUrl | trim | trimSuffix "/" -}}
{{- if not (regexMatch "^https?://[^/\\s]+$" $origin) -}}
{{- fail "ui.publicUrl must be an origin like https://ui.example.com, without a path" -}}
{{- end -}}
{{- $origin -}}
{{- end -}}
{{- define "litellm.ui.backendOrigin" -}}
{{- $origin := .Values.ui.backendUrl | trim | trimSuffix "/" -}}
{{- if not (regexMatch "^https?://[^/\\s]+$" $origin) -}}
{{- fail "ui.backendUrl must be an origin like https://backend.example.com, without a path, when ingress.componentHosts is set" -}}
{{- end -}}
{{- $origin -}}
{{- end -}}
{{- define "litellm.backend.gatewayOrigin" -}}
{{- $origin := .Values.backend.gatewayUrl | trim | trimSuffix "/" -}}
{{- if not (regexMatch "^https?://[^/\\s]+$" $origin) -}}
{{- fail "backend.gatewayUrl must be an origin like https://gateway.example.com, without a path, when ingress.componentHosts is set" -}}
{{- end -}}
{{- $origin -}}
{{- end -}}
{{- define "litellm.commonLabels" -}}
app.kubernetes.io/name: {{ include "litellm.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}

View file

@ -1,4 +1,6 @@
{{- if .Values.backend.enabled }}
{{- $componentHosts := .Values.ingress.componentHosts | default dict -}}
{{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@ -58,7 +60,17 @@ spec:
protocol: TCP
env:
{{- include "litellm.lensConnectionEnv" . | nindent 12 }}
{{- if .Values.ui.publicUrl }}
- name: LITELLM_CORS_ORIGINS
value: {{ include "litellm.ui.publicOrigin" . | quote }}
- name: LITELLM_CORS_ALLOW_CREDENTIALS
value: "true"
{{- end }}
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }}
{{- if .Values.backend.gatewayUrl }}
- name: LITELLM_GATEWAY_URL
value: {{ if $hasComponentHosts }}{{ include "litellm.backend.gatewayOrigin" . | quote }}{{ else }}{{ .Values.backend.gatewayUrl | quote }}{{ end }}
{{- end }}
{{- if .Values.gateway.config.create }}
- name: CONFIG_FILE_PATH
value: /app/config/config.yaml

View file

@ -56,6 +56,10 @@ spec:
protocol: TCP
env:
{{- include "litellm.lensConnectionEnv" . | nindent 12 }}
{{- if .Values.ui.publicUrl }}
- name: LITELLM_CORS_ORIGINS
value: {{ include "litellm.ui.publicOrigin" . | quote }}
{{- end }}
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.gateway) | nindent 12 }}
{{- if .Values.gateway.config.create }}
- name: CONFIG_FILE_PATH

View file

@ -1,3 +1,36 @@
{{- $componentHosts := .Values.ingress.componentHosts | default dict -}}
{{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}}
{{- if $hasComponentHosts }}
{{- if not (and $componentHosts.ui $componentHosts.backend $componentHosts.gateway) }}
{{- fail "ingress.componentHosts.ui, backend, and gateway must all be set together" }}
{{- end }}
{{- if not .Values.gateway.enabled }}
{{- fail "ingress.componentHosts requires gateway.enabled=true" }}
{{- end }}
{{- if not .Values.backend.enabled }}
{{- fail "ingress.componentHosts requires backend.enabled=true" }}
{{- end }}
{{- if not .Values.ui.enabled }}
{{- fail "ingress.componentHosts requires ui.enabled=true" }}
{{- end }}
{{- if .Values.ingress.host }}
{{- fail "ingress.host must be empty when ingress.componentHosts is configured" }}
{{- end }}
{{- if .Values.ingress.extraPaths }}
{{- fail "ingress.extraPaths must be empty when ingress.componentHosts is configured" }}
{{- end }}
{{- if not (trim .Values.ui.backendUrl) }}
{{- fail "ui.backendUrl is required when ingress.componentHosts is configured" }}
{{- end }}
{{- $_ := include "litellm.ui.backendOrigin" . }}
{{- if not (trim .Values.backend.gatewayUrl) }}
{{- fail "backend.gatewayUrl is required when ingress.componentHosts is configured" }}
{{- end }}
{{- $_ := include "litellm.backend.gatewayOrigin" . }}
{{- if not (trim .Values.ui.publicUrl) }}
{{- fail "ui.publicUrl is required when ingress.componentHosts is configured" }}
{{- end }}
{{- end }}
{{- if .Values.ingress.enabled -}}
{{- $gatewayName := include "litellm.gateway.fullname" . -}}
{{- $backendName := include "litellm.backend.fullname" . -}}
@ -90,6 +123,66 @@
adding to it.
*/}}
{{- $builtinPathKeys := list "/test|Exact" "/debug/memory/summary|Exact" "/|Prefix" -}}
{{- if $hasComponentHosts }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "litellm.fullname" . }}
labels:
{{- include "litellm.commonLabels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . | quote }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ $componentHosts.ui | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $uiName }}
port:
number: {{ $uiPort }}
- host: {{ $componentHosts.backend | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $backendName }}
port:
number: {{ $backendPort }}
- host: {{ $componentHosts.gateway | quote }}
http:
paths:
{{- if or (eq (include "litellm.lens.mode" .) "bundled") (and (eq (include "litellm.lens.mode" .) "external") .Values.lensWorker.externalServiceName) }}
- path: /lens-ingest
pathType: Prefix
backend:
service:
name: {{ .Values.lensWorker.externalServiceName | default (printf "%s-lens-worker" (include "litellm.fullname" .)) }}
port:
number: {{ .Values.lensWorker.service.port }}
{{- end }}
- path: /
pathType: Prefix
backend:
service:
name: {{ $gatewayName }}
port:
number: {{ $gatewayPort }}
{{- else }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@ -217,3 +310,4 @@ spec:
port:
number: {{ $backendPort }}
{{- end }}
{{- end }}

View file

@ -1,3 +1,5 @@
{{- $componentHosts := .Values.ingress.componentHosts | default dict -}}
{{- $hasComponentHosts := or $componentHosts.ui $componentHosts.backend $componentHosts.gateway -}}
{{- if .Values.ui.enabled }}
apiVersion: apps/v1
kind: Deployment
@ -56,9 +58,9 @@ spec:
- name: LITELLM_LOG
value: {{ .Values.ui.logLevel | quote }}
{{- end }}
{{- if .Values.ui.backendUrl }}
{{- if and .Values.ui.backendUrl $hasComponentHosts }}
- name: LITELLM_BACKEND_URL
value: {{ .Values.ui.backendUrl | quote }}
value: {{ include "litellm.ui.backendOrigin" . | quote }}
{{- end }}
{{- with .Values.ui.extraEnv }}
{{- toYaml . | nindent 12 }}

View file

@ -0,0 +1,441 @@
suite: ingress component hosts
templates:
- ingress.yaml
- backend/deployment.yaml
- gateway/deployment.yaml
- gateway/configmap.yaml
- ui/deployment.yaml
values:
- ./values/required.yaml
tests:
- it: routes each hostname to its component and injects the external URLs
template: ingress.yaml
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
lensWorker.mode: disabled
ingress.tls:
- hosts:
- ui.example.com
- backend.example.com
- gateway.example.com
secretName: component-hosts
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
asserts:
- equal:
path: spec.rules[0].host
value: ui.example.com
- equal:
path: spec.tls[0]
value:
hosts:
- ui.example.com
- backend.example.com
- gateway.example.com
secretName: component-hosts
- equal:
path: spec.rules[0].http.paths
value:
- path: /
pathType: Prefix
backend:
service:
name: RELEASE-NAME-litellm-ui
port:
number: 3000
- equal:
path: spec.rules[1].host
value: backend.example.com
- equal:
path: spec.rules[1].http.paths
value:
- path: /
pathType: Prefix
backend:
service:
name: RELEASE-NAME-litellm-backend
port:
number: 4001
- equal:
path: spec.rules[2].host
value: gateway.example.com
- equal:
path: spec.rules[2].http.paths
value:
- path: /
pathType: Prefix
backend:
service:
name: RELEASE-NAME-litellm-gateway
port:
number: 4000
- it: keeps Lens ingestion ahead of the gateway catch-all
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
lensWorker.mode: external
lensWorker.externalServiceName: existing-lens
lensWorker.externalUrl: https://lens.example.com
lensWorker.publicUrl: https://lens.example.com
lensWorker.serviceTokenSecret.name: existing-lens-signing
lensWorker.gateway.secretName: existing-lens-signing
asserts:
- equal:
path: spec.rules[2].http.paths[0].path
value: /lens-ingest
- equal:
path: spec.rules[2].http.paths[0].backend.service.name
value: existing-lens
- equal:
path: spec.rules[2].http.paths[1].path
value: /
template: ingress.yaml
- it: rejects a partial component host configuration
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
asserts:
- failedTemplate:
errorMessage: ingress.componentHosts.ui, backend, and gateway must all be set together
template: ingress.yaml
- it: rejects component hosts when the gateway is disabled
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
gateway.enabled: false
asserts:
- failedTemplate:
errorMessage: ingress.componentHosts requires gateway.enabled=true
template: ingress.yaml
- it: rejects component hosts when the backend is disabled
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
backend.enabled: false
asserts:
- failedTemplate:
errorMessage: ingress.componentHosts requires backend.enabled=true
template: ingress.yaml
- it: rejects component hosts when the UI is disabled
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
ui.enabled: false
asserts:
- failedTemplate:
errorMessage: ingress.componentHosts requires ui.enabled=true
template: ingress.yaml
- it: rejects a legacy host alongside component hosts
set:
ingress.enabled: true
ingress.host: legacy.example.com
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
asserts:
- failedTemplate:
errorMessage: ingress.host must be empty when ingress.componentHosts is configured
template: ingress.yaml
- it: rejects extra paths alongside component hosts
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ingress.extraPaths:
- path: /custom
asserts:
- failedTemplate:
errorMessage: ingress.extraPaths must be empty when ingress.componentHosts is configured
template: ingress.yaml
- it: requires the UI backend URL in component host mode
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
asserts:
- failedTemplate:
errorMessage: ui.backendUrl is required when ingress.componentHosts is configured
template: ingress.yaml
- it: requires the backend gateway URL in component host mode
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
asserts:
- failedTemplate:
errorMessage: backend.gatewayUrl is required when ingress.componentHosts is configured
template: ingress.yaml
- it: requires the public UI origin in component host mode
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
backend.gatewayUrl: https://gateway.example.com
asserts:
- failedTemplate:
errorMessage: ui.publicUrl is required when ingress.componentHosts is configured
template: ingress.yaml
- it: exposes the gateway URL in the backend Deployment
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: " https://gateway.example.com/ "
template: backend/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_GATEWAY_URL
value: https://gateway.example.com
- it: preserves the backend gateway URL verbatim in single-host mode
set:
ingress.enabled: true
ingress.host: litellm.example.com
backend.gatewayUrl: " https://gateway.example.com/api/ "
template: backend/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_GATEWAY_URL
value: " https://gateway.example.com/api/ "
- it: exposes the backend URL in the UI Deployment
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: " https://backend.example.com/ "
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
template: ui/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_BACKEND_URL
value: https://backend.example.com
- it: omits the backend URL from the UI Deployment in single-host mode
set:
ingress.enabled: true
ingress.host: litellm.example.com
ui.backendUrl: https://litellm.example.com/api
template: ui/deployment.yaml
asserts:
- notContains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_BACKEND_URL
any: true
- it: enables credentialed CORS from the public UI origin on the backend
set:
ui.publicUrl: https://ui.example.com
template: backend/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_CORS_ORIGINS
value: https://ui.example.com
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_CORS_ALLOW_CREDENTIALS
value: "true"
- it: lets backend extraEnv override generated CORS values
set:
ui.publicUrl: https://ui.example.com
backend.extraEnv:
- name: LITELLM_CORS_ORIGINS
value: https://backend-override.example.com
- name: LITELLM_CORS_ALLOW_CREDENTIALS
value: "false"
template: backend/deployment.yaml
asserts:
- equal:
path: spec.template.spec.containers[0].env[0].name
value: LITELLM_CORS_ORIGINS
- equal:
path: spec.template.spec.containers[0].env[0].value
value: https://ui.example.com
- equal:
path: spec.template.spec.containers[0].env[1].name
value: LITELLM_CORS_ALLOW_CREDENTIALS
- equal:
path: spec.template.spec.containers[0].env[1].value
value: "true"
- equal:
path: spec.template.spec.containers[0].env[10].name
value: LITELLM_CORS_ORIGINS
- equal:
path: spec.template.spec.containers[0].env[10].value
value: https://backend-override.example.com
- equal:
path: spec.template.spec.containers[0].env[11].name
value: LITELLM_CORS_ALLOW_CREDENTIALS
- equal:
path: spec.template.spec.containers[0].env[11].value
value: "false"
- it: exposes the public UI origin to the gateway without enabling credentials
set:
ui.publicUrl: https://ui.example.com
template: gateway/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_CORS_ORIGINS
value: https://ui.example.com
- notContains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_CORS_ALLOW_CREDENTIALS
value: "true"
- it: trims the public UI origin before setting backend CORS
set:
ui.publicUrl: " https://ui.example.com/ "
template: backend/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_CORS_ORIGINS
value: https://ui.example.com
- it: trims the public UI origin before setting gateway CORS
set:
ui.publicUrl: " https://ui.example.com/ "
template: gateway/deployment.yaml
asserts:
- contains:
path: spec.template.spec.containers[0].env
content:
name: LITELLM_CORS_ORIGINS
value: https://ui.example.com
- it: rejects a UI public URL with a path
set:
ui.publicUrl: https://ui.example.com/app
template: gateway/deployment.yaml
asserts:
- failedTemplate:
errorMessage: ui.publicUrl must be an origin like https://ui.example.com, without a path
- it: lets gateway extraEnv override the generated CORS origin
set:
ui.publicUrl: https://ui.example.com
gateway.extraEnv:
- name: LITELLM_CORS_ORIGINS
value: https://gateway-override.example.com
template: gateway/deployment.yaml
asserts:
- equal:
path: spec.template.spec.containers[0].env[0].name
value: LITELLM_CORS_ORIGINS
- equal:
path: spec.template.spec.containers[0].env[0].value
value: https://ui.example.com
- equal:
path: spec.template.spec.containers[0].env[9].name
value: LITELLM_CORS_ORIGINS
- equal:
path: spec.template.spec.containers[0].env[9].value
value: https://gateway-override.example.com
---
suite: component host UI backend origin validation
templates:
- ingress.yaml
values:
- ./values/required.yaml
tests:
- it: rejects a pathful UI backend URL in component host mode
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com/api
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com
asserts:
- failedTemplate:
errorMessage: ui.backendUrl must be an origin like https://backend.example.com, without a path, when ingress.componentHosts is set
- it: rejects a pathful backend gateway URL in component host mode
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: https://gateway.example.com/v1
asserts:
- failedTemplate:
errorMessage: backend.gatewayUrl must be an origin like https://gateway.example.com, without a path, when ingress.componentHosts is set
- it: rejects a backend gateway URL without a scheme in component host mode
set:
ingress.enabled: true
ingress.componentHosts.ui: ui.example.com
ingress.componentHosts.backend: backend.example.com
ingress.componentHosts.gateway: gateway.example.com
ui.backendUrl: https://backend.example.com
ui.publicUrl: https://ui.example.com
backend.gatewayUrl: gateway.example.com
asserts:
- failedTemplate:
errorMessage: backend.gatewayUrl must be an origin like https://gateway.example.com, without a path, when ingress.componentHosts is set

View file

@ -23,7 +23,11 @@ ingress:
# wildcard pathType, so that rule could never match there.
controller: alb
annotations: {}
host: "" # optional; if set, becomes the rule's host
host: "" # optional; if set, becomes the single ingress rule's host
componentHosts:
ui: ""
backend: ""
gateway: ""
tls: []
# Extra HTTP paths appended to the ingress rule. Additive: every built-in
# UI / gateway / backend path is still rendered, these entries are placed
@ -499,6 +503,7 @@ gateway:
backend:
enabled: true
logLevel: INFO
gatewayUrl: ""
extraEnv: []
envConfigMaps: []
envSecrets: []
@ -581,9 +586,10 @@ ui:
service:
type: ClusterIP
port: 3000
# The dashboard expects to know where to reach the backend API. Set this to
# the externally-routable URL (typically the ingress host + /api or similar).
# The backend's public origin, passed to the UI only when ingress.componentHosts is configured.
backendUrl: ""
# Public origin of the UI, used for backend and gateway CORS.
publicUrl: ""
resources:
requests:
cpu: 500m

View file

@ -33,9 +33,15 @@ async def get_ui_config():
is_control_plane: Final = len(proxy_config.worker_registry) > 0
raw_gateway_base_url: Final[str | None] = os.getenv("LITELLM_GATEWAY_URL")
gateway_base_url: Final[str | None] = (
raw_gateway_base_url.strip().rstrip("/") or None if raw_gateway_base_url is not None else None
)
return UiDiscoveryEndpoints(
server_root_path=get_server_root_path(),
proxy_base_url=get_proxy_base_url(),
gateway_base_url=gateway_base_url,
auto_redirect_to_sso=sso_configured and auto_redirect_ui_login_to_sso,
admin_ui_disabled=admin_ui_disabled,
sso_configured=sso_configured,

View file

@ -7,6 +7,7 @@ from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
class UiDiscoveryEndpoints(LiteLLMBaseModel):
server_root_path: str
proxy_base_url: str | None
gateway_base_url: str | None = None
auto_redirect_to_sso: bool
admin_ui_disabled: bool
sso_configured: bool

View file

@ -1,11 +1,11 @@
import os
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy.discovery_endpoints.ui_discovery_endpoints import router
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
@ -21,7 +21,6 @@ def test_ui_discovery_endpoints_with_defaults():
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -44,7 +43,6 @@ def test_ui_discovery_endpoints_with_custom_server_root_path():
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -69,7 +67,6 @@ def test_ui_discovery_endpoints_with_proxy_base_url_when_set():
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
):
response = client.get("/litellm/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -80,6 +77,41 @@ def test_ui_discovery_endpoints_with_proxy_base_url_when_set():
assert data["sso_configured"] is False
@pytest.mark.parametrize(
("gateway_url", "expected_gateway_url"),
[
("https://gw.example.com/", "https://gw.example.com"),
(" ", None),
(None, None),
],
)
def test_ui_discovery_endpoints_normalizes_gateway_base_url(
monkeypatch: pytest.MonkeyPatch,
gateway_url: str | None,
expected_gateway_url: str | None,
) -> None:
app: Final[FastAPI] = FastAPI()
app.include_router(router)
client: Final[TestClient] = TestClient(app)
if gateway_url is None:
monkeypatch.delenv("LITELLM_GATEWAY_URL", raising=False)
else:
monkeypatch.setenv("LITELLM_GATEWAY_URL", gateway_url)
with (
patch("litellm.proxy.utils.get_server_root_path", return_value="/"),
patch("litellm.proxy.utils.get_proxy_base_url", return_value="https://proxy.example.com"),
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
):
response: Final = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
assert response.json()["proxy_base_url"] == "https://proxy.example.com"
assert response.json()["gateway_base_url"] == expected_gateway_url
def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_enabled():
app = FastAPI()
app.include_router(router)
@ -98,7 +130,6 @@ def test_ui_discovery_endpoints_with_sso_configured_and_auto_redirect_enabled():
clear=False,
),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -155,7 +186,6 @@ def test_ui_discovery_endpoints_with_sso_configured_but_auto_redirect_disabled()
clear=False,
),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -181,7 +211,6 @@ def test_ui_discovery_endpoints_with_sso_not_configured_but_auto_redirect_enable
clear=False,
),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -210,7 +239,6 @@ def test_ui_discovery_endpoints_both_routes_return_same_data():
clear=False,
),
):
response1 = client.get("/.well-known/litellm-ui-config")
response2 = client.get("/litellm/.well-known/litellm-ui-config")
@ -265,7 +293,6 @@ def test_ui_discovery_endpoints_with_auto_redirect_env_var_overrides_general_set
clear=False,
),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -284,7 +311,6 @@ def test_ui_discovery_endpoints_with_admin_ui_disabled():
patch("litellm.proxy.auth.auth_utils.has_user_setup_sso", return_value=False),
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "true"}, clear=False),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -303,9 +329,7 @@ def test_ui_discovery_endpoints_is_control_plane_true_when_workers_configured():
mock_config = MagicMock()
mock_config.worker_registry = [
WorkerRegistryEntry(
worker_id="team-a", name="Team A", url="https://worker-1:4001"
),
WorkerRegistryEntry(worker_id="team-a", name="Team A", url="https://worker-1:4001"),
]
with (
@ -315,7 +339,6 @@ def test_ui_discovery_endpoints_is_control_plane_true_when_workers_configured():
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -405,7 +428,6 @@ def test_ui_discovery_endpoints_hide_default_credentials_hint_via_env_var():
clear=False,
),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200
@ -453,7 +475,6 @@ def test_ui_discovery_endpoints_is_control_plane_false_when_no_workers():
patch("litellm.proxy.proxy_server.proxy_config", mock_config),
patch.dict(os.environ, {"DISABLE_ADMIN_UI": "false"}, clear=False),
):
response = client.get("/.well-known/litellm-ui-config")
assert response.status_code == 200

View file

@ -33,6 +33,9 @@ RUN rm -f /etc/nginx/conf.d/default.conf
# Static export → web root.
COPY --from=builder /app/out /usr/share/nginx/html
# Runtime backend discovery config.
COPY ui/docker-entrypoint.d/40-litellm-runtime-config.sh /docker-entrypoint.d/
# Routing rules — see ui/nginx.conf for the full description.
COPY ui/nginx.conf /etc/nginx/nginx.conf

View file

@ -0,0 +1,32 @@
#!/bin/sh
set -eu
runtime_dir=/tmp/litellm-ui
runtime_file=$runtime_dir/runtime-config.json
rm -f "$runtime_file" 2>/dev/null || true
if [ "${LITELLM_BACKEND_URL+x}" = "" ]; then
exit 0
fi
newline='
'
case "$LITELLM_BACKEND_URL" in
*"$newline"*)
printf '%s\n' 'WARN: ignoring invalid LITELLM_BACKEND_URL' >&2
exit 0
;;
esac
if ! printf '%s\n' "$LITELLM_BACKEND_URL" | grep -Eq '^https?://[^"\\ ]+$'; then
printf '%s\n' 'WARN: ignoring invalid LITELLM_BACKEND_URL' >&2
exit 0
fi
backend_url=$LITELLM_BACKEND_URL
while [ "${backend_url%/}" != "$backend_url" ]; do
backend_url=${backend_url%/}
done
mkdir -p "$runtime_dir"
printf '{"backend_url":"%s"}\n' "$backend_url" > "$runtime_file"

View file

@ -10,6 +10,7 @@ let mockPasswordResetRequired = false;
vi.mock("@/components/networking", () => ({
changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args),
getProxyBaseUrl: () => "",
getUiPageUrl: (url: string) => url,
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({

View file

@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { FieldGroup } from "@/components/ui/field";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { changePasswordCall, getProxyBaseUrl } from "@/components/networking";
import { changePasswordCall, getProxyBaseUrl, getUiPageUrl } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { useZodForm } from "@/lib/forms/useZodForm";
import { toast } from "@/lib/toast";
@ -51,7 +51,7 @@ export function ChangePasswordForm() {
// until the expiry reaper gets to it.
toast.success("Password updated. Please log in with your new password.");
await revokeSessionAndClearClientState(accessToken);
window.location.replace(getLoginUrl(getProxyBaseUrl()));
window.location.replace(getUiPageUrl(getLoginUrl(getProxyBaseUrl())));
return;
}
toast.success("Password updated");

View file

@ -1,6 +1,6 @@
"use client";
import { getProxyBaseUrl } from "@/components/networking";
import { getProxyBaseUrl, getUiPageUrl } from "@/components/networking";
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils";
import { buildLoginUrlWithReturn, getLoginUrl, storeReturnUrl } from "@/utils/returnUrlUtils";
@ -21,7 +21,7 @@ const useAuthorized = () => {
// Helper function to redirect to login while preserving the current URL
const redirectToLogin = useCallback(() => {
storeReturnUrl();
const baseLoginUrl = getLoginUrl(getProxyBaseUrl());
const baseLoginUrl = getUiPageUrl(getLoginUrl(getProxyBaseUrl()));
const loginUrlWithReturn = buildLoginUrlWithReturn(baseLoginUrl);
window.location.replace(loginUrlWithReturn);
}, []);

View file

@ -1,4 +1,4 @@
import { sessionLogoutCall } from "@/components/networking";
import { getUiPageUrl, sessionLogoutCall } from "@/components/networking";
import { clearTokenCookies } from "@/utils/cookieUtils";
import { clearStoredReturnUrl } from "@/utils/returnUrlUtils";
import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
@ -33,7 +33,7 @@ export function useLogout(accessToken: string | null): () => void {
return () => {
void revokeSessionAndClearClientState(accessToken).finally(() => {
window.location.href = proxySettings.PROXY_LOGOUT_URL || "";
window.location.href = getUiPageUrl(proxySettings.PROXY_LOGOUT_URL || "");
});
};
}

View file

@ -7,6 +7,7 @@ import { getMCPToolsetTableColumns } from "./MCPToolsetTableColumns";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "http://localhost:4000",
getGatewayBaseUrl: () => "http://gateway.local:5000",
}));
const mockToolset: MCPToolset = {
@ -46,7 +47,7 @@ describe("getMCPToolsetTableColumns", () => {
it("renders the toolset with its endpoint url as subtitle", () => {
renderTable();
expect(screen.getByText("github-tools")).toBeInTheDocument();
expect(screen.getByText("http://localhost:4000/toolset/github-tools/mcp")).toBeInTheDocument();
expect(screen.getByText("http://gateway.local:5000/toolset/github-tools/mcp")).toBeInTheDocument();
});
it("renders server-prefixed tool chips capped at four with an overflow count", () => {
@ -77,7 +78,7 @@ describe("getMCPToolsetTableColumns", () => {
await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-copy-url"));
expect(await window.navigator.clipboard.readText()).toBe("http://localhost:4000/toolset/github-tools/mcp");
expect(await window.navigator.clipboard.readText()).toBe("http://gateway.local:5000/toolset/github-tools/mcp");
await user.click(screen.getByTestId("toolset-actions-ts-1"));
await user.click(await screen.findByTestId("toolset-action-copy-id"));

View file

@ -14,7 +14,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { MCPToolset } from "@/components/mcp_tools/types";
import { copyToClipboard } from "@/utils/dataUtils";
@ -30,7 +30,7 @@ export function displayToolName(serverPrefix: string | undefined, toolName: stri
}
export function toolsetEndpointUrl(toolsetName: string): string {
return `${getProxyBaseUrl()}/toolset/${toolsetName}/mcp`;
return `${getGatewayBaseUrl()}/toolset/${toolsetName}/mcp`;
}
interface ToolsetRowActionsProps {

View file

@ -14,7 +14,8 @@ vi.mock("@/components/networking", () => ({
updateMCPToolset: vi.fn(),
deleteMCPToolset: vi.fn(),
listMCPTools: vi.fn(),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
getProxyBaseUrl: vi.fn().mockReturnValue("https://backend.example.com"),
getGatewayBaseUrl: vi.fn().mockReturnValue("https://gateway.example.com"),
}));
vi.mock("@/app/(dashboard)/hooks/mcpServers/useMCPToolsets", () => ({ useMCPToolsets: vi.fn() }));
@ -195,3 +196,11 @@ describe("MCPToolsetsTab create/edit toolset form", () => {
});
});
});
describe("MCPToolsetsTab usage guide", () => {
it("shows the toolset URL on the gateway host", () => {
renderTab();
expect(screen.getByText(/https:\/\/gateway\.example\.com\/toolset\/<toolset-name>\/mcp/)).toBeInTheDocument();
});
});

View file

@ -11,7 +11,7 @@ import {
updateMCPToolset,
deleteMCPToolset,
listMCPTools,
getProxyBaseUrl,
getGatewayBaseUrl,
} from "@/components/networking";
import { MCPToolset, MCPToolsetTool } from "@/components/mcp_tools/types";
import { FieldGroup } from "@/components/ui/field";
@ -329,12 +329,12 @@ function ToolsetsEmptyState() {
function ToolsetUsageGuide() {
const [copied, setCopied] = useState(false);
const proxyBaseUrl = getProxyBaseUrl();
const gatewayBaseUrl = getGatewayBaseUrl();
const snippet = `{
"mcpServers": {
"my-toolset": {
"url": "${proxyBaseUrl}/toolset/<toolset-name>/mcp",
"url": "${gatewayBaseUrl}/toolset/<toolset-name>/mcp",
"headers": { "x-litellm-api-key": "Bearer <your-api-key>" }
}
}

View file

@ -5,11 +5,28 @@ import { describe, it, expect, vi } from "vitest";
import MCPConnect from "./mcp_connect";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
getProxyBaseUrl: vi.fn().mockReturnValue("https://backend.example.com"),
getGatewayBaseUrl: vi.fn().mockReturnValue("https://gateway.example.com"),
}));
const activePanel = () => screen.getByRole("tabpanel");
describe("MCPConnect routing", () => {
it("uses the gateway for Responses and the backend for MCP server URLs", async () => {
const user = userEvent.setup();
render(<MCPConnect />);
expect(within(activePanel()).getByText(/"server_url": "https:\/\/backend\.example\.com\/mcp"/)).toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "LiteLLM Proxy" }));
expect(within(activePanel()).getByText("https://backend.example.com/mcp")).toBeInTheDocument();
expect(
within(activePanel()).getByText(/curl --location 'https:\/\/gateway\.example\.com\/v1\/responses'/),
).toBeInTheDocument();
});
});
describe("MCPConnect (tab mount contract)", () => {
it("keeps the x-mcp-servers header toggle on after switching tabs away and back", async () => {
render(<MCPConnect />);

View file

@ -19,7 +19,7 @@ import {
ServerIcon,
Zap,
} from "lucide-react";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl, getProxyBaseUrl } from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
interface CodeBlockProps {
@ -132,6 +132,7 @@ interface MCPConnectProps {
const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = [] }) => {
const proxyBaseUrl = getProxyBaseUrl();
const gatewayBaseUrl = getGatewayBaseUrl();
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [currentServer] = useState("Zapier_MCP"); // This should match the current server being viewed
@ -238,7 +239,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
accessGroups={["dev-group"]}
>
<CodeBlock
code={`curl --location '${proxyBaseUrl}/v1/responses' \\
code={`curl --location '${gatewayBaseUrl}/v1/responses' \\
--header 'Content-Type: application/json' \\
--header "Authorization: Bearer $LITELLM_VIRTUAL_KEY" \\
--data '{

View file

@ -15,6 +15,7 @@ vi.mock("@/components/networking", () => ({
fetchMCPServerHealth: vi.fn(),
deleteMCPServer: vi.fn(),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
getGatewayBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
fetchMCPClientIp: vi.fn().mockResolvedValue(null),
getGeneralSettingsCall: vi.fn().mockResolvedValue([]),
updateConfigFieldSetting: vi.fn().mockResolvedValue(undefined),

View file

@ -43,7 +43,11 @@ vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({
vi.mock("@/components/common_components/LoadingScreen", () => ({
default: () => <div data-testid="loading-screen" />,
}));
vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" }));
vi.mock("@/components/networking", () => ({
getRuntimeBackendBaseUrl: () => null,
getUiPageUrl: (url: string) => url,
proxyBaseUrl: "",
}));
vi.mock("@/utils/uiHref", () => ({ uiHref: mockUiHref }));
vi.mock("@/utils/returnUrlUtils", () => ({
buildLoginUrlWithReturn: (u: string) => u,

View file

@ -2,7 +2,7 @@
import HomePage from "@/app/(dashboard)/home/_components/HomePage";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { proxyBaseUrl } from "@/components/networking";
import { getRuntimeBackendBaseUrl, getUiPageUrl, proxyBaseUrl } from "@/components/networking";
import { useAuth } from "@/contexts/AuthContext";
import {
buildLoginUrlWithReturn,
@ -32,7 +32,7 @@ function CreateKeyPageContent() {
// Store the current URL so we can redirect back after login
storeReturnUrl();
// Build login URL with return URL parameter
const baseLoginUrl = getLoginUrl(proxyBaseUrl || "");
const baseLoginUrl = getUiPageUrl(getLoginUrl(proxyBaseUrl || ""));
const dest = buildLoginUrlWithReturn(baseLoginUrl);
// Replace instead of assigning to avoid back-button loops
window.location.replace(dest);
@ -59,10 +59,10 @@ function CreateKeyPageContent() {
hasAttemptedReturnRedirectRef.current = true;
// Check for a stored return URL
const returnUrl = consumeReturnUrl();
if (returnUrl && isValidReturnUrl(returnUrl)) {
const returnUrl = consumeReturnUrl(getRuntimeBackendBaseUrl());
if (returnUrl && isValidReturnUrl(returnUrl, getRuntimeBackendBaseUrl())) {
// Inline origin check: only redirect to same-origin URLs to prevent open redirect.
const safeUrl = new URL(returnUrl, window.location.origin);
const safeUrl = new URL(getUiPageUrl(returnUrl, true), window.location.origin);
if (safeUrl.origin !== window.location.origin) {
return;
}

View file

@ -5,6 +5,7 @@ import CodeInterpreterOutput from "./CodeInterpreterOutput";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
@ -74,7 +75,7 @@ describe("CodeInterpreterOutput", () => {
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/v1/containers/container-1/files/file-1/content",
"https://gateway.example.com/v1/containers/container-1/files/file-1/content",
expect.objectContaining({
headers: {
Authorization: "Bearer test-token",
@ -173,7 +174,7 @@ describe("CodeInterpreterOutput", () => {
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/v1/containers/container-1/files/file-1/content",
"https://gateway.example.com/v1/containers/container-1/files/file-1/content",
expect.objectContaining({
headers: {
Authorization: "Bearer test-token",
@ -222,7 +223,7 @@ describe("CodeInterpreterOutput", () => {
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"https://example.com/v1/containers/container-1/files/file-1/content",
"https://gateway.example.com/v1/containers/container-1/files/file-1/content",
expect.objectContaining({
headers: {
Authorization: "Bearer test-token",

View file

@ -4,7 +4,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@ -39,7 +39,7 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, ann
const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
const [codeOpen, setCodeOpen] = useState(false);
const proxyBaseUrl = getProxyBaseUrl();
const proxyBaseUrl = getGatewayBaseUrl();
useEffect(() => {
const createdUrls: string[] = [];

View file

@ -5,6 +5,7 @@ import RealtimePlayground from "./RealtimePlayground";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "https://proxy.example.com",
getGatewayBaseUrl: () => "https://gateway.example.com",
}));
class FakeSocket {
@ -112,7 +113,7 @@ describe("RealtimePlayground", () => {
await user.click(screen.getByRole("button", { name: /Connect/i }));
expect(latestSocket().url).toBe("wss://proxy.example.com/v1/realtime?model=gpt-realtime");
expect(latestSocket().url).toBe("wss://gateway.example.com/v1/realtime?model=gpt-realtime");
expect(latestSocket().protocols).toEqual(["realtime", "openai-insecure-api-key.sk-realtime"]);
});

View file

@ -5,7 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { OPEN_AI_VOICE_SELECT_OPTIONS } from "./chatConstants";
interface RealtimeMessage {
@ -96,7 +96,7 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
try {
audioContextRef.current = new AudioContext({ sampleRate: 24000 });
const baseUrl = customProxyBaseUrl || getProxyBaseUrl();
const baseUrl = customProxyBaseUrl || getGatewayBaseUrl();
const wsBase = baseUrl.replace(/^http/, "ws");
let url = `${wsBase}/v1/realtime?model=${encodeURIComponent(selectedModel)}`;
if (selectedGuardrails && selectedGuardrails.length > 0) {

View file

@ -14,7 +14,7 @@ import { SimpleTooltip } from "@/components/ui/tooltip";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { cn } from "@/lib/cva.config";
import { DECISIONS_DOCS_URL } from "@/lib/decisionModels";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { uiHref } from "@/utils/uiHref";
import { useMutation } from "@tanstack/react-query";
import { BookOpen, ChevronDown, Eraser, FileText, LoaderCircle, Send } from "lucide-react";
@ -298,7 +298,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
</TabsContent>
<TabsContent value="curl" className="flex min-h-0 flex-1 flex-col">
<CurlPreview
baseUrl={getCustomProxyBaseUrl() ?? getProxyBaseUrl()}
baseUrl={getCustomProxyBaseUrl() ?? getGatewayBaseUrl()}
endpoint={endpoint}
payload={validation.payload === undefined ? rawPayload : JSON.stringify(validation.payload)}
authHeaderName={getGlobalLitellmHeaderName()}

View file

@ -2,7 +2,7 @@
// A2A Protocol (JSON-RPC 2.0) implementation for sending messages to agents
import { v4 as uuidv4 } from "uuid";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { type CustomHeaders, withRequiredHeaders } from "@/components/llm_calls/request_headers";
import { A2ATaskMetadata } from "@/components/chat_ui/types";
@ -119,7 +119,7 @@ export const makeA2ASendMessageRequest = async (
guardrails?: string[],
customHeaders?: CustomHeaders,
): Promise<void> => {
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const url = proxyBaseUrl ? `${proxyBaseUrl}/a2a/${agentId}/message/send` : `/a2a/${agentId}/message/send`;
const requestId = uuidv4();
@ -250,7 +250,7 @@ export const makeA2AStreamMessageRequest = async (
onA2AMetadata?: (metadata: A2ATaskMetadata) => void,
customBaseUrl?: string,
): Promise<void> => {
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const url = proxyBaseUrl ? `${proxyBaseUrl}/a2a/${agentId}` : `/a2a/${agentId}`;
const requestId = uuidv4();

View file

@ -5,6 +5,7 @@ import type { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
}));
const mockMessagesStream = vi.fn();

View file

@ -4,7 +4,7 @@ import { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
import { buildMcpToolBlocks } from "@/components/llm_calls/mcp_tool_blocks";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { toast } from "@/lib/toast";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
@ -46,7 +46,7 @@ export async function makeAnthropicMessagesRequest(
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const headers = buildPlaygroundHeaders(tags, customHeaders);

View file

@ -1,5 +1,5 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
import type { OpenAIVoice } from "../components/chat_ui/chatConstants";
@ -22,7 +22,7 @@ export async function makeOpenAIAudioSpeechRequest(
if (isLocal !== true) {
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const client = new openai.OpenAI({
apiKey: accessToken,
baseURL: proxyBaseUrl,

View file

@ -1,5 +1,5 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
@ -22,7 +22,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
if (isLocal !== true) {
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const client = new openai.OpenAI({
apiKey: accessToken,

View file

@ -3,6 +3,7 @@ import { makeOpenAIEmbeddingsRequest } from "./embeddings_api";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
@ -45,7 +46,7 @@ describe("embeddings_api", () => {
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith("https://example.com/embeddings", {
expect(mockFetch).toHaveBeenCalledWith("https://gateway.example.com/embeddings", {
method: "POST",
headers: {
"Content-Type": "application/json",

View file

@ -1,5 +1,5 @@
import { toast } from "@/lib/toast";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import {
buildPlaygroundHeaders,
type CustomHeaders,
@ -25,7 +25,7 @@ export async function makeOpenAIEmbeddingsRequest(
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const headers = withRequiredHeaders(buildPlaygroundHeaders(tags, customHeaders), {
"Content-Type": "application/json",
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,

View file

@ -1,5 +1,5 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
@ -19,7 +19,7 @@ export async function makeOpenAIImageEditsRequest(
if (isLocal !== true) {
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const client = new openai.OpenAI({
apiKey: accessToken,

View file

@ -1,5 +1,5 @@
import openai from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { toast } from "@/lib/toast";
@ -18,7 +18,7 @@ export async function makeOpenAIImageGenerationRequest(
if (isLocal !== true) {
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const client = new openai.OpenAI({
apiKey: accessToken,
baseURL: proxyBaseUrl,

View file

@ -3,6 +3,7 @@ import { makeInteractionsRequest } from "./interactions_api";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
@ -49,7 +50,7 @@ describe("interactions_api", () => {
await makeInteractionsRequest("hi", mockUpdateUI, "gemini-flash", "token");
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][0]).toBe("https://example.com/v1beta/interactions");
expect(mockFetch.mock.calls[0][0]).toBe("https://gateway.example.com/v1beta/interactions");
expect(mockUpdateUI).toHaveBeenCalledTimes(2);
expect(mockUpdateUI).toHaveBeenNthCalledWith(1, "Hello", "gemini-3-flash");
expect(mockUpdateUI).toHaveBeenNthCalledWith(2, " world", "gemini-3-flash");
@ -70,7 +71,7 @@ describe("interactions_api", () => {
await makeInteractionsRequest("hi", mockUpdateUI, "gpt-mini", "token");
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][0]).toBe("https://example.com/v1beta/interactions");
expect(mockFetch.mock.calls[0][0]).toBe("https://gateway.example.com/v1beta/interactions");
expect(mockUpdateUI).toHaveBeenCalledTimes(1);
expect(mockUpdateUI).toHaveBeenCalledWith("Hi", "gpt-5.4");
});

View file

@ -1,5 +1,5 @@
import { toast } from "@/lib/toast";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import {
buildPlaygroundHeaders,
type CustomHeaders,
@ -26,7 +26,7 @@ export async function makeInteractionsRequest(
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
const requestUrl = `${normalizedBaseUrl}/v1beta/interactions`;

View file

@ -6,6 +6,7 @@ import type { SystemOneRequest, SystemOneResponse } from "../components/systemOn
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://proxy.example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
@ -84,7 +85,7 @@ describe("makeSystemOneRequest", () => {
it("uses the proxy base URL when no custom base is provided", async () => {
await makeSystemOneRequest(payload, "session-key");
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://proxy.example.com/v1/systemone");
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://gateway.example.com/v1/systemone");
});
it("throws the upstream error text for failed requests", async () => {
@ -169,7 +170,7 @@ describe("/v1/decisions requests", () => {
headers: expect.objectContaining({ Authorization: "Bearer session-key", "Content-Type": "application/json" }),
};
expect(mockFetch).toHaveBeenCalledWith(
`${baseUrl?.replace(/\/$/, "") ?? "https://proxy.example.com"}/v1/decisions`,
`${baseUrl?.replace(/\/$/, "") ?? "https://gateway.example.com"}/v1/decisions`,
expect.objectContaining(expectedRequest),
);
expect(result.response).toEqual(response);

View file

@ -1,5 +1,5 @@
import { z } from "zod";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { withRequiredHeaders } from "@/components/llm_calls/request_headers";
import { createApiClient } from "@/lib/http/client";
import { isDecisionMode } from "@/lib/decisionModels";
@ -29,8 +29,8 @@ const modelGroupInfoSchema = z.object({
data: z.array(z.object({ model_group: z.string(), mode: z.string().nullish() })),
});
function proxyClient(customBaseUrl?: string) {
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
function proxyClient(customBaseUrl?: string, getDefaultBaseUrl: () => string = getProxyBaseUrl) {
const proxyBaseUrl = customBaseUrl || getDefaultBaseUrl();
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
return createApiClient({ getBaseUrl: () => normalizedBaseUrl });
}
@ -68,7 +68,7 @@ async function sendDecisionRequest<T>(
signal?: AbortSignal,
): Promise<DecisionResult<T>> {
const startedAt = performance.now();
const body = await proxyClient(customBaseUrl).post<unknown>(request.endpoint, {
const body = await proxyClient(customBaseUrl, getGatewayBaseUrl).post<unknown>(request.endpoint, {
body: request.payload,
headers: authHeaders(accessToken),
signal,

View file

@ -22,6 +22,7 @@ vi.mock("@/components/networking", () => ({
invitationCreateCall: vi.fn().mockResolvedValue({}),
userUpdateUserCall: vi.fn().mockResolvedValue({}),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
getUiPageUrl: (url: string) => url,
getProxyUISettings: vi.fn().mockResolvedValue({
PROXY_BASE_URL: null,
PROXY_LOGOUT_URL: null,

View file

@ -62,6 +62,8 @@ vi.mock("@/components/networking", async (importOriginal) => {
const original = await importOriginal<typeof import("@/components/networking")>();
return {
formatDate: original.formatDate,
getRuntimeBackendBaseUrl: original.getRuntimeBackendBaseUrl,
getUiPageUrl: original.getUiPageUrl,
serverRootPath: "/",
userGetInfoV2: (...args: unknown[]) => mockUserGetInfoV2(...args),
dailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),

View file

@ -61,6 +61,7 @@ vi.mock("@/components/networking", () => {
teamMemberAddCall: (...args: any[]) => mockTeamMemberAddCall(...args),
teamMemberDeleteCall: (...args: any[]) => mockTeamMemberDeleteCall(...args),
getProxyBaseUrl: () => "https://litellm.test",
getUiPageUrl: (url: string) => url,
fetchMCPServers: (...args: unknown[]) => mockFetchMCPServers(...args),
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
listMCPTools: (...args: unknown[]) => mockListMCPTools(...args),

View file

@ -3,7 +3,13 @@
import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin";
import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { exchangeLoginCode, getProxyBaseUrl, switchToWorkerUrl } from "@/components/networking";
import {
exchangeLoginCode,
getProxyBaseUrl,
getRuntimeBackendBaseUrl,
getUiPageUrl,
switchToWorkerUrl,
} from "@/components/networking";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
@ -118,9 +124,9 @@ function LoginPageContent() {
const rawToken = getCookieFromDocument("token");
if (rawToken && !isJwtExpired(rawToken)) {
// User already logged in - redirect to return URL or default
const returnUrl = consumeReturnUrl();
const returnUrl = consumeReturnUrl(getRuntimeBackendBaseUrl());
if (returnUrl) {
router.replace(returnUrl);
router.replace(getUiPageUrl(returnUrl, true));
} else {
router.replace("/ui");
}
@ -159,11 +165,11 @@ function LoginPageContent() {
router.push("/ui/?login=success");
} else {
// Normal (non-control-plane) login — follow the server's redirect
const returnUrl = consumeReturnUrl();
const returnUrl = consumeReturnUrl(getRuntimeBackendBaseUrl());
if (returnUrl) {
router.push(returnUrl);
router.push(getUiPageUrl(returnUrl, true));
} else {
router.push(data.redirect_url);
router.push(getUiPageUrl(data.redirect_url, true));
}
}
},

View file

@ -25,6 +25,7 @@ vi.mock("@/app/(dashboard)/hooks/onboarding/useOnboarding", () => ({
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getUiPageUrl: (url: string) => url,
}));
vi.mock("./OnboardingLoadingView", () => ({

View file

@ -3,7 +3,7 @@ import React from "react";
import { useSearchParams } from "next/navigation";
import { jwtDecode } from "jwt-decode";
import { useOnboardingCredentials, useClaimOnboardingToken } from "@/app/(dashboard)/hooks/onboarding/useOnboarding";
import { getProxyBaseUrl } from "@/components/networking";
import { getProxyBaseUrl, getUiPageUrl } from "@/components/networking";
import { clearTokenCookies, storeLoginToken } from "@/utils/cookieUtils";
import { OnboardingLoadingView } from "./OnboardingLoadingView";
import { OnboardingErrorView } from "./OnboardingErrorView";
@ -51,7 +51,9 @@ export function OnboardingForm({ variant }: OnboardingFormProps) {
clearTokenCookies();
storeLoginToken(data.token);
const proxyBaseUrl = getProxyBaseUrl();
window.location.href = proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success";
window.location.href = getUiPageUrl(
proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success",
);
},
onError: (error: Error) => {
setClaimError(error.message || "Failed to submit. Please try again.");

View file

@ -18,7 +18,15 @@ vi.mock("@/components/networking", () => ({
modelHubCall: vi.fn(),
getConfigFieldSetting: vi.fn(),
getGlobalLitellmHeaderName: vi.fn(() => "X-Litellm-Key"),
getPublicModelHubInfo: vi.fn().mockResolvedValue({
docs_title: "Docs",
custom_docs_description: null,
litellm_version: "1.0.0",
useful_links: {},
}),
getProxyBaseUrl: vi.fn(() => "http://localhost:4000"),
getGatewayBaseUrl: vi.fn(() => "http://gateway.local:5000"),
getUiPageUrl: (url: string) => url,
getAgentsList: vi.fn(),
fetchMCPServers: vi.fn(),
makeMCPPublicCall: vi.fn(),
@ -292,7 +300,7 @@ describe("ModelHubTable", () => {
it("shows a decision model how to call it on /v1/systemone, with the docs and the playground", async () => {
const dialog = await openDetails({ model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" });
expect(dialog).toHaveTextContent('"http://localhost:4000/v1/systemone"');
expect(dialog).toHaveTextContent('"http://gateway.local:5000/v1/systemone"');
expect(dialog).toHaveTextContent('headers={"X-Litellm-Key": "Bearer your_api_key"}');
expect(dialog).not.toHaveTextContent("chat.completions");
expect(screen.getByRole("link", { name: "How to call decision models" })).toHaveAttribute(
@ -311,7 +319,7 @@ describe("ModelHubTable", () => {
false,
);
expect(dialog).toHaveTextContent('"http://localhost:4000/v1/systemone"');
expect(dialog).toHaveTextContent('"http://gateway.local:5000/v1/systemone"');
expect(screen.getByRole("link", { name: "How to call decision models" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Try it in the Playground" })).not.toBeInTheDocument();
});
@ -320,6 +328,7 @@ describe("ModelHubTable", () => {
const dialog = await openDetails({ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" });
expect(dialog).toHaveTextContent("client.chat.completions.create(");
expect(dialog).toHaveTextContent('"http://gateway.local:5000"');
expect(screen.queryByRole("link", { name: "How to call decision models" })).not.toBeInTheDocument();
});
});
@ -584,6 +593,36 @@ describe("ModelHubTable", () => {
return { user, search: await screen.findByPlaceholderText("Search model names...") };
};
it("uses the backend URL for MCP server connection examples", async () => {
const server: MCPServerData = {
server_id: "server-1",
server_name: "exa_test",
url: "https://upstream.example.com/mcp",
transport: "http",
auth_type: "none",
created_at: "2024-01-01T00:00:00Z",
created_by: "test-user",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "test-user",
teams: [],
mcp_access_groups: [],
allowed_tools: [],
extra_headers: [],
mcp_info: {},
static_headers: {},
status: "active",
args: [],
env: {},
};
const { user } = await renderHub([], Promise.resolve([server]));
await user.click(screen.getByRole("tab", { name: "MCP Hub" }));
await user.click(await screen.findByText("exa_test"));
expect(await screen.findByText(/http:\/\/localhost:4000\/exa_test\/mcp/)).toBeInTheDocument();
expect(screen.queryByText(/http:\/\/gateway\.local:5000\/exa_test\/mcp/)).not.toBeInTheDocument();
});
it("requires a fresh MCP publication list before and after saving", async () => {
const servers = Promise.withResolvers<MCPServerData[]>();
const { user } = await renderHub([], servers.promise);

View file

@ -14,10 +14,12 @@ import { DataTable } from "@/components/shared/DataTable";
import ModelFilters from "@/components/model_filters";
import {
fetchMCPServers,
getGatewayBaseUrl,
getAgentsList,
getConfigFieldSetting,
getGlobalLitellmHeaderName,
getProxyBaseUrl,
getUiPageUrl,
getUiConfig,
modelHubCall,
modelHubPublicModelsCall,
@ -151,7 +153,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
// If token is invalid, redirect to login
if (!isTokenValid) {
window.location.replace(getLoginUrl(getProxyBaseUrl()));
window.location.replace(getUiPageUrl(getLoginUrl(getProxyBaseUrl())));
return;
}
}
@ -457,9 +459,9 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<div className="flex items-center space-x-4">
<p>Model Hub URL:</p>
<div className="flex items-center bg-border px-2 py-1 rounded-sm">
<p className="mr-2">{`${getProxyBaseUrl()}/ui/model_hub_table`}</p>
<p className="mr-2">{getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`)}</p>
<button
onClick={() => void copyToClipboard(`${getProxyBaseUrl()}/ui/model_hub_table`)}
onClick={() => void copyToClipboard(getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`))}
className="p-1 hover:bg-accent rounded-sm transition-colors"
title="Copy URL"
>
@ -829,7 +831,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
<SyntaxHighlighter language="python" className="text-sm" style={syntaxTheme}>
{modelUsageExample(
selectedModel.mode,
getProxyBaseUrl(),
getGatewayBaseUrl(),
selectedModel.model_group,
getGlobalLitellmHeaderName(),
)}

View file

@ -9,6 +9,7 @@ vi.mock("@/components/networking", () => ({
getPublicModelHubInfo: vi.fn(),
updateUsefulLinksCall: vi.fn(),
getProxyBaseUrl: vi.fn(),
getUiPageUrl: (url: string) => url,
}));
const mockedGetPublicModelHubInfo = vi.mocked(getPublicModelHubInfo);

View file

@ -6,7 +6,7 @@ import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { getProxyBaseUrl, getPublicModelHubInfo, updateUsefulLinksCall } from "../networking";
import { getProxyBaseUrl, getPublicModelHubInfo, getUiPageUrl, updateUsefulLinksCall } from "../networking";
interface UsefulLinksManagementProps {
accessToken: string | null;
@ -292,7 +292,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<p className="text-sm font-medium text-foreground">Manage Existing Links</p>
<div className="flex items-center space-x-2">
<Link
href={`${getProxyBaseUrl()}/ui/model_hub_table`}
href={getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`)}
target="_blank"
rel="noopener noreferrer"
className="text-xs bg-info/10 text-info px-3 py-1.5 rounded-sm hover:bg-info/15 flex items-center"

View file

@ -19,6 +19,8 @@ vi.mock("./networking", () => ({
SSO_ENABLED: false,
}),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost"),
getGlobalLitellmHeaderName: vi.fn(() => "X-Litellm-Key"),
getUiPageUrl: (url: string) => url,
}));
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({

View file

@ -11,7 +11,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Download, FileText, FileWarning, Trash2, TriangleAlert, Upload } from "lucide-react";
import { userCreateCall, invitationCreateCall, getProxyUISettings } from "./networking";
import { userCreateCall, invitationCreateCall, getProxyUISettings, getUiPageUrl } from "./networking";
import Papa from "papaparse";
import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline";
import { CopyToClipboard } from "react-copy-to-clipboard";
@ -376,7 +376,9 @@ const BulkCreateUsersButton: React.FC<BulkCreateUsersProps> = ({
if (!uiSettings?.SSO_ENABLED) {
// Regular invitation flow
const invitationData = await invitationCreateCall(accessToken, user_id);
const invitationUrl = new URL(`/ui/onboarding?invitation_id=${invitationData.id}`, baseUrl).toString();
const invitationUrl = getUiPageUrl(
new URL(`/ui/onboarding?invitation_id=${invitationData.id}`, baseUrl).toString(),
);
setParsedData((current) =>
current.map((u, i) =>

View file

@ -132,8 +132,9 @@ function gateway(replies: (ModelReply | Promise<ModelReply>)[], options: Gateway
const request = input instanceof Request ? input : new Request(new URL(String(input), "http://localhost"), init);
const body: Record<string, unknown> = request.method === "GET" ? {} : await request.clone().json();
const recorded = { url: request.url, headers: request.headers, body, signal: request.signal };
requests.push(recorded);
const path = new URL(request.url).pathname;
if (path.endsWith("/ui/litellm-runtime-config.json")) return json({});
requests.push(recorded);
if (path.endsWith("/litellm-ui-config"))
return json({ proxy_base_url: MANAGEMENT, server_root_path: "", admin_ui_disabled: false });
if (path.endsWith("/health/readiness/details")) return json({ status: "healthy" });

View file

@ -4,7 +4,7 @@ import { makeOpenAIChatCompletionRequest } from "./chat_completion";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
}));
// Mock the OpenAI client
@ -73,6 +73,7 @@ describe("chat_completion", () => {
it("should make a basic chat completion request", async () => {
await makeOpenAIChatCompletionRequest(mockChatHistory, mockUpdateUI, "gpt-4", "test-token");
expect(openai.OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://gateway.example.com" }));
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate).toHaveBeenCalledWith(
{

View file

@ -2,11 +2,13 @@ import { afterEach, expect, it, vi } from "vitest";
import { registerAuthHeaderNameGetter } from "@/lib/http/runtime";
import { createGatewayClient } from "./gateway_client";
vi.mock("@/components/networking", () => ({ getProxyBaseUrl: () => "https://management.example/root" }));
vi.mock("@/components/networking", () => ({
getGatewayBaseUrl: () => "https://gateway.example/root",
}));
afterEach(() => registerAuthHeaderNameGetter(() => "Authorization"));
it.each(["Authorization", "x-session-key"])(
"uses the gateway's %s header and retains root paths and caller headers",
"uses the gateway host and %s header while retaining root paths and caller headers",
async (header) => {
registerAuthHeaderNameGetter(() => header);
const fetchImpl = vi.fn(
@ -20,7 +22,7 @@ it.each(["Authorization", "x-session-key"])(
});
await client.models.list();
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe("https://management.example/root/models");
expect(url).toBe("https://gateway.example/root/models");
const headers = new Headers(init?.headers);
expect(headers.get(header)).toBe("Bearer session");
expect(headers.get("x-litellm-tags")).toBe("playground");

View file

@ -1,5 +1,5 @@
import OpenAI, { type ClientOptions } from "openai";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl } from "@/components/networking";
import { getAuthHeaderName } from "@/lib/http/runtime";
type GatewayClientOptions = Pick<ClientOptions, "baseURL" | "defaultHeaders" | "fetch" | "maxRetries" | "timeout"> & {
@ -11,7 +11,7 @@ export function createGatewayClient({ accessToken, ...options }: GatewayClientOp
const config: ClientOptions = {
...options,
apiKey: accessToken,
baseURL: options.baseURL || getProxyBaseUrl(),
baseURL: options.baseURL || getGatewayBaseUrl(),
dangerouslyAllowBrowser: true,
defaultHeaders: {
Authorization: null,

View file

@ -5,7 +5,8 @@ import { MessageType } from "../chat_ui/types";
import type { TokenUsage } from "../chat_ui/ResponseMetrics";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://example.com"),
getProxyBaseUrl: vi.fn(() => "https://backend.example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
}));
const mockResponsesCreate = vi.fn();
@ -59,6 +60,7 @@ describe("responses_api", () => {
it("should send a basic responses request", async () => {
await makeOpenAIResponsesRequest(messages, mockUpdateTextUI, "gpt-4", "test-token");
expect(openai.OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://gateway.example.com" }));
expect(mockResponsesCreate).toHaveBeenCalledTimes(1);
expect(mockResponsesCreate).toHaveBeenCalledWith(
expect.objectContaining({
@ -399,18 +401,19 @@ describe("responses_api", () => {
const callArgs = mockResponsesCreate.mock.calls[0][0];
expect(callArgs.tool_choice).toBe("auto");
expect(openai.OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://gateway.example.com" }));
expect(callArgs.tools).toEqual([
{
type: "mcp",
server_label: "Alpha",
server_url: "https://example.com/mcp/Alpha",
server_url: "https://backend.example.com/mcp/Alpha",
require_approval: "never",
allowed_tools: ["toolA"],
},
{
type: "mcp",
server_label: "Beta",
server_url: "https://example.com/mcp/Beta",
server_url: "https://backend.example.com/mcp/Beta",
require_approval: "never",
allowed_tools: ["toolB", "toolC"],
},

View file

@ -1,7 +1,7 @@
import openai from "openai";
import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getProxyBaseUrl } from "@/components/networking";
import { getGatewayBaseUrl, getProxyBaseUrl } from "@/components/networking";
import { toast } from "@/lib/toast";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
import { parseUsageCost } from "./usage_cost";
@ -102,7 +102,8 @@ export async function makeOpenAIResponsesRequest(
console.log = function () {};
}
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl();
const mcpBaseUrl = customBaseUrl || getProxyBaseUrl();
const headers = buildPlaygroundHeaders(tags, customHeaders);
const client = new openai.OpenAI({
@ -145,7 +146,7 @@ export async function makeOpenAIResponsesRequest(
tools.push({
type: "mcp",
server_label: "litellm",
server_url: `${proxyBaseUrl}/mcp`,
server_url: `${mcpBaseUrl}/mcp`,
require_approval: "never",
});
} else {
@ -159,7 +160,7 @@ export async function makeOpenAIResponsesRequest(
tools.push({
type: "mcp",
server_label: toolsetName,
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(toolsetName)}`,
server_url: `${mcpBaseUrl}/mcp/${encodeURIComponent(toolsetName)}`,
require_approval: "never",
});
} else {
@ -172,7 +173,7 @@ export async function makeOpenAIResponsesRequest(
tools.push({
type: "mcp",
server_label: routeName, // unique per request — collisions cause silent tool-routing failures
server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
server_url: `${mcpBaseUrl}/mcp/${encodeURIComponent(routeName)}`,
require_approval: "never",
...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}),
});

View file

@ -11,6 +11,7 @@ vi.mock("@/components/networking", () => ({
PROXY_BASE_URL: "",
PROXY_LOGOUT_URL: "https://example.com/logout",
}),
getUiPageUrl: (url: string) => url,
serverRootPath: "",
}));

View file

@ -201,11 +201,13 @@ describe("UI config and public endpoints", () => {
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue(response.data),
text: vi.fn().mockResolvedValue(JSON.stringify(response.data)),
} as any);
}
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue("{}"),
} as any);
});
global.fetch = mockFetch as any;
@ -237,7 +239,7 @@ describe("UI config and public endpoints", () => {
// Then call the public endpoint
await Networking.getProviderCreateMetadata();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(3);
const publicEndpointCall = mockFetch.mock.calls.find((call) =>
(call[0] as string).includes("/public/providers/fields"),
);
@ -260,7 +262,7 @@ describe("UI config and public endpoints", () => {
await Networking.getUiConfig();
await Networking.getPublicModelHubInfo();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(3);
const publicEndpointCall = mockFetch.mock.calls.find((call) =>
(call[0] as string).includes("/public/model_hub/info"),
);
@ -283,7 +285,7 @@ describe("UI config and public endpoints", () => {
await Networking.getUiConfig();
await Networking.modelHubPublicModelsCall();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(3);
const publicEndpointCall = mockFetch.mock.calls.find(
(call) => (call[0] as string).includes("/public/model_hub") && !(call[0] as string).includes("/info"),
);
@ -306,7 +308,7 @@ describe("UI config and public endpoints", () => {
await Networking.getUiConfig();
await Networking.agentHubPublicModelsCall();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(3);
const publicEndpointCall = mockFetch.mock.calls.find((call) => (call[0] as string).includes("/public/agent_hub"));
expect(publicEndpointCall).toBeDefined();
const calledUrl = publicEndpointCall![0] as string;
@ -327,7 +329,7 @@ describe("UI config and public endpoints", () => {
await Networking.getUiConfig();
await Networking.mcpHubPublicServersCall();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(3);
const publicEndpointCall = mockFetch.mock.calls.find((call) => (call[0] as string).includes("/public/mcp_hub"));
expect(publicEndpointCall).toBeDefined();
const calledUrl = publicEndpointCall![0] as string;
@ -348,7 +350,7 @@ describe("UI config and public endpoints", () => {
await Networking.getUiConfig();
await Networking.getProviderCreateMetadata();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledTimes(3);
const publicEndpointCall = mockFetch.mock.calls.find((call) =>
(call[0] as string).includes("/public/providers/fields"),
);
@ -367,7 +369,7 @@ describe("UI config and public endpoints", () => {
const result = await Networking.getUiConfig();
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result).toEqual(uiConfig);
const configCall = mockFetch.mock.calls.find((call) =>
(call[0] as string).includes("/litellm/.well-known/litellm-ui-config"),
@ -388,6 +390,41 @@ describe("UI config and public endpoints", () => {
expect(Networking.serverRootPath).toBe("/litellm");
expect(uiHref("api-reference")).toBe("/litellm/ui/api-reference");
});
it("sends model group connection probes to the configured gateway", async () => {
const uiConfig = {
server_root_path: "",
proxy_base_url: "https://backend.example.com",
gateway_base_url: "https://gateway.example.com",
};
const mockFetch = setupMockFetch([{ url: "/litellm/.well-known/litellm-ui-config", data: uiConfig }]);
await Networking.getUiConfig();
expect(await Networking.testModelGroupConnection("token", "chat-model", "chat")).toEqual({ status: "success" });
expect(await Networking.testModelGroupConnection("token", "embedding-model", "embedding")).toEqual({
status: "success",
});
const requestedUrls = mockFetch.mock.calls.map(([url]) => String(url));
expect(requestedUrls).toContain("https://gateway.example.com/v1/chat/completions");
expect(requestedUrls).toContain("https://gateway.example.com/v1/embeddings");
});
it("falls back to the proxy URL for model group probes when the gateway is unset", async () => {
const uiConfig = {
server_root_path: "",
proxy_base_url: "https://backend.example.com",
};
const mockFetch = setupMockFetch([{ url: "/litellm/.well-known/litellm-ui-config", data: uiConfig }]);
await Networking.getUiConfig();
expect(await Networking.testModelGroupConnection("token", "embedding-model", "embedding")).toEqual({
status: "success",
});
const requestedUrls = mockFetch.mock.calls.map(([url]) => String(url));
expect(requestedUrls).toContain("https://backend.example.com/v1/embeddings");
});
});
describe("individualModelHealthCheckCall", () => {
@ -937,3 +974,183 @@ describe("modelCostMap", () => {
expect(mockFetch.mock.calls[1][0]).toMatch(/\/public\/litellm_model_cost_map$/);
});
});
describe("getUiConfig runtime backend discovery", () => {
const discoveryConfig = () => ({
server_root_path: "/",
proxy_base_url: null,
gateway_base_url: null,
auto_redirect_to_sso: false,
admin_ui_disabled: false,
sso_configured: false,
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.resetModules();
});
const loadFreshNetworking = async () => {
const storage = new Map<string, string>();
vi.stubGlobal("window", {
location: { origin: "https://ui.example.com" },
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
},
});
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
vi.stubEnv("NEXT_PUBLIC_USE_REWRITES", "true");
vi.resetModules();
return import("./networking");
};
const loadNetworkingWithGateway = async (gatewayBaseUrl: string | null) => {
const backendBaseUrl = "https://backend.example.com";
const mockFetch = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const url = input instanceof Request ? input.url : String(input);
if (url === "/ui/litellm-runtime-config.json") {
return new Response(JSON.stringify({ backend_url: backendBaseUrl }));
}
if (url === `${backendBaseUrl}/litellm/.well-known/litellm-ui-config`) {
return new Response(
JSON.stringify({
...discoveryConfig(),
proxy_base_url: backendBaseUrl,
gateway_base_url: gatewayBaseUrl,
}),
);
}
if (url.endsWith("/v1/indexes")) {
return new Response(JSON.stringify({ object: "list", data: [] }));
}
if (url.endsWith("/v1/a2a/discover")) {
return new Response(JSON.stringify({ agent_card: {}, url: "https://agent.example.com" }));
}
return new Response("{}");
});
vi.stubGlobal("fetch", mockFetch);
const networking = await loadFreshNetworking();
await networking.getUiConfig();
return { networking, mockFetch };
};
it("uses a normalized runtime backend URL for discovery and the management fallback", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ backend_url: " https://backend.example.com/// " }),
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
...discoveryConfig(),
gateway_base_url: " https://gateway.example.com/// ",
}),
} as Response);
vi.stubGlobal("fetch", mockFetch);
const networking = await loadFreshNetworking();
await networking.getUiConfig();
expect(mockFetch).toHaveBeenNthCalledWith(1, "/ui/litellm-runtime-config.json", { cache: "no-store" });
expect(mockFetch.mock.calls[1][0]).toBe("https://backend.example.com/litellm/.well-known/litellm-ui-config");
expect(networking.getProxyBaseUrl().replace(/\/+$/, "")).toBe("https://backend.example.com");
expect(networking.getGatewayBaseUrl()).toBe("https://gateway.example.com");
networking.switchToWorkerUrl("https://worker.example.com");
expect(networking.getGatewayBaseUrl()).toBe("https://worker.example.com");
networking.switchToWorkerUrl(null);
expect(networking.getGatewayBaseUrl()).toBe("https://gateway.example.com");
});
it.each([
["404", { ok: false }],
[
"HTML 200",
{
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token '<'");
},
},
],
])("keeps discovery behavior when runtime config returns %s", async (_label, runtimeResponse) => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(runtimeResponse as Response)
.mockResolvedValueOnce({ ok: true, json: async () => discoveryConfig() } as Response);
vi.stubGlobal("fetch", mockFetch);
const networking = await loadFreshNetworking();
await networking.getUiConfig();
expect(mockFetch).toHaveBeenNthCalledWith(1, "/ui/litellm-runtime-config.json", { cache: "no-store" });
expect(mockFetch.mock.calls[1][0]).toBe("/litellm/.well-known/litellm-ui-config");
expect(networking.getGatewayBaseUrl()).toBe(networking.getProxyBaseUrl());
});
it("fetches model availability from the gateway and falls back to the backend when unset", async () => {
const { networking, mockFetch } = await loadNetworkingWithGateway("https://gateway.example.com");
await networking.modelAvailableCall("token", "user-1", "Admin");
const gatewayRequestUrl = new URL(String(mockFetch.mock.calls.at(-1)?.[0]));
expect(gatewayRequestUrl.origin).toBe("https://gateway.example.com");
expect(gatewayRequestUrl.pathname).toBe("/models");
const fallback = await loadNetworkingWithGateway(null);
await fallback.networking.modelAvailableCall("token", "user-1", "Admin");
const fallbackRequestUrl = new URL(String(fallback.mockFetch.mock.calls.at(-1)?.[0]));
expect(fallbackRequestUrl.origin).toBe("https://backend.example.com");
expect(fallbackRequestUrl.pathname).toBe("/models");
});
it("posts decision tests to the gateway", async () => {
const { networking, mockFetch } = await loadNetworkingWithGateway("https://gateway.example.com");
await networking.decisionsTestCall("token", {
model: "test-model",
input: "test input",
questions: [{ type: "predicate", name: "valid", instructions: "The input is valid" }],
});
const requestUrl = new URL(String(mockFetch.mock.calls.at(-1)?.[0]));
expect(requestUrl.origin).toBe("https://gateway.example.com");
expect(requestUrl.pathname).toBe("/v1/decisions");
});
it("ingests RAG documents through the gateway", async () => {
const { networking, mockFetch } = await loadNetworkingWithGateway("https://gateway.example.com");
await networking.ragIngestCall("token", new File(["document"], "document.txt"), "openai");
const requestUrl = new URL(String(mockFetch.mock.calls.at(-1)?.[0]));
expect(requestUrl.origin).toBe("https://gateway.example.com");
expect(requestUrl.pathname).toBe("/rag/ingest");
});
it("lists indexes from the gateway", async () => {
const { networking, mockFetch } = await loadNetworkingWithGateway("https://gateway.example.com");
await networking.indexesListCall("token");
expect(String(mockFetch.mock.calls.at(-1)?.[0])).toBe("https://gateway.example.com/v1/indexes");
});
it("discovers agent cards through the gateway with the default discovery mode", async () => {
const { networking, mockFetch } = await loadNetworkingWithGateway("https://gateway.example.com");
await networking.discoverAgentCardCall("token", "https://agent.example.com");
const [requestUrl, requestInit] = mockFetch.mock.calls.at(-1) ?? [];
expect(requestUrl instanceof Request ? requestUrl.url : String(requestUrl)).toBe(
"https://gateway.example.com/v1/a2a/discover",
);
expect(new Headers(requestInit?.headers).get(networking.getGlobalLitellmHeaderName())).toBe("Bearer token");
expect(JSON.parse(String(requestInit?.body))).toEqual({
url: "https://agent.example.com",
discovery_mode: "well_known_fallback",
});
});
});

View file

@ -89,6 +89,7 @@ export const getAutoRouterAssembledPromptCall = async (
import { toast } from "@/lib/toast";
import { clearTokenCookies, getCookie, storeLoginToken } from "@/utils/cookieUtils";
import { decodeToken } from "@/utils/jwtUtils";
import { normalizeBackendPageUrlToUiOrigin } from "@/utils/returnUrlUtils";
import { TagNewRequest, TagUpdateRequest, TagListResponse, TagInfoResponse } from "./tag_management/types";
import { Team } from "./key_team_helpers/key_list";
import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./email_events/types";
@ -189,6 +190,9 @@ const _initialWorkerUrl = (() => {
return null;
})();
export let proxyBaseUrl: string | null = _initialWorkerUrl ?? defaultProxyBaseUrl;
let discoveredProxyBaseUrl: string | null = defaultProxyBaseUrl;
let gatewayBaseUrl: string | null = null;
let runtimeBackendBaseUrl: string | null = null;
if (isLocal != true) {
console.log = function () {};
}
@ -204,14 +208,13 @@ const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string
/**
* Special function for updating the proxy base url. Should only be called by getUiConfig.
*/
// If a worker URL is in localStorage, don't let getUiConfig overwrite it
if (typeof window !== "undefined" && window.localStorage.getItem(WORKER_URL_KEY)) {
return;
}
proxyBaseUrl = resolveApiBase({
const resolvedProxyBaseUrl = resolveApiBase({
explicitBase: receivedProxyBaseUrl || resolveDefaultBase(getWindowLocation()?.origin ?? null),
serverRootPath,
});
discoveredProxyBaseUrl = resolvedProxyBaseUrl || null;
if (typeof window !== "undefined" && window.localStorage.getItem(WORKER_URL_KEY)) return;
proxyBaseUrl = resolvedProxyBaseUrl;
};
const updateServerRootPath = (receivedServerRootPath: string) => {
@ -226,6 +229,26 @@ export const getProxyBaseUrl = (): string => {
return browserLocation?.origin ?? "";
};
export const getRuntimeBackendBaseUrl = (): string | null => runtimeBackendBaseUrl;
export const getUiPageUrl = (url: string, relativePath = false): string => {
const browserLocation = getWindowLocation();
return normalizeBackendPageUrlToUiOrigin(url, runtimeBackendBaseUrl, browserLocation?.origin ?? null, relativePath);
};
const normalizeHttpUrl = (url: string | null | undefined): string | null => {
const trimmedUrl = (url ?? "").trim();
if (!trimmedUrl || !isValidHttpUrl(trimmedUrl)) return null;
return trimmedUrl.replace(/\/+$/, "");
};
export const getGatewayBaseUrl = (): string => {
if (typeof window !== "undefined" && window.localStorage.getItem(WORKER_URL_KEY)) {
return getProxyBaseUrl();
}
return gatewayBaseUrl ?? getProxyBaseUrl();
};
/**
* Switch API calls to point at a worker (or back to the control plane).
* Persists to localStorage so it survives page navigation. Also updates the
@ -252,7 +275,7 @@ export function switchToWorkerUrl(workerUrl: string | null): void {
window.localStorage.removeItem(WORKER_URL_KEY);
}
}
proxyBaseUrl = workerUrl ?? defaultProxyBaseUrl;
proxyBaseUrl = workerUrl ?? discoveredProxyBaseUrl;
}
const HTTP_REQUEST = {
@ -392,6 +415,7 @@ export interface WorkerInfo {
export interface LiteLLMWellKnownUiConfig {
server_root_path: string;
proxy_base_url: string | null;
gateway_base_url?: string | null;
auto_redirect_to_sso: boolean;
admin_ui_disabled: boolean;
sso_configured: boolean;
@ -509,6 +533,12 @@ export const apiClient = createApiClient({
onError: handleError,
});
export const gatewayApiClient = createApiClient({
getBaseUrl: getGatewayBaseUrl,
getAuthHeaderName: getGlobalLitellmHeaderName,
onError: handleError,
});
registerBaseUrlGetter(getProxyBaseUrl);
registerAuthHeaderNameGetter(getGlobalLitellmHeaderName);
registerAuthTokenGetter(() => decodeToken(getCookie("token"))?.key ?? null);
@ -531,8 +561,31 @@ export const makeModelGroupPublic = async (accessToken: string, modelGroups: str
export const getUiConfig = async () => {
/**Special route to get the proxy base url and server root path */
const url = defaultProxyBaseUrl
? `${defaultProxyBaseUrl}/litellm/.well-known/litellm-ui-config`
const runtimeBackendUrl = defaultProxyBaseUrl
? null
: await (async (): Promise<string | null> => {
try {
// eslint-disable-next-line no-restricted-syntax -- read the UI static config before the API origin is known
const response = await fetch("/ui/litellm-runtime-config.json", { cache: "no-store" });
if (!response.ok) return null;
const runtimeConfig: unknown = await response.json();
if (
typeof runtimeConfig !== "object" ||
runtimeConfig === null ||
!("backend_url" in runtimeConfig) ||
typeof runtimeConfig.backend_url !== "string"
) {
return null;
}
return normalizeHttpUrl(runtimeConfig.backend_url);
} catch {
return null;
}
})();
runtimeBackendBaseUrl = runtimeBackendUrl;
const discoveryBaseUrl = defaultProxyBaseUrl ?? runtimeBackendUrl;
const url = discoveryBaseUrl
? `${discoveryBaseUrl}/litellm/.well-known/litellm-ui-config`
: `/litellm/.well-known/litellm-ui-config`;
const response = await fetch(url);
const jsonData: LiteLLMWellKnownUiConfig = await response.json();
@ -540,7 +593,8 @@ export const getUiConfig = async () => {
* Update the proxy base url and server root path
*/
updateServerRootPath(jsonData.server_root_path);
updateProxyBaseUrl(jsonData.server_root_path, jsonData.proxy_base_url);
updateProxyBaseUrl(jsonData.server_root_path, jsonData.proxy_base_url || runtimeBackendUrl);
gatewayBaseUrl = normalizeHttpUrl(jsonData.gateway_base_url);
return jsonData;
};
@ -1792,7 +1846,7 @@ export const modelAvailableCall = async (
* Get all the models user has access to
*/
try {
return await apiClient.get(`/models`, {
return await gatewayApiClient.get(`/models`, {
accessToken,
query: {
include_model_access_groups: "True",
@ -2282,7 +2336,7 @@ export const testModelGroupConnection = async (
): Promise<ModelGroupConnectionResult> => {
const { path, body } = buildModelGroupTestRequest(modelGroup, mode, requestParams);
try {
await apiClient.post(path, { accessToken, body });
await gatewayApiClient.post(path, { accessToken, body });
return { status: "success" };
} catch (error) {
return { status: "error", error: error instanceof Error ? error.message : String(error) };
@ -3708,7 +3762,7 @@ export const testMCPSemanticFilter = async (accessToken: string, model: string,
* Returns both the response data and headers containing filter information
*/
try {
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/responses` : `/v1/responses`;
const url = `${getGatewayBaseUrl()}/v1/responses`;
const response = await fetch(url, {
method: "POST",
headers: {
@ -4676,9 +4730,14 @@ export const discoverAgentCardCall = async (
url: string,
options?: DiscoverAgentCardOptions,
): Promise<DiscoverAgentCardResponse> => {
const { data } = await fetchClient.POST("/v1/a2a/discover", {
const body: components["schemas"]["DiscoverAgentRequest"] = {
url,
...options,
discovery_mode: options?.discovery_mode ?? "well_known_fallback",
};
const data = await gatewayApiClient.post<components["schemas"]["DiscoverAgentResponse"]>("/v1/a2a/discover", {
headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` },
body: { url, ...options, discovery_mode: options?.discovery_mode ?? "well_known_fallback" },
body,
});
if (!data) throw new Error("Agent discovery response is empty");
return { ...data, agent_card: toAgentCard(data.agent_card) };
@ -5582,7 +5641,7 @@ export interface IndexesListResponse {
export const indexesListCall = async (accessToken: string): Promise<IndexesListResponse> => {
try {
return await apiClient.get<IndexesListResponse>(`/v1/indexes`, { accessToken });
return await gatewayApiClient.get<IndexesListResponse>(`/v1/indexes`, { accessToken });
} catch (error) {
console.error("Error listing indexes:", error);
throw error;
@ -5674,7 +5733,8 @@ export const ragIngestCall = async (
providerSpecificParams?: Record<string, any>,
): Promise<any> => {
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/rag/ingest` : `/rag/ingest`;
const gatewayBaseUrl = getGatewayBaseUrl();
const url = gatewayBaseUrl ? `${gatewayBaseUrl}/rag/ingest` : `/rag/ingest`;
const formData = new FormData();
formData.append("file", file);
@ -6226,7 +6286,7 @@ export const applyGuardrail = async (
};
export const decisionsTestCall = async (accessToken: string, requestBody: DecisionTestBody, signal?: AbortSignal) =>
apiClient.post(`/v1/decisions`, { accessToken, body: requestBody, signal });
gatewayApiClient.post(`/v1/decisions`, { accessToken, body: requestBody, signal });
interface TestCustomCodeGuardrailRequest {
custom_code: string;
@ -6812,7 +6872,7 @@ export const vectorStoreSearchCall = async (
query: string,
): Promise<any> => {
try {
const url = `${getProxyBaseUrl()}/v1/vector_stores/${vectorStoreId}/search`;
const url = `${getGatewayBaseUrl()}/v1/vector_stores/${vectorStoreId}/search`;
const response = await fetch(url, {
method: "POST",
headers: {
@ -6845,7 +6905,7 @@ export const searchToolQueryCall = async (
maxResults?: number,
): Promise<any> => {
try {
const url = `${getProxyBaseUrl()}/v1/search/${searchToolName}`;
const url = `${getGatewayBaseUrl()}/v1/search/${searchToolName}`;
const response = await fetch(url, {
method: "POST",
headers: {
@ -7834,7 +7894,7 @@ export const fetchMemoryList = async (
pageSize?: number;
} = {},
): Promise<MemoryListResponse> => {
const base = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`;
const base = `${getGatewayBaseUrl()}/v1/memory`;
const params = new URLSearchParams();
// Backend precedence is search > key_prefix > key; only the winner is sent
// to keep the URL clean and intent obvious.
@ -7866,7 +7926,7 @@ export const createMemory = async (
accessToken: string,
payload: { key: string; value: string; metadata?: unknown },
): Promise<MemoryRow> => {
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory` : `/v1/memory`;
const url = `${getGatewayBaseUrl()}/v1/memory`;
const body: Record<string, unknown> = {
key: payload.key,
value: payload.value,
@ -7893,7 +7953,7 @@ export const updateMemory = async (
payload: { value?: string; metadata?: unknown },
): Promise<MemoryRow> => {
const encoded = encodeMemoryKeyForPath(key);
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory/${encoded}` : `/v1/memory/${encoded}`;
const url = `${getGatewayBaseUrl()}/v1/memory/${encoded}`;
const response = await fetch(url, {
method: "PUT",
headers: {
@ -7911,7 +7971,7 @@ export const updateMemory = async (
export const deleteMemory = async (accessToken: string, key: string): Promise<void> => {
const encoded = encodeMemoryKeyForPath(key);
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/memory/${encoded}` : `/v1/memory/${encoded}`;
const url = `${getGatewayBaseUrl()}/v1/memory/${encoded}`;
const response = await fetch(url, {
method: "DELETE",
headers: {

View file

@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { copyToClipboard } from "@/utils/dataUtils";
import { getUiPageUrl } from "@/components/networking";
export interface InvitationLink {
id: string;
@ -62,12 +63,14 @@ export default function OnboardingModal({
const linkFieldId = useId();
const copyButtonRef = useRef<HTMLButtonElement>(null);
const isInvitation = modalType === "invitation";
const invitationUrl = buildOnboardingUrl({
baseUrl,
invitationId: invitationLinkData?.id,
hasUserSetupSso: invitationLinkData?.has_user_setup_sso ?? false,
resetPassword: !isInvitation,
});
const invitationUrl = getUiPageUrl(
buildOnboardingUrl({
baseUrl,
invitationId: invitationLinkData?.id,
hasUserSetupSso: invitationLinkData?.has_user_setup_sso ?? false,
resetPassword: !isInvitation,
}),
);
return (
<Dialog

View file

@ -28,6 +28,8 @@ vi.mock("./networking", async (importOriginal) => {
litellm_version: "1.0.0",
useful_links: {},
}),
getProxyBaseUrl: vi.fn(() => "https://backend.example.com"),
getGatewayBaseUrl: vi.fn(() => "https://gateway.example.com"),
agentHubPublicModelsCall: vi.fn().mockResolvedValue([]),
mcpHubPublicServersCall: vi.fn().mockResolvedValue([]),
skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }),
@ -517,6 +519,29 @@ describe("public hub MCP details modal", () => {
expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument();
});
it("uses the backend URL in displayed and copied MCP examples", async () => {
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
const user = userEvent.setup();
const clipboardWriteText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
renderHub();
await user.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
await user.click(await screen.findByRole("button", { name: "exa_test" }));
await screen.findByText("Server Overview");
expect(screen.getByText(/https:\/\/backend\.example\.com\/exa_test\/mcp/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Copy to clipboard" }));
expect(clipboardWriteText).toHaveBeenCalledWith(
expect.stringContaining('"url": "https://backend.example.com/exa_test/mcp"'),
);
const copiedSnippet = clipboardWriteText.mock.calls[0]?.[0] as string;
expect(copiedSnippet).not.toContain("https://gateway.example.com");
clipboardWriteText.mockRestore();
});
it("closes the server details modal from its close control", async () => {
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);

View file

@ -47568,6 +47568,8 @@ export interface components {
admin_ui_disabled: boolean;
/** Auto Redirect To Sso */
auto_redirect_to_sso: boolean;
/** Gateway Base Url */
gateway_base_url?: string | null;
/**
* Hide Default Credentials Hint
* @default false

View file

@ -10,6 +10,7 @@ import {
getReturnUrlFromParams,
getStoredReturnUrl,
isValidReturnUrl,
normalizeBackendPageUrlToUiOrigin,
storeReturnUrl,
} from "./returnUrlUtils";
@ -124,6 +125,83 @@ describe("returnUrlUtils", () => {
});
});
describe("normalizeBackendPageUrlToUiOrigin", () => {
const backendPageUrl = "https://backend.example.com/ui?tab=keys#details";
it("moves runtime backend page URLs to the UI origin", () => {
expect(
normalizeBackendPageUrlToUiOrigin(
backendPageUrl,
"https://backend.example.com/litellm",
"https://ui.example.com",
true,
),
).toBe("/ui?tab=keys#details");
});
it("anchors protocol-relative paths to the UI origin", () => {
const normalizedUrl = normalizeBackendPageUrlToUiOrigin(
"https://backend.example.com//attacker.example/login",
"https://backend.example.com",
"https://ui.example.com",
true,
);
expect(new URL(normalizedUrl, "https://ui.example.com").origin).toBe("https://ui.example.com");
expect(normalizedUrl).toBe("https://ui.example.com//attacker.example/login");
});
it("anchors backslash-normalized protocol-relative paths to the UI origin", () => {
const normalizedUrl = normalizeBackendPageUrlToUiOrigin(
"https://backend.example.com/\\attacker.example/login",
"https://backend.example.com",
"https://ui.example.com",
true,
);
expect(new URL(normalizedUrl, "https://ui.example.com").origin).toBe("https://ui.example.com");
expect(normalizedUrl).toBe("https://ui.example.com//attacker.example/login");
});
it("returns an absolute UI-origin URL for generated links", () => {
expect(
normalizeBackendPageUrlToUiOrigin(backendPageUrl, "https://backend.example.com", "https://ui.example.com"),
).toBe("https://ui.example.com/ui?tab=keys#details");
});
it("keeps runtime backend URLs unchanged when the backend and UI share an origin", () => {
expect(
normalizeBackendPageUrlToUiOrigin(
backendPageUrl.replace("backend.example.com", "ui.example.com"),
"https://ui.example.com/litellm",
"https://ui.example.com",
true,
),
).toBe("https://ui.example.com/ui?tab=keys#details");
});
it("keeps URLs unchanged when no runtime backend URL is configured", () => {
expect(normalizeBackendPageUrlToUiOrigin(backendPageUrl, null, "https://ui.example.com", true)).toBe(
backendPageUrl,
);
});
});
describe("consumeReturnUrl", () => {
it("accepts a return URL from the configured runtime backend", () => {
const backendPageUrl = "http://backend.lit.test:8080/ui?tab=keys";
Object.defineProperty(window, "location", {
value: {
...window.location,
search: `?redirect_to=${encodeURIComponent(backendPageUrl)}`,
},
writable: true,
});
expect(consumeReturnUrl("http://backend.lit.test:8080")).toBe(backendPageUrl);
});
});
describe("buildLoginUrlWithReturn", () => {
it("should build login URL with return URL parameter", () => {
Object.defineProperty(window, "location", {

View file

@ -17,6 +17,31 @@ export function getLoginUrl(baseUrl: string = ""): string {
return `${baseUrl}/ui/login/`;
}
export function normalizeBackendPageUrlToUiOrigin(
url: string,
runtimeBackendUrl: string | null,
uiOrigin: string | null,
relativePath = false,
): string {
if (!runtimeBackendUrl || !uiOrigin) {
return url;
}
try {
const backendOrigin = new URL(runtimeBackendUrl).origin;
const currentUiOrigin = new URL(uiOrigin).origin;
const pageUrl = new URL(url);
if (backendOrigin === currentUiOrigin || pageUrl.origin !== backendOrigin) {
return url;
}
const pagePath = `${pageUrl.pathname}${pageUrl.search}${pageUrl.hash}`;
return relativePath && !pagePath.startsWith("//") ? pagePath : `${currentUiOrigin}${pagePath}`;
} catch {
return url;
}
}
/**
* Gets the current URL with all query parameters.
* Returns null if running on server-side.
@ -201,7 +226,7 @@ function isDevEnvironment(): boolean {
* @param url - The URL to validate
* @returns true if the URL is safe to redirect to
*/
export function isValidReturnUrl(url: string): boolean {
export function isValidReturnUrl(url: string, runtimeBackendUrl: string | null = null): boolean {
if (!url) {
return false;
}
@ -220,6 +245,10 @@ export function isValidReturnUrl(url: string): boolean {
const returnUrlObj = new URL(url);
const currentHostname = window.location.hostname;
if (runtimeBackendUrl && returnUrlObj.origin === new URL(runtimeBackendUrl).origin) {
return true;
}
// Hostname must always match
if (returnUrlObj.hostname !== currentHostname) {
return false;
@ -276,11 +305,11 @@ export function normalizeUrlForCompare(url: string): string {
* 2. If redirect_to param is invalid/missing, check cookie
* 3. Only clear cookie when we have a valid URL to return
*/
export function consumeReturnUrl(): string | null {
export function consumeReturnUrl(runtimeBackendUrl: string | null = null): string | null {
// Check URL param first
const paramUrl = getReturnUrlFromParams();
if (paramUrl) {
if (isValidReturnUrl(paramUrl)) {
if (isValidReturnUrl(paramUrl, runtimeBackendUrl)) {
clearStoredReturnUrl();
return paramUrl;
}
@ -293,7 +322,7 @@ export function consumeReturnUrl(): string | null {
// Fall back to cookie
const storedUrl = getStoredReturnUrl();
if (storedUrl) {
if (isValidReturnUrl(storedUrl)) {
if (isValidReturnUrl(storedUrl, runtimeBackendUrl)) {
clearStoredReturnUrl();
return storedUrl;
}

View file

@ -76,6 +76,8 @@ vi.mock("@/components/networking", () => {
getUiSettings: vi.fn().mockResolvedValue({ values: {}, field_schema: {} }),
// Used to build the redirect URL
proxyBaseUrl: "https://example.com",
getRuntimeBackendBaseUrl: vi.fn(() => null),
getUiPageUrl: (url: string) => url,
// Called when decoding a valid token
setGlobalLitellmHeaderName: vi.fn(),
Organization: {},

View file

@ -73,6 +73,12 @@ http {
# Probe target — doesn't depend on disk.
location = /healthz { default_type text/plain; return 200 "ok\n"; }
location = /ui/litellm-runtime-config.json {
alias /tmp/litellm-ui/runtime-config.json;
default_type application/json;
add_header Cache-Control "no-store" always;
}
# Next.js App Router (output: "export") emits an RSC/flight payload
# as <route>.txt next to <route>.html, plus __next.*.txt segment
# data. The client router fetches these on soft navigation/prefetch