mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
* feat: seed Lens dev with configurable load profiles * feat: run Lens UI live through the dev launcher * fix: verify Lens UI startup before seeding * fix(ui): render run timestamps on one compact line The agent runs table printed the long locale form with timezone, which wrapped to two lines per row. Use a fixed-width 24h form with milliseconds that matches the timeline axis, keep the long form in the hover title, and show the timezone once in the column header Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(ui): use the root query client for the Lens demo Drop the demo's nested QueryClient. Cache keys are already partitioned by scope, and the root client now skips retries on 4xx ApiErrors, which covers the demo's not-in-demo and read-only rejections and live 4xx alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: drop unused synthetic_spend reference from query help Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(traces): drop the deeplite fixtures and map every SDK to a logo The deeplite captures predate the example repository and carried synthetic spend rows, which leaked a fixture-only column into the query help SQL and pinned tests to its shape. Replace them with the SDK captures in the ClickHouse round trip and query API tests, and derive the seed tenant lookup from the capture metadata The runs table only knew the two Anthropic framework slugs. Register the slugs the normalizer emits for LangChain, LangGraph, Deep Agents, CrewAI, Google ADK, LlamaIndex, OpenAI Agents, Pydantic AI, Strands, Vercel AI SDK, Codex, Cursor and Copilot, and fall back to the generic agent glyph when a run has no known framework Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(ui): inject the Lens sample through data sources instead of demo checks Components no longer ask whether they are in the demo. The traces source carries live and handoff, navigation state comes from a URL or memory route, and the preview action comes from context instead of onDemo props Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ui): infinite scroll for the agent runs list Replace the Load more button with a sentinel that fetches the next cursor page as the list nears its end. Placeholder rows hold the tail while more runs exist, and a failed page stops auto-loading until Retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ui): keep the whole Lens view in the URL Lens navigation now lives entirely in query params through nuqs: the sample session (demo=true, with a Demo data switch in the header), the open run (trace, trace_ref), the selected step, view and detail section (span, view, span_tab) and the list filters and range (q, agent, status, hours). Any Lens view is a shareable link and the back button walks runs RunView takes its selection injected: the drawer feeds it URL state and the investigations evidence sheet keeps a local one, so a finding's original run never writes step ids into the URL. Leaving the sample session clears every Lens key except the tab so sample ids never point at live data Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * style(traces): cargo fmt captures tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
646 lines
30 KiB
Python
646 lines
30 KiB
Python
import base64
|
|
import gzip
|
|
import json
|
|
import math
|
|
import re
|
|
import time
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from itertools import chain
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
|
|
|
|
from litellm.constants import OTLP_MAX_ATTRIBUTE_VALUE_BYTES
|
|
from litellm.rust_bridge._native import NativeTraceConfig, NativeTraceStorage
|
|
from litellm.rust_bridge.trace.generated.models import ActivityAvailability, LensAccessParams, TraceQueryHelp
|
|
from litellm.rust_bridge.trace.generated.types import TraceScope
|
|
from litellm.rust_bridge.trace.storage import ClickHouseStorage, TraceStorageConfig, span_rows
|
|
from litellm.tracing import Tenant, TraceReceiver, TracingPayloadTooLargeError
|
|
from litellm.tracing.types import SpendLogRecord
|
|
from scripts.seed_tracing_fixtures import (
|
|
TRACE,
|
|
TRACE_FIXTURES,
|
|
FixtureReplay,
|
|
fixture_capture,
|
|
fixture_replays,
|
|
rebase_spend,
|
|
response_pattern,
|
|
spend_fixtures,
|
|
)
|
|
from tests.test_litellm_rust.support.clickhouse import clickhouse_service
|
|
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec
|
|
|
|
pytestmark = pytest.mark.requires_rust_extension
|
|
QUERY_ROWS: Final = TypeAdapter(tuple[dict[str, JsonValue], ...])
|
|
|
|
|
|
class CapturedSpendRow(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
request_id: str
|
|
spend: float
|
|
prompt_tokens: int
|
|
completion_tokens: int
|
|
|
|
|
|
class CapturedSpendQuery(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
data: tuple[CapturedSpendRow, ...]
|
|
|
|
|
|
def _native_storage(database: str, url: str, retention_days: int = 14) -> NativeTraceStorage:
|
|
return NativeTraceStorage(NativeTraceConfig(database, url, retention_days, OTLP_MAX_ATTRIBUTE_VALUE_BYTES))
|
|
|
|
|
|
@pytest.fixture
|
|
def span_row() -> dict[str, JsonValue]:
|
|
return {
|
|
"span_id": "span-1",
|
|
"parent_span_id": "",
|
|
"name": "root",
|
|
"type": "agent",
|
|
"agent": "",
|
|
"framework": "",
|
|
"status": "STATUS_CODE_OK",
|
|
"status_message": "",
|
|
"error_truncated": 0,
|
|
"start_ns": "1000000000",
|
|
"duration_ns": "1000",
|
|
"service": "test",
|
|
"input_preview": "hello",
|
|
"model": "",
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"litellm_request_id": "",
|
|
"team_id": "",
|
|
"api_key_hash": "",
|
|
"user_id": "",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def span_params() -> dict[str, str | int | list[str]]:
|
|
return {"trace_id": "trace-1", "trace_ref": "", "all_teams": 1, "user_id": "", "team_ids": []}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_reader_projects_connection_and_parameters(
|
|
recording_server: RecordingServer, span_row: dict[str, JsonValue], span_params: dict[str, str | int | list[str]]
|
|
) -> None:
|
|
recording_server.enqueue(ResponseSpec(body={"data": [span_row]}))
|
|
url: Final = recording_server.base_url.replace("http://", "http://reader:p%40ss%2Fword%25@")
|
|
storage: Final = _native_storage("trace_test", url + "?database=wrong")
|
|
rows: Final = json.loads(await storage.query("trace_spans", span_params))
|
|
request: Final = recording_server.requests[0]
|
|
parameters: Final = parse_qs(urlsplit(request.path).query)
|
|
assert rows == {"data": [span_row]}
|
|
assert b"o.TraceId = {trace_id:String}" in request.raw_body
|
|
assert parameters["database"] == ["trace_test"]
|
|
assert parameters["param_trace_id"] == ["trace-1"]
|
|
assert parameters["readonly"] == ["1"]
|
|
assert "user" not in parameters
|
|
assert "password" not in parameters
|
|
assert request.headers["authorization"] == "Basic " + base64.b64encode(b"reader:p@ss/word%").decode()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_reader_rejects_success_status_with_embedded_error(
|
|
recording_server: RecordingServer, span_params: dict[str, str | int | list[str]]
|
|
) -> None:
|
|
recording_server.enqueue(ResponseSpec(body={"data": [], "exception": "query failed"}))
|
|
storage: Final = _native_storage("trace_test", recording_server.base_url)
|
|
with pytest.raises(RuntimeError, match="invalid or failed JSON"):
|
|
await storage.query("trace_spans", span_params)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reader_rejects_arbitrary_sql_before_sending(recording_server: RecordingServer) -> None:
|
|
recording_server.expected_requests = 0
|
|
storage: Final = _native_storage("trace_test", recording_server.base_url)
|
|
with pytest.raises(ValueError, match="unknown ClickHouse read query"):
|
|
await storage.query("SELECT 1", {})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_binding_rejects_invalid_database() -> None:
|
|
with pytest.raises(ValueError, match=r"database.*retention"):
|
|
NativeTraceConfig("db; DROP DATABASE default", "http://localhost:8123", 14, OTLP_MAX_ATTRIBUTE_VALUE_BYTES)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_binding_rejects_non_positive_retention() -> None:
|
|
with pytest.raises(ValueError, match=r"database.*retention"):
|
|
NativeTraceConfig("traces", "http://localhost:8123", 0, OTLP_MAX_ATTRIBUTE_VALUE_BYTES)
|
|
|
|
|
|
def test_invalid_url_error_does_not_expose_credentials() -> None:
|
|
with pytest.raises(RuntimeError, match="invalid ClickHouse HTTP URL") as error:
|
|
NativeTraceConfig("traces", "secret://writer:password@example.com", 7, OTLP_MAX_ATTRIBUTE_VALUE_BYTES)
|
|
assert "password" not in str(error.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_from_env_reads_with_clickhouse_url(
|
|
recording_server: RecordingServer, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
recording_server.enqueue(ResponseSpec(body={"data": []}))
|
|
monkeypatch.setenv("CLICKHOUSE_URL", recording_server.base_url)
|
|
monkeypatch.delenv("CLICKHOUSE_READER_URL", raising=False)
|
|
scope: Final[TraceScope] = {"all_teams": 1, "user_id": "", "team_ids": ()}
|
|
page: Final = await TraceReceiver.from_env().list_traces(scope, 0, 1)
|
|
assert page == {"data": (), "next_cursor": None}
|
|
assert len(recording_server.requests) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_setup_uses_configured_retention(recording_server: RecordingServer) -> None:
|
|
recording_server.expected_requests = None
|
|
storage: Final = _native_storage("trace_test", recording_server.base_url, 7)
|
|
await storage.ensure_schema()
|
|
ttl_statements: Final = tuple(
|
|
request.raw_body for request in recording_server.requests if b"MODIFY TTL" in request.raw_body
|
|
)
|
|
assert len(ttl_statements) == 3
|
|
assert all(b"INTERVAL 7 DAY" in statement for statement in ttl_statements)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_setup_uses_writer_credentials_and_rejects_failed_statement(
|
|
recording_server: RecordingServer,
|
|
) -> None:
|
|
recording_server.expected_requests = 2
|
|
recording_server.enqueue(ResponseSpec(body=""))
|
|
recording_server.enqueue(ResponseSpec(status=403, body="denied"))
|
|
writer_url: Final = recording_server.base_url.replace("http://", "http://writer:p%40ss%2Fword%25@")
|
|
storage: Final = _native_storage("trace_test", writer_url + "?database=wrong&readonly=1", 7)
|
|
with pytest.raises(RuntimeError, match="schema setup failed with HTTP status 403"):
|
|
await storage.ensure_schema()
|
|
assert len(recording_server.requests) == 2
|
|
assert recording_server.requests[0].raw_body.startswith(b"CREATE DATABASE IF NOT EXISTS")
|
|
assert recording_server.requests[1].raw_body.startswith(b"CREATE TABLE IF NOT EXISTS")
|
|
assert "readonly" not in parse_qs(urlsplit(recording_server.requests[0].path).query)
|
|
assert (
|
|
recording_server.requests[0].headers["authorization"]
|
|
== "Basic " + base64.b64encode(b"writer:p@ss/word%").decode()
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insert_encodes_and_sends_rows(recording_server: RecordingServer) -> None:
|
|
recording_server.enqueue(ResponseSpec(body=""))
|
|
storage: Final = _native_storage("trace_test", recording_server.base_url)
|
|
before: Final = time.time_ns() // 1_000_000
|
|
await storage.insert_rows("otel_traces", [{"Timestamp": 1_234_567_890, "Input": "hello", "EngineReceivedMs": -1}])
|
|
after: Final = time.time_ns() // 1_000_000
|
|
request: Final = recording_server.requests[0]
|
|
row: Final = json.loads(gzip.decompress(request.raw_body))
|
|
assert before <= row["EngineReceivedMs"] <= after
|
|
assert row == {
|
|
"Input": "hello",
|
|
"Timestamp": "1970-01-01T00:00:01.23456789Z",
|
|
"EngineReceivedMs": row["EngineReceivedMs"],
|
|
}
|
|
assert parse_qs(urlsplit(request.path).query)["query"] == [
|
|
"INSERT INTO `trace_test`.otel_traces FORMAT JSONEachRow"
|
|
]
|
|
assert request.headers["content-encoding"] == "gzip"
|
|
|
|
|
|
def _resource_export(attribute_bytes: int, span_count: int, groups: int = 1) -> bytes:
|
|
span: Final = {
|
|
"traceId": "01" * 16,
|
|
"spanId": "02" * 8,
|
|
"name": "shared-resource",
|
|
"startTimeUnixNano": "1",
|
|
"endTimeUnixNano": "2",
|
|
}
|
|
resource: Final = {
|
|
"resource": {
|
|
"attributes": [
|
|
{"key": "shared", "value": {"stringValue": "x" * attribute_bytes}},
|
|
{"key": "litellm.team_id", "value": {"stringValue": "spoofed"}},
|
|
]
|
|
},
|
|
"scopeSpans": [
|
|
{
|
|
"scope": {"name": "scope-" * 32, "version": "v" * 128},
|
|
"spans": [{**span, "spanId": f"{index + 1:016x}"} for index in range(span_count)],
|
|
}
|
|
],
|
|
}
|
|
return json.dumps({"resourceSpans": [resource] * groups}).encode()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resource_fanout_reaches_insert_with_identical_values(recording_server: RecordingServer) -> None:
|
|
body: Final = _resource_export(16 * 1024, 1024)
|
|
receiver: Final = TraceReceiver(ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test")))
|
|
tenant: Final = Tenant("team-a", "key-a", "org-a")
|
|
assert await receiver.ingest(body, "application/json", None, tenant) == 1024
|
|
encoded: Final = gzip.decompress(recording_server.requests[0].raw_body)
|
|
actual: Final = tuple(json.loads(line) for line in encoded.splitlines())
|
|
expected: Final = span_rows(body, "application/json", tenant)
|
|
assert len(encoded) < 64 * 1024 * 1024
|
|
assert tuple({key: value for key, value in row.items() if key != "EngineReceivedMs"} for row in actual) == tuple(
|
|
{**row, "Timestamp": "1970-01-01T00:00:00.000000001Z"} for row in expected
|
|
)
|
|
assert len({row["EngineReceivedMs"] for row in actual}) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shared_resource_still_hits_insert_limit_before_transport(recording_server: RecordingServer) -> None:
|
|
recording_server.expected_requests = 0
|
|
body: Final = _resource_export(64 * 1024, 1024)
|
|
receiver: Final = TraceReceiver(ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test")))
|
|
with pytest.raises(TracingPayloadTooLargeError, match="encoded size limit"):
|
|
await receiver.ingest(body, "application/json", None, Tenant("team-a", "key-a"))
|
|
assert recording_server.requests == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insert_validates_values_without_pydantic_copy(recording_server: RecordingServer) -> None:
|
|
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
|
|
invalid: Final = object()
|
|
with pytest.raises(ValueError, match=type(invalid).__name__):
|
|
await storage.insert_rows("otel_traces", [{"ResourceAttributes": invalid}])
|
|
attributes: Final = MappingProxyType({"service.name": "trace-test"})
|
|
await storage.insert_rows(
|
|
"otel_traces",
|
|
(MappingProxyType({"Timestamp": 1, "ResourceAttributes": attributes, "SpanAttributes": attributes}),),
|
|
)
|
|
stored: Final = json.loads(gzip.decompress(recording_server.requests[0].raw_body))
|
|
assert stored["Timestamp"] == "1970-01-01T00:00:00.000000001Z"
|
|
assert stored["ResourceAttributes"] == attributes
|
|
assert stored["SpanAttributes"] == attributes
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("role", "user_id", "expected_status"),
|
|
(
|
|
("proxy_admin", None, 200),
|
|
("proxy_admin_viewer", None, 200),
|
|
("internal_user", "user", 200),
|
|
("internal_user", None, 403),
|
|
),
|
|
)
|
|
def test_trace_sql_endpoint_enforces_ownership_and_preserves_clickhouse_envelope(
|
|
recording_server: RecordingServer, role: str, user_id: str | None, expected_status: int
|
|
) -> None:
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.authorization_dependencies import get_log_team_lookup
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
|
|
|
|
envelope: Final = {
|
|
"meta": [{"name": "answer", "type": "UInt8"}],
|
|
"data": [{"answer": 42}],
|
|
"rows": 1,
|
|
"statistics": {"elapsed": 0.01, "rows_read": 1, "bytes_read": 1},
|
|
}
|
|
recording_server.expected_requests = 12 if expected_status == 200 else 0
|
|
if expected_status == 200:
|
|
for _ in range(11):
|
|
recording_server.enqueue(ResponseSpec(body=""))
|
|
recording_server.enqueue(ResponseSpec(body=envelope))
|
|
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[provide_trace_query_secret] = lambda: "test-master-secret"
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=role, user_id=user_id, token="test")
|
|
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
|
|
|
|
async def permitted_teams(auth: UserAPIKeyAuth) -> tuple[str, ...]:
|
|
return ()
|
|
|
|
app.dependency_overrides[get_log_team_lookup] = lambda: permitted_teams
|
|
with TestClient(app) as client:
|
|
result: Final = client.post("/v1/traces/query", json={"sql": "SELECT 42 AS answer"})
|
|
assert result.status_code == expected_status, result.text
|
|
if expected_status == 403:
|
|
assert result.json() == {"detail": "Not allowed to view logs"}
|
|
return
|
|
assert result.json() == envelope
|
|
assert recording_server.requests[-1].raw_body == b"SELECT 42 AS answer"
|
|
assert client.post("/v1/traces/query", json={"sql": " "}).status_code == 400
|
|
assert client.post("/v1/traces/query", json={}).status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize("discovery_fails", (False, True))
|
|
def test_trace_help_endpoint_runs_native_schema_and_metadata_discovery(
|
|
recording_server: RecordingServer, discovery_fails: bool
|
|
) -> None:
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
|
|
|
|
recording_server.expected_requests = 17
|
|
for _ in range(11):
|
|
recording_server.enqueue(ResponseSpec(body=""))
|
|
for response in (
|
|
{"data": [{"name": "Model", "type": "String"}]},
|
|
{"data": []},
|
|
{"data": []},
|
|
):
|
|
recording_server.enqueue(ResponseSpec(body=response))
|
|
metadata: Final = (
|
|
ResponseSpec(status=503, body="discovery failed")
|
|
if discovery_fails
|
|
else ResponseSpec(body={"data": [{"metadata": '{"custom": {"label": "hello"}}'}]})
|
|
)
|
|
recording_server.enqueue(metadata)
|
|
recording_server.enqueue(ResponseSpec(body={"data": [{"key": "custom.span"}]}))
|
|
recording_server.enqueue(ResponseSpec(body={"data": [{"key": "custom.resource"}]}))
|
|
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[provide_trace_query_secret] = lambda: "test-master-secret"
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role="proxy_admin", token="test")
|
|
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
|
|
with TestClient(app) as client:
|
|
result: Final = client.get("/v1/traces/query/help")
|
|
assert result.status_code == 200, result.text
|
|
body: Final = result.json()
|
|
assert body["guide"].startswith("Trace SQL query guide")
|
|
assert body["tables"][0]["columns"] == [{"name": "Model", "type": "String"}]
|
|
if discovery_fails:
|
|
assert body["metadata"]["fields"] == []
|
|
assert "503" in body["metadata"]["error"]
|
|
else:
|
|
assert "JSONExtractRaw(metadata, 'custom', 'label')" in body["guide"]
|
|
assert body["metadata"]["fields"][1] == {
|
|
"path": ["custom", "label"],
|
|
"types": ["string"],
|
|
"expression": "JSONExtractRaw(metadata, 'custom', 'label')",
|
|
}
|
|
assert body["attributes"][0]["fields"][0]["expression"] == "SpanAttributes['custom.span']"
|
|
assert body["attributes"][1]["fields"][0]["expression"] == "ResourceAttributes['custom.resource']"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("clickhouse_status", "body", "expected_status"),
|
|
(
|
|
(400, b"ClickHouse rejected the query", 400),
|
|
(404, b"ClickHouse rejected the query", 400),
|
|
(500, b"ClickHouse rejected the query", 503),
|
|
(503, b"ClickHouse rejected the query", 503),
|
|
(200, b'{"data":[]}', 503),
|
|
),
|
|
)
|
|
def test_trace_sql_endpoint_distinguishes_query_errors_from_reader_failures(
|
|
recording_server: RecordingServer, clickhouse_status: int, body: bytes, expected_status: int
|
|
) -> None:
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
|
|
|
|
recording_server.expected_requests = 13
|
|
for _ in range(11):
|
|
recording_server.enqueue(ResponseSpec(body=""))
|
|
recording_server.enqueue(ResponseSpec(status=clickhouse_status, body=body))
|
|
envelope: Final = {
|
|
"meta": [{"name": "answer", "type": "UInt8"}],
|
|
"data": [{"answer": 42}],
|
|
"rows": 1,
|
|
"statistics": {"elapsed": 0.01, "rows_read": 1, "bytes_read": 1},
|
|
}
|
|
recording_server.enqueue(ResponseSpec(body=envelope))
|
|
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[provide_trace_query_secret] = lambda: "test-master-secret"
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role="proxy_admin", token="test")
|
|
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
|
|
with TestClient(app) as client:
|
|
failed: Final = client.post("/v1/traces/query", json={"sql": "SELEC 42"})
|
|
assert failed.status_code == expected_status, failed.text
|
|
recovered: Final = client.post("/v1/traces/query", json={"sql": "SELECT 42 AS answer"})
|
|
assert recovered.status_code == 200, recovered.text
|
|
assert recovered.json() == envelope
|
|
assert recording_server.requests[-2].raw_body == b"SELEC 42"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trace_receiver_reads_with_only_one_clickhouse_url(
|
|
recording_server: RecordingServer,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
span_row: dict[str, JsonValue],
|
|
span_params: dict[str, str | int | list[str]],
|
|
) -> None:
|
|
monkeypatch.setenv("CLICKHOUSE_URL", recording_server.base_url)
|
|
monkeypatch.setenv("CLICKHOUSE_DATABASE", "trace_test")
|
|
monkeypatch.delenv("CLICKHOUSE_READER_URL", raising=False)
|
|
recording_server.enqueue(ResponseSpec(body={"data": [span_row]}))
|
|
receiver: Final = TraceReceiver.from_env()
|
|
trace: Final = await receiver.get_trace("trace-1", {"all_teams": 1, "user_id": "", "team_ids": ()}, "ref")
|
|
assert trace is not None
|
|
assert trace["spans"][0]["span_id"] == span_row["span_id"]
|
|
assert trace["spans"][0]["duration_ms"] == int(str(span_row["duration_ns"])) / 1_000_000
|
|
parameters: Final = parse_qs(urlsplit(recording_server.requests[0].path).query)
|
|
assert parameters["database"] == ["trace_test"]
|
|
assert parameters["readonly"] == ["1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lens_read_uses_the_shared_native_query_and_returns_typed_rows(
|
|
recording_server: RecordingServer,
|
|
) -> None:
|
|
recording_server.enqueue(ResponseSpec(body={"data": [{"traces": 0, "requests": 1}]}))
|
|
storage: Final = ClickHouseStorage(TraceStorageConfig(recording_server.base_url, "trace_test"))
|
|
rows: Final = await storage.lens_availability(LensAccessParams(all_teams=0, team="team-a", key_hash="key-a"))
|
|
assert rows == (ActivityAvailability(traces=False, requests=True),)
|
|
parameters: Final = parse_qs(urlsplit(recording_server.requests[0].path).query)
|
|
assert parameters["param_all_teams"] == ["0"]
|
|
assert parameters["param_team"] == ["team-a"]
|
|
assert parameters["param_key_hash"] == ["key-a"]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SeededTraceAPI:
|
|
client: TestClient
|
|
storage: ClickHouseStorage
|
|
spends: tuple[SpendLogRecord, ...]
|
|
help: TraceQueryHelp
|
|
|
|
def query_example(self, name: str) -> tuple[dict[str, JsonValue], ...]:
|
|
example: Final = next(example for example in self.help.examples if example.name == name)
|
|
response: Final = self.client.post("/v1/traces/query", json={"sql": example.sql})
|
|
assert response.status_code == 200, response.text
|
|
return QUERY_ROWS.validate_python(response.json()["data"])
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_trace_api(clickhouse_url: str) -> Iterator[SeededTraceAPI]:
|
|
from scripts.seed_tracing_fixtures import (
|
|
TRACE_FIXTURES,
|
|
fixture_replays,
|
|
rebase_spend,
|
|
)
|
|
|
|
spends: Final = dict(spend_fixtures())["openai_agents_swarm"]
|
|
pattern: Final = re.compile("|".join(re.escape(row["response_id"]) for row in spends))
|
|
replays: Final = fixture_replays(TRACE_FIXTURES, time.time_ns() // 1_000_000, "query-api", pattern)
|
|
swarm: Final = next(replay for replay in replays if replay.name == "openai_agents_swarm")
|
|
rebased: Final = rebase_spend(spends, swarm.offset_ms, swarm.namespace, pattern)
|
|
stamped: Final[tuple[SpendLogRecord, ...]] = tuple(
|
|
{**row, "team_id": "team-a", "api_key": "fixture-key", "user": "fixture-user"} for row in rebased
|
|
)
|
|
yield from _fixture_trace_api(clickhouse_url, replays, stamped)
|
|
|
|
|
|
def _fixture_trace_api(
|
|
clickhouse_url: str, replays: tuple[FixtureReplay, ...], stamped: tuple[SpendLogRecord, ...]
|
|
) -> Iterator[SeededTraceAPI]:
|
|
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.tracing_endpoints import provide_receiver, provide_trace_query_secret, router
|
|
|
|
storage: Final = ClickHouseStorage(TraceStorageConfig(clickhouse_url, "trace_test"))
|
|
app: Final = FastAPI()
|
|
app.include_router(router)
|
|
app.dependency_overrides[provide_trace_query_secret] = lambda: "fixture-secret"
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
|
user_role=LitellmUserRoles.PROXY_ADMIN, team_id="team-a", token="fixture-key", user_id="fixture-user"
|
|
)
|
|
app.dependency_overrides[provide_receiver] = lambda: TraceReceiver(storage)
|
|
with TestClient(app) as client:
|
|
assert client.portal is not None
|
|
client.portal.call(storage.ensure_schema)
|
|
ingested: Final = tuple(client.post("/v1/traces", json=replay.export) for replay in replays)
|
|
for result in ingested:
|
|
assert result.status_code == 200, result.text
|
|
client.portal.call(storage.insert_rows, "spend_logs", stamped)
|
|
response: Final = client.get("/v1/traces/query/help")
|
|
assert response.status_code == 200, response.text
|
|
yield SeededTraceAPI(client, storage, stamped, TraceQueryHelp.model_validate(response.json()))
|
|
|
|
|
|
def test_fixture_backed_help_examples_execute_through_query_api(seeded_trace_api: SeededTraceAPI) -> None:
|
|
api: Final = seeded_trace_api
|
|
assert {table.name for table in api.help.tables} == {"otel_traces", "spend_logs", "agent_traces_by_key"}
|
|
assert api.help.metadata.error is None
|
|
assert api.help.metadata.sampled_rows == len(api.spends)
|
|
assert any(field.path == ("fixture_capture", "name") for field in api.help.metadata.fields)
|
|
for example in api.help.examples:
|
|
api.query_example(example.name)
|
|
records: Final = api.query_example("Recent spend records")
|
|
assert {str(row["request_id"]) for row in records} == {row["request_id"] for row in api.spends}
|
|
total: Final = sum(row["spend"] or 0 for row in api.spends)
|
|
recorded: Final = api.query_example("Recorded spend by trace")
|
|
assert len(recorded) == 1
|
|
assert recorded[0]["trace_id"] == api.spends[0]["trace_id"]
|
|
assert int(str(recorded[0]["requests"])) == len(api.spends)
|
|
assert math.isclose(float(str(recorded[0]["recorded_spend"])), total)
|
|
detail: Final = api.client.get(f"/v1/traces/{api.spends[0]['trace_id']}")
|
|
assert detail.status_code == 200, detail.text
|
|
assert math.isclose(TRACE.validate_json(detail.content)["summary"]["spend"] or 0, total)
|
|
unmatched: Final = api.query_example("LLM spans without a direct spend match")
|
|
assert unmatched
|
|
assert all(row["TraceId"] != api.spends[0]["trace_id"] for row in unmatched)
|
|
unpriced: Final = api.client.get(f"/v1/traces/{unmatched[0]['TraceId']}")
|
|
assert unpriced.status_code == 200, unpriced.text
|
|
assert unpriced.json()["summary"]["spend"] is None
|
|
|
|
|
|
@pytest.mark.parametrize("spend", (None, 0.0, 0.125), ids=("unknown", "free", "paid"))
|
|
def test_query_model_totals_deduplicate_and_preserve_unknown_cost(
|
|
seeded_trace_api: SeededTraceAPI, spend: float | None
|
|
) -> None:
|
|
api: Final = seeded_trace_api
|
|
original: Final = api.spends[0]
|
|
replacement: Final[SpendLogRecord] = {**original, "end_time": original["end_time"] + 1, "spend": spend}
|
|
assert api.client.portal is not None
|
|
api.client.portal.call(api.storage.insert_rows, "spend_logs", (replacement,))
|
|
totals: Final = api.query_example("Spend and tokens by model")
|
|
row: Final = next(row for row in totals if row["model"] == original["model"])
|
|
model_spends: Final = tuple(row for row in api.spends if row["model"] == original["model"])
|
|
assert int(str(row["requests"])) == len(model_spends)
|
|
assert int(str(row["input_tokens"])) == sum(row["prompt_tokens"] for row in model_spends)
|
|
assert int(str(row["output_tokens"])) == sum(row["completion_tokens"] for row in model_spends)
|
|
assert int(str(row["unknown_cost_requests"])) == int(spend is None)
|
|
if spend is None:
|
|
assert row["spend"] is None
|
|
else:
|
|
assert math.isclose(
|
|
float(str(row["spend"])), sum(row["spend"] or 0 for row in model_spends) - (original["spend"] or 0) + spend
|
|
)
|
|
|
|
|
|
def test_query_correlation_requires_key_or_user_ownership_within_a_team(seeded_trace_api: SeededTraceAPI) -> None:
|
|
api: Final = seeded_trace_api
|
|
original: Final = api.spends[0]
|
|
unrelated: Final[SpendLogRecord] = {
|
|
**original,
|
|
"request_id": "unrelated-request",
|
|
"api_key": "other-key",
|
|
"user": "other-user",
|
|
}
|
|
assert api.client.portal is not None
|
|
api.client.portal.call(api.storage.insert_rows, "spend_logs", (unrelated,))
|
|
matches: Final = api.query_example("Traces correlated with LLM call metadata")
|
|
assert {str(row["request_id"]) for row in matches} == {row["request_id"] for row in api.spends}
|
|
assert all(row["request_id"] != unrelated["request_id"] for row in matches)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def captured_trace_api() -> Iterator[SeededTraceAPI]:
|
|
captures: Final = spend_fixtures()
|
|
originals: Final = tuple(chain.from_iterable(rows for _, rows in captures))
|
|
pattern: Final = response_pattern(originals)
|
|
replays: Final = fixture_replays(TRACE_FIXTURES, time.time_ns() // 1_000_000, "captured-api", pattern)
|
|
by_name: Final = MappingProxyType(dict(captures))
|
|
paired: Final = tuple(
|
|
rebase_spend(by_name[replay.name], replay.offset_ms, replay.namespace, pattern)
|
|
for replay in replays
|
|
if replay.name in by_name
|
|
)
|
|
stamped: Final[tuple[SpendLogRecord, ...]] = tuple(
|
|
{**row, "team_id": "team-a", "api_key": "fixture-key", "user": "fixture-user"}
|
|
for row in chain.from_iterable(paired)
|
|
)
|
|
with clickhouse_service() as url:
|
|
yield from _fixture_trace_api(url, replays, stamped)
|
|
|
|
|
|
@pytest.mark.parametrize("name", tuple(name for name, _ in spend_fixtures()))
|
|
def test_captured_sdk_cost_survives_seeding_and_is_queryable(name: str, captured_trace_api: SeededTraceAPI) -> None:
|
|
api: Final = captured_trace_api
|
|
rows: Final = tuple(row for row in api.spends if fixture_capture("", row).name == name)
|
|
assert rows
|
|
capture: Final = fixture_capture(name, rows[0])
|
|
response: Final = api.client.get(f"/v1/traces/{capture.trace_id}")
|
|
assert response.status_code == 200, response.text
|
|
detail: Final = TRACE.validate_json(response.content)
|
|
original: Final = span_rows((TRACE_FIXTURES / f"{name}.json").read_bytes(), "application/json")
|
|
assert detail["summary"]["span_count"] == len(original)
|
|
if capture.spend_linked and capture.spend_complete:
|
|
assert detail["summary"]["spend"] is not None
|
|
assert math.isclose(detail["summary"]["spend"], sum(row["spend"] or 0 for row in rows))
|
|
else:
|
|
assert detail["summary"]["spend"] is None
|
|
query: Final = api.client.post(
|
|
"/v1/traces/query",
|
|
json={
|
|
"sql": "SELECT request_id, spend, prompt_tokens, completion_tokens FROM spend_logs FINAL "
|
|
f"WHERE JSONExtractString(metadata, 'fixture_capture', 'name') = '{name}' LIMIT 100"
|
|
},
|
|
)
|
|
assert query.status_code == 200, query.text
|
|
records: Final = CapturedSpendQuery.model_validate_json(query.content).data
|
|
assert {row.request_id for row in records} == {row["request_id"] for row in rows}
|
|
assert math.isclose(sum(row.spend for row in records), sum(row["spend"] or 0 for row in rows))
|
|
assert sum(row.prompt_tokens for row in records) == sum(row["prompt_tokens"] for row in rows)
|
|
assert sum(row.completion_tokens for row in records) == sum(row["completion_tokens"] for row in rows)
|