0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
2056 lines
80 KiB
Python
2056 lines
80 KiB
Python
#!/usr/bin/env python3
|
|
"""Replay real Claude Code sessions through baseline/token/cache simulations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import concurrent.futures
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
from collections import Counter
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from headroom.cache.compression_cache import CompressionCache
|
|
from headroom.cache.prefix_tracker import PrefixCacheTracker
|
|
from headroom.pricing.litellm_pricing import get_model_pricing
|
|
from headroom.proxy.handlers.anthropic import AnthropicHandlerMixin
|
|
from headroom.proxy.models import ProxyConfig
|
|
from headroom.proxy.server import HeadroomProxy
|
|
from headroom.tokenizers import get_tokenizer
|
|
from headroom.utils import extract_user_query
|
|
|
|
try:
|
|
from headroom.proxy.modes import PROXY_MODE_CACHE, PROXY_MODE_TOKEN
|
|
except ImportError:
|
|
PROXY_MODE_CACHE = "cache"
|
|
PROXY_MODE_TOKEN = "token"
|
|
|
|
DEFAULT_ROOT = Path.home() / ".claude" / "projects"
|
|
DEFAULT_OUTPUT_DIR = Path("benchmark_results")
|
|
DEFAULT_CACHE_TTL_MINUTES = 5
|
|
OUTPUT_MD = "claude_session_mode_simulation.md"
|
|
OUTPUT_JSON = "claude_session_mode_simulation.json"
|
|
OUTPUT_HTML = "claude_session_mode_simulation.html"
|
|
CHECKPOINT_DIRNAME = "checkpoints"
|
|
|
|
|
|
@dataclass
|
|
class ReplayTurn:
|
|
session_id: str
|
|
project_key: str
|
|
decoded_project_path: str
|
|
request_id: str
|
|
model: str
|
|
timestamp: datetime
|
|
input_messages: list[dict[str, Any]]
|
|
assistant_message: dict[str, Any]
|
|
output_tokens: int
|
|
observed_input_tokens: int = 0
|
|
observed_cache_read_tokens: int = 0
|
|
observed_cache_write_tokens: int = 0
|
|
|
|
|
|
@dataclass
|
|
class SessionReplay:
|
|
session_id: str
|
|
project_key: str
|
|
decoded_project_path: str
|
|
turns: list[ReplayTurn] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class TurnMetrics:
|
|
session_id: str
|
|
request_id: str
|
|
model: str
|
|
timestamp: str
|
|
raw_input_tokens: int
|
|
forwarded_input_tokens: int
|
|
cache_read_tokens: int
|
|
cache_write_tokens: int
|
|
regular_input_tokens: int
|
|
output_tokens: int
|
|
paid_input_cost_usd: float
|
|
cache_read_cost_usd: float
|
|
cache_write_cost_usd: float
|
|
paid_output_cost_usd: float
|
|
total_cost_usd: float
|
|
|
|
|
|
@dataclass
|
|
class ModeSummary:
|
|
mode: str
|
|
sessions: int = 0
|
|
requests: int = 0
|
|
raw_input_tokens: int = 0
|
|
forwarded_input_tokens: int = 0
|
|
cache_read_tokens: int = 0
|
|
cache_write_tokens: int = 0
|
|
regular_input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
paid_input_cost_usd: float = 0.0
|
|
cache_read_cost_usd: float = 0.0
|
|
cache_write_cost_usd: float = 0.0
|
|
paid_output_cost_usd: float = 0.0
|
|
total_cost_usd: float = 0.0
|
|
cache_eligible_turns: int = 0
|
|
cache_bust_turns: int = 0
|
|
ttl_expiry_turns: int = 0
|
|
rewrite_turns: int = 0
|
|
stable_replay_rewrite_turns: int = 0
|
|
busting_rewrite_turns: int = 0
|
|
non_cache_eligible_rewrite_turns: int = 0
|
|
retroactive_rewrite_turns: int = 0
|
|
latest_turn_only_rewrite_turns: int = 0
|
|
turns: list[TurnMetrics] = field(default_factory=list)
|
|
|
|
@property
|
|
def raw_tokens(self) -> int:
|
|
return self.raw_input_tokens + self.output_tokens
|
|
|
|
@property
|
|
def cache_tokens(self) -> int:
|
|
return self.cache_read_tokens + self.cache_write_tokens
|
|
|
|
@property
|
|
def prompt_window_with_cache(self) -> int:
|
|
return self.forwarded_input_tokens
|
|
|
|
@property
|
|
def prompt_window_without_cache_reads(self) -> int:
|
|
return self.forwarded_input_tokens - self.cache_read_tokens
|
|
|
|
@property
|
|
def no_cache_total_cost_usd(self) -> float:
|
|
return (
|
|
self.paid_input_cost_usd + (self.cache_read_cost_usd * 10.0) + self.paid_output_cost_usd
|
|
)
|
|
|
|
@property
|
|
def no_cache_paid_input_tokens(self) -> int:
|
|
return self.forwarded_input_tokens
|
|
|
|
|
|
@dataclass
|
|
class DatasetSummary:
|
|
projects: int
|
|
sessions: int
|
|
requests: int
|
|
models: dict[str, int]
|
|
decoded_project_paths: int
|
|
sampled_requests: int = 0
|
|
sampling_note: str = ""
|
|
|
|
|
|
IMPACT_DIRECTION = {
|
|
"forwarded_input_tokens": "lower",
|
|
"cache_read_tokens": "higher",
|
|
"cache_write_tokens": "lower",
|
|
"regular_input_tokens": "lower",
|
|
"output_tokens": "same",
|
|
"total_cost_usd": "lower",
|
|
"no_cache_total_cost_usd": "lower",
|
|
"prompt_window_with_cache": "lower",
|
|
"prompt_window_without_cache_reads": "lower",
|
|
"cache_bust_turns": "lower",
|
|
"ttl_expiry_turns": "lower",
|
|
"rewrite_turns": "lower",
|
|
"stable_replay_rewrite_turns": "lower",
|
|
"busting_rewrite_turns": "lower",
|
|
"non_cache_eligible_rewrite_turns": "lower",
|
|
"retroactive_rewrite_turns": "lower",
|
|
"latest_turn_only_rewrite_turns": "lower",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class ObservedSummary:
|
|
sessions: int = 0
|
|
requests: int = 0
|
|
input_tokens: int = 0
|
|
cache_read_tokens: int = 0
|
|
cache_write_tokens: int = 0
|
|
output_tokens: int = 0
|
|
total_cost_usd: float = 0.0
|
|
cache_read_cost_usd: float = 0.0
|
|
cache_write_cost_usd: float = 0.0
|
|
paid_input_cost_usd: float = 0.0
|
|
paid_output_cost_usd: float = 0.0
|
|
healthy_growth_turns: int = 0
|
|
broken_prefix_turns: int = 0
|
|
resume_like_resets: int = 0
|
|
|
|
@property
|
|
def raw_tokens(self) -> int:
|
|
return (
|
|
self.input_tokens
|
|
+ self.cache_read_tokens
|
|
+ self.cache_write_tokens
|
|
+ self.output_tokens
|
|
)
|
|
|
|
@property
|
|
def cache_ratio_pct(self) -> float:
|
|
total = self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
|
|
if total <= 0:
|
|
return 0.0
|
|
return self.cache_read_tokens / total * 100.0
|
|
|
|
|
|
def _update_dataset_with_replay(
|
|
dataset: DatasetSummary | None, replay: SessionReplay
|
|
) -> DatasetSummary:
|
|
if dataset is None:
|
|
dataset = DatasetSummary(
|
|
projects=0,
|
|
sessions=0,
|
|
requests=0,
|
|
models={},
|
|
decoded_project_paths=0,
|
|
)
|
|
projects = {replay.project_key}
|
|
project_paths = {replay.decoded_project_path}
|
|
model_counts = Counter(dataset.models)
|
|
requests = dataset.requests
|
|
for turn in replay.turns:
|
|
model_counts[turn.model] += 1
|
|
requests += 1
|
|
return DatasetSummary(
|
|
projects=dataset.projects + len(projects),
|
|
sessions=dataset.sessions + 1,
|
|
requests=requests,
|
|
models=dict(sorted(model_counts.items())),
|
|
decoded_project_paths=dataset.decoded_project_paths + len(project_paths),
|
|
)
|
|
|
|
|
|
def _turn_metrics_from_dict(data: dict[str, Any]) -> TurnMetrics:
|
|
return TurnMetrics(**data)
|
|
|
|
|
|
def _mode_summary_from_dict(data: dict[str, Any]) -> ModeSummary:
|
|
turns = [_turn_metrics_from_dict(turn) for turn in data.get("turns", [])]
|
|
summary = ModeSummary(
|
|
mode=data["mode"],
|
|
sessions=data.get("sessions", 0),
|
|
requests=data.get("requests", 0),
|
|
raw_input_tokens=data.get("raw_input_tokens", 0),
|
|
forwarded_input_tokens=data.get("forwarded_input_tokens", 0),
|
|
cache_read_tokens=data.get("cache_read_tokens", 0),
|
|
cache_write_tokens=data.get("cache_write_tokens", 0),
|
|
regular_input_tokens=data.get("regular_input_tokens", 0),
|
|
output_tokens=data.get("output_tokens", 0),
|
|
paid_input_cost_usd=data.get("paid_input_cost_usd", 0.0),
|
|
cache_read_cost_usd=data.get("cache_read_cost_usd", 0.0),
|
|
cache_write_cost_usd=data.get("cache_write_cost_usd", 0.0),
|
|
paid_output_cost_usd=data.get("paid_output_cost_usd", 0.0),
|
|
total_cost_usd=data.get("total_cost_usd", 0.0),
|
|
cache_eligible_turns=data.get("cache_eligible_turns", 0),
|
|
cache_bust_turns=data.get("cache_bust_turns", 0),
|
|
ttl_expiry_turns=data.get("ttl_expiry_turns", 0),
|
|
rewrite_turns=data.get("rewrite_turns", 0),
|
|
stable_replay_rewrite_turns=data.get("stable_replay_rewrite_turns", 0),
|
|
busting_rewrite_turns=data.get("busting_rewrite_turns", 0),
|
|
non_cache_eligible_rewrite_turns=data.get("non_cache_eligible_rewrite_turns", 0),
|
|
retroactive_rewrite_turns=data.get("retroactive_rewrite_turns", 0),
|
|
latest_turn_only_rewrite_turns=data.get("latest_turn_only_rewrite_turns", 0),
|
|
turns=turns,
|
|
)
|
|
return summary
|
|
|
|
|
|
def decode_project_key(project_key: str) -> str:
|
|
"""Decode Claude's project directory encoding back to a local path-ish string."""
|
|
if "--" not in project_key:
|
|
return project_key.replace("-", "\\")
|
|
drive, remainder = project_key.split("--", 1)
|
|
return drive + ":\\" + remainder.replace("-", "\\")
|
|
|
|
|
|
def _parse_timestamp(value: str | None) -> datetime:
|
|
if not value:
|
|
return datetime.min.replace(tzinfo=timezone.utc)
|
|
if value.endswith("Z"):
|
|
value = value[:-1] + "+00:00"
|
|
return datetime.fromisoformat(value).astimezone(timezone.utc)
|
|
|
|
|
|
def _canonical_block_key(block: Any) -> str:
|
|
return json.dumps(block, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
|
|
def _assistant_blocks_from_content(content: Any) -> list[dict[str, Any]]:
|
|
if isinstance(content, str):
|
|
return [{"type": "text", "text": content}] if content else []
|
|
if isinstance(content, list):
|
|
return [block for block in content if isinstance(block, dict)]
|
|
return []
|
|
|
|
|
|
def _messages_have_images(messages: list[dict[str, Any]]) -> bool:
|
|
for message in messages:
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
continue
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "image":
|
|
return True
|
|
return False
|
|
|
|
|
|
def _finalize_group(
|
|
group: dict[str, Any] | None,
|
|
pending_messages: list[dict[str, Any]],
|
|
turns: list[ReplayTurn],
|
|
*,
|
|
session_id: str,
|
|
project_key: str,
|
|
decoded_project_path: str,
|
|
) -> None:
|
|
if not group:
|
|
return
|
|
assistant_message = {
|
|
"role": "assistant",
|
|
"content": group["blocks"] if group["blocks"] else "",
|
|
}
|
|
turns.append(
|
|
ReplayTurn(
|
|
session_id=session_id,
|
|
project_key=project_key,
|
|
decoded_project_path=decoded_project_path,
|
|
request_id=group["request_id"],
|
|
model=group["model"],
|
|
timestamp=group["timestamp"],
|
|
input_messages=copy.deepcopy(pending_messages),
|
|
assistant_message=assistant_message,
|
|
output_tokens=group["output_tokens"],
|
|
observed_input_tokens=group["observed_input_tokens"],
|
|
observed_cache_read_tokens=group["observed_cache_read_tokens"],
|
|
observed_cache_write_tokens=group["observed_cache_write_tokens"],
|
|
)
|
|
)
|
|
|
|
|
|
def load_session_replay(session_file: Path) -> SessionReplay | None:
|
|
"""Load a top-level Claude session transcript into replayable request turns."""
|
|
project_key = session_file.parent.name
|
|
decoded_project_path = decode_project_key(project_key)
|
|
session_id = session_file.stem
|
|
pending_messages: list[dict[str, Any]] = []
|
|
turns: list[ReplayTurn] = []
|
|
current_group: dict[str, Any] | None = None
|
|
|
|
try:
|
|
with session_file.open("r", encoding="utf-8") as handle:
|
|
for raw_line in handle:
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
event_type = event.get("type")
|
|
message = event.get("message")
|
|
|
|
if (
|
|
event_type == "user"
|
|
and isinstance(message, dict)
|
|
and message.get("role") == "user"
|
|
):
|
|
_finalize_group(
|
|
current_group,
|
|
pending_messages,
|
|
turns,
|
|
session_id=session_id,
|
|
project_key=project_key,
|
|
decoded_project_path=decoded_project_path,
|
|
)
|
|
current_group = None
|
|
pending_messages.clear()
|
|
pending_messages.append(copy.deepcopy(message))
|
|
continue
|
|
|
|
if (
|
|
event_type == "assistant"
|
|
and isinstance(message, dict)
|
|
and message.get("role") == "assistant"
|
|
and event.get("requestId")
|
|
):
|
|
request_id = str(event["requestId"])
|
|
usage = message.get("usage") or {}
|
|
timestamp = _parse_timestamp(event.get("timestamp"))
|
|
blocks = _assistant_blocks_from_content(message.get("content"))
|
|
if current_group is None or current_group["request_id"] != request_id:
|
|
had_group = current_group is not None
|
|
_finalize_group(
|
|
current_group,
|
|
pending_messages,
|
|
turns,
|
|
session_id=session_id,
|
|
project_key=project_key,
|
|
decoded_project_path=decoded_project_path,
|
|
)
|
|
if had_group:
|
|
pending_messages.clear()
|
|
current_group = {
|
|
"request_id": request_id,
|
|
"model": str(message.get("model", "unknown")),
|
|
"timestamp": timestamp,
|
|
"blocks": [],
|
|
"seen": set(),
|
|
"output_tokens": 0,
|
|
"observed_input_tokens": 0,
|
|
"observed_cache_read_tokens": 0,
|
|
"observed_cache_write_tokens": 0,
|
|
}
|
|
for block in blocks:
|
|
key = _canonical_block_key(block)
|
|
if key not in current_group["seen"]:
|
|
current_group["seen"].add(key)
|
|
current_group["blocks"].append(copy.deepcopy(block))
|
|
current_group["output_tokens"] = max(
|
|
current_group["output_tokens"],
|
|
int(usage.get("output_tokens", 0) or 0),
|
|
)
|
|
current_group["observed_input_tokens"] = max(
|
|
current_group["observed_input_tokens"],
|
|
int(usage.get("input_tokens", 0) or 0),
|
|
)
|
|
current_group["observed_cache_read_tokens"] = max(
|
|
current_group["observed_cache_read_tokens"],
|
|
int(usage.get("cache_read_input_tokens", 0) or 0),
|
|
)
|
|
current_group["observed_cache_write_tokens"] = max(
|
|
current_group["observed_cache_write_tokens"],
|
|
int(usage.get("cache_creation_input_tokens", 0) or 0),
|
|
)
|
|
except OSError:
|
|
return None
|
|
|
|
_finalize_group(
|
|
current_group,
|
|
pending_messages,
|
|
turns,
|
|
session_id=session_id,
|
|
project_key=project_key,
|
|
decoded_project_path=decoded_project_path,
|
|
)
|
|
|
|
if not turns:
|
|
return None
|
|
return SessionReplay(
|
|
session_id=session_id,
|
|
project_key=project_key,
|
|
decoded_project_path=decoded_project_path,
|
|
turns=turns,
|
|
)
|
|
|
|
|
|
def trim_replay_to_recent_turns(
|
|
replay: SessionReplay, recent_turns: int | None = None
|
|
) -> SessionReplay:
|
|
if recent_turns is None or recent_turns <= 0 or len(replay.turns) <= recent_turns:
|
|
return replay
|
|
return SessionReplay(
|
|
session_id=replay.session_id,
|
|
project_key=replay.project_key,
|
|
decoded_project_path=replay.decoded_project_path,
|
|
turns=replay.turns[-recent_turns:],
|
|
)
|
|
|
|
|
|
def resolve_checkpoint_dir(
|
|
base_dir: Path,
|
|
*,
|
|
recent_turns_per_session: int | None = None,
|
|
cache_ttl_minutes: int = DEFAULT_CACHE_TTL_MINUTES,
|
|
) -> Path:
|
|
suffix_parts = ["v5", f"ttl_{cache_ttl_minutes}m"]
|
|
if recent_turns_per_session:
|
|
suffix_parts.append(f"recent_{recent_turns_per_session}")
|
|
else:
|
|
suffix_parts.append("full")
|
|
return base_dir / "__".join(suffix_parts)
|
|
|
|
|
|
def discover_session_files(root: Path) -> list[Path]:
|
|
if not root.exists():
|
|
return []
|
|
files: list[Path] = []
|
|
for project_dir in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
files.extend(
|
|
sorted(p for p in project_dir.iterdir() if p.is_file() and p.suffix == ".jsonl")
|
|
)
|
|
return files
|
|
|
|
|
|
def load_replays(root: Path, max_sessions: int | None = None) -> list[SessionReplay]:
|
|
replays: list[SessionReplay] = []
|
|
session_files = discover_session_files(root)
|
|
total = len(session_files)
|
|
for index, session_file in enumerate(session_files, start=1):
|
|
if index == 1 or index % 10 == 0 or index == total:
|
|
print(f"[load] session={index}/{total} file={session_file.name}", flush=True)
|
|
replay = load_session_replay(session_file)
|
|
if replay is not None:
|
|
replays.append(replay)
|
|
if max_sessions is not None and len(replays) >= max_sessions:
|
|
break
|
|
return replays
|
|
|
|
|
|
def select_session_files(root: Path, max_sessions: int | None = None) -> list[Path]:
|
|
session_files = discover_session_files(root)
|
|
if max_sessions is not None:
|
|
session_files = session_files[:max_sessions]
|
|
return session_files
|
|
|
|
|
|
def build_dataset_and_observed_from_files(
|
|
session_files: list[Path],
|
|
*,
|
|
cache_write_multiplier: float = 1.25,
|
|
recent_turns_per_session: int | None = None,
|
|
) -> tuple[DatasetSummary, ObservedSummary]:
|
|
model_counts: Counter[str] = Counter()
|
|
project_keys: set[str] = set()
|
|
decoded_project_paths: set[str] = set()
|
|
requests = 0
|
|
observed = ObservedSummary()
|
|
|
|
total = len(session_files)
|
|
for index, session_file in enumerate(session_files, start=1):
|
|
if index == 1 or index % 10 == 0 or index == total:
|
|
print(f"[load] session={index}/{total} file={session_file.name}", flush=True)
|
|
replay = load_session_replay(session_file)
|
|
if replay is None:
|
|
continue
|
|
replay = trim_replay_to_recent_turns(replay, recent_turns_per_session)
|
|
project_keys.add(replay.project_key)
|
|
decoded_project_paths.add(replay.decoded_project_path)
|
|
observed.sessions += 1
|
|
for turn in replay.turns:
|
|
model_counts[turn.model] += 1
|
|
requests += 1
|
|
rates = _resolve_model_rates(turn.model, cache_write_multiplier=cache_write_multiplier)
|
|
observed.requests += 1
|
|
observed.input_tokens += turn.observed_input_tokens
|
|
observed.cache_read_tokens += turn.observed_cache_read_tokens
|
|
observed.cache_write_tokens += turn.observed_cache_write_tokens
|
|
observed.output_tokens += turn.output_tokens
|
|
observed.paid_input_cost_usd += turn.observed_input_tokens * rates["input"]
|
|
observed.cache_read_cost_usd += turn.observed_cache_read_tokens * rates["cache_read"]
|
|
observed.cache_write_cost_usd += turn.observed_cache_write_tokens * rates["cache_write"]
|
|
observed.paid_output_cost_usd += turn.output_tokens * rates["output"]
|
|
|
|
prev_read = 0
|
|
prev_write = 0
|
|
for turn in replay.turns:
|
|
read = turn.observed_cache_read_tokens
|
|
write = turn.observed_cache_write_tokens
|
|
if read > prev_read and write <= prev_write:
|
|
observed.healthy_growth_turns += 1
|
|
if read == prev_read and write > prev_write:
|
|
observed.broken_prefix_turns += 1
|
|
if read < prev_read and write > 0:
|
|
observed.resume_like_resets += 1
|
|
prev_read = read
|
|
prev_write = write
|
|
|
|
observed.total_cost_usd = (
|
|
observed.paid_input_cost_usd
|
|
+ observed.cache_read_cost_usd
|
|
+ observed.cache_write_cost_usd
|
|
+ observed.paid_output_cost_usd
|
|
)
|
|
dataset = DatasetSummary(
|
|
projects=len(project_keys),
|
|
sessions=observed.sessions,
|
|
requests=requests,
|
|
models=dict(sorted(model_counts.items())),
|
|
decoded_project_paths=len(decoded_project_paths),
|
|
sampled_requests=requests,
|
|
sampling_note=(
|
|
f"Most recent {recent_turns_per_session} turns per session"
|
|
if recent_turns_per_session
|
|
else "Full replayable session history"
|
|
),
|
|
)
|
|
return dataset, observed
|
|
|
|
|
|
def summarize_dataset(replays: list[SessionReplay]) -> DatasetSummary:
|
|
model_counts: Counter[str] = Counter()
|
|
project_paths: set[str] = set()
|
|
requests = 0
|
|
for replay in replays:
|
|
project_paths.add(replay.decoded_project_path)
|
|
for turn in replay.turns:
|
|
model_counts[turn.model] += 1
|
|
requests += 1
|
|
return DatasetSummary(
|
|
projects=len({r.project_key for r in replays}),
|
|
sessions=len(replays),
|
|
requests=requests,
|
|
models=dict(sorted(model_counts.items())),
|
|
decoded_project_paths=len(project_paths),
|
|
)
|
|
|
|
|
|
def summarize_observed_usage(
|
|
replays: list[SessionReplay], *, cache_write_multiplier: float = 1.25
|
|
) -> ObservedSummary:
|
|
summary = ObservedSummary(sessions=len(replays))
|
|
for replay in replays:
|
|
prev_read = 0
|
|
prev_write = 0
|
|
for turn in replay.turns:
|
|
rates = _resolve_model_rates(turn.model, cache_write_multiplier=cache_write_multiplier)
|
|
summary.requests += 1
|
|
summary.input_tokens += turn.observed_input_tokens
|
|
summary.cache_read_tokens += turn.observed_cache_read_tokens
|
|
summary.cache_write_tokens += turn.observed_cache_write_tokens
|
|
summary.output_tokens += turn.output_tokens
|
|
|
|
summary.paid_input_cost_usd += turn.observed_input_tokens * rates["input"]
|
|
summary.cache_read_cost_usd += turn.observed_cache_read_tokens * rates["cache_read"]
|
|
summary.cache_write_cost_usd += turn.observed_cache_write_tokens * rates["cache_write"]
|
|
summary.paid_output_cost_usd += turn.output_tokens * rates["output"]
|
|
|
|
read = turn.observed_cache_read_tokens
|
|
write = turn.observed_cache_write_tokens
|
|
if read > prev_read and write <= prev_write:
|
|
summary.healthy_growth_turns += 1
|
|
if read == prev_read and write > prev_write:
|
|
summary.broken_prefix_turns += 1
|
|
if read < prev_read and write > 0:
|
|
summary.resume_like_resets += 1
|
|
prev_read = read
|
|
prev_write = write
|
|
|
|
summary.total_cost_usd = (
|
|
summary.paid_input_cost_usd
|
|
+ summary.cache_read_cost_usd
|
|
+ summary.cache_write_cost_usd
|
|
+ summary.paid_output_cost_usd
|
|
)
|
|
return summary
|
|
|
|
|
|
def _common_prefix_tokens(
|
|
prev: list[dict[str, Any]],
|
|
curr: list[dict[str, Any]],
|
|
tokenizer: Any,
|
|
) -> int:
|
|
common = 0
|
|
for a, b in zip(prev, curr):
|
|
if a != b:
|
|
break
|
|
common += tokenizer.count_message(b)
|
|
return common
|
|
|
|
|
|
def _rewrite_scope(
|
|
original_messages: list[dict[str, Any]],
|
|
forwarded_messages: list[dict[str, Any]],
|
|
*,
|
|
stable_prefix_message_count: int,
|
|
) -> tuple[bool, bool]:
|
|
if original_messages == forwarded_messages:
|
|
return False, False
|
|
stable_count = min(
|
|
stable_prefix_message_count,
|
|
len(original_messages),
|
|
len(forwarded_messages),
|
|
)
|
|
retroactive = False
|
|
if len(forwarded_messages) < stable_prefix_message_count:
|
|
retroactive = True
|
|
elif stable_count > 0 and forwarded_messages[:stable_count] != original_messages[:stable_count]:
|
|
retroactive = True
|
|
return True, retroactive
|
|
|
|
|
|
def _extract_cache_stable_delta(
|
|
current_messages: list[dict[str, Any]],
|
|
previous_original_messages: list[dict[str, Any]] | None,
|
|
previous_forwarded_messages: list[dict[str, Any]] | None,
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]] | None:
|
|
if previous_original_messages is None or previous_forwarded_messages is None:
|
|
return None
|
|
if len(current_messages) < len(previous_original_messages):
|
|
return None
|
|
stable_count = len(previous_original_messages)
|
|
if current_messages[:stable_count] != previous_original_messages:
|
|
return None
|
|
return (
|
|
copy.deepcopy(previous_forwarded_messages),
|
|
copy.deepcopy(current_messages[stable_count:]),
|
|
)
|
|
|
|
|
|
def _extract_cache_stable_last_message_suffix(
|
|
current_messages: list[dict[str, Any]],
|
|
previous_original_messages: list[dict[str, Any]] | None,
|
|
previous_forwarded_messages: list[dict[str, Any]] | None,
|
|
) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]] | None:
|
|
if not previous_original_messages or previous_forwarded_messages is None:
|
|
return None
|
|
if (
|
|
len(current_messages) != len(previous_original_messages)
|
|
or len(previous_forwarded_messages) != len(previous_original_messages)
|
|
or not current_messages
|
|
):
|
|
return None
|
|
prefix_len = len(current_messages) - 1
|
|
if prefix_len > 0 and current_messages[:prefix_len] != previous_original_messages[:prefix_len]:
|
|
return None
|
|
|
|
current_last = current_messages[-1]
|
|
previous_original_last = previous_original_messages[-1]
|
|
previous_forwarded_last = previous_forwarded_messages[-1]
|
|
if current_last.get("role") != previous_original_last.get("role") or current_last.get(
|
|
"role"
|
|
) != previous_forwarded_last.get("role"):
|
|
return None
|
|
|
|
current_content = current_last.get("content")
|
|
previous_original_content = previous_original_last.get("content")
|
|
previous_forwarded_content = previous_forwarded_last.get("content")
|
|
|
|
if (
|
|
isinstance(current_content, str)
|
|
and isinstance(previous_original_content, str)
|
|
and isinstance(previous_forwarded_content, str)
|
|
and current_content.startswith(previous_original_content)
|
|
):
|
|
suffix = current_content[len(previous_original_content) :]
|
|
delta_messages = []
|
|
if suffix:
|
|
delta_messages = [{**copy.deepcopy(current_last), "content": suffix}]
|
|
return (
|
|
copy.deepcopy(previous_forwarded_messages[:-1]),
|
|
copy.deepcopy(previous_forwarded_last),
|
|
delta_messages,
|
|
)
|
|
|
|
if (
|
|
isinstance(current_content, list)
|
|
and isinstance(previous_original_content, list)
|
|
and isinstance(previous_forwarded_content, list)
|
|
and len(current_content) >= len(previous_original_content)
|
|
and current_content[: len(previous_original_content)] == previous_original_content
|
|
):
|
|
delta_blocks = copy.deepcopy(current_content[len(previous_original_content) :])
|
|
delta_messages = []
|
|
if delta_blocks:
|
|
delta_messages = [{**copy.deepcopy(current_last), "content": delta_blocks}]
|
|
return (
|
|
copy.deepcopy(previous_forwarded_messages[:-1]),
|
|
copy.deepcopy(previous_forwarded_last),
|
|
delta_messages,
|
|
)
|
|
return None
|
|
|
|
|
|
def _merge_appended_message_delta(
|
|
previous_forwarded_message: dict[str, Any],
|
|
delta_forwarded_message: dict[str, Any] | None,
|
|
) -> dict[str, Any] | None:
|
|
if delta_forwarded_message is None:
|
|
return copy.deepcopy(previous_forwarded_message)
|
|
if previous_forwarded_message.get("role") != delta_forwarded_message.get("role"):
|
|
return None
|
|
|
|
previous_content = previous_forwarded_message.get("content")
|
|
delta_content = delta_forwarded_message.get("content")
|
|
if isinstance(previous_content, str) and isinstance(delta_content, str):
|
|
return {
|
|
**copy.deepcopy(previous_forwarded_message),
|
|
"content": previous_content + delta_content,
|
|
}
|
|
if isinstance(previous_content, list) and isinstance(delta_content, list):
|
|
return {
|
|
**copy.deepcopy(previous_forwarded_message),
|
|
"content": copy.deepcopy(previous_content) + copy.deepcopy(delta_content),
|
|
}
|
|
return None
|
|
|
|
|
|
def _make_proxy(mode: str) -> HeadroomProxy:
|
|
cfg = ProxyConfig(
|
|
mode=mode,
|
|
optimize=True,
|
|
image_optimize=True,
|
|
smart_routing=False,
|
|
code_aware_enabled=False,
|
|
read_lifecycle=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
log_requests=False,
|
|
ccr_inject_tool=False,
|
|
ccr_handle_responses=False,
|
|
ccr_context_tracking=False,
|
|
)
|
|
return HeadroomProxy(cfg)
|
|
|
|
|
|
def _apply_mode_to_messages(
|
|
proxy: HeadroomProxy | None,
|
|
mode: str,
|
|
messages: list[dict[str, Any]],
|
|
*,
|
|
model: str,
|
|
prefix_tracker: PrefixCacheTracker | None,
|
|
comp_cache: CompressionCache | None,
|
|
previous_original_messages: list[dict[str, Any]] | None = None,
|
|
previous_forwarded_messages: list[dict[str, Any]] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
if mode == "baseline":
|
|
return copy.deepcopy(messages)
|
|
|
|
assert proxy is not None
|
|
assert prefix_tracker is not None
|
|
if mode == PROXY_MODE_CACHE:
|
|
supports_delta_replay = hasattr(
|
|
AnthropicHandlerMixin, "_extract_cache_stable_last_message_suffix"
|
|
)
|
|
if not supports_delta_replay:
|
|
frozen_message_count = prefix_tracker.get_frozen_message_count()
|
|
context_limit = proxy.anthropic_provider.get_context_limit(model)
|
|
result = proxy.anthropic_pipeline.apply(
|
|
messages=copy.deepcopy(messages),
|
|
model=model,
|
|
model_limit=context_limit,
|
|
context=extract_user_query(messages),
|
|
frozen_message_count=frozen_message_count,
|
|
)
|
|
if hasattr(AnthropicHandlerMixin, "_restore_frozen_prefix"):
|
|
result.messages, _ = AnthropicHandlerMixin._restore_frozen_prefix(
|
|
messages,
|
|
result.messages,
|
|
frozen_message_count=frozen_message_count,
|
|
)
|
|
return result.messages
|
|
|
|
delta = _extract_cache_stable_delta(
|
|
messages,
|
|
previous_original_messages,
|
|
previous_forwarded_messages,
|
|
)
|
|
if delta is not None:
|
|
stable_forwarded_prefix, delta_messages = delta
|
|
if not delta_messages:
|
|
return stable_forwarded_prefix
|
|
context_limit = proxy.anthropic_provider.get_context_limit(model)
|
|
result = proxy.anthropic_pipeline.apply(
|
|
messages=delta_messages,
|
|
model=model,
|
|
model_limit=context_limit,
|
|
context=extract_user_query(delta_messages),
|
|
frozen_message_count=0,
|
|
)
|
|
return stable_forwarded_prefix + result.messages
|
|
|
|
return copy.deepcopy(messages)
|
|
|
|
frozen_message_count = prefix_tracker.get_frozen_message_count()
|
|
|
|
working_messages = copy.deepcopy(messages)
|
|
if proxy.config.image_optimize and working_messages and _messages_have_images(working_messages):
|
|
from headroom.proxy.helpers import _get_image_compressor
|
|
|
|
compressor = _get_image_compressor()
|
|
if compressor and compressor.has_images(working_messages):
|
|
if mode == PROXY_MODE_CACHE:
|
|
working_messages = (
|
|
AnthropicHandlerMixin._compress_latest_user_turn_images_cache_safe(
|
|
working_messages,
|
|
frozen_message_count=frozen_message_count,
|
|
compressor=compressor,
|
|
)
|
|
)
|
|
else:
|
|
working_messages = compressor.compress(working_messages, provider="anthropic")
|
|
|
|
if mode == PROXY_MODE_TOKEN and comp_cache is not None:
|
|
working_messages = comp_cache.apply_cached(working_messages)
|
|
cache_frozen_count = comp_cache.compute_frozen_count(messages)
|
|
frozen_message_count = min(frozen_message_count, cache_frozen_count)
|
|
|
|
context_limit = proxy.anthropic_provider.get_context_limit(model)
|
|
result = proxy.anthropic_pipeline.apply(
|
|
messages=working_messages,
|
|
model=model,
|
|
model_limit=context_limit,
|
|
context=extract_user_query(working_messages),
|
|
frozen_message_count=frozen_message_count,
|
|
)
|
|
forwarded = result.messages
|
|
|
|
if mode == PROXY_MODE_TOKEN and comp_cache is not None and forwarded != working_messages:
|
|
comp_cache.update_from_result(messages, forwarded)
|
|
if mode == PROXY_MODE_CACHE:
|
|
forwarded, _ = AnthropicHandlerMixin._restore_frozen_prefix(
|
|
messages,
|
|
forwarded,
|
|
frozen_message_count=frozen_message_count,
|
|
)
|
|
return forwarded
|
|
|
|
|
|
@dataclass
|
|
class _PendingTurn:
|
|
summary: ModeSummary
|
|
turn: ReplayTurn
|
|
tokenizer: Any
|
|
raw_input_tokens: int
|
|
request_messages: list[dict[str, Any]]
|
|
forwarded: list[dict[str, Any]]
|
|
rewrite: bool
|
|
retroactive_rewrite: bool
|
|
|
|
|
|
def _cache_gap_within_ttl(
|
|
current_ts: datetime,
|
|
previous_ts: datetime | None,
|
|
*,
|
|
ttl: timedelta,
|
|
) -> bool:
|
|
if previous_ts is None:
|
|
return False
|
|
return current_ts - previous_ts <= ttl
|
|
|
|
|
|
def _resolve_model_rates(model: str, *, cache_write_multiplier: float) -> dict[str, float]:
|
|
pricing = get_model_pricing(model)
|
|
if pricing is None:
|
|
if "opus" in model:
|
|
input_per_1m = 15.0
|
|
output_per_1m = 75.0
|
|
elif "haiku" in model:
|
|
input_per_1m = 1.0
|
|
output_per_1m = 5.0
|
|
else:
|
|
input_per_1m = 3.0
|
|
output_per_1m = 15.0
|
|
else:
|
|
input_per_1m = pricing.input_cost_per_1m
|
|
output_per_1m = pricing.output_cost_per_1m
|
|
return {
|
|
"input": input_per_1m / 1_000_000,
|
|
"output": output_per_1m / 1_000_000,
|
|
"cache_read": (input_per_1m * 0.10) / 1_000_000,
|
|
"cache_write": (input_per_1m * cache_write_multiplier) / 1_000_000,
|
|
}
|
|
|
|
|
|
def _apply_turn_metrics(
|
|
summary: ModeSummary,
|
|
turn: ReplayTurn,
|
|
*,
|
|
raw_input_tokens: int,
|
|
tokenizer: Any,
|
|
forwarded: list[dict[str, Any]],
|
|
previous_forwarded: list[dict[str, Any]],
|
|
previous_timestamp: datetime | None,
|
|
next_forwarded: list[dict[str, Any]] | None,
|
|
next_timestamp: datetime | None,
|
|
ttl: timedelta,
|
|
cache_write_multiplier: float,
|
|
) -> None:
|
|
forwarded_input_tokens = tokenizer.count_messages(forwarded)
|
|
|
|
read_tokens = 0
|
|
cache_eligible = _cache_gap_within_ttl(turn.timestamp, previous_timestamp, ttl=ttl)
|
|
if cache_eligible:
|
|
read_tokens = _common_prefix_tokens(previous_forwarded, forwarded, tokenizer)
|
|
summary.cache_eligible_turns += 1
|
|
prefix_preserved = (
|
|
len(forwarded) >= len(previous_forwarded)
|
|
and forwarded[: len(previous_forwarded)] == previous_forwarded
|
|
)
|
|
if previous_forwarded and not prefix_preserved:
|
|
summary.cache_bust_turns += 1
|
|
elif previous_timestamp is not None:
|
|
summary.ttl_expiry_turns += 1
|
|
|
|
write_tokens = 0
|
|
if next_forwarded is not None and _cache_gap_within_ttl(
|
|
next_timestamp, turn.timestamp, ttl=ttl
|
|
):
|
|
next_common = _common_prefix_tokens(forwarded, next_forwarded, tokenizer)
|
|
write_tokens = max(0, next_common - read_tokens)
|
|
|
|
regular_input_tokens = max(0, forwarded_input_tokens - read_tokens - write_tokens)
|
|
rates = _resolve_model_rates(turn.model, cache_write_multiplier=cache_write_multiplier)
|
|
paid_input_cost_usd = regular_input_tokens * rates["input"]
|
|
cache_read_cost_usd = read_tokens * rates["cache_read"]
|
|
cache_write_cost_usd = write_tokens * rates["cache_write"]
|
|
paid_output_cost_usd = turn.output_tokens * rates["output"]
|
|
total_cost_usd = (
|
|
paid_input_cost_usd + cache_read_cost_usd + cache_write_cost_usd + paid_output_cost_usd
|
|
)
|
|
|
|
summary.requests += 1
|
|
summary.raw_input_tokens += raw_input_tokens
|
|
summary.forwarded_input_tokens += forwarded_input_tokens
|
|
summary.cache_read_tokens += read_tokens
|
|
summary.cache_write_tokens += write_tokens
|
|
summary.regular_input_tokens += regular_input_tokens
|
|
summary.output_tokens += turn.output_tokens
|
|
summary.paid_input_cost_usd += paid_input_cost_usd
|
|
summary.cache_read_cost_usd += cache_read_cost_usd
|
|
summary.cache_write_cost_usd += cache_write_cost_usd
|
|
summary.paid_output_cost_usd += paid_output_cost_usd
|
|
summary.total_cost_usd += total_cost_usd
|
|
|
|
|
|
def _merge_mode_summary(target: ModeSummary, source: ModeSummary) -> None:
|
|
target.sessions += source.sessions
|
|
target.requests += source.requests
|
|
target.raw_input_tokens += source.raw_input_tokens
|
|
target.forwarded_input_tokens += source.forwarded_input_tokens
|
|
target.cache_read_tokens += source.cache_read_tokens
|
|
target.cache_write_tokens += source.cache_write_tokens
|
|
target.regular_input_tokens += source.regular_input_tokens
|
|
target.output_tokens += source.output_tokens
|
|
target.paid_input_cost_usd += source.paid_input_cost_usd
|
|
target.cache_read_cost_usd += source.cache_read_cost_usd
|
|
target.cache_write_cost_usd += source.cache_write_cost_usd
|
|
target.paid_output_cost_usd += source.paid_output_cost_usd
|
|
target.total_cost_usd += source.total_cost_usd
|
|
target.cache_eligible_turns += source.cache_eligible_turns
|
|
target.cache_bust_turns += source.cache_bust_turns
|
|
target.ttl_expiry_turns += source.ttl_expiry_turns
|
|
target.rewrite_turns += source.rewrite_turns
|
|
target.stable_replay_rewrite_turns += source.stable_replay_rewrite_turns
|
|
target.busting_rewrite_turns += source.busting_rewrite_turns
|
|
target.non_cache_eligible_rewrite_turns += source.non_cache_eligible_rewrite_turns
|
|
target.retroactive_rewrite_turns += source.retroactive_rewrite_turns
|
|
target.latest_turn_only_rewrite_turns += source.latest_turn_only_rewrite_turns
|
|
|
|
|
|
def _disable_headroom_benchmark_logging() -> None:
|
|
logging.raiseExceptions = False
|
|
for logger_name in (
|
|
"headroom",
|
|
"headroom.cache",
|
|
"headroom.cache.compression_cache",
|
|
"headroom.proxy",
|
|
"headroom.transforms",
|
|
):
|
|
logger = logging.getLogger(logger_name)
|
|
logger.handlers.clear()
|
|
logger.propagate = False
|
|
logger.setLevel(logging.CRITICAL)
|
|
|
|
|
|
def _checkpoint_path(checkpoint_dir: Path, mode: str, replay: SessionReplay) -> Path:
|
|
return checkpoint_dir / f"{mode}--{replay.session_id}.json"
|
|
|
|
|
|
def _checkpoint_path_for_session_id(checkpoint_dir: Path, mode: str, session_id: str) -> Path:
|
|
return checkpoint_dir / f"{mode}--{session_id}.json"
|
|
|
|
|
|
def _load_checkpoint(checkpoint_dir: Path, mode: str, replay: SessionReplay) -> ModeSummary | None:
|
|
path = _checkpoint_path(checkpoint_dir, mode, replay)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
return _mode_summary_from_dict(payload)
|
|
|
|
|
|
def _load_checkpoint_by_session_id(
|
|
checkpoint_dir: Path, mode: str, session_id: str
|
|
) -> ModeSummary | None:
|
|
path = _checkpoint_path_for_session_id(checkpoint_dir, mode, session_id)
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
return _mode_summary_from_dict(payload)
|
|
|
|
|
|
def _write_checkpoint(
|
|
checkpoint_dir: Path,
|
|
mode: str,
|
|
replay: SessionReplay,
|
|
summary: ModeSummary,
|
|
) -> None:
|
|
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
path = _checkpoint_path(checkpoint_dir, mode, replay)
|
|
payload = asdict(summary)
|
|
payload["turns"] = []
|
|
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _write_checkpoint_by_session_id(
|
|
checkpoint_dir: Path, mode: str, session_id: str, summary: ModeSummary
|
|
) -> None:
|
|
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
path = _checkpoint_path_for_session_id(checkpoint_dir, mode, session_id)
|
|
payload = asdict(summary)
|
|
payload["turns"] = []
|
|
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _update_prefix_tracker(
|
|
prefix_tracker: PrefixCacheTracker,
|
|
*,
|
|
cache_read_tokens: int,
|
|
cache_write_tokens: int,
|
|
messages: list[dict[str, Any]],
|
|
message_token_counts: list[int],
|
|
original_messages: list[dict[str, Any]] | None = None,
|
|
) -> None:
|
|
try:
|
|
prefix_tracker.update_from_response(
|
|
cache_read_tokens=cache_read_tokens,
|
|
cache_write_tokens=cache_write_tokens,
|
|
messages=messages,
|
|
message_token_counts=message_token_counts,
|
|
original_messages=original_messages,
|
|
)
|
|
except TypeError:
|
|
prefix_tracker.update_from_response(
|
|
cache_read_tokens=cache_read_tokens,
|
|
cache_write_tokens=cache_write_tokens,
|
|
messages=messages,
|
|
message_token_counts=message_token_counts,
|
|
)
|
|
|
|
|
|
def _simulate_single_replay_mode(
|
|
replay: SessionReplay,
|
|
mode: str,
|
|
cache_ttl_minutes: int,
|
|
cache_write_multiplier: float,
|
|
) -> ModeSummary:
|
|
_disable_headroom_benchmark_logging()
|
|
|
|
summary = ModeSummary(mode=mode, sessions=1)
|
|
ttl = timedelta(minutes=cache_ttl_minutes)
|
|
proxy = None if mode == "baseline" else _make_proxy(mode)
|
|
pending: _PendingTurn | None = None
|
|
conversation: list[dict[str, Any]] = []
|
|
conversation_token_total = 0
|
|
previous_forwarded: list[dict[str, Any]] = []
|
|
previous_original_context: list[dict[str, Any]] | None = None
|
|
previous_forwarded_context: list[dict[str, Any]] | None = None
|
|
previous_timestamp: datetime | None = None
|
|
prefix_tracker = None if mode == "baseline" else PrefixCacheTracker("anthropic")
|
|
comp_cache = CompressionCache() if mode == PROXY_MODE_TOKEN else None
|
|
|
|
for turn in replay.turns:
|
|
tokenizer = get_tokenizer(turn.model)
|
|
turn_input_token_total = sum(tokenizer.count_message(msg) for msg in turn.input_messages)
|
|
prior_context_message_count = len(conversation)
|
|
conversation.extend(turn.input_messages)
|
|
raw_input_tokens = conversation_token_total + turn_input_token_total
|
|
forwarded = _apply_mode_to_messages(
|
|
proxy,
|
|
mode,
|
|
conversation,
|
|
model=turn.model,
|
|
prefix_tracker=prefix_tracker,
|
|
comp_cache=comp_cache,
|
|
previous_original_messages=previous_original_context,
|
|
previous_forwarded_messages=previous_forwarded_context,
|
|
)
|
|
rewrite, retroactive_rewrite = _rewrite_scope(
|
|
conversation,
|
|
forwarded,
|
|
stable_prefix_message_count=prior_context_message_count,
|
|
)
|
|
if rewrite:
|
|
summary.rewrite_turns += 1
|
|
if retroactive_rewrite:
|
|
summary.retroactive_rewrite_turns += 1
|
|
else:
|
|
summary.latest_turn_only_rewrite_turns += 1
|
|
prior_forwarded_for_rewrite = (
|
|
pending.forwarded if pending is not None else previous_forwarded
|
|
)
|
|
prior_timestamp_for_rewrite = (
|
|
pending.turn.timestamp if pending is not None else previous_timestamp
|
|
)
|
|
if (
|
|
prior_timestamp_for_rewrite is not None
|
|
and _cache_gap_within_ttl(turn.timestamp, prior_timestamp_for_rewrite, ttl=ttl)
|
|
and prior_forwarded_for_rewrite
|
|
):
|
|
prefix_preserved = (
|
|
len(forwarded) >= len(prior_forwarded_for_rewrite)
|
|
and forwarded[: len(prior_forwarded_for_rewrite)] == prior_forwarded_for_rewrite
|
|
)
|
|
if prefix_preserved:
|
|
summary.stable_replay_rewrite_turns += 1
|
|
else:
|
|
summary.busting_rewrite_turns += 1
|
|
else:
|
|
summary.non_cache_eligible_rewrite_turns += 1
|
|
if pending is not None:
|
|
_apply_turn_metrics(
|
|
pending.summary,
|
|
pending.turn,
|
|
raw_input_tokens=pending.raw_input_tokens,
|
|
tokenizer=pending.tokenizer,
|
|
forwarded=pending.forwarded,
|
|
previous_forwarded=previous_forwarded,
|
|
previous_timestamp=previous_timestamp,
|
|
next_forwarded=forwarded,
|
|
next_timestamp=turn.timestamp,
|
|
ttl=ttl,
|
|
cache_write_multiplier=cache_write_multiplier,
|
|
)
|
|
previous_forwarded = copy.deepcopy(pending.forwarded)
|
|
previous_timestamp = pending.turn.timestamp
|
|
|
|
if prefix_tracker is not None:
|
|
_update_prefix_tracker(
|
|
prefix_tracker,
|
|
cache_read_tokens=0,
|
|
cache_write_tokens=0,
|
|
messages=forwarded,
|
|
message_token_counts=[tokenizer.count_message(msg) for msg in forwarded],
|
|
original_messages=conversation,
|
|
)
|
|
|
|
pending = _PendingTurn(
|
|
summary=summary,
|
|
turn=turn,
|
|
tokenizer=tokenizer,
|
|
raw_input_tokens=raw_input_tokens,
|
|
request_messages=copy.deepcopy(conversation),
|
|
forwarded=forwarded,
|
|
rewrite=rewrite,
|
|
retroactive_rewrite=retroactive_rewrite,
|
|
)
|
|
conversation.append(turn.assistant_message)
|
|
conversation_token_total = raw_input_tokens + tokenizer.count_message(
|
|
turn.assistant_message
|
|
)
|
|
previous_original_context = copy.deepcopy(conversation)
|
|
previous_forwarded_context = copy.deepcopy(forwarded) + [
|
|
copy.deepcopy(turn.assistant_message)
|
|
]
|
|
|
|
if pending is not None:
|
|
_apply_turn_metrics(
|
|
pending.summary,
|
|
pending.turn,
|
|
raw_input_tokens=pending.raw_input_tokens,
|
|
tokenizer=pending.tokenizer,
|
|
forwarded=pending.forwarded,
|
|
previous_forwarded=previous_forwarded,
|
|
previous_timestamp=previous_timestamp,
|
|
next_forwarded=None,
|
|
next_timestamp=None,
|
|
ttl=ttl,
|
|
cache_write_multiplier=cache_write_multiplier,
|
|
)
|
|
|
|
return summary
|
|
|
|
|
|
def _simulate_single_session_file_mode(
|
|
session_file: Path,
|
|
mode: str,
|
|
cache_ttl_minutes: int,
|
|
cache_write_multiplier: float,
|
|
recent_turns_per_session: int | None = None,
|
|
) -> tuple[str, ModeSummary]:
|
|
replay = load_session_replay(session_file)
|
|
if replay is None:
|
|
return session_file.stem, ModeSummary(mode=mode)
|
|
replay = trim_replay_to_recent_turns(replay, recent_turns_per_session)
|
|
return replay.session_id, _simulate_single_replay_mode(
|
|
replay,
|
|
mode,
|
|
cache_ttl_minutes,
|
|
cache_write_multiplier,
|
|
)
|
|
|
|
|
|
def simulate_replays(
|
|
replays: list[SessionReplay],
|
|
*,
|
|
cache_ttl_minutes: int = DEFAULT_CACHE_TTL_MINUTES,
|
|
cache_write_multiplier: float = 1.25,
|
|
workers: int = 1,
|
|
checkpoint_dir: Path | None = None,
|
|
) -> tuple[DatasetSummary, dict[str, ModeSummary]]:
|
|
dataset = summarize_dataset(replays)
|
|
summaries = {
|
|
"baseline": ModeSummary(mode="baseline"),
|
|
PROXY_MODE_TOKEN: ModeSummary(mode=PROXY_MODE_TOKEN),
|
|
PROXY_MODE_CACHE: ModeSummary(mode=PROXY_MODE_CACHE),
|
|
}
|
|
|
|
for mode in ("baseline", PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
print(f"[simulate] mode={mode} sessions={len(replays)}", flush=True)
|
|
worker_count = workers if workers > 0 else max(1, min(8, os.cpu_count() or 1))
|
|
if worker_count > 1 and len(replays) > 1:
|
|
with concurrent.futures.ProcessPoolExecutor(max_workers=worker_count) as executor:
|
|
future_map: dict[concurrent.futures.Future[ModeSummary], SessionReplay] = {}
|
|
completed = 0
|
|
for replay in replays:
|
|
cached = (
|
|
_load_checkpoint(checkpoint_dir, mode, replay)
|
|
if checkpoint_dir is not None
|
|
else None
|
|
)
|
|
if cached is not None:
|
|
completed += 1
|
|
_merge_mode_summary(summaries[mode], cached)
|
|
if completed == 1 or completed % 10 == 0 or completed == len(replays):
|
|
print(
|
|
f"[simulate] mode={mode} completed={completed}/{len(replays)}",
|
|
flush=True,
|
|
)
|
|
continue
|
|
future = executor.submit(
|
|
_simulate_single_replay_mode,
|
|
replay,
|
|
mode,
|
|
cache_ttl_minutes,
|
|
cache_write_multiplier,
|
|
)
|
|
future_map[future] = replay
|
|
for future in concurrent.futures.as_completed(future_map):
|
|
replay = future_map[future]
|
|
partial = future.result()
|
|
if checkpoint_dir is not None:
|
|
_write_checkpoint(checkpoint_dir, mode, replay, partial)
|
|
completed += 1
|
|
if completed == 1 or completed % 10 == 0 or completed == len(replays):
|
|
print(
|
|
f"[simulate] mode={mode} completed={completed}/{len(replays)}",
|
|
flush=True,
|
|
)
|
|
_merge_mode_summary(summaries[mode], partial)
|
|
else:
|
|
for index, replay in enumerate(replays, start=1):
|
|
cached = (
|
|
_load_checkpoint(checkpoint_dir, mode, replay)
|
|
if checkpoint_dir is not None
|
|
else None
|
|
)
|
|
if cached is not None:
|
|
_merge_mode_summary(summaries[mode], cached)
|
|
continue
|
|
if index == 1 or index % 10 == 0 or index == len(replays):
|
|
print(
|
|
f"[simulate] mode={mode} session={index}/{len(replays)} "
|
|
f"requests={len(replay.turns)}",
|
|
flush=True,
|
|
)
|
|
partial = _simulate_single_replay_mode(
|
|
replay,
|
|
mode,
|
|
cache_ttl_minutes,
|
|
cache_write_multiplier,
|
|
)
|
|
if checkpoint_dir is not None:
|
|
_write_checkpoint(checkpoint_dir, mode, replay, partial)
|
|
_merge_mode_summary(summaries[mode], partial)
|
|
|
|
return dataset, summaries
|
|
|
|
|
|
def simulate_session_files(
|
|
session_files: list[Path],
|
|
dataset: DatasetSummary,
|
|
*,
|
|
cache_ttl_minutes: int = DEFAULT_CACHE_TTL_MINUTES,
|
|
cache_write_multiplier: float = 1.25,
|
|
workers: int = 1,
|
|
checkpoint_dir: Path | None = None,
|
|
recent_turns_per_session: int | None = None,
|
|
) -> dict[str, ModeSummary]:
|
|
summaries = {
|
|
"baseline": ModeSummary(mode="baseline"),
|
|
PROXY_MODE_TOKEN: ModeSummary(mode=PROXY_MODE_TOKEN),
|
|
PROXY_MODE_CACHE: ModeSummary(mode=PROXY_MODE_CACHE),
|
|
}
|
|
total = len(session_files)
|
|
|
|
for mode in ("baseline", PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
print(f"[simulate] mode={mode} sessions={total}", flush=True)
|
|
worker_count = workers if workers > 0 else 1
|
|
if worker_count > 1 and total > 1:
|
|
with concurrent.futures.ProcessPoolExecutor(
|
|
max_workers=worker_count,
|
|
initializer=_disable_headroom_benchmark_logging,
|
|
) as executor:
|
|
future_map: dict[concurrent.futures.Future[tuple[str, ModeSummary]], str] = {}
|
|
completed = 0
|
|
for session_file in session_files:
|
|
session_id = session_file.stem
|
|
cached = (
|
|
_load_checkpoint_by_session_id(checkpoint_dir, mode, session_id)
|
|
if checkpoint_dir is not None
|
|
else None
|
|
)
|
|
if cached is not None:
|
|
completed += 1
|
|
_merge_mode_summary(summaries[mode], cached)
|
|
if completed == 1 or completed % 10 == 0 or completed == total:
|
|
print(
|
|
f"[simulate] mode={mode} completed={completed}/{total}",
|
|
flush=True,
|
|
)
|
|
continue
|
|
future = executor.submit(
|
|
_simulate_single_session_file_mode,
|
|
session_file,
|
|
mode,
|
|
cache_ttl_minutes,
|
|
cache_write_multiplier,
|
|
recent_turns_per_session,
|
|
)
|
|
future_map[future] = session_id
|
|
for future in concurrent.futures.as_completed(future_map):
|
|
session_id, partial = future.result()
|
|
if checkpoint_dir is not None:
|
|
_write_checkpoint_by_session_id(checkpoint_dir, mode, session_id, partial)
|
|
completed += 1
|
|
if completed == 1 or completed % 10 == 0 or completed == total:
|
|
print(
|
|
f"[simulate] mode={mode} completed={completed}/{total}",
|
|
flush=True,
|
|
)
|
|
_merge_mode_summary(summaries[mode], partial)
|
|
else:
|
|
for index, session_file in enumerate(session_files, start=1):
|
|
session_id = session_file.stem
|
|
cached = (
|
|
_load_checkpoint_by_session_id(checkpoint_dir, mode, session_id)
|
|
if checkpoint_dir is not None
|
|
else None
|
|
)
|
|
if cached is not None:
|
|
_merge_mode_summary(summaries[mode], cached)
|
|
if index == 1 or index % 10 == 0 or index == total:
|
|
print(
|
|
f"[simulate] mode={mode} completed={index}/{total}",
|
|
flush=True,
|
|
)
|
|
continue
|
|
replay = load_session_replay(session_file)
|
|
if replay is None:
|
|
continue
|
|
replay = trim_replay_to_recent_turns(replay, recent_turns_per_session)
|
|
if index == 1 or index % 10 == 0 or index == total:
|
|
print(
|
|
f"[simulate] mode={mode} session={index}/{total} "
|
|
f"requests={len(replay.turns)}",
|
|
flush=True,
|
|
)
|
|
partial = _simulate_single_replay_mode(
|
|
replay,
|
|
mode,
|
|
cache_ttl_minutes,
|
|
cache_write_multiplier,
|
|
)
|
|
if checkpoint_dir is not None:
|
|
_write_checkpoint_by_session_id(checkpoint_dir, mode, session_id, partial)
|
|
_merge_mode_summary(summaries[mode], partial)
|
|
|
|
return summaries
|
|
|
|
|
|
def determine_winners(summaries: dict[str, ModeSummary]) -> dict[str, str]:
|
|
return {
|
|
"total_cost": min(summaries.values(), key=lambda s: s.total_cost_usd).mode,
|
|
"no_cache_total_cost": min(
|
|
summaries.values(), key=lambda s: s.no_cache_total_cost_usd
|
|
).mode,
|
|
"window_with_cache": min(summaries.values(), key=lambda s: s.prompt_window_with_cache).mode,
|
|
"window_without_cache_reads": min(
|
|
summaries.values(), key=lambda s: s.prompt_window_without_cache_reads
|
|
).mode,
|
|
}
|
|
|
|
|
|
def _metric_value(summary: ModeSummary, field: str) -> float:
|
|
value = getattr(summary, field)
|
|
return float(value)
|
|
|
|
|
|
def classify_metric_impact(
|
|
baseline: ModeSummary,
|
|
candidate: ModeSummary,
|
|
field: str,
|
|
) -> dict[str, float | str]:
|
|
baseline_value = _metric_value(baseline, field)
|
|
candidate_value = _metric_value(candidate, field)
|
|
delta = candidate_value - baseline_value
|
|
direction = IMPACT_DIRECTION[field]
|
|
tolerance = 1e-9
|
|
|
|
if abs(delta) <= tolerance:
|
|
impact = "no_change"
|
|
elif direction == "lower":
|
|
impact = "assist" if delta < 0 else "harm"
|
|
elif direction == "higher":
|
|
impact = "assist" if delta > 0 else "harm"
|
|
else:
|
|
impact = "harm" if abs(delta) > tolerance else "no_change"
|
|
|
|
return {
|
|
"baseline": baseline_value,
|
|
"candidate": candidate_value,
|
|
"delta": delta,
|
|
"impact": impact,
|
|
"direction": direction,
|
|
}
|
|
|
|
|
|
def summarize_mode_impact_vs_baseline(
|
|
summaries: dict[str, ModeSummary],
|
|
) -> dict[str, dict[str, dict[str, float | str]]]:
|
|
baseline = summaries["baseline"]
|
|
result: dict[str, dict[str, dict[str, float | str]]] = {}
|
|
for mode in (PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
candidate = summaries[mode]
|
|
result[mode] = {
|
|
field: classify_metric_impact(baseline, candidate, field) for field in IMPACT_DIRECTION
|
|
}
|
|
return result
|
|
|
|
|
|
def format_currency(value: float) -> str:
|
|
return f"${value:,.2f}"
|
|
|
|
|
|
def print_console_report(dataset: DatasetSummary, summaries: dict[str, ModeSummary]) -> None:
|
|
winners = determine_winners(summaries)
|
|
impacts = summarize_mode_impact_vs_baseline(summaries)
|
|
print("Claude session mode simulation")
|
|
print(
|
|
f"Dataset: {dataset.projects} projects, {dataset.sessions} sessions, "
|
|
f"{dataset.requests} requests"
|
|
)
|
|
print(f"Sampling: {dataset.sampling_note}")
|
|
print()
|
|
print(
|
|
"mode raw_tok cache_tok cache_read cache_write paid_in paid_out busts ttl_exp rewrite stable_rw bust_rw noncache_rw retro_rw total_cost no_cache"
|
|
)
|
|
for mode in ("baseline", PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
summary = summaries[mode]
|
|
print(
|
|
f"{mode:<9} {summary.raw_tokens:>11,} {summary.cache_tokens:>12,} "
|
|
f"{summary.cache_read_tokens:>11,} {summary.cache_write_tokens:>12,} "
|
|
f"{summary.regular_input_tokens:>10,} {summary.output_tokens:>12,} "
|
|
f"{summary.cache_bust_turns:>7,} {summary.ttl_expiry_turns:>9,} "
|
|
f"{summary.rewrite_turns:>9,} {summary.stable_replay_rewrite_turns:>10,} "
|
|
f"{summary.busting_rewrite_turns:>8,} {summary.non_cache_eligible_rewrite_turns:>12,} "
|
|
f"{summary.retroactive_rewrite_turns:>10,} "
|
|
f"{format_currency(summary.total_cost_usd):>11} "
|
|
f"{format_currency(summary.no_cache_total_cost_usd):>11}"
|
|
)
|
|
print()
|
|
print(f"Winner by total cost: {winners['total_cost']}")
|
|
print(f"Winner by total cost with no cache help: {winners['no_cache_total_cost']}")
|
|
print(f"Winner if cache tokens count against window: {winners['window_with_cache']}")
|
|
print(
|
|
"Winner if cache read tokens do not count against window: "
|
|
f"{winners['window_without_cache_reads']}"
|
|
)
|
|
print()
|
|
print("Impact vs baseline")
|
|
for mode in (PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
impact = impacts[mode]
|
|
print(
|
|
f"{mode}: total_cost={impact['total_cost_usd']['impact']} "
|
|
f"({format_currency(impact['total_cost_usd']['delta'])}), "
|
|
f"cache_read={impact['cache_read_tokens']['impact']} "
|
|
f"({int(impact['cache_read_tokens']['delta']):,}), "
|
|
f"cache_write={impact['cache_write_tokens']['impact']} "
|
|
f"({int(impact['cache_write_tokens']['delta']):,}), "
|
|
f"paid_input={impact['regular_input_tokens']['impact']} "
|
|
f"({int(impact['regular_input_tokens']['delta']):,}), "
|
|
f"rewrite={impact['rewrite_turns']['impact']} "
|
|
f"({int(impact['rewrite_turns']['delta']):,}), "
|
|
f"stable_rw={impact['stable_replay_rewrite_turns']['impact']} "
|
|
f"({int(impact['stable_replay_rewrite_turns']['delta']):,}), "
|
|
f"bust_rw={impact['busting_rewrite_turns']['impact']} "
|
|
f"({int(impact['busting_rewrite_turns']['delta']):,}), "
|
|
f"noncache_rw={impact['non_cache_eligible_rewrite_turns']['impact']} "
|
|
f"({int(impact['non_cache_eligible_rewrite_turns']['delta']):,}), "
|
|
f"retro_rw={impact['retroactive_rewrite_turns']['impact']} "
|
|
f"({int(impact['retroactive_rewrite_turns']['delta']):,}), "
|
|
f"window={impact['prompt_window_with_cache']['impact']} "
|
|
f"({int(impact['prompt_window_with_cache']['delta']):,})"
|
|
)
|
|
|
|
|
|
def print_observed_console_report(observed: ObservedSummary) -> None:
|
|
print()
|
|
print("Observed Claude session usage")
|
|
print(
|
|
f"requests={observed.requests:,} cache_ratio={observed.cache_ratio_pct:.1f}% "
|
|
f"broken_prefix_turns={observed.broken_prefix_turns:,} "
|
|
f"resume_like_resets={observed.resume_like_resets:,}"
|
|
)
|
|
print(
|
|
f"input={observed.input_tokens:,} cache_read={observed.cache_read_tokens:,} "
|
|
f"cache_write={observed.cache_write_tokens:,} output={observed.output_tokens:,} "
|
|
f"total_cost={format_currency(observed.total_cost_usd)}"
|
|
)
|
|
|
|
|
|
def build_report_markdown(
|
|
dataset: DatasetSummary,
|
|
observed: ObservedSummary,
|
|
summaries: dict[str, ModeSummary],
|
|
) -> str:
|
|
winners = determine_winners(summaries)
|
|
impacts = summarize_mode_impact_vs_baseline(summaries)
|
|
model_lines = "\n".join(f"- `{model}`: {count}" for model, count in dataset.models.items())
|
|
rows = []
|
|
for mode in ("baseline", PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
summary = summaries[mode]
|
|
rows.append(
|
|
"| "
|
|
+ " | ".join(
|
|
[
|
|
summary.mode,
|
|
f"{summary.raw_tokens:,}",
|
|
f"{summary.cache_tokens:,}",
|
|
f"{summary.cache_read_tokens:,}",
|
|
f"{summary.cache_write_tokens:,}",
|
|
f"{summary.regular_input_tokens:,}",
|
|
f"{summary.output_tokens:,}",
|
|
format_currency(summary.paid_input_cost_usd),
|
|
format_currency(summary.cache_read_cost_usd),
|
|
format_currency(summary.cache_write_cost_usd),
|
|
format_currency(summary.paid_output_cost_usd),
|
|
format_currency(summary.total_cost_usd),
|
|
format_currency(summary.no_cache_total_cost_usd),
|
|
f"{summary.cache_bust_turns:,}",
|
|
f"{summary.ttl_expiry_turns:,}",
|
|
f"{summary.rewrite_turns:,}",
|
|
f"{summary.stable_replay_rewrite_turns:,}",
|
|
f"{summary.busting_rewrite_turns:,}",
|
|
f"{summary.non_cache_eligible_rewrite_turns:,}",
|
|
f"{summary.retroactive_rewrite_turns:,}",
|
|
f"{summary.latest_turn_only_rewrite_turns:,}",
|
|
f"{summary.prompt_window_with_cache:,}",
|
|
f"{summary.prompt_window_without_cache_reads:,}",
|
|
]
|
|
)
|
|
+ " |"
|
|
)
|
|
impact_rows = []
|
|
for mode in (PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
for metric_key, label in (
|
|
("total_cost_usd", "Total Cost"),
|
|
("cache_read_tokens", "Cache Read Tokens"),
|
|
("cache_write_tokens", "Cache Write Tokens"),
|
|
("regular_input_tokens", "Paid Input Tokens"),
|
|
("output_tokens", "Paid Output Tokens"),
|
|
("prompt_window_with_cache", "Window With Cache"),
|
|
("prompt_window_without_cache_reads", "Window Without Cache Reads"),
|
|
("cache_bust_turns", "Cache Bust Turns"),
|
|
("rewrite_turns", "Rewrite Turns"),
|
|
("stable_replay_rewrite_turns", "Stable Replay Rewrite Turns"),
|
|
("busting_rewrite_turns", "Busting Rewrite Turns"),
|
|
("non_cache_eligible_rewrite_turns", "Non-Cache-Eligible Rewrite Turns"),
|
|
("retroactive_rewrite_turns", "Retroactive Rewrite Turns"),
|
|
("latest_turn_only_rewrite_turns", "Latest-Turn-Only Rewrite Turns"),
|
|
):
|
|
impact = impacts[mode][metric_key]
|
|
delta = impact["delta"]
|
|
delta_text = format_currency(delta) if "cost" in metric_key else f"{int(delta):,}"
|
|
impact_rows.append(
|
|
f"| {mode} | {label} | {impact['impact']} | {delta_text} | {impact['direction']} |"
|
|
)
|
|
return "\n".join(
|
|
[
|
|
"# Claude Session Mode Simulation",
|
|
"",
|
|
"## Dataset",
|
|
"",
|
|
f"- Projects: {dataset.projects}",
|
|
f"- Sessions: {dataset.sessions}",
|
|
f"- Requests: {dataset.requests}",
|
|
f"- Sampled requests: {dataset.sampled_requests}",
|
|
f"- Distinct decoded project paths: {dataset.decoded_project_paths}",
|
|
f"- Sampling: {dataset.sampling_note}",
|
|
"- Models:",
|
|
model_lines or "- None",
|
|
"",
|
|
"## Assumptions",
|
|
"",
|
|
"- Uses top-level session `.jsonl` files under `~/.claude/projects`.",
|
|
"- Replays only transcript-visible messages. Hidden system/tool schemas from Claude Code are not available in local transcript files and are therefore excluded.",
|
|
"- Simulates Anthropic prompt caching with a 5 minute TTL.",
|
|
"- Estimates cache read cost as 10% of base input price and cache write/store cost as 125% of base input price.",
|
|
"- Holds recorded output token counts constant across baseline/token/cache so comparisons isolate input-side behavior.",
|
|
"",
|
|
"## Observed",
|
|
"",
|
|
f"- Requests with observed usage: {observed.requests:,}",
|
|
f"- Cache ratio: {observed.cache_ratio_pct:.1f}%",
|
|
f"- Healthy growth turns: {observed.healthy_growth_turns:,}",
|
|
f"- Broken prefix turns: {observed.broken_prefix_turns:,}",
|
|
f"- Resume-like resets: {observed.resume_like_resets:,}",
|
|
f"- Observed total cost: {format_currency(observed.total_cost_usd)}",
|
|
"",
|
|
"## Summary",
|
|
"",
|
|
"| Mode | Raw Tokens | Cache Tokens | Cache Read | Cache Write | Paid Input Tokens | Paid Output Tokens | Paid Input Cost | Cache Read Cost | Cache Write Cost | Paid Output Cost | Total Cost | No-Cache Total Cost | Cache Bust Turns | TTL Expiry Turns | Rewrite Turns | Stable Replay Rewrite Turns | Busting Rewrite Turns | Non-Cache-Eligible Rewrite Turns | Retroactive Rewrite Turns | Latest-Turn-Only Rewrite Turns | Window Tokens (Cache Counted) | Window Tokens (Cache Reads Excluded) |",
|
|
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
|
*rows,
|
|
"",
|
|
"## Impact vs Baseline",
|
|
"",
|
|
"| Mode | Metric | Classification | Delta | Better Direction |",
|
|
"| --- | --- | --- | ---: | --- |",
|
|
*impact_rows,
|
|
"",
|
|
"## Winners",
|
|
"",
|
|
f"- Total cost winner: `{winners['total_cost']}`",
|
|
f"- No-cache total cost winner: `{winners['no_cache_total_cost']}`",
|
|
f"- Window winner if cache tokens count: `{winners['window_with_cache']}`",
|
|
"- Window winner if cache read tokens do not count: "
|
|
f"`{winners['window_without_cache_reads']}`",
|
|
]
|
|
)
|
|
|
|
|
|
def build_report_html(
|
|
dataset: DatasetSummary,
|
|
observed: ObservedSummary,
|
|
summaries: dict[str, ModeSummary],
|
|
) -> str:
|
|
winners = determine_winners(summaries)
|
|
impacts = summarize_mode_impact_vs_baseline(summaries)
|
|
model_items = "".join(
|
|
f"<li><code>{model}</code><span>{count:,}</span></li>"
|
|
for model, count in dataset.models.items()
|
|
)
|
|
summary_rows = []
|
|
for mode in ("baseline", PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
summary = summaries[mode]
|
|
summary_rows.append(
|
|
"<tr>"
|
|
f"<td><span class='badge'>{summary.mode}</span></td>"
|
|
f"<td>{summary.raw_tokens:,}</td>"
|
|
f"<td>{summary.cache_tokens:,}</td>"
|
|
f"<td>{summary.cache_read_tokens:,}</td>"
|
|
f"<td>{summary.cache_write_tokens:,}</td>"
|
|
f"<td>{summary.regular_input_tokens:,}</td>"
|
|
f"<td>{summary.output_tokens:,}</td>"
|
|
f"<td>{summary.cache_bust_turns:,}</td>"
|
|
f"<td>{summary.ttl_expiry_turns:,}</td>"
|
|
f"<td>{summary.rewrite_turns:,}</td>"
|
|
f"<td>{summary.stable_replay_rewrite_turns:,}</td>"
|
|
f"<td>{summary.busting_rewrite_turns:,}</td>"
|
|
f"<td>{summary.non_cache_eligible_rewrite_turns:,}</td>"
|
|
f"<td>{summary.retroactive_rewrite_turns:,}</td>"
|
|
f"<td>{summary.latest_turn_only_rewrite_turns:,}</td>"
|
|
f"<td>{format_currency(summary.total_cost_usd)}</td>"
|
|
f"<td>{format_currency(summary.no_cache_total_cost_usd)}</td>"
|
|
f"<td>{summary.prompt_window_with_cache:,}</td>"
|
|
f"<td>{summary.prompt_window_without_cache_reads:,}</td>"
|
|
"</tr>"
|
|
)
|
|
impact_rows = []
|
|
for mode in (PROXY_MODE_TOKEN, PROXY_MODE_CACHE):
|
|
for metric_key, label in (
|
|
("total_cost_usd", "Total Cost"),
|
|
("cache_read_tokens", "Cache Read Tokens"),
|
|
("cache_write_tokens", "Cache Write Tokens"),
|
|
("regular_input_tokens", "Paid Input Tokens"),
|
|
("output_tokens", "Paid Output Tokens"),
|
|
("prompt_window_with_cache", "Window With Cache"),
|
|
("prompt_window_without_cache_reads", "Window Without Cache Reads"),
|
|
("cache_bust_turns", "Cache Bust Turns"),
|
|
("rewrite_turns", "Rewrite Turns"),
|
|
("stable_replay_rewrite_turns", "Stable Replay Rewrite Turns"),
|
|
("busting_rewrite_turns", "Busting Rewrite Turns"),
|
|
("non_cache_eligible_rewrite_turns", "Non-Cache-Eligible Rewrite Turns"),
|
|
("retroactive_rewrite_turns", "Retroactive Rewrite Turns"),
|
|
("latest_turn_only_rewrite_turns", "Latest-Turn-Only Rewrite Turns"),
|
|
):
|
|
impact = impacts[mode][metric_key]
|
|
delta = impact["delta"]
|
|
delta_text = format_currency(delta) if "cost" in metric_key else f"{int(delta):,}"
|
|
impact_rows.append(
|
|
"<tr>"
|
|
f"<td><span class='badge'>{mode}</span></td>"
|
|
f"<td>{label}</td>"
|
|
f"<td>{impact['impact']}</td>"
|
|
f"<td>{delta_text}</td>"
|
|
f"<td>{impact['direction']}</td>"
|
|
"</tr>"
|
|
)
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Claude Session Mode Simulation</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #fafaf9;
|
|
--fg: #0f172a;
|
|
--muted: #64748b;
|
|
--card: rgba(255,255,255,0.88);
|
|
--border: #e2e8f0;
|
|
--accent: #0f766e;
|
|
--accent-soft: #ccfbf1;
|
|
--warn: #b45309;
|
|
--bad: #b91c1c;
|
|
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
|
--radius: 18px;
|
|
--font: "Geist", "Segoe UI", system-ui, sans-serif;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
font-family: var(--font);
|
|
color: var(--fg);
|
|
background:
|
|
radial-gradient(circle at top left, #dbeafe 0%, transparent 35%),
|
|
radial-gradient(circle at top right, #ccfbf1 0%, transparent 30%),
|
|
linear-gradient(180deg, #f8fafc 0%, #f8fafc 100%);
|
|
}}
|
|
.shell {{ max-width: 1280px; margin: 0 auto; padding: 40px 20px 64px; }}
|
|
.hero {{
|
|
background: linear-gradient(135deg, rgba(255,255,255,0.92), rgba(248,250,252,0.86));
|
|
border: 1px solid rgba(226,232,240,0.9);
|
|
box-shadow: var(--shadow);
|
|
border-radius: 28px;
|
|
padding: 28px;
|
|
backdrop-filter: blur(12px);
|
|
}}
|
|
h1, h2 {{ margin: 0 0 12px; letter-spacing: -0.03em; }}
|
|
p {{ margin: 0; color: var(--muted); line-height: 1.55; }}
|
|
.grid {{ display: grid; gap: 16px; margin-top: 20px; }}
|
|
.grid.cards {{ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }}
|
|
.card {{
|
|
background: var(--card);
|
|
border: 1px solid rgba(226,232,240,0.95);
|
|
border-radius: var(--radius);
|
|
padding: 18px;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(10px);
|
|
}}
|
|
.eyebrow {{ color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }}
|
|
.value {{ font-size: 28px; font-weight: 700; margin-top: 10px; }}
|
|
.subtle {{ color: var(--muted); font-size: 14px; margin-top: 6px; }}
|
|
.section {{ margin-top: 22px; }}
|
|
.table-wrap {{ overflow-x: auto; }}
|
|
table {{ width: 100%; border-collapse: collapse; font-size: 14px; }}
|
|
th, td {{ text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }}
|
|
th {{ color: var(--muted); font-weight: 600; background: rgba(248,250,252,0.8); }}
|
|
.badge {{
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 6px 10px; border-radius: 999px;
|
|
background: var(--accent-soft); color: var(--accent); font-weight: 600; font-size: 12px;
|
|
}}
|
|
ul.models {{ list-style: none; padding: 0; margin: 0; }}
|
|
ul.models li {{ display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--border); }}
|
|
.winner-list div {{ margin-top: 10px; font-size: 15px; }}
|
|
.good {{ color: var(--accent); }}
|
|
.warn {{ color: var(--warn); }}
|
|
.bad {{ color: var(--bad); }}
|
|
code {{ font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .95em; }}
|
|
@media (max-width: 720px) {{
|
|
.shell {{ padding: 20px 12px 40px; }}
|
|
.hero {{ padding: 20px; border-radius: 22px; }}
|
|
.value {{ font-size: 24px; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<section class="hero">
|
|
<div class="eyebrow">Local Claude Cache Analysis</div>
|
|
<h1>Claude Session Mode Simulation</h1>
|
|
<p>Observed usage is read directly from <code>~/.claude/projects</code>. Baseline, token, and cache are replayed locally through Headroom without making API calls.</p>
|
|
<div class="grid cards">
|
|
<div class="card"><div class="eyebrow">Projects</div><div class="value">{dataset.projects:,}</div><div class="subtle">{dataset.sessions:,} sessions / {dataset.requests:,} requests</div></div>
|
|
<div class="card"><div class="eyebrow">Observed Cache Ratio</div><div class="value">{observed.cache_ratio_pct:.1f}%</div><div class="subtle">read / (read + write + input)</div></div>
|
|
<div class="card"><div class="eyebrow">Observed Total Cost</div><div class="value">{format_currency(observed.total_cost_usd)}</div><div class="subtle">{observed.cache_read_tokens:,} read / {observed.cache_write_tokens:,} write</div></div>
|
|
<div class="card"><div class="eyebrow">Broken Prefix Turns</div><div class="value">{observed.broken_prefix_turns:,}</div><div class="subtle">{dataset.sampling_note}</div></div>
|
|
</div>
|
|
</section>
|
|
<section class="section grid" style="grid-template-columns: 1.1fr .9fr;">
|
|
<div class="card">
|
|
<h2>Winners</h2>
|
|
<div class="winner-list">
|
|
<div><span class="eyebrow">Total cost</span><br><span class="badge">{winners["total_cost"]}</span></div>
|
|
<div><span class="eyebrow">No-cache total cost</span><br><span class="badge">{winners["no_cache_total_cost"]}</span></div>
|
|
<div><span class="eyebrow">Window if cache counts</span><br><span class="badge">{winners["window_with_cache"]}</span></div>
|
|
<div><span class="eyebrow">Window if cache reads do not count</span><br><span class="badge">{winners["window_without_cache_reads"]}</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Models</h2>
|
|
<ul class="models">{model_items}</ul>
|
|
</div>
|
|
</section>
|
|
<section class="section card">
|
|
<h2>Observed Diagnostics</h2>
|
|
<div class="grid cards">
|
|
<div><div class="eyebrow">Healthy Growth Turns</div><div class="value good">{observed.healthy_growth_turns:,}</div></div>
|
|
<div><div class="eyebrow">Broken Prefix Turns</div><div class="value bad">{observed.broken_prefix_turns:,}</div></div>
|
|
<div><div class="eyebrow">Resume-like Resets</div><div class="value warn">{observed.resume_like_resets:,}</div></div>
|
|
</div>
|
|
</section>
|
|
<section class="section card">
|
|
<h2>Mode Summary</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Mode</th><th>Raw Tokens</th><th>Cache Tokens</th><th>Cache Read</th><th>Cache Write</th><th>Paid Input</th><th>Paid Output</th><th>Cache Busts</th><th>TTL Expiry</th><th>Rewrite Turns</th><th>Stable Replay Rewrites</th><th>Busting Rewrites</th><th>Non-Cache-Eligible Rewrites</th><th>Retroactive Rewrites</th><th>Latest-Turn-Only Rewrites</th><th>Total Cost</th><th>No-Cache Cost</th><th>Window With Cache</th><th>Window Without Cache Reads</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{"".join(summary_rows)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="section card">
|
|
<h2>Impact vs Baseline</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Mode</th><th>Metric</th><th>Classification</th><th>Delta</th><th>Better Direction</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{"".join(impact_rows)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
def write_report(
|
|
output_dir: Path,
|
|
dataset: DatasetSummary,
|
|
observed: ObservedSummary,
|
|
summaries: dict[str, ModeSummary],
|
|
) -> tuple[Path, Path, Path]:
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
md_path = output_dir / OUTPUT_MD
|
|
json_path = output_dir / OUTPUT_JSON
|
|
html_path = output_dir / OUTPUT_HTML
|
|
md_path.write_text(build_report_markdown(dataset, observed, summaries), encoding="utf-8")
|
|
html_path.write_text(build_report_html(dataset, observed, summaries), encoding="utf-8")
|
|
payload = {
|
|
"dataset": asdict(dataset),
|
|
"observed": asdict(observed),
|
|
"summaries": {mode: asdict(summary) for mode, summary in summaries.items()},
|
|
"winners": determine_winners(summaries),
|
|
"impact_vs_baseline": summarize_mode_impact_vs_baseline(summaries),
|
|
}
|
|
json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
return md_path, json_path, html_path
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
|
parser.add_argument("--max-sessions", type=int, default=None)
|
|
parser.add_argument(
|
|
"--recent-turns-per-session",
|
|
type=int,
|
|
default=None,
|
|
help="Limit each replay to its most recent N turns for broader, faster sampling.",
|
|
)
|
|
parser.add_argument("--cache-ttl-minutes", type=int, default=DEFAULT_CACHE_TTL_MINUTES)
|
|
parser.add_argument(
|
|
"--cache-write-multiplier",
|
|
type=float,
|
|
default=1.25,
|
|
help="Multiplier over base input price used for cache writes/store cost.",
|
|
)
|
|
parser.add_argument(
|
|
"--workers",
|
|
type=int,
|
|
default=1,
|
|
help="Worker processes to use. Higher values use more memory.",
|
|
)
|
|
parser.add_argument(
|
|
"--checkpoint-dir",
|
|
type=Path,
|
|
default=DEFAULT_OUTPUT_DIR / CHECKPOINT_DIRNAME,
|
|
help="Directory for resumable per-session checkpoints.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
logging.getLogger("headroom.transforms").setLevel(logging.WARNING)
|
|
logging.getLogger("headroom.proxy").setLevel(logging.WARNING)
|
|
checkpoint_dir = resolve_checkpoint_dir(
|
|
args.checkpoint_dir,
|
|
recent_turns_per_session=args.recent_turns_per_session,
|
|
cache_ttl_minutes=args.cache_ttl_minutes,
|
|
)
|
|
session_files = select_session_files(args.root, max_sessions=args.max_sessions)
|
|
if not session_files:
|
|
print(f"No Claude session replays found under {args.root}")
|
|
return 1
|
|
dataset, observed = build_dataset_and_observed_from_files(
|
|
session_files,
|
|
cache_write_multiplier=args.cache_write_multiplier,
|
|
recent_turns_per_session=args.recent_turns_per_session,
|
|
)
|
|
print(
|
|
f"[load] loaded {dataset.sessions} sessions from {args.root}"
|
|
+ (f" (max_sessions={args.max_sessions})" if args.max_sessions is not None else ""),
|
|
flush=True,
|
|
)
|
|
summaries = simulate_session_files(
|
|
session_files,
|
|
dataset,
|
|
cache_ttl_minutes=args.cache_ttl_minutes,
|
|
cache_write_multiplier=args.cache_write_multiplier,
|
|
workers=args.workers,
|
|
checkpoint_dir=checkpoint_dir,
|
|
recent_turns_per_session=args.recent_turns_per_session,
|
|
)
|
|
md_path, json_path, html_path = write_report(args.output_dir, dataset, observed, summaries)
|
|
print_observed_console_report(observed)
|
|
print_console_report(dataset, summaries)
|
|
print()
|
|
print(f"Markdown report: {md_path}")
|
|
print(f"JSON report: {json_path}")
|
|
print(f"HTML report: {html_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|