diff --git a/.github/workflows/image-scan.yml b/.github/workflows/image-scan.yml index 8d791ca5bc7..4d4a3242399 100644 --- a/.github/workflows/image-scan.yml +++ b/.github/workflows/image-scan.yml @@ -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 diff --git a/.github/workflows/test-litellm-ui-unit.yml b/.github/workflows/test-litellm-ui-unit.yml index f0f9f504752..5374a0059de 100644 --- a/.github/workflows/test-litellm-ui-unit.yml +++ b/.github/workflows/test-litellm-ui-unit.yml @@ -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 diff --git a/.github/workflows/test-unit-proxy-endpoints.yml b/.github/workflows/test-unit-proxy-endpoints.yml index cbb36eebdb9..b3eb8f79a43 100644 --- a/.github/workflows/test-unit-proxy-endpoints.yml +++ b/.github/workflows/test-unit-proxy-endpoints.yml @@ -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 diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index 02574ca505d..f3a028f5805 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -18,6 +18,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( "/team/", "/v2/team/", "/organization/", + "/v2/organization/", "/customer/", "/end_user/", "/sso/", diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 839f5da565c..8e05f312ba0 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -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"] diff --git a/litellm/litellm_core_utils/duration_parser.py b/litellm/litellm_core_utils/duration_parser.py index 79036367652..b78a314dc45 100644 --- a/litellm/litellm_core_utils/duration_parser.py +++ b/litellm/litellm_core_utils/duration_parser.py @@ -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 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 diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 5a0f274e3ca..e99f356f8f2 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -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 diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index c38b3593465..8ce2b982955 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -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) diff --git a/litellm/llms/sagemaker/chat/transformation.py b/litellm/llms/sagemaker/chat/transformation.py index 4e4e088f491..4447d63e5a1 100644 --- a/litellm/llms/sagemaker/chat/transformation.py +++ b/litellm/llms/sagemaker/chat/transformation.py @@ -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, diff --git a/litellm/llms/sagemaker/completion/handler.py b/litellm/llms/sagemaker/completion/handler.py index 4b87271fd44..c27a3c3528c 100644 --- a/litellm/llms/sagemaker/completion/handler.py +++ b/litellm/llms/sagemaker/completion/handler.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 9a1b5cf4864..ee3196b539c 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -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", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 202eee39485..71031d64287 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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, ) diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 84bc27ef0d4..2ad8a08b8c3 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -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 diff --git a/litellm/proxy/client/cli/commands/autoroute/config.py b/litellm/proxy/client/cli/commands/autoroute/config.py index 603cea38f6f..237705564ff 100644 --- a/litellm/proxy/client/cli/commands/autoroute/config.py +++ b/litellm/proxy/client/cli/commands/autoroute/config.py @@ -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, ...]: diff --git a/litellm/proxy/client/cli/commands/autoroute/wizard.py b/litellm/proxy/client/cli/commands/autoroute/wizard.py index 8ad87315fb9..d3fe458d9f0 100644 --- a/litellm/proxy/client/cli/commands/autoroute/wizard.py +++ b/litellm/proxy/client/cli/commands/autoroute/wizard.py @@ -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) diff --git a/litellm/proxy/config_resolvers/__init__.py b/litellm/proxy/config_resolvers/__init__.py new file mode 100644 index 00000000000..88b4c3961f0 --- /dev/null +++ b/litellm/proxy/config_resolvers/__init__.py @@ -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"] diff --git a/litellm/proxy/config_resolvers/_descriptors.py b/litellm/proxy/config_resolvers/_descriptors.py new file mode 100644 index 00000000000..f67a690f92f --- /dev/null +++ b/litellm/proxy/config_resolvers/_descriptors.py @@ -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 diff --git a/litellm/proxy/config_resolvers/alerting.py b/litellm/proxy/config_resolvers/alerting.py new file mode 100644 index 00000000000..3704ec09355 --- /dev/null +++ b/litellm/proxy/config_resolvers/alerting.py @@ -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), +) diff --git a/litellm/proxy/config_resolvers/sso.py b/litellm/proxy/config_resolvers/sso.py new file mode 100644 index 00000000000..3d83c06dd62 --- /dev/null +++ b/litellm/proxy/config_resolvers/sso.py @@ -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) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index d50db8324ef..89f28a30a84 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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( diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 138a55d9227..5a289d22f99 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -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"], diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index db2cf2b70dd..90eae5bbb21 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -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 diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 70c002d2d2d..59b0cbc4ae7 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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 diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index 6bbd41b93ed..015ab1d6df6 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -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, }, ) diff --git a/litellm/proxy/mcp_registry.json b/litellm/proxy/mcp_registry.json index 84431634e24..f37fc39813e 100644 --- a/litellm/proxy/mcp_registry.json +++ b/litellm/proxy/mcp_registry.json @@ -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", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7677ae51f9a..32845763f22 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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( { diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 42111cf17f2..10c71c00110 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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 } diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 43921a847a9..5b81d1f2da3 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -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) diff --git a/litellm/repositories/team_repository.py b/litellm/repositories/team_repository.py index 3227aa812ca..68875bd7972 100644 --- a/litellm/repositories/team_repository.py +++ b/litellm/repositories/team_repository.py @@ -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) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 206736f501a..944cf58df1c 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -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: diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 7a42cb96566..ac92e5d6dcc 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -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]], diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index 7234cc2650f..742e0f7818f 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -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( diff --git a/litellm/types/proxy/model_listing.py b/litellm/types/proxy/model_listing.py index c3330da0d66..b59c0f2cf19 100644 --- a/litellm/types/proxy/model_listing.py +++ b/litellm/types/proxy/model_listing.py @@ -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] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 7b5ad3551b8..eff3b4ddf58 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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. diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 30353b93dd6..e7c48690c0a 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -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")) diff --git a/tests/e2e/lifecycle.py b/tests/e2e/lifecycle.py index c0de074f0d2..4ef25509905 100644 --- a/tests/e2e/lifecycle.py +++ b/tests/e2e/lifecycle.py @@ -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 diff --git a/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py b/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py index 3f2907f202e..d8d44820e80 100644 --- a/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py +++ b/tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py @@ -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: diff --git a/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py b/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py index c4ad0c38f31..8b93afb4752 100644 --- a/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py +++ b/tests/e2e/quota_management/budgets/test_budget_enforcement_e2e.py @@ -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)) diff --git a/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py b/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py index b57ad23ebf5..793b22a47c7 100644 --- a/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py +++ b/tests/e2e/quota_management/budgets/test_budget_reset_e2e.py @@ -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) diff --git a/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py b/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py index 3bd1b992745..b6e33627a5e 100644 --- a/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py +++ b/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py @@ -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") diff --git a/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py index bd3b4198e9e..c10ac5532a0 100644 --- a/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py +++ b/tests/litellm/llms/anthropic/test_anthropic_schema_filter.py @@ -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." + ) diff --git a/tests/proxy_migration_tests/test_offline_image_migration.py b/tests/proxy_migration_tests/test_offline_image_migration.py new file mode 100644 index 00000000000..8ed88278e69 --- /dev/null +++ b/tests/proxy_migration_tests/test_offline_image_migration.py @@ -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}" diff --git a/tests/proxy_unit_tests/test_proxy_server.py b/tests/proxy_unit_tests/test_proxy_server.py index 212f7772cad..bedd4dd1838 100644 --- a/tests/proxy_unit_tests/test_proxy_server.py +++ b/tests/proxy_unit_tests/test_proxy_server.py @@ -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 diff --git a/tests/test_litellm/litellm_core_utils/test_duration_parser.py b/tests/test_litellm/litellm_core_utils/test_duration_parser.py index b6b617610a8..cb9f273a0a7 100644 --- a/tests/test_litellm/litellm_core_utils/test_duration_parser.py +++ b/tests/test_litellm/litellm_core_utils/test_duration_parser.py @@ -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() diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py b/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py new file mode 100644 index 00000000000..90cba035760 --- /dev/null +++ b/tests/test_litellm/llms/anthropic/test_anthropic_output_format_filter.py @@ -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." + ) diff --git a/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py b/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py index 448afd5f3a5..e5a2ea9b28f 100644 --- a/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/agentcore/test_agentcore_transformation.py @@ -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: diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index fc12ead36a1..f832a4087ec 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -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).""" diff --git a/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py b/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py index af8321f24a1..c907e3249d1 100644 --- a/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py +++ b/tests/test_litellm/llms/huggingface/embedding/test_huggingface_embedding_handler.py @@ -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", diff --git a/tests/test_litellm/llms/sagemaker/test_sagemaker_chat_transformation.py b/tests/test_litellm/llms/sagemaker/test_sagemaker_chat_transformation.py new file mode 100644 index 00000000000..54e6f95c795 --- /dev/null +++ b/tests/test_litellm/llms/sagemaker/test_sagemaker_chat_transformation.py @@ -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))] diff --git a/tests/test_litellm/llms/sagemaker/test_sagemaker_completion_handler.py b/tests/test_litellm/llms/sagemaker/test_sagemaker_completion_handler.py new file mode 100644 index 00000000000..1cb27b7cf5f --- /dev/null +++ b/tests/test_litellm/llms/sagemaker/test_sagemaker_completion_handler.py @@ -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)) diff --git a/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py b/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py index 6af4cf698e2..7fea5ac0965 100644 --- a/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py +++ b/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_integration.py @@ -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): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 636c7fbd3d5..692e5340f48 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -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" diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_config.py b/tests/test_litellm/proxy/client/cli/autoroute/test_config.py index 6ab484d5004..f399b6f957a 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_config.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_config.py @@ -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" diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py b/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py index 78d4bd20338..a17fed36f52 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py @@ -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 diff --git a/tests/test_litellm/proxy/config_resolvers/test_config_resolvers.py b/tests/test_litellm/proxy/config_resolvers/test_config_resolvers.py new file mode 100644 index 00000000000..20bea98351f --- /dev/null +++ b/tests/test_litellm/proxy/config_resolvers/test_config_resolvers.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_patch_user.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_patch_user.py index f8995a6f4da..c3af4208d37 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_patch_user.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_patch_user.py @@ -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() diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index f458645e51f..7bb74285ac6 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -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() diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index 3e5bd3e9b7f..e1aaf398f97 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py index f4470e7e83d..7ed123f6cdf 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 50817b6a4c2..5202c8cbfc0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 5effa3073ee..5b67780dc58 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -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 diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 5ace46fc775..4673807a135 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -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 diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 805baed9e1e..85dbf70b452 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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") diff --git a/tests/test_litellm/repositories/test_repositories.py b/tests/test_litellm/repositories/test_repositories.py index af2eea823f4..6308faf8fc7 100644 --- a/tests/test_litellm/repositories/test_repositories.py +++ b/tests/test_litellm/repositories/test_repositories.py @@ -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( diff --git a/tests/test_litellm/responses/test_responses_prompt_management.py b/tests/test_litellm/responses/test_responses_prompt_management.py index 84e98390268..7044d8384f8 100644 --- a/tests/test_litellm/responses/test_responses_prompt_management.py +++ b/tests/test_litellm/responses/test_responses_prompt_management.py @@ -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" diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 6072c8c725b..12f19eaa1fe 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -4,20 +4,61 @@ "count": 1 } }, - "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { + "src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx": { "no-restricted-imports": { "count": 1 + } + }, + "src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupCreateModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx": { + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/agents/_components/AgentsPanel.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/agents/_components/AgentsTable.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/agents/_components/add_agent_form.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 3 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 2 @@ -27,6 +68,12 @@ } }, "src/app/(dashboard)/agents/_components/agent_card_discovery.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/refs": { "count": 3 }, @@ -35,44 +82,77 @@ } }, "src/app/(dashboard)/agents/_components/agent_cost_view.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/agents/_components/agent_form_fields.tsx": { - "no-nested-ternary": { + "local/filename-pascal-case": { "count": 1 - } - }, - "src/app/(dashboard)/agents/_components/agent_info.tsx": { + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/agents/_components/agent_info.tsx": { + "local/filename-pascal-case": { "count": 1 }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-nested-ternary": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/immutability": { "count": 1 } }, "src/app/(dashboard)/agents/_components/agent_virtual_keys.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/agents/_components/cost_config_fields.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/app/(dashboard)/agents/_components/dynamic_agent_form_fields.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 2 - } - }, - "src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx": { + }, "no-restricted-imports": { "count": 1 } }, "src/app/(dashboard)/budgets/_components/budget_modal.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/budgets/_components/budget_panel.test.tsx": { @@ -81,19 +161,31 @@ } }, "src/app/(dashboard)/budgets/_components/budget_panel.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "src/app/(dashboard)/budgets/_components/edit_budget_modal.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/caching/_components/cache_dashboard.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, + "prefer-const": { + "count": 3 + }, "react-hooks/purity": { "count": 1 }, @@ -102,6 +194,14 @@ } }, "src/app/(dashboard)/caching/_components/cache_health.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/caching/_components/cache_settings/CacheFormField.tsx": { "no-restricted-imports": { "count": 1 } @@ -111,33 +211,109 @@ "count": 1 } }, - "src/app/(dashboard)/caching/_components/cache_settings/index.tsx": { + "src/app/(dashboard)/caching/_components/cache_settings/cacheSettingsFields.ts": { "no-restricted-imports": { "count": 1 + } + }, + "src/app/(dashboard)/caching/_components/cache_settings/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": { + "src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisFormField.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx": { + "src/app/(dashboard)/caching/_components/coordination_redis_settings/CoordinationRedisTypeSelector.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx": { - "no-nested-ternary": { - "count": 2 + "src/app/(dashboard)/caching/_components/coordination_redis_settings/coordinationRedisFields.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/caching/_components/coordination_redis_settings/index.tsx": { + "local/filename-pascal-case": { + "count": 1 }, "no-restricted-imports": { "count": 1 } }, + "src/app/(dashboard)/caching/_components/response_time_indicator.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/cost-optimization/_components/AutorouterTab.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/cost-tracking/_components/add_margin_form.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/cost-tracking/_components/add_provider_form.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-nested-ternary": { + "count": 2 + }, + "no-restricted-imports": { + "count": 2 + } + }, "src/app/(dashboard)/cost-tracking/_components/how_it_works.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -148,8 +324,11 @@ } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_cost_results.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.test.tsx": { @@ -158,6 +337,9 @@ } }, "src/app/(dashboard)/cost-tracking/_components/pricing_calculator/multi_export_dropdown.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -173,11 +355,17 @@ } }, "src/app/(dashboard)/cost-tracking/_components/provider_discount_table.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "src/app/(dashboard)/cost-tracking/_components/provider_margin_table.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -193,13 +381,24 @@ } }, "src/app/(dashboard)/guardrails-monitor/_components/EvaluationSettingsModal.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/guardrails-monitor/_components/GuardrailConfig.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx": { "no-nested-ternary": { "count": 3 + }, + "no-restricted-imports": { + "count": 1 } }, "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx": { @@ -210,37 +409,64 @@ "src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx": { "no-nested-ternary": { "count": 8 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/guardrails/_components/GuardrailTestPanel.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/guardrails/_components/GuardrailTestPlayground.tsx": { "no-nested-ternary": { "count": 1 - } - }, - "src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": { + }, "no-restricted-imports": { "count": 1 } }, + "src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 2 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/guardrails/_components/add_guardrail_form.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 }, @@ -248,22 +474,44 @@ "count": 2 } }, + "src/app/(dashboard)/guardrails/_components/content_filter/CategoryTable.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/guardrails/_components/content_filter/CompetitorIntentConfiguration.tsx": { "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/guardrails/_components/content_filter/ContentCategoryConfiguration.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 3 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterConfiguration.tsx": { + "local/no-complex-jsx-arrow": { + "count": 3 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/guardrails/_components/content_filter/ContentFilterDisplay.tsx": { "no-restricted-imports": { "count": 1 @@ -273,51 +521,157 @@ "max-params": { "count": 2 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/guardrails/_components/content_filter/CustomPatternModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/content_filter/KeywordTable.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/content_filter/PatternTable.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx": { "no-nested-ternary": { "count": 6 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": { - "max-params": { + "src/app/(dashboard)/guardrails/_components/guardrailTableColumns.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx": { + "local/filename-pascal-case": { "count": 1 }, "no-restricted-imports": { "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/guardrail_garden_card.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/guardrail_garden_detail.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/guardrail_info.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "max-params": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 3 } }, + "src/app/(dashboard)/guardrails/_components/guardrail_info_helpers.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/app/(dashboard)/guardrails/_components/guardrail_optional_params.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 5 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/guardrails/_components/guardrail_provider_fields.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 5 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": { + "src/app/(dashboard)/guardrails/_components/guardrail_table.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/llm_judge/LLMJudgeFields.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/guardrails/_components/pii_components.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/pii_configuration.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/guardrails/_components/tool_permission/ToolPermissionRulesEditor.tsx": { + "no-restricted-imports": { + "count": 2 }, "react-hooks/purity": { "count": 1 @@ -483,16 +837,54 @@ "count": 2 } }, + "src/app/(dashboard)/hooks/useTeams.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/DcrBridgeToggle.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/EnvVarsSection.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.test.tsx": { "unused-imports/no-unused-imports": { "count": 1 } }, + "src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/immutability": { "count": 2 } }, + "src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/MCPPermissionManagement.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -503,89 +895,176 @@ "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx": { "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/mcp-servers/_components/OpenAPIFormSection.tsx": { + "local/no-complex-jsx-arrow": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/StdioConfiguration.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/TokenEndpointAuthMethodField.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/TokenExchangeFormFields.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/mcp-servers/_components/ToolTestPanel.tsx": { "no-nested-ternary": { "count": 3 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": { "no-nested-ternary": { "count": 2 + }, + "no-restricted-imports": { + "count": 1 } }, "src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 4 } }, - "src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": { - "no-restricted-imports": { + "src/app/(dashboard)/mcp-servers/_components/index.tsx": { + "local/filename-pascal-case": { "count": 1 + } + }, + "src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 }, "react-hooks/static-components": { "count": 4 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_connection_status.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 3 }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/immutability": { "count": 1 @@ -595,43 +1074,73 @@ } }, "src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 3 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 2 } }, - "src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx": { + "src/app/(dashboard)/mcp-servers/_components/utils.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/memory/_components/MemoryDetailDrawer.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/memory/_components/MemoryEditModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/memory/_components/MemoryView.tsx": { "no-restricted-imports": { "count": 1 - }, - "react-hooks/preserve-manual-memoization": { - "count": 4 } }, "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx": { @@ -643,8 +1152,11 @@ } }, "src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 3 @@ -660,7 +1172,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx": { @@ -668,9 +1180,20 @@ "count": 1 } }, + "src/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer.ts": { + "prefer-const": { + "count": 6 + } + }, "src/app/(dashboard)/old-usage/_components/usage.tsx": { - "no-restricted-imports": { - "count": 2 + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, + "prefer-const": { + "count": 6 }, "react-hooks/immutability": { "count": 1 @@ -679,9 +1202,19 @@ "count": 1 } }, - "src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": { + "src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx": { "no-restricted-imports": { "count": 1 + } + }, + "src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": { + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 2 @@ -691,10 +1224,18 @@ "no-nested-ternary": { "count": 2 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 5 } }, + "src/app/(dashboard)/playground/components/chat_ui/ChatImageUpload.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/playground/components/chat_ui/ChatImageUtils.test.tsx": { "max-nested-callbacks": { "count": 1 @@ -706,10 +1247,19 @@ } }, "src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 7 }, "no-restricted-imports": { + "count": 2 + }, + "prefer-const": { "count": 1 }, "react-hooks/set-state-in-effect": { @@ -723,11 +1273,19 @@ "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "no-restricted-syntax": { "count": 2 } }, "src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterTool.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx": { "no-restricted-imports": { "count": 1 } @@ -736,6 +1294,9 @@ "no-nested-ternary": { "count": 2 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/immutability": { "count": 2 }, @@ -743,25 +1304,70 @@ "count": 1 } }, + "src/app/(dashboard)/playground/components/chat_ui/ResponsesImageUpload.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/playground/components/chat_ui/SearchResultsDisplay.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx": { + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 4 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/playground/components/compareUI/components/MessageDisplay.tsx": { "no-nested-ternary": { "count": 1 } }, + "src/app/(dashboard)/playground/components/compareUI/components/MessageInput.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/playground/components/compareUI/components/ModelSelector.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/playground/components/compareUI/components/UnifiedSelector.tsx": { "no-restricted-imports": { "count": 1 } }, "src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 8 }, @@ -770,6 +1376,9 @@ } }, "src/app/(dashboard)/playground/llm_calls/a2a_send_message.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 2 }, @@ -778,21 +1387,33 @@ } }, "src/app/(dashboard)/playground/llm_calls/anthropic_messages.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 } }, "src/app/(dashboard)/playground/llm_calls/audio_speech.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 } }, "src/app/(dashboard)/playground/llm_calls/audio_transcriptions.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 } }, "src/app/(dashboard)/playground/llm_calls/embeddings_api.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 }, @@ -801,21 +1422,33 @@ } }, "src/app/(dashboard)/playground/llm_calls/fetch_agents.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-syntax": { "count": 1 } }, "src/app/(dashboard)/playground/llm_calls/image_edits.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 } }, "src/app/(dashboard)/playground/llm_calls/image_generation.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 } }, "src/app/(dashboard)/playground/llm_calls/interactions_api.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 }, @@ -829,17 +1462,26 @@ } }, "src/app/(dashboard)/policies/_components/add_attachment_form.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/immutability": { "count": 1 } }, "src/app/(dashboard)/policies/_components/add_policy_form.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, + "prefer-const": { + "count": 2 + }, "react-hooks/immutability": { "count": 2 }, @@ -848,20 +1490,35 @@ } }, "src/app/(dashboard)/policies/_components/ai_suggestion_modal.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 3 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 10 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/immutability": { "count": 1 } }, "src/app/(dashboard)/policies/_components/guardrail_selection_modal.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -872,9 +1529,20 @@ } }, "src/app/(dashboard)/policies/_components/impact_popover.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/policies/_components/impact_preview_alert.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -885,44 +1553,73 @@ } }, "src/app/(dashboard)/policies/_components/index.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 2 } }, "src/app/(dashboard)/policies/_components/policy_info.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/policies/_components/policy_test_panel.tsx": { + "src/app/(dashboard)/policies/_components/policy_templates.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 + } + }, + "src/app/(dashboard)/policies/_components/policy_test_panel.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 }, "react-hooks/immutability": { "count": 1 } }, "src/app/(dashboard)/policies/_components/template_parameter_modal.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/immutability": { "count": 1 }, @@ -933,34 +1630,74 @@ "src/app/(dashboard)/projects/_components/ProjectDetailsPage.tsx": { "no-nested-ternary": { "count": 3 + }, + "no-restricted-imports": { + "count": 1 } }, "src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 2 } }, - "src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": { + "src/app/(dashboard)/projects/_components/ProjectsPage.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/app/(dashboard)/prompts/_components/index.tsx": { - "no-nested-ternary": { + "src/app/(dashboard)/prompts/_components/add_prompt_form.tsx": { + "local/filename-pascal-case": { "count": 1 }, "no-restricted-imports": { + "count": 3 + } + }, + "src/app/(dashboard)/prompts/_components/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-nested-ternary": { "count": 1 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/app/(dashboard)/prompts/_components/prompt_editor_view.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/DeveloperMessageCard.tsx": { "no-restricted-imports": { "count": 1 @@ -968,7 +1705,7 @@ }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/ModelConfigCard.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptCodeSnippets.tsx": { @@ -976,7 +1713,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -984,17 +1721,17 @@ }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptEditorHeader.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/PromptMessagesCard.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/PublishModal.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/ToolsCard.tsx": { @@ -1008,19 +1745,38 @@ } }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/immutability": { "count": 1 } }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageInput.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/MessageList.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/VariableInput.tsx": { "no-restricted-imports": { "count": 1 } }, "src/app/(dashboard)/prompts/_components/prompt_editor_view/conversation_panel/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } @@ -1030,70 +1786,117 @@ "count": 1 } }, + "src/app/(dashboard)/prompts/_components/prompt_editor_view/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/app/(dashboard)/prompts/_components/prompt_info.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 3 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 2 } }, + "src/app/(dashboard)/prompts/_components/prompt_utils.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/prompts/_components/tool_modal.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/prompts/_components/variable_textarea.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/router-settings/_components/general_settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { + "count": 3 + }, + "prefer-const": { "count": 2 } }, "src/app/(dashboard)/search-tools/_components/CreateSearchTools.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/app/(dashboard)/search-tools/_components/SearchToolView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/search-tools/_components/SearchTools.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/static-components": { "count": 1 } }, + "src/app/(dashboard)/search-tools/_components/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/app/(dashboard)/search-tools/_components/types.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/skills/_components/add_plugin_form.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/tag-management/_components/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -1101,16 +1904,19 @@ "count": 1 } }, + "src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/app/(dashboard)/tag-management/_components/tag_info.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/transform-request/TransformRequestPanel.tsx": { "no-restricted-imports": { + "count": 3 + }, + "react-hooks/set-state-in-effect": { "count": 1 } }, @@ -1128,14 +1934,25 @@ "src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx": { "no-restricted-imports": { "count": 1 } @@ -1144,16 +1961,25 @@ "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/immutability": { "count": 1 } }, "src/app/(dashboard)/usage/_components/components/UsagePageView.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/purity": { "count": 1 @@ -1162,6 +1988,14 @@ "count": 3 } }, + "src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx": { + "local/no-complex-jsx-arrow": { + "count": 2 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": { "react-hooks/refs": { "count": 1 @@ -1170,13 +2004,29 @@ "count": 1 } }, - "src/app/(dashboard)/users/_components/DefaultUserSettings.tsx": { + "src/app/(dashboard)/users/_components/BulkEditUsers.tsx": { "no-restricted-imports": { "count": 1 + }, + "prefer-const": { + "count": 1 } }, - "src/app/(dashboard)/users/_components/edit_user.tsx": { + "src/app/(dashboard)/users/_components/DefaultUserSettings.tsx": { "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/users/_components/edit_user.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/(dashboard)/users/_components/index.tsx": { + "local/filename-pascal-case": { "count": 1 } }, @@ -1189,15 +2039,24 @@ } }, "src/app/(dashboard)/users/_components/user_edit_view.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/users/_components/view_users.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { + "count": 3 + }, + "prefer-const": { "count": 1 }, "react-hooks/set-state-in-effect": { @@ -1205,14 +2064,30 @@ } }, "src/app/(dashboard)/users/_components/view_users/user_info_view.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx": { + "no-restricted-imports": { + "count": 3 + } + }, + "src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/vector-stores/_components/TestVectorStoreTab.tsx": { "no-restricted-imports": { "count": 1 } @@ -1222,13 +2097,21 @@ "count": 2 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react/no-unescaped-entities": { "count": 1 } }, + "src/app/(dashboard)/vector-stores/_components/VectorStoreTester.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/app/(dashboard)/vector-stores/_components/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -1237,9 +2120,12 @@ } }, "src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -1248,6 +2134,9 @@ "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "no-restricted-syntax": { "count": 3 }, @@ -1261,6 +2150,12 @@ } }, "src/app/login/LoginPage.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 2 } @@ -1275,15 +2170,41 @@ "count": 1 } }, + "src/app/onboarding/OnboardingErrorView.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/onboarding/OnboardingFormBody.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/onboarding/OnboardingLoadingView.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/AIHub/ModelHubTable.test.tsx": { "max-params": { "count": 1 } }, "src/components/AIHub/ModelHubTable.tsx": { + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, + "prefer-const": { + "count": 4 + } + }, + "src/components/AIHub/SkillHubDashboard.tsx": { "no-restricted-imports": { "count": 1 } @@ -1298,7 +2219,7 @@ }, "src/components/AIHub/forms/MakeAgentPublicForm.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -1314,7 +2235,7 @@ "count": 2 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -1322,28 +2243,93 @@ }, "src/components/AIHub/forms/MakeModelPublicForm.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/CreateUserButton.tsx": { + "src/components/BetaBadge.tsx": { "no-restricted-imports": { "count": 1 + } + }, + "src/components/CloudZeroCostTracking/CloudZeroCostTracking.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/CloudZeroCostTracking/CloudZeroEmptyPlaceholder.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/CloudZeroCostTracking/CloudZeroIntegrationSettings.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/CloudZeroCostTracking/CloudZeroUpdateModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/CreateUserButton.tsx": { + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/DebugWarningBanner.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/DeletedKeysPage/DeletedKeysPage.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/DeletedTeamsPage/DeletedTeamsPage.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/DeprecationBanner.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/EntityUsageExport/EntityUsageExportModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/EntityUsageExport/ExportFormatSelector.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/EntityUsageExport/ExportSummary.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/EntityUsageExport/ExportTypeSelector.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/EntityUsageExport/UsageExportHeader.tsx": { "no-restricted-imports": { - "count": 2 + "count": 3 } }, "src/components/EntityUsageExport/types.ts": { @@ -1367,11 +2353,17 @@ "src/components/GuardrailSettingsView.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/GuardrailsMonitor/LogViewer.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/HelpLink.test.tsx": { @@ -1379,9 +2371,27 @@ "count": 1 } }, + "src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/LicenseExpiryBanner.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/ModelSelect/ModelSelect.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": { @@ -1389,20 +2399,58 @@ "count": 12 } }, - "src/components/Navbar/UserDropdown/UserDropdown.tsx": { - "react-hooks/set-state-in-effect": { + "src/components/Navbar/BlogDropdown/BlogDropdown.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx": { + "no-restricted-imports": { "count": 1 } }, - "src/components/SCIM.tsx": { + "src/components/Navbar/NotificationsBell/NotificationsBell.tsx": { "no-restricted-imports": { "count": 1 + } + }, + "src/components/Navbar/UserDropdown/UserDropdown.tsx": { + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/Navbar/ViewSwitcher.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/Navbar/WorkerDropdown/WorkerDropdown.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/SCIM.tsx": { + "no-restricted-imports": { + "count": 2 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/SSOModals.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/SSOModals.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/Settings/AdminSettings/HashicorpVault/EditHashicorpVaultModal.tsx": { "no-restricted-imports": { "count": 1 } @@ -1410,19 +2458,55 @@ "src/components/Settings/AdminSettings/HashicorpVault/HashicorpVault.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/HashicorpVault/HashicorpVaultEmptyPlaceholder.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/LoggingSettings/LoggingSettings.tsx": { + "no-restricted-imports": { + "count": 1 } }, "src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx": { "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/SSOSettings/Modals/AddSSOSettingsModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx": { @@ -1430,9 +2514,32 @@ "count": 1 } }, + "src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/Settings/AdminSettings/SSOSettings/RedactableField.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/SSOSettings/RoleMappings.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/AdminSettings/SSOSettings/SSOSettingsEmptyPlaceholder.tsx": { + "no-restricted-imports": { + "count": 1 } }, "src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.test.tsx": { @@ -1440,7 +2547,15 @@ "count": 4 } }, + "src/components/Settings/AdminSettings/SSOSettings/SSOSettingsLoadingSkeleton.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/Settings/AdminSettings/UISettings/PageVisibilitySettings.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-render": { "count": 2 } @@ -1448,19 +2563,40 @@ "src/components/Settings/AdminSettings/UISettings/UISettings.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx": { + "src/components/Settings/RouterSettings/Fallbacks/AddFallbacksModal.tsx": { "no-restricted-imports": { "count": 1 + } + }, + "src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx": { + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 @@ -1468,7 +2604,10 @@ }, "src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 + }, + "prefer-const": { + "count": 2 } }, "src/components/TeamSSOSettings.test.tsx": { @@ -1476,49 +2615,146 @@ "count": 1 } }, + "src/components/TeamSSOSettings.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/Teams.test.tsx": { "max-nested-callbacks": { "count": 4 + }, + "prefer-const": { + "count": 6 } }, "src/components/Teams.tsx": { + "local/no-complex-jsx-arrow": { + "count": 4 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 2 }, "no-restricted-imports": { - "count": 1 + "count": 2 + }, + "prefer-const": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 3 } }, + "src/components/TeamsPage/teamTableColumns.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ToolDetail.tsx": { + "no-restricted-imports": { + "count": 1 + }, "unused-imports/no-unused-imports": { "count": 2 } }, + "src/components/ToolPolicies/PolicySelect.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/ToolPolicies/ToolPoliciesTableColumns.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/UIAccessControlForm.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/UsagePage/components/EntityUsage/TopKeyView.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/UsagePage/components/KeyModelUsageView.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/UsagePage/utils/value_formatters.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/VirtualKeysPage/keyTableColumns.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "src/components/activity_metrics.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/add_model/AdaptiveRoutingConfig.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/add_model/AddModelForm.test.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "src/components/add_model/AddModelForm.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 4 + } + }, + "src/components/add_model/ClassificationMethodConfig.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/add_model/ComplexityRouterConfig.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/add_model/EscalationKeywords.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/add_model/KeywordTierRules.tsx": { "no-restricted-imports": { "count": 1 } }, "src/components/add_model/RouterConfigBuilder.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/purity": { "count": 1 }, @@ -1526,56 +2762,153 @@ "count": 1 } }, - "src/components/add_model/add_auto_router_tab.tsx": { + "src/components/add_model/SemanticKeywordMatching.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/add_model/add_auto_router_tab.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/add_model/add_auto_router_tab.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 3 + } + }, + "src/components/add_model/add_model_modes.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/add_model/add_model_tab.test.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "src/components/add_model/add_model_tab.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 4 + } + }, + "src/components/add_model/advanced_settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 4 + }, + "prefer-const": { + "count": 2 + } + }, + "src/components/add_model/auto_router_connection_test.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, - "src/components/add_model/advanced_settings.tsx": { + "src/components/add_model/cache_control_settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "prefer-const": { + "count": 1 + } + }, + "src/components/add_model/conditional_public_model_name.test.tsx": { "no-restricted-imports": { "count": 1 } }, "src/components/add_model/conditional_public_model_name.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 2 } }, + "src/components/add_model/handle_add_auto_router_submit.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/add_model/handle_add_model_submit.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/add_model/litellm_model_name.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/add_model/litellm_model_name.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, + "no-restricted-imports": { + "count": 3 + } + }, + "src/components/add_model/model_connection_test.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-nested-ternary": { + "count": 2 + }, "no-restricted-imports": { "count": 1 } }, - "src/components/add_model/model_connection_test.tsx": { - "no-nested-ternary": { - "count": 2 + "src/components/add_model/provider_specific_fields.test.tsx": { + "no-restricted-imports": { + "count": 1 } }, "src/components/add_model/provider_specific_fields.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 5 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/immutability": { "count": 3 } }, "src/components/add_pass_through.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { - "count": 2 + "count": 3 } }, "src/components/agent_management/AgentSelector.test.tsx": { @@ -1586,28 +2919,58 @@ "count": 1 } }, + "src/components/agent_management/AgentSelector.tsx": { + "no-restricted-imports": { + "count": 1 + }, + "prefer-const": { + "count": 1 + } + }, + "src/components/alerting/alerting_settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "prefer-const": { + "count": 1 + } + }, "src/components/alerting/dynamic_form.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 4 }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/bulk_create_users_button.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/callback_info_helpers.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/chat/KeysPanel.tsx": { "no-nested-ternary": { "count": 1 } }, "src/components/chat/MCPAppsPanel.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 7 } @@ -1627,17 +2990,45 @@ "count": 2 } }, - "src/components/claude_code_plugins/MakeSkillPublicForm.tsx": { + "src/components/chat_ui/MCPEventsDisplay.tsx": { "no-restricted-imports": { "count": 1 + } + }, + "src/components/chat_ui/ReasoningContent.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/chat_ui/ResponseMetrics.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/chat_ui/mode_endpoint_mapping.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/claude_code_plugins/MakeSkillPublicForm.tsx": { + "no-restricted-imports": { + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/cloudzero_export_modal.tsx": { - "no-restricted-imports": { + "src/components/claude_code_plugins/skill_detail.tsx": { + "local/filename-pascal-case": { "count": 1 + } + }, + "src/components/cloudzero_export_modal.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 }, "no-restricted-syntax": { "count": 3 @@ -1648,7 +3039,7 @@ }, "src/components/common_components/AccessGroupSelector.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/common_components/AutoRotationView.tsx": { @@ -1656,26 +3047,70 @@ "count": 1 } }, + "src/components/common_components/DefaultProxyAdminTag.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/common_components/DeleteResourceModal.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/common_components/DurationSelect.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/common_components/Filters/FilterInput.tsx": { + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/common_components/Filters/FiltersButton.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/Filters/ResetFiltersButton.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/common_components/IconActionButton/BaseActionButton.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/common_components/KeyLifecycleSettings.tsx": { + "src/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/common_components/KeyLifecycleSettings.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/common_components/LabeledField.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/MemberTable.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "src/components/common_components/ModelAliasManager.tsx": { "no-restricted-imports": { "count": 1 @@ -1686,41 +3121,93 @@ }, "src/components/common_components/ModelSelector.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/common_components/NewBadge.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/OrganizationDropdown.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/components/common_components/PassThroughGuardrailsSection.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/common_components/PassThroughSecuritySection.tsx": { + "src/components/common_components/PassThroughRoutesSelector.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/common_components/PassThroughSecuritySection.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "src/components/common_components/PremiumLoggingSettings.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/common_components/ProjectDropdown.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/RateLimitTypeFormItem.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/RateLimitTypeFormItem.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/common_components/RouterSettingsAccordion.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/budget_duration_dropdown.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/components/common_components/chartUtils.test.tsx": { "no-restricted-imports": { "count": 1 } }, "src/components/common_components/chartUtils.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, @@ -1729,16 +3216,25 @@ } }, "src/components/common_components/check_openapi_schema.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 3 } }, "src/components/common_components/fetch_teams.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 } }, "src/components/common_components/simple_table.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, @@ -1746,56 +3242,182 @@ "count": 1 } }, + "src/components/common_components/team_dropdown.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/team_multi_select.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/common_components/user_search_modal.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/components/constants.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/components/edit_auto_router/edit_auto_router_modal.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/immutability": { "count": 1 } }, "src/components/email_events/email_event_settings.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/immutability": { "count": 1 } }, "src/components/email_settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { + "count": 2 + }, + "prefer-const": { + "count": 1 + } + }, + "src/components/guardrails/GuardrailSelector.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/key_info_utils.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/key_team_helpers/BudgetFallbacksEditor.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/key_team_helpers/BudgetWindowsEditor.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/key_team_helpers/TagRateLimitEditor.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/key_team_helpers/fetch_available_models_team_key.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "prefer-const": { "count": 1 } }, "src/components/key_team_helpers/key_list.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/key_team_helpers/transform_key_info.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/key_value_input.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { + "count": 2 + } + }, + "src/components/leftnav.tsx": { + "local/filename-pascal-case": { "count": 1 } }, "src/components/llm_calls/chat_completion.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 }, "no-nested-ternary": { "count": 1 + }, + "prefer-const": { + "count": 1 + } + }, + "src/components/llm_calls/fetch_models.tsx": { + "local/filename-pascal-case": { + "count": 1 } }, "src/components/llm_calls/responses_api.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 } }, + "src/components/logging_settings_view.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/mcp_server_management/MCPServerSelector.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/components/mcp_server_management/MCPToolPermissions.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/mcp_tools/ByokCredentialModal.tsx": { "no-restricted-imports": { "count": 1 } @@ -1803,6 +3425,9 @@ "src/components/mcp_tools/MCPToolArgumentsForm.tsx": { "no-nested-ternary": { "count": 5 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/mcp_tools/McpCrudPermissionPanel.tsx": { @@ -1810,20 +3435,61 @@ "count": 3 }, "no-restricted-imports": { + "count": 2 + } + }, + "src/components/mcp_tools/types.tsx": { + "local/filename-pascal-case": { "count": 1 } }, "src/components/model_add/CredentialModal.tsx": { + "no-restricted-imports": { + "count": 3 + } + }, + "src/components/model_add/CredentialsPanel.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/model_add/CredentialsPanel.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/model_add/credential_form_helpers.test.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/model_add/credential_form_helpers.ts": { "no-restricted-imports": { "count": 1 } }, "src/components/model_add/reuse_credentials.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/model_dashboard/HealthCheckComponent.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/model_dashboard/ModelSettingsModal/ModelSettingsModal.tsx": { "no-restricted-imports": { "count": 1 } }, "src/components/model_dashboard/all_models_table.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, @@ -1832,11 +3498,17 @@ } }, "src/components/model_filters.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "src/components/model_group_alias_settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -1845,19 +3517,50 @@ } }, "src/components/model_info_view.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 14 }, "no-restricted-imports": { - "count": 1 + "count": 2 + }, + "prefer-const": { + "count": 5 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/molecules/cost_optimization_feedback_banner.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/molecules/filter.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 2 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/molecules/message_manager.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/components/molecules/models/columns.test.tsx": { @@ -1869,22 +3572,55 @@ } }, "src/components/molecules/models/columns.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "max-params": { "count": 1 }, "no-nested-ternary": { "count": 2 }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/molecules/notifications_manager.test.tsx": { "no-restricted-imports": { "count": 1 } }, + "src/components/molecules/notifications_manager.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 3 + } + }, "src/components/navbar.test.tsx": { + "prefer-const": { + "count": 1 + }, "unused-imports/no-unused-imports": { "count": 1 } }, + "src/components/navbar.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, "src/components/networking.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "max-params": { "count": 23 }, @@ -1893,14 +3629,28 @@ }, "no-restricted-syntax": { "count": 154 + }, + "prefer-const": { + "count": 33 } }, "src/components/object_permissions_view.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, "src/components/onboarding_link.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/organisms/RegenerateKeyModal.tsx": { "no-restricted-imports": { "count": 1 } @@ -1914,17 +3664,32 @@ } }, "src/components/organisms/create_key_button.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "local/no-complex-jsx-arrow": { + "count": 2 + }, + "max-lines": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + }, + "prefer-const": { + "count": 4 + }, "react-hooks/set-state-in-effect": { "count": 4 } }, "src/components/organization/organization_view.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 3 + }, "unused-imports/no-unused-imports": { "count": 1 } @@ -1935,11 +3700,17 @@ } }, "src/components/pass_through_info.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "src/components/per_user_usage.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -1949,7 +3720,7 @@ }, "src/components/permissions/AgentPermissions.tsx": { "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/permissions/MCPServerPermissions.tsx": { @@ -1957,7 +3728,7 @@ "count": 3 }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/permissions/VectorStorePermissions.tsx": { @@ -1968,19 +3739,58 @@ "src/components/policies/PolicySelector.tsx": { "no-nested-ternary": { "count": 1 - } - }, - "src/components/price_data_reload.tsx": { - "react-hooks/immutability": { - "count": 2 - } - }, - "src/components/public_model_hub.tsx": { + }, "no-restricted-imports": { "count": 1 } }, + "src/components/price_data_reload.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "react-hooks/immutability": { + "count": 2 + } + }, + "src/components/provider_info_helpers.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "prefer-const": { + "count": 3 + } + }, + "src/components/public_model_hub.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, "src/components/query_param_input.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/route_preview.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/router_settings/LatencyBasedConfiguration.tsx": { "no-restricted-imports": { "count": 1 } @@ -1988,9 +3798,49 @@ "src/components/router_settings/ReliabilityRetriesSection.tsx": { "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/router_settings/RoutingStrategySelector.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/router_settings/TagFilteringToggle.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/router_settings/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, + "prefer-const": { + "count": 2 + } + }, + "src/components/routing_groups/RoutingGroupModal.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/routing_groups/RoutingGroupsTable.tsx": { + "no-restricted-imports": { + "count": 2 } }, "src/components/routing_groups/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/preserve-manual-memoization": { "count": 1 } @@ -1998,17 +3848,42 @@ "src/components/search_tools/SearchToolSelector.tsx": { "no-nested-ternary": { "count": 1 - } - }, - "src/components/settings.tsx": { - "no-nested-ternary": { - "count": 2 }, "no-restricted-imports": { "count": 1 } }, + "src/components/settings.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/settings.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 4 + }, + "no-nested-ternary": { + "count": 2 + }, + "no-restricted-imports": { + "count": 3 + }, + "prefer-const": { + "count": 7 + } + }, + "src/components/shared/CreatedKeyDisplay.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/shared/advanced_date_picker.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -2016,67 +3891,208 @@ "count": 3 } }, + "src/components/shared/chart_loader.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/charts/area_chart.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/charts/bar_chart.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/charts/chart_legend.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/charts/chart_tooltip.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/charts/donut_chart.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/charts/line_chart.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/errorUtils.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/form/FormField.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + } + }, + "src/components/shared/form/field.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/shared/numerical_input.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, + "src/components/shared/table_cells/cell_tooltip.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/table_cells/date_cell.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/table_cells/id_cell.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/table_cells/identity_cell.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/table_cells/models_cell.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/table_cells/money_cell.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/table_cells/spend_budget_cell.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/shared/table_cells/status_badge.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/shared/usage_date_picker.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 } }, + "src/components/tag_management/TagSelector.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/tag_management/types.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/team/EditMembership.tsx": { "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/team/LoggingSettings.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/components/team/MyUserTab.tsx": { "no-restricted-imports": { "count": 1 } }, "src/components/team/TeamInfo.tsx": { + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 3 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/team/TeamMemberTab.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, + "no-restricted-imports": { + "count": 2 + } + }, "src/components/team/TeamVirtualKeysTable.tsx": { "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/team/member_permissions.tsx": { - "no-restricted-imports": { + "local/filename-pascal-case": { "count": 1 }, + "no-restricted-imports": { + "count": 2 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, + "src/components/team/permission_definitions.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/team/useMyTeamMember.ts": { "no-restricted-syntax": { "count": 1 } }, + "src/components/templates/KeyInfoHeader.tsx": { + "no-restricted-imports": { + "count": 2 + } + }, "src/components/templates/key_edit_view.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "local/no-complex-jsx-arrow": { + "count": 2 + }, "no-nested-ternary": { "count": 2 }, "no-restricted-imports": { - "count": 1 + "count": 2 } }, "src/components/templates/key_info_view.test.tsx": { @@ -2085,41 +4101,253 @@ } }, "src/components/templates/key_info_view.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "max-lines": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, "no-restricted-imports": { - "count": 1 + "count": 2 }, "react-hooks/set-state-in-effect": { "count": 1 } }, - "src/components/user_agent_activity.tsx": { + "src/components/ui/AntDLoadingSpinner.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 + } + }, + "src/components/ui/alert-dialog.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/avatar.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/badge.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/breadcrumb.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/button.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/card.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/chart.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/checkbox.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/collapsible.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/combobox.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/dialog.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/dropdown-menu.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/input-group.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/input.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/label.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/meter.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/popover.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/scroll-area.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/select.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/separator.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/sheet.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/sidebar.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/skeleton.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/switch.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/table.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/tabs.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/textarea.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/tooltip.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/ui/ui-loading-spinner.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/update_model_credentials_modal.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/user_agent_activity.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 3 }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/components/user_dashboard.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, + "prefer-const": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 2 } }, + "src/components/vector_store_management/VectorStoreSelector.test.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/vector_store_management/VectorStoreSelector.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/vector_store_management/types.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/vector_store_providers.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/view_logs/AuditLogDrawer/AuditLogDrawer.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/CostBreakdownViewer.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/view_logs/EvalViewer/EvalViewer.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/view_logs/GuardrailViewer/CompliancePanel.tsx": { "no-nested-ternary": { "count": 2 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } @@ -2132,26 +4360,90 @@ "src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx": { "no-nested-ternary": { "count": 4 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/CollapsibleMessage.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/HistoryTree.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/JsonViewer.tsx": { + "no-restricted-imports": { + "count": 1 } }, "src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx": { "no-nested-ternary": { "count": 3 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": { "no-nested-ternary": { "count": 3 }, + "no-restricted-imports": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 2 } }, + "src/components/view_logs/LogDetailsDrawer/OutputCard.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.test.tsx": { "unused-imports/no-unused-imports": { "count": 2 } }, + "src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/SimpleMessageBlock.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/SimpleToolCallBlock.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/TokenFlow.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/LogDetailsDrawer/TruncatedValue.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/components/view_logs/LogDetailsDrawer/prettyMessagesUtils.ts": { "no-nested-ternary": { "count": 1 @@ -2163,11 +4455,53 @@ } }, "src/components/view_logs/LogsTableToolbar.tsx": { + "local/no-complex-jsx-arrow": { + "count": 1 + }, "no-nested-ternary": { "count": 4 + }, + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/ToolsSection/FormattedToolView.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/ToolsSection/ToolExpandedContent.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/ToolsSection/ToolItem.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/ToolsSection/ToolsSection.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/VectorStoreViewer.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/components/view_logs/columns.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/components/view_logs/index.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-restricted-imports": { "count": 1 }, @@ -2175,16 +4509,45 @@ "count": 1 } }, + "src/components/view_logs/log_filter_logic.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/view_logs/logs_utils.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/view_logs/table.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 2 } }, + "src/components/view_model/model_name_display.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/view_user_spend.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, + "prefer-const": { + "count": 3 + }, "react-hooks/set-state-in-effect": { "count": 2 } }, + "src/contexts/AntdGlobalProvider.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "src/contexts/AuthContext.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -2211,11 +4574,17 @@ } }, "src/hooks/useMcpOAuthFlow.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } }, "src/hooks/useTestMCPConnection.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "no-nested-ternary": { "count": 1 }, @@ -2224,6 +4593,9 @@ } }, "src/hooks/useToolsOAuthFlow.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "react-hooks/refs": { "count": 1 }, @@ -2232,6 +4604,9 @@ } }, "src/hooks/useUserMcpOAuthFlow.tsx": { + "local/filename-pascal-case": { + "count": 1 + }, "react-hooks/set-state-in-effect": { "count": 1 } diff --git a/ui/litellm-dashboard/eslint.config.mjs b/ui/litellm-dashboard/eslint.config.mjs index 0cf5b4ff655..b5e876bfbba 100644 --- a/ui/litellm-dashboard/eslint.config.mjs +++ b/ui/litellm-dashboard/eslint.config.mjs @@ -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: { diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 14c8f0fdc18..5f6b4b889b1 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -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": { diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 5add2ad4e9e..63bcdfb6076 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -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", diff --git a/ui/litellm-dashboard/scripts/eslint-rules/filename-pascal-case.mjs b/ui/litellm-dashboard/scripts/eslint-rules/filename-pascal-case.mjs new file mode 100644 index 00000000000..7477925238e --- /dev/null +++ b/ui/litellm-dashboard/scripts/eslint-rules/filename-pascal-case.mjs @@ -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; diff --git a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs index 150ba1d02e9..9e9f901a6df 100644 --- a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs +++ b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs @@ -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, }, }; diff --git a/ui/litellm-dashboard/scripts/eslint-rules/no-complex-jsx-arrow.mjs b/ui/litellm-dashboard/scripts/eslint-rules/no-complex-jsx-arrow.mjs new file mode 100644 index 00000000000..b3dabe03a21 --- /dev/null +++ b/ui/litellm-dashboard/scripts/eslint-rules/no-complex-jsx-arrow.mjs @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx index 66fa0dfa63f..ad1cf28dc54 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.test.tsx @@ -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(); + + 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(); + + 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(); + + 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); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx index 333bd1cad13..9342017ed3f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/api-reference/_components/APIReferenceView.tsx @@ -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 = ({ proxySettings }) => { } return ( - <> - -
- {/* Header row with Docs link on the right */} -
-

- OpenAI Compatible Proxy: API Reference -

- -
+
+
+ {/* Header row with Docs link on the right */} +
+

OpenAI Compatible Proxy: API Reference

+ +
- - 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{" "} - +

+ 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{" "} +

- - - OpenAI Python SDK - LlamaIndex - Langchain Py - - - - + + + OpenAI Python SDK + + + LlamaIndex + + + Langchain Py + + + + - + /> + - - + - + /> + - - + - - - -
- - + /> + + +
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts index 0431a8d39f7..1a02e363de9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx index 620f76a739a..49df8206c8f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx @@ -211,6 +211,7 @@ export const MCPServerView: React.FC = ({ 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} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx index 8b0e6d62f66..3a189f50264 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.test.tsx @@ -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 = {}) => ({ ...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 }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx index 428c10da284..28c17d1e41c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx @@ -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, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx deleted file mode 100644 index ce6dd0879fa..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.test.tsx +++ /dev/null @@ -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 = {}; - 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( - - - , - ); - 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( - - - , - ); - 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( - - - , - ); - - // 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( - - - , - ); - - // 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( - - - , - ); - - 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"); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx deleted file mode 100644 index 72f4fb8caa3..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView.tsx +++ /dev/null @@ -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 = ({ premiumUser, teams }) => { - const { accessToken, token, userRole, userId: userID } = useAuthorized(); - const [addModelForm] = Form.useForm(); - const [lastRefreshed, setLastRefreshed] = useState(""); - const [providerModels, setProviderModels] = useState>([]); - const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); - const [selectedModelGroup, setSelectedModelGroup] = useState(null); - - const [retryScope, setRetryScope] = useState("global"); - const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState(null); - const [globalRetryPolicy, setGlobalRetryPolicy] = useState(null); - const [defaultRetry, setDefaultRetry] = useState(0); - const [modelGroupAlias, setModelGroupAlias] = useState<{ [key: string]: string }>({}); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const [selectedModelId, setSelectedModelId] = useState(null); - const [selectedTeamId, setSelectedTeamId] = useState(null); - const [selectedTabIndex, setSelectedTabIndex] = useState(0); - const [healthPagination, setHealthPagination] = useState({ - 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(); - 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(); - 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(() => { - if (!modelDataResponse?.data) return []; - return modelDataResponse.data.map((model: any) => model.model_name); - }, [modelDataResponse?.data]); - - const healthModelIdsOnProxy = useMemo(() => { - 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 => { - 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 ( -
- setSelectedTeamId(null)} - accessToken={accessToken} - is_team_admin={userRole === "Admin"} - is_proxy_admin={userRole === "Proxy Admin"} - userModels={allModelsOnProxy} - editTeam={false} - onUpdate={handleRefreshClick} - premiumUser={premiumUser} - /> -
- ); - } - - return ( -
- - - {/* Model Management Header */} -
-
-

Model Management

- {!all_admin_roles.includes(userRole) ? ( -

Add models for teams you are an admin for.

- ) : ( -

Add and manage models for the proxy

- )} -
-
- - {/* Cost Optimization Feedback Banner */} - - {selectedModelId && !isLoading ? ( - { - 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: {isAdmin ? "All Models" : "Your Models"}, - panel: ( - - ), - }, - ]; - if (!shouldHideAddModelTab) { - visibleTabs.push({ - tab: Add Model, - panel: ( - - - - ), - }); - } - if (isAdmin) { - visibleTabs.push( - { - tab: LLM Credentials, - panel: ( - - - - ), - }, - { - tab: Pass-Through Endpoints, - panel: ( - - - - ), - }, - { - tab: Health Status, - panel: ( - - - - ), - }, - { - tab: Model Retry Settings, - panel: ( - - ), - }, - { - tab: Model Group Alias, - panel: ( - - - - ), - }, - { - tab: Price Data Reload, - panel: , - }, - ); - } - return ( - - -
{visibleTabs.map((t) => t.tab)}
- -
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - -
-
- {visibleTabs.map((t) => t.panel)} -
- ); - })() - )} - -
-
- ); -}; - -export default ModelsAndEndpointsView; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/add/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/add/page.tsx new file mode 100644 index 00000000000..7e60ca58fc3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/add/page.tsx @@ -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.Anthropic); + const [providerModels, setProviderModels] = useState([]); + 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 ( + setProviderModels(getProviderModels(provider, modelCostMapData))} + getPlaceholder={getPlaceholder} + uploadProps={vertexCredentialsUploadProps(form)} + showAdvancedSettings={showAdvancedSettings} + setShowAdvancedSettings={setShowAdvancedSettings} + teams={teams ?? null} + credentials={credentialsResponse?.credentials || []} + accessToken={accessToken} + userRole={userRole} + /> + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 20c9e805a0d..6efc85c019b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -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 ( - +
@@ -600,7 +600,7 @@ const AllModelsTab = ({ onCancel={() => setIsModelSettingsModalVisible(false)} onSuccess={() => setIsModelSettingsModalVisible(false)} /> - +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx index 5ff761b0663..a4e3c4b958c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx @@ -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 ( - +
Retry Policy Scope: @@ -132,7 +132,7 @@ const ModelRetrySettingsTab = ({ - +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx new file mode 100644 index 00000000000..282cc2722db --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.test.tsx @@ -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 +// . A Tremor root renders nothing without that context, so +// this asserts the component's content is visible on its own — reverting the root +// back to makes the title disappear and fails this test. +vi.mock("@/components/price_data_reload", () => ({ default: () =>
reload
})); +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(); + expect(getByText("Price Data Management")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx index d44d19879d5..9420643578c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab.tsx @@ -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 ( - +
Price Data Management @@ -28,7 +28,7 @@ const PriceDataManagementTab = () => { className="w-full" />
- +
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts new file mode 100644 index 00000000000..717fdc85e28 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.test.ts @@ -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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts new file mode 100644 index 00000000000..5e120e42740 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/detailNavigation.ts @@ -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, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.test.tsx new file mode 100644 index 00000000000..677796957cc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.test.tsx @@ -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(); + + 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"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.tsx new file mode 100644 index 00000000000..8942db5f002 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/health/page.tsx @@ -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({ 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( + () => + healthModelDataResponse?.data + ?.map((model: any) => model.model_info?.id) + .filter((id: string | undefined): id is string => Boolean(id)) ?? [], + [healthModelDataResponse?.data], + ); + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.test.tsx new file mode 100644 index 00000000000..d47d8b9a04e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.test.tsx @@ -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 }) =>
model:{modelId}
, +})); +vi.mock("@/components/team/TeamInfo", () => ({ + default: ({ teamId }: { teamId: string }) =>
team:{teamId}
, +})); + +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( + + +
CHILD
+
+
, + ); +}; + +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(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx new file mode 100644 index 00000000000..1aea5330c5a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/layout.tsx @@ -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 = { + 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>( + () => [ + "", + ...(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 ( +
+ +
+ ); + } + + return ( +
+
+
+
+

Model Management

+ {isAdmin ? ( +

Add and manage models for the proxy

+ ) : ( +

Add models for teams you are an admin for.

+ )} +
+
+ + + + {modelId ? ( + + ) : ( + router.push(modelTabHref(key === BASE_TAB_KEY ? "" : key))} + items={tabItems} + tabBarExtraContent={{ + right: ( +
+ {lastRefreshed && Last Refreshed: {lastRefreshed}} + +
+ ), + }} + /> + )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/llm-credentials/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/llm-credentials/page.tsx new file mode 100644 index 00000000000..207ced5be0d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/llm-credentials/page.tsx @@ -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 ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/model-group-alias/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/model-group-alias/page.tsx new file mode 100644 index 00000000000..c06de353ddf --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/model-group-alias/page.tsx @@ -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 ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 7594ee2f492..546309cfcc1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -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 ; +export default function AllModelsPage() { + const [selectedModelGroup, setSelectedModelGroup] = useState(null); + const { availableModelGroups, availableModelAccessGroups } = useModelDashboardData(); + const { openModel, openTeam } = useModelDetailRouting(); + + return ( + + ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/pass-through/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/pass-through/page.tsx new file mode 100644 index 00000000000..4ba7b8b260b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/pass-through/page.tsx @@ -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 ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/price-data/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/price-data/page.tsx new file mode 100644 index 00000000000..b8f385be13f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/price-data/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import PriceDataManagementTab from "@/app/(dashboard)/models-and-endpoints/components/PriceDataManagementTab"; + +export default function PriceDataPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/retry-settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/retry-settings/page.tsx new file mode 100644 index 00000000000..6442be3e54d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/retry-settings/page.tsx @@ -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("global"); + const [modelGroupRetryPolicy, setModelGroupRetryPolicy] = useState(null); + const [globalRetryPolicy, setGlobalRetryPolicy] = useState(null); + const [defaultRetry, setDefaultRetry] = useState(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 ( + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.test.ts new file mode 100644 index 00000000000..920bd3dc156 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.test.ts @@ -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}/`); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts new file mode 100644 index 00000000000..ddf9546c5c8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/tabRoutes.ts @@ -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] ?? ""; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/useModelDashboardData.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/useModelDashboardData.ts new file mode 100644 index 00000000000..c793e41bfd1 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/useModelDashboardData.ts @@ -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(modelDataResponse?.data?.map((model) => model.model_name) ?? []); + return Array.from(groups).sort(); + }, [modelDataResponse?.data]); + + const availableModelAccessGroups = useMemo(() => { + const groups = new Set( + 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 }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts new file mode 100644 index 00000000000..61bfbd7a99f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload.ts @@ -0,0 +1,29 @@ +import type { FormInstance, UploadProps } from "antd"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +export function vertexCredentialsUploadProps(form: FormInstance): UploadProps { + return { + name: "file", + accept: ".json", + pastable: false, + beforeUpload: (file) => { + if (file.type === "application/json") { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target) { + form.setFieldsValue({ vertex_credentials: event.target.result as string }); + } + }; + 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.`); + } + }, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.test.tsx new file mode 100644 index 00000000000..e3db50b7300 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.test.tsx @@ -0,0 +1,202 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../../../tests/test-utils"; +import UsagePage from "./usage"; + +const networking = vi.hoisted(() => ({ + adminSpendLogsCall: vi.fn(), + adminTopKeysCall: vi.fn(), + adminTopModelsCall: vi.fn(), + adminTopEndUsersCall: vi.fn(), + teamSpendLogsCall: vi.fn(), + tagsSpendLogsCall: vi.fn(), + allTagNamesCall: vi.fn(), + adminspendByProvider: vi.fn(), + adminGlobalActivity: vi.fn(), + adminGlobalActivityPerModel: vi.fn(), + getProxyUISettings: vi.fn(), + modelAvailableCall: vi.fn(), + keyInfoV1Call: vi.fn(), +})); + +vi.mock("@/components/networking", () => networking); +vi.mock("../../../../components/networking", () => networking); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + accessToken: "sk-test", + token: "tok", + userRole: "Admin", + userId: "u1", + premiumUser: true, + }), +})); + +const UNLIMITED_SETTINGS = { DISABLE_EXPENSIVE_DB_QUERIES: false, NUM_SPEND_LOGS_ROWS: 10 }; + +const renderUsage = (overrides: Partial> = {}) => + renderWithProviders( + , + ); + +beforeEach(() => { + vi.clearAllMocks(); + networking.getProxyUISettings.mockResolvedValue(UNLIMITED_SETTINGS); + networking.adminSpendLogsCall.mockResolvedValue([{ date: "2026-07-01", spend: 12.5 }]); + networking.adminTopKeysCall.mockResolvedValue([ + { api_key: "sk-abcdefghijk", key_alias: "prod-key", total_spend: 9.5 }, + ]); + networking.adminTopModelsCall.mockResolvedValue([{ model: "gpt-5.1", total_spend: 7.25 }]); + networking.adminTopEndUsersCall.mockResolvedValue([ + { end_user: "customer-alpha", total_spend: 3.5, total_count: 42 }, + ]); + networking.teamSpendLogsCall.mockResolvedValue({ + daily_spend: [{ date: "2026-07-01", "team-a": 5 }], + teams: ["team-a"], + total_spend_per_team: [{ team_id: "team-a", total_spend: 5 }], + }); + networking.tagsSpendLogsCall.mockResolvedValue({ spend_per_tag: [{ name: "prod", spend: 4 }] }); + networking.allTagNamesCall.mockResolvedValue({ tag_names: ["prod", "staging"] }); + networking.adminspendByProvider.mockResolvedValue([{ provider: "openai", spend: 6.75 }]); + networking.adminGlobalActivity.mockResolvedValue({ + sum_api_requests: 120, + sum_total_tokens: 4500, + daily_data: [{ date: "2026-07-01", api_requests: 120, total_tokens: 4500 }], + }); + networking.adminGlobalActivityPerModel.mockResolvedValue([]); + networking.modelAvailableCall.mockResolvedValue({ data: [] }); + networking.keyInfoV1Call.mockResolvedValue({ info: {} }); +}); + +describe("old usage page", () => { + describe("when the proxy has disabled expensive DB queries", () => { + beforeEach(() => { + networking.getProxyUISettings.mockResolvedValue({ + DISABLE_EXPENSIVE_DB_QUERIES: true, + NUM_SPEND_LOGS_ROWS: 2500000, + }); + }); + + it("shows the database query limit warning instead of the usage dashboard", async () => { + renderUsage(); + + expect(await screen.findByText("Database Query Limit Reached")).toBeInTheDocument(); + expect(screen.getByText(/SpendLogs in DB has/)).toHaveTextContent("2500000"); + expect(screen.getByText(/Please follow our guide to view usage when SpendLogs has more than 1M rows/i)); + expect(screen.queryByRole("tab", { name: "All Up" })).not.toBeInTheDocument(); + }); + + it("links to the cost tracking guide in a new tab", async () => { + renderUsage(); + + const link = await screen.findByRole("link", { name: "View Usage Guide" }); + expect(link).toHaveAttribute("href", "https://docs.litellm.ai/docs/proxy/cost_tracking"); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("skips every expensive usage query", async () => { + renderUsage(); + + await screen.findByText("Database Query Limit Reached"); + await waitFor(() => expect(networking.getProxyUISettings).toHaveBeenCalled()); + + expect(networking.adminSpendLogsCall).not.toHaveBeenCalled(); + expect(networking.adminspendByProvider).not.toHaveBeenCalled(); + expect(networking.adminTopKeysCall).not.toHaveBeenCalled(); + expect(networking.adminTopModelsCall).not.toHaveBeenCalled(); + expect(networking.adminGlobalActivity).not.toHaveBeenCalled(); + expect(networking.adminGlobalActivityPerModel).not.toHaveBeenCalled(); + expect(networking.teamSpendLogsCall).not.toHaveBeenCalled(); + expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled(); + expect(networking.tagsSpendLogsCall).not.toHaveBeenCalled(); + }); + }); + + describe("as an admin", () => { + it("renders the admin tabs", async () => { + renderUsage(); + + expect(await screen.findByRole("tab", { name: "All Up" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Team Based Usage" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Customer Usage" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Tag Based Usage" })).toBeInTheDocument(); + }); + + it("renders the cost panel cards", async () => { + renderUsage(); + + expect(await screen.findByText("Monthly Spend")).toBeInTheDocument(); + expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument(); + expect(screen.getByText("Top Models")).toBeInTheDocument(); + expect(screen.getByText("Spend by Provider")).toBeInTheDocument(); + }); + + it("lists spend by provider in a table", async () => { + renderUsage(); + + const providerCell = await screen.findByText("openai"); + const row = providerCell.closest("tr"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText("$6.75")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument(); + }); + + it("shows the customer usage table when its tab is selected", async () => { + const user = userEvent.setup(); + renderUsage(); + + await user.click(await screen.findByRole("tab", { name: "Customer Usage" })); + + const customerCell = await screen.findByText("customer-alpha"); + const row = customerCell.closest("tr"); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText("$3.50")).toBeInTheDocument(); + expect(within(row as HTMLElement).getByText("42")).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Total Events" })).toBeInTheDocument(); + }); + + it("shows the tag spend panel when its tab is selected", async () => { + const user = userEvent.setup(); + renderUsage(); + + await user.click(await screen.findByRole("tab", { name: "Tag Based Usage" })); + + expect(await screen.findByText("Spend Per Tag")).toBeInTheDocument(); + }); + + it("shows the team spend panel when its tab is selected", async () => { + const user = userEvent.setup(); + renderUsage(); + + await user.click(await screen.findByRole("tab", { name: "Team Based Usage" })); + + expect(await screen.findByText("Total Spend Per Team")).toBeInTheDocument(); + expect(screen.getByText("Daily Spend Per Team")).toBeInTheDocument(); + }); + }); + + describe("as a non-admin", () => { + it("renders only the All Up tab and skips admin-only queries", async () => { + renderUsage({ userRole: "Internal User" }); + + expect(await screen.findByRole("tab", { name: "All Up" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Team Based Usage" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Customer Usage" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Tag Based Usage" })).not.toBeInTheDocument(); + + await waitFor(() => expect(networking.adminSpendLogsCall).toHaveBeenCalled()); + expect(networking.teamSpendLogsCall).not.toHaveBeenCalled(); + expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx index 01f8cb1cd45..3d55f9bb698 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/old-usage/_components/usage.tsx @@ -1,40 +1,26 @@ -import { - BarChart, - BarList, - Card, - Title, - Table, - TableHead, - TableHeaderCell, - TableRow, - TableCell, - TableBody, - Subtitle, -} from "@tremor/react"; - import React, { useState, useEffect } from "react"; import ViewUserSpend from "@/components/view_user_spend"; import { ProxySettings } from "@/components/user_dashboard"; import UsageDatePicker from "@/components/shared/usage_date_picker"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { - Grid, - Col, - Text, - TabPanel, - TabPanels, - TabGroup, - TabList, - Tab, - Select, - SelectItem, - DateRangePickerValue, - DonutChart, - AreaChart, - Button, - MultiSelect, - MultiSelectItem, -} from "@tremor/react"; + Combobox, + ComboboxChip, + ComboboxChips, + ComboboxChipsInput, + ComboboxContent, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxValue, +} from "@/components/ui/combobox"; +import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { AreaChart, BarChart, DonutChart } from "@/components/shared/charts"; import { adminSpendLogsCall, @@ -68,69 +54,41 @@ interface GlobalActivityData { daily_data: { date: string; api_requests: number; total_tokens: number }[]; } -type CustomTooltipTypeBar = { - payload: any; - active: boolean | undefined; - label: any; -}; +type UsageDateRange = { from?: Date; to?: Date }; -const customTooltip = (props: CustomTooltipTypeBar) => { - const { payload, active } = props; - if (!active || !payload) return null; +type TeamSpendTotal = { name: string; value: number }; - const value = payload[0].payload; - const date = value["startTime"]; - const model_values = value["models"]; - const entries: [string, number][] = Object.entries(model_values).map(([key, value]) => [key, value as number]); +type TagOption = { value: string; label: string; disabled: boolean }; - entries.sort((a, b) => b[1] - a[1]); - const topEntries = entries.slice(0, 5); - - return ( -
- {date} - {topEntries.map(([key, value]) => ( -
-
-

- {key} - {":"} - - {" "} - {value ? `$${formatNumberWithCommas(value, 2)}` : ""} - -

-
-
- ))} -
- ); -}; - -function getTopKeys(data: Array<{ [key: string]: unknown }>): any[] { - const spendKeys: { key: string; spend: unknown }[] = []; - - data.forEach((dict) => { - Object.entries(dict).forEach(([key, value]) => { - if (key !== "spend" && key !== "startTime" && key !== "models" && key !== "users") { - spendKeys.push({ key, spend: value }); - } - }); - }); - - spendKeys.sort((a, b) => Number(b.spend) - Number(a.spend)); - - const topKeys = spendKeys.slice(0, 5).map((k) => k.key); - return topKeys; -} -type DataDict = { [key: string]: unknown }; -type UserData = { user_id: string; spend: number }; +const ALL_TAGS = "all-tags"; const isAdminOrAdminViewer = (role: string | null): boolean => { if (role === null) return false; return role === "Admin" || role === "Admin Viewer"; }; +const TeamSpendBarList: React.FC<{ data: TeamSpendTotal[] }> = ({ data }) => { + const max = Math.max(0, ...data.map((team) => team.value)); + + return ( +
+ {data.map((team) => ( +
+

{team.name}

+ + + + + +

+ {formatNumberWithCommas(team.value, 2)} +

+
+ ))} +
+ ); +}; + const UsagePage: React.FC = ({ accessToken, token, userRole, userID, keys, premiumUser }) => { const currentDate = new Date(); const [keySpendData, setKeySpendData] = useState([]); @@ -141,13 +99,13 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use const [topTagsData, setTopTagsData] = useState([]); const [allTagNames, setAllTagNames] = useState([]); const [uniqueTeamIds, setUniqueTeamIds] = useState([]); - const [totalSpendPerTeam, setTotalSpendPerTeam] = useState([]); + const [totalSpendPerTeam, setTotalSpendPerTeam] = useState([]); const [spendByProvider, setSpendByProvider] = useState([]); const [globalActivity, setGlobalActivity] = useState({} as GlobalActivityData); const [globalActivityPerModel, setGlobalActivityPerModel] = useState([]); - const [selectedKeyID, setSelectedKeyID] = useState(""); - const [selectedTags, setSelectedTags] = useState(["all-tags"]); - const [dateValue, setDateValue] = useState({ + const [selectedKeyToken, setSelectedKeyToken] = useState(null); + const [selectedTags, setSelectedTags] = useState([ALL_TAGS]); + const [dateValue, setDateValue] = useState({ from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), to: new Date(), }); @@ -160,6 +118,21 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use let startTime = formatDate(firstDay); let endTime = formatDate(lastDay); + const selectableKeys: { token: string; alias: string }[] = (keys ?? []) + .filter((key: any) => key && typeof key["key_alias"] === "string" && key["key_alias"].length > 0) + .map((key: any) => ({ token: String(key["token"]), alias: String(key["key_alias"]) })); + + const tagOptions: TagOption[] = [ + { value: ALL_TAGS, label: "All Tags", disabled: false }, + ...allTagNames + .filter((tag) => tag !== ALL_TAGS) + .map((tag) => ({ + value: tag, + label: premiumUser ? tag : `✨ ${tag} (Enterprise only Feature)`, + disabled: !premiumUser, + })), + ]; + function valueFormatterNumbers(number: number) { const formatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0, @@ -405,7 +378,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use setUniqueTeamIds(teamSpend.teams); return teamSpend.total_spend_per_team.map((tspt: any) => ({ name: tspt["team_id"] || "", - value: formatNumberWithCommas(tspt["total_spend"] || 0, 2), + value: Number(tspt["total_spend"] || 0), })); }, setTotalSpendPerTeam, @@ -524,223 +497,252 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use if (proxySettings?.DISABLE_EXPENSIVE_DB_QUERIES) { return ( -
+
- Database Query Limit Reached - - SpendLogs in DB has {proxySettings.NUM_SPEND_LOGS_ROWS} rows. -

- Please follow our guide to view usage when SpendLogs has more than 1M rows. -
- + + Database Query Limit Reached + + +

+ SpendLogs in DB has {proxySettings.NUM_SPEND_LOGS_ROWS} rows. +

+ Please follow our guide to view usage when SpendLogs has more than 1M rows. +

+
); } return ( -
- - - All Up +
+ + + All Up - {isAdminOrAdminViewer(userRole) ? ( + {isAdminOrAdminViewer(userRole) && ( <> - Team Based Usage - Customer Usage - Tag Based Usage - - ) : ( - <> -
+ Team Based Usage + Customer Usage + Tag Based Usage )} - - - - - - Cost - Activity - - - - - - - Project Spend {new Date().toLocaleString("default", { month: "long" })} 1 -{" "} - {new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()} - - - - - - Monthly Spend - + + + + + Cost + Activity + + + +
+
+

+ Project Spend {new Date().toLocaleString("default", { month: "long" })} 1 -{" "} + {new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()} +

+ +
+
+ + + Monthly Spend + + + + + +
+
+ + + Top Virtual Keys + + + {}} /> + + +
+
+ + + Top Models + + + `$${formatNumberWithCommas(value, 2)}`} + /> + + +
+
+
+ + + Spend by Provider + + +
+
+ `$${formatNumberWithCommas(value, 2)}`} + /> +
+
+ + + + Provider + Spend + + + + {spendByProvider.map((provider) => ( + + {provider.provider} + + + + + ))} + +
+
+
+
+
+
+
+
+ + +
+ + + All Up + + +
+
+

+ API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)} +

+ - - - - - Top Virtual Keys - {}} /> - - - - - Top Models +
+
+

+ Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)} +

`$${formatNumberWithCommas(value, 2)}`} + categories={["total_tokens"]} /> - - - - - - Spend by Provider - <> - - - `$${formatNumberWithCommas(value, 2)}`} - /> - - - - - - Provider - Spend - - - - {spendByProvider.map((provider) => ( - - {provider.provider} - - - - - ))} - -
- -
- -
- - - - - - - All Up - - - +
+
+
+
+ + {globalActivityPerModel.map((globalActivity, index) => ( + + + {globalActivity.model} + + +
+
+

API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)} - +

- - - +
+
+

Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)} - +

- - - +
+
+
+
+ ))} +
+
+
+
- <> - {globalActivityPerModel.map((globalActivity, index) => ( - - {globalActivity.model} - - - - API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)} - - - - - - Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)} - - - - - - ))} - -
-
-
-
-
- - - - - Total Spend Per Team - - - - Daily Spend Per Team + +
+
+ + + Total Spend Per Team + + + + + + + + Daily Spend Per Team + + = ({ accessToken, token, userRole, use yAxisWidth={80} stack={true} /> - - - - - - -

- Customers of your LLM API calls. Tracked when a `user` param is passed in your LLM calls{" "} - - docs here - -

- - - { - setDateValue(value); - updateEndUserData(value.from, value.to, null); - }} - /> - - - Select Key - - - + + +
+
+
- - - - - Customer - Spend - Total Events - - - - - {topUsers?.map((user: any, index: number) => ( - - {user.end_user} - - - - {user.total_count} - + +

+ Customers of your LLM API calls. Tracked when a `user` param is passed in your LLM calls{" "} + + docs here + +

+
+
+ { + setDateValue(value); + updateEndUserData(value.from, value.to, null); + }} + /> +
+
+

Select Key

+
-
-
- - - - { - setDateValue(value); - updateTagSpendData(value.from, value.to); - }} - /> - + + +
+
- - {premiumUser ? ( -
- setSelectedTags(value as string[])}> - setSelectedTags(["all-tags"])} - > - All Tags - - {allTagNames && - allTagNames - .filter((tag) => tag !== "all-tags") - .map((tag: any, index: number) => { - return ( - - {tag} - - ); - })} - -
- ) : ( -
- setSelectedTags(value as string[])}> - setSelectedTags(["all-tags"])} - > - All Tags - - {allTagNames && - allTagNames - .filter((tag) => tag !== "all-tags") - .map((tag: any, index: number) => { - return ( - - ✨ {tag} (Enterprise only Feature) - - ); - })} - -
- )} - - - - - - Spend Per Tag - + + +
+ + + + Customer + Spend + Total Events + + + + + {topUsers?.map((user: any, index: number) => ( + + {user.end_user} + + + + {user.total_count} + + ))} + +
+
+
+
+ + + +
+
+ { + setDateValue(value); + updateTagSpendData(value.from, value.to); + }} + /> +
+ +
+ selectedTags.includes(option.value))} + onValueChange={(options: TagOption[]) => setSelectedTags(options.map((option) => option.value))} + isItemEqualToValue={(a: TagOption, b: TagOption) => a.value === b.value} + itemToStringLabel={(option: TagOption) => option.label} + > + + + {(options: TagOption[]) => + options.map((option) => ( + + {option.label} + + )) + } + + + + + No tags found + + {(option: TagOption) => ( + + {option.label} + + )} + + + +
+
+
+
+ + + Spend Per Tag + + +

Get Started by Tracking cost per tag{" "} here - - - - - - - - - +

+ +
+
+
+
+
+
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx index 99c58e2b98f..d6a4aaea2e1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx @@ -1,7 +1,8 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getPromptsList } from "@/components/networking"; +import { deletePromptCall, getPromptsList } from "@/components/networking"; import PromptsPanel from "./index"; @@ -12,20 +13,39 @@ vi.mock("@/components/networking", () => ({ vi.mock("./PromptTable", () => ({ __esModule: true, - default: ({ isLoading }: { isLoading: boolean }) => ( -
{isLoading ? "table-loading" : "table-loaded"}
+ default: ({ + isLoading, + onDeleteClick, + }: { + isLoading: boolean; + onDeleteClick: (id: string, name: string) => void; + }) => ( +
+ {isLoading ? "table-loading" : "table-loaded"} + +
), })); -vi.mock("./prompt_info", () => ({ __esModule: true, default: () => null })); -vi.mock("./add_prompt_form", () => ({ __esModule: true, default: () => null })); -vi.mock("./prompt_editor_view", () => ({ __esModule: true, default: () => null })); +vi.mock("./prompt_info", () => ({ __esModule: true, default: () =>
prompt-info-view
})); +vi.mock("./add_prompt_form", () => ({ + __esModule: true, + default: ({ visible }: { visible: boolean }) => (visible ?
add-prompt-form
: null), +})); +vi.mock("./prompt_editor_view", () => ({ __esModule: true, default: () =>
prompt-editor-view
})); const mockGetPromptsList = vi.mocked(getPromptsList); +const mockDeletePromptCall = vi.mocked(deletePromptCall); + +const renderPanel = (userRole?: string) => + render(); describe("PromptsPanel loading state", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetPromptsList.mockResolvedValue({ prompts: [] } as never); }); it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { @@ -39,7 +59,7 @@ describe("PromptsPanel loading state", () => { mockGetPromptsList.mockReturnValue( new Promise((resolve) => { resolveFetch = resolve; - }), + }) as never, ); render(); expect(screen.getByText("table-loading")).toBeInTheDocument(); @@ -49,3 +69,134 @@ describe("PromptsPanel loading state", () => { expect(mockGetPromptsList).toHaveBeenCalledWith("sk-test", undefined); }); }); + +describe("PromptsPanel toolbar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPromptsList.mockResolvedValue({ prompts: [] } as never); + }); + + it("should offer both create actions to a proxy admin", async () => { + renderPanel("Admin"); + + expect(await screen.findByRole("button", { name: /add new prompt/i })).toBeEnabled(); + expect(screen.getByRole("button", { name: /upload \.prompt file/i })).toBeEnabled(); + }); + + it("should hide both create actions from a read-only viewer", async () => { + renderPanel("Admin Viewer"); + + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /add new prompt/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /upload \.prompt file/i })).not.toBeInTheDocument(); + }); + + it("should open the editor view when the add action is used", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: /add new prompt/i })); + + expect(screen.getByText("prompt-editor-view")).toBeInTheDocument(); + expect(screen.queryByTestId("prompt-table")).not.toBeInTheDocument(); + }); + + it("should open the upload form when the upload action is used", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + expect(screen.queryByText("add-prompt-form")).not.toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: /upload \.prompt file/i })); + + expect(screen.getByText("add-prompt-form")).toBeInTheDocument(); + }); + + it("should refetch scoped to the environment picked in the filter", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + await screen.findByText("table-loaded"); + + expect(screen.getByText("All Environments")).toBeInTheDocument(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Production")); + + await waitFor(() => expect(mockGetPromptsList).toHaveBeenLastCalledWith("sk-test", "production")); + }); + + it("should show the picked environment by label and clear back to the unfiltered list", async () => { + // Base UI's exit animation never completes in jsdom, so the closing popup keeps + // pointer-events: none and blocks the second open. The clicks still dispatch. + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + renderPanel("Admin"); + await screen.findByText("table-loaded"); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Production")); + await waitFor(() => expect(screen.getByRole("combobox")).toHaveTextContent("Production")); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("All Environments")); + + await waitFor(() => expect(screen.getByRole("combobox")).toHaveTextContent("All Environments")); + await waitFor(() => expect(mockGetPromptsList).toHaveBeenLastCalledWith("sk-test", undefined)); + }); +}); + +describe("PromptsPanel delete confirmation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPromptsList.mockResolvedValue({ prompts: [] } as never); + mockDeletePromptCall.mockResolvedValue(undefined as never); + }); + + it("should not delete until the confirmation is accepted", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: "row-delete" })); + + expect(await screen.findByText(/delete prompt: my-prompt/i)).toBeInTheDocument(); + expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument(); + expect(mockDeletePromptCall).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: /^delete$/i })); + + await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1")); + }); + + it("should abandon the delete when the confirmation is dismissed", async () => { + const user = userEvent.setup(); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: "row-delete" })); + await screen.findByText(/delete prompt: my-prompt/i); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument()); + expect(mockDeletePromptCall).not.toHaveBeenCalled(); + }); + + it("should keep the confirmation up while the delete request is still in flight", async () => { + const user = userEvent.setup(); + let finishDelete: () => void = () => {}; + mockDeletePromptCall.mockReturnValue( + new Promise((resolve) => { + finishDelete = () => resolve(); + }) as never, + ); + renderPanel("Admin"); + + await user.click(await screen.findByRole("button", { name: "row-delete" })); + await screen.findByText(/delete prompt: my-prompt/i); + await user.click(screen.getByRole("button", { name: /^delete$/i })); + await waitFor(() => expect(mockDeletePromptCall).toHaveBeenCalledWith("sk-test", "prompt-1")); + + await user.keyboard("{Escape}"); + expect(screen.getByText(/delete prompt: my-prompt/i)).toBeInTheDocument(); + + finishDelete(); + await waitFor(() => expect(screen.queryByText(/delete prompt: my-prompt/i)).not.toBeInTheDocument()); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx index de461ebd86d..9bebabb8cf2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from "react"; -import { Button } from "@tremor/react"; -import { Modal, Select } from "antd"; +import { Plus, Upload } from "lucide-react"; import { getPromptsList, PromptSpec, ListPromptsResponse, deletePromptCall } from "@/components/networking"; import PromptTable from "./PromptTable"; import PromptInfoView from "./prompt_info"; @@ -9,6 +8,28 @@ import AddPromptForm from "./add_prompt_form"; import PromptEditorView from "./prompt_editor_view"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +const ALL_ENVIRONMENTS_LABEL = "All Environments"; + +const ENVIRONMENT_OPTIONS = [ + { label: "Development", value: "development" }, + { label: "Staging", value: "staging" }, + { label: "Production", value: "production" }, +]; + +// SelectValue falls back to the raw value unless the root can map it to a label. +const ENVIRONMENT_ITEMS = [{ label: ALL_ENVIRONMENTS_LABEL, value: null }, ...ENVIRONMENT_OPTIONS]; interface PromptsProps { accessToken: string | null; @@ -141,26 +162,33 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { {canModify && ( <> )}
= ({ accessToken, userRole }) => { /> {promptToDelete && ( - { + if (!open && !isDeleting) handleDeleteCancel(); + }} > -

Are you sure you want to delete prompt: {promptToDelete.name} ?

-

This action cannot be undone.

-
+ + + Delete Prompt + + Are you sure you want to delete prompt: {promptToDelete.name} ? This action cannot be undone. + + + + Cancel + + + + )}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.test.tsx new file mode 100644 index 00000000000..cfe5d4d843e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import SearchConnectionTest from "./SearchConnectionTest"; +import * as networking from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +vi.mock("@/components/networking", () => ({ + testSearchToolConnection: vi.fn(), +})); + +const defaultProps = { + litellmParams: { search_provider: "tavily" }, + accessToken: "test-token", +}; + +describe("SearchConnectionTest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes the access token and params to the connection test", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + }); + + render(); + + await waitFor(() => { + expect(networking.testSearchToolConnection).toHaveBeenCalledWith( + defaultProps.accessToken, + defaultProps.litellmParams, + ); + }); + }); + + it("shows a loading state naming the provider while the test is pending", () => { + vi.mocked(networking.testSearchToolConnection).mockReturnValue(new Promise(() => {})); + + render(); + + expect(screen.getByText(/Testing connection to tavily/i)).toBeInTheDocument(); + }); + + it("renders a success state with the test query and result count", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + test_query: "hello world", + results_count: 3, + }); + + render(); + + expect(await screen.findByText(/Connection to tavily successful/i)).toBeInTheDocument(); + expect(screen.getByText("hello world")).toBeInTheDocument(); + expect(screen.getByText(/Results retrieved: 3/i)).toBeInTheDocument(); + }); + + it("fires a success notification and completion callback on a successful test", async () => { + const onTestComplete = vi.fn(); + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + }); + + render(); + + await waitFor(() => { + expect(NotificationsManager.success).toHaveBeenCalledWith("Connection test successful!"); + }); + expect(onTestComplete).toHaveBeenCalledTimes(1); + }); + + it("renders a failure state with a cleaned error message and error type", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "error", + message: "litellm.AuthenticationError: Invalid API key\nstack trace: deep internals", + error_type: "AuthenticationError", + }); + + render(); + + expect(await screen.findByText(/Connection to tavily failed/i)).toBeInTheDocument(); + expect(screen.getByText("Invalid API key")).toBeInTheDocument(); + expect(screen.getByText("AuthenticationError")).toBeInTheDocument(); + expect(screen.getByText("Verify your API key is correct and active")).toBeInTheDocument(); + }); + + it("reveals the raw error details when Show Details is toggled", async () => { + const user = userEvent.setup(); + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "error", + message: "litellm.AuthenticationError: Invalid API key\nstack trace: deep internals", + error_type: "AuthenticationError", + }); + + render(); + + const toggle = await screen.findByRole("button", { name: /show details/i }); + expect(screen.queryByText("Full Error Details")).not.toBeInTheDocument(); + + await user.click(toggle); + + expect(screen.getByText("Full Error Details")).toBeInTheDocument(); + expect(screen.getByText(/stack trace: deep internals/i)).toBeInTheDocument(); + }); + + it("treats a rejected request as a connection failure", async () => { + vi.mocked(networking.testSearchToolConnection).mockRejectedValue(new Error("network down")); + + render(); + + expect(await screen.findByText(/Connection to tavily failed/i)).toBeInTheDocument(); + expect(screen.getByText("network down")).toBeInTheDocument(); + }); + + it("links out to the search documentation", async () => { + vi.mocked(networking.testSearchToolConnection).mockResolvedValue({ + status: "success", + message: "ok", + }); + + render(); + + const docLink = await screen.findByRole("link", { name: /View Search Documentation/i }); + expect(docLink).toHaveAttribute("href", "https://docs.litellm.ai/docs/search"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx index 4e8678ded71..446d9a71517 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx @@ -1,10 +1,10 @@ -import { InfoCircleOutlined, WarningOutlined } from "@ant-design/icons"; -import { Button, Divider, Typography } from "antd"; +import { AlertTriangle, CheckCircle2, Info } from "lucide-react"; import React, { useEffect, useState } from "react"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { testSearchToolConnection } from "@/components/networking"; - -const { Text } = Typography; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface SearchConnectionTestProps { litellmParams: Record; @@ -52,30 +52,23 @@ const SearchConnectionTest: React.FC = ({ litellmPara const getCleanErrorMessage = (errorMsg: string) => { if (!errorMsg) return "Unknown error"; - // Remove stack traces const mainError = errorMsg.split("stack trace:")[0].trim(); - // Remove litellm error prefixes const cleanedError = mainError.replace(/^litellm\.(.*?)Error:\s*/, ""); - // Remove AuthenticationError prefix if it exists const finalError = cleanedError.replace(/^AuthenticationError:\s*/, ""); - // If the error contains HTML (like a 401 page), extract just the key info if (finalError.includes("") || finalError.includes("(.*?)<\/title>/); if (titleMatch) { return titleMatch[1]; } - // If it's a 401 error if (finalError.includes("401") || finalError.includes("Authorization Required")) { return "Authentication failed: Invalid API key or credentials"; } return "Authentication error - please check your API key"; } - // Limit very long error messages if (finalError.length > 200) { return finalError.substring(0, 200) + "..."; } @@ -87,34 +80,12 @@ const SearchConnectionTest: React.FC = ({ litellmPara if (isLoading) { return ( -
-
-
-
-
- +
+
+ +

Testing connection to {litellmParams.search_provider || "search provider"}... - - +

); @@ -125,147 +96,88 @@ const SearchConnectionTest: React.FC = ({ litellmPara } return ( -
+
{testResult.status === "success" ? ( -
-
- -
-
- +
+ +
+

Connection to {litellmParams.search_provider} successful! - +

{testResult.test_query && ( - - Test query:{" "} - - {testResult.test_query} - - +

+ Test query: {testResult.test_query} +

)} {testResult.results_count !== undefined && ( - - Results retrieved: {testResult.results_count} - +

Results retrieved: {testResult.results_count}

)}
) : ( - <> -
-
- - - Connection to {litellmParams.search_provider || "search provider"} failed - -
+
+
+ +

+ Connection to {litellmParams.search_provider || "search provider"} failed +

+
-
- - Error:{" "} - - - {errorMessage} - +
+

Error:

+

{errorMessage}

- {testResult.error_type && ( -
- - Error type:{" "} - - {testResult.error_type} - - -
- )} - - {testResult.message && ( -
- -
- )} -
- - {showDetails && ( -
- - Full Error Details - -
-                  {testResult.message}
-                
+ {testResult.error_type && ( +
+

+ Error type:{" "} + + {testResult.error_type} + +

)} -
- - Troubleshooting tips: - -
    -
  • Verify your API key is correct and active
  • -
  • Check if the search provider service is operational
  • -
  • Ensure you have sufficient credits/quota with the provider
  • -
  • - Review the provider's documentation for any additional requirements -
  • -
-
+ {testResult.message && ( +
+ +
+ )}
- + + {showDetails && ( +
+

Full Error Details

+
+                {testResult.message}
+              
+
+ )} + +
+

Troubleshooting tips:

+
    +
  • Verify your API key is correct and active
  • +
  • Check if the search provider service is operational
  • +
  • Ensure you have sufficient credits/quota with the provider
  • +
  • Review the provider's documentation for any additional requirements
  • +
+
+
)} - -
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx index 2fe9f3b5b8c..95772608235 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx @@ -1,12 +1,12 @@ import React, { useState } from "react"; -import { Button, Input, Typography, Spin } from "antd"; +import { ExternalLink, Search } from "lucide-react"; import MessageManager from "@/components/molecules/message_manager"; -import { SearchOutlined, LoadingOutlined } from "@ant-design/icons"; import { searchToolQueryCall } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { Card, Title as TremorTitle } from "@tremor/react"; - -const { Text } = Typography; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; interface SearchResult { title: string; @@ -36,7 +36,6 @@ export const SearchToolTester: React.FC = ({ searchToolNa }[] >([]); const [expandedResults, setExpandedResults] = useState>({}); - const [isInputFocused, setIsInputFocused] = useState(false); const handleSearch = async () => { if (!query.trim()) { @@ -60,7 +59,6 @@ export const SearchToolTester: React.FC = ({ searchToolNa }; setSearchHistory((prev) => [historyEntry, ...prev]); - // Don't clear query after search so user can modify it } catch (error) { console.error("Error querying search tool:", error); NotificationsManager.fromBackend("Failed to query search tool"); @@ -87,113 +85,79 @@ export const SearchToolTester: React.FC = ({ searchToolNa })); }; - const antIcon = ; - const latestResults = searchHistory.length > 0 ? searchHistory[0] : null; return ( - -
- Test Search Tool + +
+

Test Search Tool

-
- {/* Search Bar at Top */} +
-
- +
+ setQuery(e.target.value)} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onPressEnter={(e) => { - if (!e.shiftKey) { + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSearch(); } }} placeholder="Enter your search query..." disabled={isLoading} - bordered={false} - style={{ fontSize: "15px", padding: 0, height: "100%", boxShadow: "none" }} + className="h-12 pl-11 text-[15px]" />
-
- {/* Results Area */}
{!latestResults && !isLoading ? ( -
-
- +
+
+
- Test your search tool - Enter a query above to see search results +

Test your search tool

+

Enter a query above to see search results

) : (
{isLoading && ( -
- - Searching... +
+ +

Searching...

)} {latestResults && !isLoading && ( <> - {/* Query Info Bar */} -
+
- +

Search Query - -

{latestResults.query}
+

+
{latestResults.query}
-
- {formatTimestamp(latestResults.timestamp)} -
-
+
+

{formatTimestamp(latestResults.timestamp)}

+
+
{latestResults.response?.results?.length || 0}{" "} {latestResults.response?.results?.length === 1 ? "result" : "results"}
{latestResults.latency !== undefined && ( <> - • -
{latestResults.latency}ms
+ • +
{latestResults.latency}ms
)}
@@ -201,7 +165,6 @@ export const SearchToolTester: React.FC = ({ searchToolNa
- {/* Search Results */} {latestResults.response && latestResults.response.results && latestResults.response.results.length > 0 ? ( @@ -212,73 +175,43 @@ export const SearchToolTester: React.FC = ({ searchToolNa return (
{ - e.currentTarget.style.boxShadow = - "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)"; - e.currentTarget.style.borderColor = "#e0e7ff"; - }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)"; - e.currentTarget.style.borderColor = "#e5e7eb"; - }} + className="rounded-lg border border-border bg-card transition-shadow hover:shadow-md" >
- {/* Title and External Link */} -
+
(e.currentTarget.style.textDecoration = "underline")} - onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")} + className="flex-1 text-lg leading-snug font-semibold text-primary hover:underline" > {result.title}
- {/* URL */} -
{result.url}
+
{result.url}
- {/* Snippet Preview */} -
+
{isResultExpanded ? result.snippet : `${result.snippet.substring(0, 200)}${result.snippet.length > 200 ? "..." : ""}`}
- {/* Expand/Collapse */} {result.snippet.length > 200 && ( @@ -289,31 +222,22 @@ export const SearchToolTester: React.FC = ({ searchToolNa })}
) : ( -
-
- +
+
+
- No results found - Try a different search query +

No results found

+

Try a different search query

)} )} - {/* Search History Sidebar */} {searchHistory.length > 1 && ( -
-
- Previous Searches -
@@ -321,21 +245,21 @@ export const SearchToolTester: React.FC = ({ searchToolNa {searchHistory.slice(1, 6).map((entry, index) => (
{ setQuery(entry.query); }} > -
{entry.query}
-
- +
{entry.query}
+
+ {entry.response?.results?.length || 0}{" "} {entry.response?.results?.length === 1 ? "result" : "results"} {entry.latency !== undefined && ( <> • - {entry.latency}ms + {entry.latency}ms )} • diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx index 049523c0254..e4b2cf62940 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.test.tsx @@ -155,13 +155,8 @@ describe("SearchToolView", () => { const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); expect(toolNameContainer).toBeInTheDocument(); - const copyButtons = within(toolNameContainer!).getAllByRole("button"); - const nameCopyButton = copyButtons.find((button) => { - return button.querySelector("svg") !== null; - }); - - expect(nameCopyButton).toBeInTheDocument(); - await user.click(nameCopyButton!); + const nameCopyButton = within(toolNameContainer!).getByRole("button"); + await user.click(nameCopyButton); await waitFor(() => { expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool"); @@ -176,13 +171,8 @@ describe("SearchToolView", () => { const toolIdContainer = screen.getByText("test-tool-id-123").closest("div"); expect(toolIdContainer).toBeInTheDocument(); - const copyButtons = within(toolIdContainer!).getAllByRole("button"); - const idCopyButton = copyButtons.find((button) => { - return button.querySelector("svg") !== null; - }); - - expect(idCopyButton).toBeInTheDocument(); - await user.click(idCopyButton!); + const idCopyButton = within(toolIdContainer!).getByRole("button"); + await user.click(idCopyButton); await waitFor(() => { expect(copyToClipboard).toHaveBeenCalledWith("test-tool-id-123"); @@ -197,22 +187,14 @@ describe("SearchToolView", () => { render(); const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); - const copyButtons = within(toolNameContainer!).getAllByRole("button"); - const nameCopyButton = copyButtons.find((button) => { - return button.querySelector("svg") !== null; - }); + const nameCopyButton = within(toolNameContainer!).getByRole("button"); - expect(nameCopyButton).toBeInTheDocument(); + expect(nameCopyButton.querySelector(".lucide-copy")).toBeInTheDocument(); - const initialSvg = nameCopyButton!.querySelector("svg"); - expect(initialSvg).toBeInTheDocument(); - - await user.click(nameCopyButton!); + await user.click(nameCopyButton); await waitFor(() => { - const updatedSvg = nameCopyButton!.querySelector("svg"); - expect(updatedSvg).toBeInTheDocument(); - expect(nameCopyButton).toHaveClass("text-green-600"); + expect(nameCopyButton.querySelector(".lucide-check")).toBeInTheDocument(); }); }); @@ -224,13 +206,9 @@ describe("SearchToolView", () => { render(); const toolNameContainer = screen.getByText("Test Search Tool").closest("div"); - const copyButtons = within(toolNameContainer!).getAllByRole("button"); - const nameCopyButton = copyButtons.find((button) => { - return button.querySelector("svg") !== null; - }); + const nameCopyButton = within(toolNameContainer!).getByRole("button"); - expect(nameCopyButton).toBeInTheDocument(); - await user.click(nameCopyButton!); + await user.click(nameCopyButton); await waitFor( () => { @@ -239,7 +217,7 @@ describe("SearchToolView", () => { { timeout: 3000 }, ); - expect(nameCopyButton).not.toHaveClass("text-green-600"); + expect(nameCopyButton.querySelector(".lucide-check")).not.toBeInTheDocument(); }); it("should render SearchToolTester when accessToken is provided", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx index e77234aa3a0..1f7c992aa98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolView.tsx @@ -1,9 +1,8 @@ import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; -import { ArrowLeftIcon } from "@heroicons/react/outline"; -import { Button, Card, Grid, Text, Title } from "@tremor/react"; -import { Button as AntdButton } from "antd"; -import { CheckIcon, CopyIcon } from "lucide-react"; +import { ArrowLeft, Check, Copy } from "lucide-react"; import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { SearchToolTester } from "./SearchToolTester"; import { AvailableSearchProvider, SearchTool } from "./types"; @@ -43,73 +42,73 @@ export const SearchToolView: React.FC = ({
- -
- {searchTool.search_tool_name} - : } +
+

{searchTool.search_tool_name}

+
-
- {searchTool.search_tool_id} - : } +
+

{searchTool.search_tool_id}

+
- +
- Provider -
- {getProviderDisplayName(searchTool.litellm_params.search_provider)} -
+ +

Provider

+

+ {getProviderDisplayName(searchTool.litellm_params.search_provider)} +

+
- API Key -
- {searchTool.litellm_params.api_key ? "****" : "Not set"} -
+ +

API Key

+

{searchTool.litellm_params.api_key ? "****" : "Not set"}

+
- Created At -
- {searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"} -
+ +

Created At

+

+ {searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"} +

+
- +
{searchTool.search_tool_info?.description && ( - Description -
- {searchTool.search_tool_info.description} -
+ +

Description

+

{searchTool.search_tool_info.description}

+
)} - {/* Search Tool Tester */}
{accessToken && }
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.test.tsx new file mode 100644 index 00000000000..a0add153116 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.test.tsx @@ -0,0 +1,160 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import TransformRequestPanel from "./TransformRequestPanel"; +import { transformRequestCall } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +vi.mock("@/components/networking", () => ({ + transformRequestCall: vi.fn(), +})); + +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { + success: vi.fn(), + info: vi.fn(), + fromBackend: vi.fn(), + }, +})); + +const transformRequestCallMock = vi.mocked(transformRequestCall); +const notify = vi.mocked(NotificationsManager); + +const ACCESS_TOKEN = "sk-test-token"; + +const getRequestTextarea = () => screen.getByPlaceholderText(/press cmd\/ctrl \+ enter to transform/i); + +const getTransformButton = () => screen.getByRole("button", { name: /transform/i }); + +const getCopyButton = () => screen.getByRole("button", { name: /copy to clipboard/i }); + +describe("TransformRequestPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders both panels, the prefilled request and the placeholder curl", () => { + render(); + + expect(screen.getByText("Original Request")).toBeInTheDocument(); + expect(screen.getByText("Transformed Request")).toBeInTheDocument(); + expect(screen.getByText(/sensitive headers are not shown/i)).toBeInTheDocument(); + + expect((getRequestTextarea() as HTMLTextAreaElement).value).toContain('"model": "openai/gpt-4o"'); + expect(screen.getByText(/https:\/\/api\.openai\.com\/v1\/chat\/completions/)).toBeInTheDocument(); + + expect(screen.getByRole("link", { name: /here/i })).toHaveAttribute( + "href", + "https://github.com/BerriAI/litellm/issues", + ); + }); + + it("sends the edited request body as a completion call and renders the returned curl", async () => { + const user = userEvent.setup(); + transformRequestCallMock.mockResolvedValue({ + raw_request_api_base: "https://api.anthropic.com/v1/messages", + raw_request_body: { model: "claude-opus-4-8", max_tokens: 42 }, + raw_request_headers: { "x-api-key": "redacted" }, + }); + + render(); + + const textarea = getRequestTextarea(); + await user.clear(textarea); + await user.type(textarea, '{{"model": "claude-opus-4-8"}'); + + await user.click(getTransformButton()); + + await waitFor(() => expect(transformRequestCallMock).toHaveBeenCalledTimes(1)); + expect(transformRequestCallMock).toHaveBeenCalledWith(ACCESS_TOKEN, { + call_type: "completion", + request_body: { model: "claude-opus-4-8" }, + }); + + const output = await screen.findByText(/api\.anthropic\.com\/v1\/messages/); + expect(output.textContent).toContain("curl -X POST"); + expect(output.textContent).toContain("-H 'x-api-key: redacted'"); + expect(output.textContent).toContain('"model": "claude-opus-4-8"'); + expect(output.textContent).toContain('"max_tokens": 42'); + expect(notify.success).toHaveBeenCalledWith("Request transformed successfully"); + }); + + it("transforms on Cmd/Ctrl + Enter without clicking the button", async () => { + const user = userEvent.setup(); + transformRequestCallMock.mockResolvedValue({ + raw_request_api_base: "https://api.openai.com/v1/chat/completions", + raw_request_body: { model: "gpt-4o" }, + raw_request_headers: {}, + }); + + render(); + + getRequestTextarea().focus(); + await user.keyboard("{Meta>}{Enter}{/Meta}"); + + await waitFor(() => expect(transformRequestCallMock).toHaveBeenCalledTimes(1)); + }); + + it("rejects invalid JSON without calling the backend", async () => { + const user = userEvent.setup(); + + render(); + + const textarea = getRequestTextarea(); + await user.clear(textarea); + await user.type(textarea, "not json"); + await user.click(getTransformButton()); + + await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("Invalid JSON in request body")); + expect(transformRequestCallMock).not.toHaveBeenCalled(); + }); + + it("does not call the backend when there is no access token", async () => { + const user = userEvent.setup(); + + render(); + + await user.click(getTransformButton()); + + await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("No access token found")); + expect(transformRequestCallMock).not.toHaveBeenCalled(); + }); + + it("reports a failed transform and leaves the placeholder curl in place", async () => { + const user = userEvent.setup(); + vi.spyOn(console, "error").mockImplementation(() => {}); + transformRequestCallMock.mockRejectedValue(new Error("boom")); + + render(); + + await user.click(getTransformButton()); + + await waitFor(() => expect(notify.fromBackend).toHaveBeenCalledWith("Failed to transform request")); + expect(screen.getByText(/https:\/\/api\.openai\.com\/v1\/chat\/completions/)).toBeInTheDocument(); + }); + + it("copies the transformed request to the clipboard", async () => { + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, "writeText"); + transformRequestCallMock.mockResolvedValue({ + raw_request_api_base: "https://api.anthropic.com/v1/messages", + raw_request_body: { model: "claude-opus-4-8" }, + raw_request_headers: {}, + }); + + render(); + + await user.click(getTransformButton()); + await screen.findByText(/api\.anthropic\.com\/v1\/messages/); + + await user.click(getCopyButton()); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("https://api.anthropic.com/v1/messages"); + expect(notify.success).toHaveBeenCalledWith("Copied to clipboard"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx index 04d1701de3f..0c41547b9b7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx @@ -1,9 +1,12 @@ import React, { useState } from "react"; -import { Button } from "antd"; -import { CopyOutlined } from "@ant-design/icons"; -import { Title } from "@tremor/react"; +import { ArrowRight, Copy } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { transformRequestCall } from "@/components/networking"; import NotificationsManager from "@/components/molecules/notifications_manager"; + interface TransformRequestPanelProps { accessToken: string | null; } @@ -128,130 +131,50 @@ ${formattedBody} }; return ( -
- Playground -

See how LiteLLM transforms your request for the specified provider.

-
+
+

Playground

+

+ See how LiteLLM transforms your request for the specified provider. +

+
{/* Original Request Panel */} -
-
-

Original Request

-

- The request you would send to LiteLLM /chat/completions endpoint. -

-
+ + + Original Request + The request you would send to LiteLLM /chat/completions endpoint. + -