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 */