litellm/tests/unit/proxy/common_utils/test_debug_utils.py
devin-ai-integration[bot] e136e91273
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Deploy main to gateway-dev / dispatch (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Waiting to run
unit / OpenAI 3.0.0 compatibility (push) Waiting to run
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
unit / enterprise-package (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / OpenAI 3.25.0 compatibility (push) Waiting to run
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
fix(router): keep the prompt caching affinity pin alive as long as the prefix's cache_control ttl (#46084)
* fix(router): keep the prompt caching affinity pin alive as long as the prefix's cache_control ttl

A prefix marked cache_control ttl 1h lost its pinned deployment after the fixed 300 s, so the next request went to another deployment and paid a cold cache. The pin TTL now follows the longest ephemeral breakpoint in the cacheable prefix, tools included: 3600 s for a 1h breakpoint and 300 s otherwise.

* fix(router): give prompt cache pins their own tier and honour any declared ttl

Pins now live in a DualCache of their own instead of the router's 200-slot
in-memory tier, so an hour-long pin no longer evicts cooldowns and rate windows.
The pin lifetime follows any <n>s|m|h cache_control ttl, floored at 300 s, not
only the literal "1h".

* fix(router): hash the pin ttl off the event loop and flush prompt cache pins with the router cache

Compute the cache_control ttl scan through offload_token_count beside the
prefix hash, clear the dedicated pin cache in Router.flush_cache, and point
the order-fallback and priority-group pin tests at the pin cache they read

* fix(router): pin 1h prefixes only on 1h-capable models, cap the pin at 1h, and give pins 10k in-memory slots

* fix(router): match the 1h pin gate to what Bedrock forwards

Bedrock deployments now decide the 1h pin with is_claude_4_5_on_bedrock,
the same check that decides whether the 1h ttl reaches Bedrock, so an
application inference profile ARN keeps a 5 minute pin whatever its
base_model prices

* test(router): give the anthropic_messages pin fixture its provider

* fix(router): let each pin write set its own lifetime and require a positive 1h price

In a group mixing 1h and 5m deployments the in-memory pin kept the lifetime of its first write, so a 5m deployment could hold an hour-long pin. Each write now clears the in-memory entry first, matching Redis. The Bedrock gate uses supports_bedrock_prompt_cache_ttl after #46058, and a zero 1h price no longer counts as 1h support.

* test(router): integration cells for the one-hour prompt cache pin lifetime

Cover every surface the pin lifetime touches: chat and /v1/messages through
both SDKs, the responses bridge, response-cache hits, each provider family's
1h gate, malformed and duplicated ttl values, rewrites of one prefix, mixed
and cross-group routing, concurrency, every Redis attach path, the in-memory
slot pressure without Redis reads, a Redis outage and a killed worker

* fix(caching): bound the in-memory expiry heap and count prompt cache pins in the memory debug routes

* test(integration): cover prompt cache pins in the memory debug routes and the bounded expiration heap

* fix(caching): serialize in-memory cache writes so a heap rebuild cannot lose another thread's expiry entry

* fix(caching): keep the in-memory cache bounded when entries have no usable expiry

* test(caching): cover the response cache bound when cached entries have no usable ttl

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-14 21:10:09 -07:00

200 lines
7.2 KiB
Python

import json
import os
import socket
from collections.abc import Callable, Iterator, Mapping
from dataclasses import asdict
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy import proxy_server
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.bug_report_config import build_proxy_bug_report
from litellm.proxy.common_utils.debug_utils import (
PSUTIL_MISSING_ERROR,
_ProcFilesystemProcess,
_summary_process_memory,
get_memory_summary,
)
from litellm.proxy.common_utils.debug_utils import router as debug_router
from litellm.router import Router
from litellm.router_utils.prompt_caching_cache import PromptCachingCache
PAGE_SIZE = 4096
STATM_SIZE_PAGES = 100_000
STATM_RESIDENT_PAGES = 30_000
MEMINFO_TOTAL_KB = 1_000_000
@pytest.fixture
def proc_process(tmp_path: Path) -> _ProcFilesystemProcess:
statm = tmp_path / "statm"
statm.write_text(f"{STATM_SIZE_PAGES} {STATM_RESIDENT_PAGES} 5000 1 0 20000 0\n")
meminfo = tmp_path / "meminfo"
meminfo.write_text(
f"MemTotal: {MEMINFO_TOTAL_KB} kB\nMemFree: 400000 kB\nMemAvailable: 600000 kB\n"
)
return _ProcFilesystemProcess(statm_path=str(statm), meminfo_path=str(meminfo), page_size=PAGE_SIZE)
def test_proc_filesystem_process_reads_resident_and_virtual_bytes_from_statm(
proc_process: _ProcFilesystemProcess,
) -> None:
memory_info = proc_process.memory_info()
assert memory_info.rss == STATM_RESIDENT_PAGES * PAGE_SIZE
assert memory_info.vms == STATM_SIZE_PAGES * PAGE_SIZE
def test_proc_filesystem_process_reports_share_of_meminfo_total(proc_process: _ProcFilesystemProcess) -> None:
expected_percent = STATM_RESIDENT_PAGES * PAGE_SIZE / (MEMINFO_TOTAL_KB * 1024) * 100
assert proc_process.memory_percent() == pytest.approx(expected_percent)
def test_summary_reports_rss_from_the_proc_filesystem(proc_process: _ProcFilesystemProcess) -> None:
memory, health_status = _summary_process_memory(proc_process)
assert memory["ram_usage_mb"] == round(STATM_RESIDENT_PAGES * PAGE_SIZE / (1024 * 1024), 2)
assert memory["system_memory_percent"] == pytest.approx(12.0)
assert health_status == "healthy"
assert "error" not in memory
def test_summary_without_any_memory_source_names_psutil_and_reports_no_rss() -> None:
memory, health_status = _summary_process_memory(None)
assert memory == {"error": PSUTIL_MISSING_ERROR}
assert health_status == "healthy"
@pytest.mark.asyncio
async def test_memory_summary_names_the_host_and_worker_that_answered() -> None:
summary = await get_memory_summary(UserAPIKeyAuth())
assert summary["hostname"] == socket.gethostname()
assert summary["worker_pid"] == os.getpid()
assert summary["memory"]["ram_usage_mb"] > 0
HOSTILE_CONFIG: Mapping[str, object] = {
"model_list": [
{
"model_name": "acme-prod-gpt4",
"litellm_params": {
"model": "azure/acme-gpt4o-deployment",
"api_base": "https://acme-eastus.openai.azure.com",
"api_key": "sk-live-secret-1",
},
}
],
"litellm_settings": {"drop_params": True, "callbacks": ["langfuse", "acme_hooks.audit_logger"]},
}
HOSTILE_GENERAL_SETTINGS: Mapping[str, object] = {
"master_key": "sk-live-secret-master",
"database_url": "postgres://user:hunter2@10.0.0.7/litellm",
"store_model_in_db": True,
}
HOSTILE_STRINGS = ("acme", "sk-live-secret", "hunter2", "10.0.0.7", "azure.com")
@pytest.fixture
def hostile_proxy_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
previous_config = proxy_server.proxy_config.get_config_state()
proxy_server.proxy_config.update_config_state(config=HOSTILE_CONFIG)
monkeypatch.setattr(proxy_server, "general_settings", dict(HOSTILE_GENERAL_SETTINGS))
yield
proxy_server.proxy_config.update_config_state(config=previous_config)
def _debug_client(caller: UserAPIKeyAuth) -> TestClient:
app = FastAPI()
app.include_router(debug_router)
app.dependency_overrides[user_api_key_auth] = lambda: caller
return TestClient(app)
@pytest.mark.parametrize(
"caller",
[
UserAPIKeyAuth(),
UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER),
UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
],
)
@pytest.mark.usefixtures("hostile_proxy_config")
def test_debug_report_refuses_everyone_but_proxy_admins(caller: UserAPIKeyAuth) -> None:
response = _debug_client(caller).get("/debug/report")
assert response.status_code == 403, response.text
assert "litellm_version" not in response.text
@pytest.mark.usefixtures("hostile_proxy_config")
def test_debug_report_returns_what_the_bug_report_link_carries_and_nothing_from_the_operator() -> None:
response = _debug_client(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)).get("/debug/report")
assert response.status_code == 200, response.text
assert response.json() == json.loads(json.dumps(asdict(build_proxy_bug_report(RuntimeError("boom")).environment)))
assert response.json()["config_lines"] == [
"general_settings.store_model_in_db = true",
"litellm_settings.drop_params = true",
"litellm_settings.callbacks = [langfuse]",
"model_list[*].provider = [azure]",
]
assert not any(hostile in response.text for hostile in HOSTILE_STRINGS), response.text
PINNED_PREFIXES = 3
@pytest.fixture
def router_with_pins(monkeypatch: pytest.MonkeyPatch) -> None:
router = Router(model_list=[{"model_name": "claude", "litellm_params": {"model": "anthropic/claude-sonnet-4-5"}}])
pins = PromptCachingCache(cache=router.prompt_caching_pin_cache)
for prefix in range(PINNED_PREFIXES):
pins.add_model_id(
model_id=f"deployment-{prefix}",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": f"prefix {prefix} " * 300, "cache_control": {"type": "ephemeral"}}
],
}
],
tools=None,
)
monkeypatch.setattr(proxy_server, "llm_router", router)
@pytest.mark.parametrize(
("path", "pin_count", "expected"),
[
(
"/memory-usage-in-mem-cache",
lambda body: body["num_items_in_prompt_caching_pin_cache"],
2 * PINNED_PREFIXES,
),
("/memory-usage-in-mem-cache-items", lambda body: len(body["prompt_caching_pin_cache"]), PINNED_PREFIXES),
("/debug/memory/summary", lambda body: body["caches"]["breakdown"]["prompt_caching_pins"]["count"], PINNED_PREFIXES),
(
"/debug/memory/details?include_process_info=false",
lambda body: body["cache_memory"]["prompt_caching_pin_cache"]["num_items"],
PINNED_PREFIXES,
),
],
)
@pytest.mark.usefixtures("router_with_pins")
def test_memory_routes_count_prompt_caching_pins(
path: str, pin_count: Callable[[dict[str, object]], int], expected: int
) -> None:
response = _debug_client(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)).get(path)
assert response.status_code == 200
assert pin_count(response.json()) == expected