feat(proxy): record in spend logs whether a request used a client-forwarded Anthropic OAuth token (#43063)

* feat(proxy): record in spend logs whether a request used a client-forwarded Anthropic OAuth token

Stamp metadata.used_client_oauth_token where the proxy decides to forward a
client's Anthropic OAuth token, carry it through StandardLoggingMetadata into
the spend log row, add a used_client_oauth_token filter to /spend/logs/ui, and
surface it on the Logs page as a Credential filter and drawer field. The token
itself never reaches the log

* fix(proxy): carry used_client_oauth_token onto failure spend rows for litellm_metadata routes

* fix(proxy): resolve used_client_oauth_token against the provider the call was sent to

* fix(proxy): keep the proxy's used_client_oauth_token stamp on failure rows and move the resolver under llms/anthropic

* fix(logging): read used_client_oauth_token from the proxy-stamped metadata slot

On routes that carry proxy metadata in litellm_metadata, metadata is the
caller's own body field, and merge_litellm_metadata lets it win. Resolve the
flag from litellm_metadata when the proxy stamped it there so a caller cannot
set it in the standard logging payload

* fix(spend-logs): read used_client_oauth_token from the bucket the route stamped

A guardrail on the unified path adds litellm_metadata to a chat request after
the proxy stamped metadata, so both spend row writers read the new bucket and
stored null. The success row now resolves the flag the same way the callback
payload does, and the failure row picks the bucket from the request route.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-30 19:17:01 -07:00 • committed by GitHub
parent 54ae4c5bbf
commit 0c515ed7a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 618 additions and 12 deletions

View file

@ -339,6 +339,13 @@ def get_or_create_metadata_bucket(
return metadata_key, metadata_bucket
def proxy_stamped_used_client_oauth_token(metadata: object, litellm_params: Mapping[str, object] | None) -> object:
litellm_metadata: Final = litellm_params.get("litellm_metadata") if litellm_params is not None else None
if isinstance(litellm_metadata, Mapping) and "used_client_oauth_token" in litellm_metadata:
return litellm_metadata["used_client_oauth_token"]
return metadata.get("used_client_oauth_token") if isinstance(metadata, Mapping) else None
def get_litellm_metadata_from_kwargs(kwargs: dict):
"""
Helper to get litellm metadata from all litellm request kwargs

View file

@ -72,6 +72,7 @@ from litellm.litellm_core_utils.classifier_logging import (
from litellm.litellm_core_utils.core_helpers import (
get_provider_response_headers_from_hidden_params,
is_expected_client_error,
proxy_stamped_used_client_oauth_token,
reconstruct_model_name,
set_response_cost_in_hidden_params,
)
@ -284,7 +285,10 @@ else:
_PAGERDUTY_ALERTING_FACTORY: Final = PagerDutyAlerting
_in_memory_loggers: Final[list[CustomLogger]] = []
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = frozenset(StandardLoggingMetadata.__annotations__.keys())
_STANDARD_LOGGING_METADATA_RESOLVED_KEYS: Final[frozenset[str]] = frozenset(("used_client_oauth_token",))
_STANDARD_LOGGING_METADATA_KEYS: Final[frozenset[str]] = (
frozenset(StandardLoggingMetadata.__annotations__.keys()) - _STANDARD_LOGGING_METADATA_RESOLVED_KEYS
)
def _get_provider_request_id(original_exception: Exception) -> str | None:
@ -5730,6 +5734,7 @@ class StandardLoggingPayloadSetup:
proxy_server_request: dict | None = None,
start_time: dt_object | None = None,
response_id: str | None = None,
custom_llm_provider: str | None = None,
) -> StandardLoggingMetadata:
"""
Clean and filter the metadata dictionary to include only the specified keys in StandardLoggingMetadata.
@ -5744,6 +5749,9 @@ class StandardLoggingPayloadSetup:
- If the input metadata is None or not a dictionary, an empty StandardLoggingMetadata object is returned.
- If 'user_api_key' is present in metadata and is a valid SHA256 hash, it's stored as 'user_api_key_hash'.
"""
from litellm.llms.anthropic.common_utils import ( # noqa: PLC0415 # that module imports this one transitively
resolve_used_client_oauth_token,
)
prompt_management_metadata: StandardLoggingPromptManagementMetadata | None = None
if litellm_params is not None:
@ -5793,6 +5801,10 @@ class StandardLoggingPayloadSetup:
user_api_key_auth_metadata=None,
team_alias=None,
team_id=None,
used_client_oauth_token=resolve_used_client_oauth_token(
proxy_stamped_used_client_oauth_token(metadata, litellm_params),
custom_llm_provider,
),
)
if isinstance(metadata, dict):
for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS:
@ -6516,6 +6528,7 @@ def get_standard_logging_object_payload(
stream=kwargs.get("stream", False),
)
# clean up litellm metadata
selected_provider: Final = kwargs.get("custom_llm_provider")
clean_metadata: Final = StandardLoggingPayloadSetup.get_standard_logging_metadata(
metadata=metadata,
litellm_params=litellm_params,
@ -6527,6 +6540,7 @@ def get_standard_logging_object_payload(
proxy_server_request=proxy_server_request,
start_time=start_time,
response_id=id,
custom_llm_provider=selected_provider if isinstance(selected_provider, str) else None,
)
_request_body: Final = proxy_server_request.get("body", {})
end_user_id: Final = clean_metadata["user_api_key_end_user_id"] or _request_body.get(
@ -6801,6 +6815,7 @@ def get_standard_logging_metadata(
user_api_key_auth_metadata=None,
team_alias=None,
team_id=None,
used_client_oauth_token=None,
)
if isinstance(metadata, dict):
# Update the clean_metadata with values from input metadata that match StandardLoggingMetadata fields

View file

@ -40,6 +40,7 @@ from litellm.types.llms.anthropic import (
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.proxy.model_listing import ModelInfoResponse
from litellm.types.utils import LlmProviders
_MessageT = TypeVar("_MessageT")
@ -226,6 +227,15 @@ def is_anthropic_oauth_key(value: str | None) -> bool:
return value.startswith(ANTHROPIC_OAUTH_TOKEN_PREFIX)
ANTHROPIC_OAUTH_FORWARD_PROVIDERS: Final[frozenset[str]] = frozenset((LlmProviders.ANTHROPIC.value,))
def resolve_used_client_oauth_token(client_sent_oauth_token: object, custom_llm_provider: str | None) -> bool | None:
if not isinstance(client_sent_oauth_token, bool):
return None
return client_sent_oauth_token and custom_llm_provider in ANTHROPIC_OAUTH_FORWARD_PROVIDERS
def _merge_beta_headers(existing: str | None, new_beta: str) -> str:
"""Merge a new beta value into an existing comma-separated anthropic-beta header."""
if not existing:

View file

@ -4155,6 +4155,7 @@ class SpendLogsMetadata(TypedDict):
litellm_gateway_injected_cache: ReadOnly[str | None]
router_metadata: ReadOnly[SpendLogsRouterMetadata | None] # None = deployment not flagged internal_router_model
azure_spillover: ReadOnly[AzureSpillover | None] # None = Azure did not report spillover
used_client_oauth_token: ReadOnly[bool | None] # None = row written before the flag existed
class SpendLogsPayload(TypedDict):

View file

@ -13,6 +13,7 @@ from litellm.litellm_core_utils.core_helpers import (
_get_parent_otel_span_from_kwargs,
budget_reservation_from_metadata,
get_litellm_metadata_from_kwargs,
get_metadata_variable_name_from_kwargs,
)
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
@ -29,7 +30,7 @@ from litellm.proxy.db.db_spend_update_writer import (
debitable_model_access_groups,
get_llm_router,
)
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup, metadata_variable_name_for_route
from litellm.proxy.spend_tracking.spend_counter_batch import post_call_counter_keys, spend_counter_batch_scope
from litellm.proxy.spend_tracking.spend_event import (
ObjectMapping,
@ -86,6 +87,19 @@ _CAPTURED_IDENTITY_CALL_TYPES: Final[frozenset[str]] = frozenset(
)
def _proxy_stamped_used_client_oauth_token(
request_data: Mapping[str, object], request_route: str | None
) -> bool | None:
proxy_bucket: Final = (
get_metadata_variable_name_from_kwargs(request_data)
if request_route is None
else metadata_variable_name_for_route(request_route)
)
proxy_metadata: Final = request_data.get(proxy_bucket)
stamped: Final = proxy_metadata.get("used_client_oauth_token") if isinstance(proxy_metadata, dict) else None
return stamped if isinstance(stamped, bool) else None
def _proxy_spend_writer() -> DBSpendUpdateWriter:
from litellm.proxy.proxy_server import proxy_logging_obj
@ -192,6 +206,8 @@ class _ProxyDBLogger(CustomLogger):
metadata=_metadata, original_exception=original_exception
)
_metadata["used_client_oauth_token"] = _proxy_stamped_used_client_oauth_token(request_data, request_route)
existing_metadata: Final[dict] = request_data.get("metadata", None) or {}
existing_metadata.update(_metadata)

View file

@ -7,7 +7,7 @@ from collections import OrderedDict
from collections.abc import Mapping, MutableMapping, Sequence
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, cast
from typing import TYPE_CHECKING, Any, Final, Literal, cast
from fastapi import HTTPException, Request
from pydantic import TypeAdapter
@ -45,6 +45,7 @@ from litellm.litellm_core_utils.url_utils import (
is_url_destination_allowed_by_host,
provider_url_destination_candidates,
)
from litellm.llms.anthropic.common_utils import ANTHROPIC_OAUTH_FORWARD_PROVIDERS
from litellm.proxy._types import (
AddTeamCallback,
CommonProxyErrors,
@ -648,11 +649,14 @@ def _get_metadata_variable_name(request: Request) -> str:
# Inline imports — auth_utils/route_checks participate in a proxy import cycle.
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415
path: Final = get_request_route(request)
if "thread" in path or "assistant" in path:
return metadata_variable_name_for_route(get_request_route(request))
def metadata_variable_name_for_route(route: str) -> Literal["metadata", "litellm_metadata"]:
if "thread" in route or "assistant" in route:
return "litellm_metadata"
if any(route in path for route in LITELLM_METADATA_ROUTES):
if any(metadata_route in route for metadata_route in LITELLM_METADATA_ROUTES):
return "litellm_metadata"
return "metadata"
@ -2187,7 +2191,9 @@ async def add_litellm_data_to_request(
data["api_version"] = dynamic_api_version
## Forward any LLM API Provider specific headers in extra_headers
add_provider_specific_headers_to_request(data=data, headers=_headers)
data[_metadata_variable_name]["used_client_oauth_token"] = add_provider_specific_headers_to_request(
data=data, headers=_headers
)
## Cache Controls
cache_control_header: Final = _headers.get("Cache-Control", None)
@ -3479,13 +3485,13 @@ _ANTHROPIC_API_HEADER_PROVIDERS: Final = ",".join(
LlmProviders.VERTEX_AI.value,
)
)
_ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = LlmProviders.ANTHROPIC.value
_ANTHROPIC_OAUTH_CREDENTIAL_PROVIDERS: Final = ",".join(sorted(ANTHROPIC_OAUTH_FORWARD_PROVIDERS))
def add_provider_specific_headers_to_request(
data: dict,
headers: dict,
):
) -> bool:
from litellm.llms.anthropic.common_utils import is_anthropic_oauth_key
anthropic_api_headers: Final = {header: headers[header] for header in ANTHROPIC_API_HEADERS if header in headers}
@ -3506,6 +3512,7 @@ def add_provider_specific_headers_to_request(
if scoped_headers:
data["provider_specific_header"] = scoped_headers[0] if len(scoped_headers) == 1 else scoped_headers
return bool(anthropic_oauth_credential_headers)
def _add_otel_traceparent_to_data(data: dict, request: Request):

View file

@ -2520,6 +2520,15 @@ async def ui_view_spend_logs(
default=None,
description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
),
used_client_oauth_token: Annotated[
bool | None,
fastapi.Query(
description=(
"Filter logs by the credential the upstream call used: true for a client-forwarded Anthropic OAuth "
"token, false for the deployment's configured key. Rows written before this flag existed match neither"
),
),
] = None,
span_type: str | None = fastapi.Query(
default=None,
description="Filter logs by span type: llm, agent, mcp, or batch",
@ -2929,6 +2938,10 @@ async def ui_view_spend_logs(
sql_conditions.append(f"metadata->'error_information'->>'error_message' LIKE ${p}")
sql_params.append(f"%{error_message}%")
p += 1
if used_client_oauth_token is not None:
sql_conditions.append(f"metadata->>'used_client_oauth_token' = ${p}")
sql_params.append(json.dumps(used_client_oauth_token))
p += 1
if status_filter is not None and group_by_session is True and not is_search_lookup:
session_filter_conditions: Final = " AND ".join(sql_conditions) or "TRUE"

View file

@ -33,6 +33,7 @@ from litellm.constants import (
from litellm.litellm_core_utils.classifier_logging import classifier_audit_fields, without_classifier_audit
from litellm.litellm_core_utils.core_helpers import (
get_litellm_metadata_from_kwargs,
proxy_stamped_used_client_oauth_token,
reconstruct_model_name,
)
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider
@ -45,6 +46,7 @@ from litellm.litellm_core_utils.litellm_logging import (
from litellm.litellm_core_utils.ptu_pricing import azure_spillover
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.llms.anthropic.common_utils import resolve_used_client_oauth_token
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsRouterMetadata
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
@ -155,6 +157,7 @@ _STAMPED_METADATA_KEYS: Final = frozenset(
"autorouter_savings",
"autorouter_savings_estimate",
"autorouter_baseline_observation",
"used_client_oauth_token",
)
)
@ -179,6 +182,7 @@ def _get_spend_logs_metadata(
autorouter_baseline_observation: str | None = None,
router_metadata: SpendLogsRouterMetadata | None = None,
azure_spillover: AzureSpillover | None = None,
used_client_oauth_token: bool | None = None,
) -> SpendLogsMetadata:
if metadata is None:
return SpendLogsMetadata(
@ -223,6 +227,7 @@ def _get_spend_logs_metadata(
litellm_call_id=litellm_call_id,
router_metadata=router_metadata,
azure_spillover=azure_spillover,
used_client_oauth_token=used_client_oauth_token,
)
verbose_proxy_logger.debug(
"getting payload for SpendLogs, available keys in metadata: " + str(list(metadata.keys()))
@ -238,6 +243,7 @@ def _get_spend_logs_metadata(
autorouter_baseline_observation=autorouter_baseline_observation,
router_metadata=router_metadata,
azure_spillover=azure_spillover,
used_client_oauth_token=used_client_oauth_token,
)
_raw_key: Final = clean_metadata.get("user_api_key")
_trusted_hash: Final = metadata.get("user_api_key_hash")
@ -715,6 +721,9 @@ def get_logging_payload(
selected_provider=custom_llm_provider,
router_correlation_id=litellm_call_id,
),
used_client_oauth_token=resolve_used_client_oauth_token(
proxy_stamped_used_client_oauth_token(litellm_params.get("metadata"), litellm_params), custom_llm_provider
),
azure_spillover=azure_spillover(
response_headers=kwargs.get("response_headers")
if isinstance(kwargs.get("response_headers"), Mapping)

View file

@ -3186,6 +3186,7 @@ class StandardLoggingMetadata(StandardLoggingUserAPIKeyMetadata):
cold_storage_object_key: str | None # S3/GCS object key for cold storage retrieval
team_alias: str | None
team_id: str | None
used_client_oauth_token: ReadOnly[bool | None]
class AzureSpillover(TypedDict):

View file

@ -160,6 +160,138 @@ async def test_async_post_call_failure_hook_does_not_clobber_guardrail_info_in_m
assert metadata["standard_logging_guardrail_information"] == metadata_bucket_info
@pytest.mark.asyncio
@pytest.mark.parametrize(
"used_client_oauth_token, custom_llm_provider, expected",
[(True, "anthropic", True), (True, "bedrock", False), (False, "anthropic", False)],
)
async def test_async_post_call_failure_hook_carries_used_client_oauth_token_from_litellm_metadata(
used_client_oauth_token: bool, custom_llm_provider: str, expected: bool
):
"""
/v1/messages and /v1/responses stamp the proxy's own fields into request_data["litellm_metadata"]
and leave request_data["metadata"] to the caller's native metadata, so a failed request on those
routes wrote a spend row whose used_client_oauth_token was null instead of the stamped value
"""
logger = _ProxyDBLogger()
request_data = {
"model": "claude-sonnet-5",
"custom_llm_provider": custom_llm_provider,
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {"user_id": "anthropic-native-metadata"},
"litellm_metadata": {"used_client_oauth_token": used_client_oauth_token},
"proxy_server_request": {"request_id": "test_request_id"},
}
with patch(
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
new_callable=AsyncMock,
) as mock_update_database:
await logger.async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("rate limited"),
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key"),
)
call_kwargs = mock_update_database.call_args[1]["kwargs"]
assert call_kwargs["litellm_params"]["metadata"]["user_id"] == "anthropic-native-metadata"
payload = get_logging_payload(
kwargs=call_kwargs, response_obj={}, start_time=datetime.now(), end_time=datetime.now()
)
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"metadata_buckets, expected",
[
({"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"used_client_oauth_token": False}}, False),
({"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_id": "caller"}}, None),
({"metadata": {"used_client_oauth_token": "yes"}}, None),
],
)
async def test_async_post_call_failure_hook_never_lets_caller_metadata_set_used_client_oauth_token(
metadata_buckets: dict, expected: bool | None
):
"""
On /v1/messages and /v1/responses the request's own metadata field belongs to the caller, so a
used_client_oauth_token they put there must never outrank the proxy's stamp or stand in for a missing one
"""
logger = _ProxyDBLogger()
request_data = {
"model": "claude-sonnet-5",
"custom_llm_provider": "anthropic",
"messages": [{"role": "user", "content": "Hello"}],
"proxy_server_request": {"request_id": "test_request_id"},
**metadata_buckets,
}
with patch(
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
new_callable=AsyncMock,
) as mock_update_database:
await logger.async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("rate limited"),
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key"),
)
payload = get_logging_payload(
kwargs=mock_update_database.call_args[1]["kwargs"],
response_obj={},
start_time=datetime.now(),
end_time=datetime.now(),
)
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
"request_route, metadata_buckets, expected",
[
(
"/v1/chat/completions",
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_api_key_hash": "guardrail"}},
True,
),
(
"/v1/messages",
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_api_key_hash": "proxy"}},
None,
),
],
)
async def test_async_post_call_failure_hook_reads_used_client_oauth_token_from_the_routes_stamped_bucket(
request_route: str, metadata_buckets: dict, expected: bool | None
):
logger = _ProxyDBLogger()
request_data = {
"model": "claude-sonnet-5",
"custom_llm_provider": "anthropic",
"messages": [{"role": "user", "content": "Hello"}],
"proxy_server_request": {"request_id": "test_request_id"},
**metadata_buckets,
}
with patch(
"litellm.proxy.db.db_spend_update_writer.DBSpendUpdateWriter.update_database",
new_callable=AsyncMock,
) as mock_update_database:
await logger.async_post_call_failure_hook(
request_data=request_data,
original_exception=Exception("rate limited"),
user_api_key_dict=UserAPIKeyAuth(api_key="test_api_key", request_route=request_route),
)
payload = get_logging_payload(
kwargs=mock_update_database.call_args[1]["kwargs"],
response_obj={},
start_time=datetime.now(),
end_time=datetime.now(),
)
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
@pytest.mark.asyncio
async def test_async_post_call_failure_hook_bills_guardrail_cost_on_blocked_request():
"""LIT-5651: a request blocked by a guardrail never reaches the LLM, but the

View file

@ -120,6 +120,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond)
code = re.search(r"error_code' = \$(\d+)", cond)
msg = re.search(r"error_message' LIKE \$(\d+)", cond)
credential = re.fullmatch(r"metadata->>'used_client_oauth_token' = \$(\d+)", cond)
sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond)
status = re.fullmatch(r"status = \$(\d+)", cond)
api_key_not_in = re.fullmatch(r"api_key NOT IN \(\$(\d+), \$(\d+)\)", cond)
@ -177,6 +178,13 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
"string_contains": str(params[int(msg.group(1)) - 1]).strip("%"),
}
)
elif credential:
metadata_conds.append(
{
"path": ["used_client_oauth_token"],
"equals": params[int(credential.group(1)) - 1],
}
)
else:
for sql_col, key in eq_cols.items():
eq = re.fullmatch(rf"{re.escape(sql_col)} = \$(\d+)", cond)
@ -3362,6 +3370,82 @@ async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_used_client_oauth_token_filter(client, monkeypatch):
base = {
"api_key": "sk-test-key",
"user": "test_user_1",
"team_id": "team1",
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "claude-sonnet-5",
"status": "success",
}
mock_spend_logs = [
{**base, "id": "log1", "request_id": "req-seat", "metadata": {"used_client_oauth_token": True}},
{**base, "id": "log2", "request_id": "req-key", "metadata": {"used_client_oauth_token": False}},
{**base, "id": "log3", "request_id": "req-legacy", "metadata": {"user_agent": "curl/8.7.1"}},
]
def filter_by_credential(where):
metadata_filter = where.get("metadata")
if metadata_filter is None:
return mock_spend_logs
assert metadata_filter["path"] == ["used_client_oauth_token"]
return [
log
for log in mock_spend_logs
if json.dumps(log["metadata"].get("used_client_oauth_token")) == metadata_filter["equals"]
]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_credential),
)
start_date, end_date = _default_date_range()
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
try:
for flag, expected_ids in (("true", ["req-seat"]), ("false", ["req-key"])):
response = client.get(
"/spend/logs/ui",
params={
"used_client_oauth_token": flag,
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == len(expected_ids)
assert [row["request_id"] for row in data["data"]] == expected_ids
response = client.get(
"/spend/logs/ui",
params={"start_date": start_date, "end_date": end_date},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
assert response.json()["total"] == 3
response = client.get(
"/spend/logs/ui",
params={
"used_client_oauth_token": "seat",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 422
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_span_type_filter(client, monkeypatch):
base = {
@ -3767,7 +3851,7 @@ class TestSpendLogsPayload:
"model": "gpt-4o",
"user": "",
"team_id": "",
"metadata": '{"actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
"metadata": '{"actor_agent_id": null, "target_agent_id": null, "billing_agent_id": null, "agent_execution_mode": null, "verified_human_user_id": null, "applied_guardrails": [], "attempted_fallbacks": null, "original_model_group": null, "batch_models": null, "batch_successful_requests": null, "batch_failed_requests": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "litellm_gateway_injected_cache": null, "router_metadata": null, "autorouter_savings_estimate": null, "autorouter_baseline_observation": null, "azure_spillover": null, "used_client_oauth_token": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}',
"cache_key": "Cache OFF",
"spend": 0.00022500000000000002,
"total_tokens": 30,

View file

@ -3369,6 +3369,65 @@ def test_get_spend_logs_metadata_keeps_user_agent():
assert _get_spend_logs_metadata(None)["user_agent"] is None
@pytest.mark.parametrize(
"client_sent_oauth_token, custom_llm_provider, expected",
[
(True, "anthropic", True),
(True, "bedrock", False),
(True, "vertex_ai", False),
(False, "anthropic", False),
(None, "anthropic", None),
],
)
def test_get_logging_payload_records_used_client_oauth_token_for_the_selected_provider(
client_sent_oauth_token: bool | None, custom_llm_provider: str, expected: bool | None
):
"""The client's OAuth bearer is only forwarded to an Anthropic deployment, so a request that
the router sent to Bedrock or Vertex paid with the configured key and must not read true."""
request_metadata = (
{"user_agent": "claude-cli/2.1.0"}
if client_sent_oauth_token is None
else {"user_agent": "claude-cli/2.1.0", "used_client_oauth_token": client_sent_oauth_token}
)
payload = get_logging_payload(
kwargs={
"model": "claude-sonnet-5",
"custom_llm_provider": custom_llm_provider,
"litellm_params": {"metadata": request_metadata},
},
response_obj={},
start_time=datetime.datetime.now(timezone.utc),
end_time=datetime.datetime.now(timezone.utc),
)
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
assert _get_spend_logs_metadata(None)["used_client_oauth_token"] is None
@pytest.mark.parametrize(
"litellm_params, expected",
[
(
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"user_api_key_hash": "guardrail"}},
True,
),
(
{"metadata": {"used_client_oauth_token": True}, "litellm_metadata": {"used_client_oauth_token": False}},
False,
),
],
)
def test_get_logging_payload_reads_used_client_oauth_token_from_the_bucket_the_proxy_stamped(
litellm_params: dict, expected: bool
):
payload = get_logging_payload(
kwargs={"model": "claude-sonnet-5", "custom_llm_provider": "anthropic", "litellm_params": litellm_params},
response_obj={},
start_time=datetime.datetime.now(timezone.utc),
end_time=datetime.datetime.now(timezone.utc),
)
assert json.loads(payload["metadata"])["used_client_oauth_token"] is expected
def test_redact_logged_api_key_bearer_only_returns_none():
# "bearer " with nothing after stripping is equivalent to no key
assert _redact_logged_api_key("bearer ") is None

View file

@ -38,6 +38,7 @@ from litellm.proxy.litellm_pre_call_utils import (
move_guardrails_to_metadata,
)
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.litellm_core_utils.internal_call_metadata import MODEL_ACCESS_GROUP_METADATA_KEY
from litellm.litellm_core_utils.redact_messages import _get_turn_off_message_logging_from_dynamic_params
from litellm.litellm_core_utils.get_provider_specific_headers import (
@ -6792,6 +6793,55 @@ async def test_add_litellm_data_to_request_redacts_oauth_header_from_logging_cop
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path, metadata_variable_name",
[
("/v1/messages", "litellm_metadata"),
("/v1/chat/completions", "metadata"),
],
)
async def test_add_litellm_data_to_request_stamps_used_client_oauth_token(path, metadata_variable_name):
"""A seat-billed request and a configured-key request must land in spend logs differing on exactly
the credential flag, and the flag must never carry the token itself."""
async def metadata_for(client_headers: dict) -> dict:
request_mock = _make_request_mock(path, {"Content-Type": "application/json", **client_headers})
updated = await add_litellm_data_to_request(
data={"model": "anthropic-claude", "messages": [{"role": "user", "content": "hello"}]},
request=request_mock,
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings={"forward_client_headers_to_llm_api": True},
version="test-version",
)
return updated[metadata_variable_name]
def spend_log_row_metadata(request_metadata: dict) -> dict:
row = get_logging_payload(
kwargs={
"model": "claude-sonnet-5",
"custom_llm_provider": "anthropic",
"litellm_params": {"metadata": request_metadata},
},
response_obj={},
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc),
)
return json.loads(row["metadata"])
seat_row = spend_log_row_metadata(
await metadata_for({"Authorization": _OAUTH_TOKEN, "x-litellm-api-key": "Bearer sk-virtual-key"})
)
key_row = spend_log_row_metadata(await metadata_for({"Authorization": "Bearer sk-virtual-key"}))
assert seat_row["used_client_oauth_token"] is True
assert key_row["used_client_oauth_token"] is False
differing_keys = {key for key in seat_row.keys() | key_row.keys() if seat_row.get(key) != key_row.get(key)}
assert differing_keys == {"used_client_oauth_token"}
assert "sk-ant-oat01" not in json.dumps(seat_row, default=repr)
@pytest.mark.asyncio
async def test_add_litellm_data_to_request_keeps_every_forwarded_credential_out_of_logging_copies():
"""Credentials kept for transport must not survive anywhere under proxy_server_request."""
@ -7585,6 +7635,23 @@ def test_client_anthropic_api_headers_stay_off_openai_compatible_providers():
assert forwarded == {}
@pytest.mark.parametrize("authorization_header_name", AUTHORIZATION_HEADER_CASINGS)
def test_add_provider_specific_headers_reports_a_forwarded_oauth_credential(authorization_header_name):
assert add_provider_specific_headers_to_request(data={}, headers=_client_headers(authorization_header_name)) is True
@pytest.mark.parametrize(
"headers",
[
_client_headers(None),
{"content-type": "application/json", "authorization": "Bearer sk-a-normal-key"},
{"anthropic-beta": "claude-code-20250219", "authorization": "Bearer sk-ant-api03-a-configured-key"},
],
)
def test_add_provider_specific_headers_reports_no_oauth_credential_without_a_forwarded_token(headers):
assert add_provider_specific_headers_to_request(data={}, headers=headers) is False
def test_no_provider_specific_header_when_client_sends_nothing_anthropic():
data: dict = {}
add_provider_specific_headers_to_request(

View file

@ -4858,6 +4858,75 @@ def test_get_standard_logging_object_payload_includes_litellm_call_id(logging_ob
assert payload["litellm_call_id"] == call_id
@pytest.mark.parametrize(
"client_sent_oauth_token, custom_llm_provider, expected",
[(True, "anthropic", True), (True, "bedrock", False), (False, "anthropic", False), (None, "anthropic", None)],
)
def test_get_standard_logging_object_payload_resolves_used_client_oauth_token_against_the_selected_provider(
logging_obj, client_sent_oauth_token: bool | None, custom_llm_provider: str, expected: bool | None
):
"""The proxy stamps whether the client presented an Anthropic OAuth bearer before routing, but the
bearer only reaches an Anthropic deployment, so the logged flag must follow the provider that was called."""
from datetime import datetime
from litellm.litellm_core_utils.litellm_logging import get_standard_logging_object_payload
request_metadata = {} if client_sent_oauth_token is None else {"used_client_oauth_token": client_sent_oauth_token}
now = datetime.now()
payload = get_standard_logging_object_payload(
kwargs={
"model": "claude-sonnet-5",
"messages": [],
"custom_llm_provider": custom_llm_provider,
"litellm_params": {"metadata": request_metadata},
},
init_response_obj={},
start_time=now,
end_time=now,
logging_obj=logging_obj,
status="success",
)
assert payload is not None
assert payload["metadata"]["used_client_oauth_token"] is expected
@pytest.mark.parametrize(
"metadata, litellm_metadata, expected",
[
({"used_client_oauth_token": True}, {"used_client_oauth_token": False}, False),
({"used_client_oauth_token": False}, {"used_client_oauth_token": True}, True),
({"used_client_oauth_token": True}, {"compression_savings": 1}, True),
],
)
def test_get_standard_logging_object_payload_takes_used_client_oauth_token_from_the_proxy_stamped_slot(
logging_obj, metadata: dict, litellm_metadata: dict, expected: bool
):
"""On routes that carry proxy metadata in `litellm_metadata`, `metadata` is the caller's own body field,
so a caller writing the flag there must not override what the proxy stamped."""
from datetime import datetime
from litellm.litellm_core_utils.litellm_logging import get_standard_logging_object_payload
now = datetime.now()
payload = get_standard_logging_object_payload(
kwargs={
"model": "claude-sonnet-5",
"messages": [],
"custom_llm_provider": "anthropic",
"litellm_params": {"metadata": metadata, "litellm_metadata": litellm_metadata},
},
init_response_obj={},
start_time=now,
end_time=now,
logging_obj=logging_obj,
status="success",
)
assert payload is not None
assert payload["metadata"]["used_client_oauth_token"] is expected
def test_get_standard_logging_object_payload_carries_matched_access_groups(logging_obj):
"""Access groups stamped at auth time reach the logging payload, so integrations see what a request billed."""
from datetime import datetime

View file

@ -2017,6 +2017,7 @@ interface UiSpendLogsParams {
end_user?: string;
status_filter?: string;
cache_hit_filter?: string;
used_client_oauth_token?: string;
span_type?: string;
/** Filter by model name (e.g. "gpt-4") */
model?: string;

View file

@ -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

View file

@ -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} />

View file

@ -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();

View file

@ -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)}

View file

@ -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);
};

View file

@ -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" },

View file

@ -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" },

View file

@ -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),

View file

@ -68320,6 +68320,8 @@ export interface operations {
status_filter?: string | null;
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
cache_hit_filter?: string | null;
/** @description Filter logs by the credential the upstream call used: true for a client-forwarded Anthropic OAuth token, false for the deployment's configured key. Rows written before this flag existed match neither */
used_client_oauth_token?: boolean | null;
/** @description Filter logs by span type: llm, agent, mcp, or batch */
span_type?: string | null;
/** @description Filter logs by model */
@ -68440,6 +68442,8 @@ export interface operations {
status_filter?: string | null;
/** @description Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state */
cache_hit_filter?: string | null;
/** @description Filter logs by the credential the upstream call used: true for a client-forwarded Anthropic OAuth token, false for the deployment's configured key. Rows written before this flag existed match neither */
used_client_oauth_token?: boolean | null;
/** @description Filter logs by span type: llm, agent, mcp, or batch */
span_type?: string | null;
/** @description Filter logs by model */