litellm/tests/proxy_behavior/lens/evaluate.py
moe-berri e9cfba2c17
feat(lens): isolate ingestion and investigations in a Rust service (#45148)
* feat(lens): isolate trace storage and investigation in a Rust service

* fix(lens): include Rust sources in the image build context

* feat(lens): wire service setup, scoped delivery receipts and lease attempts

* fix(lens): complete service routing and reject stale investigation results

* fix(lens): retry key propagation and validate isolated Compose setup

* fix(lens): seed through isolated ingestion and preserve upstream queue fixes

* chore: sync schema.prisma copies from root

* fix(lens): bind nullable due timestamps as text for Prisma

* chore(ui): remove stale lint suppressions

* fix(lens): address CI failures and review findings

* refactor(lens): remove retired Python worker and run evaluations in Rust

* fix(lens): reuse control connections and satisfy review checks

* test(lens): install and upgrade both Helm charts on Kubernetes

* test(lens): run connection reuse coverage as an integration test

* fix(ui): upgrade Next.js to 16.3.8 security release

* fix(lens): fence stale attempts and preserve reviewed evidence

* Revert "fix(ui): upgrade Next.js to 16.3.8 security release"

This reverts commit 2f79a51b25.

* fix(lens): stop failed investigations and stream history excerpts

* test(lens): cover model tool and result contracts

* test(lens): fix retired routes and reuse installation build artifacts

* test(lens): use portable grep in Helm installation smoke

* test(lens): wait for migrations before forwarding Helm services

* fix(lens): keep failed evidence reads retryable

* fix(lens): preserve sandbox output during process exit

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-07 17:18:58 -07:00

256 lines
9 KiB
Python

import argparse
import asyncio
import json
import logging
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from queue import SimpleQueue
from types import MappingProxyType
from typing import Final
import httpx
from pydantic import BaseModel
from litellm.proxy.lens.inference import _SYSTEM
from litellm.proxy.lens.models import (
Check,
Claim,
Execution,
ExecutionContent,
Finding,
Job,
LensSettings,
ModelRequest,
ModelResult,
Progress,
Sample,
TracePart,
)
from tests.proxy_behavior.lens.rust_worker import run_worker
logger: Final = logging.getLogger(__name__)
class Case(BaseModel):
name: str
split: str
task: str
answer: str
steps: tuple[tuple[str, str, str, str, str], ...]
expected: frozenset[str]
context: str
missing_root: bool = False
incomplete: bool = False
class Dataset(BaseModel):
checks: tuple[Check, ...]
cases: tuple[Case, ...]
feedback: tuple[Finding, ...] = ()
def fixtures(case: Case) -> tuple[Execution, tuple[TracePart, ...]]:
execution: Final = Execution(
id=case.name,
source="traces",
trace_id=case.name,
team_id="",
name="recorded task",
start_time="",
span_count=len(case.steps) + int(not case.missing_root),
root_seen=not case.missing_root,
)
root: Final = TracePart(
execution_id=case.name,
span_id="000",
name="task",
kind="agent",
content=f"Input: {case.task}\nOutput: {case.answer}\nStatus: OK",
)
parts: Final = tuple(
TracePart(
execution_id=case.name,
span_id=f"{i:03}",
parent_span_id="000",
name=name,
kind=kind,
content=f"Input: {inp}\nOutput: {out}\nStatus: {status}",
)
for i, (name, kind, inp, out, status) in enumerate(case.steps, 1)
)
return execution, parts if case.missing_root else (root, *parts)
async def evaluate(
cases: tuple[Case, ...],
checks: tuple[Check, ...],
client: httpx.AsyncClient,
model_name: str,
concurrency: int,
feedback: tuple[Finding, ...] = (),
worker_binary: Path = Path("litellm-rust/target/debug/examples/worker_once"),
) -> dict[str, object]:
records: Final = MappingProxyType({case.name: fixtures(case) for case in cases})
settings: Final = LensSettings(
name="Quality evaluation",
model=model_name,
checks=checks,
context="Assess each run against its own recorded user request. Root output is the delivered answer. No agent roles or tools are mandatory unless the task requires them.",
concurrency=concurrency,
enabled=False,
)
now: Final = datetime.now(timezone.utc)
claim: Final = Claim(
lens_id="evaluation",
findings=feedback,
job=Job(id="evaluation", created_at=now, start=now, end=now, settings=settings, revision=1),
)
async def read(identity: str, cursor: str, offset: int) -> ExecutionContent:
execution, parts = records[identity]
selected: Final = tuple(p for p in parts if p.span_id > cursor)[:40]
return ExecutionContent(
execution=execution,
parts=tuple(
p.model_copy(
update=MappingProxyType(
{
"content": p.content[offset : offset + 8000],
"truncated": len(p.content) > offset + 8000,
}
)
)
for p in selected
),
next_cursor=selected[-1].span_id if len(selected) == 40 else None,
partial=not execution.root_seen or next(c.incomplete for c in cases if c.name == identity),
)
costs: Final = SimpleQueue[float | None]()
decisions: Final = SimpleQueue[tuple[str, str]]()
started: Final = time.monotonic()
async def model(request: ModelRequest) -> ModelResult:
response: Final = await client.post(
"/v1/chat/completions",
json={
"model": model_name,
"messages": [
{"role": "system", "content": _SYSTEM},
*(message.model_dump(mode="json") for message in request.messages),
]
if request.messages
else [{"role": "system", "content": _SYSTEM}, {"role": "user", "content": request.prompt}],
"max_tokens": 4096,
"response_format": {"type": "json_object"},
},
)
response.raise_for_status()
raw_cost: Final = response.headers.get("x-litellm-response-cost")
cost: Final = float(raw_cost) if raw_cost else None
costs.put(cost)
answer: Final = response.json()["choices"][0]["message"]["content"]
if request.purpose == "investigate":
decisions.put((request.purpose, answer))
return ModelResult(content=answer, cost=cost or 0)
async def progress(body: Progress) -> None:
logger.info("%s", body.model_dump_json(exclude_none=True))
result: Final = await run_worker(
worker_binary,
claim,
Sample(executions=tuple(r[0] for r in records.values()), eligible=len(records), selected=len(records)),
read,
model,
progress,
)
assessed: Final = MappingProxyType({a.execution_id: frozenset(a.issue_checks) for a in result.assessments})
final_checks: Final = MappingProxyType(
{
case.name: frozenset(
f.check_id
for f in result.findings
if f.kind == "issue" and any(e.execution_id == case.name and e.role == "support" for e in f.evidence)
)
for case in cases
}
)
comparisons: Final = tuple(
{
"case": c.name,
"split": c.split,
"expected": sorted(c.expected),
"found": sorted(assessed.get(c.name, frozenset())),
"missed": sorted(c.expected - assessed.get(c.name, frozenset())),
"unexpected": sorted(assessed.get(c.name, frozenset()) - c.expected),
"final_found": sorted(final_checks[c.name]),
"final_missed": sorted(c.expected - final_checks[c.name]),
"final_unexpected": sorted(final_checks[c.name] - c.expected),
}
for c in cases
)
measured: Final = tuple(costs.get_nowait() for _ in range(costs.qsize()))
return {
"cases": comparisons,
"runtime_seconds": time.monotonic() - started,
"model_calls": len(measured),
"reported_cost_usd": sum(value for value in measured if value is not None)
if all(value is not None for value in measured)
else None,
"missed_checks": sum(len(c["missed"]) for c in comparisons),
"unexpected_checks": sum(len(c["unexpected"]) for c in comparisons),
"investigation_responses": tuple(decisions.get_nowait() for _ in range(decisions.qsize())),
"result": result.model_dump(mode="json"),
}
async def main() -> None:
parser: Final = argparse.ArgumentParser(description="Run paid, real-model Lens quality evaluations")
parser.add_argument("--api-base", required=True)
parser.add_argument("--dataset", type=Path, default=Path(__file__).with_name("quality_cases.json"))
parser.add_argument("--model", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--split", choices=("dev", "holdout", "all"), default="all")
parser.add_argument("--background", type=int, default=0, help="Additional clean runs for rare-problem batch tests")
parser.add_argument("--concurrency", type=int, default=8)
parser.add_argument("--worker-binary", type=Path, default=Path("litellm-rust/target/debug/examples/worker_once"))
args: Final = parser.parse_args()
dataset: Final = Dataset.model_validate_json(args.dataset.read_text())
selected: Final = tuple(c for c in dataset.cases if args.split == "all" or c.split == args.split)
background: Final = tuple(
Case(
name=f"background-{i}",
split="background",
task=f"Add {i} and 7.",
answer=str(i + 7),
steps=(),
expected=frozenset(),
context="Direct arithmetic answers do not need tools or an editor.",
)
for i in range(args.background)
)
async with httpx.AsyncClient(
base_url=args.api_base.rstrip("/"),
headers={"Authorization": "Bearer " + os.environ["LITELLM_API_KEY"]},
timeout=180,
) as client:
report: Final = await evaluate(
(*selected, *background),
dataset.checks,
client,
args.model,
args.concurrency,
dataset.feedback,
args.worker_binary,
)
args.output.write_text(
json.dumps({"model": args.model, "background_runs": args.background, **report}, indent=2) + "\n"
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main())