fix: add missing OpenAI chat completion params to OPENAI_CHAT_COMPLETION_PARAMS (#21360)

* allow filtering by user in global usage

* add server root path test to github actions

* Update .github/workflows/test_server_root_path.yml

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* address greptile review feedback (greploop iteration 1)

- Fix HTTPException swallowed by broad except block in get_user_daily_activity
  and get_user_daily_activity_aggregated: re-raise HTTPException before the
  generic handler so 403 status codes propagate correctly
- Add status_code assertions in non-admin access tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* address greptile review feedback (greploop iteration 2)

- Default user_id to caller's own ID for non-admins instead of 403 when
  omitted, preserving backward compatibility for API consumers
- Apply same fix to aggregated endpoint
- Update test to verify defaulting behavior instead of expecting 403
- Add useEffect to sync selectedUserId when auth state settles in
  UsagePageView to handle async auth initialization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fixing syntax

* remove artifacts

* feat: guardrail tracing UI - policy, detection method, match details (#21349)

* feat: add GuardrailTracingDetail TypedDict and tracing fields to StandardLoggingGuardrailInformation

* feat: add policy_template field to Guardrail config TypedDict

* feat: accept GuardrailTracingDetail in base guardrail logging method

* feat: populate tracing fields in content filter guardrail

* test: add tracing fields tests for custom guardrail base class

* test: add tracing fields e2e tests for content filter guardrail

* feat: add guardrail tracing UI - policy badges, match details, timeline

* feat: redesign GuardrailViewer to Guardrails & Policy Compliance layout

Two-column layout with request lifecycle timeline on the left
and compact evaluation detail cards on the right. Header shows
guardrail count, pass/fail status, total overhead, policy info,
and an export button.

* feat: add clickable guardrail link in metrics + show policy names

* feat: add risk_score field to StandardLoggingGuardrailInformation

* feat: compute risk_score in content filter guardrail

* feat: display backend risk_score badge on evaluation cards

* fix: fallback to frontend risk score when backend doesn't provide one

* passing in masster key for api calls

* Fix: Add blog as incident report

* Fix: Add blog as incident report

* remove timeline

* feat(models): add github_copilot/gpt-5.3-codex and github_copilot/claude-opus-4.6-fast (#21316)

Add missing GitHub Copilot model entries for gpt-5.3-codex (GA) and
claude-opus-4.6-fast (Public Preview) to both the root and backup
model pricing JSON files.

* only tests for /ui

* bump: version 1.81.12 → 1.81.13

* Fixing mapped tests

* fixing no_config test

* fixing container tests

* fixing test_basic_openai_responses_api

* Adding bedrock thinking budget tokens to docs

* fixing regen key tests

* fix: add missing OpenAI chat completion params to OPENAI_CHAT_COMPLETION_PARAMS

Add store, prompt_cache_key, prompt_cache_retention, safety_identifier, and verbosity
to OPENAI_CHAT_COMPLETION_PARAMS list.

These params were already in DEFAULT_CHAT_COMPLETION_PARAM_VALUES but missing from
the OPENAI_CHAT_COMPLETION_PARAMS list, causing them to be dropped when passed to
OpenAI-compatible providers.

---------

Co-authored-by: yuneng-jiang <yuneng.jiang@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com>
Co-authored-by: Sameer Kankute <sameer@berri.ai>
Co-authored-by: Cesar Garcia <128240629+Chesars@users.noreply.github.com>
Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>
This commit is contained in:
shin-bot-litellm 2026-02-16 20:31:21 -08:00 • committed by GitHub
parent b67c140938
commit b609f5841b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2199 additions and 829 deletions

View file

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

View file

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

View 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

View file

@ -577,6 +577,10 @@ OPENAI_CHAT_COMPLETION_PARAMS = [
"web_search_options",
"service_tier",
"store",
"prompt_cache_key",
"prompt_cache_retention",
"safety_identifier",
"verbosity",
]
OPENAI_TRANSCRIPTION_PARAMS = [

View file

@ -329,10 +329,10 @@ class ContentFilterGuardrail(CustomGuardrail):
action if action else category_config_obj.default_action
)
# Handle conditional categories (with identifier_words + inherit_from OR identifier_words + additional_block_words)
if category_config_obj.identifier_words and (
category_config_obj.inherit_from
or category_config_obj.additional_block_words
# Handle conditional categories (with identifier_words + inherit_from)
if (
category_config_obj.identifier_words
and category_config_obj.inherit_from
):
self._load_conditional_category(
category_name,
@ -387,81 +387,64 @@ class ContentFilterGuardrail(CustomGuardrail):
categories_dir: str,
) -> None:
"""
Load a conditional category that uses identifier_words + block_words.
Supports two patterns:
1. Inherit + additional: identifier_words + inherit_from + optional additional_block_words
2. Standalone: identifier_words + additional_block_words (no inheritance)
Load a conditional category that uses identifier_words + inherited block_words.
Args:
category_name: Name of the category
category_config_obj: CategoryConfig object with identifier_words and either inherit_from or additional_block_words
category_config_obj: CategoryConfig object with identifier_words and inherit_from
category_action: Action to take when match is found
severity_threshold: Minimum severity threshold
categories_dir: Directory containing category files
"""
block_words = []
# Load the inherited category to get block words
inherit_from = category_config_obj.inherit_from
if not inherit_from:
return
# Pattern 1: Load inherited category to get base block words
if inherit_from:
# Remove .json or .yaml extension if included
inherit_base = inherit_from.replace(".json", "").replace(".yaml", "")
# Remove .json or .yaml extension if included
inherit_base = inherit_from.replace(".json", "").replace(".yaml", "")
# Find the inherited category file
inherit_yaml_path = os.path.join(categories_dir, f"{inherit_base}.yaml")
inherit_json_path = os.path.join(categories_dir, f"{inherit_base}.json")
# Find the inherited category file
inherit_yaml_path = os.path.join(categories_dir, f"{inherit_base}.yaml")
inherit_json_path = os.path.join(categories_dir, f"{inherit_base}.json")
if os.path.exists(inherit_yaml_path):
inherit_file_path = inherit_yaml_path
elif os.path.exists(inherit_json_path):
inherit_file_path = inherit_json_path
else:
verbose_proxy_logger.warning(
f"Category {category_name}: inherit_from '{inherit_from}' file not found at {categories_dir}"
)
verbose_proxy_logger.debug(
f"Tried paths: {inherit_yaml_path}, {inherit_json_path}"
)
return
try:
# Load the inherited category
inherited_category = self._load_category_file(inherit_file_path)
# Extract block words from inherited category that meet severity threshold
for keyword_data in inherited_category.keywords:
keyword = keyword_data["keyword"].lower()
severity = keyword_data["severity"]
if self._should_apply_severity(severity, severity_threshold):
block_words.append(keyword)
except Exception as e:
verbose_proxy_logger.error(
f"Error loading inherited category for {category_name}: {e}"
)
return
# Pattern 2 or supplement to Pattern 1: Add additional block words
if category_config_obj.additional_block_words:
block_words.extend(category_config_obj.additional_block_words)
# Ensure we have block words before storing
if not block_words:
if os.path.exists(inherit_yaml_path):
inherit_file_path = inherit_yaml_path
elif os.path.exists(inherit_json_path):
inherit_file_path = inherit_json_path
else:
verbose_proxy_logger.warning(
f"Category {category_name}: no block words found (check inherit_from or additional_block_words)"
f"Category {category_name}: inherit_from '{inherit_from}' file not found at {categories_dir}"
)
verbose_proxy_logger.debug(
f"Tried paths: {inherit_yaml_path}, {inherit_json_path}"
)
return
# Store the conditional category configuration
self.conditional_categories[category_name] = {
"identifier_words": category_config_obj.identifier_words,
"block_words": block_words,
"action": category_action,
"severity": "high", # Combinations are always high severity
}
try:
# Load the inherited category
inherited_category = self._load_category_file(inherit_file_path)
# Extract block words from inherited category that meet severity threshold
block_words = []
for keyword_data in inherited_category.keywords:
keyword = keyword_data["keyword"].lower()
severity = keyword_data["severity"]
if self._should_apply_severity(severity, severity_threshold):
block_words.append(keyword)
# Add additional block words specific to this category
if category_config_obj.additional_block_words:
block_words.extend(category_config_obj.additional_block_words)
# Store the conditional category configuration
self.conditional_categories[category_name] = {
"identifier_words": category_config_obj.identifier_words,
"block_words": block_words,
"action": category_action,
"severity": "high", # Combinations are always high severity
}
# Log different messages based on pattern
if inherit_from and category_config_obj.additional_block_words:
verbose_proxy_logger.info(
f"Loaded conditional category {category_name}: "
f"{len(category_config_obj.identifier_words)} identifiers + "
@ -469,17 +452,9 @@ class ContentFilterGuardrail(CustomGuardrail):
f"({len(category_config_obj.additional_block_words)} additional + "
f"{len(block_words) - len(category_config_obj.additional_block_words)} from {inherit_from})"
)
elif inherit_from:
verbose_proxy_logger.info(
f"Loaded conditional category {category_name}: "
f"{len(category_config_obj.identifier_words)} identifiers + "
f"{len(block_words)} block words (from {inherit_from})"
)
else:
verbose_proxy_logger.info(
f"Loaded conditional category {category_name}: "
f"{len(category_config_obj.identifier_words)} identifiers + "
f"{len(block_words)} block words (standalone)"
except Exception as e:
verbose_proxy_logger.error(
f"Error loading inherited category for {category_name}: {e}"
)
def _load_category_file(self, file_path: str) -> CategoryConfig:
@ -1398,6 +1373,41 @@ class ContentFilterGuardrail(CustomGuardrail):
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,
@ -1444,6 +1454,7 @@ class ContentFilterGuardrail(CustomGuardrail):
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),
),
)

View file

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

View file

@ -3277,6 +3277,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
@ -3474,58 +3482,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

View file

@ -2644,6 +2644,9 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False):
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):
"""
@ -2661,6 +2664,7 @@ class GuardrailTracingDetail(TypedDict, total=False):
match_details: Optional[List[dict]]
patterns_checked: Optional[int]
alert_recipients: Optional[List[str]]
risk_score: Optional[float]
StandardLoggingPayloadStatus = Literal["success", "failure"]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1167,4 +1167,136 @@ def test_generate_request_base_validator():
# Test with None
req = GenerateRequestBase(max_budget=None)
assert req.max_budget is None
assert req.max_budget is None
@pytest.mark.asyncio
async def test_get_user_daily_activity_non_admin_cannot_view_other_users(monkeypatch):
"""
Test that non-admin users cannot view another user's daily activity data.
The endpoint should raise 403 when user_id does not match the caller's own user_id.
Also verifies that omitting user_id defaults to the caller's own user_id.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity,
)
# Mock the prisma client so the DB-not-connected check passes
mock_prisma_client = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
)
# Non-admin caller
non_admin_key_dict = UserAPIKeyAuth(
user_id="regular-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
# Case 1: Non-admin tries to view a different user's data — should get 403
with pytest.raises(HTTPException) as exc_info:
await get_user_daily_activity(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id="other-user-456",
page=1,
page_size=50,
timezone=None,
user_api_key_dict=non_admin_key_dict,
)
assert exc_info.value.status_code == 403
assert "Non-admin users can only view their own spend data" in str(
exc_info.value.detail
)
# Case 2: Non-admin omits user_id — should default to their own user_id
mock_response = MagicMock()
with patch(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_get_daily:
result = await get_user_daily_activity(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id=None,
page=1,
page_size=50,
timezone=None,
user_api_key_dict=non_admin_key_dict,
)
# Verify it called get_daily_activity with the caller's own user_id
mock_get_daily.assert_called_once()
call_kwargs = mock_get_daily.call_args
assert call_kwargs.kwargs["entity_id"] == "regular-user-123"
@pytest.mark.asyncio
async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch):
"""
Test that admin users can call the aggregated endpoint without a user_id
to get a global view. Also verifies that the correct arguments are forwarded
to the underlying get_daily_activity_aggregated helper.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity_aggregated,
)
# Mock the prisma client
mock_prisma_client = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
)
# Mock the downstream helper so we don't need a real DB
mock_response = MagicMock()
mock_get_daily_agg = AsyncMock(return_value=mock_response)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated",
mock_get_daily_agg,
)
# Admin caller
admin_key_dict = UserAPIKeyAuth(
user_id="admin-user-001",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
# Admin calls without user_id → global view (entity_id=None)
result = await get_user_daily_activity_aggregated(
start_date="2025-02-01",
end_date="2025-02-28",
model="gpt-4",
api_key=None,
user_id=None,
timezone=480,
user_api_key_dict=admin_key_dict,
)
assert result is mock_response
# Verify the helper was called with the right parameters
mock_get_daily_agg.assert_called_once_with(
prisma_client=mock_prisma_client,
table_name="litellm_dailyuserspend",
entity_id_field="user_id",
entity_id=None, # global view: no user_id filter
entity_metadata_field=None,
start_date="2025-02-01",
end_date="2025-02-28",
model="gpt-4",
api_key=None,
timezone_offset_minutes=480,
)

View file

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

View file

@ -0,0 +1,339 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useInfiniteUsers } from "./useUsers";
import { userListCall } from "@/components/networking";
import type { UserListResponse } from "@/components/networking";
vi.mock("@/components/networking", () => ({
userListCall: vi.fn(),
}));
vi.mock("../common/queryKeysFactory", () => ({
createQueryKeys: vi.fn((resource: string) => ({
all: [resource],
lists: () => [resource, "list"],
list: (params?: any) => [resource, "list", { params }],
details: () => [resource, "detail"],
detail: (uid: string) => [resource, "detail", uid],
})),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const DEFAULT_AUTH = {
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
};
const buildUserListResponse = (
page: number,
totalPages: number,
userCount = 2,
): UserListResponse => ({
page,
page_size: 50,
total: totalPages * userCount,
total_pages: totalPages,
users: Array.from({ length: userCount }, (_, i) => ({
user_id: `user-${page}-${i}`,
user_email: `user-${page}-${i}@example.com`,
user_alias: null,
user_role: "Internal User",
spend: 0,
max_budget: null,
key_count: 0,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
sso_user_id: null,
budget_duration: null,
})),
});
describe("useInfiniteUsers", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue(DEFAULT_AUTH);
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("should return paginated user data when query is successful", async () => {
const mockResponse = buildUserListResponse(1, 2);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.pages).toHaveLength(1);
expect(result.current.data?.pages[0]).toEqual(mockResponse);
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
it("should use the default page size of 50", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
it("should use a custom page size when provided", async () => {
const customPageSize = 25;
const mockResponse = buildUserListResponse(1, 1, 5);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(customPageSize), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
customPageSize,
null,
);
});
it("should pass searchEmail to userListCall when provided", async () => {
const searchEmail = "search@example.com";
const mockResponse = buildUserListResponse(1, 1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(50, searchEmail), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
searchEmail,
);
});
it("should pass null for searchEmail when not provided", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(50, undefined), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
it("should fetch the next page when more pages are available", async () => {
const page1 = buildUserListResponse(1, 3);
const page2 = buildUserListResponse(2, 3);
let callCount = 0;
(userListCall as any).mockImplementation(async () => {
callCount++;
return callCount === 1 ? page1 : page2;
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.hasNextPage).toBe(true);
result.current.fetchNextPage();
await waitFor(() => {
expect(result.current.isFetchingNextPage).toBe(false);
expect(result.current.data?.pages).toHaveLength(2);
});
expect(result.current.data?.pages[1]).toEqual(page2);
expect(userListCall).toHaveBeenCalledTimes(2);
expect(userListCall).toHaveBeenLastCalledWith(
"test-access-token",
null,
2,
50,
null,
);
});
it("should not have a next page when on the last page", async () => {
const lastPage = buildUserListResponse(2, 2);
(userListCall as any).mockResolvedValue(lastPage);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.hasNextPage).toBe(false);
});
it("should not execute query when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
accessToken: null,
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should not execute query when userRole is not an admin role", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
userRole: "Internal User",
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should not execute query when both accessToken and userRole are invalid", async () => {
mockUseAuthorized.mockReturnValue({
...DEFAULT_AUTH,
accessToken: null,
userRole: "App User",
});
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userListCall).not.toHaveBeenCalled();
});
it("should execute query for each admin role", async () => {
const adminRoles = [
"Admin",
"Admin Viewer",
"proxy_admin",
"proxy_admin_viewer",
"org_admin",
];
for (const role of adminRoles) {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
mockUseAuthorized.mockReturnValue({ ...DEFAULT_AUTH, userRole: role });
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledTimes(1);
}
});
it("should handle error when userListCall fails", async () => {
const testError = new Error("Failed to fetch users");
(userListCall as any).mockRejectedValue(testError);
const { result } = renderHook(() => useInfiniteUsers(), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(testError);
expect(result.current.data).toBeUndefined();
});
it("should pass empty string searchEmail as null", async () => {
const mockResponse = buildUserListResponse(1, 1);
(userListCall as any).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useInfiniteUsers(50, ""), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(userListCall).toHaveBeenCalledWith(
"test-access-token",
null,
1,
50,
null,
);
});
});

View file

@ -0,0 +1,41 @@
import { userListCall, UserListResponse } from "@/components/networking";
import { useInfiniteQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { all_admin_roles } from "@/utils/roles";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const infiniteUsersKeys = createQueryKeys("infiniteUsers");
const DEFAULT_PAGE_SIZE = 50;
export const useInfiniteUsers = (
pageSize: number = DEFAULT_PAGE_SIZE,
searchEmail?: string,
) => {
const { accessToken, userRole } = useAuthorized();
return useInfiniteQuery<UserListResponse>({
queryKey: infiniteUsersKeys.list({
filters: {
pageSize,
...(searchEmail && { searchEmail }),
},
}),
queryFn: async ({ pageParam }) => {
return await userListCall(
accessToken!,
null, // userIDs
pageParam as number, // page
pageSize, // page_size
searchEmail || null, // userEmail
);
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.total_pages) {
return lastPage.page + 1;
}
return undefined;
},
enabled: Boolean(accessToken) && all_admin_roles.includes(userRole!),
});
};

View file

@ -2,6 +2,7 @@ import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
@ -116,6 +117,10 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
useInfiniteUsers: vi.fn(),
}));
vi.mock("antd", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("antd")>();
@ -223,6 +228,10 @@ vi.mock("@ant-design/icons", async () => {
return React.createElement("span");
}
function LoadingOutlined(props: any) {
return React.createElement("span", { "data-testid": "loading-icon", ...props });
}
return {
GlobalOutlined: Icon,
BankOutlined: Icon,
@ -235,6 +244,8 @@ vi.mock("@ant-design/icons", async () => {
ClockCircleOutlined: Icon,
CalendarOutlined: Icon,
InfoCircleOutlined: Icon,
UserOutlined: Icon,
LoadingOutlined,
};
});
@ -320,11 +331,13 @@ vi.mock("@tremor/react", async () => {
describe("UsagePage", () => {
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
const mockTagListCall = vi.mocked(networking.tagListCall);
const mockUseCustomers = vi.mocked(useCustomers);
const mockUseAgents = vi.mocked(useAgents);
const mockUseAuthorized = vi.mocked(useAuthorized);
const mockUseCurrentUser = vi.mocked(useCurrentUser);
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
const mockSpendData = {
results: [
@ -487,6 +500,8 @@ describe("UsagePage", () => {
beforeEach(() => {
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
token: "mock-token",
accessToken: "test-token",
userId: "user-123",
@ -505,8 +520,30 @@ describe("UsagePage", () => {
error: null,
} as any);
mockUserDailyActivityAggregatedCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockTagListCall.mockClear();
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockUseInfiniteUsers.mockReturnValue({
data: {
pages: [
{
users: [
{ user_id: "user-001", user_alias: "Alice", user_email: "alice@example.com" },
{ user_id: "user-002", user_alias: null, user_email: "bob@example.com" },
{ user_id: "user-003", user_alias: null, user_email: null },
],
page: 1,
total_pages: 1,
total_count: 3,
},
],
pageParams: [1],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
} as any);
mockTagListCall.mockResolvedValue({});
mockUseCustomers.mockReturnValue({
data: [],
@ -661,4 +698,434 @@ describe("UsagePage", () => {
expect(entityUsageElements.length).toBeGreaterThan(0);
});
});
describe("admin user selector", () => {
it("should render user selector for admin users in global view", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Admin should see the user selector select element with the placeholder attribute
const userSelects = screen.getAllByRole("combobox");
const userSelect = userSelects.find(
(el) => el.getAttribute("placeholder") === "All Users (Global View)",
);
expect(userSelect).toBeDefined();
});
it("should format user options with alias when available", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// User with alias should show "alias (id)"
expect(screen.getByText("Alice (user-001)")).toBeInTheDocument();
// User without alias but with email should show "email (id)"
expect(screen.getByText("bob@example.com (user-002)")).toBeInTheDocument();
// User with neither alias nor email should show just the id
expect(screen.getByText("user-003")).toBeInTheDocument();
});
it("should call useInfiniteUsers with debounced search", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// useInfiniteUsers should be called with default page size
expect(mockUseInfiniteUsers).toHaveBeenCalledWith(50, undefined);
});
it("should deduplicate users across pages", async () => {
mockUseInfiniteUsers.mockReturnValue({
data: {
pages: [
{
users: [
{ user_id: "user-dup", user_alias: "DupUser", user_email: null },
],
page: 1,
total_pages: 2,
total_count: 2,
},
{
users: [
{ user_id: "user-dup", user_alias: "DupUser", user_email: null },
{ user_id: "user-unique", user_alias: "UniqueUser", user_email: null },
],
page: 2,
total_pages: 2,
total_count: 2,
},
],
pageParams: [1, 2],
},
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
} as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Duplicate user should appear only once
const dupElements = screen.getAllByText("DupUser (user-dup)");
expect(dupElements).toHaveLength(1);
// Unique user should also appear
expect(screen.getByText("UniqueUser (user-unique)")).toBeInTheDocument();
});
it("should pass selected userId to aggregated call", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Initially called with null (global view for admin)
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
null,
);
});
});
describe("non-admin user behavior", () => {
it("should not render user selector for non-admin users", async () => {
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
token: "mock-token",
accessToken: "test-token",
userId: "user-123",
userEmail: "test@example.com",
userRole: "Internal User",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Non-admin should not see the user selector
const userSelects = screen.getAllByRole("combobox");
const userSelect = userSelects.find(
(el) => el.getAttribute("placeholder") === "All Users (Global View)",
);
expect(userSelect).toBeUndefined();
});
it("should always pass own userId for non-admin users", async () => {
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
token: "mock-token",
accessToken: "test-token",
userId: "user-123",
userEmail: "test@example.com",
userRole: "Internal User",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
"user-123",
);
});
});
});
describe("aggregated endpoint fallback", () => {
it("should fall back to paginated calls when aggregated endpoint fails", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available"));
mockUserDailyActivityCall.mockResolvedValue({
...mockSpendData,
metadata: {
...mockSpendData.metadata,
total_pages: 1,
page: 1,
},
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockUserDailyActivityCall).toHaveBeenCalled();
});
// Should still render the data from the paginated fallback
expect(screen.getByText("1,500")).toBeInTheDocument();
});
it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available"));
const page1Data = {
results: [mockSpendData.results[0]],
metadata: {
total_spend: 60,
total_api_requests: 700,
total_successful_requests: 680,
total_failed_requests: 20,
total_tokens: 35000,
total_pages: 2,
page: 1,
},
};
const page2Data = {
results: [
{
...mockSpendData.results[0],
date: "2025-01-02",
},
],
metadata: {
total_spend: 65.75,
total_api_requests: 800,
total_successful_requests: 770,
total_failed_requests: 30,
total_tokens: 40000,
total_pages: 2,
page: 2,
},
};
mockUserDailyActivityCall
.mockResolvedValueOnce(page1Data)
.mockResolvedValueOnce(page2Data);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
// Both pages should have been fetched
expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(2);
});
// Verify first page call
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
1,
null,
);
// Verify second page call
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
2,
null,
);
});
});
describe("MCP Server Activity tab", () => {
it("should render MCP Server Activity tab", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// The tab list should contain MCP Server Activity
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();
});
});
describe("User Agent Activity view", () => {
it("should render User Agent Activity component when view is selected", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const usageSelect = screen.getByTestId("usage-view-select");
act(() => {
fireEvent.change(usageSelect, { target: { value: "user-agent-activity" } });
});
await waitFor(() => {
// "User Agent Activity" appears both in the select option and in the rendered component
const elements = screen.getAllByText("User Agent Activity");
expect(elements.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("Export Data button", () => {
it("should render Export Data button in global view for admin", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Export Data")).toBeInTheDocument();
});
});
describe("model view toggle", () => {
it("should show Public Model Name view by default", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Default should be "groups" view showing "Top Public Model Names"
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
expect(screen.getByText("Public Model Name")).toBeInTheDocument();
expect(screen.getByText("Litellm Model Name")).toBeInTheDocument();
});
it("should switch to Litellm Model Name view on toggle click", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Click the "Litellm Model Name" toggle
const litellmToggle = screen.getByText("Litellm Model Name");
act(() => {
fireEvent.click(litellmToggle);
});
// Title should change to "Top Litellm Models"
await waitFor(() => {
expect(screen.getByText("Top Litellm Models")).toBeInTheDocument();
});
});
it("should switch back to Public Model Name view", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Switch to individual first
const litellmToggle = screen.getByText("Litellm Model Name");
act(() => {
fireEvent.click(litellmToggle);
});
await waitFor(() => {
expect(screen.getByText("Top Litellm Models")).toBeInTheDocument();
});
// Switch back to groups
const publicToggle = screen.getByText("Public Model Name");
act(() => {
fireEvent.click(publicToggle);
});
await waitFor(() => {
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
});
});
});
describe("customer usage banner", () => {
it("should show and be dismissible in customer view", async () => {
mockUseCustomers.mockReturnValue({
data: mockCustomers,
isLoading: false,
error: null,
} as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const usageSelect = screen.getByTestId("usage-view-select");
act(() => {
fireEvent.change(usageSelect, { target: { value: "customer" } });
});
await waitFor(() => {
expect(screen.getByText("Customer usage is a new feature.")).toBeInTheDocument();
});
// Click the close button
const closeButton = screen.getByLabelText("Close");
act(() => {
fireEvent.click(closeButton);
});
await waitFor(() => {
expect(screen.queryByText("Customer usage is a new feature.")).not.toBeInTheDocument();
});
});
});
describe("agent usage banner", () => {
it("should show agent usage banner with A2A info", async () => {
mockUseAgents.mockReturnValue({
data: { agents: mockAgents },
isLoading: false,
error: null,
} as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
const usageSelect = screen.getByTestId("usage-view-select");
act(() => {
fireEvent.change(usageSelect, { target: { value: "agent" } });
});
await waitFor(() => {
expect(screen.getByText("Agent usage (A2A) is a new feature.")).toBeInTheDocument();
});
});
});
describe("tab navigation in global view", () => {
it("should render all expected tabs", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Cost")).toBeInTheDocument();
expect(screen.getByText("Model Activity")).toBeInTheDocument();
expect(screen.getByText("Key Activity")).toBeInTheDocument();
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
});
});
});

View file

@ -6,7 +6,7 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { InfoCircleOutlined } from "@ant-design/icons";
import { InfoCircleOutlined, LoadingOutlined, UserOutlined } from "@ant-design/icons";
import {
BarChart,
Card,
@ -21,13 +21,15 @@ import {
Text,
Title
} from "@tremor/react";
import { Alert, Segmented, Tooltip } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Segmented, Select, Tooltip } from "antd";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import React, { useCallback, useEffect, useMemo, useState, type UIEvent } from "react";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Button } from "@tremor/react";
import { all_admin_roles } from "../../../utils/roles";
@ -81,6 +83,62 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { data: currentUser } = useCurrentUser();
console.log(`currentUser: ${JSON.stringify(currentUser)}`);
console.log(`currentUser max budget: ${currentUser?.max_budget}`);
const isAdmin = all_admin_roles.includes(userRole || "");
// Debounced search for user selector
const [userSearchInput, setUserSearchInput] = useState("");
const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", {
wait: 300,
});
const {
data: usersInfiniteData,
fetchNextPage: fetchNextUsersPage,
hasNextPage: hasNextUsersPage,
isFetchingNextPage: isFetchingNextUsersPage,
isLoading: isLoadingUsers,
} = useInfiniteUsers(50, debouncedUserSearch || undefined);
const userOptions = useMemo(() => {
if (!usersInfiniteData?.pages) return [];
const seen = new Set<string>();
const result: { value: string; label: string }[] = [];
for (const page of usersInfiniteData.pages) {
for (const user of page.users) {
if (seen.has(user.user_id)) continue;
seen.add(user.user_id);
result.push({
value: user.user_id,
label: user.user_alias
? `${user.user_alias} (${user.user_id})`
: user.user_email
? `${user.user_email} (${user.user_id})`
: user.user_id,
});
}
}
return result;
}, [usersInfiniteData]);
const handleUserSearchChange = (value: string) => {
setUserSearchInput(value);
setDebouncedUserSearch(value);
};
const handleUserPopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) {
fetchNextUsersPage();
}
};
// For admins: null means global view (all users), a string means filter by that user
// For non-admins: always set to their own user ID
const [selectedUserId, setSelectedUserId] = useState<string | null>(
isAdmin ? null : (userID || null)
);
const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups");
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
@ -107,6 +165,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
getAllTags();
}, [accessToken]);
// Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render)
useEffect(() => {
if (!isAdmin && userID) {
setSelectedUserId(userID);
}
}, [isAdmin, userID]);
// Derived states from userSpendData
const totalSpend = userSpendData.metadata?.total_spend || 0;
@ -301,6 +366,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const fetchUserSpendData = useCallback(async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
// For non-admins, always pass their own user_id
const effectiveUserId = isAdmin ? selectedUserId : (userID || null);
setLoading(true);
// Create new Date objects to avoid mutating the original dates
@ -310,14 +378,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
try {
// Prefer aggregated endpoint to avoid many page requests
try {
const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime);
const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId);
setUserSpendData(aggregated);
return;
} catch (e) {
// Fallback to paginated calls if aggregated endpoint is unavailable
}
const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime);
const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime, 1, effectiveUserId);
if (firstPageData.metadata.total_pages <= 1) {
setUserSpendData(firstPageData);
@ -328,7 +396,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const aggregatedMetadata = { ...firstPageData.metadata };
for (let page = 2; page <= firstPageData.metadata.total_pages; page++) {
const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page);
const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page, effectiveUserId);
allResults.push(...pageData.results);
if (pageData.metadata) {
aggregatedMetadata.total_spend += pageData.metadata.total_spend || 0;
@ -349,7 +417,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
setLoading(false);
setIsDateChanging(false);
}
}, [accessToken, dateValue.from, dateValue.to]);
}, [accessToken, dateValue.from, dateValue.to, selectedUserId, isAdmin, userID]);
// Super responsive date change handler
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
@ -423,12 +491,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<UsageViewSelect
value={usageView}
onChange={(value) => setUsageView(value)}
isAdmin={all_admin_roles.includes(userRole || "")}
isAdmin={isAdmin}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
{/* Your Usage Panel */}
{usageView === "global" && (
<>
<TabGroup>
<div className="flex justify-between items-center">
<TabList variant="solid" className="mt-1">
@ -460,24 +529,61 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content mb-2 mt-2 text-lg">
Project Spend{" "}
{dateValue.from && dateValue.to && (
<>
{dateValue.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
})}
{" - "}
{dateValue.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</>
<div className="flex items-center gap-4 mt-2 mb-2">
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content text-lg">
Project Spend{" "}
{dateValue.from && dateValue.to && (
<>
{dateValue.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
})}
{" - "}
{dateValue.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</>
)}
</Text>
{isAdmin && (
<div className="flex items-center gap-2">
<UserOutlined style={{ fontSize: "14px", color: "#6b7280" }} />
<Select
showSearch
allowClear
style={{ width: 300 }}
placeholder="All Users (Global View)"
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
/>
{selectedUserId && (
<span className="text-xs text-gray-500">
Filtering by user
</span>
)}
</div>
)}
</Text>
</div>
<ViewUserSpend
userSpend={totalSpend}
@ -694,6 +800,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</TabPanel>
</TabPanels>
</TabGroup>
</>
)}
{/* Organization Usage Panel */}

View file

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

View file

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