mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Merge f15c94b1d7 into 4b8a846788
This commit is contained in:
commit
ea0b320f77
8 changed files with 1016 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
572
tests/integration/spend/test_requester_ip_address_logging.py
Normal file
572
tests/integration/spend/test_requester_ip_address_logging.py
Normal 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
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue