mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
commit
5ce2e8a7a4
60 changed files with 2916 additions and 268 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
257
tests/guardrails_tests/test_eu_ai_act_article5.py
Normal file
257
tests/guardrails_tests/test_eu_ai_act_article5.py
Normal 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"])
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
215
ui/litellm-dashboard/package-lock.json
generated
215
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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!),
|
||||
});
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue