mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
* feat(proxy): general_settings.disable_requester_ip_address_logging keeps caller IP out of spend logs
When the setting is on, spend log rows store requester_ip_address as null in
the column, the metadata and the stored request (which also drops client-IP
forwarding headers), and IP addresses in stored error messages are masked, so
the Logs page shows no caller IP. The setting is read the way the settings API
validates it. Capture is unchanged, so guardrails, logging callbacks and
metrics still receive the IP. Toggleable from the Admin UI General Settings.
Resolves LIT-7442
* fix(proxy): mask IPv6 addresses that end or start with :: across eight groups
The masking pattern allowed seven colons, so valid forms like 1:2:3:4:5:6:7::
and ::1:2:3:4:5:6:7 stayed in stored error text with
disable_requester_ip_address_logging on. Also lists the request metadata walker
in the recursion check, since it only walks the tree the spend log sanitizer
already returned.
* fix(proxy): mask IPs in every stored header value and after labels or underscores
With disable_requester_ip_address_logging on, client IPs carried in headers outside the forwarding list (do-connecting-ip, incap-client-ip, custom names) and addresses written as host:2001:db8::2, peer_2001:db8::2, socket_192.0.2.1 or an unbracketed IPv6 with a port were still stored in spend logs.
* fix(proxy): mask request header IPs before long values are truncated
A header value longer than the spend log string limit kept the part of an IP that fell before the truncation cut. Request headers in metadata and requester_metadata are now masked before the body is truncated, like error text already was. IPv4 followed by a dotted port (192.0.2.1.443) is masked again.
* fix(proxy): mask the IP piece a header truncation cut leaves instead of pre-masking headers
Masking header values before truncation scanned the whole client value,
including text the cut drops, and walked hook-made header cycles. Mask after
truncation instead, then mask the hex and digit run touching each side of the
truncation marker when it is short enough to be part of an IP address. Every
nested headers mapping gets the same treatment.
* docs(proxy): say the requester IP setting masks IPs in other stored header values
* fix(proxy): mask stored error text after truncation and harden the cut-piece pass
Error messages and tracebacks were regex-scanned in full before the DB cap,
which costs seconds on multi-megabyte provider errors. Mask them after the
cap like header values, with the same pass over the run beside each
truncation marker. That pass now handles every marker, skips long hex tokens
so an address beside one is still masked, keeps at most one address length,
and also masks colon-only IPv6 groups.
* fix(proxy): drop the requester IP from the originating request an auto router classifier row stores
* fix(proxy): drop the requester IP from the classifier input an auto router classifier row stores
* refactor(proxy): mask stored IPs with the content filter's ipv4 and ipv6 patterns
The requester IP setting now reuses the content filter guardrail's prebuilt
ipv4 and ipv6 patterns instead of its own address regexes, so spend logs mask
what that guardrail masks. Addresses glued to a label or underscore stay as
the content filter leaves them.
* fix(proxy): mask stored IPs with the spend-log matcher again
The content filter's ipv4 and ipv6 patterns left labelled IPv6 (host:2001:db8::2),
unbracketed IPv6 with a port and IPv4 after an underscore in stored error text and
header values, and masked parts of text like std::io::Error. Restore the
address-validating matcher and its regression cases.
This reverts commit 79537a8aa5.
---------
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
169 lines
10 KiB
Python
169 lines
10 KiB
Python
import ast
|
|
import os
|
|
|
|
IGNORE_FUNCTIONS = [
|
|
"_json_cost", # bounded at depth 32 and consumed under byte/node limits.
|
|
"_format_type",
|
|
"remove_additional_properties",
|
|
"remove_strict_from_schema",
|
|
"filter_schema_fields",
|
|
"text_completion",
|
|
"_check_for_os_environ_vars",
|
|
"clean_message",
|
|
"unpack_defs",
|
|
"convert_anyof_null_to_nullable", # has a set max depth
|
|
"add_object_type",
|
|
"strip_field",
|
|
"transform_prompt",
|
|
"mask_dict",
|
|
"_serialize", # we now set a max depth for this
|
|
"_sanitize_request_body_for_spend_logs_payload", # testing added for circular reference
|
|
"_sanitize_value", # testing added for circular reference
|
|
"set_schema_property_ordering", # testing added for infinite recursion
|
|
"process_items", # testing added for infinite recursion + max depth set.
|
|
"_can_object_call_model", # max depth set.
|
|
"encode_unserializable_types", # max depth set.
|
|
"filter_value_from_dict", # max depth set.
|
|
"normalize_json_schema_types", # max depth set.
|
|
"_extract_fields_recursive", # max depth set.
|
|
"remove_json_schema_refs", # max depth set.,
|
|
"_convert_schema_types", # max depth set.,
|
|
"_fix_enum_empty_strings", # max depth set.,
|
|
"get_access_token", # max depth set.,
|
|
"_redact_base64", # max depth set.
|
|
"_contains_vision_content", # max depth set.
|
|
"_read_all_bytes", # max depth set.
|
|
"_fix_enum_types", # max depth set.
|
|
"_collect_argument_paths", # max depth set.
|
|
"_split_text", # max depth set.
|
|
"_mask_sequence", # max depth set.
|
|
"_encrypted_param", # max depth set.
|
|
"_decrypted_param", # max depth set.
|
|
"contains_encrypted_marker", # max depth set.
|
|
"_rotate_guardrail_row", # bounded by attempts_left.
|
|
"_delete_nested_value_custom", # max depth set (bounded by number of path segments).
|
|
"filter_exceptions_from_params", # max depth set (default 20) to prevent infinite recursion.
|
|
"__getattr__", # lazy loading pattern in litellm/__init__.py with proper caching to prevent infinite recursion.
|
|
"_validate_inheritance_chain", # max depth set (default 100) to prevent infinite recursion in policy inheritance validation.
|
|
"_basic_json_schema_validate", # max depth set.
|
|
"extract_text_from_a2a_message", # max depth set (default 10) to prevent infinite recursion in A2A message parsing.
|
|
"_convert_to_json_serializable_dict", # max depth set (default 20) and circular reference protection to prevent infinite recursion.
|
|
"dict", # max depth set. _LiteLLMParamsDictView.dict() calls builtin dict(), not itself.
|
|
"_read_image_bytes", # max depth set.
|
|
"get_masked_values", # max depth set (default 20) to prevent infinite recursion while masking nested sensitive config dicts.
|
|
"_redact_sensitive_litellm_params", # max depth set (default 10).
|
|
"_redact_secret_values_in_obj", # max depth set (default 10, _REDACT_SECRET_MAX_DEPTH); fails closed by returning "REDACTED" at the cap.
|
|
"_resolve", # OCI: $ref resolver bounded by `resolving_stack` cycle guard.
|
|
"resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input).
|
|
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
|
|
"_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap.
|
|
"apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap.
|
|
"_inlined", # max depth set (_MAX_INLINE_DEPTH=32); passes the schema node through untouched at the cap. Walks a Pydantic JSON schema once at import time.
|
|
"_filter_argument_value", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by blocking the tool call at the cap.
|
|
"_redact_scanned_content", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by returning "[REDACTED]" at the cap.
|
|
"replace_ciphertexts", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); walks stored JSON, which has no cycles, and leaves values below the cap untouched.
|
|
"_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap.
|
|
"_mergeable_branch", # max depth set (_MAX_SCHEMA_FLATTEN_DEPTH=32) plus a seen_refs cycle guard; passes the schema through untouched at the cap.
|
|
"json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
|
|
"strict_json_schema", # harness: max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by raising ValueError at the cap.
|
|
"toml_value", # harness/codex: max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by raising OptionsMismatch at the cap.
|
|
"with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
|
|
"_clean_extraction", # ScaleDown: traverses the entity map built under MAX_SCHEMA_DEPTH=32 and MAX_ENTITIES=1000.
|
|
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
|
|
"_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
|
|
"_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
|
|
"_json_safe", # max depth set (_MAX_DEPTH) plus a seen-ids cycle guard for self-referential input.
|
|
"_redact_agent_params_tree", # max depth set (default 10), same shape as _redact_sensitive_litellm_params.
|
|
"_restore_redacted_nested_value", # max depth set (default 10), mirrors _redact_agent_params_tree on the write side.
|
|
"_unqualified", # bounded by the qualifier depth of a static TypedDict annotation (Annotated, Required/NotRequired, ReadOnly around one type, no cycles possible).
|
|
"_render_json", # bounded by the nesting depth of a pydantic-validated JsonValue from the operator's config (a finite JSON tree, no cycles possible).
|
|
"completion_cost", # max depth 1: recursion only fires for mixed-tier Responses WS logging objects, and each split part carries a single service_tier so _split_responses_ws_logging_object_by_service_tier returns None.
|
|
"_string_leaves", # bounded by the nesting depth of a safe_json_structure output (a finite JSON tree, no cycles possible).
|
|
"_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.
|
|
]
|
|
|
|
|
|
class RecursiveFunctionFinder(ast.NodeVisitor):
|
|
def __init__(self):
|
|
self.recursive_functions = []
|
|
self.ignored_recursive_functions = []
|
|
|
|
def visit_FunctionDef(self, node):
|
|
# Check if the function calls itself
|
|
if any(self._is_recursive_call(node, call) for call in ast.walk(node)):
|
|
if node.name in IGNORE_FUNCTIONS:
|
|
self.ignored_recursive_functions.append(node.name)
|
|
else:
|
|
self.recursive_functions.append(node.name)
|
|
self.generic_visit(node)
|
|
|
|
def _is_recursive_call(self, func_node, call_node):
|
|
# Check if the call node is a function call
|
|
if not isinstance(call_node, ast.Call):
|
|
return False
|
|
|
|
# Case 1: Direct function call (e.g., my_func())
|
|
if isinstance(call_node.func, ast.Name) and call_node.func.id == func_node.name:
|
|
return True
|
|
|
|
# Case 2: Method call with self (e.g., self.my_func())
|
|
if isinstance(call_node.func, ast.Attribute) and isinstance(
|
|
call_node.func.value, ast.Name
|
|
):
|
|
return (
|
|
call_node.func.value.id == "self"
|
|
and call_node.func.attr == func_node.name
|
|
)
|
|
|
|
return False
|
|
|
|
|
|
def find_recursive_functions_in_file(file_path):
|
|
with open(file_path, "r") as file:
|
|
tree = ast.parse(file.read(), filename=file_path)
|
|
finder = RecursiveFunctionFinder()
|
|
finder.visit(tree)
|
|
return finder.recursive_functions, finder.ignored_recursive_functions
|
|
|
|
|
|
def find_recursive_functions_in_directory(directory):
|
|
recursive_functions = {}
|
|
ignored_recursive_functions = {}
|
|
for root, _, files in os.walk(directory):
|
|
for file in files:
|
|
print("file: ", file)
|
|
if file.endswith(".py"):
|
|
file_path = os.path.join(root, file)
|
|
functions, ignored = find_recursive_functions_in_file(file_path)
|
|
if functions:
|
|
recursive_functions[file_path] = functions
|
|
if ignored:
|
|
ignored_recursive_functions[file_path] = ignored
|
|
return recursive_functions, ignored_recursive_functions
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Example usage
|
|
# raise exception if any recursive functions are found, except for the ignored ones
|
|
# this is used in the CI/CD pipeline to prevent recursive functions from being merged
|
|
|
|
directory_path = "./litellm"
|
|
recursive_functions, ignored_recursive_functions = (
|
|
find_recursive_functions_in_directory(directory_path)
|
|
)
|
|
print("UNIGNORED RECURSIVE FUNCTIONS: ", recursive_functions)
|
|
print("IGNORED RECURSIVE FUNCTIONS: ", ignored_recursive_functions)
|
|
|
|
if len(recursive_functions) > 0:
|
|
# raise exception if any recursive functions are found
|
|
for file, functions in recursive_functions.items():
|
|
print(
|
|
f"🚨 Unignored recursive functions found in {file}: {functions}. THIS IS REALLY BAD, it has caused CPU Usage spikes in the past. Only keep this if it's ABSOLUTELY necessary."
|
|
)
|
|
file, functions = list(recursive_functions.items())[0]
|
|
raise Exception(
|
|
f"🚨 Unignored recursive functions found include {file}: {functions}. THIS IS REALLY BAD, it has caused CPU Usage spikes in the past. Only keep this if it's ABSOLUTELY necessary."
|
|
)
|