Merge branch 'litellm_internal_staging' into litellm_e2e_deflake_fallback_cache

This commit is contained in:
yuneng-jiang 2026-08-27 11:23:44 -07:00 committed by GitHub
commit 938ed2c2a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 10774 additions and 1196 deletions

View file

@ -83,6 +83,24 @@ jobs:
if: steps.changes.outputs.relevant == 'true'
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Regenerate the lazy OpenAPI snapshot
if: steps.changes.outputs.relevant == 'true'
run: uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
- name: Fail if the lazy OpenAPI snapshot is stale
if: steps.changes.outputs.relevant == 'true'
run: |
if ! git diff --exit-code -- litellm/proxy/_lazy_openapi_snapshot.json; then
echo "::error file=litellm/proxy/_lazy_openapi_snapshot.json::The lazy OpenAPI snapshot is out of sync with the lazily loaded routes."
echo ""
echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features."
echo "To fix, run from the repo root:"
echo " uv run python -m litellm.proxy._lazy_openapi_snapshot"
echo "then run npm run gen:api from ui/litellm-dashboard and commit both files."
exit 1
fi
echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes."
- name: Set up Node.js
if: steps.changes.outputs.relevant == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0

View file

@ -149,7 +149,6 @@ class PromptManager:
)
self.prompts[template_id] = template
except Exception:
# Optional: print(f"Error loading prompt from JSON: {template_id}")
pass
def _load_prompt_file(self, file_path: str | Path, prompt_id: str) -> PromptTemplate:

View file

@ -2341,6 +2341,7 @@ def exception_type(
litellm_response_headers: Final = _get_response_headers(original_exception=original_exception)
try:
error_str = redact_string(str(original_exception)) if _ENABLE_SECRET_REDACTION else str(original_exception)
extra_information = ""
if model or custom_llm_provider:
if hasattr(original_exception, "message"):
error_str = (
@ -2357,7 +2358,6 @@ def exception_type(
# Common Extra information needed for all providers
# We pass num retries, api_base, vertex_deployment etc to the exception here
################################################################################
extra_information = ""
try:
_api_base: Final = litellm.get_api_base(model=model, optional_params=extra_kwargs)
messages: Final = litellm.get_first_chars_messages(kwargs=completion_kwargs)

View file

@ -7,7 +7,9 @@ from typing import Any, Final, Literal
import litellm
from litellm.constants import OPENAI_FILE_SEARCH_COST_PER_1K_CALLS
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_web_search_requests_from_usage,
)
from litellm.types.llms.openai import (
FileSearchTool,
ResponsesAPIResponse,
@ -368,7 +370,7 @@ class StandardBuiltInToolCostTracking:
get_anthropic_web_search_requests_from_response,
)
if usage is not None and (get_web_search_requests(getattr(usage, "server_tool_use", None)) is not None):
if usage is not None and (get_web_search_requests_from_usage(usage) is not None):
return usage
web_search_requests: Final = get_anthropic_web_search_requests_from_response(response_object)
if web_search_requests is None:
@ -416,7 +418,7 @@ class StandardBuiltInToolCostTracking:
# Anthropic Claude (direct API and Vertex AI) uses server_tool_use.web_search_requests.
# Without this check, Claude ModelResponse always falls through to return False
# and _handle_web_search_cost() is never called.
if hasattr(usage, "server_tool_use") and get_web_search_requests(usage.server_tool_use) is not None:
if get_web_search_requests_from_usage(usage) is not None:
return True
# xAI reports usage.server_side_tool_usage_details.web_search_calls; a searched
# answer with no url_citation annotations has no other chat-path signal
@ -429,16 +431,12 @@ class StandardBuiltInToolCostTracking:
response_object=response_object, output_type="web_search_call"
)
elif usage is not None:
if (
hasattr(usage, "server_tool_use")
and get_web_search_requests(usage.server_tool_use) is not None
or (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details.web_search_requests is not None
)
if get_web_search_requests_from_usage(usage) is not None or (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details.web_search_requests is not None
):
return True
if _usage_reports_server_side_web_search_calls(usage):

View file

@ -92,6 +92,16 @@ def get_web_search_requests(server_tool_use: Any) -> int | None:
return getattr(server_tool_use, "web_search_requests", None)
def get_web_search_requests_from_usage(usage: Usage) -> int | None:
"""Read ``web_search_requests`` from a ``Usage``'s ``server_tool_use``.
``Usage`` deletes unset optional fields from ``__dict__`` (see
``SafeAttributeModel``), so direct attribute access can raise
``AttributeError``; ``getattr`` with a default is required here.
"""
return get_web_search_requests(getattr(usage, "server_tool_use", None))
def _is_above_128k(tokens: float) -> bool:
if tokens > 128000:
return True

View file

@ -10,7 +10,7 @@ from pydantic import BaseModel, ValidationError
from litellm.litellm_core_utils.llm_cost_calc.utils import (
generic_cost_per_token,
get_provider_specific_geo_multiplier,
get_web_search_requests,
get_web_search_requests_from_usage,
)
if TYPE_CHECKING:
@ -104,7 +104,7 @@ def get_cost_for_anthropic_web_search(
if usage is None:
return 0.0
web_search_requests: Final = get_web_search_requests(getattr(usage, "server_tool_use", None))
web_search_requests: Final = get_web_search_requests_from_usage(usage)
if web_search_requests is None:
return 0.0

View file

@ -1358,12 +1358,10 @@ class LiteLLMAnthropicMessagesAdapter:
@classmethod
def _get_web_search_request_count(cls, usage: Usage) -> int:
from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_web_search_requests,
get_web_search_requests_from_usage,
)
from_server_tool_use: Final = cls._positive_int(
get_web_search_requests(getattr(usage, "server_tool_use", None))
)
from_server_tool_use: Final = cls._positive_int(get_web_search_requests_from_usage(usage))
if from_server_tool_use > 0:
return from_server_tool_use
return cls._first_positive_prompt_tokens_detail_value(usage, ("web_search_requests",))

View file

@ -1434,9 +1434,12 @@ class BaseAWSLLM:
data: str | bytes,
headers: dict,
api_key: str | None = None,
supports_bearer_token: bool = True,
) -> AWSPreparedRequest:
if api_key is not None:
aws_bearer_token: str | None = api_key
if not supports_bearer_token:
aws_bearer_token: str | None = None
elif api_key is not None:
aws_bearer_token = api_key
else:
aws_bearer_token = get_secret_str("AWS_BEARER_TOKEN_BEDROCK")

View file

@ -138,11 +138,6 @@ class BedrockRerankHandler(BaseAWSLLM):
data: dict,
optional_params: dict,
) -> BedrockPreparedRequest:
try:
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
except ImportError:
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params(optional_params, model)
### SET RUNTIME ENDPOINT ###
@ -153,24 +148,21 @@ class BedrockRerankHandler(BaseAWSLLM):
)
proxy_endpoint_url = proxy_endpoint_url.replace("bedrock-runtime", "bedrock-agent-runtime")
proxy_endpoint_url = f"{proxy_endpoint_url}/rerank"
sigv4: Final = SigV4Auth(
boto3_credentials_info.credentials,
"bedrock",
boto3_credentials_info.aws_region_name,
)
# Make POST Request
body: Final = json.dumps(data).encode("utf-8")
body: Final = json.dumps(data).encode("utf-8")
headers = {"Content-Type": "application/json"}
if extra_headers is not None:
headers = {"Content-Type": "application/json", **extra_headers}
request: Final = AWSRequest(method="POST", url=proxy_endpoint_url, data=body, headers=headers)
sigv4.add_auth(request)
if (
extra_headers is not None and "Authorization" in extra_headers
): # prevent sigv4 from overwriting the auth header
request.headers["Authorization"] = extra_headers["Authorization"]
prepped: Final = request.prepare()
prepped: Final = self.get_request_headers(
credentials=boto3_credentials_info.credentials,
aws_region_name=boto3_credentials_info.aws_region_name,
extra_headers=extra_headers,
endpoint_url=proxy_endpoint_url,
data=body,
headers=headers,
supports_bearer_token=False,
)
return BedrockPreparedRequest(
endpoint_url=proxy_endpoint_url,

View file

@ -39,7 +39,9 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
``model_info`` when available, falling back to $0.035 for models not
yet updated in the pricing JSON.
"""
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_web_search_requests_from_usage,
)
from litellm.types.utils import PromptTokensDetailsWrapper
_DEFAULT_COST: Final = 35e-3
@ -57,7 +59,7 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
)
else None
)
requests_from_server_tool_use: Final = get_web_search_requests(getattr(usage, "server_tool_use", None))
requests_from_server_tool_use: Final = get_web_search_requests_from_usage(usage)
number_of_web_search_requests: Final = requests_from_prompt_details or requests_from_server_tool_use or 0
billing_mode: Final = model_info.get("web_search_billing_unit") or "per_prompt"

View file

@ -239,5 +239,6 @@ def resolve_bridge_envelope(
if opened.identity.server_id != expected_server_id:
return BridgeEnvelopeInvalid()
grant: Final = opened.grant
upstream_authorization: Final = f"{grant.token_type} {grant.access_token.get_secret_value()}"
authorization_scheme: Final = "Bearer" if grant.token_type.lower() == "bearer" else grant.token_type
upstream_authorization: Final = f"{authorization_scheme} {grant.access_token.get_secret_value()}"
return BridgeEnvelopeAdmitted(identity=opened.identity, upstream_authorization=SecretStr(upstream_authorization))

File diff suppressed because it is too large Load diff

View file

@ -3,18 +3,27 @@ Per-feature OpenAPI snapshot for lazy-loaded routers.
The committed JSON is generated by `python -m litellm.proxy._lazy_openapi_snapshot`
and consumed at runtime so /openapi.json can show full route info for unloaded
features without importing them. No CI job regenerates this file; drift surfaces
only indirectly through check-ui-api-types.yml, which rebuilds schema.d.ts from
app.openapi() with the committed snapshot injected. After changing any lazily
loaded route or this generator, rerun the module and commit the JSON, then run
`npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
features without importing them. check-ui-api-types.yml (mirrored locally by
`make check`) regenerates this file and fails when the committed copy differs,
then rebuilds schema.d.ts from app.openapi() with the snapshot injected. After
changing any lazily loaded route or this generator, rerun the module and commit
the JSON, then run `npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
"""
import json
import re
import sys
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from typing import TYPE_CHECKING, Final
from typing_extensions import ReadOnly, TypedDict
if TYPE_CHECKING:
from fastapi import FastAPI
from litellm.proxy._lazy_features import LazyFeature
SNAPSHOT_FILE: Final = Path(__file__).parent / "_lazy_openapi_snapshot.json"
HTTP_METHOD_SUFFIXES: Final = {
@ -83,51 +92,84 @@ def _normalize_operation_ids(paths: dict[str, dict]) -> None:
break
def generate_snapshot() -> dict[str, dict]:
class SnapshotFragment(TypedDict):
paths: ReadOnly[Mapping[str, Mapping[str, object]]]
components: ReadOnly[Mapping[str, Mapping[str, object]]]
@dataclass(frozen=True, slots=True)
class SnapshotResult:
fragments: Mapping[str, SnapshotFragment]
skipped: tuple[str, ...]
def _register_feature(app: "FastAPI", feat: "LazyFeature") -> str | None:
import importlib
try:
feat.register_fn(app, importlib.import_module(feat.module_path))
except Exception as exc:
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
return feat.name
return None
def _feature_fragment(app: "FastAPI", feat: "LazyFeature", used_operation_ids: set[str]) -> SnapshotFragment | None:
from fastapi.openapi.utils import get_openapi
from litellm.proxy.proxy_server import ensure_unique_openapi_operation_ids
feat_routes: Final = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
if not feat_routes:
return None
_stabilize_multi_method_route_ids(feat_routes)
full: Final = get_openapi(title=app.title, version=app.version, routes=feat_routes)
paths: Final = full.get("paths", {})
_normalize_operation_ids(paths)
# Group all of a feature's routes under one tag.
for path_ops in paths.values():
for method, op in path_ops.items():
if isinstance(op, dict):
operation_id = op.get("operationId")
if isinstance(operation_id, str):
for suffix in HTTP_METHOD_SUFFIXES:
if operation_id.endswith(f"_{suffix}"):
op["operationId"] = operation_id[: -len(suffix)] + method
break
op["tags"] = [feat.name]
unique: Final = ensure_unique_openapi_operation_ids(full, used_operation_ids)
return {
"paths": paths,
"components": {"schemas": unique.get("components", {}).get("schemas", {})},
}
def generate_snapshot() -> SnapshotResult:
from litellm.proxy._lazy_features import LAZY_FEATURES
from litellm.proxy.proxy_server import app, ensure_unique_openapi_operation_ids
from litellm.proxy.proxy_server import app
for feat in LAZY_FEATURES:
try:
module = importlib.import_module(feat.module_path)
feat.register_fn(app, module)
except Exception as exc:
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
fragments: Final[dict[str, dict]] = {}
skipped: Final = tuple(name for feat in LAZY_FEATURES if (name := _register_feature(app, feat)) is not None)
used_operation_ids: Final[set[str]] = set()
for feat in LAZY_FEATURES:
feat_routes = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
if not feat_routes:
continue
_stabilize_multi_method_route_ids(feat_routes)
full = get_openapi(title=app.title, version=app.version, routes=feat_routes)
paths = full.get("paths", {})
_normalize_operation_ids(paths)
# Group all of a feature's routes under one tag.
for path_ops in full.get("paths", {}).values():
for method, op in path_ops.items():
if isinstance(op, dict):
operation_id = op.get("operationId")
if isinstance(operation_id, str):
for suffix in HTTP_METHOD_SUFFIXES:
if operation_id.endswith(f"_{suffix}"):
op["operationId"] = operation_id[: -len(suffix)] + method
break
op["tags"] = [feat.name]
full = ensure_unique_openapi_operation_ids(full, used_operation_ids)
fragments[feat.name] = {
"paths": paths,
"components": {"schemas": full.get("components", {}).get("schemas", {})},
}
return fragments
fragments: Final = {
feat.name: fragment
for feat in LAZY_FEATURES
if (fragment := _feature_fragment(app, feat, used_operation_ids)) is not None
}
return SnapshotResult(fragments=fragments, skipped=skipped)
def main(snapshot_file: Path = SNAPSHOT_FILE, generate: Callable[[], SnapshotResult] = generate_snapshot) -> int:
result: Final = generate()
if result.skipped:
sys.stderr.write(
f"error: {len(result.skipped)} feature(s) failed to import, so their fragments would vanish from the "
f"snapshot: {', '.join(result.skipped)}\n"
)
return 1
snapshot_file.write_text(json.dumps(result.fragments, indent=2, sort_keys=True) + "\n")
sys.stdout.write(f"wrote {len(result.fragments)} feature fragments to {snapshot_file}\n")
return 0
if __name__ == "__main__":
fragments: Final = generate_snapshot()
SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n")
sys.stdout.write(f"wrote {len(fragments)} feature fragments to {SNAPSHOT_FILE}\n")
sys.exit(main())

View file

@ -808,6 +808,15 @@ def normalize_email(email: str | None) -> str | None:
return email.lower() if isinstance(email, str) else email
# Ordered highest to lowest privilege
LITELLM_USER_ROLE_HIERARCHY: Final = (
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
)
def determine_role_from_groups(
user_groups: list[str],
role_mappings: "RoleMappings",
@ -832,19 +841,11 @@ def determine_role_from_groups(
# No role mappings configured, return default_role
return role_mappings.default_role
# Role hierarchy (highest to lowest)
role_hierarchy: Final = [
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
]
# Convert user_groups to a set for efficient lookup
user_groups_set: Final = set(user_groups) if isinstance(user_groups, list) else set()
# Find the highest privilege role the user belongs to
for role in role_hierarchy:
for role in LITELLM_USER_ROLE_HIERARCHY:
if role in role_mappings.roles:
role_groups = role_mappings.roles[role]
if isinstance(role_groups, list) and user_groups_set.intersection(set(role_groups)):
@ -4236,15 +4237,7 @@ class MicrosoftSSOHandler:
verbose_proxy_logger.debug("Extracted app roles from id_token: %s", app_roles)
# Combine groups and app roles
user_role: LitellmUserRoles | None = None
if app_roles:
# Check if any app role is a valid LitellmUserRoles
for role_str in app_roles:
role = get_litellm_user_role(role_str)
if role is not None:
user_role = role
verbose_proxy_logger.debug("Found valid LitellmUserRoles '%s' in app_roles", role.value)
break
user_role: Final = MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles)
verbose_proxy_logger.debug("Combined team_ids (groups + app roles): %s", user_team_ids)
@ -4282,6 +4275,27 @@ class MicrosoftSSOHandler:
verbose_proxy_logger.debug("Microsoft SSO OpenID Response: %s", openid_response)
return openid_response
@staticmethod
def get_user_role_from_app_roles(
app_roles: Sequence[str] | None,
) -> LitellmUserRoles | None:
"""
Resolve the one role LiteLLM stores for a user from their Entra app roles.
Entra does not guarantee `roles` claim ordering, so a user holding several app
roles resolves to the highest privilege one rather than whichever the claim
listed first. Roles the hierarchy does not rank (org_admin, team, customer)
resolve by name to stay deterministic
"""
resolved: Final = frozenset(
role for role in (get_litellm_user_role(role_str) for role_str in app_roles or ()) if role is not None
)
if not resolved:
return None
ranked: Final = next((role for role in LITELLM_USER_ROLE_HIERARCHY if role in resolved), None)
return ranked if ranked is not None else min(resolved, key=lambda role: role.value)
@staticmethod
def get_app_roles_from_id_token(id_token: str | None) -> list[str]:
"""

View file

@ -23,6 +23,7 @@ from typing_extensions import ReadOnly
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.proxy._types import *
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -54,6 +55,11 @@ router: Final = APIRouter()
SPEND_LOGS_PAGINATION_COUNT_CAP: Final = 10000
_INTERNAL_HEALTH_CHECK_API_KEYS: Final = (
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
hash_token(token=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME),
)
_RowT = TypeVar("_RowT")
@ -2248,6 +2254,10 @@ async def ui_view_spend_logs(
status_filter: str | None = fastapi.Query(
default=None, description="Filter logs by status (e.g., success, failure)"
),
cache_hit_filter: str | None = fastapi.Query(
default=None,
description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
),
model: str | None = fastapi.Query(default=None, description="Filter logs by model"),
model_id: str | None = fastapi.Query(
default=None,
@ -2268,6 +2278,10 @@ async def ui_view_spend_logs(
default="desc",
description="Sort order: asc or desc",
),
exclude_internal_health_checks: bool = fastapi.Query(
default=False,
description="Exclude LiteLLM internal health check requests from results",
),
):
"""
View spend logs with pagination support.
@ -2320,6 +2334,13 @@ async def ui_view_spend_logs(
param="sort_order",
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(cache_hit_filter, str) and cache_hit_filter not in {"hit", "miss"}:
raise ProxyException(
message=f"Invalid cache_hit_filter: {cache_hit_filter}. Must be one of: hit, miss",
type="bad_request",
param="cache_hit_filter",
code=status.HTTP_400_BAD_REQUEST,
)
try:
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
@ -2560,6 +2581,16 @@ async def ui_view_spend_logs(
sql_params.append(status_filter)
p += 1
if cache_hit_filter == "hit":
sql_conditions.append("LOWER(cache_hit) = 'true'")
elif cache_hit_filter == "miss":
sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')")
if exclude_internal_health_checks:
sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})")
sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS)
p += 2 # rebind-ok: advances the file's shared $N placeholder counter
# Spend range
if min_spend is not None:
sql_conditions.append(f"spend >= ${p}")

View file

@ -13,7 +13,7 @@
# - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step)
# + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests)
# - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint)
# - proxy/types -> regenerate dashboard API types and fail on drift (check-ui-api-types.yml)
# - proxy/types -> regenerate the lazy OpenAPI snapshot and dashboard API types, fail on drift (check-ui-api-types.yml)
#
# Each block is skipped when no matching files are in scope, so unrelated commits
# stay fast. This is intentionally not auto-installed as a git hook (see
@ -244,7 +244,7 @@ fi
genapi_checks() {
local status=0
echo "check: checking dashboard API types are in sync (npm run gen:api)"
echo "check: checking the lazy OpenAPI snapshot and dashboard API types are in sync (npm run gen:api)"
# gen-api-types.mjs imports litellm.proxy.proxy_server, which needs the proxy deps
# and an up-to-date Prisma client; check-ui-api-types.yml installs those and runs
# prisma generate before gen:api, so mirror that here or a stale client can mask
@ -260,7 +260,14 @@ genapi_checks() {
elif ! uv run --no-sync python scripts/prisma_generate_if_needed.py; then
echo "✗ Could not regenerate Prisma client (prisma generate failed)." >&2
status=1
elif ! uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot; then
echo "✗ Could not regenerate the lazy OpenAPI snapshot (python -m litellm.proxy._lazy_openapi_snapshot failed)." >&2
status=1
elif ( cd ui/litellm-dashboard && LITELLM_PYTHON="uv run --no-sync python" npm run gen:api ); then
if ! git diff --quiet -- litellm/proxy/_lazy_openapi_snapshot.json; then
echo "✗ The lazy OpenAPI snapshot is stale; regenerated litellm/proxy/_lazy_openapi_snapshot.json. Stage it and commit; re-run make check only if other checks failed too." >&2
status=1
fi
if ! git diff --quiet -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
echo "✗ Dashboard API types are stale; regenerated src/lib/http/schema.d.ts. Stage it and commit; re-run make check only if other checks failed too." >&2
status=1

View file

@ -2661,7 +2661,7 @@ async def test_run_direct_health_check_drops_only_the_rejected_kwarg(monkeypatch
rejected argument alongside working ones would probe deployments the operator opted out."""
import litellm.proxy.proxy_server as proxy_server
seen: list = []
seen: list[tuple[dict[str, str] | None, bool]] = []
async def fake_perform_health_check(
model_list,

View file

@ -10,8 +10,8 @@ import pytest
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
StandardBuiltInToolCostTracking,
get_web_search_requests,
)
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.types.utils import ModelResponse, ServerToolUse, Usage

View file

@ -1002,6 +1002,17 @@ def test_an_exception_without_a_status_is_still_a_connection_error(quiet_excepti
)
def test_an_unmapped_exception_with_no_model_or_provider_is_a_connection_error(quiet_exception_mapping):
with pytest.raises(litellm.APIConnectionError) as raised:
exception_type(
model=None,
original_exception=ValueError("boom"),
custom_llm_provider=None,
)
assert "boom" in raised.value.message
CONTEXT_WINDOW_MESSAGE = "This model's maximum context length is 4096 tokens."
CONTENT_POLICY_MESSAGE = (
'{"error": {"type": "invalid_request_error", "code": "content_policy_violation"}}'

View file

@ -17,7 +17,12 @@ from litellm.anthropic_interface import messages
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.types.utils import Delta, ModelResponse, StreamingChoices
from litellm.types.utils import (
Delta,
ModelResponse,
StandardLoggingPayloadErrorInformation,
StreamingChoices,
)
def test_anthropic_experimental_pass_through_messages_handler():
@ -1292,7 +1297,7 @@ class TestMessagesStreamingSuccessLogging:
class _FailureCapture(CustomLogger):
def __init__(self):
super().__init__()
self.error_information: List[Dict[str, Any]] = []
self.error_information: list[StandardLoggingPayloadErrorInformation] = []
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
payload = kwargs.get("standard_logging_object") or {}

View file

@ -8,10 +8,8 @@ See https://github.com/BerriAI/litellm/issues/26153.
import pytest
from litellm.llms.anthropic.cost_calculation import (
get_cost_for_anthropic_web_search,
get_web_search_requests,
)
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.llms.anthropic.cost_calculation import get_cost_for_anthropic_web_search
from litellm.types.utils import ModelInfo, ServerToolUse

View file

@ -12,6 +12,7 @@ import pytest
import litellm
from litellm.llms.bedrock.base_aws_llm import Boto3CredentialsInfo
from litellm.llms.bedrock.rerank.handler import BedrockRerankHandler
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
# Mock response for Bedrock rerank
@ -402,6 +403,66 @@ def test_bedrock_rerank_extra_headers_and_headers_merge():
pytest.fail(f"Failed to merge and forward headers: {str(e)}")
def test_bedrock_rerank_forwarded_headers_excluded_from_sigv4_signature():
"""
A forwarded header like x-forwarded-for can be rewritten between LiteLLM
signing the request and AWS receiving it (e.g. by an intermediate load
balancer), which invalidates the signature if that header was part of
the signed set. It must still reach Bedrock, just unsigned.
"""
handler = BedrockRerankHandler()
prepared_request = handler._prepare_request(
model="cohere.rerank-v3-5:0",
api_base=None,
extra_headers={"x-forwarded-for": "203.0.113.5"},
data={"query": test_query, "documents": test_documents},
optional_params={
"aws_access_key_id": "test-access-key",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
)
headers = prepared_request["prepped"].headers
signed_headers = headers["Authorization"].split("SignedHeaders=")[1].split(",")[0].split(";")
assert "x-forwarded-for" not in signed_headers, (
f"x-forwarded-for must not be part of the SigV4 signature, got SignedHeaders={signed_headers}"
)
assert headers["x-forwarded-for"] == "203.0.113.5", "forwarded header must still reach Bedrock, unsigned"
def test_bedrock_rerank_signs_with_sigv4_even_when_bedrock_api_key_is_set(monkeypatch):
"""
Bedrock API keys are only valid for Bedrock and Bedrock Runtime actions, not for
Agents for Amazon Bedrock Runtime ones. Rerank is served by bedrock-agent-runtime,
so it has to keep signing with SigV4 even when AWS_BEARER_TOKEN_BEDROCK is set.
"""
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-api-key")
handler = BedrockRerankHandler()
prepared_request = handler._prepare_request(
model="cohere.rerank-v3-5:0",
api_base=None,
extra_headers=None,
data={"query": test_query, "documents": test_documents},
optional_params={
"aws_access_key_id": "test-access-key",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
)
assert prepared_request["endpoint_url"].startswith("https://bedrock-agent-runtime.")
authorization = prepared_request["prepped"].headers["Authorization"]
assert authorization.startswith("AWS4-HMAC-SHA256"), (
f"rerank must sign with SigV4, got Authorization={authorization[:30]}"
)
@pytest.mark.asyncio
async def test_bedrock_rerank_records_llm_api_duration():
"""The bedrock rerank handler must feed httpx timing into the logging obj, so the

View file

@ -10,6 +10,7 @@ through the consumer; and no path leaks the upstream token in a repr.
from datetime import datetime, timedelta, timezone
import pytest
from pydantic import SecretStr
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
@ -188,6 +189,31 @@ def test_resolve_strips_optional_bearer_scheme_before_detection():
assert prefixed.upstream_authorization.get_secret_value() == bare.upstream_authorization.get_secret_value()
@pytest.mark.parametrize("token_type", ("bearer", "BEARER", "beArEr"))
def test_resolve_canonicalizes_case_insensitive_bearer_token_type(token_type: str):
keys = envelope_keys_from_master_key(_MASTER_KEY)
grant = UpstreamTokenGrant(access_token=SecretStr(_ACCESS_TOKEN), token_type=token_type, expires_in=600)
sealed = mint_envelope(_IDENTITY, grant, keys, _NOW)
assert isinstance(sealed, SealedEnvelope)
result = resolve_bridge_envelope(sealed.token.get_secret_value(), keys, _NOW, _SERVER_ID)
assert isinstance(result, BridgeEnvelopeAdmitted)
assert result.upstream_authorization.get_secret_value() == f"Bearer {_ACCESS_TOKEN}"
def test_resolve_preserves_non_bearer_token_type():
keys = envelope_keys_from_master_key(_MASTER_KEY)
grant = UpstreamTokenGrant(access_token=SecretStr(_ACCESS_TOKEN), token_type="DPoP", expires_in=600)
sealed = mint_envelope(_IDENTITY, grant, keys, _NOW)
assert isinstance(sealed, SealedEnvelope)
result = resolve_bridge_envelope(sealed.token.get_secret_value(), keys, _NOW, _SERVER_ID)
assert isinstance(result, BridgeEnvelopeAdmitted)
assert result.upstream_authorization.get_secret_value() == f"DPoP {_ACCESS_TOKEN}"
def test_resolve_expired_envelope_is_invalid_not_admitted():
keys = envelope_keys_from_master_key(_MASTER_KEY)
token = _sealed_token(keys, now=_NOW)

View file

@ -1,91 +1,120 @@
import jwt
import pytest
from litellm.proxy.management_endpoints.ui_sso import MicrosoftSSOHandler
from litellm.proxy.management_endpoints.types import get_litellm_user_role
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.management_endpoints.ui_sso import MicrosoftSSOHandler
def _id_token(**claims) -> str:
"""Build a signed id_token carrying the given claims."""
payload = {
"sub": "user123",
"email": "user@company.com",
"aud": "litellm-app",
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"exp": 9999999999,
**claims,
}
return jwt.encode(payload, "secret", algorithm="HS256")
def test_extracts_proxy_admin_role_from_jwt():
"""Ensure supported app roles like 'proxy_admin' are extracted from the id_token."""
payload = {
"sub": "user123",
"email": "admin@company.com",
"app_roles": ["proxy_admin"],
"aud": "litellm-app",
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"exp": 9999999999,
}
token = _id_token(app_roles=["proxy_admin"])
token = jwt.encode(payload, "secret", algorithm="HS256")
roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
assert roles == ["proxy_admin"]
def test_maps_internal_user_role():
"""Ensure internal_user role is correctly mapped to LitellmUserRoles."""
payload = {
"sub": "user456",
"email": "user@company.com",
"app_roles": ["internal_user"],
"aud": "litellm-app",
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"exp": 9999999999,
}
def test_extracts_app_roles_from_roles_claim():
"""Entra emits app role values in the `roles` claim; both spellings are read."""
token = _id_token(roles=["internal_user"])
token = jwt.encode(payload, "secret", algorithm="HS256")
roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
# Map to LitellmUserRoles
chosen = None
for r in roles:
mapped = get_litellm_user_role(r)
if mapped is not None:
chosen = mapped
break
assert chosen == LitellmUserRoles.INTERNAL_USER
assert roles == ["internal_user"]
def test_maps_proxy_admin_viewer_role():
"""Ensure proxy_admin_viewer role is correctly mapped."""
payload = {
"sub": "user789",
"email": "viewer@company.com",
"app_roles": ["proxy_admin_viewer"],
"aud": "litellm-app",
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"exp": 9999999999,
}
token = jwt.encode(payload, "secret", algorithm="HS256")
roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
chosen = None
for r in roles:
mapped = get_litellm_user_role(r)
if mapped is not None:
chosen = mapped
break
assert chosen == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
@pytest.mark.parametrize(
"app_roles, expected",
[
(["proxy_admin"], LitellmUserRoles.PROXY_ADMIN),
(["proxy_admin_viewer"], LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
(["internal_user"], LitellmUserRoles.INTERNAL_USER),
(["internal_user_viewer"], LitellmUserRoles.INTERNAL_USER_VIEW_ONLY),
# Case-insensitive, matching get_litellm_user_role.
(["PROXY_ADMIN_VIEWER"], LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
# Roles outside the privilege hierarchy still resolve.
(["org_admin"], LitellmUserRoles.ORG_ADMIN),
],
)
def test_maps_single_app_role(app_roles, expected):
"""A lone app role maps to its LitellmUserRoles equivalent."""
assert MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles) == expected
def test_defaults_to_internal_user_viewer_when_no_role():
"""Ensure default role is internal_user_viewer when no app role is present."""
payload = {
"sub": "user_no_role",
"email": "noRole@company.com",
"aud": "litellm-app",
"iss": "https://login.microsoftonline.com/tenant-id/v2.0",
"exp": 9999999999,
}
@pytest.mark.parametrize(
"app_roles",
[
["internal_user", "proxy_admin_viewer"],
["proxy_admin_viewer", "internal_user"],
],
)
def test_highest_privilege_role_wins_regardless_of_claim_order(app_roles):
"""
A user in one group mapped to `internal_user` and another mapped to
`proxy_admin_viewer` gets the higher privilege role either way.
Entra does not guarantee the ordering of the `roles` claim, so the resolved
role must not depend on it.
"""
assert MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles) == (LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)
@pytest.mark.parametrize(
"app_roles",
[
["internal_user", "proxy_admin_viewer", "proxy_admin"],
["proxy_admin", "proxy_admin_viewer", "internal_user"],
["proxy_admin_viewer", "internal_user", "proxy_admin"],
],
)
def test_proxy_admin_beats_every_other_role(app_roles):
"""proxy_admin outranks every other role in the hierarchy, in any claim order."""
assert MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles) == LitellmUserRoles.PROXY_ADMIN
def test_unrecognised_app_roles_are_ignored():
"""App roles that are not LitellmUserRoles values do not shadow ones that are."""
app_roles = ["Some.Custom.Role", "msiam_access", "internal_user"]
assert MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles) == LitellmUserRoles.INTERNAL_USER
@pytest.mark.parametrize("app_roles", [None, [], ["msiam_access"], ["User"]])
def test_returns_none_when_no_role_resolves(app_roles):
"""
Returning None lets the caller keep the user's stored role or apply
default_internal_user_params, rather than forcing a role.
"""
assert MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles) is None
def test_no_role_claim_yields_no_app_roles():
"""An id_token with no role claim produces no app roles, and so no role."""
token = _id_token()
token = jwt.encode(payload, "secret", algorithm="HS256")
roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
assert roles == []
assert MicrosoftSSOHandler.get_user_role_from_app_roles(roles) is None
# Default role would be internal_user_viewer
default_role = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
assert default_role.value == "internal_user_viewer"
def test_end_to_end_from_id_token_to_role():
"""The id_token -> role path resolves the highest privilege role."""
token = _id_token(roles=["internal_user", "proxy_admin_viewer"])
roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
assert MicrosoftSSOHandler.get_user_role_from_app_roles(roles) == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY

View file

@ -1,6 +1,7 @@
import asyncio
import collections
import datetime
import hashlib
import json
import re
from datetime import timezone
@ -96,6 +97,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
msg = re.search(r"error_message' LIKE \$(\d+)", cond)
sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond)
status = re.fullmatch(r"status = \$(\d+)", cond)
api_key_not_in = re.fullmatch(r"api_key NOT IN \(\$(\d+), \$(\d+)\)", cond)
if gte:
date_bounds["gte"] = _iso(params[int(gte.group(1)) - 1])
elif lte:
@ -104,10 +106,19 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
where["OR"] = where.get("OR", []) + [{"multi_team": True}]
elif "status = 'success'" in cond:
where["OR"] = where.get("OR", []) + [{"status": "success"}]
elif cond == "LOWER(cache_hit) = 'true'":
where["cache_hit"] = "hit"
elif cond == "(cache_hit IS NULL OR LOWER(cache_hit) != 'true')":
where["cache_hit"] = "miss"
elif sess:
where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")}
elif status:
where["status"] = {"equals": params[int(status.group(1)) - 1]}
elif api_key_not_in:
where["api_key_not_in"] = [
params[int(api_key_not_in.group(1)) - 1],
params[int(api_key_not_in.group(2)) - 1],
]
elif alias:
metadata_conds.append(
{
@ -196,6 +207,7 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No
return MockPrismaClient()
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.proxy._types import (
LitellmUserRoles,
Member,
@ -1256,6 +1268,140 @@ async def test_ui_view_spend_logs_with_team_id(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
_HEALTH_CHECK_HASHED_API_KEY = hashlib.sha256(LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME.encode()).hexdigest()
def _spend_logs_with_health_check_rows():
now = datetime.datetime.now(timezone.utc).isoformat()
return [
{
"id": "log1",
"request_id": "req1",
"api_key": "sk-test-key",
"user": "test_user_1",
"team_id": None,
"spend": 0.05,
"startTime": now,
"model": "gpt-4",
},
{
"id": "log2",
"request_id": "req2",
"api_key": _HEALTH_CHECK_HASHED_API_KEY,
"user": None,
"team_id": LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
"spend": 0.0,
"startTime": now,
"model": "gpt-4",
},
{
"id": "log3",
"request_id": "req3",
"api_key": LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
"user": None,
"team_id": LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
"spend": 0.0,
"startTime": now,
"model": "gpt-4",
},
]
@pytest.mark.asyncio
async def test_ui_view_spend_logs_exclude_internal_health_checks(client, monkeypatch):
mock_spend_logs = _spend_logs_with_health_check_rows()
def filter_health_checks(where):
excluded = where.get("api_key_not_in")
if excluded is None:
return mock_spend_logs
return [log for log in mock_spend_logs if log["api_key"] not in excluded]
observed_queries = []
def observe_query(sql_query, params):
if 'FROM "LiteLLM_SpendLogs"' in sql_query:
observed_queries.append((sql_query, params))
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_health_checks, query_observer=observe_query),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"exclude_internal_health_checks": "true",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert [row["request_id"] for row in data["data"]] == ["req1"]
page_sql, page_params = next((sql, params) for sql, params in observed_queries if "ORDER BY" in sql)
not_in = re.search(r"api_key NOT IN \(\$(\d+), \$(\d+)\)", page_sql)
assert not_in is not None
assert LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME not in page_sql
assert _HEALTH_CHECK_HASHED_API_KEY not in page_sql
assert {
page_params[int(not_in.group(1)) - 1],
page_params[int(not_in.group(2)) - 1],
} == {LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, _HEALTH_CHECK_HASHED_API_KEY}
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_includes_internal_health_checks_by_default(client, monkeypatch):
mock_spend_logs = _spend_logs_with_health_check_rows()
def filter_health_checks(where):
excluded = where.get("api_key_not_in")
if excluded is None:
return mock_spend_logs
return [log for log in mock_spend_logs if log["api_key"] not in excluded]
observed_queries = []
def observe_query(sql_query, params):
if 'FROM "LiteLLM_SpendLogs"' in sql_query:
observed_queries.append((sql_query, params))
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_health_checks, query_observer=observe_query),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={"start_date": start_date, "end_date": end_date},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
assert [row["request_id"] for row in data["data"]] == ["req1", "req2", "req3"]
assert all("NOT IN" not in sql for sql, _ in observed_queries)
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_internal_user_scoped_without_user_id(
client, monkeypatch
@ -2302,6 +2448,96 @@ async def test_ui_view_spend_logs_with_status(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch):
base = {
"api_key": "sk-test-key",
"user": "test_user_1",
"team_id": "team1",
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
"status": "success",
}
mock_spend_logs = [
{**base, "id": "log1", "request_id": "req-hit", "cache_hit": "True"},
{**base, "id": "log2", "request_id": "req-miss", "cache_hit": "False"},
{**base, "id": "log3", "request_id": "req-legacy", "cache_hit": "None"},
{**base, "id": "log4", "request_id": "req-null", "cache_hit": None},
]
def filter_by_cache(where):
cache_filter = where.get("cache_hit")
if cache_filter == "hit":
return [log for log in mock_spend_logs if str(log["cache_hit"]).lower() == "true"]
if cache_filter == "miss":
return [log for log in mock_spend_logs if str(log["cache_hit"]).lower() != "true"]
return mock_spend_logs
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_cache),
)
start_date, end_date = _default_date_range()
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
response = client.get(
"/spend/logs/ui",
params={
"cache_hit_filter": "hit",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert [row["request_id"] for row in data["data"]] == ["req-hit"]
response = client.get(
"/spend/logs/ui",
params={
"cache_hit_filter": "miss",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
assert [row["request_id"] for row in data["data"]] == ["req-miss", "req-legacy", "req-null"]
response = client.get(
"/spend/logs/ui",
params={
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert response.json()["total"] == 4
response = client.get(
"/spend/logs/ui",
params={
"cache_hit_filter": "invalid",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 400
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_model(client, monkeypatch):
mock_spend_logs = [

View file

@ -1,8 +1,9 @@
import json
import sys
from types import ModuleType, SimpleNamespace
from litellm.proxy._lazy_features import LazyFeature
from litellm.proxy._lazy_openapi_snapshot import _normalize_operation_ids
from litellm.proxy._lazy_openapi_snapshot import SnapshotResult, _normalize_operation_ids, main
def test_generate_snapshot_uses_shared_operation_id_reservations(monkeypatch):
@ -61,7 +62,7 @@ def test_generate_snapshot_uses_shared_operation_id_reservations(monkeypatch):
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
fragments = _lazy_openapi_snapshot.generate_snapshot()
fragments = _lazy_openapi_snapshot.generate_snapshot().fragments
assert fragments["feature-a"]["paths"]["/feature-a/items"]["get"]["operationId"] == "shared_operation_id_get"
assert fragments["feature-b"]["paths"]["/feature-b/items"]["get"]["operationId"] == "shared_operation_id_get_2"
@ -106,7 +107,7 @@ def test_generate_snapshot_registers_transitively_imported_modules(monkeypatch):
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
fragments = _lazy_openapi_snapshot.generate_snapshot()
fragments = _lazy_openapi_snapshot.generate_snapshot().fragments
assert fragments["transitive"]["paths"]["/transitive/items"]["get"]["tags"] == ["transitive"]
assert "/v1/{param}/deep/leaf" in fragments["transitive"]["paths"]
@ -144,3 +145,66 @@ def test_normalize_operation_ids_preserves_custom_ids():
operations = paths["/proxy/{endpoint}"]
assert operations["get"]["operationId"] == "custom_operation"
assert operations["post"]["operationId"] == "custom_operation"
def test_generate_snapshot_reports_features_whose_import_fails(monkeypatch):
from litellm.proxy import _lazy_openapi_snapshot
fake_app = SimpleNamespace(title="LiteLLM test", version="0.0.0", routes=[])
fake_module = ModuleType("fake_importable_feature")
monkeypatch.setitem(sys.modules, "fake_importable_feature", fake_module)
def register_fn(app, module):
app.routes.append(SimpleNamespace(path="/importable/items"))
fake_lazy_features_module = ModuleType("litellm.proxy._lazy_features")
fake_lazy_features_module.LAZY_FEATURES = [
LazyFeature(
name="importable",
module_path="fake_importable_feature",
path_prefixes=("/importable",),
register_fn=register_fn,
),
LazyFeature(
name="broken",
module_path="litellm.proxy.this_module_does_not_exist",
path_prefixes=("/broken",),
),
]
monkeypatch.setitem(sys.modules, "litellm.proxy._lazy_features", fake_lazy_features_module)
def fake_get_openapi(title, version, routes):
return {"paths": {route.path: {"get": {"operationId": "importable_get"}} for route in routes}}
fake_proxy_server_module = ModuleType("litellm.proxy.proxy_server")
fake_proxy_server_module.app = fake_app
fake_proxy_server_module.ensure_unique_openapi_operation_ids = lambda schema, reserved_operation_ids: schema
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi)
result = _lazy_openapi_snapshot.generate_snapshot()
assert result.skipped == ("broken",)
assert sorted(result.fragments) == ["importable"]
def test_main_refuses_to_write_a_snapshot_missing_skipped_features(tmp_path, capsys):
snapshot_file = tmp_path / "snapshot.json"
result = SnapshotResult(fragments={"importable": {"paths": {}, "components": {"schemas": {}}}}, skipped=("broken",))
assert main(snapshot_file, generate=lambda: result) == 1
assert not snapshot_file.exists()
assert "broken" in capsys.readouterr().err
def test_main_writes_sorted_snapshot_when_every_feature_loads(tmp_path):
snapshot_file = tmp_path / "snapshot.json"
fragments = {
"zeta": {"paths": {"/z": {}}, "components": {"schemas": {}}},
"alpha": {"paths": {}, "components": {"schemas": {}}},
}
assert main(snapshot_file, generate=lambda: SnapshotResult(fragments=fragments, skipped=())) == 0
assert json.loads(snapshot_file.read_text()) == fragments
assert snapshot_file.read_text() == json.dumps(fragments, indent=2, sort_keys=True) + "\n"

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -151,15 +151,18 @@ describe("AutoRouterBenchmarksTab", () => {
mockAutoRouters();
});
it("leads with total estimated savings, before the three session-shape metrics", () => {
it("leads with total estimated savings, before the four session-shape metrics", () => {
mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
renderTab();
const labels = screen
.getAllByText(/Total estimated savings|Avg turns per session|Avg session length|Avg tokens per session/)
.getAllByText(
/Total estimated savings|Avg saved per session|Avg turns per session|Avg session length|Avg tokens per session/,
)
.map((node) => node.textContent);
expect(labels).toEqual([
"Total estimated savings",
"Avg saved per session",
"Avg turns per session",
"Avg session length",
"Avg tokens per session",
@ -181,13 +184,35 @@ describe("AutoRouterBenchmarksTab", () => {
expect(screen.getByText("5.3M")).toBeInTheDocument();
});
it("pairs the savings with the session count it was earned over", () => {
it("pairs the savings with the session count it was earned over, in its own tile", () => {
mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
renderTab();
expect(screen.getByText("Avg saved per session")).toBeInTheDocument();
expect(screen.getByText("$23.13")).toBeInTheDocument();
expect(screen.getByText("across 94 sessions")).toBeInTheDocument();
const tile = screen.getByText("Avg saved per session").closest('[data-slot="card"]');
if (!tile) throw new Error("expected avg saved per session to render as a metric tile");
expect(within(tile).getByText("$23.13")).toBeInTheDocument();
expect(within(tile).getByText("· 94 sessions")).toBeInTheDocument();
});
it("exposes each spend row as a term and its value, not as loose text", () => {
mockHook({ data: response([group()]) });
renderTab();
const terms = screen.getAllByRole("term").map((node) => node.textContent);
const values = screen.getAllByRole("definition").map((node) => node.textContent);
expect(terms).toEqual(["Actual auto-router spend", "Estimated spend at highest-tier model"]);
expect(values).toEqual(["$359.86", "$2,534.45"]);
});
it("lets both hero columns shrink below their content so a large total cannot clip", () => {
const huge = totals({ saved_spend: 123_456_789_012.34 });
mockHook({ data: response([group(huge)], huge) });
renderTab();
const figure = screen.getByText("$123,456,789,012.34");
const grid = figure.closest('[data-slot="card"]')?.firstElementChild;
expect(grid).toHaveClass("md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]");
});
it("shows a cost increase as a positive delta rather than a saving", () => {
@ -315,7 +340,7 @@ describe("AutoRouterBenchmarksTab", () => {
expect(screen.getByText("Total estimated savings")).toBeInTheDocument();
expect(screen.getAllByText("$0.00")).toHaveLength(4);
expect(screen.getByText("across 0 sessions")).toBeInTheDocument();
expect(screen.getByText("· 0 sessions")).toBeInTheDocument();
expect(screen.getByText("0s")).toBeInTheDocument();
expect(screen.getByText(/turns measured/)).toBeInTheDocument();
expect(screen.getAllByText("0.0%").length).toBeGreaterThan(0);

View file

@ -8,6 +8,7 @@ import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -39,51 +40,51 @@ const Message: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<p className="py-8 text-center text-sm text-muted-foreground">{children}</p>
);
const Metric: React.FC<{ label: string; value: string }> = ({ label, value }) => (
const Metric: React.FC<{ label: string; value: string; hint?: string }> = ({ label, value, hint }) => (
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm font-normal text-muted-foreground">{label}</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="flex flex-wrap items-baseline gap-2">
<p className="text-3xl font-semibold text-foreground">{value}</p>
{hint && <p className="text-sm text-muted-foreground">{hint}</p>}
</CardContent>
</Card>
);
const SpendRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<dl className="flex items-baseline justify-between gap-6 py-3">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="text-base font-semibold tabular-nums text-foreground">{value}</dd>
</dl>
);
const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
const stats = view.stats;
const cheaper = stats.saved_spend >= 0;
return (
<Card className="overflow-hidden py-0">
<div className="grid md:grid-cols-[1fr_1fr]">
<div className="flex flex-col justify-center gap-3 p-6">
<p className="text-sm text-muted-foreground">Total estimated savings</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-5xl font-semibold tracking-tight text-foreground">{usd(stats.saved_spend)}</p>
<div className="grid md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="flex flex-col items-center justify-center gap-2 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Total estimated savings
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<p className="text-6xl font-semibold tracking-tight text-foreground">{usd(stats.saved_spend)}</p>
<Badge
variant="secondary"
className={cheaper ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"}
className={`h-6 px-2.5 text-sm ${cheaper ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"}`}
>
{stats.saved_spend !== 0 && (cheaper ? "-" : "+")}
{Math.abs(stats.saved_pct).toFixed(0)}%
</Badge>
</div>
<dl className="divide-y text-sm">
<div className="flex items-baseline justify-between gap-6 py-3">
<dt className="text-muted-foreground">Actual auto-router spend</dt>
<dd className="font-medium tabular-nums text-foreground">{usd(stats.spend)}</dd>
</div>
<div className="flex items-baseline justify-between gap-6 py-3">
<dt className="text-muted-foreground">Estimated spend at highest-tier model</dt>
<dd className="font-medium tabular-nums text-foreground">{usd(stats.baseline_spend)}</dd>
</div>
</dl>
</div>
<div className="flex flex-col items-center justify-center gap-2 border-t p-6 md:border-t-0 md:border-l">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Avg saved per session</p>
<p className="text-5xl font-semibold tracking-tight text-foreground">{usd(stats.saved_per_session)}</p>
<p className="text-sm text-muted-foreground">across {stats.sessions.toLocaleString()} sessions</p>
<div className="flex flex-col justify-center border-t p-6 md:border-t-0 md:border-l">
<SpendRow label="Actual auto-router spend" value={usd(stats.spend)} />
<Separator />
<SpendRow label="Estimated spend at highest-tier model" value={usd(stats.baseline_spend)} />
</div>
</div>
</Card>
@ -239,7 +240,12 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data,
<TierTurnsChart view={view} autoRouters={autoRouters} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric
label="Avg saved per session"
value={usd(stats.saved_per_session)}
hint={`· ${stats.sessions.toLocaleString()} sessions`}
/>
<Metric label="Avg turns per session" value={stats.avg_turns_per_session.toFixed(1)} />
<Metric label="Avg session length" value={durationLabel(stats.avg_session_seconds)} />
<Metric label="Avg tokens per session" value={formatNumberWithCommas(stats.avg_tokens_per_session, 1, true)} />

View file

@ -459,6 +459,64 @@ describe("teamInfoCall", () => {
});
});
describe("uiSpendLogsCall exclude_internal_health_checks serialization", () => {
const originalFetch = global.fetch;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
const mockOkFetch = () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: [], total: 0, page: 1, page_size: 50, total_pages: 0 }),
} as any);
global.fetch = mockFetch as any;
return mockFetch;
};
const callWith = (params: Parameters<typeof Networking.uiSpendLogsCall>[0]["params"]) =>
Networking.uiSpendLogsCall({
accessToken: "token",
start_date: "2026-01-01 00:00:00",
end_date: "2026-01-02 00:00:00",
params,
});
const lastUrl = (mockFetch: ReturnType<typeof vi.fn>) => {
const [url] = mockFetch.mock.calls.at(-1) ?? [];
return new URL(url as string, "http://example.com");
};
it("appends exclude_internal_health_checks=true when the toggle is on", async () => {
const mockFetch = mockOkFetch();
await callWith({ exclude_internal_health_checks: true });
expect(lastUrl(mockFetch).searchParams.get("exclude_internal_health_checks")).toBe("true");
});
it("omits exclude_internal_health_checks when the toggle is off", async () => {
const mockFetch = mockOkFetch();
await callWith({ exclude_internal_health_checks: false });
expect(lastUrl(mockFetch).searchParams.has("exclude_internal_health_checks")).toBe(false);
});
it("omits exclude_internal_health_checks when the param is absent", async () => {
const mockFetch = mockOkFetch();
await callWith({});
expect(lastUrl(mockFetch).searchParams.has("exclude_internal_health_checks")).toBe(false);
});
});
describe("sessionSpendLogsCall", () => {
const originalFetch = global.fetch;

View file

@ -2002,6 +2002,7 @@ interface UiSpendLogsParams {
user_id?: string;
end_user?: string;
status_filter?: string;
cache_hit_filter?: string;
/** Filter by model name (e.g. "gpt-4") */
model?: string;
/** Filter by model ID (litellm model deployment id) */
@ -2013,6 +2014,7 @@ interface UiSpendLogsParams {
sort_order?: "asc" | "desc";
min_spend?: number;
max_spend?: number;
exclude_internal_health_checks?: boolean;
}
interface UiSpendLogsCallOptions {
@ -2047,6 +2049,8 @@ export const uiSpendLogsCall = async ({
if (value == null) continue;
if (key === "min_spend" || key === "max_spend") {
queryParams.append(key, value.toString());
} else if (typeof value === "boolean") {
if (value) queryParams.append(key, "true");
} else if (typeof value === "string" && value !== "") {
queryParams.append(key, String(value));
}

View file

@ -23,6 +23,8 @@ interface LogsTableToolbarProps {
onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void;
isLiveTail: boolean;
onIsLiveTailChange: (value: boolean) => void;
excludeInternalHealthChecks: boolean;
onExcludeInternalHealthChecksChange: (value: boolean) => void;
onResetToFirstPage: () => void;
onResetFilters: () => void;
}
@ -38,6 +40,8 @@ export function LogsTableToolbar({
onSelectedTimeIntervalChange,
isLiveTail,
onIsLiveTailChange,
excludeInternalHealthChecks,
onExcludeInternalHealthChecksChange,
onResetToFirstPage,
onResetFilters,
}: LogsTableToolbarProps) {
@ -125,6 +129,15 @@ export function LogsTableToolbar({
<Switch checked={isLiveTail} onCheckedChange={onIsLiveTailChange} aria-label="Live Tail" />
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Hide Health Checks</span>
<Switch
checked={excludeInternalHealthChecks}
onCheckedChange={onExcludeInternalHealthChecksChange}
aria-label="Hide Health Checks"
/>
</div>
<Button variant="outline" size="sm" onClick={onResetFilters}>
Reset Filters
</Button>

View file

@ -69,6 +69,7 @@ describe("RequestLogsFilters", () => {
for (const label of [
"Team ID",
"Status",
"Cache",
"Key Alias",
"User ID",
"End User",
@ -259,4 +260,37 @@ describe("RequestLogsFilters", () => {
expect(await screen.findByText(label)).toBeInTheDocument();
});
it.each([
["", "All Requests"],
["hit", "Cache Hit"],
["miss", "Cache Miss"],
])("shows the human label on the Cache trigger for %s", async (cacheState, label) => {
renderFilters(cacheState === "" ? {} : { [LOG_FILTER_IDS.CACHE_STATUS]: cacheState });
expect(await screen.findByText(label)).toBeInTheDocument();
});
it.each([
["Cache Hit", "hit"],
["Cache Miss", "miss"],
])("selecting %s sets the cache filter to %s", async (label, expected) => {
const user = userEvent.setup();
const { set } = renderFilters();
await user.click(await screen.findByText("All Requests"));
await user.click(await screen.findByRole("option", { name: label }));
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, expected);
});
it("selecting All Requests clears the cache filter", async () => {
const user = userEvent.setup();
const { set } = renderFilters({ [LOG_FILTER_IDS.CACHE_STATUS]: "hit" });
await user.click(await screen.findByText("Cache Hit"));
await user.click(await screen.findByRole("option", { name: "All Requests" }));
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, undefined);
});
});

View file

@ -31,6 +31,12 @@ const STATUS_FILTER_ITEMS = [
{ value: "success", label: "Success" },
{ value: "failure", label: "Failure" },
] as const;
const CACHE_FILTER_ITEMS = [
{ value: ALL_VALUE, label: "All Requests" },
{ value: "hit", label: "Cache Hit" },
{ value: "miss", label: "Cache Miss" },
] as const;
const PAGE_SIZE = 50;
const asString = (value: unknown): string => (typeof value === "string" ? value : "");
@ -328,6 +334,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
</Select>
</DataTableFilterField>
<DataTableFilterField label="Cache">
<Select
items={CACHE_FILTER_ITEMS}
value={valueOf(LOG_FILTER_IDS.CACHE_STATUS) === "" ? ALL_VALUE : valueOf(LOG_FILTER_IDS.CACHE_STATUS)}
onValueChange={(next) =>
set(LOG_FILTER_IDS.CACHE_STATUS, next === null || next === ALL_VALUE ? undefined : next)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Requests" />
</SelectTrigger>
<SelectContent>
{CACHE_FILTER_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</DataTableFilterField>
<KeyAliasFilterField
value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)}
onChange={setter(LOG_FILTER_IDS.KEY_ALIAS)}

View file

@ -434,6 +434,44 @@ describe("RequestLogsPanel", () => {
});
});
describe("hide health checks", () => {
const toggle = () => screen.getByRole("switch", { name: "Hide Health Checks" });
it("defaults to showing health checks and refetches without them from page 1 when toggled on", async () => {
const user = userEvent.setup();
renderPanel();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCall()?.params?.exclude_internal_health_checks).toBe(false);
expect(toggle()).not.toBeChecked();
await user.click(toggle());
await waitFor(() => expect(lastCall()?.params?.exclude_internal_health_checks).toBe(true));
expect(lastCall()?.page).toBe(1);
expect(toggle()).toBeChecked();
expect(sessionStorage.getItem("excludeInternalHealthChecks")).toBe("true");
});
it("restores the persisted toggle from sessionStorage", async () => {
sessionStorage.setItem("excludeInternalHealthChecks", "true");
renderPanel();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCall()?.params?.exclude_internal_health_checks).toBe(true);
expect(toggle()).toBeChecked();
});
it("falls back to showing health checks when the persisted value is malformed", async () => {
sessionStorage.setItem("excludeInternalHealthChecks", "{not json");
renderPanel();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCall()?.params?.exclude_internal_health_checks).toBe(false);
expect(toggle()).not.toBeChecked();
});
});
describe("live tail", () => {
it("shows the auto-refresh banner on the first page and hides it once stopped", async () => {
const user = userEvent.setup();

View file

@ -72,6 +72,14 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail));
}, [isLiveTail]);
const [excludeInternalHealthChecks, setExcludeInternalHealthChecks] = useState<boolean>(
() => sessionStorage.getItem("excludeInternalHealthChecks") === "true",
);
useEffect(() => {
sessionStorage.setItem("excludeInternalHealthChecks", JSON.stringify(excludeInternalHealthChecks));
}, [excludeInternalHealthChecks]);
const { logsQuery, filteredLogs, allTeams } = useLogFilterLogic({
accessToken,
token,
@ -80,6 +88,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
columnFilters,
activeTab: isActive ? "request logs" : "inactive",
isLiveTail,
excludeInternalHealthChecks,
startTime,
endTime,
pagination,
@ -219,6 +228,14 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleExcludeInternalHealthChecksChange = useCallback(
(value: boolean) => {
setExcludeInternalHealthChecks(value);
resetToFirstPage();
},
[resetToFirstPage],
);
const handleResetFilters = useCallback(() => {
setColumnFilters([]);
setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm"));
@ -313,6 +330,8 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
onSelectedTimeIntervalChange={setSelectedTimeInterval}
isLiveTail={isLiveTail}
onIsLiveTailChange={setIsLiveTail}
excludeInternalHealthChecks={excludeInternalHealthChecks}
onExcludeInternalHealthChecksChange={handleExcludeInternalHealthChecksChange}
onResetToFirstPage={resetToFirstPage}
onResetFilters={handleResetFilters}
/>

View file

@ -47,6 +47,7 @@ const defaultProps = {
columnFilters: [] as ColumnFiltersState,
activeTab: "request logs",
isLiveTail: false,
excludeInternalHealthChecks: false,
startTime: "2025-01-01T00:00:00",
endTime: "2025-01-01T23:59:59",
pagination: FIRST_PAGE,
@ -82,6 +83,8 @@ describe("useLogFilterLogic", () => {
{ id: LOG_FILTER_IDS.SESSION_ID, value: "sess-1", param: "session_id" },
{ id: LOG_FILTER_IDS.END_USER, value: "end-user-1", param: "end_user" },
{ id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" },
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "hit", param: "cache_hit_filter" },
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "miss", param: "cache_hit_filter" },
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
{ id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" },
{ id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" },
@ -152,6 +155,7 @@ describe("useLogFilterLogic", () => {
["pagination", { pagination: { pageIndex: 1, pageSize: 50 } }],
["startTime", { startTime: "2025-02-02T00:00:00" }],
["columnFilters", { columnFilters: [{ id: LOG_FILTER_IDS.TEAM_ID, value: "team-2" }] }],
["excludeInternalHealthChecks", { excludeInternalHealthChecks: true }],
])("refetches when %s changes", async (_label, nextProps) => {
const { rerender } = renderHook((props: HookOverrides) => useLogFilterLogic({ ...defaultProps, ...props }), {
wrapper,
@ -164,6 +168,22 @@ describe("useLogFilterLogic", () => {
});
});
describe("hide health checks toggle", () => {
it("passes exclude_internal_health_checks when the toggle is on", async () => {
renderFilterHook({ excludeInternalHealthChecks: true });
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ exclude_internal_health_checks: true });
});
it("passes exclude_internal_health_checks as false when the toggle is off", async () => {
renderFilterHook();
await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled());
expect(lastCallParams()?.params).toMatchObject({ exclude_internal_health_checks: false });
});
});
describe("query enablement", () => {
it("does not query when the request logs tab is inactive", async () => {
renderFilterHook({ activeTab: "audit logs" });

View file

@ -20,6 +20,7 @@ export interface PaginatedResponse {
export const LOG_FILTER_IDS = {
TEAM_ID: "team_id",
STATUS: "status",
CACHE_STATUS: "cache_hit",
KEY_ALIAS: "key_alias",
END_USER: "end_user",
ERROR_CODE: "error_code",
@ -35,6 +36,7 @@ export const LOG_FILTER_IDS = {
export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.TEAM_ID]: "Team ID",
[LOG_FILTER_IDS.STATUS]: "Status",
[LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
[LOG_FILTER_IDS.USER_ID]: "User ID",
[LOG_FILTER_IDS.END_USER]: "End User",
@ -101,6 +103,7 @@ export function useLogFilterLogic({
columnFilters,
activeTab,
isLiveTail,
excludeInternalHealthChecks,
startTime,
endTime,
pagination,
@ -114,6 +117,7 @@ export function useLogFilterLogic({
columnFilters: ColumnFiltersState;
activeTab: string;
isLiveTail: boolean;
excludeInternalHealthChecks: boolean;
startTime: string;
endTime: string;
pagination: PaginationState;
@ -137,6 +141,7 @@ export function useLogFilterLogic({
columnFilters,
sortBy,
sortOrder,
excludeInternalHealthChecks,
],
queryFn: async () => {
if (!accessToken || !token || !userRole || !userID) {
@ -167,6 +172,7 @@ export function useLogFilterLogic({
user_id: userIdFilter,
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
cache_hit_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_STATUS),
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS),
@ -174,6 +180,7 @@ export function useLogFilterLogic({
error_message: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_MESSAGE),
sort_by: sortBy,
sort_order: sortOrder,
exclude_internal_health_checks: excludeInternalHealthChecks,
},
});
},

File diff suppressed because it is too large Load diff