mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* test: move routing, caching and callback tests out of local_testing Moves the 37 routing, caching and callback files in tests/local_testing into mirrored tests/unit paths and tests/integration sdk and security groups per the migration manifest, and deletes the 8 rows it marks for deletion * test: cover least-busy routing and tighten migrated router, cache and pass-through tests Add router-level least-busy tests for the three legacy rows that had no destination, move completion fallback and model alias tests to their mirror files, move router batch tests next to the router, and strengthen tests whose mutants survived (specific deployment, batch pair grouping, custom cache key, router cache ttl). Add a unit test for configured pass-through Authorization forwarding. * test: tighten migrated callback, proxy config and cache ttl tests Assert the full StandardLoggingPayload key set and the wire user on callback tests, add an async embedding failure callback test, drive the prometheus redis failure through a real RedisCache, use the real salt key in proxy config tests, pin the redis ttl value and rename fallback test helpers * test: fix semantic cache assertions and order-dependent router and callback tests Count semantic cache embeddings on the route, drain async cache writes before the second lookup, echo the requested model from the callback provider mock so response_cost does not depend on model_cost entries other tests register, and move the two-key authorization assertion to the auth failure test it belongs to * test: cover model_list fan-out and the usage-based routing tpm limit lost in the move * test: wait for the streamed cache entry in redis and drop deleted files from the hosted mock guard * test: restore sync callback, audio redaction and langfuse export coverage, keep redis-compat filter on the moved file * ci: run the remaining router-keyword legacy tests on one node * test: refuse the redis write in-process instead of resolving an invalid host --------- Co-authored-by: yuneng <yuneng@berri.ai>
420 lines
17 KiB
Python
420 lines
17 KiB
Python
"""Slots H1 and H2: pass-through, vector store and search tool credentials reach only their upstream.
|
|
|
|
Each test boots an owned proxy whose config declares all three credentials against one
|
|
recording upstream double:
|
|
|
|
- H1: a pass-through endpoint whose ``Authorization`` header is ``Bearer os.environ/<name>``,
|
|
with the canary in that environment variable;
|
|
- H2: an OpenAI vector store in ``vector_store_registry`` with the canary as ``api_key``;
|
|
- H2S: a Perplexity search tool in ``search_tools`` with the canary as ``api_key``.
|
|
|
|
The test sends one request through the slot's route, and the upstream answers 200 or, when the
|
|
request carries ``UPSTREAM_REJECT``, 401. Positive control: the upstream must receive
|
|
``Authorization: Bearer <canary>`` on the request carrying the marker, or the test fails before
|
|
sweeping. Sensitivity control: the marker must be reported where stored prompts belong. Then no
|
|
sweep may find any of the three canaries.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
from collections.abc import Iterator, Mapping
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Final, Literal
|
|
|
|
import httpx
|
|
import litellm
|
|
import pytest
|
|
from fastapi import Request as FastAPIRequest, Response
|
|
from litellm import Router
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm_enterprise.enterprise_callbacks.secret_detection import _ENTERPRISE_SecretDetection
|
|
from starlette.datastructures import URL
|
|
from integration._support.client import Scenario, eventually
|
|
from integration._support.database import read_rows
|
|
from integration._support.wire import Reply, Request, wire_server
|
|
from integration.security._canary import MARKER, Canary, canary
|
|
from integration.security._sinks import CONFIG_MODEL, GENERIC_SINK, Caller, Recorder, Rig, canary_rig, settle
|
|
from integration.security._sweeps import assert_marker_seen, assert_no_hits, record_route_sweep, sweep_all
|
|
|
|
Outcome = Literal["success", "upstream_401"]
|
|
PASS_THROUGH_ROUTE: Final = "/canary-pass-through"
|
|
PASS_THROUGH_ENV: Final = "CANARY_PASS_THROUGH_KEY"
|
|
LANGFUSE_ROUTE: Final = "/api/public/ingestion"
|
|
RERANK_ROUTE: Final = "/canary-rerank"
|
|
LANGFUSE_MARKER: Final = "langfuse-pass-through-contract"
|
|
RERANK_MARKER: Final = "rerank-pass-through-contract"
|
|
LANGFUSE_PUBLIC_KEY: Final = "langfuse-public-canary"
|
|
LANGFUSE_SECRET_KEY: Final = "langfuse-secret-canary"
|
|
RERANK_AUTHORIZATION: Final = "Bearer rerank-pass-through-canary"
|
|
VECTOR_STORE_ID: Final = "canary-vector-store"
|
|
SEARCH_TOOL: Final = "canary-search-tool"
|
|
UPSTREAM_REJECT: Final = "canary-upstream-reject"
|
|
SLOTS: Final = ("H1", "H2", "H2S")
|
|
SLACK: Final = timedelta(seconds=5)
|
|
|
|
|
|
def _upstream(request: Request) -> Reply:
|
|
"""Pass-through, OpenAI vector store search and Perplexity search double."""
|
|
if request.target == LANGFUSE_ROUTE:
|
|
body: Final = json.loads(request.body)
|
|
assert body == {
|
|
"batch": [
|
|
{
|
|
"id": "contract-batch",
|
|
"type": "trace-create",
|
|
"body": {"id": "contract-trace", "name": LANGFUSE_MARKER},
|
|
}
|
|
]
|
|
}
|
|
expected_auth: Final = base64.b64encode(
|
|
f"{LANGFUSE_PUBLIC_KEY}:{LANGFUSE_SECRET_KEY}".encode()
|
|
).decode()
|
|
assert request.headers.get("authorization") == f"Basic {expected_auth}"
|
|
return Reply(status=207, body=json.dumps({"received": body}).encode())
|
|
if request.target == "/v1/rerank":
|
|
body: Final = json.loads(request.body)
|
|
assert body == {
|
|
"model": "rerank-contract",
|
|
"query": RERANK_MARKER,
|
|
"top_n": 1,
|
|
"documents": [RERANK_MARKER],
|
|
}
|
|
assert request.headers.get("authorization") == RERANK_AUTHORIZATION
|
|
return Reply(body=json.dumps({"results": [{"index": 0, "relevance_score": 1.0}]}).encode())
|
|
if UPSTREAM_REJECT.encode() in request.body:
|
|
return Reply(status=401, body=b'{"error":"invalid credentials"}')
|
|
body: Final = json.loads(request.body or b"{}")
|
|
query: Final = str(body.get("query", ""))
|
|
if request.target.startswith("/v1/vector_stores/"):
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"object": "vector_store.search_results.page",
|
|
"search_query": [query],
|
|
"data": [
|
|
{
|
|
"file_id": "file-canary",
|
|
"filename": "canary.txt",
|
|
"score": 0.9,
|
|
"attributes": {},
|
|
"content": [{"type": "text", "text": query}],
|
|
}
|
|
],
|
|
"has_more": False,
|
|
"next_page": None,
|
|
}
|
|
).encode()
|
|
)
|
|
if request.target == "/search":
|
|
return Reply(
|
|
body=json.dumps({"results": [{"title": "canary", "url": "https://example.com", "snippet": query}]}).encode()
|
|
)
|
|
return Reply(body=json.dumps({"received": body}).encode())
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Upstreamed:
|
|
rig: Rig
|
|
upstream: Recorder
|
|
canaries: Mapping[str, Canary]
|
|
|
|
|
|
@pytest.fixture
|
|
def rigged(tmp_path: Path) -> Iterator[Upstreamed]:
|
|
"""One owned proxy per test: the H credentials live in its config and environment."""
|
|
canaries: Final = {slot: canary(slot) for slot in SLOTS}
|
|
with wire_server(_upstream) as wire:
|
|
|
|
def configure(config: dict[str, object], provider_url: str) -> None:
|
|
general: Final = config["general_settings"]
|
|
assert isinstance(general, dict)
|
|
general["pass_through_endpoints"] = [
|
|
{
|
|
"path": PASS_THROUGH_ROUTE,
|
|
"target": wire.url + "/pass-through",
|
|
"headers": {"Authorization": f"Bearer os.environ/{PASS_THROUGH_ENV}"},
|
|
"auth": True,
|
|
},
|
|
{
|
|
"path": LANGFUSE_ROUTE,
|
|
"target": wire.url + LANGFUSE_ROUTE,
|
|
"headers": {
|
|
"LANGFUSE_PUBLIC_KEY": "os.environ/LANGFUSE_PUBLIC_KEY",
|
|
"LANGFUSE_SECRET_KEY": "os.environ/LANGFUSE_SECRET_KEY",
|
|
},
|
|
"custom_auth_parser": "langfuse",
|
|
"auth": True,
|
|
},
|
|
{
|
|
"path": RERANK_ROUTE,
|
|
"target": wire.url + "/v1/rerank",
|
|
"headers": {"Authorization": RERANK_AUTHORIZATION},
|
|
"auth": True,
|
|
},
|
|
]
|
|
config["vector_store_registry"] = [
|
|
{
|
|
"vector_store_name": VECTOR_STORE_ID,
|
|
"litellm_params": {
|
|
"vector_store_id": VECTOR_STORE_ID,
|
|
"custom_llm_provider": "openai",
|
|
"api_key": canaries["H2"].value,
|
|
"api_base": wire.url + "/v1",
|
|
},
|
|
}
|
|
]
|
|
config["search_tools"] = [
|
|
{
|
|
"search_tool_name": SEARCH_TOOL,
|
|
"litellm_params": {
|
|
"search_provider": "perplexity",
|
|
"api_key": canaries["H2S"].value,
|
|
"api_base": wire.url,
|
|
},
|
|
}
|
|
]
|
|
|
|
with canary_rig(
|
|
tmp_path,
|
|
configure=configure,
|
|
environment={
|
|
PASS_THROUGH_ENV: canaries["H1"].value,
|
|
"LANGFUSE_PUBLIC_KEY": LANGFUSE_PUBLIC_KEY,
|
|
"LANGFUSE_SECRET_KEY": LANGFUSE_SECRET_KEY,
|
|
},
|
|
) as rig:
|
|
yield Upstreamed(rig, Recorder(wire), canaries)
|
|
|
|
|
|
def _caller(scenario: Scenario) -> Caller:
|
|
team: Final = scenario.team(metadata={"allowed_passthrough_routes": [PASS_THROUGH_ROUTE]})
|
|
user: Final = scenario.user(user_role="internal_user")
|
|
scenario.gateway.post("/team/member_add", {"team_id": team, "member": {"user_id": user, "role": "user"}})
|
|
key: Final = scenario.key(team_id=team, user_id=user, models=[CONFIG_MODEL])
|
|
return Caller(team, user, key)
|
|
|
|
|
|
def _send(rig: Rig, slot: str, key: str, text: str) -> httpx.Response:
|
|
if slot == "H1":
|
|
return rig.proxy.request("POST", PASS_THROUGH_ROUTE, {"text": text}, key=key)
|
|
if slot == "H2":
|
|
return rig.proxy.request("POST", f"/v1/vector_stores/{VECTOR_STORE_ID}/search", {"query": text}, key=key)
|
|
return rig.proxy.request("POST", f"/v1/search/{SEARCH_TOOL}", {"query": text}, key=key)
|
|
|
|
|
|
def test_langfuse_custom_auth_and_rpm_contract(rigged: Upstreamed) -> None:
|
|
rig: Final = rigged.rig
|
|
with rig.proxy.scenario() as scenario:
|
|
team: Final = scenario.team(metadata={"allowed_passthrough_routes": [LANGFUSE_ROUTE]})
|
|
user: Final = scenario.user(user_role="internal_user")
|
|
scenario.gateway.post("/team/member_add", {"team_id": team, "member": {"user_id": user, "role": "user"}})
|
|
key: Final = scenario.key(team_id=team, user_id=user, models=[CONFIG_MODEL], rpm_limit=1)
|
|
authorization: Final = "Basic " + base64.b64encode(f"{key}:anything".encode()).decode()
|
|
body: Final = {
|
|
"batch": [
|
|
{
|
|
"id": "contract-batch",
|
|
"type": "trace-create",
|
|
"body": {"id": "contract-trace", "name": LANGFUSE_MARKER},
|
|
}
|
|
],
|
|
"metadata": {"batch_size": 1, "public_key": "anything"},
|
|
}
|
|
response: Final = rig.proxy.request(
|
|
"POST",
|
|
LANGFUSE_ROUTE,
|
|
body,
|
|
key=key,
|
|
headers={"Authorization": authorization},
|
|
)
|
|
limited: Final = rig.proxy.request(
|
|
"POST",
|
|
LANGFUSE_ROUTE,
|
|
body,
|
|
key=key,
|
|
headers={"Authorization": authorization},
|
|
)
|
|
|
|
assert response.status_code == 207, response.text
|
|
assert limited.status_code == 429, limited.text
|
|
assert len(rigged.upstream.carrying(LANGFUSE_MARKER)) == 1
|
|
|
|
|
|
def test_rerank_pass_through_forwards_exact_request(rigged: Upstreamed) -> None:
|
|
rig: Final = rigged.rig
|
|
body: Final = {
|
|
"model": "rerank-contract",
|
|
"query": RERANK_MARKER,
|
|
"top_n": 1,
|
|
"documents": [RERANK_MARKER],
|
|
}
|
|
with rig.proxy.scenario() as scenario:
|
|
team: Final = scenario.team(metadata={"allowed_passthrough_routes": [RERANK_ROUTE]})
|
|
user: Final = scenario.user(user_role="internal_user")
|
|
scenario.gateway.post("/team/member_add", {"team_id": team, "member": {"user_id": user, "role": "user"}})
|
|
key: Final = scenario.key(team_id=team, user_id=user, models=[CONFIG_MODEL])
|
|
response: Final = rig.proxy.request("POST", RERANK_ROUTE, body, key=key)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert len(rigged.upstream.carrying(RERANK_MARKER)) == 1
|
|
|
|
|
|
class _SecretDetectionRecorder(CustomLogger):
|
|
def __init__(self) -> None:
|
|
self.logged_messages: object | None = None
|
|
|
|
async def async_log_success_event(
|
|
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
|
) -> None:
|
|
self.logged_messages = kwargs.get("messages")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_secret_detection_redacts_the_logged_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from litellm.proxy import proxy_server
|
|
|
|
def upstream(request: Request) -> Reply:
|
|
assert request.target.endswith("/chat/completions")
|
|
assert json.loads(request.body)["model"] == "fake"
|
|
assert json.loads(request.body)["messages"] == [
|
|
{"role": "user", "content": "Hello here is my OPENAI_API_KEY = [REDACTED]"}
|
|
]
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": "chatcmpl-secret-detection",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "fake-model",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"finish_reason": "stop",
|
|
"message": {"role": "assistant", "content": "ok"},
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
recorder: Final = _SecretDetectionRecorder()
|
|
monkeypatch.setattr(litellm, "callbacks", [_ENTERPRISE_SecretDetection(), recorder])
|
|
prompt: Final = "Hello here is my OPENAI_API_KEY = sk-98765"
|
|
|
|
with wire_server(upstream) as wire:
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "fake-model",
|
|
"litellm_params": {"model": "openai/fake", "api_base": wire.url, "api_key": "sk-fake"},
|
|
}
|
|
]
|
|
)
|
|
monkeypatch.setattr(proxy_server, "llm_router", router)
|
|
request: Final = FastAPIRequest(
|
|
scope={
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": "/chat/completions",
|
|
"headers": [(b"content-type", b"application/json")],
|
|
"query_string": b"",
|
|
}
|
|
)
|
|
request._url = URL(url="/chat/completions")
|
|
|
|
async def return_body() -> bytes:
|
|
return json.dumps(
|
|
{"model": "fake-model", "messages": [{"role": "user", "content": prompt}]}
|
|
).encode()
|
|
|
|
request.body = return_body
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.proxy_server import chat_completion
|
|
|
|
await chat_completion(
|
|
request=request,
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-fake", token="hashed_sk-fake"),
|
|
fastapi_response=Response(),
|
|
)
|
|
for _ in range(100):
|
|
if recorder.logged_messages is not None:
|
|
break
|
|
await asyncio.sleep(0)
|
|
|
|
assert recorder.logged_messages == [
|
|
{"role": "user", "content": "Hello here is my OPENAI_API_KEY = [REDACTED]"}
|
|
]
|
|
|
|
|
|
def _spend_row(marker: Canary, since: datetime) -> str:
|
|
rows: Final = eventually(
|
|
lambda: read_rows(
|
|
'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE "startTime" >= %s AND proxy_server_request::text LIKE %s',
|
|
(since.astimezone(UTC).replace(tzinfo=None) - SLACK, f"%{marker.core}%"),
|
|
),
|
|
lambda found: len(found) == 1,
|
|
seconds=70,
|
|
)
|
|
return str(rows[0]["request_id"])
|
|
|
|
|
|
@pytest.mark.timeout(240) # full S1/S2 walk: every table and ~400 GET routes as two callers
|
|
@pytest.mark.parametrize("outcome", ["success", "upstream_401"])
|
|
@pytest.mark.parametrize("slot", SLOTS)
|
|
def test_upstream_credential_reaches_only_its_upstream(
|
|
rigged: Upstreamed, slot: str, outcome: Outcome, request: pytest.FixtureRequest
|
|
) -> None:
|
|
rig: Final = rigged.rig
|
|
credential: Final = rigged.canaries[slot]
|
|
marker: Final = canary(MARKER)
|
|
text: Final = f"slot {slot} {marker.value}" + (f" {UPSTREAM_REJECT}" if outcome == "upstream_401" else "")
|
|
started: Final = datetime.now(UTC)
|
|
with rig.proxy.scenario() as scenario:
|
|
caller: Final = _caller(scenario)
|
|
response: Final = _send(rig, slot, caller.key, text)
|
|
assert response.status_code == (200 if outcome == "success" else 401), response.text
|
|
delivered: Final = rigged.upstream.carrying(marker.core)
|
|
assert [received.headers.get("authorization") for received in delivered] == [f"Bearer {credential.value}"], (
|
|
f"Positive control: the upstream never received the {slot} canary"
|
|
)
|
|
request_id: Final = _spend_row(marker, started)
|
|
delivers_to_sink: Final = not (slot == "H1" and outcome == "upstream_401")
|
|
if delivers_to_sink:
|
|
settle(rig, request_id, marker)
|
|
|
|
report: Final = sweep_all(
|
|
rig.proxy,
|
|
(marker, *rigged.canaries.values()),
|
|
responses=(response,),
|
|
sinks={name: sink.requests() for name, sink in rig.sinks.items()},
|
|
ids={
|
|
"request_id": request_id,
|
|
"team_id": caller.team_id,
|
|
"user_id": caller.user_id,
|
|
"vector_store_id": VECTOR_STORE_ID,
|
|
"search_tool_name": SEARCH_TOOL,
|
|
"model": CONFIG_MODEL,
|
|
"model_id": rig.model_id,
|
|
},
|
|
callers=caller.callers(rig),
|
|
own_headers=rig.own_headers,
|
|
since=started,
|
|
)
|
|
record_route_sweep(report.routes, request.node.nodeid)
|
|
assert_marker_seen(report, {"S2": f"GET /spend/logs?request_id={request_id} as admin -> 200"})
|
|
assert_marker_seen(
|
|
report,
|
|
{
|
|
"S1": "LiteLLM_SpendLogs.proxy_server_request",
|
|
"S2": f"GET /spend/logs/ui/{request_id} as admin -> 200",
|
|
**({"S3": "response[0] POST"} if outcome == "success" else {}),
|
|
**({"S4": f"{GENERIC_SINK}["} if delivers_to_sink else {}),
|
|
},
|
|
)
|
|
assert_no_hits(report.credential_hits(), f"slot {slot}, {outcome}")
|