From 0c515ed7a8c7a1c94e16b528dc68bf268f8d5d8c Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:17:01 -0700 Subject: [PATCH] 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> --- litellm/litellm_core_utils/core_helpers.py | 7 + litellm/litellm_core_utils/litellm_logging.py | 17 ++- litellm/llms/anthropic/common_utils.py | 10 ++ litellm/proxy/_types.py | 1 + .../proxy/hooks/proxy_track_cost_callback.py | 18 ++- litellm/proxy/litellm_pre_call_utils.py | 21 ++- .../spend_management_endpoints.py | 13 ++ .../spend_tracking/spend_tracking_utils.py | 9 ++ litellm/types/utils.py | 1 + .../hooks/test_proxy_track_cost_callback.py | 132 ++++++++++++++++++ .../test_spend_management_endpoints.py | 86 +++++++++++- .../test_spend_tracking_utils.py | 59 ++++++++ .../proxy/test_litellm_pre_call_utils.py | 67 +++++++++ .../test_litellm_logging.py | 69 +++++++++ .../src/components/networking.tsx | 1 + .../LogDetailContent.integration.test.tsx | 32 +++++ .../LogDetailsDrawer/LogDetailContent.tsx | 6 + .../view_logs/RequestLogsFilters.test.tsx | 34 +++++ .../view_logs/RequestLogsFilters.tsx | 28 +++- .../components/view_logs/RequestLogsTable.tsx | 5 +- .../src/components/view_logs/constants.ts | 5 + .../view_logs/log_filter_logic.test.tsx | 2 + .../components/view_logs/log_filter_logic.tsx | 3 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 + 24 files changed, 618 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index b095b4b12c6..64f94ed3799 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -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 diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 2162a200565..154893b6c21 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -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 diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 3e61a0caa90..2ba7e9b6657 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -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: diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d2fad212dd9..a38eab0c19d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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): diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index 6a2ec120060..dc2723c4267 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -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) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 4188e8ad58a..66705505488 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -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): diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 939026f56c7..fc5719e6a77 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -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" diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 81f583b419c..f51232531f0 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -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) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index c12a4def69a..597494a31d2 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -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): diff --git a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py index 84da227c0a6..28376be64b6 100644 --- a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py +++ b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py @@ -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 diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 3ffb6335ad4..506de58e438 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -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, diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 782ce40e624..d8d7796d67a 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -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 diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 84266325226..c97a5d1337f 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -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( diff --git a/tests/unit/litellm_core_utils/test_litellm_logging.py b/tests/unit/litellm_core_utils/test_litellm_logging.py index 60b7ed32399..761fa38e73f 100644 --- a/tests/unit/litellm_core_utils/test_litellm_logging.py +++ b/tests/unit/litellm_core_utils/test_litellm_logging.py @@ -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 diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 7ffe0e0ec85..129b1089e9b 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx index 68336e80b0f..e0551c61062 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.integration.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(); + + 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( {logEntry.requester_ip_address} )} + {typeof logEntry.metadata?.used_client_oauth_token === "boolean" && ( + + {CREDENTIAL_LABELS[String(logEntry.metadata.used_client_oauth_token)]} + + )} {hasGuardrailData && ( diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index a2da80a3f7b..38542f033dc 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -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(); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index 5059f117944..e0c3205c80f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -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 + + + + { 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); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/constants.ts b/ui/litellm-dashboard/src/components/view_logs/constants.ts index 0b74d482412..9474c54d6b6 100644 --- a/ui/litellm-dashboard/src/components/view_logs/constants.ts +++ b/ui/litellm-dashboard/src/components/view_logs/constants.ts @@ -28,6 +28,11 @@ export const SPAN_TYPE_LABELS: Record = { batch: "Batch", }; +export const CREDENTIAL_LABELS: Record = { + 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" }, diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 3528bcfbaef..baf713b1537 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -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" }, diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index d247d7b20fa..8b2d9f22c15 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -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 = { [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), diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8ffd5c96ab0..a8563b3468c 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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 */