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' if: steps.changes.outputs.relevant == 'true'
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma 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 - name: Set up Node.js
if: steps.changes.outputs.relevant == 'true' if: steps.changes.outputs.relevant == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0

View file

@ -149,7 +149,6 @@ class PromptManager:
) )
self.prompts[template_id] = template self.prompts[template_id] = template
except Exception: except Exception:
# Optional: print(f"Error loading prompt from JSON: {template_id}")
pass pass
def _load_prompt_file(self, file_path: str | Path, prompt_id: str) -> PromptTemplate: 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) litellm_response_headers: Final = _get_response_headers(original_exception=original_exception)
try: try:
error_str = redact_string(str(original_exception)) if _ENABLE_SECRET_REDACTION else str(original_exception) error_str = redact_string(str(original_exception)) if _ENABLE_SECRET_REDACTION else str(original_exception)
extra_information = ""
if model or custom_llm_provider: if model or custom_llm_provider:
if hasattr(original_exception, "message"): if hasattr(original_exception, "message"):
error_str = ( error_str = (
@ -2357,7 +2358,6 @@ def exception_type(
# Common Extra information needed for all providers # Common Extra information needed for all providers
# We pass num retries, api_base, vertex_deployment etc to the exception here # We pass num retries, api_base, vertex_deployment etc to the exception here
################################################################################ ################################################################################
extra_information = ""
try: try:
_api_base: Final = litellm.get_api_base(model=model, optional_params=extra_kwargs) _api_base: Final = litellm.get_api_base(model=model, optional_params=extra_kwargs)
messages: Final = litellm.get_first_chars_messages(kwargs=completion_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 import litellm
from litellm.constants import OPENAI_FILE_SEARCH_COST_PER_1K_CALLS 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 ( from litellm.types.llms.openai import (
FileSearchTool, FileSearchTool,
ResponsesAPIResponse, ResponsesAPIResponse,
@ -368,7 +370,7 @@ class StandardBuiltInToolCostTracking:
get_anthropic_web_search_requests_from_response, 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 return usage
web_search_requests: Final = get_anthropic_web_search_requests_from_response(response_object) web_search_requests: Final = get_anthropic_web_search_requests_from_response(response_object)
if web_search_requests is None: 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. # 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 # Without this check, Claude ModelResponse always falls through to return False
# and _handle_web_search_cost() is never called. # 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 return True
# xAI reports usage.server_side_tool_usage_details.web_search_calls; a searched # 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 # 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" response_object=response_object, output_type="web_search_call"
) )
elif usage is not None: elif usage is not None:
if ( if get_web_search_requests_from_usage(usage) is not None or (
hasattr(usage, "server_tool_use") hasattr(usage, "prompt_tokens_details")
and get_web_search_requests(usage.server_tool_use) is not None and usage.prompt_tokens_details is not None
or ( and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
hasattr(usage, "prompt_tokens_details") and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details is not None and usage.prompt_tokens_details.web_search_requests 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 return True
if _usage_reports_server_side_web_search_calls(usage): 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) 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: def _is_above_128k(tokens: float) -> bool:
if tokens > 128000: if tokens > 128000:
return True return True

View file

@ -10,7 +10,7 @@ from pydantic import BaseModel, ValidationError
from litellm.litellm_core_utils.llm_cost_calc.utils import ( from litellm.litellm_core_utils.llm_cost_calc.utils import (
generic_cost_per_token, generic_cost_per_token,
get_provider_specific_geo_multiplier, get_provider_specific_geo_multiplier,
get_web_search_requests, get_web_search_requests_from_usage,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -104,7 +104,7 @@ def get_cost_for_anthropic_web_search(
if usage is None: if usage is None:
return 0.0 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: if web_search_requests is None:
return 0.0 return 0.0

View file

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

View file

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

View file

@ -138,11 +138,6 @@ class BedrockRerankHandler(BaseAWSLLM):
data: dict, data: dict,
optional_params: dict, optional_params: dict,
) -> BedrockPreparedRequest: ) -> 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) boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params(optional_params, model)
### SET RUNTIME ENDPOINT ### ### 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 = proxy_endpoint_url.replace("bedrock-runtime", "bedrock-agent-runtime")
proxy_endpoint_url = f"{proxy_endpoint_url}/rerank" 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"} headers = {"Content-Type": "application/json"}
if extra_headers is not None: if extra_headers is not None:
headers = {"Content-Type": "application/json", **extra_headers} headers = {"Content-Type": "application/json", **extra_headers}
request: Final = AWSRequest(method="POST", url=proxy_endpoint_url, data=body, headers=headers)
sigv4.add_auth(request) prepped: Final = self.get_request_headers(
if ( credentials=boto3_credentials_info.credentials,
extra_headers is not None and "Authorization" in extra_headers aws_region_name=boto3_credentials_info.aws_region_name,
): # prevent sigv4 from overwriting the auth header extra_headers=extra_headers,
request.headers["Authorization"] = extra_headers["Authorization"] endpoint_url=proxy_endpoint_url,
prepped: Final = request.prepare() data=body,
headers=headers,
supports_bearer_token=False,
)
return BedrockPreparedRequest( return BedrockPreparedRequest(
endpoint_url=proxy_endpoint_url, 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 ``model_info`` when available, falling back to $0.035 for models not
yet updated in the pricing JSON. 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 from litellm.types.utils import PromptTokensDetailsWrapper
_DEFAULT_COST: Final = 35e-3 _DEFAULT_COST: Final = 35e-3
@ -57,7 +59,7 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
) )
else None 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 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" 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: if opened.identity.server_id != expected_server_id:
return BridgeEnvelopeInvalid() return BridgeEnvelopeInvalid()
grant: Final = opened.grant 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)) 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` 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 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 features without importing them. check-ui-api-types.yml (mirrored locally by
only indirectly through check-ui-api-types.yml, which rebuilds schema.d.ts from `make check`) regenerates this file and fails when the committed copy differs,
app.openapi() with the committed snapshot injected. After changing any lazily then rebuilds schema.d.ts from app.openapi() with the snapshot injected. After
loaded route or this generator, rerun the module and commit the JSON, then run changing any lazily loaded route or this generator, rerun the module and commit
`npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts. the JSON, then run `npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
""" """
import json import json
import re import re
import sys import sys
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path 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" SNAPSHOT_FILE: Final = Path(__file__).parent / "_lazy_openapi_snapshot.json"
HTTP_METHOD_SUFFIXES: Final = { HTTP_METHOD_SUFFIXES: Final = {
@ -83,51 +92,84 @@ def _normalize_operation_ids(paths: dict[str, dict]) -> None:
break 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 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 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._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: skipped: Final = tuple(name for feat in LAZY_FEATURES if (name := _register_feature(app, feat)) is not None)
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]] = {}
used_operation_ids: Final[set[str]] = set() used_operation_ids: Final[set[str]] = set()
for feat in LAZY_FEATURES: fragments: Final = {
feat_routes = [r for r in app.routes if feat.matches(getattr(r, "path", ""))] feat.name: fragment
if not feat_routes: for feat in LAZY_FEATURES
continue if (fragment := _feature_fragment(app, feat, used_operation_ids)) is not None
_stabilize_multi_method_route_ids(feat_routes) }
full = get_openapi(title=app.title, version=app.version, routes=feat_routes) return SnapshotResult(fragments=fragments, skipped=skipped)
paths = full.get("paths", {})
_normalize_operation_ids(paths)
# Group all of a feature's routes under one tag. def main(snapshot_file: Path = SNAPSHOT_FILE, generate: Callable[[], SnapshotResult] = generate_snapshot) -> int:
for path_ops in full.get("paths", {}).values(): result: Final = generate()
for method, op in path_ops.items(): if result.skipped:
if isinstance(op, dict): sys.stderr.write(
operation_id = op.get("operationId") f"error: {len(result.skipped)} feature(s) failed to import, so their fragments would vanish from the "
if isinstance(operation_id, str): f"snapshot: {', '.join(result.skipped)}\n"
for suffix in HTTP_METHOD_SUFFIXES: )
if operation_id.endswith(f"_{suffix}"): return 1
op["operationId"] = operation_id[: -len(suffix)] + method snapshot_file.write_text(json.dumps(result.fragments, indent=2, sort_keys=True) + "\n")
break sys.stdout.write(f"wrote {len(result.fragments)} feature fragments to {snapshot_file}\n")
op["tags"] = [feat.name] return 0
full = ensure_unique_openapi_operation_ids(full, used_operation_ids)
fragments[feat.name] = {
"paths": paths,
"components": {"schemas": full.get("components", {}).get("schemas", {})},
}
return fragments
if __name__ == "__main__": if __name__ == "__main__":
fragments: Final = generate_snapshot() sys.exit(main())
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")

View file

@ -808,6 +808,15 @@ def normalize_email(email: str | None) -> str | None:
return email.lower() if isinstance(email, str) else email 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( def determine_role_from_groups(
user_groups: list[str], user_groups: list[str],
role_mappings: "RoleMappings", role_mappings: "RoleMappings",
@ -832,19 +841,11 @@ def determine_role_from_groups(
# No role mappings configured, return default_role # No role mappings configured, return default_role
return role_mappings.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 # Convert user_groups to a set for efficient lookup
user_groups_set: Final = set(user_groups) if isinstance(user_groups, list) else set() user_groups_set: Final = set(user_groups) if isinstance(user_groups, list) else set()
# Find the highest privilege role the user belongs to # 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: if role in role_mappings.roles:
role_groups = role_mappings.roles[role] role_groups = role_mappings.roles[role]
if isinstance(role_groups, list) and user_groups_set.intersection(set(role_groups)): 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) verbose_proxy_logger.debug("Extracted app roles from id_token: %s", app_roles)
# Combine groups and app roles # Combine groups and app roles
user_role: LitellmUserRoles | None = None user_role: Final = MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles)
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
verbose_proxy_logger.debug("Combined team_ids (groups + app roles): %s", user_team_ids) 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) verbose_proxy_logger.debug("Microsoft SSO OpenID Response: %s", openid_response)
return 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 @staticmethod
def get_app_roles_from_id_token(id_token: str | None) -> list[str]: 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 import litellm
from litellm._logging import verbose_proxy_logger 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 *
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 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 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") _RowT = TypeVar("_RowT")
@ -2248,6 +2254,10 @@ async def ui_view_spend_logs(
status_filter: str | None = fastapi.Query( status_filter: str | None = fastapi.Query(
default=None, description="Filter logs by status (e.g., success, failure)" 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: str | None = fastapi.Query(default=None, description="Filter logs by model"),
model_id: str | None = fastapi.Query( model_id: str | None = fastapi.Query(
default=None, default=None,
@ -2268,6 +2278,10 @@ async def ui_view_spend_logs(
default="desc", default="desc",
description="Sort order: asc or 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. View spend logs with pagination support.
@ -2320,6 +2334,13 @@ async def ui_view_spend_logs(
param="sort_order", param="sort_order",
code=status.HTTP_400_BAD_REQUEST, 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: try:
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) 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) sql_params.append(status_filter)
p += 1 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 # Spend range
if min_spend is not None: if min_spend is not None:
sql_conditions.append(f"spend >= ${p}") 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) # - 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) # + 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) # - 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 # 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 # stay fast. This is intentionally not auto-installed as a git hook (see
@ -244,7 +244,7 @@ fi
genapi_checks() { genapi_checks() {
local status=0 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 # 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 # 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 # 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 elif ! uv run --no-sync python scripts/prisma_generate_if_needed.py; then
echo "✗ Could not regenerate Prisma client (prisma generate failed)." >&2 echo "✗ Could not regenerate Prisma client (prisma generate failed)." >&2
status=1 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 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 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 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 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.""" rejected argument alongside working ones would probe deployments the operator opted out."""
import litellm.proxy.proxy_server as proxy_server import litellm.proxy.proxy_server as proxy_server
seen: list = [] seen: list[tuple[dict[str, str] | None, bool]] = []
async def fake_perform_health_check( async def fake_perform_health_check(
model_list, model_list,

View file

@ -10,8 +10,8 @@ import pytest
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import ( from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
StandardBuiltInToolCostTracking, 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 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." CONTEXT_WINDOW_MESSAGE = "This model's maximum context length is 4096 tokens."
CONTENT_POLICY_MESSAGE = ( CONTENT_POLICY_MESSAGE = (
'{"error": {"type": "invalid_request_error", "code": "content_policy_violation"}}' '{"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.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler 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(): def test_anthropic_experimental_pass_through_messages_handler():
@ -1292,7 +1297,7 @@ class TestMessagesStreamingSuccessLogging:
class _FailureCapture(CustomLogger): class _FailureCapture(CustomLogger):
def __init__(self): def __init__(self):
super().__init__() 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): async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
payload = kwargs.get("standard_logging_object") or {} payload = kwargs.get("standard_logging_object") or {}

View file

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

View file

@ -12,6 +12,7 @@ import pytest
import litellm import litellm
from litellm.llms.bedrock.base_aws_llm import Boto3CredentialsInfo 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 from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
# Mock response for Bedrock rerank # 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)}") 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 @pytest.mark.asyncio
async def test_bedrock_rerank_records_llm_api_duration(): async def test_bedrock_rerank_records_llm_api_duration():
"""The bedrock rerank handler must feed httpx timing into the logging obj, so the """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 from datetime import datetime, timedelta, timezone
import pytest
from pydantic import SecretStr from pydantic import SecretStr
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( 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() 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(): def test_resolve_expired_envelope_is_invalid_not_admitted():
keys = envelope_keys_from_master_key(_MASTER_KEY) keys = envelope_keys_from_master_key(_MASTER_KEY)
token = _sealed_token(keys, now=_NOW) token = _sealed_token(keys, now=_NOW)

View file

@ -1,91 +1,120 @@
import jwt 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._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(): def test_extracts_proxy_admin_role_from_jwt():
"""Ensure supported app roles like 'proxy_admin' are extracted from the id_token.""" """Ensure supported app roles like 'proxy_admin' are extracted from the id_token."""
payload = { token = _id_token(app_roles=["proxy_admin"])
"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 = jwt.encode(payload, "secret", algorithm="HS256")
roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token) roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
assert roles == ["proxy_admin"] assert roles == ["proxy_admin"]
def test_maps_internal_user_role(): def test_extracts_app_roles_from_roles_claim():
"""Ensure internal_user role is correctly mapped to LitellmUserRoles.""" """Entra emits app role values in the `roles` claim; both spellings are read."""
payload = { token = _id_token(roles=["internal_user"])
"sub": "user456",
"email": "user@company.com",
"app_roles": ["internal_user"],
"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) roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
# Map to LitellmUserRoles assert roles == ["internal_user"]
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
def test_maps_proxy_admin_viewer_role(): @pytest.mark.parametrize(
"""Ensure proxy_admin_viewer role is correctly mapped.""" "app_roles, expected",
payload = { [
"sub": "user789", (["proxy_admin"], LitellmUserRoles.PROXY_ADMIN),
"email": "viewer@company.com", (["proxy_admin_viewer"], LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
"app_roles": ["proxy_admin_viewer"], (["internal_user"], LitellmUserRoles.INTERNAL_USER),
"aud": "litellm-app", (["internal_user_viewer"], LitellmUserRoles.INTERNAL_USER_VIEW_ONLY),
"iss": "https://login.microsoftonline.com/tenant-id/v2.0", # Case-insensitive, matching get_litellm_user_role.
"exp": 9999999999, (["PROXY_ADMIN_VIEWER"], LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
} # Roles outside the privilege hierarchy still resolve.
(["org_admin"], LitellmUserRoles.ORG_ADMIN),
token = jwt.encode(payload, "secret", algorithm="HS256") ],
roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token) )
def test_maps_single_app_role(app_roles, expected):
chosen = None """A lone app role maps to its LitellmUserRoles equivalent."""
for r in roles: assert MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles) == expected
mapped = get_litellm_user_role(r)
if mapped is not None:
chosen = mapped
break
assert chosen == LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
def test_defaults_to_internal_user_viewer_when_no_role(): @pytest.mark.parametrize(
"""Ensure default role is internal_user_viewer when no app role is present.""" "app_roles",
payload = { [
"sub": "user_no_role", ["internal_user", "proxy_admin_viewer"],
"email": "noRole@company.com", ["proxy_admin_viewer", "internal_user"],
"aud": "litellm-app", ],
"iss": "https://login.microsoftonline.com/tenant-id/v2.0", )
"exp": 9999999999, 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) roles = MicrosoftSSOHandler.get_app_roles_from_id_token(token)
assert roles == [] 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 def test_end_to_end_from_id_token_to_role():
assert default_role.value == "internal_user_viewer" """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 asyncio
import collections import collections
import datetime import datetime
import hashlib
import json import json
import re import re
from datetime import timezone 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) msg = re.search(r"error_message' LIKE \$(\d+)", cond)
sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond) sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond)
status = re.fullmatch(r"status = \$(\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: if gte:
date_bounds["gte"] = _iso(params[int(gte.group(1)) - 1]) date_bounds["gte"] = _iso(params[int(gte.group(1)) - 1])
elif lte: elif lte:
@ -104,10 +106,19 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
where["OR"] = where.get("OR", []) + [{"multi_team": True}] where["OR"] = where.get("OR", []) + [{"multi_team": True}]
elif "status = 'success'" in cond: elif "status = 'success'" in cond:
where["OR"] = where.get("OR", []) + [{"status": "success"}] 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: elif sess:
where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")} where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")}
elif status: elif status:
where["status"] = {"equals": params[int(status.group(1)) - 1]} 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: elif alias:
metadata_conds.append( 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() return MockPrismaClient()
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.proxy._types import ( from litellm.proxy._types import (
LitellmUserRoles, LitellmUserRoles,
Member, 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) 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 @pytest.mark.asyncio
async def test_ui_view_spend_logs_internal_user_scoped_without_user_id( async def test_ui_view_spend_logs_internal_user_scoped_without_user_id(
client, monkeypatch 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) 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 @pytest.mark.asyncio
async def test_ui_view_spend_logs_with_model(client, monkeypatch): async def test_ui_view_spend_logs_with_model(client, monkeypatch):
mock_spend_logs = [ mock_spend_logs = [

View file

@ -1,8 +1,9 @@
import json
import sys import sys
from types import ModuleType, SimpleNamespace from types import ModuleType, SimpleNamespace
from litellm.proxy._lazy_features import LazyFeature 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): 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.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi) 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-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" 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.setitem(sys.modules, "litellm.proxy.proxy_server", fake_proxy_server_module)
monkeypatch.setattr("fastapi.openapi.utils.get_openapi", fake_get_openapi) 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 fragments["transitive"]["paths"]["/transitive/items"]["get"]["tags"] == ["transitive"]
assert "/v1/{param}/deep/leaf" in fragments["transitive"]["paths"] 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}"] operations = paths["/proxy/{endpoint}"]
assert operations["get"]["operationId"] == "custom_operation" assert operations["get"]["operationId"] == "custom_operation"
assert operations["post"]["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 { 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 React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
@ -151,15 +151,18 @@ describe("AutoRouterBenchmarksTab", () => {
mockAutoRouters(); 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" })]) }); mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
renderTab(); renderTab();
const labels = screen 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); .map((node) => node.textContent);
expect(labels).toEqual([ expect(labels).toEqual([
"Total estimated savings", "Total estimated savings",
"Avg saved per session",
"Avg turns per session", "Avg turns per session",
"Avg session length", "Avg session length",
"Avg tokens per session", "Avg tokens per session",
@ -181,13 +184,35 @@ describe("AutoRouterBenchmarksTab", () => {
expect(screen.getByText("5.3M")).toBeInTheDocument(); 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" })]) }); mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
renderTab(); renderTab();
expect(screen.getByText("Avg saved per session")).toBeInTheDocument(); const tile = screen.getByText("Avg saved per session").closest('[data-slot="card"]');
expect(screen.getByText("$23.13")).toBeInTheDocument(); if (!tile) throw new Error("expected avg saved per session to render as a metric tile");
expect(screen.getByText("across 94 sessions")).toBeInTheDocument();
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", () => { 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.getByText("Total estimated savings")).toBeInTheDocument();
expect(screen.getAllByText("$0.00")).toHaveLength(4); 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("0s")).toBeInTheDocument();
expect(screen.getByText(/turns measured/)).toBeInTheDocument(); expect(screen.getByText(/turns measured/)).toBeInTheDocument();
expect(screen.getAllByText("0.0%").length).toBeGreaterThan(0); 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 { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; 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> <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"> <Card size="sm">
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-normal text-muted-foreground">{label}</CardTitle> <CardTitle className="text-sm font-normal text-muted-foreground">{label}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-wrap items-baseline gap-2">
<p className="text-3xl font-semibold text-foreground">{value}</p> <p className="text-3xl font-semibold text-foreground">{value}</p>
{hint && <p className="text-sm text-muted-foreground">{hint}</p>}
</CardContent> </CardContent>
</Card> </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 HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
const stats = view.stats; const stats = view.stats;
const cheaper = stats.saved_spend >= 0; const cheaper = stats.saved_spend >= 0;
return ( return (
<Card className="overflow-hidden py-0"> <Card className="overflow-hidden py-0">
<div className="grid md:grid-cols-[1fr_1fr]"> <div className="grid md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="flex flex-col justify-center gap-3 p-6"> <div className="flex flex-col items-center justify-center gap-2 p-6">
<p className="text-sm text-muted-foreground">Total estimated savings</p> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<div className="flex flex-wrap items-center gap-3"> Total estimated savings
<p className="text-5xl font-semibold tracking-tight text-foreground">{usd(stats.saved_spend)}</p> </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 <Badge
variant="secondary" 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 ? "-" : "+")} {stats.saved_spend !== 0 && (cheaper ? "-" : "+")}
{Math.abs(stats.saved_pct).toFixed(0)}% {Math.abs(stats.saved_pct).toFixed(0)}%
</Badge> </Badge>
</div> </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>
<div className="flex flex-col items-center justify-center gap-2 border-t p-6 md:border-t-0 md:border-l"> <div className="flex flex-col justify-center 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> <SpendRow label="Actual auto-router spend" value={usd(stats.spend)} />
<p className="text-5xl font-semibold tracking-tight text-foreground">{usd(stats.saved_per_session)}</p> <Separator />
<p className="text-sm text-muted-foreground">across {stats.sessions.toLocaleString()} sessions</p> <SpendRow label="Estimated spend at highest-tier model" value={usd(stats.baseline_spend)} />
</div> </div>
</div> </div>
</Card> </Card>
@ -239,7 +240,12 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data,
<TierTurnsChart view={view} autoRouters={autoRouters} /> <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 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 session length" value={durationLabel(stats.avg_session_seconds)} />
<Metric label="Avg tokens per session" value={formatNumberWithCommas(stats.avg_tokens_per_session, 1, true)} /> <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", () => { describe("sessionSpendLogsCall", () => {
const originalFetch = global.fetch; const originalFetch = global.fetch;

View file

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

View file

@ -23,6 +23,8 @@ interface LogsTableToolbarProps {
onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void; onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void;
isLiveTail: boolean; isLiveTail: boolean;
onIsLiveTailChange: (value: boolean) => void; onIsLiveTailChange: (value: boolean) => void;
excludeInternalHealthChecks: boolean;
onExcludeInternalHealthChecksChange: (value: boolean) => void;
onResetToFirstPage: () => void; onResetToFirstPage: () => void;
onResetFilters: () => void; onResetFilters: () => void;
} }
@ -38,6 +40,8 @@ export function LogsTableToolbar({
onSelectedTimeIntervalChange, onSelectedTimeIntervalChange,
isLiveTail, isLiveTail,
onIsLiveTailChange, onIsLiveTailChange,
excludeInternalHealthChecks,
onExcludeInternalHealthChecksChange,
onResetToFirstPage, onResetToFirstPage,
onResetFilters, onResetFilters,
}: LogsTableToolbarProps) { }: LogsTableToolbarProps) {
@ -125,6 +129,15 @@ export function LogsTableToolbar({
<Switch checked={isLiveTail} onCheckedChange={onIsLiveTailChange} aria-label="Live Tail" /> <Switch checked={isLiveTail} onCheckedChange={onIsLiveTailChange} aria-label="Live Tail" />
</div> </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}> <Button variant="outline" size="sm" onClick={onResetFilters}>
Reset Filters Reset Filters
</Button> </Button>

View file

@ -69,6 +69,7 @@ describe("RequestLogsFilters", () => {
for (const label of [ for (const label of [
"Team ID", "Team ID",
"Status", "Status",
"Cache",
"Key Alias", "Key Alias",
"User ID", "User ID",
"End User", "End User",
@ -259,4 +260,37 @@ describe("RequestLogsFilters", () => {
expect(await screen.findByText(label)).toBeInTheDocument(); 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: "success", label: "Success" },
{ value: "failure", label: "Failure" }, { value: "failure", label: "Failure" },
] as const; ] 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 PAGE_SIZE = 50;
const asString = (value: unknown): string => (typeof value === "string" ? value : ""); const asString = (value: unknown): string => (typeof value === "string" ? value : "");
@ -328,6 +334,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
</Select> </Select>
</DataTableFilterField> </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 <KeyAliasFilterField
value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)} value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)}
onChange={setter(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", () => { describe("live tail", () => {
it("shows the auto-refresh banner on the first page and hides it once stopped", async () => { it("shows the auto-refresh banner on the first page and hides it once stopped", async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View file

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

View file

@ -47,6 +47,7 @@ const defaultProps = {
columnFilters: [] as ColumnFiltersState, columnFilters: [] as ColumnFiltersState,
activeTab: "request logs", activeTab: "request logs",
isLiveTail: false, isLiveTail: false,
excludeInternalHealthChecks: false,
startTime: "2025-01-01T00:00:00", startTime: "2025-01-01T00:00:00",
endTime: "2025-01-01T23:59:59", endTime: "2025-01-01T23:59:59",
pagination: FIRST_PAGE, 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.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.END_USER, value: "end-user-1", param: "end_user" },
{ id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" }, { 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.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.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" },
{ id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" }, { id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" },
@ -152,6 +155,7 @@ describe("useLogFilterLogic", () => {
["pagination", { pagination: { pageIndex: 1, pageSize: 50 } }], ["pagination", { pagination: { pageIndex: 1, pageSize: 50 } }],
["startTime", { startTime: "2025-02-02T00:00:00" }], ["startTime", { startTime: "2025-02-02T00:00:00" }],
["columnFilters", { columnFilters: [{ id: LOG_FILTER_IDS.TEAM_ID, value: "team-2" }] }], ["columnFilters", { columnFilters: [{ id: LOG_FILTER_IDS.TEAM_ID, value: "team-2" }] }],
["excludeInternalHealthChecks", { excludeInternalHealthChecks: true }],
])("refetches when %s changes", async (_label, nextProps) => { ])("refetches when %s changes", async (_label, nextProps) => {
const { rerender } = renderHook((props: HookOverrides) => useLogFilterLogic({ ...defaultProps, ...props }), { const { rerender } = renderHook((props: HookOverrides) => useLogFilterLogic({ ...defaultProps, ...props }), {
wrapper, 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", () => { describe("query enablement", () => {
it("does not query when the request logs tab is inactive", async () => { it("does not query when the request logs tab is inactive", async () => {
renderFilterHook({ activeTab: "audit logs" }); renderFilterHook({ activeTab: "audit logs" });

View file

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

File diff suppressed because it is too large Load diff