feat(lens): add datasets built from real traces (#44765)

* 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
This commit is contained in:
ishaan-berri 2026-10-06 16:04:43 -07:00 • committed by GitHub
parent 292fcb1c65
commit 5fb23eabd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 4614 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "LiteLLM_LensDataset" (
"id" TEXT NOT NULL,
"revision" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL,
"data" JSONB NOT NULL,
CONSTRAINT "LiteLLM_LensDataset_pkey" PRIMARY KEY ("id", "revision")
);

View file

@ -1965,3 +1965,12 @@ model LiteLLM_LensWorker {
token_hash String @unique
data Json
}
model LiteLLM_LensDataset {
id String
revision Int
created_at DateTime
data Json
@@id([id, revision])
}

View file

@ -85,6 +85,32 @@ struct RawMessage {
kwargs: Option<Box<RawMessage>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AssistantSummary {
content: Option<String>,
tool_names: Vec<String>,
}
impl AssistantSummary {
fn into_ui(self) -> UiMessage {
let calls: Vec<UiToolCall> = self
.tool_names
.into_iter()
.map(|name| UiToolCall {
name,
arguments: "{}".to_owned(),
})
.collect();
UiMessage {
role: ChatRole::Assistant,
content: self.content.unwrap_or_default(),
name: None,
tool_calls: (!calls.is_empty()).then_some(calls),
}
}
}
#[derive(Deserialize)]
struct ContentBlock {
#[serde(rename = "type", default)]
@ -211,6 +237,19 @@ fn messages(parsed: &Value) -> Option<Vec<UiMessage>> {
Some(unwrapped.into_iter().map(RawMessage::into_ui).collect())
}
fn assistant_summaries(parsed: &Value) -> Option<Vec<UiMessage>> {
let summaries = Vec::<AssistantSummary>::deserialize(parsed).ok()?;
if summaries.is_empty() {
return None;
}
Some(
summaries
.into_iter()
.map(AssistantSummary::into_ui)
.collect(),
)
}
pub fn to_ui_content(raw: &str) -> UiContent {
let text = || UiContent::Text {
text: raw.to_owned(),
@ -223,7 +262,7 @@ pub fn to_ui_content(raw: &str) -> UiContent {
Ok(parsed @ (Value::Array(_) | Value::Object(_))) => parsed,
_ => return text(),
};
if let Some(messages) = messages(&parsed) {
if let Some(messages) = messages(&parsed).or_else(|| assistant_summaries(&parsed)) {
return UiContent::Messages { messages };
}
match parsed {
@ -356,6 +395,33 @@ mod tests {
);
}
#[rstest]
fn assistant_summaries_become_assistant_messages() {
let raw = json!([
{"content": "`/etc/hosts` has 11 lines.", "tool_names": []},
{"content": null, "tool_names": ["terminal"]},
]);
assert_eq!(
to_ui_content(&raw.to_string()),
UiContent::Messages {
messages: vec![
message("assistant", "`/etc/hosts` has 11 lines."),
UiMessage {
tool_calls: Some(vec![call("terminal", "{}")]),
..message("assistant", "")
},
]
}
);
}
#[rstest]
#[case::extra_field(r#"[{"content": "x", "tool_names": [], "score": 1}]"#)]
#[case::missing_tool_names(r#"[{"content": "x"}]"#)]
fn near_summaries_stay_text(#[case] raw: &str) {
assert!(matches!(to_ui_content(raw), UiContent::Text { .. }));
}
#[rstest]
fn plain_objects_become_fields_in_key_order() {
let raw = r#"{"zeta": "plain", "alpha": {"nested": [1, 2]}, "count": 3, "missing": null}"#;

View file

@ -60,6 +60,9 @@ TRACE_READ_RETRY_AFTER_SECONDS: Final = get_env_int("TRACE_READ_RETRY_AFTER_SECO
OTLP_MAX_CONCURRENT_INGESTS: Final = get_env_int("OTLP_MAX_CONCURRENT_INGESTS", 2)
AGENT_TRACING_INPUT_PREVIEW_CHARS: Final = get_env_int("AGENT_TRACING_INPUT_PREVIEW_CHARS", 240)
AGENT_TRACING_LIST_PAGE_SIZE: Final = get_env_int("AGENT_TRACING_LIST_PAGE_SIZE", 50)
LENS_DATASET_MAX_CASES: Final = get_env_int("LENS_DATASET_MAX_CASES", 200)
LENS_DATASET_MAX_CASE_CHARS: Final = get_env_int("LENS_DATASET_MAX_CASE_CHARS", 20_000)
LENS_DATASET_TRACE_PAGE_SIZE: Final = 500
DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10))
DEFAULT_S3_BATCH_SIZE: Final = int(os.getenv("DEFAULT_S3_BATCH_SIZE", 512))
DEFAULT_S3_MAX_CONCURRENT_UPLOADS: Final = int(os.getenv("DEFAULT_S3_MAX_CONCURRENT_UPLOADS", "16"))

View file

@ -349,6 +349,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_WorkflowEvent",
"LiteLLM_WorkflowMessage",
"LiteLLM_Lens",
"LiteLLM_LensDataset",
"LiteLLM_LensRun",
"LiteLLM_LensReview",
"LiteLLM_LensWorker",

View file

@ -0,0 +1,175 @@
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Annotated, Final, TypeAlias
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from litellm.constants import LENS_DATASET_TRACE_PAGE_SIZE
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
from litellm.proxy.lens.dataset_repository import DatasetRepository, DatasetStore
from litellm.proxy.lens.datasets import build_cases, export_jsonl, included_cases, revision_cases, revision_problem
from litellm.proxy.lens.endpoints import Auth, repository, user_scope
from litellm.proxy.lens.models import (
BuildRequest,
BuildResult,
Dataset,
DatasetCase,
DatasetCreate,
DatasetSummary,
EvalCases,
Finding,
RevisionSave,
Scope,
)
from litellm.proxy.lens.repository import LensRepository, WriterDatabase
from litellm.proxy.lens.state import can_access
from litellm.proxy.tracing_endpoints import read_failure
from litellm.proxy.tracing_runtime import provide_receiver, require_receiver
from litellm.rust_bridge.trace.errors import TraceChanged
from litellm.rust_bridge.trace.generated.types import SpanDetail, Trace, TraceScope
from litellm.tracing import TraceReceiver
router: Final = APIRouter(prefix="/lens/datasets", tags=["Lens"])
ALL_TRACES: Final = TraceScope(all_teams=1, user_id="", team_ids=())
def dataset_store() -> DatasetStore:
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(503, "Lens needs a connected Postgres database")
return DatasetRepository(WriterDatabase(writer_wrapper(prisma_client.db)))
Datasets: TypeAlias = Annotated[DatasetStore, Depends(dataset_store)]
Lenses: TypeAlias = Annotated[LensRepository, Depends(repository)]
Receiver: TypeAlias = Annotated[TraceReceiver | None, Depends(provide_receiver)]
RevisionQuery: TypeAlias = Annotated[int | None, Query(ge=0)]
class ProxyDatasetReader:
def __init__(self, receiver: TraceReceiver | None, lenses: LensRepository, scope: Scope) -> None:
self.receiver: Final = receiver
self.lenses: Final = lenses
self.scope: Final = scope
async def trace(self, trace_id: str, trace_ref: str) -> Trace | None:
try:
return await self._all_pages(trace_id, trace_ref)
except (TraceChanged, ValueError, OverflowError, RuntimeError) as error:
raise read_failure(error) from error
async def _all_pages(self, trace_id: str, trace_ref: str) -> Trace | None:
receiver: Final = require_receiver(self.receiver)
first: Final = await receiver.get_trace(trace_id, ALL_TRACES, trace_ref, page_size=LENS_DATASET_TRACE_PAGE_SIZE)
if first is None:
return None
spans = first["spans"] # rebind-ok: accumulate spans across cursor pages
cursor = first.get("next_cursor") # rebind-ok: advance the trace cursor
while cursor:
page = await receiver.get_trace(trace_id, ALL_TRACES, trace_ref, cursor, LENS_DATASET_TRACE_PAGE_SIZE)
if page is None:
return None
spans = (*spans, *page["spans"])
cursor = page.get("next_cursor")
return Trace(summary=first["summary"], agents=first["agents"], spans=spans, next_cursor=None)
async def span(self, trace_id: str, span_id: str, trace_ref: str) -> SpanDetail | None:
try:
return await require_receiver(self.receiver).get_span(trace_id, span_id, ALL_TRACES, trace_ref)
except (TraceChanged, ValueError, OverflowError, RuntimeError) as error:
raise read_failure(error) from error
async def findings(self, lens_id: str, ids: tuple[str, ...]) -> tuple[Finding, ...]:
lens: Final = await self.lenses.get(lens_id)
if lens is None or not can_access(self.scope, lens.scope):
raise HTTPException(404, "Lens not found")
wanted: Final = frozenset(ids)
return tuple(f for f in lens.findings if f.id in wanted)
async def get_dataset(datasets: DatasetStore, dataset_id: str, scope: Scope, revision: int | None = None) -> Dataset:
dataset: Final = await datasets.get(dataset_id, revision)
if dataset is None or not can_access(scope, Scope(team_id=dataset.team_id)):
raise HTTPException(404, "Dataset not found")
return dataset
@router.get("", response_model=tuple[DatasetSummary, ...])
async def list_datasets(auth: Auth, datasets: Datasets) -> tuple[DatasetSummary, ...]:
scope: Final = user_scope(auth)
return tuple(s.summary for s in await datasets.summaries() if can_access(scope, Scope(team_id=s.team_id)))
@router.post("", response_model=Dataset)
async def create_dataset(body: DatasetCreate, auth: Auth, datasets: Datasets) -> Dataset:
user_scope(auth, write=True)
now: Final = datetime.now(timezone.utc)
dataset: Final = Dataset(
id=str(uuid4()),
name=body.name,
agent_name=body.agent_name,
team_id=auth.team_id or "",
created_at=now,
revision=0,
created_by=auth.user_id or "",
cases=(),
)
if not await datasets.insert(dataset, now):
raise HTTPException(409, "Dataset already exists")
return dataset
@router.post("/build", response_model=BuildResult)
async def build_dataset_cases(
body: BuildRequest, auth: Auth, datasets: Datasets, lenses: Lenses, receiver: Receiver
) -> BuildResult:
scope: Final = user_scope(auth)
existing: Final[tuple[DatasetCase, ...]] = (
(await get_dataset(datasets, body.dataset_id, scope)).cases if body.dataset_id else ()
)
return await build_cases(body, ProxyDatasetReader(receiver, lenses, scope), existing)
@router.get("/{dataset_id}", response_model=Dataset)
async def read_dataset(dataset_id: str, auth: Auth, datasets: Datasets, revision: RevisionQuery = None) -> Dataset:
return await get_dataset(datasets, dataset_id, user_scope(auth), revision)
@router.post("/{dataset_id}/revisions", response_model=Dataset)
async def save_revision(dataset_id: str, body: RevisionSave, auth: Auth, datasets: Datasets) -> Dataset:
latest: Final = await get_dataset(datasets, dataset_id, user_scope(auth, write=True))
if body.base_revision != latest.revision:
raise HTTPException(409, "Dataset changed, reload")
cases: Final = revision_cases(body.cases)
if problem := revision_problem(cases):
raise HTTPException(422, problem)
saved: Final = latest.model_copy(
update=MappingProxyType({"revision": latest.revision + 1, "cases": cases, "created_by": auth.user_id or ""})
)
if not await datasets.insert(saved, datetime.now(timezone.utc)):
raise HTTPException(409, "Dataset changed, reload")
return saved
@router.get(
"/{dataset_id}/export",
response_class=Response,
responses={200: {"content": {"application/x-ndjson": {}}}},
)
async def export_dataset(dataset_id: str, auth: Auth, datasets: Datasets, revision: RevisionQuery = None) -> Response:
dataset: Final = await get_dataset(datasets, dataset_id, user_scope(auth), revision)
return Response(
content=export_jsonl(dataset.cases),
media_type="application/x-ndjson",
headers=MappingProxyType(
{"Content-Disposition": f'attachment; filename="dataset-{dataset.id}-r{dataset.revision}.jsonl"'}
),
)
@router.get("/{dataset_id}/revisions/{revision}/cases", response_model=EvalCases)
async def eval_cases(dataset_id: str, revision: int, auth: Auth, datasets: Datasets) -> EvalCases:
dataset: Final = await get_dataset(datasets, dataset_id, user_scope(auth), revision)
return EvalCases(dataset_id=dataset.id, revision=dataset.revision, cases=included_cases(dataset.cases))

View file

@ -0,0 +1,65 @@
from datetime import datetime
from typing import Final, Protocol
from pydantic import TypeAdapter
from litellm.proxy.lens.models import Dataset, DatasetSummary, Record
from litellm.proxy.lens.repository import Database, Row
_ROWS: Final = TypeAdapter(tuple[Row, ...])
class StoredSummary(Record):
team_id: str
summary: DatasetSummary
class DatasetStore(Protocol):
async def summaries(self) -> tuple[StoredSummary, ...]: ...
async def get(self, dataset_id: str, revision: int | None = None) -> Dataset | None: ...
async def insert(self, dataset: Dataset, saved_at: datetime) -> bool: ...
class DatasetRepository:
def __init__(self, db: Database) -> None:
self.db: Final = db
async def summaries(self) -> tuple[StoredSummary, ...]:
rows: Final = _ROWS.validate_python(
await self.db.query_raw(
"""SELECT jsonb_build_object(
'team_id', data->>'team_id',
'summary', jsonb_build_object(
'id', id, 'name', data->>'name', 'agent_name', data->>'agent_name', 'revision', revision,
'case_count', jsonb_array_length(data->'cases'),
'updated_at', created_at AT TIME ZONE 'UTC'
)
) AS data FROM (
SELECT DISTINCT ON (id) id, revision, created_at, data FROM "LiteLLM_LensDataset"
ORDER BY id, revision DESC
) AS latest ORDER BY created_at DESC"""
)
)
return tuple(StoredSummary.model_validate(row.data) for row in rows)
async def get(self, dataset_id: str, revision: int | None = None) -> Dataset | None:
rows: Final = _ROWS.validate_python(
await self.db.query_raw(
"""SELECT data FROM "LiteLLM_LensDataset" WHERE id=$1 AND ($2::int IS NULL OR revision=$2::int)
ORDER BY revision DESC LIMIT 1""",
dataset_id,
revision,
)
)
return Dataset.model_validate(rows[0].data) if rows else None
async def insert(self, dataset: Dataset, saved_at: datetime) -> bool:
inserted: Final = await self.db.execute_raw(
"""INSERT INTO "LiteLLM_LensDataset" (id, revision, created_at, data)
VALUES ($1, $2, $3::timestamptz AT TIME ZONE 'UTC', $4::jsonb) ON CONFLICT (id, revision) DO NOTHING""",
dataset.id,
dataset.revision,
saved_at.isoformat(),
dataset.model_dump_json(),
)
return inserted == 1

View file

@ -0,0 +1,307 @@
import hashlib
from collections.abc import Iterable
from dataclasses import dataclass
from functools import partial, reduce
from itertools import chain
from types import MappingProxyType
from typing import Final, Protocol, TypeAlias
from pydantic import BaseModel, ConfigDict, ValidationError
from typing_extensions import assert_never
from litellm.constants import LENS_DATASET_MAX_CASE_CHARS, LENS_DATASET_MAX_CASES
from litellm.proxy.lens.models import (
BuildRequest,
BuildResult,
BuildSource,
CaseSource,
DatasetCase,
DatasetMessage,
DatasetToolCall,
Finding,
FindingSource,
Record,
SkippedCase,
TextSource,
TraceSource,
)
from litellm.proxy.lens.sources import parse_execution
from litellm.rust_bridge.trace.generated.types import SpanDetail, Trace, UIContent, UIMessage
AGENT_VERSION_ATTRIBUTE: Final = "agent.version"
class DatasetReader(Protocol):
async def trace(self, trace_id: str, trace_ref: str) -> Trace | None: ...
async def span(self, trace_id: str, span_id: str, trace_ref: str) -> SpanDetail | None: ...
async def findings(self, lens_id: str, ids: tuple[str, ...]) -> tuple[Finding, ...]: ...
class _Content(Record):
messages: tuple[DatasetMessage, ...]
reply: str
tool_calls: tuple[DatasetToolCall, ...]
class _TextLine(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
messages: tuple[DatasetMessage, ...]
reply: str = ""
tool_calls: tuple[DatasetToolCall, ...] = ()
expected: str = ""
source: CaseSource = CaseSource()
agent_version: str = ""
@dataclass(frozen=True, slots=True)
class _Reply:
text: str
tool_calls: tuple[DatasetToolCall, ...]
@dataclass(frozen=True, slots=True)
class _Admission:
seen: frozenset[str]
cases: tuple[DatasetCase, ...] = ()
skipped: tuple[SkippedCase, ...] = ()
Candidate: TypeAlias = DatasetCase | SkippedCase
def case_id(messages: tuple[DatasetMessage, ...], reply: str, tool_calls: tuple[DatasetToolCall, ...]) -> str:
content: Final = _Content(messages=messages, reply=reply, tool_calls=tool_calls)
return hashlib.sha256(content.model_dump_json().encode()).hexdigest()
def _tool_call_chars(tool_calls: tuple[DatasetToolCall, ...]) -> int:
return sum(len(t.name) + len(t.arguments) for t in tool_calls)
def case_chars(case: DatasetCase) -> int:
return (
sum(len(m.content) + len(m.name) + _tool_call_chars(m.tool_calls) for m in case.messages)
+ len(case.reply)
+ _tool_call_chars(case.tool_calls)
+ len(case.expected)
)
def make_case(
messages: tuple[DatasetMessage, ...],
reply: str,
tool_calls: tuple[DatasetToolCall, ...],
source: CaseSource,
expected: str = "",
agent_version: str = "",
) -> Candidate:
if not messages and not reply.strip() and not tool_calls:
return SkippedCase(source=source, reason="no_content")
case: Final = DatasetCase(
id=case_id(messages, reply, tool_calls),
messages=messages,
reply=reply,
tool_calls=tool_calls,
expected=expected,
source=source,
agent_version=agent_version,
)
if case_chars(case) > LENS_DATASET_MAX_CASE_CHARS:
return SkippedCase(source=source, reason="too_large")
return case
def _message(message: UIMessage) -> DatasetMessage:
return DatasetMessage(
role=message["role"],
content=message["content"],
name=message.get("name") or "",
tool_calls=_tool_calls((message,)),
)
def _tool_calls(messages: Iterable[UIMessage]) -> tuple[DatasetToolCall, ...]:
calls: Final = chain.from_iterable(m.get("tool_calls", ()) for m in messages)
return tuple(DatasetToolCall(name=c["name"], arguments=c["arguments"]) for c in calls)
def _conversation(ui: UIContent, raw: str) -> tuple[DatasetMessage, ...]:
if ui["kind"] == "messages":
return tuple(_message(m) for m in ui["messages"])
return (DatasetMessage(role="user", content=raw),) if raw.strip() else ()
def _reply(ui: UIContent, raw: str) -> _Reply:
if ui["kind"] == "messages":
replies: Final = tuple(m for m in ui["messages"] if m["role"] == "assistant")
return _Reply("\n\n".join(m["content"] for m in replies if m["content"]), _tool_calls(replies))
if ui["kind"] == "text":
return _Reply(ui["text"], ())
return _Reply(raw, ())
def case_from_span(detail: SpanDetail, source: CaseSource) -> Candidate:
reply: Final = _reply(detail["output_ui"], detail["output"])
return make_case(
_conversation(detail["input_ui"], detail["input"]),
reply.text,
reply.tool_calls,
source.model_copy(update=MappingProxyType({"span_id": detail["span_id"]})),
agent_version=detail["attributes"].get(AGENT_VERSION_ATTRIBUTE, ""),
)
async def _last_conversation(reader: DatasetReader, source: TraceSource, trace: Trace) -> SpanDetail | None:
llm_spans: Final = sorted(
(s for s in trace["spans"] if s["type"] == "llm"), key=lambda s: s["start_offset_ms"], reverse=True
)
for span in llm_spans:
detail = await reader.span(source.trace_id, span["span_id"], source.trace_ref)
if detail is not None and detail["input_ui"]["kind"] == "messages":
return detail
return None
async def _trace_cases(reader: DatasetReader, source: TraceSource) -> tuple[Candidate, ...]:
origin: Final = CaseSource(trace_id=source.trace_id, trace_ref=source.trace_ref, span_id=source.span_id)
if source.span_id:
span: Final = await reader.span(source.trace_id, source.span_id, source.trace_ref)
return (case_from_span(span, origin) if span else SkippedCase(source=origin, reason="no_content"),)
trace: Final = await reader.trace(source.trace_id, source.trace_ref)
detail: Final = await _last_conversation(reader, source, trace) if trace else None
return (case_from_span(detail, origin) if detail else SkippedCase(source=origin, reason="no_content"),)
async def _evidence_case(reader: DatasetReader, origin: CaseSource, execution: str) -> Candidate:
try:
_, _, trace_id, trace_ref = parse_execution(execution)
except (ValueError, ValidationError):
return SkippedCase(source=origin, reason="no_content")
located: Final = origin.model_copy(update=MappingProxyType({"trace_id": trace_id, "trace_ref": trace_ref}))
span: Final = await reader.span(trace_id, origin.span_id, trace_ref)
return case_from_span(span, located) if span else SkippedCase(source=located, reason="no_content")
def _first_by_key(keys: tuple[str, ...]) -> frozenset[int]:
first: Final = MappingProxyType({key: index for index, key in reversed(tuple(enumerate(keys)))})
return frozenset(first.values())
def _evidence_spans(lens_id: str, findings: tuple[Finding, ...]) -> tuple[tuple[str, CaseSource], ...]:
pairs: Final = tuple(chain.from_iterable(((f.id, e) for e in f.evidence) for f in findings))
kept: Final = _first_by_key(tuple(f"{e.execution_id}\0{e.span_id}" for _, e in pairs))
return tuple(
(e.execution_id, CaseSource(span_id=e.span_id, finding_id=fid, lens_id=lens_id))
for index, (fid, e) in enumerate(pairs)
if index in kept
)
async def _finding_cases(reader: DatasetReader, source: FindingSource) -> tuple[Candidate, ...]:
findings: Final = await reader.findings(source.lens_id, source.finding_ids)
spans: Final = _evidence_spans(source.lens_id, findings)
return tuple([await _evidence_case(reader, origin, execution) for execution, origin in spans])
def _looks_like_json_line(line: str) -> bool:
return line.lstrip().startswith("{")
def _text_line_case(line: str) -> Candidate:
try:
parsed: Final = _TextLine.model_validate_json(line)
except ValidationError:
return SkippedCase(source=CaseSource(), reason="invalid")
return make_case(
parsed.messages,
parsed.reply,
parsed.tool_calls,
parsed.source,
expected=parsed.expected,
agent_version=parsed.agent_version,
)
def _text_cases(source: TextSource) -> tuple[Candidate, ...]:
lines: Final = tuple(line for line in source.text.splitlines() if line.strip())
if lines and all(_looks_like_json_line(line) for line in lines):
return tuple(_text_line_case(line) for line in lines)
return (make_case((DatasetMessage(role="user", content=source.text),), "", (), CaseSource()),)
async def _source_cases(reader: DatasetReader, source: BuildSource) -> tuple[Candidate, ...]:
match source:
case TraceSource():
return await _trace_cases(reader, source)
case FindingSource():
return await _finding_cases(reader, source)
case TextSource():
return _text_cases(source)
return assert_never(source)
def _skip(state: _Admission, skipped: SkippedCase) -> _Admission:
return _Admission(state.seen, state.cases, (*state.skipped, skipped))
def _admit(existing_count: int, state: _Admission, candidate: Candidate) -> _Admission:
if isinstance(candidate, SkippedCase):
return _skip(state, candidate)
if candidate.id in state.seen:
return _skip(state, SkippedCase(source=candidate.source, reason="duplicate"))
if existing_count + len(state.cases) >= LENS_DATASET_MAX_CASES:
return _skip(state, SkippedCase(source=candidate.source, reason="over_limit"))
return _Admission(state.seen | {candidate.id}, (*state.cases, candidate), state.skipped)
def _unread_source(source: BuildSource) -> CaseSource:
match source:
case TraceSource():
return CaseSource(trace_id=source.trace_id, trace_ref=source.trace_ref, span_id=source.span_id)
case FindingSource():
return CaseSource(lens_id=source.lens_id, finding_id=source.finding_ids[0])
case TextSource():
return CaseSource()
return assert_never(source)
async def _admit_source(
reader: DatasetReader, existing_count: int, state: _Admission, source: BuildSource
) -> _Admission:
if existing_count + len(state.cases) >= LENS_DATASET_MAX_CASES:
return _skip(state, SkippedCase(source=_unread_source(source), reason="over_limit"))
return reduce(partial(_admit, existing_count), await _source_cases(reader, source), state)
async def build_cases(request: BuildRequest, reader: DatasetReader, existing: tuple[DatasetCase, ...]) -> BuildResult:
state = _Admission(seen=frozenset(c.id for c in existing)) # rebind-ok: sources are read in order until full
for source in request.sources:
state = await _admit_source(reader, len(existing), state, source)
return BuildResult(cases=state.cases, skipped=state.skipped)
def rehashed(case: DatasetCase) -> DatasetCase:
return case.model_copy(update=MappingProxyType({"id": case_id(case.messages, case.reply, case.tool_calls)}))
def revision_cases(cases: tuple[DatasetCase, ...]) -> tuple[DatasetCase, ...]:
hashed: Final = tuple(rehashed(c) for c in cases)
kept: Final = _first_by_key(tuple(c.id for c in hashed))
return tuple(c for index, c in enumerate(hashed) if index in kept)
def revision_problem(cases: tuple[DatasetCase, ...]) -> str | None:
if len(cases) > LENS_DATASET_MAX_CASES:
return f"A dataset holds at most {LENS_DATASET_MAX_CASES} cases"
if any(case_chars(c) > LENS_DATASET_MAX_CASE_CHARS for c in cases):
return f"Each case must be at most {LENS_DATASET_MAX_CASE_CHARS} characters"
return None
def included_cases(cases: tuple[DatasetCase, ...]) -> tuple[DatasetCase, ...]:
return tuple(c for c in cases if c.included)
def export_jsonl(cases: tuple[DatasetCase, ...]) -> str:
return "".join(c.model_dump_json() + "\n" for c in included_cases(cases))

View file

@ -500,3 +500,106 @@ class ModelResult(Record):
cost: float
context_exceeded: bool = False
finish_reason: Literal["length", "content_filter"] | None = Field(default=None, exclude=True)
class DatasetToolCall(Record):
name: str
arguments: str
class DatasetMessage(Record):
role: Literal["system", "user", "assistant", "tool"]
content: str
name: str = ""
tool_calls: tuple[DatasetToolCall, ...] = ()
class CaseSource(Record):
trace_id: str = ""
trace_ref: str = ""
span_id: str = ""
finding_id: str = ""
lens_id: str = ""
class DatasetCase(Record):
id: str
messages: tuple[DatasetMessage, ...]
reply: str = ""
tool_calls: tuple[DatasetToolCall, ...] = ()
expected: str = ""
included: bool = True
source: CaseSource
agent_version: str = ""
class SkippedCase(Record):
source: CaseSource
reason: Literal["duplicate", "no_content", "too_large", "over_limit", "invalid"]
class TraceSource(Record):
kind: Literal["trace"] = "trace"
trace_id: str = Field(min_length=1)
trace_ref: str = ""
span_id: str = ""
class FindingSource(Record):
kind: Literal["finding"] = "finding"
lens_id: str = Field(min_length=1)
finding_ids: tuple[str, ...] = Field(min_length=1)
class TextSource(Record):
kind: Literal["text"] = "text"
text: str = Field(min_length=1)
BuildSource: TypeAlias = Annotated[TraceSource | FindingSource | TextSource, Field(discriminator="kind")]
class BuildRequest(Record):
sources: tuple[BuildSource, ...] = Field(min_length=1)
dataset_id: str = ""
class BuildResult(Record):
cases: tuple[DatasetCase, ...]
skipped: tuple[SkippedCase, ...]
class DatasetCreate(Record):
name: str = Field(min_length=1, max_length=120)
agent_name: str = ""
class Dataset(Record):
id: str
name: str
agent_name: str
team_id: str
created_at: datetime
revision: int
created_by: str
cases: tuple[DatasetCase, ...]
class DatasetSummary(Record):
id: str
name: str
agent_name: str
revision: int
case_count: int
updated_at: datetime
class RevisionSave(Record):
base_revision: int = Field(ge=0)
cases: tuple[DatasetCase, ...]
class EvalCases(Record):
dataset_id: str
revision: int
cases: tuple[DatasetCase, ...]

View file

@ -579,6 +579,7 @@ from litellm.proxy.hooks.prompt_injection_detection import (
)
from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger, run_spend_event
from litellm.proxy.image_endpoints.endpoints import router as image_router
from litellm.proxy.lens.dataset_endpoints import router as lens_dataset_router
from litellm.proxy.lens.endpoints import router as lens_router
from litellm.proxy.list_api.common import (
ManagementProblem,
@ -20110,6 +20111,7 @@ app.include_router(auto_router_management_router)
app.include_router(tag_management_router)
app.include_router(workflow_management_router)
app.include_router(memory_router)
app.include_router(lens_dataset_router)
app.include_router(lens_router)
app.include_router(plugin_router)
app.include_router(cost_tracking_settings_router)

View file

@ -1965,3 +1965,12 @@ model LiteLLM_LensWorker {
token_hash String @unique
data Json
}
model LiteLLM_LensDataset {
id String
revision Int
created_at DateTime
data Json
@@id([id, revision])
}

View file

@ -1965,3 +1965,12 @@ model LiteLLM_LensWorker {
token_hash String @unique
data Json
}
model LiteLLM_LensDataset {
id String
revision Int
created_at DateTime
data Json
@@id([id, revision])
}

View file

@ -0,0 +1,149 @@
import os
from collections.abc import AsyncIterator
from datetime import datetime, timedelta, timezone
from typing import Final
from uuid import uuid4
import pytest
import pytest_asyncio
from prisma import Prisma
from prisma.types import DatasourceOverride
from litellm.proxy.db.prisma_client import PrismaWrapper
from litellm.proxy.lens.dataset_repository import DatasetRepository, StoredSummary
from litellm.proxy.lens.models import CaseSource, Dataset, DatasetCase, DatasetMessage, DatasetSummary
from litellm.proxy.lens.repository import WriterDatabase
SAVED_AT: Final = datetime(2026, 3, 1, 12, 0, 0, 123000, tzinfo=timezone.utc)
@pytest_asyncio.fixture(loop_scope="function")
async def lens_db() -> AsyncIterator[Prisma]:
async with Prisma(datasource=DatasourceOverride(url=os.environ["DATABASE_URL"])) as db:
yield db
@pytest_asyncio.fixture(loop_scope="function")
async def dataset_ids(lens_db: Prisma) -> AsyncIterator[tuple[str, ...]]:
ids: Final = tuple(uuid4().hex for _ in range(3))
yield ids
await lens_db.execute_raw('DELETE FROM "LiteLLM_LensDataset" WHERE id = ANY($1::text[])', list(ids))
def _repo(db: Prisma) -> DatasetRepository:
return DatasetRepository(WriterDatabase(PrismaWrapper(db)))
def _dataset(dataset_id: str, revision: int, case_count: int, team_id: str = "team-a") -> Dataset:
return Dataset(
id=dataset_id,
name=f"Dataset r{revision}",
agent_name="support-agent",
team_id=team_id,
created_at=SAVED_AT,
revision=revision,
created_by="user-1",
cases=tuple(
DatasetCase(
id=f"case-{revision}-{i}",
messages=(DatasetMessage(role="user", content=f"question {i}"),),
reply=f"answer {i}",
source=CaseSource(trace_id=f"trace-{i}"),
)
for i in range(case_count)
),
)
@pytest.mark.asyncio
async def test_get_returns_requested_revision_and_defaults_to_latest(
lens_db: Prisma, dataset_ids: tuple[str, ...]
) -> None:
repo: Final = _repo(lens_db)
dataset_id: Final = dataset_ids[0]
revisions: Final = tuple(_dataset(dataset_id, revision, revision) for revision in (1, 3, 2))
assert [await repo.insert(dataset, SAVED_AT) for dataset in revisions] == [True, True, True]
assert await repo.get(dataset_id, revision=1) == revisions[0]
assert await repo.get(dataset_id, revision=2) == revisions[2]
assert await repo.get(dataset_id) == revisions[1]
@pytest.mark.asyncio
async def test_get_unknown_dataset_or_revision_returns_none(lens_db: Prisma, dataset_ids: tuple[str, ...]) -> None:
repo: Final = _repo(lens_db)
assert await repo.insert(_dataset(dataset_ids[0], 1, 1), SAVED_AT)
assert await repo.get(dataset_ids[1]) is None
assert await repo.get(dataset_ids[1], revision=1) is None
assert await repo.get(dataset_ids[0], revision=2) is None
@pytest.mark.asyncio
async def test_inserting_an_existing_revision_is_rejected_and_keeps_the_first(
lens_db: Prisma, dataset_ids: tuple[str, ...]
) -> None:
repo: Final = _repo(lens_db)
original: Final = _dataset(dataset_ids[0], 1, 1)
overwrite: Final = _dataset(dataset_ids[0], 1, 4).model_copy(update={"name": "Overwritten"})
assert await repo.insert(original, SAVED_AT) is True
assert await repo.insert(overwrite, SAVED_AT + timedelta(days=1)) is False
assert await repo.get(dataset_ids[0], revision=1) == original
summaries: Final = tuple(s for s in await repo.summaries() if s.summary.id == dataset_ids[0])
assert tuple(s.summary.updated_at for s in summaries) == (SAVED_AT,)
@pytest.mark.asyncio
async def test_summaries_list_each_dataset_once_at_its_latest_revision_newest_first(
lens_db: Prisma, dataset_ids: tuple[str, ...]
) -> None:
repo: Final = _repo(lens_db)
older, newer, single = dataset_ids
writes: Final = (
(_dataset(older, 1, 1, "team-a"), SAVED_AT),
(_dataset(older, 2, 3, "team-a"), SAVED_AT + timedelta(minutes=1)),
(_dataset(newer, 1, 5, "team-b"), SAVED_AT + timedelta(minutes=2)),
(_dataset(newer, 2, 2, "team-b"), SAVED_AT + timedelta(minutes=4)),
(_dataset(single, 1, 0, ""), SAVED_AT + timedelta(minutes=3)),
)
assert [await repo.insert(dataset, saved_at) for dataset, saved_at in writes] == [True] * len(writes)
summaries: Final = tuple(s for s in await repo.summaries() if s.summary.id in dataset_ids)
assert summaries == (
StoredSummary(
team_id="team-b",
summary=DatasetSummary(
id=newer,
name="Dataset r2",
agent_name="support-agent",
revision=2,
case_count=2,
updated_at=SAVED_AT + timedelta(minutes=4),
),
),
StoredSummary(
team_id="",
summary=DatasetSummary(
id=single,
name="Dataset r1",
agent_name="support-agent",
revision=1,
case_count=0,
updated_at=SAVED_AT + timedelta(minutes=3),
),
),
StoredSummary(
team_id="team-a",
summary=DatasetSummary(
id=older,
name="Dataset r2",
agent_name="support-agent",
revision=2,
case_count=3,
updated_at=SAVED_AT + timedelta(minutes=1),
),
),
)

View file

@ -0,0 +1,408 @@
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)]

View file

@ -0,0 +1,465 @@
import json
from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Final
import pytest
from litellm.constants import LENS_DATASET_MAX_CASE_CHARS, LENS_DATASET_MAX_CASES
from litellm.proxy.lens.datasets import build_cases, case_id, export_jsonl, revision_problem
from litellm.proxy.lens.models import (
BuildRequest,
BuildResult,
CaseSource,
DatasetCase,
DatasetMessage,
DatasetToolCall,
Evidence,
Finding,
FindingSource,
SkippedCase,
TextSource,
TraceSource,
)
from litellm.proxy.lens.sources import execution_id
from litellm.rust_bridge.trace.generated.types import (
Span,
SpanDetail,
SpanType,
Trace,
TraceSummary,
UIContent,
UIField,
UIFields,
UIMessage,
UIMessages,
UIText,
)
NOW: Final = datetime(2026, 1, 15, tzinfo=timezone.utc)
def messages(*items: UIMessage) -> UIMessages:
return UIMessages(kind="messages", messages=items)
def detail(
span_id: str,
question: str,
answer: str = "Done",
attributes: Mapping[str, str] | None = None,
input_ui: UIContent | None = None,
) -> SpanDetail:
return SpanDetail(
span_id=span_id,
input_ui=input_ui
or messages(UIMessage(role="system", content="Be terse"), UIMessage(role="user", content=question)),
output_ui=messages(
UIMessage(
role="assistant",
content=answer,
tool_calls=({"name": "search", "arguments": json.dumps({"q": question})},),
)
),
input=question,
output=answer,
attributes=attributes or {},
)
def span(span_id: str, kind: SpanType, offset: float) -> Span:
return Span(
span_id=span_id,
parent_span_id=None,
name=span_id,
type=kind,
agent="support",
framework="",
start_offset_ms=offset,
duration_ms=1,
status="ok",
error=None,
error_truncated=False,
input_preview="",
model=None,
input_tokens=0,
output_tokens=0,
litellm_request_id=None,
spend=None,
)
def trace(*spans: Span) -> Trace:
summary: Final = TraceSummary(
trace_id="t1",
name="run",
service="svc",
input_preview="",
start_time="",
duration_ms=1,
status="ok",
span_count=len(spans),
agent_count=1,
agent_invocations=1,
llm_calls=len(spans),
tool_calls=0,
error_count=0,
input_tokens=0,
output_tokens=0,
models=(),
spend=None,
)
return Trace(summary=summary, agents=(), spans=spans)
def stored_finding(finding_id: str, *evidence: Evidence) -> Finding:
return Finding(
id=finding_id,
title="Repeated failed searches",
description="The agent repeats the same failed search",
check_id="retries",
evidence=evidence,
first_seen=NOW,
last_seen=NOW,
revision=1,
)
class FakeReader:
def __init__(
self,
spans: Mapping[tuple[str, str], SpanDetail],
traces: Mapping[str, Trace] | None = None,
findings: tuple[Finding, ...] = (),
) -> None:
self.spans: Final = spans
self.traces: Final = traces or {}
self.stored: Final = findings
async def trace(self, trace_id: str, trace_ref: str) -> Trace | None:
return self.traces.get(trace_id)
async def span(self, trace_id: str, span_id: str, trace_ref: str) -> SpanDetail | None:
return self.spans.get((trace_id, span_id))
async def findings(self, lens_id: str, ids: tuple[str, ...]) -> tuple[Finding, ...]:
return tuple(f for f in self.stored if f.id in ids)
async def build(reader: FakeReader, *sources: TraceSource | FindingSource | TextSource) -> BuildResult:
return await build_cases(BuildRequest(sources=sources), reader, ())
@pytest.mark.asyncio
async def test_a_span_becomes_a_case_with_its_conversation_reply_and_tool_calls() -> None:
reader: Final = FakeReader({("t1", "s1"): detail("s1", "refund?", "Refunded", {"agent.version": "v7"})})
result: Final = await build(reader, TraceSource(trace_id="t1", trace_ref="ref", span_id="s1"))
case: Final = result.cases[0]
assert case.messages == (
DatasetMessage(role="system", content="Be terse"),
DatasetMessage(role="user", content="refund?"),
)
assert case.reply == "Refunded"
assert case.tool_calls == (DatasetToolCall(name="search", arguments=json.dumps({"q": "refund?"})),)
assert case.source == CaseSource(trace_id="t1", trace_ref="ref", span_id="s1")
assert case.agent_version == "v7"
assert case.id == case_id(case.messages, case.reply, case.tool_calls)
def history(tool: str) -> UIMessages:
return messages(
UIMessage(role="user", content="refund A1"),
UIMessage(role="assistant", content="", tool_calls=({"name": tool, "arguments": '{"id":"A1"}'},)),
UIMessage(role="tool", content="found"),
)
@pytest.mark.asyncio
async def test_tool_calls_in_the_conversation_history_are_kept_and_change_the_case_id() -> None:
reader: Final = FakeReader(
{
("t1", "s1"): detail("s1", "refund A1", input_ui=history("lookup_order")),
("t1", "s2"): detail("s2", "refund A1", input_ui=history("cancel_order")),
}
)
result: Final = await build(
reader, TraceSource(trace_id="t1", span_id="s1"), TraceSource(trace_id="t1", span_id="s2")
)
assert result.cases[0].messages[1].tool_calls == (DatasetToolCall(name="lookup_order", arguments='{"id":"A1"}'),)
assert result.cases[1].messages[1].tool_calls == (DatasetToolCall(name="cancel_order", arguments='{"id":"A1"}'),)
assert result.skipped == ()
@pytest.mark.asyncio
async def test_agent_version_is_empty_when_the_span_does_not_report_one() -> None:
reader: Final = FakeReader({("t1", "s1"): detail("s1", "refund?")})
result: Final = await build(reader, TraceSource(trace_id="t1", span_id="s1"))
assert result.cases[0].agent_version == ""
@pytest.mark.asyncio
async def test_whole_trace_uses_the_last_llm_span_that_has_a_conversation() -> None:
reader: Final = FakeReader(
{
("t1", "early"): detail("early", "first"),
("t1", "late"): detail("late", "second"),
("t1", "tool"): detail("tool", "not an llm"),
("t1", "text"): detail("text", "raw", input_ui=UIText(kind="text", text="raw")),
},
traces={
"t1": trace(
span("early", "llm", 1), span("late", "llm", 5), span("tool", "tool", 9), span("text", "llm", 7)
)
},
)
result: Final = await build(reader, TraceSource(trace_id="t1"))
assert tuple(c.source.span_id for c in result.cases) == ("late",)
assert result.cases[0].messages[-1].content == "second"
@pytest.mark.asyncio
async def test_finding_yields_one_case_per_distinct_evidence_span_located_by_its_execution() -> None:
first: Final = execution_id("traces", "alpha", "t1", "ref1")
second: Final = execution_id("traces", "alpha", "t2")
reader: Final = FakeReader(
{("t1", "s1"): detail("s1", "one"), ("t2", "s2"): detail("s2", "two")},
findings=(
stored_finding("f1", Evidence(execution_id=first, span_id="s1", quote="a")),
stored_finding(
"f2",
Evidence(execution_id=first, span_id="s1", quote="b"),
Evidence(execution_id=second, span_id="s2", quote="c"),
),
),
)
result: Final = await build(reader, FindingSource(lens_id="lens", finding_ids=("f1", "f2")))
assert tuple(c.source for c in result.cases) == (
CaseSource(trace_id="t1", trace_ref="ref1", span_id="s1", finding_id="f1", lens_id="lens"),
CaseSource(trace_id="t2", trace_ref="", span_id="s2", finding_id="f2", lens_id="lens"),
)
assert result.skipped == ()
@pytest.mark.asyncio
async def test_text_jsonl_lines_become_cases_and_plain_text_becomes_one_user_message() -> None:
lines: Final = "\n".join(
(
json.dumps({"messages": [{"role": "user", "content": "hi"}], "reply": "hello", "expected": "greet"}),
"",
json.dumps({"messages": [{"role": "user", "content": "bye"}]}),
)
)
jsonl: Final = await build(FakeReader({}), TextSource(text=lines))
plain: Final = await build(FakeReader({}), TextSource(text="Cancel my order\nplease"))
assert tuple((c.messages[0].content, c.reply, c.expected) for c in jsonl.cases) == (
("hi", "hello", "greet"),
("bye", "", ""),
)
assert plain.cases[0].messages == (DatasetMessage(role="user", content="Cancel my order\nplease"),)
@pytest.mark.asyncio
async def test_building_again_from_the_same_sources_adds_nothing() -> None:
reader: Final = FakeReader({("t1", "s1"): detail("s1", "one"), ("t1", "s2"): detail("s2", "two")})
request: Final = BuildRequest(
sources=(TraceSource(trace_id="t1", span_id="s1"), TraceSource(trace_id="t1", span_id="s2"))
)
first: Final = await build_cases(request, reader, ())
second: Final = await build_cases(request, reader, first.cases)
assert len(first.cases) == 2
assert second.cases == ()
assert tuple(s.reason for s in second.skipped) == ("duplicate", "duplicate")
@pytest.mark.asyncio
async def test_each_skip_reason_is_reported_against_its_source() -> None:
huge: Final = "x" * (LENS_DATASET_MAX_CASE_CHARS + 1)
reader: Final = FakeReader(
{("t1", "s1"): detail("s1", "same"), ("t1", "big"): detail("big", huge), ("t1", "s3"): detail("s3", "new")}
)
full: Final = tuple(
DatasetCase(id=str(i), messages=(DatasetMessage(role="user", content=str(i)),), source=CaseSource())
for i in range(LENS_DATASET_MAX_CASES - 1)
)
result: Final = await build_cases(
BuildRequest(
sources=(
TraceSource(trace_id="t1", span_id="missing"),
TraceSource(trace_id="t1", span_id="big"),
TraceSource(trace_id="t1", span_id="s1"),
TraceSource(trace_id="t1", span_id="s1"),
TraceSource(trace_id="t1", span_id="s3"),
)
),
reader,
full,
)
assert tuple(c.source.span_id for c in result.cases) == ("s1",)
assert tuple((s.source.span_id, s.reason) for s in result.skipped) == (
("missing", "no_content"),
("big", "too_large"),
("s1", "over_limit"),
("s3", "over_limit"),
)
class CountingReader(FakeReader):
def __init__(self, spans: Mapping[tuple[str, str], SpanDetail]) -> None:
super().__init__(spans)
self.reads: list[str] = [] # mutable-ok: records which spans the build actually fetched
async def span(self, trace_id: str, span_id: str, trace_ref: str) -> SpanDetail | None:
self.reads.append(span_id)
return await super().span(trace_id, span_id, trace_ref)
@pytest.mark.asyncio
async def test_sources_past_the_case_limit_are_not_read() -> None:
reader: Final = CountingReader({("t1", "s1"): detail("s1", "a"), ("t1", "s2"): detail("s2", "b")})
full: Final = tuple(
DatasetCase(id=str(i), messages=(DatasetMessage(role="user", content=str(i)),), source=CaseSource())
for i in range(LENS_DATASET_MAX_CASES - 1)
)
result: Final = await build_cases(
BuildRequest(sources=(TraceSource(trace_id="t1", span_id="s1"), TraceSource(trace_id="t1", span_id="s2"))),
reader,
full,
)
assert reader.reads == ["s1"]
assert result.skipped == (SkippedCase(source=CaseSource(trace_id="t1", span_id="s2"), reason="over_limit"),)
@pytest.mark.asyncio
async def test_a_malformed_jsonl_line_is_skipped_without_losing_the_valid_lines() -> None:
good: Final = json.dumps({"messages": [{"role": "user", "content": "refund?"}], "reply": "No"})
result: Final = await build(FakeReader({}), TextSource(text=f'{good}\n{{not json\n{{"reply": "no messages"}}'))
assert tuple(c.reply for c in result.cases) == ("No",)
assert tuple(s.reason for s in result.skipped) == ("invalid", "invalid")
@pytest.mark.asyncio
async def test_an_exported_case_rebuilds_with_its_source_and_agent_version() -> None:
original: Final = DatasetCase(
id="",
messages=(DatasetMessage(role="user", content="refund?"),),
reply="No",
expected="Decline politely",
source=CaseSource(trace_id="t1", span_id="s1"),
agent_version="v7",
)
result: Final = await build(FakeReader({}), TextSource(text=export_jsonl((original,))))
rebuilt: Final = result.cases[0]
assert (rebuilt.source, rebuilt.agent_version, rebuilt.expected) == (original.source, "v7", "Decline politely")
@pytest.mark.parametrize(
"case",
(
DatasetCase(
id="",
messages=(DatasetMessage(role="user", content="q"),),
expected="x" * LENS_DATASET_MAX_CASE_CHARS,
source=CaseSource(),
),
DatasetCase(
id="",
messages=(DatasetMessage(role="user", content="q", name="x" * LENS_DATASET_MAX_CASE_CHARS),),
source=CaseSource(),
),
),
)
def test_expected_and_message_names_count_toward_the_case_size_limit(case: DatasetCase) -> None:
assert revision_problem((case,)) is not None
def raw_span(span_id: str, input_ui: UIContent, output_ui: UIContent, raw_input: str, raw_output: str) -> SpanDetail:
return SpanDetail(
span_id=span_id, input_ui=input_ui, output_ui=output_ui, input=raw_input, output=raw_output, attributes={}
)
@pytest.mark.asyncio
async def test_spans_without_chat_messages_fall_back_to_their_text_or_raw_input_and_output() -> None:
fields: Final = UIFields(kind="fields", fields=(UIField(key="q", value="v"),))
reader: Final = FakeReader(
{
("t1", "text"): raw_span(
"text", UIText(kind="text", text="shown"), UIText(kind="text", text="answer"), "asked", "raw answer"
),
("t1", "fields"): raw_span("fields", fields, fields, '{"q":"v"}', '{"ok":true}'),
}
)
result: Final = await build(
reader, TraceSource(trace_id="t1", span_id="text"), TraceSource(trace_id="t1", span_id="fields")
)
assert tuple((c.messages, c.reply) for c in result.cases) == (
((DatasetMessage(role="user", content="asked"),), "answer"),
((DatasetMessage(role="user", content='{"q":"v"}'),), '{"ok":true}'),
)
@pytest.mark.asyncio
async def test_a_span_with_blank_input_and_output_is_skipped_as_no_content() -> None:
blank: Final = UIText(kind="text", text=" ")
reader: Final = FakeReader({("t1", "s1"): raw_span("s1", blank, blank, " ", "")})
result: Final = await build(reader, TraceSource(trace_id="t1", span_id="s1"))
assert result.cases == ()
assert result.skipped == (SkippedCase(source=CaseSource(trace_id="t1", span_id="s1"), reason="no_content"),)
@pytest.mark.asyncio
async def test_a_whole_trace_without_a_conversation_or_that_is_missing_is_skipped_as_no_content() -> None:
reader: Final = FakeReader(
{("t1", "text"): detail("text", "raw", input_ui=UIText(kind="text", text="raw"))},
traces={"t1": trace(span("text", "llm", 1), span("gone", "llm", 2))},
)
result: Final = await build(reader, TraceSource(trace_id="t1"), TraceSource(trace_id="missing"))
assert result.cases == ()
assert result.skipped == (
SkippedCase(source=CaseSource(trace_id="t1"), reason="no_content"),
SkippedCase(source=CaseSource(trace_id="missing"), reason="no_content"),
)
@pytest.mark.asyncio
async def test_finding_evidence_with_an_undecodable_execution_or_a_missing_span_is_skipped() -> None:
located: Final = execution_id("traces", "alpha", "t1", "ref1")
reader: Final = FakeReader(
{},
findings=(
stored_finding(
"f1",
Evidence(execution_id="not-an-execution", span_id="s1", quote="a"),
Evidence(execution_id=located, span_id="gone", quote="b"),
),
),
)
result: Final = await build(reader, FindingSource(lens_id="lens", finding_ids=("f1",)))
assert result.cases == ()
assert result.skipped == (
SkippedCase(source=CaseSource(span_id="s1", finding_id="f1", lens_id="lens"), reason="no_content"),
SkippedCase(
source=CaseSource(trace_id="t1", trace_ref="ref1", span_id="gone", finding_id="f1", lens_id="lens"),
reason="no_content",
),
)
def test_export_writes_only_included_cases_one_per_line() -> None:
kept: Final = DatasetCase(id="a", messages=(DatasetMessage(role="user", content="keep"),), source=CaseSource())
dropped: Final = kept.model_copy(update={"id": "b", "included": False})
lines: Final = export_jsonl((kept, dropped)).splitlines()
assert tuple(DatasetCase.model_validate_json(line) for line in lines) == (kept,)

View file

@ -11,6 +11,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
import { LensServicesProvider, useLensAccessToken, useLensApi, useLiveLensServices } from "./data/LensServices";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { DatasetsView } from "./datasets/DatasetsView";
import { LensSettings } from "./settings/LensSettings";
import { createLensDemo } from "./data/demo/createLensDemo";
import { lensQueries } from "./data/queries";
@ -213,6 +214,9 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
</p>
)}
</TabsContent>
<TabsContent value="datasets" className={PANEL}>
<DatasetsPanel canView={canViewInvestigations} isAdmin={isAdmin} readOnly={readOnly} />
</TabsContent>
</>
)}
{workers && list && (
@ -237,6 +241,12 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
);
}
function DatasetsPanel({ canView, isAdmin, readOnly }: { canView: boolean; isAdmin: boolean; readOnly: boolean }) {
if (!canView)
return <p className="py-6 text-sm text-muted-foreground">Datasets require proxy administrator access.</p>;
return <DatasetsView readOnly={readOnly || !isAdmin} />;
}
function needsSetup(
state: LensReadiness,
location: {
@ -249,7 +259,7 @@ function needsSetup(
issueKey: string | null;
},
) {
if (location.tab === "settings") return false;
if (location.tab === "settings" || location.tab === "datasets") return false;
if (location.requested) return true;
const selected = location.tab === "traces" ? location.trace : location.lensId || location.dialog || location.issueKey;
if (!state.missingTraces || selected) return false;

View file

@ -26,6 +26,8 @@ function useLensServices(): LensServices {
export const useLensApi = (): LensApi => useLensServices().lens;
export const useOptionalLensApi = (): LensApi | null => useContext(LensServicesContext)?.lens ?? null;
export const useLensAccessToken = (): string => useLensServices().accessToken;
export function useLiveLensServices(accessToken: string): LensServices {

View file

@ -2,6 +2,7 @@ import { ApiError } from "@/lib/http/client";
import type { TracesApi } from "@/components/lens/traces/api";
import type { LensServices } from "../LensServices";
import type { LensApi } from "../service";
import { demoDatasetsApi } from "./demoDatasets";
import { createLensDemoData, type LensDemoData } from "./fixtures";
const notInDemo = (): Promise<never> =>
@ -14,6 +15,7 @@ function demoLensApi(data: LensDemoData): LensApi {
const jobs = (lensId: string) => data.lenses.find((lens) => lens.id === lensId)?.jobs;
return {
scope: "demo",
datasets: demoDatasetsApi(data),
lenses: async () => ({ lenses: data.lenses, workers: [], tracing_enabled: true }),
activity: async () => ({ traces: true, requests: false }),
runs: (lensId, offset) => found(jobs(lensId)?.slice(offset)),

View file

@ -0,0 +1,206 @@
import { ApiError } from "@/lib/http/client";
import type { SpanDetail } from "@/components/lens/traces/types";
import { evidenceTarget } from "../../model/findings";
import type { DatasetsApi } from "../../datasets/client";
import type { BuildSource, CaseSource, Dataset, DatasetCase, DatasetMessage, SkippedCase } from "../../datasets/types";
import type { LensDemoData } from "./fixtures";
const MAX_CASES = 200;
const NO_SOURCE: CaseSource = { trace_id: "", trace_ref: "", span_id: "", finding_id: "", lens_id: "" };
const ROLES: ReadonlySet<string> = new Set(["system", "user", "assistant", "tool"]);
type Candidate = DatasetCase | SkippedCase;
const isRole = (role: unknown): role is DatasetMessage["role"] => typeof role === "string" && ROLES.has(role);
function contentHash(text: string): string {
const hash = [...text].reduce((acc, char) => (Math.imul(acc, 31) + char.charCodeAt(0)) | 0, 7);
return (hash >>> 0).toString(16).padStart(8, "0");
}
function parsedMessages(raw: string): DatasetMessage[] {
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((item: { role?: unknown; content?: unknown }) =>
isRole(item?.role) && typeof item.content === "string"
? [{ role: item.role, content: item.content, name: "", tool_calls: [] }]
: [],
);
} catch {
return [];
}
}
function makeCase(messages: DatasetMessage[], reply: string, source: CaseSource): Candidate {
if (messages.length === 0 && !reply) return { source, reason: "no_content" };
const id = contentHash(JSON.stringify({ messages, reply }));
return { id, messages, reply, tool_calls: [], expected: "", included: true, source, agent_version: "" };
}
function caseFromSpan(detail: SpanDetail, source: CaseSource): Candidate {
const messages = parsedMessages(detail.input);
const replies = parsedMessages(detail.output).filter((message) => message.role === "assistant");
const reply = replies.at(-1)?.content ?? detail.output;
return makeCase(
messages.length ? messages : [{ role: "user", content: detail.input, name: "", tool_calls: [] }],
reply,
source,
);
}
function textCases(text: string): Candidate[] {
const lines = text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const parsed = lines.map((line) => {
try {
const value: { messages?: unknown; reply?: unknown } = JSON.parse(line);
return Array.isArray(value.messages) ? value : null;
} catch {
return null;
}
});
if (parsed.length === 0 || parsed.some((value) => value === null))
return [makeCase([{ role: "user", content: text, name: "", tool_calls: [] }], "", NO_SOURCE)];
return parsed.map((value) =>
makeCase(
parsedMessages(JSON.stringify(value?.messages)),
typeof value?.reply === "string" ? value.reply : "",
NO_SOURCE,
),
);
}
const isCase = (candidate: Candidate): candidate is DatasetCase => "id" in candidate;
export function demoDatasetsApi(data: LensDemoData, now: () => Date = () => new Date()): DatasetsApi {
const revisions = new Map<string, Dataset[]>();
const missing = () => Promise.reject(new ApiError("Dataset not found", 404, { detail: "Dataset not found" }));
const latest = (id: string) => revisions.get(id)?.at(-1);
const run = (traceId: string) => data.runs.find(({ trace }) => trace.summary.trace_id === traceId);
const spanCase = (traceId: string, spanId: string, source: CaseSource): Candidate => {
const found = run(traceId);
const detail = spanId
? found?.details.find((span) => span.span_id === spanId)
: found?.details.findLast((span) => found.trace.spans.find((s) => s.span_id === span.span_id)?.type === "llm");
return detail ? caseFromSpan(detail, source) : { source, reason: "no_content" };
};
const sourceCases = (source: BuildSource): Candidate[] => {
if (source.kind === "text") return textCases(source.text);
if (source.kind === "finding") {
const findings = data.lenses
.flatMap((lens) => lens.jobs.flatMap((job) => job.findings ?? []))
.filter((finding) => source.finding_ids.includes(finding.id));
const spans = new Map(
findings.flatMap((finding) =>
finding.evidence.map((evidence) => {
const traceId = evidenceTarget(evidence.execution_id)?.id ?? "";
const origin = { ...NO_SOURCE, trace_id: traceId, span_id: evidence.span_id, finding_id: finding.id };
return [`${traceId}/${evidence.span_id}`, { ...origin, lens_id: source.lens_id }] as const;
}),
),
);
return [...spans.values()].map((origin) => spanCase(origin.trace_id, origin.span_id, origin));
}
const origin = {
...NO_SOURCE,
trace_id: source.trace_id,
trace_ref: source.trace_ref ?? "",
span_id: source.span_id ?? "",
};
return [spanCase(source.trace_id, source.span_id ?? "", origin)];
};
return {
list: async () =>
[...revisions.values()].map((history) => {
const dataset = history.at(-1)!;
return {
id: dataset.id,
name: dataset.name,
agent_name: dataset.agent_name,
revision: dataset.revision,
case_count: dataset.cases.length,
updated_at: dataset.created_at,
};
}),
get: (id, revision) => {
const history = revisions.get(id);
const found = revision === undefined ? history?.at(-1) : history?.find((item) => item.revision === revision);
return found ? Promise.resolve(found) : missing();
},
create: async ({ name, agent_name }) => {
const dataset: Dataset = {
id: `demo-dataset-${revisions.size + 1}`,
name,
agent_name: agent_name ?? "",
team_id: "",
created_at: now().toISOString(),
revision: 0,
created_by: "demo",
cases: [],
};
revisions.set(dataset.id, [dataset]);
return dataset;
},
build: async ({ sources, dataset_id }) => {
const existing = (dataset_id && latest(dataset_id)?.cases) || [];
const seen = new Set(existing.map((item) => item.id));
const candidates = sources.flatMap(sourceCases);
const cases: DatasetCase[] = [];
const skipped: SkippedCase[] = [];
for (const candidate of candidates) {
if (!isCase(candidate)) skipped.push(candidate);
else if (seen.has(candidate.id)) skipped.push({ source: candidate.source, reason: "duplicate" });
else if (existing.length + cases.length >= MAX_CASES)
skipped.push({ source: candidate.source, reason: "over_limit" });
else {
seen.add(candidate.id);
cases.push(candidate);
}
}
return { cases, skipped };
},
saveRevision: async (id, { base_revision, cases }) => {
const current = latest(id);
if (!current) return missing();
if (current.revision !== base_revision)
throw new ApiError("Dataset changed, reload", 409, { detail: "Dataset changed, reload" });
const saved: Dataset = {
...current,
revision: current.revision + 1,
created_at: now().toISOString(),
cases: cases.map((item) => ({
...item,
reply: item.reply ?? "",
tool_calls: item.tool_calls ?? [],
expected: item.expected ?? "",
included: item.included ?? true,
agent_version: item.agent_version ?? "",
messages: item.messages.map((message) => ({
...message,
name: message.name ?? "",
tool_calls: message.tool_calls ?? [],
})),
source: { ...NO_SOURCE, ...item.source },
})),
};
revisions.get(id)!.push(saved);
return saved;
},
exportJsonl: async (id, revision) => {
const history = revisions.get(id);
const found = revision === undefined ? history?.at(-1) : history?.find((item) => item.revision === revision);
if (!found) return missing();
const lines = found.cases.filter((item) => item.included).map((item) => `${JSON.stringify(item)}\n`);
return new Blob(lines, { type: "application/x-ndjson" });
},
evalCases: async (id, revision) => {
const found = revisions.get(id)?.find((item) => item.revision === revision);
if (!found) return missing();
return { dataset_id: id, revision, cases: found.cases.filter((item) => item.included) };
},
};
}

View file

@ -3,6 +3,7 @@ import type { ApiClient } from "@/lib/http/client";
import { getAuthHeaderName } from "@/lib/http/runtime";
import type { Client } from "openapi-fetch";
import type { components, paths } from "@/lib/http/schema";
import { liveDatasetsApi, type DatasetsApi } from "../datasets/client";
import type {
ActivitySelection,
AnalysisModelInfo,
@ -45,6 +46,7 @@ export interface AnalysisKeyRequest {
export interface LensApi {
/** Partitions query caches between backends (one token, or the demo). */
readonly scope: string;
readonly datasets: DatasetsApi;
lenses(): Promise<LensList>;
activity(): Promise<components["schemas"]["ActivityAvailability"]>;
runs(lensId: string, offset: number): Promise<Job[]>;
@ -87,6 +89,7 @@ export function liveLensApi(client: LensClient, apiClient: ApiClient, accessToke
const worker = (worker_id: string) => ({ headers, params: { path: { worker_id } } });
return {
scope: accessToken,
datasets: liveDatasetsApi(client, apiClient, accessToken),
lenses: () => required(client.GET("/lens", { headers })),
activity: () => required(client.GET("/lens/activity/available", { headers })),
runs: (lensId, offset) =>

View file

@ -0,0 +1,138 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, it, vi } from "vitest";
import { renderWithLens, stubGateway } from "@/../tests/lens-test-utils";
import { testQueryClient } from "@/../tests/test-utils";
import { AddToDatasetDialog } from "./AddToDatasetDialog";
import type { Dataset, DatasetCase } from "./types";
const source = { trace_id: "trace-1", trace_ref: "", span_id: "", finding_id: "", lens_id: "" };
const makeCase = (id: string, question: string, reply: string): DatasetCase => ({
id,
messages: [{ role: "user", content: question, name: "", tool_calls: [] }],
reply,
tool_calls: [],
expected: "",
included: true,
source,
agent_version: "",
});
const kept = makeCase("kept", "Where is order 1042?", "It ships tomorrow.");
const junk = makeCase("junk", "asdf", "I did not understand.");
const existing = makeCase("existing", "Can I return headphones?", "Yes, within 30 days.");
const dataset: Dataset = {
id: "ds-1",
name: "support cases",
agent_name: "support_agent",
team_id: "",
created_at: "2026-10-01T00:00:00Z",
revision: 3,
created_by: "admin",
cases: [existing],
};
const sources = [{ kind: "trace" as const, trace_id: "trace-1", trace_ref: "", span_id: "" }];
let proxy = stubGateway();
beforeEach(() => {
testQueryClient.clear();
proxy = stubGateway();
proxy.get.mockImplementation(async (path) => {
if (path === "/lens/datasets")
return [
{ id: "ds-1", name: "support cases", agent_name: "support_agent", revision: 3, case_count: 1, updated_at: "" },
];
if (path === "/lens/datasets/ds-1") return dataset;
throw new Error(`unexpected GET ${path}`);
});
proxy.post.mockImplementation(async (path) => {
if (path === "/lens/datasets/build")
return { cases: [kept, junk], skipped: [{ source: { ...source, trace_id: "trace-2" }, reason: "duplicate" }] };
if (path === "/lens/datasets/ds-1/revisions") return { ...dataset, revision: 4 };
throw new Error(`unexpected POST ${path}`);
});
});
it("saves the dataset's existing cases plus only the ticked new ones, with their expected text", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithLens(<AddToDatasetDialog sources={sources} agentName="support_agent" onClose={onClose} />);
const list = await screen.findByRole("list", { name: "Cases to add" });
expect(within(list).getAllByRole("listitem")[0]).toHaveTextContent("Where is order 1042?It ships tomorrow.");
expect(within(screen.getByRole("list", { name: "Skipped" })).getByText("Already in the dataset")).toBeVisible();
expect(proxy.post.mock.calls.find(([path]) => path === "/lens/datasets/build")?.[1].body).toEqual({
sources,
dataset_id: "ds-1",
});
await user.click(screen.getByRole("checkbox", { name: "Include Case 2" }));
expect(screen.getByText("1 of 2 selected")).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: "Expected for Case 1" }), {
target: { value: "Gives the ship date" },
});
await user.click(screen.getByRole("button", { name: "Save 1 case" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
const saved = proxy.post.mock.calls.find(([path]) => path === "/lens/datasets/ds-1/revisions")?.[1].body;
expect(saved).toEqual({
base_revision: 3,
cases: [existing, { ...kept, expected: "Gives the ship date" }],
});
});
it("tells the user to reload when someone else saved the dataset first", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost");
const method = input instanceof Request ? input.method : init?.method ?? "GET";
if (method === "POST" && url.pathname === "/lens/datasets/ds-1/revisions")
return Response.json({ detail: "Dataset changed, reload" }, { status: 409 });
if (method === "POST") return Response.json({ cases: [kept], skipped: [] });
if (url.pathname === "/lens/datasets/ds-1") return Response.json(dataset);
return Response.json([
{ id: "ds-1", name: "support cases", agent_name: "support_agent", revision: 3, case_count: 1, updated_at: "" },
]);
}),
);
renderWithLens(<AddToDatasetDialog sources={sources} agentName="support_agent" onClose={onClose} />);
await user.click(await screen.findByRole("button", { name: "Save 1 case" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Reload to add to the latest version");
expect(screen.getByRole("button", { name: "Save 1 case" })).toBeDisabled();
expect(onClose).not.toHaveBeenCalled();
});
it("creates a new dataset by name and saves the cases into its first revision", async () => {
const user = userEvent.setup();
const created: Dataset = { ...dataset, id: "ds-new", name: "refund cases", revision: 0, cases: [] };
proxy.get.mockImplementation(async (path) => {
if (path === "/lens/datasets") return [];
throw new Error(`unexpected GET ${path}`);
});
proxy.post.mockImplementation(async (path) => {
if (path === "/lens/datasets/build") return { cases: [kept], skipped: [] };
if (path === "/lens/datasets") return created;
if (path === "/lens/datasets/ds-new/revisions") return { ...created, revision: 1 };
throw new Error(`unexpected POST ${path}`);
});
renderWithLens(<AddToDatasetDialog sources={sources} agentName="support_agent" onClose={vi.fn()} />);
fireEvent.change(await screen.findByRole("textbox", { name: "Name" }), { target: { value: "refund cases" } });
await user.click(await screen.findByRole("button", { name: "Save 1 case" }));
await waitFor(() =>
expect(proxy.post.mock.calls.map(([path, request]) => [path, request.body])).toEqual([
["/lens/datasets/build", { sources, dataset_id: "" }],
["/lens/datasets", { name: "refund cases", agent_name: "support_agent" }],
["/lens/datasets/ds-new/revisions", { base_revision: 0, cases: [kept] }],
]),
);
});

View file

@ -0,0 +1,424 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Check, DatabaseZap, Loader2, RotateCw, TriangleAlert } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { cn } from "@/lib/cva.config";
import { toast } from "@/lib/toast";
import { useTracesLive } from "../traces/api";
import { useOptionalLensApi } from "../data/LensServices";
import { useOptionalOnboarding } from "../onboarding/OnboardingContext";
import { useDatasetRoute, useLensRoute } from "../route";
import {
datasetKeys,
isRevisionConflict,
useBuildCases,
useCreateDataset,
useDataset,
useDatasets,
useSaveRevision,
} from "./api";
import {
casePrompt,
caseReplySummary,
EMPTY_DRAFT,
revisionCases,
setExpected,
SKIP_REASON_TEXT,
toggleCase,
type Draft,
} from "./draft";
import type { BuildSource, Dataset, DatasetCase, DatasetMessage, DatasetSummary, SkippedCase } from "./types";
const NEW_DATASET = "__new__";
const cases = (count: number): string => `${count} ${count === 1 ? "case" : "cases"}`;
/** Admins can save cases; the sample session keeps its datasets in memory so anyone can try it. */
export function useCanAddToDataset(): boolean {
const api = useOptionalLensApi();
const onboarding = useOptionalOnboarding();
const live = useTracesLive();
const canWrite = !!onboarding && onboarding.canInvestigate && !onboarding.readOnly;
return !!api && (!live || canWrite);
}
export interface AddToDatasetDialogProps {
readonly sources: readonly BuildSource[];
readonly agentName?: string;
readonly onClose: () => void;
}
function useOpenSavedDataset() {
const { setTab } = useLensRoute();
const { openDataset } = useDatasetRoute();
return (id: string) => {
setTab("datasets");
openDataset(id);
};
}
/** Defaults to the agent's dataset when one exists, otherwise to a new one once the list has loaded. */
function useDatasetTarget(agentName: string) {
const datasets = useDatasets();
const [picked, setPicked] = useState<string | null>(null);
const forAgent = datasets.data?.find((item) => agentName && item.agent_name === agentName)?.id;
const target = picked ?? forAgent ?? (datasets.isPending ? null : NEW_DATASET);
const existingId = target === NEW_DATASET ? null : target;
return { datasets: datasets.data ?? [], target, existingId, setPicked };
}
export function AddToDatasetDialog({ sources, agentName = "", onClose }: AddToDatasetDialogProps) {
const { datasets, target, existingId, setPicked } = useDatasetTarget(agentName);
const dataset = useDataset(existingId);
const build = useBuildCases({ sources: [...sources], dataset_id: existingId ?? "" }, target !== null);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [name, setName] = useState(agentName ? `${agentName} cases` : "");
const [conflict, setConflict] = useState(false);
const create = useCreateDataset();
const save = useSaveRevision();
const queryClient = useQueryClient();
const openSaved = useOpenSavedDataset();
const built = build.data?.cases ?? [];
const chosen = built.filter((item) => !draft.excluded.has(item.id)).length;
const busy = create.isPending || save.isPending;
const targetReady = existingId ? dataset.isSuccess : name.trim().length > 0;
const hasCases = build.isSuccess && chosen > 0;
const idle = !busy && !conflict;
const canSave = hasCases && targetReady && idle;
const pickTarget = (next: string) => {
setPicked(next);
setConflict(false);
};
const reload = async () => {
setConflict(false);
await queryClient.invalidateQueries({ queryKey: datasetKeys.all() });
};
const baseDataset = async (): Promise<Dataset | undefined> => {
if (existingId) return dataset.data;
return create.mutateAsync({ name: name.trim(), agent_name: agentName }).catch((error: unknown) => {
toast.fromError(error);
return undefined;
});
};
const submit = async () => {
const base = await baseDataset();
if (!base) return;
try {
const saved = await save.mutateAsync({
datasetId: base.id,
body: { base_revision: base.revision, cases: revisionCases(base.cases, built, draft) },
});
toast.success(`Added ${cases(chosen)} to ${saved.name}`, {
description: (
<button type="button" className="underline underline-offset-2" onClick={() => openSaved(saved.id)}>
Open dataset
</button>
),
});
onClose();
} catch (error) {
if (isRevisionConflict(error)) setConflict(true);
else toast.fromError(error);
}
};
return (
<Dialog open onOpenChange={(open) => !open && !busy && onClose()}>
<DialogContent className="max-h-[90vh] grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-4 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add to dataset</DialogTitle>
<DialogDescription>Saves a copy of each conversation, so it stays after the trace expires.</DialogDescription>
</DialogHeader>
<TargetFields datasets={datasets} target={target} name={name} onTarget={pickTarget} onName={setName} />
<CasePreview
build={build}
draft={draft}
chosen={chosen}
onToggle={(id) => setDraft((current) => toggleCase(current, id))}
onExpected={(id, text) => setDraft((current) => setExpected(current, id, text))}
/>
<DialogFooter className="items-center sm:justify-between">
{conflict ? <ConflictNotice onReload={() => void reload()} /> : <span />}
<div className="flex gap-2">
<Button variant="outline" disabled={busy} onClick={onClose}>
Cancel
</Button>
<Button disabled={!canSave} onClick={() => void submit()}>
{busy && <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" />}
{chosen > 0 ? `Save ${cases(chosen)}` : "Save"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface TargetFieldsProps {
readonly datasets: readonly DatasetSummary[];
readonly target: string | null;
readonly name: string;
readonly onTarget: (target: string) => void;
readonly onName: (name: string) => void;
}
function TargetFields({ datasets, target, name, onTarget, onName }: TargetFieldsProps) {
const nameId = useId();
const items = [
{ value: NEW_DATASET, label: "New dataset" },
...datasets.map((item) => ({ value: item.id, label: item.name })),
];
return (
<div className="grid gap-2 sm:grid-cols-2">
<label className="grid gap-1.5 text-xs text-muted-foreground">
Dataset
<Select value={target} items={items} onValueChange={(next: string | null) => next !== null && onTarget(next)}>
<SelectTrigger aria-label="Dataset" size="sm" className="w-full text-sm">
<SelectValue placeholder="Loading datasets…" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW_DATASET}>New dataset</SelectItem>
{datasets.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
<span className="text-xs text-muted-foreground">{cases(item.case_count)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</label>
{target === NEW_DATASET && (
<label htmlFor={nameId} className="grid gap-1.5 text-xs text-muted-foreground">
Name
<Input
id={nameId}
value={name}
maxLength={120}
placeholder="checkout regressions"
onChange={(event) => onName(event.target.value)}
className="h-8"
/>
</label>
)}
</div>
);
}
function ConflictNotice({ onReload }: { onReload: () => void }) {
return (
<p role="alert" className="flex items-center gap-2 text-xs text-destructive">
<TriangleAlert aria-hidden="true" className="size-3.5" />
Someone saved this dataset since you opened it. Reload to add to the latest version.
<Button variant="outline" size="xs" onClick={onReload}>
<RotateCw aria-hidden="true" />
Reload
</Button>
</p>
);
}
interface CasePreviewProps {
readonly build: ReturnType<typeof useBuildCases>;
readonly draft: Draft;
readonly chosen: number;
readonly onToggle: (id: string) => void;
readonly onExpected: (id: string, text: string) => void;
}
function CasePreview({ build, draft, chosen, onToggle, onExpected }: CasePreviewProps) {
if (build.isPending)
return (
<p role="status" className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" />
Reading the conversation…
</p>
);
if (build.isError)
return (
<p role="alert" className="flex items-center gap-2 py-6 text-sm text-destructive">
<TriangleAlert aria-hidden="true" className="size-4" />
{extractProxyErrorMessage(build.error)}
</p>
);
const { cases, skipped } = build.data;
return (
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto">
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium">Cases</p>
<p className="text-xs text-muted-foreground tabular-nums">
{chosen} of {cases.length} selected
</p>
</div>
{cases.length === 0 ? (
<p className="rounded-xl bg-muted/60 px-3 py-4 text-sm text-muted-foreground">Nothing new to add.</p>
) : (
<ul aria-label="Cases to add" className="grid gap-2.5">
{cases.map((item, index) => (
<CaseTile
key={item.id}
item={item}
index={index}
selected={!draft.excluded.has(item.id)}
expected={draft.expected.get(item.id) ?? item.expected}
onToggle={() => onToggle(item.id)}
onExpected={(text) => onExpected(item.id, text)}
/>
))}
</ul>
)}
{skipped.length > 0 && <SkippedList skipped={skipped} />}
</div>
);
}
function CaseTile({
item,
index,
selected,
expected,
onToggle,
onExpected,
}: {
item: DatasetCase;
index: number;
selected: boolean;
expected: string;
onToggle: () => void;
onExpected: (text: string) => void;
}) {
const label = `Case ${index + 1}`;
return (
<li
className={cn(
"relative grid gap-2 rounded-xl p-3 transition-colors",
selected
? "bg-background ring-[1.5px] ring-foreground ring-inset"
: "bg-muted/60 text-muted-foreground hover:bg-muted",
)}
>
{selected && (
<Check
aria-hidden="true"
className="absolute top-3 right-3 size-3.5 animate-in fade-in-0 zoom-in-50 duration-200 motion-reduce:animate-none"
/>
)}
<div className="flex min-w-0 items-start gap-2.5 pr-6">
<Checkbox checked={selected} onCheckedChange={onToggle} aria-label={`Include ${label}`} className="mt-0.5" />
<span className="grid min-w-0 gap-0.5">
<span className="line-clamp-2 text-sm font-medium text-foreground">{casePrompt(item) || label}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{caseReplySummary(item)}</span>
</span>
</div>
{item.messages.length > 0 && (
<details className="pl-6.5 text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Conversation · {item.messages.length} {item.messages.length === 1 ? "message" : "messages"}
</summary>
<CaseConversation messages={item.messages} />
</details>
)}
{selected && (
<label className="grid gap-1 pl-6.5 text-xs text-muted-foreground">
Expected
<Textarea
value={expected}
rows={1}
placeholder="What a good reply does (optional)"
onChange={(event) => onExpected(event.target.value)}
aria-label={`Expected for ${label}`}
className="min-h-8 text-sm"
/>
</label>
)}
</li>
);
}
export function CaseConversation({ messages }: { messages: readonly DatasetMessage[] }) {
return (
<ol className="mt-2 grid gap-1.5">
{messages.map((message, index) => (
<li key={index} className="grid gap-0.5 rounded-md bg-muted/50 px-2.5 py-1.5">
<span className="font-medium text-foreground capitalize">{message.name || message.role}</span>
{message.content && <span className="line-clamp-4 whitespace-pre-wrap">{message.content}</span>}
{message.tool_calls.map((call, callIndex) => (
<code key={callIndex} className="line-clamp-2 font-mono break-all text-muted-foreground">
{call.name}({call.arguments})
</code>
))}
</li>
))}
</ol>
);
}
function SkippedList({ skipped }: { skipped: readonly SkippedCase[] }) {
return (
<div className="grid gap-1.5">
<p className="text-sm font-medium">Skipped</p>
<ul aria-label="Skipped" className="grid gap-1 text-xs text-muted-foreground">
{skipped.map((item, index) => (
<li key={`${item.source.trace_id}-${item.source.span_id}-${index}`} className="flex gap-2">
<span className="text-foreground">{SKIP_REASON_TEXT[item.reason]}</span>
{item.source.trace_id && <span className="truncate">trace {item.source.trace_id.slice(0, 12)}</span>}
</li>
))}
</ul>
</div>
);
}
export interface AddToDatasetButtonProps {
readonly sources: readonly BuildSource[];
readonly agentName?: string;
readonly label?: string;
readonly className?: string;
}
/** Renders nothing for viewers who cannot save; the dialog mounts, and builds cases, only once opened. */
export function AddToDatasetButton({
sources,
agentName,
label = "Add to dataset",
className,
}: AddToDatasetButtonProps) {
const allowed = useCanAddToDataset();
const [open, setOpen] = useState(false);
if (!allowed) return null;
return (
<>
<Button
variant="outline"
size="xs"
className={cn("h-7 shrink-0 gap-1.5 text-xs shadow-none active:scale-[0.97]", className)}
onClick={() => setOpen(true)}
>
<DatabaseZap aria-hidden="true" className="size-3" />
{label}
</Button>
{open && <AddToDatasetDialog sources={sources} agentName={agentName} onClose={() => setOpen(false)} />}
</>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { useId, type ReactNode } from "react";
import { SquareArrowOutUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useOpenSourceTrace } from "../route";
import { MessageList } from "../traces/detail/content/Messages";
import { Section } from "../traces/detail/content/Section";
import { IdChip } from "../traces/ui/IdChip";
import { caseConversation, caseOutput, shortCaseId, type CaseEdit } from "./caseView";
import type { DatasetCase } from "./types";
export interface CasePanelProps {
readonly item: DatasetCase;
readonly datasetName: string;
readonly editable: boolean;
readonly onEdit: (edit: CaseEdit) => void;
}
export function CasePanel({ item, datasetName, editable, onEdit }: CasePanelProps) {
return (
<Tabs defaultValue="case" className="min-h-0 flex-1 gap-0">
<header className="flex shrink-0 flex-col gap-1.5 px-4 pt-3 pb-2">
<div className="flex min-w-0 items-center gap-2">
<h2 className="min-w-0 truncate text-base font-semibold text-foreground">
Case #{shortCaseId(item.id)} <span className="font-normal text-muted-foreground">@ {datasetName}</span>
</h2>
<IdChip value={item.id} label="Copy case ID" />
<label className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
Included
<Switch
size="sm"
checked={item.included}
disabled={!editable}
onCheckedChange={(included) => onEdit({ included })}
/>
</label>
</div>
</header>
<div className="shrink-0 border-b px-4">
<TabsList variant="line" aria-label="Case sections" className="h-9 gap-4 px-0">
<TabsTrigger value="case" className="flex-none px-0 text-sm">
Case
</TabsTrigger>
<TabsTrigger value="source" className="flex-none px-0 text-sm">
Source
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="case" className="min-h-0 overflow-auto pt-1">
<CaseBody item={item} editable={editable} onEdit={onEdit} />
</TabsContent>
<TabsContent value="source" className="min-h-0 overflow-auto">
<SourceBody item={item} />
</TabsContent>
</Tabs>
);
}
function CaseBody({ item, editable, onEdit }: Omit<CasePanelProps, "datasetName">) {
const expectedId = useId();
const conversation = caseConversation(item);
const output = caseOutput(item);
return (
<>
<Section title="Input" count={conversation.length}>
{conversation.length ? <MessageList messages={conversation} /> : <Empty>No conversation recorded</Empty>}
</Section>
<Section title="Output">
{output.length ? <MessageList messages={output} /> : <Empty>No reply recorded</Empty>}
</Section>
<section aria-label="Expected" className="flex flex-col gap-2 px-4 pt-2 pb-5">
<label htmlFor={expectedId} className="text-sm font-semibold text-foreground">
Expected
</label>
{editable ? (
<Textarea
id={expectedId}
value={item.expected}
placeholder="What a good reply looks like"
className="min-h-24 bg-background text-sm"
onChange={(event) => onEdit({ expected: event.target.value })}
/>
) : (
<p id={expectedId} className="text-sm whitespace-pre-wrap text-foreground">
{item.expected || <span className="text-muted-foreground italic">Not set</span>}
</p>
)}
</section>
</>
);
}
function Empty({ children }: { children: ReactNode }) {
return <p className="py-2 text-sm text-muted-foreground italic">{children}</p>;
}
const SOURCE_FIELDS = [
["Trace", "trace_id"],
["Span", "span_id"],
["Finding", "finding_id"],
["Investigation", "lens_id"],
] as const;
function SourceBody({ item }: { item: DatasetCase }) {
const openSourceTrace = useOpenSourceTrace();
const { source } = item;
return (
<div className="flex flex-col gap-4 p-4">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-6 gap-y-2 text-sm">
{SOURCE_FIELDS.map(([label, key]) => (
<div key={key} className="contents">
<dt className="text-muted-foreground">{label}</dt>
<dd className="flex min-w-0 items-center gap-1">
{source[key] ? (
<IdChip value={source[key]} label={`Copy ${label.toLowerCase()} ID`} showValue />
) : (
<span className="text-muted-foreground italic">None</span>
)}
</dd>
</div>
))}
</dl>
{source.trace_id && (
<Button
size="sm"
variant="outline"
className="self-start"
onClick={() =>
openSourceTrace({ traceId: source.trace_id, traceRef: source.trace_ref, spanId: source.span_id })
}
>
Open source trace
<SquareArrowOutUpRight className="size-3.5" />
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,212 @@
"use client";
import {
getCoreRowModel,
useReactTable,
type CellContext,
type ColumnDef,
type TableOptions,
} from "@tanstack/react-table";
import { createContext, useContext, type ReactNode } from "react";
import { SquareArrowOutUpRight } from "lucide-react";
import { useMediaQuery } from "usehooks-ts";
import { Inspector } from "@/components/shared/Inspector";
import { InspectorTable } from "@/components/shared/InspectorTable";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/cva.config";
import { useOpenSourceTrace } from "../route";
import { FINDING_PANEL_WIDTH_KEY } from "../storage";
import { caseInput, caseReplyText, toolCallCount } from "./caseView";
import type { DatasetCase } from "./types";
const CASE_HEIGHT = 36;
const MOBILE_CASE_HEIGHT = 52;
const MUTED_ITALIC = "text-muted-foreground/80 italic";
interface CaseTableContextValue {
readonly editable: boolean;
readonly onToggle: (id: string, included: boolean) => void;
}
const CaseTableContext = createContext<CaseTableContextValue | null>(null);
function useCaseTable(): CaseTableContextValue {
const value = useContext(CaseTableContext);
if (value === null) throw new Error("Case cells must be rendered inside CaseTable");
return value;
}
export const caseLabel = (index: number): string => `Case ${index + 1}`;
type Cell = CellContext<DatasetCase, unknown>;
function IncludeCell({ row }: Cell) {
const { editable, onToggle } = useCaseTable();
return (
<Checkbox
aria-label={`Include ${caseLabel(row.index).toLowerCase()}`}
checked={row.original.included}
disabled={!editable}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onCheckedChange={(included) => onToggle(row.original.id, included)}
/>
);
}
function InputText({ item }: { item: DatasetCase }) {
const input = caseInput(item);
if (!input) return <span className={MUTED_ITALIC}>No input</span>;
return (
<>
<span className="text-muted-foreground">{input.role}: </span>
<span className="text-foreground">{input.text}</span>
</>
);
}
function ReplyText({ item }: { item: DatasetCase }) {
const reply = caseReplyText(item);
return reply ? <span>{reply}</span> : <span className={MUTED_ITALIC}>No reply</span>;
}
function InputCell({ row: { original: item } }: Cell) {
return (
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate">
<InputText item={item} />
</span>
<span className="truncate text-muted-foreground md:hidden">
<ReplyText item={item} />
</span>
</span>
);
}
function ReplyCell({ row: { original: item } }: Cell) {
return (
<span className="block truncate text-foreground">
<ReplyText item={item} />
</span>
);
}
function ExpectedCell({ row: { original: item } }: Cell) {
return item.expected ? (
<span className="block truncate text-foreground">{item.expected}</span>
) : (
<span className={MUTED_ITALIC}>Not set</span>
);
}
function SourceCell({ row: { original: item } }: Cell) {
const openSourceTrace = useOpenSourceTrace();
const { trace_id: traceId, trace_ref: traceRef, span_id: spanId } = item.source;
if (!traceId) return null;
return (
<button
type="button"
aria-label="Open source trace"
title="Open source trace"
onClick={(event) => {
event.stopPropagation();
openSourceTrace({ traceId, traceRef, spanId });
}}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<SquareArrowOutUpRight className="size-3.5" />
</button>
);
}
function ToolCallsCell({ row: { original: item } }: Cell) {
const count = toolCallCount(item);
if (count === 0) return null;
return (
<span
title={`${count} tool ${count === 1 ? "call" : "calls"}`}
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-xs tabular-nums text-foreground"
>
{count}
</span>
);
}
const COLUMNS: ColumnDef<DatasetCase>[] = [
{
id: "include",
size: 40,
header: "Include",
cell: IncludeCell,
meta: { headerClassName: "sr-only", className: "pr-0" },
},
{ id: "input", header: "Input", cell: InputCell },
{ id: "reply", header: "Reply", cell: ReplyCell },
{ id: "expected", size: 220, header: "Expected", cell: ExpectedCell },
{ id: "source", size: 72, header: "Source", cell: SourceCell },
{ id: "tools", size: 104, header: "Tool calls", cell: ToolCallsCell, meta: { numeric: true } },
];
export interface CaseTableProps {
readonly cases: readonly DatasetCase[];
readonly editable: boolean;
readonly selectedId: string | null;
readonly onSelect: (id: string | null) => void;
readonly onToggle: (id: string, included: boolean) => void;
readonly children: (item: DatasetCase) => ReactNode;
}
export function CaseTable({ cases, editable, selectedId, onSelect, onToggle, children }: CaseTableProps) {
const desktop = useMediaQuery("(min-width: 768px)");
const tableOptions: TableOptions<DatasetCase> = {
data: [...cases],
columns: COLUMNS,
defaultColumn: { size: undefined },
state: { columnVisibility: { reply: desktop, expected: desktop, source: desktop } },
getRowId: (item) => item.id,
autoResetAll: false,
getCoreRowModel: getCoreRowModel(),
};
const table = useReactTable(tableOptions);
const selected = cases.find((item) => item.id === selectedId) ?? null;
const included = cases.filter((item) => item.included).length;
return (
<Inspector.Root
items={cases}
itemKey={(item) => item.id}
selected={selected}
onSelectedChange={(item) => onSelect(item?.id ?? null)}
noun="case"
storageKey={FINDING_PANEL_WIDTH_KEY}
>
<CaseTableContext.Provider value={{ editable, onToggle }}>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-card">
<InspectorTable.Root table={table}>
<InspectorTable.Grid aria-label="Cases" className="text-xs md:min-w-[860px]">
<InspectorTable.Header />
<InspectorTable.Body<DatasetCase> rowHeight={() => (desktop ? CASE_HEIGHT : MOBILE_CASE_HEIGHT)}>
{(row) => (
<InspectorTable.Row
row={row}
item={row.original}
tabIndex={0}
aria-label={caseLabel(row.index)}
className={cn("group h-13 md:h-9", !row.original.included && "[&>td:not(:first-child)]:opacity-50")}
/>
)}
</InspectorTable.Body>
</InspectorTable.Grid>
</InspectorTable.Root>
<footer className="flex h-8 shrink-0 items-center border-t bg-muted/30 px-3 text-xs text-muted-foreground">
{cases.length} {cases.length === 1 ? "case" : "cases"} · {included} included
</footer>
</div>
</CaseTableContext.Provider>
<Inspector.Panel<DatasetCase> label="Case details" testId="case-panel">
{children}
</Inspector.Panel>
</Inspector.Root>
);
}

View file

@ -0,0 +1,217 @@
"use client";
import { useState } from "react";
import { ChevronRight, Database, Download, Loader2, TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StateMessage } from "@/components/shared/StateMessage";
import { useDatasetRoute } from "../route";
import { IdChip } from "../traces/ui/IdChip";
import { isRevisionConflict, useDataset, useExportDataset, useSaveRevision } from "./api";
import { CasePanel } from "./CasePanel";
import { CaseTable } from "./CaseTable";
import { changedCount, withEdits, type CaseEdit, type CaseEdits } from "./caseView";
import type { Dataset } from "./types";
export interface DatasetDetailProps {
readonly datasetId: string;
readonly readOnly: boolean;
readonly onBack: () => void;
}
export function DatasetDetail({ datasetId, readOnly, onBack }: DatasetDetailProps) {
const { revision, setRevision } = useDatasetRoute();
const latest = useDataset(datasetId);
const viewed = useDataset(datasetId, revision);
if (latest.isPending || viewed.isPending)
return (
<StateMessage
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title="Loading dataset…"
description="Fetching its cases."
/>
);
const error = latest.error ?? viewed.error;
if (error || !latest.data || !viewed.data)
return (
<StateMessage
role="alert"
tone="destructive"
icon={<TriangleAlert className="size-5" />}
title="Couldn't load this dataset"
description={error?.message ?? "The proxy returned no dataset."}
>
<Button size="sm" variant="outline" onClick={onBack}>
All datasets
</Button>
</StateMessage>
);
return (
<DatasetRevision
key={viewed.data.revision}
dataset={viewed.data}
latestRevision={latest.data.revision}
readOnly={readOnly}
onBack={onBack}
onPickRevision={(next) => setRevision(next === latest.data.revision ? null : next)}
onShowLatest={() => setRevision(null)}
onReload={() => {
setRevision(null);
void latest.refetch();
}}
/>
);
}
interface DatasetRevisionProps {
readonly dataset: Dataset;
readonly latestRevision: number;
readonly readOnly: boolean;
readonly onBack: () => void;
readonly onPickRevision: (revision: number) => void;
readonly onShowLatest: () => void;
readonly onReload: () => void;
}
function DatasetRevision(props: DatasetRevisionProps) {
const { dataset, latestRevision, readOnly, onBack, onPickRevision, onShowLatest, onReload } = props;
const { caseId, setCaseId } = useDatasetRoute();
const [edits, setEdits] = useState<CaseEdits>({});
const save = useSaveRevision();
const exportDataset = useExportDataset();
const isLatest = dataset.revision === latestRevision;
const editable = isLatest && !readOnly;
const cases = withEdits(dataset.cases, edits);
const changed = changedCount(dataset.cases, edits);
const edit = (id: string, next: CaseEdit) =>
setEdits((current) => ({ ...current, [id]: { ...current[id], ...next } }));
const saveRevision = () =>
save.mutate(
{ datasetId: dataset.id, body: { base_revision: dataset.revision, cases } },
{ onSuccess: onShowLatest },
);
const reload = () => {
save.reset();
setEdits({});
onReload();
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<header className="flex shrink-0 flex-col gap-1 border-b bg-background px-3 pt-2.5 pb-2 sm:px-4">
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-xs text-muted-foreground">
<button type="button" onClick={onBack} className="rounded hover:text-foreground hover:underline">
Datasets
</button>
<ChevronRight aria-hidden="true" className="size-3" />
<span className="truncate">{dataset.name}</span>
</nav>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Database aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<h2 className="min-w-0 truncate text-base font-semibold">{dataset.name}</h2>
<IdChip value={dataset.id} label="Copy dataset ID" />
<span className="hidden truncate text-xs text-muted-foreground sm:inline">
{dataset.agent_name || "Any agent"}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<RevisionPicker revision={dataset.revision} latest={latestRevision} onPick={onPickRevision} />
<Button
size="sm"
variant="outline"
disabled={exportDataset.isPending}
onClick={() => exportDataset.mutate({ id: dataset.id, name: dataset.name, revision: dataset.revision })}
>
<Download className="size-3.5" />
Export JSONL
</Button>
{editable && (
<Button size="sm" disabled={changed === 0 || save.isPending} onClick={saveRevision}>
{save.isPending && <Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />}
Save as revision {dataset.revision + 1}
</Button>
)}
</div>
</div>
</header>
<SaveProblem error={save.error ?? exportDataset.error} onReload={reload} />
{!isLatest && (
<p role="status" className="border-b px-4 py-2 text-xs text-muted-foreground">
Revision {dataset.revision} is read-only. Switch to the latest revision to make changes.
</p>
)}
{cases.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
This revision has no cases. Add some from a trace or a finding.
</p>
) : (
<CaseTable
cases={cases}
editable={editable}
selectedId={caseId}
onSelect={setCaseId}
onToggle={(id, included) => edit(id, { included })}
>
{(item) => (
<CasePanel
key={item.id}
item={item}
datasetName={dataset.name}
editable={editable}
onEdit={(next) => edit(item.id, next)}
/>
)}
</CaseTable>
)}
</div>
);
}
function SaveProblem({ error, onReload }: { error: Error | null; onReload: () => void }) {
if (!error) return null;
const conflict = isRevisionConflict(error);
return (
<div
role="alert"
className="flex items-center justify-between gap-3 border-b border-destructive/30 bg-destructive/5 px-4 py-2 text-sm text-destructive"
>
<span className="min-w-0">
{conflict
? "Someone saved a newer revision while you were editing. Reload to see it, then make your changes again."
: error.message}
</span>
{conflict && (
<Button variant="ghost" size="sm" onClick={onReload}>
Reload
</Button>
)}
</div>
);
}
function RevisionPicker({
revision,
latest,
onPick,
}: {
revision: number;
latest: number;
onPick: (revision: number) => void;
}) {
const revisions = Array.from({ length: latest + 1 }, (_, offset) => latest - offset);
return (
<select
aria-label="Revision"
className="h-8 rounded-md border-0 bg-transparent pr-7 pl-2 text-xs text-muted-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
value={revision}
onChange={(event) => onPick(Number(event.target.value))}
>
{revisions.map((value) => (
<option key={value} value={value}>
Revision {value}
{value === latest ? " (latest)" : ""}
</option>
))}
</select>
);
}

View file

@ -0,0 +1,203 @@
import { fireEvent, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { testQueryClient } from "@/../tests/test-utils";
import { readRequest, renderWithLens, stubGateway, type GatewayRequest } from "@/../tests/lens-test-utils";
import { DatasetsView } from "./DatasetsView";
import type { Dataset, DatasetCase, DatasetSummary } from "./types";
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
proxyBaseUrl: "",
getProxyBaseUrl: () => "",
}));
const refundCase: DatasetCase = {
id: "case-refund",
messages: [
{ role: "user", content: "Can I get a refund for order 42?", name: "", tool_calls: [] },
{ role: "assistant", content: "", name: "", tool_calls: [{ name: "lookup_order", arguments: '{"id": 42}' }] },
],
reply: "I issued the refund.",
tool_calls: [{ name: "issue_refund", arguments: '{"order": 42}' }],
expected: "",
included: true,
source: { trace_id: "trace-1", trace_ref: "", span_id: "span-1", finding_id: "", lens_id: "" },
agent_version: "",
};
const shippingCase: DatasetCase = {
...refundCase,
id: "case-shipping",
messages: [{ role: "user", content: "Where is my package?", name: "", tool_calls: [] }],
reply: "It ships tomorrow.",
tool_calls: [],
source: { ...refundCase.source, trace_id: "trace-2", span_id: "" },
};
const revisionOne: Dataset = {
id: "ds-1",
name: "Refund agent regressions",
agent_name: "refund-agent",
team_id: "",
created_at: "2026-10-01T10:00:00Z",
revision: 1,
created_by: "admin",
cases: [refundCase],
};
const revisionTwo: Dataset = {
...revisionOne,
revision: 2,
cases: [{ ...refundCase, expected: "Refunds above $40 need approval" }, shippingCase],
};
const summary: DatasetSummary = {
id: revisionTwo.id,
name: revisionTwo.name,
agent_name: revisionTwo.agent_name,
revision: revisionTwo.revision,
case_count: revisionTwo.cases.length,
updated_at: "2026-10-02T10:00:00Z",
};
let proxy = stubGateway();
const serveDatasets = (path: string, request: GatewayRequest) => {
if (path === "/lens/datasets") return [summary];
if (path !== `/lens/datasets/${revisionTwo.id}`) throw new Error(`unexpected GET ${path}`);
return request.query.revision === "1" ? revisionOne : revisionTwo;
};
const savedBodies = () =>
proxy.post.mock.calls
.filter(([path]) => path === `/lens/datasets/${revisionTwo.id}/revisions`)
.map(([, request]) => request.body);
beforeEach(() => {
testQueryClient.clear();
proxy = stubGateway();
proxy.get.mockImplementation(serveDatasets);
});
describe("DatasetsView", () => {
it("lists datasets and opens one from the list", async () => {
const user = userEvent.setup();
renderWithLens(<DatasetsView />, { searchParams: "?tab=datasets" });
const row = await screen.findByRole("row", { name: /Refund agent regressions/ });
expect(
within(row)
.getAllByRole("cell")
.map((cell) => cell.textContent),
).toEqual([`${summary.name}refund-agent`, "2", "2", ""]);
await user.click(row);
expect(await screen.findByRole("heading", { name: summary.name })).toBeInTheDocument();
const first = screen.getByRole("row", { name: "Case 1" });
expect(
within(first)
.getAllByRole("cell")
.map((cell) => cell.textContent),
).toEqual(["", "user: Can I get a refund for order 42?I issued the refund.", "2"]);
await user.click(first);
const panel = await screen.findByRole("complementary", { name: "Case details" });
expect(within(panel).getByRole("heading", { name: `Case #case-ref @ ${summary.name}` })).toBeInTheDocument();
const input = within(panel).getByRole("region", { name: "Input" });
expect(input).toHaveTextContent("Can I get a refund for order 42?");
expect(within(input).getByText("lookup_order")).toBeInTheDocument();
const output = within(panel).getByRole("region", { name: "Output" });
expect(output).toHaveTextContent("I issued the refund.");
expect(within(output).getByText("issue_refund")).toBeInTheDocument();
expect(within(panel).getByRole("textbox", { name: "Expected" })).toHaveValue("Refunds above $40 need approval");
});
it("shows an empty state when there are no datasets", async () => {
proxy.get.mockResolvedValue([]);
renderWithLens(<DatasetsView />, { searchParams: "?tab=datasets" });
expect(await screen.findByText("No datasets yet")).toBeInTheDocument();
});
it("saves an edited expected answer and an unticked case as the next revision", async () => {
const user = userEvent.setup();
proxy.post.mockResolvedValue({ ...revisionTwo, revision: 3 });
renderWithLens(<DatasetsView />, { searchParams: `?tab=datasets&dataset=${revisionTwo.id}` });
await user.click(await screen.findByRole("row", { name: "Case 1" }));
const save = screen.getByRole("button", { name: "Save as revision 3" });
expect(save).toBeDisabled();
const panel = await screen.findByRole("complementary", { name: "Case details" });
fireEvent.change(within(panel).getByRole("textbox", { name: "Expected" }), {
target: { value: "Refunds need approval" },
});
await user.click(screen.getByRole("checkbox", { name: "Include case 2" }));
expect(screen.getByRole("contentinfo")).toHaveTextContent("2 cases · 1 included");
await user.click(save);
await vi.waitFor(() => expect(savedBodies()).toHaveLength(1));
expect(savedBodies()[0]).toEqual({
base_revision: 2,
cases: [
{ ...refundCase, expected: "Refunds need approval" },
{ ...shippingCase, included: false },
],
});
});
it("tells the user to reload when someone saved first", async () => {
const user = userEvent.setup();
const gateway = vi.mocked(fetch).getMockImplementation() as typeof fetch;
vi.mocked(fetch).mockImplementation(async (input, init) =>
(await readRequest(input, init)).method === "POST"
? Response.json({ detail: "Dataset changed, reload" }, { status: 409 })
: gateway(input, init),
);
renderWithLens(<DatasetsView />, { searchParams: `?tab=datasets&dataset=${revisionTwo.id}` });
await user.click(await screen.findByRole("checkbox", { name: "Include case 1" }));
await user.click(screen.getByRole("button", { name: "Save as revision 3" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone saved a newer revision");
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
});
it("shows an older revision's content read-only when picked", async () => {
const user = userEvent.setup();
renderWithLens(<DatasetsView />, { searchParams: `?tab=datasets&dataset=${revisionTwo.id}` });
await screen.findByRole("row", { name: "Case 2" });
await user.selectOptions(screen.getByRole("combobox", { name: "Revision" }), "1");
expect(
await screen.findByText("Revision 1 is read-only. Switch to the latest revision to make changes."),
).toBeInTheDocument();
expect(screen.queryByRole("row", { name: "Case 2" })).not.toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Include case 1" })).toHaveAttribute("aria-disabled", "true");
expect(screen.queryByRole("button", { name: /Save as revision/ })).not.toBeInTheDocument();
await user.click(screen.getByRole("row", { name: "Case 1" }));
const panel = await screen.findByRole("complementary", { name: "Case details" });
expect(within(panel).getByRole("region", { name: "Expected" })).toHaveTextContent("Not set");
expect(within(panel).queryByRole("textbox")).not.toBeInTheDocument();
expect(within(panel).getByRole("switch", { name: "Included" })).toHaveAttribute("aria-disabled", "true");
});
it("exports the viewed revision as JSONL with the auth header", async () => {
const user = userEvent.setup();
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:dataset");
vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined);
vi.spyOn(HTMLAnchorElement.prototype, "click").mockReturnValue(undefined);
renderWithLens(<DatasetsView />, {
accessToken: "sk-admin",
searchParams: `?tab=datasets&dataset=${revisionTwo.id}&revision=1`,
});
await screen.findByText(/Revision 1 is read-only/);
await user.click(screen.getByRole("button", { name: "Export JSONL" }));
await vi.waitFor(() =>
expect(proxy.get).toHaveBeenCalledWith(
`/lens/datasets/${revisionTwo.id}/export`,
expect.objectContaining({ query: { revision: "1" }, authorization: "Bearer sk-admin" }),
),
);
});
});

View file

@ -0,0 +1,168 @@
"use client";
import {
getCoreRowModel,
useReactTable,
type CellContext,
type ColumnDef,
type TableOptions,
} from "@tanstack/react-table";
import { ChevronRight, Database, Loader2, TriangleAlert } from "lucide-react";
import { useMediaQuery } from "usehooks-ts";
import { Inspector } from "@/components/shared/Inspector";
import { InspectorTable } from "@/components/shared/InspectorTable";
import { Button } from "@/components/ui/button";
import { formatActivityTimestamp } from "@/utils/activityTimestamp";
import { FINDING_PANEL_WIDTH_KEY } from "../storage";
import { StateMessage } from "@/components/shared/StateMessage";
import { useDatasetRoute } from "../route";
import { useDatasets } from "./api";
import { DatasetDetail } from "./DatasetDetail";
import type { DatasetSummary } from "./types";
export interface DatasetsViewProps {
readonly readOnly?: boolean;
}
export function DatasetsView({ readOnly = false }: DatasetsViewProps) {
const { datasetId, openDataset } = useDatasetRoute();
return (
<section aria-label="Datasets" className="flex w-full min-w-0 flex-1 flex-col">
{datasetId ? (
<DatasetDetail key={datasetId} datasetId={datasetId} readOnly={readOnly} onBack={() => openDataset(null)} />
) : (
<DatasetList onOpen={openDataset} />
)}
</section>
);
}
function DatasetList({ onOpen }: { onOpen: (id: string) => void }) {
const datasets = useDatasets();
if (datasets.isPending)
return (
<StateMessage
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title="Loading datasets…"
description="Fetching the datasets you can see."
/>
);
if (datasets.isError)
return (
<StateMessage
role="alert"
tone="destructive"
icon={<TriangleAlert className="size-5" />}
title="Couldn't load datasets"
description={datasets.error.message}
>
<Button size="sm" onClick={() => void datasets.refetch()}>
Try again
</Button>
</StateMessage>
);
if (datasets.data.length === 0)
return (
<StateMessage
role="status"
icon={<Database className="size-5" />}
title="No datasets yet"
description="Open a trace or a finding and choose Add to dataset to save real conversations for an agent."
/>
);
return <DatasetTable datasets={datasets.data} onOpen={onOpen} />;
}
type Cell = CellContext<DatasetSummary, unknown>;
const DATASET_HEIGHT = 36;
function NameCell({ row: { original: dataset } }: Cell) {
return (
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground">{dataset.name}</span>
<span className="truncate text-xs text-muted-foreground md:hidden">{dataset.agent_name || "Any agent"}</span>
</span>
);
}
function AgentCell({ row: { original: dataset } }: Cell) {
return <span className="block truncate text-muted-foreground">{dataset.agent_name || "Any agent"}</span>;
}
function RevisionCell({ row: { original: dataset } }: Cell) {
return <span className="tabular-nums text-muted-foreground">{dataset.revision}</span>;
}
function CasesCell({ row: { original: dataset } }: Cell) {
return (
<span className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-xs tabular-nums text-foreground">
{dataset.case_count}
</span>
);
}
function UpdatedCell({ row: { original: dataset } }: Cell) {
return <span className="whitespace-nowrap text-muted-foreground">{formatActivityTimestamp(dataset.updated_at)}</span>;
}
function OpenCell() {
return <ChevronRight aria-hidden="true" className="mr-2 ml-auto size-3.5 text-muted-foreground/60" />;
}
const COLUMNS: ColumnDef<DatasetSummary>[] = [
{ id: "name", header: "Dataset", cell: NameCell },
{ id: "agent", size: 160, header: "Agent", cell: AgentCell },
{ id: "revision", size: 80, header: "Revision", cell: RevisionCell, meta: { numeric: true } },
{ id: "cases", size: 64, header: "Cases", cell: CasesCell, meta: { numeric: true } },
{ id: "updated", size: 200, header: "Updated", cell: UpdatedCell },
{ id: "open", size: 40, header: "Open", cell: OpenCell, meta: { headerClassName: "sr-only", className: "pl-0" } },
];
function DatasetTable({ datasets, onOpen }: { datasets: readonly DatasetSummary[]; onOpen: (id: string) => void }) {
const desktop = useMediaQuery("(min-width: 768px)");
const tableOptions: TableOptions<DatasetSummary> = {
data: [...datasets],
columns: COLUMNS,
defaultColumn: { size: undefined },
state: { columnVisibility: { agent: desktop, updated: desktop } },
getRowId: (dataset) => dataset.id,
autoResetAll: false,
getCoreRowModel: getCoreRowModel(),
};
const table = useReactTable(tableOptions);
const cases = datasets.reduce((total, dataset) => total + dataset.case_count, 0);
return (
<Inspector.Root
items={datasets}
itemKey={(dataset) => dataset.id}
selected={null}
onSelectedChange={(dataset) => dataset && onOpen(dataset.id)}
noun="dataset"
storageKey={FINDING_PANEL_WIDTH_KEY}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-card">
<InspectorTable.Root table={table}>
<InspectorTable.Grid aria-label="Datasets" className="text-xs md:min-w-[720px]">
<InspectorTable.Header />
<InspectorTable.Body<DatasetSummary> rowHeight={() => (desktop ? DATASET_HEIGHT : 48)}>
{(row) => (
<InspectorTable.Row
row={row}
item={row.original}
tabIndex={0}
aria-label={row.original.name}
className="group h-12 md:h-9"
/>
)}
</InspectorTable.Body>
</InspectorTable.Grid>
</InspectorTable.Root>
<footer className="flex h-8 shrink-0 items-center border-t bg-muted/30 px-3 text-xs text-muted-foreground">
{datasets.length} {datasets.length === 1 ? "dataset" : "datasets"} · {cases} {cases === 1 ? "case" : "cases"}
</footer>
</div>
</Inspector.Root>
);
}

View file

@ -0,0 +1,108 @@
"use client";
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "@/lib/http/client";
import { lensKeys } from "../data/queries";
import { useLensApi } from "../data/LensServices";
import type { DatasetsApi } from "./client";
import type { BuildRequest, Dataset, DatasetCreate, RevisionSave } from "./types";
export const datasetKeys = {
all: () => [...lensKeys.all, "datasets"] as const,
lists: () => [...datasetKeys.all(), "list"] as const,
list: (scope: string) => [...datasetKeys.lists(), { scope }] as const,
details: () => [...datasetKeys.all(), "detail"] as const,
detail: (scope: string, datasetId: string, revision: number | null) =>
[...datasetKeys.details(), { scope, datasetId, revision }] as const,
build: (scope: string, request: BuildRequest) => [...datasetKeys.all(), "build", { scope, request }] as const,
};
export const datasetQueries = {
list(api: DatasetsApi, scope: string) {
return queryOptions({ queryKey: datasetKeys.list(scope), queryFn: () => api.list() });
},
detail(api: DatasetsApi, scope: string, datasetId: string | null, revision: number | null = null) {
return queryOptions({
queryKey: datasetKeys.detail(scope, datasetId ?? "", revision),
queryFn: () => api.get(datasetId ?? "", revision ?? undefined),
enabled: !!datasetId,
});
},
build(api: DatasetsApi, scope: string, request: BuildRequest, enabled = true) {
const options = {
queryKey: datasetKeys.build(scope, request),
queryFn: () => api.build(request),
enabled,
staleTime: Infinity,
gcTime: 0,
};
return queryOptions(options);
},
};
export const isRevisionConflict = (error: unknown): boolean => error instanceof ApiError && error.status === 409;
export function useDatasets() {
const api = useLensApi();
return useQuery(datasetQueries.list(api.datasets, api.scope));
}
export function useDataset(datasetId: string | null, revision: number | null = null) {
const api = useLensApi();
return useQuery(datasetQueries.detail(api.datasets, api.scope, datasetId, revision));
}
export function useBuildCases(request: BuildRequest, enabled = true) {
const api = useLensApi();
return useQuery(datasetQueries.build(api.datasets, api.scope, request, enabled));
}
export function useInvalidateDatasets() {
const client = useQueryClient();
return () =>
Promise.all([
client.invalidateQueries({ queryKey: datasetKeys.lists() }),
client.invalidateQueries({ queryKey: datasetKeys.details() }),
]);
}
export function useCreateDataset() {
const api = useLensApi();
const invalidate = useInvalidateDatasets();
return useMutation({
retry: false,
mutationFn: (body: DatasetCreate) => api.datasets.create(body),
onSettled: invalidate,
});
}
export function useSaveRevision() {
const api = useLensApi();
const invalidate = useInvalidateDatasets();
return useMutation({
retry: false,
mutationFn: ({ datasetId, body }: { datasetId: string; body: RevisionSave }) =>
api.datasets.saveRevision(datasetId, body),
onSettled: invalidate,
});
}
export async function downloadDataset(api: DatasetsApi, dataset: Pick<Dataset, "id" | "name" | "revision">) {
const blob = await api.exportJsonl(dataset.id, dataset.revision);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${dataset.name || dataset.id}-r${dataset.revision}.jsonl`;
link.click();
URL.revokeObjectURL(url);
}
export function useExportDataset() {
const api = useLensApi();
return useMutation({
retry: false,
mutationFn: (dataset: Pick<Dataset, "id" | "name" | "revision">) => downloadDataset(api.datasets, dataset),
});
}

View file

@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import {
caseConversation,
caseInput,
caseOutput,
caseReplyText,
changedCount,
toolCallCount,
withEdits,
} from "./caseView";
import type { DatasetCase } from "./types";
const source = { trace_id: "t", trace_ref: "", span_id: "", finding_id: "", lens_id: "" };
const makeCase = (id: string, overrides: Partial<DatasetCase> = {}): DatasetCase => ({
id,
messages: [{ role: "user", content: `question ${id}`, name: "", tool_calls: [] }],
reply: `reply ${id}`,
tool_calls: [],
expected: "",
included: true,
source,
agent_version: "",
...overrides,
});
describe("withEdits / changedCount", () => {
it("applies only the edited fields and counts a case once however many of its fields changed", () => {
const cases = [makeCase("a", { expected: "old" }), makeCase("b"), makeCase("c")];
const edits = { a: { expected: "new", included: false }, b: { included: true }, c: { expected: "" } };
expect(withEdits(cases, edits).map((item) => [item.id, item.expected, item.included])).toEqual([
["a", "new", false],
["b", "", true],
["c", "", true],
]);
expect(changedCount(cases, edits)).toBe(1);
});
it("does not count an edit that matches the saved value", () => {
expect(changedCount([makeCase("a")], { a: { included: true, expected: "" } })).toBe(0);
});
});
describe("caseInput", () => {
it("picks the first user turn over a system prompt that comes before it", () => {
const item = makeCase("a", {
messages: [
{ role: "system", content: "Be helpful", name: "", tool_calls: [] },
{ role: "user", content: "first ask", name: "", tool_calls: [] },
{ role: "user", content: "second ask", name: "", tool_calls: [] },
],
});
expect(caseInput(item)).toEqual({ role: "user", text: "first ask" });
});
it("falls back to the first message with text when there is no user turn", () => {
const item = makeCase("a", { messages: [{ role: "system", content: "Be helpful", name: "", tool_calls: [] }] });
expect(caseInput(item)).toEqual({ role: "system", text: "Be helpful" });
expect(caseInput(makeCase("b", { messages: [] }))).toBeNull();
});
});
describe("toolCallCount", () => {
it("counts calls made during the conversation and in the reply", () => {
const call = { name: "lookup", arguments: "{}" };
const item = makeCase("a", {
messages: [{ role: "assistant", content: "", name: "", tool_calls: [call, call] }],
tool_calls: [call],
});
expect(toolCallCount(item)).toBe(3);
});
});
describe("caseConversation / caseOutput", () => {
it("parses tool call arguments into objects and keeps unparseable ones as text", () => {
const item = makeCase("a", {
messages: [
{ role: "assistant", content: "", name: "", tool_calls: [{ name: "lookup", arguments: '{"id": 42}' }] },
],
reply: "",
tool_calls: [{ name: "refund", arguments: "not json" }],
});
expect(caseConversation(item)[0].tool_calls).toEqual([{ name: "lookup", args: { id: 42 } }]);
const reply = { role: "assistant", content: "", name: "", tool_calls: [{ name: "refund", args: "not json" }] };
expect(caseOutput(item)).toEqual([reply]);
});
it("has no output when the agent neither replied nor called a tool", () => {
expect(caseOutput(makeCase("a", { reply: "" }))).toEqual([]);
});
});
describe("JSON text from older revisions", () => {
const summaryReply = '[{"content":"The result is 5050.","tool_names":[]}]';
it("reads an assistant summary reply as an assistant message in the panel and as plain text in the table", () => {
const item = makeCase("a", { reply: summaryReply });
expect(caseOutput(item)).toEqual([{ role: "assistant", content: "The result is 5050.", tool_calls: [] }]);
expect(caseReplyText(item)).toBe("The result is 5050.");
});
it("keeps the recorded tool calls after a decoded reply", () => {
const item = makeCase("a", { reply: summaryReply, tool_calls: [{ name: "sum", arguments: '{"n":100}' }] });
expect(caseOutput(item).map((message) => [message.content, message.tool_calls])).toEqual([
["The result is 5050.", []],
["", [{ name: "sum", args: { n: 100 } }]],
]);
});
it("expands a user turn whose text is a JSON message list into those messages", () => {
const content = JSON.stringify([{ role: "user", content: "Add 1 to 100" }]);
const item = makeCase("a", { messages: [{ role: "user", content, name: "", tool_calls: [] }] });
expect(caseConversation(item).map((message) => [message.role, message.content])).toEqual([
["user", "Add 1 to 100"],
]);
expect(caseInput(item)).toEqual({ role: "user", text: "Add 1 to 100" });
});
it("leaves ordinary text untouched", () => {
const item = makeCase("a", { reply: "Plain [not json] reply" });
expect(caseReplyText(item)).toBe("Plain [not json] reply");
expect(caseOutput(item)[0].content).toBe("Plain [not json] reply");
});
});

View file

@ -0,0 +1,66 @@
import { toTraceMessage } from "../traces/detail/content/payload";
import type { TraceMessage } from "../traces/types";
import { parseAssistantSummary, parseMessages } from "../traces/utils";
import type { DatasetCase, DatasetMessage } from "./types";
export interface CaseEdit {
readonly included?: boolean;
readonly expected?: string;
}
export type CaseEdits = Readonly<Record<string, CaseEdit>>;
export const editedCase = (item: DatasetCase, edit: CaseEdit | undefined): DatasetCase =>
edit ? { ...item, included: edit.included ?? item.included, expected: edit.expected ?? item.expected } : item;
export const withEdits = (cases: readonly DatasetCase[], edits: CaseEdits): DatasetCase[] =>
cases.map((item) => editedCase(item, edits[item.id]));
export const changedCount = (cases: readonly DatasetCase[], edits: CaseEdits): number =>
cases.filter((item) => {
const next = editedCase(item, edits[item.id]);
return next.included !== item.included || next.expected !== item.expected;
}).length;
export const shortCaseId = (id: string): string => id.slice(0, 8);
/** Text that is really a JSON message list or an assistant summary, decoded the way the trace view reads it. */
const decodedMessages = (text: string): TraceMessage[] | null => parseAssistantSummary(text) ?? parseMessages(text);
const previewText = (text: string): string =>
decodedMessages(text)
?.map((message) => message.content)
.filter(Boolean)
.join(" ") ?? text;
const normalizedMessages = (message: DatasetMessage): TraceMessage[] => {
const decoded = message.content ? decodedMessages(message.content) : null;
if (!decoded) return [toTraceMessage(message)];
const calls = message.tool_calls.length ? [toTraceMessage({ ...message, content: "" })] : [];
return [...decoded, ...calls];
};
export interface CaseInput {
readonly role: string;
readonly text: string;
}
/** The turn that opened the conversation: the first user message, else the first message with any text. */
export const caseInput = (item: DatasetCase): CaseInput | null => {
const opening =
item.messages.find((message) => message.role === "user" && message.content) ??
item.messages.find((message) => message.content);
return opening ? { role: opening.role, text: previewText(opening.content) } : null;
};
export const caseReplyText = (item: DatasetCase): string => previewText(item.reply);
export const toolCallCount = (item: DatasetCase): number =>
item.tool_calls.length + item.messages.reduce((total, message) => total + message.tool_calls.length, 0);
export const caseConversation = (item: DatasetCase): TraceMessage[] => item.messages.flatMap(normalizedMessages);
export const caseOutput = (item: DatasetCase): TraceMessage[] => {
const reply: DatasetMessage = { role: "assistant", content: item.reply, name: "", tool_calls: item.tool_calls };
return item.reply || item.tool_calls.length ? normalizedMessages(reply) : [];
};

View file

@ -0,0 +1,67 @@
import type { Client } from "openapi-fetch";
import type { ApiClient } from "@/lib/http/client";
import { getAuthHeaderName } from "@/lib/http/runtime";
import type { paths } from "@/lib/http/schema";
import type {
BuildRequest,
BuildResult,
Dataset,
DatasetCreate,
DatasetSummary,
EvalCases,
RevisionSave,
} from "./types";
export interface DatasetsApi {
list(): Promise<readonly DatasetSummary[]>;
get(datasetId: string, revision?: number): Promise<Dataset>;
create(body: DatasetCreate): Promise<Dataset>;
build(body: BuildRequest): Promise<BuildResult>;
saveRevision(datasetId: string, body: RevisionSave): Promise<Dataset>;
exportJsonl(datasetId: string, revision?: number): Promise<Blob>;
evalCases(datasetId: string, revision: number): Promise<EvalCases>;
}
async function required<T>(request: Promise<{ data?: T }>): Promise<T> {
const { data } = await request;
if (data === undefined) throw new Error("The proxy returned an empty response");
return data;
}
export const datasetExportPath = (datasetId: string): string =>
`/lens/datasets/${encodeURIComponent(datasetId)}/export`;
export function liveDatasetsApi(client: Client<paths>, apiClient: ApiClient, accessToken: string): DatasetsApi {
const headers = { [getAuthHeaderName()]: `Bearer ${accessToken}` };
return {
list: () => required(client.GET("/lens/datasets", { headers })),
get: (datasetId, revision) =>
required(
client.GET("/lens/datasets/{dataset_id}", {
headers,
params: { path: { dataset_id: datasetId }, query: { revision } },
}),
),
create: (body) => required(client.POST("/lens/datasets", { headers, body })),
build: (body) => required(client.POST("/lens/datasets/build", { headers, body })),
saveRevision: (datasetId, body) =>
required(
client.POST("/lens/datasets/{dataset_id}/revisions", {
headers,
params: { path: { dataset_id: datasetId } },
body,
}),
),
exportJsonl: (datasetId, revision) =>
apiClient.getBlob(datasetExportPath(datasetId), { accessToken, query: { revision } }),
evalCases: (datasetId, revision) =>
required(
client.GET("/lens/datasets/{dataset_id}/revisions/{revision}/cases", {
headers,
params: { path: { dataset_id: datasetId, revision } },
}),
),
};
}

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { caseReplySummary, EMPTY_DRAFT, revisionCases, setExpected, toggleCase } from "./draft";
import type { DatasetCase } from "./types";
const source = { trace_id: "t", trace_ref: "", span_id: "", finding_id: "", lens_id: "" };
const makeCase = (id: string, overrides: Partial<DatasetCase> = {}): DatasetCase => ({
id,
messages: [{ role: "user", content: `question ${id}`, name: "", tool_calls: [] }],
reply: `reply ${id}`,
tool_calls: [],
expected: "",
included: true,
source,
agent_version: "",
...overrides,
});
describe("revisionCases", () => {
it("keeps every existing case first and appends only the ticked new ones with their edited expected", () => {
const existing = [makeCase("old", { expected: "kept" })];
const built = [makeCase("a"), makeCase("b"), makeCase("c")];
const draft = setExpected(toggleCase(EMPTY_DRAFT, "b"), "c", "should refund");
expect(revisionCases(existing, built, draft).map((item) => [item.id, item.expected])).toEqual([
["old", "kept"],
["a", ""],
["c", "should refund"],
]);
});
it("re-ticking a case puts it back", () => {
const built = [makeCase("a")];
expect(revisionCases([], built, toggleCase(toggleCase(EMPTY_DRAFT, "a"), "a"))).toHaveLength(1);
});
});
describe("caseReplySummary", () => {
it("shows tool calls when the agent only called tools", () => {
expect(
caseReplySummary(makeCase("a", { reply: "", tool_calls: [{ name: "lookup", arguments: '{"id":1}' }] })),
).toBe('lookup({"id":1})');
});
});

View file

@ -0,0 +1,52 @@
import type { DatasetCase, DatasetToolCall, SkipReason } from "./types";
export const SKIP_REASON_TEXT: Readonly<Record<SkipReason, string>> = {
duplicate: "Already in the dataset",
no_content: "No conversation or reply was recorded",
too_large: "Too large to store as one case",
over_limit: "The dataset is full",
invalid: "Not a valid case line",
};
export interface Draft {
readonly excluded: ReadonlySet<string>;
readonly expected: ReadonlyMap<string, string>;
}
export const EMPTY_DRAFT: Draft = { excluded: new Set(), expected: new Map() };
export const toggleCase = (draft: Draft, id: string): Draft => {
const excluded = new Set(draft.excluded);
if (!excluded.delete(id)) excluded.add(id);
return { ...draft, excluded };
};
export const setExpected = (draft: Draft, id: string, text: string): Draft => ({
...draft,
expected: new Map(draft.expected).set(id, text),
});
export const pickedCases = (built: readonly DatasetCase[], draft: Draft): DatasetCase[] =>
built
.filter((item) => !draft.excluded.has(item.id))
.map((item) => ({ ...item, expected: draft.expected.get(item.id) ?? item.expected }));
/** A save replaces the whole list, so the dataset's current cases go first and the ticked new ones follow. */
export const revisionCases = (
existing: readonly DatasetCase[],
built: readonly DatasetCase[],
draft: Draft,
): DatasetCase[] => [...existing, ...pickedCases(built, draft)];
export const casePrompt = (item: DatasetCase): string => {
const users = item.messages.filter((message) => message.role === "user");
return (users.at(-1) ?? item.messages.at(-1))?.content ?? "";
};
const callText = (call: DatasetToolCall): string => `${call.name}(${call.arguments})`;
export const caseReplySummary = (item: DatasetCase): string => {
const calls = item.tool_calls.map(callText).join(", ");
if (item.reply && calls) return `${item.reply} · ${calls}`;
return item.reply || calls || "No reply recorded";
};

View file

@ -0,0 +1,18 @@
import type { paths } from "@/lib/http/schema";
type Json<T> = T extends { content: { "application/json": infer Body } } ? Body : never;
export type Dataset = Json<paths["/lens/datasets/{dataset_id}"]["get"]["responses"][200]>;
export type DatasetSummary = Json<paths["/lens/datasets"]["get"]["responses"][200]>[number];
export type DatasetCreate = Json<paths["/lens/datasets"]["post"]["requestBody"]>;
export type BuildRequest = Json<paths["/lens/datasets/build"]["post"]["requestBody"]>;
export type BuildResult = Json<paths["/lens/datasets/build"]["post"]["responses"][200]>;
export type BuildSource = BuildRequest["sources"][number];
export type RevisionSave = Json<paths["/lens/datasets/{dataset_id}/revisions"]["post"]["requestBody"]>;
export type EvalCases = Json<paths["/lens/datasets/{dataset_id}/revisions/{revision}/cases"]["get"]["responses"][200]>;
export type DatasetCase = Dataset["cases"][number];
export type DatasetMessage = DatasetCase["messages"][number];
export type DatasetToolCall = DatasetCase["tool_calls"][number];
export type SkippedCase = BuildResult["skipped"][number];
export type SkipReason = SkippedCase["reason"];
export type CaseSource = DatasetCase["source"];

View file

@ -7,6 +7,7 @@ import { Inspector } from "@/components/shared/Inspector";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { AddToDatasetButton } from "../datasets/AddToDatasetDialog";
import { evidenceTarget } from "../model/findings";
import { runTime } from "../model/format";
import { findingAgents, findingKey, type OwnedFinding, sampledExecutions } from "../model/inbox";
@ -19,6 +20,7 @@ export const ownedFindingKey = (owned: OwnedFinding): string => findingKey(owned
export interface FindingDetailsProps {
readonly finding: Finding;
readonly lensId?: string;
readonly agents?: readonly string[];
readonly sampledRuns: Sample["executions"];
readonly readOnly: boolean;
@ -29,6 +31,7 @@ export interface FindingDetailsProps {
export function FindingDetails({
finding,
lensId,
agents = [],
sampledRuns,
readOnly,
@ -59,6 +62,14 @@ export function FindingDetails({
{finding.investigation_runs.length === 1 ? "run" : "runs"}
</p>
)}
{lensId && finding.evidence.length > 0 && (
<AddToDatasetButton
sources={[{ kind: "finding", lens_id: lensId, finding_ids: [finding.id] }]}
agentName={agents[0]}
label="Add evidence to dataset"
className="mt-1 self-start"
/>
)}
</header>
<div className="space-y-6 p-4">
{finding.brief ? (
@ -184,6 +195,7 @@ export function FindingPanelBody({
<FindingDetails
key={ownedFindingKey(owned)}
finding={owned.finding}
lensId={owned.lens.id}
agents={findingAgents(owned.lens, owned.finding)}
sampledRuns={sampledRuns ?? sampledExecutions(owned.lens)}
readOnly={readOnly}

View file

@ -22,3 +22,5 @@ export function useOnboarding(): Onboarding {
if (!onboarding) throw new Error("useOnboarding needs an OnboardingProvider above it");
return onboarding;
}
export const useOptionalOnboarding = (): Onboarding | null => useContext(OnboardingContext);

View file

@ -1,6 +1,6 @@
"use client";
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import { parseAsBoolean, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import { useCallback } from "react";
import { OPEN_TRACE_PARSERS, RUN_FILTER_PARSERS } from "@/components/lens/traces/routing";
@ -8,6 +8,7 @@ export const LENS_TABS = {
traces: "Traces",
findings: "Findings",
investigations: "Investigations",
datasets: "Datasets",
settings: "Settings",
} as const;
export type LensTab = keyof typeof LENS_TABS;
@ -33,6 +34,8 @@ const INBOX_PARSERS = {
priority: parseAsStringLiteral(["all", "high", "medium", "low"]).withDefault("all"),
};
const DATASET_PARSERS = { dataset: parseAsString, revision: parseAsInteger, case: parseAsString };
const LIST_PARSERS = { search: parseAsString.withDefault("") };
const RESULT_PARSERS = {
@ -57,6 +60,7 @@ const SESSION_PARSERS = {
...INBOX_PARSERS,
...RESULT_PARSERS,
...DIALOG_PARSERS,
...DATASET_PARSERS,
};
const nulls = <K extends string>(keys: readonly K[]) =>
Object.fromEntries(keys.map((key) => [key, null])) as Record<K, null>;
@ -198,3 +202,45 @@ export function useInboxFilters() {
setPriority: (priority: "all" | "high" | "medium" | "low") => void setParams({ priority }),
};
}
/** `revision` null means the latest revision, so a dataset link keeps following new saves. */
export function useDatasetRoute() {
const [{ dataset, revision, case: caseId }, setParams] = useQueryStates(DATASET_PARSERS, { history: "push" });
const openDataset = useCallback(
(next: string | null) => void setParams({ dataset: next, revision: null, case: null }),
[setParams],
);
const setRevision = useCallback(
(next: number | null) => void setParams({ revision: next }, { history: "replace" }),
[setParams],
);
const setCaseId = useCallback((next: string | null) => void setParams({ case: next }), [setParams]);
return { datasetId: dataset, revision, caseId, openDataset, setRevision, setCaseId };
}
const SOURCE_TRACE_PARSERS = { tab: LENS_PARSERS.tab, ...OPEN_TRACE_PARSERS };
export interface SourceTrace {
readonly traceId: string;
readonly traceRef: string;
readonly spanId: string;
}
const FRESH_TRACE_VIEW = { view: null, span_tab: null, steps_q: null, errors: null } as const;
export function useOpenSourceTrace() {
const [, setParams] = useQueryStates(SOURCE_TRACE_PARSERS, { history: "push" });
return useCallback(
(source: SourceTrace) => {
const opened = {
...FRESH_TRACE_VIEW,
tab: "traces" as const,
trace: source.traceId,
trace_ref: source.traceRef || null,
span: source.spanId || null,
};
void setParams(opened);
},
[setParams],
);
}

View file

@ -9,6 +9,7 @@ import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
import { AddToDatasetButton } from "../../../datasets/AddToDatasetDialog";
import type { TraceHandoff } from "../../api";
import { runCost } from "../../list/AgentTracesTable";
import { traceRefOf, traceShareUrl } from "../../routing";
@ -143,6 +144,10 @@ export function RunHeader({
<RefreshCw className={cn("size-3", refreshing && "animate-spin")} />
Refresh
</Button>
<AddToDatasetButton
sources={[{ kind: "trace", trace_id: summary.trace_id, trace_ref: summary.trace_ref ?? "", span_id: "" }]}
agentName={traceAgentNames(summary)[0]}
/>
<CopyButton
label="Copy link"
icon={Link}

View file

@ -1,6 +1,8 @@
"use client";
import CopyButton from "@/components/shared/CopyButton";
import { AddToDatasetButton } from "../../../datasets/AddToDatasetDialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTracesApi } from "../../api";
@ -23,6 +25,8 @@ export interface SpanTabProps {
type Fact = readonly [label: string, value: string];
const TURN_TYPES: ReadonlySet<Span["type"]> = new Set(["agent", "llm"]);
const spanFacts = (span: Span): readonly Fact[] => {
const tokens = span.input_tokens + span.output_tokens;
const optional: readonly (Fact | null)[] = [
@ -55,7 +59,19 @@ export function SpanPane({
idValue={span.span_id}
facts={spanFacts(span)}
links={<SpendLogLink span={span} accessToken={accessToken} traceStartMs={traceStartMs} />}
actions={<CopyButton variant="action" value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />}
actions={
<>
{TURN_TYPES.has(span.type) && (
<AddToDatasetButton
sources={[{ kind: "trace", trace_id: traceId, trace_ref: traceRef ?? "", span_id: span.span_id }]}
agentName={span.agent}
label="Add this turn to dataset"
className="h-6"
/>
)}
<CopyButton variant="action" value={handoff.text} label="Copy step" copiedLabel={handoff.copied} />
</>
}
onClose={onClose}
/>
<Tabs value={tab} onValueChange={(value) => onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">

View file

@ -8983,6 +8983,109 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/datasets": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Datasets */
get: operations["list_datasets_lens_datasets_get"];
put?: never;
/** Create Dataset */
post: operations["create_dataset_lens_datasets_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/datasets/build": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Build Dataset Cases */
post: operations["build_dataset_cases_lens_datasets_build_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/datasets/{dataset_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Read Dataset */
get: operations["read_dataset_lens_datasets__dataset_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/datasets/{dataset_id}/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Export Dataset */
get: operations["export_dataset_lens_datasets__dataset_id__export_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/datasets/{dataset_id}/revisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Save Revision */
post: operations["save_revision_lens_datasets__dataset_id__revisions_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/datasets/{dataset_id}/revisions/{revision}/cases": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Eval Cases */
get: operations["eval_cases_lens_datasets__dataset_id__revisions__revision__cases_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/preview/sample": {
parameters: {
query?: never;
@ -27625,6 +27728,23 @@ export interface components {
/** Month */
month: string;
};
/** BuildRequest */
BuildRequest: {
/**
* Dataset Id
* @default
*/
dataset_id: string;
/** Sources */
sources: (components["schemas"]["TraceSource"] | components["schemas"]["FindingSource"] | components["schemas"]["TextSource"])[];
};
/** BuildResult */
BuildResult: {
/** Cases */
cases: components["schemas"]["DatasetCase"][];
/** Skipped */
skipped: components["schemas"]["SkippedCase"][];
};
/**
* BulkDeleteUserRequest
* @description Body of `POST /management/v1/users/bulk_delete`.
@ -28427,6 +28547,34 @@ export interface components {
/** Threshold */
threshold: number;
};
/** CaseSource */
CaseSource: {
/**
* Finding Id
* @default
*/
finding_id: string;
/**
* Lens Id
* @default
*/
lens_id: string;
/**
* Span Id
* @default
*/
span_id: string;
/**
* Trace Id
* @default
*/
trace_id: string;
/**
* Trace Ref
* @default
*/
trace_ref: string;
};
/** ChangePasswordRequest */
ChangePasswordRequest: {
/** Current Password */
@ -31266,6 +31414,116 @@ export interface components {
*/
total_tokens: number;
};
/** Dataset */
Dataset: {
/** Agent Name */
agent_name: string;
/** Cases */
cases: components["schemas"]["DatasetCase"][];
/**
* Created At
* Format: date-time
*/
created_at: string;
/** Created By */
created_by: string;
/** Id */
id: string;
/** Name */
name: string;
/** Revision */
revision: number;
/** Team Id */
team_id: string;
};
/** DatasetCase */
DatasetCase: {
/**
* Agent Version
* @default
*/
agent_version: string;
/**
* Expected
* @default
*/
expected: string;
/** Id */
id: string;
/**
* Included
* @default true
*/
included: boolean;
/** Messages */
messages: components["schemas"]["DatasetMessage"][];
/**
* Reply
* @default
*/
reply: string;
source: components["schemas"]["CaseSource"];
/**
* Tool Calls
* @default []
*/
tool_calls: components["schemas"]["DatasetToolCall"][];
};
/** DatasetCreate */
DatasetCreate: {
/**
* Agent Name
* @default
*/
agent_name: string;
/** Name */
name: string;
};
/** DatasetMessage */
DatasetMessage: {
/** Content */
content: string;
/**
* Name
* @default
*/
name: string;
/**
* Role
* @enum {string}
*/
role: "system" | "user" | "assistant" | "tool";
/**
* Tool Calls
* @default []
*/
tool_calls: components["schemas"]["DatasetToolCall"][];
};
/** DatasetSummary */
DatasetSummary: {
/** Agent Name */
agent_name: string;
/** Case Count */
case_count: number;
/** Id */
id: string;
/** Name */
name: string;
/** Revision */
revision: number;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
};
/** DatasetToolCall */
DatasetToolCall: {
/** Arguments */
arguments: string;
/** Name */
name: string;
};
/**
* DefaultInternalUserParams
* @description Default parameters to apply when a new user signs in via SSO or is created on the /user/new API endpoint
@ -31877,6 +32135,15 @@ export interface components {
/** Updated At */
updated_at?: number | null;
};
/** EvalCases */
EvalCases: {
/** Cases */
cases: components["schemas"]["DatasetCase"][];
/** Dataset Id */
dataset_id: string;
/** Revision */
revision: number;
};
/** Evidence */
Evidence: {
/** Execution Id */
@ -32275,6 +32542,18 @@ export interface components {
/** Title */
title: string;
};
/** FindingSource */
FindingSource: {
/** Finding Ids */
finding_ids: string[];
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "finding";
/** Lens Id */
lens_id: string;
};
/** FindingUpdate */
FindingUpdate: {
/**
@ -44089,6 +44368,13 @@ export interface components {
/** Execution Id */
execution_id: string;
};
/** RevisionSave */
RevisionSave: {
/** Base Revision */
base_revision: number;
/** Cases */
cases: components["schemas"]["DatasetCase"][];
};
/**
* RoleMappings
* @description Configuration for mapping SSO groups to LiteLLM roles.
@ -45244,6 +45530,15 @@ export interface components {
/** Version */
version?: string;
};
/** SkippedCase */
SkippedCase: {
/**
* Reason
* @enum {string}
*/
reason: "duplicate" | "no_content" | "too_large" | "over_limit" | "invalid";
source: components["schemas"]["CaseSource"];
};
/** Span */
Span: {
/** Agent */
@ -46644,6 +46939,16 @@ export interface components {
} & {
[key: string]: unknown;
};
/** TextSource */
TextSource: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "text";
/** Text */
text: string;
};
/** TierCohortStatistic */
TierCohortStatistic: {
/** Cohort */
@ -47414,6 +47719,26 @@ export interface components {
[key: string]: components["schemas"]["JsonValue"];
}[];
};
/** TraceSource */
TraceSource: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
kind: "trace";
/**
* Span Id
* @default
*/
span_id: string;
/** Trace Id */
trace_id: string;
/**
* Trace Ref
* @default
*/
trace_ref: string;
};
/** TraceSummary */
TraceSummary: {
/** Agent Count */
@ -62504,6 +62829,225 @@ export interface operations {
};
};
};
list_datasets_lens_datasets_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DatasetSummary"][];
};
};
};
};
create_dataset_lens_datasets_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DatasetCreate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Dataset"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
build_dataset_cases_lens_datasets_build_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BuildRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BuildResult"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
read_dataset_lens_datasets__dataset_id__get: {
parameters: {
query?: {
revision?: number | null;
};
header?: never;
path: {
dataset_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Dataset"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export_dataset_lens_datasets__dataset_id__export_get: {
parameters: {
query?: {
revision?: number | null;
};
header?: never;
path: {
dataset_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/x-ndjson": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
save_revision_lens_datasets__dataset_id__revisions_post: {
parameters: {
query?: never;
header?: never;
path: {
dataset_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RevisionSave"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Dataset"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
eval_cases_lens_datasets__dataset_id__revisions__revision__cases_get: {
parameters: {
query?: never;
header?: never;
path: {
dataset_id: string;
revision: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EvalCases"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
preview_sample_lens_preview_sample_post: {
parameters: {
query?: never;