mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(proxy): record in spend logs whether a request used a client-forwarded Anthropic OAuth token (#43063)
* feat(proxy): record in spend logs whether a request used a client-forwarded Anthropic OAuth token Stamp metadata.used_client_oauth_token where the proxy decides to forward a client's Anthropic OAuth token, carry it through StandardLoggingMetadata into the spend log row, add a used_client_oauth_token filter to /spend/logs/ui, and surface it on the Logs page as a Credential filter and drawer field. The token itself never reaches the log * fix(proxy): carry used_client_oauth_token onto failure spend rows for litellm_metadata routes * fix(proxy): resolve used_client_oauth_token against the provider the call was sent to * fix(proxy): keep the proxy's used_client_oauth_token stamp on failure rows and move the resolver under llms/anthropic * fix(logging): read used_client_oauth_token from the proxy-stamped metadata slot On routes that carry proxy metadata in litellm_metadata, metadata is the caller's own body field, and merge_litellm_metadata lets it win. Resolve the flag from litellm_metadata when the proxy stamped it there so a caller cannot set it in the standard logging payload * fix(spend-logs): read used_client_oauth_token from the bucket the route stamped A guardrail on the unified path adds litellm_metadata to a chat request after the proxy stamped metadata, so both spend row writers read the new bucket and stored null. The success row now resolves the flag the same way the callback payload does, and the failure row picks the bucket from the request route. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
54ae4c5bbf
commit
0c515ed7a8
24 changed files with 618 additions and 12 deletions
|
|
@ -339,6 +339,13 @@ def get_or_create_metadata_bucket(
|
|||
return metadata_key, metadata_bucket
|
||||
|
||||
|
||||
def proxy_stamped_used_client_oauth_token(metadata: object, litellm_params: Mapping[str, object] | None) -> object:
|
||||
litellm_metadata: Final = litellm_params.get("litellm_metadata") if litellm_params is not None else None
|
||||
if isinstance(litellm_metadata, Mapping) and "used_client_oauth_token" in litellm_metadata:
|
||||
return litellm_metadata["used_client_oauth_token"]
|
||||
return metadata.get("used_client_oauth_token") if isinstance(metadata, Mapping) else None
|
||||
|
||||
|
||||
def get_litellm_metadata_from_kwargs(kwargs: dict):
|
||||
"""
|
||||
Helper to get litellm metadata from all litellm request kwargs
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ from litellm.litellm_core_utils.classifier_logging import (
|
|||
from litellm.litellm_core_utils.core_helpers import (
|
||||
get_provider_response_headers_from_hidden_params,
|
||||
is_expected_client_error,
|
||||
proxy_stamped_used_client_oauth_token,
|
||||
reconstruct_model_name,
|
||||
set_response_cost_in_hidden_params,
|
||||
)
|
||||
|
|
@ -284,7 +285,10 @@ else:
|
|||
_PAGERDUTY_ALERTING_FACTORY: Final = PagerDutyAlerting
|
||||
_in_memory_loggers: Final[list[CustomLogger]] = []
|
||||
|
||||
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = frozenset(StandardLoggingMetadata.__annotations__.keys())
|
||||
_STANDARD_LOGGING_METADATA_RESOLVED_KEYS: Final[frozenset[str]] = frozenset(("used_client_oauth_token",))
|
||||
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = (
|
||||
frozenset(StandardLoggingMetadata.__annotations__.keys()) - _STANDARD_LOGGING_METADATA_RESOLVED_KEYS
|
||||
)
|
||||
|
||||
|
||||
def _get_provider_request_id(original_exception: Exception) -> str | None:
|
||||
|
|
@ -5730,6 +5734,7 @@ class StandardLoggingPayloadSetup:
|
|||
proxy_server_request: dict | None = None,
|
||||
start_time: dt_object | None = None,
|
||||
response_id: str | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
) -> StandardLoggingMetadata:
|
||||
"""
|
||||
Clean and filter the metadata dictionary to include only the specified keys in StandardLoggingMetadata.
|
||||
|
|
@ -5744,6 +5749,9 @@ class StandardLoggingPayloadSetup:
|
|||
- If the input metadata is None or not a dictionary, an empty StandardLoggingMetadata object is returned.
|
||||
- If 'user_api_key' is present in metadata and is a valid SHA256 hash, it's stored as 'user_api_key_hash'.
|
||||
"""
|
||||
from litellm.llms.anthropic.common_utils import ( # noqa: PLC0415 # that module imports this one transitively
|
||||
resolve_used_client_oauth_token,
|
||||
)
|
||||
|
||||
prompt_management_metadata: StandardLoggingPromptManagementMetadata | None = None
|
||||
if litellm_params is not None:
|
||||
|
|
@ -5793,6 +5801,10 @@ class StandardLoggingPayloadSetup:
|
|||
user_api_key_auth_metadata=None,
|
||||
team_alias=None,
|
||||
team_id=None,
|
||||
used_client_oauth_token=resolve_used_client_oauth_token(
|
||||
proxy_stamped_used_client_oauth_token(metadata, litellm_params),
|
||||
custom_llm_provider,
|
||||
),
|
||||
)
|
||||
if isinstance(metadata, dict):
|
||||
for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS:
|
||||
|
|
@ -6516,6 +6528,7 @@ def get_standard_logging_object_payload(
|
|||
stream=kwargs.get("stream", False),
|
||||
)
|
||||
# clean up litellm metadata
|
||||
selected_provider: Final = kwargs.get("custom_llm_provider")
|
||||
clean_metadata: Final = StandardLoggingPayloadSetup.get_standard_logging_metadata(
|
||||
metadata=metadata,
|
||||
litellm_params=litellm_params,
|
||||
|
|
@ -6527,6 +6540,7 @@ def get_standard_logging_object_payload(
|
|||
proxy_server_request=proxy_server_request,
|
||||
start_time=start_time,
|
||||
response_id=id,
|
||||
custom_llm_provider=selected_provider if isinstance(selected_provider, str) else None,
|
||||
)
|
||||
_request_body: Final = proxy_server_request.get("body", {})
|
||||
end_user_id: Final = clean_metadata["user_api_key_end_user_id"] or _request_body.get(
|
||||
|
|
@ -6801,6 +6815,7 @@ def get_standard_logging_metadata(
|
|||
user_api_key_auth_metadata=None,
|
||||
team_alias=None,
|
||||
team_id=None,
|
||||
used_client_oauth_token=None,
|
||||
)
|
||||
if isinstance(metadata, dict):
|
||||
# Update the clean_metadata with values from input metadata that match StandardLoggingMetadata fields
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from litellm.types.llms.anthropic import (
|
|||
)
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.proxy.model_listing import ModelInfoResponse
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
_MessageT = TypeVar("_MessageT")
|
||||
|
||||
|
|
@ -226,6 +227,15 @@ def is_anthropic_oauth_key(value: str | None) -> bool:
|
|||
return value.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX)
|
||||
|
||||
|
||||
ANTHROPIC_OAUTH_FORWARD_PROVIDERS: Final[frozenset[str]] = frozenset((LlmProviders.ANTHROPIC.value,))
|
||||
|
||||
|
||||
def resolve_used_client_oauth_token(client_sent_oauth_token: object, custom_llm_provider: str | None) -> bool | None:
|
||||
if not isinstance(client_sent_oauth_token, bool):
|
||||
return None
|
||||
return client_sent_oauth_token and custom_llm_provider in ANTHROPIC_OAUTH_FORWARD_PROVIDERS
|
||||
|
||||
|
||||
def _merge_beta_headers(existing: str | None, new_beta: str) -> str:
|
||||
"""Merge a new beta value into an existing comma-separated anthropic-beta header."""
|
||||
if not existing:
|
||||
|
|
|
|||
|
|
@ -4155,6 +4155,7 @@ class SpendLogsMetadata(TypedDict):
|
|||
litellm_gateway_injected_cache: ReadOnly[str | None]
|
||||
router_metadata: ReadOnly[SpendLogsRouterMetadata | None] # None = deployment not flagged internal_router_model
|
||||
azure_spillover: ReadOnly[AzureSpillover | None] # None = Azure did not report spillover
|
||||
used_client_oauth_token: ReadOnly[bool | None] # None = row written before the flag existed
|
||||
|
||||
|
||||
class SpendLogsPayload(TypedDict):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from litellm.litellm_core_utils.core_helpers import (
|
|||
_get_parent_otel_span_from_kwargs,
|
||||
budget_reservation_from_metadata,
|
||||
get_litellm_metadata_from_kwargs,
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
|
||||
|
|
@ -29,7 +30,7 @@ from litellm.proxy.db.db_spend_update_writer import (
|
|||
debitable_model_access_groups,
|
||||
get_llm_router,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
|
||||
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup, metadata_variable_name_for_route
|
||||
from litellm.proxy.spend_tracking.spend_counter_batch import post_call_counter_keys, spend_counter_batch_scope
|
||||
from litellm.proxy.spend_tracking.spend_event import (
|
||||
ObjectMapping,
|
||||
|
|
@ -86,6 +87,19 @@ _CAPTURED_IDENTITY_CALL_TYPES: Final[frozenset[str]] = frozenset(
|
|||
)
|
||||
|
||||
|
||||
def _proxy_stamped_used_client_oauth_token(
|
||||
request_data: Mapping[str, object], request_route: str | None
|
||||
) -> bool | None:
|
||||
proxy_bucket: Final = (
|
||||
get_metadata_variable_name_from_kwargs(request_data)
|
||||
if request_route is None
|
||||
else metadata_variable_name_for_route(request_route)
|
||||
)
|
||||
proxy_metadata: Final = request_data.get(proxy_bucket)
|
||||
stamped: Final = proxy_metadata.get("used_client_oauth_token") if isinstance(proxy_metadata, dict) else None
|
||||
return stamped if isinstance(stamped, bool) else None
|
||||
|
||||
|
||||
def _proxy_spend_writer() -> DBSpendUpdateWriter:
|
||||
from litellm.proxy.proxy_server import proxy_logging_obj
|
||||
|
||||
|
|
@ -192,6 +206,8 @@ class _ProxyDBLogger(CustomLogger):
|
|||
metadata=_metadata, original_exception=original_exception
|
||||
)
|
||||
|
||||
_metadata["used_client_oauth_token"] = _proxy_stamped_used_client_oauth_token(request_data, request_route)
|
||||
|
||||
existing_metadata: Final[dict] = request_data.get("metadata", None) or {}
|
||||
existing_metadata.update(_metadata)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from collections import OrderedDict
|
|||
from collections.abc import Mapping, MutableMapping, Sequence
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, cast
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -45,6 +45,7 @@ from litellm.litellm_core_utils.url_utils import (
|
|||
is_url_destination_allowed_by_host,
|
||||
provider_url_destination_candidates,
|
||||
)
|
||||
from litellm.llms.anthropic.common_utils import ANTHROPIC_OAUTH_FORWARD_PROVIDERS
|
||||
from litellm.proxy._types import (
|
||||
AddTeamCallback,
|
||||
CommonProxyErrors,
|
||||
|
|
@ -648,11 +649,14 @@ def _get_metadata_variable_name(request: Request) -> str:
|
|||
# Inline imports — auth_utils/route_checks participate in a proxy import cycle.
|
||||
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
|
||||
|
||||
path: Final = get_request_route(request)
|
||||
if "thread" in path or "assistant" in path:
|
||||
return metadata_variable_name_for_route(get_request_route(request))
|
||||
|
||||
|
||||
def metadata_variable_name_for_route(route: str) -> Literal["metadata", "litellm_metadata"]:
|
||||
if "thread" in route or "assistant" in route:
|
||||
return "litellm_metadata"
|
||||
|
||||
if any(route in path for route in LITELLM_METADATA_ROUTES):
|
||||
if any(metadata_route in route for metadata_route in LITELLM_METADATA_ROUTES):
|
||||
return "litellm_metadata"
|
||||
|
||||
return "metadata"
|
||||
|
|
@ -2187,7 +2191,9 @@ async def add_litellm_data_to_request(
|
|||
data["api_version"] = dynamic_api_version
|
||||
|
||||
## Forward any LLM API Provider specific headers in extra_headers
|
||||
add_provider_specific_headers_to_request(data=data, headers=_headers)
|
||||
data[_metadata_variable_name]["used_client_oauth_token"] = add_provider_specific_headers_to_request(
|
||||
data=data, headers=_headers
|
||||
)
|
||||
|
||||
## Cache Controls
|
||||
cache_control_header: Final = _headers.get("Cache-Control", None)
|
||||
|
|
@ -3479,13 +3485,13 @@ _ANTHROPIC_API_HEADER_PROVIDERS: Final = ",".join(
|
|||
LlmProviders.VERTEX_AI.value,
|
||||
)
|
||||
)
|
||||
_ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = LlmProviders.ANTHROPIC.value
|
||||
_ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = ",".join(sorted(ANTHROPIC_OAUTH_FORWARD_PROVIDERS))
|
||||
|
||||
|
||||
def add_provider_specific_headers_to_request(
|
||||
data: dict,
|
||||
headers: dict,
|
||||
):
|
||||
) -> bool:
|
||||
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
|
||||
|
||||
anthropic_api_headers: Final = {header: headers[header] for header in ANTHROPIC_API_HEADERS if header in headers}
|
||||
|
|
@ -3506,6 +3512,7 @@ def add_provider_specific_headers_to_request(
|
|||
|
||||
if scoped_headers:
|
||||
data["provider_specific_header"] = scoped_headers[0] if len(scoped_headers) == 1 else scoped_headers
|
||||
return bool(anthropic_oauth_credential_headers)
|
||||
|
||||
|
||||
def _add_otel_traceparent_to_data(data: dict, request: Request):
|
||||
|
|
|
|||
|
|
@ -2520,6 +2520,15 @@ async def ui_view_spend_logs(
|
|||
default=None,
|
||||
description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
|
||||
),
|
||||
used_client_oauth_token: Annotated[
|
||||
bool | None,
|
||||
fastapi.Query(
|
||||
description=(
|
||||
"Filter logs by the credential the upstream call used: true for a client-forwarded Anthropic OAuth "
|
||||
"token, false for the deployment's configured key. Rows written before this flag existed match neither"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
span_type: str | None = fastapi.Query(
|
||||
default=None,
|
||||
description="Filter logs by span type: llm, agent, mcp, or batch",
|
||||
|
|
@ -2929,6 +2938,10 @@ async def ui_view_spend_logs(
|
|||
sql_conditions.append(f"metadata->'error_information'->>'error_message' LIKE ${p}")
|
||||
sql_params.append(f"%{error_message}%")
|
||||
p += 1
|
||||
if used_client_oauth_token is not None:
|
||||
sql_conditions.append(f"metadata->>'used_client_oauth_token' = ${p}")
|
||||
sql_params.append(json.dumps(used_client_oauth_token))
|
||||
p += 1
|
||||
|
||||
if status_filter is not None and group_by_session is True and not is_search_lookup:
|
||||
session_filter_conditions: Final = " AND ".join(sql_conditions) or "TRUE"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from litellm.constants import (
|
|||
from litellm.litellm_core_utils.classifier_logging import classifier_audit_fields, without_classifier_audit
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
get_litellm_metadata_from_kwargs,
|
||||
proxy_stamped_used_client_oauth_token,
|
||||
reconstruct_model_name,
|
||||
)
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider
|
||||
|
|
@ -45,6 +46,7 @@ from litellm.litellm_core_utils.litellm_logging import (
|
|||
from litellm.litellm_core_utils.ptu_pricing import azure_spillover
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
||||
from litellm.llms.anthropic.common_utils import resolve_used_client_oauth_token
|
||||
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsRouterMetadata
|
||||
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
|
||||
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
|
||||
|
|
@ -155,6 +157,7 @@ _STAMPED_METADATA_KEYS: Final = frozenset(
|
|||
"autorouter_savings",
|
||||
"autorouter_savings_estimate",
|
||||
"autorouter_baseline_observation",
|
||||
"used_client_oauth_token",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -179,6 +182,7 @@ def _get_spend_logs_metadata(
|
|||
autorouter_baseline_observation: str | None = None,
|
||||
router_metadata: SpendLogsRouterMetadata | None = None,
|
||||
azure_spillover: AzureSpillover | None = None,
|
||||
used_client_oauth_token: bool | None = None,
|
||||
) -> SpendLogsMetadata:
|
||||
if metadata is None:
|
||||
return SpendLogsMetadata(
|
||||
|
|
@ -223,6 +227,7 @@ def _get_spend_logs_metadata(
|
|||
litellm_call_id=litellm_call_id,
|
||||
router_metadata=router_metadata,
|
||||
azure_spillover=azure_spillover,
|
||||
used_client_oauth_token=used_client_oauth_token,
|
||||
)
|
||||
verbose_proxy_logger.debug(
|
||||
"getting payload for SpendLogs, available keys in metadata: " + str(list(metadata.keys()))
|
||||
|
|
@ -238,6 +243,7 @@ def _get_spend_logs_metadata(
|
|||
autorouter_baseline_observation=autorouter_baseline_observation,
|
||||
router_metadata=router_metadata,
|
||||
azure_spillover=azure_spillover,
|
||||
used_client_oauth_token=used_client_oauth_token,
|
||||
)
|
||||
_raw_key: Final = clean_metadata.get("user_api_key")
|
||||
_trusted_hash: Final = metadata.get("user_api_key_hash")
|
||||
|
|
@ -715,6 +721,9 @@ def get_logging_payload(
|
|||
selected_provider=custom_llm_provider,
|
||||
router_correlation_id=litellm_call_id,
|
||||
),
|
||||
used_client_oauth_token=resolve_used_client_oauth_token(
|
||||
proxy_stamped_used_client_oauth_token(litellm_params.get("metadata"), litellm_params), custom_llm_provider
|
||||
),
|
||||
azure_spillover=azure_spillover(
|
||||
response_headers=kwargs.get("response_headers")
|
||||
if isinstance(kwargs.get("response_headers"), Mapping)
|
||||
|
|
|
|||
|
|
@ -3186,6 +3186,7 @@ class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata):
|
|||
cold_storage_object_key: str | None # S3/GCS object key for cold storage retrieval
|
||||
team_alias: str | None
|
||||
team_id: str | None
|
||||
used_client_oauth_token: ReadOnly[bool | None]
|
||||
|
||||
|
||||
class AzureSpillover(TypedDict):
|
||||
|
|
|
|||
|
|
@ -160,6 +160,138 @@ async def test_async_post_call_failure_hook_does_not_clobber_guardrail_info_in_m
|
|||
assert metadata["standard_logging_guardrail_information"] == metadata_bucket_info
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"used_client_oauth_token, custom_llm_provider, expected",
|
||||
[(True, "anthropic", True), (True, "bedrock", False), (False, "anthropic", False)],
|
||||
)
|
||||
async def test_async_post_call_failure_hook_carries_used_client_oauth_token_from_litellm_metadata(
|
||||
used_client_oauth_token: bool, custom_llm_provider: str, expected: bool
|
||||
):
|
||||
"""
|
||||
/v1/messages and /v1/responses stamp the proxy's own fields into request_data["litellm_metadata"]
|
||||
and leave request_data["metadata"] to the caller's native metadata, so a failed request on those
|
||||
routes wrote a spend row whose used_client_oauth_token was null instead of the stamped value
|
||||
"""
|
||||
logger = _ProxyDBLogger()
|
||||
request_data = {
|
||||
"model": "claude-sonnet-5",
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"metadata": {"user_id": "anthropic-native-metadata"},
|
||||
"litellm_metadata": {"used_client_oauth_token": used_client_oauth_token},
|
||||
"proxy_server_request": {"request_id": "test_request_id"},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update_database:
|
||||
await logger.async_post_call_failure_hook(
|
||||
request_data=request_data,
|
||||
original_exception=Exception("rate limited"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key"),
|
||||
)
|
||||
|
||||
call_kwargs = mock_update_database.call_args[1]["kwargs"]
|
||||
assert call_kwargs["litellm_params"]["metadata"]["user_id"] == "anthropic-native-metadata"
|
||||
payload = get_logging_payload(
|
||||
kwargs=call_kwargs, response_obj={}, start_time=datetime.now(), end_time=datetime.now()
|
||||
)
|
||||
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"metadata_buckets, expected",
|
||||
[
|
||||
({"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"used_client_oauth_token": False}}, False),
|
||||
({"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_id": "caller"}}, None),
|
||||
({"metadata": {"used_client_oauth_token": "yes"}}, None),
|
||||
],
|
||||
)
|
||||
async def test_async_post_call_failure_hook_never_lets_caller_metadata_set_used_client_oauth_token(
|
||||
metadata_buckets: dict, expected: bool | None
|
||||
):
|
||||
"""
|
||||
On /v1/messages and /v1/responses the request's own metadata field belongs to the caller, so a
|
||||
used_client_oauth_token they put there must never outrank the proxy's stamp or stand in for a missing one
|
||||
"""
|
||||
logger = _ProxyDBLogger()
|
||||
request_data = {
|
||||
"model": "claude-sonnet-5",
|
||||
"custom_llm_provider": "anthropic",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"proxy_server_request": {"request_id": "test_request_id"},
|
||||
**metadata_buckets,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update_database:
|
||||
await logger.async_post_call_failure_hook(
|
||||
request_data=request_data,
|
||||
original_exception=Exception("rate limited"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key"),
|
||||
)
|
||||
|
||||
payload = get_logging_payload(
|
||||
kwargs=mock_update_database.call_args[1]["kwargs"],
|
||||
response_obj={},
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"request_route, metadata_buckets, expected",
|
||||
[
|
||||
(
|
||||
"/v1/chat/completions",
|
||||
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_api_key_hash": "guardrail"}},
|
||||
True,
|
||||
),
|
||||
(
|
||||
"/v1/messages",
|
||||
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_api_key_hash": "proxy"}},
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_async_post_call_failure_hook_reads_used_client_oauth_token_from_the_routes_stamped_bucket(
|
||||
request_route: str, metadata_buckets: dict, expected: bool | None
|
||||
):
|
||||
logger = _ProxyDBLogger()
|
||||
request_data = {
|
||||
"model": "claude-sonnet-5",
|
||||
"custom_llm_provider": "anthropic",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"proxy_server_request": {"request_id": "test_request_id"},
|
||||
**metadata_buckets,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update_database:
|
||||
await logger.async_post_call_failure_hook(
|
||||
request_data=request_data,
|
||||
original_exception=Exception("rate limited"),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key", request_route=request_route),
|
||||
)
|
||||
|
||||
payload = get_logging_payload(
|
||||
kwargs=mock_update_database.call_args[1]["kwargs"],
|
||||
response_obj={},
|
||||
start_time=datetime.now(),
|
||||
end_time=datetime.now(),
|
||||
)
|
||||
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_post_call_failure_hook_bills_guardrail_cost_on_blocked_request():
|
||||
"""LIT-5651: a request blocked by a guardrail never reaches the LLM, but the
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
|
|||
alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond)
|
||||
code = re.search(r"error_code' = \$(\d+)", cond)
|
||||
msg = re.search(r"error_message' LIKE \$(\d+)", cond)
|
||||
credential = re.fullmatch(r"metadata->>'used_client_oauth_token' = \$(\d+)", cond)
|
||||
sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond)
|
||||
status = re.fullmatch(r"status = \$(\d+)", cond)
|
||||
api_key_not_in = re.fullmatch(r"api_key NOT IN \(\$(\d+), \$(\d+)\)", cond)
|
||||
|
|
@ -177,6 +178,13 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
|
|||
"string_contains": str(params[int(msg.group(1)) - 1]).strip("%"),
|
||||
}
|
||||
)
|
||||
elif credential:
|
||||
metadata_conds.append(
|
||||
{
|
||||
"path": ["used_client_oauth_token"],
|
||||
"equals": params[int(credential.group(1)) - 1],
|
||||
}
|
||||
)
|
||||
else:
|
||||
for sql_col, key in eq_cols.items():
|
||||
eq = re.fullmatch(rf"{re.escape(sql_col)} = \$(\d+)", cond)
|
||||
|
|
@ -3362,6 +3370,82 @@ async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch):
|
|||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_with_used_client_oauth_token_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": "claude-sonnet-5",
|
||||
"status": "success",
|
||||
}
|
||||
mock_spend_logs = [
|
||||
{**base, "id": "log1", "request_id": "req-seat", "metadata": {"used_client_oauth_token": True}},
|
||||
{**base, "id": "log2", "request_id": "req-key", "metadata": {"used_client_oauth_token": False}},
|
||||
{**base, "id": "log3", "request_id": "req-legacy", "metadata": {"user_agent": "curl/8.7.1"}},
|
||||
]
|
||||
|
||||
def filter_by_credential(where):
|
||||
metadata_filter = where.get("metadata")
|
||||
if metadata_filter is None:
|
||||
return mock_spend_logs
|
||||
assert metadata_filter["path"] == ["used_client_oauth_token"]
|
||||
return [
|
||||
log
|
||||
for log in mock_spend_logs
|
||||
if json.dumps(log["metadata"].get("used_client_oauth_token")) == metadata_filter["equals"]
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_credential),
|
||||
)
|
||||
|
||||
start_date, end_date = _default_date_range()
|
||||
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
for flag, expected_ids in (("true", ["req-seat"]), ("false", ["req-key"])):
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"used_client_oauth_token": flag,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == len(expected_ids)
|
||||
assert [row["request_id"] for row in data["data"]] == expected_ids
|
||||
|
||||
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"] == 3
|
||||
|
||||
response = client.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"used_client_oauth_token": "seat",
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
headers={"Authorization": "Bearer sk-test"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_view_spend_logs_with_span_type_filter(client, monkeypatch):
|
||||
base = {
|
||||
|
|
@ -3767,7 +3851,7 @@ class TestSpendLogsPayload:
|
|||
"model": "gpt-4o",
|
||||
"user": "",
|
||||
"team_id": "",
|
||||
"metadata": '{"actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
|
||||
"metadata": '{"actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "used_client_oauth_token": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
|
||||
"cache_key": "Cache OFF",
|
||||
"spend": 0.00022500000000000002,
|
||||
"total_tokens": 30,
|
||||
|
|
|
|||
|
|
@ -3369,6 +3369,65 @@ def test_get_spend_logs_metadata_keeps_user_agent():
|
|||
assert _get_spend_logs_metadata(None)["user_agent"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"client_sent_oauth_token, custom_llm_provider, expected",
|
||||
[
|
||||
(True, "anthropic", True),
|
||||
(True, "bedrock", False),
|
||||
(True, "vertex_ai", False),
|
||||
(False, "anthropic", False),
|
||||
(None, "anthropic", None),
|
||||
],
|
||||
)
|
||||
def test_get_logging_payload_records_used_client_oauth_token_for_the_selected_provider(
|
||||
client_sent_oauth_token: bool | None, custom_llm_provider: str, expected: bool | None
|
||||
):
|
||||
"""The client's OAuth bearer is only forwarded to an Anthropic deployment, so a request that
|
||||
the router sent to Bedrock or Vertex paid with the configured key and must not read true."""
|
||||
request_metadata = (
|
||||
{"user_agent": "claude-cli/2.1.0"}
|
||||
if client_sent_oauth_token is None
|
||||
else {"user_agent": "claude-cli/2.1.0", "used_client_oauth_token": client_sent_oauth_token}
|
||||
)
|
||||
payload = get_logging_payload(
|
||||
kwargs={
|
||||
"model": "claude-sonnet-5",
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"litellm_params": {"metadata": request_metadata},
|
||||
},
|
||||
response_obj={},
|
||||
start_time=datetime.datetime.now(timezone.utc),
|
||||
end_time=datetime.datetime.now(timezone.utc),
|
||||
)
|
||||
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
|
||||
assert _get_spend_logs_metadata(None)["used_client_oauth_token"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"litellm_params, expected",
|
||||
[
|
||||
(
|
||||
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_api_key_hash": "guardrail"}},
|
||||
True,
|
||||
),
|
||||
(
|
||||
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"used_client_oauth_token": False}},
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_logging_payload_reads_used_client_oauth_token_from_the_bucket_the_proxy_stamped(
|
||||
litellm_params: dict, expected: bool
|
||||
):
|
||||
payload = get_logging_payload(
|
||||
kwargs={"model": "claude-sonnet-5", "custom_llm_provider": "anthropic", "litellm_params": litellm_params},
|
||||
response_obj={},
|
||||
start_time=datetime.datetime.now(timezone.utc),
|
||||
end_time=datetime.datetime.now(timezone.utc),
|
||||
)
|
||||
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
|
||||
|
||||
|
||||
def test_redact_logged_api_key_bearer_only_returns_none():
|
||||
# "bearer " with nothing after stripping is equivalent to no key
|
||||
assert _redact_logged_api_key("bearer ") is None
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from litellm.proxy.litellm_pre_call_utils import (
|
|||
move_guardrails_to_metadata,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
|
||||
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
|
||||
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
|
||||
from litellm.litellm_core_utils.redact_messages import _get_turn_off_message_logging_from_dynamic_params
|
||||
from litellm.litellm_core_utils.get_provider_specific_headers import (
|
||||
|
|
@ -6792,6 +6793,55 @@ async def test_add_litellm_data_to_request_redacts_oauth_header_from_logging_cop
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path, metadata_variable_name",
|
||||
[
|
||||
("/v1/messages", "litellm_metadata"),
|
||||
("/v1/chat/completions", "metadata"),
|
||||
],
|
||||
)
|
||||
async def test_add_litellm_data_to_request_stamps_used_client_oauth_token(path, metadata_variable_name):
|
||||
"""A seat-billed request and a configured-key request must land in spend logs differing on exactly
|
||||
the credential flag, and the flag must never carry the token itself."""
|
||||
|
||||
async def metadata_for(client_headers: dict) -> dict:
|
||||
request_mock = _make_request_mock(path, {"Content-Type": "application/json", **client_headers})
|
||||
updated = await add_litellm_data_to_request(
|
||||
data={"model": "anthropic-claude", "messages": [{"role": "user", "content": "hello"}]},
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={"forward_client_headers_to_llm_api": True},
|
||||
version="test-version",
|
||||
)
|
||||
return updated[metadata_variable_name]
|
||||
|
||||
def spend_log_row_metadata(request_metadata: dict) -> dict:
|
||||
row = get_logging_payload(
|
||||
kwargs={
|
||||
"model": "claude-sonnet-5",
|
||||
"custom_llm_provider": "anthropic",
|
||||
"litellm_params": {"metadata": request_metadata},
|
||||
},
|
||||
response_obj={},
|
||||
start_time=datetime.now(timezone.utc),
|
||||
end_time=datetime.now(timezone.utc),
|
||||
)
|
||||
return json.loads(row["metadata"])
|
||||
|
||||
seat_row = spend_log_row_metadata(
|
||||
await metadata_for({"Authorization": _OAUTH_TOKEN, "x-litellm-api-key": "Bearer sk-virtual-key"})
|
||||
)
|
||||
key_row = spend_log_row_metadata(await metadata_for({"Authorization": "Bearer sk-virtual-key"}))
|
||||
|
||||
assert seat_row["used_client_oauth_token"] is True
|
||||
assert key_row["used_client_oauth_token"] is False
|
||||
differing_keys = {key for key in seat_row.keys() | key_row.keys() if seat_row.get(key) != key_row.get(key)}
|
||||
assert differing_keys == {"used_client_oauth_token"}
|
||||
assert "sk-ant-oat01" not in json.dumps(seat_row, default=repr)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_keeps_every_forwarded_credential_out_of_logging_copies():
|
||||
"""Credentials kept for transport must not survive anywhere under proxy_server_request."""
|
||||
|
|
@ -7585,6 +7635,23 @@ def test_client_anthropic_api_headers_stay_off_openai_compatible_providers():
|
|||
assert forwarded == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("authorization_header_name", AUTHORIZATION_HEADER_CASINGS)
|
||||
def test_add_provider_specific_headers_reports_a_forwarded_oauth_credential(authorization_header_name):
|
||||
assert add_provider_specific_headers_to_request(data={}, headers=_client_headers(authorization_header_name)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
_client_headers(None),
|
||||
{"content-type": "application/json", "authorization": "Bearer sk-a-normal-key"},
|
||||
{"anthropic-beta": "claude-code-20250219", "authorization": "Bearer sk-ant-api03-a-configured-key"},
|
||||
],
|
||||
)
|
||||
def test_add_provider_specific_headers_reports_no_oauth_credential_without_a_forwarded_token(headers):
|
||||
assert add_provider_specific_headers_to_request(data={}, headers=headers) is False
|
||||
|
||||
|
||||
def test_no_provider_specific_header_when_client_sends_nothing_anthropic():
|
||||
data: dict = {}
|
||||
add_provider_specific_headers_to_request(
|
||||
|
|
|
|||
|
|
@ -4858,6 +4858,75 @@ def test_get_standard_logging_object_payload_includes_litellm_call_id(logging_ob
|
|||
assert payload["litellm_call_id"] == call_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"client_sent_oauth_token, custom_llm_provider, expected",
|
||||
[(True, "anthropic", True), (True, "bedrock", False), (False, "anthropic", False), (None, "anthropic", None)],
|
||||
)
|
||||
def test_get_standard_logging_object_payload_resolves_used_client_oauth_token_against_the_selected_provider(
|
||||
logging_obj, client_sent_oauth_token: bool | None, custom_llm_provider: str, expected: bool | None
|
||||
):
|
||||
"""The proxy stamps whether the client presented an Anthropic OAuth bearer before routing, but the
|
||||
bearer only reaches an Anthropic deployment, so the logged flag must follow the provider that was called."""
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import get_standard_logging_object_payload
|
||||
|
||||
request_metadata = {} if client_sent_oauth_token is None else {"used_client_oauth_token": client_sent_oauth_token}
|
||||
now = datetime.now()
|
||||
payload = get_standard_logging_object_payload(
|
||||
kwargs={
|
||||
"model": "claude-sonnet-5",
|
||||
"messages": [],
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"litellm_params": {"metadata": request_metadata},
|
||||
},
|
||||
init_response_obj={},
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=logging_obj,
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["metadata"]["used_client_oauth_token"] is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata, litellm_metadata, expected",
|
||||
[
|
||||
({"used_client_oauth_token": True}, {"used_client_oauth_token": False}, False),
|
||||
({"used_client_oauth_token": False}, {"used_client_oauth_token": True}, True),
|
||||
({"used_client_oauth_token": True}, {"compression_savings": 1}, True),
|
||||
],
|
||||
)
|
||||
def test_get_standard_logging_object_payload_takes_used_client_oauth_token_from_the_proxy_stamped_slot(
|
||||
logging_obj, metadata: dict, litellm_metadata: dict, expected: bool
|
||||
):
|
||||
"""On routes that carry proxy metadata in `litellm_metadata`, `metadata` is the caller's own body field,
|
||||
so a caller writing the flag there must not override what the proxy stamped."""
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import get_standard_logging_object_payload
|
||||
|
||||
now = datetime.now()
|
||||
payload = get_standard_logging_object_payload(
|
||||
kwargs={
|
||||
"model": "claude-sonnet-5",
|
||||
"messages": [],
|
||||
"custom_llm_provider": "anthropic",
|
||||
"litellm_params": {"metadata": metadata, "litellm_metadata": litellm_metadata},
|
||||
},
|
||||
init_response_obj={},
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
logging_obj=logging_obj,
|
||||
status="success",
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
assert payload["metadata"]["used_client_oauth_token"] is expected
|
||||
|
||||
|
||||
def test_get_standard_logging_object_payload_carries_matched_access_groups(logging_obj):
|
||||
"""Access groups stamped at auth time reach the logging payload, so integrations see what a request billed."""
|
||||
from datetime import datetime
|
||||
|
|
|
|||
|
|
@ -2017,6 +2017,7 @@ interface UiSpendLogsParams {
|
|||
end_user?: string;
|
||||
status_filter?: string;
|
||||
cache_hit_filter?: string;
|
||||
used_client_oauth_token?: string;
|
||||
span_type?: string;
|
||||
/** Filter by model name (e.g. "gpt-4") */
|
||||
model?: string;
|
||||
|
|
|
|||
|
|
@ -399,6 +399,38 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getByText("192.168.1.1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Client OAuth token as the credential when the client's OAuth token was forwarded upstream", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
logEntry={createLogEntry({ metadata: { status: "success", used_client_oauth_token: true } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Credential")).toBeInTheDocument();
|
||||
expect(screen.getByText("Client OAuth token")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Configured key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Configured key as the credential when the deployment's own API key was used", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
logEntry={createLogEntry({ metadata: { status: "success", used_client_oauth_token: false } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Credential")).toBeInTheDocument();
|
||||
expect(screen.getByText("Configured key")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Client OAuth token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the Credential row for a log written before the credential was recorded", () => {
|
||||
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success" } })} />);
|
||||
|
||||
expect(screen.queryByText("Credential")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Client OAuth token")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Configured key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display guardrail label when guardrail data exists", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { CostBreakdownViewer } from "../CostBreakdownViewer";
|
||||
import { ConfigInfoMessage } from "../ConfigInfoMessage";
|
||||
import { VectorStoreViewer } from "../VectorStoreViewer";
|
||||
import { CREDENTIAL_LABELS } from "../constants";
|
||||
import { TruncatedValue } from "./TruncatedValue";
|
||||
import { TokenFlow } from "./TokenFlow";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
|
|
@ -162,6 +163,11 @@ export function LogDetailContent({
|
|||
{logEntry.requester_ip_address && (
|
||||
<DescriptionItem label="IP Address">{logEntry.requester_ip_address}</DescriptionItem>
|
||||
)}
|
||||
{typeof logEntry.metadata?.used_client_oauth_token === "boolean" && (
|
||||
<DescriptionItem label="Credential">
|
||||
{CREDENTIAL_LABELS[String(logEntry.metadata.used_client_oauth_token)]}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<DescriptionItem label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ describe("RequestLogsFilters", () => {
|
|||
"Span Type",
|
||||
"Status",
|
||||
"Cache",
|
||||
"Credential",
|
||||
"Key Alias",
|
||||
"User ID",
|
||||
"End User",
|
||||
|
|
@ -287,6 +288,16 @@ describe("RequestLogsFilters", () => {
|
|||
expect(await screen.findByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "All Credentials"],
|
||||
["true", "Client OAuth token"],
|
||||
["false", "Configured key"],
|
||||
])("shows the human label on the Credential trigger for %s", async (credential, label) => {
|
||||
renderFilters(credential === "" ? {} : { [LOG_FILTER_IDS.CREDENTIAL]: credential });
|
||||
|
||||
expect(await screen.findByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "All Types"],
|
||||
["llm", "LLM"],
|
||||
|
|
@ -332,6 +343,29 @@ describe("RequestLogsFilters", () => {
|
|||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Client OAuth token", "true"],
|
||||
["Configured key", "false"],
|
||||
])("selecting %s sets the credential filter to %s", async (label, expected) => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters();
|
||||
|
||||
await user.click(await screen.findByText("All Credentials"));
|
||||
await user.click(await screen.findByRole("option", { name: label }));
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CREDENTIAL, expected);
|
||||
});
|
||||
|
||||
it("selecting All Credentials clears the credential filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters({ [LOG_FILTER_IDS.CREDENTIAL]: "true" });
|
||||
|
||||
await user.click(await screen.findByText("Client OAuth token"));
|
||||
await user.click(await screen.findByRole("option", { name: "All Credentials" }));
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CREDENTIAL, undefined);
|
||||
});
|
||||
|
||||
it("stores the raw status code when a labeled error code is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import { ERROR_CODE_OPTIONS } from "./constants";
|
||||
import { CREDENTIAL_LABELS, ERROR_CODE_OPTIONS } from "./constants";
|
||||
import { LOG_FILTER_IDS, type LogsWindow } from "./log_filter_logic";
|
||||
|
||||
const ALL_VALUE = "all";
|
||||
|
|
@ -38,6 +38,11 @@ const CACHE_FILTER_ITEMS = [
|
|||
{ value: "miss", label: "Cache Miss" },
|
||||
] as const;
|
||||
|
||||
const CREDENTIAL_FILTER_ITEMS = [
|
||||
{ value: ALL_VALUE, label: "All Credentials" },
|
||||
...Object.entries(CREDENTIAL_LABELS).map(([value, label]) => ({ value, label })),
|
||||
] as const;
|
||||
|
||||
const SPAN_TYPE_FILTER_ITEMS = [
|
||||
{ value: ALL_VALUE, label: "All Types" },
|
||||
{ value: "llm", label: "LLM" },
|
||||
|
|
@ -397,6 +402,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
|
|||
</Select>
|
||||
</DataTableFilterField>
|
||||
|
||||
<DataTableFilterField label="Credential">
|
||||
<Select
|
||||
items={CREDENTIAL_FILTER_ITEMS}
|
||||
value={valueOf(LOG_FILTER_IDS.CREDENTIAL) === "" ? ALL_VALUE : valueOf(LOG_FILTER_IDS.CREDENTIAL)}
|
||||
onValueChange={(next) =>
|
||||
set(LOG_FILTER_IDS.CREDENTIAL, next === null || next === ALL_VALUE ? undefined : next)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Credentials" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREDENTIAL_FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
|
||||
<KeyAliasFilterField
|
||||
value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)}
|
||||
onChange={setter(LOG_FILTER_IDS.KEY_ALIAS)}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components
|
|||
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import type { LogEntry } from "./columns";
|
||||
import { SPAN_TYPE_LABELS } from "./constants";
|
||||
import { CREDENTIAL_LABELS, SPAN_TYPE_LABELS } from "./constants";
|
||||
import { LOG_FILTER_IDS, LOG_FILTER_LABELS, type LogsWindow } from "./log_filter_logic";
|
||||
import { RequestLogsFilters } from "./RequestLogsFilters";
|
||||
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
|
||||
|
|
@ -40,6 +40,9 @@ const formatFilterValue = (columnId: string, value: unknown): string => {
|
|||
if (columnId === LOG_FILTER_IDS.SPAN_TYPE) {
|
||||
return SPAN_TYPE_LABELS[String(value)] ?? String(value);
|
||||
}
|
||||
if (columnId === LOG_FILTER_IDS.CREDENTIAL) {
|
||||
return CREDENTIAL_LABELS[String(value)] ?? String(value);
|
||||
}
|
||||
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export const SPAN_TYPE_LABELS: Record<string, string> = {
|
|||
batch: "Batch",
|
||||
};
|
||||
|
||||
export const CREDENTIAL_LABELS: Record<string, string> = {
|
||||
true: "Client OAuth token",
|
||||
false: "Configured key",
|
||||
};
|
||||
|
||||
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
|
||||
{ label: "Last Minute", value: 1, unit: "minutes" },
|
||||
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ describe("useLogFilterLogic", () => {
|
|||
{ 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.CREDENTIAL, value: "true", param: "used_client_oauth_token" },
|
||||
{ id: LOG_FILTER_IDS.CREDENTIAL, value: "false", param: "used_client_oauth_token" },
|
||||
{ id: LOG_FILTER_IDS.SPAN_TYPE, value: "batch", param: "span_type" },
|
||||
{ id: LOG_FILTER_IDS.SPAN_TYPE, value: "mcp", param: "span_type" },
|
||||
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const LOG_FILTER_IDS = {
|
|||
SPAN_TYPE: "span_type",
|
||||
STATUS: "status",
|
||||
CACHE_STATUS: "cache_hit",
|
||||
CREDENTIAL: "used_client_oauth_token",
|
||||
KEY_ALIAS: "key_alias",
|
||||
END_USER: "end_user",
|
||||
ERROR_CODE: "error_code",
|
||||
|
|
@ -42,6 +43,7 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
|
|||
[LOG_FILTER_IDS.SPAN_TYPE]: "Span Type",
|
||||
[LOG_FILTER_IDS.STATUS]: "Status",
|
||||
[LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
|
||||
[LOG_FILTER_IDS.CREDENTIAL]: "Credential",
|
||||
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
|
||||
[LOG_FILTER_IDS.USER_ID]: "User ID",
|
||||
[LOG_FILTER_IDS.END_USER]: "End User",
|
||||
|
|
@ -185,6 +187,7 @@ export function useLogFilterLogic({
|
|||
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
|
||||
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
|
||||
cache_hit_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_STATUS),
|
||||
used_client_oauth_token: getFilterValue(columnFilters, LOG_FILTER_IDS.CREDENTIAL),
|
||||
span_type: getFilterValue(columnFilters, LOG_FILTER_IDS.SPAN_TYPE),
|
||||
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
|
||||
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -68320,6 +68320,8 @@ export interface operations {
|
|||
status_filter?: string | null;
|
||||
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
|
||||
cache_hit_filter?: string | null;
|
||||
/** @description Filter logs by the credential the upstream call used: true for a client-forwarded Anthropic OAuth token, false for the deployment's configured key. Rows written before this flag existed match neither */
|
||||
used_client_oauth_token?: boolean | null;
|
||||
/** @description Filter logs by span type: llm, agent, mcp, or batch */
|
||||
span_type?: string | null;
|
||||
/** @description Filter logs by model */
|
||||
|
|
@ -68440,6 +68442,8 @@ export interface operations {
|
|||
status_filter?: string | null;
|
||||
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
|
||||
cache_hit_filter?: string | null;
|
||||
/** @description Filter logs by the credential the upstream call used: true for a client-forwarded Anthropic OAuth token, false for the deployment's configured key. Rows written before this flag existed match neither */
|
||||
used_client_oauth_token?: boolean | null;
|
||||
/** @description Filter logs by span type: llm, agent, mcp, or batch */
|
||||
span_type?: string | null;
|
||||
/** @description Filter logs by model */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue