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:
mateo-berri 2026-09-24 15:40:16 -07:00
parent 1628978db7
commit 23a4bc5b6c
19 changed files with 290 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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