mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge branch 'litellm_internal_staging' into litellm_e2e_deflake_fallback_cache
This commit is contained in:
commit
938ed2c2a5
38 changed files with 10774 additions and 1196 deletions
18
.github/workflows/check-ui-api-types.yml
vendored
18
.github/workflows/check-ui-api-types.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}}'
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
2950
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2950
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue