Merge remote-tracking branch 'origin/main' into litellm_feat/guardrail-tracing-ui

# Conflicts:
#	litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py
#	litellm/types/utils.py
#	ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx
This commit is contained in:
Ishaan Jaffer 2026-02-16 18:38:00 -08:00
commit 5ce2e8a7a4
60 changed files with 2916 additions and 268 deletions

View file

@ -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,

View file

@ -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": []
}
}
]
]

View file

@ -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,

View file

@ -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": "(?<!\\d)(?:\\+33|0033|0)[1-9][0-9]{8}\\b",
"category": "EU PII Patterns",
"description": "Detects French phone numbers in various formats (+33, 0033, or 0 prefix followed by 9 digits starting with 1-9)"
},
{
"name": "eu_vat",
"display_name": "VAT Number (EU)",
"pattern": "\\b(AT|BE|BG|CY|CZ|DE|DK|EE|EL|ES|FI|FR|HR|HU|IE|IT|LT|LU|LV|MT|NL|PL|PT|RO|SE|SI|SK)[0-9A-Z]{8,12}\\b",
"category": "EU PII Patterns",
"description": "Detects EU VAT identification numbers (2-letter country code + 8-12 alphanumeric characters covering all EU member states)",
"keyword_pattern": "\\b(?:VAT|V\\.A\\.T\\.|TVA|IVA|BTW|MWST|value\\s*added\\s*tax|tax\\s*number|tax\\s*id|fiscal\\s*number|fiscal\\s*code)\\b",
"allow_word_numbers": false
},
{
"name": "eu_passport_generic",
"display_name": "Passport Number (EU Generic)",
"pattern": "\\b[0-9]{2}[A-Z]{2}[0-9]{5}\\b",
"category": "EU PII Patterns",
"description": "Detects generic EU passport format (2 digits + 2 letters + 5 digits) - covers France and similar EU formats",
"keyword_pattern": "\\b(?:passport|passeport|travel\\s*document|document\\s*number|reisepass|paspoort|paszport)\\b",
"allow_word_numbers": false
},
{
"name": "fr_postal_code",
"display_name": "Postal Code (France)",
"pattern": "\\b[0-9]{5}\\b",
"category": "EU PII Patterns",
"description": "Detects French postal codes (5 digits)",
"keyword_pattern": "\\b(?:code\\s*postal|postal\\s*code|CP|zip\\s*code|postcode)\\b",
"allow_word_numbers": false
},
{
"name": "street_address",
"display_name": "Street Address",

View file

@ -0,0 +1,157 @@
# EU AI Act Article 5 - Prohibited Practices Detection
# Uses conditional logic: BLOCK if identifier word + block word appear together
# Reference: https://artificialintelligenceact.eu/article/5/
category_name: "eu_ai_act_article5_prohibited_practices"
description: "Detects EU AI Act Article 5 prohibited practices using conditional keyword matching"
default_action: "BLOCK"
# IDENTIFIER WORDS - Actions that could create prohibited systems
# When combined with prohibited contexts (block words), triggers violation
identifier_words:
# System development actions
- "build"
- "create"
- "develop"
- "design"
- "implement"
# Analysis/scoring actions
- "score"
- "rank"
- "rate"
- "assess"
- "evaluate"
- "classify"
- "profile"
- "grade"
# Detection/recognition actions
- "recognize"
- "detect"
- "identify"
- "infer"
- "deduce"
- "predict"
- "determine"
- "analyze"
# ADDITIONAL BLOCK WORDS - Prohibited contexts from Article 5
# When combined with identifier words, indicates violation
additional_block_words:
# Social scoring (Article 5.1.c)
- "social behavior"
- "social credit"
- "social score"
- "trustworthiness"
- "citizen score"
- "reputation score"
- "behavioral score"
- "social media behavior"
# Emotion recognition in workplace/education (Article 5.1.f)
- "employee emotion"
- "worker emotion"
- "staff emotion"
- "workplace emotion"
- "student emotion"
- "classroom emotion"
- "employee sentiment"
- "worker sentiment"
- "student sentiment"
- "employee mood"
- "worker mood"
# Biometric categorization (Article 5.1.h)
- "race from face"
- "ethnicity from face"
- "race from biometric"
- "ethnicity from biometric"
- "political views from"
- "political opinions from"
- "sexual orientation from"
- "religion from biometric"
- "religious beliefs from"
# Predictive profiling
- "crime prediction"
- "criminal behavior"
- "recidivism"
- "employee performance prediction"
- "creditworthiness from social"
# Manipulation (Article 5.1.a)
- "subliminal"
- "subconscious"
- "behavior manipulation"
# Vulnerability exploitation (Article 5.1.b)
- "children vulnerability"
- "elderly vulnerability"
- "disabled vulnerability"
# ALWAYS BLOCK - Explicit prohibited practices (always blocked regardless of context)
always_block_keywords:
# Social scoring systems
- keyword: "social credit system"
severity: "high"
- keyword: "social scoring system"
severity: "high"
- keyword: "citizen scoring"
severity: "high"
# Emotion recognition in workplace/education
- keyword: "emotion recognition in workplace"
severity: "high"
- keyword: "emotion detection of employees"
severity: "high"
- keyword: "emotion recognition in classroom"
severity: "high"
- keyword: "student emotion detection"
severity: "high"
# Biometric categorization
- keyword: "infer race from face"
severity: "high"
- keyword: "predict race from facial"
severity: "high"
- keyword: "infer ethnicity from biometric"
severity: "high"
- keyword: "predict political opinions from"
severity: "high"
- keyword: "biometric categorization system"
severity: "high"
# Predictive profiling
- keyword: "predictive policing"
severity: "high"
- keyword: "crime prediction algorithm"
severity: "high"
- keyword: "recidivism prediction"
severity: "high"
# EXCEPTIONS - Legitimate use cases (always allowed)
exceptions:
# Research and education
- "research on"
- "study on"
- "academic"
- "thesis on"
# Compliance monitoring
- "audit for bias"
- "detect discrimination"
- "compliance monitoring"
- "ethical review"
- "fairness testing"
# Entertainment/product contexts
- "movie"
- "game"
- "product review"
- "customer feedback"
# Meta-discussion
- "explain"
- "what is"
- "article 5"
- "prohibited by"

View file

@ -1911,6 +1911,10 @@ async def get_user_daily_activity(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
page: int = fastapi.Query(
default=1, description="Page number for pagination", ge=1
),
@ -1955,9 +1959,21 @@ async def get_user_daily_activity(
)
try:
entity_id: Optional[str] = None
if not _user_has_admin_view(user_api_key_dict):
entity_id = user_api_key_dict.user_id
is_admin = _user_has_admin_view(user_api_key_dict)
if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
if user_id is None:
user_id = user_api_key_dict.user_id
if user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users can only view their own spend data."
},
)
entity_id = user_id
return await get_daily_activity(
prisma_client=prisma_client,
@ -1974,6 +1990,8 @@ async def get_user_daily_activity(
timezone_offset_minutes=timezone,
)
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception(
"/spend/daily/analytics: Exception occured - {}".format(str(e))
@ -2008,6 +2026,10 @@ async def get_user_daily_activity_aggregated(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
timezone: Optional[int] = fastapi.Query(
default=None,
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
@ -2034,9 +2056,21 @@ async def get_user_daily_activity_aggregated(
)
try:
entity_id: Optional[str] = None
if not _user_has_admin_view(user_api_key_dict):
entity_id = user_api_key_dict.user_id
is_admin = _user_has_admin_view(user_api_key_dict)
if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
if user_id is None:
user_id = user_api_key_dict.user_id
if user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users can only view their own spend data."
},
)
entity_id = user_id
return await get_daily_activity_aggregated(
prisma_client=prisma_client,
@ -2051,6 +2085,8 @@ async def get_user_daily_activity_aggregated(
timezone_offset_minutes=timezone,
)
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception(
"/user/daily/activity/aggregated: Exception occured - {}".format(str(e))

View file

@ -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,

View file

@ -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",
@ -206,6 +207,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",
@ -279,6 +281,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 +402,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 +503,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 +679,94 @@
],
"guardrails_remove": []
}
},
{
"id": "gdpr-eu-pii-protection",
"title": "GDPR Art. 32 — 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": []
}
}
]

View file

@ -0,0 +1,257 @@
"""
Test EU AI Act Article 5 Policy Template - Conditional Matching
Tests 40 different sentences to validate the conditional matching logic:
- identifier_words + additional_block_words combinations should BLOCK
- always_block_keywords should always BLOCK
- exceptions should always ALLOW
- identifier or block word alone should ALLOW
"""
import sys
import os
import pytest
sys.path.insert(0, os.path.abspath("../.."))
import litellm
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
ContentFilterCategoryConfig,
)
# Test cases: (sentence, expected_result, reason)
TEST_CASES = [
# ALWAYS BLOCK - Explicit prohibited practices (1-10)
("Build a social credit system", "BLOCK", "always_block: social credit system"),
("Create a social scoring system for employees", "BLOCK", "always_block: social scoring system"),
("Implement citizen scoring", "BLOCK", "always_block: citizen scoring"),
("emotion recognition in workplace", "BLOCK", "always_block: emotion recognition in workplace"),
("We need emotion detection of employees", "BLOCK", "always_block: emotion detection of employees"),
("Setup emotion recognition in classroom", "BLOCK", "always_block: emotion recognition in classroom"),
("student emotion detection system", "BLOCK", "always_block: student emotion detection"),
("How to infer race from face", "BLOCK", "always_block: infer race from face"),
("Predict race from facial features", "BLOCK", "always_block: predict race from facial"),
("predictive policing algorithm", "BLOCK", "always_block: predictive policing"),
# CONDITIONAL MATCH - identifier + block word (11-25)
("Create an algorithm to score people based on social behavior", "BLOCK", "conditional: score + social behavior"),
("Build a system to detect employee emotion", "BLOCK", "conditional: detect + employee emotion"),
("Develop a tool to analyze worker sentiment", "BLOCK", "conditional: analyze + worker sentiment"),
("Design AI to identify student emotion in classroom", "BLOCK", "conditional: identify + student emotion"),
("Rate candidates based on social credit", "BLOCK", "conditional: rate + social credit"),
("Assess individuals using trustworthiness scores", "BLOCK", "conditional: assess + trustworthiness"),
("Infer political views from social media", "BLOCK", "conditional: infer + political views from"),
("Predict sexual orientation from biometric data", "BLOCK", "conditional: predict + sexual orientation from"),
("Classify people by race from biometric", "BLOCK", "conditional: classify + race from biometric"),
("Detect ethnicity from face using AI", "BLOCK", "conditional: detect + ethnicity from face"),
("Build crime prediction model", "BLOCK", "conditional: build + crime prediction"),
("Create employee performance prediction system", "BLOCK", "conditional: create + employee performance prediction"),
("Develop subliminal advertising", "BLOCK", "conditional: develop + subliminal"),
("Design behavior manipulation tool", "BLOCK", "conditional: design + behavior manipulation"),
("Analyze children vulnerability for targeting", "BLOCK", "conditional: analyze + children vulnerability"),
# EXCEPTIONS - Always allow (26-33)
("I'm doing research on emotion recognition bias", "ALLOW", "exception: research on"),
("Academic study on social scoring systems", "ALLOW", "exception: academic"),
("Audit for bias in hiring algorithm", "ALLOW", "exception: audit for bias"),
("Compliance monitoring for discrimination", "ALLOW", "exception: compliance monitoring"),
("Ethical review of AI systems", "ALLOW", "exception: ethical review"),
("Analyze customer feedback sentiment", "ALLOW", "exception: customer feedback"),
("Detect emotions in movie scenes", "ALLOW", "exception: movie"),
("Game emotion detection for players", "ALLOW", "exception: game"),
# NO MATCH - identifier only, no block word (34-37)
("How do I score my test results?", "ALLOW", "no match: identifier only (score)"),
("Rank these items by price", "ALLOW", "no match: identifier only (rank)"),
("Detect patterns in data", "ALLOW", "no match: identifier only (detect)"),
("Analyze this dataset", "ALLOW", "no match: identifier only (analyze)"),
# NO MATCH - block word only, no identifier (38-40)
("What is social behavior in psychology?", "ALLOW", "no match: block word only"),
("Tell me about employee emotion theories", "ALLOW", "no match: block word only"),
("Explain trustworthiness as a concept", "ALLOW", "no match: block word only"),
]
@pytest.fixture
def content_filter_guardrail():
"""Initialize content filter guardrail with EU AI Act Article 5 template."""
# Get absolute path to the policy template
import os
content_filter_dir = os.path.join(
os.path.dirname(__file__),
"../../litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter"
)
policy_template_path = os.path.join(
content_filter_dir,
"policy_templates/eu_ai_act_article5.yaml"
)
policy_template_path = os.path.abspath(policy_template_path)
# Load the EU AI Act Article 5 policy template
categories = [
ContentFilterCategoryConfig(
category="eu_ai_act_article5_prohibited_practices",
category_file=policy_template_path,
enabled=True,
action="BLOCK",
severity_threshold="medium",
)
]
guardrail = ContentFilterGuardrail(
guardrail_name="eu-ai-act-test",
categories=categories,
event_hook=litellm.types.guardrails.GuardrailEventHooks.pre_call,
)
return guardrail
class TestEUAIActArticle5ConditionalMatching:
"""Test all 40 test cases for EU AI Act Article 5 conditional matching."""
@pytest.mark.parametrize("sentence,expected,reason", TEST_CASES, ids=[f"test_{i+1}" for i in range(len(TEST_CASES))])
@pytest.mark.asyncio
async def test_sentence(self, content_filter_guardrail, sentence, expected, reason):
"""Test a single sentence against the EU AI Act Article 5 guardrail."""
# Prepare request data
request_data = {
"messages": [{"role": "user", "content": sentence}]
}
# Apply guardrail
if expected == "BLOCK":
# Should raise an exception or return modified response indicating block
with pytest.raises(Exception) as exc_info:
await content_filter_guardrail.apply_guardrail(
inputs={"texts": [sentence]},
request_data=request_data,
input_type="request",
)
# Verify the exception indicates a policy violation
assert "blocked" in str(exc_info.value).lower() or "violation" in str(exc_info.value).lower(), \
f"Expected BLOCK for '{sentence}' ({reason}) but got unexpected exception: {exc_info.value}"
else: # expected == "ALLOW"
# Should not raise an exception
result = await content_filter_guardrail.apply_guardrail(
inputs={"texts": [sentence]},
request_data=request_data,
input_type="request",
)
# Result should be None or unchanged (no violation)
assert result is None or result["texts"][0] == sentence, \
f"Expected ALLOW for '{sentence}' ({reason}) but request was blocked or modified"
@pytest.mark.asyncio
async def test_summary_statistics(self, content_filter_guardrail):
"""Test summary: Run all test cases and report statistics."""
total = len(TEST_CASES)
blocked_count = sum(1 for _, expected, _ in TEST_CASES if expected == "BLOCK")
allowed_count = sum(1 for _, expected, _ in TEST_CASES if expected == "ALLOW")
print(f"\n{'='*60}")
print(f"EU AI Act Article 5 Test Summary")
print(f"{'='*60}")
print(f"Total test cases: {total}")
print(f"Expected BLOCK: {blocked_count} ({blocked_count/total*100:.1f}%)")
print(f"Expected ALLOW: {allowed_count} ({allowed_count/total*100:.1f}%)")
print(f"{'='*60}")
print(f"\nBreakdown by category:")
print(f" Always block keywords: 10")
print(f" Conditional matches: 15")
print(f" Exceptions: 8")
print(f" No matches: 7")
print(f"{'='*60}\n")
# Additional edge case tests
class TestEUAIActEdgeCases:
"""Test edge cases and corner scenarios."""
@pytest.mark.asyncio
async def test_case_insensitive_matching(self, content_filter_guardrail):
"""Test that matching is case-insensitive."""
sentences = [
"Build a SOCIAL CREDIT SYSTEM",
"CREATE AN ALGORITHM TO SCORE PEOPLE BASED ON SOCIAL BEHAVIOR",
]
for sentence in sentences:
request_data = {"messages": [{"role": "user", "content": sentence}]}
with pytest.raises(Exception):
await content_filter_guardrail.apply_guardrail(
inputs={"texts": [sentence]},
request_data=request_data,
input_type="request",
)
@pytest.mark.asyncio
async def test_multiple_violations_in_one_sentence(self, content_filter_guardrail):
"""Test sentence with multiple violations."""
sentence = "Build a social credit system and detect employee emotion"
request_data = {"messages": [{"role": "user", "content": sentence}]}
# Should block (contains multiple violations)
with pytest.raises(Exception):
await content_filter_guardrail.apply_guardrail(
inputs={"texts": [sentence]},
request_data=request_data,
input_type="request",
)
@pytest.mark.asyncio
async def test_exception_overrides_violation(self, content_filter_guardrail):
"""Test that exception overrides a violation match."""
# Contains both violation and exception - exception should win
sentence = "I'm doing research on social credit systems and their impact"
request_data = {"messages": [{"role": "user", "content": sentence}]}
# Should allow (exception takes precedence)
result = await content_filter_guardrail.apply_guardrail(
inputs={"texts": [sentence]},
request_data=request_data,
input_type="request",
)
assert result is None or result["texts"][0] == sentence
class TestEUAIActPerformance:
"""Test performance characteristics."""
@pytest.mark.asyncio
async def test_zero_cost_no_api_calls(self, content_filter_guardrail):
"""Verify no external API calls are made (zero cost)."""
sentence = "Build a social credit system"
request_data = {"messages": [{"role": "user", "content": sentence}]}
# Should not make any HTTP requests
# Just verify the guardrail runs without requiring network
try:
await content_filter_guardrail.apply_guardrail(
inputs={"texts": [sentence]},
request_data=request_data,
input_type="request",
)
except Exception:
pass # Expected to block, but should not require network
# If we got here without network errors, test passes
assert True, "Conditional matching works without network access"
if __name__ == "__main__":
# Run tests with: pytest test_eu_ai_act_article5.py -v
pytest.main([__file__, "-v", "-s"])

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -1167,4 +1167,136 @@ def test_generate_request_base_validator():
# Test with None
req = GenerateRequestBase(max_budget=None)
assert req.max_budget is None
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,
)

View file

@ -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

View file

@ -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"
}
}
}
}

View file

@ -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,
);
});
});

View file

@ -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<UserListResponse>({
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!),
});
};

View file

@ -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<typeof import("antd")>();
@ -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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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(<UsagePage {...defaultProps} />);
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();
});
});
});

View file

@ -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<UsagePageProps> = ({ 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<string>();
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<HTMLDivElement>) => {
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<string | null>(
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<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
<UsageViewSelect
value={usageView}
onChange={(value) => setUsageView(value)}
isAdmin={all_admin_roles.includes(userRole || "")}
isAdmin={isAdmin}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
{/* Your Usage Panel */}
{usageView === "global" && (
<>
<TabGroup>
<div className="flex justify-between items-center">
<TabList variant="solid" className="mt-1">
@ -460,24 +529,61 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content mb-2 mt-2 text-lg">
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",
})}
</>
<div className="flex items-center gap-4 mt-2 mb-2">
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content text-lg">
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",
})}
</>
)}
</Text>
{isAdmin && (
<div className="flex items-center gap-2">
<UserOutlined style={{ fontSize: "14px", color: "#6b7280" }} />
<Select
showSearch
allowClear
style={{ width: 300 }}
placeholder="All Users (Global View)"
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
{selectedUserId && (
<span className="text-xs text-gray-500">
Filtering by user
</span>
)}
</div>
)}
</Text>
</div>
<ViewUserSpend
userSpend={totalSpend}
@ -694,6 +800,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</TabPanel>
</TabPanels>
</TabGroup>
</>
)}
{/* Organization Usage Panel */}

View file

@ -1726,7 +1726,7 @@ const fetchDailyActivity = async ({
}
};
export const userDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date, page: number = 1) => {
export const userDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date, page: number = 1, userId: string | null = null) => {
/**
* Get daily user activity on proxy
*/
@ -1736,6 +1736,9 @@ export const userDailyActivityCall = async (accessToken: string, startTime: Date
startTime,
endTime,
page,
extraQueryParams: {
user_id: userId,
},
});
};
@ -3405,7 +3408,7 @@ export interface User {
[key: string]: string; // Include any other potential keys in the dictionary
}
export const userDailyActivityAggregatedCall = async (accessToken: string, startTime: Date, endTime: Date) => {
export const userDailyActivityAggregatedCall = async (accessToken: string, startTime: Date, endTime: Date, userId: string | null = null) => {
/**
* Get aggregated daily user activity (no pagination)
*/
@ -3423,6 +3426,9 @@ export const userDailyActivityAggregatedCall = async (accessToken: string, start
queryParams.append("end_date", formatDate(endTime));
// Send timezone offset so backend can adjust date range for UTC storage
queryParams.append("timezone", new Date().getTimezoneOffset().toString());
if (userId) {
queryParams.append("user_id", userId);
}
const queryString = queryParams.toString();
if (queryString) {
url += `?${queryString}`;

View file

@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import AdditionalModelSettings from "./AdditionalModelSettings";
@ -47,4 +47,55 @@ describe("AdditionalModelSettings", () => {
expect(temperatureSlider).not.toBeDisabled();
expect(maxTokensSlider).not.toBeDisabled();
});
it("should not show Simulate failure to test fallbacks when onMockTestFallbacksChange is not provided", () => {
render(<AdditionalModelSettings />);
expect(screen.queryByText(/Simulate failure to test fallbacks/i)).not.toBeInTheDocument();
});
it("should show and toggle Simulate failure to test fallbacks when callback is provided", async () => {
const user = userEvent.setup();
const onMockTestFallbacksChange = vi.fn();
let currentValue = false;
const handleChange = (value: boolean) => {
currentValue = value;
onMockTestFallbacksChange(value);
};
const { rerender } = render(
<AdditionalModelSettings
mockTestFallbacks={currentValue}
onMockTestFallbacksChange={handleChange}
/>,
);
const fallbacksCheckbox = screen.getByRole("checkbox", {
name: /Simulate failure to test fallbacks/i,
});
expect(fallbacksCheckbox).toBeInTheDocument();
expect(fallbacksCheckbox).not.toBeChecked();
await act(async () => {
await user.click(fallbacksCheckbox);
});
await waitFor(() => {
expect(onMockTestFallbacksChange).toHaveBeenCalledWith(true);
});
rerender(
<AdditionalModelSettings
mockTestFallbacks={currentValue}
onMockTestFallbacksChange={handleChange}
/>,
);
await act(async () => {
await user.click(screen.getByRole("checkbox", { name: /Simulate failure to test fallbacks/i }));
});
await waitFor(() => {
expect(onMockTestFallbacksChange).toHaveBeenCalledWith(false);
});
});
});

View file

@ -1,6 +1,6 @@
import { InfoCircleOutlined } from "@ant-design/icons";
import { Text } from "@tremor/react";
import { Checkbox, InputNumber, Slider, Tooltip } from "antd";
import { Checkbox, InputNumber, Popover, Slider, Tooltip, Typography } from "antd";
import React, { useEffect, useState } from "react";
interface AdditionalModelSettingsProps {
@ -10,6 +10,8 @@ interface AdditionalModelSettingsProps {
onTemperatureChange?: (value: number) => void;
onMaxTokensChange?: (value: number) => void;
onUseAdvancedParamsChange?: (value: boolean) => void;
mockTestFallbacks?: boolean;
onMockTestFallbacksChange?: (value: boolean) => void;
}
const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
@ -19,6 +21,8 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
onTemperatureChange,
onMaxTokensChange,
onUseAdvancedParamsChange,
mockTestFallbacks,
onMockTestFallbacksChange,
}) => {
const [internalUseAdvancedParams, setInternalUseAdvancedParams] = useState(false);
const useAdvancedParams =
@ -64,6 +68,45 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
<span className="font-medium">Use Advanced Parameters</span>
</Checkbox>
{onMockTestFallbacksChange && (
<div className="flex items-center gap-1">
<Checkbox
checked={mockTestFallbacks ?? false}
onChange={(e) => onMockTestFallbacksChange(e.target.checked)}
>
<span className="font-medium">Simulate failure to test fallbacks</span>
</Checkbox>
<Popover
trigger="hover"
placement="right"
content={
<div style={{ maxWidth: 340 }}>
<Typography.Paragraph className="text-sm" style={{ marginBottom: 8 }}>
Causes the first request to fail so the router tries fallbacks (if configured). Use
this to verify your fallback setup.
</Typography.Paragraph>
<Typography.Paragraph className="text-sm" style={{ marginBottom: 0 }}>
Behavior can differ when keys, teams, or router settings are configured.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
Learn more
</a>
</Typography.Paragraph>
</div>
}
>
<InfoCircleOutlined
className="text-xs text-gray-400 cursor-pointer shrink-0 hover:text-gray-600"
aria-label="Help: Simulate failure to test fallbacks"
/>
</Popover>
</div>
)}
<div className="space-y-4 transition-opacity duration-200" style={{ opacity: disabledOpacity }}>
<div>
<div className="flex items-center justify-between mb-2">

View file

@ -271,6 +271,70 @@ describe("ChatUI", () => {
});
});
it("should show Simulate failure to test fallbacks in Model Settings when chat endpoint is selected", async () => {
render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(screen.getByText("Test Key")).toBeInTheDocument();
});
// Model Settings button only appears when a chat model is selected; select "Model 1" first
const selectModelLabel = screen.getByText("Select Model");
const modelSelectContainer = selectModelLabel.closest("div");
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
expect(modelSelect).toBeTruthy();
await act(async () => {
fireEvent.mouseDown(modelSelect!);
});
await waitFor(() => {
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
});
// Ant Design Select options may not have role="option"; click the dropdown option by text
const model1Options = screen.getAllByText("Model 1");
await act(async () => {
fireEvent.click(model1Options[model1Options.length - 1]);
});
await waitFor(() => {
const modelSettingsButton = screen.getByTestId("model-settings-button");
expect(modelSettingsButton).toBeInTheDocument();
});
const modelSettingsButton = screen.getByTestId("model-settings-button");
await act(async () => {
fireEvent.click(modelSettingsButton);
});
await waitFor(() => {
expect(screen.getByText("Model Settings")).toBeInTheDocument();
expect(screen.getByText(/Simulate failure to test fallbacks/i)).toBeInTheDocument();
});
const fallbacksCheckbox = screen.getByRole("checkbox", {
name: /Simulate failure to test fallbacks/i,
});
expect(fallbacksCheckbox).not.toBeChecked();
await act(async () => {
fireEvent.click(fallbacksCheckbox);
});
await waitFor(() => {
expect(screen.getByRole("checkbox", { name: /Simulate failure to test fallbacks/i })).toBeChecked();
});
});
it("should show Fill button and populate customProxyBaseUrl when proxySettings.LITELLM_UI_API_DOC_BASE_URL is provided", async () => {
const testProxyUrl = "http://localhost:5000";

View file

@ -229,6 +229,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
const [temperature, setTemperature] = useState<number>(1.0);
const [maxTokens, setMaxTokens] = useState<number>(2048);
const [useAdvancedParams, setUseAdvancedParams] = useState<boolean>(false);
const [mockTestFallbacks, setMockTestFallbacks] = useState<boolean>(false);
// Code Interpreter state (using custom hook)
const codeInterpreter = useCodeInterpreter();
@ -982,6 +983,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
mcpServers,
mcpServerToolRestrictions,
handleMCPEvent,
mockTestFallbacks,
);
} else if (endpointType === EndpointType.IMAGE) {
// For image generation
@ -1401,6 +1403,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
onTemperatureChange={setTemperature}
onMaxTokensChange={setMaxTokens}
onUseAdvancedParamsChange={setUseAdvancedParams}
mockTestFallbacks={mockTestFallbacks}
onMockTestFallbacksChange={setMockTestFallbacks}
/>
}
title="Model Settings"
@ -1412,6 +1416,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
size="small"
icon={<SettingOutlined />}
className="text-gray-500 hover:text-gray-700"
aria-label="Model Settings"
data-testid="model-settings-button"
/>
</Popover>
) : (
@ -2390,7 +2396,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
</Card>
<Modal
title="Generated Code"
visible={isGetCodeModalVisible}
open={isGetCodeModalVisible}
onCancel={() => setIsGetCodeModalVisible(false)}
footer={null}
width={800}

View file

@ -190,4 +190,70 @@ describe("chat_completion", () => {
expect(secondTool.require_approval).toBe("never");
expect(secondTool.allowed_tools).toEqual(["toolC"]);
});
it("should include mock_testing_fallbacks in request body when mockTestFallbacks is true", async () => {
await makeOpenAIChatCompletionRequest(
mockChatHistory,
mockUpdateUI,
"gpt-4",
"test-token",
undefined, // tags
undefined, // signal
undefined, // onReasoningContent
undefined, // onTimingData
undefined, // onUsageData
undefined, // traceId
undefined, // vector_store_ids
undefined, // guardrails
undefined, // policies
undefined, // selectedMCPServers
undefined, // onImageGenerated
undefined, // onSearchResults
undefined, // temperature
undefined, // max_tokens
undefined, // onTotalLatency
undefined, // customBaseUrl
undefined, // mcpServers
undefined, // mcpServerToolRestrictions
undefined, // onMCPEvent
true, // mockTestFallbacks
);
expect(mockCreate).toHaveBeenCalledTimes(1);
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.mock_testing_fallbacks).toBe(true);
});
it("should not include mock_testing_fallbacks in request body when mockTestFallbacks is false or undefined", async () => {
await makeOpenAIChatCompletionRequest(
mockChatHistory,
mockUpdateUI,
"gpt-4",
"test-token",
undefined, // tags
undefined, // signal
undefined, // onReasoningContent
undefined, // onTimingData
undefined, // onUsageData
undefined, // traceId
undefined, // vector_store_ids
undefined, // guardrails
undefined, // policies
undefined, // selectedMCPServers
undefined, // onImageGenerated
undefined, // onSearchResults
undefined, // temperature
undefined, // max_tokens
undefined, // onTotalLatency
undefined, // customBaseUrl
undefined, // mcpServers
undefined, // mcpServerToolRestrictions
undefined, // onMCPEvent
false, // mockTestFallbacks
);
expect(mockCreate).toHaveBeenCalledTimes(1);
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs).not.toHaveProperty("mock_testing_fallbacks");
});
});

View file

@ -30,6 +30,7 @@ export async function makeOpenAIChatCompletionRequest(
mcpServers?: MCPServer[],
mcpServerToolRestrictions?: Record<string, string[]>,
onMCPEvent?: (event: MCPEvent) => void,
mockTestFallbacks?: boolean,
) {
// base url should be the current base_url
const isLocal = process.env.NODE_ENV === "development";
@ -115,6 +116,7 @@ export async function makeOpenAIChatCompletionRequest(
...(tools.length > 0 ? { tools, tool_choice: "auto" } : {}),
...(temperature !== undefined ? { temperature } : {}),
...(max_tokens !== undefined ? { max_tokens } : {}),
...(mockTestFallbacks ? { mock_testing_fallbacks: true } : {}),
},
{ signal },
);

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Card, Button, Spin, message } from "antd";
import React, { useState, useEffect, useMemo } from "react";
import { Card, Button, Spin, message, Radio } from "antd";
import {
ShieldCheckIcon,
ShieldExclamationIcon,
@ -116,6 +116,17 @@ const iconMap: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>
const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({ onUseTemplate, accessToken }) => {
const [templates, setTemplates] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [selectedRegion, setSelectedRegion] = useState<string>("All");
const availableRegions = useMemo(() => {
const regions = new Set(templates.map(t => t.region || "Global"));
return ["All", ...Array.from(regions).sort()];
}, [templates]);
const filteredTemplates = useMemo(() => {
if (selectedRegion === "All") return templates;
return templates.filter(t => (t.region || "Global") === selectedRegion);
}, [templates, selectedRegion]);
useEffect(() => {
const fetchTemplates = async () => {
@ -158,8 +169,23 @@ const PolicyTemplates: React.FC<PolicyTemplatesProps> = ({ onUseTemplate, access
</div>
</div>
<div className="flex items-center gap-3 mb-4">
<span className="text-sm font-medium text-gray-700">Region:</span>
<Radio.Group
value={selectedRegion}
onChange={(e) => setSelectedRegion(e.target.value)}
buttonStyle="solid"
>
{availableRegions.map(region => (
<Radio.Button key={region} value={region}>
{region}
</Radio.Button>
))}
</Radio.Group>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{templates.map((template, index) => (
{filteredTemplates.map((template, index) => (
<PolicyTemplateCard
key={template.id || index}
title={template.title}