Files
litellm/tests/test_litellm/proxy/test_component_allowlists.py
T
Mateo Wang 53a206a179 fix(anthropic/adapter): emit thinking block for reasoning_content-only streaming chunks (#29600)
* fix(anthropic/adapter): open thinking block for reasoning_content-only streaming chunks

The /v1/messages streaming content-block classifier (_translate_streaming_openai_chunk_to_anthropic_content_block) only recognized thinking_blocks. OpenAI-compatible reasoning backends (vLLM/SGLang reasoning parsers: DeepSeek-R1, Qwen3, gpt-oss, ...) populate reasoning_content with thinking_blocks=None, so the classifier fell through to a text block. The delta translator already emits thinking_delta for reasoning_content, so those deltas landed inside a text block and Anthropic streaming clients (Claude Code, SDK .stream()) silently dropped the chain-of-thought.

Mirror the reasoning_content fallback already present in the non-stream translator and the streaming delta translator so the classifier opens a thinking block. Adds a focused regression test.

* fix(anthropic/adapter): reach reasoning_content branch when thinking_blocks attr is absent

Delta deletes the thinking_blocks attribute when unset, so the prior nested check was unreachable for reasoning-only chunks (vLLM/SGLang). Make it a sibling elif so the content block is classified as thinking.

* test(proxy): stop component-allowlist test leaking DATABASE_URL into xdist peers

The component-allowlist test pins throwaway DATABASE_URL/LITELLM_MASTER_KEY
values at import time via os.environ so importing proxy_server doesn't need a
live database. Those values persisted for the whole pytest-xdist worker, so a
sibling test sharing the worker (test_key_rotation_e2e's DB-backed E2E case)
saw the leaked sqlite DATABASE_URL, treated it as an available database instead
of skipping, and the Prisma engine rejected the non-postgres URL (P1012 ->
httpx.ConnectError). Restore the prior environment after the import so the
throwaway values never escape the module.

---------

Co-authored-by: Tai An <antai12232931@outlook.com>
2026-06-03 11:26:13 -07:00

91 lines
3.6 KiB
Python

"""Coverage test for the gateway / backend component allowlists.
The componentization scaffold splits the proxy FastAPI app into two runtime
components by trimming the route table inside a wrapped lifespan context:
gateway.main -> only paths matched by gateway/routes/allowlist.py
backend.main -> only paths matched by backend/routes/allowlist.py
If either allowlist drops a path that was reachable on the monolithic app,
clients hitting that path on the corresponding pod get a 404. This test
guarantees that the union of the two trimmed route sets equals the full set
of routes on the proxy app — i.e. no endpoint is dropped on the floor.
The test reproduces the same predicate that ``gateway/main.py`` and
``backend/main.py`` use, without importing them. The component modules wrap
the shared ``app.router.lifespan_context``; importing them in the test process
would chain wrappers and corrupt the snapshot.
"""
import os
import sys
# Importing ``litellm.proxy.proxy_server`` runs its module-level setup, which
# reads ``DATABASE_URL`` (Prisma) and ``LITELLM_MASTER_KEY``. Tier-zero CI
# runners don't set these. We pin throwaway values before the import so the
# test never depends on a live database or master key, then restore the prior
# environment so the throwaway values don't leak into sibling tests sharing the
# xdist worker (a leaked non-postgres ``DATABASE_URL`` makes DB-backed tests
# treat a phantom database as available instead of skipping).
_THROWAWAY_ENV = {
"DATABASE_URL": "sqlite:///:memory:",
"LITELLM_MASTER_KEY": "sk-test-component-allowlist",
}
_PRE_EXISTING_ENV = {key: os.environ.get(key) for key in _THROWAWAY_ENV}
for _key, _value in _THROWAWAY_ENV.items():
os.environ.setdefault(_key, _value)
from fastapi.routing import Mount
# gateway/ and backend/ live at the repo root, not inside litellm/.
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from backend.routes.allowlist import BACKEND_EXACT_PATHS, BACKEND_PATH_PREFIXES
from gateway.routes.allowlist import GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES
from litellm.proxy.proxy_server import app
for _key, _previous in _PRE_EXISTING_ENV.items():
if _previous is None:
os.environ.pop(_key, None)
else:
os.environ[_key] = _previous
def _component_paths(routes, exact_paths, path_prefixes) -> set[str]:
"""Reproduce ``gateway.main._is_gateway_route`` / ``backend.main._is_backend_route``."""
out: set[str] = set()
for route in routes:
if isinstance(route, Mount):
continue
path = getattr(route, "path", None)
if path is None:
continue
if path in exact_paths or any(path.startswith(p) for p in path_prefixes):
out.add(path)
return out
def test_gateway_plus_backend_covers_full_app():
"""Every route on the proxy app must be served by gateway or backend."""
all_paths = {
getattr(r, "path")
for r in app.router.routes
if not isinstance(r, Mount) and getattr(r, "path", None) is not None
}
gateway_paths = _component_paths(
app.router.routes, GATEWAY_EXACT_PATHS, GATEWAY_PATH_PREFIXES
)
backend_paths = _component_paths(
app.router.routes, BACKEND_EXACT_PATHS, BACKEND_PATH_PREFIXES
)
uncovered = all_paths - (gateway_paths | backend_paths)
assert not uncovered, (
f"{len(uncovered)} route(s) are not exposed on either component. "
f"Update gateway/routes/allowlist.py or backend/routes/allowlist.py to cover:\n "
+ "\n ".join(sorted(uncovered))
)