From f76f7e1368a713852892caea4216964fd3955a02 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 14 Oct 2026 14:56:36 -0700 Subject: [PATCH] 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> --- helm/litellm/README.md | 18 + helm/litellm/templates/_helpers.tpl | 24 + .../litellm/templates/backend/deployment.yaml | 12 + .../litellm/templates/gateway/deployment.yaml | 4 + helm/litellm/templates/ingress.yaml | 94 ++++ helm/litellm/templates/ui/deployment.yaml | 6 +- helm/litellm/tests/component_hosts_tests.yaml | 441 ++++++++++++++++++ helm/litellm/values.yaml | 12 +- .../ui_discovery_endpoints.py | 6 + .../ui_discovery_endpoints.py | 1 + .../test_ui_discovery_endpoints.py | 53 ++- ui/Dockerfile | 3 + .../40-litellm-runtime-config.sh | 32 ++ .../ChangePasswordForm.integration.test.tsx | 1 + .../change-password/ChangePasswordForm.tsx | 4 +- .../app/(dashboard)/hooks/useAuthorized.ts | 4 +- .../src/app/(dashboard)/hooks/useLogout.ts | 4 +- .../MCPToolsetTableColumns.test.tsx | 5 +- .../_components/MCPToolsetTableColumns.tsx | 4 +- .../MCPToolsetsTab.integration.test.tsx | 11 +- .../_components/MCPToolsetsTab.tsx | 6 +- .../_components/mcp_connect.test.tsx | 19 +- .../mcp-servers/_components/mcp_connect.tsx | 5 +- .../_components/mcp_servers.test.tsx | 1 + .../src/app/(dashboard)/page.test.tsx | 6 +- .../src/app/(dashboard)/page.tsx | 10 +- .../chat_ui/CodeInterpreterOutput.test.tsx | 7 +- .../chat_ui/CodeInterpreterOutput.tsx | 4 +- .../chat_ui/RealtimePlayground.test.tsx | 3 +- .../components/chat_ui/RealtimePlayground.tsx | 4 +- .../components/systemOneUI/SystemOneUI.tsx | 4 +- .../playground/llm_calls/a2a_send_message.tsx | 6 +- .../llm_calls/anthropic_messages.test.tsx | 1 + .../llm_calls/anthropic_messages.tsx | 4 +- .../playground/llm_calls/audio_speech.tsx | 4 +- .../llm_calls/audio_transcriptions.tsx | 4 +- .../llm_calls/embeddings_api.test.tsx | 3 +- .../playground/llm_calls/embeddings_api.tsx | 4 +- .../playground/llm_calls/image_edits.tsx | 4 +- .../playground/llm_calls/image_generation.tsx | 4 +- .../llm_calls/interactions_api.test.tsx | 5 +- .../playground/llm_calls/interactions_api.tsx | 4 +- .../playground/llm_calls/system_one.test.ts | 5 +- .../playground/llm_calls/system_one.ts | 8 +- .../users/_components/view_users.test.tsx | 1 + .../user_info_view.integration.test.tsx | 2 + .../view_users/user_info_view.test.tsx | 1 + .../src/app/login/LoginPage.tsx | 18 +- .../app/onboarding/OnboardingForm.test.tsx | 1 + .../src/app/onboarding/OnboardingForm.tsx | 6 +- .../components/AIHub/ModelHubTable.test.tsx | 43 +- .../src/components/AIHub/ModelHubTable.tsx | 10 +- .../AIHub/UsefulLinksManagement.test.tsx | 1 + .../AIHub/UsefulLinksManagement.tsx | 4 +- .../src/components/CreateUserButton.test.tsx | 2 + .../components/bulk_create_users_button.tsx | 6 +- .../liteadmin/LiteAdmin.integration.test.tsx | 3 +- .../llm_calls/chat_completion.test.tsx | 3 +- .../llm_calls/gateway_client.test.ts | 8 +- .../components/llm_calls/gateway_client.ts | 4 +- .../llm_calls/responses_api.test.tsx | 9 +- .../components/llm_calls/responses_api.tsx | 11 +- .../src/components/navbar.test.tsx | 1 + .../src/components/networking.test.ts | 231 ++++++++- .../src/components/networking.tsx | 106 ++++- .../src/components/onboarding_link.tsx | 15 +- .../src/components/public_model_hub.test.tsx | 25 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + .../src/utils/returnUrlUtils.test.ts | 78 ++++ .../src/utils/returnUrlUtils.ts | 37 +- .../tests/CreateKeyPage.expiredToken.test.tsx | 2 + ui/nginx.conf | 6 + 72 files changed, 1347 insertions(+), 153 deletions(-) create mode 100644 helm/litellm/tests/component_hosts_tests.yaml create mode 100755 ui/docker-entrypoint.d/40-litellm-runtime-config.sh diff --git a/helm/litellm/README.md b/helm/litellm/README.md index 959657b1b8f..53dc300b886 100644 --- a/helm/litellm/README.md +++ b/helm/litellm/README.md @@ -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 +``` diff --git a/helm/litellm/templates/_helpers.tpl b/helm/litellm/templates/_helpers.tpl index 3a073b1444f..647e435bdf6 100644 --- a/helm/litellm/templates/_helpers.tpl +++ b/helm/litellm/templates/_helpers.tpl @@ -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 }} diff --git a/helm/litellm/templates/backend/deployment.yaml b/helm/litellm/templates/backend/deployment.yaml index 40aa70ec58e..7360261dab1 100644 --- a/helm/litellm/templates/backend/deployment.yaml +++ b/helm/litellm/templates/backend/deployment.yaml @@ -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 diff --git a/helm/litellm/templates/gateway/deployment.yaml b/helm/litellm/templates/gateway/deployment.yaml index 9b3b58c97ed..b0e7f0dcebb 100644 --- a/helm/litellm/templates/gateway/deployment.yaml +++ b/helm/litellm/templates/gateway/deployment.yaml @@ -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 diff --git a/helm/litellm/templates/ingress.yaml b/helm/litellm/templates/ingress.yaml index a933b5e85c6..22ff8a95566 100644 --- a/helm/litellm/templates/ingress.yaml +++ b/helm/litellm/templates/ingress.yaml @@ -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 }} diff --git a/helm/litellm/templates/ui/deployment.yaml b/helm/litellm/templates/ui/deployment.yaml index efee2d5fc34..394eea17451 100644 --- a/helm/litellm/templates/ui/deployment.yaml +++ b/helm/litellm/templates/ui/deployment.yaml @@ -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 }} diff --git a/helm/litellm/tests/component_hosts_tests.yaml b/helm/litellm/tests/component_hosts_tests.yaml new file mode 100644 index 00000000000..f9c9ce6fad2 --- /dev/null +++ b/helm/litellm/tests/component_hosts_tests.yaml @@ -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 diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index f17a379189d..d8e77085010 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -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 diff --git a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py index 4c85d6148c0..86e77596e70 100644 --- a/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -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, diff --git a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py index c30fb7caf2e..e30ad26bfe5 100644 --- a/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py +++ b/litellm/types/proxy/discovery_endpoints/ui_discovery_endpoints.py @@ -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 diff --git a/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py b/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py index 34a7f78659b..1ef0eee761a 100644 --- a/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py +++ b/tests/unit/proxy/discovery_endpoints/test_ui_discovery_endpoints.py @@ -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 diff --git a/ui/Dockerfile b/ui/Dockerfile index 073dadb2da4..4b6b0c1581a 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -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 diff --git a/ui/docker-entrypoint.d/40-litellm-runtime-config.sh b/ui/docker-entrypoint.d/40-litellm-runtime-config.sh new file mode 100755 index 00000000000..4b53ab0b19f --- /dev/null +++ b/ui/docker-entrypoint.d/40-litellm-runtime-config.sh @@ -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" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx index c4cf8ebcf9d..22d13239b54 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.integration.test.tsx @@ -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", () => ({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx index ad35b5c90a3..9e9c2ad7fc8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/change-password/ChangePasswordForm.tsx @@ -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"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index 581ee8b2580..0d240c02f45 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -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); }, []); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts index ed8c6d25192..1df7ba406f7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts @@ -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 || ""); }); }; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx index b29320cb535..f29029e23bd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.test.tsx @@ -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")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx index 525700d3c49..d1bf4112cb9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetTableColumns.tsx @@ -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 { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx index bd391b4e7c3..81462c6bf9b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.integration.test.tsx @@ -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\/\/mcp/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index de7d203bd90..ff0e8f7b6a9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -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//mcp", + "url": "${gatewayBaseUrl}/toolset//mcp", "headers": { "x-litellm-api-key": "Bearer " } } } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx index 54a32e87b07..625ff2e50b8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.test.tsx @@ -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(); + + 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx index 07f8413e3a0..bbd22bac181 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx @@ -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 = ({ currentServerAccessGroups = [] }) => { const proxyBaseUrl = getProxyBaseUrl(); + const gatewayBaseUrl = getGatewayBaseUrl(); const [copiedStates, setCopiedStates] = useState>({}); const [currentServer] = useState("Zapier_MCP"); // This should match the current server being viewed @@ -238,7 +239,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] accessGroups={["dev-group"]} > ({ 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), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index f5baff0e727..d1d9d1b43d0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -43,7 +43,11 @@ vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({ vi.mock("@/components/common_components/LoadingScreen", () => ({ default: () =>
, })); -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 94f4ba79a25..86821470303 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -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; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.test.tsx index c9cfc3ac833..9e7531aab86 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.test.tsx @@ -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", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx index 1ddcec75173..b5b195a5011 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx @@ -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 = ({ code, ann const [imageUrls, setImageUrls] = useState>({}); const [loadingImages, setLoadingImages] = useState>({}); const [codeOpen, setCodeOpen] = useState(false); - const proxyBaseUrl = getProxyBaseUrl(); + const proxyBaseUrl = getGatewayBaseUrl(); useEffect(() => { const createdUrls: string[] = []; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx index 7f866eb2548..117796d658e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.test.tsx @@ -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"]); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx index b14707bc16a..8acc54bee8d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx @@ -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 = ({ 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) { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx index 43d9d4f3024..300022668f1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx @@ -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 = => { - 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 => { - const proxyBaseUrl = customBaseUrl || getProxyBaseUrl(); + const proxyBaseUrl = customBaseUrl || getGatewayBaseUrl(); const url = proxyBaseUrl ? `${proxyBaseUrl}/a2a/${agentId}` : `/a2a/${agentId}`; const requestId = uuidv4(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx index bed6d9f4fd9..281721cb904 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.test.tsx @@ -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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx index 4143d2641e8..e53d6378341 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx @@ -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); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx index 367bd9aa6a6..e7bf2d3fc3c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_speech.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx index 12b2d945f98..1063cfe48e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx index ef5c9f5025d..c28da4688c3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.test.tsx @@ -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", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx index ef8a650f1d2..51854cefcf0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx @@ -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}`, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx index b956cd1d7de..d9211698fae 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_edits.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx index 1bbda4234b0..9f39e4ad33b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/image_generation.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.test.tsx index af862063c9c..a5407df172e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.test.tsx @@ -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"); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx index be4b09ae33b..6414a9f3b93 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/interactions_api.tsx @@ -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`; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts index 720152d3143..90e9482c89c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.test.ts @@ -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); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts index f49874b4735..a4030029903 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/llm_calls/system_one.ts @@ -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( signal?: AbortSignal, ): Promise> { const startedAt = performance.now(); - const body = await proxyClient(customBaseUrl).post(request.endpoint, { + const body = await proxyClient(customBaseUrl, getGatewayBaseUrl).post(request.endpoint, { body: request.payload, headers: authHeaders(accessToken), signal, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 10a13c1dbd7..13958a26adf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx index 4195a3c9913..948607bff37 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx @@ -62,6 +62,8 @@ vi.mock("@/components/networking", async (importOriginal) => { const original = await importOriginal(); return { formatDate: original.formatDate, + getRuntimeBackendBaseUrl: original.getRuntimeBackendBaseUrl, + getUiPageUrl: original.getUiPageUrl, serverRootPath: "/", userGetInfoV2: (...args: unknown[]) => mockUserGetInfoV2(...args), dailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx index 3eae1dbdfc5..3a29578c5c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.test.tsx @@ -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), diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx index 14d50555dec..10d8051232c 100644 --- a/ui/litellm-dashboard/src/app/login/LoginPage.tsx +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -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)); } } }, diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.test.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.test.tsx index 965b0db47c3..0891de52736 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.test.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.test.tsx @@ -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", () => ({ diff --git a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx index 23a8bc6725a..304ee161a9a 100644 --- a/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/OnboardingForm.tsx @@ -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."); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index d1a118f238d..43cd3137bbc 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -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(); const { user } = await renderHub([], servers.promise); diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 3e23d64d28a..fb665eb69c2 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -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 = ({ // 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 = ({

Model Hub URL:

-

{`${getProxyBaseUrl()}/ui/model_hub_table`}

+

{getUiPageUrl(`${getProxyBaseUrl()}/ui/model_hub_table`)}