mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* feat(lens): consume the independent Lens service and shared UI * fix(lens): keep embedded setup stable and vendor UI before builds * chore(lens): pin embedded UI provenance to published Lens source * fix(lens): complete embedded UI extraction across CI builds * docs(lens): record latest main and removal-boundary validation * fix(lens): wire external chart credentials and ingestion modes * test(lens): cover adapter failures and fix extraction CI * docs(lens): refresh bundled chart setup instructions * test(lens): restore gateway adapter test package marker * build(lens): pin clean-cut UI and supported storage chart * test(ui): await guardrail scope menu before checking options * perf(lens): omit unused trace-team lookup from product requests * docs(lens): record final-image adapter permission checks * test(lens): assert delegated investigation authorization * refactor(lens): remove copied trace runtime and preserve spend logging * test(lens): verify independent gateway image upgrades and outages * test(lens): pin the chart qualification database image * fix(lens): update embedded UI metadata filters * test: wait for Lens chart pod identity convergence * fix(e2e): select Lens chart pods by deployment ownership * fix(e2e): select Helm migration ownership for Lens upgrades * test(lens): retain migration Jobs through rollout assertions * docs(helm): require existing database for migration hooks * test(lens): qualify release boundaries with embedded navigation update * docs(lens): record browser qualification for embedded tabs * fix(lens): remove unrelated CI and guardrail test changes * test(lens): give qualification tenants unique key aliases * feat(lens): embed the latest canonical Lens interface * fix(lens): qualify split inference through the gateway * fix(lens): preserve scoped auth and isolate delegated credentials * chore(lens): remove unrelated documentation and lint changes * fix(lens): bound concurrent forwarding buffers through response delivery * fix(lens): preserve OAuth2 dispatch without inference policies * chore(lens): adopt current shared onboarding UI * fix(lens): refresh shared setup UI and review context * fix(lens): bound uploads before gateway authentication * docs(lens): remove extraction evidence from the gateway repo * docs(lens): refresh shared setup prompt for paired releases
199 lines
8.3 KiB
Python
199 lines
8.3 KiB
Python
import asyncio
|
|
import json
|
|
from collections.abc import Mapping
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import JsonValue
|
|
|
|
from litellm.tracing.errors import TraceChanged
|
|
from litellm.tracing.generated.types import AllQueryScope, TraceScope
|
|
from litellm.tracing.remote import LensConnection, RemoteTraceStore, bounded_response
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url", ("", "ftp://lens", "http://", "http://user:secret@lens", "https://lens?q=1", "https://lens/#x")
|
|
)
|
|
def test_service_url_rejects_unsupported_or_credential_bearing_destinations(url: str) -> None:
|
|
with pytest.raises(ValueError, match="URL"):
|
|
LensConnection.from_env({"LITELLM_LENS_URL": url, "LITELLM_LENS_SERVICE_TOKEN": "x" * 32})
|
|
|
|
|
|
def test_connection_requires_a_strong_secret_and_preserves_the_configured_prefix() -> None:
|
|
with pytest.raises(ValueError, match="secret"):
|
|
LensConnection.from_env({"LITELLM_LENS_URL": "https://lens", "LITELLM_LENS_SERVICE_TOKEN": "short"})
|
|
connection: Final = LensConnection.from_env(
|
|
{"LITELLM_LENS_URL": "https://lens/prefix/", "LITELLM_LENS_SERVICE_TOKEN": "x" * 32}
|
|
)
|
|
assert connection.url == "https://lens/prefix"
|
|
assert "x" * 32 not in repr(connection)
|
|
|
|
|
|
async def _read_case(
|
|
store: RemoteTraceStore, operation: str, scope: TraceScope, query_scope: AllQueryScope
|
|
) -> tuple[JsonValue, Mapping[str, object]]:
|
|
match operation:
|
|
case "list":
|
|
return (
|
|
await store.list_traces(scope, 10, 20, "next", 17),
|
|
{
|
|
"operation": operation,
|
|
"scope": scope,
|
|
"start_ms": 10,
|
|
"end_ms": 20,
|
|
"cursor": "next",
|
|
"limit": 17,
|
|
},
|
|
)
|
|
case "trace":
|
|
return (
|
|
await store.get_trace("trace", scope, "ref", "next", 17),
|
|
{
|
|
"operation": operation,
|
|
"scope": scope,
|
|
"trace_id": "trace",
|
|
"trace_ref": "ref",
|
|
"cursor": "next",
|
|
"page_size": 17,
|
|
},
|
|
)
|
|
case "span":
|
|
return (
|
|
await store.get_span("trace", "span", scope, "ref"),
|
|
{
|
|
"operation": operation,
|
|
"scope": scope,
|
|
"trace_id": "trace",
|
|
"trace_ref": "ref",
|
|
"span_id": "span",
|
|
},
|
|
)
|
|
case "span_error":
|
|
return (
|
|
await store.get_span_error("trace", "span", scope, "ref", "next"),
|
|
{
|
|
"operation": operation,
|
|
"scope": scope,
|
|
"trace_id": "trace",
|
|
"trace_ref": "ref",
|
|
"span_id": "span",
|
|
"cursor": "next",
|
|
},
|
|
)
|
|
case "sql":
|
|
return (
|
|
json.loads(await store.query_sql("SELECT 1", query_scope, "unused-local-secret")),
|
|
{"operation": operation, "scope": query_scope, "sql": "SELECT 1"},
|
|
)
|
|
case "help":
|
|
return (
|
|
await store.query_help(query_scope, "unused-local-secret"),
|
|
{"operation": operation, "scope": query_scope},
|
|
)
|
|
case _:
|
|
return (
|
|
json.loads(await store.query("trace_agents", {"start_ms": 1})),
|
|
{"operation": operation, "name": "trace_agents", "parameters": {"start_ms": 1}},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("operation", ("list", "trace", "span", "span_error", "sql", "help", "query"))
|
|
async def test_remote_reads_preserve_scope_and_pagination(operation: str) -> None:
|
|
scope: Final = TraceScope(all_teams=0, user_id="owner", team_ids=("team",))
|
|
query_scope: Final = AllQueryScope(kind="all")
|
|
requests: Final = asyncio.Queue[httpx.Request]()
|
|
|
|
def accept(request: httpx.Request) -> httpx.Response:
|
|
requests.put_nowait(request)
|
|
return httpx.Response(200, json={"data": [{"value": "safe"}]})
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens/prefix/", transport=httpx.MockTransport(accept)) as client:
|
|
store: Final = RemoteTraceStore(client)
|
|
result, expected = await _read_case(store, operation, scope, query_scope)
|
|
request: Final = requests.get_nowait()
|
|
assert request.url.path == "/prefix/internal/read"
|
|
assert json.loads(request.content) == json.loads(json.dumps(expected))
|
|
assert result == {"data": [{"value": "safe"}]}
|
|
assert b"unused-local-secret" not in request.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"status,error",
|
|
((400, ValueError), (409, TraceChanged), (413, OverflowError), (503, RuntimeError), (302, RuntimeError)),
|
|
)
|
|
async def test_remote_failures_preserve_public_error_categories_without_leaking_storage_details(
|
|
status: int, error: type[Exception]
|
|
) -> None:
|
|
async with httpx.AsyncClient(
|
|
base_url="http://lens",
|
|
transport=httpx.MockTransport(lambda request: httpx.Response(status, text="private storage credentials")),
|
|
) as client:
|
|
with pytest.raises(error) as failure:
|
|
await RemoteTraceStore(client).get_trace("trace", TraceScope(all_teams=1, user_id="", team_ids=()), "ref")
|
|
assert "private storage credentials" not in str(failure.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_network_failure_is_retryable_without_exposing_the_remote_url() -> None:
|
|
def fail(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("private storage credentials", request=request)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(fail)) as client:
|
|
with pytest.raises(RuntimeError, match="Lens trace storage is unavailable"):
|
|
await RemoteTraceStore(client).query_help(AllQueryScope(kind="all"), "secret")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reads_reject_oversized_responses_and_invalid_json() -> None:
|
|
with pytest.raises(RuntimeError, match="size limit"):
|
|
await bounded_response(httpx.Response(200, content=b"abcd"), 3)
|
|
assert await bounded_response(httpx.Response(200, content=b"abcd"), 4) == b"abcd"
|
|
async with httpx.AsyncClient(
|
|
base_url="http://lens", transport=httpx.MockTransport(lambda request: httpx.Response(200, content=b"{"))
|
|
) as client:
|
|
with pytest.raises(ValueError, match="Invalid Lens response"):
|
|
await RemoteTraceStore(client).query_help(AllQueryScope(kind="all"), "secret")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gateway_cannot_write_arbitrary_tables() -> None:
|
|
def fail(request: httpx.Request) -> httpx.Response:
|
|
raise AssertionError("No network access is allowed for refused writes")
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(fail)) as client:
|
|
store: Final = RemoteTraceStore(client)
|
|
with pytest.raises(ValueError, match="request records and feedback"):
|
|
await store.insert_rows("otel_traces", ())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_records_use_the_internal_service_endpoint() -> None:
|
|
requests: Final = asyncio.Queue[httpx.Request]()
|
|
|
|
def accept(request: httpx.Request) -> httpx.Response:
|
|
requests.put_nowait(request)
|
|
return httpx.Response(204)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(accept)) as client:
|
|
await RemoteTraceStore(client).insert_rows("spend_logs", ({"request_id": "r"},))
|
|
request: Final = requests.get_nowait()
|
|
assert request.url.path == "/internal/spend"
|
|
assert json.loads(request.content) == [{"request_id": "r"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_feedback_rows_use_the_internal_feedback_endpoint() -> None:
|
|
requests: Final = asyncio.Queue[httpx.Request]()
|
|
|
|
def accept(request: httpx.Request) -> httpx.Response:
|
|
requests.put_nowait(request)
|
|
return httpx.Response(204)
|
|
|
|
async with httpx.AsyncClient(base_url="http://lens", transport=httpx.MockTransport(accept)) as client:
|
|
await RemoteTraceStore(client).insert_rows("lens_feedback", ({"TraceId": "t", "Score": 2},))
|
|
request: Final = requests.get_nowait()
|
|
assert request.url.path == "/internal/feedback"
|
|
assert json.loads(request.content) == [{"TraceId": "t", "Score": 2}]
|