mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
b67c140938
commit
b609f5841b
24 changed files with 2199 additions and 829 deletions
96
.github/workflows/test_server_root_path.yml
vendored
Normal file
96
.github/workflows/test_server_root_path.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue