mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
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
This commit is contained in:
parent
1628978db7
commit
23a4bc5b6c
19 changed files with 290 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -399,6 +399,38 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getByText("192.168.1.1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Client OAuth token as the credential when the client's OAuth token was forwarded upstream", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
logEntry={createLogEntry({ metadata: { status: "success", used_client_oauth_token: true } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Credential")).toBeInTheDocument();
|
||||
expect(screen.getByText("Client OAuth token")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Configured key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Configured key as the credential when the deployment's own API key was used", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
logEntry={createLogEntry({ metadata: { status: "success", used_client_oauth_token: false } })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Credential")).toBeInTheDocument();
|
||||
expect(screen.getByText("Configured key")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Client OAuth token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the Credential row for a log written before the credential was recorded", () => {
|
||||
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success" } })} />);
|
||||
|
||||
expect(screen.queryByText("Credential")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Client OAuth token")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Configured key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display guardrail label when guardrail data exists", () => {
|
||||
render(
|
||||
<LogDetailContent
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { CostBreakdownViewer } from "../CostBreakdownViewer";
|
||||
import { ConfigInfoMessage } from "../ConfigInfoMessage";
|
||||
import { VectorStoreViewer } from "../VectorStoreViewer";
|
||||
import { CREDENTIAL_LABELS } from "../constants";
|
||||
import { TruncatedValue } from "./TruncatedValue";
|
||||
import { TokenFlow } from "./TokenFlow";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
|
|
@ -162,6 +163,11 @@ export function LogDetailContent({
|
|||
{logEntry.requester_ip_address && (
|
||||
<DescriptionItem label="IP Address">{logEntry.requester_ip_address}</DescriptionItem>
|
||||
)}
|
||||
{typeof logEntry.metadata?.used_client_oauth_token === "boolean" && (
|
||||
<DescriptionItem label="Credential">
|
||||
{CREDENTIAL_LABELS[String(logEntry.metadata.used_client_oauth_token)]}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<DescriptionItem label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ describe("RequestLogsFilters", () => {
|
|||
"Span Type",
|
||||
"Status",
|
||||
"Cache",
|
||||
"Credential",
|
||||
"Key Alias",
|
||||
"User ID",
|
||||
"End User",
|
||||
|
|
@ -287,6 +288,16 @@ describe("RequestLogsFilters", () => {
|
|||
expect(await screen.findByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "All Credentials"],
|
||||
["true", "Client OAuth token"],
|
||||
["false", "Configured key"],
|
||||
])("shows the human label on the Credential trigger for %s", async (credential, label) => {
|
||||
renderFilters(credential === "" ? {} : { [LOG_FILTER_IDS.CREDENTIAL]: credential });
|
||||
|
||||
expect(await screen.findByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "All Types"],
|
||||
["llm", "LLM"],
|
||||
|
|
@ -332,6 +343,29 @@ describe("RequestLogsFilters", () => {
|
|||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Client OAuth token", "true"],
|
||||
["Configured key", "false"],
|
||||
])("selecting %s sets the credential filter to %s", async (label, expected) => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters();
|
||||
|
||||
await user.click(await screen.findByText("All Credentials"));
|
||||
await user.click(await screen.findByRole("option", { name: label }));
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CREDENTIAL, expected);
|
||||
});
|
||||
|
||||
it("selecting All Credentials clears the credential filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters({ [LOG_FILTER_IDS.CREDENTIAL]: "true" });
|
||||
|
||||
await user.click(await screen.findByText("Client OAuth token"));
|
||||
await user.click(await screen.findByRole("option", { name: "All Credentials" }));
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CREDENTIAL, undefined);
|
||||
});
|
||||
|
||||
it("stores the raw status code when a labeled error code is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import { ERROR_CODE_OPTIONS } from "./constants";
|
||||
import { CREDENTIAL_LABELS, ERROR_CODE_OPTIONS } from "./constants";
|
||||
import { LOG_FILTER_IDS, type LogsWindow } from "./log_filter_logic";
|
||||
|
||||
const ALL_VALUE = "all";
|
||||
|
|
@ -38,6 +38,11 @@ const CACHE_FILTER_ITEMS = [
|
|||
{ value: "miss", label: "Cache Miss" },
|
||||
] as const;
|
||||
|
||||
const CREDENTIAL_FILTER_ITEMS = [
|
||||
{ value: ALL_VALUE, label: "All Credentials" },
|
||||
...Object.entries(CREDENTIAL_LABELS).map(([value, label]) => ({ value, label })),
|
||||
] as const;
|
||||
|
||||
const SPAN_TYPE_FILTER_ITEMS = [
|
||||
{ value: ALL_VALUE, label: "All Types" },
|
||||
{ value: "llm", label: "LLM" },
|
||||
|
|
@ -397,6 +402,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
|
|||
</Select>
|
||||
</DataTableFilterField>
|
||||
|
||||
<DataTableFilterField label="Credential">
|
||||
<Select
|
||||
items={CREDENTIAL_FILTER_ITEMS}
|
||||
value={valueOf(LOG_FILTER_IDS.CREDENTIAL) === "" ? ALL_VALUE : valueOf(LOG_FILTER_IDS.CREDENTIAL)}
|
||||
onValueChange={(next) =>
|
||||
set(LOG_FILTER_IDS.CREDENTIAL, next === null || next === ALL_VALUE ? undefined : next)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Credentials" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREDENTIAL_FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
|
||||
<KeyAliasFilterField
|
||||
value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)}
|
||||
onChange={setter(LOG_FILTER_IDS.KEY_ALIAS)}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components
|
|||
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import type { LogEntry } from "./columns";
|
||||
import { SPAN_TYPE_LABELS } from "./constants";
|
||||
import { CREDENTIAL_LABELS, SPAN_TYPE_LABELS } from "./constants";
|
||||
import { LOG_FILTER_IDS, LOG_FILTER_LABELS, type LogsWindow } from "./log_filter_logic";
|
||||
import { RequestLogsFilters } from "./RequestLogsFilters";
|
||||
import { getRequestLogsTableColumns } from "./RequestLogsTableColumns";
|
||||
|
|
@ -40,6 +40,9 @@ const formatFilterValue = (columnId: string, value: unknown): string => {
|
|||
if (columnId === LOG_FILTER_IDS.SPAN_TYPE) {
|
||||
return SPAN_TYPE_LABELS[String(value)] ?? String(value);
|
||||
}
|
||||
if (columnId === LOG_FILTER_IDS.CREDENTIAL) {
|
||||
return CREDENTIAL_LABELS[String(value)] ?? String(value);
|
||||
}
|
||||
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export const SPAN_TYPE_LABELS: Record<string, string> = {
|
|||
batch: "Batch",
|
||||
};
|
||||
|
||||
export const CREDENTIAL_LABELS: Record<string, string> = {
|
||||
true: "Client OAuth token",
|
||||
false: "Configured key",
|
||||
};
|
||||
|
||||
export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [
|
||||
{ label: "Last Minute", value: 1, unit: "minutes" },
|
||||
{ label: "Last 15 Minutes", value: 15, unit: "minutes" },
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ describe("useLogFilterLogic", () => {
|
|||
{ id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" },
|
||||
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "hit", param: "cache_hit_filter" },
|
||||
{ id: LOG_FILTER_IDS.CACHE_STATUS, value: "miss", param: "cache_hit_filter" },
|
||||
{ id: LOG_FILTER_IDS.CREDENTIAL, value: "true", param: "used_client_oauth_token" },
|
||||
{ id: LOG_FILTER_IDS.CREDENTIAL, value: "false", param: "used_client_oauth_token" },
|
||||
{ id: LOG_FILTER_IDS.SPAN_TYPE, value: "batch", param: "span_type" },
|
||||
{ id: LOG_FILTER_IDS.SPAN_TYPE, value: "mcp", param: "span_type" },
|
||||
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const LOG_FILTER_IDS = {
|
|||
SPAN_TYPE: "span_type",
|
||||
STATUS: "status",
|
||||
CACHE_STATUS: "cache_hit",
|
||||
CREDENTIAL: "used_client_oauth_token",
|
||||
KEY_ALIAS: "key_alias",
|
||||
END_USER: "end_user",
|
||||
ERROR_CODE: "error_code",
|
||||
|
|
@ -42,6 +43,7 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
|
|||
[LOG_FILTER_IDS.SPAN_TYPE]: "Span Type",
|
||||
[LOG_FILTER_IDS.STATUS]: "Status",
|
||||
[LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
|
||||
[LOG_FILTER_IDS.CREDENTIAL]: "Credential",
|
||||
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
|
||||
[LOG_FILTER_IDS.USER_ID]: "User ID",
|
||||
[LOG_FILTER_IDS.END_USER]: "End User",
|
||||
|
|
@ -185,6 +187,7 @@ export function useLogFilterLogic({
|
|||
end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER),
|
||||
status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS),
|
||||
cache_hit_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.CACHE_STATUS),
|
||||
used_client_oauth_token: getFilterValue(columnFilters, LOG_FILTER_IDS.CREDENTIAL),
|
||||
span_type: getFilterValue(columnFilters, LOG_FILTER_IDS.SPAN_TYPE),
|
||||
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
|
||||
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue