Skip to content
Merged
2 changes: 2 additions & 0 deletions litellm/litellm_core_utils/litellm_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -5789,6 +5789,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):
for key in metadata.keys() & _STANDARD_LOGGING_METADATA_KEYS:
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

## Cache Controls
cache_control_header: Final = _headers.get("Cache-Control", None)
Expand Down Expand Up @@ -3460,7 +3462,7 @@ async def add_guardrails_from_policy_engine(
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}
Expand All @@ -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):
Expand Down
11 changes: 11 additions & 0 deletions litellm/proxy/spend_tracking/spend_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2838,6 +2845,10 @@ def parse_date(date_str: str) -> datetime:
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
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/spend_tracking/spend_tracking_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
1 change: 1 addition & 0 deletions litellm/types/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def _iso(value):
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)
Expand Down Expand Up @@ -178,6 +179,13 @@ def _iso(value):
"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)
Expand Down Expand Up @@ -3429,6 +3437,82 @@ def filter_by_cache(where):
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 = {
Expand Down Expand Up @@ -3845,7 +3929,7 @@ async def test_spend_logs_payload_e2e(self):
"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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/test_litellm/proxy/test_litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
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 (
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions ui/litellm-dashboard/src/components/networking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
})();
export let proxyBaseUrl: string | null = _initialWorkerUrl ?? defaultProxyBaseUrl;
if (isLocal != true) {
console.log = function () {};

Check warning on line 167 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected console statement. Only these console methods are allowed: warn, error
}

const getWindowLocation = () => {
Expand Down Expand Up @@ -278,7 +278,7 @@
organization_id: string;
organization_alias: string;
budget_id: string;
metadata: Record<string, any>;

Check warning on line 281 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
models: string[];
spend: number;
model_spend: Record<string, number>;
Expand All @@ -286,16 +286,16 @@
created_by: string;
updated_at: string;
updated_by: string;
litellm_budget_table: any; // Simplified to any since we don't need the detailed structure

Check warning on line 289 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
teams: any[] | null;

Check warning on line 290 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
users: any[] | null;

Check warning on line 291 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
members: any[] | null;

Check warning on line 292 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
object_permission?: ObjectPermission | null;
}

export interface CredentialItem {
credential_name: string;
credential_values: any;

Check warning on line 298 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
credential_info: {
custom_llm_provider?: string;
description?: string;
Expand Down Expand Up @@ -378,7 +378,7 @@

let lastErrorTime = 0;

export const handleError = async (errorData: string | any) => {

Check warning on line 381 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const currentTime = Date.now();
if (currentTime - lastErrorTime > 60000) {
// 60000 milliseconds = 60 seconds
Expand Down Expand Up @@ -711,7 +711,7 @@

export const budgetCreateCall = async (
accessToken: string,
formValues: Record<string, any>, // Assuming formValues is an object

Check warning on line 714 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
) => {
try {
const data = await apiClient.post(`/budget/new`, {
Expand All @@ -730,7 +730,7 @@

export const budgetUpdateCall = async (
accessToken: string,
formValues: Record<string, any>, // Assuming formValues is an object

Check warning on line 733 in ui/litellm-dashboard/src/components/networking.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
) => {
try {
const data = await apiClient.post(`/budget/update`, {
Expand Down Expand Up @@ -2072,6 +2072,7 @@
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;
Expand Down
Loading
Loading