mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-09 14:22:12 +00:00
Merge branch 'main' into litellm_oss_staging_02_16_2026
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
name: Test Proxy SERVER_ROOT_PATH Routing
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test-server-root-path:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
root_path: ["/api/v1", "/llmproxy"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.database
|
||||
tags: litellm-test:${{ github.sha }}
|
||||
load: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Start LiteLLM container with SERVER_ROOT_PATH
|
||||
run: |
|
||||
docker run -d \
|
||||
--name litellm-test \
|
||||
-p 4000:4000 \
|
||||
-e SERVER_ROOT_PATH="${{ matrix.root_path }}" \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
litellm-test:${{ github.sha }} \
|
||||
--detailed_debug
|
||||
|
||||
- name: Wait for container to be healthy
|
||||
run: |
|
||||
echo "Waiting for LiteLLM to start..."
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if docker logs litellm-test 2>&1 | grep -q "Uvicorn running"; then
|
||||
echo "LiteLLM started successfully"
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
echo "Attempt $attempt/$max_attempts - waiting for server to start..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "Server failed to start within timeout"
|
||||
docker logs litellm-test
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
|
||||
- name: Show container logs
|
||||
if: always()
|
||||
run: docker logs litellm-test
|
||||
|
||||
- name: Test UI endpoint with root path
|
||||
run: |
|
||||
ROOT_PATH="${{ matrix.root_path }}"
|
||||
echo "Testing UI at: http://localhost:4000${ROOT_PATH}/ui/"
|
||||
|
||||
for i in 1 2 3; do
|
||||
content=$(curl -sL --max-time 5 -H "Authorization: Bearer sk-1234" "http://localhost:4000${ROOT_PATH}/ui/")
|
||||
if echo "$content" | grep -q -E "(html|<!DOCTYPE|<head|<body)"; then
|
||||
echo "UI page contains valid HTML content"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/3 - no valid HTML, retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo "UI page does not contain expected HTML content"
|
||||
echo "Response: $content"
|
||||
docker logs litellm-test
|
||||
exit 1
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker stop litellm-test || true
|
||||
docker rm litellm-test || true
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
slug: claude_code_beta_headers
|
||||
title: "Claude Code - Managing Anthropic Beta Headers"
|
||||
slug: claude-code-beta-headers-incident
|
||||
title: "Incident Report: Invalid beta headers with Claude Code"
|
||||
date: 2026-02-16T10:00:00
|
||||
authors:
|
||||
- name: Sameer Kankute
|
||||
@@ -15,260 +15,161 @@ authors:
|
||||
title: "CEO, LiteLLM"
|
||||
url: https://www.linkedin.com/in/krish-d/
|
||||
image_url: https://pbs.twimg.com/profile_images/1298587542745358340/DZv3Oj-h_400x400.jpg
|
||||
description: "How to manage and configure Anthropic beta headers with Claude Code in LiteLLM: filtering, mapping, and dynamic updates across providers."
|
||||
tags: [anthropic, claude, beta headers, configuration, liteLLM]
|
||||
tags: [incident-report, anthropic, stability]
|
||||
hide_table_of_contents: false
|
||||
---
|
||||
|
||||
**Date:** February 13, 2026
|
||||
**Duration:** ~3 hours
|
||||
**Severity:** High
|
||||
**Status:** Resolved
|
||||
|
||||
## Summary
|
||||
|
||||
Claude Code began sending unsupported Anthropic beta headers to non-Anthropic providers (Bedrock, Azure AI, Vertex AI), causing `invalid beta flag` errors. LiteLLM was forwarding all beta headers without provider-specific validation. Users experienced request failures when routing Claude Code requests through LiteLLM to these providers.
|
||||
|
||||
- **LLM calls to Anthropic:** No impact.
|
||||
- **LLM calls to Bedrock/Azure/Vertex:** Failed with `invalid beta flag` errors when unsupported headers were present.
|
||||
- **Cost tracking and routing:** No impact.
|
||||
|
||||
{/* truncate */}
|
||||
|
||||
---
|
||||
import Image from '@theme/IdealImage';
|
||||
|
||||
When using Claude Code with LiteLLM and non-Anthropic providers (Bedrock, Azure AI, Vertex AI), you need to ensure that only supported beta headers are sent to each provider. This guide explains how to add support for new beta headers or fix invalid beta header errors.
|
||||
## Background
|
||||
|
||||
## What Are Beta Headers?
|
||||
Anthropic uses beta headers to enable experimental features in Claude. When Claude Code makes API requests, it includes headers like `anthropic-beta: prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20`. However, not all providers support all Anthropic beta features.
|
||||
|
||||
Anthropic uses beta headers to enable experimental features in Claude. When you use Claude Code, it may send beta headers like:
|
||||
|
||||
```
|
||||
anthropic-beta: prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20
|
||||
```
|
||||
|
||||
However, not all providers support all Anthropic beta features. LiteLLM uses `anthropic_beta_headers_config.json` to manage which beta headers are supported by each provider.
|
||||
|
||||
## Common Error Message
|
||||
|
||||
```bash
|
||||
Error: The model returned the following errors: invalid beta flag
|
||||
```
|
||||
|
||||
## How LiteLLM Handles Beta Headers
|
||||
|
||||
LiteLLM uses a strict validation approach with a configuration file:
|
||||
|
||||
```
|
||||
litellm/litellm/anthropic_beta_headers_config.json
|
||||
```
|
||||
|
||||
This JSON file contains a **mapping** of beta headers for each provider:
|
||||
- **Keys**: Input beta header names (from Anthropic)
|
||||
- **Values**: Provider-specific header names (or `null` if unsupported)
|
||||
- **Validation**: Only headers present in the mapping with non-null values are forwarded
|
||||
|
||||
This enforces stricter validation than just filtering unsupported headers - headers must be explicitly defined to be allowed.
|
||||
|
||||
## Adding Support for a New Beta Header
|
||||
|
||||
When Anthropic releases a new beta feature, you need to add it to the configuration file for each provider.
|
||||
|
||||
### Step 1: Add the New Beta Header
|
||||
|
||||
Open `anthropic_beta_headers_config.json` and add the new header to each provider's mapping:
|
||||
|
||||
```json title="anthropic_beta_headers_config.json"
|
||||
{
|
||||
"description": "Mapping of Anthropic beta headers for each provider. Keys are input header names, values are provider-specific header names (or null if unsupported). Only headers present in mapping keys with non-null values can be forwarded.",
|
||||
"anthropic": {
|
||||
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
|
||||
"new-feature-2026-03-01": "new-feature-2026-03-01",
|
||||
...
|
||||
},
|
||||
"azure_ai": {
|
||||
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
|
||||
"new-feature-2026-03-01": "new-feature-2026-03-01",
|
||||
...
|
||||
},
|
||||
"bedrock_converse": {
|
||||
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
|
||||
"new-feature-2026-03-01": null,
|
||||
...
|
||||
},
|
||||
"bedrock": {
|
||||
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
|
||||
"new-feature-2026-03-01": null,
|
||||
...
|
||||
},
|
||||
"vertex_ai": {
|
||||
"advanced-tool-use-2025-11-20": "tool-search-tool-2025-10-19",
|
||||
"new-feature-2026-03-01": null,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- **Supported headers**: Set the value to the provider-specific header name (often the same as the key)
|
||||
- **Unsupported headers**: Set the value to `null`
|
||||
- **Header transformations**: Some providers use different header names (e.g., Bedrock maps `advanced-tool-use-2025-11-20` to `tool-search-tool-2025-10-19`)
|
||||
- **Alphabetical order**: Keep headers sorted alphabetically for maintainability
|
||||
|
||||
### Step 2: Reload Configuration (No Restart Required!)
|
||||
|
||||
**Option 1: Dynamic Reload Without Restart**
|
||||
|
||||
Instead of restarting your application, you can dynamically reload the beta headers configuration using environment variables and API endpoints:
|
||||
|
||||
```bash
|
||||
# Set environment variable to fetch from remote URL (Do this if you want to point it to some other URL)
|
||||
export LITELLM_ANTHROPIC_BETA_HEADERS_URL="https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/anthropic_beta_headers_config.json"
|
||||
|
||||
# Manually trigger reload via API (no restart needed!)
|
||||
curl -X POST "https://your-proxy-url/reload/anthropic_beta_headers" \
|
||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
**Option 2: Schedule Automatic Reloads**
|
||||
|
||||
Set up automatic reloading to always stay up-to-date with the latest beta headers:
|
||||
|
||||
```bash
|
||||
# Reload configuration every 24 hours
|
||||
curl -X POST "https://your-proxy-url/schedule/anthropic_beta_headers_reload?hours=24" \
|
||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
**Option 3: Traditional Restart**
|
||||
|
||||
If you prefer the traditional approach, restart your LiteLLM proxy or application:
|
||||
|
||||
```bash
|
||||
# If using LiteLLM proxy
|
||||
litellm --config config.yaml
|
||||
|
||||
# If using Python SDK
|
||||
# Just restart your Python application
|
||||
```
|
||||
|
||||
:::tip Zero-Downtime Updates
|
||||
With dynamic reloading, you can fix invalid beta header errors **without restarting your service**! This is especially useful in production environments where downtime is costly.
|
||||
|
||||
See [Auto Sync Anthropic Beta Headers](../proxy/sync_anthropic_beta_headers.md) for complete documentation.
|
||||
:::
|
||||
|
||||
## Fixing Invalid Beta Header Errors
|
||||
|
||||
If you encounter an "invalid beta flag" error, it means a beta header is being sent that the provider doesn't support.
|
||||
|
||||
### Step 1: Identify the Problematic Header
|
||||
|
||||
Check your logs to see which header is causing the issue:
|
||||
|
||||
```bash
|
||||
Error: The model returned the following errors: invalid beta flag: new-feature-2026-03-01
|
||||
```
|
||||
|
||||
### Step 2: Update the Config
|
||||
|
||||
Set the header value to `null` for that provider:
|
||||
|
||||
```json title="anthropic_beta_headers_config.json"
|
||||
{
|
||||
"bedrock_converse": {
|
||||
"new-feature-2026-03-01": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Restart and Test
|
||||
|
||||
Restart your application and verify the header is now filtered out.
|
||||
|
||||
## Contributing a Fix to LiteLLM
|
||||
|
||||
Help the community by contributing your fix!
|
||||
|
||||
### What to Include in Your PR
|
||||
|
||||
1. **Update the config file**: Add the new beta header to `litellm/anthropic_beta_headers_config.json`
|
||||
2. **Test your changes**: Verify the header is correctly filtered/mapped for each provider
|
||||
3. **Documentation**: Include provider documentation links showing which headers are supported
|
||||
|
||||
### Example PR Description
|
||||
|
||||
```markdown
|
||||
## Add support for new-feature-2026-03-01 beta header
|
||||
|
||||
### Changes
|
||||
- Added `new-feature-2026-03-01` to anthropic_beta_headers_config.json
|
||||
- Set to `null` for bedrock_converse (unsupported)
|
||||
- Set to header name for anthropic, azure_ai (supported)
|
||||
|
||||
### Testing
|
||||
Tested with:
|
||||
- ✅ Anthropic: Header passed through correctly
|
||||
- ✅ Azure AI: Header passed through correctly
|
||||
- ✅ Bedrock Converse: Header filtered out (returns error without fix)
|
||||
|
||||
### References
|
||||
- Anthropic docs: [link]
|
||||
- AWS Bedrock docs: [link]
|
||||
```
|
||||
|
||||
|
||||
## How Beta Header Filtering Works
|
||||
|
||||
When you make a request through LiteLLM:
|
||||
Before this incident, LiteLLM forwarded all beta headers to all providers without validation:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CC as Claude Code
|
||||
participant LP as LiteLLM
|
||||
participant Config as Beta Headers Config
|
||||
participant Provider as Provider (Bedrock/Azure/etc)
|
||||
participant LP as LiteLLM (old behavior)
|
||||
participant Provider as Provider (Bedrock/Azure/Vertex)
|
||||
|
||||
CC->>LP: Request with beta headers
|
||||
Note over CC,LP: anthropic-beta: header1,header2,header3
|
||||
|
||||
|
||||
LP->>Provider: Forward ALL headers (no validation)
|
||||
Note over LP,Provider: anthropic-beta: header1,header2,header3
|
||||
|
||||
Provider-->>LP: ❌ Error: invalid beta flag
|
||||
LP-->>CC: Request fails
|
||||
```
|
||||
|
||||
Requests succeeded for Anthropic (native support) but failed for other providers when Claude Code sent headers those providers didn't support.
|
||||
|
||||
---
|
||||
|
||||
## Root cause
|
||||
|
||||
LiteLLM lacked provider-specific beta header validation. When Claude Code introduced new beta features or sent headers that specific providers didn't support, those headers were blindly forwarded, causing provider API errors.
|
||||
|
||||
---
|
||||
|
||||
## Remediation
|
||||
|
||||
| # | Action | Status | Code |
|
||||
|---|---|---|---|
|
||||
| 1 | Create `anthropic_beta_headers_config.json` with provider-specific mappings | ✅ Done | [`anthropic_beta_headers_config.json`](https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json) |
|
||||
| 2 | Implement strict validation: headers must be explicitly mapped to be forwarded | ✅ Done | [`litellm_logging.py`](https://github.com/BerriAI/litellm/blob/main/litellm/litellm_core_utils/litellm_logging.py) |
|
||||
| 3 | Add `/reload/anthropic_beta_headers` endpoint for dynamic config updates | ✅ Done | Proxy management endpoints |
|
||||
| 4 | Add `/schedule/anthropic_beta_headers_reload` for automatic periodic updates | ✅ Done | Proxy management endpoints |
|
||||
| 5 | Support `LITELLM_ANTHROPIC_BETA_HEADERS_URL` for custom config sources | ✅ Done | Environment configuration |
|
||||
| 6 | Support `LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS` for air-gapped deployments | ✅ Done | Environment configuration |
|
||||
|
||||
Now LiteLLM validates and transforms headers per-provider:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CC as Claude Code
|
||||
participant LP as LiteLLM (new behavior)
|
||||
participant Config as Beta Headers Config
|
||||
participant Provider as Provider (Bedrock/Azure/Vertex)
|
||||
|
||||
CC->>LP: Request with beta headers
|
||||
Note over CC,LP: anthropic-beta: header1,header2,header3
|
||||
|
||||
LP->>Config: Load header mapping for provider
|
||||
Config-->>LP: Returns mapping (header→value or null)
|
||||
|
||||
|
||||
Note over LP: Validate & Transform:<br/>1. Check if header exists in mapping<br/>2. Filter out null values<br/>3. Map to provider-specific names
|
||||
|
||||
|
||||
LP->>Provider: Request with filtered & mapped headers
|
||||
Note over LP,Provider: anthropic-beta: mapped-header2<br/>(header1, header3 filtered out)
|
||||
|
||||
Provider-->>LP: Success response
|
||||
|
||||
Provider-->>LP: ✅ Success response
|
||||
LP-->>CC: Response
|
||||
```
|
||||
|
||||
### Filtering Rules
|
||||
---
|
||||
|
||||
1. **Header must exist in mapping**: Unknown headers are filtered out
|
||||
2. **Header must have non-null value**: Headers with `null` values are filtered out
|
||||
3. **Header transformation**: Headers are mapped to provider-specific names (e.g., `advanced-tool-use-2025-11-20` → `tool-search-tool-2025-10-19` for Bedrock)
|
||||
## Dynamic configuration updates
|
||||
|
||||
### Example
|
||||
A key improvement is zero-downtime configuration updates. When Anthropic releases new beta features, users can update their configuration without restarting:
|
||||
|
||||
Request with headers:
|
||||
```
|
||||
anthropic-beta: advanced-tool-use-2025-11-20,computer-use-2025-01-24,unknown-header
|
||||
```
|
||||
|
||||
For Bedrock Converse:
|
||||
- ✅ `computer-use-2025-01-24` → `computer-use-2025-01-24` (supported, passed through)
|
||||
- ❌ `advanced-tool-use-2025-11-20` → filtered out (null value in config)
|
||||
- ❌ `unknown-header` → filtered out (not in config)
|
||||
|
||||
Result sent to Bedrock:
|
||||
```
|
||||
anthropic-beta: computer-use-2025-01-24
|
||||
```
|
||||
|
||||
## Dynamic Configuration Management (No Restart Required!)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Control how LiteLLM loads the beta headers configuration:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `LITELLM_ANTHROPIC_BETA_HEADERS_URL` | URL to fetch config from | GitHub main branch |
|
||||
| `LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS` | Set to `True` to use local config only | `False` |
|
||||
|
||||
**Example: Use Custom Config URL**
|
||||
```bash
|
||||
export LITELLM_ANTHROPIC_BETA_HEADERS_URL="https://your-company.com/custom-beta-headers.json"
|
||||
# Manually trigger reload (no restart needed)
|
||||
curl -X POST "https://your-proxy-url/reload/anthropic_beta_headers" \
|
||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
||||
|
||||
# Or schedule automatic reloads every 24 hours
|
||||
curl -X POST "https://your-proxy-url/schedule/anthropic_beta_headers_reload?hours=24" \
|
||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
**Example: Use Local Config Only (No Remote Fetching)**
|
||||
```bash
|
||||
export LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS=True
|
||||
This prevents future incidents where Claude Code introduces new headers before LiteLLM configuration is updated.
|
||||
|
||||
---
|
||||
|
||||
## Configuration format
|
||||
|
||||
The `anthropic_beta_headers_config.json` file maps input headers to provider-specific output headers:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Mapping of Anthropic beta headers for each provider.",
|
||||
"anthropic": {
|
||||
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
|
||||
"computer-use-2025-01-24": "computer-use-2025-01-24"
|
||||
},
|
||||
"bedrock_converse": {
|
||||
"advanced-tool-use-2025-11-20": null,
|
||||
"computer-use-2025-01-24": "computer-use-2025-01-24"
|
||||
},
|
||||
"azure_ai": {
|
||||
"advanced-tool-use-2025-11-20": "advanced-tool-use-2025-11-20",
|
||||
"computer-use-2025-01-24": "computer-use-2025-01-24"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation rules:**
|
||||
1. Headers must exist in the mapping for the target provider
|
||||
2. Headers with `null` values are filtered out (unsupported)
|
||||
3. Header names can be transformed per-provider (e.g., Bedrock uses different names for some features)
|
||||
|
||||
---
|
||||
|
||||
## Resolution steps for users
|
||||
|
||||
For users still experiencing issues, update to the latest LiteLLM version if < v1.81.11-nightly:
|
||||
|
||||
```bash
|
||||
pip install --upgrade litellm
|
||||
```
|
||||
|
||||
Or manually reload the configuration without restarting:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://your-proxy-url/reload/anthropic_beta_headers" \
|
||||
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Managing Anthropic Beta Headers](../proxy/sync_anthropic_beta_headers.md) - Complete configuration guide
|
||||
- [`anthropic_beta_headers_config.json`](https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json) - Current configuration file
|
||||
|
||||
@@ -450,6 +450,7 @@ router_settings:
|
||||
| BATCH_STATUS_POLL_INTERVAL_SECONDS | Interval in seconds for polling batch status. Default is 3600 (1 hour)
|
||||
| BATCH_STATUS_POLL_MAX_ATTEMPTS | Maximum number of attempts for polling batch status. Default is 24 (for 24 hours)
|
||||
| BEDROCK_MAX_POLICY_SIZE | Maximum size for Bedrock policy. Default is 75
|
||||
| BEDROCK_MIN_THINKING_BUDGET_TOKENS | Minimum thinking budget in tokens for Bedrock reasoning models. Bedrock returns a 400 error if budget_tokens is below this value. Requests with lower values are clamped to this minimum. Default is 1024
|
||||
| BERRISPEND_ACCOUNT_ID | Account ID for BerriSpend service
|
||||
| BRAINTRUST_API_KEY | API key for Braintrust integration
|
||||
| BRAINTRUST_API_BASE | Base URL for Braintrust API. Default is https://api.braintrustdata.com/v1
|
||||
|
||||
@@ -26,6 +26,7 @@ from litellm.types.utils import (
|
||||
CallTypes,
|
||||
GenericGuardrailAPIInputs,
|
||||
GuardrailStatus,
|
||||
GuardrailTracingDetail,
|
||||
LLMResponseTypes,
|
||||
StandardLoggingGuardrailInformation,
|
||||
)
|
||||
@@ -520,9 +521,15 @@ class CustomGuardrail(CustomLogger):
|
||||
masked_entity_count: Optional[Dict[str, int]] = None,
|
||||
guardrail_provider: Optional[str] = None,
|
||||
event_type: Optional[GuardrailEventHooks] = None,
|
||||
tracing_detail: Optional[GuardrailTracingDetail] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Builds `StandardLoggingGuardrailInformation` and adds it to the request metadata so it can be used for logging to DataDog, Langfuse, etc.
|
||||
|
||||
Args:
|
||||
tracing_detail: Optional typed dict with provider-specific tracing fields
|
||||
(guardrail_id, policy_template, detection_method, confidence_score,
|
||||
classification, match_details, patterns_checked, alert_recipients).
|
||||
"""
|
||||
if isinstance(guardrail_json_response, Exception):
|
||||
guardrail_json_response = str(guardrail_json_response)
|
||||
@@ -559,6 +566,7 @@ class CustomGuardrail(CustomLogger):
|
||||
end_time=end_time,
|
||||
duration=duration,
|
||||
masked_entity_count=masked_entity_count,
|
||||
**(tracing_detail or {}),
|
||||
)
|
||||
|
||||
def _append_guardrail_info(container: dict) -> None:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+119
-10
@@ -10,8 +10,19 @@ import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import (TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Literal,
|
||||
Optional, Pattern, Tuple, Union, cast)
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Pattern,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
import yaml
|
||||
from fastapi import HTTPException
|
||||
@@ -20,18 +31,26 @@ from litellm import Router
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
from litellm.types.utils import GuardrailTracingDetail, ModelResponseStream
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus
|
||||
|
||||
from litellm.types.guardrails import (BlockedWord, ContentFilterAction,
|
||||
ContentFilterPattern,
|
||||
GuardrailEventHooks, Mode)
|
||||
from litellm.types.guardrails import (
|
||||
BlockedWord,
|
||||
ContentFilterAction,
|
||||
ContentFilterPattern,
|
||||
GuardrailEventHooks,
|
||||
Mode,
|
||||
)
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
|
||||
BlockedWordDetection, CategoryKeywordDetection,
|
||||
ContentFilterCategoryConfig, ContentFilterDetection, PatternDetection)
|
||||
BlockedWordDetection,
|
||||
CategoryKeywordDetection,
|
||||
ContentFilterCategoryConfig,
|
||||
ContentFilterDetection,
|
||||
PatternDetection,
|
||||
)
|
||||
|
||||
from .patterns import PATTERN_EXTRA_CONFIG, get_compiled_pattern
|
||||
|
||||
@@ -114,6 +133,8 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
def __init__(
|
||||
self,
|
||||
guardrail_name: Optional[str] = None,
|
||||
guardrail_id: Optional[str] = None,
|
||||
policy_template: Optional[str] = None,
|
||||
patterns: Optional[List[ContentFilterPattern]] = None,
|
||||
blocked_words: Optional[List[BlockedWord]] = None,
|
||||
blocked_words_file: Optional[str] = None,
|
||||
@@ -158,6 +179,8 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
)
|
||||
|
||||
self.guardrail_provider = "litellm_content_filter"
|
||||
self.config_guardrail_id = guardrail_id
|
||||
self.config_policy_template = policy_template
|
||||
self.pattern_redaction_format = (
|
||||
pattern_redaction_format or self.PATTERN_REDACTION_FORMAT
|
||||
)
|
||||
@@ -1308,6 +1331,83 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
masked_entity_count.get(category, 0) + 1
|
||||
)
|
||||
|
||||
def _build_match_details(
|
||||
self, detections: List[ContentFilterDetection]
|
||||
) -> List[dict]:
|
||||
"""Build match_details list from content filter detections."""
|
||||
match_details: List[dict] = []
|
||||
for detection in detections:
|
||||
detail: dict = {"type": detection["type"], "action_taken": detection["action"]}
|
||||
if detection["type"] == "pattern":
|
||||
detail["detection_method"] = "regex"
|
||||
detail["snippet"] = cast(PatternDetection, detection).get("pattern_name", "")
|
||||
elif detection["type"] == "blocked_word":
|
||||
detail["detection_method"] = "keyword"
|
||||
detail["snippet"] = cast(BlockedWordDetection, detection).get("keyword", "")
|
||||
elif detection["type"] == "category_keyword":
|
||||
detail["detection_method"] = "keyword"
|
||||
cat_det = cast(CategoryKeywordDetection, detection)
|
||||
detail["snippet"] = cat_det.get("keyword", "")
|
||||
detail["category"] = cat_det.get("category", "")
|
||||
match_details.append(detail)
|
||||
return match_details
|
||||
|
||||
def _get_detection_methods(self, detections: List[ContentFilterDetection]) -> str:
|
||||
"""Get comma-separated detection methods used."""
|
||||
methods: set = set()
|
||||
for detection in detections:
|
||||
if detection["type"] == "pattern":
|
||||
methods.add("regex")
|
||||
else:
|
||||
methods.add("keyword")
|
||||
return ",".join(sorted(methods)) if methods else ""
|
||||
|
||||
def _get_patterns_checked_count(self) -> int:
|
||||
"""Get total number of patterns and keywords that were evaluated."""
|
||||
return len(self.compiled_patterns) + len(self.blocked_words) + len(self.category_keywords)
|
||||
|
||||
def _get_policy_templates(self) -> Optional[str]:
|
||||
"""Get comma-separated policy template names from loaded categories."""
|
||||
if not self.loaded_categories:
|
||||
return None
|
||||
names = [cat.description or cat.category_name for cat in self.loaded_categories.values()]
|
||||
return ", ".join(names) if names else None
|
||||
|
||||
def _compute_risk_score(
|
||||
self,
|
||||
detections: List[ContentFilterDetection],
|
||||
masked_entity_count: Dict[str, int],
|
||||
status: "GuardrailStatus",
|
||||
) -> float:
|
||||
"""
|
||||
Compute a risk score from 0-10 for this guardrail evaluation.
|
||||
|
||||
Factors:
|
||||
- Match ratio: how many patterns matched vs total checked
|
||||
- Number of entities masked
|
||||
- Whether the guardrail blocked the request (max risk)
|
||||
"""
|
||||
if status == "guardrail_intervened":
|
||||
return 10.0
|
||||
|
||||
total_masked = sum(masked_entity_count.values()) if masked_entity_count else 0
|
||||
patterns_checked = self._get_patterns_checked_count()
|
||||
|
||||
# Match ratio contribution (0-7 points)
|
||||
match_ratio = total_masked / patterns_checked if patterns_checked > 0 else 0.0
|
||||
ratio_score = match_ratio * 7.0
|
||||
|
||||
# Detection count contribution (0-3 points, capped)
|
||||
detection_score = min(len(detections), 5) * 0.6
|
||||
|
||||
score = ratio_score + detection_score
|
||||
|
||||
# Floor: if anything matched, minimum risk is 2
|
||||
if total_masked > 0 and score < 2.0:
|
||||
score = 2.0
|
||||
|
||||
return round(min(10.0, score), 1)
|
||||
|
||||
def _log_guardrail_information(
|
||||
self,
|
||||
request_data: dict,
|
||||
@@ -1348,6 +1448,14 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
end_time=datetime.now().timestamp(),
|
||||
duration=(datetime.now() - start_time).total_seconds(),
|
||||
masked_entity_count=masked_entity_count,
|
||||
tracing_detail=GuardrailTracingDetail(
|
||||
guardrail_id=self.config_guardrail_id or self.guardrail_name,
|
||||
policy_template=self.config_policy_template or self._get_policy_templates(),
|
||||
detection_method=self._get_detection_methods(detections) if detections else None,
|
||||
match_details=self._build_match_details(detections) if detections else None,
|
||||
patterns_checked=self._get_patterns_checked_count(),
|
||||
risk_score=self._compute_risk_score(detections, masked_entity_count, status),
|
||||
),
|
||||
)
|
||||
|
||||
async def apply_guardrail(
|
||||
@@ -1518,7 +1626,8 @@ class ContentFilterGuardrail(CustomGuardrail):
|
||||
|
||||
@staticmethod
|
||||
def get_config_model():
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import \
|
||||
LitellmContentFilterGuardrailConfigModel
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
|
||||
LitellmContentFilterGuardrailConfigModel,
|
||||
)
|
||||
|
||||
return LitellmContentFilterGuardrailConfigModel
|
||||
|
||||
@@ -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",
|
||||
|
||||
+157
@@ -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))
|
||||
|
||||
@@ -481,6 +481,7 @@ async def _common_key_generation_helper( # noqa: PLR0915
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
"budget_duration",
|
||||
"duration",
|
||||
]:
|
||||
setattr(data, key, litellm.default_key_generate_params.get(key, None))
|
||||
elif key == "models" and value == []:
|
||||
@@ -3293,6 +3294,14 @@ async def _execute_virtual_key_regeneration(
|
||||
update_data.update(non_default_values)
|
||||
update_data = prisma_client.jsonify_object(data=update_data)
|
||||
|
||||
# If grace period set, insert deprecated key so old key remains valid
|
||||
await _insert_deprecated_key(
|
||||
prisma_client=prisma_client,
|
||||
old_token_hash=hashed_api_key,
|
||||
new_token_hash=new_token_hash,
|
||||
grace_period=data.grace_period if data else None,
|
||||
)
|
||||
|
||||
updated_token = await prisma_client.db.litellm_verificationtoken.update(
|
||||
where={"token": hashed_api_key},
|
||||
data=update_data, # type: ignore
|
||||
@@ -3490,58 +3499,6 @@ async def regenerate_key_fn( # noqa: PLR0915
|
||||
)
|
||||
verbose_proxy_logger.debug("key_in_db: %s", _key_in_db)
|
||||
|
||||
new_token = get_new_token(data=data)
|
||||
|
||||
new_token_hash = hash_token(new_token)
|
||||
new_token_key_name = f"sk-...{new_token[-4:]}"
|
||||
|
||||
# Prepare the update data
|
||||
update_data = {
|
||||
"token": new_token_hash,
|
||||
"key_name": new_token_key_name,
|
||||
}
|
||||
|
||||
non_default_values = {}
|
||||
if data is not None:
|
||||
# Update with any provided parameters from GenerateKeyRequest
|
||||
non_default_values = await prepare_key_update_data(
|
||||
data=data, existing_key_row=_key_in_db
|
||||
)
|
||||
verbose_proxy_logger.debug("non_default_values: %s", non_default_values)
|
||||
|
||||
update_data.update(non_default_values)
|
||||
update_data = prisma_client.jsonify_object(data=update_data)
|
||||
|
||||
# If grace period set, insert deprecated key so old key remains valid
|
||||
await _insert_deprecated_key(
|
||||
prisma_client=prisma_client,
|
||||
old_token_hash=hashed_api_key,
|
||||
new_token_hash=new_token_hash,
|
||||
grace_period=data.grace_period if data else None,
|
||||
)
|
||||
|
||||
# Update the token in the database
|
||||
updated_token = await prisma_client.db.litellm_verificationtoken.update(
|
||||
where={"token": hashed_api_key},
|
||||
data=update_data, # type: ignore
|
||||
)
|
||||
|
||||
updated_token_dict = {}
|
||||
if updated_token is not None:
|
||||
updated_token_dict = dict(updated_token)
|
||||
|
||||
updated_token_dict["key"] = new_token
|
||||
updated_token_dict["token_id"] = updated_token_dict.pop("token")
|
||||
|
||||
### 3. remove existing key entry from cache
|
||||
######################################################################
|
||||
|
||||
if hashed_api_key or key:
|
||||
await _delete_cache_key_object(
|
||||
hashed_token=hash_token(key),
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
# Normalize litellm_changed_by: if it's a Header object or not a string, convert to None
|
||||
if litellm_changed_by is not None and not isinstance(litellm_changed_by, str):
|
||||
litellm_changed_by = None
|
||||
|
||||
@@ -741,6 +741,7 @@ class Guardrail(TypedDict, total=False):
|
||||
guardrail_name: Required[str]
|
||||
litellm_params: Required[LitellmParams]
|
||||
guardrail_info: Optional[Dict]
|
||||
policy_template: Optional[str]
|
||||
created_at: Optional[datetime]
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
|
||||
@@ -2620,6 +2620,52 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False):
|
||||
}
|
||||
"""
|
||||
|
||||
guardrail_id: Optional[str]
|
||||
"""Unique identifier for the guardrail configuration, e.g. 'gd-eu-pii-001'"""
|
||||
|
||||
policy_template: Optional[str]
|
||||
"""Name of the policy template this guardrail belongs to, e.g. 'EU AI Act Article 5'"""
|
||||
|
||||
detection_method: Optional[str]
|
||||
"""How detection was performed: 'regex', 'keyword', 'llm-judge', 'presidio', etc."""
|
||||
|
||||
confidence_score: Optional[float]
|
||||
"""For LLM-judge guardrails: confidence score 0.0-1.0"""
|
||||
|
||||
classification: Optional[dict]
|
||||
"""For LLM-judge guardrails: structured classification output"""
|
||||
|
||||
match_details: Optional[List[dict]]
|
||||
"""Detailed match information for each detected pattern"""
|
||||
|
||||
patterns_checked: Optional[int]
|
||||
"""Total number of patterns evaluated by this guardrail"""
|
||||
|
||||
alert_recipients: Optional[List[str]]
|
||||
"""Email addresses that were notified"""
|
||||
|
||||
risk_score: Optional[float]
|
||||
"""Risk score 0-10 indicating how risky the request was (higher = riskier). Computed by the guardrail provider."""
|
||||
|
||||
|
||||
class GuardrailTracingDetail(TypedDict, total=False):
|
||||
"""
|
||||
Typed fields for guardrail tracing metadata.
|
||||
|
||||
Passed to add_standard_logging_guardrail_information_to_request_data()
|
||||
to enrich the StandardLoggingGuardrailInformation with provider-specific details.
|
||||
"""
|
||||
|
||||
guardrail_id: Optional[str]
|
||||
policy_template: Optional[str]
|
||||
detection_method: Optional[str]
|
||||
confidence_score: Optional[float]
|
||||
classification: Optional[dict]
|
||||
match_details: Optional[List[dict]]
|
||||
patterns_checked: Optional[int]
|
||||
alert_recipients: Optional[List[str]]
|
||||
risk_score: Optional[float]
|
||||
|
||||
|
||||
StandardLoggingPayloadStatus = Literal["success", "failure"]
|
||||
|
||||
|
||||
@@ -2408,6 +2408,7 @@ def supports_response_schema(
|
||||
litellm.LlmProviders.FIREWORKS_AI,
|
||||
litellm.LlmProviders.LM_STUDIO,
|
||||
litellm.LlmProviders.NEBIUS,
|
||||
litellm.LlmProviders.DATABRICKS,
|
||||
]
|
||||
|
||||
if custom_llm_provider in PROVIDERS_GLOBALLY_SUPPORT_RESPONSE_SCHEMA:
|
||||
|
||||
@@ -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,
|
||||
@@ -17112,6 +17112,19 @@
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"github_copilot/claude-opus-4.6-fast": {
|
||||
"litellm_provider": "github_copilot",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 16000,
|
||||
"max_tokens": 16000,
|
||||
"mode": "chat",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"github_copilot/claude-opus-41": {
|
||||
"litellm_provider": "github_copilot",
|
||||
"max_input_tokens": 80000,
|
||||
@@ -17363,6 +17376,20 @@
|
||||
"supports_response_schema": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"github_copilot/gpt-5.3-codex": {
|
||||
"litellm_provider": "github_copilot",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "responses",
|
||||
"supported_endpoints": [
|
||||
"/v1/responses"
|
||||
],
|
||||
"supports_function_calling": true,
|
||||
"supports_parallel_function_calling": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"github_copilot/text-embedding-3-small": {
|
||||
"litellm_provider": "github_copilot",
|
||||
"max_input_tokens": 8191,
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "litellm"
|
||||
version = "1.81.12"
|
||||
version = "1.81.13"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
authors = ["BerriAI"]
|
||||
license = "MIT"
|
||||
@@ -182,7 +182,7 @@ requires = ["poetry-core", "wheel"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "1.81.12"
|
||||
version = "1.81.13"
|
||||
version_files = [
|
||||
"pyproject.toml:^version"
|
||||
]
|
||||
|
||||
@@ -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"])
|
||||
@@ -1065,6 +1065,7 @@ def test_parse_content_for_reasoning(content, expected_reasoning, expected_conte
|
||||
("vertex_ai/gemini-1.5-pro", True),
|
||||
("gemini/gemini-1.5-pro", True),
|
||||
("predibase/llama3-8b-instruct", True),
|
||||
("databricks/databricks-meta-llama-3-1-70b-instruct", True),
|
||||
("gpt-3.5-turbo", False),
|
||||
("groq/llama-3.3-70b-versatile", False),
|
||||
],
|
||||
|
||||
@@ -74,7 +74,7 @@ def validate_responses_api_response(response, final_chunk: bool = False):
|
||||
"top_p": (int, float, type(None)),
|
||||
"max_output_tokens": (int, type(None)),
|
||||
"previous_response_id": (str, type(None)),
|
||||
"reasoning": dict,
|
||||
"reasoning": (dict, type(None)),
|
||||
"status": str,
|
||||
"text": dict,
|
||||
"truncation": (str, type(None)),
|
||||
|
||||
@@ -385,6 +385,15 @@ class TestContainerIntegration:
|
||||
@pytest.mark.parametrize("provider", ["openai"])
|
||||
def test_provider_support(self, provider):
|
||||
"""Test that the container API works with supported providers."""
|
||||
import importlib
|
||||
import litellm.containers.main as containers_main_module
|
||||
|
||||
# Reload the module to ensure it has a fresh reference to base_llm_http_handler
|
||||
# after conftest reloads litellm (same pattern as test_error_handling_integration)
|
||||
importlib.reload(containers_main_module)
|
||||
|
||||
from litellm.containers.main import create_container as create_container_fresh
|
||||
|
||||
mock_response = ContainerObject(
|
||||
id="cntr_provider_test",
|
||||
object="container",
|
||||
@@ -398,7 +407,7 @@ class TestContainerIntegration:
|
||||
with patch('litellm.containers.main.base_llm_http_handler') as mock_handler:
|
||||
mock_handler.container_create_handler.return_value = mock_response
|
||||
|
||||
response = create_container(
|
||||
response = create_container_fresh(
|
||||
name="Provider Test Container",
|
||||
custom_llm_provider=provider
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytest
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import CallTypes, UserAPIKeyAuth
|
||||
from litellm.types.utils import GuardrailTracingDetail
|
||||
|
||||
|
||||
class TestCustomGuardrailDeploymentHook:
|
||||
@@ -723,3 +724,99 @@ class TestEventTypeLogging:
|
||||
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
|
||||
assert len(logged_info) == 1
|
||||
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call
|
||||
|
||||
|
||||
class TestTracingFieldsPopulation:
|
||||
"""Verify add_standard_logging_guardrail_information_to_request_data passes tracing_detail fields."""
|
||||
|
||||
def test_new_fields_set_on_slg(self):
|
||||
cg = CustomGuardrail(guardrail_name="test-rail")
|
||||
request_data = {"metadata": {}}
|
||||
cg.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response={"result": "ok"},
|
||||
request_data=request_data,
|
||||
guardrail_status="success",
|
||||
tracing_detail=GuardrailTracingDetail(
|
||||
guardrail_id="rail-123",
|
||||
policy_template="EU AI Act Article 5",
|
||||
detection_method="regex",
|
||||
confidence_score=0.95,
|
||||
match_details=[{"type": "pattern", "action_taken": "BLOCK"}],
|
||||
patterns_checked=12,
|
||||
alert_recipients=["admin@example.com"],
|
||||
),
|
||||
)
|
||||
slg_list = request_data["metadata"]["standard_logging_guardrail_information"]
|
||||
assert len(slg_list) == 1
|
||||
slg = slg_list[0]
|
||||
assert slg["guardrail_id"] == "rail-123"
|
||||
assert slg["policy_template"] == "EU AI Act Article 5"
|
||||
assert slg["detection_method"] == "regex"
|
||||
assert slg["confidence_score"] == 0.95
|
||||
assert slg["patterns_checked"] == 12
|
||||
assert slg["alert_recipients"] == ["admin@example.com"]
|
||||
assert len(slg["match_details"]) == 1
|
||||
|
||||
def test_new_fields_default_to_absent(self):
|
||||
"""When tracing_detail is not passed, new fields are absent from the SLG dict."""
|
||||
cg = CustomGuardrail(guardrail_name="test-rail")
|
||||
request_data = {"metadata": {}}
|
||||
cg.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response="ok",
|
||||
request_data=request_data,
|
||||
guardrail_status="success",
|
||||
)
|
||||
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
|
||||
assert slg.get("guardrail_id") is None
|
||||
assert slg.get("policy_template") is None
|
||||
assert slg.get("confidence_score") is None
|
||||
|
||||
def test_multiple_guardrails_with_different_policies(self):
|
||||
"""One request, multiple guardrails each with own policy_template."""
|
||||
cg1 = CustomGuardrail(guardrail_name="rail-1")
|
||||
cg2 = CustomGuardrail(guardrail_name="rail-2")
|
||||
request_data = {"metadata": {}}
|
||||
|
||||
cg1.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response="ok",
|
||||
request_data=request_data,
|
||||
guardrail_status="success",
|
||||
tracing_detail=GuardrailTracingDetail(policy_template="GDPR"),
|
||||
)
|
||||
cg2.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response="blocked",
|
||||
request_data=request_data,
|
||||
guardrail_status="guardrail_intervened",
|
||||
tracing_detail=GuardrailTracingDetail(policy_template="EU AI Act Article 5"),
|
||||
)
|
||||
|
||||
slg_list = request_data["metadata"]["standard_logging_guardrail_information"]
|
||||
assert len(slg_list) == 2
|
||||
assert slg_list[0]["policy_template"] == "GDPR"
|
||||
assert slg_list[1]["policy_template"] == "EU AI Act Article 5"
|
||||
|
||||
def test_classification_field_passed_through(self):
|
||||
"""Classification dict for LLM-judge guardrails is passed through."""
|
||||
cg = CustomGuardrail(guardrail_name="judge-rail")
|
||||
request_data = {"metadata": {}}
|
||||
classification = {
|
||||
"flagged": True,
|
||||
"category": "workplace_emotion_recognition",
|
||||
"article_reference": "Article 5(1)(f)",
|
||||
"confidence": 0.94,
|
||||
"reason": "Request asks to analyze employee sentiment",
|
||||
}
|
||||
cg.add_standard_logging_guardrail_information_to_request_data(
|
||||
guardrail_json_response="blocked",
|
||||
request_data=request_data,
|
||||
guardrail_status="guardrail_intervened",
|
||||
tracing_detail=GuardrailTracingDetail(
|
||||
classification=classification,
|
||||
detection_method="llm-judge",
|
||||
confidence_score=0.94,
|
||||
),
|
||||
)
|
||||
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
|
||||
assert slg["classification"] == classification
|
||||
assert slg["detection_method"] == "llm-judge"
|
||||
assert slg["confidence_score"] == 0.94
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -47,67 +46,26 @@ def test_map_openai_params():
|
||||
assert "response_format" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llama_api_streaming_no_307_error():
|
||||
"""Test that streaming works without 307 redirect errors due to follow_redirects=True"""
|
||||
def test_llama_api_streaming_no_307_error():
|
||||
"""
|
||||
Test that the OpenAI-compatible httpx clients use follow_redirects=True.
|
||||
|
||||
# Mock the httpx client to simulate a successful streaming response
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.get_async_httpx_client"
|
||||
) as mock_get_client:
|
||||
# Create a mock client
|
||||
mock_client = AsyncMock()
|
||||
mock_get_client.return_value = mock_client
|
||||
meta_llama routes through the OpenAI SDK path (BaseOpenAILLM), so the
|
||||
follow_redirects setting on that SDK's underlying httpx client is what
|
||||
actually prevents 307 redirect errors for LLaMA API streaming.
|
||||
"""
|
||||
from litellm.llms.openai.common_utils import BaseOpenAILLM
|
||||
|
||||
# Mock a successful streaming response (not a 307 redirect)
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "text/plain; charset=utf-8"}
|
||||
# Verify the async httpx client has follow_redirects enabled
|
||||
async_client = BaseOpenAILLM._get_async_http_client()
|
||||
assert async_client is not None
|
||||
assert (
|
||||
async_client.follow_redirects is True
|
||||
), "Async httpx client should set follow_redirects=True to prevent 307 errors"
|
||||
|
||||
# Mock streaming data that would come from a successful request
|
||||
async def mock_aiter_lines():
|
||||
yield 'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"meta_llama/Llama-4-Maverick-17B-128E-Instruct-FP8","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}'
|
||||
yield 'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"meta_llama/Llama-4-Maverick-17B-128E-Instruct-FP8","choices":[{"index":0,"delta":{"content":" there"},"finish_reason":null}]}'
|
||||
yield 'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1234567890,"model":"meta_llama/Llama-4-Maverick-17B-128E-Instruct-FP8","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}'
|
||||
yield "data: [DONE]"
|
||||
|
||||
mock_response.aiter_lines.return_value = mock_aiter_lines()
|
||||
mock_client.stream.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
# Test the streaming completion
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model="meta_llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
messages=[{"role": "user", "content": "Tell me about yourself"}],
|
||||
stream=True,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
# Verify we get a CustomStreamWrapper (streaming response)
|
||||
from litellm.utils import CustomStreamWrapper
|
||||
|
||||
assert isinstance(response, CustomStreamWrapper)
|
||||
|
||||
# Verify the HTTP client was called with follow_redirects=True
|
||||
mock_client.stream.assert_called_once()
|
||||
call_kwargs = mock_client.stream.call_args[1]
|
||||
assert (
|
||||
call_kwargs.get("follow_redirects") is True
|
||||
), "follow_redirects should be True to prevent 307 errors"
|
||||
|
||||
# Verify the response status is 200 (not 307)
|
||||
assert (
|
||||
mock_response.status_code == 200
|
||||
), "Should get 200 response, not 307 redirect"
|
||||
|
||||
except Exception as e:
|
||||
# If there's an exception, make sure it's not a 307 error
|
||||
error_str = str(e)
|
||||
assert (
|
||||
"307" not in error_str
|
||||
), f"Should not get 307 redirect error: {error_str}"
|
||||
|
||||
# Still verify that follow_redirects was set correctly
|
||||
if mock_client.stream.called:
|
||||
call_kwargs = mock_client.stream.call_args[1]
|
||||
assert call_kwargs.get("follow_redirects") is True
|
||||
# Verify the sync httpx client has follow_redirects enabled
|
||||
sync_client = BaseOpenAILLM._get_sync_http_client()
|
||||
assert sync_client is not None
|
||||
assert (
|
||||
sync_client.follow_redirects is True
|
||||
), "Sync httpx client should set follow_redirects=True to prevent 307 errors"
|
||||
|
||||
@@ -7,6 +7,7 @@ PublicAI is an OpenAI-compatible provider with minor customizations.
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../../..")
|
||||
@@ -51,9 +52,13 @@ class TestPublicAIConfig:
|
||||
assert result["Authorization"] == f"Bearer {api_key}"
|
||||
assert result["Content-Type"] == "application/json"
|
||||
|
||||
def test_get_supported_openai_params(self, config):
|
||||
@patch("litellm.utils.supports_function_calling", return_value=True)
|
||||
def test_get_supported_openai_params(self, mock_supports_fc, config):
|
||||
"""
|
||||
Test that get_supported_openai_params returns correct params
|
||||
Test that get_supported_openai_params returns correct params.
|
||||
We mock supports_function_calling because the test model name
|
||||
'swiss-ai-apertus' is not in the model registry; this test validates
|
||||
config behaviour, not registry lookups.
|
||||
"""
|
||||
supported_params = config.get_supported_openai_params(model="swiss-ai-apertus")
|
||||
|
||||
@@ -66,9 +71,12 @@ class TestPublicAIConfig:
|
||||
# Note: JSON-based configs inherit from OpenAIGPTConfig which includes functions
|
||||
# This is expected behavior for JSON-based providers
|
||||
|
||||
def test_map_openai_params_includes_functions(self, config):
|
||||
@patch("litellm.utils.supports_function_calling", return_value=True)
|
||||
def test_map_openai_params_includes_functions(self, mock_supports_fc, config):
|
||||
"""
|
||||
Test that functions parameter is mapped (JSON-based configs don't exclude functions)
|
||||
Test that functions parameter is mapped (JSON-based configs don't exclude functions).
|
||||
We mock supports_function_calling because the test model name
|
||||
'swiss-ai-apertus' is not in the model registry.
|
||||
"""
|
||||
non_default_params = {
|
||||
"functions": [{"name": "test_function", "description": "Test function"}],
|
||||
|
||||
@@ -22,6 +22,8 @@ class TestVertexAIRerankTransform:
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"GOOGLE_CLOUD_PROJECT",
|
||||
"VERTEXAI_PROJECT",
|
||||
"VERTEXAI_CREDENTIALS",
|
||||
"VERTEX_AI_CREDENTIALS",
|
||||
"VERTEX_PROJECT",
|
||||
"VERTEX_LOCATION",
|
||||
"VERTEX_AI_PROJECT",
|
||||
@@ -471,16 +473,20 @@ class TestVertexAIRerankTransform:
|
||||
}
|
||||
assert headers == expected_headers
|
||||
|
||||
@patch('litellm.llms.vertex_ai.rerank.transformation.VertexAIRerankConfig._ensure_access_token')
|
||||
def test_validate_environment_preserves_optional_params_for_get_complete_url(
|
||||
self,
|
||||
mock_ensure_access_token,
|
||||
):
|
||||
"""
|
||||
Validate that calling validate_environment does not remove vertex-specific
|
||||
parameters needed later by get_complete_url.
|
||||
|
||||
Uses instance-level mocking to avoid class-reference issues caused by
|
||||
importlib.reload(litellm) in conftest.py.
|
||||
"""
|
||||
mock_ensure_access_token.return_value = ("test-access-token", "project-from-token")
|
||||
mock_ensure_access_token = MagicMock(
|
||||
return_value=("test-access-token", "project-from-token")
|
||||
)
|
||||
self.config._ensure_access_token = mock_ensure_access_token
|
||||
|
||||
optional_params = {
|
||||
"vertex_credentials": "path/to/credentials.json",
|
||||
|
||||
+212
@@ -23,6 +23,9 @@ from litellm.types.guardrails import (
|
||||
ContentFilterPattern,
|
||||
GuardrailEventHooks,
|
||||
)
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
|
||||
ContentFilterCategoryConfig,
|
||||
)
|
||||
|
||||
|
||||
class TestContentFilterGuardrail:
|
||||
@@ -1842,3 +1845,212 @@ class TestContentFilterGuardrail:
|
||||
)
|
||||
# Should pass - 'Indian' in sentence 1, 'lazy' in sentence 2
|
||||
assert len(result["texts"]) == 1
|
||||
|
||||
|
||||
class TestTracingFieldsE2E:
|
||||
"""E2E tests for new tracing fields (guardrail_id, policy_template, detection_method, match_details, patterns_checked)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracing_fields_populated_on_mask_detection(self):
|
||||
"""New tracing fields are populated in SpendLog metadata when content is masked."""
|
||||
patterns = [
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="email",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
]
|
||||
blocked_words = [
|
||||
BlockedWord(
|
||||
keyword="secret",
|
||||
action=ContentFilterAction.MASK,
|
||||
description="Secret keyword",
|
||||
),
|
||||
]
|
||||
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name="tracing-test",
|
||||
guardrail_id="gd-tracing-001",
|
||||
policy_template="Test Policy Template",
|
||||
patterns=patterns,
|
||||
blocked_words=blocked_words,
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"messages": [{"role": "user", "content": "Test"}],
|
||||
"model": "gpt-4o",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["Email me at user@test.com, it's a secret"]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
slg_list = request_data["metadata"]["standard_logging_guardrail_information"]
|
||||
assert len(slg_list) == 1
|
||||
slg = slg_list[0]
|
||||
|
||||
# New tracing fields
|
||||
assert slg["guardrail_id"] == "gd-tracing-001"
|
||||
assert slg["policy_template"] == "Test Policy Template"
|
||||
assert slg["detection_method"] == "keyword,regex"
|
||||
assert slg["patterns_checked"] >= 2 # at least 1 pattern + 1 keyword
|
||||
|
||||
# match_details
|
||||
assert isinstance(slg["match_details"], list)
|
||||
assert len(slg["match_details"]) >= 2
|
||||
methods = {d["detection_method"] for d in slg["match_details"]}
|
||||
assert "regex" in methods
|
||||
assert "keyword" in methods
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracing_fields_fallback_when_no_config_id(self):
|
||||
"""guardrail_id falls back to guardrail_name when config id not provided."""
|
||||
patterns = [
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="us_ssn",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
]
|
||||
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name="fallback-test",
|
||||
patterns=patterns,
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"messages": [{"role": "user", "content": "Test"}],
|
||||
"model": "gpt-4o",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["SSN: 123-45-6789"]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
|
||||
assert slg["guardrail_id"] == "fallback-test"
|
||||
assert slg.get("policy_template") is None # no categories loaded
|
||||
assert slg["detection_method"] == "regex"
|
||||
assert slg["patterns_checked"] >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracing_fields_with_category_keywords(self):
|
||||
"""Tracing fields populated correctly when category keywords trigger detections."""
|
||||
categories = [
|
||||
ContentFilterCategoryConfig(
|
||||
category="harm_toxic_abuse",
|
||||
enabled=True,
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
]
|
||||
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name="category-tracing",
|
||||
guardrail_id="gd-cat-001",
|
||||
categories=categories,
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"messages": [{"role": "user", "content": "Test"}],
|
||||
"model": "gpt-4o",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
# Use a word from the harm_toxic_abuse category
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["You are an idiot and stupid"]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
|
||||
assert slg["guardrail_id"] == "gd-cat-001"
|
||||
assert slg["patterns_checked"] >= 1 # category keywords counted
|
||||
|
||||
if slg.get("match_details"):
|
||||
# If detections happened, verify category info
|
||||
cat_matches = [d for d in slg["match_details"] if d.get("category")]
|
||||
for m in cat_matches:
|
||||
assert m["detection_method"] == "keyword"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracing_fields_on_blocked_request(self):
|
||||
"""Tracing fields populated even when request is blocked."""
|
||||
patterns = [
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="us_ssn",
|
||||
action=ContentFilterAction.BLOCK,
|
||||
),
|
||||
]
|
||||
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name="block-tracing",
|
||||
guardrail_id="gd-block-001",
|
||||
policy_template="SSN Protection",
|
||||
patterns=patterns,
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"messages": [{"role": "user", "content": "Test"}],
|
||||
"model": "gpt-4o",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["SSN: 123-45-6789"]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
|
||||
assert slg["guardrail_id"] == "gd-block-001"
|
||||
assert slg["policy_template"] == "SSN Protection"
|
||||
assert slg["guardrail_status"] == "guardrail_intervened"
|
||||
assert slg["patterns_checked"] >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracing_fields_no_detections(self):
|
||||
"""When no detections occur, tracing fields still populated with metadata."""
|
||||
patterns = [
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="email",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
]
|
||||
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name="clean-tracing",
|
||||
guardrail_id="gd-clean-001",
|
||||
policy_template="Email Protection",
|
||||
patterns=patterns,
|
||||
)
|
||||
|
||||
request_data = {
|
||||
"messages": [{"role": "user", "content": "Test"}],
|
||||
"model": "gpt-4o",
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
await guardrail.apply_guardrail(
|
||||
inputs={"texts": ["Hello world, no sensitive content here"]},
|
||||
request_data=request_data,
|
||||
input_type="request",
|
||||
)
|
||||
|
||||
slg = request_data["metadata"]["standard_logging_guardrail_information"][0]
|
||||
assert slg["guardrail_id"] == "gd-clean-001"
|
||||
assert slg["policy_template"] == "Email Protection"
|
||||
assert slg["guardrail_status"] == "success"
|
||||
assert slg["patterns_checked"] >= 1
|
||||
# No detections, so these should be None
|
||||
assert slg.get("detection_method") is None
|
||||
assert slg.get("match_details") is None
|
||||
|
||||
+90
@@ -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
|
||||
+293
@@ -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
|
||||
+24
-1
@@ -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,
|
||||
)
|
||||
@@ -5725,3 +5725,58 @@ async def test_rotate_master_key_model_data_valid_for_prisma(
|
||||
|
||||
# Verify delete_many was called inside the transaction (before create_many)
|
||||
mock_tx.litellm_proxymodeltable.delete_many.assert_called_once()
|
||||
async def test_default_key_generate_params_duration(monkeypatch):
|
||||
"""
|
||||
Test that default_key_generate_params with 'duration' is applied
|
||||
when no duration is provided in the key generation request.
|
||||
|
||||
Regression test for bug where 'duration' was missing from the list
|
||||
of fields populated from default_key_generate_params.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_insert_data = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
token="hashed_token_123", litellm_budget_table=None, object_permission=None
|
||||
)
|
||||
)
|
||||
mock_prisma_client.insert_data = mock_insert_data
|
||||
mock_prisma_client.db = MagicMock()
|
||||
mock_prisma_client.db.litellm_verificationtoken = MagicMock()
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(
|
||||
return_value=[]
|
||||
)
|
||||
mock_prisma_client.db.litellm_verificationtoken.count = AsyncMock(return_value=0)
|
||||
mock_prisma_client.db.litellm_verificationtoken.update = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
token="hashed_token_123", litellm_budget_table=None, object_permission=None
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||||
|
||||
# Set default_key_generate_params with duration
|
||||
original_value = litellm.default_key_generate_params
|
||||
litellm.default_key_generate_params = {"duration": "180d"}
|
||||
|
||||
try:
|
||||
request = GenerateKeyRequest() # No duration specified
|
||||
response = await _common_key_generation_helper(
|
||||
data=request,
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-1234",
|
||||
user_id="1234",
|
||||
),
|
||||
litellm_changed_by=None,
|
||||
team_table=None,
|
||||
)
|
||||
|
||||
# Verify duration was applied from defaults
|
||||
assert request.duration == "180d"
|
||||
finally:
|
||||
litellm.default_key_generate_params = original_value
|
||||
|
||||
@@ -446,8 +446,24 @@ class TestProxyInitializationHelpers:
|
||||
mock_proxy_config_instance.get_config = mock_get_config
|
||||
mock_proxy_config.return_value = mock_proxy_config_instance
|
||||
|
||||
# Ensure DATABASE_URL is not set in the environment
|
||||
with patch.dict(os.environ, {"DATABASE_URL": ""}, clear=True):
|
||||
mock_proxy_server_module = MagicMock(app=mock_app)
|
||||
|
||||
# Only remove DATABASE_URL and DIRECT_URL to prevent the database setup
|
||||
# code path from running. Do NOT use clear=True as it removes PATH, HOME,
|
||||
# etc., which causes imports inside run_server to break in CI (the real
|
||||
# litellm.proxy.proxy_server import at line 820 of proxy_cli.py has heavy
|
||||
# side effects that fail without a proper environment).
|
||||
env_overrides = {
|
||||
"DATABASE_URL": "",
|
||||
"DIRECT_URL": "",
|
||||
"IAM_TOKEN_DB_AUTH": "",
|
||||
"USE_AWS_KMS": "",
|
||||
}
|
||||
with patch.dict(os.environ, env_overrides):
|
||||
# Remove DATABASE_URL entirely so the DB setup block is skipped
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
os.environ.pop("DIRECT_URL", None)
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
@@ -456,7 +472,11 @@ class TestProxyInitializationHelpers:
|
||||
ProxyConfig=mock_proxy_config,
|
||||
KeyManagementSettings=mock_key_mgmt,
|
||||
save_worker_config=mock_save_worker_config,
|
||||
)
|
||||
),
|
||||
# Also mock litellm.proxy.proxy_server to prevent the real
|
||||
# import at line 820 of proxy_cli.py which has heavy side
|
||||
# effects (FastAPI app init, logging setup, etc.)
|
||||
"litellm.proxy.proxy_server": mock_proxy_server_module,
|
||||
},
|
||||
), patch(
|
||||
"litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args"
|
||||
@@ -470,7 +490,10 @@ class TestProxyInitializationHelpers:
|
||||
# Test with no config parameter (config=None)
|
||||
result = runner.invoke(run_server, ["--local"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code == 0, (
|
||||
f"run_server failed with exit_code={result.exit_code}, "
|
||||
f"output={result.output}, exception={result.exception}"
|
||||
)
|
||||
|
||||
# Verify that uvicorn.run was called
|
||||
mock_uvicorn_run.assert_called_once()
|
||||
@@ -481,7 +504,10 @@ class TestProxyInitializationHelpers:
|
||||
# Test with explicit --config None (should behave the same)
|
||||
result = runner.invoke(run_server, ["--local", "--config", "None"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code == 0, (
|
||||
f"run_server failed with exit_code={result.exit_code}, "
|
||||
f"output={result.output}, exception={result.exception}"
|
||||
)
|
||||
|
||||
# Verify that uvicorn.run was called again
|
||||
mock_uvicorn_run.assert_called_once()
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+63
-152
@@ -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}`;
|
||||
|
||||
+52
-1
@@ -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}
|
||||
|
||||
+611
-173
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
import { Tooltip, Collapse } from "antd";
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
} from "@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails";
|
||||
import ContentFilterDetails from "./ContentFilterDetails";
|
||||
|
||||
// ── Interfaces ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface RecognitionMetadata {
|
||||
recognizer_name: string;
|
||||
recognizer_identifier: string;
|
||||
@@ -24,6 +26,15 @@ interface MaskedEntityCount {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
interface MatchDetail {
|
||||
type: string;
|
||||
detection_method?: string;
|
||||
action_taken?: string;
|
||||
snippet?: string;
|
||||
category?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface GuardrailInformation {
|
||||
duration: number;
|
||||
end_time: number;
|
||||
@@ -33,46 +44,213 @@ interface GuardrailInformation {
|
||||
guardrail_status: string;
|
||||
guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse | any;
|
||||
masked_entity_count: MaskedEntityCount;
|
||||
guardrail_provider?: string; // "presidio" | "bedrock" | "litellm_content_filter" | other providers
|
||||
guardrail_provider?: string;
|
||||
guardrail_id?: string;
|
||||
policy_template?: string;
|
||||
detection_method?: string;
|
||||
confidence_score?: number;
|
||||
classification?: Record<string, any>;
|
||||
match_details?: MatchDetail[];
|
||||
patterns_checked?: number;
|
||||
alert_recipients?: string[];
|
||||
risk_score?: number;
|
||||
}
|
||||
|
||||
interface GuardrailViewerProps {
|
||||
data: GuardrailInformation | GuardrailInformation[];
|
||||
}
|
||||
|
||||
interface GuardrailDetailsProps {
|
||||
entry: GuardrailInformation;
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
const PROVIDERS_WITH_CUSTOM_RENDERERS = new Set([
|
||||
"presidio",
|
||||
"bedrock",
|
||||
"litellm_content_filter",
|
||||
]);
|
||||
|
||||
const formatMode = (mode: string): string => {
|
||||
return mode.replace(/_/g, "-").toUpperCase();
|
||||
};
|
||||
|
||||
// Providers with custom renderers
|
||||
const PROVIDERS_WITH_CUSTOM_RENDERERS = new Set(["presidio", "bedrock", "litellm_content_filter"]);
|
||||
const formatDurationMs = (seconds: number): string => {
|
||||
const ms = Math.round(seconds * 1000);
|
||||
return `${ms}ms`;
|
||||
};
|
||||
|
||||
const getTotalMasked = (entry: GuardrailInformation): number => {
|
||||
return Object.values(entry.masked_entity_count || {}).reduce(
|
||||
(sum, count) => sum + (typeof count === "number" ? count : 0),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
const isEntrySuccess = (entry: GuardrailInformation): boolean => {
|
||||
return (entry.guardrail_status ?? "").toLowerCase() === "success";
|
||||
};
|
||||
|
||||
const getRiskColor = (score: number): string => {
|
||||
if (score <= 3) return "text-green-600 bg-green-50 border-green-200";
|
||||
if (score <= 6) return "text-amber-600 bg-amber-50 border-amber-200";
|
||||
return "text-red-600 bg-red-50 border-red-200";
|
||||
};
|
||||
|
||||
const getRiskScore = (entry: GuardrailInformation): number | null => {
|
||||
if (!isEntrySuccess(entry)) return null;
|
||||
|
||||
// Prefer backend-computed score
|
||||
if (entry.risk_score != null) return entry.risk_score;
|
||||
|
||||
// Fallback: compute from available data
|
||||
const totalMasked = getTotalMasked(entry);
|
||||
const patternsChecked = entry.patterns_checked ?? 0;
|
||||
const confidence = entry.confidence_score ?? 0;
|
||||
|
||||
if (patternsChecked === 0 && confidence === 0) return 0;
|
||||
|
||||
const matchRatio = patternsChecked > 0 ? totalMasked / patternsChecked : 0;
|
||||
let score = matchRatio * 7 + confidence * 3;
|
||||
if (totalMasked > 0 && score < 2) score = 2;
|
||||
return Math.min(10, Math.round(score * 10) / 10);
|
||||
};
|
||||
|
||||
const getDisplayName = (entry: GuardrailInformation): string => {
|
||||
return entry.policy_template || entry.guardrail_name;
|
||||
};
|
||||
|
||||
// ── Icons (inline SVGs) ─────────────────────────────────────────────────────
|
||||
|
||||
const ShieldIcon = () => (
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||
<circle cx="20" cy="20" r="20" fill="#EEF2FF" />
|
||||
<path
|
||||
d="M20 10l8 4v6c0 5.25-3.4 10.15-8 11.5C15.4 30.15 12 25.25 12 20v-6l8-4z"
|
||||
stroke="#6366F1"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M16 20l3 3 5-6"
|
||||
stroke="#6366F1"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckCircleIcon = ({ className }: { className?: string }) => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" className={className}>
|
||||
<circle cx="11" cy="11" r="10" stroke="#16A34A" strokeWidth="1.5" fill="#F0FDF4" />
|
||||
<path d="M7 11l3 3 5-6" stroke="#16A34A" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FailCircleIcon = ({ className }: { className?: string }) => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" className={className}>
|
||||
<circle cx="11" cy="11" r="10" stroke="#DC2626" strokeWidth="1.5" fill="#FEF2F2" />
|
||||
<path d="M8 8l6 6M14 8l-6 6" stroke="#DC2626" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlayCircleIcon = () => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<circle cx="11" cy="11" r="10" stroke="#3B82F6" strokeWidth="1.5" fill="#EFF6FF" />
|
||||
<path d="M9 7.5l6 3.5-6 3.5V7.5z" fill="#3B82F6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GrayDotIcon = () => (
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<circle cx="11" cy="11" r="5" fill="#9CA3AF" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className={`transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
>
|
||||
<path d="M6 8l4 4 4-4" stroke="#6B7280" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 2v8m0 0l-3-3m3 3l3-3M3 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExternalLinkIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="inline ml-1">
|
||||
<path d="M6 2H3a1 1 0 00-1 1v8a1 1 0 001 1h8a1 1 0 001-1V8M8 2h4m0 0v4m0-4L6.5 7.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => {
|
||||
if (!matchDetails || matchDetails.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<h5 className="text-sm font-medium mb-2 text-gray-700">Match Details ({matchDetails.length})</h5>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 pr-4 font-medium">Type</th>
|
||||
<th className="pb-2 pr-4 font-medium">Method</th>
|
||||
<th className="pb-2 pr-4 font-medium">Action</th>
|
||||
<th className="pb-2 font-medium">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matchDetails.map((match, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-4">{match.type}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-700 rounded text-xs">
|
||||
{match.detection_method ?? "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
match.action_taken === "BLOCK" ? "bg-red-100 text-red-800" : "bg-blue-50 text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{match.action_taken ?? "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs text-gray-600 break-all">
|
||||
{match.category ? `[${match.category}] ` : ""}
|
||||
{match.snippet ?? "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GenericGuardrailResponse = ({ response }: { response: any }) => {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mt-3">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${showRaw ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<h5 className="font-medium">Raw Guardrail Response</h5>
|
||||
<ChevronIcon expanded={showRaw} />
|
||||
<h5 className="font-medium text-sm ml-1">Raw Guardrail Response</h5>
|
||||
</div>
|
||||
</div>
|
||||
{showRaw && (
|
||||
@@ -87,16 +265,162 @@ const GenericGuardrailResponse = ({ response }: { response: any }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => {
|
||||
const guardrailProvider = entry.guardrail_provider ?? "presidio";
|
||||
const statusLabel = entry.guardrail_status ?? "unknown";
|
||||
const isSuccess = statusLabel.toLowerCase() === "success";
|
||||
const maskedEntityCount = entry.masked_entity_count || {};
|
||||
const totalMaskedEntities = Object.values(maskedEntityCount).reduce(
|
||||
(sum, count) => sum + (typeof count === "number" ? count : 0),
|
||||
0,
|
||||
// ── Timeline entry types ────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineEntry {
|
||||
type: "request" | "guardrail" | "llm" | "response";
|
||||
label: string;
|
||||
offsetMs: number;
|
||||
status?: string;
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
||||
const sorted = useMemo(
|
||||
() => [...entries].sort((a, b) => (a.start_time ?? 0) - (b.start_time ?? 0)),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const baseTime = sorted[0].start_time;
|
||||
const items: TimelineEntry[] = [];
|
||||
|
||||
// Request received
|
||||
items.push({ type: "request", label: "Request received", offsetMs: 0 });
|
||||
|
||||
// Pre-call guardrails
|
||||
const preCalls = sorted.filter((e) => e.guardrail_mode === "pre_call");
|
||||
const postCalls = sorted.filter((e) => e.guardrail_mode === "post_call" || e.guardrail_mode === "logging_only");
|
||||
const duringCalls = sorted.filter((e) => e.guardrail_mode === "during_call");
|
||||
|
||||
for (const e of preCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Pre-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
|
||||
isSuccess: isEntrySuccess(e),
|
||||
});
|
||||
}
|
||||
|
||||
// LLM call — infer from gap between pre-call end and post-call start
|
||||
const lastPreEnd = preCalls.length > 0 ? Math.max(...preCalls.map((e) => e.end_time)) : baseTime;
|
||||
const firstPostStart = postCalls.length > 0 ? Math.min(...postCalls.map((e) => e.start_time)) : undefined;
|
||||
const llmEndTime = firstPostStart ?? (lastPreEnd + 1);
|
||||
const llmOffsetMs = Math.round((llmEndTime - baseTime) * 1000);
|
||||
|
||||
items.push({
|
||||
type: "llm",
|
||||
label: "LLM call",
|
||||
offsetMs: llmOffsetMs,
|
||||
});
|
||||
|
||||
// During-call guardrails (rare)
|
||||
for (const e of duringCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `During-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
|
||||
isSuccess: isEntrySuccess(e),
|
||||
});
|
||||
}
|
||||
|
||||
// Post-call guardrails
|
||||
for (const e of postCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Post-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
|
||||
isSuccess: isEntrySuccess(e),
|
||||
});
|
||||
}
|
||||
|
||||
// Response returned
|
||||
const maxEnd = Math.max(...sorted.map((e) => e.end_time));
|
||||
const responseOffsetMs = Math.round((maxEnd - baseTime) * 1000) + 1;
|
||||
items.push({ type: "response", label: "Response returned", offsetMs: responseOffsetMs });
|
||||
|
||||
return items;
|
||||
}, [sorted]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Request Lifecycle
|
||||
</h4>
|
||||
<div className="relative">
|
||||
{timeline.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 relative">
|
||||
{/* Vertical line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{item.type === "request" || item.type === "response" ? (
|
||||
<GrayDotIcon />
|
||||
) : item.type === "llm" ? (
|
||||
<PlayCircleIcon />
|
||||
) : item.isSuccess ? (
|
||||
<CheckCircleIcon />
|
||||
) : (
|
||||
<FailCircleIcon />
|
||||
)}
|
||||
</div>
|
||||
{idx < timeline.length - 1 && (
|
||||
<div className="w-0.5 bg-gray-200 flex-grow" style={{ minHeight: "24px" }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="pb-4 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
item.type === "llm" ? "text-blue-600 font-medium" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.status && (
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${
|
||||
item.isSuccess
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 font-mono ml-auto flex-shrink-0">
|
||||
T+{item.offsetMs}ms
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Evaluation Card ─────────────────────────────────────────────────────────
|
||||
|
||||
const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const success = isEntrySuccess(entry);
|
||||
const totalMasked = getTotalMasked(entry);
|
||||
const displayName = getDisplayName(entry);
|
||||
const durationStr = formatDurationMs(entry.duration);
|
||||
const modeStr = formatMode(entry.guardrail_mode);
|
||||
const riskScore = getRiskScore(entry);
|
||||
|
||||
const guardrailProvider = entry.guardrail_provider ?? "presidio";
|
||||
const guardrailResponse = entry.guardrail_response;
|
||||
const presidioEntities = Array.isArray(guardrailResponse) ? guardrailResponse : [];
|
||||
const bedrockResponse =
|
||||
@@ -107,173 +431,287 @@ const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => {
|
||||
? (guardrailResponse as BedrockGuardrailResponse)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
{total > 1 && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold">
|
||||
Guardrail #{index + 1}
|
||||
<span className="ml-2 font-mono text-sm text-gray-600">{entry.guardrail_name}</span>
|
||||
</h4>
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded-md text-xs capitalize">
|
||||
{guardrailProvider}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
// Match count string: "X/Y matched" or "X matched"
|
||||
const matchCountStr =
|
||||
entry.patterns_checked != null
|
||||
? `${totalMasked}/${entry.patterns_checked} matched`
|
||||
: totalMasked > 0
|
||||
? `${totalMasked} matched`
|
||||
: null;
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Guardrail Name:</span>
|
||||
<span className="font-mono break-words">{entry.guardrail_name}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Mode:</span>
|
||||
<span className="font-mono break-words">{entry.guardrail_mode}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Status:</span>
|
||||
<Tooltip title={isSuccess ? null : "Guardrail failed to run."} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
isSuccess ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{statusLabel}
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg bg-white">
|
||||
{/* Collapsed header row */}
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{success ? <CheckCircleIcon /> : <FailCircleIcon />}
|
||||
</div>
|
||||
|
||||
{/* Name + badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
|
||||
<span className="font-semibold text-gray-900 text-sm truncate">{displayName}</span>
|
||||
|
||||
<span className="px-2 py-0.5 border border-blue-200 bg-blue-50 text-blue-700 rounded text-[11px] font-semibold uppercase flex-shrink-0">
|
||||
{modeStr}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-semibold uppercase flex-shrink-0 ${
|
||||
success ? "bg-green-100 text-green-700 border border-green-200" : "bg-red-100 text-red-700 border border-red-200"
|
||||
}`}
|
||||
>
|
||||
{success ? "PASSED" : "FAILED"}
|
||||
</span>
|
||||
|
||||
{matchCountStr && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium flex-shrink-0 ${
|
||||
totalMasked === 0 ? "bg-green-50 text-green-700 border border-green-200" : "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{matchCountStr}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{entry.confidence_score != null && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 border border-gray-200 rounded text-[11px] font-medium flex-shrink-0">
|
||||
{(entry.confidence_score * 100).toFixed(0)}% conf
|
||||
</span>
|
||||
)}
|
||||
|
||||
{riskScore != null && success && (
|
||||
<Tooltip title={`Risk score: ${riskScore}/10`}>
|
||||
<span className={`px-2 py-0.5 border rounded text-[11px] font-semibold flex-shrink-0 ${getRiskColor(riskScore)}`}>
|
||||
Risk {riskScore}/10
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(entry.start_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(entry.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{entry.duration.toFixed(4)}s</span>
|
||||
</div>
|
||||
{/* Right side: duration + method + chevron */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className="text-sm text-gray-500 font-mono">{durationStr}</span>
|
||||
{entry.detection_method && (
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 border border-gray-200 rounded text-[11px] font-medium">
|
||||
{entry.detection_method.split(",")[0].trim()}
|
||||
</span>
|
||||
)}
|
||||
<ChevronIcon expanded={expanded} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalMaskedEntities > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<h5 className="font-medium mb-2">Masked Entity Summary</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(maskedEntityCount).map(([entityType, count]) => (
|
||||
<span key={entityType} className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{entityType}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 px-4 py-3">
|
||||
{/* View Policy Configuration link */}
|
||||
{entry.policy_template && (
|
||||
<div className="flex justify-end mb-3">
|
||||
<a
|
||||
href="/ui/policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
View Policy Configuration
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Classification details for llm-judge */}
|
||||
{entry.classification && (
|
||||
<div className="mb-3 bg-gray-50 rounded-lg p-3 space-y-1">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Classification</h5>
|
||||
{entry.classification.category && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Category:</span>
|
||||
<span>{entry.classification.category}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.article_reference && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Reference:</span>
|
||||
<span className="font-mono">{entry.classification.article_reference}</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.confidence != null && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Confidence:</span>
|
||||
<span>{(entry.classification.confidence * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.classification.reason && (
|
||||
<div className="flex text-sm">
|
||||
<span className="font-medium w-1/3 text-gray-500">Reason:</span>
|
||||
<span>{entry.classification.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match details table */}
|
||||
{entry.match_details && entry.match_details.length > 0 && (
|
||||
<MatchDetailsTable matchDetails={entry.match_details} />
|
||||
)}
|
||||
|
||||
{/* Masked entity summary */}
|
||||
{totalMasked > 0 && (
|
||||
<div className="mt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Masked Entities</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(entry.masked_entity_count || {}).map(([entityType, count]) => (
|
||||
<span key={entityType} className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entityType}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider-specific details */}
|
||||
{guardrailProvider === "presidio" && presidioEntities.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<PresidioDetectedEntities entities={presidioEntities} />
|
||||
</div>
|
||||
)}
|
||||
{guardrailProvider === "bedrock" && bedrockResponse && (
|
||||
<div className="mt-3">
|
||||
<BedrockGuardrailDetails response={bedrockResponse} />
|
||||
</div>
|
||||
)}
|
||||
{guardrailProvider === "litellm_content_filter" && guardrailResponse && (
|
||||
<div className="mt-3">
|
||||
<ContentFilterDetails response={guardrailResponse} />
|
||||
</div>
|
||||
)}
|
||||
{guardrailProvider &&
|
||||
!PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) &&
|
||||
guardrailResponse && <GenericGuardrailResponse response={guardrailResponse} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guardrailProvider === "presidio" && presidioEntities.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<PresidioDetectedEntities entities={presidioEntities} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guardrailProvider === "bedrock" && bedrockResponse && (
|
||||
<div className="mt-4">
|
||||
<BedrockGuardrailDetails response={bedrockResponse} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{guardrailProvider === "litellm_content_filter" && guardrailResponse && (
|
||||
<div className="mt-4">
|
||||
<ContentFilterDetails response={guardrailResponse} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generic fallback for unknown guardrail providers */}
|
||||
{guardrailProvider &&
|
||||
!PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) &&
|
||||
guardrailResponse && <GenericGuardrailResponse response={guardrailResponse} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
|
||||
const guardrailEntries = Array.isArray(data)
|
||||
? data.filter((entry): entry is GuardrailInformation => Boolean(entry))
|
||||
: data
|
||||
? [data]
|
||||
: [];
|
||||
const guardrailEntries = useMemo(() => {
|
||||
return Array.isArray(data)
|
||||
? data.filter((entry): entry is GuardrailInformation => Boolean(entry))
|
||||
: data
|
||||
? [data]
|
||||
: [];
|
||||
}, [data]);
|
||||
|
||||
const primaryName =
|
||||
guardrailEntries.length === 1 ? guardrailEntries[0].guardrail_name : `${guardrailEntries.length} guardrails`;
|
||||
const statuses = Array.from(new Set(guardrailEntries.map((entry) => entry.guardrail_status)));
|
||||
const allSucceeded = statuses.every((status) => (status ?? "").toLowerCase() === "success");
|
||||
const aggregatedStatus = allSucceeded ? "success" : "failure";
|
||||
const totalMaskedEntities = guardrailEntries.reduce((sum, entry) => {
|
||||
return (
|
||||
sum +
|
||||
Object.values(entry.masked_entity_count || {}).reduce(
|
||||
(acc, count) => acc + (typeof count === "number" ? count : 0),
|
||||
0,
|
||||
)
|
||||
);
|
||||
}, 0);
|
||||
const passedCount = guardrailEntries.filter(isEntrySuccess).length;
|
||||
const allPassed = passedCount === guardrailEntries.length;
|
||||
|
||||
const tooltipTitle = allSucceeded ? null : "Guardrail failed to run.";
|
||||
const totalOverheadMs = useMemo(() => {
|
||||
return Math.round(guardrailEntries.reduce((sum, e) => sum + (e.duration ?? 0), 0) * 1000);
|
||||
}, [guardrailEntries]);
|
||||
|
||||
const policyTemplates = useMemo(() => {
|
||||
return Array.from(new Set(guardrailEntries.map((e) => e.policy_template).filter(Boolean)));
|
||||
}, [guardrailEntries]);
|
||||
|
||||
if (guardrailEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const blob = new Blob([JSON.stringify(guardrailEntries, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `guardrail-compliance-log-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">Guardrail Information</h3>
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<ShieldIcon />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Guardrails & Policy Compliance
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-sm text-gray-500">
|
||||
{guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""} evaluated
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||
allPassed
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
}`}
|
||||
>
|
||||
{allPassed ? (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 6l2.5 2.5L9 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : null}
|
||||
{passedCount} Passed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{aggregatedStatus}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<span className="font-mono text-sm text-gray-600">{primaryName}</span>
|
||||
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Total: {totalOverheadMs}ms overhead
|
||||
</div>
|
||||
{policyTemplates.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
Policy: {policyTemplates.join(" / ")}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-4 space-y-6">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<GuardrailDetails
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
index={index}
|
||||
total={guardrailEntries.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Export Compliance Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body: two columns ──────────────────────────────────── */}
|
||||
<div className="flex">
|
||||
{/* Left column: Request Lifecycle */}
|
||||
<div className="w-[340px] flex-shrink-0 border-r border-gray-100 px-6 py-5">
|
||||
<RequestLifecycle entries={guardrailEntries} />
|
||||
</div>
|
||||
|
||||
{/* Right column: Evaluation Details */}
|
||||
<div className="flex-1 px-6 py-5 min-w-0">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">
|
||||
Evaluation Details
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<EvaluationCard
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+19
-4
@@ -66,6 +66,9 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails =
|
||||
const hasGuardrailData = guardrailEntries.length > 0;
|
||||
const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries);
|
||||
const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries);
|
||||
const guardrailPolicyNames = Array.from(
|
||||
new Set(guardrailEntries.map((e: any) => e?.policy_template).filter(Boolean))
|
||||
) as string[];
|
||||
|
||||
// Vector store data
|
||||
const hasVectorStoreData = checkHasVectorStoreData(metadata);
|
||||
@@ -124,7 +127,7 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails =
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<Descriptions.Item label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} policyNames={guardrailPolicyNames} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
@@ -164,7 +167,11 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails =
|
||||
)}
|
||||
|
||||
{/* Guardrail Data */}
|
||||
{hasGuardrailData && <GuardrailViewer data={guardrailInfo} />}
|
||||
{hasGuardrailData && (
|
||||
<div id="guardrail-section">
|
||||
<GuardrailViewer data={guardrailInfo} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vector Store Data */}
|
||||
{hasVectorStoreData && <VectorStoreViewer data={metadata.vector_store_request_metadata} />}
|
||||
@@ -218,15 +225,23 @@ function TagsSection({ tags }: { tags: Record<string, any> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) {
|
||||
function GuardrailLabel({ label, maskedCount, policyNames }: { label: string; maskedCount: number; policyNames: string[] }) {
|
||||
const handleClick = () => {
|
||||
const el = document.getElementById("guardrail-section");
|
||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<span>{label}</span>
|
||||
<a onClick={handleClick} style={{ cursor: "pointer" }}>{label}</a>
|
||||
{maskedCount > 0 && (
|
||||
<Tag color="blue">
|
||||
{maskedCount} masked
|
||||
</Tag>
|
||||
)}
|
||||
{policyNames.map((name) => (
|
||||
<Tag key={name} color="purple">{name}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user