Merge branch 'litellm_internal_staging' into feature/improve-gigachat-provider

This commit is contained in:
KnyazSh 2026-07-23 18:27:55 +03:00
commit b1a778cb86
137 changed files with 11429 additions and 2960 deletions

View file

@ -9,6 +9,7 @@ on:
- "litellm_**"
paths:
- docker/Dockerfile.non_root
- tests/proxy_migration_tests/test_offline_image_migration.py
- uv.lock
- ui/litellm-dashboard/package-lock.json
- .github/workflows/image-scan.yml
@ -51,6 +52,23 @@ jobs:
- name: Build runtime image
run: docker build -f docker/Dockerfile.non_root -t litellm-image-scan:${{ github.sha }} .
# The prisma bake must migrate a fresh DB with no egress as an arbitrary
# non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyRootFilesystem).
# `docker run` as the default uid with network hides a broken bake because
# the migration entrypoint exits 0 even when it applied nothing; asserting
# the schema was created is what catches it.
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Verify offline migration as a non-root uid
env:
LITELLM_IMAGE: litellm-image-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py -v
# Scans the whole shipped artifact: OS/apk plus every language package
# baked into the image, including ones no lockfile declares (e.g. prisma's
# vendored node engine) that osv-scan cannot see. osv-scan stays the fast

View file

@ -19,7 +19,7 @@ concurrency:
jobs:
ui-unit-tests:
runs-on: ubuntu-latest
runs-on: ubuntu-latest-16-cores
timeout-minutes: 20
defaults:
run:
@ -50,8 +50,8 @@ jobs:
if [ -n "$BASE_SHA" ]; then
echo "Pull request: running only tests related to changes since $BASE_SHA"
npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \
--pool forks --poolOptions.forks.maxForks=4
--pool forks --poolOptions.forks.maxForks=14
else
echo "Push to $GITHUB_REF_NAME: running the full suite"
npm run test -- --run --pool forks --poolOptions.forks.maxForks=4
npm run test -- --run --pool forks --poolOptions.forks.maxForks=14
fi

View file

@ -46,6 +46,7 @@ jobs:
tests/test_litellm/proxy/rag_endpoints
tests/test_litellm/proxy/realtime_endpoints
tests/test_litellm/proxy/ui_crud_endpoints
tests/test_litellm/proxy/config_resolvers
tests/test_litellm/proxy/utils
workers: 2
reruns: 2

View file

@ -18,6 +18,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/team/",
"/v2/team/",
"/organization/",
"/v2/organization/",
"/customer/",
"/end_user/",
"/sso/",

View file

@ -54,7 +54,6 @@ ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
UV_LINK_MODE=copy \
PATH="/app/.venv/bin:${PATH}" \
LITELLM_NON_ROOT=true \
PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \
XDG_CACHE_HOME=/app/.cache
# Copy dependency metadata first for layer caching
@ -106,7 +105,9 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
--python python3; \
fi
RUN prisma generate --schema=./schema.prisma
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \
prisma generate --schema=./schema.prisma
RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \
sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
@ -127,8 +128,6 @@ RUN for i in 1 2 3; do \
# the rest of the builder's /app is source and build metadata that must not
# ship (manifest-scanning tools attribute everything in it to this image).
# entrypoint.sh invokes litellm/proxy/prisma_migration.py by source path.
# Prisma caches live under /app/.cache here (XDG_CACHE_HOME /
# PRISMA_BINARY_CACHE_DIR) so the runtime prisma generate finds them.
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/docker /app/docker
COPY --from=builder /app/schema.prisma /app/schema.prisma
@ -138,21 +137,35 @@ COPY --from=builder /app/litellm/proxy/prisma_migration.py /app/litellm/proxy/pr
# enterprise.enterprise_hooks from it)
COPY --from=builder /app/enterprise /app/enterprise
COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras
COPY --from=builder /app/.cache /app/.cache
# Prisma CLI + engines are baked under /opt/prisma, a fixed path every runtime
# uid can read and that no cache volume mount shadows (unlike /app/.cache or
# $HOME/.cache under readOnlyRootFilesystem + emptyDir or arbitrary-uid setups).
# PRISMA_CLI_QUERY_ENGINE_TYPE=binary makes the CLI use the baked binary query
# engine directly, so `prisma migrate deploy` on a fresh database needs no npm
# and no network access; without it the CLI looks for the library engine, which
# prisma stopped baking, and falls back to a download that fails offline or as a
# non-writable uid (#33650, #24554).
COPY --from=builder /opt/prisma /opt/prisma
COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui
COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets
# XDG_CACHE_HOME is intentionally left unset so it falls back to $HOME/.cache
# (/app/.cache, writable by the runtime uid). The prisma bake at the read-only
# /opt/prisma is anchored by PRISMA_BINARY_CACHE_DIR / PRISMA_CLI_PATH, so
# nothing needs XDG to point there; pointing it at the read-only bake would
# deny any XDG-aware library that writes a cache at runtime.
ENV PATH="/app/.venv/bin:${PATH}" \
PRISMA_BINARY_CACHE_DIR=/app/.cache/prisma-python/binaries \
PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
PRISMA_CLI_PATH=/opt/prisma/binaries/node_modules/.bin/prisma \
PRISMA_CLI_QUERY_ENGINE_TYPE=binary \
HOME=/app \
LITELLM_NON_ROOT=true \
XDG_CACHE_HOME=/app/.cache \
PRISMA_SKIP_POSTINSTALL_GENERATE=1 \
PRISMA_HIDE_UPDATE_MESSAGE=1 \
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 \
PRISMA_OFFLINE_MODE=true
RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \
RUN mkdir -p /nonexistent /app/.cache /var/lib/litellm/assets /var/lib/litellm/ui && \
chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent && \
PRISMA_PATH=$(python -c "import os, prisma; print(os.path.dirname(prisma.__file__))") && \
chown -R nobody:nogroup "$PRISMA_PATH" && \
@ -165,12 +178,14 @@ RUN mkdir -p /nonexistent /var/lib/litellm/assets /var/lib/litellm/ui && \
[ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g=u "$LITELLM_PROXY_EXTRAS_PATH" || true && \
chmod -R g+w "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \
[ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w "$LITELLM_PROXY_EXTRAS_PATH" || true && \
chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets /app/.cache
chmod -R g+rX "$PRISMA_PATH" /var/lib/litellm/ui /var/lib/litellm/assets && \
chmod -R a+rX /opt/prisma && \
test -x /opt/prisma/binaries/node_modules/.bin/prisma && \
test -f /opt/prisma/binaries/node_modules/prisma/build/index.js && \
ls /opt/prisma/binaries/node_modules/@prisma/engines/query-engine-* >/dev/null 2>&1
USER 65534
RUN prisma generate --schema=./schema.prisma
EXPOSE 4000/tcp
ENTRYPOINT ["/app/docker/prod_entrypoint.sh"]

View file

@ -9,9 +9,22 @@ duration_in_seconds is used in diff parts of the code base, example
import re
import time as time_module
from datetime import datetime, time, timedelta, timezone, tzinfo
from typing import Optional, Tuple
from typing import Final, Optional, Tuple
from zoneinfo import ZoneInfo
from litellm._logging import verbose_logger
_BUDGET_DURATION_WORD_ALIASES: Final[dict[str, str]] = {
"hourly": "1h",
"daily": "24h",
"weekly": "7d",
"monthly": "30d",
}
def _normalize_duration(duration: str) -> str:
return _BUDGET_DURATION_WORD_ALIASES.get(duration.strip().lower(), duration)
def _extract_from_regex(duration: str) -> Tuple[int, str]:
match = re.match(r"(\d+)(mo|[smhdw]?)", duration)
@ -48,7 +61,7 @@ def duration_in_seconds(duration: str) -> int:
Returns time in seconds till when budget needs to be reset
"""
value, unit = _extract_from_regex(duration=duration)
value, unit = _extract_from_regex(duration=_normalize_duration(duration))
if unit == "s":
return value
@ -124,9 +137,13 @@ def get_next_standardized_reset_time(
current_time, _ = _setup_timezone(current_time, timezone_str)
# Parse duration
value, unit = _parse_duration(duration)
value, unit = _parse_duration(_normalize_duration(duration))
if value is None:
# Fall back to default if format is invalid
verbose_logger.warning(
"Unrecognized budget_duration %r; falling back to a next-midnight reset. "
"Use the <int><unit> format (e.g. '1h', '7d', '30d', '1mo').",
duration,
)
return current_time.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
# Midnight of the current day in the specified timezone

View file

@ -480,10 +480,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
"""
Filter out unsupported fields from JSON schema for Anthropic's output_format API.
Anthropic's output_format doesn't support certain JSON schema properties:
- maxItems/minItems: Not supported for array types
- minimum/maximum: Not supported for numeric types
- minLength/maxLength: Not supported for string types
Anthropic's output_format doesn't support certain JSON schema properties.
These are constraints that cannot be enforced by the constrained-decoding
grammar Anthropic compiles the schema into, so the API rejects them with a
400 ``invalid_request_error`` (e.g. "output_format.schema: For 'array' type,
property 'uniqueItems' is not supported"):
- maxItems/minItems/uniqueItems/contains/minContains/maxContains/prefixItems: array constraints
- minimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf: numeric constraints
- minLength/maxLength: string constraints
- minProperties/maxProperties/patternProperties/propertyNames: object constraints
- dependentRequired/dependentSchemas/unevaluatedProperties: object constraints
- if/then/else/not: conditional and negation keywords
``oneOf`` is also rejected ("Schema type 'oneOf' is not supported") and is
rewritten to ``anyOf``, matching the Anthropic SDK. Unknown keywords are
ignored by the API, so anything not listed here passes through untouched.
This mirrors the transformation done by the Anthropic Python SDK.
See: https://platform.claude.com/docs/en/build-with-claude/structured-outputs#how-sdk-transformation-works
@ -504,33 +515,53 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
if not isinstance(schema, dict):
return schema
# All numeric/string/array constraints not supported by Anthropic
unsupported_fields = {
"maxItems",
"minItems", # array constraints
"minimum",
"maximum", # numeric constraints
"exclusiveMinimum",
"exclusiveMaximum", # numeric constraints
"minLength",
"maxLength", # string constraints
}
# Build description additions from removed constraints
constraint_descriptions: list = []
constraint_labels = {
"minItems": "minimum number of items: {}",
"maxItems": "maximum number of items: {}",
"uniqueItems": "all array items must be unique",
"contains": "array must contain an item matching: {}",
"minContains": "minimum number of matching items: {}",
"maxContains": "maximum number of matching items: {}",
"prefixItems": "leading items must match, in order: {}",
"minimum": "minimum value: {}",
"maximum": "maximum value: {}",
"exclusiveMinimum": "exclusive minimum value: {}",
"exclusiveMaximum": "exclusive maximum value: {}",
"multipleOf": "must be a multiple of {}",
"minLength": "minimum length: {}",
"maxLength": "maximum length: {}",
"minProperties": "minimum number of properties: {}",
"maxProperties": "maximum number of properties: {}",
"patternProperties": "properties whose names match each pattern must satisfy: {}",
"propertyNames": "property names must satisfy: {}",
"dependentRequired": "dependent required properties: {}",
"dependentSchemas": "dependent schemas: {}",
"unevaluatedProperties": "unevaluated properties must satisfy: {}",
"if": "conditional (if): {}",
"then": "conditional (then): {}",
"else": "conditional (else): {}",
"not": "must not match: {}",
}
for field in unsupported_fields:
if field in schema:
constraint_descriptions.append(constraint_labels[field].format(schema[field]))
unsupported_fields = set(constraint_labels)
# Build description additions from removed constraints. Iterating
# constraint_labels (not the set) keeps the note order deterministic across
# processes, so identical requests serialize identically regardless of
# PYTHONHASHSEED and stay cache-friendly.
constraint_descriptions: list = []
for field, label in constraint_labels.items():
if field not in schema:
continue
value = schema[field]
# A falsy boolean constraint (e.g. ``uniqueItems: false``) imposes no
# real requirement, so don't add a misleading advisory note for it.
if isinstance(value, bool) and not value:
continue
# Sub-schema constraints (e.g. ``contains``) are serialized as JSON so
# the advisory note preserves what the constraint actually required,
# instead of just noting that it existed.
note_value = json.dumps(value) if isinstance(value, (dict, list)) else value
constraint_descriptions.append(label.format(note_value))
result: Dict[str, Any] = {}
@ -557,11 +588,17 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
elif key == "$defs" and isinstance(value, dict):
result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in value.items()}
elif key == "anyOf" and isinstance(value, list):
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
result["anyOf"] = result.get("anyOf", []) + [
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
]
elif key == "allOf" and isinstance(value, list):
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
elif key == "oneOf" and isinstance(value, list):
result[key] = [AnthropicConfig.filter_anthropic_output_schema(item) for item in value]
# Anthropic rejects oneOf ("Schema type 'oneOf' is not supported");
# the Anthropic SDK rewrites it to anyOf, so do the same.
result["anyOf"] = result.get("anyOf", []) + [
AnthropicConfig.filter_anthropic_output_schema(item) for item in value
]
else:
result[key] = value

View file

@ -895,9 +895,8 @@ class AmazonConverseConfig(BaseConfig):
if _tool_choice_value is not None:
optional_params["tool_choice"] = _tool_choice_value
if param == "parallel_tool_calls":
disable_parallel = not value
optional_params["_parallel_tool_use_config"] = {
"tool_choice": {"disable_parallel_tool_use": disable_parallel}
"tool_choice": {"type": "auto", "disable_parallel_tool_use": not value}
}
if param == "thinking":
if (
@ -1208,6 +1207,22 @@ class AmazonConverseConfig(BaseConfig):
return {}
@staticmethod
def _merge_parallel_tool_use_config(additional_request_params: dict, parallel_tool_use_config: dict) -> dict:
merged_entries = {
key: (
{
**value,
**additional_request_params[key],
**{k: v for k, v in value.items() if k != "type"},
}
if isinstance(additional_request_params.get(key), dict) and isinstance(value, dict)
else value
)
for key, value in parallel_tool_use_config.items()
}
return {**additional_request_params, **merged_entries}
def _prepare_request_params(
self, optional_params: dict, model: str, drop_params: bool = False
) -> Tuple[dict, dict, dict, Optional[OutputConfigBlock]]:
@ -1276,15 +1291,9 @@ class AmazonConverseConfig(BaseConfig):
# Handle parallel_tool_calls configuration
parallel_tool_use_config = additional_request_params.pop("_parallel_tool_use_config", None)
if parallel_tool_use_config is not None and bedrock_converse_supports_parallel_tool_use_config(model):
for key, value in parallel_tool_use_config.items():
if (
key in additional_request_params
and isinstance(additional_request_params[key], dict)
and isinstance(value, dict)
):
additional_request_params[key].update(value)
else:
additional_request_params[key] = value
additional_request_params = self._merge_parallel_tool_use_config(
additional_request_params, parallel_tool_use_config
)
additional_request_params.pop("parallel_tool_calls", None)

View file

@ -143,7 +143,7 @@ class SagemakerChatConfig(OpenAIGPTConfig, BaseAWSLLM):
raise SagemakerError(status_code=response.status_code, message=response.text)
custom_stream_decoder = AWSEventStreamDecoder(model="", is_messages_api=True)
completion_stream = custom_stream_decoder.iter_bytes(response.iter_bytes(chunk_size=1024))
completion_stream = custom_stream_decoder.iter_bytes(response.iter_bytes())
streaming_response = CustomStreamWrapper(
completion_stream=completion_stream,
@ -189,7 +189,7 @@ class SagemakerChatConfig(OpenAIGPTConfig, BaseAWSLLM):
raise SagemakerError(status_code=response.status_code, message=response.text)
custom_stream_decoder = AWSEventStreamDecoder(model="", is_messages_api=True)
completion_stream = custom_stream_decoder.aiter_bytes(response.aiter_bytes(chunk_size=1024))
completion_stream = custom_stream_decoder.aiter_bytes(response.aiter_bytes())
streaming_response = CustomStreamWrapper(
completion_stream=completion_stream,

View file

@ -200,23 +200,12 @@ class SagemakerLLM(BaseAWSLLM):
# Add model_id as InferenceComponentName header
# boto3 doc: https://docs.aws.amazon.com/sagemaker/latest/APIReference/API_runtime_InvokeEndpoint.html
prepared_request.headers.update({"X-Amzn-SageMaker-Inference-Component": model_id})
sync_handler = _get_httpx_client()
sync_response = sync_handler.post(
url=prepared_request.url,
completion_stream = self.make_sync_call(
api_base=prepared_request.url,
headers=prepared_request.headers, # type: ignore
data=prepared_request.body,
stream=stream,
data=cast(str, prepared_request.body), # cast-ok: signed body is a JSON str, mirrors async path
logging_obj=logging_obj,
)
if sync_response.status_code != 200:
raise SagemakerError(
status_code=sync_response.status_code,
message=str(sync_response.read()),
)
decoder = AWSEventStreamDecoder(model="")
completion_stream = decoder.iter_bytes(sync_response.iter_bytes(chunk_size=1024))
streaming_response = CustomStreamWrapper(
completion_stream=completion_stream,
model=model,
@ -334,6 +323,29 @@ class SagemakerLLM(BaseAWSLLM):
litellm_params=litellm_params,
)
def make_sync_call(
self,
api_base: str,
headers: dict,
data: str,
logging_obj,
client=None,
):
if client is None:
client = _get_httpx_client()
sync_response = client.post(
api_base,
headers=headers,
data=data,
stream=True,
)
if sync_response.status_code != 200:
raise SagemakerError(status_code=sync_response.status_code, message=str(sync_response.read()))
decoder = AWSEventStreamDecoder(model="")
return decoder.iter_bytes(sync_response.iter_bytes())
async def make_async_call(
self,
api_base: str,
@ -358,7 +370,7 @@ class SagemakerLLM(BaseAWSLLM):
raise SagemakerError(status_code=response.status_code, message=response.text)
decoder = AWSEventStreamDecoder(model="")
completion_stream = decoder.aiter_bytes(response.aiter_bytes(chunk_size=1024))
completion_stream = decoder.aiter_bytes(response.aiter_bytes())
return completion_stream

View file

@ -3,6 +3,7 @@ import html as _html
import json
import secrets
import time
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
@ -13,6 +14,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError
from litellm._logging import verbose_logger
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
@ -20,7 +22,9 @@ from litellm.llms.custom_httpx.http_handler import (
from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
TokenEndpointAuthConfigError,
build_token_endpoint_client_auth,
normalize_token_endpoint_auth_method,
)
from litellm.types.mcp_server.mcp_server_manager import MCPTokenEndpointAuthMethod
from litellm.proxy._experimental.mcp_server.bridge_token_flow import (
_bridge_mint_error_response,
_BridgeMintReady,
@ -111,6 +115,9 @@ def encode_state_with_base_url(
client_redirect_uri: Optional[str] = None,
litellm_user_id: str | None = None,
mcp_server_id: str | None = None,
dcr_client_id: str | None = None,
dcr_client_secret: str | None = None,
dcr_token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None,
) -> str:
"""
Encode the base_url, original state, and PKCE parameters using encryption.
@ -124,8 +131,18 @@ def encode_state_with_base_url(
litellm_user_id: The SSO-authenticated litellm user captured at the bridge authorize
(interactive dcr_bridge oauth_delegate only); the callback seals it into the gateway
authorization code so the token mint can bind the envelope to this user
mcp_server_id: The bridge server the interactive flow targets, sealed alongside
litellm_user_id so the gateway code cannot be replayed against another server
mcp_server_id: The server the flow targets, sealed alongside litellm_user_id (bridge) or
dcr_client_id (ephemeral mint) so the gateway code cannot be replayed against another
server
dcr_client_id: The ephemeral DCR client the gateway minted at authorize for a
client-forwarded-token server with no caller-supplied client; the callback seals it
into the forwarded authorization code so the token exchange can authenticate with it
while the gateway stores nothing
dcr_client_secret: The minted client's secret, when the upstream issued one
dcr_token_endpoint_auth_method: The token-endpoint auth method the upstream's registration
response granted the minted client, sealed alongside the credentials so the exchange
authenticates the way the upstream expects instead of falling back to the server row's
configured method
Returns:
An encrypted string that encodes all values
@ -138,6 +155,9 @@ def encode_state_with_base_url(
"client_redirect_uri": client_redirect_uri,
"litellm_user_id": litellm_user_id,
"mcp_server_id": mcp_server_id,
"dcr_client_id": dcr_client_id,
"dcr_client_secret": dcr_client_secret,
"dcr_token_endpoint_auth_method": dcr_token_endpoint_auth_method,
}
state_json = json.dumps(state_data, sort_keys=True)
encrypted_state = encrypt_value_helper(state_json)
@ -217,6 +237,93 @@ def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None
return None
_PASSTHROUGH_AUTH_CODE_PREFIX = "llm_ptcode_"
class PassthroughAuthorizationCode(BaseModel):
"""The ephemeral DCR client and upstream code the gateway seals into the authorization code it
forwards for a client-forwarded-token server (``true_passthrough`` / ``oauth_delegate``) whose
authorize fell through to gateway-side registration. These modes forbid the gateway from storing
an OAuth client identity, so the minted client survives only inside this sealed value: the
client echoes it back at the token endpoint, where the gateway recovers the client to
authenticate the upstream exchange. ``mcp_server_id`` binds the code to the server it was minted
for so it cannot be spent at another server's token endpoint."""
model_config = ConfigDict(frozen=True)
upstream_code: str = Field(min_length=1)
client_id: str = Field(min_length=1)
client_secret: str | None = None
token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None
mcp_server_id: str = Field(min_length=1)
def seal_passthrough_authorization_code(
upstream_code: str,
client_id: str,
client_secret: str | None,
mcp_server_id: str,
token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None,
) -> str:
"""Seal the upstream authorization code together with the ephemeral DCR client that authorized
it. Encrypted with the same authenticated symmetric helper as the OAuth state and bridge codes,
so the client can neither read the (possibly confidential) client credentials nor forge a
code."""
payload = json.dumps(
{
"upstream_code": upstream_code,
"client_id": client_id,
"client_secret": client_secret,
"token_endpoint_auth_method": token_endpoint_auth_method,
"mcp_server_id": mcp_server_id,
},
sort_keys=True,
)
return _PASSTHROUGH_AUTH_CODE_PREFIX + encrypt_value_helper(payload)
def open_passthrough_authorization_code(code: str) -> PassthroughAuthorizationCode | None:
"""Recover the sealed ephemeral client and upstream code, or ``None`` when ``code`` is not a
gateway passthrough code or does not decrypt / validate, so a raw upstream code falls through to
the existing caller-supplied-client behavior."""
if not code.startswith(_PASSTHROUGH_AUTH_CODE_PREFIX):
return None
decrypted = decrypt_value_helper(
code[len(_PASSTHROUGH_AUTH_CODE_PREFIX) :], "passthrough_authorization_code", return_original_value=False
)
if not isinstance(decrypted, str):
return None
try:
return PassthroughAuthorizationCode.model_validate_json(decrypted)
except ValidationError:
return None
def redeem_passthrough_authorization_code(
code: str | None, mcp_server: MCPServer, code_verifier: str | None
) -> PassthroughAuthorizationCode | None:
"""The single redemption gate for sealed passthrough codes: a raw or foreign code returns
``None`` so the caller keeps its existing behavior, while a genuine sealed code must be spent
at the server it was minted for and must carry the PKCE verifier of the S256 flow that minted
it (the mint refuses downgraded flows, so a verifier-less redemption is an interception
attempt, not a legitimate client)."""
if not code:
return None
sealed = open_passthrough_authorization_code(code)
if sealed is None:
return None
if sealed.mcp_server_id != mcp_server.server_id:
raise HTTPException(
status_code=400,
detail="Authorization code was issued for a different MCP server",
)
if not code_verifier:
raise HTTPException(
status_code=400,
detail="code_verifier is required to redeem this authorization code",
)
return sealed
def _redirect_to_litellm_login(request: Request) -> RedirectResponse:
"""Send an unauthenticated browser through litellm login before the interactive bridge authorize
can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code,
@ -594,6 +701,7 @@ async def authorize_with_server(
code_challenge_method: Optional[str] = None,
response_type: Optional[str] = None,
scope: Optional[str] = None,
ephemeral_dcr_client: "EphemeralDcrClient | None" = None,
):
_raise_if_not_oauth2(mcp_server)
if mcp_server.authorization_url is None:
@ -612,7 +720,10 @@ async def authorize_with_server(
# calling this for its enforcement side effect, then falls through to the gateway
# /callback flow below, which reads the original code_challenge names.
bridge_challenge, bridge_method = _require_s256_pkce(code_challenge, code_challenge_method)
if _dcr_bridge_relays_client_registration(mcp_server):
# A gateway-minted ephemeral client is registered against {base}/callback, so its
# flow must run the short-circuit arm; the relay arm is only for clients that
# registered themselves through the front door and hold their own redirect binding.
if _dcr_bridge_relays_client_registration(mcp_server) and ephemeral_dcr_client is None:
return _redirect_to_upstream_authorize(
mcp_server=mcp_server,
client_id=client_id,
@ -656,7 +767,12 @@ async def authorize_with_server(
code_challenge_method=code_challenge_method,
client_redirect_uri=redirect_uri,
litellm_user_id=litellm_user_id,
mcp_server_id=mcp_server.server_id if litellm_user_id else None,
mcp_server_id=mcp_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None,
dcr_client_id=ephemeral_dcr_client.client_id if ephemeral_dcr_client else None,
dcr_client_secret=ephemeral_dcr_client.client_secret if ephemeral_dcr_client else None,
dcr_token_endpoint_auth_method=ephemeral_dcr_client.token_endpoint_auth_method
if ephemeral_dcr_client
else None,
)
relay_state = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES)
@ -703,6 +819,7 @@ async def exchange_token_with_server(
code_verifier: Optional[str],
refresh_token: Optional[str] = None,
scope: Optional[str] = None,
client_token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None,
):
_raise_if_not_oauth2(mcp_server)
if grant_type not in ("authorization_code", "refresh_token"):
@ -718,15 +835,24 @@ async def exchange_token_with_server(
),
)
# The id and secret must come from the same source. When the server-side client_id wins,
# falling back to the caller's secret pairs the persisted client with a foreign secret; the
# register short-circuit hands clients a placeholder secret ("dummy"), so a re-auth against a
# persisted public PKCE client (no stored secret) would send that placeholder and the IdP 401s.
# The id, secret, and token-endpoint auth method must come from the same source. When the
# server-side client_id wins, falling back to the caller's secret pairs the persisted client
# with a foreign secret; the register short-circuit hands clients a placeholder secret
# ("dummy"), so a re-auth against a persisted public PKCE client (no stored secret) would send
# that placeholder and the IdP 401s. Symmetrically, a caller-side client (an ephemeral mint
# recovered from a sealed code) must authenticate the way its own registration was granted,
# not the way the server row is configured; callers that carry no method keep the row's method
# as before.
resolved_client_id = mcp_server.client_id if mcp_server.client_id else client_id
resolved_client_secret = mcp_server.client_secret if mcp_server.client_id else client_secret
resolved_auth_method = (
mcp_server.token_endpoint_auth_method
if mcp_server.client_id
else (client_token_endpoint_auth_method or mcp_server.token_endpoint_auth_method)
)
try:
client_auth = build_token_endpoint_client_auth(
auth_method=mcp_server.token_endpoint_auth_method,
auth_method=resolved_auth_method,
client_id=resolved_client_id,
client_secret=resolved_client_secret,
)
@ -1229,7 +1355,7 @@ async def _persist_dcr_client_registration(
return "failed"
def _client_supplied_redirect_uris(value: object) -> list[str] | None:
def client_supplied_redirect_uris(value: object) -> list[str] | None:
"""RFC 7591 redirect_uris must be a non-empty array of URI strings. Any other shape (not a list,
an empty list, or a list holding a non-string or empty-string element) yields None so every
register arm falls back to the gateway callback instead of echoing a malformed value back to the
@ -1241,6 +1367,142 @@ def _client_supplied_redirect_uris(value: object) -> list[str] | None:
return uris if len(uris) == len(value) else None
async def _post_dcr_registration(
registration_url: str,
register_data: Mapping[str, object],
server_id: str,
) -> httpx.Response:
"""POST an RFC 7591 registration to the upstream and return its response, relaying a classified
upstream rejection instead of a generic 500 and failing loud on an absent response."""
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register)
try:
response = await async_client.post(
registration_url,
headers=headers,
json=register_data,
)
if response is not None:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
status_code, detail = dcr_fault_detail(classify_upstream_dcr_rejection(exc.response, log_context=server_id))
raise HTTPException(status_code=status_code, detail=detail) from exc
if response is None:
raise HTTPException(
status_code=502,
detail="MCP upstream registration endpoint returned no response",
)
return response
class EphemeralDcrClient(BaseModel):
"""A DCR client minted for a single authorize round trip and never stored by the gateway."""
model_config = ConfigDict(frozen=True)
client_id: str = Field(min_length=1)
client_secret: str | None = None
token_endpoint_auth_method: MCPTokenEndpointAuthMethod | None = None
_EPHEMERAL_DCR_CLIENT_CACHE = InMemoryCache(default_ttl=_OAUTH_STATE_COOKIE_TTL_SECONDS)
_EPHEMERAL_DCR_MINT_LOCKS: dict[str, asyncio.Lock] = {}
async def mint_ephemeral_dcr_client(request: Request, mcp_server: MCPServer) -> EphemeralDcrClient | None:
"""Mint a throwaway OAuth client via the upstream's RFC 7591 registration endpoint for a
client-forwarded-token server whose authorize arrived with no client_id. Returns ``None`` when
the upstream exposes no registration endpoint, so the caller keeps its existing failure path.
The minted client is deliberately not persisted anywhere: ``true_passthrough`` /
``oauth_delegate`` require the gateway to hold no OAuth client identity, so it survives only in
the encrypted OAuth state and the sealed authorization code the callback forwards.
Reloading the authorize page or retrying a flow must not register a fresh upstream client every
time (an OAuth client identifies the application, not the user, so reuse is semantically
correct). A per-process TTL cache bounded to the OAuth state cookie's lifetime dedupes the mint
per (server, gateway origin), and a per-server lock single-flights concurrent mints (the
``_OAUTH_METADATA_FETCH_LOCKS`` pattern; keyed by server_id alone so the lock registry stays
bounded by the server count even when the request origin varies) so parallel authorize requests
cannot each register an upstream client; the cache stamps nothing onto the server record and
correctness never depends on it because the sealed state carries the client through the flow."""
if mcp_server.registration_url is None:
return None
request_base_url = get_request_base_url(request)
cache_key = f"mcp_ephemeral_dcr_client:{mcp_server.server_id}:{request_base_url}"
cached = _EPHEMERAL_DCR_CLIENT_CACHE.get_cache(cache_key)
if isinstance(cached, EphemeralDcrClient):
return cached
lock = _EPHEMERAL_DCR_MINT_LOCKS.setdefault(mcp_server.server_id, asyncio.Lock())
async with lock:
cached_after_wait = _EPHEMERAL_DCR_CLIENT_CACHE.get_cache(cache_key)
if isinstance(cached_after_wait, EphemeralDcrClient):
return cached_after_wait
register_data: dict[str, object] = {
"client_name": mcp_server.server_name or mcp_server.server_id,
"redirect_uris": [f"{request_base_url}/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}
response = await _post_dcr_registration(
registration_url=mcp_server.registration_url,
register_data=register_data,
server_id=mcp_server.server_id,
)
try:
registration = _DcrClientRegistration.model_validate_json(response.text)
except ValidationError as exc:
raise HTTPException(
status_code=502,
detail="MCP upstream registration endpoint returned no usable client_id",
) from exc
if not registration.client_id:
raise HTTPException(
status_code=502,
detail="MCP upstream registration endpoint returned no usable client_id",
)
minted = EphemeralDcrClient(
client_id=registration.client_id,
client_secret=registration.client_secret,
token_endpoint_auth_method=normalize_token_endpoint_auth_method(registration.token_endpoint_auth_method),
)
_EPHEMERAL_DCR_CLIENT_CACHE.set_cache(cache_key, minted)
return minted
async def resolve_ephemeral_dcr_client(
request: Request,
mcp_server: MCPServer,
code_challenge: str | None,
code_challenge_method: str | None,
redirect_uri: str,
) -> EphemeralDcrClient | None:
"""The single owner of the gateway-side mint policy for a clientless authorize. Returns
``None`` for servers whose mode does not permit gateway minting and for upstreams without a
registration endpoint, so those callers keep their existing failure paths: plain ``oauth2``
keeps its persisted-client contract, and the interactive ``oauth_delegate`` dcr_bridge
sign-in has its own sealed-identity flow. ``true_passthrough`` mints regardless of the
``dcr_bridge`` flag (the UI creates passthrough servers with the flag on by default): a
minted flow runs the bridge short-circuit arm, while the relay front door remains for
external clients that registered themselves. Flows that could never succeed fail loud
before any upstream registration: a missing ``authorization_url``, a downgraded PKCE pair
(without S256 the sealed code would be bearer-redeemable by any authenticated caller who
intercepts the redirect), or an untrusted ``redirect_uri`` (a rejected redirect must not be
usable to generate orphan IdP clients)."""
if not (mcp_server.is_true_passthrough or (mcp_server.is_oauth_delegate and not mcp_server.is_dcr_bridge)):
return None
if mcp_server.authorization_url is None:
raise HTTPException(
status_code=400,
detail="MCP server authorization url is not set",
)
_require_s256_pkce(code_challenge, code_challenge_method)
validate_trusted_redirect_uri(request, redirect_uri)
return await mint_ephemeral_dcr_client(request, mcp_server)
async def register_client_with_server(
request: Request,
mcp_server: MCPServer,
@ -1302,30 +1564,11 @@ async def register_client_with_server(
"response_types": response_types or (["code"] if bridge_relay else []),
"token_endpoint_auth_method": token_endpoint_auth_method or ("none" if bridge_relay else ""),
}
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
async_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Register)
try:
response = await async_client.post(
mcp_server.registration_url,
headers=headers,
json=register_data,
)
if response is not None:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
status_code, detail = dcr_fault_detail(
classify_upstream_dcr_rejection(exc.response, log_context=mcp_server.server_id)
)
raise HTTPException(status_code=status_code, detail=detail) from exc
if response is None:
raise HTTPException(
status_code=502,
detail="MCP upstream registration endpoint returned no response",
)
response = await _post_dcr_registration(
registration_url=mcp_server.registration_url,
register_data=register_data,
server_id=mcp_server.server_id,
)
token_response = response.json()
@ -1563,11 +1806,23 @@ async def callback(
# envelope to this user. Every other flow forwards the raw code unchanged.
litellm_user_id = state_data.get("litellm_user_id")
mcp_server_id = state_data.get("mcp_server_id")
dcr_client_id = state_data.get("dcr_client_id")
dcr_client_secret = state_data.get("dcr_client_secret")
forwarded_code = code
if isinstance(litellm_user_id, str) and litellm_user_id and isinstance(mcp_server_id, str) and mcp_server_id:
forwarded_code = seal_bridge_authorization_code(
upstream_code=code, litellm_user_id=litellm_user_id, mcp_server_id=mcp_server_id
)
elif isinstance(dcr_client_id, str) and dcr_client_id and isinstance(mcp_server_id, str) and mcp_server_id:
forwarded_code = seal_passthrough_authorization_code(
upstream_code=code,
client_id=dcr_client_id,
client_secret=dcr_client_secret if isinstance(dcr_client_secret, str) and dcr_client_secret else None,
mcp_server_id=mcp_server_id,
token_endpoint_auth_method=normalize_token_endpoint_auth_method(
state_data.get("dcr_token_endpoint_auth_method")
),
)
params = {"code": forwarded_code, "state": original_state}
complete_returned_url = _append_query_params(redirect_uri, params)
@ -2158,7 +2413,7 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non
request_data = await _read_request_body(request=request)
data: dict = {**request_data}
client_redirect_uris = _client_supplied_redirect_uris(data.get("redirect_uris"))
client_redirect_uris = client_supplied_redirect_uris(data.get("redirect_uris"))
dummy_return = {
"client_id": mcp_server_name or "dummy_client",

View file

@ -1857,6 +1857,17 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
default_team_member_models: Optional[List[str]] = None # default allowed_models seeded onto new team members
class PatchTeamRequest(UpdateTeamRequest):
"""
Body of PATCH /team/{team_id}.
Identical to UpdateTeamRequest except team_id is optional, because PATCH takes it
from the path. A team_id in the body is still accepted when it matches the path.
"""
team_id: str | None = None
class ResetTeamBudgetRequest(LiteLLMPydanticObjectBase):
"""
internal type used to reset the budget on a team
@ -2768,6 +2779,30 @@ class LiteLLM_OrganizationTableUpdate(LiteLLM_BudgetTable):
return values
class OrganizationUpdateRequestV2(LiteLLMPydanticObjectBase):
"""
Typed PATCH body for ``/v2/organization/{organization_id}`` (RFC 7396 merge-patch).
Presence is read from ``model_fields_set``, so a sent field is written and an omitted one is
left untouched. ``extra="forbid"`` makes an unknown key a 422 rather than a silent no-op, since
the contract hinges on which keys are present. See the endpoint for the per-field clear tokens.
"""
model_config = ConfigDict(extra="forbid")
organization_alias: str | None = None
models: list[str] | None = None
metadata: dict | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
max_budget: float | None = None
soft_budget: float | None = None
max_parallel_requests: int | None = None
model_max_budget: dict | None = None
budget_duration: str | None = None
object_permission: LiteLLM_ObjectPermissionBase | None = None
from litellm.models.organization import ( # noqa: E402
LiteLLM_OrganizationTable as LiteLLM_OrganizationTable,
)

View file

@ -523,7 +523,7 @@ export LITELLM_PROXY_API_KEY=sk-...
lite model-groups list [--format table|json]
```
Lists the model groups your key can reach on the proxy, via `/model_group/info`, along with each group's mode (`chat`, `embedding`, etc.) and per-token pricing. This is also what `lite autoroute configure` uses internally to discover what it can offer you.
Lists the model groups your key can reach on the proxy, via `/model_group/info`, along with each group's mode (`chat`, `embedding`, etc.) and per-token pricing. Note this route needs management access; `lite autoroute configure` instead discovers models through `/v1/models`, so it works with a key scoped to just the AI API routes
#### Configure the Auto-Router

View file

@ -15,41 +15,25 @@ class DiscoveredModel(BaseModel):
name: str
mode: str = "chat"
input_cost_per_token: float | None = None
output_cost_per_token: float | None = None
class _RawModelGroup(BaseModel):
class _RawModelListing(BaseModel):
model_config = ConfigDict(extra="ignore")
model_group: str
# Optional: some real deployments return an explicit `"mode": null` for models that
# were registered without a mode (seen for embedding models like voyage-4-large).
# ModelGroupInfo's own "chat" default (litellm/types/router.py) only applies when the
# key is missing entirely, not when it's present as null, so this must tolerate None.
mode: str | None = "chat"
input_cost_per_token: float | None = None
output_cost_per_token: float | None = None
id: str
# /v1/models attaches "mode" (sourced from the cost map) only for models it can resolve;
# a model whose mode is unknown arrives without the field, so default it to chat rather
# than dropping it, which keeps it selectable as a routing target in the wizard.
mode: str = "chat"
_RAW_MODEL_GROUPS_ADAPTER = TypeAdapter(list[_RawModelGroup])
_RAW_MODEL_LISTING_ADAPTER = TypeAdapter(list[_RawModelListing])
def parse_discovered_models(raw: list[JsonValue]) -> tuple[DiscoveredModel, ...]:
"""Validate a raw `/model_group/info` response into typed models."""
parsed = _RAW_MODEL_GROUPS_ADAPTER.validate_python(raw)
return tuple(
DiscoveredModel(
name=group.model_group,
# A null mode means the server genuinely doesn't know what this model does;
# "unknown" (rather than guessing "chat") keeps it out of both chat_models()
# and embedding_models() instead of risking a wrong-mode deployment.
mode=group.mode or "unknown",
input_cost_per_token=group.input_cost_per_token,
output_cost_per_token=group.output_cost_per_token,
)
for group in parsed
)
"""Validate a raw `/v1/models` response into typed models."""
parsed = _RAW_MODEL_LISTING_ADAPTER.validate_python(raw)
return tuple(DiscoveredModel(name=item.id, mode=item.mode) for item in parsed)
def chat_models(models: tuple[DiscoveredModel, ...]) -> tuple[DiscoveredModel, ...]:

View file

@ -111,12 +111,12 @@ def run_configure_wizard(ctx: click.Context) -> Path:
api_key = ctx.obj["api_key"]
client = Client(base_url=base_url, api_key=api_key)
raw_groups = client.model_groups.info()
if not isinstance(raw_groups, list):
raw_models = client.models.list()
if not isinstance(raw_models, list):
raise click.ClickException(
f"Unexpected response from /model_group/info: expected a list, got {type(raw_groups).__name__}"
f"Unexpected response from /v1/models: expected a list, got {type(raw_models).__name__}"
)
discovered = parse_discovered_models(raw_groups)
discovered = parse_discovered_models(raw_models)
chat_pool = chat_models(discovered)
embedding_pool = embedding_models(discovered)

View file

@ -0,0 +1,9 @@
"""Typed, provenance-aware resolution of proxy settings from DB then env."""
from litellm.proxy.config_resolvers._descriptors import (
FieldDescriptor,
FieldSource,
resolve_fields,
)
__all__ = ["FieldDescriptor", "FieldSource", "resolve_fields"]

View file

@ -0,0 +1,73 @@
"""Shared primitive for resolving a settings value from its sources.
A ``FieldDescriptor`` names, for one setting, where it lives in the stored DB
row (``db_key``), which process env var carries it (``env_var``), whether it is
a secret, and its effective default. ``resolve_fields`` reconciles a set of
descriptors against a decrypted DB row and the process environment with a fixed
precedence, returning the resolved values plus per-field provenance so a caller
can tell whether a value came from the database, the environment, a default, or
is unset.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Literal
FieldSource = Literal["db", "env", "default", "unset"]
@dataclass(frozen=True, slots=True)
class FieldDescriptor:
field_name: str
db_key: str
env_var: str
is_secret: bool = False
default: str | None = None
def _db_is_set(db_value: object, empty_db_is_set: bool) -> bool:
if empty_db_is_set:
# A stored key that is present, even as "", is an explicit admin choice
# (e.g. clearing an alerting webhook) and must win over a stale env var.
return db_value is not None
# A blank stored value is treated as absent, so it falls through to env. This
# fits settings whose clear path also unsets the env var (e.g. SSO).
return isinstance(db_value, str) and bool(db_value.strip())
def _resolve_one(
descriptor: FieldDescriptor,
db_values: Mapping[str, object],
env: Mapping[str, str],
empty_db_is_set: bool,
) -> tuple[str, str | None, FieldSource]:
db_value = db_values.get(descriptor.db_key)
if _db_is_set(db_value, empty_db_is_set):
return descriptor.field_name, db_value if isinstance(db_value, str) else str(db_value), "db"
env_value = env.get(descriptor.env_var)
if isinstance(env_value, str) and env_value.strip():
return descriptor.field_name, env_value, "env"
if descriptor.default is not None:
return descriptor.field_name, descriptor.default, "default"
return descriptor.field_name, None, "unset"
def resolve_fields(
descriptors: Sequence[FieldDescriptor],
db_values: Mapping[str, object],
env: Mapping[str, str],
empty_db_is_set: bool = False,
) -> tuple[dict[str, str | None], dict[str, FieldSource]]:
"""Resolve every descriptor to (values, provenance).
Precedence per field: a set stored value wins, else a non-blank process env
var, else the descriptor default, else unset. ``empty_db_is_set`` selects
how a present-but-empty stored value is read: ``False`` treats it as absent
so it falls back to env (SSO, whose clear path also unsets the env var);
``True`` treats it as an explicit clear that wins over env (alerting, whose
clear path stores "" without unsetting the env var).
"""
resolved = tuple(_resolve_one(descriptor, db_values, env, empty_db_is_set) for descriptor in descriptors)
values = {field_name: value for field_name, value, _ in resolved}
provenance = {field_name: source for field_name, _, source in resolved}
return values, provenance

View file

@ -0,0 +1,25 @@
"""Descriptor tables for the alerting settings surfaced by /get/config/callbacks.
These reconcile the stored ``environment_variables`` blob (keyed by the
uppercase env-var names) with the process environment. SMTP_PORT and SMTP_TLS
carry the same effective defaults the mail-send path applies, so the settings
page shows the config that mail would actually use rather than a blank.
"""
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor
EMAIL_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
FieldDescriptor("SMTP_HOST", "SMTP_HOST", "SMTP_HOST"),
FieldDescriptor("SMTP_PORT", "SMTP_PORT", "SMTP_PORT", default="587"),
FieldDescriptor("SMTP_TLS", "SMTP_TLS", "SMTP_TLS", default="True"),
FieldDescriptor("SMTP_USERNAME", "SMTP_USERNAME", "SMTP_USERNAME", is_secret=True),
FieldDescriptor("SMTP_PASSWORD", "SMTP_PASSWORD", "SMTP_PASSWORD", is_secret=True),
FieldDescriptor("SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL"),
FieldDescriptor("TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS"),
FieldDescriptor("EMAIL_LOGO_URL", "EMAIL_LOGO_URL", "EMAIL_LOGO_URL"),
FieldDescriptor("EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT"),
)
SLACK_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
FieldDescriptor("SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", is_secret=True),
)

View file

@ -0,0 +1,94 @@
"""Resolved SSO config object.
Reconciles the dedicated ``sso_config`` DB row (lowercase, per-value encrypted
keys) with the process environment (uppercase env vars) into a typed
``SSOConfig`` plus per-field provenance. This is the single source of truth for
the SSO field -> env-var mapping, used by both the read-back endpoint and the
save endpoint so the two can never drift.
"""
from collections.abc import Mapping
from dataclasses import dataclass
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.config_resolvers._descriptors import (
FieldDescriptor,
FieldSource,
resolve_fields,
)
from litellm.types.proxy.management_endpoints.ui_sso import (
RoleMappings,
SSOConfig,
TeamMappings,
)
SSO_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
FieldDescriptor("google_client_id", "google_client_id", "GOOGLE_CLIENT_ID"),
FieldDescriptor("google_client_secret", "google_client_secret", "GOOGLE_CLIENT_SECRET", is_secret=True),
FieldDescriptor("microsoft_client_id", "microsoft_client_id", "MICROSOFT_CLIENT_ID"),
FieldDescriptor("microsoft_client_secret", "microsoft_client_secret", "MICROSOFT_CLIENT_SECRET", is_secret=True),
FieldDescriptor("microsoft_tenant", "microsoft_tenant", "MICROSOFT_TENANT"),
FieldDescriptor("generic_client_id", "generic_client_id", "GENERIC_CLIENT_ID"),
FieldDescriptor("generic_client_secret", "generic_client_secret", "GENERIC_CLIENT_SECRET", is_secret=True),
FieldDescriptor(
"generic_authorization_endpoint", "generic_authorization_endpoint", "GENERIC_AUTHORIZATION_ENDPOINT"
),
FieldDescriptor("generic_token_endpoint", "generic_token_endpoint", "GENERIC_TOKEN_ENDPOINT"),
FieldDescriptor("generic_userinfo_endpoint", "generic_userinfo_endpoint", "GENERIC_USERINFO_ENDPOINT"),
FieldDescriptor("generic_scope", "generic_scope", "GENERIC_SCOPE", default="openid email profile"),
FieldDescriptor("proxy_base_url", "proxy_base_url", "PROXY_BASE_URL"),
)
# Derived from the descriptor table so read (masking) and the field->env mapping
# never diverge from the resolver.
SSO_SECRET_FIELDS: frozenset[str] = frozenset(d.field_name for d in SSO_DESCRIPTORS if d.is_secret)
SSO_FIELD_ENV_VARS: dict[str, str] = {d.field_name: d.env_var for d in SSO_DESCRIPTORS}
# Structured sub-objects stored on the SSO row that are not simple env-backed
# scalars; handled outside the descriptor resolution.
_STRUCTURED_KEYS = ("role_mappings", "team_mappings")
@dataclass(frozen=True, slots=True)
class ResolvedSSOConfig:
config: SSOConfig
provenance: dict[str, FieldSource]
def _decrypt(raw: Mapping[str, object]) -> dict[str, object]:
return {
key: (
decrypt_value_helper(value=value, key=key, return_original_value=True) if isinstance(value, str) else value
)
for key, value in raw.items()
}
def _parse_role_mappings(data: object) -> RoleMappings | None:
# The stored row is JSON, so mappings arrive as a dict (or are absent).
return RoleMappings(**data) if isinstance(data, dict) else None
def _parse_team_mappings(data: object) -> TeamMappings | None:
return TeamMappings(**data) if isinstance(data, dict) else None
def resolve_sso_config(sso_db_settings: Mapping[str, object] | None, env: Mapping[str, str]) -> ResolvedSSOConfig:
"""Resolve the effective SSO config: stored row first, then process env.
Decryption happens here, once, via the pure ``decrypt_value_helper``; this
function never writes ``os.environ`` (unlike the legacy read path). Values
are returned unmasked so the login path could consume them; the read-back
endpoint is responsible for masking secrets before responding to the UI.
"""
raw = dict(sso_db_settings) if sso_db_settings else {}
decrypted = _decrypt({key: value for key, value in raw.items() if key not in _STRUCTURED_KEYS})
values, provenance = resolve_fields(SSO_DESCRIPTORS, decrypted, env)
structured = {
"user_email": decrypted.get("user_email"),
"ui_access_mode": decrypted.get("ui_access_mode"),
"role_mappings": _parse_role_mappings(raw.get("role_mappings")),
"team_mappings": _parse_team_mappings(raw.get("team_mappings")),
}
config = SSOConfig(**{**values, **structured})
return ResolvedSSOConfig(config=config, provenance=provenance)

View file

@ -136,9 +136,12 @@ if MCP_AVAILABLE:
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_raise_if_not_oauth2,
authorize_with_server,
client_supplied_redirect_uris,
exchange_token_with_server,
get_request_base_url,
redeem_passthrough_authorization_code,
register_client_with_server,
resolve_ephemeral_dcr_client,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
@ -1661,7 +1664,21 @@ if MCP_AVAILABLE:
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
_raise_if_not_oauth2(mcp_server)
# Use the server's stored client_id when the caller doesn't supply one
resolved_client_id = mcp_server.client_id or client_id or ""
stored_or_supplied_client_id = mcp_server.client_id or client_id or ""
ephemeral_dcr_client = (
await resolve_ephemeral_dcr_client(
request=request,
mcp_server=mcp_server,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
redirect_uri=redirect_uri,
)
if not stored_or_supplied_client_id
else None
)
resolved_client_id = stored_or_supplied_client_id or (
ephemeral_dcr_client.client_id if ephemeral_dcr_client else ""
)
if not resolved_client_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -1683,6 +1700,7 @@ if MCP_AVAILABLE:
code_challenge_method=code_challenge_method,
response_type=response_type,
scope=scope,
ephemeral_dcr_client=ephemeral_dcr_client,
)
@router.post(
@ -1705,7 +1723,21 @@ if MCP_AVAILABLE:
):
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
_raise_if_not_oauth2(mcp_server)
resolved_client_id = mcp_server.client_id or client_id or ""
# Sealed passthrough codes exist only for the authorization_code grant. A refresh_token
# grant must never open one: the minted client is unrecoverable after the single flow by
# contract, so an expired browser-held token re-runs authorize instead.
sealed_code = (
redeem_passthrough_authorization_code(code=code, mcp_server=mcp_server, code_verifier=code_verifier)
if grant_type == "authorization_code"
else None
)
resolved_code = sealed_code.upstream_code if sealed_code else code
# A sealed flow ran the gateway /callback as its upstream redirect (bridge short-circuit
# or plain flow alike), so the exchange must present that binding, not the browser page.
resolved_redirect_uri = f"{get_request_base_url(request)}/callback" if sealed_code else redirect_uri
caller_client_id = sealed_code.client_id if sealed_code else client_id
caller_client_secret = sealed_code.client_secret if sealed_code else client_secret
resolved_client_id = mcp_server.client_id or caller_client_id or ""
if not resolved_client_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -1721,13 +1753,14 @@ if MCP_AVAILABLE:
request=request,
mcp_server=mcp_server,
grant_type=grant_type,
code=code,
redirect_uri=redirect_uri,
code=resolved_code,
redirect_uri=resolved_redirect_uri,
client_id=resolved_client_id,
client_secret=client_secret,
client_secret=caller_client_secret,
code_verifier=code_verifier,
refresh_token=refresh_token,
scope=scope,
client_token_endpoint_auth_method=sealed_code.token_endpoint_auth_method if sealed_code else None,
)
@router.post(
@ -1743,6 +1776,7 @@ if MCP_AVAILABLE:
mcp_server = await _get_cached_temporary_mcp_server_or_404(server_id, user_api_key_dict, request=request)
request_data = await _read_request_body(request=request)
data: dict = {**request_data}
client_redirect_uris = client_supplied_redirect_uris(data.get("redirect_uris"))
return await register_client_with_server(
request=request,
@ -1753,6 +1787,7 @@ if MCP_AVAILABLE:
token_endpoint_auth_method=data.get("token_endpoint_auth_method", ""),
fallback_client_id=server_id,
persist_credentials=_user_is_full_admin(user_api_key_dict),
client_redirect_uris=client_redirect_uris,
)
@router.delete(

View file

@ -13,16 +13,18 @@ Endpoints for /organization operations
#### ORGANIZATION MANAGEMENT ####
from typing import Any, Dict, List, Optional, Tuple
from typing import Annotated, Any, Dict, List, Mapping, Optional, Tuple
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import TypeAdapter
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.proxy._types import *
from litellm.proxy.auth.auth_checks import can_user_call_model, get_user_object
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
from litellm.proxy.management_endpoints.budget_management_endpoints import (
new_budget,
update_budget,
@ -34,6 +36,7 @@ from litellm.proxy.management_endpoints.common_utils import (
)
from litellm.proxy.management_helpers.object_permission_utils import (
handle_update_object_permission_common,
prepare_object_permission_upsert,
)
from litellm.proxy.management_helpers.utils import (
get_new_internal_user_defaults,
@ -101,6 +104,30 @@ async def _verify_org_access(
)
_STR_OBJECT_DICT_ADAPTER = TypeAdapter(dict[str, object])
_BUDGET_SETTABLE_FIELDS = frozenset(LiteLLM_BudgetTable.model_fields.keys()) - {"budget_id"}
_ORG_COLUMN_FIELDS = frozenset({"organization_alias", "models"})
def build_budget_write_data(budget_updates: Mapping[str, object], updated_by: str) -> Mapping[str, object]:
"""
Budget-row columns to write. ``budget_reset_at`` tracks any sent ``budget_duration``:
recomputed for a new duration, cleared alongside a ``None`` duration so no stale reset
timestamp survives. Other sent fields (including a ``None`` clear) are written as-is.
"""
budget_duration = budget_updates.get("budget_duration")
recomputed_reset_at: Mapping[str, object] = (
{
"budget_reset_at": (
get_budget_reset_time(budget_duration=budget_duration) if isinstance(budget_duration, str) else None
)
}
if "budget_duration" in budget_updates
else {}
)
return {**budget_updates, **recomputed_reset_at, "updated_by": updated_by}
def handle_nested_budget_structure_in_organization_update_request(
raw_data: dict,
) -> dict:
@ -556,6 +583,154 @@ async def handle_update_object_permission(
return data_json
@router.patch(
"/v2/organization/{organization_id}",
tags=["organization management"],
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_OrganizationTableWithMembers,
include_in_schema=False,
)
async def update_organization_v2(
organization_id: str,
data: OrganizationUpdateRequestV2,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Partial update of an organization (RESTful PATCH, RFC 7396 merge-patch semantics).
A sent field is written and an omitted one is left untouched (presence is read from
``model_fields_set``). Clear tokens are per field: budget limits and ``metadata`` clear with
``null``, ``models`` with ``[]``, and ``object_permission`` with ``null`` (it merges when sent,
so an empty ``{}`` is rejected). ``organization_alias`` is required and cannot be cleared.
Validation failures return 422; the object-permission upsert, budget-row write, and
org-row write are one transaction.
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
if user_api_key_dict.user_id is None:
raise HTTPException(
status_code=400,
detail={
"error": "Cannot associate a user_id to this action. Check `/key/info` to validate if 'user_id' is set."
},
)
if data.max_budget is not None and (not math.isfinite(data.max_budget) or data.max_budget < 0):
raise HTTPException(
status_code=422,
detail={"error": f"max_budget must be a non-negative finite number. Received: {data.max_budget}"},
)
if data.soft_budget is not None and (not math.isfinite(data.soft_budget) or data.soft_budget < 0):
raise HTTPException(
status_code=422,
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
if data.model_max_budget:
from litellm.proxy.management_endpoints.key_management_endpoints import (
validate_model_max_budget,
)
try:
validate_model_max_budget(data.model_max_budget)
except ValueError as e:
raise HTTPException(status_code=422, detail={"error": str(e)})
if "organization_alias" in data.model_fields_set and data.organization_alias is None:
raise HTTPException(
status_code=422,
detail={"error": "organization_alias cannot be cleared; it is required"},
)
if "models" in data.model_fields_set and data.models is None:
raise HTTPException(
status_code=422,
detail={"error": "models cannot be set to null; send [] to clear it"},
)
if data.object_permission is not None and not data.object_permission.model_dump(exclude_none=True):
raise HTTPException(
status_code=422,
detail={
"error": "object_permission cannot be an empty object; send null to clear it, or a non-empty object to set grants"
},
)
await _verify_org_access(
organization_id=organization_id,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
existing_organization_row = await OrganizationRepository(prisma_client).table.find_unique(
where={"organization_id": organization_id},
)
if existing_organization_row is None:
raise HTTPException(
status_code=404,
detail={"error": f"Organization not found for organization_id={organization_id}"},
)
field_values = _STR_OBJECT_DICT_ADAPTER.validate_python(data.model_dump())
present_fields = data.model_fields_set
budget_updates = {field: field_values[field] for field in present_fields if field in _BUDGET_SETTABLE_FIELDS}
org_column_updates: Mapping[str, object] = {
**{field: field_values[field] for field in present_fields if field in _ORG_COLUMN_FIELDS},
**({"metadata": data.metadata or {}} if "metadata" in present_fields else {}),
}
object_permission_cleared = "object_permission" in present_fields and data.object_permission is None
object_permission_upsert = (
await prepare_object_permission_upsert(
new_object_permission=data.object_permission.model_dump(exclude_none=True),
existing_object_permission_id=existing_organization_row.object_permission_id,
prisma_client=prisma_client,
)
if data.object_permission is not None
else None
)
object_permission_write: Mapping[str, object] = (
{"object_permission_id": object_permission_upsert.object_permission_id}
if object_permission_upsert is not None
else ({"object_permission_id": None} if object_permission_cleared else {})
)
organization_write_data = prisma_client.jsonify_object(
{
**org_column_updates,
**object_permission_write,
"updated_by": user_api_key_dict.user_id,
}
)
async with prisma_client.db.tx() as tx:
if object_permission_upsert is not None:
await tx.litellm_objectpermissiontable.upsert(
where={"object_permission_id": object_permission_upsert.object_permission_id},
data={
"create": object_permission_upsert.record,
"update": object_permission_upsert.record,
},
)
if budget_updates:
await tx.litellm_budgettable.update(
where={"budget_id": existing_organization_row.budget_id},
data=prisma_client.jsonify_object(
dict(build_budget_write_data(budget_updates, user_api_key_dict.user_id))
),
)
response = await tx.litellm_organizationtable.update(
where={"organization_id": organization_id},
data=organization_write_data,
include={"members": True, "teams": True, "litellm_budget_table": True},
)
return response
@router.delete(
"/organization/delete",
tags=["organization management"],

View file

@ -1317,6 +1317,13 @@ async def delete_user(
where={"team_id": team.team_id}, data={"members": new_members}
)
team_row = LiteLLM_TeamTable(**team.model_dump())
if any(member.user_id == user_id for member in team_row.members_with_roles or []):
await team_member_delete(
data=TeamMemberDeleteRequest(team_id=team_row.team_id, user_id=user_id),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
await _set_user_keys_blocked(user_id=user_id, blocked=True)
await _delete_rows_referencing_user(prisma_client, user_id=user_id)
@ -1346,6 +1353,31 @@ def _extract_group_values(value: Any) -> List[str]:
return group_values
def _extract_ids_from_path_filter(path: str | None, attribute: str) -> List[str]:
"""Return ids from a SCIM filtered path like ``members[value eq "id"]``.
Okta commonly sends membership removals as a filtered path and omits the
request body ``value``, so the id lives only inside the ``[value eq "..."]``
filter. The ``eq`` operator is matched case-insensitively per the SCIM
spec; the id keeps its original case. Per the SCIM filter grammar the
compared value must be quoted (single or double), so malformed unquoted
filters yield no id. A quoted id may contain escaped quotes and
backslashes (``\\"`` and ``\\\\``), which are unescaped before use.
``path`` must be the raw, case-preserving path from the patch op.
"""
if not path:
return []
match = re.match(
rf"""\s*{re.escape(attribute)}\s*\[\s*value\s+eq\s+(['"])((?:\\.|[^\\])*?)\1\s*\]\s*$""",
path,
flags=re.IGNORECASE,
)
if not match:
return []
extracted = re.sub(r"\\(.)", r"\1", match.group(2))
return [extracted] if extracted else []
def _handle_displayname_update(op_type: str, value: Any, update_data: Dict[str, Any]) -> None:
"""Handle displayname updates."""
if op_type == "remove":
@ -1389,9 +1421,11 @@ def _handle_name_update(path: str, op_type: str, value: Any, scim_metadata: Dict
scim_metadata["familyName"] = str(value)
def _handle_group_operations(op_type: str, value: Any, teams_set: Set[str]) -> Optional[Set[str]]:
def _handle_group_operations(op_type: str, value: Any, teams_set: Set[str], path: str | None) -> Set[str] | None:
"""Handle group/team membership operations."""
group_values = _extract_group_values(value)
if not group_values and value is None:
group_values = _extract_ids_from_path_filter(path, "groups")
if op_type == "replace":
return set(group_values)
elif op_type == "add":
@ -1504,7 +1538,7 @@ def _apply_patch_ops(
elif _multi_valued_attribute_base(path) in SCIM_MULTI_VALUED_ATTRIBUTE_METADATA_KEYS:
_handle_multi_valued_attribute_update(path, op_type, value, metadata)
elif path.startswith("groups"):
new_replace_set = _handle_group_operations(op_type, value, teams_set)
new_replace_set = _handle_group_operations(op_type, value, teams_set, op.path)
if new_replace_set is not None:
replace_team_set = new_replace_set
else:
@ -1925,8 +1959,16 @@ async def delete_group(
async def _process_group_patch_operations(
patch_ops: SCIMPatchOp, existing_team, prisma_client
) -> Tuple[Dict[str, Any], Set[str]]:
"""Process patch operations for a group and return update data and final members."""
) -> Tuple[Dict[str, Any], Set[str], Set[str] | None]:
"""Process patch operations for a group and return update data, final members
and, when the request contained a member ``replace`` op, the absolute target
roster it declared (``None`` otherwise).
``add``/``remove`` are deltas relative to the current roster, but ``replace``
is absolute: it declares the roster is exactly this set, so the caller must
reconcile against it as a set-to-target rather than rebasing it onto a
concurrently-mutated roster.
"""
update_data: Dict[str, Any] = {}
# Create a fresh copy of existing metadata to avoid Prisma issues
@ -1960,6 +2002,8 @@ async def _process_group_patch_operations(
elif path.startswith("members"):
# Handle member operations
member_values = _extract_group_values(value)
if not member_values and value is None:
member_values = _extract_ids_from_path_filter(op.path, "members")
# Check the feature flag
scim_upsert_user = await _get_scim_upsert_user_setting()
# Validate all users exist or create them based on feature flag
@ -2012,7 +2056,12 @@ async def _process_group_patch_operations(
if metadata:
update_data["metadata"] = metadata
return update_data, final_members
member_replace_present = any(
op.op == "replace" and (op.path or "").lower().startswith("members") for op in patch_ops.Operations
)
replace_target = set(final_members) if member_replace_present else None
return update_data, final_members, replace_target
async def _apply_group_patch_updates(group_id: str, update_data: Dict[str, Any], prisma_client):
@ -2083,27 +2132,29 @@ async def patch_group(
existing_team = await _check_team_exists(group_id)
# Process patch operations
update_data, final_members = await _process_group_patch_operations(patch_ops, existing_team, prisma_client)
update_data, final_members, replace_target = await _process_group_patch_operations(
patch_ops, existing_team, prisma_client
)
# Track current members BEFORE update for comparison
current_members = set(await _get_team_member_user_ids_from_team(existing_team))
snapshot_members = set(await _get_team_member_user_ids_from_team(existing_team))
intended_add = final_members - snapshot_members
intended_remove = snapshot_members - final_members
# Apply the metadata/displayName updates to the database
updated_team = await _apply_group_patch_updates(group_id, update_data, prisma_client)
# Refresh team data from database to get the latest state after concurrent updates
# This prevents race conditions when multiple PATCH requests come in simultaneously
refreshed_team = await TeamRepository(prisma_client).table.find_unique(where={"team_id": group_id})
if refreshed_team:
# Re-read current members from refreshed team to account for concurrent updates
refreshed_current_members = set(
await _get_team_member_user_ids_from_team(LiteLLM_TeamTable(**refreshed_team.model_dump()))
)
# Use the refreshed members for comparison
current_members = refreshed_current_members
refreshed_current = (
set(await _get_team_member_user_ids_from_team(LiteLLM_TeamTable(**refreshed_team.model_dump())))
if refreshed_team
else snapshot_members
)
# Handle user-team relationship changes
await _handle_group_membership_changes(group_id, current_members, final_members)
effective_final = (
replace_target if replace_target is not None else (refreshed_current | intended_add) - intended_remove
)
await _handle_group_membership_changes(group_id, refreshed_current, effective_final)
# A rename can flip whether this group matches scim_admin_group by display
# name, so retained members must be re-resolved too, not just the ones whose
@ -2112,7 +2163,7 @@ async def patch_group(
alias_changed = new_alias != existing_team.team_alias
await _recompute_scim_member_roles(
prisma_client,
(current_members | final_members if alias_changed else current_members ^ final_members),
(refreshed_current | effective_final if alias_changed else refreshed_current ^ effective_final),
)
# Refresh team one more time to get final state after membership changes

View file

@ -47,6 +47,7 @@ from litellm.proxy._types import (
Member,
NewTeamRequest,
OrgMember,
PatchTeamRequest,
ProxyErrorTypes,
ProxyException,
SpecialManagementEndpointEnums,
@ -1956,6 +1957,7 @@ async def update_team(
)
async def patch_team(
team_id: str,
data: PatchTeamRequest,
http_request: Request,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
litellm_changed_by: Annotated[
@ -1968,11 +1970,12 @@ async def patch_team(
"""
Partially update a team using RFC 7386 JSON Merge Patch semantics.
`team_id` is taken from the path. `metadata` is merged with the team's stored
metadata rather than replacing it: an omitted key is preserved, `key: null`
deletes it, and any other value overwrites (recursing into nested objects).
Every other field behaves exactly like `POST /team/update` (omitted preserves,
a value overwrites). Returns the full updated team.
`team_id` is taken from the path; a `team_id` in the body is accepted only when it
matches. `metadata` is merged with the team's stored metadata rather than replacing
it: an omitted key is preserved, `key: null` deletes it, and any other value
overwrites (recursing into nested objects). Every other field behaves exactly like
`POST /team/update` (omitted preserves, a value overwrites). Returns the full
updated team.
```
curl --location --request PATCH 'http://0.0.0.0:4000/team/8d916b1c-510d-4894-a334-1c16a93344f5' \
@ -1992,21 +1995,15 @@ async def patch_team(
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
try:
body = await http_request.json()
except (json.JSONDecodeError, ValueError):
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail={"error": "Request body must be a JSON object"})
body_team_id = body.pop("team_id", None)
if body_team_id is not None and body_team_id != team_id:
if data.team_id is not None and data.team_id != team_id:
raise HTTPException(
status_code=400,
detail={"error": f"team_id in body ({body_team_id}) does not match team_id in path ({team_id})"},
detail={"error": f"team_id in body ({data.team_id}) does not match team_id in path ({team_id})"},
)
if "metadata" in body:
patch_fields = data.model_dump(exclude_unset=True, exclude={"team_id"})
if "metadata" in patch_fields:
existing_team_row = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
if existing_team_row is None:
raise HTTPException(
@ -2014,9 +2011,9 @@ async def patch_team(
detail={"error": f"Team not found, passed team_id={team_id}"},
)
existing_metadata = existing_team_row.metadata if isinstance(existing_team_row.metadata, dict) else {}
body["metadata"] = apply_json_merge_patch(existing_metadata, body["metadata"])
patch_fields["metadata"] = apply_json_merge_patch(existing_metadata, patch_fields["metadata"])
update_request = UpdateTeamRequest(team_id=team_id, **body)
update_request = UpdateTeamRequest(team_id=team_id, **patch_fields)
result = await update_team(
data=update_request,
@ -2375,7 +2372,15 @@ async def _add_team_members_to_team(
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: str,
) -> Tuple[LiteLLM_TeamTable, List[LiteLLM_UserTable], List[LiteLLM_TeamMembership]]:
"""Add team members to the team."""
"""Add team members to the team.
The members_with_roles reconciliation runs inside a transaction that locks
the team row with ``SELECT ... FOR UPDATE`` before reading the current
membership. Concurrent /team/member_add calls for the same team therefore
serialize on the row lock and each appends onto the other's committed
result, instead of both rewriting the whole JSON array from a stale
snapshot (which silently drops one member on the losing write).
"""
# Process and add new members
updated_users, updated_team_memberships = await _process_team_members(
data=data,
@ -2385,19 +2390,22 @@ async def _add_team_members_to_team(
litellm_proxy_admin_name=litellm_proxy_admin_name,
)
# Update team members list
await _update_team_members_list(
data=data,
complete_team_data=complete_team_data,
updated_users=updated_users,
)
async with prisma_client.tx() as tx:
complete_team_data.members_with_roles = await TeamRepository(prisma_client).get_members_with_roles_locked(
tx, data.team_id
)
# ADD MEMBER TO TEAM
_db_team_members = [m.model_dump() for m in complete_team_data.members_with_roles]
updated_team = await TeamRepository(prisma_client).table.update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_team_members)}, # type: ignore
)
await _update_team_members_list(
data=data,
complete_team_data=complete_team_data,
updated_users=updated_users,
)
_db_team_members = [m.model_dump() for m in complete_team_data.members_with_roles]
updated_team = await tx.litellm_teamtable.update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_team_members)},
)
return updated_team, updated_users, updated_team_memberships

View file

@ -4,7 +4,8 @@ organizations, teams, and keys.
"""
import json
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Union
from fastapi import HTTPException, status
@ -64,6 +65,57 @@ async def attach_object_permission_to_dict(
return data_dict
@dataclass(frozen=True, slots=True)
class ObjectPermissionUpsert:
object_permission_id: str
record: dict[str, object]
async def prepare_object_permission_upsert(
new_object_permission: Mapping[str, object],
existing_object_permission_id: str | None,
prisma_client: PrismaClient,
) -> ObjectPermissionUpsert:
"""
Read-and-merge half of an object permission upsert; performs no writes.
Merges the sent grants over the existing row (looked up by
``existing_object_permission_id``, or a fresh uuid when the entity has none) and
returns the id plus the full record to upsert. The id is pinned inside the record
because the column has ``@default(uuid())``, so a create without it would mint a
different id than the one the caller links. ``mcp_tool_permissions`` is serialized
to a JSON string to avoid GraphQL parsing issues (e.g. server IDs starting with
"3e64" being interpreted as floats).
Keeping this separate from the write lets callers run the upsert inside the same
transaction as the row that links ``object_permission_id``, so a rolled-back
update cannot leave permission changes live.
"""
object_permission_id = existing_object_permission_id or str(uuid.uuid4())
existing_object_permission = await ObjectPermissionRepository(prisma_client).table.find_unique(
where={"object_permission_id": object_permission_id},
)
existing_fields: dict[str, object] = (
existing_object_permission.model_dump(exclude_unset=True, exclude_none=True)
if existing_object_permission is not None
else {}
)
merged: dict[str, object] = {
**existing_fields,
**new_object_permission,
"object_permission_id": object_permission_id,
}
record: dict[str, object] = {
**merged,
**(
{"mcp_tool_permissions": safe_dumps(merged["mcp_tool_permissions"])}
if "mcp_tool_permissions" in merged
else {}
),
}
return ObjectPermissionUpsert(object_permission_id=object_permission_id, record=record)
async def handle_update_object_permission_common(
data_json: Dict,
existing_object_permission_id: Optional[str],
@ -93,50 +145,23 @@ async def handle_update_object_permission_common(
if prisma_client is None:
raise ValueError("Prisma client not found")
#########################################################
# Ensure `object_permission` is not added to the data_json
# We need to update the entity at the object_permission_id level in the LiteLLM_ObjectPermissionTable
#########################################################
new_object_permission: Union[dict, str] = data_json.pop("object_permission", None)
new_object_permission: Union[dict, str, None] = data_json.pop("object_permission", None)
if new_object_permission is None:
return None
# Lookup existing object permission ID and update that entry
object_permission_id_to_use: str = existing_object_permission_id or str(uuid.uuid4())
existing_object_permissions_dict: Dict = {}
existing_object_permission = await ObjectPermissionRepository(prisma_client).table.find_unique(
where={"object_permission_id": object_permission_id_to_use},
)
# Update the object permission
if existing_object_permission is not None:
existing_object_permissions_dict = existing_object_permission.model_dump(exclude_unset=True, exclude_none=True)
# Handle string JSON object permission
if isinstance(new_object_permission, str):
new_object_permission = json.loads(new_object_permission)
if isinstance(new_object_permission, dict):
existing_object_permissions_dict.update(new_object_permission)
#########################################################
# Serialize mcp_tool_permissions JSON field to avoid GraphQL parsing issues
# (e.g., server IDs starting with "3e64" being interpreted as floats)
#########################################################
if "mcp_tool_permissions" in existing_object_permissions_dict:
existing_object_permissions_dict["mcp_tool_permissions"] = safe_dumps(
existing_object_permissions_dict["mcp_tool_permissions"]
)
#########################################################
# Commit the update to the LiteLLM_ObjectPermissionTable
#########################################################
upsert = await prepare_object_permission_upsert(
new_object_permission=new_object_permission if isinstance(new_object_permission, dict) else {},
existing_object_permission_id=existing_object_permission_id,
prisma_client=prisma_client,
)
created_object_permission_row = await ObjectPermissionRepository(prisma_client).table.upsert(
where={"object_permission_id": object_permission_id_to_use},
where={"object_permission_id": upsert.object_permission_id},
data={
"create": existing_object_permissions_dict,
"update": existing_object_permissions_dict,
"create": upsert.record,
"update": upsert.record,
},
)

View file

@ -198,13 +198,9 @@
"icon_url": "https://cdn.simpleicons.org/googledrive",
"category": "Productivity",
"registry_url": null,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-gdrive"],
"env_vars": [
{"name": "GOOGLE_CLIENT_ID", "description": "Google OAuth Client ID", "secret": false},
{"name": "GOOGLE_CLIENT_SECRET", "description": "Google OAuth Client Secret", "secret": true}
]
"transport": "http",
"url": "https://drivemcp.googleapis.com/mcp/v1",
"env_vars": []
},
{
"name": "google_calendar",

View file

@ -304,6 +304,11 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
encrypt_value_helper,
)
from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form
from litellm.proxy.config_resolvers import resolve_fields
from litellm.proxy.config_resolvers.alerting import (
EMAIL_DESCRIPTORS,
SLACK_DESCRIPTORS,
)
from litellm.proxy.common_utils.http_parsing_utils import (
_read_request_body,
_safe_get_request_headers,
@ -1159,9 +1164,9 @@ _OPENAPI_HTTP_METHODS = {
# Credentials surfaced by `/get/config/callbacks` in the alerting block: the
# full Slack incoming-webhook URL is itself a credential, and the SMTP
# password is a service password. Masked on read so plaintext never reaches
# the UI. Kept here at module scope to match the analogous
# `_SSO_SENSITIVE_FIELDS` / `_CACHE_SENSITIVE_FIELDS` constants in the SSO
# and cache endpoint files.
# the UI. Kept here at module scope to match the analogous descriptor
# `is_secret` flags in litellm.proxy.config_resolvers and the
# `_CACHE_SENSITIVE_FIELDS` constant in the cache endpoint file.
_ALERTING_SENSITIVE_VARS: Set[str] = {"SLACK_WEBHOOK_URL", "SMTP_PASSWORD"}
@ -15491,14 +15496,10 @@ async def get_config(
_alerting = _general_settings.get("alerting", [])
alerting_data = []
if "slack" in _alerting:
_slack_vars = [
"SLACK_WEBHOOK_URL",
]
_slack_env_vars = {
_var: (value if (value := environment_variables.get(_var)) is not None else os.getenv(_var))
for _var in _slack_vars
}
_slack_env_vars = _apply_alerting_env_role_gate(_slack_env_vars, is_full_admin)
_slack_values, _ = resolve_fields(
SLACK_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True
)
_slack_env_vars = _apply_alerting_env_role_gate(_slack_values, is_full_admin)
_alerting_types = proxy_logging_obj.slack_alerting_instance.alert_types
_all_alert_types = proxy_logging_obj.slack_alerting_instance._all_possible_alert_types()
@ -15514,19 +15515,8 @@ async def get_config(
}
)
# pass email alerting vars
_email_vars = [
"SMTP_HOST",
"SMTP_PORT",
"SMTP_USERNAME",
"SMTP_PASSWORD",
"SMTP_SENDER_EMAIL",
"TEST_EMAIL_ADDRESS",
"EMAIL_LOGO_URL",
"EMAIL_SUPPORT_CONTACT",
]
_email_env_vars = _apply_alerting_env_role_gate(
{_var: environment_variables.get(_var) for _var in _email_vars}, is_full_admin
)
_email_values, _ = resolve_fields(EMAIL_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True)
_email_env_vars = _apply_alerting_env_role_gate(_email_values, is_full_admin)
alerting_data.append(
{

View file

@ -15,6 +15,11 @@ from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys
from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.config_resolvers.sso import (
SSO_FIELD_ENV_VARS,
SSO_SECRET_FIELDS,
resolve_sso_config,
)
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.table_repositories import (
SSOConfigRepository,
@ -27,16 +32,6 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
router = APIRouter()
# SSO secret fields returned by /get/sso_settings. These are masked on read so
# the UI can show "(set)" without ever transporting the plaintext OAuth secret
# off the server, matching the write-once + masked-on-read contract used for
# the HashiCorp Vault config override.
_SSO_SENSITIVE_FIELDS: Set[str] = {
"google_client_secret",
"microsoft_client_secret",
"generic_client_secret",
}
# Maps each UIThemeConfig field to the env var the UI branding path reads it
# from. /update/ui_theme_settings writes both the stored ui_theme_config and
# these env vars, so /get/ui_theme_settings resolves the same env vars to
@ -109,7 +104,8 @@ class SettingsResponse(BaseModel):
class SSOSettingsResponse(SettingsResponse):
"""Response model for SSO settings"""
pass
provenance: Dict[str, str] = Field(default_factory=dict)
"""Per-field source of each value: 'db', 'env', 'default', or 'unset'."""
class InternalUserSettingsResponse(SettingsResponse):
@ -757,7 +753,7 @@ async def get_sso_settings():
Returns a structured object with values and descriptions for UI display.
"""
from litellm.proxy.proxy_server import prisma_client, proxy_config
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
@ -765,59 +761,12 @@ async def get_sso_settings():
detail={"error": "Database not connected. Please connect a database."},
)
# Get SSO config from dedicated table
# Resolve the effective SSO config: the stored row wins, else the process
# environment, else each field's default. Unlike the legacy read path this
# does not write os.environ; a GET has no business mutating the environment.
sso_db_record = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
# Initialize with defaults
sso_settings_dict = {}
if sso_db_record and sso_db_record.sso_settings:
# Load settings from database
sso_settings_dict = dict(sso_db_record.sso_settings)
role_mappings_data = sso_settings_dict.pop("role_mappings", None)
role_mappings = None
if role_mappings_data:
from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings
if isinstance(role_mappings_data, dict):
role_mappings = RoleMappings(**role_mappings_data)
elif isinstance(role_mappings_data, RoleMappings):
role_mappings = role_mappings_data
team_mappings_data = sso_settings_dict.pop("team_mappings", None)
team_mappings = None
if team_mappings_data:
from litellm.types.proxy.management_endpoints.ui_sso import TeamMappings
if isinstance(team_mappings_data, dict):
team_mappings = TeamMappings(**team_mappings_data)
elif isinstance(team_mappings_data, TeamMappings):
team_mappings = team_mappings_data
decrypted_sso_settings_dict = proxy_config._decrypt_and_set_db_env_variables(
environment_variables=sso_settings_dict
)
# Build SSO config with database values or environment fallback
sso_config = SSOConfig(
google_client_id=decrypted_sso_settings_dict.get("google_client_id", None),
google_client_secret=decrypted_sso_settings_dict.get("google_client_secret", None),
microsoft_client_id=decrypted_sso_settings_dict.get("microsoft_client_id", None),
microsoft_client_secret=decrypted_sso_settings_dict.get("microsoft_client_secret", None),
microsoft_tenant=decrypted_sso_settings_dict.get("microsoft_tenant", None),
generic_client_id=decrypted_sso_settings_dict.get("generic_client_id", None),
generic_client_secret=decrypted_sso_settings_dict.get("generic_client_secret", None),
generic_authorization_endpoint=decrypted_sso_settings_dict.get("generic_authorization_endpoint", None),
generic_token_endpoint=decrypted_sso_settings_dict.get("generic_token_endpoint", None),
generic_userinfo_endpoint=decrypted_sso_settings_dict.get("generic_userinfo_endpoint", None),
proxy_base_url=decrypted_sso_settings_dict.get("proxy_base_url", None),
user_email=decrypted_sso_settings_dict.get("user_email"),
ui_access_mode=decrypted_sso_settings_dict.get("ui_access_mode"),
role_mappings=role_mappings,
team_mappings=team_mappings,
)
sso_db_settings = dict(sso_db_record.sso_settings) if sso_db_record and sso_db_record.sso_settings else None
resolved = resolve_sso_config(sso_db_settings, os.environ)
# Get the schema for UI display
from pydantic import TypeAdapter
@ -826,11 +775,12 @@ async def get_sso_settings():
# Convert to dict for response, masking OAuth client secrets so plaintext
# is never sent to the UI.
sso_dict = mask_sensitive_keys(sso_config.model_dump(), _SSO_SENSITIVE_FIELDS)
sso_dict = mask_sensitive_keys(resolved.config.model_dump(), set(SSO_SECRET_FIELDS))
# Add descriptions to the response
result = {
"values": sso_dict,
"provenance": resolved.provenance,
"field_schema": {
"description": schema.get("description", ""),
"properties": {},
@ -881,21 +831,6 @@ async def update_sso_settings(
detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
)
# Update environment variables
env_var_mapping = {
"google_client_id": "GOOGLE_CLIENT_ID",
"google_client_secret": "GOOGLE_CLIENT_SECRET",
"microsoft_client_id": "MICROSOFT_CLIENT_ID",
"microsoft_client_secret": "MICROSOFT_CLIENT_SECRET",
"microsoft_tenant": "MICROSOFT_TENANT",
"generic_client_id": "GENERIC_CLIENT_ID",
"generic_client_secret": "GENERIC_CLIENT_SECRET",
"generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT",
"generic_token_endpoint": "GENERIC_TOKEN_ENDPOINT",
"generic_userinfo_endpoint": "GENERIC_USERINFO_ENDPOINT",
"proxy_base_url": "PROXY_BASE_URL",
}
# Read the existing SSO row first so the audit log captures a real
# before/after diff. Stored values are encrypted; decrypt them so the
# before-snapshot has the same shape as after_value, and rely on
@ -924,8 +859,8 @@ async def update_sso_settings(
# Update environment variables in config and in memory
sso_data = sso_config.model_dump()
for field_name, value in sso_data.items():
if field_name in env_var_mapping:
env_var_name = env_var_mapping[field_name]
if field_name in SSO_FIELD_ENV_VARS:
env_var_name = SSO_FIELD_ENV_VARS[field_name]
if value:
os.environ[env_var_name] = value
else:
@ -975,7 +910,7 @@ async def update_sso_settings(
else:
environment_variables = {}
env_vars_to_remove = set(env_var_mapping.values())
env_vars_to_remove = set(SSO_FIELD_ENV_VARS.values())
filtered_env_vars = {
key: value for key, value in environment_variables.items() if key not in env_vars_to_remove
}

View file

@ -172,6 +172,7 @@ from litellm.types.utils import LLMResponseTypes, LoggedLiteLLMParams
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from prisma.client import TransactionManager
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -2922,6 +2923,14 @@ class PrismaClient:
return self.db.writer
return self.db
def tx(self) -> "TransactionManager":
"""Open an interactive transaction on the writer.
Callers go through this instead of reaching into ``self.db`` so writer
selection and read-replica routing stay encapsulated in the wrapper.
"""
return cast("TransactionManager", self.db.tx()) # cast-ok: wrappers delegate tx via __getattr__ (untyped)
def get_request_status(self, payload: Union[dict, SpendLogsPayload]) -> Literal["success", "failure"]:
"""
Determine if a request was successful or failed based on payload metadata.
@ -6159,6 +6168,9 @@ def create_model_info_response(
if model_cost_info is not None:
max_input_tokens = coerce_token_limit(model_cost_info.get("max_input_tokens"))
max_output_tokens = coerce_token_limit(model_cost_info.get("max_output_tokens"))
mode = model_cost_info.get("mode")
if isinstance(mode, str):
base["mode"] = mode
if llm_router is not None:
configured_input, configured_output = llm_router.get_configured_token_limits(model_id)

View file

@ -4,11 +4,18 @@ Team repository for database operations on LiteLLM_TeamTable.
import json
from datetime import datetime
from typing import Any, Dict, List, Optional, Type
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
from litellm.models.team import LiteLLM_TeamTable
from pydantic import TypeAdapter
from litellm.models.team import LiteLLM_TeamTable, Member
from litellm.repositories.base_repository import BaseRepository
if TYPE_CHECKING:
from prisma import Prisma
_MEMBERS_WITH_ROLES_ADAPTER = TypeAdapter(list[Member])
class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
"""Repository for team database operations."""
@ -46,6 +53,24 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
return LiteLLM_TeamTable(**data)
async def get_members_with_roles_locked(self, tx: "Prisma", team_id: str) -> List[Member]:
"""Return the team's members_with_roles, locking the row FOR UPDATE.
Must be called inside a transaction so the row lock is held until
commit. This serializes concurrent membership writers on the team row
so the losing writer appends onto the winner's committed result instead
of overwriting it from a stale snapshot.
"""
rows = await tx.query_raw(
'SELECT members_with_roles FROM "LiteLLM_TeamTable" WHERE team_id = $1 FOR UPDATE',
team_id,
)
raw_value = rows[0]["members_with_roles"] if rows else None
parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value
if not parsed:
return []
return _MEMBERS_WITH_ROLES_ADAPTER.validate_python(parsed)
async def find_by_id(self, team_id: str, id_field: str = "team_id") -> Optional[LiteLLM_TeamTable]:
return await super().find_by_id(team_id, id_field)

View file

@ -494,7 +494,14 @@ async def aresponses(
prompt_label=kwargs.get("prompt_label", None),
prompt_version=kwargs.get("prompt_version", None),
)
input = cast(Union[str, ResponseInputParam], merged_input)
input = cast(
Union[str, ResponseInputParam],
ResponsesAPIRequestUtils.merge_prompt_management_input(
original_input=input,
client_input=client_input,
merged_input=merged_input,
),
)
if model != original_model:
_, custom_llm_provider, _, _ = litellm.get_llm_provider(model=model)
kwargs.pop("prompt_id", None)
@ -609,7 +616,14 @@ def _apply_prompt_management_to_responses_call(
prompt_label=kwargs.get("prompt_label", None),
prompt_version=kwargs.get("prompt_version", None),
)
input = cast(Union[str, ResponseInputParam], merged_input)
input = cast(
Union[str, ResponseInputParam],
ResponsesAPIRequestUtils.merge_prompt_management_input(
original_input=input,
client_input=client_input,
merged_input=merged_input,
),
)
local_vars["input"] = input
local_vars["model"] = model
if model != original_model:

View file

@ -19,7 +19,9 @@ import litellm
from litellm._logging import verbose_logger
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.types.llms.openai import (
AllMessageValues,
ResponseAPIUsage,
ResponseInputParam,
ResponsesAPIOptionalRequestParams,
ResponsesAPIResponse,
ResponseText,
@ -36,6 +38,57 @@ from litellm.types.utils import (
class ResponsesAPIRequestUtils:
"""Helper utils for constructing ResponseAPI requests"""
@staticmethod
def merge_prompt_management_input(
original_input: str | ResponseInputParam,
client_input: list[AllMessageValues],
merged_input: list[AllMessageValues],
) -> list[object]:
if isinstance(original_input, str):
return [*merged_input]
original_items = tuple(original_input)
client_item_ids = frozenset(id(item) for item in client_input)
message_positions = tuple(index for index, item in enumerate(original_items) if id(item) in client_item_ids)
if len(message_positions) == len(original_items):
return [*merged_input]
if not message_positions:
verbose_logger.warning(
"Prompt management hook returned messages without Responses API input messages; merged messages were ignored"
)
return [*original_items]
corresponding_messages = len(client_input) == len(merged_input) and all(
original.get("role") == merged.get("role")
and (not isinstance(original.get("id"), str) or original.get("id") == merged.get("id"))
for original, merged in zip(client_input, merged_input)
)
if corresponding_messages:
merged_by_position = dict(zip(message_positions, merged_input))
return [
merged_by_position[index] if index in merged_by_position else item
for index, item in enumerate(original_items)
]
all_messages_preserved = all(any(original is merged for merged in merged_input) for original in client_input)
if all_messages_preserved:
prefixes = {
id(original_items[position]): original_items[
message_positions[index - 1] + 1 if index else 0 : position
]
for index, position in enumerate(message_positions)
}
trailing_items = original_items[message_positions[-1] + 1 :]
return [item for merged in merged_input for item in (*prefixes.get(id(merged), ()), merged)] + list(
trailing_items
)
verbose_logger.warning(
"Prompt management hook replaced Responses API messages; non-message input items were dropped"
)
return [*merged_input]
@staticmethod
def _check_valid_arg(
supported_params: Optional[List[str]],

View file

@ -148,6 +148,10 @@ class SSOConfig(LiteLLMPydanticObjectBase):
default=None,
description="User info endpoint URL for generic OAuth provider",
)
generic_scope: Optional[str] = Field(
default=None,
description="Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'",
)
# Common settings
proxy_base_url: Optional[str] = Field(

View file

@ -10,12 +10,16 @@ class ModelInfoMetadata(TypedDict):
class ModelInfoResponse(TypedDict):
"""OpenAI-compatible model object. `metadata` is present only when the
endpoint is called with include_metadata=true.
"""OpenAI-compatible model object. `mode`, `max_input_tokens`, and
`max_output_tokens` are attached when the cost map knows them; `metadata`
is present only when the endpoint is called with include_metadata=true.
"""
id: str
object: Literal["model"]
created: int
owned_by: str
mode: NotRequired[str]
max_input_tokens: NotRequired[int]
max_output_tokens: NotRequired[int]
metadata: NotRequired[ModelInfoMetadata]

View file

@ -5,9 +5,9 @@ answers or when credentials/env are missing; they never skip. Pure unit coverage
of the harness itself carries no `e2e` marker and runs regardless of whether a
proxy is up.
Lifecycle: the `resources` fixture maps the init -> run -> teardown contract
(lifecycle.E2ECase) onto pytest - setup is init(), the test body is run(), and
teardown deletes every resource the test created on the long-lived proxy.
Lifecycle: the `resources` fixture hands each test a lifecycle.ResourceManager -
the test registers a cleanup for every resource it creates, and the fixture's
teardown deletes them all on the long-lived proxy, even when the test fails.
Each suite provides its own `client` fixture (a lifecycle.ResourceClient); these
shared fixtures build on it.

View file

@ -72,6 +72,8 @@ POLL_TIMEOUT = float(os.environ.get("E2E_POLL_TIMEOUT", "120"))
POLL_INTERVAL = float(os.environ.get("E2E_POLL_INTERVAL", "5"))
REQUEST_TIMEOUT = float(os.environ.get("E2E_REQUEST_TIMEOUT", "60"))
EXPECT_RUST = os.environ.get("E2E_EXPECT_RUST", "").strip().lower() in ("1", "true", "yes")
LOAD_USERS = int(os.environ.get("E2E_LOAD_USERS", "750"))
LOAD_SPAWN_RATE = float(os.environ.get("E2E_LOAD_SPAWN_RATE", "50"))
LOAD_DURATION_SECONDS = float(os.environ.get("E2E_LOAD_DURATION_SECONDS", "60"))

View file

@ -1,13 +1,11 @@
"""Lifecycle contract and resource cleanup for stateful e2e tests.
"""Resource cleanup for stateful e2e tests.
Shared by every e2e suite under tests/e2e/. The proxy under test is
long-lived and never reset between tests, so anything a test creates (keys,
customers, teams, orgs, users, guardrails, budgets, ...) persists unless
explicitly deleted. Every check follows an init -> run -> teardown lifecycle;
teardown releases each resource init() created, even when run() raises.
In pytest terms (see conftest.py): the `resources` fixture's setup is init(),
the test body is run(), and the fixture's teardown is teardown().
explicitly deleted. The `resources` fixture (see conftest.py) hands each test a
ResourceManager; the test registers a cleanup for every resource it creates, and
the fixture's teardown releases them all even when the test body raises.
"""
from dataclasses import dataclass, field
@ -17,38 +15,6 @@ from proxy_client import ProxyClient
from models import KeyGenerateBody
@runtime_checkable
class E2ECase(Protocol):
"""A stateful e2e check run against a long-lived proxy.
init() acquires resources, run() exercises behaviour and asserts, teardown()
releases everything init() created. teardown() must run even if init() fails
partway or run() raises.
"""
def init(self) -> None: ...
def run(self) -> None: ...
def teardown(self) -> None: ...
def run_case(case: E2ECase) -> None:
"""Drive a case through its lifecycle: init -> run -> teardown.
teardown always runs - even when init() fails partway or run() raises (or
skips) - so resources the case already registered on the long-lived proxy are
released. init() is inside the try because cases register cleanups
progressively (e.g. create team, then user, then key), and a failure after
the first creation must still release what came before.
"""
try:
case.init()
case.run()
finally:
case.teardown()
@runtime_checkable
class ResourceClient(Protocol):
"""Proxy operations the convenience creators use. Resource types without a

View file

@ -12,7 +12,7 @@ from __future__ import annotations
import pytest
from e2e_config import unique_marker
from e2e_config import EXPECT_RUST, unique_marker
from e2e_http import StreamingResponse, require_successful_call, unwrap
from endpoints_client import EndpointsClient
from lifecycle import ResourceManager
@ -50,6 +50,13 @@ def _assert_streamed_ok(result: StreamingResponse) -> None:
assert any("message_stop" in event for event in result.stream_events), (
"stream never reached message_stop"
)
if EXPECT_RUST:
assert result.headers.get("x-litellm-rust") == "true", (
"E2E_EXPECT_RUST is set, so this gateway must serve /v1/messages through the "
"Rust path, but the response carried no x-litellm-rust marker. The request "
"still succeeded, which is exactly the failure mode: a gateway whose native "
f"extension is unavailable falls back to Python silently. headers={result.headers}"
)
class TestAzureFoundryMessages:

View file

@ -1,28 +1,36 @@
"""Live e2e: a tiny max_budget on an entity actually blocks requests.
Each entity is an E2ECase (lifecycle.E2ECase) driven by run_case: init() creates
the budgeted entity + a key, run() drives spend until a `budget_exceeded` block,
teardown() deletes everything init() created (always runs, even on failure/skip).
Covers the entities with no prior live coverage - internal user, end-user,
organization, team member - plus key and team. See BUDGET_TEST_COVERAGE_MATRIX.md.
One test per budget level (key, team, internal user, end-user, organization,
team member): put the tiny cap on that level, drive spend until a
`budget_exceeded` block, and where a cap could be confused with a neighbor,
prove isolation with an uncapped control key that must keep serving. The
capped-key sweep proves the key's own max_budget blocks across mint shapes
(personal, team, team-member) with roomy surroundings, so the key-level cap is
provably the blocker no matter who the key was minted to.
A non-budget error fails hard (never a skip); if calls never get blocked, budget
enforcement is broken -> fail.
"""
import time
from dataclasses import dataclass, field
from typing import Callable, List, Type
import pytest
from budget_client import BudgetClient, is_budget_block
from e2e_config import unique_marker
from e2e_http import StreamingResponse, require_successful_call
from lifecycle import run_case
from lifecycle import ResourceManager
pytestmark = pytest.mark.e2e
TINY_CAP = 3e-6
ROOMY_CAP = 100.0
def _chat(client: BudgetClient, key: str, *, user: str | None = None) -> StreamingResponse:
return client.chat(key, "claude-haiku-4-5", f"spend {unique_marker()}", max_tokens=16, user=user)
def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") -> StreamingResponse:
"""Send paid calls until the entity's budget blocks one; return the blocked
response so callers can assert on its shape. Key/user/org/member block within
@ -30,13 +38,7 @@ def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") ->
enforces off table spend that lands on the batch write, so it takes a few
more. A non-budget error fails hard (never a skip)."""
for _ in range(40):
result = client.chat(
key,
"claude-haiku-4-5",
f"spend {unique_marker()}",
max_tokens=16,
user=user or None,
)
result = _chat(client, key, user=user or None)
if is_budget_block(result):
return result
require_successful_call(result)
@ -44,225 +46,154 @@ def _assert_budget_blocks(client: BudgetClient, key: str, *, user: str = "") ->
pytest.fail("budget never enforced within the call budget")
@dataclass
class _BudgetCase:
"""Base E2ECase: a key under some budgeted entity must get blocked.
Subclasses set up the budgeted entity in init() and register every created id
in `_undo` (run LIFO in teardown so a key is deleted before its team/org).
"""
client: BudgetClient
key: str = ""
_undo: List[Callable[[], None]] = field(
default_factory=list
) # mutable-ok: per-case teardown registry
def init(self) -> None:
raise NotImplementedError
def run(self) -> None:
_assert_budget_blocks(self.client, self.key)
def teardown(self) -> None:
for undo in reversed(self._undo):
undo()
def _assert_blocked_429(client: BudgetClient, key: str) -> StreamingResponse:
blocked = _assert_budget_blocks(client, key)
assert blocked.status_code == 429, (
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
)
return blocked
class KeyBudgetCase(_BudgetCase):
"""A bare key (no team_id / user_id) carrying its own max_budget, so only the
key-level budget can be the thing that blocks. The refusal must be a 429
budget_exceeded; any other error already fails via _assert_budget_blocks."""
class TestBudgetBlocksPerLevel:
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
def test_bare_key_blocks_over_its_own_budget(self, client: BudgetClient, resources: ResourceManager) -> None:
key = client.generate_key(max_budget=TINY_CAP)
resources.defer(lambda: client.delete_key(key))
def init(self) -> None:
self.key = self.client.generate_key(max_budget=3e-6)
self._undo.append(lambda: self.client.delete_key(self.key))
_assert_blocked_429(client, key)
def run(self) -> None:
blocked = _assert_budget_blocks(self.client, self.key)
assert blocked.status_code == 429, (
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
)
@pytest.mark.covers("quota_management.budget.team.blocks_over_limit")
def test_team_budget_blocks_every_team_key(self, client: BudgetClient, resources: ResourceManager) -> None:
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}", max_budget=TINY_CAP)
resources.defer(lambda: client.delete_team(team_id))
spender_key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(spender_key))
sibling_key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(sibling_key))
class TeamBudgetCase(_BudgetCase):
"""An admin caps a whole team: two keys under a tiny-budget team, neither with
a key-level budget. Key A is driven until the team cap blocks it; key B's very
first call must then be refused too, proving the cap sits on the team, not the
key that spent. Both refusals must be 429 budget_exceeded."""
def init(self) -> None:
team_id = self.client.create_team(
alias=f"e2e-budget-team-{unique_marker()}", max_budget=3e-6
)
self._undo.append(lambda: self.client.delete_team(team_id))
self.key = self.client.generate_key(team_id=team_id)
self._undo.append(lambda: self.client.delete_key(self.key))
self._sibling_key = self.client.generate_key(team_id=team_id)
self._undo.append(lambda: self.client.delete_key(self._sibling_key))
def run(self) -> None:
blocked = _assert_budget_blocks(self.client, self.key)
assert blocked.status_code == 429, (
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
)
sibling = self.client.chat(
self._sibling_key,
"claude-haiku-4-5",
f"spend {unique_marker()}",
max_tokens=16,
)
_assert_blocked_429(client, spender_key)
sibling = _chat(client, sibling_key)
assert is_budget_block(sibling) and sibling.status_code == 429, (
f"a sibling key on the capped team must get the same 429 budget_exceeded, "
f"got {sibling.status_code}: {sibling.body[:200]}"
)
@pytest.mark.covers("quota_management.budget.internal_user.blocks_over_limit")
def test_user_budget_enforced_across_all_their_keys(
self, client: BudgetClient, resources: ResourceManager
) -> None:
user_id = client.create_user(max_budget=TINY_CAP)
resources.defer(lambda: client.delete_user(user_id))
first_key = client.generate_key(user_id=user_id)
resources.defer(lambda: client.delete_key(first_key))
second_key = client.generate_key(user_id=user_id)
resources.defer(lambda: client.delete_key(second_key))
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}")
resources.defer(lambda: client.delete_team(team_id))
client.add_team_member(team_id, user_id)
team_key = client.generate_key(team_id=team_id, user_id=user_id)
resources.defer(lambda: client.delete_key(team_key))
class InternalUserBudgetCase(_BudgetCase):
"""A user's max_budget follows the person, not the key. The capped user holds
two personal keys (no team, no key budgets) plus a team-member key on an
uncapped team; once the first personal key is refused, the other two must be
refused as well - a second key is not a fresh allowance, and since #32005 the
user budget draws down team keys too. All refusals must be 429 budget_exceeded."""
def init(self) -> None:
user_id = self.client.create_user(max_budget=3e-6)
self._undo.append(lambda: self.client.delete_user(user_id))
self.key = self.client.generate_key(user_id=user_id)
self._undo.append(lambda: self.client.delete_key(self.key))
self._second_key = self.client.generate_key(user_id=user_id)
self._undo.append(lambda: self.client.delete_key(self._second_key))
team_id = self.client.create_team(alias=f"e2e-budget-team-{unique_marker()}")
self._undo.append(lambda: self.client.delete_team(team_id))
self.client.add_team_member(team_id, user_id)
self._team_key = self.client.generate_key(team_id=team_id, user_id=user_id)
self._undo.append(lambda: self.client.delete_key(self._team_key))
def run(self) -> None:
blocked = _assert_budget_blocks(self.client, self.key)
assert blocked.status_code == 429, (
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
)
for label, key in (("second personal key", self._second_key), ("team-member key", self._team_key)):
result = self.client.chat(key, "claude-haiku-4-5", f"spend {unique_marker()}", max_tokens=16)
_assert_blocked_429(client, first_key)
for label, key in (("second personal key", second_key), ("team-member key", team_key)):
result = _chat(client, key)
assert is_budget_block(result) and result.status_code == 429, (
f"the {label} of a user over budget must get the same 429 budget_exceeded, "
f"got {result.status_code}: {result.body[:200]}"
)
class EndUserBudgetCase(_BudgetCase):
def init(self) -> None:
@pytest.mark.covers("quota_management.budget.end_user.blocks_over_limit")
def test_end_user_budget_blocks_attributed_calls(
self, client: BudgetClient, resources: ResourceManager
) -> None:
customer = f"e2e-budget-cust-{unique_marker()}"
self.client.create_customer(customer, max_budget=3e-6)
self._undo.append(lambda: self.client.delete_customers([customer]))
self.key = self.client.generate_key(models=["claude-haiku-4-5"])
self._undo.append(lambda: self.client.delete_key(self.key))
self._customer = customer
client.create_customer(customer, max_budget=TINY_CAP)
resources.defer(lambda: client.delete_customers([customer]))
key = client.generate_key(models=["claude-haiku-4-5"])
resources.defer(lambda: client.delete_key(key))
def run(self) -> None:
_assert_budget_blocks(self.client, self.key, user=self._customer)
_assert_budget_blocks(client, key, user=customer)
@pytest.mark.covers("quota_management.budget.organization.blocks_over_limit")
def test_org_budget_blocks_keys_under_it(self, client: BudgetClient, resources: ResourceManager) -> None:
org_id = client.create_org(max_budget=TINY_CAP, alias=f"e2e-budget-org-{unique_marker()}")
resources.defer(lambda: client.delete_org(org_id))
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}", organization_id=org_id)
resources.defer(lambda: client.delete_team(team_id))
key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(key))
class OrganizationBudgetCase(_BudgetCase):
"""Org carries the tiny budget; the team under it and the key carry none, so
the org is the only entity that can block (the historically weak link). The
refusal must be a 429 budget_exceeded that names the org as the blocker."""
def init(self) -> None:
self._org_id = self.client.create_org(
max_budget=3e-6, alias=f"e2e-budget-org-{unique_marker()}"
)
self._undo.append(lambda: self.client.delete_org(self._org_id))
team_id = self.client.create_team(
alias=f"e2e-budget-team-{unique_marker()}", organization_id=self._org_id
)
self._undo.append(lambda: self.client.delete_team(team_id))
self.key = self.client.generate_key(team_id=team_id)
self._undo.append(lambda: self.client.delete_key(self.key))
def run(self) -> None:
blocked = _assert_budget_blocks(self.client, self.key)
assert blocked.status_code == 429, (
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
)
assert f"Organization={self._org_id}" in blocked.body, (
blocked = _assert_blocked_429(client, key)
assert f"Organization={org_id}" in blocked.body, (
f"refusal must name the org as the blocker, got: {blocked.body[:200]}"
)
@pytest.mark.covers("quota_management.budget.team_member.blocks_over_limit")
def test_member_budget_blocks_without_touching_teammates(
self, client: BudgetClient, resources: ResourceManager
) -> None:
team_id = client.create_team(alias=f"e2e-budget-team-{unique_marker()}", max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_team(team_id))
member_id = client.create_user(max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_user(member_id))
client.add_team_member(team_id, member_id, max_budget_in_team=TINY_CAP)
member_key = client.generate_key(team_id=team_id, user_id=member_id)
resources.defer(lambda: client.delete_key(member_key))
teammate_id = client.create_user(max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_user(teammate_id))
client.add_team_member(team_id, teammate_id)
teammate_key = client.generate_key(team_id=team_id, user_id=teammate_id)
resources.defer(lambda: client.delete_key(teammate_key))
class TeamMemberBudgetCase(_BudgetCase):
"""Member A's per-team budget is tiny while the team and both members' user
budgets are roomy (100.0), so the only cap that can trip is A's: a block
proves member-level enforcement and must be a 429 budget_exceeded. Teammate
B, uncapped on the same team, must keep serving after A is cut off, proving
the member cap does not leak onto the team or its members."""
def init(self) -> None:
self._team_id = self.client.create_team(
alias=f"e2e-budget-team-{unique_marker()}", max_budget=100.0
)
self._undo.append(lambda: self.client.delete_team(self._team_id))
self._member_id = self.client.create_user(max_budget=100.0)
self._undo.append(lambda: self.client.delete_user(self._member_id))
self.client.add_team_member(self._team_id, self._member_id, max_budget_in_team=3e-6)
self.key = self.client.generate_key(team_id=self._team_id, user_id=self._member_id)
self._undo.append(lambda: self.client.delete_key(self.key))
teammate_id = self.client.create_user(max_budget=100.0)
self._undo.append(lambda: self.client.delete_user(teammate_id))
self.client.add_team_member(self._team_id, teammate_id)
self._teammate_key = self.client.generate_key(team_id=self._team_id, user_id=teammate_id)
self._undo.append(lambda: self.client.delete_key(self._teammate_key))
def run(self) -> None:
blocked = _assert_budget_blocks(self.client, self.key)
assert blocked.status_code == 429, (
f"budget refusal must be 429, got {blocked.status_code}: {blocked.body[:200]}"
)
teammate = self.client.chat(
self._teammate_key,
"claude-haiku-4-5",
f"spend {unique_marker()}",
max_tokens=16,
)
require_successful_call(teammate)
_assert_blocked_429(client, member_key)
require_successful_call(_chat(client, teammate_key))
def _case_id(case_cls: Type[_BudgetCase]) -> str:
return case_cls.__name__
class TestKeyBudgetBlocksAcrossKeyKinds:
"""The tiny max_budget sits on the key itself while every budget around it
(user / team / membership) is roomy, so only the key-level cap can block; the
uncapped control key minted to the same surroundings must keep serving after
the capped key is refused, proving nothing around the key was the blocker."""
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
def test_personal_key_blocks_over_its_own_budget(
self, client: BudgetClient, resources: ResourceManager
) -> None:
user_id = client.create_user(max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_user(user_id))
capped_key = client.generate_key(user_id=user_id, max_budget=TINY_CAP)
resources.defer(lambda: client.delete_key(capped_key))
control_key = client.generate_key(user_id=user_id)
resources.defer(lambda: client.delete_key(control_key))
@pytest.mark.parametrize(
"case_cls",
[
pytest.param(
KeyBudgetCase,
marks=pytest.mark.covers("quota_management.budget.key.blocks_over_limit"),
),
pytest.param(
TeamBudgetCase,
marks=pytest.mark.covers("quota_management.budget.team.blocks_over_limit"),
),
pytest.param(
InternalUserBudgetCase,
marks=pytest.mark.covers("quota_management.budget.internal_user.blocks_over_limit"),
),
pytest.param(
EndUserBudgetCase,
marks=pytest.mark.covers("quota_management.budget.end_user.blocks_over_limit"),
),
pytest.param(
OrganizationBudgetCase,
marks=pytest.mark.covers("quota_management.budget.organization.blocks_over_limit"),
),
pytest.param(
TeamMemberBudgetCase,
marks=pytest.mark.covers("quota_management.budget.team_member.blocks_over_limit"),
),
],
ids=_case_id,
)
def test_budget_enforcement(
client: BudgetClient, case_cls: Type[_BudgetCase]
) -> None:
run_case(case_cls(client))
_assert_blocked_429(client, capped_key)
require_successful_call(_chat(client, control_key))
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
def test_team_key_blocks_over_its_own_budget(self, client: BudgetClient, resources: ResourceManager) -> None:
team_id = client.create_team(alias=f"e2e-key-cap-team-{unique_marker()}", max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_team(team_id))
capped_key = client.generate_key(team_id=team_id, max_budget=TINY_CAP)
resources.defer(lambda: client.delete_key(capped_key))
control_key = client.generate_key(team_id=team_id)
resources.defer(lambda: client.delete_key(control_key))
_assert_blocked_429(client, capped_key)
require_successful_call(_chat(client, control_key))
@pytest.mark.covers("quota_management.budget.key.blocks_over_limit")
def test_team_member_key_blocks_over_its_own_budget(
self, client: BudgetClient, resources: ResourceManager
) -> None:
team_id = client.create_team(alias=f"e2e-key-cap-team-{unique_marker()}", max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_team(team_id))
member_id = client.create_user(max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_user(member_id))
client.add_team_member(team_id, member_id, max_budget_in_team=ROOMY_CAP)
capped_key = client.generate_key(team_id=team_id, user_id=member_id, max_budget=TINY_CAP)
resources.defer(lambda: client.delete_key(capped_key))
control_key = client.generate_key(team_id=team_id, user_id=member_id)
resources.defer(lambda: client.delete_key(control_key))
_assert_blocked_429(client, capped_key)
require_successful_call(_chat(client, control_key))

View file

@ -12,6 +12,7 @@ from lifecycle import ResourceManager
pytestmark = pytest.mark.e2e
TINY_CAP = 3e-6
ROOMY_CAP = 100.0
WINDOW = "30s"
RESET_DEADLINE_SECONDS = 150
@ -46,7 +47,7 @@ def _poll_until_serves_again(client: BudgetClient, key: str) -> None:
pytest.fail(f"budget never reset within {RESET_DEADLINE_SECONDS}s")
class TestBudgetResetDiagonal:
class TestBudgetResetPerLevel:
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
def test_bare_key_budget_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
key = client.generate_key(max_budget=TINY_CAP, budget_duration=WINDOW)
@ -115,3 +116,42 @@ class TestBudgetResetDiagonal:
_drive_to_block(client, key)
_poll_until_serves_again(client, key)
class TestKeyBudgetResetAcrossKeyKinds:
"""The tiny max_budget and its 30s window sit on the key itself while the user,
team, and membership around it are roomy (100.0), so the key's own budget is
the only thing that can block and the only thing that has to reset."""
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
def test_personal_key_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
user_id = client.create_user(max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_user(user_id))
key = client.generate_key(user_id=user_id, max_budget=TINY_CAP, budget_duration=WINDOW)
resources.defer(lambda: client.delete_key(key))
_drive_to_block(client, key)
_poll_until_serves_again(client, key)
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
def test_team_key_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
team_id = client.create_team(alias=f"e2e-key-reset-team-{unique_marker()}", max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_team(team_id))
key = client.generate_key(team_id=team_id, max_budget=TINY_CAP, budget_duration=WINDOW)
resources.defer(lambda: client.delete_key(key))
_drive_to_block(client, key)
_poll_until_serves_again(client, key)
@pytest.mark.covers("quota_management.budget.key.resets_after_window")
def test_team_member_key_resets_after_window(self, client: BudgetClient, resources: ResourceManager) -> None:
team_id = client.create_team(alias=f"e2e-key-reset-team-{unique_marker()}", max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_team(team_id))
member_id = client.create_user(max_budget=ROOMY_CAP)
resources.defer(lambda: client.delete_user(member_id))
client.add_team_member(team_id, member_id, max_budget_in_team=ROOMY_CAP)
key = client.generate_key(team_id=team_id, user_id=member_id, max_budget=TINY_CAP, budget_duration=WINDOW)
resources.defer(lambda: client.delete_key(key))
_drive_to_block(client, key)
_poll_until_serves_again(client, key)

View file

@ -23,7 +23,7 @@ import pytest
from e2e_http import Result, Success
from lifecycle import ResourceManager
from models import ChatResponse, SpendLogs, SpendLogsParams
from models import ChatResponse, LiteLLMParamsBody, SpendLogs, SpendLogsParams
from spend_e2e_client import SpendClient, SpendLogRow, is_ok, unique_marker, unwrap
pytestmark = pytest.mark.e2e
@ -232,14 +232,12 @@ def test_cache_hit_is_zero_cost_and_suffixed(
rows = client.poll_logs_for_key(
scoped_key, predicate=lambda rs: any(r.cache_hit == "True" for r in rs)
)
cache_rows = [r for r in rows if r.cache_hit == "True"]
if not cache_rows:
pytest.skip(
"no cache-hit row observed; caching may be disabled on this proxy. "
f"rows seen: {_summarize(rows)}"
)
cache_row = cache_rows[0]
cache_row = _require_row(
rows,
lambda r: r.cache_hit == "True",
"with cache_hit=True (caching is enabled on the e2e proxy, so an identical "
"repeat call must hit the cache)",
)
assert (
cache_row.spend or 0
) == 0.0, f"cache hit was charged (double-charge regression): {_summarize(rows)}"
@ -504,22 +502,27 @@ def test_each_model_on_a_shared_key_gets_its_own_row(
@pytest.mark.covers("quota_management.spend_tracking.failure.writes_failure_row")
def test_failure_call_writes_failure_status_row(
client: SpendClient, scoped_key: str
client: SpendClient, resources: ResourceManager, scoped_key: str
) -> None:
result = client.chat(scoped_key, "gemini-2.5-flash", "", max_tokens=1)
if is_ok(result):
pytest.skip("call unexpectedly succeeded; could not induce a failure row")
model = f"e2e-spend-failure-{unique_marker()}"
model_id = client.proxy.create_model(
model,
LiteLLMParamsBody(model="openai/gpt-5.5", api_key="sk-invalid-e2e-failure-row"),
)
resources.defer(lambda: client.proxy.delete_model(model_id))
result = client.chat(scoped_key, model, f"trigger failure {unique_marker()}", max_tokens=1)
assert not is_ok(result), (
f"a call to a deployment with an invalid upstream key must fail, not succeed: {result}"
)
rows = client.poll_logs_for_key(
scoped_key, predicate=lambda rs: any(r.status == "failure" for r in rs)
)
failure_rows = [r for r in rows if r.status == "failure"]
if not failure_rows:
pytest.skip(
"no failure-status row was logged for the rejected call; "
"failure logging is environment-specific"
)
assert (failure_rows[0].spend or 0) == 0.0, "failed call must not be charged"
failure_row = _require_row(
rows, lambda r: r.status == "failure", "with status=failure for the rejected call"
)
assert (failure_row.spend or 0) == 0.0, "failed call must not be charged"
@pytest.mark.covers("quota_management.spend_tracking.spend_calculate.returns_cost")

View file

@ -45,21 +45,14 @@ class TestFilterAnthropicOutputSchema:
assert "minimum value: 0" in result["properties"]["age"]["description"]
assert "maximum value: 150" in result["properties"]["age"]["description"]
# Score had no description, should get one from constraints
assert (
"exclusive minimum value: 0" in result["properties"]["score"]["description"]
)
assert (
"exclusive maximum value: 100"
in result["properties"]["score"]["description"]
)
assert "exclusive minimum value: 0" in result["properties"]["score"]["description"]
assert "exclusive maximum value: 100" in result["properties"]["score"]["description"]
def test_removes_string_constraints(self):
"""Test that minLength/maxLength are removed from string schemas."""
schema = {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 100}
},
"properties": {"name": {"type": "string", "minLength": 1, "maxLength": 100}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
@ -154,3 +147,203 @@ class TestFilterAnthropicOutputSchema:
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result == schema # Should be unchanged
def test_removes_uniqueitems(self):
"""Test that uniqueItems is removed from array schemas.
Reproduces the 400 ``invalid_request_error``:
"output_format.schema: For 'array' type, property 'uniqueItems' is not
supported".
"""
schema = {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": True,
}
},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "uniqueItems" not in result["properties"]["tags"]
assert result["properties"]["tags"]["items"] == {"type": "string"}
# Constraint intent preserved in the description
assert "all array items must be unique" in result["properties"]["tags"]["description"]
def test_removes_contains_constraints(self):
"""Test that contains/minContains/maxContains are removed from arrays."""
schema = {
"type": "array",
"items": {"type": "integer"},
"contains": {"type": "integer", "const": 1},
"minContains": 1,
"maxContains": 3,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "contains" not in result
assert "minContains" not in result
assert "maxContains" not in result
assert result["items"] == {"type": "integer"}
# The contains sub-schema is serialized into the advisory note so the model
# knows what item the array must contain.
assert "array must contain an item matching:" in result["description"]
assert '"const": 1' in result["description"]
assert "minimum number of matching items: 1" in result["description"]
assert "maximum number of matching items: 3" in result["description"]
def test_removes_object_property_constraints(self):
"""Test that minProperties/maxProperties are removed from object schemas."""
schema = {
"type": "object",
"properties": {"a": {"type": "string"}},
"minProperties": 1,
"maxProperties": 5,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "minProperties" not in result
assert "maxProperties" not in result
assert "minimum number of properties: 1" in result["description"]
assert "maximum number of properties: 5" in result["description"]
def test_uniqueitems_false_skips_misleading_note(self):
"""``uniqueItems: false`` is stripped but must not add a 'unique' note."""
schema = {
"type": "array",
"items": {"type": "string"},
"uniqueItems": False,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "uniqueItems" not in result
# A disabled constraint imposes no requirement -> no advisory note
assert "unique" not in result.get("description", "")
def test_removes_multipleof(self):
"""multipleOf is rejected by Anthropic for integer and number types."""
schema = {
"type": "object",
"properties": {"n": {"type": "integer", "multipleOf": 5}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "multipleOf" not in result["properties"]["n"]
assert "must be a multiple of 5" in result["properties"]["n"]["description"]
def test_removes_conditional_and_negation_keywords(self):
"""if/then/else and not are rejected by Anthropic and stripped into notes."""
schema = {
"type": "object",
"properties": {"kind": {"type": "string"}, "sound": {"type": "string", "not": {"const": "moo"}}},
"if": {"properties": {"kind": {"const": "dog"}}},
"then": {"required": ["sound"]},
"else": {"required": ["kind"]},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "if" not in result
assert "then" not in result
assert "else" not in result
assert "not" not in result["properties"]["sound"]
assert 'conditional (if): {"properties": {"kind": {"const": "dog"}}}' in result["description"]
assert 'conditional (then): {"required": ["sound"]}' in result["description"]
assert 'conditional (else): {"required": ["kind"]}' in result["description"]
assert 'must not match: {"const": "moo"}' in result["properties"]["sound"]["description"]
def test_removes_object_shape_keywords(self):
"""patternProperties/propertyNames/dependent*/unevaluatedProperties are stripped."""
schema = {
"type": "object",
"properties": {"first": {"type": "string"}},
"patternProperties": {"^x": {"type": "string"}},
"propertyNames": {"pattern": "^[a-z]+$"},
"dependentRequired": {"first": ["last"]},
"dependentSchemas": {"first": {"required": ["last"]}},
"unevaluatedProperties": {"type": "string"},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
for field in (
"patternProperties",
"propertyNames",
"dependentRequired",
"dependentSchemas",
"unevaluatedProperties",
):
assert field not in result
assert 'properties whose names match each pattern must satisfy: {"^x": {"type": "string"}}' in result["description"]
assert 'property names must satisfy: {"pattern": "^[a-z]+$"}' in result["description"]
assert 'dependent required properties: {"first": ["last"]}' in result["description"]
assert 'dependent schemas: {"first": {"required": ["last"]}}' in result["description"]
assert 'unevaluated properties must satisfy: {"type": "string"}' in result["description"]
def test_removes_prefixitems(self):
"""prefixItems is rejected by Anthropic for array types."""
schema = {
"type": "array",
"prefixItems": [{"type": "number"}, {"type": "string"}],
"items": {"type": "number"},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "prefixItems" not in result
assert result["items"] == {"type": "number"}
assert 'leading items must match, in order: [{"type": "number"}, {"type": "string"}]' in result["description"]
def test_oneof_rewritten_to_anyof(self):
"""oneOf 400s ("Schema type 'oneOf' is not supported") and becomes anyOf, like the SDK."""
schema = {
"type": "object",
"properties": {"id": {"oneOf": [{"type": "string", "minLength": 1}, {"type": "integer"}]}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
id_schema = result["properties"]["id"]
assert "oneOf" not in id_schema
assert [v["type"] for v in id_schema["anyOf"]] == ["string", "integer"]
assert "minLength" not in id_schema["anyOf"][0]
assert "minimum length: 1" in id_schema["anyOf"][0]["description"]
def test_oneof_merges_into_existing_anyof(self):
schema = {
"anyOf": [{"type": "string"}],
"oneOf": [{"type": "integer"}],
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "oneOf" not in result
assert [v["type"] for v in result["anyOf"]] == ["string", "integer"]
def test_constraint_note_order_is_deterministic(self):
"""Note order must not depend on set iteration order (PYTHONHASHSEED), or the
serialized request differs across proxy workers and breaks caching."""
schema = {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 10,
"uniqueItems": True,
"minContains": 2,
"maxContains": 3,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["description"] == (
"Note: minimum number of items: 1, maximum number of items: 10, "
"all array items must be unique, minimum number of matching items: 2, "
"maximum number of matching items: 3."
)

View file

@ -0,0 +1,143 @@
"""Image-level regression net for the prisma bake in the shipped runtime image.
Boots a built image's migration entrypoint the way an OpenShift / air-gapped
deployment does (an internal-only network with no egress, an arbitrary non-root
uid in GID 0) against a brand-new Postgres, and asserts the schema was created.
This catches the whole failure class, not one symptom: a bake that only works
under `docker run` as the default uid with network still passes every existing
check, because the migration entrypoint exits 0 even when it applied nothing.
Asserting the table count is what turns that silent success into a hard fail.
Gated on LITELLM_IMAGE (the tag of the image to exercise) so it is skipped in
the normal unit-test run and exercised only where an image has been built (the
image-scan workflow). Requires a working docker CLI.
"""
import shutil
import subprocess
import uuid
import os
import pytest
IMAGE = os.getenv("LITELLM_IMAGE")
POSTGRES_IMAGE = os.getenv("LITELLM_TEST_POSTGRES_IMAGE", "postgres:16-alpine")
MIN_TABLES = int(os.getenv("LITELLM_TEST_MIN_TABLES", "20"))
NON_ROOT_UID = "12345:0" # arbitrary uid in GID 0, as OpenShift restricted-v2 assigns
pytestmark = [
pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"),
pytest.mark.skipif(shutil.which("docker") is None, reason="requires the docker CLI"),
]
def _docker(*args: str, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
["docker", *args], capture_output=True, text=True, check=check
)
@pytest.fixture()
def offline_postgres():
"""A fresh Postgres reachable only over an internal-only (no egress) network.
Yields (network_name, postgres_host). Both are torn down afterwards.
"""
run_id = f"offlinemig-{uuid.uuid4().hex[:8]}"
network = f"{run_id}-net"
pg = f"{run_id}-pg"
# Pull Postgres while egress still exists; the internal network below has none.
_docker("pull", "--quiet", POSTGRES_IMAGE)
# --internal => containers on this network cannot reach the internet, so a
# prisma engine download (binaries.prisma.sh / npm) fails instead of masking
# a non-self-contained bake.
_docker("network", "create", "--internal", network)
try:
_docker(
"run", "-d", "--name", pg, "--network", network,
"-e", "POSTGRES_PASSWORD=pw", "-e", "POSTGRES_DB=litellm",
POSTGRES_IMAGE,
)
_wait_until_ready(pg)
yield network, pg
finally:
_docker("rm", "-f", pg, check=False)
_docker("network", "rm", network, check=False)
def _wait_until_ready(pg: str, attempts: int = 60) -> None:
for _ in range(attempts):
running = _docker(
"ps", "--filter", f"name={pg}", "--filter", "status=running",
"--format", "{{.Names}}", check=False,
).stdout
if pg not in running:
logs = _docker("logs", pg, check=False).stdout + _docker("logs", pg, check=False).stderr
pytest.fail(f"postgres container is not running:\n{logs}")
ready = _docker(
"exec", pg, "pg_isready", "-U", "postgres", "-d", "litellm", check=False
)
if ready.returncode == 0:
return
subprocess.run(["sleep", "1"])
pytest.fail(f"postgres never became ready after {attempts}s")
def _table_count(pg: str) -> int:
result = _docker(
"exec", pg, "psql", "-U", "postgres", "-d", "litellm", "-tAc",
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public';",
)
return int(result.stdout.strip() or "0")
def test_migration_offline_as_non_root_uid(offline_postgres):
"""The migration entrypoint creates the full schema offline as an arbitrary uid.
Reproduces the OpenShift / air-gapped failure: on the pre-fix image the
migration exits 0 having created 0 tables (every DB endpoint then 500s on
missing columns); a self-contained bake creates the full schema.
"""
network, pg = offline_postgres
assert IMAGE is not None
migrate = _docker(
"run", "--rm", "--network", network, "--user", NON_ROOT_UID,
"-e", f"DATABASE_URL=postgresql://postgres:pw@{pg}:5432/litellm",
"-e", "LITELLM_MASTER_KEY=sk-offline-migration-test",
"-e", "DISABLE_SCHEMA_UPDATE=false",
"-w", "/app", "--entrypoint", "python",
IMAGE, "litellm/proxy/prisma_migration.py",
check=False,
)
tables = _table_count(pg)
assert migrate.returncode == 0, (
f"migration entrypoint exited {migrate.returncode} offline as uid {NON_ROOT_UID}\n"
f"stdout:\n{migrate.stdout}\nstderr:\n{migrate.stderr}"
)
assert tables >= MIN_TABLES, (
f"only {tables} tables created (need >= {MIN_TABLES}) offline as uid {NON_ROOT_UID}. "
"The prisma bake is not self-contained: it needs a runtime download or a "
"writable HOME/cache, so OpenShift and air-gapped deployments start on an "
f"empty database.\nstdout:\n{migrate.stdout}\nstderr:\n{migrate.stderr}"
)
def test_runtime_cache_env_not_read_only():
"""No runtime cache env var may point at the world-read-only /opt/prisma bake.
/opt/prisma is baked `a+rX` (no write). Pointing XDG_CACHE_HOME (or any cache
var an XDG-aware library honours) there would deny writes for every uid, so
guard against a future edit reintroducing that.
"""
assert IMAGE is not None
env = _docker("run", "--rm", "--entrypoint", "env", IMAGE).stdout
offenders = [
line for line in env.splitlines()
if line.startswith(("XDG_CACHE_HOME=", "XDG_DATA_HOME=", "HOME="))
and line.split("=", 1)[1].startswith("/opt/prisma")
]
assert not offenders, f"cache/home env points at the read-only bake: {offenders}"

View file

@ -1252,6 +1252,17 @@ async def test_create_team_member_add(prisma_client, new_member_method):
return_value=LiteLLM_TeamTableCachedObj(team_id="1234")
)
tx_mock = AsyncMock()
tx_mock.query_raw = AsyncMock(return_value=[{"members_with_roles": []}])
tx_mock.litellm_teamtable = team_mock_client
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=tx_mock)
tx_cm.__aexit__ = AsyncMock(return_value=None)
original_tx = litellm.proxy.proxy_server.prisma_client.tx
litellm.proxy.proxy_server.prisma_client.tx = MagicMock(
return_value=tx_cm
)
print(f"team_member_add_request={team_member_add_request}")
await team_member_add(
data=team_member_add_request,
@ -1273,6 +1284,7 @@ async def test_create_team_member_add(prisma_client, new_member_method):
)
litellm.proxy.proxy_server.prisma_client.db.litellm_teamtable = original_val
litellm.proxy.proxy_server.prisma_client.tx = original_tx
@pytest.mark.parametrize("team_member_role", ["admin", "user"])
@ -1434,42 +1446,51 @@ async def test_create_team_member_add_team_admin(
mock_litellm_usertable.find_unique = AsyncMock(return_value=None)
team_mock_client = AsyncMock()
original_val = getattr(
litellm.proxy.proxy_server.prisma_client.db, "litellm_teamtable"
)
litellm.proxy.proxy_server.prisma_client.db.litellm_teamtable = team_mock_client
team_mock_client.update = AsyncMock(
return_value=LiteLLM_TeamTableCachedObj(team_id="1234")
)
try:
await team_member_add(
data=team_member_add_request,
user_api_key_dict=valid_token,
tx_mock = AsyncMock()
tx_mock.query_raw = AsyncMock(return_value=[{"members_with_roles": []}])
tx_mock.litellm_teamtable = team_mock_client
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=tx_mock)
tx_cm.__aexit__ = AsyncMock(return_value=None)
with (
patch.object(
litellm.proxy.proxy_server.prisma_client.db,
"litellm_teamtable",
team_mock_client,
),
patch.object(
litellm.proxy.proxy_server.prisma_client,
"tx",
MagicMock(return_value=tx_cm),
),
):
try:
await team_member_add(
data=team_member_add_request,
user_api_key_dict=valid_token,
)
except HTTPException as e:
if user_role == "user":
assert e.status_code == 403
return
else:
raise e
mock_client.assert_called()
assert (
mock_client.call_args.kwargs["data"]["create"]["max_budget"]
== litellm.max_internal_user_budget
)
assert (
mock_client.call_args.kwargs["data"]["create"]["budget_duration"]
== litellm.internal_user_budget_duration
)
except HTTPException as e:
if user_role == "user":
assert e.status_code == 403
return
else:
raise e
mock_client.assert_called()
print(f"mock_client.call_args: {mock_client.call_args}")
print("mock_client.call_args.kwargs: {}".format(mock_client.call_args.kwargs))
assert (
mock_client.call_args.kwargs["data"]["create"]["max_budget"]
== litellm.max_internal_user_budget
)
assert (
mock_client.call_args.kwargs["data"]["create"]["budget_duration"]
== litellm.internal_user_budget_duration
)
litellm.proxy.proxy_server.prisma_client.db.litellm_teamtable = original_val
@pytest.mark.asyncio

View file

@ -1,8 +1,13 @@
import unittest
from datetime import datetime, time, timezone
from unittest.mock import patch
from zoneinfo import ZoneInfo
from litellm.litellm_core_utils.duration_parser import get_next_standardized_reset_time
import litellm.litellm_core_utils.duration_parser as duration_parser
from litellm.litellm_core_utils.duration_parser import (
duration_in_seconds,
get_next_standardized_reset_time,
)
class TestStandardizedResetTime(unittest.TestCase):
@ -316,5 +321,69 @@ class TestResetTimeOfDay(unittest.TestCase):
)
class TestWordFormBudgetDurations(unittest.TestCase):
"""The Admin UI historically persisted word-form budget durations
(hourly/daily/weekly/monthly). They must resolve to their real interval
instead of silently collapsing to a next-midnight (daily) reset.
"""
def test_word_forms_map_to_correct_reset_times(self):
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
self.assertEqual(
get_next_standardized_reset_time("hourly", base_time, "UTC"),
datetime(2023, 5, 17, 16, 0, 0, tzinfo=timezone.utc),
)
self.assertEqual(
get_next_standardized_reset_time("daily", base_time, "UTC"),
datetime(2023, 5, 18, 0, 0, 0, tzinfo=timezone.utc),
)
self.assertEqual(
get_next_standardized_reset_time("weekly", base_time, "UTC"),
datetime(2023, 5, 22, 0, 0, 0, tzinfo=timezone.utc),
)
self.assertEqual(
get_next_standardized_reset_time("monthly", base_time, "UTC"),
datetime(2023, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
)
def test_word_forms_are_not_all_collapsed_to_daily(self):
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
results = {
word: get_next_standardized_reset_time(word, base_time, "UTC")
for word in ("hourly", "daily", "weekly", "monthly")
}
self.assertEqual(len(set(results.values())), len(results))
def test_word_forms_match_canonical_int_unit_forms(self):
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
for word, canonical in (("hourly", "1h"), ("daily", "24h"), ("weekly", "7d"), ("monthly", "30d")):
self.assertEqual(
get_next_standardized_reset_time(word, base_time, "UTC"),
get_next_standardized_reset_time(canonical, base_time, "UTC"),
)
def test_word_forms_are_case_and_whitespace_insensitive(self):
base_time = datetime(2023, 5, 17, 15, 20, 30, tzinfo=timezone.utc)
self.assertEqual(
get_next_standardized_reset_time(" Monthly ", base_time, "UTC"),
datetime(2023, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
)
def test_duration_in_seconds_accepts_word_forms(self):
self.assertEqual(duration_in_seconds("hourly"), 3600)
self.assertEqual(duration_in_seconds("daily"), 86400)
self.assertEqual(duration_in_seconds("weekly"), 604800)
self.assertEqual(duration_in_seconds("monthly"), 2592000)
def test_invalid_duration_logs_warning_and_falls_back(self):
base_time = datetime(2023, 5, 15, 15, 0, 0, tzinfo=timezone.utc)
with patch.object(duration_parser.verbose_logger, "warning") as mock_warning:
result = get_next_standardized_reset_time("garbage", base_time, "UTC")
self.assertEqual(result, datetime(2023, 5, 16, 0, 0, 0, tzinfo=timezone.utc))
mock_warning.assert_called_once()
self.assertIn("garbage", mock_warning.call_args.args)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,147 @@
"""
Coverage for filter_anthropic_output_schema's array/object constraint stripping.
Mirrors tests/litellm/llms/anthropic/test_anthropic_schema_filter.py, but lives
under tests/test_litellm/ so the coverage-uploading CI job exercises the stripped
keyword handling (uniqueItems / contains / minProperties / maxProperties plus
multipleOf / patternProperties / propertyNames / dependentRequired /
dependentSchemas / unevaluatedProperties / if / then / else / not / prefixItems),
the ``uniqueItems: false`` branch, the oneOf to anyOf rewrite, and the
deterministic note ordering.
"""
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
class TestOutputFormatArrayObjectConstraints:
def test_removes_uniqueitems(self):
schema = {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string"},
"uniqueItems": True,
}
},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "uniqueItems" not in result["properties"]["tags"]
assert "all array items must be unique" in result["properties"]["tags"]["description"]
def test_uniqueitems_false_skips_misleading_note(self):
schema = {
"type": "array",
"items": {"type": "string"},
"uniqueItems": False,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "uniqueItems" not in result
assert "unique" not in result.get("description", "")
def test_removes_contains_constraints(self):
schema = {
"type": "array",
"items": {"type": "integer"},
"contains": {"type": "integer", "const": 1},
"minContains": 1,
"maxContains": 3,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "contains" not in result
assert "minContains" not in result
assert "maxContains" not in result
assert "array must contain an item matching:" in result["description"]
assert '"const": 1' in result["description"]
def test_removes_object_property_constraints(self):
schema = {
"type": "object",
"properties": {"a": {"type": "string"}},
"minProperties": 1,
"maxProperties": 5,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert "minProperties" not in result
assert "maxProperties" not in result
assert "minimum number of properties: 1" in result["description"]
assert "maximum number of properties: 5" in result["description"]
def test_removes_remaining_rejected_keywords(self):
schema = {
"type": "object",
"properties": {
"n": {"type": "integer", "multipleOf": 5},
"pair": {"type": "array", "prefixItems": [{"type": "number"}], "items": {"type": "number"}},
"color": {"type": "string", "not": {"const": "red"}},
},
"patternProperties": {"^x": {"type": "string"}},
"propertyNames": {"pattern": "^[a-z]+$"},
"dependentRequired": {"n": ["pair"]},
"dependentSchemas": {"n": {"required": ["pair"]}},
"unevaluatedProperties": {"type": "string"},
"if": {"properties": {"n": {"const": 5}}},
"then": {"required": ["pair"]},
"else": {"required": ["color"]},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
for field in (
"patternProperties",
"propertyNames",
"dependentRequired",
"dependentSchemas",
"unevaluatedProperties",
"if",
"then",
"else",
):
assert field not in result
assert "multipleOf" not in result["properties"]["n"]
assert "must be a multiple of 5" in result["properties"]["n"]["description"]
assert "prefixItems" not in result["properties"]["pair"]
assert 'leading items must match, in order: [{"type": "number"}]' in result["properties"]["pair"]["description"]
assert "not" not in result["properties"]["color"]
assert 'must not match: {"const": "red"}' in result["properties"]["color"]["description"]
assert 'conditional (if): {"properties": {"n": {"const": 5}}}' in result["description"]
def test_oneof_rewritten_to_anyof(self):
schema = {
"type": "object",
"properties": {"id": {"oneOf": [{"type": "string", "minLength": 1}, {"type": "integer"}]}},
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
id_schema = result["properties"]["id"]
assert "oneOf" not in id_schema
assert [v["type"] for v in id_schema["anyOf"]] == ["string", "integer"]
assert "minLength" not in id_schema["anyOf"][0]
def test_constraint_note_order_is_deterministic(self):
schema = {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 10,
"uniqueItems": True,
"minContains": 2,
"maxContains": 3,
}
result = AnthropicConfig.filter_anthropic_output_schema(schema)
assert result["description"] == (
"Note: minimum number of items: 1, maximum number of items: 10, "
"all array items must be unique, minimum number of matching items: 2, "
"maximum number of matching items: 3."
)

View file

@ -70,25 +70,33 @@ class TestAgentCoreAcceptHeader:
"""
End-to-end test: verify Accept header appears in the final HTTP request
when using JWT auth through litellm.completion().
No exception swallowing: if completion() raises (for example because the
injected client was silently ignored and a real network call was made),
the test must fail with that error, not a misleading mock assertion.
"""
from litellm.llms.custom_httpx.http_handler import HTTPHandler
client = HTTPHandler()
with patch.object(client, "post", return_value=MagicMock()) as mock_post:
try:
litellm.completion(
model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_runtime",
messages=[{"role": "user", "content": "test"}],
api_key="test-jwt-token",
client=client,
)
except Exception:
pass
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.json.return_value = {
"result": {"role": "assistant", "content": [{"text": "agent reply"}]}
}
mock_post.assert_called_once()
headers = mock_post.call_args.kwargs["headers"]
assert "Accept" in headers
assert headers["Accept"] == "application/json, text/event-stream"
with patch.object(client, "post", return_value=mock_response) as mock_post:
response = litellm.completion(
model="bedrock/agentcore/arn:aws:bedrock-agentcore:us-west-2:888602223428:runtime/test_runtime",
messages=[{"role": "user", "content": "test"}],
api_key="test-jwt-token",
client=client,
)
mock_post.assert_called_once()
headers = mock_post.call_args.kwargs["headers"]
assert headers["Accept"] == "application/json, text/event-stream"
assert response.choices[0].message.content == "agent reply"
class TestAgentCoreJsonResponseParsing:

View file

@ -633,7 +633,8 @@ def test_parallel_tool_calls_config_kept_for_sonnet_5():
)
assert data["additionalModelRequestFields"]["tool_choice"] == {
"disable_parallel_tool_use": True
"type": "auto",
"disable_parallel_tool_use": True,
}
finally:
litellm.model_cost = old_cost
@ -4251,6 +4252,49 @@ def test_parallel_tool_calls_older_model_drops_disable_flag():
assert "parallel_tool_calls" not in additional
@pytest.mark.parametrize(
"parallel_tool_calls, expected_disable",
[(True, False), (False, True)],
)
def test_parallel_tool_calls_emits_typed_auto_tool_choice(parallel_tool_calls, expected_disable):
config = AmazonConverseConfig()
model = "us.anthropic.claude-opus-4-8"
messages = [{"role": "user", "content": "What's the weather in SF and NYC?"}]
optional_params = config.map_openai_params(
non_default_params={"parallel_tool_calls": parallel_tool_calls, "tools": _TOOL_PARAM},
optional_params={},
model=model,
drop_params=False,
)
request_data = config.transform_request(
model=model,
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert request_data["additionalModelRequestFields"]["tool_choice"] == {
"type": "auto",
"disable_parallel_tool_use": expected_disable,
}
def test_parallel_tool_use_merge_preserves_user_tool_choice_type():
merged = AmazonConverseConfig._merge_parallel_tool_use_config(
{"tool_choice": {"type": "tool", "name": "get_weather", "disable_parallel_tool_use": False}},
{"tool_choice": {"type": "auto", "disable_parallel_tool_use": True}},
)
assert merged["tool_choice"] == {
"type": "tool",
"name": "get_weather",
"disable_parallel_tool_use": True,
}
class TestBedrockMinThinkingBudgetTokens:
"""Test that thinking.budget_tokens is clamped to the Bedrock minimum (1024)."""

View file

@ -1,4 +1,3 @@
import importlib
import json
import os
import sys
@ -16,22 +15,7 @@ MOCK_EMBEDDING_RESPONSE = [[0.1, 0.2, 0.3, 0.4, 0.5]]
@pytest.fixture
def reload_huggingface_modules():
"""
Reload modules to ensure fresh references after conftest reloads litellm.
This ensures the HTTPHandler class being patched is the same one used by
the embedding handler during parallel test execution.
"""
import litellm.llms.custom_httpx.http_handler as http_handler_module
import litellm.llms.huggingface.embedding.handler as hf_embedding_handler_module
importlib.reload(http_handler_module)
importlib.reload(hf_embedding_handler_module)
yield
@pytest.fixture
def mock_embedding_http_handler(reload_huggingface_modules):
def mock_embedding_http_handler():
"""Fixture to mock the HTTP handler for embedding tests"""
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
mock_response = MagicMock()
@ -43,7 +27,7 @@ def mock_embedding_http_handler(reload_huggingface_modules):
@pytest.fixture
def mock_embedding_async_http_handler(reload_huggingface_modules):
def mock_embedding_async_http_handler():
"""Fixture to mock the async HTTP handler for embedding tests"""
with patch(
"litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",

View file

@ -0,0 +1,235 @@
"""
Regression tests for LIT-4313: sagemaker_chat streaming must forward each AWS
event-stream frame as it arrives instead of buffering to a fixed 1024-byte
threshold and then draining a burst of deltas.
The buffering came from `response.iter_bytes(chunk_size=1024)` /
`response.aiter_bytes(chunk_size=1024)`: httpx's ByteChunker withholds bytes until
`chunk_size` accumulates, so the first client delta could not be produced until
enough later frames had arrived to cross 1024 bytes, inflating TTFT and turning a
steady provider stream into gap-then-burst delivery.
"""
import binascii
import json
import struct
from typing import AsyncIterator, Iterator
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.sagemaker.chat.transformation import SagemakerChatConfig
def _encode_header(name: str, value: str) -> bytes:
name_b = name.encode("utf-8")
value_b = value.encode("utf-8")
return struct.pack("B", len(name_b)) + name_b + struct.pack("B", 7) + struct.pack(">H", len(value_b)) + value_b
def _encode_event_frame(payload: bytes) -> bytes:
"""Encode one AWS event-stream message that botocore's EventStreamBuffer decodes."""
headers = {
":event-type": "PayloadPart",
":content-type": "application/json",
":message-type": "event",
}
headers_b = b"".join(_encode_header(k, v) for k, v in headers.items())
total_len = 16 + len(headers_b) + len(payload)
prelude = struct.pack(">I", total_len) + struct.pack(">I", len(headers_b))
prelude_crc = struct.pack(">I", binascii.crc32(prelude) & 0xFFFFFFFF)
message = prelude + prelude_crc + headers_b + payload
message_crc = struct.pack(">I", binascii.crc32(message) & 0xFFFFFFFF)
return message + message_crc
def _delta_frame(index: int, content: str) -> bytes:
sse = (
"data: "
+ json.dumps(
{
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1700000000,
"choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}],
}
)
+ "\n\n"
)
return _encode_event_frame(sse.encode("utf-8"))
def _make_frames(n: int) -> list[bytes]:
# Small single-token frames (< 1024 bytes each) so a fixed 1024-byte chunker
# would have to swallow several frames before releasing the first delta.
frames = [_delta_frame(i, f"token{i} ") for i in range(n)]
assert all(len(f) < 1024 for f in frames)
return frames
class _CountingSyncStream(httpx.SyncByteStream):
"""Yields provider frames one at a time and records how many have been pulled."""
def __init__(self, frames: list[bytes]) -> None:
self._frames = frames
self.consumed = 0
def __iter__(self) -> Iterator[bytes]:
for frame in self._frames:
self.consumed += 1
yield frame
class _CountingAsyncStream(httpx.AsyncByteStream):
def __init__(self, frames: list[bytes]) -> None:
self._frames = frames
self.consumed = 0
async def __aiter__(self) -> AsyncIterator[bytes]:
for frame in self._frames:
self.consumed += 1
yield frame
class _FakeSyncClient:
def __init__(self, response: httpx.Response) -> None:
self._response = response
def post(self, *args, **kwargs) -> httpx.Response:
return self._response
class _FakeAsyncClient:
def __init__(self, response: httpx.Response) -> None:
self._response = response
async def post(self, *args, **kwargs) -> httpx.Response:
return self._response
def _content_of(chunk) -> str | None:
return chunk.choices[0].delta.content
def test_sync_first_event_emitted_after_a_single_frame():
"""The first delta must be available after exactly one source frame is pulled.
With the old chunk_size=1024 the httpx chunker would consume several small
frames before yielding, so `consumed` would be > 1 at the first delta.
"""
frames = _make_frames(24)
stream = _CountingSyncStream(frames)
response = httpx.Response(200, stream=stream)
wrapper = SagemakerChatConfig().get_sync_custom_stream_wrapper(
model="phi-4",
custom_llm_provider="sagemaker_chat",
logging_obj=MagicMock(),
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
headers={},
data={},
messages=[],
client=_FakeSyncClient(response),
)
first = next(c for c in wrapper.completion_stream if c is not None and _content_of(c) is not None)
assert _content_of(first) == "token0 "
assert stream.consumed == 1
def test_sync_events_emitted_incrementally_without_bursting():
"""Each successive delta must correspond to exactly one newly-pulled frame."""
frames = _make_frames(24)
stream = _CountingSyncStream(frames)
response = httpx.Response(200, stream=stream)
wrapper = SagemakerChatConfig().get_sync_custom_stream_wrapper(
model="phi-4",
custom_llm_provider="sagemaker_chat",
logging_obj=MagicMock(),
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
headers={},
data={},
messages=[],
client=_FakeSyncClient(response),
)
consumed_at_delta = [
stream.consumed for chunk in wrapper.completion_stream if chunk is not None and _content_of(chunk) is not None
]
assert consumed_at_delta == list(range(1, len(frames) + 1))
@pytest.mark.asyncio
async def test_async_first_event_emitted_after_a_single_frame():
frames = _make_frames(24)
stream = _CountingAsyncStream(frames)
response = httpx.Response(200, stream=stream)
wrapper = await SagemakerChatConfig().get_async_custom_stream_wrapper(
model="phi-4",
custom_llm_provider="sagemaker_chat",
logging_obj=MagicMock(),
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
headers={},
data={},
messages=[],
client=_FakeAsyncClient(response),
)
consumed_at_delta = []
async for chunk in wrapper.completion_stream:
if chunk is not None and _content_of(chunk) is not None:
consumed_at_delta.append(stream.consumed)
assert consumed_at_delta == list(range(1, len(frames) + 1))
def test_signed_body_includes_stream_flag():
"""A streaming request must carry `stream: true` in the signed body sent to SageMaker.
`stream` flows into the request body through the transformed request (`{**optional_params}`)
and must survive SigV4 signing so the endpoint enables token-level streaming.
"""
headers, signed_body = SagemakerChatConfig().sign_request(
headers={},
optional_params={
"aws_access_key_id": "AKIATESTTESTTESTTEST",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
request_data={"model": "phi-4", "messages": [{"role": "user", "content": "hi"}], "stream": True},
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
model="phi-4",
stream=True,
)
assert signed_body is not None
assert json.loads(signed_body)["stream"] is True
@pytest.mark.parametrize("split_size", [1, 3, 7, 64, 4096])
def test_decoder_reassembles_frames_across_arbitrary_byte_boundaries(split_size):
"""Correctness must not depend on chunk boundaries falling on frame edges.
Removing `chunk_size=1024` lets httpx yield raw transport reads, so in
production a single read can straddle several frames or split one frame in
half. This re-chunks the concatenated stream at boundaries that deliberately
ignore frame edges and asserts every delta still decodes, in order, exactly
once - the guarantee botocore's EventStreamBuffer provides.
"""
from litellm.llms.sagemaker.chat.transformation import AWSEventStreamDecoder
frames = _make_frames(24)
blob = b"".join(frames)
chunks = [blob[i : i + split_size] for i in range(0, len(blob), split_size)]
decoder = AWSEventStreamDecoder(model="phi-4", is_messages_api=True)
texts = [
_content_of(chunk)
for chunk in decoder.iter_bytes(iter(chunks))
if chunk is not None and _content_of(chunk) is not None
]
assert texts == [f"token{i} " for i in range(len(frames))]

View file

@ -0,0 +1,174 @@
"""
Regression tests for LIT-4313: the native `sagemaker/` streaming path must
forward each AWS event-stream frame as it arrives instead of buffering to a
fixed 1024-byte threshold and then draining a burst of tokens.
The buffering came from `response.aiter_bytes(chunk_size=1024)`: httpx's
ByteChunker withholds bytes until `chunk_size` accumulates, so the first token
could not be produced until enough later frames had arrived to cross 1024 bytes,
inflating TTFT and turning a steady provider stream into gap-then-burst delivery.
"""
import binascii
import json
import struct
from typing import AsyncIterator, Iterator
from unittest.mock import MagicMock
import httpx
import pytest
from litellm.llms.sagemaker.common_utils import SagemakerError
from litellm.llms.sagemaker.completion.handler import SagemakerLLM
def _encode_header(name: str, value: str) -> bytes:
name_b = name.encode("utf-8")
value_b = value.encode("utf-8")
return struct.pack("B", len(name_b)) + name_b + struct.pack("B", 7) + struct.pack(">H", len(value_b)) + value_b
def _encode_event_frame(payload: bytes) -> bytes:
"""Encode one AWS event-stream message that botocore's EventStreamBuffer decodes."""
headers = {
":event-type": "PayloadPart",
":content-type": "application/json",
":message-type": "event",
}
headers_b = b"".join(_encode_header(k, v) for k, v in headers.items())
total_len = 16 + len(headers_b) + len(payload)
prelude = struct.pack(">I", total_len) + struct.pack(">I", len(headers_b))
prelude_crc = struct.pack(">I", binascii.crc32(prelude) & 0xFFFFFFFF)
message = prelude + prelude_crc + headers_b + payload
message_crc = struct.pack(">I", binascii.crc32(message) & 0xFFFFFFFF)
return message + message_crc
def _token_frame(text: str) -> bytes:
# SageMaker HF TGI streaming payloads are `{"token": {"text": ...}}` blobs.
sse = "data: " + json.dumps({"token": {"text": text}}) + "\n\n"
return _encode_event_frame(sse.encode("utf-8"))
def _make_frames(n: int) -> list[bytes]:
frames = [_token_frame(f"token{i} ") for i in range(n)]
assert all(len(f) < 1024 for f in frames)
return frames
class _CountingSyncStream(httpx.SyncByteStream):
"""Yields provider frames one at a time and records how many have been pulled."""
def __init__(self, frames: list[bytes]) -> None:
self._frames = frames
self.consumed = 0
def __iter__(self) -> Iterator[bytes]:
for frame in self._frames:
self.consumed += 1
yield frame
class _CountingAsyncStream(httpx.AsyncByteStream):
"""Yields provider frames one at a time and records how many have been pulled."""
def __init__(self, frames: list[bytes]) -> None:
self._frames = frames
self.consumed = 0
async def __aiter__(self) -> AsyncIterator[bytes]:
for frame in self._frames:
self.consumed += 1
yield frame
class _FakeSyncClient:
def __init__(self, response: httpx.Response) -> None:
self._response = response
def post(self, *args, **kwargs) -> httpx.Response:
return self._response
class _FakeAsyncClient:
def __init__(self, response: httpx.Response) -> None:
self._response = response
async def post(self, *args, **kwargs) -> httpx.Response:
return self._response
def test_sync_native_streaming_forwards_each_frame_incrementally():
"""Each token must be emitted after exactly one newly-pulled source frame.
With the old `chunk_size=1024` the httpx chunker would swallow several small
frames before yielding, so the first token would arrive only after `consumed`
had already crossed multiple frames, and tokens would then replay in a burst.
"""
frames = _make_frames(24)
stream = _CountingSyncStream(frames)
response = httpx.Response(200, stream=stream)
completion_stream = SagemakerLLM().make_sync_call(
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
headers={},
data="",
logging_obj=MagicMock(),
client=_FakeSyncClient(response),
)
consumed_at_token = []
texts = []
for chunk in completion_stream:
if chunk is not None and chunk["text"]:
consumed_at_token.append(stream.consumed)
texts.append(chunk["text"])
assert texts == [f"token{i} " for i in range(len(frames))]
assert consumed_at_token == list(range(1, len(frames) + 1))
def test_sync_native_streaming_raises_sagemaker_error_on_non_200():
response = httpx.Response(500, text="boom")
with pytest.raises(SagemakerError) as exc_info:
SagemakerLLM().make_sync_call(
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
headers={},
data="",
logging_obj=MagicMock(),
client=_FakeSyncClient(response),
)
assert exc_info.value.status_code == 500
@pytest.mark.asyncio
async def test_async_native_streaming_forwards_each_frame_incrementally():
"""Each token must be emitted after exactly one newly-pulled source frame.
With the old `chunk_size=1024` the httpx chunker would swallow several small
frames before yielding, so the first token would arrive only after `consumed`
had already crossed multiple frames, and tokens would then replay in a burst.
"""
frames = _make_frames(24)
stream = _CountingAsyncStream(frames)
response = httpx.Response(200, stream=stream)
completion_stream = await SagemakerLLM().make_async_call(
api_base="https://runtime.sagemaker.us-east-1.amazonaws.com/endpoints/phi-4/invocations-response-stream",
headers={},
data="",
logging_obj=MagicMock(),
client=_FakeAsyncClient(response),
)
consumed_at_token = []
texts = []
async for chunk in completion_stream:
if chunk is not None and chunk["text"]:
consumed_at_token.append(stream.consumed)
texts.append(chunk["text"])
assert texts == [f"token{i} " for i in range(len(frames))]
assert consumed_at_token == list(range(1, len(frames) + 1))

View file

@ -3,26 +3,16 @@ Integration tests for Vertex AI rerank functionality.
These tests demonstrate end-to-end usage of the Vertex AI rerank feature.
"""
import importlib
from unittest.mock import MagicMock
import httpx
from litellm.llms.vertex_ai.rerank.transformation import VertexAIRerankConfig
class TestVertexAIRerankIntegration:
def setup_method(self):
# Reload modules to ensure fresh references after conftest reloads litellm.
# This ensures the class being patched is the same one used by the tests.
import litellm.llms.vertex_ai.rerank.transformation as rerank_transformation_module
importlib.reload(rerank_transformation_module)
# Re-import after reload to get the fresh class
from litellm.llms.vertex_ai.rerank.transformation import (
VertexAIRerankConfig as FreshConfig,
)
self.config = FreshConfig()
self.config = VertexAIRerankConfig()
self.model = "semantic-ranker-default@latest"
def test_end_to_end_rerank_flow(self):

View file

@ -8148,3 +8148,489 @@ async def test_register_wall_names_the_fix_for_urlless_servers():
detail_text = str(exc_info.value.detail)
assert "set Authorization URL and Token URL" in detail_text
assert "Issuer" in detail_text
def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input():
"""The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code,
and is total over hostile input: a raw upstream code opens to None, and a tampered or
non-gateway value opens to None rather than raising, so every existing caller-supplied-client
flow is untouched."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
open_passthrough_authorization_code,
seal_passthrough_authorization_code,
)
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
sealed = seal_passthrough_authorization_code(
upstream_code="up-code",
client_id="minted-77",
client_secret="mint-secret",
mcp_server_id="srv-1",
token_endpoint_auth_method="client_secret_basic",
)
opened = open_passthrough_authorization_code(sealed)
assert opened is not None
assert opened.upstream_code == "up-code"
assert opened.client_id == "minted-77"
assert opened.client_secret == "mint-secret"
assert opened.mcp_server_id == "srv-1"
assert opened.token_endpoint_auth_method == "client_secret_basic"
assert open_passthrough_authorization_code("raw-upstream-code") is None
assert open_passthrough_authorization_code(sealed[:-4] + "aaaa") is None
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
_BRIDGE_AUTH_CODE_PREFIX,
_PASSTHROUGH_AUTH_CODE_PREFIX,
open_bridge_authorization_code,
seal_bridge_authorization_code,
)
bridge_sealed = seal_bridge_authorization_code(
upstream_code="up-code", litellm_user_id="sso-user-9", mcp_server_id="srv-1"
)
reprefixed_as_passthrough = _PASSTHROUGH_AUTH_CODE_PREFIX + bridge_sealed[len(_BRIDGE_AUTH_CODE_PREFIX) :]
reprefixed_as_bridge = _BRIDGE_AUTH_CODE_PREFIX + sealed[len(_PASSTHROUGH_AUTH_CODE_PREFIX) :]
assert open_passthrough_authorization_code(reprefixed_as_passthrough) is None
assert open_bridge_authorization_code(reprefixed_as_bridge) is None
@pytest.mark.asyncio
async def test_authorize_with_ephemeral_dcr_client_seals_client_into_state():
"""When mcp_authorize fell through to a gateway-side DCR mint, authorize_with_server seals the
minted client and the target server into the encrypted OAuth state, so the callback can bind
them into the forwarded authorization code while the gateway stores nothing."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
authorize_with_server,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None)
captured: dict = {}
def _capture(**kwargs):
captured.update(kwargs)
return "mocked_encrypted_state"
with patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.encode_state_with_base_url",
side_effect=_capture,
):
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_id="minted-77",
redirect_uri="http://127.0.0.1:60108/callback",
state="s",
code_challenge="chal",
code_challenge_method="S256",
ephemeral_dcr_client=EphemeralDcrClient(
client_id="minted-77", client_secret="mint-secret", token_endpoint_auth_method="client_secret_basic"
),
)
assert captured["dcr_client_id"] == "minted-77"
assert captured["dcr_client_secret"] == "mint-secret"
assert captured["dcr_token_endpoint_auth_method"] == "client_secret_basic"
assert captured["mcp_server_id"] == server.server_id
assert "client_id=minted-77" in response.headers["location"]
@pytest.mark.asyncio
async def test_callback_wraps_code_into_passthrough_code_for_ephemeral_dcr_state():
"""When the OAuth state carries an ephemeral DCR client, the callback forwards a sealed
passthrough code (binding the client and the upstream code to the server) instead of the raw
upstream code, so the client's later token call can authenticate the exchange with a client the
gateway never stored."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
callback,
open_passthrough_authorization_code,
)
state_data = {
"original_state": "client-state",
"client_redirect_uri": "http://127.0.0.1:60108/cb",
"base_url": "http://127.0.0.1:60108/cb",
"mcp_server_id": "srv-1",
"dcr_client_id": "minted-77",
"dcr_client_secret": "mint-secret",
"dcr_token_endpoint_auth_method": "client_secret_basic",
}
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
return_value="enc",
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.decode_state_hash",
return_value=state_data,
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
return_value="http://127.0.0.1:60108/cb",
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
opened = open_passthrough_authorization_code(forwarded_code)
assert opened is not None
assert opened.upstream_code == "REAL-UPSTREAM-CODE"
assert opened.client_id == "minted-77"
assert opened.client_secret == "mint-secret"
assert opened.mcp_server_id == "srv-1"
assert opened.token_endpoint_auth_method == "client_secret_basic"
@pytest.mark.asyncio
async def test_authorize_bridge_server_with_ephemeral_client_takes_short_circuit_arm():
"""A gateway-minted client is registered against {base}/callback, so a bridge server's
authorize with an ephemeral client must run the short-circuit (gateway /callback) arm with a
relay state cookie, never the verbatim relay: relaying would send the browser's redirect_uri
to an IdP that has the gateway callback registered, stranding the flow."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
authorize_with_server,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_id="minted-77",
redirect_uri="http://127.0.0.1:60108/callback",
state="client-state",
code_challenge="chal",
code_challenge_method="S256",
ephemeral_dcr_client=EphemeralDcrClient(client_id="minted-77", client_secret=None),
)
location = response.headers["location"]
params = parse_qs(urlparse(location).query)
assert params["redirect_uri"] == ["https://litellm.example.com/callback"]
assert params["client_id"] == ["minted-77"]
assert params["state"] != ["client-state"]
assert any(cookie.startswith("mcp_oauth_state_") for cookie in response.headers.get("set-cookie", "").split(";"))
@pytest.mark.asyncio
async def test_callback_forwards_raw_code_when_dcr_state_lacks_server_binding():
"""A state carrying a dcr client but no server id cannot produce a server-bound sealed code, so
the callback falls back to forwarding the raw upstream code instead of sealing an unbindable
one."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import callback
state_data = {
"original_state": "client-state",
"client_redirect_uri": "http://127.0.0.1:60108/cb",
"base_url": "http://127.0.0.1:60108/cb",
"dcr_client_id": "minted-77",
}
with (
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
return_value="enc",
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.decode_state_hash",
return_value=state_data,
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
return_value="http://127.0.0.1:60108/cb",
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
assert forwarded_code == "REAL-UPSTREAM-CODE"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"auth_type_value",
[
"none",
"api_key",
"bearer_token",
"basic",
"authorization",
"oauth2",
"aws_sigv4",
"token",
"oauth2_token_exchange",
"oauth2_id_jag",
"true_passthrough",
"oauth_delegate",
],
)
@pytest.mark.parametrize("dcr_bridge", [True, False])
async def test_resolve_ephemeral_dcr_client_mint_set_is_exact(auth_type_value, dcr_bridge):
"""The full authorize-time mint decision matrix, one cell per (auth_type, dcr_bridge). The gateway
mints iff true_passthrough (any bridge) or oauth_delegate-and-not-dcr_bridge; every other mode
returns None so no non-OAuth mode ever registers an upstream client, and the interactive
oauth_delegate dcr_bridge sign-in is left to its own browser-front-door flow. The UI
gatewayMintsClientFor helper mirrors this exact set; ui/.../mcp_tools/types.test.tsx pins the
frontend side against the same table, so a divergence fails on one side or the other."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
resolve_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth(auth_type_value),
dcr_bridge=dcr_bridge,
server_id=f"matrix_{auth_type_value}_{dcr_bridge}",
server_name=f"matrix_{auth_type_value}_{dcr_bridge}",
)
expected_mint = server.is_true_passthrough or (server.is_oauth_delegate and not server.is_dcr_bridge)
mint_mock = AsyncMock(return_value=EphemeralDcrClient(client_id="minted", client_secret=None))
with patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
mint_mock,
):
result = await resolve_ephemeral_dcr_client(
request=_bridge_mock_request(),
mcp_server=server,
code_challenge="chal",
code_challenge_method="S256",
redirect_uri="http://127.0.0.1:9/callback",
)
if expected_mint:
mint_mock.assert_awaited_once()
assert result is not None
else:
mint_mock.assert_not_awaited()
assert result is None
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_returns_none_without_registration_endpoint():
"""A server whose upstream exposes no RFC 7591 registration endpoint cannot mint, so the
fall-through reports None and the caller keeps its existing missing_client_id failure."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, registration_url=None)
assert await mint_ephemeral_dcr_client(_bridge_mock_request(), server) is None
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_posts_rfc7591_and_returns_client():
"""The mint POSTs a public-client RFC 7591 registration bound to the gateway /callback and hands
back the upstream's client without persisting it anywhere."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_posts_srv", server_name="mint_posts_srv"
)
mock_response = MagicMock()
mock_response.text = json.dumps(
{"client_id": "minted-77", "client_secret": "mint-secret", "token_endpoint_auth_method": "client_secret_basic"}
)
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
minted = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
assert minted is not None
assert minted.client_id == "minted-77"
assert minted.client_secret == "mint-secret"
assert minted.token_endpoint_auth_method == "client_secret_basic"
register_data = mock_async_client.post.call_args.kwargs["json"]
assert register_data["redirect_uris"] == ["https://litellm.example.com/callback"]
assert register_data["token_endpoint_auth_method"] == "none"
assert register_data["grant_types"] == ["authorization_code", "refresh_token"]
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_reuses_minted_client_within_flow_ttl():
"""Reloading the authorize page must not spam the upstream registration endpoint with orphan
clients: within the OAuth state's lifetime a second mint for the same server and gateway origin
reuses the cached client and performs no second upstream POST."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_reuse_srv", server_name="mint_reuse_srv"
)
mock_response = MagicMock()
mock_response.text = json.dumps({"client_id": "minted-77"})
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
first = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
second = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
assert first is not None
assert second == first
mock_async_client.post.assert_called_once()
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_single_flights_concurrent_mints():
"""Two in-flight authorize requests for the same server must not both register an upstream
client: the per-key lock makes the second waiter reuse the first mint, so exactly one upstream
POST happens."""
import asyncio
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth.true_passthrough,
dcr_bridge=None,
server_id="mint_concurrent_srv",
server_name="mint_concurrent_srv",
)
mock_response = MagicMock()
mock_response.text = json.dumps({"client_id": "minted-77"})
mock_response.raise_for_status = MagicMock()
async def _slow_post(*args, **kwargs):
await asyncio.sleep(0.05)
return mock_response
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(side_effect=_slow_post)
with patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
first, second = await asyncio.gather(
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
)
assert first is not None
assert second == first
mock_async_client.post.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"payload, server_id",
[
({"unexpected": "shape"}, "mint_bad_shape_srv"),
({"client_id": ""}, "mint_empty_id_srv"),
],
)
async def test_mint_ephemeral_dcr_client_unusable_registration_response_is_502(payload, server_id):
"""An upstream registration response without a usable client_id, whether the field is missing or
an empty string, surfaces as a loud 502 instead of letting the authorize proceed with an empty
client and fail opaquely at the IdP."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id=server_id, server_name=server_id)
mock_response = MagicMock()
mock_response.text = json.dumps(payload)
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
with pytest.raises(HTTPException) as exc:
await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
assert exc.value.status_code == 502
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sealed_auth_method, expects_basic_header",
[
("client_secret_basic", True),
(None, False),
],
)
async def test_token_exchange_authenticates_with_the_sealed_clients_own_auth_method(
sealed_auth_method, expects_basic_header
):
"""The id, secret, and token-endpoint auth method must come from the same source: a client
recovered from a sealed passthrough code authenticates the upstream exchange the way its own
registration was granted, not the way the server row is configured. A sealed
``client_secret_basic`` grant sends the Basic header and keeps the secret out of the body; a
sealed public client (no method) keeps the body-credential path."""
import base64
import httpx
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="sealed_method_srv")
upstream_request = httpx.Request("POST", server.token_url)
upstream_response = httpx.Response(
200, json={"access_token": "up-token", "token_type": "Bearer"}, request=upstream_request
)
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=upstream_response)
with patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="up-code",
redirect_uri="https://litellm.example.com/callback",
client_id="minted-77",
client_secret="mint-secret",
code_verifier="verifier",
client_token_endpoint_auth_method=sealed_auth_method,
)
sent_headers = mock_async_client.post.call_args.kwargs["headers"]
sent_body = mock_async_client.post.call_args.kwargs["data"]
if expects_basic_header:
expected = base64.b64encode(b"minted-77:mint-secret").decode()
assert sent_headers["Authorization"] == f"Basic {expected}"
assert "client_secret" not in sent_body
else:
assert "Authorization" not in sent_headers
assert sent_body["client_id"] == "minted-77"
assert sent_body["client_secret"] == "mint-secret"

View file

@ -48,27 +48,22 @@ def _base_config(**overrides: Any) -> AutorouteConfig:
class TestParseDiscoveredModels:
def test_parses_valid_raw_list_into_typed_tuple(self):
raw = [
{
"model_group": "gpt-4o",
"mode": "chat",
"input_cost_per_token": 0.01,
"output_cost_per_token": 0.02,
},
{"model_group": "text-embedding-3-small", "mode": "embedding"},
{"id": "gpt-4o", "object": "model", "mode": "chat"},
{"id": "text-embedding-3-small", "object": "model", "mode": "embedding"},
]
result = parse_discovered_models(raw)
assert result == (
DiscoveredModel(name="gpt-4o", mode="chat", input_cost_per_token=0.01, output_cost_per_token=0.02),
DiscoveredModel(name="gpt-4o", mode="chat"),
DiscoveredModel(name="text-embedding-3-small", mode="embedding"),
)
def test_ignores_unknown_extra_fields(self):
raw = [{"model_group": "gpt-4o", "mode": "chat", "totally_unknown_field": "whatever"}]
raw = [{"id": "gpt-4o", "mode": "chat", "created": 123, "owned_by": "openai", "max_input_tokens": 128000}]
result = parse_discovered_models(raw)
assert result == (DiscoveredModel(name="gpt-4o", mode="chat"),)
def test_missing_mode_defaults_to_chat(self):
raw = [{"model_group": "gpt-4o"}]
raw = [{"id": "gpt-4o", "object": "model"}]
result = parse_discovered_models(raw)
assert result[0].mode == "chat"

View file

@ -16,22 +16,22 @@ from litellm.proxy.client.cli.commands.autoroute.config import DiscoveredModel
from litellm.proxy.client.cli.commands.autoroute.wizard import run_configure_wizard
CHAT_AND_EMBEDDING_GROUPS: List[Dict[str, Any]] = [
{"model_group": "gpt-4o-mini", "mode": "chat", "input_cost_per_token": 0.01, "output_cost_per_token": 0.02},
{"model_group": "gpt-4o", "mode": "chat", "input_cost_per_token": 0.01, "output_cost_per_token": 0.02},
{"model_group": "claude-opus", "mode": "chat"},
{"model_group": "o1", "mode": "chat"},
{"model_group": "text-embedding-3-small", "mode": "embedding"},
{"id": "gpt-4o-mini", "object": "model", "mode": "chat", "max_input_tokens": 128000},
{"id": "gpt-4o", "object": "model", "mode": "chat", "max_input_tokens": 128000},
{"id": "claude-opus", "object": "model", "mode": "chat"},
{"id": "o1", "object": "model", "mode": "chat"},
{"id": "text-embedding-3-small", "object": "model", "mode": "embedding"},
]
CHAT_ONLY_GROUPS: List[Dict[str, Any]] = [
{"model_group": "gpt-4o-mini", "mode": "chat"},
{"model_group": "gpt-4o", "mode": "chat"},
{"model_group": "claude-opus", "mode": "chat"},
{"model_group": "o1", "mode": "chat"},
{"id": "gpt-4o-mini", "object": "model", "mode": "chat"},
{"id": "gpt-4o", "object": "model", "mode": "chat"},
{"id": "claude-opus", "object": "model", "mode": "chat"},
{"id": "o1", "object": "model", "mode": "chat"},
]
EMBEDDING_ONLY_GROUPS: List[Dict[str, Any]] = [
{"model_group": "text-embedding-3-small", "mode": "embedding"},
{"id": "text-embedding-3-small", "object": "model", "mode": "embedding"},
]
@ -73,7 +73,7 @@ def _run(
patch.object(wizard_module, "_render_and_prompt_for_models", side_effect=_fake_prompt_for_models),
patch.object(wizard_module, "_render_and_prompt_for_model", side_effect=_fake_prompt_for_model),
):
mock_client_cls.return_value.model_groups.info.return_value = raw_groups
mock_client_cls.return_value.models.list.return_value = raw_groups
result = runner.invoke(
_invoke_wizard,
obj={"base_url": "http://localhost:4000", "api_key": "sk-test"},
@ -262,7 +262,7 @@ class TestRunConfigureWizardNoChatModels:
assert result.exit_code != 0
assert result.exception is None or not isinstance(result.exception, AssertionError)
assert "Unexpected response from /model_group/info" in result.output
assert "Unexpected response from /v1/models" in result.output
assert not config_path.exists()
@ -275,7 +275,7 @@ class TestRunConfigureWizardNotInteractive:
patch.object(wizard_module, "CONFIG_PATH", config_path),
patch.object(wizard_module, "_is_interactive", return_value=False),
):
mock_client_cls.return_value.model_groups.info.return_value = CHAT_AND_EMBEDDING_GROUPS
mock_client_cls.return_value.models.list.return_value = CHAT_AND_EMBEDDING_GROUPS
result = runner.invoke(_invoke_wizard, obj={"base_url": "http://localhost:4000", "api_key": "sk-test"})
assert result.exit_code != 0

View file

@ -0,0 +1,105 @@
import os
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor, resolve_fields
from litellm.proxy.config_resolvers.sso import (
SSO_FIELD_ENV_VARS,
SSO_SECRET_FIELDS,
resolve_sso_config,
)
_D = (
FieldDescriptor("client_id", "client_id", "CLIENT_ID"),
FieldDescriptor("scope", "scope", "SCOPE", default="openid"),
)
def test_resolve_fields_db_wins_over_env():
values, provenance = resolve_fields(_D, {"client_id": "from-db"}, {"CLIENT_ID": "from-env"})
assert values["client_id"] == "from-db"
assert provenance["client_id"] == "db"
def test_resolve_fields_blank_db_falls_back_to_env():
values, provenance = resolve_fields(_D, {"client_id": " "}, {"CLIENT_ID": "from-env"})
assert values["client_id"] == "from-env"
assert provenance["client_id"] == "env"
def test_resolve_fields_blank_everywhere_falls_to_default():
values, provenance = resolve_fields(_D, {}, {"SCOPE": ""})
assert values["scope"] == "openid"
assert provenance["scope"] == "default"
def test_resolve_fields_unset_everywhere():
values, provenance = resolve_fields(_D, {}, {})
assert values["client_id"] is None
assert provenance["client_id"] == "unset"
def test_resolve_fields_empty_db_absent_by_default_falls_to_env():
# SSO semantics: a present-but-empty stored value is absent, so env wins.
values, provenance = resolve_fields(_D, {"client_id": ""}, {"CLIENT_ID": "from-env"})
assert values["client_id"] == "from-env"
assert provenance["client_id"] == "env"
def test_resolve_fields_empty_db_is_explicit_clear_when_flag_set():
# Alerting semantics: a present-but-empty stored value is an explicit clear
# that must win over a stale env var.
values, provenance = resolve_fields(
_D, {"client_id": ""}, {"CLIENT_ID": "stale-env"}, empty_db_is_set=True
)
assert values["client_id"] == ""
assert provenance["client_id"] == "db"
def test_sso_descriptor_mapping_is_single_sourced():
# The write path and read path both consume this mapping; it must cover every
# env-backed SSO field and map to the uppercase env var.
assert SSO_FIELD_ENV_VARS["generic_client_id"] == "GENERIC_CLIENT_ID"
assert SSO_SECRET_FIELDS == frozenset(
{"google_client_secret", "microsoft_client_secret", "generic_client_secret"}
)
def test_resolve_sso_config_returns_unmasked_secret_and_provenance():
# The resolver hands back plaintext; masking is the endpoint's job. If the
# resolver masked, the login path would consume a masked secret and fail.
resolved = resolve_sso_config(
{"generic_client_secret": "super-secret-value"},
{"GENERIC_CLIENT_ID": "env-id"},
)
assert resolved.config.generic_client_secret == "super-secret-value"
assert resolved.provenance["generic_client_secret"] == "db"
assert resolved.config.generic_client_id == "env-id"
assert resolved.provenance["generic_client_id"] == "env"
def test_resolve_sso_config_parses_structured_mappings():
resolved = resolve_sso_config(
{
"generic_client_id": "id",
"role_mappings": {
"provider": "generic",
"group_claim": "groups",
"default_role": "internal_user",
"roles": {},
},
"team_mappings": {"team_ids_jwt_field": "teams"},
},
{},
)
assert resolved.config.role_mappings is not None
assert resolved.config.role_mappings.group_claim == "groups"
assert resolved.config.team_mappings is not None
assert resolved.config.team_mappings.team_ids_jwt_field == "teams"
def test_resolve_sso_config_does_not_mutate_os_environ(monkeypatch):
# Unlike the legacy read path, resolving must not write os.environ.
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
before = dict(os.environ)
resolve_sso_config({"generic_client_id": "id-from-db"}, os.environ)
assert dict(os.environ) == before
assert "GENERIC_CLIENT_ID" not in os.environ

View file

@ -465,3 +465,58 @@ def test_apply_patch_ops_filtered_path_raises_400_instead_of_junk_metadata():
)
assert exc_info.value.status_code == 400
def test_apply_patch_ops_remove_group_filtered_path_without_value():
"""Okta removes a user from a team with groups[value eq "..."] and no body
value; the team id must be parsed from the filter so the remove takes effect"""
user = LiteLLM_UserTable(
user_id="user-fp",
user_email="fp@example.com",
teams=["team-1", "team-2"],
metadata={},
)
patch_ops = SCIMPatchOp(
Operations=[SCIMPatchOperation(op="remove", path='groups[value eq "team-1"]')]
)
_, final_team_set = _apply_patch_ops(existing_user=user, patch_ops=patch_ops)
assert final_team_set == {"team-2"}
def test_apply_patch_ops_add_group_filtered_path_without_value():
"""A filtered add path with no body value adds the team id from the filter."""
user = LiteLLM_UserTable(
user_id="user-fp",
user_email="fp@example.com",
teams=["team-1"],
metadata={},
)
patch_ops = SCIMPatchOp(
Operations=[SCIMPatchOperation(op="add", path="groups[value eq 'team-3']")]
)
_, final_team_set = _apply_patch_ops(existing_user=user, patch_ops=patch_ops)
assert final_team_set == {"team-1", "team-3"}
def test_apply_patch_ops_replace_groups_empty_value_does_not_use_path_filter():
"""A filtered replace with an explicit empty value must not resurrect the
filter id; the team set is replaced with the empty value as given."""
user = LiteLLM_UserTable(
user_id="user-fp",
user_email="fp@example.com",
teams=["team-1", "team-2"],
metadata={},
)
patch_ops = SCIMPatchOp(
Operations=[
SCIMPatchOperation(op="replace", path='groups[value eq "team-1"]', value=[])
]
)
_, final_team_set = _apply_patch_ops(existing_user=user, patch_ops=patch_ops)
assert final_team_set == set()

View file

@ -1,3 +1,4 @@
import time
from unittest.mock import AsyncMock
import pytest
@ -17,12 +18,14 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
UserProvisionerHelpers,
_apply_group_patch_updates,
_extract_group_member_ids,
_extract_ids_from_path_filter,
_handle_team_membership_changes,
_process_group_patch_operations,
_recompute_scim_member_roles,
create_group,
create_user,
delete_group,
delete_user,
get_groups,
get_users,
get_service_provider_config,
@ -1908,7 +1911,7 @@ async def test_process_group_patch_operations_with_flag_true_creates_users(mocke
)
# Execute the function
update_data, final_members = await _process_group_patch_operations(
update_data, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
existing_team=mock_existing_team,
prisma_client=mock_prisma_client,
@ -2947,7 +2950,7 @@ async def test_process_group_patch_operations_add_retains_existing_members(
return_value=mocker.MagicMock(user_id="new-user")
)
_, final_members = await _process_group_patch_operations(
_, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
existing_team=existing_team,
prisma_client=mock_prisma_client,
@ -2995,7 +2998,7 @@ async def test_process_group_patch_operations_remove_uses_members_with_roles(
return_value=mocker.MagicMock(user_id="drop-user")
)
_, final_members = await _process_group_patch_operations(
_, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
existing_team=existing_team,
prisma_client=mock_prisma_client,
@ -3062,3 +3065,457 @@ async def test_apply_group_patch_updates_does_not_write_legacy_members(mocker):
written = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs["data"]
assert "members" not in written
assert written["team_alias"] == "Renamed"
def _mock_prisma_for_delete_user(mocker, team):
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=team)
mock_prisma_client.db.litellm_teamtable.update = AsyncMock()
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.delete = AsyncMock()
return mock_prisma_client
def _patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user):
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists",
AsyncMock(return_value=existing_user),
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._set_user_keys_blocked",
AsyncMock(),
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._delete_rows_referencing_user",
AsyncMock(),
)
@pytest.mark.asyncio
async def test_delete_user_prunes_members_with_roles(mocker):
"""Deleting a SCIM user must remove them from every team they belong to via
team_member_delete, which prunes members_with_roles (the source of truth for
SCIM group membership) so GET /Groups no longer returns a dangling reference
to the now-deleted user."""
user_id = "scim-del-user"
existing_user = mocker.MagicMock()
existing_user.teams = ["team-1"]
team = LiteLLM_TeamTable(
team_id="team-1",
members=[user_id, "other-user"],
members_with_roles=[Member(user_id=user_id, role="user"), Member(user_id="other-user", role="admin")],
)
mock_prisma_client = _mock_prisma_for_delete_user(mocker, team)
_patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user)
team_member_delete_mock = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(),
)
await delete_user(user_id=user_id)
team_member_delete_mock.assert_awaited_once()
call = team_member_delete_mock.call_args
assert call.kwargs["data"].team_id == "team-1"
assert call.kwargs["data"].user_id == user_id
assert call.kwargs["user_api_key_dict"].user_role == LitellmUserRoles.PROXY_ADMIN
mock_prisma_client.db.litellm_usertable.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_delete_user_surfaces_prune_failure_and_keeps_user(mocker):
"""A genuine failure while pruning members_with_roles must surface: the
endpoint fails loudly and the user row is NOT deleted, so we never report a
successful delete while leaving a dangling member (SCIM DELETE is idempotent,
so the IdP retries)."""
user_id = "scim-del-user"
existing_user = mocker.MagicMock()
existing_user.teams = ["team-1"]
team = LiteLLM_TeamTable(
team_id="team-1",
members=[user_id],
members_with_roles=[Member(user_id=user_id, role="user")],
)
mock_prisma_client = _mock_prisma_for_delete_user(mocker, team)
_patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(side_effect=Exception("database connection lost")),
)
with pytest.raises(Exception):
await delete_user(user_id=user_id)
mock_prisma_client.db.litellm_usertable.delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_delete_user_skips_teams_where_not_a_member(mocker):
"""If the user is not in a team's members_with_roles, deletion must treat that
team as a no-op (no team_member_delete call, no error) and still delete the
user, so a stale legacy membership can't block the delete."""
user_id = "scim-del-user"
existing_user = mocker.MagicMock()
existing_user.teams = ["team-1"]
team = LiteLLM_TeamTable(
team_id="team-1",
members=[user_id],
members_with_roles=[Member(user_id="someone-else", role="admin")],
)
mock_prisma_client = _mock_prisma_for_delete_user(mocker, team)
_patch_delete_user_dependencies(mocker, mock_prisma_client, existing_user)
team_member_delete_mock = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(),
)
await delete_user(user_id=user_id)
team_member_delete_mock.assert_not_awaited()
mock_prisma_client.db.litellm_usertable.delete.assert_awaited_once()
@pytest.mark.asyncio
async def test_patch_group_add_applies_delta_and_keeps_concurrent_add(mocker):
"""A group PATCH op:add must be applied as a delta against the live roster,
not as a snapshot-based absolute target.
When a concurrent PATCH has already added a member between this request's
initial read and its post-write refresh, that member shows up in the
refreshed roster but not in this request's snapshot-derived target. Diffing
the refreshed roster against the snapshot target would issue a spurious
team_member_delete for the concurrently-added member. Applying only this
request's intended delta on top of the refreshed roster must retain them.
"""
from litellm.proxy.management_endpoints.scim.scim_transformations import (
ScimTransformations,
)
group_id = "team-concurrent"
snapshot_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Group",
members_with_roles=[Member(user_id="zed", role="user")],
metadata={"externalId": "grp-ext"},
)
refreshed_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Group",
members_with_roles=[
Member(user_id="zed", role="user"),
Member(user_id="alice", role="user"),
],
metadata={"externalId": "grp-ext"},
)
final_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Group",
members_with_roles=[
Member(user_id="zed", role="user"),
Member(user_id="alice", role="user"),
Member(user_id="bob", role="user"),
],
metadata={"externalId": "grp-ext"},
)
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[SCIMPatchOperation(op="add", path="members", value=[{"value": "bob"}])],
)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
side_effect=[snapshot_team, refreshed_team, final_team]
)
mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
patch_membership_mock = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
AsyncMock(),
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles",
AsyncMock(),
)
mocker.patch.object(
ScimTransformations,
"transform_litellm_team_to_scim_group",
AsyncMock(
return_value=SCIMGroup(
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
id=group_id,
displayName="Group",
)
),
)
await patch_group(group_id=group_id, patch_ops=patch_ops)
calls = patch_membership_mock.call_args_list
removed_user_ids = {
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_remove_user_from") == [group_id]
}
assert removed_user_ids == set()
added_user_ids = {
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_add_user_to") == [group_id]
}
assert added_user_ids == {"bob"}
@pytest.mark.asyncio
async def test_patch_group_replace_stays_absolute_against_concurrent_roster(mocker):
"""A group PATCH ``replace`` op declares the roster is exactly the given set,
so it must reconcile as a set-to-target, not as a delta.
Unlike ``add``/``remove``, ``replace`` is absolute. A member that another
request added concurrently is present in the refreshed roster but not in the
replace target, and ``replace`` must drop it. Rebasing the replace onto the
refreshed roster (the delta behavior correct only for add/remove) would
wrongly retain that concurrently-added member.
"""
from litellm.proxy.management_endpoints.scim.scim_transformations import (
ScimTransformations,
)
group_id = "team-replace-concurrent"
snapshot_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Group",
members_with_roles=[Member(user_id="zed", role="user")],
metadata={"externalId": "grp-ext"},
)
refreshed_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Group",
members_with_roles=[
Member(user_id="alice", role="user"),
Member(user_id="bob", role="user"),
],
metadata={"externalId": "grp-ext"},
)
final_team = LiteLLM_TeamTable(
team_id=group_id,
team_alias="Group",
members_with_roles=[Member(user_id="alice", role="user")],
metadata={"externalId": "grp-ext"},
)
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[SCIMPatchOperation(op="replace", path="members", value=[{"value": "alice"}])],
)
mock_prisma_client = mocker.MagicMock()
mock_prisma_client.db = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
side_effect=[snapshot_team, refreshed_team, final_team]
)
mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=final_team)
mock_prisma_client.db.litellm_usertable = mocker.MagicMock()
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock())
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
patch_membership_mock = mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
AsyncMock(),
)
mocker.patch(
"litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles",
AsyncMock(),
)
mocker.patch.object(
ScimTransformations,
"transform_litellm_team_to_scim_group",
AsyncMock(
return_value=SCIMGroup(
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
id=group_id,
displayName="Group",
)
),
)
await patch_group(group_id=group_id, patch_ops=patch_ops)
calls = patch_membership_mock.call_args_list
removed_user_ids = {
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_remove_user_from") == [group_id]
}
assert removed_user_ids == {"bob"}
added_user_ids = {
call.kwargs["user_id"] for call in calls if call.kwargs.get("teams_ids_to_add_user_to") == [group_id]
}
assert added_user_ids == set()
@pytest.mark.parametrize(
"path, attribute, expected",
[
('members[value eq "user-1"]', "members", ["user-1"]),
("members[value eq 'user-1']", "members", ["user-1"]),
('members[value EQ "user-1"]', "members", ["user-1"]),
('members[ value eq "user-1" ]', "members", ["user-1"]),
('groups[value eq "team-1"]', "groups", ["team-1"]),
('members[value eq "Mixed-CASE-Id"]', "members", ["Mixed-CASE-Id"]),
('members[value eq "a\\"b"]', "members", ['a"b']),
('members[value eq "a\\\\b"]', "members", ["a\\b"]),
("members[value eq 'a\\'b']", "members", ["a'b"]),
("members", "members", []),
('groups[value eq "team-1"]', "members", []),
(None, "members", []),
('members[value eq ""]', "members", []),
("members[value eq user-1]", "members", []),
("members[value eq unintendeduser]", "members", []),
],
)
def test_extract_ids_from_path_filter(path, attribute, expected):
assert _extract_ids_from_path_filter(path, attribute) == expected
def test_extract_ids_from_path_filter_unterminated_is_linear():
"""A pathological unterminated quoted filter must not trigger super-linear
backtracking; it returns no id and completes near-instantly."""
pathological = 'members[value eq "' + ("\\" * 200)
start = time.perf_counter()
result = _extract_ids_from_path_filter(pathological, "members")
elapsed = time.perf_counter() - start
assert result == []
assert elapsed < 1.0
@pytest.mark.asyncio
async def test_process_group_patch_remove_filtered_path_without_value(mocker):
"""Okta sends group membership removals as a filtered path with no request
body value; the member id must be parsed out of members[value eq "..."]"""
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[SCIMPatchOperation(op="remove", path='members[value eq "user-1"]')],
)
existing_team = LiteLLM_TeamTable(
team_id="team-1",
team_alias="Team One",
members=[],
members_with_roles=[
Member(user_id="user-1", role="user"),
Member(user_id="user-2", role="user"),
],
)
prisma_client = mocker.MagicMock()
prisma_client.db = mocker.MagicMock()
prisma_client.db.litellm_usertable = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(user_id="user-1")
)
_, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
existing_team=existing_team,
prisma_client=prisma_client,
)
assert final_members == {"user-2"}
@pytest.mark.asyncio
async def test_process_group_patch_add_filtered_path_without_value(mocker):
"""A filtered add path with no body value adds the id parsed from the filter."""
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[SCIMPatchOperation(op="add", path='members[value eq "user-3"]')],
)
existing_team = LiteLLM_TeamTable(
team_id="team-1",
team_alias="Team One",
members=[],
members_with_roles=[Member(user_id="user-1", role="user")],
)
prisma_client = mocker.MagicMock()
prisma_client.db = mocker.MagicMock()
prisma_client.db.litellm_usertable = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(user_id="user-3")
)
_, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
existing_team=existing_team,
prisma_client=prisma_client,
)
assert final_members == {"user-1", "user-3"}
@pytest.mark.asyncio
async def test_process_group_patch_replace_empty_value_does_not_use_path_filter(mocker):
"""An explicit empty replace value must clear membership rather than pull an
id from the filtered path, which would retain one member and drop the rest."""
patch_ops = SCIMPatchOp(
schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations=[
SCIMPatchOperation(op="replace", path='members[value eq "user-1"]', value=[])
],
)
existing_team = LiteLLM_TeamTable(
team_id="team-1",
team_alias="Team One",
members=[],
members_with_roles=[
Member(user_id="user-1", role="user"),
Member(user_id="user-2", role="user"),
],
)
prisma_client = mocker.MagicMock()
prisma_client.db = mocker.MagicMock()
prisma_client.db.litellm_usertable = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(user_id="user-1")
)
_, final_members, _ = await _process_group_patch_operations(
patch_ops=patch_ops,
existing_team=existing_team,
prisma_client=prisma_client,
)
assert final_members == set()

View file

@ -2,6 +2,7 @@ import os
import sys
import types
import json
from contextlib import ExitStack
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import List, Optional
@ -2025,8 +2026,469 @@ class TestTemporaryMCPSessionEndpoints:
code_challenge_method="S256",
response_type="code",
scope="scope1",
ephemeral_dcr_client=None,
)
async def _authorize_without_client_id(
self, server, mint_mock=None, code_challenge="chal", code_challenge_method="S256"
):
"""Drive mcp_authorize with no caller client_id against ``server``, returning the
(authorize_with_server mock, raised HTTPException or None) pair. Sends a valid S256 PKCE
pair by default because the ephemeral mint requires it."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
mcp_authorize,
)
request = MagicMock()
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
patches = [
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
return_value=server,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.authorize_with_server",
AsyncMock(return_value=MagicMock()),
),
]
if mint_mock is not None:
patches.append(
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
mint_mock,
)
)
with ExitStack() as stack:
entered = [stack.enter_context(p) for p in patches]
authorize_mock = entered[1]
try:
await mcp_authorize(
request=request,
server_id=server.server_id,
user_api_key_dict=admin_auth,
client_id=None,
redirect_uri="http://127.0.0.1:60108/callback",
state="state123",
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
)
except HTTPException as exc:
return authorize_mock, exc
return authorize_mock, None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"code_challenge, code_challenge_method",
[(None, None), ("chal", "plain"), ("chal", None)],
)
async def test_mcp_authorize_mint_requires_s256_pkce(self, code_challenge, code_challenge_method):
"""Without PKCE the sealed code would be bearer-redeemable by any authenticated caller who
intercepts the redirect, so the ephemeral mint refuses to run for a downgraded flow (no
challenge, or a non-S256 method) before any upstream registration happens."""
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
server.authorization_url = "https://idp.example.com/authorize"
server.registration_url = "https://idp.example.com/register"
mint_mock = AsyncMock()
authorize_mock, exc = await self._authorize_without_client_id(
server, mint_mock=mint_mock, code_challenge=code_challenge, code_challenge_method=code_challenge_method
)
assert exc is not None
assert exc.status_code == 400
assert "PKCE" in str(exc.detail)
mint_mock.assert_not_awaited()
authorize_mock.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
async def test_mcp_authorize_client_forwarded_modes_mint_ephemeral_dcr_client_when_none_supplied(self, auth_type):
"""LIT-4581 regression: a client-forwarded-token server created without an auth step has no
stored client_id and the tools-tab browser flow supplies none, so authorize must fall
through to a gateway-side DCR mint and proceed with the minted client instead of
dead-ending on a 400 missing_client_id. Both modes share the caller-held-client contract,
so both get the fall-through."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
)
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = auth_type
server.authorization_url = "https://idp.example.com/authorize"
server.registration_url = "https://idp.example.com/register"
minted = EphemeralDcrClient(client_id="minted-77", client_secret="mint-secret")
mint_mock = AsyncMock(return_value=minted)
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
assert exc is None
mint_mock.assert_awaited_once()
assert authorize_mock.await_args.kwargs["client_id"] == "minted-77"
assert authorize_mock.await_args.kwargs["ephemeral_dcr_client"] is minted
@pytest.mark.asyncio
async def test_mcp_authorize_rejects_untrusted_redirect_before_minting(self):
"""An untrusted redirect_uri must be rejected before the gateway performs any upstream
registration, so bad-redirect requests cannot be used to generate orphan clients at the
IdP."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
mcp_authorize,
)
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
server.authorization_url = "https://idp.example.com/authorize"
server.registration_url = "https://idp.example.com/register"
mint_mock = AsyncMock()
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
request = MagicMock()
request.base_url = "https://litellm.example.com/"
request.headers = {}
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
return_value=server,
),
patch(
"litellm.proxy._experimental.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
mint_mock,
),
):
with pytest.raises(HTTPException) as exc:
await mcp_authorize(
request=request,
server_id="server-1",
user_api_key_dict=admin_auth,
client_id=None,
redirect_uri="https://evil.example.net/steal",
state="state123",
code_challenge="chal",
code_challenge_method="S256",
)
assert exc.value.status_code == 400
mint_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_authorize_true_passthrough_without_authorization_url_reports_the_real_fault(self):
"""A passthrough server whose discovery never yielded an authorize endpoint cannot start any
flow, minted client or not, so the error names the missing authorization url instead of the
misleading missing_client_id remedy."""
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
server.authorization_url = None
server.registration_url = "https://idp.example.com/register"
mint_mock = AsyncMock()
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
assert exc is not None
assert exc.status_code == 400
assert "authorization url" in str(exc.detail)
mint_mock.assert_not_awaited()
authorize_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_authorize_true_passthrough_without_registration_endpoint_keeps_missing_client_id(self):
"""When the upstream exposes no registration endpoint the mint is impossible, so the
authorize fails closed with the existing missing_client_id 400 instead of proceeding with an
empty client."""
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
server.authorization_url = "https://idp.example.com/authorize"
server.registration_url = None
authorize_mock, exc = await self._authorize_without_client_id(server)
assert exc is not None
assert exc.status_code == 400
assert exc.detail["error"] == "missing_client_id"
authorize_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_authorize_oauth2_server_does_not_mint(self):
"""The ephemeral mint is scoped to the client-forwarded-token modes: a plain oauth2 server
keeps the gateway-held-client contract (its client is persisted by the admin register flow),
so an empty client_id stays a 400 and no upstream registration is attempted."""
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.oauth2
server.authorization_url = "https://idp.example.com/authorize"
server.registration_url = "https://idp.example.com/register"
mint_mock = AsyncMock()
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
assert exc is not None
assert exc.status_code == 400
assert exc.detail["error"] == "missing_client_id"
mint_mock.assert_not_awaited()
authorize_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_authorize_true_passthrough_dcr_bridge_mints_too(self):
"""The UI creates passthrough servers with dcr_bridge enabled by default, so the default
clientless tools-page authorize is a bridge server; it must mint exactly like a non-bridge
one (the minted flow runs the bridge short-circuit arm) instead of dead-ending on
missing_client_id. The relay front door stays reserved for clients that present their own
client_id."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
)
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
server.dcr_bridge = True
server.authorization_url = "https://idp.example.com/authorize"
server.registration_url = "https://idp.example.com/register"
minted = EphemeralDcrClient(client_id="minted-77", client_secret=None)
mint_mock = AsyncMock(return_value=minted)
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
assert exc is None
mint_mock.assert_awaited_once()
assert authorize_mock.await_args.kwargs["client_id"] == "minted-77"
assert authorize_mock.await_args.kwargs["ephemeral_dcr_client"] is minted
@pytest.mark.asyncio
async def test_mcp_authorize_oauth_delegate_dcr_bridge_does_not_mint(self):
"""The interactive oauth_delegate dcr_bridge sign-in has its own sealed-identity flow that
captures the SSO user at authorize; the ephemeral mint must not preempt it."""
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.oauth_delegate
server.dcr_bridge = True
server.authorization_url = "https://idp.example.com/authorize"
server.registration_url = "https://idp.example.com/register"
mint_mock = AsyncMock()
authorize_mock, exc = await self._authorize_without_client_id(server, mint_mock=mint_mock)
assert exc is not None
assert exc.status_code == 400
assert exc.detail["error"] == "missing_client_id"
mint_mock.assert_not_awaited()
authorize_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_token_opens_sealed_passthrough_code_and_exchanges_with_minted_client(self):
"""LIT-4581 regression, token leg: the client echoes back the sealed passthrough code the
callback forwarded, so the token endpoint recovers the ephemeral client and the real
upstream code from it and authenticates the exchange with them, with no client_id supplied
by the caller and none stored on the server."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
seal_passthrough_authorization_code,
)
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
mcp_token,
)
request = MagicMock()
request.base_url = "https://litellm.example.com/"
request.headers = {}
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
return_value=server,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
AsyncMock(return_value={"access_token": "token"}),
) as exchange_mock,
):
sealed = seal_passthrough_authorization_code(
upstream_code="up-code",
client_id="minted-77",
client_secret="mint-secret",
mcp_server_id="server-1",
token_endpoint_auth_method="client_secret_basic",
)
result = await mcp_token(
request=request,
server_id="server-1",
user_api_key_dict=admin_auth,
grant_type="authorization_code",
code=sealed,
redirect_uri="https://example.com/callback",
client_id=None,
client_secret=None,
code_verifier="verifier",
refresh_token=None,
scope=None,
)
assert result == {"access_token": "token"}
assert exchange_mock.await_args.kwargs["code"] == "up-code"
assert exchange_mock.await_args.kwargs["client_id"] == "minted-77"
assert exchange_mock.await_args.kwargs["client_secret"] == "mint-secret"
assert exchange_mock.await_args.kwargs["redirect_uri"] == "https://litellm.example.com/callback"
assert exchange_mock.await_args.kwargs["client_token_endpoint_auth_method"] == "client_secret_basic"
@pytest.mark.asyncio
async def test_mcp_token_refresh_grant_never_opens_sealed_code(self):
"""The minted client is unrecoverable outside the single authorization_code flow by
contract: a refresh_token grant that echoes a leftover sealed passthrough code (plus any
verifier) must not recover the minted credentials, so a clientless server answers
missing_client_id and the client re-runs authorize instead."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
seal_passthrough_authorization_code,
)
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
mcp_token,
)
request = MagicMock()
request.base_url = "https://litellm.example.com/"
request.headers = {}
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
return_value=server,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
AsyncMock(return_value={"access_token": "token"}),
) as exchange_mock,
):
sealed = seal_passthrough_authorization_code(
upstream_code="up-code",
client_id="minted-77",
client_secret="mint-secret",
mcp_server_id="server-1",
token_endpoint_auth_method="client_secret_basic",
)
with pytest.raises(HTTPException) as exc:
await mcp_token(
request=request,
server_id="server-1",
user_api_key_dict=admin_auth,
grant_type="refresh_token",
code=sealed,
redirect_uri="https://example.com/callback",
client_id=None,
client_secret=None,
code_verifier="verifier",
refresh_token="leftover-refresh",
scope=None,
)
assert exc.value.status_code == 400
assert exc.value.detail["error"] == "missing_client_id"
exchange_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_token_sealed_code_requires_code_verifier(self):
"""A sealed code is minted only for S256 PKCE flows, so redeeming one without the
corresponding verifier is refused at the gateway rather than trusting the upstream to
enforce the binding."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
seal_passthrough_authorization_code,
)
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
mcp_token,
)
request = MagicMock()
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
return_value=server,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
AsyncMock(),
) as exchange_mock,
):
sealed = seal_passthrough_authorization_code(
upstream_code="up-code", client_id="minted-77", client_secret=None, mcp_server_id="server-1"
)
with pytest.raises(HTTPException) as exc:
await mcp_token(
request=request,
server_id="server-1",
user_api_key_dict=admin_auth,
grant_type="authorization_code",
code=sealed,
redirect_uri="https://example.com/callback",
client_id=None,
client_secret=None,
code_verifier=None,
refresh_token=None,
scope=None,
)
assert exc.value.status_code == 400
assert "code_verifier" in str(exc.value.detail)
exchange_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_token_rejects_sealed_code_for_another_server(self):
"""A sealed passthrough code is bound to the server it was minted for: presenting it at
another server's token endpoint is a 400 before any upstream exchange, so a code cannot be
replayed across a server boundary."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
seal_passthrough_authorization_code,
)
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
mcp_token,
)
request = MagicMock()
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.true_passthrough
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch("litellm.proxy.proxy_server.master_key", "sk-lit4581-test-master-key"),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
return_value=server,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.exchange_token_with_server",
AsyncMock(),
) as exchange_mock,
):
sealed = seal_passthrough_authorization_code(
upstream_code="up-code",
client_id="minted-77",
client_secret=None,
mcp_server_id="a-different-server",
)
with pytest.raises(HTTPException) as exc:
await mcp_token(
request=request,
server_id="server-1",
user_api_key_dict=admin_auth,
grant_type="authorization_code",
code=sealed,
redirect_uri="https://example.com/callback",
client_id=None,
client_secret=None,
code_verifier="verifier",
refresh_token=None,
scope=None,
)
assert exc.value.status_code == 400
exchange_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_mcp_authorize_rejects_non_oauth2_server(self):
"""mcp_authorize must reject a none-auth server with an accurate 'does not use OAuth'
@ -2163,6 +2625,7 @@ class TestTemporaryMCPSessionEndpoints:
code_verifier="verifier",
refresh_token=None,
scope=None,
client_token_endpoint_auth_method=None,
)
@pytest.mark.asyncio
@ -2216,6 +2679,7 @@ class TestTemporaryMCPSessionEndpoints:
code_verifier=None,
refresh_token="rt-123",
scope=None,
client_token_endpoint_auth_method=None,
)
@pytest.mark.asyncio
@ -2270,8 +2734,59 @@ class TestTemporaryMCPSessionEndpoints:
token_endpoint_auth_method="client_secret_basic",
fallback_client_id="server-1",
persist_credentials=True,
client_redirect_uris=None,
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"raw_redirect_uris, forwarded",
[
(["https://app.example.com/ui/callback"], ["https://app.example.com/ui/callback"]),
(["https://app.example.com/ui/callback", 42, "", None], None),
("not-a-list", None),
([], None),
([123], None),
],
)
async def test_mcp_register_forwards_validated_redirect_uris(self, raw_redirect_uris, forwarded):
"""dcr_bridge servers relay the registration upstream and require the browser client's own
redirect_uris, so mcp_register must forward them; the value is caller-controlled and is
validated by the same client_supplied_redirect_uris boundary helper as the root /register
door, so a malformed list is rejected whole at both doors (RFC 7591 redirect_uris is
all-or-nothing) rather than silently forwarding the surviving entries here and rejecting
them there."""
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
mcp_register,
)
request = MagicMock()
server = generate_mock_mcp_server_config_record(server_id="server-1")
server.auth_type = MCPAuth.oauth2
request_body = {"client_name": "LiteLLM", "redirect_uris": raw_redirect_uris}
admin_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._get_cached_temporary_mcp_server_or_404",
return_value=server,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._read_request_body",
AsyncMock(return_value=request_body),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.register_client_with_server",
AsyncMock(return_value={"client_id": "generated"}),
) as register_mock,
):
await mcp_register(
request=request,
server_id="server-1",
user_api_key_dict=admin_auth,
)
assert register_mock.await_args.kwargs["client_redirect_uris"] == forwarded
@pytest.mark.asyncio
async def test_mcp_register_does_not_persist_for_non_admin(self):
"""A non-admin caller (who may have access to a real server) must not persist the DCR

View file

@ -10,9 +10,7 @@ import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
sys.path.insert(
0, os.path.abspath("../../../")
) # Adds the parent directory to the system path
sys.path.insert(0, os.path.abspath("../../../")) # Adds the parent directory to the system path
@pytest.mark.asyncio
@ -58,16 +56,12 @@ async def test_organization_update_object_permissions_existing_permission(monkey
"vector_stores": ["old_store_1", "old_store_2"],
}
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
return_value=existing_object_permission
)
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=existing_object_permission)
# Mock upsert operation
updated_permission = MagicMock()
updated_permission.object_permission_id = "existing_perm_id_123"
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(
return_value=updated_permission
)
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(return_value=updated_permission)
# Test data with new object permission
data_json = {
@ -107,9 +101,7 @@ async def test_get_organization_daily_activity_admin_param_passing(monkeypatch):
# Mock prisma client
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
return_value=[]
)
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Admin view -> skip membership restriction
@ -121,9 +113,7 @@ async def test_get_organization_daily_activity_admin_param_passing(monkeypatch):
# Patch downstream common function and verify call args
mocked_response = MagicMock(name="SpendAnalyticsPaginatedResponse")
get_daily_activity_mock = AsyncMock(return_value=mocked_response)
monkeypatch.setattr(
organization_endpoints, "get_daily_activity", get_daily_activity_mock
)
monkeypatch.setattr(organization_endpoints, "get_daily_activity", get_daily_activity_mock)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin1")
result = await get_organization_daily_activity(
@ -172,17 +162,11 @@ async def test_get_organization_daily_activity_non_admin_defaults_to_admin_orgs(
# Mock prisma client and memberships
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
return_value=[]
)
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.litellm_organizationmembership.find_many = AsyncMock(
return_value=[
SimpleNamespace(
organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value
),
SimpleNamespace(
organization_id="orgB", user_role=LitellmUserRoles.ORG_ADMIN.value
),
SimpleNamespace(organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value),
SimpleNamespace(organization_id="orgB", user_role=LitellmUserRoles.ORG_ADMIN.value),
]
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
@ -196,13 +180,9 @@ async def test_get_organization_daily_activity_non_admin_defaults_to_admin_orgs(
# Patch downstream aggregator
mocked_response = MagicMock(name="SpendAnalyticsPaginatedResponse")
get_daily_activity_mock = AsyncMock(return_value=mocked_response)
monkeypatch.setattr(
organization_endpoints, "get_daily_activity", get_daily_activity_mock
)
monkeypatch.setattr(organization_endpoints, "get_daily_activity", get_daily_activity_mock)
auth = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user"
)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user")
await get_organization_daily_activity(
organization_ids=None,
start_date="2024-02-01",
@ -238,15 +218,9 @@ async def test_get_organization_daily_activity_non_admin_unauthorized_org_raises
# Mock prisma client and memberships (only orgA is admin)
mock_prisma_client = AsyncMock()
mock_prisma_client.db.litellm_organizationmembership.find_many = AsyncMock(
return_value=[
SimpleNamespace(
organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value
)
]
)
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
return_value=[]
return_value=[SimpleNamespace(organization_id="orgA", user_role=LitellmUserRoles.ORG_ADMIN.value)]
)
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Non-admin view
@ -255,9 +229,7 @@ async def test_get_organization_daily_activity_non_admin_unauthorized_org_raises
lambda _: False,
)
auth = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user"
)
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="regular-user")
with pytest.raises(HTTPException) as exc:
await get_organization_daily_activity(
@ -312,21 +284,17 @@ async def test_organization_update_object_permissions_no_existing_permission(
)
# Mock find_unique to return None (no existing permission)
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
return_value=None
)
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=None)
# Mock upsert to create new record
new_permission = MagicMock()
new_permission.object_permission_id = "new_perm_id_456"
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(
return_value=new_permission
)
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(return_value=new_permission)
data_json = {
"object_permission": LiteLLM_ObjectPermissionBase(
vector_stores=["brand_new_store"]
).model_dump(exclude_unset=True, exclude_none=True),
"object_permission": LiteLLM_ObjectPermissionBase(vector_stores=["brand_new_store"]).model_dump(
exclude_unset=True, exclude_none=True
),
"organization_alias": "updated_org_2",
}
@ -381,21 +349,17 @@ async def test_organization_update_object_permissions_missing_permission_record(
)
# Mock find_unique to return None (permission record not found)
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
return_value=None
)
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=None)
# Mock upsert to create new record
new_permission = MagicMock()
new_permission.object_permission_id = "recreated_perm_id_789"
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(
return_value=new_permission
)
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock(return_value=new_permission)
data_json = {
"object_permission": LiteLLM_ObjectPermissionBase(
vector_stores=["recreated_store"]
).model_dump(exclude_unset=True, exclude_none=True),
"object_permission": LiteLLM_ObjectPermissionBase(vector_stores=["recreated_store"]).model_dump(
exclude_unset=True, exclude_none=True
),
"organization_alias": "updated_org_3",
}
@ -446,18 +410,14 @@ async def test_list_organization_filter_by_org_id(monkeypatch):
)
# Mock find_many to return filtered results
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
return_value=[mock_org1]
)
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[mock_org1])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Test as proxy admin
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
result = await list_organization(
org_id="org-123", org_alias=None, user_api_key_dict=auth
)
result = await list_organization(org_id="org-123", org_alias=None, user_api_key_dict=auth)
# Verify the correct organization was returned
assert len(result) == 1
@ -512,18 +472,14 @@ async def test_list_organization_filter_by_org_alias(monkeypatch):
)
# Mock find_many to return filtered results
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(
return_value=[mock_org1, mock_org2]
)
mock_prisma_client.db.litellm_organizationtable.find_many = AsyncMock(return_value=[mock_org1, mock_org2])
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Test as proxy admin with org_alias filter
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user")
result = await list_organization(
org_id=None, org_alias="test", user_api_key_dict=auth
)
result = await list_organization(org_id=None, org_alias="test", user_api_key_dict=auth)
# Verify organizations with "test" in alias were returned
assert len(result) == 2
@ -532,9 +488,7 @@ async def test_list_organization_filter_by_org_alias(monkeypatch):
# Verify find_many was called with correct where conditions (case-insensitive contains)
mock_prisma_client.db.litellm_organizationtable.find_many.assert_called_once()
call_args = mock_prisma_client.db.litellm_organizationtable.find_many.call_args
assert call_args.kwargs["where"] == {
"organization_alias": {"contains": "test", "mode": "insensitive"}
}
assert call_args.kwargs["where"] == {"organization_alias": {"contains": "test", "mode": "insensitive"}}
assert call_args.kwargs["include"] == {
"litellm_budget_table": True,
"members": True,
@ -612,16 +566,12 @@ def patched_org_prisma():
),
patch("litellm.proxy.proxy_server.proxy_logging_obj"),
):
mock_prisma.db.litellm_organizationtable.find_unique = AsyncMock(
return_value=victim_row
)
mock_prisma.db.litellm_organizationtable.find_unique = AsyncMock(return_value=victim_row)
yield mock_prisma
@pytest.mark.asyncio
async def test_organization_member_add_rejects_unauthorized_caller(
patched_org_prisma, unauthorized_caller
):
async def test_organization_member_add_rejects_unauthorized_caller(patched_org_prisma, unauthorized_caller):
# ``organization_member_add`` catches HTTPException in its
# catch-all and re-wraps as ProxyException with the original status
# code preserved.
@ -653,9 +603,7 @@ async def test_organization_member_add_rejects_unauthorized_caller(
@pytest.mark.asyncio
async def test_organization_member_update_rejects_unauthorized_caller(
patched_org_prisma, unauthorized_caller
):
async def test_organization_member_update_rejects_unauthorized_caller(patched_org_prisma, unauthorized_caller):
from litellm.proxy._types import OrganizationMemberUpdateRequest
from litellm.proxy.management_endpoints.organization_endpoints import (
organization_member_update,
@ -676,9 +624,7 @@ async def test_organization_member_update_rejects_unauthorized_caller(
@pytest.mark.asyncio
async def test_organization_member_delete_rejects_unauthorized_caller(
patched_org_prisma, unauthorized_caller
):
async def test_organization_member_delete_rejects_unauthorized_caller(patched_org_prisma, unauthorized_caller):
from litellm.proxy._types import OrganizationMemberDeleteRequest
from litellm.proxy.management_endpoints.organization_endpoints import (
organization_member_delete,
@ -695,3 +641,354 @@ async def test_organization_member_delete_rejects_unauthorized_caller(
user_api_key_dict=unauthorized_caller,
)
assert exc.value.status_code == 403
@pytest.mark.parametrize(
"body",
[{"tpm_limit": ""}, {"tmp_limit": None}],
ids=["non-numeric-limit", "unknown-key"],
)
def test_v2_model_rejects_invalid_body(body):
"""A non-numeric limit and an unknown/misspelled key are both rejected at model validation (422 at the route)."""
from pydantic import ValidationError
from litellm.proxy._types import OrganizationUpdateRequestV2
with pytest.raises(ValidationError):
OrganizationUpdateRequestV2.model_validate(body)
class _FakeTxContext:
def __init__(self, tx):
self._tx = tx
async def __aenter__(self):
return self._tx
async def __aexit__(self, exc_type, exc, tb):
return False
async def _run_update_organization_v2(
monkeypatch,
*,
body: dict,
existing_budget_id,
existing_metadata,
existing_object_permission_id=None,
existing_object_permission_row=None,
):
from litellm.proxy._types import (
LitellmUserRoles,
OrganizationUpdateRequestV2,
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints import organization_endpoints
from litellm.proxy.management_endpoints.organization_endpoints import (
update_organization_v2,
)
from litellm.proxy.utils import jsonify_object
mock_prisma_client = AsyncMock()
mock_prisma_client.jsonify_object = jsonify_object
existing_org = MagicMock()
existing_org.budget_id = existing_budget_id
existing_org.object_permission_id = existing_object_permission_id
existing_org.metadata = existing_metadata
mock_prisma_client.db.litellm_organizationtable.find_unique = AsyncMock(return_value=existing_org)
mock_prisma_client.db.litellm_organizationtable.update = AsyncMock(return_value=MagicMock())
mock_prisma_client.db.litellm_budgettable.update = AsyncMock()
mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(
return_value=existing_object_permission_row
)
mock_prisma_client.db.litellm_objectpermissiontable.upsert = AsyncMock()
tx = MagicMock()
tx.litellm_organizationtable = mock_prisma_client.db.litellm_organizationtable
tx.litellm_budgettable = mock_prisma_client.db.litellm_budgettable
tx.litellm_objectpermissiontable.upsert = AsyncMock()
mock_prisma_client.db.tx = MagicMock(return_value=_FakeTxContext(tx))
mock_prisma_client.tx = tx
call_order = MagicMock()
call_order.attach_mock(tx.litellm_objectpermissiontable.upsert, "permission_upsert")
call_order.attach_mock(mock_prisma_client.db.litellm_organizationtable.update, "org_update")
mock_prisma_client.call_order = call_order
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(organization_endpoints, "_verify_org_access", AsyncMock())
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
await update_organization_v2(
organization_id="org-1",
data=OrganizationUpdateRequestV2.model_validate(body),
user_api_key_dict=auth,
)
return mock_prisma_client
@pytest.mark.asyncio
async def test_v2_update_clears_tpm_limit_and_metadata(monkeypatch):
"""A cleared tpm_limit is written to the budget row as None; a cleared metadata is written as {}."""
prisma = await _run_update_organization_v2(
monkeypatch,
body={"tpm_limit": None, "metadata": None},
existing_budget_id="budget-1",
existing_metadata={"stale": "value"},
)
budget_write = prisma.db.litellm_budgettable.update.await_args
assert budget_write.kwargs["where"] == {"budget_id": "budget-1"}
assert budget_write.kwargs["data"]["tpm_limit"] is None
assert "soft_budget" not in budget_write.kwargs["data"]
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
assert json.loads(write_data["metadata"]) == {}
assert "budget_id" not in write_data
@pytest.mark.asyncio
async def test_v2_update_untouched_fields_not_written(monkeypatch):
"""Omitted fields are left untouched: only organization_alias is written, no budget-row write."""
prisma = await _run_update_organization_v2(
monkeypatch,
body={"organization_alias": "renamed"},
existing_budget_id="budget-1",
existing_metadata={"keep": "me"},
)
prisma.db.litellm_budgettable.update.assert_not_awaited()
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
assert write_data["organization_alias"] == "renamed"
assert "metadata" not in write_data
assert "tpm_limit" not in write_data
@pytest.mark.asyncio
async def test_v2_update_metadata_replaces_not_merges(monkeypatch):
"""Sending metadata replaces the stored blob wholesale; a previously-present key is gone."""
prisma = await _run_update_organization_v2(
monkeypatch,
body={"metadata": {"a": 1}},
existing_budget_id="budget-1",
existing_metadata={"stale": "value"},
)
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
assert json.loads(write_data["metadata"]) == {"a": 1}
@pytest.mark.asyncio
async def test_v2_rejects_null_clear_of_non_nullable_fields(monkeypatch):
"""organization_alias and models are non-nullable columns, so a null clear is a 422, not a 500."""
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
for body in ({"organization_alias": None}, {"models": None}):
with pytest.raises(HTTPException) as exc:
await update_organization_v2(
organization_id="org-1",
data=OrganizationUpdateRequestV2.model_validate(body),
user_api_key_dict=auth,
)
assert exc.value.status_code == 422
@pytest.mark.asyncio
async def test_v2_rejects_negative_max_budget(monkeypatch):
"""v2 rejects a negative max_budget with a 422 before touching the DB."""
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
with pytest.raises(HTTPException) as exc:
await update_organization_v2(
organization_id="org-1",
data=OrganizationUpdateRequestV2.model_validate({"max_budget": -5}),
user_api_key_dict=auth,
)
assert exc.value.status_code == 422
assert "max_budget" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_v2_rejects_caller_without_org_access(monkeypatch):
"""v2 runs the real _verify_org_access guard: a non-admin without ORG_ADMIN on the org gets 403 and no write."""
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
from litellm.proxy.management_endpoints import organization_endpoints
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
mock_prisma_client = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(organization_endpoints, "_user_has_admin_view", lambda _: False)
caller = MagicMock()
caller.organization_memberships = []
monkeypatch.setattr(organization_endpoints, "get_user_object", AsyncMock(return_value=caller))
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1")
with pytest.raises(HTTPException) as exc:
await update_organization_v2(
organization_id="org-1",
data=OrganizationUpdateRequestV2.model_validate({"tpm_limit": 5}),
user_api_key_dict=auth,
)
assert exc.value.status_code == 403
mock_prisma_client.db.litellm_organizationtable.update.assert_not_awaited()
@pytest.mark.asyncio
async def test_v2_wires_object_permission_onto_org_write(monkeypatch):
"""A sent object_permission merges over the existing permission row and its id is linked onto the org write."""
existing_row = MagicMock()
existing_row.model_dump.return_value = {
"object_permission_id": "op-123",
"mcp_servers": ["server-1"],
}
prisma = await _run_update_organization_v2(
monkeypatch,
body={"object_permission": {"vector_stores": ["vs-1"]}},
existing_budget_id="budget-1",
existing_metadata={},
existing_object_permission_id="op-123",
existing_object_permission_row=existing_row,
)
upsert = prisma.tx.litellm_objectpermissiontable.upsert.await_args.kwargs
assert upsert["where"] == {"object_permission_id": "op-123"}
assert upsert["data"]["update"]["mcp_servers"] == ["server-1"]
assert upsert["data"]["update"]["vector_stores"] == ["vs-1"]
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
assert write_data["object_permission_id"] == "op-123"
@pytest.mark.asyncio
async def test_v2_object_permission_upsert_runs_inside_transaction(monkeypatch):
"""The permission upsert runs on the tx client, before the org write that links it, so a rollback cannot
leave merged grants live on a row the org still points at."""
prisma = await _run_update_organization_v2(
monkeypatch,
body={"object_permission": {"vector_stores": ["vs-1"]}},
existing_budget_id="budget-1",
existing_metadata={},
)
prisma.tx.litellm_objectpermissiontable.upsert.assert_awaited_once()
prisma.db.litellm_objectpermissiontable.upsert.assert_not_awaited()
upsert = prisma.tx.litellm_objectpermissiontable.upsert.await_args.kwargs
linked_id = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]["object_permission_id"]
assert upsert["where"] == {"object_permission_id": linked_id}
assert upsert["data"]["create"]["object_permission_id"] == linked_id
ordered = [name for name, _, _ in prisma.call_order.mock_calls if name in ("permission_upsert", "org_update")]
assert ordered == ["permission_upsert", "org_update"]
@pytest.mark.asyncio
async def test_v2_clears_object_permission_when_sent_null(monkeypatch):
"""object_permission: null detaches the org's permission row (object_permission_id -> None), no merge."""
prisma = await _run_update_organization_v2(
monkeypatch,
body={"object_permission": None},
existing_budget_id="budget-1",
existing_metadata={},
)
prisma.tx.litellm_objectpermissiontable.upsert.assert_not_awaited()
prisma.db.litellm_objectpermissiontable.find_unique.assert_not_awaited()
write_data = prisma.db.litellm_organizationtable.update.await_args.kwargs["data"]
assert write_data["object_permission_id"] is None
@pytest.mark.asyncio
async def test_v2_rejects_empty_object_permission(monkeypatch):
"""object_permission: {} merges nothing, so it is rejected (send null to clear) rather than silently leaving grants."""
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
from litellm.proxy.management_endpoints import organization_endpoints
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
mock_prisma_client = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr(organization_endpoints, "_verify_org_access", AsyncMock())
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
with pytest.raises(HTTPException) as exc:
await update_organization_v2(
organization_id="org-1",
data=OrganizationUpdateRequestV2.model_validate({"object_permission": {}}),
user_api_key_dict=auth,
)
assert exc.value.status_code == 422
assert "object_permission" in str(exc.value.detail)
mock_prisma_client.db.litellm_organizationtable.update.assert_not_awaited()
@pytest.mark.asyncio
async def test_v2_writes_budget_and_org_in_one_transaction(monkeypatch):
"""A change touching both the budget row and the org row runs both writes inside one prisma transaction."""
prisma = await _run_update_organization_v2(
monkeypatch,
body={"tpm_limit": 500, "metadata": {"a": 1}},
existing_budget_id="budget-1",
existing_metadata={},
)
prisma.db.tx.assert_called_once()
prisma.db.litellm_budgettable.update.assert_awaited_once()
prisma.db.litellm_organizationtable.update.assert_awaited_once()
@pytest.mark.asyncio
async def test_v2_serializes_model_max_budget_on_budget_write(monkeypatch):
"""model_max_budget is a Json column, so it is JSON-serialized on the budget-row write like new_budget/metadata."""
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.validate_model_max_budget",
lambda _: None,
)
prisma = await _run_update_organization_v2(
monkeypatch,
body={"model_max_budget": {"gpt-4o": {"max_budget": 10}}},
existing_budget_id="budget-1",
existing_metadata={},
)
written = prisma.db.litellm_budgettable.update.await_args.kwargs["data"]["model_max_budget"]
assert isinstance(written, str)
assert json.loads(written) == {"gpt-4o": {"max_budget": 10}}
def test_build_budget_write_data_recomputes_reset_at_on_duration():
"""A sent budget_duration recomputes budget_reset_at so the reset window follows the new duration."""
from litellm.proxy.management_endpoints.organization_endpoints import build_budget_write_data
data = build_budget_write_data({"budget_duration": "30d"}, "admin-1")
assert data["budget_duration"] == "30d"
assert "budget_reset_at" in data
assert data["updated_by"] == "admin-1"
def test_build_budget_write_data_no_reset_at_without_duration():
"""Clearing a limit writes it through untouched and does not recompute budget_reset_at."""
from litellm.proxy.management_endpoints.organization_endpoints import build_budget_write_data
data = build_budget_write_data({"tpm_limit": None}, "admin-1")
assert data["tpm_limit"] is None
assert "budget_reset_at" not in data
def test_build_budget_write_data_clears_reset_at_with_null_duration():
"""Clearing budget_duration also nulls budget_reset_at so no stale reset timestamp survives."""
from litellm.proxy.management_endpoints.organization_endpoints import build_budget_write_data
data = build_budget_write_data({"budget_duration": None}, "admin-1")
assert data["budget_duration"] is None
assert data["budget_reset_at"] is None

View file

@ -1693,6 +1693,78 @@ async def test_update_team_members_list_duplicate_prevention():
assert len(mock_team.members_with_roles) == 1
@pytest.mark.asyncio
async def test_add_team_members_reconciles_against_freshly_locked_row():
"""
Regression: _add_team_members_to_team must build the new members_with_roles
from the row it re-reads under a lock inside the write transaction, not from
the stale complete_team_data snapshot captured at the start of the request.
Two concurrent /team/member_add calls for the same team read the same
snapshot; without the locked re-read the losing write rewrites the whole
JSON array from its stale copy and silently drops the member the other call
already committed. Here the snapshot holds only "zed", a concurrent writer
has already committed "alice" (returned by the locked SELECT), and this call
adds "bob". The write must contain all three.
"""
from litellm.proxy.management_endpoints.team_endpoints import (
_add_team_members_to_team,
)
stale_snapshot = LiteLLM_TeamTable(
team_id="test-team-lock",
members_with_roles=[Member(user_id="zed", role="user")],
)
freshly_committed = [
{"user_id": "zed", "user_email": None, "role": "user"},
{"user_id": "alice", "user_email": None, "role": "user"},
]
captured: dict = {}
async def _capture_update(where, data):
captured["data"] = data
return LiteLLM_TeamTable(
team_id="test-team-lock",
members_with_roles=json.loads(data["members_with_roles"]),
)
tx = MagicMock()
tx.query_raw = AsyncMock(return_value=[{"members_with_roles": freshly_committed}])
tx.litellm_teamtable.update = AsyncMock(side_effect=_capture_update)
tx_cm = MagicMock()
tx_cm.__aenter__ = AsyncMock(return_value=tx)
tx_cm.__aexit__ = AsyncMock(return_value=None)
prisma_client = MagicMock()
prisma_client.tx = MagicMock(return_value=tx_cm)
with patch(
"litellm.proxy.management_endpoints.team_endpoints._process_team_members",
new=AsyncMock(return_value=([], [])),
):
updated_team, _, _ = await _add_team_members_to_team(
data=TeamMemberAddRequest(
team_id="test-team-lock",
member=Member(user_id="bob", role="user"),
),
complete_team_data=stale_snapshot,
prisma_client=cast(object, prisma_client),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
litellm_proxy_admin_name="admin",
)
written_ids = sorted(m["user_id"] for m in json.loads(captured["data"]["members_with_roles"]))
assert written_ids == ["alice", "bob", "zed"]
lock_reads = [call for call in tx.query_raw.call_args_list if "FOR UPDATE" in str(call.args[0])]
assert lock_reads, "expected a SELECT ... FOR UPDATE row-lock read before the write"
assert [m.user_id for m in updated_team.members_with_roles] == ["zed", "alice", "bob"]
def test_add_new_models_to_team_with_existing_models():
"""
Test add_new_models_to_team function with existing models
@ -9741,7 +9813,6 @@ async def _drive_team_write(
raw_body=None,
user=None,
find_returns_none=False,
json_side_effect=None,
):
"""Drive POST ``update_team`` or PATCH ``patch_team`` against a mocked team.
@ -9756,6 +9827,7 @@ async def _drive_team_write(
from litellm.proxy._types import (
LiteLLM_TeamTable,
LitellmUserRoles,
PatchTeamRequest,
UpdateTeamRequest,
UserAPIKeyAuth,
)
@ -9801,14 +9873,10 @@ async def _drive_team_write(
litellm_changed_by=None,
)
else:
if json_side_effect is not None:
req.json = AsyncMock(side_effect=json_side_effect)
else:
req.json = AsyncMock(
return_value=raw_body if raw_body is not None else dict(payload or {})
)
body = raw_body if raw_body is not None else dict(payload or {})
result = await patch_team(
team_id=_PATCH_TEAM_ID,
data=PatchTeamRequest.model_validate(body),
http_request=req,
user_api_key_dict=auth,
litellm_changed_by=None,
@ -9956,25 +10024,36 @@ async def test_patch_strips_system_managed_metadata_key_like_post():
assert patch_meta == {"cost_center": "9999"}
@pytest.mark.asyncio
@pytest.mark.parametrize("raw_body", [["not", "an", "object"], "a-string", 42, True])
async def test_patch_rejects_non_object_body(raw_body):
from litellm.proxy._types import ProxyException
@pytest.mark.parametrize(
"kwargs",
[
{"json": ["not", "an", "object"]},
{"json": "a-string"},
{"json": 42},
{"content": b"{not json"},
{"json": {"tpm_limit": "not-an-int"}},
],
ids=["list", "string", "number", "malformed-json", "wrong-field-type"],
)
def test_patch_rejects_a_malformed_body_with_422(kwargs):
"""The body is a declared parameter, so FastAPI rejects a malformed one before the
handler runs. This is the same 422 POST /team/update already returns; the route
previously answered 400 here and 500 for a wrongly typed field, reporting a caller
mistake as a server fault."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
with pytest.raises(ProxyException) as exc:
await _drive_team_write("patch", existing_metadata={"a": 1}, raw_body=raw_body)
assert exc.value.code == "400" or exc.value.code == 400
from litellm.proxy._types import PatchTeamRequest
app = FastAPI()
@pytest.mark.asyncio
async def test_patch_rejects_invalid_json_body():
from litellm.proxy._types import ProxyException
@app.patch("/team/{team_id}")
async def _route(team_id: str, data: PatchTeamRequest): # pragma: no cover - schema only
return {}
with pytest.raises(ProxyException) as exc:
await _drive_team_write(
"patch", existing_metadata={"a": 1}, json_side_effect=ValueError("no body")
)
assert exc.value.code == "400" or exc.value.code == 400
response = TestClient(app).patch("/team/abc", **kwargs)
assert response.status_code == 422
@pytest.mark.asyncio
@ -10044,3 +10123,103 @@ async def test_patch_returns_full_team_object_not_wrapper():
)
assert isinstance(result, LiteLLM_TeamTable)
assert result.team_id == _PATCH_TEAM_ID
# ---------------------------------------------------------------------------
# PATCH body is validated through PatchTeamRequest before it is handed to
# update_team. The write below must stay byte-identical to what the untyped
# **body construction produced, or a partial update starts writing columns the
# caller never mentioned.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_patch_writes_only_the_keys_the_caller_sent():
"""An omitted field must not reach the DB write at all. If validation ever
materialises defaults, every unmentioned column gets overwritten with null."""
_, update_mock = await _drive_team_write("patch", raw_body={"tpm_limit": 5})
written = update_mock.call_args.kwargs["data"]
assert written["tpm_limit"] == 5
for untouched in ("rpm_limit", "max_budget", "models", "blocked", "budget_duration"):
assert untouched not in written, f"{untouched} was written despite not being sent"
@pytest.mark.asyncio
async def test_patch_preserves_explicit_null_as_a_clear():
"""null is a clear, not an omission: it has to survive validation and reach the write."""
_, update_mock = await _drive_team_write("patch", raw_body={"max_budget": None})
written = update_mock.call_args.kwargs["data"]
assert "max_budget" in written
assert written["max_budget"] is None
def _patch_body_to_update_request(body: dict):
"""The exact reshaping patch_team performs between the raw body and update_team."""
from litellm.proxy._types import PatchTeamRequest, UpdateTeamRequest
parsed = PatchTeamRequest.model_validate(body)
return UpdateTeamRequest(
team_id=_PATCH_TEAM_ID,
**parsed.model_dump(exclude_unset=True, exclude={"team_id"}),
)
@pytest.mark.parametrize(
"body",
[
{"tpm_limit": 5},
{"max_budget": None},
{"object_permission": {"vector_stores": []}},
{"metadata": {"a": 1, "b": None}},
{"models": ["gpt-4"], "blocked": False},
],
ids=["scalar", "explicit-null", "partial-nested", "metadata-with-null", "list-and-false"],
)
def test_patch_body_reshaping_adds_no_keys_the_caller_did_not_send(body):
"""Validating through PatchTeamRequest must be shape-preserving. If it ever
materialises defaults, a partial update silently overwrites untouched columns,
and for the merge-only object_permission it would wipe sibling sub-keys."""
reshaped = _patch_body_to_update_request(body)
dumped = reshaped.model_dump(exclude_unset=True, exclude={"team_id"})
assert dumped == body
assert reshaped.model_fields_set == set(body) | {"team_id"}
@pytest.mark.asyncio
async def test_patch_ignores_unknown_body_keys():
"""Unknown keys were silently dropped by the previous construction; keep that."""
_, update_mock = await _drive_team_write(
"patch", raw_body={"tpm_limit": 5, "not_a_team_field": "x"}
)
written = update_mock.call_args.kwargs["data"]
assert written["tpm_limit"] == 5
assert "not_a_team_field" not in written
def test_patch_team_request_makes_team_id_optional():
"""PATCH takes team_id from the path, so the body model must not require it,
while still inheriting every UpdateTeamRequest field."""
from litellm.proxy._types import PatchTeamRequest, UpdateTeamRequest
parsed = PatchTeamRequest.model_validate({"tpm_limit": 5})
assert parsed.team_id is None
assert parsed.model_fields_set == {"tpm_limit"}
assert set(UpdateTeamRequest.model_fields).issubset(set(PatchTeamRequest.model_fields))
def test_patch_team_route_publishes_its_request_body_schema():
"""The dashboard's generated client types this call off the OpenAPI spec, which
FastAPI can only emit because the body is a declared parameter."""
from litellm.proxy.proxy_server import app
operation = app.openapi()["paths"]["/team/{team_id}"]["patch"]
schema = operation["requestBody"]["content"]["application/json"]["schema"]
assert schema == {"$ref": "#/components/schemas/PatchTeamRequest"}
properties = app.openapi()["components"]["schemas"]["PatchTeamRequest"]["properties"]
assert "tpm_limit" in properties and "metadata" in properties

View file

@ -1174,6 +1174,113 @@ def test_get_config_returns_email_settings(monkeypatch):
assert "*" in variables["SMTP_PASSWORD"]
def _get_email_alert_variables(monkeypatch, config_data):
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
mock_router = MagicMock()
mock_router.get_settings.return_value = {}
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
original_overrides = app.dependency_overrides.copy()
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234"
)
client = TestClient(app)
try:
response = client.get("/get/config/callbacks")
finally:
app.dependency_overrides = original_overrides
assert response.status_code == 200
email_alert = next((a for a in response.json()["alerts"] if a["name"] == "email"), None)
assert email_alert is not None
return email_alert["variables"]
def test_get_config_returns_email_settings_set_only_in_process_env(monkeypatch):
"""
Regression for LIT-4165.
SMTP supplied purely as process env vars (helm/terraform, no UI writes) is
live at runtime because litellm/proxy/utils.py::send_email resolves every
field from os.getenv. The /get/config/callbacks email block only read the
config/DB environment_variables overlay though, so those deployments saw an
empty Email Server Settings page and could not tell SMTP was configured.
The slack block one branch above already fell back to os.getenv.
"""
smtp_password = "env-only-app-password"
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_TLS", "False")
monkeypatch.setenv("SMTP_USERNAME", "env-user")
monkeypatch.setenv("SMTP_PASSWORD", smtp_password)
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
monkeypatch.setenv("TEST_EMAIL_ADDRESS", "admin@env-host.com")
variables = _get_email_alert_variables(
monkeypatch,
{
"litellm_settings": {},
"general_settings": {"alerting": ["email"]},
"environment_variables": {},
},
)
# Every one of these was None before the fix, despite SMTP working.
assert variables["SMTP_HOST"] == "smtp.env-host.com"
assert variables["SMTP_PORT"] == "2525"
assert variables["SMTP_TLS"] == "False"
assert variables["SMTP_USERNAME"] == "env-user"
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
assert variables["TEST_EMAIL_ADDRESS"] == "admin@env-host.com"
# An env-sourced secret is masked exactly like a stored one.
assert variables["SMTP_PASSWORD"] not in (None, smtp_password)
assert "*" in variables["SMTP_PASSWORD"]
def test_get_config_email_settings_prefer_stored_over_process_env(monkeypatch):
"""
Stored environment_variables win over the process environment, matching the
load order in ProxyConfig.get_config, which pushes stored values into
os.environ. Only a field with no stored entry falls back to os.getenv.
"""
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
variables = _get_email_alert_variables(
monkeypatch,
{
"litellm_settings": {},
"general_settings": {"alerting": ["email"]},
"environment_variables": {"SMTP_HOST": "smtp.stored-host.com"},
},
)
assert variables["SMTP_HOST"] == "smtp.stored-host.com"
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
def test_get_config_email_settings_absent_everywhere_stay_none(monkeypatch):
"""A field set in neither source is reported unset rather than invented."""
for var in ("SMTP_HOST", "SMTP_PORT", "SMTP_TLS", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_SENDER_EMAIL"):
monkeypatch.delenv(var, raising=False)
variables = _get_email_alert_variables(
monkeypatch,
{
"litellm_settings": {},
"general_settings": {"alerting": ["email"]},
"environment_variables": {},
},
)
assert variables["SMTP_HOST"] is None
assert variables["SMTP_PASSWORD"] is None
def test_get_config_returns_slack_webhook(monkeypatch):
"""
Same double-decryption regression as the email block (issue #19221): the

View file

@ -709,6 +709,39 @@ def test_create_model_info_response_reads_real_cost_map():
assert response["max_output_tokens"] > 0
def test_create_model_info_response_includes_mode_from_lookup():
response = create_model_info_response(
model_id="text-embedding-3-small",
provider="openai",
llm_router=None,
get_model_info=lambda _model: _fake_model_info(mode="embedding"),
)
assert response["mode"] == "embedding"
def test_create_model_info_response_omits_mode_when_lookup_raises():
response = create_model_info_response(
model_id="my-custom-deployment",
provider="openai",
llm_router=None,
get_model_info=_raise_unmapped,
)
assert "mode" not in response
def test_create_model_info_response_omits_non_string_mode():
response = create_model_info_response(
model_id="some-model",
provider="openai",
llm_router=None,
get_model_info=lambda _model: _fake_model_info(mode=None),
)
assert "mode" not in response
class TestPostCallFailureHookLLMExceptionAlerting:
"""The llm_exceptions alert is for infra / LLM-API failures, not user
errors (https://github.com/BerriAI/litellm/issues/3395). Already-normalized

View file

@ -396,6 +396,146 @@ class TestProxySettingEndpoints:
call_args = mock_prisma.db.litellm_ssoconfig.find_unique.call_args
assert call_args.kwargs["where"]["id"] == "sso_config"
def _mock_sso_db_record(self, monkeypatch, sso_settings):
"""Point /get/sso_settings at a stored SSO row (or None for no row)."""
from unittest.mock import AsyncMock, MagicMock
mock_prisma = MagicMock()
if sso_settings is None:
mock_db_record = None
else:
mock_db_record = MagicMock()
mock_db_record.sso_settings = sso_settings
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=mock_db_record)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
# The resolver decrypts stored values via decrypt_value_helper; make it an
# identity so the plaintext fixtures round-trip.
monkeypatch.setattr(
"litellm.proxy.config_resolvers.sso.decrypt_value_helper",
lambda value, key, exception_type="error", return_original_value=False: value,
)
def test_get_sso_settings_falls_back_to_process_env(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""
Regression for LIT-4165.
SSO configured purely as process env vars (helm/terraform, no UI writes)
logs users in successfully, because ui_sso.py resolves every setting from
os.environ. /get/sso_settings read only the sso_config table though, so
the Admin UI showed "not configured" for a working SSO deployment and hid
the Edit/Delete controls behind an empty-state placeholder.
"""
self._mock_sso_db_record(monkeypatch, None)
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
monkeypatch.setenv("GENERIC_CLIENT_SECRET", "env-client-secret-value")
monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://idp.example.com/authorize")
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
monkeypatch.setenv("GENERIC_USERINFO_ENDPOINT", "https://idp.example.com/userinfo")
monkeypatch.setenv("GENERIC_SCOPE", "openid email profile groups")
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
# Every one of these was None before the fix, despite SSO working.
assert values["generic_client_id"] == "env-client-id"
assert values["generic_authorization_endpoint"] == "https://idp.example.com/authorize"
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
assert values["generic_userinfo_endpoint"] == "https://idp.example.com/userinfo"
assert values["generic_scope"] == "openid email profile groups"
assert values["proxy_base_url"] == "https://gateway.example.com"
# An env-sourced secret is masked exactly like a stored one.
assert values["generic_client_secret"] not in (None, "env-client-secret-value")
assert "*" in values["generic_client_secret"]
def test_get_sso_settings_does_not_mutate_os_environ(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""A GET must not write os.environ. The legacy read path decrypted DB
values straight into the environment, so opening the settings page
repopulated env and masked any consumer that stopped reading it."""
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "db-only-id"})
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
response = client.get("/get/sso_settings")
assert response.status_code == 200
assert response.json()["values"]["generic_client_id"] == "db-only-id"
# The DB value must NOT have leaked into the process environment.
assert "GENERIC_CLIENT_ID" not in os.environ
def test_get_sso_settings_prefers_stored_over_process_env(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""A stored value wins; only fields absent from the row fall back to env."""
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "stored-client-id"})
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["generic_client_id"] == "stored-client-id"
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
def test_get_sso_settings_blank_stored_value_falls_back_to_process_env(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""
Blank means absent. update_sso_settings clears the env var for a blank
field, so a blank row entry cannot describe a live setting; os.environ is
the effective config and is what the UI must report.
"""
self._mock_sso_db_record(monkeypatch, {"generic_client_id": " ", "generic_token_endpoint": ""})
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["generic_client_id"] == "env-client-id"
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
def test_get_sso_settings_unset_everywhere_reports_source(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""A field set in neither source is unset (or its effective default),
and provenance reports which."""
self._mock_sso_db_record(monkeypatch, None)
for env_var in (
"GENERIC_CLIENT_ID",
"GENERIC_CLIENT_SECRET",
"GENERIC_TOKEN_ENDPOINT",
"GENERIC_SCOPE",
"GOOGLE_CLIENT_ID",
"MICROSOFT_CLIENT_ID",
"PROXY_BASE_URL",
):
monkeypatch.delenv(env_var, raising=False)
response = client.get("/get/sso_settings")
assert response.status_code == 200
body = response.json()
values = body["values"]
provenance = body["provenance"]
assert values["generic_client_id"] is None
assert provenance["generic_client_id"] == "unset"
assert values["generic_client_secret"] is None
assert values["google_client_id"] is None
# generic_scope carries the same effective default the login path applies,
# so the settings page shows the scope logins would actually request.
assert values["generic_scope"] == "openid email profile"
assert provenance["generic_scope"] == "default"
def test_update_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
"""Test updating the SSO settings to the dedicated database table"""
import json
@ -1463,19 +1603,20 @@ class TestProxySettingEndpoints:
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
# Mock the decryption method to return decrypted values
def mock_decrypt_and_set(environment_variables):
return {
"google_client_id": "decrypted_google_id",
"google_client_secret": "decrypted_google_secret",
"microsoft_client_id": "decrypted_microsoft_id",
"proxy_base_url": "https://decrypted.example.com",
}
# The resolver decrypts each stored value via decrypt_value_helper; map
# the ciphertext fixtures to their plaintext.
decrypted_by_ciphertext = {
"encrypted_google_id": "decrypted_google_id",
"encrypted_google_secret": "decrypted_google_secret",
"encrypted_microsoft_id": "decrypted_microsoft_id",
"encrypted_proxy_url": "https://decrypted.example.com",
}
from litellm.proxy.proxy_server import proxy_config
def mock_decrypt(value, key, exception_type="error", return_original_value=False):
return decrypted_by_ciphertext.get(value, value)
monkeypatch.setattr(
proxy_config, "_decrypt_and_set_db_env_variables", mock_decrypt_and_set
"litellm.proxy.config_resolvers.sso.decrypt_value_helper", mock_decrypt
)
response = client.get("/get/sso_settings")

View file

@ -5,7 +5,7 @@ Tests for gateway repository layer.
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -499,6 +499,42 @@ class TestTeamRepository:
assert team.team_id == "team-123"
assert team.team_alias == "Engineering"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"raw_value, expected_ids",
[
(
[
{"user_id": "a", "role": "user"},
{"user_id": "b", "role": "admin"},
],
["a", "b"],
),
(json.dumps([{"user_id": "a", "role": "user"}]), ["a"]),
({}, []),
(None, []),
],
)
async def test_get_members_with_roles_locked(self, repo, raw_value, expected_ids):
tx = MagicMock()
tx.query_raw = AsyncMock(return_value=[{"members_with_roles": raw_value}])
members = await repo.get_members_with_roles_locked(tx, "team-1")
assert [m.user_id for m in members] == expected_ids
sql = tx.query_raw.call_args.args[0]
assert "FOR UPDATE" in sql
assert tx.query_raw.call_args.args[1] == "team-1"
@pytest.mark.asyncio
async def test_get_members_with_roles_locked_missing_row(self, repo):
tx = MagicMock()
tx.query_raw = AsyncMock(return_value=[])
members = await repo.get_members_with_roles_locked(tx, "missing")
assert members == []
@pytest.mark.asyncio
async def test_create_team_all_fields(self, repo):
team = await repo.create_team(

View file

@ -14,13 +14,19 @@ Covers:
"""
import asyncio
from typing import List
from typing import List, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.integrations.anthropic_cache_control_hook import (
AnthropicCacheControlHook,
)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.openai import (
AllMessageValues,
ResponseInputParam,
)
# ---------------------------------------------------------------------------
# Helpers
@ -71,6 +77,56 @@ def _patch_responses_dispatch():
]
def _make_cache_control_case() -> tuple[
ResponseInputParam,
list[AllMessageValues],
dict[str, object],
]:
system_message = cast(
AllMessageValues,
{"role": "system", "content": "Analyze the request"},
)
assistant_message = cast(
AllMessageValues,
{
"type": "message",
"id": "msg_1",
"role": "assistant",
"status": "completed",
"content": [
{
"type": "output_text",
"text": "The code has a bug",
"annotations": [],
}
],
},
)
user_message = cast(
AllMessageValues,
{"role": "user", "content": "Check for security issues"},
)
reasoning_item = {
"type": "reasoning",
"id": "rs_1",
"summary": [],
"encrypted_content": "encrypted",
}
original_input = cast(
ResponseInputParam,
[system_message, reasoning_item, assistant_message, user_message],
)
_, merged_messages, _ = AnthropicCacheControlHook().get_chat_completion_prompt(
model="azure/gpt-5-codex",
messages=[system_message, assistant_message, user_message],
non_default_params={"cache_control_injection_points": [{"location": "message", "role": "system"}]},
prompt_id=None,
prompt_variables=None,
dynamic_callback_params={},
)
return original_input, merged_messages, reasoning_item
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@ -256,6 +312,66 @@ class TestResponsesAPIPromptManagement:
assert all(isinstance(m, dict) and "role" in m for m in passed_messages)
assert len(passed_messages) == 1
def test_cache_control_hook_preserves_reasoning_items(self):
original_input, merged_messages, reasoning_item = _make_cache_control_case()
logging_obj = _make_logging_obj(
merged_model="azure/gpt-5-codex",
merged_messages=merged_messages,
)
patches = _patch_responses_dispatch()
with patches[0], patches[1], patches[2], patches[3] as mock_handler:
import litellm
litellm.responses(
input=original_input,
model="azure/gpt-5-codex",
litellm_logging_obj=logging_obj,
cache_control_injection_points=[{"location": "message", "role": "system"}],
)
sent_input = mock_handler.call_args.kwargs["input"]
assert [item.get("type") for item in sent_input] == [
None,
"reasoning",
"message",
None,
]
assert sent_input[0]["cache_control"] == {"type": "ephemeral"}
assert sent_input[1] == reasoning_item
assert sent_input[2]["id"] == "msg_1"
def test_all_non_message_input_items_remain_unchanged(self):
reasoning_item = {
"type": "reasoning",
"id": "rs_1",
"summary": [],
"encrypted_content": "encrypted",
}
original_input = cast(ResponseInputParam, [reasoning_item])
logging_obj = _make_logging_obj(
merged_model="openai/gpt-4o",
merged_messages=[
cast(
AllMessageValues,
{"role": "system", "content": "Analyze the request"},
)
],
)
patches = _patch_responses_dispatch()
with patches[0], patches[1], patches[2], patches[3] as mock_handler:
import litellm
litellm.responses(
input=original_input,
model="gpt-4o",
prompt_id="all-non-message",
litellm_logging_obj=logging_obj,
)
assert mock_handler.call_args.kwargs["input"] == original_input
def test_model_override_re_resolves_provider(self):
"""[G] When the prompt template overrides the model to a different provider,
custom_llm_provider is re-resolved so downstream routing uses the correct provider.
@ -393,3 +509,33 @@ class TestAsyncResponsesAPIPromptManagement:
passed_messages = call_kwargs["messages"]
assert all(isinstance(m, dict) and "role" in m for m in passed_messages)
assert len(passed_messages) == 1
@pytest.mark.asyncio
async def test_async_cache_control_hook_preserves_reasoning_items(self):
original_input, merged_messages, reasoning_item = _make_cache_control_case()
logging_obj = _make_logging_obj(
merged_model="azure/gpt-5-codex",
merged_messages=merged_messages,
)
patches = _patch_responses_dispatch()
with patches[0], patches[1], patches[2], patches[3] as mock_handler:
import litellm
await litellm.aresponses(
input=original_input,
model="azure/gpt-5-codex",
litellm_logging_obj=logging_obj,
cache_control_injection_points=[{"location": "message", "role": "system"}],
)
sent_input = mock_handler.call_args.kwargs["input"]
assert [item.get("type") for item in sent_input] == [
None,
"reasoning",
"message",
None,
]
assert sent_input[0]["cache_control"] == {"type": "ephemeral"}
assert sent_input[1] == reasoning_item
assert sent_input[2]["id"] == "msg_1"

File diff suppressed because it is too large Load diff

View file

@ -19,12 +19,13 @@ const eslintConfig = [
"unused-imports/no-unused-imports": "error",
"local/no-large-inline-object-arg": "warn",
"local/no-long-condition-chain": "warn",
"local/no-complex-jsx-arrow": ["error", { maxStatements: 2 }],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": ["warn", { allow: ["warn", "error"] }],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/ban-ts-comment": "off",
"prefer-const": "off",
"prefer-const": "error",
"no-empty": "off",
"no-prototype-builtins": "off",
"no-useless-catch": "off",
@ -51,13 +52,32 @@ const eslintConfig = [
patterns: [
{
group: ["@tremor/react", "@tremor/react/*"],
message: "@tremor/react is being phased out; build new UI with antd instead of adding tremor imports.",
message:
"@tremor/react is being phased out; build new UI with shadcn/ui primitives instead of adding tremor imports.",
},
{
group: ["antd", "antd/*"],
message:
"antd is being phased out; build new UI with shadcn/ui primitives instead of adding antd imports.",
},
],
},
],
},
},
{
files: ["src/**/*.tsx"],
rules: {
"local/filename-pascal-case": "error",
},
},
{
files: ["src/**/*.{ts,tsx}"],
ignores: ["src/**/*.test.{ts,tsx}", "src/**/*.spec.{ts,tsx}", "src/data/**"],
rules: {
"max-lines": ["error", { max: 800, skipBlankLines: true, skipComments: true }],
},
},
{
files: ["src/lib/http/**"],
rules: {

View file

@ -27,7 +27,7 @@
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
"next": "16.2.6",
"next": "16.2.11",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
@ -61,7 +61,7 @@
"@vitest/coverage-v8": "3.2.6",
"@vitest/ui": "3.2.6",
"eslint": "9.39.2",
"eslint-config-next": "16.2.6",
"eslint-config-next": "16.2.11",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-unused-imports": "4.3.0",
"jsdom": "27.4.0",
@ -2244,15 +2244,15 @@
}
},
"node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz",
"integrity": "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz",
"integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.11.tgz",
"integrity": "sha512-vMEf/aXOpzFFdtIvFYOnIDPKb0xBbrXONsz83CcKdRrekfxNdL8PNkq5qHqAHSXVlIifnX68LOMaxr3z5PkeLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2260,9 +2260,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.11.tgz",
"integrity": "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==",
"cpu": [
"arm64"
],
@ -2276,9 +2276,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.11.tgz",
"integrity": "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==",
"cpu": [
"x64"
],
@ -2292,9 +2292,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.11.tgz",
"integrity": "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==",
"cpu": [
"arm64"
],
@ -2308,9 +2308,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.11.tgz",
"integrity": "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==",
"cpu": [
"arm64"
],
@ -2324,9 +2324,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.11.tgz",
"integrity": "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==",
"cpu": [
"x64"
],
@ -2340,9 +2340,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.11.tgz",
"integrity": "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==",
"cpu": [
"x64"
],
@ -2356,9 +2356,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.11.tgz",
"integrity": "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==",
"cpu": [
"arm64"
],
@ -2372,9 +2372,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.11.tgz",
"integrity": "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==",
"cpu": [
"x64"
],
@ -3620,6 +3620,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
@ -6642,13 +6708,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz",
"integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.11.tgz",
"integrity": "sha512-FIpbK/dUyxUExchDB7eBg3k+VU8R2iR/Cx9/kqTBUTFv2bOIR9aRrpno4rvAQ9VhiPQAyFKNA2NlZwouGWtclA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.2.6",
"@next/eslint-plugin-next": "16.2.11",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@ -10309,12 +10375,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.11.tgz",
"integrity": "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.6",
"@next/env": "16.2.11",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@ -10328,14 +10394,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.6",
"@next/swc-darwin-x64": "16.2.6",
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-arm64-musl": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6",
"@next/swc-linux-x64-musl": "16.2.6",
"@next/swc-win32-arm64-msvc": "16.2.6",
"@next/swc-win32-x64-msvc": "16.2.6",
"@next/swc-darwin-arm64": "16.2.11",
"@next/swc-darwin-x64": "16.2.11",
"@next/swc-linux-arm64-gnu": "16.2.11",
"@next/swc-linux-arm64-musl": "16.2.11",
"@next/swc-linux-x64-gnu": "16.2.11",
"@next/swc-linux-x64-musl": "16.2.11",
"@next/swc-win32-arm64-msvc": "16.2.11",
"@next/swc-win32-x64-msvc": "16.2.11",
"sharp": "^0.34.5"
},
"peerDependencies": {

View file

@ -39,7 +39,7 @@
"jwt-decode": "4.0.0",
"lucide-react": "0.513.0",
"moment": "2.30.1",
"next": "16.2.6",
"next": "16.2.11",
"openai": "4.104.0",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
@ -73,7 +73,7 @@
"@vitest/coverage-v8": "3.2.6",
"@vitest/ui": "3.2.6",
"eslint": "9.39.2",
"eslint-config-next": "16.2.6",
"eslint-config-next": "16.2.11",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-unused-imports": "4.3.0",
"jsdom": "27.4.0",

View file

@ -0,0 +1,59 @@
import { basename } from "path";
const NEXT_RESERVED = new Set([
"page",
"layout",
"route",
"template",
"default",
"loading",
"error",
"global-error",
"not-found",
"middleware",
"instrumentation",
"sitemap",
"robots",
"manifest",
"icon",
"apple-icon",
"favicon",
"opengraph-image",
"twitter-image",
]);
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
const rule = {
meta: {
type: "suggestion",
docs: {
description: "Require PascalCase filenames for .tsx modules; exempt Next.js reserved files and test/spec files.",
},
schema: [],
messages: {
notPascalCase: "Filename '{{name}}' should be PascalCase (e.g. '{{suggestion}}.tsx').",
},
},
create(context) {
const filename = context.filename;
const stem = basename(filename).replace(/\.tsx$/, "");
const [head, ...rest] = stem.split(".");
if (rest.includes("test") || rest.includes("spec")) return {};
if (NEXT_RESERVED.has(head)) return {};
if (PASCAL_CASE.test(head)) return {};
const pascalHead = head
.split(/[-_]/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
const suggestion = [pascalHead, ...rest].join(".");
return {
Program(node) {
context.report({ node, messageId: "notPascalCase", data: { name: `${stem}.tsx`, suggestion } });
},
};
},
};
export default rule;

View file

@ -1,10 +1,14 @@
import noLargeInlineObjectArg from "./no-large-inline-object-arg.mjs";
import noLongConditionChain from "./no-long-condition-chain.mjs";
import noComplexJsxArrow from "./no-complex-jsx-arrow.mjs";
import filenamePascalCase from "./filename-pascal-case.mjs";
const plugin = {
rules: {
"no-large-inline-object-arg": noLargeInlineObjectArg,
"no-long-condition-chain": noLongConditionChain,
"no-complex-jsx-arrow": noComplexJsxArrow,
"filename-pascal-case": filenamePascalCase,
},
};

View file

@ -0,0 +1,41 @@
const DEFAULT_MAX_STATEMENTS = 2;
const isJsxAttributeValue = (node) => {
const parent = node.parent;
if (parent == null) return false;
return parent.type === "JSXExpressionContainer" && parent.parent?.type === "JSXAttribute";
};
const rule = {
meta: {
type: "suggestion",
docs: {
description:
"Disallow arrow functions with block bodies over a few statements passed inline as JSX attributes; extract them into a named handler.",
},
schema: [
{
type: "object",
properties: { maxStatements: { type: "integer", minimum: 1 } },
additionalProperties: false,
},
],
messages: {
tooComplex: "Inline JSX arrow handler has {{count}} statements; extract it into a named function (max {{max}}).",
},
},
create(context) {
const maxStatements = context.options[0]?.maxStatements ?? DEFAULT_MAX_STATEMENTS;
return {
ArrowFunctionExpression(node) {
if (node.body.type !== "BlockStatement") return;
if (!isJsxAttributeValue(node)) return;
const count = node.body.body.length;
if (count <= maxStatements) return;
context.report({ node, messageId: "tooComplex", data: { count, max: maxStatements } });
},
};
},
};
export default rule;

View file

@ -1,4 +1,5 @@
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import APIReferenceView from "./APIReferenceView";
@ -44,4 +45,48 @@ describe("APIReferenceView", () => {
expect(renderedCode).toContain(apiDocUrl);
expect(renderedCode).not.toContain(proxyUrl);
});
it("renders the page title, blurb and docs link", () => {
render(<APIReferenceView proxySettings={{ PROXY_BASE_URL: "https://proxy.litellm.test" }} />);
expect(screen.getByText("OpenAI Compatible Proxy: API Reference")).toBeTruthy();
expect(screen.getByText(/LiteLLM is OpenAI Compatible/)).toBeTruthy();
const docsLink = screen.getByRole("link", { name: /API Reference Docs/ });
expect(docsLink.getAttribute("href")).toBe("https://docs.litellm.ai/docs/proxy/user_keys");
expect(docsLink.getAttribute("target")).toBe("_blank");
});
it("exposes the three SDK tabs with the first selected by default", () => {
render(<APIReferenceView proxySettings={{ PROXY_BASE_URL: "https://proxy.litellm.test" }} />);
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
"OpenAI Python SDK",
"LlamaIndex",
"Langchain Py",
]);
expect(screen.getAllByRole("tab").map((tab) => tab.getAttribute("aria-selected"))).toEqual([
"true",
"false",
"false",
]);
});
it.each([
["OpenAI Python SDK", "import openai"],
["LlamaIndex", "from llama_index.llms import AzureOpenAI"],
["Langchain Py", "from langchain.chat_models import ChatOpenAI"],
])("selecting %s shows its snippet wired to the base url", async (tabName, marker) => {
const proxyUrl = "https://proxy.litellm.test";
const user = userEvent.setup();
render(<APIReferenceView proxySettings={{ PROXY_BASE_URL: proxyUrl }} />);
await user.click(screen.getByRole("tab", { name: tabName }));
expect(screen.getByRole("tab", { name: tabName }).getAttribute("aria-selected")).toBe("true");
const selectedPanel = screen.getByRole("tabpanel");
expect(selectedPanel.textContent).toContain(marker);
expect(selectedPanel.textContent).toContain(proxyUrl);
});
});

View file

@ -1,7 +1,7 @@
"use client";
import React from "react";
import { Text, Tab, TabGroup, TabList, TabPanel, TabPanels, Grid } from "@tremor/react";
import CodeBlock from "@/components/CodeBlock";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import DocLink from "./DocLink";
interface ApiRefProps {
@ -21,33 +21,35 @@ const APIReferenceView: React.FC<ApiRefProps> = ({ proxySettings }) => {
}
return (
<>
<Grid className="gap-2 p-8 h-[80vh] w-full mt-2">
<div className="mb-5">
{/* Header row with Docs link on the right */}
<div className="flex items-center justify-between">
<p className="text-2xl text-tremor-content-strong dark:text-dark-tremor-content-strong font-semibold">
OpenAI Compatible Proxy: API Reference
</p>
<DocLink className="ml-3 shrink-0" href="https://docs.litellm.ai/docs/proxy/user_keys" />
</div>
<div className="grid grid-cols-1 gap-2 p-8 h-[80vh] w-full mt-2">
<div className="mb-5">
{/* Header row with Docs link on the right */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">OpenAI Compatible Proxy: API Reference</h1>
<DocLink className="ml-3 shrink-0" href="https://docs.litellm.ai/docs/proxy/user_keys" />
</div>
<Text className="mt-2 mb-2">
LiteLLM is OpenAI Compatible. This means your API Key works with the OpenAI SDK. Just replace the base_url
to point to your litellm proxy. Example Below{" "}
</Text>
<p className="mt-2 mb-2 text-sm text-muted-foreground">
LiteLLM is OpenAI Compatible. This means your API Key works with the OpenAI SDK. Just replace the base_url to
point to your litellm proxy. Example Below{" "}
</p>
<TabGroup>
<TabList>
<Tab>OpenAI Python SDK</Tab>
<Tab>LlamaIndex</Tab>
<Tab>Langchain Py</Tab>
</TabList>
<TabPanels>
<TabPanel>
<CodeBlock
language="python"
code={`import openai
<Tabs defaultValue="openai">
<TabsList variant="line" className="border-b rounded-none w-full justify-start h-auto p-0">
<TabsTrigger value="openai" className="rounded-none px-4 py-2 flex-none">
OpenAI Python SDK
</TabsTrigger>
<TabsTrigger value="llamaindex" className="rounded-none px-4 py-2 flex-none">
LlamaIndex
</TabsTrigger>
<TabsTrigger value="langchain" className="rounded-none px-4 py-2 flex-none">
Langchain Py
</TabsTrigger>
</TabsList>
<TabsContent value="openai">
<CodeBlock
language="python"
code={`import openai
client = openai.OpenAI(
api_key="your_api_key",
base_url="${base_url}" # LiteLLM Proxy is OpenAI compatible, Read More: https://docs.litellm.ai/docs/proxy/user_keys
@ -64,13 +66,13 @@ response = client.chat.completions.create(
)
print(response)`}
/>
</TabPanel>
/>
</TabsContent>
<TabPanel>
<CodeBlock
language="python"
code={`import os, dotenv
<TabsContent value="llamaindex">
<CodeBlock
language="python"
code={`import os, dotenv
from llama_index.llms import AzureOpenAI
from llama_index.embeddings import AzureOpenAIEmbedding
@ -98,13 +100,13 @@ index = VectorStoreIndex.from_documents(documents, service_context=service_conte
query_engine = index.as_query_engine()
response = query_engine.query("What did the author do growing up?")
print(response)`}
/>
</TabPanel>
/>
</TabsContent>
<TabPanel>
<CodeBlock
language="python"
code={`from langchain.chat_models import ChatOpenAI
<TabsContent value="langchain">
<CodeBlock
language="python"
code={`from langchain.chat_models import ChatOpenAI
from langchain.prompts.chat import (
ChatPromptTemplate,
HumanMessagePromptTemplate,
@ -129,13 +131,11 @@ messages = [
response = chat(messages)
print(response)`}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
</Grid>
</>
/>
</TabsContent>
</Tabs>
</div>
</div>
);
};

View file

@ -24,6 +24,7 @@ export interface SSOSettingsValues {
generic_authorization_endpoint: string | null;
generic_token_endpoint: string | null;
generic_userinfo_endpoint: string | null;
generic_scope: string | null;
proxy_base_url: string | null;
user_email: string | null;
ui_access_mode: string | null;

View file

@ -211,6 +211,7 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
auth_type={mcpServer.auth_type}
oauth2_flow={mcpServer.oauth2_flow}
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
dcr_bridge={mcpServer.dcr_bridge}
tokenUrl={mcpServer.token_url}
userRole={userRole}
userID={userID}

View file

@ -17,8 +17,12 @@ vi.mock("@/utils/mcpTokenStore", () => ({
removeToken: vi.fn(),
}));
const { toolsOAuthFlowSpy } = vi.hoisted(() => ({
toolsOAuthFlowSpy: vi.fn(() => ({ startOAuthFlow: vi.fn(), status: "idle", error: null })),
}));
vi.mock("@/hooks/useToolsOAuthFlow", () => ({
useToolsOAuthFlow: () => ({ startOAuthFlow: vi.fn(), status: "idle", error: null }),
useToolsOAuthFlow: toolsOAuthFlowSpy,
}));
vi.mock("@/hooks/useUserMcpOAuthFlow", () => ({
@ -54,6 +58,27 @@ const credStatus = (overrides: Record<string, unknown> = {}) => ({
...overrides,
});
describe("MCPToolsViewer gatewayMintsClient wiring", () => {
// Pins the call site (not just the helper): the viewer must pass the bridge-AWARE
// gatewayMintsClientFor value to useToolsOAuthFlow, so the browser skips its own register exactly
// when the gateway mints. The oauth_delegate + dcr_bridge cell is the regression guard: with the
// old bridge-blind predicate it would have passed true here and dead-ended.
beforeEach(() => toolsOAuthFlowSpy.mockClear());
it.each([
{ auth_type: "true_passthrough", dcr_bridge: true, gatewayMintsClient: true },
{ auth_type: "true_passthrough", dcr_bridge: false, gatewayMintsClient: true },
{ auth_type: "oauth_delegate", dcr_bridge: false, gatewayMintsClient: true },
{ auth_type: "oauth_delegate", dcr_bridge: true, gatewayMintsClient: false },
])(
"passes gatewayMintsClient=$gatewayMintsClient for $auth_type dcr_bridge=$dcr_bridge",
({ auth_type, dcr_bridge, gatewayMintsClient }) => {
renderViewer({ auth_type, dcr_bridge, tokenUrl: null });
expect(toolsOAuthFlowSpy).toHaveBeenCalledWith(expect.objectContaining({ gatewayMintsClient }));
},
);
});
describe("MCPToolsViewer auth gate routing", () => {
beforeEach(() => {
vi.mocked(listMCPTools).mockReset().mockResolvedValue({ tools: [], error: null });

View file

@ -4,6 +4,7 @@ import { ToolTestPanel } from "./ToolTestPanel";
import { resolveLogoSrc } from "@/lib/assetPaths";
import {
isClientForwardedTokenMode,
gatewayMintsClientFor,
MCPTool,
MCPToolsViewerProps,
MCPContent,
@ -28,6 +29,7 @@ const MCPToolsViewer = ({
auth_type,
oauth2_flow,
delegate_auth_to_upstream,
dcr_bridge,
userRole,
userID,
serverAlias,
@ -76,6 +78,7 @@ const MCPToolsViewer = ({
serverId,
serverAlias,
userId: userID,
gatewayMintsClient: gatewayMintsClientFor({ auth_type, dcr_bridge }),
onSuccess: setOauthToken,
});

View file

@ -1,212 +0,0 @@
/* @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelsAndEndpointsView from "./ModelsAndEndpointsView";
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// Minimal stubs to avoid Next.js router and network usage during render
vi.mock("@/components/networking", () => ({
credentialListCall: vi.fn().mockResolvedValue({ credentials: [] }),
modelInfoCall: vi.fn().mockResolvedValue({ data: [] }),
modelCostMap: vi.fn().mockResolvedValue({}),
getPassThroughEndpointsCall: vi.fn().mockResolvedValue({ endpoints: {} }),
getCallbacksCall: vi.fn().mockResolvedValue({ router_settings: {} }),
setCallbacksCall: vi.fn().mockResolvedValue(undefined),
getUiSettings: vi.fn().mockResolvedValue({ values: {} }),
latestHealthChecksCall: vi.fn().mockResolvedValue({ latest_health_checks: {} }),
getModelCostMapReloadStatus: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/app/(dashboard)/models-and-endpoints/components/ModelAnalyticsTab/ModelAnalyticsTab", () => ({
default: () => null,
}));
vi.mock("@/components/add_model/add_auto_router_tab", () => ({
default: () => null,
}));
vi.mock("@/components/add_model/AddModelForm", () => ({
default: () => null,
}));
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
default: (props: { all_models_on_proxy?: string[] }) => {
mockHealthCheckComponent(props);
return null;
},
}));
vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
default: () => ({
teams: [],
setTeams: vi.fn(),
}),
}));
const mockUseModelsInfo = vi.fn();
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useModelsInfo: () => mockUseModelsInfo(),
}));
const mockUseUISettings = vi.fn();
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => mockUseUISettings(),
}));
const mockUseModelCostMap = vi.fn();
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
useModelCostMap: () => mockUseModelCostMap(),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const createQueryClient = () =>
new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
describe("ModelsAndEndpointsView", () => {
beforeEach(() => {
mockUseModelsInfo.mockReturnValue({
data: { data: [] },
isLoading: false,
refetch: vi.fn(),
});
mockUseUISettings.mockReturnValue({
data: { values: {} },
});
mockUseModelCostMap.mockReturnValue({
data: {},
isLoading: false,
error: null,
});
mockUseAuthorized.mockReturnValue({
accessToken: "123",
token: "123",
userRole: "Admin",
userId: "123",
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
});
it("should render the models and endpoints view", async () => {
const queryClient = createQueryClient();
const { findByText } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
</QueryClientProvider>,
);
expect(await findByText("Model Management", {}, { timeout: 10000 })).toBeInTheDocument();
});
it("should show Cost Optimization feedback banner by default", async () => {
localStorageMock.clear();
const queryClient = createQueryClient();
const { findByText } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
</QueryClientProvider>,
);
expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument();
});
it("should hide Cost Optimization feedback banner when dismiss button is clicked and persist to localStorage", async () => {
localStorageMock.clear();
const queryClient = createQueryClient();
const { findByText, queryByText, container } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
</QueryClientProvider>,
);
// Wait for banner to appear
expect(await findByText("Help shape cost optimization", {}, { timeout: 10000 })).toBeInTheDocument();
// Find and click dismiss button (X button)
const dismissButton = container.querySelector('button[aria-label="Dismiss banner"]');
expect(dismissButton).not.toBeNull();
fireEvent.click(dismissButton!);
// Banner should be hidden
expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument();
// LocalStorage should be updated
expect(localStorageMock.getItem("hideCostOptimizationFeedbackBanner")).toBe("true");
});
it("should keep Cost Optimization feedback banner hidden across remounts once dismissed", async () => {
// Set localStorage to hide banner
localStorageMock.setItem("hideCostOptimizationFeedbackBanner", "true");
const queryClient = createQueryClient();
const { findByText, queryByText } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
</QueryClientProvider>,
);
// Wait for component to render
await findByText("Model Management", {}, { timeout: 10000 });
// Banner should not be visible
expect(queryByText("Help shape cost optimization")).not.toBeInTheDocument();
});
it("should pass model IDs (not model names) to HealthCheckComponent as all_models_on_proxy", async () => {
mockHealthCheckComponent.mockClear();
const modelDataWithIds = {
data: [
{ model_name: "gpt-4", model_info: { id: "deployment-id-1" } },
{ model_name: "gpt-4", model_info: { id: "deployment-id-2" } },
],
};
mockUseModelsInfo.mockReturnValue({
data: { data: modelDataWithIds.data },
isLoading: false,
refetch: vi.fn(),
});
const queryClient = createQueryClient();
const { getByRole } = render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsView premiumUser={false} teams={[]} />
</QueryClientProvider>,
);
const healthStatusTab = getByRole("tab", { name: "Health Status" });
await act(async () => {
healthStatusTab.click();
});
expect(mockHealthCheckComponent).toHaveBeenCalled();
const healthCheckProps = mockHealthCheckComponent.mock.calls[0][0];
expect(healthCheckProps.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]);
expect(healthCheckProps.all_models_on_proxy).not.toContain("gpt-4");
});
});

View file

@ -1,487 +0,0 @@
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab";
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
import { Team } from "@/components/key_team_helpers/key_list";
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
import { getCallbacksCall } from "@/components/networking";
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
import { getDisplayModelName } from "@/components/view_model/model_name_display";
import { transformModelData } from "./utils/modelDataTransformer";
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import { RefreshIcon } from "@heroicons/react/outline";
import { useQueryClient } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table";
import { Col, Grid, Icon, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import type { UploadProps } from "antd";
import { Form } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import AddModelTab from "../../../components/add_model/add_model_tab";
import HealthCheckComponent from "../../../components/model_dashboard/HealthCheckComponent";
import ModelGroupAliasSettings from "../../../components/model_group_alias_settings";
import ModelInfoView from "../../../components/model_info_view";
import NotificationsManager from "../../../components/molecules/notifications_manager";
import PassThroughSettings from "../../../components/PassThroughSettings/PassThroughSettings";
import TeamInfoView from "../../../components/team/TeamInfo";
import useAuthorized from "../hooks/useAuthorized";
interface ModelDashboardProps {
premiumUser: boolean;
teams: Team[] | null;
}
interface RetryPolicyObject {
[key: string]: { [retryPolicyKey: string]: number } | undefined;
}
interface GlobalRetryPolicyObject {
[retryPolicyKey: string]: number;
}
interface RouterSettings {
model_group_retry_policy?: RetryPolicyObject | null;
retry_policy?: GlobalRetryPolicyObject | null;
num_retries?: number | null;
model_group_alias?: { [key: string]: string } | null;
}
const HEALTH_PAGE_SIZE = 50;
const ModelsAndEndpointsView: React.FC<ModelDashboardProps> = ({ premiumUser, teams }) => {
const { accessToken, token, userRole, userId: userID } = useAuthorized();
const [addModelForm] = Form.useForm();
const [lastRefreshed, setLastRefreshed] = useState("");
const [providerModels, setProviderModels] = useState<Array<string>>([]);
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
const [retryScope, setRetryScope] = useState<string | null>("global");
const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState<RetryPolicyObject | null>(null);
const [globalRetryPolicy, setGlobalRetryPolicy] = useState<GlobalRetryPolicyObject | null>(null);
const [defaultRetry, setDefaultRetry] = useState<number>(0);
const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({});
const [showAdvancedSettings, setShowAdvancedSettings] = useState<boolean>(false);
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [healthPagination, setHealthPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: HEALTH_PAGE_SIZE,
});
const queryClient = useQueryClient();
const { data: modelDataResponse, isLoading: isLoadingModels, refetch: refetchModels } = useModelsInfo();
const { data: healthModelDataResponse, isLoading: isLoadingHealthModels } = useModelsInfo(
healthPagination.pageIndex + 1,
healthPagination.pageSize,
);
const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap();
const { data: credentialsResponse, isLoading: isLoadingCredentials } = useCredentials();
const credentialsList = credentialsResponse?.credentials || [];
const { data: uiSettings, isLoading: isLoadingUISettings } = useUISettings();
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
const availableModelGroups = useMemo(() => {
if (!modelDataResponse?.data) return [];
const allModelGroups = new Set<string>();
for (const model of modelDataResponse.data) {
allModelGroups.add(model.model_name);
}
return Array.from(allModelGroups).sort();
}, [modelDataResponse?.data]);
const availableModelAccessGroups = useMemo(() => {
if (!modelDataResponse?.data) return [];
const allModelAccessGroups = new Set<string>();
for (const model of modelDataResponse.data) {
const modelInfo = model.model_info;
if (modelInfo?.access_groups) {
for (const group of modelInfo.access_groups) {
allModelAccessGroups.add(group);
}
}
}
return Array.from(allModelAccessGroups);
}, [modelDataResponse?.data]);
const allModelsOnProxy = useMemo<string[]>(() => {
if (!modelDataResponse?.data) return [];
return modelDataResponse.data.map((model: any) => model.model_name);
}, [modelDataResponse?.data]);
const healthModelIdsOnProxy = useMemo<string[]>(() => {
if (!healthModelDataResponse?.data) return [];
return healthModelDataResponse.data
.map((model: any) => model.model_info?.id)
.filter((id: string | undefined): id is string => Boolean(id));
}, [healthModelDataResponse?.data]);
const getProviderFromModel = (model: string) => {
if (modelCostMapData !== null && modelCostMapData !== undefined) {
if (typeof modelCostMapData == "object" && model in modelCostMapData) {
return modelCostMapData[model]["litellm_provider"];
}
}
return "openai";
};
const processedModelData = useMemo(() => {
if (!modelDataResponse?.data) return { data: [] };
return transformModelData(modelDataResponse, getProviderFromModel);
}, [modelDataResponse?.data, getProviderFromModel]);
const processedHealthModelData = useMemo(() => {
if (!healthModelDataResponse?.data) return { data: [] };
return transformModelData(healthModelDataResponse, getProviderFromModel);
}, [healthModelDataResponse?.data, getProviderFromModel]);
const healthRowCount = healthModelDataResponse?.total_count ?? 0;
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
const isInternalUser = userRole && internalUserRoles.includes(userRole);
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams, userID);
const addModelDisabledForInternalUsers =
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
// Hide tab if user is NOT a proxy admin AND (internal user with setting enabled OR not a team admin)
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
const setProviderModelsFn = (provider: Providers) => {
const _providerModels = getProviderModels(provider, modelCostMapData);
setProviderModels(_providerModels);
};
const uploadProps: UploadProps = {
name: "file",
accept: ".json",
pastable: false,
beforeUpload: (file) => {
if (file.type === "application/json") {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target) {
const jsonStr = e.target.result as string;
addModelForm.setFieldsValue({ vertex_credentials: jsonStr });
}
};
reader.readAsText(file);
}
return false;
},
onChange(info) {
if (info.file.status === "done") {
NotificationsManager.success(`${info.file.name} file uploaded successfully`);
} else if (info.file.status === "error") {
NotificationsManager.fromBackend(`${info.file.name} file upload failed.`);
}
},
};
const handleRefreshClick = () => {
const currentDate = new Date();
setLastRefreshed(currentDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
setHealthPagination((previous) => ({ ...previous, pageIndex: 0 }));
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
refetchModels();
};
const fetchRouterSettings = useCallback(async (): Promise<RouterSettings | null> => {
if (!accessToken || !userID || !userRole) {
return null;
}
try {
const routerSettingsInfo = await getCallbacksCall(accessToken, userID, userRole);
return routerSettingsInfo.router_settings;
} catch (error) {
console.error("Error fetching model data:", error);
return null;
}
}, [accessToken, userID, userRole]);
const applyRouterSettings = useCallback((routerSettings: RouterSettings) => {
setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null);
setGlobalRetryPolicy(routerSettings.retry_policy ?? null);
setDefaultRetry(routerSettings.num_retries ?? 2);
setModelGroupAlias(routerSettings.model_group_alias || {});
}, []);
const loadRetrySettings = useCallback(async () => {
const routerSettings = await fetchRouterSettings();
if (routerSettings) {
applyRouterSettings(routerSettings);
}
}, [fetchRouterSettings, applyRouterSettings]);
const handleSaveRetrySettings = () => {
updateRetryPolicy.mutate(
{
retry_policy: globalRetryPolicy,
model_group_retry_policy: modelGroupRetryPolicy,
},
{
onSuccess: () => {
NotificationsManager.success("Retry settings saved successfully");
loadRetrySettings();
},
onError: () => {
NotificationsManager.fromBackend("Failed to save retry settings");
},
},
);
};
useEffect(() => {
if (!accessToken || !token || !userRole || !userID || !modelDataResponse) {
return;
}
let active = true;
void (async () => {
const routerSettings = await fetchRouterSettings();
if (active && routerSettings) {
applyRouterSettings(routerSettings);
}
})();
return () => {
active = false;
};
}, [accessToken, token, userRole, userID, modelDataResponse, fetchRouterSettings, applyRouterSettings]);
const isLoading = isLoadingModels || isLoadingModelCostMap || isLoadingCredentials || isLoadingUISettings;
// Admin Viewer can view all models read-only — page render proceeds; the
// individual write-action tabs (Add Model, LLM Credentials, etc.) are
// gated separately below.
const handleOk = async () => {
try {
const values = await addModelForm.validateFields();
await handleAddModelSubmit(values, accessToken, addModelForm, handleRefreshClick);
} catch (error: any) {
const errorMessages =
error.errorFields
?.map((field: any) => {
return `${field.name.join(".")}: ${field.errors.join(", ")}`;
})
.join(" | ") || "Unknown validation error";
NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`);
}
};
Object.keys(Providers).find((key) => (Providers as { [index: string]: any })[key] === selectedProvider);
// If a team is selected, render TeamInfoView in full page layout
if (selectedTeamId) {
return (
<div className="w-full h-full">
<TeamInfoView
teamId={selectedTeamId}
onClose={() => setSelectedTeamId(null)}
accessToken={accessToken}
is_team_admin={userRole === "Admin"}
is_proxy_admin={userRole === "Proxy Admin"}
userModels={allModelsOnProxy}
editTeam={false}
onUpdate={handleRefreshClick}
premiumUser={premiumUser}
/>
</div>
);
}
return (
<div className="mx-4 h-[75vh]">
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
<Col numColSpan={1} className="flex flex-col gap-2">
{/* Model Management Header */}
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-lg font-semibold">Model Management</h2>
{!all_admin_roles.includes(userRole) ? (
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
) : (
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
)}
</div>
</div>
{/* Cost Optimization Feedback Banner */}
<CostOptimizationFeedbackBanner />
{selectedModelId && !isLoading ? (
<ModelInfoView
modelId={selectedModelId}
onClose={() => {
setSelectedModelId(null);
}}
accessToken={accessToken}
userID={userID}
userRole={userRole}
onModelUpdate={(updatedModel) => {
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
handleRefreshClick();
}}
modelAccessGroups={availableModelAccessGroups}
/>
) : (
(() => {
// Build a single source-of-truth list of {tab, panel} pairs.
// Conditionally-hidden tabs (e.g. "Add Model" for non-admin) get
// filtered out as a unit so tab indices and panel indices can
// never drift apart — Tremor's TabList and TabPanels filter
// falsy children inconsistently, which previously caused
// "click LLM Credentials, see nothing" for Admin Viewer.
const isAdmin = all_admin_roles.includes(userRole);
const visibleTabs: Array<{ tab: React.ReactElement; panel: React.ReactElement }> = [
{
tab: <Tab key="all-models">{isAdmin ? "All Models" : "Your Models"}</Tab>,
panel: (
<AllModelsTab
key="all-models"
selectedModelGroup={selectedModelGroup}
setSelectedModelGroup={setSelectedModelGroup}
availableModelGroups={availableModelGroups}
availableModelAccessGroups={availableModelAccessGroups}
setSelectedModelId={setSelectedModelId}
setSelectedTeamId={setSelectedTeamId}
/>
),
},
];
if (!shouldHideAddModelTab) {
visibleTabs.push({
tab: <Tab key="add-model">Add Model</Tab>,
panel: (
<TabPanel key="add-model" className="h-full">
<AddModelTab
form={addModelForm}
handleOk={handleOk}
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
providerModels={providerModels}
setProviderModelsFn={setProviderModelsFn}
getPlaceholder={getPlaceholder}
uploadProps={uploadProps}
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams}
credentials={credentialsList}
accessToken={accessToken}
userRole={userRole}
/>
</TabPanel>
),
});
}
if (isAdmin) {
visibleTabs.push(
{
tab: <Tab key="llm-credentials">LLM Credentials</Tab>,
panel: (
<TabPanel key="llm-credentials">
<CredentialsPanel uploadProps={uploadProps} />
</TabPanel>
),
},
{
tab: <Tab key="pass-through">Pass-Through Endpoints</Tab>,
panel: (
<TabPanel key="pass-through">
<PassThroughSettings
accessToken={accessToken}
userRole={userRole}
userID={userID}
premiumUser={premiumUser}
/>
</TabPanel>
),
},
{
tab: <Tab key="health-status">Health Status</Tab>,
panel: (
<TabPanel key="health-status">
<HealthCheckComponent
accessToken={accessToken}
modelData={processedHealthModelData}
all_models_on_proxy={healthModelIdsOnProxy}
getDisplayModelName={getDisplayModelName}
setSelectedModelId={setSelectedModelId}
teams={teams}
isLoading={isLoadingHealthModels}
pagination={healthPagination}
onPaginationChange={setHealthPagination}
rowCount={healthRowCount}
/>
</TabPanel>
),
},
{
tab: <Tab key="model-retry-settings">Model Retry Settings</Tab>,
panel: (
<ModelRetrySettingsTab
key="model-retry-settings"
selectedModelGroup={retryScope}
setSelectedModelGroup={setRetryScope}
availableModelGroups={availableModelGroups}
globalRetryPolicy={globalRetryPolicy}
setGlobalRetryPolicy={setGlobalRetryPolicy}
defaultRetry={defaultRetry}
modelGroupRetryPolicy={modelGroupRetryPolicy}
setModelGroupRetryPolicy={setModelGroupRetryPolicy}
handleSaveRetrySettings={handleSaveRetrySettings}
isSaving={updateRetryPolicy.isPending}
/>
),
},
{
tab: <Tab key="model-group-alias">Model Group Alias</Tab>,
panel: (
<TabPanel key="model-group-alias">
<ModelGroupAliasSettings
accessToken={accessToken}
initialModelGroupAlias={modelGroupAlias}
onAliasUpdate={setModelGroupAlias}
/>
</TabPanel>
),
},
{
tab: <Tab key="price-data-reload">Price Data Reload</Tab>,
panel: <PriceDataManagementTab key="price-data-reload" />,
},
);
}
return (
<TabGroup
index={selectedTabIndex}
onIndexChange={setSelectedTabIndex}
className="gap-2 h-[75vh] w-full "
>
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">{visibleTabs.map((t) => t.tab)}</div>
<div className="flex items-center space-x-2 self-center">
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
<Icon
icon={RefreshIcon}
variant="shadow"
size="xs"
className="cursor-pointer"
onClick={handleRefreshClick}
/>
</div>
</TabList>
<TabPanels>{visibleTabs.map((t) => t.panel)}</TabPanels>
</TabGroup>
);
})()
)}
</Col>
</Grid>
</div>
);
};
export default ModelsAndEndpointsView;

View file

@ -0,0 +1,59 @@
"use client";
import { Form } from "antd";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import AddModelTab from "@/components/add_model/add_model_tab";
import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit";
import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
export default function AddModelPage() {
const { accessToken, userRole } = useAuthorized();
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: modelCostMapData } = useModelCostMap();
const { data: credentialsResponse } = useCredentials();
const { data: teams } = useTeams();
const [selectedProvider, setSelectedProvider] = useState<Providers>(Providers.Anthropic);
const [providerModels, setProviderModels] = useState<string[]>([]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
const handleOk = async () => {
try {
const values = await form.validateFields();
await handleAddModelSubmit(values, accessToken, form, refresh);
} catch (error: any) {
const errorMessages =
error.errorFields?.map((field: any) => `${field.name.join(".")}: ${field.errors.join(", ")}`).join(" | ") ||
"Unknown validation error";
NotificationsManager.fromBackend(`Please fill in the following required fields: ${errorMessages}`);
}
};
return (
<AddModelTab
form={form}
handleOk={handleOk}
selectedProvider={selectedProvider}
setSelectedProvider={setSelectedProvider}
providerModels={providerModels}
setProviderModelsFn={(provider) => setProviderModels(getProviderModels(provider, modelCostMapData))}
getPlaceholder={getPlaceholder}
uploadProps={vertexCredentialsUploadProps(form)}
showAdvancedSettings={showAdvancedSettings}
setShowAdvancedSettings={setShowAdvancedSettings}
teams={teams ?? null}
credentials={credentialsResponse?.credentials || []}
accessToken={accessToken}
userRole={userRole}
/>
);
}

View file

@ -11,7 +11,7 @@ import { modelDeleteCall, modelPatchUpdateCall } from "@/components/networking";
import { InfoCircleOutlined, SettingOutlined } from "@ant-design/icons";
import { PaginationState, SortingState } from "@tanstack/react-table";
import { useQueryClient } from "@tanstack/react-query";
import { Grid, TabPanel } from "@tremor/react";
import { Grid } from "@tremor/react";
import { Badge, Button, Select, Skeleton, Space, Typography } from "antd";
import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
@ -232,7 +232,7 @@ const AllModelsTab = ({
};
return (
<TabPanel>
<div className="w-full">
<Grid>
<div className="flex flex-col space-y-4">
<div className="bg-white rounded-lg shadow-sm">
@ -600,7 +600,7 @@ const AllModelsTab = ({
onCancel={() => setIsModelSettingsModalVisible(false)}
onSuccess={() => setIsModelSettingsModalVisible(false)}
/>
</TabPanel>
</div>
);
};

View file

@ -1,4 +1,4 @@
import { Button, Select, SelectItem, TabPanel, Text, Title } from "@tremor/react";
import { Button, Select, SelectItem, Text, Title } from "@tremor/react";
import { InputNumber } from "antd";
import React from "react";
@ -64,7 +64,7 @@ const ModelRetrySettingsTab = ({
};
return (
<TabPanel>
<div>
<div className="flex items-center gap-4 mb-6">
<div className="flex items-center">
<Text>Retry Policy Scope:</Text>
@ -132,7 +132,7 @@ const ModelRetrySettingsTab = ({
<Button className="mt-6 mr-8" onClick={handleSaveRetrySettings} loading={isSaving} disabled={isSaving}>
Save
</Button>
</TabPanel>
</div>
);
};

View file

@ -0,0 +1,22 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PriceDataManagementTab from "./PriceDataManagementTab";
// Deliberately do NOT mock @tremor/react. These tab components render standalone
// (inside antd Tabs / directly as a route page), no longer inside a Tremor
// <TabGroup>. A Tremor <TabPanel> root renders nothing without that context, so
// this asserts the component's content is visible on its own — reverting the root
// back to <TabPanel> makes the title disappear and fails this test.
vi.mock("@/components/price_data_reload", () => ({ default: () => <div>reload</div> }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "sk-test" }) }));
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
useModelCostMap: () => ({ refetch: vi.fn() }),
}));
describe("PriceDataManagementTab", () => {
it("renders its content standalone, without a Tremor TabGroup ancestor", () => {
const { getByText } = render(<PriceDataManagementTab />);
expect(getByText("Price Data Management")).toBeInTheDocument();
});
});

View file

@ -1,4 +1,4 @@
import { TabPanel, Text, Title } from "@tremor/react";
import { Text, Title } from "@tremor/react";
import PriceDataReload from "@/components/price_data_reload";
import React from "react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -9,7 +9,7 @@ const PriceDataManagementTab = () => {
const { refetch: refetchModelCostMap } = useModelCostMap();
return (
<TabPanel>
<div>
<div className="p-6">
<div className="mb-6">
<Title>Price Data Management</Title>
@ -28,7 +28,7 @@ const PriceDataManagementTab = () => {
className="w-full"
/>
</div>
</TabPanel>
</div>
);
};

View file

@ -0,0 +1,52 @@
/* @vitest-environment jsdom */
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useModelDetailRouting } from "./detailNavigation";
// The detail overlay is driven by ?model=/?team= on the current path. Under the
// /ui static mount a router.push to the same path (query-only change) is a no-op,
// so navigation goes through history.pushState (client-side, no full reload).
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(window.location.search) }));
describe("useModelDetailRouting", () => {
beforeEach(() => {
window.history.pushState(null, "", "/models-and-endpoints/");
});
it("openModel sets ?model= via history.pushState (no full navigation)", () => {
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useModelDetailRouting());
act(() => result.current.openModel("abc-1"));
expect(spy).toHaveBeenCalledWith(null, "", expect.stringContaining("model=abc-1"));
spy.mockRestore();
});
it("openTeam sets ?team= and drops any model param", () => {
window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useModelDetailRouting());
act(() => result.current.openTeam("team-9"));
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).toContain("team=team-9");
expect(url).not.toContain("model=");
spy.mockRestore();
});
it("close removes both model and team params", () => {
window.history.pushState(null, "", "/models-and-endpoints/?model=abc-1");
const spy = vi.spyOn(window.history, "pushState");
const { result } = renderHook(() => useModelDetailRouting());
act(() => result.current.close());
const url = spy.mock.calls.at(-1)?.[2] as string;
expect(url).not.toContain("model=");
expect(url).not.toContain("team=");
spy.mockRestore();
});
it("reads modelId and teamId from the query string", () => {
window.history.pushState(null, "", "/models-and-endpoints/?model=xyz");
const { result } = renderHook(() => useModelDetailRouting());
expect(result.current.modelId).toBe("xyz");
expect(result.current.teamId).toBeNull();
});
});

View file

@ -0,0 +1,51 @@
import { useSearchParams } from "next/navigation";
import { useCallback } from "react";
export interface ModelDetailRouting {
modelId: string | null;
teamId: string | null;
openModel: (id: string) => void;
openTeam: (id: string) => void;
close: () => void;
}
function navigateWithParams(mutate: (params: URLSearchParams) => void): void {
const params = new URLSearchParams(window.location.search);
mutate(params);
const qs = params.toString();
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
window.history.pushState(null, "", url);
}
export function useModelDetailRouting(): ModelDetailRouting {
const searchParams = useSearchParams();
const openModel = useCallback((id: string) => {
navigateWithParams((params) => {
params.delete("team");
params.set("model", id);
});
}, []);
const openTeam = useCallback((id: string) => {
navigateWithParams((params) => {
params.delete("model");
params.set("team", id);
});
}, []);
const close = useCallback(() => {
navigateWithParams((params) => {
params.delete("model");
params.delete("team");
});
}, []);
return {
modelId: searchParams?.get("model") ?? null,
teamId: searchParams?.get("team") ?? null,
openModel,
openTeam,
close,
};
}

View file

@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import HealthStatusPage from "./page";
vi.mock("next/navigation", () => ({
usePathname: () => "/models-and-endpoints/health",
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(""),
}));
const mockHealthCheckComponent = vi.fn((_props: { all_models_on_proxy?: string[] }) => null);
vi.mock("@/components/model_dashboard/HealthCheckComponent", () => ({
default: (props: { all_models_on_proxy?: string[] }) => {
mockHealthCheckComponent(props);
return null;
},
}));
vi.mock("@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer", () => ({
transformModelData: () => ({ data: [] }),
}));
const mockUseModelsInfo = vi.fn();
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useModelsInfo: () => mockUseModelsInfo() }));
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ useModelCostMap: () => ({ data: {} }) }));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "123" }) }));
describe("HealthStatusPage", () => {
beforeEach(() => {
mockHealthCheckComponent.mockClear();
});
it("passes deployment ids (not model names) to HealthCheckComponent as all_models_on_proxy", () => {
mockUseModelsInfo.mockReturnValue({
data: {
data: [
{ model_name: "gpt-4", model_info: { id: "deployment-id-1" } },
{ model_name: "gpt-4", model_info: { id: "deployment-id-2" } },
],
total_count: 2,
},
isLoading: false,
});
render(<HealthStatusPage />);
expect(mockHealthCheckComponent).toHaveBeenCalled();
const props = mockHealthCheckComponent.mock.calls[0][0];
expect(props.all_models_on_proxy).toEqual(["deployment-id-1", "deployment-id-2"]);
expect(props.all_models_on_proxy).not.toContain("gpt-4");
});
});

View file

@ -0,0 +1,63 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import HealthCheckComponent from "@/components/model_dashboard/HealthCheckComponent";
import { getDisplayModelName } from "@/components/view_model/model_name_display";
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
const HEALTH_PAGE_SIZE = 50;
export default function HealthStatusPage() {
const { accessToken } = useAuthorized();
const { data: teams } = useTeams();
const { data: modelCostMapData } = useModelCostMap();
const { openModel } = useModelDetailRouting();
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: HEALTH_PAGE_SIZE });
const { data: healthModelDataResponse, isLoading } = useModelsInfo(pagination.pageIndex + 1, pagination.pageSize);
const getProviderFromModel = useCallback(
(model: string) => {
if (modelCostMapData && typeof modelCostMapData === "object" && model in modelCostMapData) {
return modelCostMapData[model]["litellm_provider"];
}
return "openai";
},
[modelCostMapData],
);
const processedHealthModelData = useMemo(() => {
if (!healthModelDataResponse?.data) {
return { data: [] };
}
return transformModelData(healthModelDataResponse, getProviderFromModel);
}, [healthModelDataResponse, getProviderFromModel]);
const healthModelIdsOnProxy = useMemo<string[]>(
() =>
healthModelDataResponse?.data
?.map((model: any) => model.model_info?.id)
.filter((id: string | undefined): id is string => Boolean(id)) ?? [],
[healthModelDataResponse?.data],
);
return (
<HealthCheckComponent
accessToken={accessToken}
modelData={processedHealthModelData}
all_models_on_proxy={healthModelIdsOnProxy}
getDisplayModelName={getDisplayModelName}
setSelectedModelId={openModel}
teams={teams ?? null}
isLoading={isLoading}
pagination={pagination}
onPaginationChange={setPagination}
rowCount={healthModelDataResponse?.total_count ?? 0}
/>
);
}

View file

@ -0,0 +1,126 @@
/* @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ModelsAndEndpointsLayout from "./layout";
const { mockPush, mockReplace, navState } = vi.hoisted(() => ({
mockPush: vi.fn(),
mockReplace: vi.fn(),
navState: { pathname: "/models-and-endpoints", search: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => navState.pathname,
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useSearchParams: () => new URLSearchParams(navState.search),
}));
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
vi.mock("@/components/molecules/cost_optimization_feedback_banner", () => ({ default: () => null }));
vi.mock("@/components/model_info_view", () => ({
default: ({ modelId }: { modelId: string }) => <div data-testid="model-info">model:{modelId}</div>,
}));
vi.mock("@/components/team/TeamInfo", () => ({
default: ({ teamId }: { teamId: string }) => <div data-testid="team-info">team:{teamId}</div>,
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() }));
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ useTeams: () => ({ data: [] }) }));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: () => ({ data: { values: {} } }),
}));
vi.mock("@/app/(dashboard)/models-and-endpoints/useModelDashboardData", () => ({
useModelDashboardData: () => ({
availableModelGroups: [],
availableModelAccessGroups: [],
allModelsOnProxy: [],
isLoading: false,
}),
}));
const renderLayout = () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsLayout>
<div data-testid="tab-content">CHILD</div>
</ModelsAndEndpointsLayout>
</QueryClientProvider>,
);
};
describe("ModelsAndEndpointsLayout", () => {
beforeEach(() => {
navState.pathname = "/models-and-endpoints";
navState.search = "";
mockPush.mockClear();
mockReplace.mockClear();
mockUseAuthorized.mockReturnValue({
accessToken: "123",
token: "123",
userRole: "Admin",
userId: "123",
premiumUser: false,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
});
it("renders the admin tab bar and the active tab's page content", () => {
const { getByRole, getByTestId } = renderLayout();
expect(getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument();
expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
expect(getByTestId("tab-content")).toHaveTextContent("CHILD");
});
it("navigates to a tab's path when its tab is clicked", async () => {
const { getByRole } = renderLayout();
await act(async () => {
getByRole("tab", { name: "Health Status" }).click();
});
expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/health\/$/));
});
it("redirects to the base models path when the tab path is not permitted for the role", async () => {
const replaceMock = vi.fn();
const originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" },
});
mockUseAuthorized.mockReturnValue({
accessToken: "123",
token: "123",
userRole: "Internal User",
userId: "123",
premiumUser: false,
});
navState.pathname = "/models-and-endpoints/llm-credentials";
await act(async () => {
renderLayout();
});
expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/models-and-endpoints\/$/));
Object.defineProperty(window, "location", { configurable: true, value: originalLocation });
});
it("renders the model detail overlay from ?model and hides the tabs and page content", () => {
navState.search = "model=abc-123";
const { getByTestId, queryByTestId, queryByRole } = renderLayout();
expect(getByTestId("model-info")).toHaveTextContent("model:abc-123");
expect(queryByTestId("tab-content")).toBeNull();
expect(queryByRole("tab", { name: "Health Status" })).toBeNull();
});
it("renders the team detail overlay from ?team", () => {
navState.search = "team=team-9";
const { getByTestId, queryByTestId } = renderLayout();
expect(getByTestId("team-info")).toHaveTextContent("team:team-9");
expect(queryByTestId("tab-content")).toBeNull();
});
});

View file

@ -0,0 +1,162 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Tabs } from "antd";
import { RefreshIcon } from "@heroicons/react/outline";
import { useQueryClient } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { all_admin_roles, internalUserRoles, isProxyAdminRole, isUserTeamAdminForAnyTeam } from "@/utils/roles";
import CostOptimizationFeedbackBanner from "@/components/molecules/cost_optimization_feedback_banner";
import ModelInfoView from "@/components/model_info_view";
import TeamInfoView from "@/components/team/TeamInfo";
import { modelTabHref, slugFromPathname, type ModelTabSlug } from "@/app/(dashboard)/models-and-endpoints/tabRoutes";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
const BASE_TAB_KEY = "all-models";
const TAB_LABELS: Record<ModelTabSlug, string> = {
add: "Add Model",
"llm-credentials": "LLM Credentials",
"pass-through": "Pass-Through Endpoints",
health: "Health Status",
"retry-settings": "Model Retry Settings",
"model-group-alias": "Model Group Alias",
"price-data": "Price Data Reload",
};
export default function ModelsAndEndpointsLayout({ children }: { children: ReactNode }) {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
const { data: teams, isLoading: teamsLoading } = useTeams();
const { data: uiSettings, isLoading: uiSettingsLoading } = useUISettings();
const pathname = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
const { modelId, teamId, close } = useModelDetailRouting();
const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData();
const [lastRefreshed, setLastRefreshed] = useState("");
const isProxyAdmin = userRole && isProxyAdminRole(userRole);
const isInternalUser = userRole && internalUserRoles.includes(userRole);
const isUserTeamAdmin = userID && isUserTeamAdminForAnyTeam(teams ?? null, userID);
const addModelDisabledForInternalUsers =
isInternalUser && uiSettings?.values?.disable_model_add_for_internal_users === true;
const shouldHideAddModelTab = !isProxyAdmin && (addModelDisabledForInternalUsers || !isUserTeamAdmin);
const isAdmin = all_admin_roles.includes(userRole);
const visibleSlugs = useMemo<Array<"" | ModelTabSlug>>(
() => [
"",
...(shouldHideAddModelTab ? [] : (["add"] as const)),
...(isAdmin
? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const)
: []),
],
[shouldHideAddModelTab, isAdmin],
);
const activeSlug = slugFromPathname(pathname);
const isKnownSlug = visibleSlugs.some((slug) => slug === activeSlug);
const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY;
useEffect(() => {
if (teamsLoading || uiSettingsLoading) {
return;
}
if (activeSlug !== "" && !isKnownSlug) {
window.location.replace(modelTabHref(""));
}
}, [activeSlug, isKnownSlug, teamsLoading, uiSettingsLoading]);
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
const tabItems = visibleSlugs.map((slug) => {
const key = slug || BASE_TAB_KEY;
return {
key,
label: slug ? TAB_LABELS[slug] : allModelsLabel,
children: key === activeKey ? children : null,
};
});
const handleRefreshClick = () => {
setLastRefreshed(new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }));
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
};
const invalidateModels = () => queryClient.invalidateQueries({ queryKey: ["models", "list"] });
if (teamId) {
return (
<div className="w-full h-full">
<TeamInfoView
teamId={teamId}
onClose={close}
accessToken={accessToken}
is_team_admin={userRole === "Admin"}
is_proxy_admin={userRole === "Proxy Admin"}
userModels={allModelsOnProxy}
editTeam={false}
onUpdate={invalidateModels}
premiumUser={premiumUser}
/>
</div>
);
}
return (
<div className="mx-4 h-[75vh]">
<div className="flex flex-col gap-2 p-8 w-full mt-2">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-lg font-semibold">Model Management</h2>
{isAdmin ? (
<p className="text-sm text-gray-600">Add and manage models for the proxy</p>
) : (
<p className="text-sm text-gray-600">Add models for teams you are an admin for.</p>
)}
</div>
</div>
<CostOptimizationFeedbackBanner />
{modelId ? (
<ModelInfoView
modelId={modelId}
onClose={close}
accessToken={accessToken}
userID={userID}
userRole={userRole}
onModelUpdate={invalidateModels}
modelAccessGroups={availableModelAccessGroups}
/>
) : (
<Tabs
activeKey={activeKey}
onChange={(key) => router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))}
items={tabItems}
tabBarExtraContent={{
right: (
<div className="flex items-center space-x-2 self-center">
{lastRefreshed && <span className="text-xs text-gray-500">Last Refreshed: {lastRefreshed}</span>}
<button
type="button"
onClick={handleRefreshClick}
aria-label="Refresh models"
className="cursor-pointer"
>
<RefreshIcon className="h-4 w-4 text-gray-500" />
</button>
</div>
),
}}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
"use client";
import { Form } from "antd";
import CredentialsPanel from "@/components/model_add/CredentialsPanel";
import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload";
export default function LlmCredentialsPage() {
const [form] = Form.useForm();
return <CredentialsPanel uploadProps={vertexCredentialsUploadProps(form)} />;
}

View file

@ -0,0 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import ModelGroupAliasSettings from "@/components/model_group_alias_settings";
import { getCallbacksCall } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function ModelGroupAliasPage() {
const { accessToken, userId: userID, userRole } = useAuthorized();
const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({});
useEffect(() => {
if (!accessToken || !userID || !userRole) {
return;
}
let active = true;
void (async () => {
try {
const info = await getCallbacksCall(accessToken, userID, userRole);
if (active) {
setModelGroupAlias(info.router_settings?.model_group_alias || {});
}
} catch (error) {
console.error("Error fetching model group alias:", error);
}
})();
return () => {
active = false;
};
}, [accessToken, userID, userRole]);
return (
<ModelGroupAliasSettings
accessToken={accessToken}
initialModelGroupAlias={modelGroupAlias}
onAliasUpdate={setModelGroupAlias}
/>
);
}

View file

@ -1,11 +1,23 @@
"use client";
import ModelsAndEndpointsView from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useState } from "react";
import AllModelsTab from "@/app/(dashboard)/models-and-endpoints/components/AllModelsTab";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
import { useModelDetailRouting } from "@/app/(dashboard)/models-and-endpoints/detailNavigation";
export default function ModelsAndEndpointsPage() {
const { premiumUser } = useAuthorized();
const { data: teams } = useTeams();
return <ModelsAndEndpointsView premiumUser={premiumUser} teams={teams ?? null} />;
export default function AllModelsPage() {
const [selectedModelGroup, setSelectedModelGroup] = useState<string | null>(null);
const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData();
const { openModel, openTeam } = useModelDetailRouting();
return (
<AllModelsTab
selectedModelGroup={selectedModelGroup}
setSelectedModelGroup={setSelectedModelGroup}
availableModelGroups={availableModelGroups}
availableModelAccessGroups={availableModelAccessGroups}
setSelectedModelId={openModel}
setSelectedTeamId={openTeam}
/>
);
}

View file

@ -0,0 +1,11 @@
"use client";
import PassThroughSettings from "@/components/PassThroughSettings/PassThroughSettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function PassThroughPage() {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
return (
<PassThroughSettings accessToken={accessToken} userRole={userRole} userID={userID} premiumUser={premiumUser} />
);
}

View file

@ -0,0 +1,7 @@
"use client";
import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab";
export default function PriceDataPage() {
return <PriceDataManagementTab />;
}

View file

@ -0,0 +1,100 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab";
import { getCallbacksCall } from "@/components/networking";
import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy";
import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/useModelDashboardData";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import NotificationsManager from "@/components/molecules/notifications_manager";
interface RetryPolicyObject {
[key: string]: { [retryPolicyKey: string]: number } | undefined;
}
interface GlobalRetryPolicyObject {
[retryPolicyKey: string]: number;
}
interface RouterSettings {
model_group_retry_policy?: RetryPolicyObject | null;
retry_policy?: GlobalRetryPolicyObject | null;
num_retries?: number | null;
}
export default function ModelRetrySettingsPage() {
const { accessToken, userId: userID, userRole } = useAuthorized();
const { availableModelGroups } = useModelDashboardData();
const updateRetryPolicy = useUpdateRetryPolicy(accessToken);
const [retryScope, setRetryScope] = useState<string | null>("global");
const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState<RetryPolicyObject | null>(null);
const [globalRetryPolicy, setGlobalRetryPolicy] = useState<GlobalRetryPolicyObject | null>(null);
const [defaultRetry, setDefaultRetry] = useState<number>(0);
const fetchRetrySettings = useCallback(async () => {
if (!accessToken || !userID || !userRole) {
return null;
}
try {
const info = await getCallbacksCall(accessToken, userID, userRole);
return info.router_settings;
} catch (error) {
console.error("Error fetching router settings:", error);
return null;
}
}, [accessToken, userID, userRole]);
const applyRetrySettings = useCallback((routerSettings: RouterSettings) => {
setModelGroupRetryPolicy(routerSettings.model_group_retry_policy ?? null);
setGlobalRetryPolicy(routerSettings.retry_policy ?? null);
setDefaultRetry(routerSettings.num_retries ?? 2);
}, []);
useEffect(() => {
let active = true;
void (async () => {
const routerSettings = await fetchRetrySettings();
if (active && routerSettings) {
applyRetrySettings(routerSettings);
}
})();
return () => {
active = false;
};
}, [fetchRetrySettings, applyRetrySettings]);
const handleSaveRetrySettings = () => {
updateRetryPolicy.mutate(
{ retry_policy: globalRetryPolicy, model_group_retry_policy: modelGroupRetryPolicy },
{
onSuccess: () => {
NotificationsManager.success("Retry settings saved successfully");
void fetchRetrySettings().then((routerSettings) => {
if (routerSettings) {
applyRetrySettings(routerSettings);
}
});
},
onError: () => {
NotificationsManager.fromBackend("Failed to save retry settings");
},
},
);
};
return (
<ModelRetrySettingsTab
selectedModelGroup={retryScope}
setSelectedModelGroup={setRetryScope}
availableModelGroups={availableModelGroups}
globalRetryPolicy={globalRetryPolicy}
setGlobalRetryPolicy={setGlobalRetryPolicy}
defaultRetry={defaultRetry}
modelGroupRetryPolicy={modelGroupRetryPolicy}
setModelGroupRetryPolicy={setModelGroupRetryPolicy}
handleSaveRetrySettings={handleSaveRetrySettings}
isSaving={updateRetryPolicy.isPending}
/>
);
}

View file

@ -0,0 +1,38 @@
/* @vitest-environment jsdom */
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/networking", () => ({ serverRootPath: "" }));
import { MODEL_TAB_SLUGS, modelTabHref, slugFromPathname } from "./tabRoutes";
describe("slugFromPathname", () => {
it("returns empty string for the base path with or without a trailing slash", () => {
expect(slugFromPathname("/models-and-endpoints")).toBe("");
expect(slugFromPathname("/models-and-endpoints/")).toBe("");
});
it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => {
expect(slugFromPathname("/models-and-endpoints/add")).toBe("add");
expect(slugFromPathname("/ui/models-and-endpoints/llm-credentials/")).toBe("llm-credentials");
});
it("returns the raw segment for an unknown tab so the view can redirect to base", () => {
expect(slugFromPathname("/ui/models-and-endpoints/bogus")).toBe("bogus");
});
it("returns empty string when the models base segment is not in the path", () => {
expect(slugFromPathname("/teams")).toBe("");
});
});
describe("modelTabHref", () => {
it("builds the trailing-slash base href for the empty slug", () => {
expect(modelTabHref("")).toBe("/ui/models-and-endpoints/");
});
it("builds a trailing-slash href for every tab slug (required by static export)", () => {
for (const slug of MODEL_TAB_SLUGS) {
expect(modelTabHref(slug)).toBe(`/ui/models-and-endpoints/${slug}/`);
}
});
});

View file

@ -0,0 +1,29 @@
import { migratedHref } from "@/utils/migratedPages";
export const MODELS_BASE_SEGMENT = "models-and-endpoints";
export const MODEL_TAB_SLUGS = [
"add",
"llm-credentials",
"pass-through",
"health",
"retry-settings",
"model-group-alias",
"price-data",
] as const;
export type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number];
export function modelTabHref(slug: string): string {
const base = migratedHref(MODELS_BASE_SEGMENT);
return slug ? `${base}/${slug}/` : `${base}/`;
}
export function slugFromPathname(pathname: string): string {
const parts = pathname.split("/").filter(Boolean);
const idx = parts.indexOf(MODELS_BASE_SEGMENT);
if (idx === -1) {
return "";
}
return parts[idx + 1] ?? "";
}

View file

@ -0,0 +1,32 @@
import { useMemo } from "react";
import { useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
export interface ModelDashboardData {
availableModelGroups: string[];
availableModelAccessGroups: string[];
allModelsOnProxy: string[];
isLoading: boolean;
}
export function useModelDashboardData(): ModelDashboardData {
const { data: modelDataResponse, isLoading } = useModelsInfo();
const availableModelGroups = useMemo(() => {
const groups = new Set<string>(modelDataResponse?.data?.map((model) => model.model_name) ?? []);
return Array.from(groups).sort();
}, [modelDataResponse?.data]);
const availableModelAccessGroups = useMemo(() => {
const groups = new Set<string>(
modelDataResponse?.data?.flatMap((model) => model.model_info?.access_groups ?? []) ?? [],
);
return Array.from(groups);
}, [modelDataResponse?.data]);
const allModelsOnProxy = useMemo(
() => modelDataResponse?.data?.map((model) => model.model_name) ?? [],
[modelDataResponse?.data],
);
return { availableModelGroups, availableModelAccessGroups, allModelsOnProxy, isLoading };
}

Some files were not shown because too many files have changed in this diff Show more