diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml new file mode 100644 index 0000000000..bc55981750 --- /dev/null +++ b/.github/workflows/test_server_root_path.yml @@ -0,0 +1,96 @@ +name: Test Proxy SERVER_ROOT_PATH Routing +permissions: + contents: read + +on: + pull_request: + branches: [main] + +jobs: + test-server-root-path: + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + matrix: + root_path: ["/api/v1", "/llmproxy"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./docker/Dockerfile.database + tags: litellm-test:${{ github.sha }} + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start LiteLLM container with SERVER_ROOT_PATH + run: | + docker run -d \ + --name litellm-test \ + -p 4000:4000 \ + -e SERVER_ROOT_PATH="${{ matrix.root_path }}" \ + -e LITELLM_MASTER_KEY="sk-1234" \ + litellm-test:${{ github.sha }} \ + --detailed_debug + + - name: Wait for container to be healthy + run: | + echo "Waiting for LiteLLM to start..." + max_attempts=30 + attempt=0 + + while [ $attempt -lt $max_attempts ]; do + if docker logs litellm-test 2>&1 | grep -q "Uvicorn running"; then + echo "LiteLLM started successfully" + break + fi + attempt=$((attempt + 1)) + echo "Attempt $attempt/$max_attempts - waiting for server to start..." + sleep 2 + done + + if [ $attempt -eq $max_attempts ]; then + echo "Server failed to start within timeout" + docker logs litellm-test + exit 1 + fi + + sleep 5 + + - name: Show container logs + if: always() + run: docker logs litellm-test + + - name: Test UI endpoint with root path + run: | + ROOT_PATH="${{ matrix.root_path }}" + echo "Testing UI at: http://localhost:4000${ROOT_PATH}/ui/" + + for i in 1 2 3; do + content=$(curl -sL --max-time 5 -H "Authorization: Bearer sk-1234" "http://localhost:4000${ROOT_PATH}/ui/") + if echo "$content" | grep -q -E "(html|>LP: Request with beta headers Note over CC,LP: anthropic-beta: header1,header2,header3 - + + LP->>Provider: Forward ALL headers (no validation) + Note over LP,Provider: anthropic-beta: header1,header2,header3 + + Provider-->>LP: ❌ Error: invalid beta flag + LP-->>CC: Request fails +``` + +Requests succeeded for Anthropic (native support) but failed for other providers when Claude Code sent headers those providers didn't support. + +--- + +## Root cause + +LiteLLM lacked provider-specific beta header validation. When Claude Code introduced new beta features or sent headers that specific providers didn't support, those headers were blindly forwarded, causing provider API errors. + +--- + +## Remediation + +| # | Action | Status | Code | +|---|---|---|---| +| 1 | Create `anthropic_beta_headers_config.json` with provider-specific mappings | ✅ Done | [`anthropic_beta_headers_config.json`](https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json) | +| 2 | Implement strict validation: headers must be explicitly mapped to be forwarded | ✅ Done | [`litellm_logging.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/litellm_logging.py) | +| 3 | Add `/reload/anthropic_beta_headers` endpoint for dynamic config updates | ✅ Done | Proxy management endpoints | +| 4 | Add `/schedule/anthropic_beta_headers_reload` for automatic periodic updates | ✅ Done | Proxy management endpoints | +| 5 | Support `LITELLM_ANTHROPIC_BETA_HEADERS_URL` for custom config sources | ✅ Done | Environment configuration | +| 6 | Support `LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS` for air-gapped deployments | ✅ Done | Environment configuration | + +Now LiteLLM validates and transforms headers per-provider: + +```mermaid +sequenceDiagram + participant CC as Claude Code + participant LP as LiteLLM (new behavior) + participant Config as Beta Headers Config + participant Provider as Provider (Bedrock/Azure/Vertex) + + CC->>LP: Request with beta headers + Note over CC,LP: anthropic-beta: header1,header2,header3 + LP->>Config: Load header mapping for provider Config-->>LP: Returns mapping (header→value or null) - + Note over LP: Validate & Transform:
1. Check if header exists in mapping
2. Filter out null values
3. Map to provider-specific names - + LP->>Provider: Request with filtered & mapped headers Note over LP,Provider: anthropic-beta: mapped-header2
(header1, header3 filtered out) - - Provider-->>LP: Success response + + Provider-->>LP: ✅ Success response LP-->>CC: Response ``` -### Filtering Rules +--- -1. **Header must exist in mapping**: Unknown headers are filtered out -2. **Header must have non-null value**: Headers with `null` values are filtered out -3. **Header transformation**: Headers are mapped to provider-specific names (e.g., `advanced-tool-use-2025-11-20` → `tool-search-tool-2025-10-19` for Bedrock) +## Dynamic configuration updates -### Example +A key improvement is zero-downtime configuration updates. When Anthropic releases new beta features, users can update their configuration without restarting: -Request with headers: -``` -anthropic-beta: advanced-tool-use-2025-11-20,computer-use-2025-01-24,unknown-header -``` - -For Bedrock Converse: -- ✅ `computer-use-2025-01-24` → `computer-use-2025-01-24` (supported, passed through) -- ❌ `advanced-tool-use-2025-11-20` → filtered out (null value in config) -- ❌ `unknown-header` → filtered out (not in config) - -Result sent to Bedrock: -``` -anthropic-beta: computer-use-2025-01-24 -``` - -## Dynamic Configuration Management (No Restart Required!) - -### Environment Variables - -Control how LiteLLM loads the beta headers configuration: - -| Variable | Description | Default | -|----------|-------------|---------| -| `LITELLM_ANTHROPIC_BETA_HEADERS_URL` | URL to fetch config from | GitHub main branch | -| `LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS` | Set to `True` to use local config only | `False` | - -**Example: Use Custom Config URL** ```bash -export LITELLM_ANTHROPIC_BETA_HEADERS_URL="https://your-company.com/custom-beta-headers.json" +# Manually trigger reload (no restart needed) +curl -X POST "https://your-proxy-url/reload/anthropic_beta_headers" \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" + +# Or schedule automatic reloads every 24 hours +curl -X POST "https://your-proxy-url/schedule/anthropic_beta_headers_reload?hours=24" \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" ``` -**Example: Use Local Config Only (No Remote Fetching)** -```bash -export LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS=True +This prevents future incidents where Claude Code introduces new headers before LiteLLM configuration is updated. + +--- + +## Configuration format + +The `anthropic_beta_headers_config.json` file maps input headers to provider-specific output headers: + +```json +{ + "description": "Mapping of Anthropic beta headers for each provider.", + "anthropic": { + "advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20", + "computer-use-2025-01-24": "computer-use-2025-01-24" + }, + "bedrock_converse": { + "advanced-tool-use-2025-11-20": null, + "computer-use-2025-01-24": "computer-use-2025-01-24" + }, + "azure_ai": { + "advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20", + "computer-use-2025-01-24": "computer-use-2025-01-24" + } +} ``` + +**Validation rules:** +1. Headers must exist in the mapping for the target provider +2. Headers with `null` values are filtered out (unsupported) +3. Header names can be transformed per-provider (e.g., Bedrock uses different names for some features) + +--- + +## Resolution steps for users + +For users still experiencing issues, update to the latest LiteLLM version if < v1.81.11-nightly: + +```bash +pip install --upgrade litellm +``` + +Or manually reload the configuration without restarting: + +```bash +curl -X POST "https://your-proxy-url/reload/anthropic_beta_headers" \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" +``` + +--- + +## Related documentation + +- [Managing Anthropic Beta Headers](../proxy/sync_anthropic_beta_headers.md) - Complete configuration guide +- [`anthropic_beta_headers_config.json`](https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json) - Current configuration file diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 5e3f56c420..775cdf6876 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -450,6 +450,7 @@ router_settings: | BATCH_STATUS_POLL_INTERVAL_SECONDS | Interval in seconds for polling batch status. Default is 3600 (1 hour) | BATCH_STATUS_POLL_MAX_ATTEMPTS | Maximum number of attempts for polling batch status. Default is 24 (for 24 hours) | BEDROCK_MAX_POLICY_SIZE | Maximum size for Bedrock policy. Default is 75 +| BEDROCK_MIN_THINKING_BUDGET_TOKENS | Minimum thinking budget in tokens for Bedrock reasoning models. Bedrock returns a 400 error if budget_tokens is below this value. Requests with lower values are clamped to this minimum. Default is 1024 | BERRISPEND_ACCOUNT_ID | Account ID for BerriSpend service | BRAINTRUST_API_KEY | API key for Braintrust integration | BRAINTRUST_API_BASE | Base URL for Braintrust API. Default is https://api.braintrustdata.com/v1 diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 0b7b95e7bb..a8f1ba7ced 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -26,6 +26,7 @@ from litellm.types.utils import ( CallTypes, GenericGuardrailAPIInputs, GuardrailStatus, + GuardrailTracingDetail, LLMResponseTypes, StandardLoggingGuardrailInformation, ) @@ -520,9 +521,15 @@ class CustomGuardrail(CustomLogger): masked_entity_count: Optional[Dict[str, int]] = None, guardrail_provider: Optional[str] = None, event_type: Optional[GuardrailEventHooks] = None, + tracing_detail: Optional[GuardrailTracingDetail] = None, ) -> None: """ Builds `StandardLoggingGuardrailInformation` and adds it to the request metadata so it can be used for logging to DataDog, Langfuse, etc. + + Args: + tracing_detail: Optional typed dict with provider-specific tracing fields + (guardrail_id, policy_template, detection_method, confidence_score, + classification, match_details, patterns_checked, alert_recipients). """ if isinstance(guardrail_json_response, Exception): guardrail_json_response = str(guardrail_json_response) @@ -559,6 +566,7 @@ class CustomGuardrail(CustomLogger): end_time=end_time, duration=duration, masked_entity_count=masked_entity_count, + **(tracing_detail or {}), ) def _append_guardrail_info(container: dict) -> None: diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2b6d280012..9ea9f39b1d 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1083,7 +1083,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, - "apac.anthropic.claude-opus-4-6-v1": { + "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, diff --git a/litellm/policy_templates_backup.json b/litellm/policy_templates_backup.json index a0ffd6acd3..b4869cc70d 100644 --- a/litellm/policy_templates_backup.json +++ b/litellm/policy_templates_backup.json @@ -3,6 +3,7 @@ "id": "advanced-au-pii-protection", "title": "Advanced PII Protection (Australia)", "description": "Protects Australian-specific identifiers, international employee data, financial information, credentials, protected class information, and industry-specific sensitive data.", + "region": "AU", "icon": "ShieldCheckIcon", "iconColor": "text-purple-500", "iconBg": "bg-purple-50", @@ -70,22 +71,86 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "us_ssn", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "us_ssn_no_dash", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_us", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_uk", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_germany", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_france", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_netherlands", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "nl_bsn_contextual", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_china", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_india", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_japan", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "passport_canada", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "br_cpf", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "br_cpf_unformatted", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "br_rg", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "br_cnpj", "action": "MASK"} + { + "pattern_type": "prebuilt", + "pattern_name": "us_ssn", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "us_ssn_no_dash", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_us", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_uk", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_germany", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_france", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_netherlands", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "nl_bsn_contextual", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_china", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_india", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_japan", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "passport_canada", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "br_cpf", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "br_cpf_unformatted", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "br_rg", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "br_cnpj", + "action": "MASK" + } ], "pattern_redaction_format": "[{pattern_name}_REDACTED]" }, @@ -99,12 +164,36 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "email", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "us_phone", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "br_phone_landline", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "br_phone_mobile", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "street_address", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "br_cep", "action": "MASK"} + { + "pattern_type": "prebuilt", + "pattern_name": "email", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "us_phone", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "br_phone_landline", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "br_phone_mobile", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "street_address", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "br_cep", + "action": "MASK" + } ], "pattern_redaction_format": "[{pattern_name}_REDACTED]" }, @@ -118,12 +207,36 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "visa", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "mastercard", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "amex", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "discover", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "credit_card", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "iban", "action": "MASK"} + { + "pattern_type": "prebuilt", + "pattern_name": "visa", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "mastercard", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "amex", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "discover", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "credit_card", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "iban", + "action": "MASK" + } ], "pattern_redaction_format": "[{pattern_name}_REDACTED]" }, @@ -137,11 +250,31 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "aws_access_key", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "aws_secret_key", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "github_token", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "slack_token", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "generic_api_key", "action": "BLOCK"} + { + "pattern_type": "prebuilt", + "pattern_name": "aws_access_key", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "aws_secret_key", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "github_token", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "slack_token", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "generic_api_key", + "action": "BLOCK" + } ], "pattern_redaction_format": "[{pattern_name}_REDACTED]" }, @@ -155,8 +288,16 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "ipv4", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "ipv6", "action": "MASK"} + { + "pattern_type": "prebuilt", + "pattern_name": "ipv4", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "ipv6", + "action": "MASK" + } ], "pattern_redaction_format": "[INTERNAL_IP_REDACTED]" }, @@ -170,14 +311,46 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "gender_sexual_orientation", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "race_ethnicity_national_origin", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "religion", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "age_discrimination", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "disability", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "marital_family_status", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "military_status", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "public_assistance", "action": "MASK"} + { + "pattern_type": "prebuilt", + "pattern_name": "gender_sexual_orientation", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "race_ethnicity_national_origin", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "religion", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "age_discrimination", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "disability", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "marital_family_status", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "military_status", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "public_assistance", + "action": "MASK" + } ], "pattern_redaction_format": "[PROTECTED_CLASS_INFO_REDACTED]" }, @@ -206,6 +379,7 @@ "id": "baseline-pii-protection", "title": "Baseline PII Protection", "description": "Baseline PII protection for internal tools and testing. Focuses on credentials and high-risk identifiers only. Suitable for non-sensitive internal use.", + "region": "Global", "icon": "ShieldCheckIcon", "iconColor": "text-blue-500", "iconBg": "bg-blue-50", @@ -222,13 +396,27 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "au_tfn", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "au_abn", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "au_medicare", "action": "MASK"} + { + "pattern_type": "prebuilt", + "pattern_name": "au_tfn", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "au_abn", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "au_medicare", + "action": "MASK" + } ], "pattern_redaction_format": "[{pattern_name}_REDACTED]" }, - "guardrail_info": {"description": "Masks Australian Tax File Numbers, Business Numbers, and Medicare Numbers"} + "guardrail_info": { + "description": "Masks Australian Tax File Numbers, Business Numbers, and Medicare Numbers" + } }, { "guardrail_name": "credentials-api-keys", @@ -236,15 +424,37 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "aws_access_key", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "aws_secret_key", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "github_token", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "slack_token", "action": "BLOCK"}, - {"pattern_type": "prebuilt", "pattern_name": "generic_api_key", "action": "BLOCK"} + { + "pattern_type": "prebuilt", + "pattern_name": "aws_access_key", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "aws_secret_key", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "github_token", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "slack_token", + "action": "BLOCK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "generic_api_key", + "action": "BLOCK" + } ], "pattern_redaction_format": "[{pattern_name}_REDACTED]" }, - "guardrail_info": {"description": "Blocks requests containing API keys and credentials (AWS, GitHub, Slack)"} + "guardrail_info": { + "description": "Blocks requests containing API keys and credentials (AWS, GitHub, Slack)" + } }, { "guardrail_name": "financial-pii", @@ -252,16 +462,42 @@ "guardrail": "litellm_content_filter", "mode": "pre_call", "patterns": [ - {"pattern_type": "prebuilt", "pattern_name": "visa", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "mastercard", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "amex", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "discover", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "credit_card", "action": "MASK"}, - {"pattern_type": "prebuilt", "pattern_name": "iban", "action": "MASK"} + { + "pattern_type": "prebuilt", + "pattern_name": "visa", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "mastercard", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "amex", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "discover", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "credit_card", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "iban", + "action": "MASK" + } ], "pattern_redaction_format": "[{pattern_name}_REDACTED]" }, - "guardrail_info": {"description": "Masks financial information including credit cards and bank account numbers"} + "guardrail_info": { + "description": "Masks financial information including credit cards and bank account numbers" + } } ], "templateData": { @@ -279,6 +515,7 @@ "id": "nsfw-content-filter-australia", "title": "NSFW Content Filter (Australia)", "description": "Blocks profanity, sexual content, NSFW requests, self-harm content, and child safety violations using English and Australian-specific slang. Protects against inappropriate content including sexual solicitation, explicit content, Australian profanity, self-harm, and content involving minors.", + "region": "AU", "icon": "ShieldExclamationIcon", "iconColor": "text-red-500", "iconBg": "bg-red-50", @@ -399,6 +636,7 @@ "id": "nsfw-content-filter-basic", "title": "NSFW Content Filter (Basic)", "description": "Basic NSFW content filtering for English only. Blocks profanity, sexual content, slurs, solicitation, explicit requests, self-harm content, and child safety violations. Suitable for most applications requiring content moderation.", + "region": "Global", "icon": "ShieldExclamationIcon", "iconColor": "text-orange-500", "iconBg": "bg-orange-50", @@ -499,6 +737,7 @@ "id": "nsfw-content-filter-all-regions", "title": "NSFW Content Filter (All Regions)", "description": "Comprehensive multi-language NSFW content filtering. Blocks profanity, sexual content, inappropriate requests, self-harm content, and child safety violations in English, Spanish, French, German, and Australian. Best for global applications.", + "region": "Global", "icon": "ShieldExclamationIcon", "iconColor": "text-purple-500", "iconBg": "bg-purple-50", @@ -674,5 +913,126 @@ ], "guardrails_remove": [] } + }, + { + "id": "gdpr-eu-pii-protection", + "title": "GDPR Art. 32 \u2014 EU PII Protection", + "description": "GDPR Article 32 compliance for EU personal data protection. Masks French national IDs (NIR/INSEE), EU IBANs, French phone numbers, EU VAT numbers, EU passport numbers, and email addresses. Suitable for applications processing EU citizen data requiring GDPR compliance.", + "region": "EU", + "icon": "ShieldCheckIcon", + "iconColor": "text-indigo-500", + "iconBg": "bg-indigo-50", + "guardrails": [ + "gdpr-eu-national-identifiers", + "gdpr-eu-financial-data", + "gdpr-eu-contact-information", + "gdpr-eu-business-identifiers" + ], + "complexity": "Medium", + "guardrailDefinitions": [ + { + "guardrail_name": "gdpr-eu-national-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "fr_nir", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "eu_passport_generic", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks EU national identification numbers including French NIR/INSEE and EU passport numbers for GDPR compliance" + } + }, + { + "guardrail_name": "gdpr-eu-financial-data", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "eu_iban_enhanced", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "iban", + "action": "MASK" + } + ], + "pattern_redaction_format": "[IBAN_REDACTED]" + }, + "guardrail_info": { + "description": "Masks EU bank account numbers (IBANs) to protect financial data under GDPR Article 32" + } + }, + { + "guardrail_name": "gdpr-eu-contact-information", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "email", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "fr_phone", + "action": "MASK" + }, + { + "pattern_type": "prebuilt", + "pattern_name": "fr_postal_code", + "action": "MASK" + } + ], + "pattern_redaction_format": "[{pattern_name}_REDACTED]" + }, + "guardrail_info": { + "description": "Masks contact information including emails, French phone numbers, and postal codes for EU data subjects" + } + }, + { + "guardrail_name": "gdpr-eu-business-identifiers", + "litellm_params": { + "guardrail": "litellm_content_filter", + "mode": "pre_call", + "patterns": [ + { + "pattern_type": "prebuilt", + "pattern_name": "eu_vat", + "action": "MASK" + } + ], + "pattern_redaction_format": "[VAT_NUMBER_REDACTED]" + }, + "guardrail_info": { + "description": "Masks EU VAT identification numbers to protect business entity information under GDPR" + } + } + ], + "templateData": { + "policy_name": "gdpr-eu-pii-protection", + "description": "GDPR Article 32 compliance policy for EU personal data protection. Masks French national IDs, EU IBANs, phone numbers, VAT numbers, passports, and contact information.", + "guardrails_add": [ + "gdpr-eu-national-identifiers", + "gdpr-eu-financial-data", + "gdpr-eu-contact-information", + "gdpr-eu-business-identifiers" + ], + "guardrails_remove": [] + } } -] +] \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404/index.html similarity index 100% rename from litellm/proxy/_experimental/out/404.html rename to litellm/proxy/_experimental/out/404/index.html diff --git a/litellm/proxy/_experimental/out/_not-found.html b/litellm/proxy/_experimental/out/_not-found/index.html similarity index 100% rename from litellm/proxy/_experimental/out/_not-found.html rename to litellm/proxy/_experimental/out/_not-found/index.html diff --git a/litellm/proxy/_experimental/out/api-reference.html b/litellm/proxy/_experimental/out/api-reference/index.html similarity index 100% rename from litellm/proxy/_experimental/out/api-reference.html rename to litellm/proxy/_experimental/out/api-reference/index.html diff --git a/litellm/proxy/_experimental/out/experimental/api-playground.html b/litellm/proxy/_experimental/out/experimental/api-playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/api-playground.html rename to litellm/proxy/_experimental/out/experimental/api-playground/index.html diff --git a/litellm/proxy/_experimental/out/experimental/budgets.html b/litellm/proxy/_experimental/out/experimental/budgets/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/budgets.html rename to litellm/proxy/_experimental/out/experimental/budgets/index.html diff --git a/litellm/proxy/_experimental/out/experimental/caching.html b/litellm/proxy/_experimental/out/experimental/caching/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/caching.html rename to litellm/proxy/_experimental/out/experimental/caching/index.html diff --git a/litellm/proxy/_experimental/out/experimental/claude-code-plugins.html b/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/claude-code-plugins.html rename to litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html diff --git a/litellm/proxy/_experimental/out/experimental/old-usage.html b/litellm/proxy/_experimental/out/experimental/old-usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/old-usage.html rename to litellm/proxy/_experimental/out/experimental/old-usage/index.html diff --git a/litellm/proxy/_experimental/out/experimental/prompts.html b/litellm/proxy/_experimental/out/experimental/prompts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/prompts.html rename to litellm/proxy/_experimental/out/experimental/prompts/index.html diff --git a/litellm/proxy/_experimental/out/experimental/tag-management.html b/litellm/proxy/_experimental/out/experimental/tag-management/index.html similarity index 100% rename from litellm/proxy/_experimental/out/experimental/tag-management.html rename to litellm/proxy/_experimental/out/experimental/tag-management/index.html diff --git a/litellm/proxy/_experimental/out/guardrails.html b/litellm/proxy/_experimental/out/guardrails/index.html similarity index 100% rename from litellm/proxy/_experimental/out/guardrails.html rename to litellm/proxy/_experimental/out/guardrails/index.html diff --git a/litellm/proxy/_experimental/out/login.html b/litellm/proxy/_experimental/out/login/index.html similarity index 100% rename from litellm/proxy/_experimental/out/login.html rename to litellm/proxy/_experimental/out/login/index.html diff --git a/litellm/proxy/_experimental/out/logs.html b/litellm/proxy/_experimental/out/logs/index.html similarity index 100% rename from litellm/proxy/_experimental/out/logs.html rename to litellm/proxy/_experimental/out/logs/index.html diff --git a/litellm/proxy/_experimental/out/mcp/oauth/callback.html b/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html similarity index 100% rename from litellm/proxy/_experimental/out/mcp/oauth/callback.html rename to litellm/proxy/_experimental/out/mcp/oauth/callback/index.html diff --git a/litellm/proxy/_experimental/out/model-hub.html b/litellm/proxy/_experimental/out/model-hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model-hub.html rename to litellm/proxy/_experimental/out/model-hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub.html b/litellm/proxy/_experimental/out/model_hub/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub.html rename to litellm/proxy/_experimental/out/model_hub/index.html diff --git a/litellm/proxy/_experimental/out/model_hub_table.html b/litellm/proxy/_experimental/out/model_hub_table/index.html similarity index 100% rename from litellm/proxy/_experimental/out/model_hub_table.html rename to litellm/proxy/_experimental/out/model_hub_table/index.html diff --git a/litellm/proxy/_experimental/out/models-and-endpoints.html b/litellm/proxy/_experimental/out/models-and-endpoints/index.html similarity index 100% rename from litellm/proxy/_experimental/out/models-and-endpoints.html rename to litellm/proxy/_experimental/out/models-and-endpoints/index.html diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding/index.html similarity index 100% rename from litellm/proxy/_experimental/out/onboarding.html rename to litellm/proxy/_experimental/out/onboarding/index.html diff --git a/litellm/proxy/_experimental/out/organizations.html b/litellm/proxy/_experimental/out/organizations/index.html similarity index 100% rename from litellm/proxy/_experimental/out/organizations.html rename to litellm/proxy/_experimental/out/organizations/index.html diff --git a/litellm/proxy/_experimental/out/playground.html b/litellm/proxy/_experimental/out/playground/index.html similarity index 100% rename from litellm/proxy/_experimental/out/playground.html rename to litellm/proxy/_experimental/out/playground/index.html diff --git a/litellm/proxy/_experimental/out/policies.html b/litellm/proxy/_experimental/out/policies/index.html similarity index 100% rename from litellm/proxy/_experimental/out/policies.html rename to litellm/proxy/_experimental/out/policies/index.html diff --git a/litellm/proxy/_experimental/out/settings/admin-settings.html b/litellm/proxy/_experimental/out/settings/admin-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/admin-settings.html rename to litellm/proxy/_experimental/out/settings/admin-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/logging-and-alerts.html b/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/logging-and-alerts.html rename to litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html diff --git a/litellm/proxy/_experimental/out/settings/router-settings.html b/litellm/proxy/_experimental/out/settings/router-settings/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/router-settings.html rename to litellm/proxy/_experimental/out/settings/router-settings/index.html diff --git a/litellm/proxy/_experimental/out/settings/ui-theme.html b/litellm/proxy/_experimental/out/settings/ui-theme/index.html similarity index 100% rename from litellm/proxy/_experimental/out/settings/ui-theme.html rename to litellm/proxy/_experimental/out/settings/ui-theme/index.html diff --git a/litellm/proxy/_experimental/out/teams.html b/litellm/proxy/_experimental/out/teams/index.html similarity index 100% rename from litellm/proxy/_experimental/out/teams.html rename to litellm/proxy/_experimental/out/teams/index.html diff --git a/litellm/proxy/_experimental/out/test-key.html b/litellm/proxy/_experimental/out/test-key/index.html similarity index 100% rename from litellm/proxy/_experimental/out/test-key.html rename to litellm/proxy/_experimental/out/test-key/index.html diff --git a/litellm/proxy/_experimental/out/tools/mcp-servers.html b/litellm/proxy/_experimental/out/tools/mcp-servers/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/mcp-servers.html rename to litellm/proxy/_experimental/out/tools/mcp-servers/index.html diff --git a/litellm/proxy/_experimental/out/tools/vector-stores.html b/litellm/proxy/_experimental/out/tools/vector-stores/index.html similarity index 100% rename from litellm/proxy/_experimental/out/tools/vector-stores.html rename to litellm/proxy/_experimental/out/tools/vector-stores/index.html diff --git a/litellm/proxy/_experimental/out/usage.html b/litellm/proxy/_experimental/out/usage/index.html similarity index 100% rename from litellm/proxy/_experimental/out/usage.html rename to litellm/proxy/_experimental/out/usage/index.html diff --git a/litellm/proxy/_experimental/out/users.html b/litellm/proxy/_experimental/out/users/index.html similarity index 100% rename from litellm/proxy/_experimental/out/users.html rename to litellm/proxy/_experimental/out/users/index.html diff --git a/litellm/proxy/_experimental/out/virtual-keys.html b/litellm/proxy/_experimental/out/virtual-keys/index.html similarity index 100% rename from litellm/proxy/_experimental/out/virtual-keys.html rename to litellm/proxy/_experimental/out/virtual-keys/index.html diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py index ec6fc53d3c..32883f0ce9 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/__init__.py @@ -33,6 +33,8 @@ def initialize_guardrail( content_filter_guardrail = ContentFilterGuardrail( guardrail_name=guardrail_name, + guardrail_id=guardrail.get("guardrail_id"), + policy_template=guardrail.get("policy_template"), patterns=litellm_params.patterns, blocked_words=litellm_params.blocked_words, blocked_words_file=litellm_params.blocked_words_file, diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 55fbd54bc8..55746e5e52 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -10,8 +10,19 @@ import json import os import re from datetime import datetime -from typing import (TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Literal, - Optional, Pattern, Tuple, Union, cast) +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + Dict, + List, + Literal, + Optional, + Pattern, + Tuple, + Union, + cast, +) import yaml from fastapi import HTTPException @@ -20,18 +31,26 @@ from litellm import Router from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth -from litellm.types.utils import ModelResponseStream +from litellm.types.utils import GuardrailTracingDetail, ModelResponseStream if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus -from litellm.types.guardrails import (BlockedWord, ContentFilterAction, - ContentFilterPattern, - GuardrailEventHooks, Mode) +from litellm.types.guardrails import ( + BlockedWord, + ContentFilterAction, + ContentFilterPattern, + GuardrailEventHooks, + Mode, +) from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( - BlockedWordDetection, CategoryKeywordDetection, - ContentFilterCategoryConfig, ContentFilterDetection, PatternDetection) + BlockedWordDetection, + CategoryKeywordDetection, + ContentFilterCategoryConfig, + ContentFilterDetection, + PatternDetection, +) from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern @@ -114,6 +133,8 @@ class ContentFilterGuardrail(CustomGuardrail): def __init__( self, guardrail_name: Optional[str] = None, + guardrail_id: Optional[str] = None, + policy_template: Optional[str] = None, patterns: Optional[List[ContentFilterPattern]] = None, blocked_words: Optional[List[BlockedWord]] = None, blocked_words_file: Optional[str] = None, @@ -158,6 +179,8 @@ class ContentFilterGuardrail(CustomGuardrail): ) self.guardrail_provider = "litellm_content_filter" + self.config_guardrail_id = guardrail_id + self.config_policy_template = policy_template self.pattern_redaction_format = ( pattern_redaction_format or self.PATTERN_REDACTION_FORMAT ) @@ -1308,6 +1331,83 @@ class ContentFilterGuardrail(CustomGuardrail): masked_entity_count.get(category, 0) + 1 ) + def _build_match_details( + self, detections: List[ContentFilterDetection] + ) -> List[dict]: + """Build match_details list from content filter detections.""" + match_details: List[dict] = [] + for detection in detections: + detail: dict = {"type": detection["type"], "action_taken": detection["action"]} + if detection["type"] == "pattern": + detail["detection_method"] = "regex" + detail["snippet"] = cast(PatternDetection, detection).get("pattern_name", "") + elif detection["type"] == "blocked_word": + detail["detection_method"] = "keyword" + detail["snippet"] = cast(BlockedWordDetection, detection).get("keyword", "") + elif detection["type"] == "category_keyword": + detail["detection_method"] = "keyword" + cat_det = cast(CategoryKeywordDetection, detection) + detail["snippet"] = cat_det.get("keyword", "") + detail["category"] = cat_det.get("category", "") + match_details.append(detail) + return match_details + + def _get_detection_methods(self, detections: List[ContentFilterDetection]) -> str: + """Get comma-separated detection methods used.""" + methods: set = set() + for detection in detections: + if detection["type"] == "pattern": + methods.add("regex") + else: + methods.add("keyword") + return ",".join(sorted(methods)) if methods else "" + + def _get_patterns_checked_count(self) -> int: + """Get total number of patterns and keywords that were evaluated.""" + return len(self.compiled_patterns) + len(self.blocked_words) + len(self.category_keywords) + + def _get_policy_templates(self) -> Optional[str]: + """Get comma-separated policy template names from loaded categories.""" + if not self.loaded_categories: + return None + names = [cat.description or cat.category_name for cat in self.loaded_categories.values()] + return ", ".join(names) if names else None + + def _compute_risk_score( + self, + detections: List[ContentFilterDetection], + masked_entity_count: Dict[str, int], + status: "GuardrailStatus", + ) -> float: + """ + Compute a risk score from 0-10 for this guardrail evaluation. + + Factors: + - Match ratio: how many patterns matched vs total checked + - Number of entities masked + - Whether the guardrail blocked the request (max risk) + """ + if status == "guardrail_intervened": + return 10.0 + + total_masked = sum(masked_entity_count.values()) if masked_entity_count else 0 + patterns_checked = self._get_patterns_checked_count() + + # Match ratio contribution (0-7 points) + match_ratio = total_masked / patterns_checked if patterns_checked > 0 else 0.0 + ratio_score = match_ratio * 7.0 + + # Detection count contribution (0-3 points, capped) + detection_score = min(len(detections), 5) * 0.6 + + score = ratio_score + detection_score + + # Floor: if anything matched, minimum risk is 2 + if total_masked > 0 and score < 2.0: + score = 2.0 + + return round(min(10.0, score), 1) + def _log_guardrail_information( self, request_data: dict, @@ -1348,6 +1448,14 @@ class ContentFilterGuardrail(CustomGuardrail): end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), masked_entity_count=masked_entity_count, + tracing_detail=GuardrailTracingDetail( + guardrail_id=self.config_guardrail_id or self.guardrail_name, + policy_template=self.config_policy_template or self._get_policy_templates(), + detection_method=self._get_detection_methods(detections) if detections else None, + match_details=self._build_match_details(detections) if detections else None, + patterns_checked=self._get_patterns_checked_count(), + risk_score=self._compute_risk_score(detections, masked_entity_count, status), + ), ) async def apply_guardrail( @@ -1518,7 +1626,8 @@ class ContentFilterGuardrail(CustomGuardrail): @staticmethod def get_config_model(): - from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import \ - LitellmContentFilterGuardrailConfigModel + from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( + LitellmContentFilterGuardrailConfigModel, + ) return LitellmContentFilterGuardrailConfigModel diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json index f4ad9c53a3..8bb231102a 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json @@ -398,6 +398,54 @@ "category": "Payment Card Patterns", "description": "Detects IBANs (2 letter country code + 2 check digits + 4 char bank code + 7 digit base + optional 0-16 alphanumeric)" }, + { + "name": "fr_nir", + "display_name": "NIR/INSEE (French Social Security Number)", + "pattern": "\\b[12][0-9]{2}(0[1-9]|1[0-2])[0-9]{2}[0-9]{3}[0-9]{3}[0-9]{2}\\b", + "category": "EU PII Patterns", + "description": "Detects French National Identification Number (Numéro d'Inscription au Répertoire) - 15 digits with specific format: sex + year + month + department + commune + order + key" + }, + { + "name": "eu_iban_enhanced", + "display_name": "IBAN (Enhanced EU Format)", + "pattern": "\\b[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}[A-Z0-9]{0,16}\\b", + "category": "EU PII Patterns", + "description": "Enhanced IBAN detection with more specific format validation (2 letter country + 2 check digits + 4 char bank code + 7 digit account base + optional 0-16 alphanumeric)" + }, + { + "name": "fr_phone", + "display_name": "Phone Number (France)", + "pattern": "(?= 2 # at least 1 pattern + 1 keyword + + # match_details + assert isinstance(slg["match_details"], list) + assert len(slg["match_details"]) >= 2 + methods = {d["detection_method"] for d in slg["match_details"]} + assert "regex" in methods + assert "keyword" in methods + + @pytest.mark.asyncio + async def test_tracing_fields_fallback_when_no_config_id(self): + """guardrail_id falls back to guardrail_name when config id not provided.""" + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="fallback-test", + patterns=patterns, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + await guardrail.apply_guardrail( + inputs={"texts": ["SSN: 123-45-6789"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "fallback-test" + assert slg.get("policy_template") is None # no categories loaded + assert slg["detection_method"] == "regex" + assert slg["patterns_checked"] >= 1 + + @pytest.mark.asyncio + async def test_tracing_fields_with_category_keywords(self): + """Tracing fields populated correctly when category keywords trigger detections.""" + categories = [ + ContentFilterCategoryConfig( + category="harm_toxic_abuse", + enabled=True, + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="category-tracing", + guardrail_id="gd-cat-001", + categories=categories, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + # Use a word from the harm_toxic_abuse category + await guardrail.apply_guardrail( + inputs={"texts": ["You are an idiot and stupid"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "gd-cat-001" + assert slg["patterns_checked"] >= 1 # category keywords counted + + if slg.get("match_details"): + # If detections happened, verify category info + cat_matches = [d for d in slg["match_details"] if d.get("category")] + for m in cat_matches: + assert m["detection_method"] == "keyword" + + @pytest.mark.asyncio + async def test_tracing_fields_on_blocked_request(self): + """Tracing fields populated even when request is blocked.""" + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="block-tracing", + guardrail_id="gd-block-001", + policy_template="SSN Protection", + patterns=patterns, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + with pytest.raises(HTTPException): + await guardrail.apply_guardrail( + inputs={"texts": ["SSN: 123-45-6789"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "gd-block-001" + assert slg["policy_template"] == "SSN Protection" + assert slg["guardrail_status"] == "guardrail_intervened" + assert slg["patterns_checked"] >= 1 + + @pytest.mark.asyncio + async def test_tracing_fields_no_detections(self): + """When no detections occur, tracing fields still populated with metadata.""" + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="clean-tracing", + guardrail_id="gd-clean-001", + policy_template="Email Protection", + patterns=patterns, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + await guardrail.apply_guardrail( + inputs={"texts": ["Hello world, no sensitive content here"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "gd-clean-001" + assert slg["policy_template"] == "Email Protection" + assert slg["guardrail_status"] == "success" + assert slg["patterns_checked"] >= 1 + # No detections, so these should be None + assert slg.get("detection_method") is None + assert slg.get("match_details") is None diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_eu_patterns.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_eu_patterns.py new file mode 100644 index 0000000000..85bc3fd148 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_eu_patterns.py @@ -0,0 +1,90 @@ +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.patterns import ( + get_compiled_pattern, +) + + +class TestFrenchNIR: + """Test French NIR/INSEE detection""" + + def test_valid_nir_detected(self): + pattern = get_compiled_pattern("fr_nir") + # Valid NIR: sex=1, year=92, month=05, dept=75, commune=123, order=456, key=78 + assert pattern.search("192057512345678") is not None + assert pattern.search("292057512345678") is not None # Female + + def test_invalid_month_rejected(self): + pattern = get_compiled_pattern("fr_nir") + assert pattern.search("192137512345678") is None # Month 13 + assert pattern.search("192007512345678") is None # Month 00 + + def test_invalid_sex_digit_rejected(self): + pattern = get_compiled_pattern("fr_nir") + assert pattern.search("392057512345678") is None # Sex digit 3 + + +class TestEUIBANEnhanced: + """Test enhanced EU IBAN detection""" + + def test_french_iban(self): + pattern = get_compiled_pattern("eu_iban_enhanced") + assert pattern.search("FR7630006000011234567890189") is not None + + def test_german_iban(self): + pattern = get_compiled_pattern("eu_iban_enhanced") + assert pattern.search("DE89370400440532013000") is not None + + +class TestFrenchPhone: + """Test French phone number detection""" + + def test_formats(self): + pattern = get_compiled_pattern("fr_phone") + assert pattern.search("+33612345678") is not None + assert pattern.search("0033612345678") is not None + assert pattern.search("0612345678") is not None + + def test_invalid_first_digit(self): + pattern = get_compiled_pattern("fr_phone") + assert pattern.search("0012345678") is None # First digit can't be 0 + + +class TestEUVAT: + """Test EU VAT number detection""" + + def test_major_eu_countries(self): + pattern = get_compiled_pattern("eu_vat") + assert pattern.search("FR12345678901") is not None + assert pattern.search("DE123456789") is not None + assert pattern.search("IT12345678901") is not None + + def test_pattern_requires_keyword_context(self): + """ + NOTE: The eu_vat raw pattern CAN match common words like DEPARTMENT (DE+PARTMENT). + This is why the pattern REQUIRES keyword_pattern in production use. + The ContentFilterGuardrail enforces keyword context, preventing false positives. + This test documents the raw pattern's broad matching behavior. + """ + pattern = get_compiled_pattern("eu_vat") + # These WILL match the raw pattern (by design - pattern is broad) + assert pattern.search("DEPARTMENT") is not None # DE + PARTMENT + assert pattern.search("ITALY12345678") is not None # IT + digits + + # But in production, keyword_pattern guard prevents these false positives + + +class TestEUPassportGeneric: + """Test generic EU passport detection""" + + def test_format(self): + pattern = get_compiled_pattern("eu_passport_generic") + assert pattern.search("12AB34567") is not None + + +class TestFrenchPostalCode: + """Test French postal code contextual detection""" + + def test_with_context(self): + # This test validates the pattern exists + # Contextual matching is tested in integration tests + pattern = get_compiled_pattern("fr_postal_code") + assert pattern.search("75001") is not None diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_gdpr_policy_e2e.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_gdpr_policy_e2e.py new file mode 100644 index 0000000000..238331b32c --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_gdpr_policy_e2e.py @@ -0,0 +1,293 @@ +""" +End-to-end tests for GDPR Art. 32 EU PII Protection policy template +Tests the complete policy with various EU PII patterns +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath("../../")) + +from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( + ContentFilterGuardrail, +) +from litellm.types.guardrails import ( + ContentFilterAction, + ContentFilterPattern, +) + + +class TestGDPRPolicyE2E: + """End-to-end tests for GDPR policy template""" + + def setup_gdpr_guardrail(self): + """ + Setup guardrail with all GDPR patterns (mimics the policy template) + """ + patterns = [ + # National identifiers + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="fr_nir", + action=ContentFilterAction.MASK, + ), + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="eu_passport_generic", + action=ContentFilterAction.MASK, + ), + # Financial data + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="eu_iban_enhanced", + action=ContentFilterAction.MASK, + ), + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="iban", + action=ContentFilterAction.MASK, + ), + # Contact information + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="fr_phone", + action=ContentFilterAction.MASK, + ), + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="fr_postal_code", + action=ContentFilterAction.MASK, + ), + # Business identifiers + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="eu_vat", + action=ContentFilterAction.MASK, + ), + ] + + return ContentFilterGuardrail( + guardrail_name="gdpr-eu-pii-protection", + patterns=patterns, + ) + + @pytest.mark.asyncio + async def test_french_nir_masked(self): + """ + Test 1 - SHOULD MASK: French NIR/INSEE number is detected and masked + """ + guardrail = self.setup_gdpr_guardrail() + + text = "The employee's NIR is 192057512345678 for tax purposes" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + assert "[FR_NIR_REDACTED]" in result + assert "192057512345678" not in result + + @pytest.mark.asyncio + async def test_eu_iban_masked(self): + """ + Test 2 - SHOULD MASK: EU IBAN is detected and masked + """ + guardrail = self.setup_gdpr_guardrail() + + text = "Wire transfer to account FR7630006000011234567890189" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # Either pattern could match first + assert "[EU_IBAN_ENHANCED_REDACTED]" in result or "[IBAN_REDACTED]" in result + assert "FR7630006000011234567890189" not in result + + @pytest.mark.asyncio + async def test_french_phone_masked(self): + """ + Test 3 - SHOULD MASK: French phone number is detected and masked + """ + guardrail = self.setup_gdpr_guardrail() + + text = "Call me at +33612345678 tomorrow" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + assert "[FR_PHONE_REDACTED]" in result + assert "+33612345678" not in result + + @pytest.mark.asyncio + async def test_eu_vat_masked(self): + """ + Test 4 - SHOULD MASK: EU VAT number with keyword context is detected and masked + """ + guardrail = self.setup_gdpr_guardrail() + + # Include VAT keyword for contextual matching (max 1 word gap) + text = "Company VAT number: FR12345678901" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + assert "[EU_VAT_REDACTED]" in result + assert "FR12345678901" not in result + + @pytest.mark.asyncio + async def test_normal_text_passes(self): + """ + Test 5 - SHOULD NOT MASK: Normal text without PII passes through + """ + guardrail = self.setup_gdpr_guardrail() + + text = "This is a regular business communication about our meeting" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # No redaction markers should be present + assert "REDACTED" not in result + assert result == text + + @pytest.mark.asyncio + async def test_invalid_nir_passes(self): + """ + Test 6 - SHOULD NOT MASK: Invalid NIR (month 13) is not detected + """ + guardrail = self.setup_gdpr_guardrail() + + text = "The invalid number 192137512345678 is not a valid NIR" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # Should not mask invalid NIR + assert "192137512345678" in result + assert "REDACTED" not in result + + @pytest.mark.asyncio + async def test_invalid_phone_passes(self): + """ + Test 7 - SHOULD NOT MASK: Invalid French phone (starts with 0) is not detected + """ + guardrail = self.setup_gdpr_guardrail() + + text = "This number 0012345678 is not a valid French phone" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # Should not mask invalid phone + assert "0012345678" in result + assert "REDACTED" not in result + + @pytest.mark.asyncio + async def test_random_digits_without_context_passes(self): + """ + Test 8 - SHOULD NOT MASK: Random 5-digit number without postal code context + """ + guardrail = self.setup_gdpr_guardrail() + + text = "The order number is 12345 for tracking" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # Should not mask 5-digit number without postal code context + assert "12345" in result + assert "REDACTED" not in result + + @pytest.mark.asyncio + async def test_multiple_pii_types_masked(self): + """ + Bonus test: Multiple PII types in same message are all masked + """ + guardrail = self.setup_gdpr_guardrail() + + text = "Contact jean@example.com at +33612345678 with NIR 192057512345678" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # All PII should be masked + assert "EMAIL_REDACTED" in result + assert "FR_PHONE_REDACTED" in result or "FR_NIR_REDACTED" in result + assert "jean@example.com" not in result + assert "+33612345678" not in result + assert "192057512345678" not in result + + @pytest.mark.asyncio + async def test_vat_number_without_keyword_context_passes(self): + """ + Test 10 - SHOULD NOT MASK: VAT-like pattern without keyword context + Contextual keyword guard prevents false positives + """ + guardrail = self.setup_gdpr_guardrail() + + # Text with VAT-like format but no VAT keyword context + text = "Product code FR12345678 for the shipment" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # Should not mask without VAT keyword context + assert "FR12345678" in result + assert "REDACTED" not in result + + @pytest.mark.asyncio + async def test_passport_number_without_keyword_context_passes(self): + """ + Test 11 - SHOULD NOT MASK: Passport-like pattern without keyword context + Contextual keyword guard prevents false positives + """ + guardrail = self.setup_gdpr_guardrail() + + # Text with passport-like format but no passport keyword context + text = "Reference number 12AB34567 for your order" + guardrailed_inputs = await guardrail.apply_guardrail( + inputs={"texts": [text]}, + request_data={}, + input_type="request", + ) + result = guardrailed_inputs.get("texts", [])[0] + + # Should not mask without passport keyword context + assert "12AB34567" in result + assert "REDACTED" not in result diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_patterns.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_patterns.py index 3380cefa65..ddfbf95989 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_patterns.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_patterns.py @@ -151,7 +151,30 @@ def test_all_dictionaries_consistent(): pattern_names_from_patterns = set(PREBUILT_PATTERNS.keys()) pattern_names_from_display = set(PATTERN_DISPLAY_NAMES.keys()) pattern_names_from_descriptions = set(PATTERN_DESCRIPTIONS.keys()) - + assert pattern_names_from_patterns == pattern_names_from_display assert pattern_names_from_patterns == pattern_names_from_descriptions + +def test_eu_patterns_loaded(): + """Verify all EU PII patterns are loaded""" + required_patterns = [ + "fr_nir", + "eu_iban_enhanced", + "fr_phone", + "eu_vat", + "eu_passport_generic", + "fr_postal_code" + ] + for pattern_name in required_patterns: + assert pattern_name in PREBUILT_PATTERNS, f"Pattern {pattern_name} not found" + + +def test_eu_patterns_have_category(): + """Verify EU patterns are in correct category""" + eu_patterns = ["fr_nir", "eu_iban_enhanced", "fr_phone", "eu_vat", "eu_passport_generic", "fr_postal_code"] + eu_category_patterns = PATTERN_CATEGORIES.get("EU PII Patterns", []) + + for pattern_name in eu_patterns: + assert pattern_name in eu_category_patterns, f"Pattern {pattern_name} not in EU PII Patterns category" + diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 919af96f76..9a417f3566 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1167,4 +1167,136 @@ def test_generate_request_base_validator(): # Test with None req = GenerateRequestBase(max_budget=None) - assert req.max_budget is None \ No newline at end of file + assert req.max_budget is None + + +@pytest.mark.asyncio +async def test_get_user_daily_activity_non_admin_cannot_view_other_users(monkeypatch): + """ + Test that non-admin users cannot view another user's daily activity data. + The endpoint should raise 403 when user_id does not match the caller's own user_id. + Also verifies that omitting user_id defaults to the caller's own user_id. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + get_user_daily_activity, + ) + + # Mock the prisma client so the DB-not-connected check passes + mock_prisma_client = MagicMock() + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) + + # Non-admin caller + non_admin_key_dict = UserAPIKeyAuth( + user_id="regular-user-123", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + # Case 1: Non-admin tries to view a different user's data — should get 403 + with pytest.raises(HTTPException) as exc_info: + await get_user_daily_activity( + start_date="2025-01-01", + end_date="2025-01-31", + model=None, + api_key=None, + user_id="other-user-456", + page=1, + page_size=50, + timezone=None, + user_api_key_dict=non_admin_key_dict, + ) + + assert exc_info.value.status_code == 403 + assert "Non-admin users can only view their own spend data" in str( + exc_info.value.detail + ) + + # Case 2: Non-admin omits user_id — should default to their own user_id + mock_response = MagicMock() + with patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity", + new_callable=AsyncMock, + return_value=mock_response, + ) as mock_get_daily: + result = await get_user_daily_activity( + start_date="2025-01-01", + end_date="2025-01-31", + model=None, + api_key=None, + user_id=None, + page=1, + page_size=50, + timezone=None, + user_api_key_dict=non_admin_key_dict, + ) + + # Verify it called get_daily_activity with the caller's own user_id + mock_get_daily.assert_called_once() + call_kwargs = mock_get_daily.call_args + assert call_kwargs.kwargs["entity_id"] == "regular-user-123" + + +@pytest.mark.asyncio +async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch): + """ + Test that admin users can call the aggregated endpoint without a user_id + to get a global view. Also verifies that the correct arguments are forwarded + to the underlying get_daily_activity_aggregated helper. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + get_user_daily_activity_aggregated, + ) + + # Mock the prisma client + mock_prisma_client = MagicMock() + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", mock_prisma_client + ) + + # Mock the downstream helper so we don't need a real DB + mock_response = MagicMock() + mock_get_daily_agg = AsyncMock(return_value=mock_response) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", + mock_get_daily_agg, + ) + + # Admin caller + admin_key_dict = UserAPIKeyAuth( + user_id="admin-user-001", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + # Admin calls without user_id → global view (entity_id=None) + result = await get_user_daily_activity_aggregated( + start_date="2025-02-01", + end_date="2025-02-28", + model="gpt-4", + api_key=None, + user_id=None, + timezone=480, + user_api_key_dict=admin_key_dict, + ) + + assert result is mock_response + + # Verify the helper was called with the right parameters + mock_get_daily_agg.assert_called_once_with( + prisma_client=mock_prisma_client, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, # global view: no user_id filter + entity_metadata_field=None, + start_date="2025-02-01", + end_date="2025-02-28", + model="gpt-4", + api_key=None, + timezone_offset_minutes=480, + ) \ No newline at end of file diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 2c526d340e..efa7d27ec4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -5725,3 +5725,58 @@ async def test_rotate_master_key_model_data_valid_for_prisma( # Verify delete_many was called inside the transaction (before create_many) mock_tx.litellm_proxymodeltable.delete_many.assert_called_once() +async def test_default_key_generate_params_duration(monkeypatch): + """ + Test that default_key_generate_params with 'duration' is applied + when no duration is provided in the key generation request. + + Regression test for bug where 'duration' was missing from the list + of fields populated from default_key_generate_params. + """ + import litellm + + mock_prisma_client = AsyncMock() + mock_insert_data = AsyncMock( + return_value=MagicMock( + token="hashed_token_123", litellm_budget_table=None, object_permission=None + ) + ) + mock_prisma_client.insert_data = mock_insert_data + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_verificationtoken = MagicMock() + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=None + ) + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[] + ) + mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0) + mock_prisma_client.db.litellm_verificationtoken.update = AsyncMock( + return_value=MagicMock( + token="hashed_token_123", litellm_budget_table=None, object_permission=None + ) + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + # Set default_key_generate_params with duration + original_value = litellm.default_key_generate_params + litellm.default_key_generate_params = {"duration": "180d"} + + try: + request = GenerateKeyRequest() # No duration specified + response = await _common_key_generation_helper( + data=request, + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="1234", + ), + litellm_changed_by=None, + team_table=None, + ) + + # Verify duration was applied from defaults + assert request.duration == "180d" + finally: + litellm.default_key_generate_params = original_value diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index be91800732..a18c2dba03 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -446,8 +446,24 @@ class TestProxyInitializationHelpers: mock_proxy_config_instance.get_config = mock_get_config mock_proxy_config.return_value = mock_proxy_config_instance - # Ensure DATABASE_URL is not set in the environment - with patch.dict(os.environ, {"DATABASE_URL": ""}, clear=True): + mock_proxy_server_module = MagicMock(app=mock_app) + + # Only remove DATABASE_URL and DIRECT_URL to prevent the database setup + # code path from running. Do NOT use clear=True as it removes PATH, HOME, + # etc., which causes imports inside run_server to break in CI (the real + # litellm.proxy.proxy_server import at line 820 of proxy_cli.py has heavy + # side effects that fail without a proper environment). + env_overrides = { + "DATABASE_URL": "", + "DIRECT_URL": "", + "IAM_TOKEN_DB_AUTH": "", + "USE_AWS_KMS": "", + } + with patch.dict(os.environ, env_overrides): + # Remove DATABASE_URL entirely so the DB setup block is skipped + os.environ.pop("DATABASE_URL", None) + os.environ.pop("DIRECT_URL", None) + with patch.dict( "sys.modules", { @@ -456,7 +472,11 @@ class TestProxyInitializationHelpers: ProxyConfig=mock_proxy_config, KeyManagementSettings=mock_key_mgmt, save_worker_config=mock_save_worker_config, - ) + ), + # Also mock litellm.proxy.proxy_server to prevent the real + # import at line 820 of proxy_cli.py which has heavy side + # effects (FastAPI app init, logging setup, etc.) + "litellm.proxy.proxy_server": mock_proxy_server_module, }, ), patch( "litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args" @@ -470,7 +490,10 @@ class TestProxyInitializationHelpers: # Test with no config parameter (config=None) result = runner.invoke(run_server, ["--local"]) - assert result.exit_code == 0 + assert result.exit_code == 0, ( + f"run_server failed with exit_code={result.exit_code}, " + f"output={result.output}, exception={result.exception}" + ) # Verify that uvicorn.run was called mock_uvicorn_run.assert_called_once() @@ -481,7 +504,10 @@ class TestProxyInitializationHelpers: # Test with explicit --config None (should behave the same) result = runner.invoke(run_server, ["--local", "--config", "None"]) - assert result.exit_code == 0 + assert result.exit_code == 0, ( + f"run_server failed with exit_code={result.exit_code}, " + f"output={result.output}, exception={result.exception}" + ) # Verify that uvicorn.run was called again mock_uvicorn_run.assert_called_once() diff --git a/tests/test_litellm/test_claude_opus_4_6_config.py b/tests/test_litellm/test_claude_opus_4_6_config.py index 071d0a2636..6ccba580bc 100644 --- a/tests/test_litellm/test_claude_opus_4_6_config.py +++ b/tests/test_litellm/test_claude_opus_4_6_config.py @@ -8,6 +8,43 @@ import os import litellm +def test_opus_4_6_australia_region_uses_au_prefix_not_apac(): + """ + Test that Australia region uses 'au.' prefix instead of incorrect 'apac.' prefix. + + AWS Bedrock cross-region inference uses specific regional prefixes: + - 'us.' for United States + - 'eu.' for Europe + - 'au.' for Australia (ap-southeast-2) + - 'apac.' for Asia-Pacific (Singapore, ap-southeast-1) + + This test ensures the Claude Opus 4.6 model correctly uses 'au.' for Australia, + and that 'apac.' is NOT incorrectly used for Australia region. + + Related: The 'apac.' prefix is valid for Asia-Pacific (Singapore) region models, + but should not be used for Australia which has its own 'au.' prefix. + """ + json_path = os.path.join(os.path.dirname(__file__), "../../model_prices_and_context_window.json") + with open(json_path) as f: + model_data = json.load(f) + + # Verify au.anthropic.claude-opus-4-6-v1 exists (correct) + assert "au.anthropic.claude-opus-4-6-v1" in model_data, \ + "Missing Australia region model: au.anthropic.claude-opus-4-6-v1" + + # Verify apac.anthropic.claude-opus-4-6-v1 does NOT exist (incorrect) + assert "apac.anthropic.claude-opus-4-6-v1" not in model_data, \ + "Incorrect model entry exists: apac.anthropic.claude-opus-4-6-v1 should be au.anthropic.claude-opus-4-6-v1" + + # Verify the au. model is registered in bedrock_converse_models + assert "au.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models, \ + "au.anthropic.claude-opus-4-6-v1 not registered in bedrock_converse_models" + + # Verify apac. is NOT registered for this model + assert "apac.anthropic.claude-opus-4-6-v1" not in litellm.bedrock_converse_models, \ + "apac.anthropic.claude-opus-4-6-v1 should not be in bedrock_converse_models" + + def test_opus_4_6_model_pricing_and_capabilities(): json_path = os.path.join(os.path.dirname(__file__), "../../model_prices_and_context_window.json") with open(json_path) as f: @@ -112,17 +149,7 @@ def test_opus_4_6_bedrock_regional_model_pricing(): "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, }, - "apac.anthropic.claude-opus-4-6-v1": { - "input_cost_per_token": 5.5e-06, - "output_cost_per_token": 2.75e-05, - "cache_creation_input_token_cost": 6.875e-06, - "cache_read_input_token_cost": 5.5e-07, - "input_cost_per_token_above_200k_tokens": 1.1e-05, - "output_cost_per_token_above_200k_tokens": 4.125e-05, - "cache_creation_input_token_cost_above_200k_tokens": 1.375e-05, - "cache_read_input_token_cost_above_200k_tokens": 1.1e-06, - }, - "apac.anthropic.claude-opus-4-6-v1": { + "au.anthropic.claude-opus-4-6-v1": { "input_cost_per_token": 5.5e-06, "output_cost_per_token": 2.75e-05, "cache_creation_input_token_cost": 6.875e-06, @@ -180,4 +207,4 @@ def test_opus_4_6_bedrock_converse_registration(): assert "global.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models assert "us.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models assert "eu.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models - assert "apac.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models + assert "au.anthropic.claude-opus-4-6-v1" in litellm.bedrock_converse_models diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index 3a21813fbf..0fb032b2fe 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -90,7 +90,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1769,9 +1768,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1795,7 +1794,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1806,7 +1804,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1816,14 +1813,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2001,7 +1996,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2015,7 +2009,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2025,7 +2018,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2349,7 +2341,7 @@ "version": "1.58.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.58.1" @@ -3454,14 +3446,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.2.48", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3503,7 +3493,6 @@ "version": "0.26.0", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", - "dev": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -4390,14 +4379,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4407,11 +4394,22 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -4780,7 +4778,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4804,7 +4801,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4920,7 +4916,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5044,7 +5039,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -5069,7 +5063,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -5145,7 +5138,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5213,7 +5205,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -5627,14 +5618,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/doctrine": { @@ -6548,7 +6537,6 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6581,7 +6569,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -6619,7 +6606,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -6780,7 +6766,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6931,7 +6916,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -7445,7 +7429,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -7498,7 +7481,6 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -7559,7 +7541,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7605,7 +7586,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7654,7 +7634,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7931,7 +7910,6 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -8156,19 +8134,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/knip/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/knip/node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -8182,6 +8147,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/knip/node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8220,7 +8195,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -8233,7 +8207,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -8548,7 +8521,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -9000,7 +8972,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -9010,6 +8981,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -9113,7 +9096,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -9325,7 +9307,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9344,7 +9325,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -9689,7 +9669,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -9733,13 +9712,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -9749,7 +9727,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9759,7 +9736,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -9769,7 +9745,7 @@ "version": "1.58.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.58.1" @@ -9788,7 +9764,7 @@ "version": "1.58.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -9811,7 +9787,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9840,7 +9815,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -9858,7 +9832,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9884,7 +9857,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9927,7 +9899,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -9953,7 +9924,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -9967,7 +9937,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -10081,7 +10050,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -10870,7 +10838,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -10880,7 +10847,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -10889,6 +10855,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -11145,7 +11123,6 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -11186,7 +11163,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -11242,7 +11218,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -11883,7 +11858,6 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -11919,7 +11893,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11955,7 +11928,6 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -11993,7 +11965,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -12010,7 +11981,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -12064,7 +12034,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -12074,7 +12043,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -12116,7 +12084,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -12129,19 +12096,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -12196,7 +12150,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -12284,7 +12237,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/tsconfig-paths": { @@ -12401,7 +12353,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12603,7 +12555,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -12782,19 +12733,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", @@ -12868,19 +12806,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -13083,7 +13008,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -13141,11 +13066,12 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13159,21 +13085,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", - "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts new file mode 100644 index 0000000000..b0a96eff0e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useInfiniteUsers } from "./useUsers"; +import { userListCall } from "@/components/networking"; +import type { UserListResponse } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + userListCall: vi.fn(), +})); + +vi.mock("../common/queryKeysFactory", () => ({ + createQueryKeys: vi.fn((resource: string) => ({ + all: [resource], + lists: () => [resource, "list"], + list: (params?: any) => [resource, "list", { params }], + details: () => [resource, "detail"], + detail: (uid: string) => [resource, "detail", uid], + })), +})); + +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +const DEFAULT_AUTH = { + accessToken: "test-access-token", + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, +}; + +const buildUserListResponse = ( + page: number, + totalPages: number, + userCount = 2, +): UserListResponse => ({ + page, + page_size: 50, + total: totalPages * userCount, + total_pages: totalPages, + users: Array.from({ length: userCount }, (_, i) => ({ + user_id: `user-${page}-${i}`, + user_email: `user-${page}-${i}@example.com`, + user_alias: null, + user_role: "Internal User", + spend: 0, + max_budget: null, + key_count: 0, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + sso_user_id: null, + budget_duration: null, + })), +}); + +describe("useInfiniteUsers", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue(DEFAULT_AUTH); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should return paginated user data when query is successful", async () => { + const mockResponse = buildUserListResponse(1, 2); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.data?.pages[0]).toEqual(mockResponse); + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); + + it("should use the default page size of 50", async () => { + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); + + it("should use a custom page size when provided", async () => { + const customPageSize = 25; + const mockResponse = buildUserListResponse(1, 1, 5); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(customPageSize), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + customPageSize, + null, + ); + }); + + it("should pass searchEmail to userListCall when provided", async () => { + const searchEmail = "search@example.com"; + const mockResponse = buildUserListResponse(1, 1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(50, searchEmail), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + searchEmail, + ); + }); + + it("should pass null for searchEmail when not provided", async () => { + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(50, undefined), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); + + it("should fetch the next page when more pages are available", async () => { + const page1 = buildUserListResponse(1, 3); + const page2 = buildUserListResponse(2, 3); + let callCount = 0; + (userListCall as any).mockImplementation(async () => { + callCount++; + return callCount === 1 ? page1 : page2; + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.hasNextPage).toBe(true); + + result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.isFetchingNextPage).toBe(false); + expect(result.current.data?.pages).toHaveLength(2); + }); + + expect(result.current.data?.pages[1]).toEqual(page2); + expect(userListCall).toHaveBeenCalledTimes(2); + expect(userListCall).toHaveBeenLastCalledWith( + "test-access-token", + null, + 2, + 50, + null, + ); + }); + + it("should not have a next page when on the last page", async () => { + const lastPage = buildUserListResponse(2, 2); + (userListCall as any).mockResolvedValue(lastPage); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.hasNextPage).toBe(false); + }); + + it("should not execute query when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ + ...DEFAULT_AUTH, + accessToken: null, + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when userRole is not an admin role", async () => { + mockUseAuthorized.mockReturnValue({ + ...DEFAULT_AUTH, + userRole: "Internal User", + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("should not execute query when both accessToken and userRole are invalid", async () => { + mockUseAuthorized.mockReturnValue({ + ...DEFAULT_AUTH, + accessToken: null, + userRole: "App User", + }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + expect(userListCall).not.toHaveBeenCalled(); + }); + + it("should execute query for each admin role", async () => { + const adminRoles = [ + "Admin", + "Admin Viewer", + "proxy_admin", + "proxy_admin_viewer", + "org_admin", + ]; + + for (const role of adminRoles) { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: role }); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledTimes(1); + } + }); + + it("should handle error when userListCall fails", async () => { + const testError = new Error("Failed to fetch users"); + (userListCall as any).mockRejectedValue(testError); + + const { result } = renderHook(() => useInfiniteUsers(), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toEqual(testError); + expect(result.current.data).toBeUndefined(); + }); + + it("should pass empty string searchEmail as null", async () => { + const mockResponse = buildUserListResponse(1, 1); + (userListCall as any).mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useInfiniteUsers(50, ""), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(userListCall).toHaveBeenCalledWith( + "test-access-token", + null, + 1, + 50, + null, + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts new file mode 100644 index 0000000000..cb30299f46 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -0,0 +1,41 @@ +import { userListCall, UserListResponse } from "@/components/networking"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +const infiniteUsersKeys = createQueryKeys("infiniteUsers"); + +const DEFAULT_PAGE_SIZE = 50; + +export const useInfiniteUsers = ( + pageSize: number = DEFAULT_PAGE_SIZE, + searchEmail?: string, +) => { + const { accessToken, userRole } = useAuthorized(); + return useInfiniteQuery({ + queryKey: infiniteUsersKeys.list({ + filters: { + pageSize, + ...(searchEmail && { searchEmail }), + }, + }), + queryFn: async ({ pageParam }) => { + return await userListCall( + accessToken!, + null, // userIDs + pageParam as number, // page + pageSize, // page_size + searchEmail || null, // userEmail + ); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + if (lastPage.page < lastPage.total_pages) { + return lastPage.page + 1; + } + return undefined; + }, + enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!), + }); +}; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index 1a344d3dd9..5f5ffe83ba 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -2,6 +2,7 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../../tests/test-utils"; @@ -116,6 +117,10 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ useCurrentUser: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ + useInfiniteUsers: vi.fn(), +})); + vi.mock("antd", async (importOriginal) => { const React = await import("react"); const actual = await importOriginal(); @@ -223,6 +228,10 @@ vi.mock("@ant-design/icons", async () => { return React.createElement("span"); } + function LoadingOutlined(props: any) { + return React.createElement("span", { "data-testid": "loading-icon", ...props }); + } + return { GlobalOutlined: Icon, BankOutlined: Icon, @@ -235,6 +244,8 @@ vi.mock("@ant-design/icons", async () => { ClockCircleOutlined: Icon, CalendarOutlined: Icon, InfoCircleOutlined: Icon, + UserOutlined: Icon, + LoadingOutlined, }; }); @@ -320,11 +331,13 @@ vi.mock("@tremor/react", async () => { describe("UsagePage", () => { const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); + const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); const mockTagListCall = vi.mocked(networking.tagListCall); const mockUseCustomers = vi.mocked(useCustomers); const mockUseAgents = vi.mocked(useAgents); const mockUseAuthorized = vi.mocked(useAuthorized); const mockUseCurrentUser = vi.mocked(useCurrentUser); + const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers); const mockSpendData = { results: [ @@ -487,6 +500,8 @@ describe("UsagePage", () => { beforeEach(() => { mockUseAuthorized.mockReturnValue({ + isLoading: false, + isAuthorized: true, token: "mock-token", accessToken: "test-token", userId: "user-123", @@ -505,8 +520,30 @@ describe("UsagePage", () => { error: null, } as any); mockUserDailyActivityAggregatedCall.mockClear(); + mockUserDailyActivityCall.mockClear(); mockTagListCall.mockClear(); mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); + mockUseInfiniteUsers.mockReturnValue({ + data: { + pages: [ + { + users: [ + { user_id: "user-001", user_alias: "Alice", user_email: "alice@example.com" }, + { user_id: "user-002", user_alias: null, user_email: "bob@example.com" }, + { user_id: "user-003", user_alias: null, user_email: null }, + ], + page: 1, + total_pages: 1, + total_count: 3, + }, + ], + pageParams: [1], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + } as any); mockTagListCall.mockResolvedValue({}); mockUseCustomers.mockReturnValue({ data: [], @@ -661,4 +698,434 @@ describe("UsagePage", () => { expect(entityUsageElements.length).toBeGreaterThan(0); }); }); + + describe("admin user selector", () => { + it("should render user selector for admin users in global view", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Admin should see the user selector select element with the placeholder attribute + const userSelects = screen.getAllByRole("combobox"); + const userSelect = userSelects.find( + (el) => el.getAttribute("placeholder") === "All Users (Global View)", + ); + expect(userSelect).toBeDefined(); + }); + + it("should format user options with alias when available", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // User with alias should show "alias (id)" + expect(screen.getByText("Alice (user-001)")).toBeInTheDocument(); + // User without alias but with email should show "email (id)" + expect(screen.getByText("bob@example.com (user-002)")).toBeInTheDocument(); + // User with neither alias nor email should show just the id + expect(screen.getByText("user-003")).toBeInTheDocument(); + }); + + it("should call useInfiniteUsers with debounced search", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // useInfiniteUsers should be called with default page size + expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined); + }); + + it("should deduplicate users across pages", async () => { + mockUseInfiniteUsers.mockReturnValue({ + data: { + pages: [ + { + users: [ + { user_id: "user-dup", user_alias: "DupUser", user_email: null }, + ], + page: 1, + total_pages: 2, + total_count: 2, + }, + { + users: [ + { user_id: "user-dup", user_alias: "DupUser", user_email: null }, + { user_id: "user-unique", user_alias: "UniqueUser", user_email: null }, + ], + page: 2, + total_pages: 2, + total_count: 2, + }, + ], + pageParams: [1, 2], + }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Duplicate user should appear only once + const dupElements = screen.getAllByText("DupUser (user-dup)"); + expect(dupElements).toHaveLength(1); + // Unique user should also appear + expect(screen.getByText("UniqueUser (user-unique)")).toBeInTheDocument(); + }); + + it("should pass selected userId to aggregated call", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Initially called with null (global view for admin) + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + null, + ); + }); + }); + + describe("non-admin user behavior", () => { + it("should not render user selector for non-admin users", async () => { + mockUseAuthorized.mockReturnValue({ + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "test-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Internal User", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Non-admin should not see the user selector + const userSelects = screen.getAllByRole("combobox"); + const userSelect = userSelects.find( + (el) => el.getAttribute("placeholder") === "All Users (Global View)", + ); + expect(userSelect).toBeUndefined(); + }); + + it("should always pass own userId for non-admin users", async () => { + mockUseAuthorized.mockReturnValue({ + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "test-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "Internal User", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + "user-123", + ); + }); + }); + }); + + describe("aggregated endpoint fallback", () => { + it("should fall back to paginated calls when aggregated endpoint fails", async () => { + mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available")); + mockUserDailyActivityCall.mockResolvedValue({ + ...mockSpendData, + metadata: { + ...mockSpendData.metadata, + total_pages: 1, + page: 1, + }, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockUserDailyActivityCall).toHaveBeenCalled(); + }); + + // Should still render the data from the paginated fallback + expect(screen.getByText("1,500")).toBeInTheDocument(); + }); + + it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => { + mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available")); + + const page1Data = { + results: [mockSpendData.results[0]], + metadata: { + total_spend: 60, + total_api_requests: 700, + total_successful_requests: 680, + total_failed_requests: 20, + total_tokens: 35000, + total_pages: 2, + page: 1, + }, + }; + + const page2Data = { + results: [ + { + ...mockSpendData.results[0], + date: "2025-01-02", + }, + ], + metadata: { + total_spend: 65.75, + total_api_requests: 800, + total_successful_requests: 770, + total_failed_requests: 30, + total_tokens: 40000, + total_pages: 2, + page: 2, + }, + }; + + mockUserDailyActivityCall + .mockResolvedValueOnce(page1Data) + .mockResolvedValueOnce(page2Data); + + renderWithProviders(); + + await waitFor(() => { + // Both pages should have been fetched + expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(2); + }); + + // Verify first page call + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + 1, + null, + ); + + // Verify second page call + expect(mockUserDailyActivityCall).toHaveBeenCalledWith( + "test-token", + expect.any(Date), + expect.any(Date), + 2, + null, + ); + }); + }); + + describe("MCP Server Activity tab", () => { + it("should render MCP Server Activity tab", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // The tab list should contain MCP Server Activity + expect(screen.getByText("MCP Server Activity")).toBeInTheDocument(); + }); + }); + + describe("User Agent Activity view", () => { + it("should render User Agent Activity component when view is selected", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const usageSelect = screen.getByTestId("usage-view-select"); + act(() => { + fireEvent.change(usageSelect, { target: { value: "user-agent-activity" } }); + }); + + await waitFor(() => { + // "User Agent Activity" appears both in the select option and in the rendered component + const elements = screen.getAllByText("User Agent Activity"); + expect(elements.length).toBeGreaterThanOrEqual(2); + }); + }); + }); + + describe("Export Data button", () => { + it("should render Export Data button in global view for admin", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + expect(screen.getByText("Export Data")).toBeInTheDocument(); + }); + }); + + describe("model view toggle", () => { + it("should show Public Model Name view by default", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Default should be "groups" view showing "Top Public Model Names" + expect(screen.getByText("Top Public Model Names")).toBeInTheDocument(); + expect(screen.getByText("Public Model Name")).toBeInTheDocument(); + expect(screen.getByText("Litellm Model Name")).toBeInTheDocument(); + }); + + it("should switch to Litellm Model Name view on toggle click", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Click the "Litellm Model Name" toggle + const litellmToggle = screen.getByText("Litellm Model Name"); + act(() => { + fireEvent.click(litellmToggle); + }); + + // Title should change to "Top Litellm Models" + await waitFor(() => { + expect(screen.getByText("Top Litellm Models")).toBeInTheDocument(); + }); + }); + + it("should switch back to Public Model Name view", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + // Switch to individual first + const litellmToggle = screen.getByText("Litellm Model Name"); + act(() => { + fireEvent.click(litellmToggle); + }); + + await waitFor(() => { + expect(screen.getByText("Top Litellm Models")).toBeInTheDocument(); + }); + + // Switch back to groups + const publicToggle = screen.getByText("Public Model Name"); + act(() => { + fireEvent.click(publicToggle); + }); + + await waitFor(() => { + expect(screen.getByText("Top Public Model Names")).toBeInTheDocument(); + }); + }); + }); + + describe("customer usage banner", () => { + it("should show and be dismissible in customer view", async () => { + mockUseCustomers.mockReturnValue({ + data: mockCustomers, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const usageSelect = screen.getByTestId("usage-view-select"); + act(() => { + fireEvent.change(usageSelect, { target: { value: "customer" } }); + }); + + await waitFor(() => { + expect(screen.getByText("Customer usage is a new feature.")).toBeInTheDocument(); + }); + + // Click the close button + const closeButton = screen.getByLabelText("Close"); + act(() => { + fireEvent.click(closeButton); + }); + + await waitFor(() => { + expect(screen.queryByText("Customer usage is a new feature.")).not.toBeInTheDocument(); + }); + }); + }); + + describe("agent usage banner", () => { + it("should show agent usage banner with A2A info", async () => { + mockUseAgents.mockReturnValue({ + data: { agents: mockAgents }, + isLoading: false, + error: null, + } as any); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + const usageSelect = screen.getByTestId("usage-view-select"); + act(() => { + fireEvent.change(usageSelect, { target: { value: "agent" } }); + }); + + await waitFor(() => { + expect(screen.getByText("Agent usage (A2A) is a new feature.")).toBeInTheDocument(); + }); + }); + }); + + describe("tab navigation in global view", () => { + it("should render all expected tabs", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + expect(screen.getByText("Cost")).toBeInTheDocument(); + expect(screen.getByText("Model Activity")).toBeInTheDocument(); + expect(screen.getByText("Key Activity")).toBeInTheDocument(); + expect(screen.getByText("MCP Server Activity")).toBeInTheDocument(); + expect(screen.getByText("Endpoint Activity")).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 688ee73767..f81da6e245 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -6,7 +6,7 @@ * Works at 1m+ spend logs, by querying an aggregate table instead. */ -import { InfoCircleOutlined } from "@ant-design/icons"; +import { InfoCircleOutlined, LoadingOutlined, UserOutlined } from "@ant-design/icons"; import { BarChart, Card, @@ -21,13 +21,15 @@ import { Text, Title } from "@tremor/react"; -import { Alert, Segmented, Tooltip } from "antd"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, Segmented, Select, Tooltip } from "antd"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import React, { useCallback, useEffect, useMemo, useState, type UIEvent } from "react"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Button } from "@tremor/react"; import { all_admin_roles } from "../../../utils/roles"; @@ -81,6 +83,62 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const { data: currentUser } = useCurrentUser(); console.log(`currentUser: ${JSON.stringify(currentUser)}`); console.log(`currentUser max budget: ${currentUser?.max_budget}`); + const isAdmin = all_admin_roles.includes(userRole || ""); + + // Debounced search for user selector + const [userSearchInput, setUserSearchInput] = useState(""); + const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", { + wait: 300, + }); + + const { + data: usersInfiniteData, + fetchNextPage: fetchNextUsersPage, + hasNextPage: hasNextUsersPage, + isFetchingNextPage: isFetchingNextUsersPage, + isLoading: isLoadingUsers, + } = useInfiniteUsers(50, debouncedUserSearch || undefined); + + const userOptions = useMemo(() => { + if (!usersInfiniteData?.pages) return []; + const seen = new Set(); + const result: { value: string; label: string }[] = []; + for (const page of usersInfiniteData.pages) { + for (const user of page.users) { + if (seen.has(user.user_id)) continue; + seen.add(user.user_id); + result.push({ + value: user.user_id, + label: user.user_alias + ? `${user.user_alias} (${user.user_id})` + : user.user_email + ? `${user.user_email} (${user.user_id})` + : user.user_id, + }); + } + } + return result; + }, [usersInfiniteData]); + + const handleUserSearchChange = (value: string) => { + setUserSearchInput(value); + setDebouncedUserSearch(value); + }; + + const handleUserPopupScroll = (e: UIEvent) => { + const target = e.currentTarget; + const scrollRatio = + (target.scrollTop + target.clientHeight) / target.scrollHeight; + if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) { + fetchNextUsersPage(); + } + }; + + // For admins: null means global view (all users), a string means filter by that user + // For non-admins: always set to their own user ID + const [selectedUserId, setSelectedUserId] = useState( + isAdmin ? null : (userID || null) + ); const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); @@ -107,6 +165,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { getAllTags(); }, [accessToken]); + // Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render) + useEffect(() => { + if (!isAdmin && userID) { + setSelectedUserId(userID); + } + }, [isAdmin, userID]); + // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -301,6 +366,9 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const fetchUserSpendData = useCallback(async () => { if (!accessToken || !dateValue.from || !dateValue.to) return; + // For non-admins, always pass their own user_id + const effectiveUserId = isAdmin ? selectedUserId : (userID || null); + setLoading(true); // Create new Date objects to avoid mutating the original dates @@ -310,14 +378,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { try { // Prefer aggregated endpoint to avoid many page requests try { - const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime); + const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId); setUserSpendData(aggregated); return; } catch (e) { // Fallback to paginated calls if aggregated endpoint is unavailable } - const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime); + const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime, 1, effectiveUserId); if (firstPageData.metadata.total_pages <= 1) { setUserSpendData(firstPageData); @@ -328,7 +396,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const aggregatedMetadata = { ...firstPageData.metadata }; for (let page = 2; page <= firstPageData.metadata.total_pages; page++) { - const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page); + const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page, effectiveUserId); allResults.push(...pageData.results); if (pageData.metadata) { aggregatedMetadata.total_spend += pageData.metadata.total_spend || 0; @@ -349,7 +417,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setLoading(false); setIsDateChanging(false); } - }, [accessToken, dateValue.from, dateValue.to]); + }, [accessToken, dateValue.from, dateValue.to, selectedUserId, isAdmin, userID]); // Super responsive date change handler const handleDateChange = useCallback((newValue: DateRangePickerValue) => { @@ -423,12 +491,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { setUsageView(value)} - isAdmin={all_admin_roles.includes(userRole || "")} + isAdmin={isAdmin} /> {/* Your Usage Panel */} {usageView === "global" && ( + <>
@@ -460,24 +529,61 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {/* Total Spend Card */} - - Project Spend{" "} - {dateValue.from && dateValue.to && ( - <> - {dateValue.from.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, - })} - {" - "} - {dateValue.to.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - +
+ + Project Spend{" "} + {dateValue.from && dateValue.to && ( + <> + {dateValue.from.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, + })} + {" - "} + {dateValue.to.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + )} + + {isAdmin && ( +
+ +