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'
|
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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",))
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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")
|
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"}}'
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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