From 23a4bc5b6c03eaf8a4e21d8382ed1de09051a6b3 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 24 Sep 2026 15:40:16 -0700
Subject: [PATCH] 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
---
litellm/litellm_core_utils/litellm_logging.py | 2 +
litellm/proxy/_types.py | 1 +
litellm/proxy/litellm_pre_call_utils.py | 7 +-
.../spend_management_endpoints.py | 11 +++
.../spend_tracking/spend_tracking_utils.py | 1 +
litellm/types/utils.py | 1 +
.../test_spend_management_endpoints.py | 86 ++++++++++++++++++-
.../test_spend_tracking_utils.py | 8 ++
.../proxy/test_litellm_pre_call_utils.py | 58 +++++++++++++
.../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 +
19 files changed, 290 insertions(+), 5 deletions(-)
diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py
index 3b9419d483b..e35ec13e023 100644
--- a/litellm/litellm_core_utils/litellm_logging.py
+++ b/litellm/litellm_core_utils/litellm_logging.py
@@ -5789,6 +5789,7 @@ class StandardLoggingPayloadSetup:
user_api_key_auth_metadata=None,
team_alias=None,
team_id=None,
+ used_client_oauth_token=None,
)
if isinstance(metadata, dict):
for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS:
@@ -6797,6 +6798,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/proxy/_types.py b/litellm/proxy/_types.py
index b6de36f8423..dd9496dd638 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -4068,6 +4068,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/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py
index 8fc5faee2c9..907dbc58ace 100644
--- a/litellm/proxy/litellm_pre_call_utils.py
+++ b/litellm/proxy/litellm_pre_call_utils.py
@@ -2162,7 +2162,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)
@@ -3460,7 +3462,7 @@ _ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = LlmProviders.ANTHROPIC.value
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}
@@ -3481,6 +3483,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 822b827f985..39b86a2bf42 100644
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -2429,6 +2429,13 @@ 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: bool | None = fastapi.Query(
+ default=None,
+ 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"
+ ),
+ ),
span_type: str | None = fastapi.Query(
default=None,
description="Filter logs by span type: llm, agent, mcp, or batch",
@@ -2838,6 +2845,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 (
group_by_session is True
diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py
index 8f85ecdd480..aca72a954b1 100644
--- a/litellm/proxy/spend_tracking/spend_tracking_utils.py
+++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py
@@ -212,6 +212,7 @@ def _get_spend_logs_metadata(
litellm_call_id=litellm_call_id,
router_metadata=router_metadata,
azure_spillover=azure_spillover,
+ used_client_oauth_token=None,
)
verbose_proxy_logger.debug(
"getting payload for SpendLogs, available keys in metadata: " + str(list(metadata.keys()))
diff --git a/litellm/types/utils.py b/litellm/types/utils.py
index caf88e5d517..9933691c3c9 100644
--- a/litellm/types/utils.py
+++ b/litellm/types/utils.py
@@ -3160,6 +3160,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/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
index c6a4173b583..a1ba9661e9a 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
@@ -121,6 +121,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)
@@ -178,6 +179,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)
@@ -3429,6 +3437,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 = {
@@ -3845,7 +3929,7 @@ class TestSpendLogsPayload:
"model": "gpt-4o",
"user": "",
"team_id": "",
- "metadata": '{"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": '{"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 f471e3f8fbb..f9a415afd63 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
@@ -3271,6 +3271,14 @@ def test_get_spend_logs_metadata_keeps_user_agent():
assert _get_spend_logs_metadata(None)["user_agent"] is None
+@pytest.mark.parametrize("used_client_oauth_token", [True, False])
+def test_get_spend_logs_metadata_keeps_used_client_oauth_token(used_client_oauth_token: bool):
+ meta = _get_spend_logs_metadata({"used_client_oauth_token": used_client_oauth_token})
+ assert meta["used_client_oauth_token"] is used_client_oauth_token
+ assert _get_spend_logs_metadata(None)["used_client_oauth_token"] is None
+ assert _get_spend_logs_metadata({"user_agent": "curl/8.7.1"})["used_client_oauth_token"] is None
+
+
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 b2241191ced..d7692f9f3ff 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,8 @@ 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.litellm_core_utils.litellm_logging import get_standard_logging_metadata
+from litellm.proxy.spend_tracking.spend_tracking_utils import _get_spend_logs_metadata
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 (
@@ -6767,6 +6769,45 @@ 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:
+ return dict(_get_spend_logs_metadata(dict(get_standard_logging_metadata(metadata=request_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."""
@@ -7560,6 +7601,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/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx
index 19af2e79bdd..1b4ce46624b 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -2072,6 +2072,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 5d0bb56936a..f50164a8dc8 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -65633,6 +65633,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 */
@@ -65753,6 +65755,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 */