mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(enterprise): bundle LiteAdmin Slack with native gateway login (#44444)
* feat(enterprise): bundle LiteAdmin Slack worker with native gateway login * fix(enterprise): preserve gateway prefixes during native Slack linking * fix(enterprise): retain native Slack linking on the admin backend * fix(enterprise): reuse shared native Slack connection services
This commit is contained in:
parent
4b67a2b845
commit
6370104c53
14 changed files with 893 additions and 1 deletions
|
|
@ -41,6 +41,7 @@ legacy_paths() {
|
|||
echo tests/unit/enterprise/proxy/hooks
|
||||
echo tests/unit/enterprise/proxy/management_endpoints
|
||||
echo tests/unit/enterprise/proxy/test_audit_logging_endpoints.py
|
||||
echo tests/unit/enterprise/proxy/test_liteadmin.py
|
||||
echo tests/unit/enterprise/enterprise_callbacks/test_prometheus_logging_callbacks.py ;;
|
||||
enterprise-routing)
|
||||
echo tests/unit/google_genai
|
||||
|
|
|
|||
11
Dockerfile
11
Dockerfile
|
|
@ -114,6 +114,16 @@ RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/
|
|||
RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \
|
||||
sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
|
||||
|
||||
FROM $LITELLM_BUILD_IMAGE AS liteadmin-builder
|
||||
COPY --from=uvbin /uv /usr/local/bin/uv
|
||||
RUN apk add --no-cache python-3.13
|
||||
ADD --checksum=sha256:2f7ae5cdd9d91731c0990e74a58239dc3e3fd2bf28dab23b55eafcdc47aaf87e \
|
||||
https://github.com/BerriAI/litellm-admin-agent/archive/ef501e94bc9fbacb9233b922abf71427f030408c.tar.gz /tmp/liteadmin.tar.gz
|
||||
RUN mkdir /tmp/liteadmin && tar xzf /tmp/liteadmin.tar.gz --strip-components=1 -C /tmp/liteadmin && \
|
||||
uv venv /opt/liteadmin --python python3.13 && \
|
||||
uv pip install --python /opt/liteadmin/bin/python --require-hashes -r /tmp/liteadmin/requirements.txt && \
|
||||
uv pip install --python /opt/liteadmin/bin/python --no-deps /tmp/liteadmin
|
||||
|
||||
# Runtime stage
|
||||
FROM $LITELLM_RUNTIME_IMAGE AS runtime
|
||||
ARG LITELLM_RELEASE_TAG=""
|
||||
|
|
@ -143,6 +153,7 @@ ENV PATH="/app/.venv/bin:${PATH}" \
|
|||
# ship (manifest-scanning tools attribute everything in it to this image).
|
||||
# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path.
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=liteadmin-builder /opt/liteadmin /opt/liteadmin
|
||||
COPY --from=builder /app/docker /app/docker
|
||||
COPY --from=builder /app/schema.prisma /app/schema.prisma
|
||||
COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/prisma_migration.py
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
|
|||
"/customer/",
|
||||
"/end_user/",
|
||||
"/sso/",
|
||||
"/liteadmin/slack/connect/",
|
||||
"/login",
|
||||
"/v2/login",
|
||||
"/v3/login",
|
||||
|
|
|
|||
44
docker-compose.liteadmin.yml
Normal file
44
docker-compose.liteadmin.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
services:
|
||||
litellm:
|
||||
image: ${LITELLM_IMAGE:?Set the native-enabled gateway image}
|
||||
environment:
|
||||
LITELLM_ADMIN_AGENT_URL: http://liteadmin:10000
|
||||
ADMIN_AGENT_SERVICE_TOKEN: ${ADMIN_AGENT_SERVICE_TOKEN:?Set a shared worker token}
|
||||
PROXY_BASE_URL: ${LITELLM_PUBLIC_URL:?Set the existing HTTPS gateway URL}
|
||||
|
||||
liteadmin:
|
||||
image: ${LITELLM_IMAGE:?Set the same native-enabled image used by the gateway}
|
||||
command: ["--admin-agent"]
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
stop_grace_period: 75s
|
||||
environment:
|
||||
CONNECTION_AUTH_MODE: native
|
||||
LITELLM_BASE_URL: ${LITELLM_PUBLIC_URL:?Set the existing HTTPS gateway URL}
|
||||
LITELLM_MODEL: ${LITELLM_ADMIN_MODEL:?Set a gateway model with tool support}
|
||||
SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN:?Install the Slack app}
|
||||
SLACK_APP_TOKEN: ${SLACK_APP_TOKEN:?Enable Socket Mode}
|
||||
SLACK_WORKSPACE_ID: ${SLACK_WORKSPACE_ID:?Set the Slack workspace ID}
|
||||
ADMIN_AGENT_SERVICE_TOKEN: ${ADMIN_AGENT_SERVICE_TOKEN:?Set a shared worker token}
|
||||
CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY:?Set a persistent Fernet key}
|
||||
STATE_DB: /var/data/events.sqlite3
|
||||
ADMIN_READ_ONLY: ${ADMIN_READ_ONLY:-false}
|
||||
OPENAI_AGENTS_DISABLE_TRACING: "1"
|
||||
volumes:
|
||||
- liteadmin_state:/var/data
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=64m
|
||||
healthcheck:
|
||||
test: ["CMD", "/opt/liteadmin/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:10000/readyz', timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
litellm:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
liteadmin_state:
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
#!/bin/sh
|
||||
|
||||
if [ "$1" = "--admin-agent" ]; then
|
||||
shift
|
||||
export CONNECTION_AUTH_MODE=native
|
||||
exec /opt/liteadmin/bin/litellm-admin-agent --web "$@"
|
||||
fi
|
||||
|
||||
case "$USE_DDTRACE" in
|
||||
[Tt][Rr][Uu][Ee])
|
||||
export DD_TRACE_OPENAI_ENABLED="False"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from litellm_enterprise.enterprise_callbacks.send_emails.endpoints import (
|
|||
|
||||
from . import ui_crud_endpoints # side-effect: registers extra UI settings
|
||||
from .audit_logging_endpoints import router as audit_logging_router
|
||||
from .liteadmin import router as liteadmin_router
|
||||
from .management_endpoints import management_endpoints_router
|
||||
from .utils import _should_block_robots
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ __all__ = ["router", "ui_crud_endpoints"]
|
|||
router = APIRouter()
|
||||
router.include_router(email_events_router)
|
||||
router.include_router(audit_logging_router)
|
||||
router.include_router(liteadmin_router)
|
||||
router.include_router(management_endpoints_router)
|
||||
|
||||
|
||||
|
|
|
|||
283
enterprise/litellm_enterprise/proxy/liteadmin.py
Normal file
283
enterprise/litellm_enterprise/proxy/liteadmin.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Final
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, TypeAdapter, ValidationError
|
||||
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.proxy._experimental.mcp_server.oauth_utils import get_request_base_url
|
||||
from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
|
||||
|
||||
router: Final = APIRouter()
|
||||
_PREFIX: Final = "/liteadmin/slack/connect/"
|
||||
_COOKIE: Final = "__Host-litellm-slack-connect-"
|
||||
_HEADERS: Final = {
|
||||
"Cache-Control": "no-store",
|
||||
"Referrer-Policy": "same-origin",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
||||
}
|
||||
|
||||
|
||||
class LinkDetails(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, strict=True, extra="forbid")
|
||||
workspace_id: str = Field(min_length=1, max_length=64)
|
||||
slack_user_id: str = Field(min_length=1, max_length=64)
|
||||
email: str = Field(min_length=1, max_length=320)
|
||||
|
||||
|
||||
class AdminSession(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
user_id: str
|
||||
credential: SecretStr
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NativeAdminContext:
|
||||
worker_url: str
|
||||
service_token: SecretStr
|
||||
client: httpx.AsyncClient
|
||||
session_user: Callable[[Request], Awaitable[str | None]]
|
||||
load_user: Callable[[str], Awaitable[LiteLLM_UserTable | None]]
|
||||
mint_session: Callable[[LiteLLM_UserTable], AdminSession]
|
||||
|
||||
async def worker_request(self, token: str, session: AdminSession | None = None) -> httpx.Response:
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{43}", token) is None:
|
||||
raise HTTPException(410, "Connection link expired. Send connect in Slack for a new link")
|
||||
try:
|
||||
response: Final = await self.client.request(
|
||||
"GET" if session is None else "POST",
|
||||
f"{self.worker_url}/internal/liteadmin/links/{token}",
|
||||
headers={"X-LiteLLM-Admin-Agent-Token": self.service_token.get_secret_value()},
|
||||
json=None
|
||||
if session is None
|
||||
else {
|
||||
"user_id": session.user_id,
|
||||
"credential": session.credential.get_secret_value(),
|
||||
"expires_at": session.expires_at,
|
||||
},
|
||||
timeout=15,
|
||||
follow_redirects=False,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(503, "LiteAdmin is temporarily unavailable") from None
|
||||
if response.status_code == 410:
|
||||
raise HTTPException(410, "Connection link expired. Send connect in Slack for a new link")
|
||||
if response.status_code == 403:
|
||||
raise HTTPException(403, "Connect your own active LiteLLM proxy-admin account with the same email as Slack")
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(503, "LiteAdmin could not verify this connection")
|
||||
return response
|
||||
|
||||
async def details(self, token: str) -> LinkDetails:
|
||||
response: Final = await self.worker_request(token)
|
||||
try:
|
||||
return LinkDetails.model_validate_json(response.content)
|
||||
except ValidationError:
|
||||
raise HTTPException(503, "LiteAdmin could not verify this connection") from None
|
||||
|
||||
async def admin(self, user_id: str, details: LinkDetails) -> LiteLLM_UserTable:
|
||||
user: Final = await self.load_user(user_id)
|
||||
if (
|
||||
user is None
|
||||
or user.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
or not user.user_email
|
||||
or user.user_email.strip().casefold() != details.email.strip().casefold()
|
||||
):
|
||||
raise HTTPException(403, "Connect your own active LiteLLM proxy-admin account with the same email as Slack")
|
||||
return user
|
||||
|
||||
|
||||
def _page(title: str, body: str) -> HTMLResponse:
|
||||
return HTMLResponse(
|
||||
f'<!doctype html><html lang="en"><meta charset="utf-8">'
|
||||
f'<meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(title)}</title>'
|
||||
"<style>body{font:17px system-ui;color:#18252f;max-width:560px;margin:10vh auto;padding:24px}"
|
||||
"p{line-height:1.6}button{font:inherit;border:0;border-radius:8px;padding:14px 20px;background:#5b3fd1;"
|
||||
"color:white;cursor:pointer}small{color:#556}</style>"
|
||||
f"<main><h1>{html.escape(title)}</h1>{body}</main></html>",
|
||||
headers=_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
def _cookie_name(token: str) -> str:
|
||||
return _COOKIE + hashlib.sha256(token.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
async def _session_user(request: Request) -> str | None:
|
||||
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import (
|
||||
get_authenticated_browser_user_id,
|
||||
)
|
||||
|
||||
return await get_authenticated_browser_user_id(request)
|
||||
|
||||
|
||||
async def _load_user(user_id: str) -> LiteLLM_UserTable | None:
|
||||
from litellm.proxy.auth.auth_checks import get_user_object
|
||||
from litellm.proxy.proxy_server import prisma_client, user_api_key_cache
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(503, "LiteAdmin requires a database")
|
||||
try:
|
||||
return await get_user_object(
|
||||
user_id=user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
check_db_only=True,
|
||||
)
|
||||
except UserNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
raise HTTPException(503, "LiteAdmin could not verify your current permissions") from None
|
||||
|
||||
|
||||
def mint_admin_session(user: LiteLLM_UserTable) -> AdminSession:
|
||||
from litellm.proxy.auth.auth_checks import LITELLM_SESSION_TOKEN_PREFIX
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_bearer_token
|
||||
|
||||
expires: Final = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
token="liteadmin-" + secrets.token_urlsafe(24),
|
||||
key_name="LiteAdmin Slack",
|
||||
key_alias="LiteAdmin Slack",
|
||||
user_id=user.user_id,
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
models=TypeAdapter(list[str]).validate_python(user.model_dump().get("models", [])),
|
||||
expires=expires,
|
||||
is_session_token=True,
|
||||
)
|
||||
return AdminSession(
|
||||
user_id=user.user_id,
|
||||
credential=SecretStr(
|
||||
encrypt_bearer_token(auth.model_dump_json(exclude_none=True), LITELLM_SESSION_TOKEN_PREFIX)
|
||||
),
|
||||
expires_at=expires.timestamp(),
|
||||
)
|
||||
|
||||
|
||||
def validate_native_configuration(
|
||||
worker_url: str, service_token: str, enterprise: bool, database_available: bool
|
||||
) -> None:
|
||||
if not worker_url:
|
||||
raise HTTPException(404, "LiteAdmin Slack is not enabled")
|
||||
if not enterprise:
|
||||
raise HTTPException(403, "LiteAdmin Slack requires LiteLLM Enterprise")
|
||||
if not database_available:
|
||||
raise HTTPException(503, "LiteAdmin requires a database")
|
||||
try:
|
||||
parsed: Final = urlsplit(worker_url)
|
||||
port: Final = parsed.port
|
||||
except ValueError:
|
||||
raise HTTPException(503, "LiteAdmin worker configuration is invalid") from None
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or port == 0
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.path
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or len(service_token) < 32
|
||||
or any(character.isspace() for character in service_token)
|
||||
):
|
||||
raise HTTPException(503, "LiteAdmin worker configuration is invalid")
|
||||
|
||||
|
||||
async def native_admin_context() -> NativeAdminContext:
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client
|
||||
|
||||
worker_url: Final = os.getenv("LITELLM_ADMIN_AGENT_URL", "").rstrip("/")
|
||||
service_token: Final = os.getenv("ADMIN_AGENT_SERVICE_TOKEN", "")
|
||||
validate_native_configuration(worker_url, service_token, premium_user is True, prisma_client is not None)
|
||||
client: Final = get_async_httpx_client(
|
||||
llm_provider="liteadmin_native", params={"timeout": 15.0, "follow_redirects": False}
|
||||
).client
|
||||
return NativeAdminContext(
|
||||
worker_url, SecretStr(service_token), client, _session_user, _load_user, mint_admin_session
|
||||
)
|
||||
|
||||
|
||||
@router.get(_PREFIX + "{token}", include_in_schema=False, response_class=HTMLResponse)
|
||||
async def connect_page(
|
||||
request: Request,
|
||||
token: str,
|
||||
context: Annotated[NativeAdminContext, Depends(native_admin_context)],
|
||||
) -> Response:
|
||||
details: Final = await context.details(token)
|
||||
base_url: Final = get_request_base_url(request)
|
||||
parsed_base: Final = urlsplit(base_url)
|
||||
if parsed_base.scheme != "https":
|
||||
raise HTTPException(400, "LiteAdmin account connections require HTTPS")
|
||||
user_id: Final = await context.session_user(request)
|
||||
if user_id is None:
|
||||
return RedirectResponse(
|
||||
base_url + "/sso/key/generate?" + urlencode({"return_to": parsed_base.path + _PREFIX + token}),
|
||||
status_code=303,
|
||||
headers=_HEADERS,
|
||||
)
|
||||
await context.admin(user_id, details)
|
||||
csrf: Final = secrets.token_urlsafe(32)
|
||||
page: Final = _page(
|
||||
"Connect LiteAdmin to Slack",
|
||||
f"<p>Connect <strong>{html.escape(details.email)}</strong> to LiteAdmin in your Slack workspace?</p>"
|
||||
"<p>Model requests and administrative actions will use your own LiteLLM account and current permissions</p>"
|
||||
f'<form method="post"><input type="hidden" name="csrf" value="{csrf}">'
|
||||
'<button type="submit">Connect account</button></form>'
|
||||
"<p><small>This connection lasts 24 hours. Send disconnect in Slack to remove the saved session</small></p>",
|
||||
)
|
||||
page.set_cookie(_cookie_name(token), csrf, max_age=600, secure=True, httponly=True, samesite="strict", path="/")
|
||||
return page
|
||||
|
||||
|
||||
@router.post(_PREFIX + "{token}", include_in_schema=False, response_class=HTMLResponse)
|
||||
async def connect_account(
|
||||
request: Request,
|
||||
token: str,
|
||||
context: Annotated[NativeAdminContext, Depends(native_admin_context)],
|
||||
) -> Response:
|
||||
base_url: Final = get_request_base_url(request)
|
||||
parsed_base: Final = urlsplit(base_url)
|
||||
origin: Final = f"{parsed_base.scheme}://{parsed_base.netloc}"
|
||||
if parsed_base.scheme != "https" or request.headers.get("Origin") != origin:
|
||||
raise HTTPException(403, "Reopen your private Slack connection link")
|
||||
if request.headers.get("Content-Type", "").split(";", 1)[0] != "application/x-www-form-urlencoded":
|
||||
raise HTTPException(400, "Expected a connection form")
|
||||
form: Final = await request.form(max_fields=1, max_files=0, max_part_size=1024)
|
||||
supplied: Final = form.get("csrf")
|
||||
expected: Final = request.cookies.get(_cookie_name(token), "")
|
||||
if (
|
||||
not isinstance(supplied, str)
|
||||
or len(expected) != 43
|
||||
or len(supplied) != 43
|
||||
or not hmac.compare_digest(supplied.encode(), expected.encode())
|
||||
):
|
||||
raise HTTPException(403, "Reopen your private Slack connection link")
|
||||
user_id: Final = await context.session_user(request)
|
||||
if user_id is None:
|
||||
raise HTTPException(401, "Your login expired. Reopen your private Slack connection link")
|
||||
details: Final = await context.details(token)
|
||||
user: Final = await context.admin(user_id, details)
|
||||
await context.worker_request(token, context.mint_session(user))
|
||||
page: Final = _page(
|
||||
"Account connected", "<p>Return to Slack and ask LiteAdmin to list your teams or check a budget</p>"
|
||||
)
|
||||
page.delete_cookie(_cookie_name(token), path="/", secure=True, httponly=True, samesite="strict")
|
||||
return page
|
||||
|
|
@ -57,6 +57,19 @@ spec:
|
|||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
{{- include "litellm.proxyEnv" . | nindent 12 }}
|
||||
{{- if .Values.liteadmin.enabled }}
|
||||
- name: LITELLM_ADMIN_AGENT_URL
|
||||
value: {{ printf "http://%s-liteadmin:10000" (include "litellm.fullname" . | trunc 53 | trimSuffix "-") | quote }}
|
||||
- name: ADMIN_AGENT_SERVICE_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "liteadmin.existingSecret is required" .Values.liteadmin.existingSecret }}
|
||||
key: ADMIN_AGENT_SERVICE_TOKEN
|
||||
{{- if not (hasKey (default dict .Values.envVars) "PROXY_BASE_URL") }}
|
||||
- name: PROXY_BASE_URL
|
||||
value: {{ required "liteadmin.gatewayUrl is required" .Values.liteadmin.gatewayUrl | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- include "litellm.proxyMetricsEnv" . | nindent 12 }}
|
||||
{{- if .Values.collector.enabled }}
|
||||
{{- include "litellm.collectorEnv" . | nindent 12 }}
|
||||
|
|
|
|||
112
helm/litellm-helm/templates/liteadmin.yaml
Normal file
112
helm/litellm-helm/templates/liteadmin.yaml
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
{{- if .Values.liteadmin.enabled }}
|
||||
{{- $name := printf "%s-liteadmin" (include "litellm.fullname" . | trunc 53 | trimSuffix "-") }}
|
||||
{{- $secret := required "liteadmin.existingSecret is required" .Values.liteadmin.existingSecret }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ $name }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ $name }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
terminationGracePeriodSeconds: 75
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
fsGroup: 10001
|
||||
runAsNonRoot: true
|
||||
containers:
|
||||
- name: liteadmin
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args: ["--admin-agent"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ $secret }}
|
||||
env:
|
||||
- name: CONNECTION_AUTH_MODE
|
||||
value: native
|
||||
- name: LITELLM_BASE_URL
|
||||
value: {{ required "liteadmin.gatewayUrl is required" .Values.liteadmin.gatewayUrl | quote }}
|
||||
- name: LITELLM_MODEL
|
||||
value: {{ required "liteadmin.model is required" .Values.liteadmin.model | quote }}
|
||||
- name: STATE_DB
|
||||
value: /var/data/events.sqlite3
|
||||
- name: ADMIN_READ_ONLY
|
||||
value: {{ .Values.liteadmin.readOnly | quote }}
|
||||
- name: OPENAI_AGENTS_DISABLE_TRACING
|
||||
value: "1"
|
||||
ports:
|
||||
- name: health
|
||||
containerPort: 10000
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: health
|
||||
periodSeconds: 15
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: health
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
{{- toYaml .Values.liteadmin.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: state
|
||||
mountPath: /var/data
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: state
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ $name }}
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 64Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ $name }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
ports:
|
||||
- port: 10000
|
||||
targetPort: health
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ $name }}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
{{- with .Values.liteadmin.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.liteadmin.storageSize }}
|
||||
{{- end }}
|
||||
|
|
@ -3,6 +3,20 @@
|
|||
# Declare variables to be passed into your templates.
|
||||
|
||||
replicaCount: 1
|
||||
liteadmin:
|
||||
enabled: false
|
||||
existingSecret: ""
|
||||
gatewayUrl: ""
|
||||
model: ""
|
||||
readOnly: false
|
||||
storageSize: 1Gi
|
||||
storageClassName: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 1Gi
|
||||
# numWorkers: 2
|
||||
|
||||
image:
|
||||
|
|
|
|||
|
|
@ -171,6 +171,42 @@ async def _session_key_is_live(session_key: str | None) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
async def get_authenticated_browser_user_id(request: Request) -> str | None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.proxy._types import hash_token
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken, get_key_object
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
|
||||
|
||||
user_id, session_key = _session_identity_from_cookie(request)
|
||||
if not user_id or not session_key or prisma_client is None:
|
||||
return None
|
||||
try:
|
||||
auth: Final = (
|
||||
await get_key_object(
|
||||
hash_token(session_key),
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=True,
|
||||
)
|
||||
if session_key.startswith("sk-")
|
||||
else ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(session_key)
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if auth is None or auth.user_id != user_id or auth.blocked or auth.expires is None:
|
||||
return None
|
||||
try:
|
||||
expiration: Final = TypeAdapter(datetime).validate_python(auth.expires)
|
||||
except ValidationError:
|
||||
return None
|
||||
expires: Final = expiration.replace(tzinfo=timezone.utc) if expiration.tzinfo is None else expiration
|
||||
return user_id if expires > datetime.now(timezone.utc) else None
|
||||
|
||||
|
||||
async def _byok_session_auth(request: Request) -> UserAPIKeyAuth:
|
||||
"""Require the UI session cookie, with the embedded session key
|
||||
re-resolved against the DB so a revoked (logged-out) session cannot
|
||||
|
|
|
|||
244
tests/unit/enterprise/proxy/test_liteadmin.py
Normal file
244
tests/unit/enterprise/proxy/test_liteadmin.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from litellm_enterprise.proxy.liteadmin import AdminSession, NativeAdminContext, native_admin_context, router
|
||||
from pydantic import SecretStr, TypeAdapter
|
||||
|
||||
from litellm.proxy._types import LiteLLM_UserTable
|
||||
|
||||
TOKEN: Final = "a" * 43
|
||||
PATH: Final = "/liteadmin/slack/connect/" + TOKEN
|
||||
ORIGIN: Final = "https://gateway.example.com"
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(self, *, email: str = "alice@example.com", status: int = 200) -> None:
|
||||
self.email = email
|
||||
self.status = status
|
||||
self.session: object = None
|
||||
self.role = "proxy_admin"
|
||||
|
||||
def request(self, request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["X-LiteLLM-Admin-Agent-Token"] == "s" * 32
|
||||
if request.method == "POST":
|
||||
self.session = json.loads(request.content)
|
||||
return httpx.Response(self.status, json={"status": "connected"})
|
||||
return httpx.Response(
|
||||
self.status,
|
||||
json={
|
||||
"workspace_id": "Tworkspace",
|
||||
"slack_user_id": "Ualice",
|
||||
"email": self.email,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def client_for(
|
||||
worker: Worker, *, role: str | None = None, logged_in: bool = True, email: str = "alice@example.com"
|
||||
) -> TestClient:
|
||||
async def session_user(request: Request) -> str | None:
|
||||
return "alice" if logged_in else None
|
||||
|
||||
async def load_user(user_id: str) -> LiteLLM_UserTable:
|
||||
return LiteLLM_UserTable(user_id=user_id, user_email=email, user_role=role or worker.role)
|
||||
|
||||
def mint(user: LiteLLM_UserTable) -> AdminSession:
|
||||
return AdminSession(user_id=user.user_id, credential=SecretStr("personal-session"), expires_at=86400)
|
||||
|
||||
context: Final = NativeAdminContext(
|
||||
"http://private-worker:10000",
|
||||
SecretStr("s" * 32),
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(worker.request)),
|
||||
session_user,
|
||||
load_user,
|
||||
mint,
|
||||
)
|
||||
app: Final = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[native_admin_context] = lambda: context
|
||||
return TestClient(app, base_url=ORIGIN)
|
||||
|
||||
|
||||
def csrf_from(client: TestClient) -> str:
|
||||
page: Final = client.get(PATH)
|
||||
assert page.status_code == 200
|
||||
match: Final = re.search('name="csrf" value="([^"]+)"', page.text)
|
||||
assert match is not None
|
||||
return match[1]
|
||||
|
||||
|
||||
def test_connect_uses_existing_login_without_a_hosted_oauth_callback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
with client_for(Worker(), logged_in=False) as client:
|
||||
response: Final = client.get(PATH, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert (
|
||||
response.headers["location"] == ORIGIN + "/sso/key/generate?return_to=%2Fliteadmin%2Fslack%2Fconnect%2F" + TOKEN
|
||||
)
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_connect_hands_off_personal_session_only_over_private_worker_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
worker: Final = Worker()
|
||||
with client_for(worker) as client:
|
||||
csrf: Final = csrf_from(client)
|
||||
response: Final = client.post(PATH, data={"csrf": csrf}, headers={"Origin": ORIGIN})
|
||||
assert response.status_code == 200
|
||||
assert "Account connected" in response.text
|
||||
assert worker.session == {"user_id": "alice", "credential": "personal-session", "expires_at": 86400.0}
|
||||
assert "personal-session" not in response.text
|
||||
assert "Max-Age=0" in response.headers["set-cookie"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role,email", [("internal_user", "alice@example.com"), ("proxy_admin", "bob@example.com")])
|
||||
def test_connect_rejects_nonadmin_and_another_slack_users_link(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
role: str,
|
||||
email: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
worker: Final = Worker(email=email)
|
||||
with client_for(worker, role=role) as client:
|
||||
response: Final = client.get(PATH)
|
||||
assert response.status_code == 403
|
||||
assert worker.session is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("origin,csrf", [("https://attacker.example", None), ("null", None), (ORIGIN, "b" * 43)])
|
||||
def test_connect_requires_same_origin_and_browser_csrf(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
origin: str,
|
||||
csrf: str | None,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
worker: Final = Worker()
|
||||
with client_for(worker) as client:
|
||||
valid: Final = csrf_from(client)
|
||||
response: Final = client.post(PATH, data={"csrf": csrf or valid}, headers={"Origin": origin})
|
||||
assert response.status_code == 403
|
||||
assert worker.session is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,expected", [(410, 410), (403, 403), (500, 503), (302, 503)])
|
||||
def test_worker_denial_expiry_and_failure_never_mint_a_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status: int,
|
||||
expected: int,
|
||||
) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
worker: Final = Worker(status=status)
|
||||
with client_for(worker) as client:
|
||||
response: Final = client.get(PATH)
|
||||
assert response.status_code == expected
|
||||
assert worker.session is None
|
||||
|
||||
|
||||
def test_csrf_cookie_cannot_cross_links(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
worker: Final = Worker()
|
||||
with client_for(worker) as client:
|
||||
csrf: Final = csrf_from(client)
|
||||
response: Final = client.post(PATH.replace(TOKEN, "b" * 43), data={"csrf": csrf}, headers={"Origin": ORIGIN})
|
||||
assert response.status_code == 403
|
||||
assert worker.session is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,secret,enterprise,database,status",
|
||||
[
|
||||
("", "s" * 32, True, True, 404),
|
||||
("http://worker:10000", "s" * 32, False, True, 403),
|
||||
("http://worker:10000", "s" * 32, True, False, 503),
|
||||
("file:///etc/passwd", "s" * 32, True, True, 503),
|
||||
("https://user:password@worker", "s" * 32, True, True, 503),
|
||||
("https://worker/path", "s" * 32, True, True, 503),
|
||||
("https://worker", "short", True, True, 503),
|
||||
("http://[broken", "s" * 32, True, True, 503),
|
||||
("http://worker:broken", "s" * 32, True, True, 503),
|
||||
],
|
||||
)
|
||||
def test_native_configuration_requires_enterprise_database_and_private_worker_credentials(
|
||||
url: str,
|
||||
secret: str,
|
||||
enterprise: bool,
|
||||
database: bool,
|
||||
status: int,
|
||||
) -> None:
|
||||
from fastapi import HTTPException
|
||||
from litellm_enterprise.proxy.liteadmin import validate_native_configuration
|
||||
|
||||
with pytest.raises(HTTPException) as error:
|
||||
validate_native_configuration(url, secret, enterprise, database)
|
||||
assert error.value.status_code == status
|
||||
|
||||
|
||||
def test_connect_rechecks_admin_permission_after_consent_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
worker: Final = Worker()
|
||||
with client_for(worker) as client:
|
||||
csrf: Final = csrf_from(client)
|
||||
worker.role = "internal_user"
|
||||
response: Final = client.post(PATH, data={"csrf": csrf}, headers={"Origin": ORIGIN})
|
||||
assert response.status_code == 403
|
||||
assert worker.session is None
|
||||
|
||||
|
||||
def test_consent_escapes_slack_email(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN)
|
||||
email: Final = '<script>alert("x")</script>@example.com'
|
||||
with client_for(Worker(email=email), email=email) as client:
|
||||
page: Final = client.get(PATH)
|
||||
assert page.status_code == 200
|
||||
assert "<script>" not in page.text
|
||||
assert "<script>" in page.text
|
||||
|
||||
|
||||
def test_native_session_is_encrypted_personal_and_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litellm_enterprise.proxy.liteadmin import mint_admin_session
|
||||
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "local-test-only-encryption-key")
|
||||
before: Final = datetime.now(timezone.utc).timestamp()
|
||||
session: Final = mint_admin_session(LiteLLM_UserTable(user_id="alice", user_role="proxy_admin"))
|
||||
decoded: Final = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(session.credential.get_secret_value())
|
||||
assert decoded is not None
|
||||
assert decoded.user_id == "alice"
|
||||
assert decoded.is_session_token is True
|
||||
assert decoded.user_role == "proxy_admin"
|
||||
assert decoded.expires is not None
|
||||
assert TypeAdapter(datetime).validate_python(decoded.expires).timestamp() == session.expires_at
|
||||
assert before + 86400 <= session.expires_at <= datetime.now(timezone.utc).timestamp() + 86400
|
||||
assert "alice" not in session.credential.get_secret_value()
|
||||
|
||||
|
||||
def test_worker_configuration_accepts_private_service_address() -> None:
|
||||
from litellm_enterprise.proxy.liteadmin import validate_native_configuration
|
||||
|
||||
validate_native_configuration("http://liteadmin.default.svc:10000", "s" * 32, True, True)
|
||||
|
||||
|
||||
def test_connect_preserves_gateway_prefix_through_login_and_consent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("PROXY_BASE_URL", ORIGIN + "/gateway")
|
||||
with client_for(Worker(), logged_in=False) as client:
|
||||
login: Final = client.get(PATH, follow_redirects=False)
|
||||
assert login.status_code == 303
|
||||
assert login.headers["location"] == (
|
||||
ORIGIN + "/gateway/sso/key/generate?return_to=%2Fgateway%2Fliteadmin%2Fslack%2Fconnect%2F" + TOKEN
|
||||
)
|
||||
worker: Final = Worker()
|
||||
with client_for(worker) as client:
|
||||
csrf: Final = csrf_from(client)
|
||||
connected: Final = client.post(PATH, data={"csrf": csrf}, headers={"Origin": ORIGIN})
|
||||
assert connected.status_code == 200
|
||||
assert worker.session is not None
|
||||
|
|
@ -16,7 +16,7 @@ import json
|
|||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Final, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -1943,3 +1943,48 @@ def test_parse_trusted_redirect_origins_drops_bare_path_entries(monkeypatch):
|
|||
)
|
||||
# The two malformed entries drop out; only the real host survives.
|
||||
assert _parse_trusted_redirect_origins() == ["app.example.com"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"case,expected",
|
||||
[
|
||||
("valid", "alice"),
|
||||
("wrong_user", None),
|
||||
("expired", None),
|
||||
("blocked", None),
|
||||
("missing_key", None),
|
||||
("tampered", None),
|
||||
("invalid_expiry", None),
|
||||
],
|
||||
)
|
||||
async def test_authenticated_browser_identity_checks_embedded_session(
|
||||
monkeypatch: pytest.MonkeyPatch, case: str, expected: str | None
|
||||
) -> None:
|
||||
from datetime import datetime, timezone
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import get_authenticated_browser_user_id
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_bearer_token
|
||||
from litellm.proxy.auth.auth_checks import LITELLM_SESSION_TOKEN_PREFIX
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "local-test-encryption-key")
|
||||
monkeypatch.setattr(proxy_server, "master_key", "local-test-cookie-signing-key-32-bytes")
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
||||
session: Final = UserAPIKeyAuth(
|
||||
user_id="bob" if case == "wrong_user" else "alice",
|
||||
blocked=case == "blocked",
|
||||
expires="invalid"
|
||||
if case == "invalid_expiry"
|
||||
else datetime(2000 if case == "expired" else 2099, 1, 1, tzinfo=timezone.utc),
|
||||
is_session_token=True,
|
||||
)
|
||||
key: Final = encrypt_bearer_token(session.model_dump_json(exclude_none=True), LITELLM_SESSION_TOKEN_PREFIX)
|
||||
cookie: Final = jwt.encode(
|
||||
{"user_id": "alice", "key": None if case == "missing_key" else key, "login_method": "sso", "exp": 9999999999},
|
||||
"different-key-at-least-32-bytes-long" if case == "tampered" else "local-test-cookie-signing-key-32-bytes",
|
||||
algorithm="HS256",
|
||||
)
|
||||
request: Final = Request({"type": "http", "headers": [(b"cookie", ("token=" + cookie).encode())]})
|
||||
assert await get_authenticated_browser_user_id(request) == expected
|
||||
|
|
|
|||
80
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
80
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -9281,6 +9281,24 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/liteadmin/slack/connect/{token}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Connect Page */
|
||||
get: operations["connect_page_liteadmin_slack_connect__token__get"];
|
||||
put?: never;
|
||||
/** Connect Account */
|
||||
post: operations["connect_account_liteadmin_slack_connect__token__post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/litellm/.well-known/litellm-ui-config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -63646,6 +63664,68 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
connect_page_liteadmin_slack_connect__token__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
token: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/html": string;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
connect_account_liteadmin_slack_connect__token__post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
token: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"text/html": string;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_ui_config_litellm__well_known_litellm_ui_config_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue