|
|
@@ -0,0 +1,501 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+import os
|
|
|
+import re
|
|
|
+from collections import defaultdict
|
|
|
+from collections.abc import Iterable, Sequence
|
|
|
+from datetime import datetime, timezone
|
|
|
+from pathlib import Path
|
|
|
+from typing import Literal
|
|
|
+from uuid import uuid4
|
|
|
+
|
|
|
+from pydantic import BaseModel, ConfigDict, Field
|
|
|
+
|
|
|
+from agent_lab.application.benchmark import (
|
|
|
+ BenchmarkCaseId,
|
|
|
+ BenchmarkConfig,
|
|
|
+ BenchmarkMode,
|
|
|
+ BenchmarkRunResult,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+REPORT_SCHEMA_VERSION = 1
|
|
|
+ERROR_LIMIT = 2048
|
|
|
+REDACTED = "[REDACTED]"
|
|
|
+
|
|
|
+
|
|
|
+class _StrictReportModel(BaseModel):
|
|
|
+ model_config = ConfigDict(
|
|
|
+ extra="forbid",
|
|
|
+ strict=True,
|
|
|
+ hide_input_in_errors=True,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkLatencyAggregate(_StrictReportModel):
|
|
|
+ count: int = Field(ge=0)
|
|
|
+ p50: float | None
|
|
|
+ p95: float | None
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkValueAggregate(_StrictReportModel):
|
|
|
+ count: int = Field(ge=0)
|
|
|
+ total: int
|
|
|
+ mean: float | None
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkAggregate(_StrictReportModel):
|
|
|
+ case_id: BenchmarkCaseId
|
|
|
+ mode: BenchmarkMode
|
|
|
+ attempted: int = Field(ge=0)
|
|
|
+ passed: int = Field(ge=0)
|
|
|
+ failed: int = Field(ge=0)
|
|
|
+ initial_provider_ttft_ms: BenchmarkLatencyAggregate
|
|
|
+ visible_ttft_ms: BenchmarkLatencyAggregate
|
|
|
+ turn_wall_time_ms: BenchmarkLatencyAggregate
|
|
|
+ prompt_tokens: BenchmarkValueAggregate
|
|
|
+ completion_tokens: BenchmarkValueAggregate
|
|
|
+ total_tokens: BenchmarkValueAggregate
|
|
|
+ cached_tokens: BenchmarkValueAggregate
|
|
|
+ model_call_count: BenchmarkValueAggregate
|
|
|
+ fallback_count: BenchmarkValueAggregate
|
|
|
+ tool_count: BenchmarkValueAggregate
|
|
|
+ warnings: list[str]
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkReportTarget(_StrictReportModel):
|
|
|
+ base_url: str
|
|
|
+ model: str
|
|
|
+ mock: bool
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkReportConfig(_StrictReportModel):
|
|
|
+ runs_per_case: int = Field(ge=1)
|
|
|
+ cases: list[BenchmarkCaseId]
|
|
|
+ modes: list[BenchmarkMode]
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkOverall(_StrictReportModel):
|
|
|
+ attempted: int = Field(ge=0)
|
|
|
+ passed: int = Field(ge=0)
|
|
|
+ failed: int = Field(ge=0)
|
|
|
+ warnings: list[str]
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkReport(_StrictReportModel):
|
|
|
+ schema_version: Literal[1]
|
|
|
+ generated_at: datetime
|
|
|
+ target: BenchmarkReportTarget
|
|
|
+ config: BenchmarkReportConfig
|
|
|
+ runs: list[BenchmarkRunResult]
|
|
|
+ aggregates: list[BenchmarkAggregate]
|
|
|
+ overall: BenchmarkOverall
|
|
|
+
|
|
|
+
|
|
|
+class BenchmarkReportWriteError(RuntimeError):
|
|
|
+ pass
|
|
|
+
|
|
|
+
|
|
|
+def percentile(values: Sequence[int | float], quantile: float) -> float | None:
|
|
|
+ if not 0.0 <= quantile <= 1.0:
|
|
|
+ raise ValueError("quantile must be between 0 and 1")
|
|
|
+ if not values:
|
|
|
+ return None
|
|
|
+ ordered = sorted(float(value) for value in values)
|
|
|
+ index = (len(ordered) - 1) * quantile
|
|
|
+ lower_index = int(index)
|
|
|
+ upper_index = min(lower_index + 1, len(ordered) - 1)
|
|
|
+ fraction = index - lower_index
|
|
|
+ return ordered[lower_index] + (
|
|
|
+ ordered[upper_index] - ordered[lower_index]
|
|
|
+ ) * fraction
|
|
|
+
|
|
|
+
|
|
|
+def redact_text(value: str, *, secrets: Iterable[str] = ()) -> str:
|
|
|
+ redacted = value
|
|
|
+ for secret in sorted(
|
|
|
+ {secret for secret in secrets if secret},
|
|
|
+ key=len,
|
|
|
+ reverse=True,
|
|
|
+ ):
|
|
|
+ redacted = redacted.replace(secret, REDACTED)
|
|
|
+
|
|
|
+ redacted = re.sub(
|
|
|
+ r"(?i)\bBearer\s+[^\s,;]+",
|
|
|
+ f"Bearer {REDACTED}",
|
|
|
+ redacted,
|
|
|
+ )
|
|
|
+ redacted = re.sub(
|
|
|
+ r"(?i)\b(Authorization|X-API-Key|API-Key)\b"
|
|
|
+ r"(\s*[:=]\s*)"
|
|
|
+ r"(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)",
|
|
|
+ lambda match: f"{match.group(1)}{match.group(2)}{REDACTED}",
|
|
|
+ redacted,
|
|
|
+ )
|
|
|
+ redacted = re.sub(
|
|
|
+ r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|auth[_-]?token|token|key)"
|
|
|
+ r"[\"']?\s*(?:=|:)\s*)"
|
|
|
+ r"(?:\"[^\"]*\"|'[^']*'|[^&\s,;}]+)",
|
|
|
+ lambda match: f"{match.group(1)}{REDACTED}",
|
|
|
+ redacted,
|
|
|
+ )
|
|
|
+ return redacted
|
|
|
+
|
|
|
+
|
|
|
+def sanitize_error(value: str | None, *, secrets: Iterable[str] = ()) -> str | None:
|
|
|
+ if value is None:
|
|
|
+ return None
|
|
|
+ return redact_text(value, secrets=secrets)[:ERROR_LIMIT]
|
|
|
+
|
|
|
+
|
|
|
+def build_benchmark_report(
|
|
|
+ config: BenchmarkConfig,
|
|
|
+ runs: Sequence[BenchmarkRunResult],
|
|
|
+ *,
|
|
|
+ mock: bool,
|
|
|
+ generated_at: datetime | None = None,
|
|
|
+ secrets: Iterable[str] = (),
|
|
|
+) -> BenchmarkReport:
|
|
|
+ secret_values = tuple(secret for secret in secrets if secret)
|
|
|
+ sanitized_runs = [
|
|
|
+ _sanitize_run(run, secrets=secret_values) for run in runs
|
|
|
+ ]
|
|
|
+ aggregates = _aggregate_runs(sanitized_runs)
|
|
|
+ warnings = [
|
|
|
+ warning
|
|
|
+ for aggregate in aggregates
|
|
|
+ for warning in aggregate.warnings
|
|
|
+ ]
|
|
|
+ passed = sum(run.status == "passed" for run in sanitized_runs)
|
|
|
+ failed = len(sanitized_runs) - passed
|
|
|
+ resolved_generated_at = generated_at or datetime.now(timezone.utc)
|
|
|
+ if resolved_generated_at.tzinfo is None:
|
|
|
+ resolved_generated_at = resolved_generated_at.replace(tzinfo=timezone.utc)
|
|
|
+ return BenchmarkReport(
|
|
|
+ schema_version=REPORT_SCHEMA_VERSION,
|
|
|
+ generated_at=resolved_generated_at,
|
|
|
+ target=BenchmarkReportTarget(
|
|
|
+ base_url=redact_text(config.base_url, secrets=secret_values),
|
|
|
+ model=redact_text(config.model, secrets=secret_values),
|
|
|
+ mock=mock,
|
|
|
+ ),
|
|
|
+ config=BenchmarkReportConfig(
|
|
|
+ runs_per_case=config.runs_per_case,
|
|
|
+ cases=list(config.cases),
|
|
|
+ modes=list(config.modes),
|
|
|
+ ),
|
|
|
+ runs=sanitized_runs,
|
|
|
+ aggregates=aggregates,
|
|
|
+ overall=BenchmarkOverall(
|
|
|
+ attempted=len(sanitized_runs),
|
|
|
+ passed=passed,
|
|
|
+ failed=failed,
|
|
|
+ warnings=warnings,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _sanitize_run(
|
|
|
+ run: BenchmarkRunResult,
|
|
|
+ *,
|
|
|
+ secrets: Sequence[str],
|
|
|
+) -> BenchmarkRunResult:
|
|
|
+ string_list_fields = (
|
|
|
+ "event_names",
|
|
|
+ "batch_event_names",
|
|
|
+ "tool_event_names",
|
|
|
+ "event_sources",
|
|
|
+ "tool_statuses",
|
|
|
+ "semantic_failures",
|
|
|
+ )
|
|
|
+ updates: dict[str, object] = {
|
|
|
+ field_name: [
|
|
|
+ redact_text(value, secrets=secrets)
|
|
|
+ for value in getattr(run, field_name)
|
|
|
+ ]
|
|
|
+ for field_name in string_list_fields
|
|
|
+ }
|
|
|
+ updates["error"] = sanitize_error(run.error, secrets=secrets)
|
|
|
+ updates["model_calls"] = [
|
|
|
+ call.model_copy(
|
|
|
+ update={
|
|
|
+ "first_item_kind": (
|
|
|
+ redact_text(call.first_item_kind, secrets=secrets)
|
|
|
+ if call.first_item_kind is not None
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ }
|
|
|
+ )
|
|
|
+ for call in run.model_calls
|
|
|
+ ]
|
|
|
+ return run.model_copy(update=updates)
|
|
|
+
|
|
|
+
|
|
|
+def _aggregate_runs(
|
|
|
+ runs: Sequence[BenchmarkRunResult],
|
|
|
+) -> list[BenchmarkAggregate]:
|
|
|
+ grouped: dict[
|
|
|
+ tuple[BenchmarkCaseId, BenchmarkMode],
|
|
|
+ list[BenchmarkRunResult],
|
|
|
+ ] = defaultdict(list)
|
|
|
+ for run in runs:
|
|
|
+ grouped[(run.case_id, run.mode)].append(run)
|
|
|
+
|
|
|
+ return [
|
|
|
+ _aggregate_group(case_id, mode, grouped[(case_id, mode)])
|
|
|
+ for case_id, mode in sorted(
|
|
|
+ grouped,
|
|
|
+ key=lambda item: (item[0].value, item[1].value),
|
|
|
+ )
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+def _aggregate_group(
|
|
|
+ case_id: BenchmarkCaseId,
|
|
|
+ mode: BenchmarkMode,
|
|
|
+ runs: Sequence[BenchmarkRunResult],
|
|
|
+) -> BenchmarkAggregate:
|
|
|
+ passed_runs = [run for run in runs if run.status == "passed"]
|
|
|
+ latency_fields = (
|
|
|
+ "initial_provider_ttft_ms",
|
|
|
+ "visible_ttft_ms",
|
|
|
+ "turn_wall_time_ms",
|
|
|
+ )
|
|
|
+ latency_values = {
|
|
|
+ field_name: [
|
|
|
+ value
|
|
|
+ for run in passed_runs
|
|
|
+ if (value := getattr(run, field_name)) is not None
|
|
|
+ ]
|
|
|
+ for field_name in latency_fields
|
|
|
+ }
|
|
|
+ warnings = [
|
|
|
+ f"{case_id.value}/{mode.value} {field_name} p95 low confidence: "
|
|
|
+ f"{len(values)} samples; fewer than 20"
|
|
|
+ for field_name, values in latency_values.items()
|
|
|
+ if 0 < len(values) < 20
|
|
|
+ ]
|
|
|
+ value_fields = (
|
|
|
+ "prompt_tokens",
|
|
|
+ "completion_tokens",
|
|
|
+ "total_tokens",
|
|
|
+ "cached_tokens",
|
|
|
+ "model_call_count",
|
|
|
+ "fallback_count",
|
|
|
+ "tool_count",
|
|
|
+ )
|
|
|
+ value_aggregates = {
|
|
|
+ field_name: _value_aggregate(
|
|
|
+ [
|
|
|
+ value
|
|
|
+ for run in passed_runs
|
|
|
+ if (value := getattr(run, field_name)) is not None
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ for field_name in value_fields
|
|
|
+ }
|
|
|
+ return BenchmarkAggregate(
|
|
|
+ case_id=case_id,
|
|
|
+ mode=mode,
|
|
|
+ attempted=len(runs),
|
|
|
+ passed=len(passed_runs),
|
|
|
+ failed=len(runs) - len(passed_runs),
|
|
|
+ initial_provider_ttft_ms=_latency_aggregate(
|
|
|
+ latency_values["initial_provider_ttft_ms"]
|
|
|
+ ),
|
|
|
+ visible_ttft_ms=_latency_aggregate(
|
|
|
+ latency_values["visible_ttft_ms"]
|
|
|
+ ),
|
|
|
+ turn_wall_time_ms=_latency_aggregate(
|
|
|
+ latency_values["turn_wall_time_ms"]
|
|
|
+ ),
|
|
|
+ prompt_tokens=value_aggregates["prompt_tokens"],
|
|
|
+ completion_tokens=value_aggregates["completion_tokens"],
|
|
|
+ total_tokens=value_aggregates["total_tokens"],
|
|
|
+ cached_tokens=value_aggregates["cached_tokens"],
|
|
|
+ model_call_count=value_aggregates["model_call_count"],
|
|
|
+ fallback_count=value_aggregates["fallback_count"],
|
|
|
+ tool_count=value_aggregates["tool_count"],
|
|
|
+ warnings=warnings,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _latency_aggregate(values: Sequence[int]) -> BenchmarkLatencyAggregate:
|
|
|
+ return BenchmarkLatencyAggregate(
|
|
|
+ count=len(values),
|
|
|
+ p50=percentile(values, 0.50),
|
|
|
+ p95=percentile(values, 0.95),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _value_aggregate(values: Sequence[int]) -> BenchmarkValueAggregate:
|
|
|
+ total = sum(values)
|
|
|
+ return BenchmarkValueAggregate(
|
|
|
+ count=len(values),
|
|
|
+ total=total,
|
|
|
+ mean=(float(total) / len(values)) if values else None,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def report_to_json(report: BenchmarkReport) -> str:
|
|
|
+ return json.dumps(
|
|
|
+ report.model_dump(mode="json"),
|
|
|
+ ensure_ascii=False,
|
|
|
+ indent=2,
|
|
|
+ ) + "\n"
|
|
|
+
|
|
|
+
|
|
|
+def render_markdown(report: BenchmarkReport) -> str:
|
|
|
+ payload = report.model_dump(mode="json")
|
|
|
+ target = payload["target"]
|
|
|
+ overall = payload["overall"]
|
|
|
+ lines = [
|
|
|
+ "# Agent Lab Benchmark Report",
|
|
|
+ "",
|
|
|
+ f"- Schema version: {payload['schema_version']}",
|
|
|
+ f"- Generated at: {payload['generated_at']}",
|
|
|
+ f"- Base URL: `{_escape_markdown(str(target['base_url']))}`",
|
|
|
+ f"- Model: `{_escape_markdown(str(target['model']))}`",
|
|
|
+ f"- Mock: `{str(target['mock']).lower()}`",
|
|
|
+ "",
|
|
|
+ "## Overall",
|
|
|
+ "",
|
|
|
+ f"- Attempted: {overall['attempted']}",
|
|
|
+ f"- Passed: {overall['passed']}",
|
|
|
+ f"- Failed: {overall['failed']}",
|
|
|
+ f"- Warnings: {len(overall['warnings'])}",
|
|
|
+ "",
|
|
|
+ "## Aggregates",
|
|
|
+ "",
|
|
|
+ "| Case | Mode | Attempted | Passed | Failed | Provider TTFT p50/p95 ms | Visible TTFT p50/p95 ms | Turn wall p50/p95 ms | Tokens total/mean | Model calls total/mean | Fallbacks total/mean | Tools total/mean |",
|
|
|
+ "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
|
|
+ ]
|
|
|
+ for aggregate in payload["aggregates"]:
|
|
|
+ lines.append(
|
|
|
+ "| "
|
|
|
+ + " | ".join(
|
|
|
+ [
|
|
|
+ _escape_markdown(str(aggregate["case_id"])),
|
|
|
+ _escape_markdown(str(aggregate["mode"])),
|
|
|
+ str(aggregate["attempted"]),
|
|
|
+ str(aggregate["passed"]),
|
|
|
+ str(aggregate["failed"]),
|
|
|
+ _percentile_pair(aggregate["initial_provider_ttft_ms"]),
|
|
|
+ _percentile_pair(aggregate["visible_ttft_ms"]),
|
|
|
+ _percentile_pair(aggregate["turn_wall_time_ms"]),
|
|
|
+ _total_mean(aggregate["total_tokens"]),
|
|
|
+ _total_mean(aggregate["model_call_count"]),
|
|
|
+ _total_mean(aggregate["fallback_count"]),
|
|
|
+ _total_mean(aggregate["tool_count"]),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ + " |"
|
|
|
+ )
|
|
|
+
|
|
|
+ lines.extend(["", "## Warnings", ""])
|
|
|
+ if overall["warnings"]:
|
|
|
+ lines.extend(
|
|
|
+ f"- {_escape_markdown(str(warning))}"
|
|
|
+ for warning in overall["warnings"]
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ lines.append("- None")
|
|
|
+
|
|
|
+ lines.extend(
|
|
|
+ [
|
|
|
+ "",
|
|
|
+ "## Runs",
|
|
|
+ "",
|
|
|
+ "| Case | Mode | Iteration | Status | Error |",
|
|
|
+ "| --- | --- | ---: | --- | --- |",
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ for run in payload["runs"]:
|
|
|
+ failure_text = "; ".join(run["semantic_failures"])
|
|
|
+ error_text = run["error"] or failure_text or "-"
|
|
|
+ lines.append(
|
|
|
+ "| "
|
|
|
+ + " | ".join(
|
|
|
+ [
|
|
|
+ _escape_markdown(str(run["case_id"])),
|
|
|
+ _escape_markdown(str(run["mode"])),
|
|
|
+ str(run["iteration"]),
|
|
|
+ str(run["status"]),
|
|
|
+ _escape_markdown(str(error_text)),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ + " |"
|
|
|
+ )
|
|
|
+ return "\n".join(lines) + "\n"
|
|
|
+
|
|
|
+
|
|
|
+def _percentile_pair(metric: dict[str, object]) -> str:
|
|
|
+ return f"{_display(metric['p50'])}/{_display(metric['p95'])}"
|
|
|
+
|
|
|
+
|
|
|
+def _total_mean(metric: dict[str, object]) -> str:
|
|
|
+ return f"{_display(metric['total'])}/{_display(metric['mean'])}"
|
|
|
+
|
|
|
+
|
|
|
+def _display(value: object) -> str:
|
|
|
+ return "-" if value is None else str(value)
|
|
|
+
|
|
|
+
|
|
|
+def _escape_markdown(value: str) -> str:
|
|
|
+ return value.replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
|
|
+
|
|
|
+
|
|
|
+def write_benchmark_report(
|
|
|
+ report: BenchmarkReport,
|
|
|
+ output_dir: str | Path,
|
|
|
+ *,
|
|
|
+ timestamp: str | None = None,
|
|
|
+) -> tuple[Path, Path]:
|
|
|
+ directory = Path(output_dir)
|
|
|
+ temp_paths: list[Path] = []
|
|
|
+ published_paths: list[Path] = []
|
|
|
+ try:
|
|
|
+ directory.mkdir(parents=True, exist_ok=True)
|
|
|
+ stem = timestamp or report.generated_at.astimezone(timezone.utc).strftime(
|
|
|
+ "%Y%m%dT%H%M%SZ"
|
|
|
+ )
|
|
|
+ json_path, markdown_path = _unique_report_paths(directory, stem)
|
|
|
+ nonce = uuid4().hex
|
|
|
+ json_temp = directory / f".{json_path.name}.{nonce}.tmp"
|
|
|
+ markdown_temp = directory / f".{markdown_path.name}.{nonce}.tmp"
|
|
|
+ temp_paths.extend([json_temp, markdown_temp])
|
|
|
+ _write_text(json_temp, report_to_json(report))
|
|
|
+ _write_text(markdown_temp, render_markdown(report))
|
|
|
+ os.replace(json_temp, json_path)
|
|
|
+ published_paths.append(json_path)
|
|
|
+ os.replace(markdown_temp, markdown_path)
|
|
|
+ published_paths.append(markdown_path)
|
|
|
+ return json_path, markdown_path
|
|
|
+ except Exception as exc:
|
|
|
+ for path in temp_paths + published_paths:
|
|
|
+ try:
|
|
|
+ path.unlink(missing_ok=True)
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
+ raise BenchmarkReportWriteError(
|
|
|
+ "failed to write benchmark reports"
|
|
|
+ ) from exc
|
|
|
+
|
|
|
+
|
|
|
+def _write_text(path: Path, content: str) -> None:
|
|
|
+ with path.open("x", encoding="utf-8") as handle:
|
|
|
+ handle.write(content)
|
|
|
+ handle.flush()
|
|
|
+ os.fsync(handle.fileno())
|
|
|
+
|
|
|
+
|
|
|
+def _unique_report_paths(directory: Path, stem: str) -> tuple[Path, Path]:
|
|
|
+ suffix = 0
|
|
|
+ while True:
|
|
|
+ candidate = stem if suffix == 0 else f"{stem}-{suffix}"
|
|
|
+ json_path = directory / f"{candidate}.json"
|
|
|
+ markdown_path = directory / f"{candidate}.md"
|
|
|
+ if not json_path.exists() and not markdown_path.exists():
|
|
|
+ return json_path, markdown_path
|
|
|
+ suffix += 1
|