diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2b6d2800124..9ea9f39b1db 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 a0ffd6acd30..b4869cc70d5 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 ec6fc53d3c8..32883f0ce9a 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/patterns.json b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json index f4ad9c53a35..8bb231102a3 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": "(?=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 00000000000..b0a96eff0e7 --- /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 00000000000..cb30299f46f --- /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 1a344d3dd95..5f5ffe83baa 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 688ee73767f..f81da6e2455 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 && ( +
+ +