mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-26 06:18:56 +00:00
Merge branch 'litellm_internal_staging' into litellm_token_verification_query_opt
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
name: Check Lazy OpenAPI Snapshot
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- "litellm_**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
version: "0.10.9"
|
||||
|
||||
- name: Cache uv dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
.venv
|
||||
key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --all-groups --all-extras
|
||||
|
||||
- name: Regenerate snapshot to /tmp
|
||||
id: regen
|
||||
run: |
|
||||
cp litellm/proxy/_lazy_openapi_snapshot.json /tmp/snapshot.committed.json
|
||||
uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
|
||||
mv litellm/proxy/_lazy_openapi_snapshot.json /tmp/snapshot.fresh.json
|
||||
mv /tmp/snapshot.committed.json litellm/proxy/_lazy_openapi_snapshot.json
|
||||
|
||||
- name: Compare
|
||||
id: diff
|
||||
continue-on-error: true
|
||||
run: |
|
||||
diff -q /tmp/snapshot.fresh.json litellm/proxy/_lazy_openapi_snapshot.json
|
||||
|
||||
- name: Mark neutral if drift
|
||||
if: steps.diff.outcome == 'failure'
|
||||
uses: LouisBrunner/checks-action@6b626ffbad7cc56fd58627f774b9067e6118af23 # v2.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
name: lazy-openapi-snapshot
|
||||
conclusion: neutral
|
||||
output: |
|
||||
{
|
||||
"title": "Lazy openapi snapshot is stale",
|
||||
"summary": "Run `python -m litellm.proxy._lazy_openapi_snapshot` and commit the regenerated `litellm/proxy/_lazy_openapi_snapshot.json`. Not blocking — the snapshot will regenerate at release if not committed."
|
||||
}
|
||||
@@ -1425,6 +1425,7 @@ LITELLM_PROXY_ADMIN_NAME = "default_user_id"
|
||||
LITELLM_CLI_SOURCE_IDENTIFIER = "litellm-cli"
|
||||
LITELLM_CLI_SESSION_TOKEN_PREFIX = "litellm-session-token"
|
||||
CLI_SSO_SESSION_CACHE_KEY_PREFIX = "cli_sso_session"
|
||||
CLI_SSO_SESSION_TTL_SECONDS = 600
|
||||
CLI_JWT_TOKEN_NAME = "cli-jwt-token"
|
||||
# Support both CLI_JWT_EXPIRATION_HOURS and LITELLM_CLI_JWT_EXPIRATION_HOURS for backwards compatibility
|
||||
CLI_JWT_EXPIRATION_HOURS = int(
|
||||
|
||||
@@ -23,6 +23,13 @@ def _raise_env_reference_error(param: str, *, source: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_no_callback_env_reference(
|
||||
param: str, value: object, *, source: str
|
||||
) -> None:
|
||||
if _is_env_reference(value):
|
||||
_raise_env_reference_error(param, source=source)
|
||||
|
||||
|
||||
# Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict
|
||||
_supported_callback_params = [
|
||||
"langfuse_public_key",
|
||||
@@ -66,8 +73,9 @@ def initialize_standard_callback_dynamic_params(
|
||||
for param in _supported_callback_params:
|
||||
if param in kwargs:
|
||||
_param_value = kwargs.get(param)
|
||||
if _is_env_reference(_param_value):
|
||||
_raise_env_reference_error(param, source="request body")
|
||||
validate_no_callback_env_reference(
|
||||
param, _param_value, source="request body"
|
||||
)
|
||||
standard_callback_dynamic_params[param] = _param_value # type: ignore
|
||||
|
||||
# 2. Fallback: check "metadata" or "litellm_params" -> "metadata"
|
||||
@@ -80,8 +88,9 @@ def initialize_standard_callback_dynamic_params(
|
||||
for param in _supported_callback_params:
|
||||
if param not in standard_callback_dynamic_params and param in metadata:
|
||||
_param_value = metadata.get(param)
|
||||
if _is_env_reference(_param_value):
|
||||
_raise_env_reference_error(param, source="metadata")
|
||||
validate_no_callback_env_reference(
|
||||
param, _param_value, source="metadata"
|
||||
)
|
||||
standard_callback_dynamic_params[param] = _param_value # type: ignore
|
||||
|
||||
return standard_callback_dynamic_params
|
||||
|
||||
@@ -60,6 +60,9 @@ def _redact_choice_content(choice):
|
||||
def _redact_responses_api_output(output_items):
|
||||
"""Helper to redact ResponsesAPIResponse output items."""
|
||||
for output_item in output_items:
|
||||
if hasattr(output_item, "text"):
|
||||
output_item.text = "redacted-by-litellm"
|
||||
|
||||
if hasattr(output_item, "content") and isinstance(output_item.content, list):
|
||||
for content_part in output_item.content:
|
||||
if hasattr(content_part, "text"):
|
||||
@@ -75,6 +78,28 @@ def _redact_responses_api_output(output_items):
|
||||
summary_item.text = "redacted-by-litellm"
|
||||
|
||||
|
||||
def _redact_responses_api_output_dict(output_items, redacted_str: str):
|
||||
"""Helper to redact ResponsesAPIResponse output items in dict form."""
|
||||
for output_item in output_items:
|
||||
if not isinstance(output_item, dict):
|
||||
continue
|
||||
|
||||
if "text" in output_item:
|
||||
output_item["text"] = redacted_str
|
||||
|
||||
if isinstance(output_item.get("content"), list):
|
||||
for content_item in output_item["content"]:
|
||||
if isinstance(content_item, dict) and "text" in content_item:
|
||||
content_item["text"] = redacted_str
|
||||
|
||||
if output_item.get("type") == "reasoning" and isinstance(
|
||||
output_item.get("summary"), list
|
||||
):
|
||||
for summary_item in output_item["summary"]:
|
||||
if isinstance(summary_item, dict) and "text" in summary_item:
|
||||
summary_item["text"] = redacted_str
|
||||
|
||||
|
||||
def _redact_standard_logging_object(model_call_details: dict):
|
||||
"""Redact messages and response inside standard_logging_object if present."""
|
||||
standard_logging_object = model_call_details.get("standard_logging_object")
|
||||
@@ -93,28 +118,11 @@ def _redact_standard_logging_object(model_call_details: dict):
|
||||
if isinstance(response, dict) and "output" in response:
|
||||
# ResponsesAPIResponse format - redact content in output items
|
||||
if isinstance(response.get("output"), list):
|
||||
for output_item in response["output"]:
|
||||
if isinstance(output_item, dict) and "content" in output_item:
|
||||
if isinstance(output_item["content"], list):
|
||||
for content_item in output_item["content"]:
|
||||
if (
|
||||
isinstance(content_item, dict)
|
||||
and "text" in content_item
|
||||
):
|
||||
content_item["text"] = redacted_str
|
||||
_redact_responses_api_output_dict(response["output"], redacted_str)
|
||||
elif isinstance(response, dict) and "choices" in response:
|
||||
# ModelResponse dict format - redact content in choices
|
||||
if isinstance(response.get("choices"), list):
|
||||
for choice in response["choices"]:
|
||||
if isinstance(choice, dict):
|
||||
if "message" in choice and isinstance(choice["message"], dict):
|
||||
choice["message"]["content"] = redacted_str
|
||||
if "audio" in choice["message"]:
|
||||
choice["message"]["audio"] = None
|
||||
elif "delta" in choice and isinstance(choice["delta"], dict):
|
||||
choice["delta"]["content"] = redacted_str
|
||||
if "audio" in choice["delta"]:
|
||||
choice["delta"]["audio"] = None
|
||||
_redact_model_response_dict_choices(response["choices"], redacted_str)
|
||||
elif isinstance(response, str):
|
||||
standard_logging_object["response"] = redacted_str
|
||||
else:
|
||||
@@ -122,6 +130,29 @@ def _redact_standard_logging_object(model_call_details: dict):
|
||||
standard_logging_object["response"] = {"text": redacted_str}
|
||||
|
||||
|
||||
def _redact_model_response_dict_choices(choices, redacted_str: str):
|
||||
for choice in choices:
|
||||
if isinstance(choice, dict):
|
||||
if "message" in choice and isinstance(choice["message"], dict):
|
||||
choice["message"]["content"] = redacted_str
|
||||
if "reasoning_content" in choice["message"]:
|
||||
choice["message"]["reasoning_content"] = redacted_str
|
||||
if "thinking_blocks" in choice["message"]:
|
||||
choice["message"]["thinking_blocks"] = None
|
||||
if "audio" in choice["message"]:
|
||||
choice["message"]["audio"] = None
|
||||
elif "delta" in choice and isinstance(choice["delta"], dict):
|
||||
choice["delta"]["content"] = redacted_str
|
||||
if "reasoning_content" in choice["delta"]:
|
||||
choice["delta"]["reasoning_content"] = redacted_str
|
||||
if "thinking_blocks" in choice["delta"]:
|
||||
choice["delta"]["thinking_blocks"] = None
|
||||
if "audio" in choice["delta"]:
|
||||
choice["delta"]["audio"] = None
|
||||
else:
|
||||
_redact_choice_content(choice)
|
||||
|
||||
|
||||
def perform_redaction(model_call_details: dict, result):
|
||||
"""
|
||||
Performs the actual redaction on the logging object and result.
|
||||
@@ -132,6 +163,7 @@ def perform_redaction(model_call_details: dict, result):
|
||||
]
|
||||
model_call_details["prompt"] = ""
|
||||
model_call_details["input"] = ""
|
||||
_redact_standard_logging_object(model_call_details)
|
||||
|
||||
# Redact streaming response
|
||||
if (
|
||||
@@ -171,30 +203,14 @@ def perform_redaction(model_call_details: dict, result):
|
||||
elif isinstance(_result, dict) and "choices" in _result:
|
||||
# Handle dict representation of ModelResponse (e.g., from model_dump())
|
||||
if _result.get("choices") is not None:
|
||||
for choice in _result["choices"]:
|
||||
if isinstance(choice, dict):
|
||||
if "message" in choice and isinstance(choice["message"], dict):
|
||||
choice["message"]["content"] = "redacted-by-litellm"
|
||||
if "reasoning_content" in choice["message"]:
|
||||
choice["message"][
|
||||
"reasoning_content"
|
||||
] = "redacted-by-litellm"
|
||||
if "thinking_blocks" in choice["message"]:
|
||||
choice["message"]["thinking_blocks"] = None
|
||||
if "audio" in choice["message"]:
|
||||
choice["message"]["audio"] = None
|
||||
elif "delta" in choice and isinstance(choice["delta"], dict):
|
||||
choice["delta"]["content"] = "redacted-by-litellm"
|
||||
if "reasoning_content" in choice["delta"]:
|
||||
choice["delta"][
|
||||
"reasoning_content"
|
||||
] = "redacted-by-litellm"
|
||||
if "thinking_blocks" in choice["delta"]:
|
||||
choice["delta"]["thinking_blocks"] = None
|
||||
if "audio" in choice["delta"]:
|
||||
choice["delta"]["audio"] = None
|
||||
else:
|
||||
_redact_choice_content(choice)
|
||||
_redact_model_response_dict_choices(
|
||||
_result["choices"], "redacted-by-litellm"
|
||||
)
|
||||
elif isinstance(_result, dict) and "output" in _result:
|
||||
if isinstance(_result.get("output"), list):
|
||||
_redact_responses_api_output_dict(
|
||||
_result["output"], "redacted-by-litellm"
|
||||
)
|
||||
elif isinstance(_result, litellm.ResponsesAPIResponse):
|
||||
if hasattr(_result, "output"):
|
||||
_redact_responses_api_output(_result.output)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
@@ -37,6 +38,11 @@ else:
|
||||
AWSPreparedRequest = Any
|
||||
|
||||
|
||||
# Real AWS region names are lowercase letters, digits, and hyphens
|
||||
# (e.g. "us-east-1", "eu-west-2", "us-gov-west-1", "cn-north-1").
|
||||
_VALID_AWS_REGION_PATTERN = re.compile(r"\A[a-z0-9-]+\Z")
|
||||
|
||||
|
||||
class Boto3CredentialsInfo(BaseModel):
|
||||
credentials: Credentials
|
||||
aws_region_name: str
|
||||
@@ -284,6 +290,9 @@ class BaseAWSLLM:
|
||||
if not region: # Check if region is empty
|
||||
return None
|
||||
|
||||
if not _VALID_AWS_REGION_PATTERN.match(region):
|
||||
return None
|
||||
|
||||
return region
|
||||
except Exception:
|
||||
# Catch any unexpected errors and return None
|
||||
@@ -481,6 +490,7 @@ class BaseAWSLLM:
|
||||
str: The AWS region name
|
||||
"""
|
||||
aws_region_name = optional_params.get("aws_region_name", None)
|
||||
self._validate_aws_region_name(aws_region_name)
|
||||
### SET REGION NAME ###
|
||||
if aws_region_name is None:
|
||||
# check model arn #
|
||||
@@ -519,8 +529,25 @@ class BaseAWSLLM:
|
||||
except Exception:
|
||||
aws_region_name = "us-west-2"
|
||||
|
||||
self._validate_aws_region_name(aws_region_name)
|
||||
return aws_region_name
|
||||
|
||||
@staticmethod
|
||||
def _validate_aws_region_name(aws_region_name: Optional[str]) -> None:
|
||||
"""
|
||||
Validate that an AWS region name conforms to the expected format
|
||||
(lowercase alphanumerics and hyphens). Raises ValueError otherwise.
|
||||
"""
|
||||
if aws_region_name is None:
|
||||
return
|
||||
if not isinstance(aws_region_name, str) or not _VALID_AWS_REGION_PATTERN.match(
|
||||
aws_region_name
|
||||
):
|
||||
raise ValueError(
|
||||
f"Invalid AWS region format: {aws_region_name!r}. "
|
||||
"Region names must contain only lowercase letters, digits, and hyphens."
|
||||
)
|
||||
|
||||
def get_aws_region_name_for_non_llm_api_calls(
|
||||
self,
|
||||
aws_region_name: Optional[str] = None,
|
||||
@@ -532,6 +559,7 @@ class BaseAWSLLM:
|
||||
|
||||
For non-llm api calls eg. Guardrails, Vector Stores we just need to check the dynamic param or env vars.
|
||||
"""
|
||||
self._validate_aws_region_name(aws_region_name)
|
||||
if aws_region_name is None:
|
||||
# check env #
|
||||
litellm_aws_region_name = get_secret("AWS_REGION_NAME", None)
|
||||
@@ -549,6 +577,8 @@ class BaseAWSLLM:
|
||||
|
||||
if aws_region_name is None:
|
||||
aws_region_name = "us-west-2"
|
||||
|
||||
self._validate_aws_region_name(aws_region_name)
|
||||
return aws_region_name
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -25,7 +25,6 @@ else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
MILVUS_OPTIONAL_PARAMS = {
|
||||
"dbName",
|
||||
"annsField",
|
||||
"limit",
|
||||
"filter",
|
||||
@@ -33,7 +32,6 @@ MILVUS_OPTIONAL_PARAMS = {
|
||||
"groupingField",
|
||||
"outputFields",
|
||||
"searchParams",
|
||||
"partitionNames",
|
||||
"consistencyLevel",
|
||||
}
|
||||
|
||||
@@ -173,13 +171,21 @@ class MilvusVectorStoreConfig(BaseVectorStoreConfig):
|
||||
url = f"{api_base}/v2/vectordb/entities/search"
|
||||
|
||||
# Build the request body for Azure AI Search with vector search
|
||||
request_body = {
|
||||
request_body: Dict[str, Any] = {
|
||||
"collectionName": index_name,
|
||||
"data": [query_vector],
|
||||
"annsField": "book_intro_vector",
|
||||
**vector_store_search_optional_params,
|
||||
}
|
||||
|
||||
db_name = litellm_params.get("milvus_db_name")
|
||||
if db_name:
|
||||
request_body["dbName"] = db_name
|
||||
|
||||
partition_names = litellm_params.get("milvus_partition_names")
|
||||
if partition_names:
|
||||
request_body["partitionNames"] = partition_names
|
||||
|
||||
#########################################################
|
||||
# Update logging object with details of the request
|
||||
#########################################################
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast
|
||||
@@ -498,6 +499,82 @@ async def rotate_mcp_server_credentials_master_key(
|
||||
)
|
||||
|
||||
|
||||
def _decode_user_credential(stored: str) -> Optional[str]:
|
||||
"""Read back a value persisted in ``LiteLLM_MCPUserCredentials.credential_b64``.
|
||||
|
||||
Tries nacl decryption first (current write format). Falls back to a
|
||||
plain ``urlsafe_b64decode`` for rows persisted by older code that wrote
|
||||
the credential without encryption. Returns ``None`` when neither path
|
||||
yields a valid string.
|
||||
"""
|
||||
decrypted = decrypt_value_helper(
|
||||
value=stored,
|
||||
key="mcp_user_credential",
|
||||
exception_type="debug",
|
||||
return_original_value=False,
|
||||
)
|
||||
if decrypted is not None:
|
||||
return decrypted
|
||||
try:
|
||||
return base64.urlsafe_b64decode(stored).decode()
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _decode_oauth_payload(stored: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the OAuth2 payload dict if ``stored`` holds one, else ``None``.
|
||||
|
||||
A row is considered an OAuth2 credential iff its decoded value parses as
|
||||
a JSON object with ``"type": "oauth2"``. Plain BYOK credentials (which
|
||||
share the same column) decode to a non-JSON string and return ``None``.
|
||||
"""
|
||||
decoded = _decode_user_credential(stored)
|
||||
if decoded is None:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(decoded)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if isinstance(parsed, dict) and parsed.get("type") == "oauth2":
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
async def rotate_mcp_user_credentials_master_key(
|
||||
prisma_client: PrismaClient, new_master_key: str
|
||||
):
|
||||
"""Re-encrypt every ``LiteLLM_MCPUserCredentials`` row with ``new_master_key``.
|
||||
|
||||
Reads each ``credential_b64`` with the current salt key (falling back to
|
||||
legacy plain base64 for unmigrated rows) and writes it back encrypted
|
||||
under the new master key. Rows that are unreadable under both paths
|
||||
are logged and skipped so one corrupt row does not abort the rotation.
|
||||
"""
|
||||
rows = await prisma_client.db.litellm_mcpusercredentials.find_many()
|
||||
for row in rows:
|
||||
plaintext = _decode_user_credential(row.credential_b64)
|
||||
if plaintext is None:
|
||||
verbose_proxy_logger.warning(
|
||||
"rotate_mcp_user_credentials_master_key: could not decode "
|
||||
"credential for user_id=%s server_id=%s, skipping",
|
||||
row.user_id,
|
||||
row.server_id,
|
||||
)
|
||||
continue
|
||||
re_encrypted = encrypt_value_helper(
|
||||
plaintext, new_encryption_key=new_master_key
|
||||
)
|
||||
await prisma_client.db.litellm_mcpusercredentials.update(
|
||||
where={
|
||||
"user_id_server_id": {
|
||||
"user_id": row.user_id,
|
||||
"server_id": row.server_id,
|
||||
}
|
||||
},
|
||||
data={"credential_b64": re_encrypted},
|
||||
)
|
||||
|
||||
|
||||
async def store_user_credential(
|
||||
prisma_client: PrismaClient,
|
||||
user_id: str,
|
||||
@@ -506,7 +583,7 @@ async def store_user_credential(
|
||||
) -> None:
|
||||
"""Store a user credential for a BYOK MCP server."""
|
||||
|
||||
encoded = base64.urlsafe_b64encode(credential.encode()).decode()
|
||||
encoded = encrypt_value_helper(credential)
|
||||
await prisma_client.db.litellm_mcpusercredentials.upsert(
|
||||
where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}},
|
||||
data={
|
||||
@@ -532,16 +609,7 @@ async def get_user_credential(
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
return base64.urlsafe_b64decode(row.credential_b64).decode()
|
||||
except Exception:
|
||||
# Fall back to nacl decryption for credentials stored by older code
|
||||
return decrypt_value_helper(
|
||||
value=row.credential_b64,
|
||||
key="byok_credential",
|
||||
exception_type="debug",
|
||||
return_original_value=False,
|
||||
)
|
||||
return _decode_user_credential(row.credential_b64)
|
||||
|
||||
|
||||
async def has_user_credential(
|
||||
@@ -582,7 +650,7 @@ async def store_user_oauth_credential(
|
||||
) -> None:
|
||||
"""Persist an OAuth2 access token for a user+server pair.
|
||||
|
||||
The payload is JSON-serialised and stored base64-encoded in the same
|
||||
The payload is JSON-serialised and stored encrypted in the same
|
||||
``credential_b64`` column used by BYOK. A ``"type": "oauth2"`` key
|
||||
differentiates it from plain BYOK API keys.
|
||||
"""
|
||||
@@ -606,29 +674,27 @@ async def store_user_oauth_credential(
|
||||
payload["scopes"] = scopes
|
||||
|
||||
# Guard against silently overwriting a BYOK credential with an OAuth token.
|
||||
# BYOK credentials lack a "type" field (or use a non-"oauth2" type).
|
||||
# Skip the guard when the caller knows the row is already an OAuth2 credential
|
||||
# (e.g. during token refresh), saving an extra DB round-trip.
|
||||
if not skip_byok_guard:
|
||||
existing = await prisma_client.db.litellm_mcpusercredentials.find_unique(
|
||||
where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}}
|
||||
)
|
||||
if existing is not None:
|
||||
_byok_error = ValueError(
|
||||
f"A non-OAuth2 credential already exists for user {user_id} "
|
||||
f"and server {server_id}. Refusing to overwrite."
|
||||
if (
|
||||
existing is not None
|
||||
and _decode_oauth_payload(existing.credential_b64) is None
|
||||
):
|
||||
# Existing row is either a BYOK secret or an OAuth2 row that no
|
||||
# longer decrypts (e.g. after a salt-key rotation). In either
|
||||
# case, refuse to overwrite — the caller would clobber data
|
||||
# that may still be recoverable.
|
||||
raise ValueError(
|
||||
f"Existing credential for user {user_id} and server "
|
||||
f"{server_id} could not be verified as an OAuth2 token. "
|
||||
f"Refusing to overwrite."
|
||||
)
|
||||
try:
|
||||
raw = json.loads(
|
||||
base64.urlsafe_b64decode(existing.credential_b64).decode()
|
||||
)
|
||||
except Exception:
|
||||
# Credential is not base64+JSON — it's a plain-text BYOK key.
|
||||
raise _byok_error
|
||||
if raw.get("type") != "oauth2":
|
||||
raise _byok_error
|
||||
|
||||
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
||||
encoded = encrypt_value_helper(json.dumps(payload))
|
||||
await prisma_client.db.litellm_mcpusercredentials.upsert(
|
||||
where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}},
|
||||
data={
|
||||
@@ -672,15 +738,7 @@ async def get_user_oauth_credential(
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(row.credential_b64).decode()
|
||||
parsed = json.loads(decoded)
|
||||
if isinstance(parsed, dict) and parsed.get("type") == "oauth2":
|
||||
return parsed
|
||||
# Row exists but is a BYOK (plain string), not an OAuth token
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return _decode_oauth_payload(row.credential_b64)
|
||||
|
||||
|
||||
async def list_user_oauth_credentials(
|
||||
@@ -694,14 +752,11 @@ async def list_user_oauth_credentials(
|
||||
)
|
||||
results: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(row.credential_b64).decode()
|
||||
parsed = json.loads(decoded)
|
||||
if isinstance(parsed, dict) and parsed.get("type") == "oauth2":
|
||||
parsed["server_id"] = row.server_id
|
||||
results.append(parsed)
|
||||
except Exception:
|
||||
pass # Skip non-OAuth rows (BYOK plain strings)
|
||||
payload = _decode_oauth_payload(row.credential_b64)
|
||||
if payload is None:
|
||||
continue
|
||||
payload["server_id"] = row.server_id
|
||||
results.append(payload)
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from litellm.constants import (
|
||||
MCP_TOOL_LISTING_TIMEOUT,
|
||||
)
|
||||
from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get
|
||||
from litellm.experimental_mcp_client.client import MCPClient, MCPSigV4Auth
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
@@ -1499,6 +1500,47 @@ class MCPServerManager:
|
||||
)
|
||||
return await client.get_prompt(get_prompt_request_params)
|
||||
|
||||
@staticmethod
|
||||
def _is_same_authority_metadata_url(url: str, server_url: str) -> bool:
|
||||
"""
|
||||
Whether ``url`` shares scheme, host, and port with ``server_url``.
|
||||
|
||||
Same-authority metadata URLs are produced by our well-known discovery
|
||||
construction and by resource servers that publish protected-resource
|
||||
metadata on the resource origin. These must keep working for
|
||||
administrator-configured internal MCP servers, so they are fetched
|
||||
directly. Cross-origin URLs are fetched through ``async_safe_get``.
|
||||
"""
|
||||
try:
|
||||
target = urlparse(url)
|
||||
base = urlparse(server_url)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if target.scheme not in ("http", "https") or not target.hostname:
|
||||
return False
|
||||
|
||||
target_port = target.port or (443 if target.scheme == "https" else 80)
|
||||
base_port = base.port or (443 if base.scheme == "https" else 80)
|
||||
return (
|
||||
base.scheme == target.scheme
|
||||
and (base.hostname or "").lower() == target.hostname.lower()
|
||||
and base_port == target_port
|
||||
)
|
||||
|
||||
async def _fetch_oauth_discovery_url(self, url: str, server_url: str) -> Any:
|
||||
client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.MCP,
|
||||
params={"timeout": MCP_METADATA_TIMEOUT},
|
||||
)
|
||||
if self._is_same_authority_metadata_url(url, server_url):
|
||||
# Same-authority URLs may point at administrator-configured
|
||||
# internal MCP servers. Do not run them through user URL
|
||||
# validation, but also do not follow redirects because the
|
||||
# redirect target would not inherit the same-authority guarantee.
|
||||
return await client.get(url, follow_redirects=False)
|
||||
return await async_safe_get(client, url)
|
||||
|
||||
async def _descovery_metadata(
|
||||
self,
|
||||
server_url: str,
|
||||
@@ -1514,7 +1556,7 @@ class MCPServerManager:
|
||||
resource_scopes,
|
||||
) = await self._attempt_well_known_discovery(server_url)
|
||||
metadata = await self._fetch_authorization_server_metadata(
|
||||
authorization_servers
|
||||
authorization_servers, server_url
|
||||
)
|
||||
if (
|
||||
metadata is None
|
||||
@@ -1555,7 +1597,7 @@ class MCPServerManager:
|
||||
authorization_servers,
|
||||
resource_scopes,
|
||||
) = await self._fetch_oauth_metadata_from_resource(
|
||||
resource_metadata_url
|
||||
resource_metadata_url, server_url
|
||||
)
|
||||
else:
|
||||
(
|
||||
@@ -1576,7 +1618,7 @@ class MCPServerManager:
|
||||
|
||||
if authorization_servers:
|
||||
metadata = await self._fetch_authorization_server_metadata(
|
||||
authorization_servers
|
||||
authorization_servers, server_url
|
||||
)
|
||||
|
||||
preferred_scopes = scopes or resource_scopes
|
||||
@@ -1616,19 +1658,26 @@ class MCPServerManager:
|
||||
return resource_metadata_url, scopes
|
||||
|
||||
async def _fetch_oauth_metadata_from_resource(
|
||||
self, resource_metadata_url: str
|
||||
self, resource_metadata_url: str, server_url: str
|
||||
) -> Tuple[List[str], Optional[List[str]]]:
|
||||
if not resource_metadata_url:
|
||||
return [], None
|
||||
|
||||
try:
|
||||
client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.MCP,
|
||||
params={"timeout": MCP_METADATA_TIMEOUT},
|
||||
response = await self._fetch_oauth_discovery_url(
|
||||
resource_metadata_url, server_url
|
||||
)
|
||||
response = await client.get(resource_metadata_url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except SSRFError as exc:
|
||||
verbose_logger.warning(
|
||||
"MCP OAuth discovery: refusing to fetch resource metadata from %s "
|
||||
"(rejected by SSRF guard for server %s): %s",
|
||||
resource_metadata_url,
|
||||
server_url,
|
||||
exc,
|
||||
)
|
||||
return [], None
|
||||
except Exception as exc: # pragma: no cover - network issues
|
||||
verbose_logger.debug(
|
||||
"Failed to fetch MCP OAuth metadata from %s: %s",
|
||||
@@ -1677,23 +1726,25 @@ class MCPServerManager:
|
||||
(
|
||||
authorization_servers,
|
||||
scopes,
|
||||
) = await self._fetch_oauth_metadata_from_resource(url)
|
||||
) = await self._fetch_oauth_metadata_from_resource(url, server_url)
|
||||
if authorization_servers:
|
||||
return authorization_servers, scopes
|
||||
|
||||
return [], None
|
||||
|
||||
async def _fetch_authorization_server_metadata(
|
||||
self, authorization_servers: List[str]
|
||||
self, authorization_servers: List[str], server_url: str
|
||||
) -> Optional[MCPOAuthMetadata]:
|
||||
for issuer in authorization_servers:
|
||||
metadata = await self._fetch_single_authorization_server_metadata(issuer)
|
||||
metadata = await self._fetch_single_authorization_server_metadata(
|
||||
issuer, server_url
|
||||
)
|
||||
if metadata is not None:
|
||||
return metadata
|
||||
return None
|
||||
|
||||
async def _fetch_single_authorization_server_metadata(
|
||||
self, issuer_url: str
|
||||
self, issuer_url: str, server_url: str
|
||||
) -> Optional[MCPOAuthMetadata]:
|
||||
try:
|
||||
parsed = urlparse(issuer_url)
|
||||
@@ -1721,13 +1772,18 @@ class MCPServerManager:
|
||||
|
||||
for url in candidate_urls:
|
||||
try:
|
||||
client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.MCP,
|
||||
params={"timeout": MCP_METADATA_TIMEOUT},
|
||||
)
|
||||
response = await client.get(url)
|
||||
response = await self._fetch_oauth_discovery_url(url, server_url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except SSRFError as exc:
|
||||
verbose_logger.warning(
|
||||
"MCP OAuth discovery: refusing to fetch authorization-server "
|
||||
"metadata from %s (rejected by SSRF guard for server %s): %s",
|
||||
url,
|
||||
server_url,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
except Exception as exc: # pragma: no cover - network issues
|
||||
verbose_logger.debug(
|
||||
"Failed to fetch authorization metadata from %s: %s",
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Lazy registration for optional feature routers. Each LAZY_FEATURES entry
|
||||
imports its module only on the first request matching its path prefix,
|
||||
saving ~700 MB at idle for deployments that don't use these features.
|
||||
First hit pays the import cost (1-3 s for heavy modules); /openapi.json
|
||||
omits each feature's routes until the feature is warmed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Tuple
|
||||
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
|
||||
def _include_router(attr_name: str = "router") -> Callable[["FastAPI", object], None]:
|
||||
def _register(app: "FastAPI", module: object) -> None:
|
||||
app.include_router(getattr(module, attr_name))
|
||||
|
||||
return _register
|
||||
|
||||
|
||||
def _mount_app(
|
||||
prefix: str, attr_name: str = "app"
|
||||
) -> Callable[["FastAPI", object], None]:
|
||||
def _register(app: "FastAPI", module: object) -> None:
|
||||
app.mount(path=prefix, app=getattr(module, attr_name))
|
||||
|
||||
return _register
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LazyFeature:
|
||||
name: str
|
||||
module_path: str
|
||||
path_prefixes: Tuple[str, ...]
|
||||
register_fn: Callable[["FastAPI", object], None] = field(
|
||||
default_factory=lambda: _include_router("router")
|
||||
)
|
||||
# For routes whose path has a leading parameter (e.g. /{server}/authorize)
|
||||
# — startswith can't match those, so the matcher also checks endswith.
|
||||
path_suffixes: Tuple[str, ...] = ()
|
||||
# Keep the stub injected even after load — for mounted ASGI sub-apps
|
||||
# whose routes don't appear in the parent app's openapi spec.
|
||||
persistent_swagger_stub: bool = False
|
||||
|
||||
|
||||
LAZY_FEATURES: Tuple[LazyFeature, ...] = (
|
||||
LazyFeature(
|
||||
name="guardrails",
|
||||
module_path="litellm.proxy.guardrails.guardrail_endpoints",
|
||||
path_prefixes=(
|
||||
"/guardrails",
|
||||
"/v2/guardrails",
|
||||
"/apply_guardrail",
|
||||
"/policies/usage",
|
||||
),
|
||||
),
|
||||
LazyFeature(
|
||||
name="policies",
|
||||
module_path="litellm.proxy.management_endpoints.policy_endpoints",
|
||||
# Trailing slash to avoid matching /policies/... (policy_engine).
|
||||
path_prefixes=("/policy/", "/utils/test_policies_and_guardrails"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="policy_engine",
|
||||
module_path="litellm.proxy.policy_engine.policy_endpoints",
|
||||
path_prefixes=("/policies",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="policy_resolve",
|
||||
module_path="litellm.proxy.policy_engine.policy_resolve_endpoints",
|
||||
path_prefixes=("/policies/resolve", "/policies/attachments/estimate-impact"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="agents",
|
||||
module_path="litellm.proxy.agent_endpoints.endpoints",
|
||||
path_prefixes=("/v1/agents", "/agents", "/agent/"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="a2a",
|
||||
module_path="litellm.proxy.agent_endpoints.a2a_endpoints",
|
||||
path_prefixes=("/a2a", "/v1/a2a"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="vector_stores",
|
||||
module_path="litellm.proxy.vector_store_endpoints.endpoints",
|
||||
path_prefixes=("/v1/vector_stores", "/vector_stores", "/v1/indexes"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="vector_store_management",
|
||||
module_path="litellm.proxy.vector_store_endpoints.management_endpoints",
|
||||
# Trailing slash to avoid matching /vector_stores/... (vector_stores).
|
||||
path_prefixes=("/vector_store/", "/v1/vector_store/"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="vector_store_files",
|
||||
# Routes appear under both /v1/vector_stores/{id}/files and the
|
||||
# un-versioned form, so both prefixes must trigger the load.
|
||||
module_path="litellm.proxy.vector_store_files_endpoints.endpoints",
|
||||
path_prefixes=("/v1/vector_stores", "/vector_stores"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="tools",
|
||||
module_path="litellm.proxy.management_endpoints.tool_management_endpoints",
|
||||
path_prefixes=("/v1/tool", "/tool"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="search_tools",
|
||||
module_path="litellm.proxy.search_endpoints.search_tool_management",
|
||||
path_prefixes=("/search_tools",),
|
||||
),
|
||||
# mcp_management owns most /v1/mcp/* admin routes; mcp_app is the mounted
|
||||
# streaming sub-app at /mcp.
|
||||
LazyFeature(
|
||||
name="mcp_management",
|
||||
module_path="litellm.proxy.management_endpoints.mcp_management_endpoints",
|
||||
path_prefixes=("/v1/mcp/",),
|
||||
),
|
||||
LazyFeature(
|
||||
# Also serves /.well-known/oauth-* (OAuth metadata discovery).
|
||||
# No /mcp/oauth prefix here: the mounted /mcp sub-app would
|
||||
# shadow it, and there are no actual routes there anyway.
|
||||
name="mcp_byok_oauth",
|
||||
module_path="litellm.proxy._experimental.mcp_server.byok_oauth_endpoints",
|
||||
path_prefixes=("/v1/mcp/oauth", "/.well-known/oauth-"),
|
||||
),
|
||||
LazyFeature(
|
||||
# Serves OAuth dance endpoints (/authorize, /token, /callback,
|
||||
# /register) plus several /.well-known/ discovery URLs at the proxy
|
||||
# root — needed for MCP-over-OAuth flows even before /mcp is hit.
|
||||
name="mcp_discoverable",
|
||||
module_path="litellm.proxy._experimental.mcp_server.discoverable_endpoints",
|
||||
path_prefixes=(
|
||||
"/.well-known/oauth-",
|
||||
"/.well-known/openid-configuration",
|
||||
"/.well-known/jwks.json",
|
||||
"/authorize",
|
||||
"/token",
|
||||
"/callback",
|
||||
"/register",
|
||||
),
|
||||
# Catches the /{mcp_server_name}/authorize|token|register variants.
|
||||
path_suffixes=("/authorize", "/token", "/register"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="mcp_rest",
|
||||
module_path="litellm.proxy._experimental.mcp_server.rest_endpoints",
|
||||
path_prefixes=("/mcp-rest",),
|
||||
),
|
||||
LazyFeature(
|
||||
# Hardcoded /mcp matches BASE_MCP_ROUTE; importing the constant
|
||||
# here would defeat lazy loading.
|
||||
name="mcp_app",
|
||||
module_path="litellm.proxy._experimental.mcp_server.server",
|
||||
path_prefixes=("/mcp",),
|
||||
register_fn=_mount_app("/mcp", attr_name="app"),
|
||||
persistent_swagger_stub=True,
|
||||
),
|
||||
LazyFeature(
|
||||
name="config_overrides",
|
||||
module_path="litellm.proxy.management_endpoints.config_override_endpoints",
|
||||
path_prefixes=("/config_overrides",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="realtime",
|
||||
module_path="litellm.proxy.realtime_endpoints.endpoints",
|
||||
path_prefixes=("/openai/v1/realtime", "/v1/realtime", "/realtime"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="anthropic_passthrough",
|
||||
module_path="litellm.proxy.anthropic_endpoints.endpoints",
|
||||
path_prefixes=("/v1/messages", "/anthropic", "/api/event_logging"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="anthropic_skills",
|
||||
module_path="litellm.proxy.anthropic_endpoints.skills_endpoints",
|
||||
path_prefixes=("/v1/skills", "/skills"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="langfuse_passthrough",
|
||||
module_path="litellm.proxy.vertex_ai_endpoints.langfuse_endpoints",
|
||||
path_prefixes=("/langfuse",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="evals",
|
||||
module_path="litellm.proxy.openai_evals_endpoints.endpoints",
|
||||
path_prefixes=("/v1/evals", "/evals"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="claude_code_marketplace",
|
||||
module_path="litellm.proxy.anthropic_endpoints.claude_code_endpoints",
|
||||
path_prefixes=("/claude-code",),
|
||||
register_fn=_include_router("claude_code_marketplace_router"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="scim",
|
||||
module_path="litellm.proxy.management_endpoints.scim.scim_v2",
|
||||
path_prefixes=("/scim",),
|
||||
register_fn=_include_router("scim_router"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="cloudzero",
|
||||
module_path="litellm.proxy.spend_tracking.cloudzero_endpoints",
|
||||
path_prefixes=("/cloudzero",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="vantage",
|
||||
module_path="litellm.proxy.spend_tracking.vantage_endpoints",
|
||||
path_prefixes=("/vantage",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="usage_ai",
|
||||
module_path="litellm.proxy.management_endpoints.usage_endpoints",
|
||||
path_prefixes=("/usage/ai",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="prompts",
|
||||
module_path="litellm.proxy.prompts.prompt_endpoints",
|
||||
path_prefixes=("/prompts", "/utils/dotprompt_json_converter"),
|
||||
),
|
||||
LazyFeature(
|
||||
name="jwt_mappings",
|
||||
module_path="litellm.proxy.management_endpoints.jwt_key_mapping_endpoints",
|
||||
path_prefixes=("/jwt/key/mapping",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="compliance",
|
||||
module_path="litellm.proxy.management_endpoints.compliance_endpoints",
|
||||
path_prefixes=("/compliance",),
|
||||
),
|
||||
LazyFeature(
|
||||
name="access_groups",
|
||||
module_path="litellm.proxy.management_endpoints.access_group_endpoints",
|
||||
path_prefixes=("/access_group", "/v1/access_group", "/v1/unified_access_group"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class LazyFeatureMiddleware:
|
||||
"""ASGI middleware that imports + registers a feature router on first
|
||||
matching request. Idempotent; once loaded, subsequent requests skip."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
fastapi_app: "FastAPI",
|
||||
features: Tuple[LazyFeature, ...] = LAZY_FEATURES,
|
||||
):
|
||||
self.app = app
|
||||
self._fastapi_app = fastapi_app
|
||||
self._features = features
|
||||
# Loaded set / per-feature locks live on app.state so the warm endpoint
|
||||
# and the middleware share them — preventing duplicate registrations
|
||||
# when both paths fire for the same feature.
|
||||
if not hasattr(fastapi_app.state, "lazy_loaded"):
|
||||
fastapi_app.state.lazy_loaded = set()
|
||||
fastapi_app.state.lazy_locks = {}
|
||||
|
||||
@property
|
||||
def _loaded(self) -> set:
|
||||
return self._fastapi_app.state.lazy_loaded
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
# Short-circuit once every feature has loaded.
|
||||
if scope["type"] in ("http", "websocket") and len(self._loaded) < len(
|
||||
self._features
|
||||
):
|
||||
path = scope.get("path", "")
|
||||
for feat in self._features:
|
||||
if feat.module_path in self._loaded:
|
||||
continue
|
||||
if any(path.startswith(p) for p in feat.path_prefixes) or any(
|
||||
path.endswith(s) for s in feat.path_suffixes
|
||||
):
|
||||
await _force_load(self._fastapi_app, feat)
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
async def _force_load(app: "FastAPI", feat: LazyFeature) -> bool:
|
||||
"""Import + register a lazy feature exactly once per (app, module).
|
||||
Shared by the middleware and the /lazy/warm endpoint."""
|
||||
if not hasattr(app.state, "lazy_loaded"):
|
||||
app.state.lazy_loaded = set()
|
||||
app.state.lazy_locks = {}
|
||||
lock = app.state.lazy_locks.setdefault(feat.module_path, asyncio.Lock())
|
||||
async with lock:
|
||||
if feat.module_path in app.state.lazy_loaded:
|
||||
return False
|
||||
try:
|
||||
# Import on a thread (heavy modules take 1-3 s). register_fn
|
||||
# mutates app.router.routes, so it stays on the loop thread.
|
||||
loop = asyncio.get_running_loop()
|
||||
module = await loop.run_in_executor(
|
||||
None, importlib.import_module, feat.module_path
|
||||
)
|
||||
feat.register_fn(app, module)
|
||||
app.state.lazy_loaded.add(feat.module_path)
|
||||
app.openapi_schema = None
|
||||
verbose_proxy_logger.info(
|
||||
"Lazy-loaded optional feature %r (module: %s)",
|
||||
feat.name,
|
||||
feat.module_path,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
# Mark loaded anyway so we don't retry on every request.
|
||||
app.state.lazy_loaded.add(feat.module_path)
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to lazy-load optional feature %r (module: %s): %s. "
|
||||
"This feature's endpoints will return 404 until restart.",
|
||||
feat.name,
|
||||
feat.module_path,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def attach_lazy_features(app: "FastAPI") -> None:
|
||||
app.include_router(_make_warmup_router(app))
|
||||
app.add_middleware(LazyFeatureMiddleware, fastapi_app=app)
|
||||
|
||||
|
||||
def _make_warmup_router(app: "FastAPI") -> "APIRouter":
|
||||
"""POST /lazy/warm/{name}: load a feature and return its partial openapi
|
||||
so the Swagger plugin can merge in-place without a full /openapi.json refetch.
|
||||
Requires auth — anyone who can hit the proxy can already trigger the same
|
||||
imports by sending a real request to a feature's prefix, but gating this
|
||||
debug endpoint avoids unauthenticated callers forcing the import chain."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post(
|
||||
"/lazy/warm/{name}",
|
||||
include_in_schema=False,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def warm(name: str):
|
||||
feat = next((f for f in LAZY_FEATURES if f.name == name), None)
|
||||
if feat is None:
|
||||
raise HTTPException(404, f"unknown lazy feature: {name}")
|
||||
if feat.persistent_swagger_stub:
|
||||
return {"stub_path": None, "paths": {}, "components": {"schemas": {}}}
|
||||
|
||||
await _force_load(app, feat)
|
||||
|
||||
feat_routes = [
|
||||
r
|
||||
for r in app.routes
|
||||
if any(getattr(r, "path", "").startswith(p) for p in feat.path_prefixes)
|
||||
]
|
||||
full = get_openapi(title=app.title, version=app.version, routes=feat_routes)
|
||||
# Force all operations under one tag so they group under a single Swagger
|
||||
# section — many lazy modules tag routes inconsistently.
|
||||
for path_ops in full.get("paths", {}).values():
|
||||
for op in path_ops.values():
|
||||
if isinstance(op, dict):
|
||||
op["tags"] = [feat.name]
|
||||
return {
|
||||
"stub_path": feat.path_prefixes[0],
|
||||
"paths": full.get("paths", {}),
|
||||
"components": {"schemas": full.get("components", {}).get("schemas", {})},
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def inject_lazy_stubs(schema: Dict) -> Dict:
|
||||
"""Inject openapi entries for unloaded features. Uses the snapshot file
|
||||
when available (full route info), otherwise falls back to a single
|
||||
placeholder per feature. Any failure logs and returns the schema unchanged
|
||||
so /openapi.json never 500s on a cosmetic injection bug."""
|
||||
try:
|
||||
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
|
||||
|
||||
snapshot = load_snapshot()
|
||||
paths = schema.setdefault("paths", {})
|
||||
schemas = schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
|
||||
for feat in LAZY_FEATURES:
|
||||
if feat.module_path in sys.modules and not feat.persistent_swagger_stub:
|
||||
continue
|
||||
|
||||
fragment = (snapshot or {}).get(feat.name)
|
||||
if fragment:
|
||||
for p, ops in fragment.get("paths", {}).items():
|
||||
paths.setdefault(p, ops)
|
||||
for name, sch in (
|
||||
fragment.get("components", {}).get("schemas", {}).items()
|
||||
):
|
||||
schemas.setdefault(name, sch)
|
||||
continue
|
||||
|
||||
prefix = feat.path_prefixes[0]
|
||||
if prefix in paths:
|
||||
continue
|
||||
paths[prefix] = {
|
||||
"get": {
|
||||
"tags": [feat.name],
|
||||
"summary": feat.name,
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
}
|
||||
}
|
||||
except Exception as exc:
|
||||
verbose_proxy_logger.warning("inject_lazy_stubs failed: %s", exc)
|
||||
return schema
|
||||
|
||||
|
||||
def lazy_tag_to_prefix() -> Dict[str, str]:
|
||||
"""feature.name -> first prefix, used by the Swagger warmup JS plugin.
|
||||
Returns empty when the snapshot is loaded — the plugin is unnecessary
|
||||
because /openapi.json already has full route info."""
|
||||
from litellm.proxy._lazy_openapi_snapshot import load_snapshot
|
||||
|
||||
if load_snapshot():
|
||||
return {}
|
||||
return {
|
||||
feat.name: feat.path_prefixes[0]
|
||||
for feat in LAZY_FEATURES
|
||||
if not feat.persistent_swagger_stub
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Per-feature OpenAPI snapshot for lazy-loaded routers.
|
||||
|
||||
The committed JSON is generated by `python -m litellm.proxy._lazy_openapi_snapshot`
|
||||
and consumed at runtime so /openapi.json can show full route info for unloaded
|
||||
features without importing them. CI verifies the file is current and surfaces
|
||||
any drift as a neutral check.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
SNAPSHOT_FILE = Path(__file__).parent / "_lazy_openapi_snapshot.json"
|
||||
|
||||
|
||||
def load_snapshot() -> Optional[Dict[str, Dict]]:
|
||||
if not SNAPSHOT_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
with SNAPSHOT_FILE.open() as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def generate_snapshot() -> Dict[str, Dict]:
|
||||
import importlib
|
||||
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
for feat in LAZY_FEATURES:
|
||||
if feat.module_path in sys.modules:
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module(feat.module_path)
|
||||
feat.register_fn(app, module)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
|
||||
|
||||
fragments: Dict[str, Dict] = {}
|
||||
for feat in LAZY_FEATURES:
|
||||
feat_routes = [
|
||||
r
|
||||
for r in app.routes
|
||||
if any(getattr(r, "path", "").startswith(p) for p in feat.path_prefixes)
|
||||
]
|
||||
if not feat_routes:
|
||||
continue
|
||||
full = get_openapi(title=app.title, version=app.version, routes=feat_routes)
|
||||
# Group all of a feature's routes under one tag.
|
||||
for path_ops in full.get("paths", {}).values():
|
||||
for op in path_ops.values():
|
||||
if isinstance(op, dict):
|
||||
op["tags"] = [feat.name]
|
||||
fragments[feat.name] = {
|
||||
"paths": full.get("paths", {}),
|
||||
"components": {"schemas": full.get("components", {}).get("schemas", {})},
|
||||
}
|
||||
return fragments
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fragments = generate_snapshot()
|
||||
SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n")
|
||||
sys.stdout.write(f"wrote {len(fragments)} feature fragments to {SNAPSHOT_FILE}\n")
|
||||
@@ -17,6 +17,9 @@ from typing_extensions import Required, TypedDict
|
||||
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import MCP_STDIO_ALLOWED_COMMANDS
|
||||
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
||||
validate_no_callback_env_reference,
|
||||
)
|
||||
from litellm.types.integrations.slack_alerting import AlertType
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
@@ -1869,8 +1872,10 @@ class AddTeamCallback(LiteLLMPydanticObjectBase):
|
||||
raise ValueError(
|
||||
f"Invalid callback variable: {key}. Must be one of {valid_keys}"
|
||||
)
|
||||
if not isinstance(value, str):
|
||||
callback_vars[key] = str(value)
|
||||
callback_vars[key] = str(value)
|
||||
validate_no_callback_env_reference(
|
||||
key, callback_vars[key], source="key/team callback metadata"
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
@@ -2156,8 +2161,8 @@ class PassThroughGenericEndpoint(LiteLLMPydanticObjectBase):
|
||||
description="The USD cost per request to the target endpoint. This is used to calculate the cost of the request to the target endpoint.",
|
||||
)
|
||||
auth: bool = Field(
|
||||
default=False,
|
||||
description="Whether authentication is required for the pass-through endpoint. If True, requests to the endpoint will require a valid LiteLLM API key.",
|
||||
default=True,
|
||||
description="Whether authentication is required for the pass-through endpoint. Defaults to True so a pass-through silently created without an explicit value still requires a valid LiteLLM API key — set to False only if the endpoint is meant to be a public forwarder (e.g. an unauthenticated webhook target).",
|
||||
)
|
||||
guardrails: Optional[PassThroughGuardrailsConfig] = Field(
|
||||
default=None,
|
||||
|
||||
@@ -473,7 +473,12 @@ async def check_api_key_for_custom_headers_or_pass_through_endpoints(
|
||||
for endpoint in pass_through_endpoints:
|
||||
if isinstance(endpoint, dict) and endpoint.get("path", "") == route:
|
||||
## IF AUTH DISABLED
|
||||
if endpoint.get("auth") is not True:
|
||||
# Default to True: a config dict with no ``auth`` key
|
||||
# otherwise produced an unauthenticated forwarder. The
|
||||
# Pydantic ``PassThroughGenericEndpoint.auth`` default
|
||||
# is also True, but raw config dicts skip that path —
|
||||
# so this runtime check has to default to True too.
|
||||
if endpoint.get("auth", True) is not True:
|
||||
return UserAPIKeyAuth()
|
||||
## IF AUTH ENABLED
|
||||
### IF CUSTOM PARSER REQUIRED
|
||||
|
||||
@@ -313,23 +313,24 @@ sequenceDiagram
|
||||
participant Proxy as LiteLLM Proxy
|
||||
participant SSO as SSO Provider
|
||||
|
||||
CLI->>CLI: Generate key ID (sk-uuid)
|
||||
CLI->>Browser: Open /sso/key/generate?source=litellm-cli&key=sk-uuid
|
||||
CLI->>Proxy: POST /sso/cli/start
|
||||
Proxy->>CLI: Return login_id, poll_secret, user_code
|
||||
CLI->>Browser: Open /sso/key/generate?source=litellm-cli&key=login_id
|
||||
|
||||
Browser->>Proxy: GET /sso/key/generate?source=litellm-cli&key=sk-uuid
|
||||
Proxy->>Proxy: Set cli_state = litellm-session-token:sk-uuid
|
||||
Proxy->>SSO: Redirect with state=litellm-session-token:sk-uuid
|
||||
Browser->>Proxy: GET /sso/key/generate?source=litellm-cli&key=login_id
|
||||
Proxy->>Proxy: Set cli_state = litellm-session-token:login_id
|
||||
Proxy->>SSO: Redirect with state=litellm-session-token:login_id
|
||||
|
||||
SSO->>Browser: Show login page
|
||||
Browser->>SSO: User authenticates
|
||||
SSO->>Proxy: Redirect to /sso/callback?state=litellm-session-token:sk-uuid
|
||||
SSO->>Proxy: Redirect to /sso/callback?state=litellm-session-token:login_id
|
||||
|
||||
Proxy->>Proxy: Check if state starts with "litellm-session-token:"
|
||||
Proxy->>Proxy: Generate API key with ID=sk-uuid
|
||||
Proxy->>Browser: Show success page
|
||||
Proxy->>Browser: Prompt for user_code
|
||||
Browser->>Proxy: POST /sso/cli/complete/login_id
|
||||
|
||||
CLI->>Proxy: Poll /sso/cli/poll/sk-uuid
|
||||
Proxy->>CLI: Return {"status": "ready", "key": "sk-uuid"}
|
||||
CLI->>Proxy: Poll /sso/cli/poll/login_id with poll_secret header
|
||||
Proxy->>CLI: Return {"status": "ready", "key": "jwt"}
|
||||
CLI->>CLI: Save key to ~/.litellm/token.json
|
||||
```
|
||||
|
||||
@@ -343,13 +344,13 @@ The CLI provides three authentication commands:
|
||||
|
||||
### Authentication Flow Steps
|
||||
|
||||
1. **Generate Session ID**: CLI generates a unique key ID (`sk-{uuid}`)
|
||||
2. **Open Browser**: CLI opens browser to `/sso/key/generate` with CLI source and key parameters
|
||||
3. **SSO Redirect**: Proxy sets the formatted state (`litellm-session-token:sk-uuid`) as OAuth state parameter and redirects to SSO provider
|
||||
1. **Start Session**: CLI creates a short-lived login session with `/sso/cli/start`
|
||||
2. **Open Browser**: CLI opens browser to `/sso/key/generate` with CLI source and login ID parameters
|
||||
3. **SSO Redirect**: Proxy sets the formatted state (`litellm-session-token:{login_id}`) as OAuth state parameter and redirects to SSO provider
|
||||
4. **User Authentication**: User completes SSO authentication in browser
|
||||
5. **Callback Processing**: SSO provider redirects back to proxy with state parameter
|
||||
6. **Key Generation**: Proxy detects CLI login (state starts with "litellm-session-token:") and generates API key with pre-specified ID
|
||||
7. **Polling**: CLI polls `/sso/cli/poll/{key_id}` endpoint until key is ready
|
||||
6. **User Code Verification**: Browser confirms the verification code shown in the CLI
|
||||
7. **Polling**: CLI polls `/sso/cli/poll/{login_id}` with the polling secret header until the JWT is ready
|
||||
8. **Token Storage**: CLI saves the authentication token to `~/.litellm/token.json`
|
||||
|
||||
### Benefits of This Approach
|
||||
@@ -357,7 +358,7 @@ The CLI provides three authentication commands:
|
||||
- **No Local Server**: No need to run a local callback server
|
||||
- **Standard OAuth**: Uses OAuth 2.0 state parameter correctly
|
||||
- **Remote Compatible**: Works with remote proxy servers
|
||||
- **Secure**: Uses UUID session identifiers
|
||||
- **Secure**: Keeps the polling secret out of the browser handoff
|
||||
- **Simple Setup**: No additional OAuth redirect URL configuration needed
|
||||
|
||||
### Token Storage
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import click
|
||||
import requests
|
||||
@@ -241,7 +242,7 @@ def prompt_team_selection(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any
|
||||
|
||||
|
||||
def prompt_team_selection_fallback(
|
||||
teams: List[Dict[str, Any]]
|
||||
teams: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fallback team selection for non-interactive environments"""
|
||||
if not teams:
|
||||
@@ -279,6 +280,7 @@ def prompt_team_selection_fallback(
|
||||
def _poll_for_ready_data(
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
total_timeout: int = 300,
|
||||
poll_interval: int = 2,
|
||||
request_timeout: int = 10,
|
||||
@@ -291,7 +293,10 @@ def _poll_for_ready_data(
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
for attempt in range(total_timeout // poll_interval):
|
||||
try:
|
||||
response = requests.get(url, timeout=request_timeout)
|
||||
request_kwargs: Dict[str, Any] = {"timeout": request_timeout}
|
||||
if headers is not None:
|
||||
request_kwargs["headers"] = headers
|
||||
response = requests.get(url, **request_kwargs)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
status = data.get("status")
|
||||
@@ -346,7 +351,23 @@ def _normalize_teams(teams, team_details):
|
||||
return []
|
||||
|
||||
|
||||
def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]:
|
||||
def _start_cli_sso_flow(base_url: str) -> Dict[str, Any]:
|
||||
response = requests.post(f"{base_url}/sso/cli/start", timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
required_fields = ("login_id", "poll_secret", "user_code")
|
||||
if not all(isinstance(data.get(field), str) for field in required_fields):
|
||||
raise ValueError("Invalid CLI SSO start response")
|
||||
return data
|
||||
|
||||
|
||||
def _get_cli_sso_poll_headers(poll_secret: str) -> Dict[str, str]:
|
||||
return {"x-litellm-cli-poll-secret": poll_secret}
|
||||
|
||||
|
||||
def _poll_for_authentication(
|
||||
base_url: str, key_id: str, poll_secret: str
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Poll the server for authentication completion and handle team selection.
|
||||
|
||||
@@ -356,6 +377,7 @@ def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]:
|
||||
poll_url = f"{base_url}/sso/cli/poll/{key_id}"
|
||||
data = _poll_for_ready_data(
|
||||
poll_url,
|
||||
headers=_get_cli_sso_poll_headers(poll_secret),
|
||||
pending_message="Still waiting for authentication...",
|
||||
)
|
||||
if not data:
|
||||
@@ -373,6 +395,7 @@ def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]:
|
||||
jwt_with_team = _handle_team_selection_during_polling(
|
||||
base_url=base_url,
|
||||
key_id=key_id,
|
||||
poll_secret=poll_secret,
|
||||
teams=normalized_teams,
|
||||
)
|
||||
|
||||
@@ -410,7 +433,7 @@ def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]:
|
||||
|
||||
|
||||
def _handle_team_selection_during_polling(
|
||||
base_url: str, key_id: str, teams: List[Dict[str, Any]]
|
||||
base_url: str, key_id: str, poll_secret: str, teams: List[Dict[str, Any]]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Handle team selection and re-poll with selected team_id.
|
||||
@@ -441,6 +464,7 @@ def _handle_team_selection_during_polling(
|
||||
poll_url = f"{base_url}/sso/cli/poll/{key_id}?team_id={team_id}"
|
||||
data = _poll_for_ready_data(
|
||||
poll_url,
|
||||
headers=_get_cli_sso_poll_headers(poll_secret),
|
||||
pending_message="Still waiting for team authentication...",
|
||||
other_status_message="Waiting for team authentication to complete...",
|
||||
http_error_log_every=10,
|
||||
@@ -514,29 +538,24 @@ def _render_and_prompt_for_team_selection(teams: List[Dict[str, Any]]) -> Option
|
||||
@click.pass_context
|
||||
def login(ctx: click.Context):
|
||||
"""Login to LiteLLM proxy using SSO authentication"""
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import LITELLM_CLI_SOURCE_IDENTIFIER
|
||||
from litellm.proxy.client.cli.interface import show_commands
|
||||
|
||||
base_url = ctx.obj["base_url"]
|
||||
|
||||
# Check if we have an existing key to regenerate
|
||||
existing_key = get_stored_api_key()
|
||||
|
||||
# Generate unique key ID for this login session
|
||||
key_id = f"sk-{str(uuid.uuid4())}"
|
||||
|
||||
try:
|
||||
# Construct SSO login URL with CLI source and pre-generated key
|
||||
sso_url = f"{base_url}/sso/key/generate?source={LITELLM_CLI_SOURCE_IDENTIFIER}&key={key_id}"
|
||||
cli_sso_flow = _start_cli_sso_flow(base_url=base_url)
|
||||
key_id = cli_sso_flow["login_id"]
|
||||
poll_secret = cli_sso_flow["poll_secret"]
|
||||
user_code = cli_sso_flow["user_code"]
|
||||
|
||||
# If we have an existing key, include it as a parameter to the login endpoint
|
||||
# The server will encode it in the OAuth state parameter for the SSO flow
|
||||
if existing_key:
|
||||
sso_url += f"&existing_key={existing_key}"
|
||||
sso_url = f"{base_url}/sso/key/generate?" + urlencode(
|
||||
{"source": LITELLM_CLI_SOURCE_IDENTIFIER, "key": key_id}
|
||||
)
|
||||
|
||||
click.echo(f"Opening browser to: {sso_url}")
|
||||
click.echo("Please complete the SSO authentication in your browser...")
|
||||
click.echo(f"Verification code: {user_code}")
|
||||
click.echo(f"Session ID: {key_id}")
|
||||
|
||||
# Open browser
|
||||
@@ -545,7 +564,9 @@ def login(ctx: click.Context):
|
||||
# Poll for authentication completion
|
||||
click.echo("Waiting for authentication...")
|
||||
|
||||
auth_result = _poll_for_authentication(base_url=base_url, key_id=key_id)
|
||||
auth_result = _poll_for_authentication(
|
||||
base_url=base_url, key_id=key_id, poll_secret=poll_secret
|
||||
)
|
||||
|
||||
if auth_result:
|
||||
api_key = auth_result["api_key"]
|
||||
|
||||
@@ -14,6 +14,8 @@ from litellm.types.utils import (
|
||||
blue_color_code = "\033[94m"
|
||||
reset_color_code = "\033[0m"
|
||||
|
||||
TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY = "_pillar_response_headers_trusted"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
|
||||
@@ -417,10 +419,19 @@ def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
|
||||
if "semantic-similarity" in _metadata:
|
||||
headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"])
|
||||
|
||||
is_trusted_pillar_metadata = (
|
||||
_metadata.get(TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY) is True
|
||||
)
|
||||
pillar_headers = _metadata.get("pillar_response_headers")
|
||||
if isinstance(pillar_headers, dict):
|
||||
headers.update(pillar_headers)
|
||||
elif "pillar_flagged" in _metadata:
|
||||
if is_trusted_pillar_metadata and isinstance(pillar_headers, dict):
|
||||
headers.update(
|
||||
{
|
||||
key: str(value)
|
||||
for key, value in pillar_headers.items()
|
||||
if isinstance(key, str) and key.lower().startswith("x-pillar-")
|
||||
}
|
||||
)
|
||||
elif is_trusted_pillar_metadata and "pillar_flagged" in _metadata:
|
||||
headers["x-pillar-flagged"] = str(_metadata["pillar_flagged"]).lower()
|
||||
|
||||
return headers
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Helpers for unauthenticated logo / favicon endpoints."""
|
||||
|
||||
import os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
LOCAL_IMAGE_HEADER_BYTES = 512
|
||||
|
||||
|
||||
def detect_local_image_media_type(header: bytes) -> Optional[str]:
|
||||
"""Return a browser image media type for supported local image signatures."""
|
||||
if header[0:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return "image/png"
|
||||
if header[0:4] == b"GIF8" and header[5:6] == b"a":
|
||||
return "image/gif"
|
||||
if header[0:3] == b"\xff\xd8\xff":
|
||||
return "image/jpeg"
|
||||
if header[0:4] == b"RIFF" and header[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
if header[0:4] in (b"\x00\x00\x01\x00", b"\x00\x00\x02\x00"):
|
||||
return "image/x-icon"
|
||||
return None
|
||||
|
||||
|
||||
def resolve_validated_local_image_path(candidate: str) -> Optional[Tuple[str, str]]:
|
||||
"""Resolve ``candidate`` only when it is an existing supported image file."""
|
||||
if not candidate:
|
||||
return None
|
||||
try:
|
||||
resolved = os.path.realpath(os.path.expanduser(candidate))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not os.path.isfile(resolved):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(resolved, "rb") as f:
|
||||
header = f.read(LOCAL_IMAGE_HEADER_BYTES)
|
||||
except OSError as exc:
|
||||
verbose_proxy_logger.debug("Could not read local asset %r: %s", candidate, exc)
|
||||
return None
|
||||
|
||||
media_type = detect_local_image_media_type(header)
|
||||
if media_type is None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Local asset %r is not a supported image file; falling back to default.",
|
||||
candidate,
|
||||
)
|
||||
return None
|
||||
|
||||
return resolved, media_type
|
||||
@@ -71,6 +71,7 @@ from litellm.types.utils import (
|
||||
)
|
||||
|
||||
GUARDRAIL_NAME = "bedrock"
|
||||
_BEDROCK_DYNAMIC_BODY_DENYLIST = frozenset({"content", "source"})
|
||||
|
||||
|
||||
class GuardrailMessageFilterResult(NamedTuple):
|
||||
@@ -413,11 +414,18 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
||||
)
|
||||
api_key: Optional[str] = None
|
||||
if request_data:
|
||||
bedrock_request_data.update(
|
||||
dynamic_request_body_params = (
|
||||
self.get_guardrail_dynamic_request_body_params(
|
||||
request_data=request_data
|
||||
)
|
||||
)
|
||||
bedrock_request_data.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in dynamic_request_body_params.items()
|
||||
if key not in _BEDROCK_DYNAMIC_BODY_DENYLIST
|
||||
}
|
||||
)
|
||||
if request_data.get("api_key") is not None:
|
||||
api_key = request_data["api_key"]
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY,
|
||||
add_guardrail_to_applied_guardrails_header,
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
)
|
||||
@@ -144,6 +145,7 @@ def build_pillar_response_headers(metadata_store: Dict[str, Any]) -> Dict[str, s
|
||||
|
||||
if headers:
|
||||
metadata_store["pillar_response_headers"] = headers
|
||||
metadata_store[TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY] = True
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class KeyManagementEventHooks:
|
||||
"""
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
@@ -61,9 +62,11 @@ class KeyManagementEventHooks:
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.KEY_TABLE_NAME,
|
||||
object_id=response.token_id or "",
|
||||
@@ -102,6 +105,7 @@ class KeyManagementEventHooks:
|
||||
"""
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
@@ -117,9 +121,11 @@ class KeyManagementEventHooks:
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.KEY_TABLE_NAME,
|
||||
object_id=data.key,
|
||||
@@ -140,6 +146,7 @@ class KeyManagementEventHooks:
|
||||
):
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
@@ -189,9 +196,11 @@ class KeyManagementEventHooks:
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.token,
|
||||
table_name=LitellmTableNames.KEY_TABLE_NAME,
|
||||
object_id=existing_key_row.token,
|
||||
@@ -220,6 +229,7 @@ class KeyManagementEventHooks:
|
||||
"""
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
from litellm.proxy.proxy_server import litellm_proxy_admin_name
|
||||
|
||||
@@ -237,9 +247,11 @@ class KeyManagementEventHooks:
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.token,
|
||||
table_name=LitellmTableNames.KEY_TABLE_NAME,
|
||||
object_id=key.token,
|
||||
|
||||
@@ -192,13 +192,19 @@ class UserManagementEventHooks:
|
||||
if not litellm.store_audit_logs:
|
||||
return
|
||||
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
||||
await create_audit_log_for_update(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.USER_TABLE_NAME,
|
||||
object_id=user_id,
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections import OrderedDict
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
import litellm
|
||||
@@ -104,6 +105,112 @@ LITELLM_METADATA_ROUTES = (
|
||||
"files",
|
||||
)
|
||||
|
||||
_UNTRUSTED_ROOT_CONTROL_FIELDS = (
|
||||
"proxy_server_request",
|
||||
"standard_logging_object",
|
||||
"secret_fields",
|
||||
"mock_response",
|
||||
"mock_tool_calls",
|
||||
"disable_global_guardrails",
|
||||
"disable_global_guardrail",
|
||||
"opted_out_global_guardrails",
|
||||
"applied_guardrails",
|
||||
"applied_policies",
|
||||
"policy_sources",
|
||||
"pillar_response_headers",
|
||||
"_guardrail_pipelines",
|
||||
"_pipeline_managed_guardrails",
|
||||
)
|
||||
|
||||
_UNTRUSTED_METADATA_CONTROL_FIELDS = (
|
||||
"disable_global_guardrails",
|
||||
"disable_global_guardrail",
|
||||
"opted_out_global_guardrails",
|
||||
"pillar_response_headers",
|
||||
"_pillar_response_headers_trusted",
|
||||
"pillar_flagged",
|
||||
"pillar_scanners",
|
||||
"pillar_evidence",
|
||||
"pillar_evidence_truncated",
|
||||
"pillar_session_id_response",
|
||||
"applied_guardrails",
|
||||
"applied_policies",
|
||||
"policy_sources",
|
||||
"standard_logging_object",
|
||||
"proxy_server_request",
|
||||
"secret_fields",
|
||||
"_guardrail_pipelines",
|
||||
"_pipeline_managed_guardrails",
|
||||
)
|
||||
|
||||
_UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS = frozenset(
|
||||
{
|
||||
"litellm-disable-message-redaction",
|
||||
}
|
||||
)
|
||||
_CLIENT_MOCK_CONTROL_FIELDS = frozenset({"mock_response", "mock_tool_calls"})
|
||||
_ALLOW_CLIENT_MOCK_RESPONSE_METADATA_KEY = "allow_client_mock_response"
|
||||
_ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY = (
|
||||
"allow_client_message_redaction_opt_out"
|
||||
)
|
||||
|
||||
|
||||
def _strip_untrusted_request_header_controls(
|
||||
headers: Any,
|
||||
*,
|
||||
allow_client_message_redaction_opt_out: bool = False,
|
||||
) -> None:
|
||||
if not isinstance(headers, dict):
|
||||
return
|
||||
|
||||
for header_name in list(headers.keys()):
|
||||
if (
|
||||
isinstance(header_name, str)
|
||||
and header_name.lower() in _UNTRUSTED_REQUEST_HEADER_CONTROL_FIELDS
|
||||
):
|
||||
if allow_client_message_redaction_opt_out:
|
||||
continue
|
||||
headers.pop(header_name, None)
|
||||
|
||||
|
||||
def _is_false_like(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"false", "0", "no", "off"}
|
||||
return False
|
||||
|
||||
|
||||
def _key_or_team_metadata_flag_is_true(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
metadata_key: str,
|
||||
) -> bool:
|
||||
for admin_metadata in (user_api_key_dict.metadata, user_api_key_dict.team_metadata):
|
||||
if (
|
||||
isinstance(admin_metadata, dict)
|
||||
and admin_metadata.get(metadata_key) is True
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _key_or_team_allows_client_mock_response(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> bool:
|
||||
return _key_or_team_metadata_flag_is_true(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
metadata_key=_ALLOW_CLIENT_MOCK_RESPONSE_METADATA_KEY,
|
||||
)
|
||||
|
||||
|
||||
def _key_or_team_allows_client_message_redaction_opt_out(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> bool:
|
||||
return _key_or_team_metadata_flag_is_true(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
metadata_key=_ALLOW_CLIENT_MESSAGE_REDACTION_OPT_OUT_METADATA_KEY,
|
||||
)
|
||||
|
||||
|
||||
def _get_metadata_variable_name(request: Request) -> str:
|
||||
"""
|
||||
@@ -228,13 +335,25 @@ def convert_key_logging_metadata_to_callback(
|
||||
for var, value in data.callback_vars.items():
|
||||
if team_callback_settings_obj.callback_vars is None:
|
||||
team_callback_settings_obj.callback_vars = {}
|
||||
team_callback_settings_obj.callback_vars[var] = str(
|
||||
litellm.utils.get_secret(value, default_value=value) or value
|
||||
)
|
||||
team_callback_settings_obj.callback_vars[var] = str(value)
|
||||
|
||||
return team_callback_settings_obj
|
||||
|
||||
|
||||
def _get_validated_callback_metadata(
|
||||
item: dict, *, source: str
|
||||
) -> Optional[AddTeamCallback]:
|
||||
try:
|
||||
return AddTeamCallback(**item)
|
||||
except (PydanticValidationError, ValueError) as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Ignoring invalid %s callback metadata: %s",
|
||||
source,
|
||||
_sanitize_for_log(str(e)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class KeyAndTeamLoggingSettings:
|
||||
"""
|
||||
Helper class to get the dynamic logging settings for the key and team
|
||||
@@ -274,8 +393,11 @@ def _get_dynamic_logging_metadata(
|
||||
#########################################################################################
|
||||
if key_dynamic_logging_settings is not None:
|
||||
for item in key_dynamic_logging_settings:
|
||||
callback = _get_validated_callback_metadata(item=item, source="key-level")
|
||||
if callback is None:
|
||||
continue
|
||||
callback_settings_obj = convert_key_logging_metadata_to_callback(
|
||||
data=AddTeamCallback(**item),
|
||||
data=callback,
|
||||
team_callback_settings_obj=callback_settings_obj,
|
||||
)
|
||||
#########################################################################################
|
||||
@@ -283,8 +405,11 @@ def _get_dynamic_logging_metadata(
|
||||
#########################################################################################
|
||||
elif team_dynamic_logging_settings is not None:
|
||||
for item in team_dynamic_logging_settings:
|
||||
callback = _get_validated_callback_metadata(item=item, source="team-level")
|
||||
if callback is None:
|
||||
continue
|
||||
callback_settings_obj = convert_key_logging_metadata_to_callback(
|
||||
data=AddTeamCallback(**item),
|
||||
data=callback,
|
||||
team_callback_settings_obj=callback_settings_obj,
|
||||
)
|
||||
#########################################################################################
|
||||
@@ -904,6 +1029,14 @@ class LiteLLMProxyRequestSetup:
|
||||
callback_vars_dict.pop("team_id", None)
|
||||
callback_vars_dict.pop("success_callback", None)
|
||||
callback_vars_dict.pop("failure_callback", None)
|
||||
callback_vars_dict = {
|
||||
key: (
|
||||
litellm.utils.get_secret(value, default_value=value) or value
|
||||
if isinstance(value, str)
|
||||
else value
|
||||
)
|
||||
for key, value in callback_vars_dict.items()
|
||||
}
|
||||
|
||||
return TeamCallbackMetadata(
|
||||
success_callback=team_config.get("success_callback", None),
|
||||
@@ -962,11 +1095,15 @@ async def add_litellm_data_to_request( # noqa: PLR0915
|
||||
# Strip internal-only keys from user input before the proxy sets its own.
|
||||
# These keys are injected by the proxy itself below — user-supplied values
|
||||
# must not be trusted.
|
||||
for _internal_key in (
|
||||
"proxy_server_request",
|
||||
"standard_logging_object",
|
||||
"secret_fields",
|
||||
):
|
||||
_allow_client_mock_response = _key_or_team_allows_client_mock_response(
|
||||
user_api_key_dict
|
||||
)
|
||||
_allow_client_message_redaction_opt_out = (
|
||||
_key_or_team_allows_client_message_redaction_opt_out(user_api_key_dict)
|
||||
)
|
||||
for _internal_key in _UNTRUSTED_ROOT_CONTROL_FIELDS:
|
||||
if _allow_client_mock_response and _internal_key in _CLIENT_MOCK_CONTROL_FIELDS:
|
||||
continue
|
||||
data.pop(_internal_key, None)
|
||||
# Strip spoofable auth metadata from user-supplied metadata dict
|
||||
_user_metadata = data.get("metadata")
|
||||
@@ -1007,6 +1144,17 @@ async def add_litellm_data_to_request( # noqa: PLR0915
|
||||
forward_llm_provider_auth_headers=forward_llm_auth,
|
||||
authenticated_with_header=authenticated_with_header,
|
||||
)
|
||||
_strip_untrusted_request_header_controls(
|
||||
_headers,
|
||||
allow_client_message_redaction_opt_out=_allow_client_message_redaction_opt_out,
|
||||
)
|
||||
if (
|
||||
not _allow_client_message_redaction_opt_out
|
||||
and litellm.turn_off_message_logging is True
|
||||
and "turn_off_message_logging" in data
|
||||
and _is_false_like(data["turn_off_message_logging"])
|
||||
):
|
||||
data.pop("turn_off_message_logging", None)
|
||||
verbose_proxy_logger.debug(f"Request Headers: {_headers}")
|
||||
verbose_proxy_logger.debug(f"Raw Headers: {_raw_headers}")
|
||||
|
||||
@@ -1144,8 +1292,18 @@ async def add_litellm_data_to_request( # noqa: PLR0915
|
||||
for _meta_key in ("metadata", "litellm_metadata"):
|
||||
_user_meta = data.get(_meta_key)
|
||||
if isinstance(_user_meta, dict):
|
||||
_user_meta.pop("_pipeline_managed_guardrails", None)
|
||||
for _k in [k for k in _user_meta if k.startswith("user_api_key_")]:
|
||||
_strip_untrusted_request_header_controls(
|
||||
_user_meta.get("headers"),
|
||||
allow_client_message_redaction_opt_out=(
|
||||
_allow_client_message_redaction_opt_out
|
||||
),
|
||||
)
|
||||
for _k in [
|
||||
k
|
||||
for k in _user_meta
|
||||
if k.startswith("user_api_key_")
|
||||
or k in _UNTRUSTED_METADATA_CONTROL_FIELDS
|
||||
]:
|
||||
_user_meta.pop(_k, None)
|
||||
|
||||
# Strip caller-supplied routing/budget tags unless the admin has opted
|
||||
|
||||
@@ -2069,6 +2069,9 @@ async def delete_user(
|
||||
litellm_proxy_admin_name,
|
||||
prisma_client,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
@@ -2162,9 +2165,11 @@ async def delete_user(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.USER_TABLE_NAME,
|
||||
object_id=user_id,
|
||||
|
||||
@@ -37,6 +37,7 @@ from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.proxy._experimental.mcp_server.db import (
|
||||
rotate_mcp_server_credentials_master_key,
|
||||
rotate_mcp_user_credentials_master_key,
|
||||
)
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy._types import LiteLLM_VerificationToken
|
||||
@@ -3718,6 +3719,17 @@ async def _rotate_master_key( # noqa: PLR0915
|
||||
"Failed to rotate MCP server credentials: %s", str(e)
|
||||
)
|
||||
|
||||
# 4b. process MCP user-scoped credentials table (BYOK + OAuth2 tokens)
|
||||
try:
|
||||
await rotate_mcp_user_credentials_master_key(
|
||||
prisma_client=prisma_client,
|
||||
new_master_key=new_master_key,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to rotate MCP user credentials: %s", str(e)
|
||||
)
|
||||
|
||||
# 5. process credentials table
|
||||
try:
|
||||
credentials = await prisma_client.db.litellm_credentialstable.find_many()
|
||||
@@ -5254,6 +5266,9 @@ async def block_key(
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("{}".format(CommonProxyErrors.db_not_connected_error.value))
|
||||
@@ -5297,9 +5312,11 @@ async def block_key(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.KEY_TABLE_NAME,
|
||||
object_id=hashed_token,
|
||||
@@ -5363,6 +5380,9 @@ async def unblock_key(
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("{}".format(CommonProxyErrors.db_not_connected_error.value))
|
||||
@@ -5406,9 +5426,11 @@ async def unblock_key(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.KEY_TABLE_NAME,
|
||||
object_id=hashed_token,
|
||||
@@ -5589,7 +5611,6 @@ async def test_key_logging(
|
||||
"content": "Hello, this is a test from litellm /key/health. No LLM API call was made for this",
|
||||
}
|
||||
],
|
||||
"mock_response": "test response",
|
||||
}
|
||||
data = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
@@ -5598,6 +5619,7 @@ async def test_key_logging(
|
||||
general_settings=general_settings,
|
||||
request=request,
|
||||
)
|
||||
data["mock_response"] = "test response"
|
||||
await litellm.acompletion(
|
||||
**data
|
||||
) # make mock completion call to trigger key based callbacks
|
||||
|
||||
@@ -56,6 +56,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||||
decrypt_value_helper,
|
||||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import get_audit_log_changed_by
|
||||
|
||||
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
|
||||
|
||||
@@ -2230,7 +2231,12 @@ if MCP_AVAILABLE:
|
||||
detail={"error": "Only proxy admins can create MCP toolsets."},
|
||||
)
|
||||
touched_by = (
|
||||
litellm_changed_by or user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME
|
||||
get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=LITELLM_PROXY_ADMIN_NAME,
|
||||
)
|
||||
or LITELLM_PROXY_ADMIN_NAME
|
||||
)
|
||||
try:
|
||||
result = await create_mcp_toolset(prisma_client, payload, touched_by)
|
||||
@@ -2321,7 +2327,12 @@ if MCP_AVAILABLE:
|
||||
detail={"error": "Only proxy admins can update MCP toolsets."},
|
||||
)
|
||||
touched_by = (
|
||||
litellm_changed_by or user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME
|
||||
get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=LITELLM_PROXY_ADMIN_NAME,
|
||||
)
|
||||
or LITELLM_PROXY_ADMIN_NAME
|
||||
)
|
||||
try:
|
||||
result = await update_mcp_toolset(prisma_client, payload, touched_by)
|
||||
|
||||
@@ -906,6 +906,9 @@ async def new_team( # noqa: PLR0915
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
@@ -1174,9 +1177,11 @@ async def new_team( # noqa: PLR0915
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.TEAM_TABLE_NAME,
|
||||
object_id=data.team_id,
|
||||
@@ -1214,7 +1219,10 @@ async def _create_team_update_audit_log(
|
||||
user_api_key_dict: User API key authentication details
|
||||
litellm_proxy_admin_name: Name of the proxy admin
|
||||
"""
|
||||
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
||||
_before_value = existing_team_row.json(exclude_none=True)
|
||||
_before_value = json.dumps(_before_value, default=str)
|
||||
@@ -1225,9 +1233,11 @@ async def _create_team_update_audit_log(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.TEAM_TABLE_NAME,
|
||||
object_id=team_id,
|
||||
@@ -3037,6 +3047,9 @@ async def delete_team(
|
||||
litellm_proxy_admin_name,
|
||||
prisma_client,
|
||||
)
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
@@ -3096,9 +3109,11 @@ async def delete_team(
|
||||
request_data=LiteLLM_AuditLogs(
|
||||
id=str(uuid.uuid4()),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
changed_by=litellm_changed_by
|
||||
or user_api_key_dict.user_id
|
||||
or litellm_proxy_admin_name,
|
||||
changed_by=get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
),
|
||||
changed_by_api_key=user_api_key_dict.api_key,
|
||||
table_name=LitellmTableNames.TEAM_TABLE_NAME,
|
||||
object_id=team_id,
|
||||
|
||||
@@ -13,7 +13,9 @@ import base64
|
||||
import hashlib
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from html import escape
|
||||
from copy import deepcopy
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -27,19 +29,22 @@ from typing import (
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import (
|
||||
CLI_SSO_SESSION_CACHE_KEY_PREFIX,
|
||||
CLI_SSO_SESSION_TTL_SECONDS,
|
||||
LITELLM_CLI_SOURCE_IDENTIFIER,
|
||||
LITELLM_UI_SESSION_DURATION,
|
||||
MAX_SPENDLOG_ROWS_TO_QUERY,
|
||||
MICROSOFT_USER_DISPLAY_NAME_ATTRIBUTE,
|
||||
@@ -71,6 +76,7 @@ from litellm.proxy._types import (
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken, get_user_object
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
|
||||
from litellm.proxy.auth.auth_utils import _get_request_ip_address, _has_user_setup_sso
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.admin_ui_utils import (
|
||||
@@ -123,6 +129,250 @@ router = APIRouter()
|
||||
# Metadata fields (token_type, expires_in, scope) are intentionally kept so
|
||||
# response convertors see the same fields in the PKCE path as in the non-PKCE path.
|
||||
_OAUTH_TOKEN_FIELDS = frozenset({"access_token", "id_token", "refresh_token"})
|
||||
_CLI_SSO_FLOW_CACHE_KEY_PREFIX = f"{CLI_SSO_SESSION_CACHE_KEY_PREFIX}:flow"
|
||||
_CLI_SSO_START_RATE_LIMIT_CACHE_KEY_PREFIX = (
|
||||
f"{_CLI_SSO_FLOW_CACHE_KEY_PREFIX}:start_rate_limit"
|
||||
)
|
||||
_CLI_SSO_START_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
_CLI_SSO_START_RATE_LIMIT_MAX_ATTEMPTS = 30
|
||||
_CLI_SSO_USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
_CLI_SSO_LOGIN_ID_RE = re.compile(r"^cli-[A-Za-z0-9_-]{12,124}$")
|
||||
|
||||
|
||||
def _hash_cli_sso_secret(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_cli_sso_user_code(user_code: str) -> str:
|
||||
return "".join(ch for ch in user_code.upper() if ch.isalnum())
|
||||
|
||||
|
||||
def _generate_cli_sso_user_code() -> str:
|
||||
user_code = "".join(secrets.choice(_CLI_SSO_USER_CODE_ALPHABET) for _ in range(8))
|
||||
return f"{user_code[:4]}-{user_code[4:]}"
|
||||
|
||||
|
||||
def _get_cli_sso_flow_cache_key(login_id: str) -> str:
|
||||
return f"{_CLI_SSO_FLOW_CACHE_KEY_PREFIX}:{login_id}"
|
||||
|
||||
|
||||
def _is_valid_cli_sso_login_id(login_id: Optional[str]) -> bool:
|
||||
return isinstance(login_id, str) and bool(_CLI_SSO_LOGIN_ID_RE.fullmatch(login_id))
|
||||
|
||||
|
||||
def _get_cli_sso_start_rate_limit_cache_key(
|
||||
request: Request, use_x_forwarded_for: Optional[bool] = False
|
||||
) -> str:
|
||||
client_ip = (
|
||||
_get_request_ip_address(
|
||||
request=request, use_x_forwarded_for=use_x_forwarded_for
|
||||
)
|
||||
or "unknown"
|
||||
)
|
||||
client_ip_hash = _hash_cli_sso_secret(client_ip)
|
||||
return f"{_CLI_SSO_START_RATE_LIMIT_CACHE_KEY_PREFIX}:{client_ip_hash}"
|
||||
|
||||
|
||||
def _check_cli_sso_start_rate_limit(
|
||||
request: Request,
|
||||
cache: DualCache,
|
||||
use_x_forwarded_for: Optional[bool] = False,
|
||||
) -> None:
|
||||
rate_limit_cache_key = _get_cli_sso_start_rate_limit_cache_key(
|
||||
request=request, use_x_forwarded_for=use_x_forwarded_for
|
||||
)
|
||||
current_attempts = cache.increment_cache(
|
||||
key=rate_limit_cache_key,
|
||||
value=1,
|
||||
ttl=_CLI_SSO_START_RATE_LIMIT_WINDOW_SECONDS,
|
||||
)
|
||||
if current_attempts > _CLI_SSO_START_RATE_LIMIT_MAX_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many CLI login attempts. Try again later.",
|
||||
)
|
||||
|
||||
|
||||
def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dict:
|
||||
if not _is_valid_cli_sso_login_id(login_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid CLI login session")
|
||||
|
||||
cache_key = _get_cli_sso_flow_cache_key(cast(str, login_id))
|
||||
flow = cache.get_cache(key=cache_key)
|
||||
if not isinstance(flow, dict) or "poll_secret_hash" not in flow:
|
||||
raise HTTPException(status_code=400, detail="Invalid CLI login session")
|
||||
return flow
|
||||
|
||||
|
||||
def _set_cli_sso_flow(login_id: str, cache: DualCache, flow: dict) -> None:
|
||||
cache.set_cache(
|
||||
key=_get_cli_sso_flow_cache_key(login_id),
|
||||
value=flow,
|
||||
ttl=CLI_SSO_SESSION_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
def _verify_cli_sso_poll_secret(flow: dict, poll_secret: Optional[str]) -> bool:
|
||||
expected_poll_secret_hash = flow.get("poll_secret_hash")
|
||||
if not isinstance(expected_poll_secret_hash, str) or not isinstance(
|
||||
poll_secret, str
|
||||
):
|
||||
return False
|
||||
supplied_poll_secret_hash = _hash_cli_sso_secret(poll_secret)
|
||||
return secrets.compare_digest(supplied_poll_secret_hash, expected_poll_secret_hash)
|
||||
|
||||
|
||||
def _render_cli_sso_verification_page(
|
||||
verify_url: str, browser_complete_token: str
|
||||
) -> str:
|
||||
escaped_verify_url = escape(verify_url, quote=True)
|
||||
escaped_browser_complete_token = escape(browser_complete_token, quote=True)
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>LiteLLM CLI Login</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}}
|
||||
main {{
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
}}
|
||||
h1 {{ font-size: 22px; margin: 0 0 12px; }}
|
||||
p {{ line-height: 1.5; margin: 0 0 18px; color: #334155; }}
|
||||
label {{ display: block; font-weight: 600; margin-bottom: 8px; }}
|
||||
input {{
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
button {{
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Complete CLI Login</h1>
|
||||
<p>Enter the verification code shown in your terminal to finish this login.</p>
|
||||
<form method="post" action="{escaped_verify_url}">
|
||||
<input type="hidden" name="browser_complete_token" value="{escaped_browser_complete_token}" />
|
||||
<label for="user_code">Verification code</label>
|
||||
<input id="user_code" name="user_code" autocomplete="one-time-code" required autofocus />
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@router.post("/sso/cli/start", tags=["experimental"], include_in_schema=False)
|
||||
async def cli_sso_start(request: Request):
|
||||
from litellm.proxy.proxy_server import general_settings, user_api_key_cache
|
||||
|
||||
_check_cli_sso_start_rate_limit(
|
||||
request=request,
|
||||
cache=user_api_key_cache,
|
||||
use_x_forwarded_for=bool(
|
||||
(general_settings or {}).get("use_x_forwarded_for", False)
|
||||
),
|
||||
)
|
||||
|
||||
login_id = f"cli-{secrets.token_urlsafe(24)}"
|
||||
poll_secret = secrets.token_urlsafe(32)
|
||||
user_code = _generate_cli_sso_user_code()
|
||||
|
||||
flow = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret(poll_secret),
|
||||
"user_code_hash": _hash_cli_sso_secret(_normalize_cli_sso_user_code(user_code)),
|
||||
"sso_complete": False,
|
||||
"user_code_verified": False,
|
||||
"session_data": None,
|
||||
}
|
||||
_set_cli_sso_flow(login_id=login_id, cache=user_api_key_cache, flow=flow)
|
||||
|
||||
return {
|
||||
"login_id": login_id,
|
||||
"poll_secret": poll_secret,
|
||||
"user_code": user_code,
|
||||
"expires_in": CLI_SSO_SESSION_TTL_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sso/cli/complete/{login_id}", tags=["experimental"], include_in_schema=False
|
||||
)
|
||||
async def cli_sso_complete(request: Request, login_id: str):
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from litellm.proxy.common_utils.html_forms.cli_sso_success import (
|
||||
render_cli_sso_success_page,
|
||||
)
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
flow = _get_cli_sso_flow_or_raise(login_id=login_id, cache=user_api_key_cache)
|
||||
if not flow.get("sso_complete") or not flow.get("session_data"):
|
||||
raise HTTPException(status_code=400, detail="CLI login is not ready")
|
||||
|
||||
body = (await request.body()).decode("utf-8")
|
||||
form_values = parse_qs(body)
|
||||
supplied_user_code = (form_values.get("user_code") or [""])[0]
|
||||
supplied_browser_complete_token = (
|
||||
form_values.get("browser_complete_token") or [""]
|
||||
)[0]
|
||||
supplied_user_code_hash = _hash_cli_sso_secret(
|
||||
_normalize_cli_sso_user_code(supplied_user_code)
|
||||
)
|
||||
supplied_browser_complete_token_hash = _hash_cli_sso_secret(
|
||||
supplied_browser_complete_token
|
||||
)
|
||||
|
||||
expected_user_code_hash = flow.get("user_code_hash")
|
||||
if not isinstance(expected_user_code_hash, str) or not secrets.compare_digest(
|
||||
supplied_user_code_hash, expected_user_code_hash
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Invalid verification code")
|
||||
|
||||
expected_browser_complete_token_hash = flow.get("browser_complete_token_hash")
|
||||
if not isinstance(
|
||||
expected_browser_complete_token_hash, str
|
||||
) or not secrets.compare_digest(
|
||||
supplied_browser_complete_token_hash, expected_browser_complete_token_hash
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Invalid verification code")
|
||||
|
||||
flow["user_code_verified"] = True
|
||||
_set_cli_sso_flow(login_id=login_id, cache=user_api_key_cache, flow=flow)
|
||||
|
||||
html_content = render_cli_sso_success_page()
|
||||
return HTMLResponse(content=html_content, status_code=200)
|
||||
|
||||
|
||||
def normalize_email(email: Optional[str]) -> Optional[str]:
|
||||
@@ -333,6 +583,7 @@ async def google_login(
|
||||
from litellm.proxy.proxy_server import (
|
||||
premium_user,
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
user_custom_ui_sso_sign_in_handler,
|
||||
)
|
||||
|
||||
@@ -382,14 +633,15 @@ async def google_login(
|
||||
redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso(
|
||||
request=request,
|
||||
sso_callback_route="sso/callback",
|
||||
existing_key=existing_key,
|
||||
)
|
||||
|
||||
# Store CLI key in state for OAuth flow
|
||||
if source == LITELLM_CLI_SOURCE_IDENTIFIER:
|
||||
_get_cli_sso_flow_or_raise(login_id=key, cache=user_api_key_cache)
|
||||
|
||||
# Store CLI login handle in state for OAuth flow
|
||||
cli_state: Optional[str] = SSOAuthenticationHandler._get_cli_state(
|
||||
source=source,
|
||||
key=key,
|
||||
existing_key=existing_key,
|
||||
)
|
||||
|
||||
# check if user defined a custom auth sso sign in handler, if yes, use it
|
||||
@@ -1389,18 +1641,12 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa:
|
||||
)
|
||||
|
||||
if state and state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"):
|
||||
# Extract the key ID and existing_key from the state
|
||||
# State format: {PREFIX}:{key}:{existing_key} or {PREFIX}:{key}
|
||||
state_parts = state.split(":", 2) # Split into max 3 parts
|
||||
# State format: {PREFIX}:{login_id}
|
||||
state_parts = state.split(":", 1)
|
||||
key_id = state_parts[1] if len(state_parts) > 1 else None
|
||||
existing_key = state_parts[2] if len(state_parts) > 2 else None
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"CLI SSO callback detected for key: {key_id}, existing_key: {existing_key}"
|
||||
)
|
||||
return await cli_sso_callback(
|
||||
request=request, key=key_id, existing_key=existing_key, result=result
|
||||
)
|
||||
verbose_proxy_logger.info("CLI SSO callback detected")
|
||||
return await cli_sso_callback(request=request, key=key_id, result=result)
|
||||
|
||||
# Control-plane cross-origin: read return_to from cookie.
|
||||
# Starlette's cookie_parser already handles RFC 2109 unquoting.
|
||||
@@ -1421,13 +1667,10 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa:
|
||||
async def cli_sso_callback(
|
||||
request: Request,
|
||||
key: Optional[str] = None,
|
||||
existing_key: Optional[str] = None,
|
||||
result: Optional[Union[OpenID, dict]] = None,
|
||||
):
|
||||
"""CLI SSO callback - stores session info for JWT generation on polling"""
|
||||
verbose_proxy_logger.info(
|
||||
f"CLI SSO callback for key: {key}, existing_key: {existing_key}"
|
||||
)
|
||||
verbose_proxy_logger.info("CLI SSO callback")
|
||||
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
@@ -1435,11 +1678,7 @@ async def cli_sso_callback(
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if not key or not key.startswith("sk-"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid key parameter. Must be a valid key ID starting with 'sk-'",
|
||||
)
|
||||
flow = _get_cli_sso_flow_or_raise(login_id=key, cache=user_api_key_cache)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
@@ -1477,9 +1716,6 @@ async def cli_sso_callback(
|
||||
status_code=500, detail="Failed to retrieve user information from SSO"
|
||||
)
|
||||
|
||||
# Store session info in cache (10 min TTL)
|
||||
from litellm.constants import CLI_SSO_SESSION_CACHE_KEY_PREFIX
|
||||
|
||||
# Get all teams from user_info - CLI will let user select which one
|
||||
teams: List[str] = []
|
||||
if hasattr(user_info, "teams") and user_info.teams:
|
||||
@@ -1520,21 +1756,25 @@ async def cli_sso_callback(
|
||||
"team_details": team_details,
|
||||
}
|
||||
|
||||
cache_key = f"{CLI_SSO_SESSION_CACHE_KEY_PREFIX}:{key}"
|
||||
user_api_key_cache.set_cache(key=cache_key, value=session_data, ttl=600)
|
||||
flow["session_data"] = session_data
|
||||
flow["sso_complete"] = True
|
||||
browser_complete_token = secrets.token_urlsafe(32)
|
||||
flow["browser_complete_token_hash"] = _hash_cli_sso_secret(
|
||||
browser_complete_token
|
||||
)
|
||||
_set_cli_sso_flow(login_id=cast(str, key), cache=user_api_key_cache, flow=flow)
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"Stored CLI SSO session for user: {user_info.user_id}, teams: {teams}, num_teams: {len(teams)}"
|
||||
)
|
||||
|
||||
# Return success page
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from litellm.proxy.common_utils.html_forms.cli_sso_success import (
|
||||
render_cli_sso_success_page,
|
||||
verify_url = str(request.url_for("cli_sso_complete", login_id=key))
|
||||
html_content = _render_cli_sso_verification_page(
|
||||
verify_url=verify_url,
|
||||
browser_complete_token=browser_complete_token,
|
||||
)
|
||||
|
||||
html_content = render_cli_sso_success_page()
|
||||
return HTMLResponse(content=html_content, status_code=200)
|
||||
|
||||
except Exception as e:
|
||||
@@ -1545,7 +1785,11 @@ async def cli_sso_callback(
|
||||
|
||||
|
||||
@router.get("/sso/cli/poll/{key_id}", tags=["experimental"], include_in_schema=False)
|
||||
async def cli_poll_key(key_id: str, team_id: Optional[str] = None):
|
||||
async def cli_poll_key(
|
||||
key_id: str,
|
||||
team_id: Optional[str] = None,
|
||||
x_litellm_cli_poll_secret: Optional[str] = Header(default=None),
|
||||
):
|
||||
"""
|
||||
CLI polling endpoint - retrieves session from cache and generates JWT.
|
||||
|
||||
@@ -1554,22 +1798,25 @@ async def cli_poll_key(key_id: str, team_id: Optional[str] = None):
|
||||
2. Second poll (with team_id): Generates JWT with selected team and deletes session
|
||||
|
||||
Args:
|
||||
key_id: The session key ID
|
||||
key_id: The CLI login session ID
|
||||
team_id: Optional team ID to assign to the JWT. If provided, must be one of user's teams.
|
||||
"""
|
||||
from litellm.constants import CLI_SSO_SESSION_CACHE_KEY_PREFIX
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
from litellm.proxy.proxy_server import user_api_key_cache
|
||||
|
||||
if not key_id.startswith("sk-"):
|
||||
raise HTTPException(status_code=400, detail="Invalid key ID format")
|
||||
|
||||
try:
|
||||
# Look up session in cache
|
||||
cache_key = f"{CLI_SSO_SESSION_CACHE_KEY_PREFIX}:{key_id}"
|
||||
session_data = user_api_key_cache.get_cache(key=cache_key)
|
||||
flow = _get_cli_sso_flow_or_raise(login_id=key_id, cache=user_api_key_cache)
|
||||
if not _verify_cli_sso_poll_secret(
|
||||
flow=flow, poll_secret=x_litellm_cli_poll_secret
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Invalid CLI polling secret")
|
||||
|
||||
if session_data:
|
||||
if not flow.get("sso_complete") or not flow.get("user_code_verified"):
|
||||
return {"status": "pending"}
|
||||
|
||||
session_data = flow.get("session_data")
|
||||
|
||||
if isinstance(session_data, dict):
|
||||
user_teams = session_data.get("teams", [])
|
||||
user_team_details = session_data.get("team_details")
|
||||
user_id = session_data["user_id"]
|
||||
@@ -1629,7 +1876,7 @@ async def cli_poll_key(key_id: str, team_id: Optional[str] = None):
|
||||
)
|
||||
|
||||
# Delete cache entry (single-use)
|
||||
user_api_key_cache.delete_cache(key=cache_key)
|
||||
user_api_key_cache.delete_cache(key=_get_cli_sso_flow_cache_key(key_id))
|
||||
|
||||
verbose_proxy_logger.info(
|
||||
f"CLI JWT generated for user: {user_id}, team: {team_id}"
|
||||
@@ -1647,6 +1894,8 @@ async def cli_poll_key(key_id: str, team_id: Optional[str] = None):
|
||||
else:
|
||||
return {"status": "pending"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(f"Error polling for CLI JWT: {e}")
|
||||
raise HTTPException(
|
||||
@@ -2390,20 +2639,15 @@ class SSOAuthenticationHandler:
|
||||
|
||||
This is used to authenticate through the CLI login flow.
|
||||
|
||||
The state parameter format is: {PREFIX}:{key}:{existing_key}
|
||||
- If existing_key is provided, it's included in the state
|
||||
The state parameter format is: {PREFIX}:{login_id}
|
||||
- The state parameter is used to pass data through the OAuth flow without changing the callback URL
|
||||
"""
|
||||
from litellm.constants import (
|
||||
LITELLM_CLI_SESSION_TOKEN_PREFIX,
|
||||
LITELLM_CLI_SOURCE_IDENTIFIER,
|
||||
)
|
||||
|
||||
if source == LITELLM_CLI_SOURCE_IDENTIFIER and key:
|
||||
if existing_key:
|
||||
return f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}:{existing_key}"
|
||||
else:
|
||||
return f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}"
|
||||
return f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@@ -21,6 +21,28 @@ from litellm.proxy._types import (
|
||||
from litellm.types.utils import StandardAuditLogPayload
|
||||
|
||||
_audit_log_callback_cache: Dict[str, CustomLogger] = {}
|
||||
ALLOW_LITELLM_CHANGED_BY_HEADER_METADATA_KEY = "allow_litellm_changed_by_header"
|
||||
|
||||
|
||||
def _allows_litellm_changed_by_header(user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
for admin_metadata in (user_api_key_dict.metadata, user_api_key_dict.team_metadata):
|
||||
if (
|
||||
isinstance(admin_metadata, dict)
|
||||
and admin_metadata.get(ALLOW_LITELLM_CHANGED_BY_HEADER_METADATA_KEY) is True
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_audit_log_changed_by(
|
||||
*,
|
||||
litellm_changed_by: Optional[str],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
litellm_proxy_admin_name: Optional[str],
|
||||
) -> Optional[str]:
|
||||
if litellm_changed_by and _allows_litellm_changed_by_header(user_api_key_dict):
|
||||
return litellm_changed_by
|
||||
return user_api_key_dict.user_id or litellm_proxy_admin_name
|
||||
|
||||
|
||||
def _resolve_audit_log_callback(name: str) -> Optional[CustomLogger]:
|
||||
@@ -143,8 +165,10 @@ async def create_object_audit_log(
|
||||
if _store_audit_logs is not True:
|
||||
return
|
||||
|
||||
_changed_by = (
|
||||
litellm_changed_by or user_api_key_dict.user_id or litellm_proxy_admin_name
|
||||
_changed_by = get_audit_log_changed_by(
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
)
|
||||
|
||||
await create_audit_log_for_update(
|
||||
|
||||
@@ -41,7 +41,6 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.passthrough import BasePassthroughUtils
|
||||
from litellm.proxy._types import (
|
||||
CommonProxyErrors,
|
||||
ConfigFieldInfo,
|
||||
ConfigFieldUpdate,
|
||||
LiteLLMRoutes,
|
||||
@@ -2325,12 +2324,14 @@ async def _register_pass_through_endpoint(
|
||||
dependencies = None
|
||||
|
||||
if auth is not None and str(auth).lower() == "true":
|
||||
if premium_user is not True:
|
||||
raise ValueError(
|
||||
"Error Setting Authentication on Pass Through Endpoint: {}".format(
|
||||
CommonProxyErrors.not_premium_user.value
|
||||
)
|
||||
)
|
||||
# Authentication on a pass-through endpoint used to be enterprise-
|
||||
# only — which left the OSS tier with no safe configuration: the
|
||||
# default was ``auth=False`` (unauthenticated forwarder) and the
|
||||
# safe ``auth=True`` raised at startup unless the operator had a
|
||||
# license. The default is now ``True`` (safe-by-default), and
|
||||
# turning it on no longer requires a license: an unauthenticated
|
||||
# forwarder is a deployment choice the operator should be allowed
|
||||
# to make explicitly, but the safe option must always be free.
|
||||
dependencies = [Depends(user_api_key_auth)]
|
||||
if path not in LiteLLMRoutes.openai_routes.value:
|
||||
LiteLLMRoutes.openai_routes.value.append(path)
|
||||
|
||||
+142
-183
@@ -241,37 +241,11 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import (
|
||||
router as mcp_byok_oauth_router,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
router as mcp_discoverable_endpoints_router,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.rest_endpoints import (
|
||||
router as mcp_rest_endpoints_router,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.server import app as mcp_app
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import (
|
||||
global_mcp_tool_registry,
|
||||
)
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.agent_endpoints.a2a_endpoints import router as a2a_router
|
||||
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
|
||||
from litellm.proxy.agent_endpoints.endpoints import router as agent_endpoints_router
|
||||
from litellm.proxy.agent_endpoints.model_list_helpers import (
|
||||
append_agents_to_model_group,
|
||||
append_agents_to_model_info,
|
||||
)
|
||||
from litellm.proxy._lazy_features import attach_lazy_features
|
||||
from litellm.proxy.analytics_endpoints.analytics_endpoints import (
|
||||
router as analytics_router,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.claude_code_endpoints import (
|
||||
claude_code_marketplace_router,
|
||||
)
|
||||
from litellm.proxy.anthropic_endpoints.endpoints import router as anthropic_router
|
||||
from litellm.proxy.anthropic_endpoints.skills_endpoints import (
|
||||
router as anthropic_skills_router,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
ExperimentalUIJWTToken,
|
||||
get_team_object,
|
||||
@@ -334,7 +308,6 @@ from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router
|
||||
from litellm.proxy.fine_tuning_endpoints.endpoints import set_fine_tuning_config
|
||||
from litellm.proxy.google_endpoints.endpoints import router as google_router
|
||||
from litellm.proxy.guardrails.guardrail_endpoints import router as guardrails_router
|
||||
from litellm.proxy.guardrails.init_guardrails import (
|
||||
init_guardrails_v2,
|
||||
initialize_guardrails,
|
||||
@@ -350,9 +323,6 @@ from litellm.proxy.hooks.prompt_injection_detection import (
|
||||
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger
|
||||
from litellm.proxy.image_endpoints.endpoints import router as image_router
|
||||
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
|
||||
from litellm.proxy.management_endpoints.access_group_endpoints import (
|
||||
router as access_group_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.budget_management_endpoints import (
|
||||
router as budget_management_router,
|
||||
)
|
||||
@@ -366,12 +336,6 @@ from litellm.proxy.management_endpoints.common_utils import (
|
||||
_user_has_admin_privileges,
|
||||
admin_can_invite_user,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.compliance_endpoints import (
|
||||
router as compliance_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.config_override_endpoints import (
|
||||
router as config_override_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.cost_tracking_settings import (
|
||||
router as cost_tracking_settings_router,
|
||||
)
|
||||
@@ -385,9 +349,6 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import (
|
||||
router as internal_user_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
|
||||
from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import (
|
||||
router as jwt_key_mapping_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
delete_verification_tokens,
|
||||
duration_in_seconds,
|
||||
@@ -396,9 +357,6 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
router as key_management_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
router as mcp_management_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.model_access_group_management_endpoints import (
|
||||
router as model_access_group_management_router,
|
||||
)
|
||||
@@ -413,11 +371,9 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
router as organization_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.policy_endpoints import router as policy_router
|
||||
from litellm.proxy.management_endpoints.router_settings_endpoints import (
|
||||
router as router_settings_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.scim.scim_v2 import scim_router
|
||||
from litellm.proxy.management_endpoints.tag_management_endpoints import (
|
||||
router as tag_management_router,
|
||||
)
|
||||
@@ -429,9 +385,6 @@ from litellm.proxy.management_endpoints.team_endpoints import (
|
||||
update_team,
|
||||
validate_membership,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.tool_management_endpoints import (
|
||||
router as tool_management_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.workflow_management_endpoints import (
|
||||
router as workflow_management_router,
|
||||
)
|
||||
@@ -440,7 +393,6 @@ from litellm.proxy.management_endpoints.ui_sso import (
|
||||
get_disabled_non_admin_personal_key_creation,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.ui_sso import router as ui_sso_router
|
||||
from litellm.proxy.management_endpoints.usage_endpoints import router as usage_ai_router
|
||||
from litellm.proxy.management_endpoints.user_agent_analytics_endpoints import (
|
||||
router as user_agent_analytics_router,
|
||||
)
|
||||
@@ -450,7 +402,6 @@ from litellm.proxy.middleware.in_flight_requests_middleware import (
|
||||
)
|
||||
from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware
|
||||
from litellm.proxy.ocr_endpoints.endpoints import router as ocr_router
|
||||
from litellm.proxy.openai_evals_endpoints.endpoints import router as evals_router
|
||||
from litellm.proxy.openai_files_endpoints.files_endpoints import (
|
||||
router as openai_files_router,
|
||||
)
|
||||
@@ -470,27 +421,16 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
router as pass_through_router,
|
||||
)
|
||||
from litellm.proxy.policy_engine.policy_endpoints import router as policy_crud_router
|
||||
from litellm.proxy.policy_engine.policy_resolve_endpoints import (
|
||||
router as policy_resolve_router,
|
||||
)
|
||||
from litellm.proxy.prompts.prompt_endpoints import router as prompts_router
|
||||
from litellm.proxy.public_endpoints import router as public_endpoints_router
|
||||
from litellm.proxy.rag_endpoints.endpoints import router as rag_router
|
||||
from litellm.proxy.realtime_endpoints.endpoints import router as webrtc_router
|
||||
from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router
|
||||
from litellm.proxy.response_api_endpoints.endpoints import router as response_router
|
||||
from litellm.proxy.route_llm_request import route_request
|
||||
from litellm.proxy.search_endpoints.endpoints import router as search_router
|
||||
from litellm.proxy.search_endpoints.search_tool_management import (
|
||||
router as search_tool_management_router,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.cloudzero_endpoints import router as cloudzero_router
|
||||
from litellm.proxy.spend_tracking.spend_management_endpoints import (
|
||||
router as spend_management_router,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
|
||||
from litellm.proxy.spend_tracking.vantage_endpoints import router as vantage_router
|
||||
from litellm.proxy.types_utils.utils import get_instance_fn
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
|
||||
router as ui_crud_endpoints_router,
|
||||
@@ -520,16 +460,6 @@ from litellm.proxy.utils import (
|
||||
prefetch_config_params,
|
||||
update_spend,
|
||||
)
|
||||
from litellm.proxy.vector_store_endpoints.endpoints import router as vector_store_router
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import (
|
||||
router as vector_store_management_router,
|
||||
)
|
||||
from litellm.proxy.vector_store_files_endpoints.endpoints import (
|
||||
router as vector_store_files_router,
|
||||
)
|
||||
from litellm.proxy.vertex_ai_endpoints.langfuse_endpoints import (
|
||||
router as langfuse_router,
|
||||
)
|
||||
from litellm.proxy.video_endpoints.endpoints import router as video_router
|
||||
from litellm.router import (
|
||||
AssistantsTypedDict,
|
||||
@@ -1109,6 +1039,11 @@ def get_openapi_schema():
|
||||
|
||||
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
|
||||
|
||||
# Stub unloaded lazy features so they appear as Swagger sections.
|
||||
from litellm.proxy._lazy_features import inject_lazy_stubs
|
||||
|
||||
openapi_schema = inject_lazy_stubs(openapi_schema)
|
||||
|
||||
# Fix Swagger UI execute path error when server_root_path is set
|
||||
if server_root_path:
|
||||
openapi_schema["servers"] = [{"url": "/" + server_root_path.strip("/")}]
|
||||
@@ -1135,6 +1070,11 @@ def custom_openapi():
|
||||
|
||||
openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema)
|
||||
|
||||
# Stub unloaded lazy features so they appear as Swagger sections.
|
||||
from litellm.proxy._lazy_features import inject_lazy_stubs
|
||||
|
||||
openapi_schema = inject_lazy_stubs(openapi_schema)
|
||||
|
||||
# Fix Swagger UI execute path error when server_root_path is set
|
||||
if server_root_path:
|
||||
openapi_schema["servers"] = [{"url": "/" + server_root_path.strip("/")}]
|
||||
@@ -1562,14 +1502,78 @@ def mount_swagger_ui():
|
||||
|
||||
app.mount("/swagger", StaticFiles(directory=swagger_directory), name="swagger")
|
||||
|
||||
# On dropdown expand: one-time fetch to the prefix (triggers lazy load),
|
||||
# then spec re-download so real routes replace the stub. Raw JS (no
|
||||
# <script> tag) since it's injected inside the existing inline script.
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from litellm.proxy._lazy_features import lazy_tag_to_prefix
|
||||
|
||||
_lazy_plugin_js = (
|
||||
"const TAG_TO_PREFIX = " + json.dumps(lazy_tag_to_prefix()) + ";"
|
||||
"const warmedTags = new Set();"
|
||||
"const LAZY_TAGS = new Set(Object.keys(TAG_TO_PREFIX));"
|
||||
"const hideStubRows = () => {"
|
||||
"document.querySelectorAll('.opblock').forEach(op => {"
|
||||
"const d = op.querySelector('.opblock-summary-description');"
|
||||
"if (d && LAZY_TAGS.has(d.textContent.trim())) op.style.display = 'none';"
|
||||
"});};"
|
||||
"const annotateLazyHeaders = () => {"
|
||||
"document.querySelectorAll('.opblock-tag').forEach(tagEl => {"
|
||||
"const m = (tagEl.id || '').match(/^operations-tag-(.+)$/);"
|
||||
"if (!m || !LAZY_TAGS.has(m[1])) return;"
|
||||
"const existing = tagEl.querySelector('.lazy-load-hint');"
|
||||
"if (warmedTags.has(m[1])) { if (existing) existing.remove(); return; }"
|
||||
"if (existing) return;"
|
||||
"const hint = document.createElement('small');"
|
||||
"hint.className = 'lazy-load-hint';"
|
||||
"hint.textContent = ' (expand to load routes)';"
|
||||
"hint.style.opacity = '0.6';"
|
||||
"hint.style.marginLeft = '6px';"
|
||||
"const target = tagEl.querySelector('a span') || tagEl.querySelector('span') || tagEl;"
|
||||
"target.appendChild(hint);"
|
||||
"});};"
|
||||
"setInterval(() => { hideStubRows(); annotateLazyHeaders(); }, 200);"
|
||||
"const LazyLoadPlugin = () => ({"
|
||||
"afterLoad:function(system){setTimeout(()=>{"
|
||||
"for(const tag of LAZY_TAGS)system.layoutActions.show(['operations-tag',tag],false);"
|
||||
"},200);},"
|
||||
"statePlugins:{layout:{wrapActions:{show:(ori,sys)=>(...args)=>{"
|
||||
"const thing=args[0];const shown=args[1];let tag=null;"
|
||||
"if(Array.isArray(thing)){for(const t of thing)if(TAG_TO_PREFIX[t])tag=t;}"
|
||||
"if(shown!==false&&tag&&!warmedTags.has(tag)){warmedTags.add(tag);"
|
||||
"fetch('/lazy/warm/'+tag,{method:'POST',credentials:'include'}).then(r=>r.json()).then(d=>{"
|
||||
"if(!d.paths||Object.keys(d.paths).length===0)return;"
|
||||
"const cur=sys.specSelectors.specJson().toJS();"
|
||||
"const merged={};let inserted=false;"
|
||||
"for(const k in (cur.paths||{})){"
|
||||
"if(k===d.stub_path){for(const nk in d.paths)merged[nk]=d.paths[nk];inserted=true;}"
|
||||
"else{merged[k]=cur.paths[k];}}"
|
||||
"if(!inserted)Object.assign(merged,d.paths);"
|
||||
"cur.paths=merged;"
|
||||
"cur.components=cur.components||{};"
|
||||
"cur.components.schemas=Object.assign(cur.components.schemas||{},(d.components||{}).schemas||{});"
|
||||
"sys.specActions.updateSpec(JSON.stringify(cur));"
|
||||
"}).catch(()=>{});}"
|
||||
"return ori(...args);}}}}});"
|
||||
)
|
||||
|
||||
def swagger_monkey_patch(*args, **kwargs):
|
||||
return get_swagger_ui_html(
|
||||
response = get_swagger_ui_html(
|
||||
*args,
|
||||
**kwargs,
|
||||
swagger_js_url=f"{custom_root_path_swagger_path}/swagger-ui-bundle.js",
|
||||
swagger_css_url=f"{custom_root_path_swagger_path}/swagger-ui.css",
|
||||
swagger_favicon_url=f"{custom_root_path_swagger_path}/favicon.png",
|
||||
)
|
||||
body = response.body.decode("utf-8")
|
||||
body = body.replace(
|
||||
"const ui = SwaggerUIBundle({",
|
||||
_lazy_plugin_js
|
||||
+ 'const ui = SwaggerUIBundle({plugins:[LazyLoadPlugin],tagsSorter:"alpha",',
|
||||
1,
|
||||
)
|
||||
return HTMLResponse(content=body)
|
||||
|
||||
applications.get_swagger_ui_html = swagger_monkey_patch
|
||||
|
||||
@@ -3908,11 +3912,19 @@ class ProxyConfig:
|
||||
## MCP TOOLS
|
||||
mcp_tools_config = config.get("mcp_tools", None)
|
||||
if mcp_tools_config:
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import (
|
||||
global_mcp_tool_registry,
|
||||
)
|
||||
|
||||
global_mcp_tool_registry.load_tools_from_config(mcp_tools_config)
|
||||
|
||||
## AGENTS
|
||||
agent_config = config.get("agent_list", None)
|
||||
if agent_config:
|
||||
from litellm.proxy.agent_endpoints.agent_registry import (
|
||||
global_agent_registry,
|
||||
)
|
||||
|
||||
global_agent_registry.load_agents_from_config(agent_config) # type: ignore
|
||||
|
||||
mcp_servers_config = config.get("mcp_servers", None)
|
||||
@@ -10630,6 +10642,10 @@ async def model_info_v2(
|
||||
verbose_proxy_logger.debug("all_models: %s", all_models)
|
||||
|
||||
# Append A2A agents to models list
|
||||
from litellm.proxy.agent_endpoints.model_list_helpers import (
|
||||
append_agents_to_model_info,
|
||||
)
|
||||
|
||||
all_models = await append_agents_to_model_info(
|
||||
models=all_models,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
@@ -11479,6 +11495,10 @@ async def model_group_info(
|
||||
)
|
||||
|
||||
# Append A2A agents to model groups
|
||||
from litellm.proxy.agent_endpoints.model_list_helpers import (
|
||||
append_agents_to_model_group,
|
||||
)
|
||||
|
||||
model_groups = await append_agents_to_model_group(
|
||||
model_groups=model_groups,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
@@ -12427,9 +12447,20 @@ async def claim_onboarding_link(data: InvitationClaim, request: Request):
|
||||
|
||||
@app.get("/get_logo_url", include_in_schema=False)
|
||||
def get_logo_url():
|
||||
"""Get the current logo URL from environment"""
|
||||
"""Get the current logo URL from environment.
|
||||
|
||||
Only HTTP(S) URLs are returned — those are intended to be loaded
|
||||
directly by the browser from a public/internal CDN. Local file
|
||||
paths set via ``UI_LOGO_PATH`` are NOT returned: they are admin-
|
||||
only filesystem details, the dashboard falls back to ``/get_image``
|
||||
which serves the file only when it is a supported image. Without
|
||||
this filter, the unauthenticated endpoint would disclose internal
|
||||
hostnames or filesystem paths to any caller.
|
||||
"""
|
||||
logo_path = os.getenv("UI_LOGO_PATH", "")
|
||||
return {"logo_url": logo_path}
|
||||
if logo_path.startswith(("http://", "https://")):
|
||||
return {"logo_url": logo_path}
|
||||
return {"logo_url": ""}
|
||||
|
||||
|
||||
@app.get("/get_image", include_in_schema=False)
|
||||
@@ -12468,61 +12499,44 @@ async def get_image():
|
||||
if assets_dir != current_dir and not os.path.exists(default_logo):
|
||||
default_logo = default_site_logo
|
||||
|
||||
cache_dir = assets_dir if os.access(assets_dir, os.W_OK) else current_dir
|
||||
cache_path = os.path.join(cache_dir, "cached_logo.jpg")
|
||||
|
||||
logo_path = os.getenv("UI_LOGO_PATH", default_logo)
|
||||
verbose_proxy_logger.debug("Reading logo from path: %s", logo_path)
|
||||
|
||||
# If UI_LOGO_PATH points to a local file, serve it directly (skip cache)
|
||||
from litellm.proxy.common_utils.static_asset_utils import (
|
||||
resolve_validated_local_image_path,
|
||||
)
|
||||
|
||||
if logo_path != default_logo and not logo_path.startswith(("http://", "https://")):
|
||||
if os.path.exists(logo_path):
|
||||
return FileResponse(logo_path, media_type="image/jpeg")
|
||||
# Custom path doesn't exist — fall back to default
|
||||
safe_logo = resolve_validated_local_image_path(logo_path)
|
||||
if safe_logo is not None:
|
||||
safe_logo_path, media_type = safe_logo
|
||||
return FileResponse(safe_logo_path, media_type=media_type)
|
||||
verbose_proxy_logger.warning(
|
||||
f"UI_LOGO_PATH '{logo_path}' does not exist, falling back to default logo"
|
||||
"UI_LOGO_PATH %r is not a supported image file or does not exist, "
|
||||
"falling back to default logo",
|
||||
logo_path,
|
||||
)
|
||||
logo_path = default_logo
|
||||
|
||||
# [OPTIMIZATION] For HTTP URLs and default logo, check if the cached image exists
|
||||
if os.path.exists(cache_path):
|
||||
return FileResponse(cache_path, media_type="image/jpeg")
|
||||
|
||||
# Check if the logo path is an HTTP/HTTPS URL
|
||||
# Remote logo URLs are loaded by the browser. The proxy should not fetch
|
||||
# arbitrary admin-configured URLs server-side.
|
||||
if logo_path.startswith(("http://", "https://")):
|
||||
try:
|
||||
# Download the image and cache it
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
return RedirectResponse(url=logo_path)
|
||||
|
||||
async_client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.UI,
|
||||
params={"timeout": 5.0},
|
||||
)
|
||||
response = await async_client.get(logo_path)
|
||||
if response.status_code == 200:
|
||||
# Save the image to a local file
|
||||
with open(cache_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
# Return the cached image as a FileResponse
|
||||
return FileResponse(cache_path, media_type="image/jpeg")
|
||||
else:
|
||||
# Handle the case when the image cannot be downloaded
|
||||
return FileResponse(default_logo, media_type="image/jpeg")
|
||||
except Exception as e:
|
||||
# Handle any exceptions during the download (e.g., timeout, connection error)
|
||||
verbose_proxy_logger.debug(f"Error downloading logo from {logo_path}: {e}")
|
||||
return FileResponse(default_logo, media_type="image/jpeg")
|
||||
else:
|
||||
# Return the local image file if the logo path is not an HTTP/HTTPS URL
|
||||
return FileResponse(logo_path, media_type="image/jpeg")
|
||||
# Default logo (resolved from the bundled asset, not user-controlled).
|
||||
safe_logo = resolve_validated_local_image_path(logo_path)
|
||||
if safe_logo is not None:
|
||||
safe_logo_path, media_type = safe_logo
|
||||
return FileResponse(safe_logo_path, media_type=media_type)
|
||||
return FileResponse(default_site_logo, media_type="image/jpeg")
|
||||
|
||||
|
||||
@app.get("/get_favicon", include_in_schema=False)
|
||||
async def get_favicon():
|
||||
"""Get custom favicon for the admin UI."""
|
||||
from fastapi.responses import Response
|
||||
from litellm.proxy.common_utils.static_asset_utils import (
|
||||
resolve_validated_local_image_path,
|
||||
)
|
||||
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
default_favicon = os.path.join(current_dir, "_experimental", "out", "favicon.ico")
|
||||
@@ -12535,42 +12549,17 @@ async def get_favicon():
|
||||
raise HTTPException(status_code=404, detail="Default favicon not found")
|
||||
|
||||
if favicon_url.startswith(("http://", "https://")):
|
||||
try:
|
||||
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
|
||||
from litellm.types.llms.custom_http import httpxSpecialProvider
|
||||
|
||||
async_client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.UI,
|
||||
params={"timeout": 5.0},
|
||||
)
|
||||
response = await async_client.get(favicon_url)
|
||||
if response.status_code == 200:
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
return Response(
|
||||
content=response.content,
|
||||
media_type=content_type,
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to fetch favicon from %s: status %s",
|
||||
favicon_url,
|
||||
response.status_code,
|
||||
)
|
||||
if os.path.exists(default_favicon):
|
||||
return FileResponse(default_favicon, media_type="image/x-icon")
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(
|
||||
"Error downloading favicon from %s: %s", favicon_url, e
|
||||
)
|
||||
if os.path.exists(default_favicon):
|
||||
return FileResponse(default_favicon, media_type="image/x-icon")
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
return RedirectResponse(url=favicon_url)
|
||||
else:
|
||||
if os.path.exists(favicon_url):
|
||||
return FileResponse(favicon_url, media_type="image/x-icon")
|
||||
safe_favicon = resolve_validated_local_image_path(favicon_url)
|
||||
if safe_favicon is not None:
|
||||
safe_favicon_path, media_type = safe_favicon
|
||||
return FileResponse(safe_favicon_path, media_type=media_type)
|
||||
verbose_proxy_logger.warning(
|
||||
"LITELLM_FAVICON_URL %r is not a supported image file or does not "
|
||||
"exist, falling back to default favicon",
|
||||
favicon_url,
|
||||
)
|
||||
if os.path.exists(default_favicon):
|
||||
return FileResponse(default_favicon, media_type="image/x-icon")
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
@@ -14441,66 +14430,41 @@ app.include_router(container_router)
|
||||
app.include_router(search_router)
|
||||
app.include_router(image_router)
|
||||
app.include_router(fine_tuning_router)
|
||||
app.include_router(vector_store_router)
|
||||
app.include_router(vector_store_management_router)
|
||||
app.include_router(vector_store_files_router)
|
||||
app.include_router(credential_router)
|
||||
app.include_router(llm_passthrough_router)
|
||||
app.include_router(webrtc_router)
|
||||
app.include_router(mcp_management_router)
|
||||
app.include_router(mcp_byok_oauth_router)
|
||||
app.include_router(anthropic_router)
|
||||
app.include_router(anthropic_skills_router)
|
||||
app.include_router(evals_router)
|
||||
app.include_router(claude_code_marketplace_router)
|
||||
app.include_router(google_router)
|
||||
app.include_router(langfuse_router)
|
||||
app.include_router(pass_through_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(key_management_router)
|
||||
app.include_router(internal_user_router)
|
||||
app.include_router(team_router)
|
||||
app.include_router(ui_sso_router)
|
||||
app.include_router(scim_router)
|
||||
app.include_router(organization_router)
|
||||
app.include_router(customer_router)
|
||||
app.include_router(spend_management_router)
|
||||
app.include_router(cloudzero_router)
|
||||
app.include_router(vantage_router)
|
||||
app.include_router(caching_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(guardrails_router)
|
||||
app.include_router(policy_router)
|
||||
app.include_router(usage_ai_router)
|
||||
app.include_router(policy_crud_router)
|
||||
app.include_router(policy_resolve_router)
|
||||
app.include_router(search_tool_management_router)
|
||||
app.include_router(prompts_router)
|
||||
app.include_router(callback_management_endpoints_router)
|
||||
app.include_router(debugging_endpoints_router)
|
||||
app.include_router(ui_crud_endpoints_router)
|
||||
app.include_router(openai_files_router)
|
||||
app.include_router(team_callback_router)
|
||||
app.include_router(jwt_key_mapping_router)
|
||||
app.include_router(budget_management_router)
|
||||
app.include_router(model_management_router)
|
||||
app.include_router(model_access_group_management_router)
|
||||
app.include_router(tag_management_router)
|
||||
app.include_router(tool_management_router)
|
||||
app.include_router(workflow_management_router)
|
||||
app.include_router(memory_router)
|
||||
app.include_router(cost_tracking_settings_router)
|
||||
app.include_router(router_settings_router)
|
||||
app.include_router(fallback_management_router)
|
||||
app.include_router(cache_settings_router)
|
||||
app.include_router(config_override_router)
|
||||
app.include_router(user_agent_analytics_router)
|
||||
app.include_router(enterprise_router)
|
||||
app.include_router(ui_discovery_endpoints_router)
|
||||
app.include_router(agent_endpoints_router)
|
||||
app.include_router(compliance_router)
|
||||
app.include_router(a2a_router)
|
||||
app.include_router(access_group_router)
|
||||
# Eager: /models/{name}:method overlaps with the OpenAI /models endpoint.
|
||||
app.include_router(google_router)
|
||||
|
||||
attach_lazy_features(app)
|
||||
|
||||
|
||||
async def _stream_mcp_asgi_response(
|
||||
@@ -14733,8 +14697,3 @@ async def dynamic_mcp_route(mcp_server_name: str, request: Request):
|
||||
f"Error handling dynamic MCP route for {mcp_server_name}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
||||
|
||||
|
||||
app.mount(path=BASE_MCP_ROUTE, app=mcp_app)
|
||||
app.include_router(mcp_rest_endpoints_router)
|
||||
app.include_router(mcp_discoverable_endpoints_router)
|
||||
|
||||
@@ -237,6 +237,8 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
|
||||
# Vector Store Params
|
||||
vector_store_id: Optional[str] = None
|
||||
milvus_text_field: Optional[str] = None
|
||||
milvus_db_name: Optional[str] = None
|
||||
milvus_partition_names: Optional[List[str]] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -812,7 +812,7 @@ def test_redact_msgs_from_logs_with_dynamic_params():
|
||||
# Assert redaction occurred
|
||||
assert _redacted_response_obj.choices[0].message.content == "redacted-by-litellm"
|
||||
|
||||
# Test Case 3: standard_callback_dynamic_params does not override litellm.turn_off_message_logging
|
||||
# Test Case 3: standard_callback_dynamic_params does not set turn_off_message_logging
|
||||
# since litellm.turn_off_message_logging is True redaction should occur
|
||||
standard_callback_dynamic_params = StandardCallbackDynamicParams()
|
||||
litellm_logging_obj.model_call_details["standard_callback_dynamic_params"] = (
|
||||
|
||||
@@ -13,11 +13,13 @@ import logging
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.responses.main import mock_responses_api_response
|
||||
from litellm.types.utils import StandardLoggingPayload
|
||||
|
||||
|
||||
@@ -126,17 +128,10 @@ async def test_redaction_responses_api():
|
||||
test_custom_logger = TestCustomLogger(turn_off_message_logging=True)
|
||||
litellm.callbacks = [test_custom_logger]
|
||||
|
||||
# Mock a ResponsesAPIResponse-style response
|
||||
mock_response = {
|
||||
"output": [{"text": "This is a test response"}],
|
||||
"model": "gpt-3.5-turbo",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
|
||||
}
|
||||
|
||||
response = await litellm.aresponses(
|
||||
model="gpt-3.5-turbo",
|
||||
input="hi",
|
||||
mock_response=mock_response,
|
||||
mock_response="This is a test response",
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
@@ -163,6 +158,7 @@ async def test_redaction_responses_api():
|
||||
assert (
|
||||
content_item["text"] == "redacted-by-litellm"
|
||||
), f"Expected redacted text but got: {content_item['text']}"
|
||||
assert "This is a test response" not in json.dumps(standard_logging_payload)
|
||||
print(
|
||||
"logged standard logging payload for ResponsesAPIResponse",
|
||||
json.dumps(standard_logging_payload, indent=2),
|
||||
@@ -176,29 +172,36 @@ async def test_redaction_responses_api_stream():
|
||||
test_custom_logger = TestCustomLogger(turn_off_message_logging=True)
|
||||
litellm.callbacks = [test_custom_logger]
|
||||
|
||||
# Mock a ResponsesAPIResponse-style response with streaming chunks
|
||||
mock_response = [
|
||||
{
|
||||
"output": [{"text": "This"}],
|
||||
"model": "gpt-3.5-turbo",
|
||||
},
|
||||
{
|
||||
"output": [{"text": " is"}],
|
||||
"model": "gpt-3.5-turbo",
|
||||
},
|
||||
{
|
||||
"output": [{"text": " a test response"}],
|
||||
"model": "gpt-3.5-turbo",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
|
||||
},
|
||||
]
|
||||
mocked_response_payload = mock_responses_api_response(
|
||||
"This is a test response"
|
||||
).model_dump()
|
||||
|
||||
response = await litellm.aresponses(
|
||||
model="gpt-3.5-turbo",
|
||||
input="hi",
|
||||
mock_response=mock_response,
|
||||
stream=True,
|
||||
)
|
||||
async def mock_post(self, url, headers, timeout, stream=False, **kwargs):
|
||||
stream_content = (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": mocked_response_payload,
|
||||
}
|
||||
)
|
||||
+ "\n\ndata: [DONE]\n\n"
|
||||
)
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=stream_content,
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
|
||||
new=mock_post,
|
||||
):
|
||||
response = await litellm.aresponses(
|
||||
model="gpt-3.5-turbo",
|
||||
input="hi",
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Consume the stream
|
||||
chunks = []
|
||||
@@ -445,18 +448,11 @@ async def test_disable_redaction_header_responses_api():
|
||||
test_custom_logger = TestCustomLogger()
|
||||
litellm.callbacks = [test_custom_logger]
|
||||
|
||||
# Mock a ResponsesAPIResponse-style response
|
||||
mock_response = {
|
||||
"output": [{"text": "This is a test response"}],
|
||||
"model": "gpt-3.5-turbo",
|
||||
"usage": {"input_tokens": 5, "output_tokens": 5, "total_tokens": 10},
|
||||
}
|
||||
|
||||
# Pass the header via litellm_metadata (as the proxy does for Responses API)
|
||||
response = await litellm.aresponses(
|
||||
model="gpt-3.5-turbo",
|
||||
input="hi",
|
||||
mock_response=mock_response,
|
||||
mock_response="This is a test response",
|
||||
litellm_metadata={"headers": {"litellm-disable-message-redaction": "true"}},
|
||||
)
|
||||
|
||||
@@ -464,14 +460,14 @@ async def test_disable_redaction_header_responses_api():
|
||||
standard_logging_payload = test_custom_logger.logged_standard_logging_payload
|
||||
assert standard_logging_payload is not None
|
||||
|
||||
# Verify that messages are NOT redacted because the header was set
|
||||
# Verify that the direct SDK path still honors the explicit header.
|
||||
print(
|
||||
"logged standard logging payload for ResponsesAPI with disable header",
|
||||
json.dumps(standard_logging_payload, indent=2, default=str),
|
||||
)
|
||||
|
||||
# The content should NOT be redacted
|
||||
assert standard_logging_payload["response"] != {"text": "redacted-by-litellm"}
|
||||
response = standard_logging_payload["response"]
|
||||
assert response["output"][0]["content"][0]["text"] == "This is a test response"
|
||||
assert standard_logging_payload["messages"][0]["content"] == "hi"
|
||||
|
||||
|
||||
|
||||
@@ -45,8 +45,11 @@ verbose_proxy_logger.setLevel(level=logging.DEBUG)
|
||||
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from litellm.proxy.management_helpers.audit_logs import create_audit_log_for_update
|
||||
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames
|
||||
from litellm.proxy.management_helpers.audit_logs import (
|
||||
create_audit_log_for_update,
|
||||
get_audit_log_changed_by,
|
||||
)
|
||||
from litellm.proxy._types import LiteLLM_AuditLogs, LitellmTableNames, UserAPIKeyAuth
|
||||
from litellm.caching.caching import DualCache
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
@@ -54,6 +57,119 @@ proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
|
||||
import json
|
||||
|
||||
|
||||
def test_get_audit_log_changed_by_prefers_authenticated_user():
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="authenticated-user",
|
||||
)
|
||||
|
||||
assert (
|
||||
get_audit_log_changed_by(
|
||||
litellm_changed_by="spoofed-user",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name="proxy-admin",
|
||||
)
|
||||
== "authenticated-user"
|
||||
)
|
||||
|
||||
|
||||
def test_get_audit_log_changed_by_honors_header_with_admin_opt_in():
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="service-account",
|
||||
metadata={"allow_litellm_changed_by_header": True},
|
||||
)
|
||||
|
||||
assert (
|
||||
get_audit_log_changed_by(
|
||||
litellm_changed_by="delegated-user",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name="proxy-admin",
|
||||
)
|
||||
== "delegated-user"
|
||||
)
|
||||
|
||||
|
||||
def test_get_audit_log_changed_by_honors_header_with_team_opt_in():
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="service-account",
|
||||
team_metadata={"allow_litellm_changed_by_header": True},
|
||||
)
|
||||
|
||||
assert (
|
||||
get_audit_log_changed_by(
|
||||
litellm_changed_by="delegated-user",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name="proxy-admin",
|
||||
)
|
||||
== "delegated-user"
|
||||
)
|
||||
|
||||
|
||||
def test_get_audit_log_changed_by_ignores_header_without_opt_in_when_user_id_missing():
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
|
||||
|
||||
assert (
|
||||
get_audit_log_changed_by(
|
||||
litellm_changed_by="spoofed-user",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name="proxy-admin",
|
||||
)
|
||||
== "proxy-admin"
|
||||
)
|
||||
|
||||
|
||||
def test_get_audit_log_changed_by_honors_header_with_opt_in_when_user_id_missing():
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
metadata={"allow_litellm_changed_by_header": True},
|
||||
)
|
||||
|
||||
assert (
|
||||
get_audit_log_changed_by(
|
||||
litellm_changed_by="delegated-user",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name="proxy-admin",
|
||||
)
|
||||
== "delegated-user"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_internal_user_audit_log_uses_changed_by_helper():
|
||||
from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventHooks
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
user_id="service-account",
|
||||
metadata={"allow_litellm_changed_by_header": True},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.store_audit_logs", True),
|
||||
patch(
|
||||
"litellm.proxy.hooks.user_management_event_hooks.create_audit_log_for_update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create_audit_log_for_update,
|
||||
):
|
||||
await UserManagementEventHooks.create_internal_user_audit_log(
|
||||
user_id="target-user",
|
||||
action="updated",
|
||||
litellm_changed_by="delegated-user",
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name="proxy-admin",
|
||||
before_value='{"before": true}',
|
||||
after_value='{"after": true}',
|
||||
)
|
||||
|
||||
request_data = mock_create_audit_log_for_update.await_args.kwargs["request_data"]
|
||||
assert request_data.changed_by == "delegated-user"
|
||||
assert request_data.changed_by_api_key == "test-key"
|
||||
assert request_data.object_id == "target-user"
|
||||
assert request_data.action == "updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_audit_log_for_update_premium_user():
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
@@ -26,50 +25,30 @@ async def test_get_favicon_default():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_favicon_with_custom_url():
|
||||
"""Test that get_favicon fetches from a custom URL."""
|
||||
os.environ["LITELLM_FAVICON_URL"] = "https://example.com/favicon.ico"
|
||||
async def test_get_favicon_with_custom_url(monkeypatch):
|
||||
"""Test that get_favicon redirects browser-loaded custom URLs."""
|
||||
monkeypatch.setenv("LITELLM_FAVICON_URL", "https://example.com/favicon.ico")
|
||||
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = b"\x00\x00\x01\x00"
|
||||
mock_response.headers = {"content-type": "image/x-icon"}
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as ac:
|
||||
response = await ac.get("/get_favicon")
|
||||
|
||||
try:
|
||||
with mock.patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get"
|
||||
) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as ac:
|
||||
response = await ac.get("/get_favicon")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/x-icon"
|
||||
finally:
|
||||
os.environ.pop("LITELLM_FAVICON_URL", None)
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "https://example.com/favicon.ico"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_favicon_url_error_fallback():
|
||||
"""Test that get_favicon falls back to default on error."""
|
||||
os.environ["LITELLM_FAVICON_URL"] = "https://invalid.com/favicon.ico"
|
||||
async def test_get_favicon_remote_url_is_not_server_fetched(monkeypatch):
|
||||
"""Test that get_favicon does not validate remote URLs server-side."""
|
||||
monkeypatch.setenv("LITELLM_FAVICON_URL", "https://invalid.com/favicon.ico")
|
||||
|
||||
try:
|
||||
with mock.patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get"
|
||||
) as mock_get:
|
||||
mock_get.side_effect = httpx.ConnectError("unreachable")
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as ac:
|
||||
response = await ac.get("/get_favicon")
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://testserver",
|
||||
) as ac:
|
||||
response = await ac.get("/get_favicon")
|
||||
|
||||
assert response.status_code in [200, 404]
|
||||
finally:
|
||||
os.environ.pop("LITELLM_FAVICON_URL", None)
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "https://invalid.com/favicon.ico"
|
||||
|
||||
@@ -5,85 +5,48 @@ from unittest import mock
|
||||
# Standard path insertion
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
import pytest
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_error_handling():
|
||||
async def test_get_image_redirects_remote_logo_without_server_fetch(monkeypatch):
|
||||
"""
|
||||
Test that get_image handles network errors gracefully and doesn't hang.
|
||||
Remote logo URLs should be loaded by the browser, not fetched by the proxy.
|
||||
"""
|
||||
# Set an unreachable URL
|
||||
os.environ["UI_LOGO_PATH"] = "http://invalid-url-12345.com/logo.jpg"
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "http://invalid-url-12345.com/logo.jpg")
|
||||
|
||||
# Clear cache
|
||||
parent_dir = os.path.dirname(
|
||||
os.path.dirname(
|
||||
app.__file__
|
||||
if hasattr(app, "__file__")
|
||||
else "litellm/proxy/proxy_server.py"
|
||||
)
|
||||
)
|
||||
cache_path = os.path.join(parent_dir, "proxy", "cached_logo.jpg")
|
||||
if os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
|
||||
# Mock AsyncHTTPHandler to simulate a timeout or connection error
|
||||
with mock.patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get"
|
||||
) as mock_get:
|
||||
mock_get.side_effect = httpx.ConnectError("Network is unreachable")
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as ac:
|
||||
response = await ac.get("/get_image")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/jpeg"
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "http://invalid-url-12345.com/logo.jpg"
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_cache_logic():
|
||||
async def test_get_image_remote_logo_does_not_use_stale_cache(monkeypatch, tmp_path):
|
||||
"""
|
||||
Test that once cached, get_image doesn't hit the network.
|
||||
A stale pre-fix cache file should not mask a configured remote logo URL.
|
||||
"""
|
||||
os.environ["UI_LOGO_PATH"] = "http://example.com/logo.jpg"
|
||||
|
||||
# Clear cache
|
||||
parent_dir = os.path.dirname(
|
||||
os.path.dirname(
|
||||
app.__file__
|
||||
if hasattr(app, "__file__")
|
||||
else "litellm/proxy/proxy_server.py"
|
||||
)
|
||||
)
|
||||
cache_path = os.path.join(parent_dir, "proxy", "cached_logo.jpg")
|
||||
if os.path.exists(cache_path):
|
||||
os.remove(cache_path)
|
||||
|
||||
# Mock response
|
||||
mock_response = mock.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = b"fake image data"
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "http://example.com/logo.jpg")
|
||||
monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path))
|
||||
(tmp_path / "cached_logo.jpg").write_bytes(b"\xff\xd8\xff cached logo")
|
||||
|
||||
with mock.patch(
|
||||
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get"
|
||||
) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
||||
) as ac:
|
||||
# First call - should hit download logic
|
||||
response1 = await ac.get("/get_image")
|
||||
assert response1.status_code == 200
|
||||
assert mock_get.call_count == 1
|
||||
response = await ac.get("/get_image")
|
||||
|
||||
# Second call - should hit cache
|
||||
response2 = await ac.get("/get_image")
|
||||
assert response2.status_code == 200
|
||||
# If cache works, mock_get shouldn't be called again
|
||||
assert mock_get.call_count == 1
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "http://example.com/logo.jpg"
|
||||
mock_get.assert_not_called()
|
||||
|
||||
@@ -39,6 +39,23 @@ def test_routes_on_litellm_proxy():
|
||||
|
||||
this prevents accidentelly deleting /threads, or /batches etc
|
||||
"""
|
||||
# Force-load lazy features so the test sees the full route set. Continue
|
||||
# on per-feature import failure — the assertion below still catches
|
||||
# missing-route regressions.
|
||||
import importlib
|
||||
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||||
|
||||
registered_paths = [getattr(r, "path", "") for r in app.routes]
|
||||
for feat in LAZY_FEATURES:
|
||||
if any(rp.startswith(p) for p in feat.path_prefixes for rp in registered_paths):
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module(feat.module_path)
|
||||
feat.register_fn(app, module)
|
||||
except Exception as exc:
|
||||
print(f"warning: failed to force-load {feat.name}: {exc}")
|
||||
|
||||
_all_routes = []
|
||||
for route in app.routes:
|
||||
|
||||
|
||||
@@ -1553,6 +1553,7 @@ async def test_add_callback_via_key(prisma_client):
|
||||
fastapi_response=Response(),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
metadata={
|
||||
"allow_client_mock_response": True,
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse", # 'otel', 'langfuse', 'lunary'
|
||||
@@ -1563,7 +1564,7 @@ async def test_add_callback_via_key(prisma_client):
|
||||
"langfuse_host": "https://us.cloud.langfuse.com",
|
||||
},
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -1657,6 +1658,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils(
|
||||
team_id=None,
|
||||
max_parallel_requests=None,
|
||||
metadata={
|
||||
"allow_client_mock_response": True,
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
@@ -1667,7 +1669,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils(
|
||||
"langfuse_host": "https://us.cloud.langfuse.com",
|
||||
},
|
||||
}
|
||||
]
|
||||
],
|
||||
},
|
||||
tpm_limit=None,
|
||||
rpm_limit=None,
|
||||
@@ -1813,6 +1815,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_gcs_bucket(
|
||||
team_id=None,
|
||||
max_parallel_requests=None,
|
||||
metadata={
|
||||
"allow_client_mock_response": True,
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "gcs_bucket",
|
||||
@@ -1822,7 +1825,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_gcs_bucket(
|
||||
"gcs_path_service_account": "pathrise-convert-1606954137718-a956eef1a2a8.json",
|
||||
},
|
||||
}
|
||||
]
|
||||
],
|
||||
},
|
||||
tpm_limit=None,
|
||||
rpm_limit=None,
|
||||
@@ -1946,6 +1949,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_langsmith(
|
||||
team_id=None,
|
||||
max_parallel_requests=None,
|
||||
metadata={
|
||||
"allow_client_mock_response": True,
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langsmith",
|
||||
@@ -1956,7 +1960,7 @@ async def test_add_callback_via_key_litellm_pre_call_utils_langsmith(
|
||||
"langsmith_base_url": "https://api.smith.langchain.com",
|
||||
},
|
||||
}
|
||||
]
|
||||
],
|
||||
},
|
||||
tpm_limit=None,
|
||||
rpm_limit=None,
|
||||
|
||||
@@ -235,18 +235,10 @@ async def test_add_key_or_team_level_spend_logs_metadata_to_request(
|
||||
"langfuse_host": "https://us.cloud.langfuse.com",
|
||||
"langfuse_public_key": "pk-lf-9636b7a6-c066",
|
||||
"langfuse_secret_key": "sk-lf-7cc8b620",
|
||||
},
|
||||
{
|
||||
"langfuse_host": "os.environ/LANGFUSE_HOST_TEMP",
|
||||
"langfuse_public_key": "os.environ/LANGFUSE_PUBLIC_KEY_TEMP",
|
||||
"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY_TEMP",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
def test_dynamic_logging_metadata_key_and_team_metadata(callback_vars):
|
||||
os.environ["LANGFUSE_PUBLIC_KEY_TEMP"] = "pk-lf-9636b7a6-c066"
|
||||
os.environ["LANGFUSE_SECRET_KEY_TEMP"] = "sk-lf-7cc8b620"
|
||||
os.environ["LANGFUSE_HOST_TEMP"] = "https://us.cloud.langfuse.com"
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
@@ -317,6 +309,41 @@ def test_dynamic_logging_metadata_key_and_team_metadata(callback_vars):
|
||||
assert "os.environ" not in var
|
||||
|
||||
|
||||
def test_dynamic_logging_metadata_ignores_env_references_from_key_metadata(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY_TEMP", "server-side-secret")
|
||||
monkeypatch.setattr(
|
||||
litellm.utils,
|
||||
"get_secret",
|
||||
lambda *args, **kwargs: pytest.fail("get_secret should not be called"),
|
||||
)
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
metadata={
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success",
|
||||
"callback_vars": {
|
||||
"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY_TEMP",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
team_metadata={},
|
||||
)
|
||||
|
||||
callbacks = _get_dynamic_logging_metadata(
|
||||
user_api_key_dict=user_api_key_dict, proxy_config=proxy_config
|
||||
)
|
||||
|
||||
assert callbacks is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"callback_vars",
|
||||
[
|
||||
@@ -1263,11 +1290,16 @@ def test_proxy_config_state_post_init_callback_call(monkeypatch):
|
||||
}
|
||||
)
|
||||
|
||||
LiteLLMProxyRequestSetup.add_team_based_callbacks_from_config(
|
||||
callback_metadata = LiteLLMProxyRequestSetup.add_team_based_callbacks_from_config(
|
||||
team_id="test",
|
||||
proxy_config=pc,
|
||||
)
|
||||
|
||||
assert callback_metadata is not None
|
||||
assert callback_metadata.callback_vars is not None
|
||||
assert callback_metadata.callback_vars["langfuse_public_key"] == "test_public_key"
|
||||
assert callback_metadata.callback_vars["langfuse_secret"] == "test_secret_key"
|
||||
|
||||
config = pc.get_config_state()
|
||||
assert config["litellm_settings"]["default_team_settings"][0]["team_id"] == "test"
|
||||
|
||||
|
||||
@@ -5,10 +5,17 @@ Covers the proxy flow where headers arrive in litellm_params["metadata"]["header
|
||||
but litellm_params["litellm_metadata"] is None.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.redact_messages import should_redact_message_logging
|
||||
from litellm.litellm_core_utils.redact_messages import (
|
||||
_redact_responses_api_output,
|
||||
perform_redaction,
|
||||
should_redact_message_logging,
|
||||
)
|
||||
from litellm.responses.main import mock_responses_api_response
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -68,8 +75,7 @@ class TestShouldRedactMessageLogging:
|
||||
assert should_redact_message_logging(details) is True
|
||||
|
||||
def test_disable_redaction_via_header_proxy_flow(self):
|
||||
"""litellm-disable-message-redaction should suppress redaction
|
||||
even when global setting is on, and litellm_metadata is None."""
|
||||
"""Core helper still honors the explicit disable-redaction header."""
|
||||
litellm.turn_off_message_logging = True
|
||||
details = _make_model_call_details(
|
||||
metadata_headers={"litellm-disable-message-redaction": "true"},
|
||||
@@ -77,6 +83,14 @@ class TestShouldRedactMessageLogging:
|
||||
)
|
||||
assert should_redact_message_logging(details) is False
|
||||
|
||||
def test_disable_redaction_via_header_when_global_off(self):
|
||||
"""litellm-disable-message-redaction is still honored when global redaction is off."""
|
||||
details = _make_model_call_details(
|
||||
metadata_headers={"litellm-disable-message-redaction": "true"},
|
||||
litellm_metadata=None,
|
||||
)
|
||||
assert should_redact_message_logging(details) is False
|
||||
|
||||
# ---- SDK direct-call flow: headers in litellm_metadata ----
|
||||
|
||||
def test_enable_redaction_via_header_in_litellm_metadata(self):
|
||||
@@ -127,6 +141,16 @@ class TestShouldRedactMessageLogging:
|
||||
)
|
||||
assert should_redact_message_logging(details) is False
|
||||
|
||||
def test_dynamic_param_false_overrides_global_redaction(self):
|
||||
"""Dynamic turn_off_message_logging=False should take precedence."""
|
||||
litellm.turn_off_message_logging = True
|
||||
details = _make_model_call_details(
|
||||
metadata_headers={},
|
||||
litellm_metadata=None,
|
||||
standard_callback_dynamic_params={"turn_off_message_logging": False},
|
||||
)
|
||||
assert should_redact_message_logging(details) is False
|
||||
|
||||
# ---- non-dict metadata safety ----
|
||||
|
||||
def test_both_metadata_fields_none(self):
|
||||
@@ -145,3 +169,183 @@ class TestShouldRedactMessageLogging:
|
||||
litellm_metadata=None,
|
||||
)
|
||||
assert should_redact_message_logging(details) is True
|
||||
|
||||
|
||||
class TestPerformRedaction:
|
||||
def test_redacts_standard_logging_and_responses_api_dicts(self):
|
||||
details = {
|
||||
"messages": [{"role": "user", "content": "sensitive input"}],
|
||||
"prompt": "sensitive prompt",
|
||||
"input": "sensitive input",
|
||||
"standard_logging_object": {
|
||||
"messages": [{"role": "user", "content": "sensitive input"}],
|
||||
"response": {
|
||||
"output": [
|
||||
{"text": "top-level text"},
|
||||
{"content": [{"text": "nested text"}]},
|
||||
{"type": "reasoning", "summary": [{"text": "reasoning"}]},
|
||||
],
|
||||
"usage": {"total_tokens": 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
result = {
|
||||
"output": [
|
||||
{"text": "top-level result"},
|
||||
{"content": [{"text": "nested result"}]},
|
||||
{"type": "reasoning", "summary": [{"text": "reasoning result"}]},
|
||||
],
|
||||
"usage": {"total_tokens": 1},
|
||||
}
|
||||
|
||||
redacted = perform_redaction(details, result)
|
||||
|
||||
assert details["messages"] == [
|
||||
{"role": "user", "content": "redacted-by-litellm"}
|
||||
]
|
||||
assert details["prompt"] == ""
|
||||
assert details["input"] == ""
|
||||
|
||||
logged_response = details["standard_logging_object"]["response"]
|
||||
assert logged_response["usage"] == {"total_tokens": 1}
|
||||
assert logged_response["output"][0]["text"] == "redacted-by-litellm"
|
||||
assert logged_response["output"][1]["content"][0]["text"] == (
|
||||
"redacted-by-litellm"
|
||||
)
|
||||
assert logged_response["output"][2]["summary"][0]["text"] == (
|
||||
"redacted-by-litellm"
|
||||
)
|
||||
|
||||
assert redacted["usage"] == {"total_tokens": 1}
|
||||
assert redacted["output"][0]["text"] == "redacted-by-litellm"
|
||||
assert redacted["output"][1]["content"][0]["text"] == "redacted-by-litellm"
|
||||
assert redacted["output"][2]["summary"][0]["text"] == "redacted-by-litellm"
|
||||
assert result["output"][0]["text"] == "top-level result"
|
||||
|
||||
def test_redacts_model_response_dict_choices(self):
|
||||
result = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "message content",
|
||||
"reasoning_content": "message reasoning",
|
||||
"thinking_blocks": ["thinking"],
|
||||
"audio": {"data": "audio"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"content": "delta content",
|
||||
"reasoning_content": "delta reasoning",
|
||||
"thinking_blocks": ["delta thinking"],
|
||||
"audio": {"data": "audio"},
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
redacted = perform_redaction({}, result)
|
||||
|
||||
message = redacted["choices"][0]["message"]
|
||||
assert message["content"] == "redacted-by-litellm"
|
||||
assert message["reasoning_content"] == "redacted-by-litellm"
|
||||
assert message["thinking_blocks"] is None
|
||||
assert message["audio"] is None
|
||||
|
||||
delta = redacted["choices"][1]["delta"]
|
||||
assert delta["content"] == "redacted-by-litellm"
|
||||
assert delta["reasoning_content"] == "redacted-by-litellm"
|
||||
assert delta["thinking_blocks"] is None
|
||||
assert delta["audio"] is None
|
||||
|
||||
def test_redacts_standard_logging_model_response_dict_choices(self):
|
||||
details = {
|
||||
"standard_logging_object": {
|
||||
"response": {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "message content",
|
||||
"reasoning_content": "message reasoning",
|
||||
"thinking_blocks": ["thinking"],
|
||||
"audio": {"data": "audio"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"delta": {
|
||||
"content": "delta content",
|
||||
"reasoning_content": "delta reasoning",
|
||||
"thinking_blocks": ["delta thinking"],
|
||||
"audio": {"data": "audio"},
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perform_redaction(details, None)
|
||||
|
||||
choices = details["standard_logging_object"]["response"]["choices"]
|
||||
message = choices[0]["message"]
|
||||
assert message["content"] == "redacted-by-litellm"
|
||||
assert message["reasoning_content"] == "redacted-by-litellm"
|
||||
assert message["thinking_blocks"] is None
|
||||
assert message["audio"] is None
|
||||
|
||||
delta = choices[1]["delta"]
|
||||
assert delta["content"] == "redacted-by-litellm"
|
||||
assert delta["reasoning_content"] == "redacted-by-litellm"
|
||||
assert delta["thinking_blocks"] is None
|
||||
assert delta["audio"] is None
|
||||
|
||||
def test_redacts_object_choices_inside_model_response_dict(self):
|
||||
result = {
|
||||
"choices": [
|
||||
litellm.Choices(
|
||||
message=litellm.Message(
|
||||
content="message content",
|
||||
role="assistant",
|
||||
reasoning_content="message reasoning",
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
redacted = perform_redaction({}, result)
|
||||
|
||||
choice = redacted["choices"][0]
|
||||
assert choice.message.content == "redacted-by-litellm"
|
||||
assert choice.message.reasoning_content == "redacted-by-litellm"
|
||||
|
||||
def test_redacts_response_output_objects_with_top_level_text(self):
|
||||
output_items = [
|
||||
SimpleNamespace(text="top-level output"),
|
||||
"non-dict output item",
|
||||
]
|
||||
|
||||
_redact_responses_api_output(output_items)
|
||||
|
||||
assert output_items[0].text == "redacted-by-litellm"
|
||||
assert output_items[1] == "non-dict output item"
|
||||
|
||||
def test_skips_non_dict_response_output_items(self):
|
||||
result = {
|
||||
"output": [
|
||||
"non-dict output item",
|
||||
{"content": [{"text": "nested result"}]},
|
||||
]
|
||||
}
|
||||
|
||||
redacted = perform_redaction({}, result)
|
||||
|
||||
assert redacted["output"][0] == "non-dict output item"
|
||||
assert redacted["output"][1]["content"][0]["text"] == "redacted-by-litellm"
|
||||
|
||||
def test_redacts_responses_api_response_object(self):
|
||||
response = mock_responses_api_response("sensitive output")
|
||||
|
||||
redacted = perform_redaction({}, response)
|
||||
|
||||
assert redacted.output[0].content[0].text == "redacted-by-litellm"
|
||||
assert response.output[0].content[0].text == "sensitive output"
|
||||
|
||||
@@ -180,6 +180,140 @@ def test_get_aws_region_name_boto3_fallback():
|
||||
mock_boto3_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_region",
|
||||
[
|
||||
"us-east-1@example.com/",
|
||||
"us-east-1@example.com",
|
||||
"us-east-1/path",
|
||||
"us-east-1.example.com",
|
||||
"us-east-1:8080",
|
||||
"us-east-1#fragment",
|
||||
"us-east-1?query=1",
|
||||
"us-east-1\\path",
|
||||
"US-EAST-1", # uppercase not allowed
|
||||
"us east 1", # spaces not allowed
|
||||
"", # empty string not allowed
|
||||
"us-east-1\n", # trailing newline must not slip past $
|
||||
],
|
||||
)
|
||||
def test_get_aws_region_name_rejects_malformed_region(bad_region):
|
||||
"""
|
||||
Region names are interpolated into endpoint URL templates, so any value
|
||||
containing characters that would alter URL parsing must be rejected.
|
||||
"""
|
||||
base_aws_llm = BaseAWSLLM()
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid AWS region format"):
|
||||
base_aws_llm._get_aws_region_name(
|
||||
optional_params={"aws_region_name": bad_region}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valid_region",
|
||||
[
|
||||
"us-east-1",
|
||||
"eu-west-2",
|
||||
"ap-southeast-1",
|
||||
"us-gov-west-1",
|
||||
"cn-north-1",
|
||||
"me-south-1",
|
||||
],
|
||||
)
|
||||
def test_get_aws_region_name_accepts_valid_regions(valid_region):
|
||||
"""Real AWS region formats must continue to work after the format guard."""
|
||||
base_aws_llm = BaseAWSLLM()
|
||||
result = base_aws_llm._get_aws_region_name(
|
||||
optional_params={"aws_region_name": valid_region}
|
||||
)
|
||||
assert result == valid_region
|
||||
|
||||
|
||||
def test_get_aws_region_name_rejects_malformed_region_from_env():
|
||||
"""
|
||||
A malformed AWS_REGION / AWS_REGION_NAME env value must also be rejected
|
||||
before it can flow into a URL template.
|
||||
"""
|
||||
base_aws_llm = BaseAWSLLM()
|
||||
|
||||
with patch("litellm.llms.bedrock.base_aws_llm.get_secret") as mock_get_secret:
|
||||
|
||||
def side_effect(key, default=None):
|
||||
if key == "AWS_REGION_NAME":
|
||||
return "us-east-1@example.com/"
|
||||
return default
|
||||
|
||||
mock_get_secret.side_effect = side_effect
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid AWS region format"):
|
||||
base_aws_llm._get_aws_region_name(optional_params={})
|
||||
|
||||
|
||||
def test_get_aws_region_name_for_non_llm_api_calls_rejects_malformed_param():
|
||||
"""
|
||||
The non-LLM helper (used by Guardrails, Vector Stores, etc.) must validate
|
||||
a region passed in directly so it can't flow into a URL template.
|
||||
"""
|
||||
base_aws_llm = BaseAWSLLM()
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid AWS region format"):
|
||||
base_aws_llm.get_aws_region_name_for_non_llm_api_calls(
|
||||
aws_region_name="us-east-1@example.com/"
|
||||
)
|
||||
|
||||
|
||||
def test_get_aws_region_name_for_non_llm_api_calls_rejects_malformed_env():
|
||||
"""
|
||||
A malformed AWS_REGION / AWS_REGION_NAME env value must be rejected on the
|
||||
non-LLM path too — Guardrails and Vector Stores read the same env vars.
|
||||
"""
|
||||
base_aws_llm = BaseAWSLLM()
|
||||
|
||||
with patch("litellm.llms.bedrock.base_aws_llm.get_secret") as mock_get_secret:
|
||||
|
||||
def side_effect(key, default=None):
|
||||
if key == "AWS_REGION_NAME":
|
||||
return "us-east-1@example.com/"
|
||||
return default
|
||||
|
||||
mock_get_secret.side_effect = side_effect
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid AWS region format"):
|
||||
base_aws_llm.get_aws_region_name_for_non_llm_api_calls()
|
||||
|
||||
|
||||
def test_get_aws_region_name_for_non_llm_api_calls_accepts_valid_region():
|
||||
"""The non-LLM helper still returns valid regions unchanged."""
|
||||
base_aws_llm = BaseAWSLLM()
|
||||
assert (
|
||||
base_aws_llm.get_aws_region_name_for_non_llm_api_calls(
|
||||
aws_region_name="us-east-1"
|
||||
)
|
||||
== "us-east-1"
|
||||
)
|
||||
|
||||
|
||||
def test_get_aws_region_from_model_arn_rejects_malformed_region():
|
||||
"""
|
||||
If the region segment of a model ARN does not match the expected format,
|
||||
the helper must return None so the caller falls back to env / default.
|
||||
"""
|
||||
base_aws_llm = BaseAWSLLM()
|
||||
|
||||
bad_arn = (
|
||||
"arn:aws:bedrock:us-east-1@example.com:123456789012"
|
||||
":foundation-model/anthropic.claude-3-sonnet"
|
||||
)
|
||||
assert base_aws_llm._get_aws_region_from_model_arn(bad_arn) is None
|
||||
|
||||
good_arn = (
|
||||
"arn:aws:bedrock:us-east-1:123456789012"
|
||||
":foundation-model/anthropic.claude-3-sonnet"
|
||||
)
|
||||
assert base_aws_llm._get_aws_region_from_model_arn(good_arn) == "us-east-1"
|
||||
|
||||
|
||||
def test_sign_request_with_env_var_bearer_token():
|
||||
# Create instance of actual class
|
||||
llm = BaseAWSLLM()
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Tests for the encrypted-at-rest persistence of MCP user credentials.
|
||||
|
||||
The ``LiteLLM_MCPUserCredentials.credential_b64`` column previously stored
|
||||
both BYOK API keys and OAuth2 access tokens as plain ``urlsafe_b64encode``
|
||||
of the raw value, leaving credentials readable from any DB read. The fix
|
||||
runs every write through ``encrypt_value_helper`` (nacl SecretBox) and
|
||||
keeps a plain-base64 fallback on read so existing rows continue to work.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.db import (
|
||||
_decode_user_credential,
|
||||
get_user_credential,
|
||||
get_user_oauth_credential,
|
||||
list_user_oauth_credentials,
|
||||
rotate_mcp_user_credentials_master_key,
|
||||
store_user_credential,
|
||||
store_user_oauth_credential,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
|
||||
|
||||
SALT_KEY = "test-salt-key-for-byok-credential-tests-1234"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_salt_key(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", SALT_KEY)
|
||||
|
||||
|
||||
def _make_prisma_with_existing(row):
|
||||
"""Build a MagicMock prisma_client whose user-credentials table returns ``row``
|
||||
for find_unique and behaves async-correctly for upsert/find_many."""
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=row)
|
||||
prisma.db.litellm_mcpusercredentials.upsert = AsyncMock()
|
||||
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[])
|
||||
return prisma
|
||||
|
||||
|
||||
def _legacy_row(payload: str):
|
||||
"""A row exactly as the pre-fix code would have written it: plain
|
||||
``urlsafe_b64encode`` of the raw payload, no encryption."""
|
||||
row = MagicMock()
|
||||
row.credential_b64 = base64.urlsafe_b64encode(payload.encode()).decode()
|
||||
row.user_id = "alice"
|
||||
row.server_id = "srv-1"
|
||||
return row
|
||||
|
||||
|
||||
def _stored_value(prisma) -> str:
|
||||
"""Pull the credential_b64 value passed to the most recent upsert call."""
|
||||
call = prisma.db.litellm_mcpusercredentials.upsert.call_args
|
||||
data = call.kwargs["data"]
|
||||
create_value = data["create"]["credential_b64"]
|
||||
update_value = data["update"]["credential_b64"]
|
||||
assert create_value == update_value, "create/update must agree"
|
||||
return create_value
|
||||
|
||||
|
||||
# ── BYOK round-trip ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_user_credential_does_not_persist_plaintext():
|
||||
# Stored bytes must not just be base64 of the secret — that's the regression.
|
||||
secret = "sk-proj-very-secret-byok-key"
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
|
||||
await store_user_credential(prisma, "alice", "srv-1", secret)
|
||||
|
||||
stored = _stored_value(prisma)
|
||||
plain_b64 = base64.urlsafe_b64encode(secret.encode()).decode()
|
||||
assert stored != plain_b64
|
||||
# And the secret must not appear anywhere in a plain-b64 decode of the column.
|
||||
try:
|
||||
decoded_bytes = base64.urlsafe_b64decode(stored)
|
||||
except Exception:
|
||||
decoded_bytes = b""
|
||||
assert secret.encode() not in decoded_bytes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byok_round_trip_returns_plaintext():
|
||||
secret = "sk-proj-very-secret-byok-key"
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
await store_user_credential(prisma, "alice", "srv-1", secret)
|
||||
|
||||
stored = _stored_value(prisma)
|
||||
row = MagicMock()
|
||||
row.credential_b64 = stored
|
||||
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=row)
|
||||
|
||||
result = await get_user_credential(prisma, "alice", "srv-1")
|
||||
assert result == secret
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byok_get_returns_plaintext_for_legacy_row():
|
||||
# Backward-compat: rows persisted by the pre-fix code (plain base64) must
|
||||
# still decrypt-or-decode cleanly.
|
||||
legacy_secret = "legacy-byok-key"
|
||||
prisma = _make_prisma_with_existing(row=_legacy_row(legacy_secret))
|
||||
|
||||
result = await get_user_credential(prisma, "alice", "srv-1")
|
||||
assert result == legacy_secret
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byok_get_returns_none_for_missing_row():
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
result = await get_user_credential(prisma, "alice", "srv-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── OAuth2 round-trip ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_user_oauth_credential_does_not_persist_plaintext():
|
||||
access_token = "ya29.a0AfH6SMBverysecretaccesstoken"
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
|
||||
await store_user_oauth_credential(
|
||||
prisma, "alice", "srv-1", access_token, refresh_token="rfr-xyz"
|
||||
)
|
||||
|
||||
stored = _stored_value(prisma)
|
||||
try:
|
||||
decoded_bytes = base64.urlsafe_b64decode(stored)
|
||||
except Exception:
|
||||
decoded_bytes = b""
|
||||
assert access_token.encode() not in decoded_bytes
|
||||
assert b"rfr-xyz" not in decoded_bytes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_round_trip_returns_payload():
|
||||
access_token = "ya29.a0AfH6SMBverysecretaccesstoken"
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
await store_user_oauth_credential(
|
||||
prisma,
|
||||
"alice",
|
||||
"srv-1",
|
||||
access_token,
|
||||
refresh_token="rfr-xyz",
|
||||
scopes=["a", "b"],
|
||||
)
|
||||
|
||||
stored = _stored_value(prisma)
|
||||
row = MagicMock()
|
||||
row.credential_b64 = stored
|
||||
row.server_id = "srv-1"
|
||||
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=row)
|
||||
|
||||
result = await get_user_oauth_credential(prisma, "alice", "srv-1")
|
||||
assert result is not None
|
||||
assert result["type"] == "oauth2"
|
||||
assert result["access_token"] == access_token
|
||||
assert result["refresh_token"] == "rfr-xyz"
|
||||
assert result["scopes"] == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_get_returns_payload_for_legacy_row():
|
||||
payload = {
|
||||
"type": "oauth2",
|
||||
"access_token": "legacy-token",
|
||||
"connected_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
legacy_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
||||
row = MagicMock()
|
||||
row.credential_b64 = legacy_b64
|
||||
row.server_id = "srv-1"
|
||||
prisma = _make_prisma_with_existing(row=row)
|
||||
|
||||
result = await get_user_oauth_credential(prisma, "alice", "srv-1")
|
||||
assert result is not None
|
||||
assert result["access_token"] == "legacy-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_get_returns_none_for_byok_row():
|
||||
# A row that holds a BYOK string must not leak as an OAuth payload.
|
||||
prisma = _make_prisma_with_existing(row=_legacy_row("plain-byok-not-json"))
|
||||
result = await get_user_oauth_credential(prisma, "alice", "srv-1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── BYOK guard inside store_user_oauth_credential ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byok_guard_rejects_overwriting_legacy_byok():
|
||||
prisma = _make_prisma_with_existing(row=_legacy_row("plain-byok-key"))
|
||||
with pytest.raises(ValueError, match="could not be verified as an OAuth2"):
|
||||
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byok_guard_rejects_overwriting_encrypted_byok():
|
||||
# Simulate a row written by the new (encrypted) code path: write a BYOK,
|
||||
# then attempt to overwrite with an OAuth token.
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
await store_user_credential(prisma, "alice", "srv-1", "sk-secret-byok")
|
||||
|
||||
encrypted_row = MagicMock()
|
||||
encrypted_row.credential_b64 = _stored_value(prisma)
|
||||
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(
|
||||
return_value=encrypted_row
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="could not be verified as an OAuth2"):
|
||||
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_byok_guard_allows_overwriting_existing_oauth():
|
||||
# Refresh path: row already holds an OAuth payload, write must succeed.
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok-1")
|
||||
|
||||
oauth_row = MagicMock()
|
||||
oauth_row.credential_b64 = _stored_value(prisma)
|
||||
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=oauth_row)
|
||||
|
||||
await store_user_oauth_credential(prisma, "alice", "srv-1", "tok-2")
|
||||
# Final upsert wrote a new payload (different from the first)
|
||||
assert _stored_value(prisma) != oauth_row.credential_b64
|
||||
|
||||
|
||||
# ── list_user_oauth_credentials ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_oauth_credentials_filters_byok_and_returns_payloads():
|
||||
# Three rows: one encrypted OAuth, one legacy-plaintext OAuth, one BYOK.
|
||||
# Only the two OAuth rows should come back.
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
|
||||
await store_user_oauth_credential(prisma, "alice", "srv-encrypted", "tok-enc")
|
||||
encrypted_b64 = _stored_value(prisma)
|
||||
encrypted_row = MagicMock()
|
||||
encrypted_row.credential_b64 = encrypted_b64
|
||||
encrypted_row.server_id = "srv-encrypted"
|
||||
|
||||
legacy_payload = {
|
||||
"type": "oauth2",
|
||||
"access_token": "tok-legacy",
|
||||
"connected_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
legacy_row = MagicMock()
|
||||
legacy_row.credential_b64 = base64.urlsafe_b64encode(
|
||||
json.dumps(legacy_payload).encode()
|
||||
).decode()
|
||||
legacy_row.server_id = "srv-legacy"
|
||||
|
||||
byok_row = MagicMock()
|
||||
byok_row.credential_b64 = base64.urlsafe_b64encode(b"plain-byok-key").decode()
|
||||
byok_row.server_id = "srv-byok"
|
||||
|
||||
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
|
||||
return_value=[encrypted_row, legacy_row, byok_row]
|
||||
)
|
||||
|
||||
results = await list_user_oauth_credentials(prisma, "alice")
|
||||
|
||||
server_ids = {r["server_id"] for r in results}
|
||||
assert server_ids == {"srv-encrypted", "srv-legacy"}
|
||||
tokens = {r["access_token"] for r in results}
|
||||
assert tokens == {"tok-enc", "tok-legacy"}
|
||||
|
||||
|
||||
# ── _decode_user_credential helper ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_decode_user_credential_handles_garbage():
|
||||
# Malformed input must return None, not raise.
|
||||
assert _decode_user_credential("not-base64-and-not-encrypted!!!") is None
|
||||
|
||||
|
||||
def test_decode_user_credential_handles_none():
|
||||
# Defensive: a null DB value must return None, not propagate TypeError.
|
||||
assert _decode_user_credential(None) is None
|
||||
|
||||
|
||||
def test_decode_user_credential_legacy_path():
|
||||
plain = "legacy-secret"
|
||||
stored = base64.urlsafe_b64encode(plain.encode()).decode()
|
||||
assert _decode_user_credential(stored) == plain
|
||||
|
||||
|
||||
# ── master-key rotation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_re_encrypts_byok_with_new_key(monkeypatch):
|
||||
# Encrypt a row under the current salt, then rotate to a new key, then
|
||||
# confirm the stored ciphertext decrypts under the NEW key — and not under
|
||||
# the old one.
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
secret = "sk-original-byok-key"
|
||||
await store_user_credential(prisma, "alice", "srv-1", secret)
|
||||
encrypted_old = _stored_value(prisma)
|
||||
|
||||
row = MagicMock()
|
||||
row.user_id = "alice"
|
||||
row.server_id = "srv-1"
|
||||
row.credential_b64 = encrypted_old
|
||||
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[row])
|
||||
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
|
||||
|
||||
new_master_key = "rotated-salt-key-9999-9999-9999-9999"
|
||||
await rotate_mcp_user_credentials_master_key(
|
||||
prisma_client=prisma, new_master_key=new_master_key
|
||||
)
|
||||
|
||||
update_call = prisma.db.litellm_mcpusercredentials.update.call_args
|
||||
new_stored = update_call.kwargs["data"]["credential_b64"]
|
||||
assert new_stored != encrypted_old, "rotation must produce different ciphertext"
|
||||
|
||||
# Decrypt the rotated value under the NEW salt key — round-trips to plaintext.
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", new_master_key)
|
||||
assert (
|
||||
decrypt_value_helper(
|
||||
value=new_stored,
|
||||
key="mcp_user_credential",
|
||||
exception_type="debug",
|
||||
return_original_value=False,
|
||||
)
|
||||
== secret
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_migrates_legacy_plaintext_rows(monkeypatch):
|
||||
# A legacy plain-base64 row must also get re-encrypted under the new key.
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
|
||||
legacy_row = MagicMock()
|
||||
legacy_row.user_id = "alice"
|
||||
legacy_row.server_id = "srv-legacy"
|
||||
legacy_row.credential_b64 = base64.urlsafe_b64encode(b"legacy-plain").decode()
|
||||
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
|
||||
return_value=[legacy_row]
|
||||
)
|
||||
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
|
||||
|
||||
new_key = "another-rotation-key-aaaa-bbbb-cccc-dddd"
|
||||
await rotate_mcp_user_credentials_master_key(
|
||||
prisma_client=prisma, new_master_key=new_key
|
||||
)
|
||||
|
||||
new_stored = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["data"][
|
||||
"credential_b64"
|
||||
]
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", new_key)
|
||||
assert (
|
||||
decrypt_value_helper(
|
||||
value=new_stored,
|
||||
key="mcp_user_credential",
|
||||
exception_type="debug",
|
||||
return_original_value=False,
|
||||
)
|
||||
== "legacy-plain"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_skips_undecodable_rows():
|
||||
# One bad row must not abort the rotation for the rest.
|
||||
prisma = _make_prisma_with_existing(row=None)
|
||||
|
||||
bad_row = MagicMock()
|
||||
bad_row.user_id = "alice"
|
||||
bad_row.server_id = "srv-corrupt"
|
||||
bad_row.credential_b64 = "!!! not base64 and not encrypted !!!"
|
||||
|
||||
good_row = MagicMock()
|
||||
good_row.user_id = "bob"
|
||||
good_row.server_id = "srv-ok"
|
||||
good_row.credential_b64 = base64.urlsafe_b64encode(b"good-byok").decode()
|
||||
|
||||
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
|
||||
return_value=[bad_row, good_row]
|
||||
)
|
||||
prisma.db.litellm_mcpusercredentials.update = AsyncMock()
|
||||
|
||||
await rotate_mcp_user_credentials_master_key(
|
||||
prisma_client=prisma, new_master_key="new-key-xxxx"
|
||||
)
|
||||
|
||||
# Only one update call — the good row.
|
||||
assert prisma.db.litellm_mcpusercredentials.update.call_count == 1
|
||||
where = prisma.db.litellm_mcpusercredentials.update.call_args.kwargs["where"]
|
||||
assert where["user_id_server_id"]["server_id"] == "srv-ok"
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -719,7 +720,8 @@ class TestMCPServerManager:
|
||||
return_value=mock_client,
|
||||
):
|
||||
servers, scopes = await manager._fetch_oauth_metadata_from_resource(
|
||||
"https://protected.example.com/.well-known/oauth"
|
||||
"https://protected.example.com/.well-known/oauth",
|
||||
"https://protected.example.com/mcp",
|
||||
)
|
||||
|
||||
assert servers == [
|
||||
@@ -772,7 +774,8 @@ class TestMCPServerManager:
|
||||
|
||||
mock_well_known.assert_awaited_once_with("http://localhost:8001/mcp")
|
||||
mock_fetch_auth.assert_awaited_once_with(
|
||||
["https://login.microsoftonline.com/test-tenant-id/v2.0"]
|
||||
["https://login.microsoftonline.com/test-tenant-id/v2.0"],
|
||||
"http://localhost:8001/mcp",
|
||||
)
|
||||
assert result is mock_metadata
|
||||
assert result.scopes == ["api://some-scope/.default"]
|
||||
@@ -784,7 +787,7 @@ class TestMCPServerManager:
|
||||
manager = MCPServerManager()
|
||||
issuer = "https://login.microsoftonline.com/test-tenant-id/v2.0"
|
||||
|
||||
def build_response(url: str):
|
||||
def build_response(url: str, **kwargs):
|
||||
mock_response = MagicMock()
|
||||
if url == f"{issuer}/.well-known/openid-configuration":
|
||||
mock_response.json.return_value = {
|
||||
@@ -810,7 +813,12 @@ class TestMCPServerManager:
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await manager._fetch_single_authorization_server_metadata(issuer)
|
||||
# The Azure issuer is cross-origin against the server_url — use
|
||||
# the issuer itself as server_url so the test exercises the
|
||||
# well-known fetch logic without needing real DNS.
|
||||
result = await manager._fetch_single_authorization_server_metadata(
|
||||
issuer, issuer
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert (
|
||||
@@ -846,7 +854,9 @@ class TestMCPServerManager:
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await manager._fetch_single_authorization_server_metadata(issuer)
|
||||
result = await manager._fetch_single_authorization_server_metadata(
|
||||
issuer, issuer
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert (
|
||||
@@ -910,7 +920,7 @@ class TestMCPServerManager:
|
||||
):
|
||||
result = await manager._descovery_metadata(server_url)
|
||||
|
||||
mock_fetch_auth.assert_awaited_once_with(["https://example.com"])
|
||||
mock_fetch_auth.assert_awaited_once_with(["https://example.com"], server_url)
|
||||
assert result is mock_metadata
|
||||
assert result.scopes == ["read"]
|
||||
|
||||
@@ -2947,5 +2957,359 @@ class TestMCPServerManagerExpandToolPermissions:
|
||||
assert sorted(result["uuid-a"]) == ["read_file", "write_file"]
|
||||
|
||||
|
||||
class TestOAuthDiscoverySSRFGuard:
|
||||
"""SSRF guard for the OAuth metadata discovery follow-up fetches.
|
||||
|
||||
The vulnerability: a malicious MCP server returns a ``WWW-Authenticate``
|
||||
header pointing at an attacker-chosen ``resource_metadata`` URL, then a
|
||||
PRM JSON whose ``authorization_servers[0]`` points at internal/loopback
|
||||
addresses, coercing the proxy into making blind GETs to cloud-metadata
|
||||
services, internal admin panels, or loopback debug endpoints.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _patch_resolves(monkeypatch, mapping):
|
||||
"""Patch ``socket.getaddrinfo`` for a deterministic SSRF-guard test.
|
||||
|
||||
``mapping`` is ``{hostname: [ip-string, ...]}``; unknown hosts raise
|
||||
``gaierror`` (treated as "unresolvable" -> blocked by async_safe_get).
|
||||
"""
|
||||
import socket as _socket
|
||||
|
||||
def fake_getaddrinfo(host, port, *args, **kwargs):
|
||||
if host not in mapping:
|
||||
raise _socket.gaierror(f"unknown host {host}")
|
||||
family = _socket.AF_INET
|
||||
return [
|
||||
(family, _socket.SOCK_STREAM, 0, "", (ip, port)) for ip in mapping[host]
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.litellm_core_utils.url_utils.socket.getaddrinfo",
|
||||
fake_getaddrinfo,
|
||||
)
|
||||
|
||||
def test_same_authority_url_is_direct_fetch_eligible(self):
|
||||
# Same scheme + host + port skips DNS entirely — the well-known
|
||||
# endpoint construction in _attempt_well_known_discovery always
|
||||
# produces same-authority URLs against the admin's server_url.
|
||||
assert MCPServerManager._is_same_authority_metadata_url(
|
||||
"https://example.com/.well-known/oauth-protected-resource",
|
||||
"https://example.com/mcp",
|
||||
)
|
||||
|
||||
def test_same_host_different_port_uses_safe_fetch_path(self):
|
||||
assert not MCPServerManager._is_same_authority_metadata_url(
|
||||
"https://example.com:9999/.well-known/oauth-protected-resource",
|
||||
"https://example.com/mcp",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"127.0.0.1", # loopback
|
||||
"10.0.0.5", # RFC1918
|
||||
"172.16.0.1", # RFC1918
|
||||
"192.168.1.1", # RFC1918
|
||||
"169.254.169.254", # AWS / Azure / GCP IMDS
|
||||
"100.100.100.200", # Alibaba Cloud metadata
|
||||
"0.0.0.0", # unspecified
|
||||
"::1", # IPv6 loopback
|
||||
"fe80::1", # IPv6 link-local
|
||||
"fc00::1", # IPv6 ULA
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_origin_blocked_when_resolves_to_unsafe_ip(
|
||||
self, monkeypatch, ip
|
||||
):
|
||||
self._patch_resolves(monkeypatch, {"attacker.example.com": [ip]})
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await manager._fetch_single_authorization_server_metadata(
|
||||
"https://attacker.example.com",
|
||||
"https://legit-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_origin_allowed_when_resolves_to_public_ip(self, monkeypatch):
|
||||
self._patch_resolves(
|
||||
monkeypatch, {"login.microsoftonline.com": ["20.190.151.7"]}
|
||||
)
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_redirect = False
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"authorization_servers": ["https://login.microsoftonline.com/tenant/v2.0"],
|
||||
"scopes_supported": ["mcp.read"],
|
||||
}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
servers, scopes = await manager._fetch_oauth_metadata_from_resource(
|
||||
"https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration",
|
||||
"https://atlassian-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert servers == ["https://login.microsoftonline.com/tenant/v2.0"]
|
||||
assert scopes == ["mcp.read"]
|
||||
mock_client.get.assert_awaited_once()
|
||||
assert mock_client.get.await_args.kwargs["follow_redirects"] is False
|
||||
assert (
|
||||
mock_client.get.await_args.kwargs["headers"]["Host"]
|
||||
== "login.microsoftonline.com"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_origin_blocked_when_unresolvable(self, monkeypatch):
|
||||
self._patch_resolves(monkeypatch, {})
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
servers, scopes = await manager._fetch_oauth_metadata_from_resource(
|
||||
"https://nope.example.invalid/.well-known/oauth-authorization-server",
|
||||
"https://legit-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert servers == []
|
||||
assert scopes is None
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_http_scheme_is_not_safe(self):
|
||||
manager = MCPServerManager()
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
servers, scopes = await manager._fetch_oauth_metadata_from_resource(
|
||||
"file:///etc/passwd",
|
||||
"https://example.com/mcp",
|
||||
)
|
||||
result = await manager._fetch_single_authorization_server_metadata(
|
||||
"gopher://example.com/",
|
||||
"https://example.com/mcp",
|
||||
)
|
||||
|
||||
assert servers == []
|
||||
assert scopes is None
|
||||
assert result is None
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dual_resolution_blocked_if_any_ip_unsafe(self, monkeypatch):
|
||||
# If the attacker controls a DNS record returning multiple A records,
|
||||
# one of which is private, async_safe_get rejects before any network call.
|
||||
self._patch_resolves(
|
||||
monkeypatch, {"dual-stack.example.com": ["8.8.8.8", "127.0.0.1"]}
|
||||
)
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
servers, scopes = await manager._fetch_oauth_metadata_from_resource(
|
||||
"https://dual-stack.example.com/.well-known/oauth-authorization-server",
|
||||
"https://legit-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert servers == []
|
||||
assert scopes is None
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_oauth_metadata_refuses_unsafe_url(self, monkeypatch):
|
||||
# End-to-end: a malicious WWW-Authenticate redirecting to a loopback
|
||||
# resource_metadata URL must not produce a network call.
|
||||
self._patch_resolves(monkeypatch, {"attacker.example.com": ["127.0.0.1"]})
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
servers, scopes = await manager._fetch_oauth_metadata_from_resource(
|
||||
"https://attacker.example.com/meta",
|
||||
"https://legit-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert servers == []
|
||||
assert scopes is None
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_getaddrinfo_result_blocks_url(self, monkeypatch):
|
||||
# POSIX doesn't strictly forbid an empty success-list from getaddrinfo.
|
||||
# async_safe_get must fail closed rather than making a network call.
|
||||
monkeypatch.setattr(
|
||||
"litellm.litellm_core_utils.url_utils.socket.getaddrinfo",
|
||||
lambda *a, **k: [],
|
||||
)
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
servers, scopes = await manager._fetch_oauth_metadata_from_resource(
|
||||
"https://no-records.example.com/.well-known/oauth-authorization-server",
|
||||
"https://legit-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert servers == []
|
||||
assert scopes is None
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_origin_redirect_is_revalidated(self, monkeypatch):
|
||||
self._patch_resolves(
|
||||
monkeypatch,
|
||||
{
|
||||
"provider.example.com": ["8.8.8.8"],
|
||||
"127.0.0.1": ["127.0.0.1"],
|
||||
},
|
||||
)
|
||||
manager = MCPServerManager()
|
||||
|
||||
redirect_response = MagicMock()
|
||||
redirect_response.is_redirect = True
|
||||
redirect_response.headers = {"location": "http://127.0.0.1/admin"}
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock(return_value=redirect_response)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await manager._fetch_single_authorization_server_metadata(
|
||||
"https://provider.example.com",
|
||||
"https://legit-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert mock_client.get.await_count == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_authority_fetch_does_not_follow_redirects(self):
|
||||
# Same-authority URLs may be internal admin-configured MCP servers, so
|
||||
# they are fetched directly. Redirects are still disabled because a
|
||||
# Location target would not inherit the same-authority guarantee.
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"authorization_servers": ["https://auth.example.com"],
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
captured_kwargs: Dict[str, Any] = {}
|
||||
|
||||
async def fake_get(url, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return mock_response
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
await manager._fetch_oauth_metadata_from_resource(
|
||||
"https://protected.example.com/.well-known/oauth",
|
||||
"https://protected.example.com/mcp",
|
||||
)
|
||||
assert captured_kwargs.get("follow_redirects") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_authority_auth_server_fetch_does_not_follow_redirects(self):
|
||||
# Same redirect-bypass concern for the authorization-server fetch path.
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"authorization_endpoint": "https://provider.example.com/authorize",
|
||||
"token_endpoint": "https://provider.example.com/token",
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
captured_kwargs: Dict[str, Any] = {}
|
||||
|
||||
async def fake_get(url, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return mock_response
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock(side_effect=fake_get)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
await manager._fetch_single_authorization_server_metadata(
|
||||
"https://provider.example.com",
|
||||
"https://provider.example.com",
|
||||
)
|
||||
assert captured_kwargs.get("follow_redirects") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_authorization_server_refuses_unsafe_issuer(self, monkeypatch):
|
||||
# Mirrors the GHSA-mrfv repro: PRM lists a loopback issuer URL.
|
||||
self._patch_resolves(monkeypatch, {"attacker.example.com": ["127.0.0.1"]})
|
||||
manager = MCPServerManager()
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await manager._fetch_single_authorization_server_metadata(
|
||||
"http://attacker.example.com:19999",
|
||||
"https://legit-mcp.example.com/mcp",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
mock_client.get.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
|
||||
@@ -6,11 +6,12 @@ This module tests the auth commands and their associated functionality.
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import AsyncMock, patch, Mock, call
|
||||
from unittest.mock import patch, Mock, call
|
||||
from litellm.proxy.client.cli.commands.auth import (
|
||||
_normalize_teams,
|
||||
_poll_for_ready_data,
|
||||
_poll_for_authentication,
|
||||
_start_cli_sso_flow,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +58,18 @@ async def test_normalize_teams_with_details_with_aliases():
|
||||
]
|
||||
|
||||
|
||||
@patch("litellm.proxy.client.cli.commands.auth.requests.post")
|
||||
def test_start_cli_sso_flow_rejects_invalid_response(request_mock):
|
||||
"""Test CLI SSO start rejects malformed server responses"""
|
||||
response = Mock()
|
||||
response.raise_for_status = Mock()
|
||||
response.json.return_value = {"login_id": "cli-session", "user_code": "ABCD-EFGH"}
|
||||
request_mock.return_value = response
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid CLI SSO start response"):
|
||||
_start_cli_sso_flow("https://litellm.com")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"litellm.proxy.client.cli.commands.auth.requests.get",
|
||||
@@ -195,10 +208,11 @@ async def test_poll_for_ready_connection_failure(sleep_mock, click_mock, request
|
||||
@patch("litellm.proxy.client.cli.commands.auth.click.echo")
|
||||
async def test_poll_for_authentication_no_data(click_mock, poll_mock, handle_mock):
|
||||
"""Test poll_for_authentication function"""
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123")
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123", "poll-secret")
|
||||
assert actual is None
|
||||
poll_mock.assert_called_once_with(
|
||||
"https://litellm.com/sso/cli/poll/key-123",
|
||||
headers={"x-litellm-cli-poll-secret": "poll-secret"},
|
||||
pending_message="Still waiting for authentication...",
|
||||
)
|
||||
handle_mock.assert_not_called()
|
||||
@@ -214,10 +228,11 @@ async def test_poll_for_authentication_no_data(click_mock, poll_mock, handle_moc
|
||||
@patch("litellm.proxy.client.cli.commands.auth.click.echo")
|
||||
async def test_poll_for_authentication_no_teams(click_mock, poll_mock, handle_mock):
|
||||
"""Test poll_for_authentication function"""
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123")
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123", "poll-secret")
|
||||
assert actual is None
|
||||
poll_mock.assert_called_once_with(
|
||||
"https://litellm.com/sso/cli/poll/key-123",
|
||||
headers={"x-litellm-cli-poll-secret": "poll-secret"},
|
||||
pending_message="Still waiting for authentication...",
|
||||
)
|
||||
handle_mock.assert_not_called()
|
||||
@@ -243,7 +258,7 @@ async def test_poll_for_authentication_team_selection_success(
|
||||
click_mock, poll_mock, handle_mock
|
||||
):
|
||||
"""Test poll_for_authentication function"""
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123")
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123", "poll-secret")
|
||||
assert actual == {
|
||||
"api_key": "jwt-123",
|
||||
"user_id": "user-123",
|
||||
@@ -252,11 +267,13 @@ async def test_poll_for_authentication_team_selection_success(
|
||||
}
|
||||
poll_mock.assert_called_once_with(
|
||||
"https://litellm.com/sso/cli/poll/key-123",
|
||||
headers={"x-litellm-cli-poll-secret": "poll-secret"},
|
||||
pending_message="Still waiting for authentication...",
|
||||
)
|
||||
handle_mock.assert_called_once_with(
|
||||
base_url="https://litellm.com",
|
||||
key_id="key-123",
|
||||
poll_secret="poll-secret",
|
||||
teams=[
|
||||
{"team_id": "1", "team_alias": None},
|
||||
{"team_id": "2", "team_alias": None},
|
||||
@@ -283,15 +300,17 @@ async def test_poll_for_authentication_team_selection_cancelled(
|
||||
click_mock, poll_mock, handle_mock
|
||||
):
|
||||
"""Test poll_for_authentication function"""
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123")
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123", "poll-secret")
|
||||
assert actual is None
|
||||
poll_mock.assert_called_once_with(
|
||||
"https://litellm.com/sso/cli/poll/key-123",
|
||||
headers={"x-litellm-cli-poll-secret": "poll-secret"},
|
||||
pending_message="Still waiting for authentication...",
|
||||
)
|
||||
handle_mock.assert_called_once_with(
|
||||
base_url="https://litellm.com",
|
||||
key_id="key-123",
|
||||
poll_secret="poll-secret",
|
||||
teams=[{"team_id": "team-1", "team_alias": None}],
|
||||
)
|
||||
click_mock.assert_called_once()
|
||||
@@ -314,7 +333,7 @@ async def test_poll_for_authentication_auto_assigned_team(
|
||||
click_mock, poll_mock, handle_mock
|
||||
):
|
||||
"""Test poll_for_authentication function"""
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123")
|
||||
actual = _poll_for_authentication("https://litellm.com", "key-123", "poll-secret")
|
||||
assert actual == {
|
||||
"api_key": "jwt-456",
|
||||
"user_id": "user-456",
|
||||
@@ -323,6 +342,7 @@ async def test_poll_for_authentication_auto_assigned_team(
|
||||
}
|
||||
poll_mock.assert_called_once_with(
|
||||
"https://litellm.com/sso/cli/poll/key-123",
|
||||
headers={"x-litellm-cli-poll-secret": "poll-secret"},
|
||||
pending_message="Still waiting for authentication...",
|
||||
)
|
||||
handle_mock.assert_not_called()
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, mock_open, patch
|
||||
from unittest.mock import Mock, mock_open, patch
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from litellm.proxy.client.cli.commands.auth import (
|
||||
@@ -26,6 +24,22 @@ from litellm.proxy.client.cli.commands.auth import (
|
||||
)
|
||||
|
||||
|
||||
def _mock_cli_sso_start_response(
|
||||
login_id: str = "cli-session-uuid-456",
|
||||
poll_secret: str = "poll-secret",
|
||||
user_code: str = "ABCD-EFGH",
|
||||
) -> Mock:
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"login_id": login_id,
|
||||
"poll_secret": poll_secret,
|
||||
"user_code": user_code,
|
||||
}
|
||||
mock_response.raise_for_status = Mock()
|
||||
return mock_response
|
||||
|
||||
|
||||
class TestTokenUtilities:
|
||||
"""Test token file utility functions"""
|
||||
|
||||
@@ -243,12 +257,15 @@ class TestLoginCommand:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open") as mock_browser,
|
||||
patch(
|
||||
"requests.post",
|
||||
return_value=_mock_cli_sso_start_response(login_id="cli-test-uuid-123"),
|
||||
) as mock_post,
|
||||
patch("requests.get", return_value=mock_response) as mock_get,
|
||||
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
||||
patch(
|
||||
"litellm.proxy.client.cli.interface.show_commands"
|
||||
) as mock_show_commands,
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
@@ -261,7 +278,13 @@ class TestLoginCommand:
|
||||
mock_browser.assert_called_once()
|
||||
call_args = mock_browser.call_args[0][0]
|
||||
assert "https://test.example.com/sso/key/generate" in call_args
|
||||
assert "sk-test-uuid-123" in call_args
|
||||
assert "cli-test-uuid-123" in call_args
|
||||
assert "Verification code: ABCD-EFGH" in result.output
|
||||
mock_post.assert_called_once()
|
||||
mock_get.assert_called()
|
||||
assert mock_get.call_args.kwargs["headers"] == {
|
||||
"x-litellm-cli-poll-secret": "poll-secret"
|
||||
}
|
||||
|
||||
# Verify JWT was saved
|
||||
mock_save.assert_called_once()
|
||||
@@ -284,9 +307,9 @@ class TestLoginCommand:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open"),
|
||||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch("requests.get", return_value=mock_response),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
|
||||
# Mock time.sleep to avoid actual delays in tests
|
||||
@@ -306,9 +329,9 @@ class TestLoginCommand:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open"),
|
||||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch("requests.get", return_value=mock_response),
|
||||
patch("time.sleep"),
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
@@ -325,12 +348,12 @@ class TestLoginCommand:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open"),
|
||||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch(
|
||||
"requests.get",
|
||||
side_effect=requests.RequestException("Connection failed"),
|
||||
),
|
||||
patch("time.sleep"),
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
@@ -345,8 +368,8 @@ class TestLoginCommand:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open"),
|
||||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch("requests.get", side_effect=KeyboardInterrupt),
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
@@ -369,9 +392,9 @@ class TestLoginCommand:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open"),
|
||||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch("requests.get", return_value=mock_response),
|
||||
patch("time.sleep"),
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
@@ -386,8 +409,8 @@ class TestLoginCommand:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open"),
|
||||
patch("requests.post", return_value=_mock_cli_sso_start_response()),
|
||||
patch("requests.get", side_effect=ValueError("Invalid value")),
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
@@ -556,6 +579,12 @@ class TestCLIKeyRegenerationFlow:
|
||||
# Simulate user selecting team #2 (team-beta)
|
||||
with (
|
||||
patch("webbrowser.open") as mock_browser,
|
||||
patch(
|
||||
"requests.post",
|
||||
return_value=_mock_cli_sso_start_response(
|
||||
login_id="cli-session-uuid-456"
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"requests.get", side_effect=[mock_first_response, mock_second_response]
|
||||
) as mock_get,
|
||||
@@ -563,7 +592,6 @@ class TestCLIKeyRegenerationFlow:
|
||||
patch(
|
||||
"litellm.proxy.client.cli.interface.show_commands"
|
||||
) as mock_show_commands,
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="session-uuid-456"),
|
||||
patch("click.prompt", return_value="2"),
|
||||
): # User selects index 2
|
||||
|
||||
@@ -585,8 +613,11 @@ class TestCLIKeyRegenerationFlow:
|
||||
|
||||
# First poll should be without team_id
|
||||
first_poll_url = mock_get.call_args_list[0][0][0]
|
||||
assert "sk-session-uuid-456" in first_poll_url
|
||||
assert "cli-session-uuid-456" in first_poll_url
|
||||
assert "team_id=" not in first_poll_url
|
||||
assert mock_get.call_args_list[0].kwargs["headers"] == {
|
||||
"x-litellm-cli-poll-secret": "poll-secret"
|
||||
}
|
||||
|
||||
# Second poll should include team_id=team-beta
|
||||
second_poll_url = mock_get.call_args_list[1][0][0]
|
||||
@@ -621,10 +652,15 @@ class TestCLIKeyRegenerationFlow:
|
||||
|
||||
with (
|
||||
patch("webbrowser.open") as mock_browser,
|
||||
patch(
|
||||
"requests.post",
|
||||
return_value=_mock_cli_sso_start_response(
|
||||
login_id="cli-session-uuid-solo"
|
||||
),
|
||||
),
|
||||
patch("requests.get", return_value=mock_response),
|
||||
patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save,
|
||||
patch("litellm.proxy.client.cli.interface.show_commands"),
|
||||
patch("litellm._uuid.uuid.uuid4", return_value="session-uuid-solo"),
|
||||
):
|
||||
|
||||
result = self.runner.invoke(login, obj=mock_context.obj)
|
||||
@@ -637,7 +673,7 @@ class TestCLIKeyRegenerationFlow:
|
||||
call_args = mock_browser.call_args[0][0]
|
||||
assert "https://test.example.com/sso/key/generate" in call_args
|
||||
assert "source=litellm-cli" in call_args
|
||||
assert "key=sk-session-uuid-solo" in call_args
|
||||
assert "key=cli-session-uuid-solo" in call_args
|
||||
|
||||
# Verify JWT was saved
|
||||
mock_save.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Unit tests for unauthenticated logo / favicon endpoint helpers.
|
||||
|
||||
Local image paths are an existing deployment workflow, so the helper keeps
|
||||
arbitrary local image paths working while refusing non-image files like
|
||||
``/etc/passwd`` or ``/proc/self/environ``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
from litellm.proxy.common_utils.static_asset_utils import (
|
||||
detect_local_image_media_type,
|
||||
resolve_validated_local_image_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "media_type"),
|
||||
[
|
||||
(b"\x89PNG\r\n\x1a\nfake png body", "image/png"),
|
||||
(b"GIF89a fake gif body", "image/gif"),
|
||||
(b"\xff\xd8\xff fake jpeg body", "image/jpeg"),
|
||||
(b"RIFF\x00\x00\x00\x00WEBP fake webp body", "image/webp"),
|
||||
(b"\x00\x00\x01\x00 fake ico body", "image/x-icon"),
|
||||
],
|
||||
)
|
||||
def test_detect_local_image_media_type_accepts_supported_images(body, media_type):
|
||||
assert detect_local_image_media_type(body) == media_type
|
||||
|
||||
|
||||
def test_detect_local_image_media_type_rejects_non_images():
|
||||
assert detect_local_image_media_type(b"root:x:0:0:root:/root:/bin/bash") is None
|
||||
|
||||
|
||||
class TestResolveValidatedLocalImagePath:
|
||||
def test_returns_resolved_path_for_arbitrary_local_image(self, tmp_path):
|
||||
logo = tmp_path / "logo.png"
|
||||
logo.write_bytes(b"\x89PNG\r\n\x1a\nfake png body")
|
||||
|
||||
result = resolve_validated_local_image_path(str(logo))
|
||||
|
||||
assert result == (str(logo.resolve()), "image/png")
|
||||
|
||||
def test_rejects_etc_passwd(self):
|
||||
result = resolve_validated_local_image_path("/etc/passwd")
|
||||
assert result is None
|
||||
|
||||
def test_rejects_proc_self_environ(self):
|
||||
result = resolve_validated_local_image_path("/proc/self/environ")
|
||||
assert result is None
|
||||
|
||||
def test_rejects_symlink_pointing_to_non_image(self, tmp_path):
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("password=hunter2")
|
||||
symlink = tmp_path / "logo.png"
|
||||
os.symlink(str(secret), str(symlink))
|
||||
|
||||
result = resolve_validated_local_image_path(str(symlink))
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_accepts_symlink_pointing_to_image(self, tmp_path):
|
||||
logo = tmp_path / "real_logo.png"
|
||||
logo.write_bytes(b"\x89PNG\r\n\x1a\nfake png body")
|
||||
symlink = tmp_path / "logo.png"
|
||||
os.symlink(str(logo), str(symlink))
|
||||
|
||||
result = resolve_validated_local_image_path(str(symlink))
|
||||
|
||||
assert result == (str(logo.resolve()), "image/png")
|
||||
|
||||
def test_rejects_path_traversal_to_non_image(self, tmp_path):
|
||||
assets_dir = tmp_path / "assets"
|
||||
assets_dir.mkdir()
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("nope")
|
||||
traversal = str(assets_dir / ".." / "secret.txt")
|
||||
|
||||
result = resolve_validated_local_image_path(traversal)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_rejects_directory(self, tmp_path):
|
||||
result = resolve_validated_local_image_path(str(tmp_path))
|
||||
assert result is None
|
||||
|
||||
def test_rejects_nonexistent_file(self, tmp_path):
|
||||
result = resolve_validated_local_image_path(str(tmp_path / "missing.jpg"))
|
||||
assert result is None
|
||||
|
||||
def test_rejects_empty_path(self):
|
||||
assert resolve_validated_local_image_path("") is None
|
||||
@@ -1853,6 +1853,60 @@ async def test_make_bedrock_api_request_logging_event_type_for_spend_logs():
|
||||
assert mock_log.call_args.kwargs["event_type"] == GuardrailEventHooks.pre_call
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_bedrock_api_request_filters_dynamic_evaluation_overrides():
|
||||
guardrail = BedrockGuardrail(
|
||||
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
|
||||
)
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.access_key = "test-access-key"
|
||||
mock_credentials.secret_key = "test-secret-key"
|
||||
mock_credentials.token = None
|
||||
|
||||
mock_bedrock_response = MagicMock()
|
||||
mock_bedrock_response.status_code = 200
|
||||
mock_bedrock_response.json.return_value = {"action": "NONE", "assessments": []}
|
||||
|
||||
prepared_request = MagicMock()
|
||||
prepared_request.url = "https://bedrock.test/apply"
|
||||
prepared_request.body = b"{}"
|
||||
prepared_request.headers = {}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
guardrail.async_handler, "post", new_callable=AsyncMock
|
||||
) as mock_post,
|
||||
patch.object(
|
||||
guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")
|
||||
),
|
||||
patch.object(
|
||||
guardrail, "_prepare_request", return_value=prepared_request
|
||||
) as mock_prepare_request,
|
||||
patch.object(
|
||||
guardrail,
|
||||
"get_guardrail_dynamic_request_body_params",
|
||||
return_value={
|
||||
"content": [{"text": {"text": "benign replacement"}}],
|
||||
"source": "OUTPUT",
|
||||
"outputScope": "FULL",
|
||||
},
|
||||
),
|
||||
):
|
||||
mock_post.return_value = mock_bedrock_response
|
||||
|
||||
await guardrail.make_bedrock_api_request(
|
||||
source="INPUT",
|
||||
messages=[{"role": "user", "content": "actual prompt"}],
|
||||
request_data={"model": "gpt-4o"},
|
||||
)
|
||||
|
||||
prepared_data = mock_prepare_request.call_args.kwargs["data"]
|
||||
assert prepared_data["source"] == "INPUT"
|
||||
assert "actual prompt" in json.dumps(prepared_data["content"])
|
||||
assert "benign replacement" not in json.dumps(prepared_data["content"])
|
||||
assert prepared_data["outputScope"] == "FULL"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_during_call_hook_invokes_bedrock_async_moderation_hook():
|
||||
"""
|
||||
|
||||
@@ -505,6 +505,38 @@ def test_get_logging_caching_headers_pillar_metadata():
|
||||
)
|
||||
|
||||
|
||||
def test_get_logging_caching_headers_ignores_untrusted_pillar_headers():
|
||||
request_data = {
|
||||
"metadata": {
|
||||
"pillar_response_headers": {
|
||||
"set-cookie": "session=evil",
|
||||
"x-pillar-flagged": "true",
|
||||
},
|
||||
"pillar_flagged": True,
|
||||
}
|
||||
}
|
||||
|
||||
headers = get_logging_caching_headers(request_data)
|
||||
|
||||
assert "set-cookie" not in headers
|
||||
assert "x-pillar-flagged" not in headers
|
||||
|
||||
|
||||
def test_get_logging_caching_headers_filters_non_pillar_headers():
|
||||
request_data = {
|
||||
"metadata": {
|
||||
"pillar_flagged": True,
|
||||
}
|
||||
}
|
||||
build_pillar_response_headers(request_data["metadata"])
|
||||
request_data["metadata"]["pillar_response_headers"]["set-cookie"] = "session=evil"
|
||||
|
||||
headers = get_logging_caching_headers(request_data)
|
||||
|
||||
assert headers["x-pillar-flagged"] == "true"
|
||||
assert "set-cookie" not in headers
|
||||
|
||||
|
||||
def test_get_logging_caching_headers_truncates_large_evidence():
|
||||
long_text = "悪" * 6000 # multi-byte unicode to test URL encoding and truncation
|
||||
request_data = {
|
||||
|
||||
@@ -2,9 +2,9 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
@@ -25,7 +25,6 @@ from litellm.proxy.management_endpoints.ui_sso import (
|
||||
SSOAuthenticationHandler,
|
||||
_setup_team_mappings,
|
||||
_sync_user_role_from_jwt_role_map,
|
||||
determine_role_from_groups,
|
||||
normalize_email,
|
||||
process_sso_jwt_access_token,
|
||||
)
|
||||
@@ -1471,13 +1470,13 @@ class TestAuthCallbackRouting:
|
||||
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
||||
|
||||
# Test CLI state detection logic
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-test123"
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-test1234567890"
|
||||
|
||||
# This mimics the logic in auth_callback
|
||||
if cli_state and cli_state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"):
|
||||
# Extract the key ID from the state
|
||||
# Extract the login ID from the state
|
||||
key_id = cli_state.split(":", 1)[1]
|
||||
assert key_id == "sk-test123"
|
||||
assert key_id == "cli-test1234567890"
|
||||
else:
|
||||
assert False, "CLI state should have been detected"
|
||||
|
||||
@@ -1510,13 +1509,13 @@ class TestGoogleLoginCLIIntegration:
|
||||
|
||||
# Test the CLI state generation logic used in google_login
|
||||
source = "litellm-cli"
|
||||
key = "sk-test123"
|
||||
key = "cli-test1234567890"
|
||||
|
||||
cli_state = SSOAuthenticationHandler._get_cli_state(source=source, key=key)
|
||||
|
||||
assert cli_state is not None
|
||||
assert cli_state.startswith("litellm-session-token:")
|
||||
assert "sk-test123" in cli_state
|
||||
assert "cli-test1234567890" in cli_state
|
||||
|
||||
def test_google_login_no_cli_state_when_missing_params(self):
|
||||
"""Test that google_login doesn't generate CLI state when CLI parameters are missing"""
|
||||
@@ -1526,8 +1525,8 @@ class TestGoogleLoginCLIIntegration:
|
||||
test_cases = [
|
||||
(None, None),
|
||||
("litellm-cli", None),
|
||||
(None, "sk-test123"),
|
||||
("wrong-source", "sk-test123"),
|
||||
(None, "cli-test1234567890"),
|
||||
("wrong-source", "cli-test1234567890"),
|
||||
]
|
||||
|
||||
for source, key in test_cases:
|
||||
@@ -1634,19 +1633,19 @@ class TestSSOStateHandling:
|
||||
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
||||
|
||||
state = SSOAuthenticationHandler._get_cli_state(
|
||||
source="litellm-cli", key="sk-test123"
|
||||
source="litellm-cli", key="cli-test1234567890"
|
||||
)
|
||||
|
||||
assert state is not None
|
||||
assert state.startswith("litellm-session-token:")
|
||||
assert "sk-test123" in state
|
||||
assert "cli-test1234567890" in state
|
||||
|
||||
def test_get_cli_state_invalid_source(self):
|
||||
"""Test generating CLI state with invalid source"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
||||
|
||||
state = SSOAuthenticationHandler._get_cli_state(
|
||||
source="invalid_source", key="sk-test123"
|
||||
source="invalid_source", key="cli-test1234567890"
|
||||
)
|
||||
|
||||
assert state is None
|
||||
@@ -1663,40 +1662,40 @@ class TestSSOStateHandling:
|
||||
"""Test generating CLI state without source"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
||||
|
||||
state = SSOAuthenticationHandler._get_cli_state(source=None, key="sk-test123")
|
||||
state = SSOAuthenticationHandler._get_cli_state(
|
||||
source=None, key="cli-test1234567890"
|
||||
)
|
||||
|
||||
assert state is None
|
||||
|
||||
def test_get_cli_state_with_existing_key(self):
|
||||
"""Test generating CLI state with existing_key embedded in state parameter"""
|
||||
def test_get_cli_state_ignores_existing_key(self):
|
||||
"""Test CLI state does not embed an existing key"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
||||
|
||||
state = SSOAuthenticationHandler._get_cli_state(
|
||||
source="litellm-cli",
|
||||
key="sk-new-key-123",
|
||||
key="cli-new-key-1234567890",
|
||||
existing_key="sk-existing-key-456",
|
||||
)
|
||||
|
||||
assert state is not None
|
||||
assert state.startswith("litellm-session-token:")
|
||||
assert "sk-new-key-123" in state
|
||||
assert "sk-existing-key-456" in state
|
||||
# Verify the format: {PREFIX}:{key}:{existing_key}
|
||||
assert state == "litellm-session-token:sk-new-key-123:sk-existing-key-456"
|
||||
assert "cli-new-key-1234567890" in state
|
||||
assert "sk-existing-key-456" not in state
|
||||
assert state == "litellm-session-token:cli-new-key-1234567890"
|
||||
|
||||
def test_get_cli_state_without_existing_key(self):
|
||||
"""Test generating CLI state without existing_key"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
||||
|
||||
state = SSOAuthenticationHandler._get_cli_state(
|
||||
source="litellm-cli", key="sk-new-key-789", existing_key=None
|
||||
source="litellm-cli", key="cli-new-key-789123456", existing_key=None
|
||||
)
|
||||
|
||||
assert state is not None
|
||||
assert state.startswith("litellm-session-token:")
|
||||
assert "sk-new-key-789" in state
|
||||
# Verify the format: {PREFIX}:{key} (no third part)
|
||||
assert state == "litellm-session-token:sk-new-key-789"
|
||||
assert "cli-new-key-789123456" in state
|
||||
assert state == "litellm-session-token:cli-new-key-789123456"
|
||||
assert state.count(":") == 1 # Only one colon separator
|
||||
|
||||
|
||||
@@ -1708,44 +1707,37 @@ class TestStateRouting:
|
||||
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
||||
|
||||
# Test CLI state format
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-test123"
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-test1234567890"
|
||||
assert cli_state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:")
|
||||
|
||||
# Test extraction of key from state
|
||||
key_id = cli_state.split(":", 1)[1]
|
||||
assert key_id == "sk-test123"
|
||||
assert key_id == "cli-test1234567890"
|
||||
|
||||
def test_cli_state_parsing_with_existing_key(self):
|
||||
"""Test parsing CLI state with existing_key embedded"""
|
||||
def test_cli_state_parsing_uses_single_login_id(self):
|
||||
"""Test parsing CLI state with a single login ID"""
|
||||
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
||||
|
||||
# State format: {PREFIX}:{key}:{existing_key}
|
||||
cli_state = (
|
||||
f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-new-key-456:sk-existing-key-789"
|
||||
)
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-key-456123"
|
||||
|
||||
# Parse as done in auth_callback
|
||||
state_parts = cli_state.split(":", 2) # Split into max 3 parts
|
||||
state_parts = cli_state.split(":", 1)
|
||||
key_id = state_parts[1] if len(state_parts) > 1 else None
|
||||
existing_key = state_parts[2] if len(state_parts) > 2 else None
|
||||
|
||||
assert key_id == "sk-new-key-456"
|
||||
assert existing_key == "sk-existing-key-789"
|
||||
assert key_id == "cli-new-key-456123"
|
||||
|
||||
def test_cli_state_parsing_without_existing_key(self):
|
||||
"""Test parsing CLI state without existing_key"""
|
||||
def test_cli_state_parsing_without_extra_segments(self):
|
||||
"""Test parsing CLI state uses a single login ID"""
|
||||
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
||||
|
||||
# State format: {PREFIX}:{key}
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-new-key-999"
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-key-999123"
|
||||
|
||||
# Parse as done in auth_callback
|
||||
state_parts = cli_state.split(":", 2) # Split into max 3 parts
|
||||
state_parts = cli_state.split(":", 1)
|
||||
key_id = state_parts[1] if len(state_parts) > 1 else None
|
||||
existing_key = state_parts[2] if len(state_parts) > 2 else None
|
||||
|
||||
assert key_id == "sk-new-key-999"
|
||||
assert existing_key is None
|
||||
assert key_id == "cli-new-key-999123"
|
||||
|
||||
def test_non_cli_state_detection(self):
|
||||
"""Test detection of non-CLI state parameters"""
|
||||
@@ -2007,6 +1999,178 @@ class TestCustomUISSO:
|
||||
class TestCLIKeyRegenerationFlow:
|
||||
"""Test the end-to-end CLI key regeneration flow"""
|
||||
|
||||
def test_cli_sso_login_id_validation_restricts_charset(self):
|
||||
"""Test CLI SSO login IDs only allow the generated character set"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_is_valid_cli_sso_login_id,
|
||||
)
|
||||
|
||||
assert _is_valid_cli_sso_login_id("cli-test_1234567890")
|
||||
assert not _is_valid_cli_sso_login_id("cli-session")
|
||||
assert not _is_valid_cli_sso_login_id("cli-test\n1234567890")
|
||||
assert not _is_valid_cli_sso_login_id("cli-test\x001234567890")
|
||||
assert not _is_valid_cli_sso_login_id("sk-test1234567890")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_sso_start_creates_bound_flow(self):
|
||||
"""Test CLI SSO start creates a polling secret bound flow"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
_normalize_cli_sso_user_code,
|
||||
cli_sso_start,
|
||||
)
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.client = SimpleNamespace(host="127.0.0.1")
|
||||
mock_request.headers = {}
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.increment_cache.return_value = 1
|
||||
|
||||
with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
|
||||
result = await cli_sso_start(request=mock_request)
|
||||
|
||||
assert result["login_id"].startswith("cli-")
|
||||
assert result["poll_secret"]
|
||||
assert result["user_code"]
|
||||
|
||||
mock_cache.increment_cache.assert_called_once()
|
||||
assert mock_cache.increment_cache.call_args.kwargs["ttl"] == 60
|
||||
mock_cache.set_cache.assert_called_once()
|
||||
flow_data = mock_cache.set_cache.call_args.kwargs["value"]
|
||||
assert flow_data["poll_secret_hash"] == _hash_cli_sso_secret(
|
||||
result["poll_secret"]
|
||||
)
|
||||
assert flow_data["user_code_hash"] == _hash_cli_sso_secret(
|
||||
_normalize_cli_sso_user_code(result["user_code"])
|
||||
)
|
||||
assert flow_data["poll_secret_hash"] != result["poll_secret"]
|
||||
assert flow_data["user_code_hash"] != result["user_code"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_sso_start_rate_limits_by_client_ip(self):
|
||||
"""Test CLI SSO start enforces a coarse per-client rate limit"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import cli_sso_start
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.client = SimpleNamespace(host="127.0.0.1")
|
||||
mock_request.headers = {}
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.increment_cache.return_value = 31
|
||||
|
||||
with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cli_sso_start(request=mock_request)
|
||||
|
||||
assert exc_info.value.status_code == 429
|
||||
mock_cache.set_cache.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_sso_complete_verifies_user_code(self):
|
||||
"""Test CLI SSO complete marks a session as verified"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
_normalize_cli_sso_user_code,
|
||||
cli_sso_complete,
|
||||
)
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.body = AsyncMock(
|
||||
return_value=b"user_code=ABCD-EFGH&browser_complete_token=browser-token"
|
||||
)
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
|
||||
"user_code_hash": _hash_cli_sso_secret(
|
||||
_normalize_cli_sso_user_code("ABCD-EFGH")
|
||||
),
|
||||
"browser_complete_token_hash": _hash_cli_sso_secret("browser-token"),
|
||||
"sso_complete": True,
|
||||
"user_code_verified": False,
|
||||
"session_data": {"user_id": "test-user-123"},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache),
|
||||
patch(
|
||||
"litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page",
|
||||
return_value="<html>Success</html>",
|
||||
),
|
||||
):
|
||||
result = await cli_sso_complete(
|
||||
request=mock_request, login_id="cli-session-4567890"
|
||||
)
|
||||
|
||||
assert result.status_code == 200
|
||||
flow_data = mock_cache.set_cache.call_args.kwargs["value"]
|
||||
assert flow_data["user_code_verified"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_sso_complete_requires_callback_token(self):
|
||||
"""Test CLI SSO complete requires the callback-delivered token"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
_normalize_cli_sso_user_code,
|
||||
cli_sso_complete,
|
||||
)
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.body = AsyncMock(return_value=b"user_code=ABCD-EFGH")
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
|
||||
"user_code_hash": _hash_cli_sso_secret(
|
||||
_normalize_cli_sso_user_code("ABCD-EFGH")
|
||||
),
|
||||
"browser_complete_token_hash": _hash_cli_sso_secret("browser-token"),
|
||||
"sso_complete": True,
|
||||
"user_code_verified": False,
|
||||
"session_data": {"user_id": "test-user-123"},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cli_sso_complete(
|
||||
request=mock_request, login_id="cli-session-4567890"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
mock_cache.set_cache.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_sso_complete_waits_for_callback_before_token_checks(self):
|
||||
"""Test CLI SSO complete returns not-ready before verification checks"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
_normalize_cli_sso_user_code,
|
||||
cli_sso_complete,
|
||||
)
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.body = AsyncMock(
|
||||
return_value=b"user_code=ABCD-EFGH&browser_complete_token=browser-token"
|
||||
)
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
|
||||
"user_code_hash": _hash_cli_sso_secret(
|
||||
_normalize_cli_sso_user_code("ABCD-EFGH")
|
||||
),
|
||||
"sso_complete": False,
|
||||
"user_code_verified": False,
|
||||
"session_data": None,
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cli_sso_complete(
|
||||
request=mock_request, login_id="cli-session-4567890"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "CLI login is not ready"
|
||||
mock_request.body.assert_not_awaited()
|
||||
mock_cache.set_cache.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_sso_callback_stores_session(self):
|
||||
"""Test CLI SSO callback stores session data in cache for JWT generation"""
|
||||
@@ -2017,7 +2181,7 @@ class TestCLIKeyRegenerationFlow:
|
||||
mock_request = MagicMock(spec=Request)
|
||||
|
||||
# Test data
|
||||
session_key = "sk-session-456"
|
||||
session_key = "cli-session-4567890"
|
||||
|
||||
# Mock user info
|
||||
mock_user_info = LiteLLM_UserTable(
|
||||
@@ -2032,6 +2196,16 @@ class TestCLIKeyRegenerationFlow:
|
||||
|
||||
# Mock cache
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": "poll-secret-hash",
|
||||
"user_code_hash": "user-code-hash",
|
||||
"sso_complete": False,
|
||||
"user_code_verified": False,
|
||||
"session_data": None,
|
||||
}
|
||||
mock_request.url_for.return_value = (
|
||||
"https://test.example.com/sso/cli/complete/cli-session-4567890"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -2049,7 +2223,6 @@ class TestCLIKeyRegenerationFlow:
|
||||
result = await cli_sso_callback(
|
||||
request=mock_request,
|
||||
key=session_key,
|
||||
existing_key=None,
|
||||
result=mock_sso_result,
|
||||
)
|
||||
|
||||
@@ -2062,14 +2235,18 @@ class TestCLIKeyRegenerationFlow:
|
||||
assert session_key in call_args.kwargs["key"]
|
||||
|
||||
# Verify session data structure
|
||||
session_data = call_args.kwargs["value"]
|
||||
flow_data = call_args.kwargs["value"]
|
||||
session_data = flow_data["session_data"]
|
||||
assert flow_data["sso_complete"] is True
|
||||
assert flow_data["user_code_verified"] is False
|
||||
assert isinstance(flow_data["browser_complete_token_hash"], str)
|
||||
assert session_data["user_id"] == "test-user-123"
|
||||
assert session_data["user_role"] == "internal_user"
|
||||
assert session_data["teams"] == ["team1", "team2"]
|
||||
assert session_data["models"] == ["gpt-4"]
|
||||
|
||||
# Verify TTL
|
||||
assert call_args.kwargs["ttl"] == 600 # 10 minutes
|
||||
assert call_args.kwargs["ttl"] == 600
|
||||
|
||||
assert result.status_code == 200
|
||||
# Verify response contains success message (response is HTML)
|
||||
@@ -2078,10 +2255,13 @@ class TestCLIKeyRegenerationFlow:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_poll_key_returns_teams_for_selection(self):
|
||||
"""Test CLI poll endpoint returns teams for user selection when multiple teams exist"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import cli_poll_key
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
cli_poll_key,
|
||||
)
|
||||
|
||||
# Test data
|
||||
session_key = "sk-session-789"
|
||||
session_key = "cli-session-789123"
|
||||
session_data = {
|
||||
"user_id": "test-user-456",
|
||||
"user_role": "internal_user",
|
||||
@@ -2091,11 +2271,20 @@ class TestCLIKeyRegenerationFlow:
|
||||
|
||||
# Mock cache
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = session_data
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
|
||||
"sso_complete": True,
|
||||
"user_code_verified": True,
|
||||
"session_data": session_data,
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
|
||||
# Act - First poll without team_id
|
||||
result = await cli_poll_key(key_id=session_key, team_id=None)
|
||||
result = await cli_poll_key(
|
||||
key_id=session_key,
|
||||
team_id=None,
|
||||
x_litellm_cli_poll_secret="poll-secret",
|
||||
)
|
||||
|
||||
# Assert - should return teams list for selection
|
||||
assert result["status"] == "ready"
|
||||
@@ -2108,16 +2297,72 @@ class TestCLIKeyRegenerationFlow:
|
||||
mock_cache.delete_cache.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_callback_routes_to_cli_with_existing_key(self):
|
||||
"""Test that auth_callback properly routes CLI requests and extracts existing_key from state parameter"""
|
||||
async def test_cli_poll_key_requires_poll_secret(self):
|
||||
"""Test CLI poll endpoint rejects callers without the polling secret"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
cli_poll_key,
|
||||
)
|
||||
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
|
||||
"sso_complete": True,
|
||||
"user_code_verified": True,
|
||||
"session_data": {
|
||||
"user_id": "test-user-456",
|
||||
"user_role": "internal_user",
|
||||
"teams": [],
|
||||
"models": ["gpt-4"],
|
||||
},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cli_poll_key(key_id="cli-session-789123", team_id=None)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cli_poll_key_waits_for_user_code_verification(self):
|
||||
"""Test CLI poll endpoint stays pending until user code verification"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
cli_poll_key,
|
||||
)
|
||||
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
|
||||
"sso_complete": True,
|
||||
"user_code_verified": False,
|
||||
"session_data": {
|
||||
"user_id": "test-user-456",
|
||||
"user_role": "internal_user",
|
||||
"teams": [],
|
||||
"models": ["gpt-4"],
|
||||
},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache):
|
||||
result = await cli_poll_key(
|
||||
key_id="cli-session-789123",
|
||||
team_id=None,
|
||||
x_litellm_cli_poll_secret="poll-secret",
|
||||
)
|
||||
|
||||
assert result == {"status": "pending"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_callback_routes_to_cli(self):
|
||||
"""Test that auth_callback properly routes CLI requests"""
|
||||
from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX
|
||||
from litellm.proxy.management_endpoints.ui_sso import auth_callback
|
||||
|
||||
# Mock request (no query params needed - existing_key is in state)
|
||||
# Mock request
|
||||
mock_request = MagicMock(spec=Request)
|
||||
|
||||
# CLI state with existing_key embedded: {PREFIX}:{key}:{existing_key}
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-new-session-key-456:sk-existing-cli-key-123"
|
||||
cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-session-key-456"
|
||||
|
||||
# Mock the CLI callback and required proxy server components
|
||||
mock_result = {"user_id": "test-user", "email": "test@example.com"}
|
||||
@@ -2142,16 +2387,14 @@ class TestCLIKeyRegenerationFlow:
|
||||
# Act
|
||||
await auth_callback(request=mock_request, state=cli_state)
|
||||
|
||||
# Assert - existing_key should be extracted from state parameter
|
||||
mock_cli_callback.assert_called_once_with(
|
||||
request=mock_request,
|
||||
key="sk-new-session-key-456",
|
||||
existing_key="sk-existing-cli-key-123",
|
||||
key="cli-new-session-key-456",
|
||||
result=mock_result,
|
||||
)
|
||||
|
||||
def test_get_redirect_url_does_not_include_existing_key_in_url(self):
|
||||
"""Test that redirect URL generation does NOT include existing_key in URL (uses state parameter instead)"""
|
||||
"""Test that redirect URL generation does NOT include existing_key in URL"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
||||
|
||||
# Mock request
|
||||
@@ -2194,10 +2437,13 @@ class TestCLIKeyRegenerationFlow:
|
||||
async def test_cli_poll_key_generates_jwt_with_team(self):
|
||||
"""Test CLI poll endpoint generates JWT when team_id is provided"""
|
||||
from litellm.proxy._types import LiteLLM_UserTable
|
||||
from litellm.proxy.management_endpoints.ui_sso import cli_poll_key
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
_hash_cli_sso_secret,
|
||||
cli_poll_key,
|
||||
)
|
||||
|
||||
# Test data
|
||||
session_key = "sk-session-999"
|
||||
session_key = "cli-session-999123"
|
||||
selected_team = "team-b"
|
||||
session_data = {
|
||||
"user_id": "test-user-789",
|
||||
@@ -2217,7 +2463,12 @@ class TestCLIKeyRegenerationFlow:
|
||||
|
||||
# Mock cache
|
||||
mock_cache = MagicMock()
|
||||
mock_cache.get_cache.return_value = session_data
|
||||
mock_cache.get_cache.return_value = {
|
||||
"poll_secret_hash": _hash_cli_sso_secret("poll-secret"),
|
||||
"sso_complete": True,
|
||||
"user_code_verified": True,
|
||||
"session_data": session_data,
|
||||
}
|
||||
|
||||
mock_jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token"
|
||||
|
||||
@@ -2235,7 +2486,11 @@ class TestCLIKeyRegenerationFlow:
|
||||
)
|
||||
|
||||
# Act - Second poll with team_id
|
||||
result = await cli_poll_key(key_id=session_key, team_id=selected_team)
|
||||
result = await cli_poll_key(
|
||||
key_id=session_key,
|
||||
team_id=selected_team,
|
||||
x_litellm_cli_poll_secret="poll-secret",
|
||||
)
|
||||
|
||||
# Assert - should return JWT
|
||||
assert result["status"] == "ready"
|
||||
@@ -2901,7 +3156,7 @@ class TestGetGenericSSORedirectParams:
|
||||
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
|
||||
|
||||
# Arrange
|
||||
cli_state = "litellm-session-token:sk-test123"
|
||||
cli_state = "litellm-session-token:cli-test1234567890"
|
||||
|
||||
with patch.dict(os.environ, {"GENERIC_CLIENT_STATE": "env_state_value"}):
|
||||
# Act
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Regression tests for the pass-through endpoint auth-default fix
|
||||
(GHSA-7h34-mmrh-6g58).
|
||||
|
||||
Two failures the fix closes:
|
||||
|
||||
1. ``PassThroughGenericEndpoint.auth`` defaulted to ``False`` — an
|
||||
admin who added a pass-through to ``general_settings`` without
|
||||
explicitly setting ``auth: true`` shipped an unauthenticated
|
||||
forwarder.
|
||||
2. Setting ``auth: true`` was rejected at startup unless the operator
|
||||
had a LiteLLM Enterprise license, leaving OSS deployments with no
|
||||
safe configuration.
|
||||
|
||||
The fix flips the default to ``True`` (safe-by-default) and removes
|
||||
the enterprise gate so OSS operators can register an authenticated
|
||||
pass-through. The runtime check in ``user_api_key_auth.py`` also now
|
||||
defaults to ``True`` so a config dict (raw, not Pydantic) without an
|
||||
``auth`` key still requires authentication.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
from litellm.proxy._types import PassThroughGenericEndpoint
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
check_api_key_for_custom_headers_or_pass_through_endpoints,
|
||||
)
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
_register_pass_through_endpoint,
|
||||
)
|
||||
|
||||
|
||||
def test_passthrough_auth_defaults_to_true():
|
||||
# Regression: an admin who configures a pass-through without setting
|
||||
# auth explicitly used to ship an unauthenticated forwarder. The
|
||||
# default is now safe.
|
||||
endpoint = PassThroughGenericEndpoint(
|
||||
path="/canary-forwarder",
|
||||
target="https://postman-echo.com/get",
|
||||
)
|
||||
assert endpoint.auth is True
|
||||
|
||||
|
||||
def test_passthrough_auth_can_still_be_explicitly_disabled():
|
||||
# Operators who genuinely need an unauthenticated forwarder (e.g.
|
||||
# public webhook receiver) can opt in explicitly.
|
||||
endpoint = PassThroughGenericEndpoint(
|
||||
path="/public-webhook",
|
||||
target="https://example.com/webhook",
|
||||
auth=False,
|
||||
)
|
||||
assert endpoint.auth is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_passthrough_with_auth_true_works_for_oss(monkeypatch):
|
||||
# Regression: setting ``auth: true`` used to raise at startup
|
||||
# unless ``premium_user`` was True, leaving OSS with no safe
|
||||
# configuration.
|
||||
app = MagicMock(spec=FastAPI)
|
||||
visited: set = set()
|
||||
|
||||
endpoint = PassThroughGenericEndpoint(
|
||||
path="/forwarder",
|
||||
target="https://example.com",
|
||||
auth=True,
|
||||
)
|
||||
|
||||
# Should not raise; OSS premium_user=False is allowed to use auth=True.
|
||||
await _register_pass_through_endpoint(
|
||||
endpoint=endpoint,
|
||||
app=app,
|
||||
premium_user=False,
|
||||
visited_endpoints=visited,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_check_treats_missing_auth_key_as_authenticated():
|
||||
# The runtime dispatch in user_api_key_auth pulls
|
||||
# pass_through_endpoints from general_settings as raw dicts (the
|
||||
# Pydantic default never applies). A dict without an ``auth`` key
|
||||
# must default to "authenticated" — without this, the previous
|
||||
# behaviour (``endpoint.get("auth") is not True`` -> True -> empty
|
||||
# auth) ships an unauthenticated forwarder.
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
raw_endpoint_no_auth_key = {
|
||||
"path": "/forwarder",
|
||||
"target": "https://example.com",
|
||||
# ``auth`` deliberately omitted
|
||||
}
|
||||
|
||||
result = await check_api_key_for_custom_headers_or_pass_through_endpoints(
|
||||
request=request,
|
||||
route="/forwarder",
|
||||
pass_through_endpoints=[raw_endpoint_no_auth_key],
|
||||
api_key="sk-1234",
|
||||
)
|
||||
|
||||
# Result is the api_key string (auth is REQUIRED for this endpoint
|
||||
# — flow continues to normal key validation), NOT an empty
|
||||
# ``UserAPIKeyAuth()`` (which was the unauthenticated-forwarder
|
||||
# bug).
|
||||
assert result == "sk-1234"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_check_explicit_auth_false_still_skips_validation():
|
||||
# Operators who explicitly set ``auth: False`` get the legacy
|
||||
# behaviour — an empty UserAPIKeyAuth, no key required.
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
raw_endpoint_auth_false = {
|
||||
"path": "/public-webhook",
|
||||
"target": "https://example.com",
|
||||
"auth": False,
|
||||
}
|
||||
|
||||
result = await check_api_key_for_custom_headers_or_pass_through_endpoints(
|
||||
request=request,
|
||||
route="/public-webhook",
|
||||
pass_through_endpoints=[raw_endpoint_auth_false],
|
||||
api_key="",
|
||||
)
|
||||
|
||||
assert isinstance(result, UserAPIKeyAuth)
|
||||
@@ -7,10 +7,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import TeamCallbackMetadata, UserAPIKeyAuth
|
||||
from litellm.proxy._types import AddTeamCallback, TeamCallbackMetadata, UserAPIKeyAuth
|
||||
from litellm.proxy.litellm_pre_call_utils import (
|
||||
KeyAndTeamLoggingSettings,
|
||||
LiteLLMProxyRequestSetup,
|
||||
@@ -512,6 +513,247 @@ async def test_add_litellm_data_to_request_strips_string_encoded_admin_injection
|
||||
assert "_pipeline_managed_guardrails" not in other
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_strips_user_control_fields():
|
||||
"""Strip untrusted proxy-control fields before guardrails, logging, and headers read metadata."""
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
malicious_metadata = {
|
||||
"disable_global_guardrails": True,
|
||||
"opted_out_global_guardrails": ["pii"],
|
||||
"pillar_response_headers": {"set-cookie": "session=evil"},
|
||||
"_pillar_response_headers_trusted": True,
|
||||
"pillar_flagged": True,
|
||||
"pillar_scanners": {"jailbreak": True},
|
||||
"pillar_evidence": [{"evidence": "spoofed"}],
|
||||
"pillar_session_id_response": "spoofed-session",
|
||||
"applied_guardrails": ["spoofed"],
|
||||
"applied_policies": ["spoofed-policy"],
|
||||
"policy_sources": {"spoofed-policy": "request"},
|
||||
"_guardrail_pipelines": [{"name": "spoofed"}],
|
||||
"_pipeline_managed_guardrails": ["evaded"],
|
||||
"safe_user_metadata": "kept",
|
||||
}
|
||||
data = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"mock_response": "free response",
|
||||
"mock_tool_calls": [{"id": "call_1"}],
|
||||
"disable_global_guardrails": True,
|
||||
"metadata": copy.deepcopy(malicious_metadata),
|
||||
"litellm_metadata": copy.deepcopy(malicious_metadata),
|
||||
}
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert "mock_response" not in updated
|
||||
assert "mock_tool_calls" not in updated
|
||||
assert "disable_global_guardrails" not in updated
|
||||
|
||||
stripped_keys = {
|
||||
"disable_global_guardrails",
|
||||
"opted_out_global_guardrails",
|
||||
"pillar_response_headers",
|
||||
"_pillar_response_headers_trusted",
|
||||
"pillar_flagged",
|
||||
"pillar_scanners",
|
||||
"pillar_evidence",
|
||||
"pillar_session_id_response",
|
||||
"applied_guardrails",
|
||||
"applied_policies",
|
||||
"policy_sources",
|
||||
"_guardrail_pipelines",
|
||||
"_pipeline_managed_guardrails",
|
||||
}
|
||||
for metadata_key in ("metadata", "litellm_metadata"):
|
||||
cleaned_metadata = updated.get(metadata_key) or {}
|
||||
for stripped_key in stripped_keys:
|
||||
assert stripped_key not in cleaned_metadata
|
||||
assert cleaned_metadata.get("safe_user_metadata") == "kept"
|
||||
|
||||
requester_metadata = updated["metadata"]["requester_metadata"]
|
||||
for stripped_key in stripped_keys:
|
||||
assert stripped_key not in requester_metadata
|
||||
|
||||
snapshot_body = updated["proxy_server_request"]["body"]
|
||||
assert "mock_response" not in snapshot_body
|
||||
assert "mock_tool_calls" not in snapshot_body
|
||||
assert "pillar_response_headers" not in snapshot_body["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_allows_client_mock_response_with_admin_opt_in():
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"mock_response": "allowed mock",
|
||||
"mock_tool_calls": [{"id": "call_1"}],
|
||||
},
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
api_key="hashed-key",
|
||||
metadata={"allow_client_mock_response": True},
|
||||
),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated["mock_response"] == "allowed mock"
|
||||
assert updated["mock_tool_calls"] == [{"id": "call_1"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_strips_client_redaction_bypass_controls():
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"litellm-disable-message-redaction": "true",
|
||||
}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
original_turn_off_message_logging = litellm.turn_off_message_logging
|
||||
litellm.turn_off_message_logging = True
|
||||
try:
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"turn_off_message_logging": False,
|
||||
"metadata": {"headers": {"litellm-disable-message-redaction": "true"}},
|
||||
"litellm_metadata": json.dumps(
|
||||
{"headers": {"LiteLLM-Disable-Message-Redaction": "true"}}
|
||||
),
|
||||
},
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
finally:
|
||||
litellm.turn_off_message_logging = original_turn_off_message_logging
|
||||
|
||||
assert "turn_off_message_logging" not in updated
|
||||
assert "litellm-disable-message-redaction" not in {
|
||||
header.lower() for header in updated["metadata"]["headers"]
|
||||
}
|
||||
assert "litellm-disable-message-redaction" not in {
|
||||
header.lower()
|
||||
for header in updated["metadata"]["requester_metadata"].get("headers", {})
|
||||
}
|
||||
assert "litellm-disable-message-redaction" not in {
|
||||
header.lower() for header in updated["proxy_server_request"]["headers"]
|
||||
}
|
||||
assert "litellm-disable-message-redaction" not in {
|
||||
header.lower()
|
||||
for header in updated["proxy_server_request"]["body"]["metadata"]["headers"]
|
||||
}
|
||||
assert "litellm-disable-message-redaction" not in {
|
||||
header.lower()
|
||||
for header in (updated.get("litellm_metadata") or {}).get("headers", {})
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"auth_kwargs",
|
||||
[
|
||||
{"metadata": {"allow_client_message_redaction_opt_out": True}},
|
||||
{"team_metadata": {"allow_client_message_redaction_opt_out": True}},
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_allows_redaction_opt_out_with_admin_opt_in(
|
||||
auth_kwargs,
|
||||
):
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"litellm-disable-message-redaction": "true",
|
||||
}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
original_turn_off_message_logging = litellm.turn_off_message_logging
|
||||
litellm.turn_off_message_logging = True
|
||||
try:
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"turn_off_message_logging": False,
|
||||
"metadata": {"headers": {"litellm-disable-message-redaction": "true"}},
|
||||
"litellm_metadata": json.dumps(
|
||||
{"headers": {"LiteLLM-Disable-Message-Redaction": "true"}}
|
||||
),
|
||||
},
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", **auth_kwargs),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
finally:
|
||||
litellm.turn_off_message_logging = original_turn_off_message_logging
|
||||
|
||||
assert updated["turn_off_message_logging"] is False
|
||||
assert "litellm-disable-message-redaction" in {
|
||||
header.lower() for header in updated["metadata"]["headers"]
|
||||
}
|
||||
assert "litellm-disable-message-redaction" in {
|
||||
header.lower()
|
||||
for header in updated["metadata"]["requester_metadata"].get("headers", {})
|
||||
}
|
||||
assert "litellm-disable-message-redaction" in {
|
||||
header.lower() for header in updated["proxy_server_request"]["headers"]
|
||||
}
|
||||
assert "litellm-disable-message-redaction" in {
|
||||
header.lower()
|
||||
for header in updated["proxy_server_request"]["body"]["metadata"]["headers"]
|
||||
}
|
||||
assert "litellm-disable-message-redaction" in {
|
||||
header.lower()
|
||||
for header in (updated.get("litellm_metadata") or {}).get("headers", {})
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_ignores_x_litellm_tags_header_without_permission():
|
||||
"""Regression: the `x-litellm-tags` header bypassed the body-metadata
|
||||
@@ -1274,6 +1516,51 @@ def test_get_dynamic_logging_metadata_with_arize_team_logging():
|
||||
assert result.callback_vars["arize_space_id"] == "test_arize_space_id"
|
||||
|
||||
|
||||
def test_add_team_callback_rejects_env_reference():
|
||||
with pytest.raises(PydanticValidationError) as exc_info:
|
||||
AddTeamCallback(
|
||||
callback_name="langfuse",
|
||||
callback_type="success",
|
||||
callback_vars={
|
||||
"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY_TEMP"
|
||||
},
|
||||
)
|
||||
|
||||
assert "os.environ/" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_dynamic_logging_metadata_ignores_env_reference_from_key_metadata(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY_TEMP", "server-side-secret")
|
||||
monkeypatch.setattr(
|
||||
litellm.utils,
|
||||
"get_secret",
|
||||
lambda *args, **kwargs: pytest.fail("get_secret should not be called"),
|
||||
)
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key",
|
||||
metadata={
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success",
|
||||
"callback_vars": {
|
||||
"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY_TEMP",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
team_metadata={},
|
||||
)
|
||||
|
||||
result = _get_dynamic_logging_metadata(
|
||||
user_api_key_dict=user_api_key_dict, proxy_config=MagicMock()
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_num_retries_from_request():
|
||||
"""
|
||||
Test LiteLLMProxyRequestSetup._get_num_retries_from_request method
|
||||
@@ -1669,7 +1956,10 @@ async def test_add_litellm_metadata_from_request_headers():
|
||||
|
||||
# Create mock user API key dict
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key", user_id="test-user", org_id="test-org"
|
||||
api_key="test-key",
|
||||
user_id="test-user",
|
||||
org_id="test-org",
|
||||
metadata={"allow_client_mock_response": True},
|
||||
)
|
||||
|
||||
# Create mock proxy logging object
|
||||
@@ -1782,7 +2072,9 @@ async def test_anthropic_messages_standard_logging_object_matches_fixture():
|
||||
|
||||
mock_fastapi_response = MagicMock(spec=Response)
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="test-key", user_id="default_user_id"
|
||||
api_key="test-key",
|
||||
user_id="default_user_id",
|
||||
metadata={"allow_client_mock_response": True},
|
||||
)
|
||||
|
||||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||||
@@ -3326,7 +3618,9 @@ async def test_team_guardrail_merges_with_global_policy():
|
||||
policy_registry = get_policy_registry()
|
||||
policy_registry._policies = {
|
||||
"global-policy": Policy(
|
||||
guardrails=PolicyGuardrails(add=["policy-guardrail-1", "policy-guardrail-2"]),
|
||||
guardrails=PolicyGuardrails(
|
||||
add=["policy-guardrail-1", "policy-guardrail-2"]
|
||||
),
|
||||
),
|
||||
}
|
||||
policy_registry._initialized = True
|
||||
@@ -3347,14 +3641,18 @@ async def test_team_guardrail_merges_with_global_policy():
|
||||
|
||||
guardrails = data["metadata"].get("guardrails", [])
|
||||
|
||||
assert "team-direct-guardrail" in guardrails, \
|
||||
f"Team guardrail missing from merged list: {guardrails}"
|
||||
assert "policy-guardrail-1" in guardrails, \
|
||||
f"policy-guardrail-1 missing: {guardrails}"
|
||||
assert "policy-guardrail-2" in guardrails, \
|
||||
f"policy-guardrail-2 missing: {guardrails}"
|
||||
assert len(guardrails) == len(set(guardrails)), \
|
||||
f"Duplicates in guardrails list: {guardrails}"
|
||||
assert (
|
||||
"team-direct-guardrail" in guardrails
|
||||
), f"Team guardrail missing from merged list: {guardrails}"
|
||||
assert (
|
||||
"policy-guardrail-1" in guardrails
|
||||
), f"policy-guardrail-1 missing: {guardrails}"
|
||||
assert (
|
||||
"policy-guardrail-2" in guardrails
|
||||
), f"policy-guardrail-2 missing: {guardrails}"
|
||||
assert len(guardrails) == len(
|
||||
set(guardrails)
|
||||
), f"Duplicates in guardrails list: {guardrails}"
|
||||
|
||||
# Verify get_guardrail_from_metadata returns the merged list even
|
||||
# when litellm_metadata is present (the bug: it returned [] before fix)
|
||||
@@ -3365,9 +3663,9 @@ async def test_team_guardrail_merges_with_global_policy():
|
||||
|
||||
dummy = _DummyGuardrail(guardrail_name="team-direct-guardrail")
|
||||
returned = dummy.get_guardrail_from_metadata(data)
|
||||
assert "team-direct-guardrail" in returned, (
|
||||
f"get_guardrail_from_metadata shadowed by litellm_metadata; got: {returned}"
|
||||
)
|
||||
assert (
|
||||
"team-direct-guardrail" in returned
|
||||
), f"get_guardrail_from_metadata shadowed by litellm_metadata; got: {returned}"
|
||||
|
||||
finally:
|
||||
policy_registry._policies = {}
|
||||
@@ -3396,9 +3694,10 @@ async def test_get_guardrail_from_metadata_prefers_metadata_over_litellm_metadat
|
||||
}
|
||||
|
||||
result = dummy.get_guardrail_from_metadata(data)
|
||||
assert result == ["my-guardrail", "other-guardrail"], (
|
||||
f"Expected guardrails from metadata, got: {result}"
|
||||
)
|
||||
assert result == [
|
||||
"my-guardrail",
|
||||
"other-guardrail",
|
||||
], f"Expected guardrails from metadata, got: {result}"
|
||||
|
||||
|
||||
def test_get_guardrail_from_metadata_reads_litellm_metadata_when_no_metadata():
|
||||
@@ -3419,6 +3718,6 @@ def test_get_guardrail_from_metadata_reads_litellm_metadata_when_no_metadata():
|
||||
}
|
||||
|
||||
result = dummy.get_guardrail_from_metadata(data)
|
||||
assert result == ["my-guardrail"], (
|
||||
f"Expected guardrails from litellm_metadata fallback, got: {result}"
|
||||
)
|
||||
assert result == [
|
||||
"my-guardrail"
|
||||
], f"Expected guardrails from litellm_metadata fallback, got: {result}"
|
||||
|
||||
@@ -457,6 +457,59 @@ def test_fallback_login_has_no_deprecation_banner(client_no_auth):
|
||||
assert "<form" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ui_logo_path",
|
||||
[
|
||||
"/etc/litellm/secret-config.json",
|
||||
"/var/secrets/admin.key",
|
||||
"/proc/self/environ",
|
||||
"relative/path/logo.png",
|
||||
],
|
||||
)
|
||||
def test_get_logo_url_does_not_disclose_local_paths(
|
||||
client_no_auth, monkeypatch, ui_logo_path
|
||||
):
|
||||
# ``/get_logo_url`` is unauthenticated. Returning a local filesystem
|
||||
# path verbatim discloses admin-only config to any caller. Only
|
||||
# browser-loadable HTTP(S) URLs should be returned; for local paths
|
||||
# the dashboard falls back to ``/get_image``.
|
||||
monkeypatch.setenv("UI_LOGO_PATH", ui_logo_path)
|
||||
|
||||
response = client_no_auth.get("/get_logo_url")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"logo_url": ""}
|
||||
|
||||
|
||||
def test_get_logo_url_returns_https_url(client_no_auth, monkeypatch):
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "https://cdn.public.example/logo.png")
|
||||
|
||||
response = client_no_auth.get("/get_logo_url")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"logo_url": "https://cdn.public.example/logo.png"}
|
||||
|
||||
|
||||
def test_get_logo_url_returns_http_url(client_no_auth, monkeypatch):
|
||||
# HTTP URLs (typically internal CDN) are still returned — those are
|
||||
# intended to be loaded directly by the browser.
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "http://internal-cdn.corp:8080/logo.png")
|
||||
|
||||
response = client_no_auth.get("/get_logo_url")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"logo_url": "http://internal-cdn.corp:8080/logo.png"}
|
||||
|
||||
|
||||
def test_get_logo_url_returns_empty_when_unset(client_no_auth, monkeypatch):
|
||||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||||
|
||||
response = client_no_auth.get("/get_logo_url")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"logo_url": ""}
|
||||
|
||||
|
||||
def test_sso_key_generate_shows_deprecation_banner(client_no_auth, monkeypatch):
|
||||
# Ensure the route returns the HTML form instead of redirecting
|
||||
monkeypatch.setattr(
|
||||
@@ -3980,7 +4033,7 @@ async def test_get_image_root_case_uses_current_dir(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch):
|
||||
async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch, tmp_path):
|
||||
"""
|
||||
Test that when UI_LOGO_PATH is set to a local file, get_image serves it
|
||||
directly and does not return a stale cached_logo.jpg.
|
||||
@@ -3989,11 +4042,11 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch):
|
||||
so a pre-existing cached_logo.jpg (e.g. from the base Docker image) would
|
||||
always be returned, ignoring the user's custom logo.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from litellm.proxy.proxy_server import get_image
|
||||
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "/app/custom_logo.jpg")
|
||||
custom_logo = tmp_path / "custom_logo.jpg"
|
||||
custom_logo.write_bytes(b"\xff\xd8\xff custom logo")
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo))
|
||||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||||
monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False)
|
||||
|
||||
@@ -4004,8 +4057,6 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch):
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.os.path.exists", return_value=True),
|
||||
patch("litellm.proxy.proxy_server.os.access", return_value=True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||||
),
|
||||
@@ -4015,25 +4066,27 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch):
|
||||
assert (
|
||||
len(calls_to_file_response) == 1
|
||||
), "FileResponse should be called exactly once"
|
||||
assert calls_to_file_response[0] == "/app/custom_logo.jpg", (
|
||||
assert calls_to_file_response[0] == str(custom_logo.resolve()), (
|
||||
f"Expected custom logo path, got {calls_to_file_response[0]}. "
|
||||
"A stale cached_logo.jpg may have been returned instead."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_default_logo_still_uses_cache(monkeypatch):
|
||||
async def test_get_image_default_logo_ignores_stale_cache(monkeypatch, tmp_path):
|
||||
"""
|
||||
Test that when UI_LOGO_PATH is NOT set (default logo), the cache
|
||||
optimization still works — cached_logo.jpg is returned if it exists.
|
||||
Test that when UI_LOGO_PATH is NOT set, stale pre-fix cached_logo.jpg
|
||||
files are ignored and the default logo is served.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from litellm.proxy.proxy_server import get_image
|
||||
|
||||
cache_path = tmp_path / "cached_logo.jpg"
|
||||
cache_path.write_bytes(b"\xff\xd8\xff cached logo")
|
||||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||||
monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False)
|
||||
monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path))
|
||||
|
||||
calls_to_file_response = []
|
||||
|
||||
@@ -4042,8 +4095,6 @@ async def test_get_image_default_logo_still_uses_cache(monkeypatch):
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.os.path.exists", return_value=True),
|
||||
patch("litellm.proxy.proxy_server.os.access", return_value=True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||||
),
|
||||
@@ -4054,24 +4105,26 @@ async def test_get_image_default_logo_still_uses_cache(monkeypatch):
|
||||
len(calls_to_file_response) == 1
|
||||
), "FileResponse should be called exactly once"
|
||||
served_path = calls_to_file_response[0]
|
||||
assert served_path.endswith(
|
||||
"cached_logo.jpg"
|
||||
), f"Expected cached_logo.jpg for default logo, got {served_path}"
|
||||
assert served_path != str(cache_path.resolve())
|
||||
assert served_path.endswith("logo.jpg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatch):
|
||||
async def test_get_image_custom_logo_missing_falls_through_to_default(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""
|
||||
Test that when UI_LOGO_PATH points to a non-existent local file,
|
||||
get_image falls through to the cache/default logo instead of failing.
|
||||
get_image falls through to the default logo instead of failing.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from litellm.proxy.proxy_server import get_image
|
||||
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "/app/nonexistent_logo.jpg")
|
||||
custom_logo_path = tmp_path / "nonexistent_logo.jpg"
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo_path))
|
||||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||||
monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False)
|
||||
monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path))
|
||||
|
||||
calls_to_file_response = []
|
||||
|
||||
@@ -4079,17 +4132,7 @@ async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatc
|
||||
calls_to_file_response.append(path)
|
||||
return MagicMock()
|
||||
|
||||
def exists_side_effect(path):
|
||||
# The custom logo does NOT exist; cache and default DO exist
|
||||
if path == "/app/nonexistent_logo.jpg":
|
||||
return False
|
||||
return True
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.os.access", return_value=True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||||
),
|
||||
@@ -4100,28 +4143,29 @@ async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatc
|
||||
len(calls_to_file_response) == 1
|
||||
), "FileResponse should be called exactly once"
|
||||
served_path = calls_to_file_response[0]
|
||||
assert (
|
||||
served_path != "/app/nonexistent_logo.jpg"
|
||||
assert served_path != str(
|
||||
custom_logo_path
|
||||
), "Should not attempt to serve a non-existent custom logo"
|
||||
assert served_path.endswith(
|
||||
"cached_logo.jpg"
|
||||
), f"Expected fallback to cached_logo.jpg, got {served_path}"
|
||||
assert served_path.endswith("logo.jpg")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch):
|
||||
async def test_get_image_custom_logo_missing_no_cache_serves_default(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""
|
||||
Test that when UI_LOGO_PATH points to a non-existent file AND there is no
|
||||
cached_logo.jpg, get_image serves the default logo instead of the
|
||||
non-existent custom path.
|
||||
cached_logo.jpg, get_image serves the default logo instead of the non-existent
|
||||
custom path.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from litellm.proxy.proxy_server import get_image
|
||||
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "/app/nonexistent_logo.jpg")
|
||||
custom_logo_path = tmp_path / "nonexistent_logo.jpg"
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo_path))
|
||||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||||
monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False)
|
||||
monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path))
|
||||
|
||||
calls_to_file_response = []
|
||||
|
||||
@@ -4129,19 +4173,7 @@ async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch
|
||||
calls_to_file_response.append(path)
|
||||
return MagicMock()
|
||||
|
||||
def exists_side_effect(path):
|
||||
# Neither the custom logo nor the cache exist
|
||||
if path == "/app/nonexistent_logo.jpg":
|
||||
return False
|
||||
if "cached_logo.jpg" in path:
|
||||
return False
|
||||
return True
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.os.access", return_value=True),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||||
),
|
||||
@@ -4152,8 +4184,8 @@ async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch
|
||||
len(calls_to_file_response) == 1
|
||||
), "FileResponse should be called exactly once"
|
||||
served_path = calls_to_file_response[0]
|
||||
assert (
|
||||
served_path != "/app/nonexistent_logo.jpg"
|
||||
assert served_path != str(
|
||||
custom_logo_path
|
||||
), "Should not attempt to serve a non-existent custom logo"
|
||||
assert served_path.endswith(
|
||||
"logo.jpg"
|
||||
@@ -5471,3 +5503,250 @@ async def test_reseed_warms_cache_even_on_zero_db_spend():
|
||||
finally:
|
||||
ps.spend_counter_cache = orig_counter
|
||||
ps.prisma_client = orig_prisma
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy feature loading (LazyFeatureMiddleware) — verifies that optional
|
||||
# routers are NOT imported at module load and ARE imported on first request
|
||||
# to a matching path prefix. The same module isn't re-imported on subsequent
|
||||
# requests.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
class TestLazyFeatureRegistry:
|
||||
"""Sanity checks on the registry shape — guards against accidental edits."""
|
||||
|
||||
def test_registry_entries_have_required_fields(self):
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature
|
||||
|
||||
assert len(LAZY_FEATURES) > 0
|
||||
for feat in LAZY_FEATURES:
|
||||
assert isinstance(feat, LazyFeature)
|
||||
assert feat.name
|
||||
assert feat.module_path
|
||||
assert feat.path_prefixes
|
||||
assert all(p.startswith("/") for p in feat.path_prefixes)
|
||||
assert callable(feat.register_fn)
|
||||
|
||||
def test_registry_names_unique(self):
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||||
|
||||
names = [f.name for f in LAZY_FEATURES]
|
||||
assert len(names) == len(set(names)), "duplicate feature names"
|
||||
|
||||
|
||||
class TestLazyFeaturesNotImportedAtStartup:
|
||||
"""
|
||||
The whole point of the refactor: gated feature modules must NOT be
|
||||
present in `sys.modules` immediately after `proxy_server` imports.
|
||||
"""
|
||||
|
||||
def test_heavy_modules_absent_at_startup(self):
|
||||
# Static scan of proxy_server.py source — catches any top-level
|
||||
# `from <lazy_module> import` that would defeat lazy loading.
|
||||
# Importing proxy_server in a subprocess and diffing sys.modules
|
||||
# would also work, but takes 60-120 s and flakes on slow CI runners.
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||||
|
||||
proxy_server_src = (
|
||||
Path(__file__).resolve().parents[3] / "litellm/proxy/proxy_server.py"
|
||||
).read_text()
|
||||
|
||||
leaks = []
|
||||
for feat in LAZY_FEATURES:
|
||||
# Anchor at column 0 — indented imports inside function bodies
|
||||
# are fine (deferred until the function runs).
|
||||
pattern = (
|
||||
rf"^(from\s+{re.escape(feat.module_path)}\s+import|"
|
||||
rf"import\s+{re.escape(feat.module_path)})"
|
||||
)
|
||||
if re.search(pattern, proxy_server_src, re.MULTILINE):
|
||||
leaks.append(feat.module_path)
|
||||
|
||||
assert not leaks, (
|
||||
"proxy_server.py top-level imports a lazy feature module — these "
|
||||
f"should be loaded via LazyFeatureMiddleware: {leaks}"
|
||||
)
|
||||
|
||||
|
||||
class TestLazyFeatureMiddleware:
|
||||
"""Behavior of the middleware itself, exercised in isolation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_request_triggers_load_subsequent_does_not(self):
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy._lazy_features import (
|
||||
LazyFeature,
|
||||
LazyFeatureMiddleware,
|
||||
)
|
||||
|
||||
loads = []
|
||||
|
||||
def fake_register(app, module):
|
||||
loads.append(getattr(module, "__name__", "?"))
|
||||
|
||||
feat = LazyFeature(
|
||||
name="dummy",
|
||||
module_path="json", # any always-importable stdlib module
|
||||
path_prefixes=("/dummy",),
|
||||
register_fn=fake_register,
|
||||
)
|
||||
|
||||
# Build a minimal ASGI receiver to satisfy the middleware contract
|
||||
async def downstream(scope, receive, send):
|
||||
# echo back; no-op handler
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
target_app = FastAPI()
|
||||
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
sent: list = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
# First request matching the prefix triggers register
|
||||
await mw(
|
||||
{"type": "http", "path": "/dummy/x", "method": "GET", "headers": []},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
assert loads == ["json"]
|
||||
|
||||
# Second matching request must NOT re-register
|
||||
sent.clear()
|
||||
await mw(
|
||||
{"type": "http", "path": "/dummy/y", "method": "GET", "headers": []},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
assert loads == ["json"], "register_fn called twice for the same feature"
|
||||
|
||||
# Non-matching path must not trigger anything
|
||||
await mw(
|
||||
{"type": "http", "path": "/unrelated", "method": "GET", "headers": []},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
assert loads == ["json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_first_requests_only_register_once(self):
|
||||
"""
|
||||
Two requests to the same prefix arriving in parallel must result in
|
||||
exactly one `register_fn` invocation — the lock prevents the import +
|
||||
register from racing with itself.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy._lazy_features import (
|
||||
LazyFeature,
|
||||
LazyFeatureMiddleware,
|
||||
)
|
||||
|
||||
loads = []
|
||||
|
||||
def slow_register(app, module):
|
||||
loads.append(getattr(module, "__name__", "?"))
|
||||
|
||||
feat = LazyFeature(
|
||||
name="dummy_concurrent",
|
||||
module_path="json",
|
||||
path_prefixes=("/dummy_c",),
|
||||
register_fn=slow_register,
|
||||
)
|
||||
|
||||
async def downstream(scope, receive, send):
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
target_app = FastAPI()
|
||||
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
sent: list = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
async def hit():
|
||||
await mw(
|
||||
{
|
||||
"type": "http",
|
||||
"path": "/dummy_c/x",
|
||||
"method": "GET",
|
||||
"headers": [],
|
||||
},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
|
||||
await asyncio.gather(hit(), hit(), hit(), hit(), hit())
|
||||
assert loads == [
|
||||
"json"
|
||||
], f"expected one registration despite concurrent first hits, got {loads}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failing_import_does_not_loop(self):
|
||||
"""
|
||||
If a feature's module can't be imported, the middleware should mark it
|
||||
loaded anyway so subsequent requests don't repeatedly retry the failing
|
||||
import (which would amplify the cost on every request).
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy._lazy_features import (
|
||||
LazyFeature,
|
||||
LazyFeatureMiddleware,
|
||||
)
|
||||
|
||||
attempts = []
|
||||
|
||||
def fail_register(app, module):
|
||||
attempts.append("called")
|
||||
raise RuntimeError("boom")
|
||||
|
||||
feat = LazyFeature(
|
||||
name="failing",
|
||||
module_path="json",
|
||||
path_prefixes=("/fail",),
|
||||
register_fn=fail_register,
|
||||
)
|
||||
|
||||
async def downstream(scope, receive, send):
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
|
||||
target_app = FastAPI()
|
||||
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
sent: list = []
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
for _ in range(3):
|
||||
await mw(
|
||||
{"type": "http", "path": "/fail/x", "method": "GET", "headers": []},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
assert attempts == [
|
||||
"called"
|
||||
], f"failing register_fn should be invoked once, not on every request; got {attempts}"
|
||||
|
||||
@@ -786,8 +786,23 @@ class TestVectorStoreManagementEndpointsExist:
|
||||
- POST /vector_store/info
|
||||
- POST /vector_store/update
|
||||
"""
|
||||
import importlib
|
||||
|
||||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
# Force-register the lazy vector_store_management routes so the
|
||||
# assertions can find them.
|
||||
already_registered = any(
|
||||
getattr(r, "path", None) == "/vector_store/new" for r in app.routes
|
||||
)
|
||||
if not already_registered:
|
||||
for feat in LAZY_FEATURES:
|
||||
if feat.name == "vector_store_management":
|
||||
module = importlib.import_module(feat.module_path)
|
||||
feat.register_fn(app, module)
|
||||
break
|
||||
|
||||
# Define expected endpoints
|
||||
expected_endpoints = [
|
||||
("POST", "/vector_store/new"),
|
||||
|
||||
@@ -310,6 +310,139 @@ class TestMilvusVectorStore:
|
||||
assert result["attributes"]["book_id"] == expected["book_id"] # type: ignore
|
||||
assert "book_intro_text" not in result["attributes"] # type: ignore # Should be in content, not attributes
|
||||
|
||||
def _extract_request_body(self, mock_post):
|
||||
call_args = mock_post.call_args
|
||||
request_data_str = call_args.kwargs.get("data")
|
||||
if request_data_str:
|
||||
return json.loads(request_data_str)
|
||||
request_data = call_args.kwargs.get("json")
|
||||
if (
|
||||
request_data is None
|
||||
and len(call_args.args) > 0
|
||||
and isinstance(call_args.args[0], dict)
|
||||
):
|
||||
request_data = call_args.args[0]
|
||||
return request_data
|
||||
|
||||
def test_user_supplied_db_and_partition_are_dropped(self):
|
||||
"""User-supplied dbName / partitionNames must not be forwarded to Milvus."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = MOCK_MILVUS_SEARCH_RESPONSE
|
||||
mock_response.text = json.dumps(MOCK_MILVUS_SEARCH_RESPONSE)
|
||||
|
||||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
vector_store_search(
|
||||
query="what is machine learning?",
|
||||
vector_store_id="book_2",
|
||||
custom_llm_provider="milvus",
|
||||
api_base="https://in03-test.serverless.aws-eu-central-1.cloud.zilliz.com",
|
||||
api_key="mock_milvus_api_key",
|
||||
litellm_embedding_model="text-embedding-3-large",
|
||||
litellm_embedding_config={
|
||||
"api_key": "mock_openai_api_key",
|
||||
},
|
||||
outputFields=["book_intro_text"],
|
||||
annsField="book_intro_vector",
|
||||
milvus_text_field="book_intro_text",
|
||||
dbName="other_tenant_db",
|
||||
partitionNames=["other_tenant_partition"],
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
request_data = self._extract_request_body(mock_post)
|
||||
assert request_data is not None
|
||||
assert "dbName" not in request_data
|
||||
assert "partitionNames" not in request_data
|
||||
assert request_data["collectionName"] == "book_2"
|
||||
assert request_data["annsField"] == "book_intro_vector"
|
||||
assert request_data["outputFields"] == ["book_intro_text"]
|
||||
|
||||
def test_backend_configured_db_and_partition_are_forwarded(self):
|
||||
"""milvus_db_name / milvus_partition_names from litellm_params must be sent."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = MOCK_MILVUS_SEARCH_RESPONSE
|
||||
mock_response.text = json.dumps(MOCK_MILVUS_SEARCH_RESPONSE)
|
||||
|
||||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
vector_store_search(
|
||||
query="what is machine learning?",
|
||||
vector_store_id="book_2",
|
||||
custom_llm_provider="milvus",
|
||||
api_base="https://in03-test.serverless.aws-eu-central-1.cloud.zilliz.com",
|
||||
api_key="mock_milvus_api_key",
|
||||
litellm_embedding_model="text-embedding-3-large",
|
||||
litellm_embedding_config={
|
||||
"api_key": "mock_openai_api_key",
|
||||
},
|
||||
outputFields=["book_intro_text"],
|
||||
annsField="book_intro_vector",
|
||||
milvus_text_field="book_intro_text",
|
||||
milvus_db_name="tenant_a_db",
|
||||
milvus_partition_names=["tenant_a_partition"],
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
request_data = self._extract_request_body(mock_post)
|
||||
assert request_data is not None
|
||||
assert request_data["dbName"] == "tenant_a_db"
|
||||
assert request_data["partitionNames"] == ["tenant_a_partition"]
|
||||
|
||||
def test_user_params_cannot_override_backend_db_and_partition(self):
|
||||
"""Backend-config dbName/partitionNames must win over user-supplied values."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = MOCK_MILVUS_SEARCH_RESPONSE
|
||||
mock_response.text = json.dumps(MOCK_MILVUS_SEARCH_RESPONSE)
|
||||
|
||||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
vector_store_search(
|
||||
query="what is machine learning?",
|
||||
vector_store_id="book_2",
|
||||
custom_llm_provider="milvus",
|
||||
api_base="https://in03-test.serverless.aws-eu-central-1.cloud.zilliz.com",
|
||||
api_key="mock_milvus_api_key",
|
||||
litellm_embedding_model="text-embedding-3-large",
|
||||
litellm_embedding_config={
|
||||
"api_key": "mock_openai_api_key",
|
||||
},
|
||||
outputFields=["book_intro_text"],
|
||||
annsField="book_intro_vector",
|
||||
milvus_text_field="book_intro_text",
|
||||
milvus_db_name="tenant_a_db",
|
||||
milvus_partition_names=["tenant_a_partition"],
|
||||
dbName="other_tenant_db",
|
||||
partitionNames=["other_tenant_partition"],
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
request_data = self._extract_request_body(mock_post)
|
||||
assert request_data is not None
|
||||
assert request_data["dbName"] == "tenant_a_db"
|
||||
assert request_data["partitionNames"] == ["tenant_a_partition"]
|
||||
|
||||
|
||||
# @pytest.mark.parametrize("sync_mode", [True, False])
|
||||
# @pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user