mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-17 04:34:25 +00:00
* feat(lens): add dataset case and size limits * feat(lens): add dataset, case and build models * feat(lens): build dataset cases from traces, findings and text * feat(lens): store dataset revisions insert-only * feat(lens): add dataset routes for build, save, export and eval cases * feat(lens): mount the dataset router before lens routes * feat(lens): add LiteLLM_LensDataset table * feat(lens): add LiteLLM_LensDataset table to proxy schema * feat(lens): add LiteLLM_LensDataset table to extras schema * feat(lens): add migration that creates the dataset table * test(lens): cover dataset case building, dedupe and limits * test(lens): cover dataset revisions, conflicts and eval cases * chore(ui): regenerate API types for lens datasets * feat(lens): add dataset UI types * feat(lens): add datasets API client * feat(lens): add dataset query and mutation hooks * feat(lens): add case selection and expected edit logic * test(lens): cover case selection and expected edits * feat(lens): add the add to dataset dialog * test(lens): cover saving picked cases from the dialog * feat(lens): add datasets list * feat(lens): add dataset detail with revisions and export * test(lens): cover editing, revisions and export in datasets tab * feat(lens): expose datasets on the lens API * feat(lens): add in-memory datasets for demo mode * feat(lens): wire demo datasets into the demo lens API * feat(lens): add optional lens API hook * feat(lens): add optional onboarding hook * feat(lens): add datasets tab and dataset routing * feat(lens): show the datasets tab * feat(lens): add to dataset from the trace header * feat(lens): add a single turn to a dataset from a step * feat(lens): add finding evidence to a dataset * docs(lens): add datasets screenshots for the PR * test(lens): cover dataset revision storage against Postgres * test(lens): cover dataset trace paging, findings and route errors * test(lens): cover dataset build fallbacks and no_content skips * fix(lens): register the dataset table for postgres span names * feat(lens): add invalid skip reason for unparseable case lines * fix(lens): keep valid JSONL cases, provenance and size limits; stop reading past the case cap * test(lens): cover malformed JSONL, re-import provenance, size fields and early cap * chore(ui): regenerate API types for the invalid skip reason * feat(lens): show text for the invalid skip reason * feat(lens): render datasets in the same inspector table as investigations * test(lens): open a dataset by clicking its table row * docs(lens): update the datasets list screenshot * style(lens): format the datasets table * fix(lens): normalize JSON text span input and output into messages when building cases * test(lens): cover JSON text span normalization for dataset cases * Revert "fix(lens): normalize JSON text span input and output into messages when building cases" This reverts commit 29c3e34962fa12427dbd516c08f2599db997a905. * fix(traces): normalize agent assistant summaries into UI messages * feat(lens): add dataset case view helpers built on the trace parsers * test(lens): cover dataset case view helpers * feat(lens): show dataset cases in an inspector table * feat(lens): open a dataset case in a side panel with trace message cards * feat(lens): rebuild the dataset page header and layout * feat(lens): keep the open dataset case in the URL * test(lens): drive dataset edits through the case table and panel * docs(lens): update dataset view screenshots * Revert "test(lens): cover JSON text span normalization for dataset cases" This reverts commit 30229ae8ae90265c09d755c43c1f1eeaa66050c7. * fix(lens): import StateMessage from shared in dataset detail * fix(lens): import StateMessage from shared in datasets list * fix(lens): give the datasets migration a unique timestamp after review checkpoints
408 lines
16 KiB
Python
408 lines
16 KiB
Python
import json
|
|
from collections.abc import Mapping
|
|
from datetime import datetime
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.constants import LENS_DATASET_MAX_CASE_CHARS, LENS_DATASET_MAX_CASES
|
|
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.lens.dataset_endpoints import (
|
|
ProxyDatasetReader,
|
|
build_dataset_cases,
|
|
create_dataset,
|
|
eval_cases,
|
|
export_dataset,
|
|
list_datasets,
|
|
read_dataset,
|
|
save_revision,
|
|
)
|
|
from litellm.proxy.lens.dataset_repository import StoredSummary
|
|
from litellm.proxy.lens.datasets import case_id
|
|
from litellm.proxy.lens.models import (
|
|
BuildRequest,
|
|
CaseSource,
|
|
Dataset,
|
|
DatasetCase,
|
|
DatasetCreate,
|
|
DatasetMessage,
|
|
DatasetSummary,
|
|
Evidence,
|
|
Lens,
|
|
RevisionSave,
|
|
Scope,
|
|
TextSource,
|
|
)
|
|
from litellm.proxy.lens.repository import LensRepository, Row
|
|
from litellm.rust_bridge.trace.errors import TraceChanged
|
|
from litellm.rust_bridge.trace.generated.types import SpanDetail, Trace, TraceScope
|
|
from litellm.rust_bridge.trace.storage import ClickHouseStorage
|
|
from litellm.tracing import TraceReceiver
|
|
from tests.unit.proxy.lens.test_datasets import detail, span, stored_finding, trace
|
|
from tests.unit.proxy.lens.test_state import lens
|
|
|
|
ADMIN: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin", team_id="alpha")
|
|
|
|
|
|
class MemoryStore:
|
|
def __init__(self) -> None:
|
|
self.rows: Final[dict[tuple[str, int], Dataset]] = {} # mutable-ok: stands in for the table
|
|
|
|
async def summaries(self) -> tuple[StoredSummary, ...]:
|
|
latest: Final = {i: d for (i, _), d in sorted(self.rows.items(), key=lambda item: item[0][1])}
|
|
return tuple(StoredSummary(team_id=d.team_id, summary=summary(d)) for d in latest.values())
|
|
|
|
async def get(self, dataset_id: str, revision: int | None = None) -> Dataset | None:
|
|
revisions: Final = sorted(r for i, r in self.rows if i == dataset_id)
|
|
wanted: Final = revision if revision is not None else (revisions[-1] if revisions else None)
|
|
return self.rows.get((dataset_id, wanted)) if wanted is not None else None
|
|
|
|
async def insert(self, dataset: Dataset, saved_at: datetime) -> bool:
|
|
key: Final = (dataset.id, dataset.revision)
|
|
if key in self.rows:
|
|
return False
|
|
self.rows[key] = dataset
|
|
return True
|
|
|
|
|
|
def case(text: str, included: bool = True) -> DatasetCase:
|
|
message: Final = (DatasetMessage(role="user", content=text),)
|
|
return DatasetCase(id=case_id(message, "", ()), messages=message, included=included, source=CaseSource())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saving_inserts_a_new_revision_and_leaves_the_older_one_unchanged() -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
first: Final = await save_revision(created.id, RevisionSave(base_revision=0, cases=(case("a"),)), ADMIN, store)
|
|
second: Final = await save_revision(
|
|
created.id, RevisionSave(base_revision=1, cases=(case("a"), case("b"))), ADMIN, store
|
|
)
|
|
|
|
assert (first.revision, second.revision) == (1, 2)
|
|
assert await read_dataset(created.id, ADMIN, store, revision=1) == first
|
|
assert (await read_dataset(created.id, ADMIN, store, revision=0)).cases == ()
|
|
assert await read_dataset(created.id, ADMIN, store) == second
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saving_on_a_stale_revision_is_a_conflict_and_writes_nothing() -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
await save_revision(created.id, RevisionSave(base_revision=0, cases=(case("a"),)), ADMIN, store)
|
|
|
|
with pytest.raises(HTTPException) as error:
|
|
await save_revision(created.id, RevisionSave(base_revision=0, cases=(case("b"),)), ADMIN, store)
|
|
assert error.value.status_code == 409
|
|
assert sorted(store.rows) == [(created.id, 0), (created.id, 1)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saving_dedupes_cases_and_restores_content_hash_ids_but_keeps_edits() -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
edited: Final = case("a").model_copy(update={"id": "forged", "expected": "say hi"})
|
|
saved: Final = await save_revision(
|
|
created.id, RevisionSave(base_revision=0, cases=(edited, case("a"))), ADMIN, store
|
|
)
|
|
|
|
assert len(saved.cases) == 1
|
|
assert saved.cases[0].id == case("a").id
|
|
assert saved.cases[0].expected == "say hi"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_eval_cases_return_only_included_cases_of_the_requested_revision() -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
await save_revision(
|
|
created.id, RevisionSave(base_revision=0, cases=(case("a"), case("b", included=False))), ADMIN, store
|
|
)
|
|
await save_revision(created.id, RevisionSave(base_revision=1, cases=(case("c"),)), ADMIN, store)
|
|
|
|
cases: Final = await eval_cases(created.id, 1, ADMIN, store)
|
|
assert (cases.dataset_id, cases.revision) == (created.id, 1)
|
|
assert tuple(c.messages[0].content for c in cases.cases) == ("a",)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_only_admin_can_read_but_cannot_save_a_revision() -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
viewer: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY)
|
|
|
|
assert (await read_dataset(created.id, viewer, store)).id == created.id
|
|
with pytest.raises(HTTPException) as error:
|
|
await save_revision(created.id, RevisionSave(base_revision=0, cases=(case("a"),)), viewer, store)
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
async def create_dataset_named(store: MemoryStore) -> Dataset:
|
|
return await create_dataset(DatasetCreate(name="Refunds", agent_name="support"), ADMIN, store)
|
|
|
|
|
|
def summary(dataset: Dataset) -> DatasetSummary:
|
|
return DatasetSummary(
|
|
id=dataset.id,
|
|
name=dataset.name,
|
|
agent_name=dataset.agent_name,
|
|
revision=dataset.revision,
|
|
case_count=len(dataset.cases),
|
|
updated_at=dataset.created_at,
|
|
)
|
|
|
|
|
|
class RejectingStore:
|
|
def __init__(self, stored: Dataset | None = None) -> None:
|
|
self.stored: Final = stored
|
|
|
|
async def summaries(self) -> tuple[StoredSummary, ...]:
|
|
return ()
|
|
|
|
async def get(self, dataset_id: str, revision: int | None = None) -> Dataset | None:
|
|
return self.stored
|
|
|
|
async def insert(self, dataset: Dataset, saved_at: datetime) -> bool:
|
|
return False
|
|
|
|
|
|
class TraceStorage(ClickHouseStorage):
|
|
def __init__(
|
|
self,
|
|
pages: Mapping[str | None, Trace],
|
|
spans: Mapping[str, SpanDetail] | None = None,
|
|
error: Exception | None = None,
|
|
) -> None:
|
|
self.pages: Final = pages
|
|
self.spans: Final = spans or {}
|
|
self.error: Final = error
|
|
|
|
async def get_trace(
|
|
self,
|
|
trace_id: str,
|
|
scope: TraceScope,
|
|
trace_ref: str = "",
|
|
cursor: str | None = None,
|
|
page_size: int | None = None,
|
|
) -> Trace | None:
|
|
if self.error:
|
|
raise self.error
|
|
return self.pages.get(cursor)
|
|
|
|
async def get_span(self, trace_id: str, span_id: str, scope: TraceScope, trace_ref: str = "") -> SpanDetail | None:
|
|
if self.error:
|
|
raise self.error
|
|
return self.spans.get(span_id)
|
|
|
|
|
|
class LensTable:
|
|
def __init__(self, stored: Lens) -> None:
|
|
self.stored: Final = stored
|
|
|
|
async def query_raw(self, query: str, *args: object) -> tuple[Row, ...]:
|
|
return (Row(data=self.stored.model_dump(mode="json")),) if args == (self.stored.id,) else ()
|
|
|
|
async def execute_raw(self, query: str, *args: object) -> int:
|
|
return 0
|
|
|
|
|
|
def page(next_cursor: str | None, *span_ids: str) -> Trace:
|
|
return {**trace(*(span(s, "llm", i) for i, s in enumerate(span_ids))), "next_cursor": next_cursor}
|
|
|
|
|
|
def lenses(stored: Lens | None = None) -> LensRepository:
|
|
return LensRepository(LensTable(stored or lens()))
|
|
|
|
|
|
def reader(
|
|
storage: TraceStorage | None = None, stored: Lens | None = None, scope: Scope | None = None
|
|
) -> ProxyDatasetReader:
|
|
return ProxyDatasetReader(
|
|
TraceReceiver(storage) if storage else None,
|
|
lenses(stored),
|
|
scope or Scope(all_teams=True),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reading_a_trace_follows_every_cursor_page_and_joins_the_spans() -> None:
|
|
storage: Final = TraceStorage({None: page("c1", "a", "b"), "c1": page("c2", "c"), "c2": page(None, "d")})
|
|
read: Final = await reader(storage).trace("t1", "ref")
|
|
|
|
assert read is not None
|
|
assert tuple(s["span_id"] for s in read["spans"]) == ("a", "b", "c", "d")
|
|
assert read.get("next_cursor") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("pages", ({}, {None: page("c1", "a")}), ids=("missing-trace", "page-disappears"))
|
|
async def test_reading_a_trace_is_none_when_the_trace_or_a_later_page_is_gone(
|
|
pages: Mapping[str | None, Trace],
|
|
) -> None:
|
|
assert await reader(TraceStorage(pages)).trace("t1", "ref") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("error", "status"), ((TraceChanged("moved"), 409), (ValueError("bad cursor"), 400), (RuntimeError("down"), 503))
|
|
)
|
|
async def test_trace_and_span_read_failures_become_the_matching_http_error(error: Exception, status: int) -> None:
|
|
failing: Final = reader(TraceStorage({}, error=error))
|
|
|
|
with pytest.raises(HTTPException) as traced:
|
|
await failing.trace("t1", "ref")
|
|
with pytest.raises(HTTPException) as spanned:
|
|
await failing.span("t1", "s1", "ref")
|
|
assert (traced.value.status_code, spanned.value.status_code) == (status, status)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reading_traces_without_tracing_enabled_is_not_implemented() -> None:
|
|
disabled: Final = reader()
|
|
|
|
with pytest.raises(HTTPException) as traced:
|
|
await disabled.trace("t1", "ref")
|
|
with pytest.raises(HTTPException) as spanned:
|
|
await disabled.span("t1", "s1", "ref")
|
|
assert (traced.value.status_code, spanned.value.status_code) == (501, 501)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reading_a_span_returns_its_detail() -> None:
|
|
stored: Final = detail("s1", "refund?")
|
|
assert await reader(TraceStorage({}, {"s1": stored})).span("t1", "s1", "") == stored
|
|
|
|
|
|
def lens_with_findings() -> Lens:
|
|
evidence: Final = Evidence(execution_id="run", span_id="s1", quote="q")
|
|
return lens().model_copy(
|
|
update={
|
|
"findings": (stored_finding("f1", evidence), stored_finding("f2", evidence), stored_finding("f3", evidence))
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_findings_returns_only_the_requested_ids() -> None:
|
|
found: Final = await reader(stored=lens_with_findings()).findings("lens", ("f3", "f1", "unknown"))
|
|
assert tuple(f.id for f in found) == ("f1", "f3")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("lens_id", "scope"),
|
|
(("missing", Scope(all_teams=True)), ("lens", Scope(team_id="beta"))),
|
|
ids=("unknown-lens", "lens-outside-scope"),
|
|
)
|
|
async def test_findings_of_an_unknown_or_inaccessible_lens_are_not_found(lens_id: str, scope: Scope) -> None:
|
|
with pytest.raises(HTTPException) as error:
|
|
await reader(stored=lens_with_findings(), scope=scope).findings(lens_id, ("f1",))
|
|
assert error.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_returns_a_summary_of_the_latest_revision_of_each_dataset() -> None:
|
|
store: Final = MemoryStore()
|
|
first: Final = await create_dataset_named(store)
|
|
second: Final = await create_dataset_named(store)
|
|
await save_revision(first.id, RevisionSave(base_revision=0, cases=(case("a"), case("b"))), ADMIN, store)
|
|
|
|
listed: Final = await list_datasets(ADMIN, store)
|
|
assert sorted((s.id, s.revision, s.case_count) for s in listed) == sorted(((first.id, 1, 2), (second.id, 0, 0)))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_listing_requires_proxy_admin_access() -> None:
|
|
with pytest.raises(HTTPException) as error:
|
|
await list_datasets(UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER), MemoryStore())
|
|
assert error.value.status_code == 403
|
|
|
|
|
|
def text_source(*texts: str) -> TextSource:
|
|
return TextSource(text="\n".join(json.dumps({"messages": [{"role": "user", "content": t}]}) for t in texts))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_building_into_a_dataset_skips_cases_it_already_holds() -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
await save_revision(created.id, RevisionSave(base_revision=0, cases=(case("a"),)), ADMIN, store)
|
|
request: Final = BuildRequest(sources=(text_source("a", "b"),), dataset_id=created.id)
|
|
|
|
built: Final = await build_dataset_cases(request, ADMIN, store, lenses(), None)
|
|
fresh: Final = await build_dataset_cases(
|
|
request.model_copy(update={"dataset_id": ""}), ADMIN, store, lenses(), None
|
|
)
|
|
|
|
assert tuple(c.messages[0].content for c in built.cases) == ("b",)
|
|
assert tuple(s.reason for s in built.skipped) == ("duplicate",)
|
|
assert tuple(c.messages[0].content for c in fresh.cases) == ("a", "b")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_building_into_an_unknown_dataset_is_not_found() -> None:
|
|
request: Final = BuildRequest(sources=(text_source("a"),), dataset_id="missing")
|
|
with pytest.raises(HTTPException) as error:
|
|
await build_dataset_cases(request, ADMIN, MemoryStore(), lenses(), None)
|
|
assert error.value.status_code == 404
|
|
|
|
|
|
def exported_texts(body: bytes) -> tuple[str, ...]:
|
|
return tuple(DatasetCase.model_validate_json(line).messages[0].content for line in body.splitlines())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_downloads_included_cases_of_the_requested_revision_as_ndjson() -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
await save_revision(
|
|
created.id, RevisionSave(base_revision=0, cases=(case("a"), case("b", included=False))), ADMIN, store
|
|
)
|
|
await save_revision(created.id, RevisionSave(base_revision=1, cases=(case("c"),)), ADMIN, store)
|
|
|
|
exported: Final = await export_dataset(created.id, ADMIN, store, revision=1)
|
|
latest: Final = await export_dataset(created.id, ADMIN, store)
|
|
|
|
assert exported.media_type == "application/x-ndjson"
|
|
assert exported.headers["content-disposition"] == f'attachment; filename="dataset-{created.id}-r1.jsonl"'
|
|
assert exported_texts(bytes(exported.body)) == ("a",)
|
|
assert latest.headers["content-disposition"] == f'attachment; filename="dataset-{created.id}-r2.jsonl"'
|
|
assert exported_texts(bytes(latest.body)) == ("c",)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_creating_a_dataset_whose_insert_is_rejected_is_a_conflict() -> None:
|
|
with pytest.raises(HTTPException) as error:
|
|
await create_dataset(DatasetCreate(name="Refunds"), ADMIN, RejectingStore())
|
|
assert error.value.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saving_loses_to_a_concurrent_save_of_the_same_revision() -> None:
|
|
created: Final = await create_dataset_named(MemoryStore())
|
|
|
|
with pytest.raises(HTTPException) as error:
|
|
await save_revision(
|
|
created.id, RevisionSave(base_revision=0, cases=(case("a"),)), ADMIN, RejectingStore(created)
|
|
)
|
|
assert error.value.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"cases",
|
|
(
|
|
tuple(case(str(i)) for i in range(LENS_DATASET_MAX_CASES + 1)),
|
|
(case("x" * (LENS_DATASET_MAX_CASE_CHARS + 1)),),
|
|
),
|
|
ids=("too-many-cases", "case-too-large"),
|
|
)
|
|
async def test_saving_an_oversized_revision_is_rejected_and_writes_nothing(cases: tuple[DatasetCase, ...]) -> None:
|
|
store: Final = MemoryStore()
|
|
created: Final = await create_dataset_named(store)
|
|
|
|
with pytest.raises(HTTPException) as error:
|
|
await save_revision(created.id, RevisionSave(base_revision=0, cases=cases), ADMIN, store)
|
|
assert error.value.status_code == 422
|
|
assert sorted(store.rows) == [(created.id, 0)]
|