mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
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:
parent
461cbea6e6
commit
f76f7e1368
72 changed files with 1347 additions and 153 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
441
helm/litellm/tests/component_hosts_tests.yaml
Normal file
441
helm/litellm/tests/component_hosts_tests.yaml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
32
ui/docker-entrypoint.d/40-litellm-runtime-config.sh
Executable file
32
ui/docker-entrypoint.d/40-litellm-runtime-config.sh
Executable 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"
|
||||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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 || "");
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
|
|
|||
|
|
@ -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 '{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ vi.mock("@/components/networking", () => ({
|
|||
PROXY_BASE_URL: "",
|
||||
PROXY_LOGOUT_URL: "https://example.com/logout",
|
||||
}),
|
||||
getUiPageUrl: (url: string) => url,
|
||||
serverRootPath: "",
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue