This commit is contained in:
yucheng-berri 2026-10-11 14:52:03 -07:00 • committed by GitHub
commit ea0b320f77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1016 additions and 3 deletions

View file

@ -2776,6 +2776,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="If True, stores request messages and responses in spend logs. Default is False.",
)
disable_requester_ip_address_logging: bool | None = Field(
None,
description="If True, new spend log rows store requester_ip_address as null in the column, the metadata and the stored request. The stored request also drops client-IP forwarding headers such as x-forwarded-for, and IP addresses in stored error messages are masked. Guardrails, metrics and callbacks that read the standard logging payload still receive the IP. Default is False.",
)
spend_logs_metadata_fields: SpendLogsMetadataFields | None = Field(
None,
description="Which keys of LiteLLM_SpendLogs.metadata are written to the database. Set exactly one of 'include' (write only these keys) or 'exclude' (drop these keys). 'status' and 'cold_storage_object_key' are always written. Daily spend tables, budgets and logging callbacks still see every key. Unset writes every key",

View file

@ -18405,6 +18405,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
"pass_through_endpoints": "PydanticModel",
"store_model_in_db": "Boolean",
"store_prompts_in_spend_logs": "Boolean",
"disable_requester_ip_address_logging": "Boolean",
"disable_fallbacks_on_per_model_rate_limits": "Boolean",
"maximum_spend_logs_retention_period": "String",
"maximum_health_check_retention_period": "String",

View file

@ -1,3 +1,4 @@
import ipaddress
import json
import os
import re
@ -770,6 +771,15 @@ def get_logging_payload(
if isinstance(cache_write_tokens, int) and cache_write_tokens > 0:
additional_usage_values["cache_creation_input_tokens"] = cache_write_tokens
clean_metadata["additional_usage_values"] = additional_usage_values
drop_requester_ip_address: Final = requester_ip_address_logging_disabled()
error_information: Final = clean_metadata["error_information"]
if drop_requester_ip_address:
clean_metadata["requester_ip_address"] = None
if drop_requester_ip_address and isinstance(error_information, Mapping):
clean_metadata["error_information"] = cast( # cast-ok: same keys, only the str values are masked
StandardLoggingPayloadErrorInformation,
_error_information_without_ip_addresses(_OBJECT_MAPPING.validate_python(error_information)),
)
if litellm.cache is None:
cache_key = "Cache OFF"
@ -831,6 +841,7 @@ def get_logging_payload(
else litellm_params
),
kwargs=kwargs,
drop_requester_ip_address=drop_requester_ip_address,
),
session_id=_get_session_id_for_spend_log(
kwargs=kwargs,
@ -1448,7 +1459,15 @@ def sanitize_error_information_for_spend_logs(
if isinstance(original_exception, ProxyModelNotFoundError)
else error_information
)
sanitized = cast(dict, {**persisted})
ip_masked: Final = (
cast( # cast-ok: same keys, only the str values are masked
StandardLoggingPayloadErrorInformation,
_error_information_without_ip_addresses(_OBJECT_MAPPING.validate_python(persisted)),
)
if requester_ip_address_logging_disabled()
else persisted
)
sanitized = cast(dict, {**ip_masked})
if not should_store_prompts_and_responses_in_spend_logs():
for field in ("error_message", "traceback"):
@ -1580,10 +1599,111 @@ def _placeholder_stored_request_body(
)
_CLIENT_IP_HEADERS: Final = frozenset(
{
"x-forwarded-for",
"x-real-ip",
"forwarded",
"true-client-ip",
"cf-connecting-ip",
"x-client-ip",
"x-cluster-client-ip",
"fastly-client-ip",
"x-original-forwarded-for",
"x-envoy-external-address",
"x-azure-clientip",
"cloudfront-viewer-address",
"fly-client-ip",
"cf-connecting-ipv6",
"cf-pseudo-ipv4",
"x-appengine-user-ip",
"x-vercel-forwarded-for",
"x-arr-clientip",
}
)
_REQUEST_METADATA_KEYS: Final = ("metadata", "litellm_metadata")
_OBJECT_MAPPING: Final = TypeAdapter(Mapping[object, object])
_OBJECT_LIST: Final = TypeAdapter(list[object])
_IPV4_ADDRESS: Final = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
_IPV6_ADDRESS_CANDIDATE: Final = re.compile(r"(?<![\w:])(?:[0-9A-Fa-f]{0,4}:){2,8}[0-9A-Fa-f]{0,4}(?![\w:])")
def _masked_ipv6_address(candidate: re.Match[str]) -> str:
try:
ipaddress.IPv6Address(candidate.group(0))
except ValueError:
return candidate.group(0)
return REDACTED_BY_LITELM_STRING
def _without_ip_addresses(text: str) -> str:
return _IPV6_ADDRESS_CANDIDATE.sub(_masked_ipv6_address, _IPV4_ADDRESS.sub(REDACTED_BY_LITELM_STRING, text))
def _error_information_without_ip_addresses(error_information: Mapping[object, object]) -> Mapping[object, object]:
"""The error information with every IP address in its error message and traceback masked."""
return {
key: _without_ip_addresses(value) if key in _SCRUBBED_ERROR_TEXT_FIELDS and isinstance(value, str) else value
for key, value in error_information.items()
}
def _error_information_in(metadata: object) -> object:
return _OBJECT_MAPPING.validate_python(metadata).get("error_information") if isinstance(metadata, Mapping) else None
def _metadata_with_masked_error_text(metadata: object) -> object:
error_information: Final = _error_information_in(metadata)
if not isinstance(error_information, Mapping):
return metadata
return {
**_OBJECT_MAPPING.validate_python(metadata),
"error_information": _error_information_without_ip_addresses(
_OBJECT_MAPPING.validate_python(error_information)
),
}
def _stored_request_with_masked_error_text(request_body: Mapping[str, object]) -> Mapping[str, object]:
"""The request body with every IP address in its metadata's error message and traceback masked."""
return {
key: _metadata_with_masked_error_text(value) if key in _REQUEST_METADATA_KEYS else value
for key, value in request_body.items()
}
def _stored_value_without_requester_ip_address(key: object, value: object) -> object:
if key == "requester_ip_address":
return None
return _without_requester_ip_address(value, headers=key == "headers")
def _without_requester_ip_address(value: object, *, headers: bool = False) -> object:
if isinstance(value, Mapping):
return {
key: _stored_value_without_requester_ip_address(key, item)
for key, item in _OBJECT_MAPPING.validate_python(value).items()
if not (headers and isinstance(key, str) and key.lower() in _CLIENT_IP_HEADERS)
}
if isinstance(value, list):
return [_without_requester_ip_address(item) for item in _OBJECT_LIST.validate_python(value)]
return value
def _stored_request_without_requester_ip_address(request_body: Mapping[object, object]) -> Mapping[object, object]:
"""The request body with `requester_ip_address` set to null and client-IP forwarding headers dropped inside its
metadata."""
return {
key: _without_requester_ip_address(value) if key in _REQUEST_METADATA_KEYS else value
for key, value in request_body.items()
}
def _get_proxy_server_request_for_spend_logs_payload(
metadata: dict,
litellm_params: Mapping[str, object],
kwargs: dict | None = None,
drop_requester_ip_address: bool = False,
) -> str:
"""
Only store if should_store_prompts_and_responses_in_spend_logs() is True
@ -1623,8 +1743,15 @@ def _get_proxy_server_request_for_spend_logs_payload(
_request_body = _convert_mapping_to_json_serializable(without_classifier_audit(_request_body))
perform_redaction(model_call_details=_request_body, result=None)
_request_body = _sanitize_request_body_for_spend_logs_payload(_request_body)
_request_body_json_str: Final = safe_dumps(_request_body)
_request_body = _sanitize_request_body_for_spend_logs_payload(
_stored_request_with_masked_error_text(_request_body) if drop_requester_ip_address else _request_body
)
stored_request_body: Final = _OBJECT_MAPPING.validate_python(_request_body)
_request_body_json_str: Final = safe_dumps(
_stored_request_without_requester_ip_address(stored_request_body)
if drop_requester_ip_address
else stored_request_body
)
if LITELLM_TRUNCATED_PAYLOAD_FIELD in _request_body_json_str:
verbose_proxy_logger.info(
"Spend Log: request body was truncated before storing in DB. %s",
@ -1749,6 +1876,22 @@ def configured_spend_logs_metadata_fields() -> SpendLogsMetadataFields | None:
return None
_OPTIONAL_BOOL_ADAPTER: Final[TypeAdapter[bool | None]] = TypeAdapter(bool | None)
def requester_ip_address_logging_disabled() -> bool:
"""True when `general_settings.disable_requester_ip_address_logging` holds a value the settings API accepts as true."""
from litellm.proxy.proxy_server import general_settings_view
try:
return (
_OPTIONAL_BOOL_ADAPTER.validate_python(general_settings_view().get("disable_requester_ip_address_logging"))
is True
)
except ValidationError:
return False
def spend_log_row_with_retained_metadata(
row: Mapping[str, object], fields: SpendLogsMetadataFields | None
) -> Mapping[str, object]:

View file

@ -82,6 +82,7 @@ IGNORE_FUNCTIONS = [
"_replace_string_leaves", # bounded by the nesting depth of a safe_json_structure output (a finite JSON tree, no cycles possible).
"_sort_processed_sets", # bounded by the nesting depth of the log-record extra it walks (a finite JSON tree, no cycles possible).
"scrub_json_strings", # max depth set (MAX_SCRUB_DEPTH); fails closed by returning "[Filtered]" for anything nested past the cap.
"_without_requester_ip_address", # walks only the fresh tree _sanitize_request_body_for_spend_logs_payload returns (mapping cycles already cut), so it never goes deeper than that sanitizer did.
]

View file

@ -0,0 +1,572 @@
import json
import re
from collections.abc import Iterator, Mapping
from contextlib import ExitStack
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import MappingProxyType
from typing import Final
from uuid import uuid4
import pytest
from integration._support.client import (
Gateway,
Scenario,
eventually,
gateway_from_environment,
list_value,
object_value,
string_value,
)
from integration._support.database import read_rows, scratch_database
from integration._support.process import owned_proxy
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue
FORWARDED_IP: Final = "203.0.113.74"
PROXY_HOP: Final = "198.51.100.23"
REAL_IP: Final = "203.0.113.75"
SETTING: Final = "disable_requester_ip_address_logging"
AKTO_KEY: Final = "synthetic-akto-key"
AKTO_GUARDRAIL: Final = "akto-requester-ip"
PROVIDER_FAILURE: Final = "synthetic-requester-ip-provider-failure"
LONG_PROVIDER_FAILURE: Final = "synthetic-requester-ip-long-provider-failure"
UPSTREAM_IP: Final = "192.0.2.61"
CALLBACK_SECRET: Final = "synthetic-callback-sink-secret"
_ROW_COLUMNS: Final = (
"SELECT s.request_id, s.metadata, s.requester_ip_address, row_to_json(s)::text AS row_text "
'FROM "LiteLLM_SpendLogs" s'
)
def _config(tmp_path: Path, general_settings: Mapping[str, JsonValue], **sections: JsonValue) -> Path:
config: Final = tmp_path / f"requester-ip-{uuid4()}.json"
config.write_text(
json.dumps(
{
**sections,
"model_list": [],
"general_settings": {
"master_key": "os.environ/LITELLM_MASTER_KEY",
"database_url": "os.environ/DATABASE_URL",
"store_model_in_db": True,
"proxy_batch_write_at": 1,
"proxy_batch_polling_interval": 1,
"use_x_forwarded_for": True,
**general_settings,
},
}
)
)
return config
def _caller(scenario: Scenario) -> tuple[str, str, str]:
model: Final = scenario.model()
user_id: Final = scenario.user(user_role="internal_user")
return model, user_id, scenario.key(user_id=user_id, models=[model])
def _chat(proxy: Gateway, model: str, key: str) -> str:
response: Final = proxy.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "requester ip"}]},
key=key,
headers={"x-forwarded-for": FORWARDED_IP},
)
assert response.status_code == 200, response.text
return string_value(object_value(response.json())["id"])
def _stored_row(request_id: str, database_url: str | None = None) -> dict[str, JsonValue]:
rows: Final = eventually(
lambda: read_rows(f"{_ROW_COLUMNS} WHERE s.request_id=%s", (request_id,), database_url=database_url),
lambda values: len(values) == 1,
seconds=70,
)
return rows[0]
def _served_row(proxy: Gateway, key: str, request_id: str, since: datetime) -> tuple[dict[str, JsonValue], str]:
response: Final = proxy.request(
"GET",
"/spend/logs/ui",
key=key,
params={
"request_id": request_id,
"start_date": (since - timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S"),
"end_date": (since + timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S"),
},
)
assert response.status_code == 200, response.text
served: Final = tuple(
object_value(row)
for row in list_value(object_value(response.json())["data"])
if object_value(row)["request_id"] == request_id
)
assert len(served) == 1, response.text
return served[0], response.text
def _assert_recorded(row: Mapping[str, JsonValue], user_id: str) -> None:
metadata: Final = object_value(row["metadata"])
assert metadata["user_api_key_user_id"] == user_id, metadata
assert row["requester_ip_address"] == FORWARDED_IP, row
assert metadata["requester_ip_address"] == FORWARDED_IP, metadata
def _assert_not_recorded(row: Mapping[str, JsonValue], text: str, user_id: str) -> None:
metadata: Final = object_value(row["metadata"])
assert metadata["user_api_key_user_id"] == user_id, metadata
assert row["requester_ip_address"] is None, row
assert metadata.get("requester_ip_address") is None, metadata
assert FORWARDED_IP not in text, text
def test_allowed_ips_denial_row_masks_the_ip_while_the_caller_still_sees_it(gateway: Gateway, tmp_path: Path) -> None:
user_agent: Final = f"requester-ip-allowed-ips-{uuid4()}"
config: Final = _config(tmp_path, {SETTING: True, "allowed_ips": ["127.0.0.1"]})
with owned_proxy(gateway, tmp_path, {}, config=config) as proxy, proxy.scenario() as scenario:
model, _, key = _caller(scenario)
response: Final = proxy.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": "requester ip"}]},
key=key,
headers={"x-forwarded-for": FORWARDED_IP, "user-agent": user_agent},
)
assert response.status_code == 403, response.text
assert FORWARDED_IP in response.text, response.text
rows: Final = eventually(
lambda: read_rows(f"{_ROW_COLUMNS} WHERE s.metadata->>'user_agent' = %s", (user_agent,)),
lambda values: len(values) == 1,
seconds=70,
)
denied: Final = rows[0]
error_information: Final = object_value(object_value(denied["metadata"])["error_information"])
assert object_value(denied["metadata"])["status"] == "failure", denied
assert denied["requester_ip_address"] is None, denied
assert "IP address REDACTED_BY_LITELM not allowed" in string_value(error_information["error_message"]), denied
assert FORWARDED_IP not in string_value(denied["row_text"]), denied
def test_admin_setting_change_applies_to_the_next_request(gateway: Gateway, tmp_path: Path) -> None:
since: Final = datetime.now(UTC)
with (
scratch_database() as database_url,
owned_proxy(
gateway,
tmp_path,
{"DATABASE_URL": database_url},
config=_config(tmp_path, {}),
remove_environment=("DATABASE_URL_READ_REPLICA",),
) as proxy,
proxy.scenario() as scenario,
):
model, user_id, key = _caller(scenario)
before: Final = _chat(proxy, model, key)
_assert_recorded(_stored_row(before, database_url), user_id)
proxy.post(
"/config/field/update",
{"field_name": SETTING, "field_value": True, "config_type": "general_settings"},
)
listed: Final = tuple(
object_value(field)
for field in list_value(
proxy.request("GET", "/config/list", params={"config_type": "general_settings"}).json()
)
if object_value(field)["field_name"] == SETTING
)
assert len(listed) == 1, listed
assert listed[0]["field_type"] == "Boolean" and listed[0]["field_value"] is True, listed
disabled: Final = _chat(proxy, model, key)
stored_disabled: Final = _stored_row(disabled, database_url)
served_disabled, served_disabled_text = _served_row(proxy, key, disabled, since)
proxy.post(
"/config/field/update",
{"field_name": SETTING, "field_value": False, "config_type": "general_settings"},
)
enabled: Final = _chat(proxy, model, key)
stored_enabled: Final = _stored_row(enabled, database_url)
_assert_not_recorded(stored_disabled, string_value(stored_disabled["row_text"]), user_id)
_assert_not_recorded(served_disabled, served_disabled_text, user_id)
_assert_recorded(stored_enabled, user_id)
def _completion_reply(marker: str, streaming: bool) -> Reply:
if not streaming:
return Reply(
body=json.dumps(
{
"id": "chatcmpl-" + marker,
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": marker}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
).encode()
)
chunks: Final = (
{"choices": [{"index": 0, "delta": {"role": "assistant", "content": marker}, "finish_reason": None}]},
{"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
{"choices": [], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}},
)
frames: Final = tuple(
(
"data: "
+ json.dumps(
{
"id": "chatcmpl-" + marker,
"object": "chat.completion.chunk",
"created": 1,
"model": "gpt-4o-mini",
**chunk,
}
)
+ "\n\n"
).encode()
for chunk in chunks
)
return Reply(chunks=(*frames, b"data: [DONE]\n\n"), content_type="text/event-stream")
def _provider(request: Request) -> Reply:
if request.method == "GET" and request.target.endswith("/v1/models"):
return Reply(body=json.dumps({"object": "list", "data": []}).encode())
body: Final = object_value(json.loads(request.body))
if request.target.endswith("/v1/embeddings"):
return Reply(
body=json.dumps(
{
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}],
"model": "text-embedding-3-small",
"usage": {"prompt_tokens": 2, "total_tokens": 2},
}
).encode()
)
if request.target.endswith("/v1/messages"):
return Reply(
body=json.dumps(
{
"id": f"msg_{uuid4().hex}",
"type": "message",
"role": "assistant",
"model": "claude-haiku-5-5",
"content": [{"type": "text", "text": "requester ip"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 5},
}
).encode()
)
if request.target.endswith("/v1/responses"):
identity: Final = uuid4().hex
return Reply(
body=json.dumps(
{
"id": f"resp_{identity}",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-4o-mini",
"output": [
{
"type": "message",
"id": f"msg_{identity}",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "requester ip", "annotations": []}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
).encode()
)
assert request.target.endswith("/v1/chat/completions"), request.target
if LONG_PROVIDER_FAILURE.encode() in request.body:
return Reply(
status=400,
body=json.dumps(
{"error": {"message": f"upstream {UPSTREAM_IP} " * 400, "type": "invalid_request_error"}}
).encode(),
)
if PROVIDER_FAILURE.encode() in request.body:
return Reply(
status=400,
body=json.dumps({"error": {"message": PROVIDER_FAILURE, "type": "invalid_request_error"}}).encode(),
)
return _completion_reply(uuid4().hex, body.get("stream") is True)
def _akto_allows(_: Request) -> Reply:
return Reply(body=json.dumps({"data": {"guardrailsResult": {"Allowed": True}}}).encode())
@dataclass(frozen=True, slots=True)
class Rig:
proxy: Gateway
akto: Wire
callbacks: Wire
key: str
chat_model: str
claude_model: str
embedding_model: str
def send(self, path: str, body: Mapping[str, JsonValue], forwarded_for: str) -> str:
response: Final = self.proxy.request(
"POST", path, body, key=self.key, headers={"x-forwarded-for": forwarded_for, "x-real-ip": REAL_IP}
)
assert response.status_code == 200, response.text
return response.headers["x-litellm-call-id"]
def callback_metadata(self, call_id: str) -> dict[str, JsonValue]:
received: Final[
list[Request]
] = [] # mutable-ok: drain() consumes the queue, later polls must keep earlier batches
def delivered() -> tuple[dict[str, JsonValue], ...]:
received.extend(self.callbacks.drain())
return tuple(
event
for batch in received
for event in (object_value(item) for item in list_value(json.loads(batch.body)))
if event.get("litellm_call_id") == call_id
)
events: Final = eventually(delivered, lambda values: len(values) == 1, seconds=20)
return object_value(events[0]["metadata"])
def _row_for_call(call_id: str) -> dict[str, JsonValue]:
rows: Final = eventually(
lambda: read_rows(f"{_ROW_COLUMNS} WHERE s.litellm_call_id=%s", (call_id,)),
lambda values: len(values) == 1,
seconds=70,
)
return rows[0]
def _rig(stack: ExitStack, gateway: Gateway, root: Path, provider: Wire, disabled: bool) -> Rig:
akto: Final = stack.enter_context(wire_server(_akto_allows))
callbacks: Final = stack.enter_context(wire_server(lambda _: Reply()))
config: Final = _config(
root,
{SETTING: True, "store_prompts_in_spend_logs": True} if disabled else {"store_prompts_in_spend_logs": True},
litellm_settings={"callbacks": ["generic_api"], "DEFAULT_FLUSH_INTERVAL_SECONDS": 1},
guardrails=[
{
"guardrail_name": AKTO_GUARDRAIL,
"litellm_params": {
"guardrail": "akto",
"mode": "pre_call",
"default_on": False,
"akto_base_url": akto.url,
"akto_api_key": AKTO_KEY,
},
}
],
)
proxy: Final = stack.enter_context(
owned_proxy(
gateway,
root,
{
"GENERIC_LOGGER_ENDPOINT": callbacks.url,
"GENERIC_LOGGER_HEADERS": f"Authorization=Bearer {CALLBACK_SECRET}",
},
config=config,
workers=2,
)
)
scenario: Final = stack.enter_context(proxy.scenario())
chat: Final = scenario.model(api_base=provider.url + "/v1", api_key="synthetic-openai-key")
claude: Final = scenario.model(
model="anthropic/claude-haiku-5-5", api_base=provider.url, api_key="synthetic-anthropic-key"
)
embedding: Final = scenario.model(
model="openai/text-embedding-3-small",
api_base=provider.url + "/v1",
api_key="synthetic-openai-key",
model_info={"mode": "embedding"},
)
return Rig(proxy, akto, callbacks, scenario.key(models=[chat, claude, embedding]), chat, claude, embedding)
@pytest.fixture(scope="module")
def rigs(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Mapping[bool, Rig]]:
with ExitStack() as stack:
gateway: Final = stack.enter_context(gateway_from_environment())
provider: Final = stack.enter_context(wire_server(_provider))
yield MappingProxyType(
{
disabled: _rig(stack, gateway, tmp_path_factory.mktemp("requester-ip"), provider, disabled)
for disabled in (False, True)
}
)
def _endpoint_request(rig: Rig, endpoint: str, marker: str) -> tuple[str, dict[str, JsonValue]]:
if endpoint == "embeddings":
return "/v1/embeddings", {"model": rig.embedding_model, "input": marker}
if endpoint == "responses":
return "/v1/responses", {"model": rig.chat_model, "input": marker}
if endpoint == "messages":
return "/v1/messages", {
"model": rig.claude_model,
"max_tokens": 16,
"messages": [{"role": "user", "content": marker}],
}
return "/v1/chat/completions", {
"model": rig.chat_model,
"messages": [{"role": "user", "content": marker}],
"stream": endpoint == "chat-stream",
}
def _ips_in_row(row: Mapping[str, JsonValue]) -> tuple[str, ...]:
return tuple(ip for ip in (FORWARDED_IP, PROXY_HOP, REAL_IP) if ip in string_value(row["row_text"]))
DISABLED: Final = pytest.mark.parametrize("disabled", [False, True], ids=["setting-unset", "setting-on"])
@pytest.mark.parametrize("forwarded_for", [FORWARDED_IP, f"{FORWARDED_IP}, {PROXY_HOP}"], ids=["single", "chain"])
@pytest.mark.parametrize("endpoint", ["chat", "chat-stream", "messages", "responses", "embeddings"])
@DISABLED
def test_spend_logs_follow_the_setting_on_every_endpoint_while_callbacks_keep_the_ip(
rigs: Mapping[bool, Rig], disabled: bool, endpoint: str, forwarded_for: str
) -> None:
rig: Final = rigs[disabled]
path, body = _endpoint_request(rig, endpoint, f"requester-ip-{uuid4().hex}")
call_id: Final = rig.send(path, body, forwarded_for)
row: Final = _row_for_call(call_id)
callback_metadata: Final = rig.callback_metadata(call_id)
recorded_ip: Final = None if disabled else forwarded_for
assert callback_metadata["requester_ip_address"] == forwarded_for, callback_metadata
assert row["requester_ip_address"] == recorded_ip, row
assert object_value(row["metadata"])["requester_ip_address"] == recorded_ip, row
assert (REAL_IP in _ips_in_row(row)) is not disabled, row
assert (_ips_in_row(row) == ()) is disabled, row
@DISABLED
def test_logs_page_serves_no_ip_when_the_setting_is_on(rigs: Mapping[bool, Rig], disabled: bool) -> None:
rig: Final = rigs[disabled]
since: Final = datetime.now(UTC)
call_id: Final = rig.send(
"/v1/chat/completions",
{"model": rig.chat_model, "messages": [{"role": "user", "content": f"requester-ip-{uuid4().hex}"}]},
FORWARDED_IP,
)
request_id: Final = string_value(_row_for_call(call_id)["request_id"])
window: Final = {
"start_date": (since - timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S"),
"end_date": (since + timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S"),
}
listed: Final = rig.proxy.request("GET", "/spend/logs/ui", params={"request_id": request_id, **window})
detail: Final = rig.proxy.request("GET", f"/spend/logs/ui/{request_id}", params=window)
assert listed.status_code == 200 and detail.status_code == 200, (listed.text, detail.text)
assert request_id in listed.text and request_id in detail.text, (listed.text, detail.text)
served: Final = listed.text + detail.text
assert (FORWARDED_IP in served) is not disabled, served
assert (REAL_IP in served) is not disabled, served
@DISABLED
def test_auth_failure_row_follows_the_setting(rigs: Mapping[bool, Rig], disabled: bool) -> None:
rig: Final = rigs[disabled]
user_agent: Final = f"requester-ip-auth-failure-{uuid4()}"
response: Final = rig.proxy.request(
"POST",
"/v1/chat/completions",
{"model": rig.chat_model, "messages": [{"role": "user", "content": "requester ip"}]},
key=f"sk-requester-ip-{uuid4().hex}",
headers={"x-forwarded-for": FORWARDED_IP, "user-agent": user_agent},
)
assert response.status_code == 401, response.text
rows: Final = eventually(
lambda: read_rows(f"{_ROW_COLUMNS} WHERE s.metadata->>'user_agent' = %s", (user_agent,)),
lambda values: len(values) == 1,
seconds=70,
)
assert object_value(rows[0]["metadata"])["status"] == "failure", rows[0]
assert rows[0]["requester_ip_address"] == (None if disabled else FORWARDED_IP), rows[0]
assert _ips_in_row(rows[0]) == (() if disabled else (FORWARDED_IP,)), rows[0]
@DISABLED
def test_provider_failure_row_follows_the_setting(rigs: Mapping[bool, Rig], disabled: bool) -> None:
rig: Final = rigs[disabled]
response: Final = rig.proxy.request(
"POST",
"/v1/chat/completions",
{"model": rig.chat_model, "messages": [{"role": "user", "content": PROVIDER_FAILURE}]},
key=rig.key,
headers={"x-forwarded-for": FORWARDED_IP, "x-real-ip": REAL_IP},
)
assert response.status_code == 400, response.text
row: Final = _row_for_call(response.headers["x-litellm-call-id"])
assert object_value(row["metadata"])["status"] == "failure", row
assert row["requester_ip_address"] == (None if disabled else FORWARDED_IP), row
assert _ips_in_row(row) == (() if disabled else (FORWARDED_IP, REAL_IP)), row
def test_long_provider_error_row_keeps_no_ip_fragment_at_the_truncation_cut(rigs: Mapping[bool, Rig]) -> None:
rig: Final = rigs[True]
response: Final = rig.proxy.request(
"POST",
"/v1/chat/completions",
{"model": rig.chat_model, "messages": [{"role": "user", "content": LONG_PROVIDER_FAILURE}]},
key=rig.key,
headers={"x-forwarded-for": FORWARDED_IP},
)
assert response.status_code == 400, response.text
row: Final = _row_for_call(response.headers["x-litellm-call-id"])
error_message: Final = string_value(
object_value(object_value(row["metadata"])["error_information"])["error_message"]
)
cut: Final = re.search(r"\.\.\. \(litellm_truncated .*?\) \.\.\.", error_message)
assert cut is not None, error_message
assert re.search(r"[\d.]$", error_message[: cut.start()]) is None, error_message[: cut.start()][-60:]
assert re.search(r"^[\d.]", error_message[cut.end() :]) is None, error_message[cut.end() :][:60]
assert UPSTREAM_IP not in string_value(row["row_text"]), row
@DISABLED
def test_pre_call_guardrail_still_receives_the_ip(rigs: Mapping[bool, Rig], disabled: bool) -> None:
rig: Final = rigs[disabled]
marker: Final = f"requester-ip-{uuid4().hex}"
call_id: Final = rig.send(
"/v1/chat/completions",
{"model": rig.chat_model, "messages": [{"role": "user", "content": marker}], "guardrails": [AKTO_GUARDRAIL]},
f"{FORWARDED_IP}, {PROXY_HOP}",
)
row: Final = _row_for_call(call_id)
akto_calls: Final = tuple(
object_value(json.loads(call.body)) for call in rig.akto.drain() if marker.encode() in call.body
)
assert [call["ip"] for call in akto_calls] == [FORWARDED_IP], akto_calls
assert object_value(row["metadata"])["applied_guardrails"] == [AKTO_GUARDRAIL], row

View file

@ -40,6 +40,8 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import (
configured_spend_logs_metadata_fields,
get_logging_payload,
get_spend_logs_id,
_stored_request_without_requester_ip_address,
requester_ip_address_logging_disabled,
should_store_prompts_and_responses_in_spend_logs,
spend_log_row_with_retained_metadata,
)
@ -6470,3 +6472,257 @@ def test_spend_logs_payload_with_prompts_enabled(monkeypatch: pytest.MonkeyPatch
)
assert payload_disabled["messages"] == "{}"
assert payload_disabled["response"] == "{}"
@pytest.mark.parametrize(
("configured", "disabled"),
[
(True, True),
("true", True),
("TRUE", True),
("yes", True),
("on", True),
("1", True),
(1, True),
(False, False),
(None, False),
("false", False),
("no", False),
(0, False),
("", False),
("maybe", False),
([1], False),
],
)
def test_requester_ip_address_logging_disabled_reads_the_setting_as_the_settings_api_validates_it(
configured: object, disabled: bool
) -> None:
with patch( # test-quality-ok: general_settings is proxy config, loaded from yaml, not an HTTP boundary
"litellm.proxy.proxy_server.general_settings", {"disable_requester_ip_address_logging": configured}
):
assert requester_ip_address_logging_disabled() is disabled
def _request_ip_metadata() -> dict[str, object]:
return {
"requester_ip_address": "203.0.113.9",
"user_agent": "requester-ip-test",
"headers": {"X-Forwarded-For": "203.0.113.9, 10.0.0.1", "x-real-ip": "203.0.113.9", "accept": "*/*"},
"requester_metadata": {"headers": {"x-forwarded-for": "203.0.113.9"}, "team": "blue"},
"error_information": {
"error_code": "403",
"error_message": "Access forbidden: IP address 203.0.113.9 not allowed.",
"traceback": "HTTPException: client 2001:db8::9 via 198.51.100.7 denied at 12:30:45 (litellm_settings::modify_params, qwen2.5.1.5b)",
},
}
def _request_ip_kwargs(request_metadata: dict[str, object]) -> dict[str, object]:
return {
"model": "gpt-5-mini",
"messages": [{"role": "user", "content": "Hello!"}],
"litellm_params": {
"metadata": request_metadata,
"proxy_server_request": {
"body": {
"model": "gpt-5-mini",
"messages": [{"role": "user", "content": "Hello!"}],
"metadata": request_metadata,
}
},
},
"standard_logging_object": {
"messages": [{"role": "user", "content": "Hello!"}],
"response": {"role": "assistant", "content": "Hi there!"},
"metadata": {"requester_ip_address": "203.0.113.9"},
"model_map_information": {"tpm": 1000, "rpm": 1000},
},
}
@pytest.mark.parametrize(
("disabled", "recorded_ip", "error_message", "traceback"),
[
(
False,
"203.0.113.9",
"Access forbidden: IP address 203.0.113.9 not allowed.",
"HTTPException: client 2001:db8::9 via 198.51.100.7 denied at 12:30:45 (litellm_settings::modify_params, qwen2.5.1.5b)",
),
(
True,
None,
"Access forbidden: IP address REDACTED_BY_LITELM not allowed.",
"HTTPException: client REDACTED_BY_LITELM via REDACTED_BY_LITELM denied at 12:30:45 (litellm_settings::modify_params, qwen2.5.1.5b)",
),
],
ids=["setting-off", "setting-on"],
)
def test_spend_logs_payload_keeps_no_requester_ip_address_when_ip_logging_is_disabled(
monkeypatch: pytest.MonkeyPatch, disabled: bool, recorded_ip: str | None, error_message: str, traceback: str
) -> None:
from litellm.proxy.proxy_server import general_settings
monkeypatch.setitem(general_settings, "store_prompts_in_spend_logs", True)
monkeypatch.setitem(general_settings, "disable_requester_ip_address_logging", disabled)
logged_at: Final = datetime.datetime(2026, 1, 15, 12, 0, 0)
request_metadata: Final = _request_ip_metadata()
payload: Final[SpendLogsPayload] = get_logging_payload(
kwargs=_request_ip_kwargs(request_metadata),
response_obj=litellm.ModelResponse(id="chatcmpl-ip", model="gpt-5-mini"),
start_time=logged_at,
end_time=logged_at,
)
stored_metadata: Final = json.loads(payload["metadata"])
stored_request: Final = json.loads(payload["proxy_server_request"] or "{}")
stored_errors: Final = (stored_metadata["error_information"], stored_request["metadata"]["error_information"])
assert request_metadata == _request_ip_metadata()
assert stored_metadata["user_agent"] == "requester-ip-test"
assert stored_request["metadata"]["headers"]["accept"] == "*/*"
assert stored_request["metadata"]["requester_metadata"]["team"] == "blue"
assert payload["requester_ip_address"] == recorded_ip
assert stored_metadata["requester_ip_address"] == recorded_ip
assert stored_request["metadata"]["requester_ip_address"] == recorded_ip
assert [error["error_message"] for error in stored_errors] == [error_message, error_message]
assert [error["traceback"] for error in stored_errors] == [traceback, traceback]
assert [error["error_code"] for error in stored_errors] == ["403", "403"]
assert ("203.0.113.9" in json.dumps(payload, default=str)) is not disabled
def test_stored_request_without_requester_ip_address_drops_only_ip_fields_from_request_metadata() -> None:
body: Final = {
"model": "gpt-5-mini",
"messages": [{"role": "user", "content": "203.0.113.9"}],
"tools": [{"type": "function", "function": {"parameters": {"headers": {"x-real-ip": "keep"}}}}],
"error_information": {"error_message": "203.0.113.9"},
"litellm_metadata": {
"requester_ip_address": "203.0.113.9",
"headers": {
"Forwarded": "for=203.0.113.9",
"CF-Connecting-IP": "203.0.113.9",
"X-Envoy-External-Address": "203.0.113.9",
"CF-Connecting-IPv6": "2001:db8::9",
"CF-Pseudo-IPv4": "203.0.113.9",
"X-AppEngine-User-IP": "203.0.113.9",
"X-Vercel-Forwarded-For": "203.0.113.9",
"X-ARR-ClientIP": "203.0.113.9",
"user-agent": "ua",
},
"forwarded": "not a header",
"spend_logs_metadata": [{"headers": {"true-client-ip": "203.0.113.9"}}],
"error_information": {"error_message": "denied 203.0.113.9"},
},
}
assert _stored_request_without_requester_ip_address(body) == {
"model": "gpt-5-mini",
"messages": [{"role": "user", "content": "203.0.113.9"}],
"tools": [{"type": "function", "function": {"parameters": {"headers": {"x-real-ip": "keep"}}}}],
"error_information": {"error_message": "203.0.113.9"},
"litellm_metadata": {
"requester_ip_address": None,
"headers": {"user-agent": "ua"},
"forwarded": "not a header",
"spend_logs_metadata": [{"headers": {}}],
"error_information": {"error_message": "denied 203.0.113.9"},
},
}
@pytest.mark.parametrize("error_field", ["error_message", "traceback"])
def test_stored_request_masks_ip_addresses_in_error_text_before_long_text_is_truncated(
monkeypatch: pytest.MonkeyPatch, error_field: str
) -> None:
from litellm.proxy.proxy_server import general_settings
monkeypatch.setitem(general_settings, "store_prompts_in_spend_logs", True)
monkeypatch.setenv("MAX_STRING_LENGTH_PROMPT_IN_DB", "1000")
text: Final = "a" * 345 + " 203.0.113.9 " + "b" * 2000 + " 2001:db8::9 " + "c" * 644
metadata: Final = {"error_information": {"error_code": "500", error_field: text}}
stored: Final = json.loads(
_get_proxy_server_request_for_spend_logs_payload(
metadata=metadata,
litellm_params={"proxy_server_request": {"body": {"model": "gpt-5-mini", "metadata": metadata}}},
drop_requester_ip_address=True,
)
)
stored_text: Final = stored["metadata"]["error_information"][error_field]
kept_head_and_tail: Final = stored_text[:350] + stored_text[-650:]
assert LITELLM_TRUNCATED_PAYLOAD_FIELD in stored_text
assert kept_head_and_tail.startswith("a" * 345 + " REDA")
assert not any(char.isdigit() for char in kept_head_and_tail)
assert metadata["error_information"][error_field] == text
def test_stored_request_with_ip_logging_disabled_matches_setting_off_for_bodies_without_ip_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy.proxy_server import general_settings
monkeypatch.setitem(general_settings, "store_prompts_in_spend_logs", True)
loop: Final[dict[str, object]] = {}
loop["self"] = (loop,)
body: Final[dict[object, object]] = {"model": "gpt-5-mini", 7: "non-string key", "metadata": {"tags": (loop,)}}
stored: Final = [
_get_proxy_server_request_for_spend_logs_payload(
metadata={},
litellm_params={"proxy_server_request": {"body": body}},
drop_requester_ip_address=drop_requester_ip_address,
)
for drop_requester_ip_address in (False, True)
]
assert json.loads(stored[0])["model"] == "gpt-5-mini"
assert stored[1] == stored[0]
@pytest.mark.parametrize("disabled", [False, True], ids=["setting-off", "setting-on"])
def test_failure_error_information_masks_ip_addresses_before_long_text_is_truncated(
monkeypatch: pytest.MonkeyPatch, disabled: bool
) -> None:
from litellm.proxy.proxy_server import general_settings
monkeypatch.setitem(general_settings, "disable_requester_ip_address_logging", disabled)
monkeypatch.setenv("MAX_STRING_LENGTH_PROMPT_IN_DB", "1000")
text: Final = "a" * 345 + " 203.0.113.9 " + "b" * 2000 + " 2001:db8::9 " + "c" * 644
sanitized: Final = sanitize_error_information_for_spend_logs(
{"error_code": "500", "error_message": text, "traceback": text}
)
assert sanitized is not None
for stored_text in (sanitized["error_message"], sanitized["traceback"]):
kept_head_and_tail = stored_text[:350] + stored_text[-650:]
assert LITELLM_TRUNCATED_PAYLOAD_FIELD in stored_text
assert any(char.isdigit() for char in kept_head_and_tail) is not disabled
assert sanitized["error_code"] == "500"
@pytest.mark.parametrize(
"address", ["203.0.113.9", "2001:db8::9", "1:2:3:4:5:6:7:8", "1:2:3:4:5:6:7::", "::1:2:3:4:5:6:7", "fe80::1"]
)
def test_failure_error_information_masks_every_ip_address_form_when_ip_logging_is_disabled(
monkeypatch: pytest.MonkeyPatch, address: str
) -> None:
from litellm.proxy.proxy_server import general_settings
monkeypatch.setitem(general_settings, "disable_requester_ip_address_logging", True)
sanitized: Final = sanitize_error_information_for_spend_logs(
{
"error_code": "403",
"error_message": f"IP address {address} not allowed.",
"traceback": f"peer [{address}]:443",
}
)
assert sanitized == {
"error_code": "403",
"error_message": "IP address REDACTED_BY_LITELM not allowed.",
"traceback": "peer [REDACTED_BY_LITELM]:443",
}

View file

@ -234,6 +234,37 @@ async def test_add_litellm_data_to_request_parses_string_metadata():
assert updated_data["metadata"]["generation_name"] == "gen123"
@pytest.mark.asyncio
@pytest.mark.parametrize("disabled", [False, True])
async def test_add_litellm_data_to_request_keeps_requester_ip_address_for_guardrails_when_ip_logging_is_disabled(
monkeypatch: pytest.MonkeyPatch, disabled: bool
) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
settings = {"use_x_forwarded_for": True, "disable_requester_ip_address_logging": disabled}
monkeypatch.setattr(proxy_server, "general_settings", settings)
request_mock = MagicMock(spec=Request)
request_mock.url = MagicMock()
request_mock.url.path = "/v1/chat/completions"
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
request_mock.method = "POST"
request_mock.query_params = {}
request_mock.headers = {"Content-Type": "application/json", "x-forwarded-for": "203.0.113.9"}
request_mock.client = MagicMock()
request_mock.client.host = "127.0.0.1"
updated_data = await add_litellm_data_to_request(
data={"model": "gpt-4o"},
request=request_mock,
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
proxy_config=MagicMock(),
general_settings=settings,
)
assert updated_data["metadata"]["requester_ip_address"] == "203.0.113.9"
@pytest.mark.asyncio
async def test_key_otel_service_name_outranks_team_metadata_merge():
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request

View file

@ -29574,6 +29574,11 @@ export interface components {
* @description If True and SSO is configured (MICROSOFT_CLIENT_ID, GOOGLE_CLIENT_ID, GENERIC_CLIENT_ID, or SAML_IDP_METADATA_URL/XML), disables username/password login on /login, /v2/login, and /v3/login so SSO is the only way to reach the Admin UI. An admin locked out of the UI can still administer the proxy over the API with the master key; unset this setting and restart the proxy to restore UI username/password login. Default is False.
*/
disable_password_login_when_sso_enabled?: boolean | null;
/**
* Disable Requester Ip Address Logging
* @description If True, new spend log rows store requester_ip_address as null in the column, the metadata and the stored request. The stored request also drops client-IP forwarding headers such as x-forwarded-for, and IP addresses in stored error messages are masked. Guardrails, metrics and callbacks that read the standard logging payload still receive the IP. Default is False.
*/
disable_requester_ip_address_logging?: boolean | null;
/**
* Disable Responses Id Security
* @description If True, disables ownership enforcement on Responses API ids. Keys may then retrieve, cancel, delete, and chain from any response id, including ids belonging to another user or team and ids this proxy never issued. WARNING: this removes tenant isolation on /v1/responses