From b609f5841b030029fa885149731ed43ab26dddbb Mon Sep 17 00:00:00 2001 From: shin-bot-litellm Date: Mon, 16 Feb 2026 20:31:21 -0800 Subject: [PATCH] fix: add missing OpenAI chat completion params to OPENAI_CHAT_COMPLETION_PARAMS (#21360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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 Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Ishaan Jaff Co-authored-by: Sameer Kankute Co-authored-by: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Co-authored-by: Krish Dholakia --- .github/workflows/test_server_root_path.yml | 96 ++ .../blog/claude_code_beta_headers/index.md | 363 +++---- docs/my-website/docs/proxy/config_settings.md | 1 + litellm/constants.py | 4 + .../litellm_content_filter/content_filter.py | 159 +-- .../internal_user_endpoints.py | 48 +- .../key_management_endpoints.py | 60 +- litellm/types/utils.py | 4 + model_prices_and_context_window.json | 27 + pyproject.toml | 4 +- .../base_responses_api.py | 2 +- .../containers/test_container_integration.py | 11 +- .../test_meta_llama_chat_transformation.py | 82 +- .../test_publicai_chat_transformation.py | 16 +- .../test_vertex_ai_rerank_transformation.py | 12 +- .../test_internal_user_endpoints.py | 134 ++- tests/test_litellm/proxy/test_proxy_cli.py | 36 +- .../(dashboard)/hooks/users/useUsers.test.ts | 339 +++++++ .../app/(dashboard)/hooks/users/useUsers.ts | 41 + .../components/UsagePageView.test.tsx | 467 +++++++++ .../UsagePage/components/UsagePageView.tsx | 157 ++- .../src/components/networking.tsx | 10 +- .../GuardrailViewer/GuardrailViewer.tsx | 932 +++++++++++------- .../LogDetailsDrawer/LogDetailContent.tsx | 23 +- 24 files changed, 2199 insertions(+), 829 deletions(-) create mode 100644 .github/workflows/test_server_root_path.yml create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts diff --git a/.github/workflows/test_server_root_path.yml b/.github/workflows/test_server_root_path.yml new file mode 100644 index 0000000000..bc55981750 --- /dev/null +++ b/.github/workflows/test_server_root_path.yml @@ -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|>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:
1. Check if header exists in mapping
2. Filter out null values
3. Map to provider-specific names - + LP->>Provider: Request with filtered & mapped headers Note over LP,Provider: anthropic-beta: mapped-header2
(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 diff --git a/docs/my-website/docs/proxy/config_settings.md b/docs/my-website/docs/proxy/config_settings.md index 5e3f56c420..775cdf6876 100644 --- a/docs/my-website/docs/proxy/config_settings.md +++ b/docs/my-website/docs/proxy/config_settings.md @@ -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 diff --git a/litellm/constants.py b/litellm/constants.py index 7c21111d31..458f48cb0b 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -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 = [ diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 9d1c254d1a..55746e5e52 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -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), ), ) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index c028540785..57b6453ac4 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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)) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 9dcc25e7a8..21459a1b80 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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 diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5fbfd23b2d..5f8798c771 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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"] diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9ea9f39b1d..41acb5c810 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 68b38fb5ff..4deb61836b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" ] diff --git a/tests/llm_responses_api_testing/base_responses_api.py b/tests/llm_responses_api_testing/base_responses_api.py index 0850f74223..f38ce67ced 100644 --- a/tests/llm_responses_api_testing/base_responses_api.py +++ b/tests/llm_responses_api_testing/base_responses_api.py @@ -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)), diff --git a/tests/test_litellm/containers/test_container_integration.py b/tests/test_litellm/containers/test_container_integration.py index b2f52fcea9..177996abd9 100644 --- a/tests/test_litellm/containers/test_container_integration.py +++ b/tests/test_litellm/containers/test_container_integration.py @@ -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 ) diff --git a/tests/test_litellm/llms/meta_llama/test_meta_llama_chat_transformation.py b/tests/test_litellm/llms/meta_llama/test_meta_llama_chat_transformation.py index fa605154bb..7b974aba35 100644 --- a/tests/test_litellm/llms/meta_llama/test_meta_llama_chat_transformation.py +++ b/tests/test_litellm/llms/meta_llama/test_meta_llama_chat_transformation.py @@ -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" diff --git a/tests/test_litellm/llms/publicai/test_publicai_chat_transformation.py b/tests/test_litellm/llms/publicai/test_publicai_chat_transformation.py index f6e5e05fe5..cd530cd3b4 100644 --- a/tests/test_litellm/llms/publicai/test_publicai_chat_transformation.py +++ b/tests/test_litellm/llms/publicai/test_publicai_chat_transformation.py @@ -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"}], diff --git a/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_transformation.py b/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_transformation.py index fbf5239797..5e29f927b6 100644 --- a/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/rerank/test_vertex_ai_rerank_transformation.py @@ -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", diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 919af96f76..9a417f3566 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1167,4 +1167,136 @@ def test_generate_request_base_validator(): # Test with None req = GenerateRequestBase(max_budget=None) - assert req.max_budget is None \ No newline at end of file + 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, + ) \ No newline at end of file diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index be91800732..a18c2dba03 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -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() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts new file mode 100644 index 0000000000..b0a96eff0e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.test.ts @@ -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, + ); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts new file mode 100644 index 0000000000..cb30299f46 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/users/useUsers.ts @@ -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({ + 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!), + }); +}; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index 1a344d3dd9..5f5ffe83ba 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -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(); @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 688ee73767..f81da6e245 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -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 = ({ 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(); + 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) => { + 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( + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ teams, organizations }) => { setUsageView(value)} - isAdmin={all_admin_roles.includes(userRole || "")} + isAdmin={isAdmin} /> {/* Your Usage Panel */} {usageView === "global" && ( + <>
@@ -460,24 +529,61 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {/* Total Spend Card */} - - 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", - })} - +
+ + 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", + })} + + )} + + {isAdmin && ( +
+ +