Skip to content

Commit ff02d5c

Browse files
authored
Merge pull request #30736 from nitishagar/litellm_fix_raw_key_log_persistence
fix(spend-tracking): hash raw api keys before persisting to spend logs
2 parents e17988f + a50590f commit ff02d5c

3 files changed

Lines changed: 400 additions & 41 deletions

File tree

litellm/proxy/spend_tracking/spend_tracking_utils.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import litellm
1111
from litellm._logging import verbose_proxy_logger
1212
from litellm.constants import (
13+
LITELLM_PROXY_MASTER_KEY_ALIAS,
1314
LITELLM_TRUNCATED_PAYLOAD_FIELD,
1415
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE,
1516
REDACTED_BY_LITELM_STRING,
@@ -21,6 +22,7 @@
2122
get_litellm_metadata_from_kwargs,
2223
reconstruct_model_name,
2324
)
25+
from litellm.litellm_core_utils.litellm_logging import is_valid_sha256_hash
2426
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes
2527
from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
2628
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
@@ -53,13 +55,6 @@ def _get_max_string_length_prompt_in_db() -> int:
5355
return DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB
5456

5557

56-
def _hash_api_key_for_spend_log(api_key: str) -> str:
57-
stripped: Final = api_key[7:] if api_key[:7].lower() == "bearer " else api_key
58-
if stripped.startswith("sk-"):
59-
return hash_token(stripped)
60-
return stripped
61-
62-
6358
def _is_master_key(api_key: str | None, _master_key: str | None) -> bool:
6459
"""
6560
Raw-only constant-time master-key comparison. The hashed form is never
@@ -70,6 +65,28 @@ def _is_master_key(api_key: str | None, _master_key: str | None) -> bool:
7065
return secrets.compare_digest(api_key, _master_key)
7166

7267

68+
_HASHED_JWT_RE = re.compile(r"hashed-jwt-[a-fA-F0-9]{64}")
69+
70+
71+
def _is_non_secret_key_value(value: str) -> bool:
72+
return (
73+
value == LITELLM_PROXY_MASTER_KEY_ALIAS
74+
or is_valid_sha256_hash(value)
75+
or _HASHED_JWT_RE.fullmatch(value) is not None
76+
)
77+
78+
79+
def _redact_logged_api_key(value: str | None, *, already_redacted: bool = False) -> str | None:
80+
if not isinstance(value, str) or not value:
81+
return None
82+
stripped: Final = re.sub(r"(?i)^bearer ", "", value)
83+
if not stripped:
84+
return None
85+
if already_redacted and _is_non_secret_key_value(stripped):
86+
return stripped
87+
return hash_token(stripped)
88+
89+
7390
def _get_spend_logs_metadata(
7491
metadata: dict | None,
7592
applied_guardrails: list[str] | None = None,
@@ -123,9 +140,12 @@ def _get_spend_logs_metadata(
123140

124141
# Filter the metadata dictionary to include only the specified keys
125142
clean_metadata: Final = SpendLogsMetadata(**{key: metadata.get(key) for key in SpendLogsMetadata.__annotations__})
126-
raw_user_api_key: Final = clean_metadata.get("user_api_key")
127-
if raw_user_api_key is not None and isinstance(raw_user_api_key, str):
128-
clean_metadata["user_api_key"] = _hash_api_key_for_spend_log(raw_user_api_key)
143+
_raw_key: Final = clean_metadata.get("user_api_key")
144+
_trusted_hash: Final = metadata.get("user_api_key_hash")
145+
_already_redacted: Final = (
146+
isinstance(_trusted_hash, str) and _is_non_secret_key_value(_trusted_hash) and _trusted_hash == _raw_key
147+
)
148+
clean_metadata["user_api_key"] = _redact_logged_api_key(_raw_key, already_redacted=_already_redacted)
129149
clean_metadata["applied_guardrails"] = applied_guardrails
130150
clean_metadata["batch_models"] = batch_models
131151
clean_metadata["mcp_tool_call_metadata"] = mcp_tool_call_metadata
@@ -281,16 +301,23 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
281301
standard_logging_prompt_tokens = standard_logging_payload.get("prompt_tokens", 0)
282302
standard_logging_completion_tokens = standard_logging_payload.get("completion_tokens", 0)
283303
standard_logging_total_tokens = standard_logging_payload.get("total_tokens", 0)
284-
if api_key is not None and isinstance(api_key, str):
285-
api_key = _hash_api_key_for_spend_log(api_key)
304+
_trusted_hash = metadata.get("user_api_key_hash")
305+
_key_already_redacted = (
306+
isinstance(_trusted_hash, str) and _is_non_secret_key_value(_trusted_hash) and _trusted_hash == api_key
307+
)
308+
api_key = _redact_logged_api_key(api_key, already_redacted=_key_already_redacted) or ""
286309

287310
if (
288311
standard_logging_payload is not None
289312
): # [TODO] migrate completely to sl payload. currently missing pass-through endpoint data
290-
api_key = api_key or standard_logging_payload["metadata"].get("user_api_key_hash") or ""
313+
api_key = (
314+
api_key
315+
or _redact_logged_api_key(
316+
standard_logging_payload["metadata"].get("user_api_key_hash"), already_redacted=True
317+
)
318+
or ""
319+
)
291320
end_user_id = end_user_id or standard_logging_payload["metadata"].get("user_api_key_end_user_id")
292-
# BUG FIX: Don't overwrite api_key when standard_logging_payload is None
293-
# The api_key was already extracted from metadata (line 243) and hashed (lines 256-259)
294321
request_tags = safe_dumps(metadata.get("tags", [])) if isinstance(metadata.get("tags", []), list) else "[]"
295322
if (
296323
standard_logging_payload is not None and standard_logging_payload.get("request_tags") is not None

tests/test_litellm/proxy/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_PROXY_MODULE_GLOBALS_TO_ISOLATE = (
1919
"master_key",
2020
"prisma_client",
21+
"llm_router",
2122
)
2223

2324

@@ -56,7 +57,10 @@ def pytest_runtest_setup(item):
5657
5758
Without this, a leaked value (e.g. master_key set by a sibling test)
5859
flips the auth short-circuit in user_api_key_auth and causes unrelated
59-
tests in the same xdist worker to return 401 instead of 200.
60+
tests in the same xdist worker to return 401 instead of 200. A leaked
61+
llm_router does the same to anything that reads the running router out
62+
of sys.modules, such as the PTU rollup's deployment scan, which then
63+
counts a sibling test's deployments as if the proxy owned them.
6064
6165
This must be a hook pair, not an autouse fixture: an autouse fixture in
6266
the root conftest requests monkeypatch, so monkeypatch's undo stack

0 commit comments

Comments
 (0)