headroom/tests/test_cli_perf_format.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

361 lines
14 KiB
Python
Raw Permalink Normal View History

feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
"""Tests for `headroom perf --format {text,json,csv}` (issue #595)."""
from __future__ import annotations
import csv
import io
import json
import pytest
from click.testing import CliRunner
from headroom.cli.main import main
from headroom.perf import analyzer
from headroom.perf.analyzer import (
PerfRecord,
PerfReport,
TransformRecord,
build_overhead_summary,
feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
build_perf_summary,
perf_records_as_dicts,
)
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
def _sample_report() -> PerfReport:
"""A small report with two models, cache numbers, and a transform."""
return PerfReport(
perf_records=[
PerfRecord(
timestamp="2026-06-05 10:00:00,000",
request_id="hr_1",
model="claude-sonnet-4.5",
num_messages=10,
tokens_before=1000,
tokens_after=400,
tokens_saved=600,
cache_read=800,
cache_write=200,
cache_hit_pct=80,
optimization_ms=12.0,
transforms=["content_router"],
),
PerfRecord(
timestamp="2026-06-05 11:00:00,000",
request_id="hr_2",
model="claude-opus-4-8",
num_messages=4,
tokens_before=1000,
tokens_after=600,
tokens_saved=400,
cache_read=200,
cache_write=0,
cache_hit_pct=100,
optimization_ms=8.0,
transforms=["content_router"],
),
],
transform_records=[
TransformRecord(
timestamp="2026-06-05 10:00:00,000",
name="content_router",
tokens_before=2000,
tokens_after=1000,
tokens_saved=1000,
),
],
log_files_read=1,
total_lines_parsed=42,
requested_hours=24.0,
oldest_kept_ts="2026-06-05 10:00:00,000",
newest_kept_ts="2026-06-05 11:00:00,000",
)
# ---------------------------------------------------------------------------
# Pure builders
# ---------------------------------------------------------------------------
def test_build_perf_summary_totals_and_pct():
summary = build_perf_summary(_sample_report())
assert summary["total_requests"] == 2
assert summary["total_tokens_before"] == 2000
assert summary["total_tokens_after"] == 1000
assert summary["tokens_saved"] == 1000
# 1000 / 2000 == 50.0%
assert summary["savings_pct"] == 50.0
# cache: read 1000, write 200 -> 1000 / 1200 == 83.3%
assert summary["cache_read_tokens"] == 1000
assert summary["cache_write_tokens"] == 200
assert summary["cache_hit_pct"] == 83.3
assert summary["window_hours"] == 24.0
def test_build_perf_summary_by_model_and_transform():
summary = build_perf_summary(_sample_report())
models = {m["model"]: m for m in summary["by_model"]}
assert set(models) == {"claude-sonnet-4.5", "claude-opus-4-8"}
assert models["claude-sonnet-4.5"]["tokens_saved"] == 600
assert models["claude-sonnet-4.5"]["savings_pct"] == 60.0
assert models["claude-opus-4-8"]["savings_pct"] == 40.0
assert summary["by_transform"][0]["transform"] == "content_router"
assert summary["by_transform"][0]["tokens_saved"] == 1000
assert summary["by_transform"][0]["uses"] == 1
def test_build_perf_summary_empty_report_no_zero_division():
summary = build_perf_summary(PerfReport(requested_hours=168.0))
assert summary["total_requests"] == 0
assert summary["savings_pct"] == 0.0
assert summary["cache_hit_pct"] == 0.0
assert summary["by_model"] == []
assert summary["overhead"]["optimization_ms"]["count"] == 0
def test_build_overhead_summary_attributes_slow_stages():
report = PerfReport(
perf_records=[
PerfRecord(
timestamp="2026-06-05 10:00:00,000",
request_id="fast",
model="gpt-5",
tokens_before=1000,
tokens_after=500,
tokens_saved=500,
optimization_ms=100.0,
total_ms=300.0,
stages={"cache_align": 10.0, "content_router": 90.0},
),
PerfRecord(
timestamp="2026-06-05 10:01:00,000",
request_id="slow",
model="gpt-5",
tokens_before=1000,
tokens_after=500,
tokens_saved=500,
optimization_ms=700.0,
total_ms=900.0,
stages={"kompress": 650.0, "content_router": 40.0},
),
]
)
overhead = build_overhead_summary(report, slow_threshold_ms=500.0)
assert overhead["optimization_ms"]["count"] == 2
assert overhead["optimization_ms"]["average_ms"] == 400.0
assert overhead["optimization_ms"]["p50_ms"] == 400.0
assert overhead["optimization_ms"]["p95_ms"] == 670.0
assert overhead["optimization_ms"]["p99_ms"] == 694.0
assert overhead["optimization_ms"]["slow_request_count"] == 1
assert overhead["stage_breakdown"][0]["stage"] == "kompress"
assert overhead["stage_breakdown"][0]["total_ms"] == 650.0
assert overhead["top_slow_requests"][0]["request_id"] == "slow"
assert overhead["top_slow_requests"][0]["slowest_stage"] == "kompress"
feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
def test_perf_records_as_dicts_roundtrips_fields():
dicts = perf_records_as_dicts(_sample_report())
assert len(dicts) == 2
assert dicts[0]["request_id"] == "hr_1"
assert dicts[0]["tokens_saved"] == 600
# transforms stays a list for JSON consumers
assert dicts[0]["transforms"] == ["content_router"]
# ---------------------------------------------------------------------------
# CLI integration
# ---------------------------------------------------------------------------
def _patch_report(monkeypatch, report: PerfReport) -> None:
monkeypatch.setattr(analyzer, "parse_log_files", lambda last_n_hours=168.0: report)
def test_perf_json_format(runner, monkeypatch):
_patch_report(monkeypatch, _sample_report())
result = runner.invoke(main, ["perf", "--format", "json"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data["savings_pct"] == 50.0
assert "by_model" in data
assert data["total_requests"] == 2
assert data["overhead"]["optimization_ms"]["p95_ms"] == 11.8
feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
def test_perf_json_raw_is_array(runner, monkeypatch):
_patch_report(monkeypatch, _sample_report())
result = runner.invoke(main, ["perf", "--format", "json", "--raw"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert isinstance(data, list)
assert len(data) == 2
assert data[0]["request_id"] == "hr_1"
feat: add dashboard agent usage stats (#814) ## Description Add a clear dashboard view for per-agent token usage so end users can see Cursor, Claude, Codex, and other detected clients with before/after token counts, tokens saved, and savings percentages. The stats API now exposes a stable `agent_usage` object that the dashboard renders near the top of the session view. Fixes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made ### New Files **Tests:** - `tests/test_dashboard_agent_usage.py` — Covers agent classification, exact per-request aggregation, and aggregate fallback behavior. ### Modified Files - `headroom/proxy/server.py` — Adds per-agent usage aggregation to `/stats` with before tokens, after tokens, output tokens, saved tokens, savings percentage, source, providers, and models. - `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent Usage panel with totals, coverage status, per-agent token-flow bars, request counts, before/after tokens, saved tokens, and share of savings. ## Testing - [x] Unit tests pass: `.venv312/bin/pytest tests/test_dashboard_agent_usage.py` - [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py tests/test_dashboard_agent_usage.py` - [x] Diff whitespace check passes: `git diff --check origin/main...HEAD` - [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured Chrome headless screenshot of `/dashboard` - [x] New tests added for new functionality ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing relevant unit tests pass locally with my changes - [ ] I have made corresponding changes to the documentation - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The agent usage panel uses exact request-log data when available. If detailed request logs are empty, it falls back to aggregate provider/model request counts and labels the coverage as aggregate fallback so users are not misled.
2026-06-12 22:12:22 +03:00
def test_perf_json_raw_preserves_client_field(runner, monkeypatch):
report = _sample_report()
report.perf_records[0].client = "codex"
_patch_report(monkeypatch, report)
result = runner.invoke(main, ["perf", "--format", "json", "--raw"])
assert result.exit_code == 0, result.output
data = json.loads(result.output)
assert data[0]["client"] == "codex"
def test_parse_perf_line_preserves_client_field(monkeypatch, tmp_path):
log_dir = tmp_path / "logs"
log_dir.mkdir()
(log_dir / "proxy.log").write_text(
"2026-06-10 10:00:00,000 - headroom.proxy - INFO - "
"[hr_codex] PERF model=gpt-5 msgs=3 tok_before=1000 "
"tok_after=90 tok_saved=910 cache_read=0 cache_write=0 "
"cache_hit_pct=0 opt_ms=12 transforms=content_router client=codex\n"
)
monkeypatch.setattr(analyzer, "LOG_DIR", log_dir)
report = analyzer.parse_log_files(last_n_hours=0)
assert len(report.perf_records) == 1
assert report.perf_records[0].client == "codex"
feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
def test_perf_csv_by_model(runner, monkeypatch):
_patch_report(monkeypatch, _sample_report())
result = runner.invoke(main, ["perf", "--format", "csv"])
assert result.exit_code == 0, result.output
rows = list(csv.DictReader(io.StringIO(result.output)))
assert {r["model"] for r in rows} == {"claude-sonnet-4.5", "claude-opus-4-8"}
sonnet = next(r for r in rows if r["model"] == "claude-sonnet-4.5")
assert sonnet["tokens_saved"] == "600"
def test_perf_csv_raw_per_record(runner, monkeypatch):
feat(proxy): add agent-90 savings profile (#830) ## Summary - add an `agent-90` savings profile with cross-agent proxy env exports - wire the profile into proxy/router runtime kwargs, including force-Kompress routing and a smaller read-protection window - expose effective savings-profile config in `/stats` and add focused regression coverage ## Type of change - [x] feat (non-breaking change which adds functionality) - [ ] fix (non-breaking change which fixes an issue) - [ ] docs - [ ] test/CI-only - [ ] refactor-only ## Testing - [x] `python3 -m py_compile headroom/agent_savings.py headroom/cli/agent_savings.py headroom/cli/main.py headroom/cli/proxy.py headroom/proxy/models.py headroom/proxy/server.py headroom/transforms/content_router.py tests/test_agent_savings.py tests/test_proxy_healthchecks.py tests/test_cli/test_wrap_persistent.py tests/test_transforms/test_content_router.py` - [x] `git diff --check` - [x] manual smoke: `agent-savings --profile agent-90 --format json` returns `HEADROOM_TARGET_RATIO=0.10` - [x] manual smoke: `proxy_pipeline_kwargs(ProxyConfig(savings_profile="agent-90"))` enables `force_kompress`, system/user compression, and `read_protection_window=2` - [x] manual smoke: Anthropic-style `tool_result` routes through Kompress with `target_ratio=0.10` - [ ] `pytest` suite not run: pytest is not installed in the available local Python environments ## Notes This keeps agent-90 as an opt-in profile. Existing defaults remain unchanged unless `HEADROOM_SAVINGS_PROFILE=agent-90` or `ProxyConfig(savings_profile="agent-90")` is set.
2026-06-12 02:58:06 +03:00
report = _sample_report()
report.perf_records[0].client = "codex"
_patch_report(monkeypatch, report)
feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
result = runner.invoke(main, ["perf", "--format", "csv", "--raw"])
assert result.exit_code == 0, result.output
rows = list(csv.DictReader(io.StringIO(result.output)))
assert len(rows) == 2
assert rows[0]["request_id"] == "hr_1"
feat(proxy): add agent-90 savings profile (#830) ## Summary - add an `agent-90` savings profile with cross-agent proxy env exports - wire the profile into proxy/router runtime kwargs, including force-Kompress routing and a smaller read-protection window - expose effective savings-profile config in `/stats` and add focused regression coverage ## Type of change - [x] feat (non-breaking change which adds functionality) - [ ] fix (non-breaking change which fixes an issue) - [ ] docs - [ ] test/CI-only - [ ] refactor-only ## Testing - [x] `python3 -m py_compile headroom/agent_savings.py headroom/cli/agent_savings.py headroom/cli/main.py headroom/cli/proxy.py headroom/proxy/models.py headroom/proxy/server.py headroom/transforms/content_router.py tests/test_agent_savings.py tests/test_proxy_healthchecks.py tests/test_cli/test_wrap_persistent.py tests/test_transforms/test_content_router.py` - [x] `git diff --check` - [x] manual smoke: `agent-savings --profile agent-90 --format json` returns `HEADROOM_TARGET_RATIO=0.10` - [x] manual smoke: `proxy_pipeline_kwargs(ProxyConfig(savings_profile="agent-90"))` enables `force_kompress`, system/user compression, and `read_protection_window=2` - [x] manual smoke: Anthropic-style `tool_result` routes through Kompress with `target_ratio=0.10` - [ ] `pytest` suite not run: pytest is not installed in the available local Python environments ## Notes This keeps agent-90 as an opt-in profile. Existing defaults remain unchanged unless `HEADROOM_SAVINGS_PROFILE=agent-90` or `ProxyConfig(savings_profile="agent-90")` is set.
2026-06-12 02:58:06 +03:00
assert rows[0]["client"] == "codex"
feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
# transforms flattened to a string cell
assert rows[0]["transforms"] == "content_router"
def test_perf_text_default_unchanged(runner, monkeypatch):
_patch_report(monkeypatch, _sample_report())
result = runner.invoke(main, ["perf"])
assert result.exit_code == 0, result.output
assert "Headroom Performance Report" in result.output
assert "p50/p95/p99" in result.output
feat(perf): add --format {text,json,csv} to `headroom perf` (#648) * feat(perf): add structured summary/record builders to analyzer parse_log_files() already returns a fully-structured PerfReport, but the only way to read it was the colored text report. Add reusable machine-readable views so CI guards, dashboards, and agent harnesses can consume perf data without scraping ANSI text: - build_perf_summary(report) -> dict with the aggregated KPIs (savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring format_report() numbers exactly. - perf_records_as_dicts(report) -> per-record list for --raw output. - PERF_RECORD_FIELDS: shared column order for CSV/raw consumers. Pure additions; no behaviour change to existing callers. Part of #595. * feat(perf): add --format {text,json,csv} to headroom perf Adds a machine-readable output path to the perf command (issue #595): - --format json: aggregated summary (default) or, with --raw, a JSON array of per-record dicts. - --format csv: per-model breakdown (default) or, with --raw, one row per PERF record using the shared PERF_RECORD_FIELDS column order. - --format text (default): unchanged human-readable report. Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent wrappers to consume perf data without scraping ANSI text. Closes #595. * test(perf): cover --format json/csv and structured builders Unit tests for build_perf_summary (totals, savings/cache pct, by_model/by_transform, empty-report zero-division guard) and perf_records_as_dicts, plus CliRunner integration tests for --format json, json --raw, csv, csv --raw, the unchanged text default, and rejection of an unknown format. Part of #595. * fix(perf): rename transform loop var to satisfy mypy The structured-summary builder reused `recs` for both the per-model (list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so mypy flagged the second assignment as an incompatible-type reuse (analyzer.py:704). Rename the transform loop variable to `t_recs` so each loop keeps a single element type. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
def test_perf_rejects_unknown_format(runner, monkeypatch):
_patch_report(monkeypatch, _sample_report())
result = runner.invoke(main, ["perf", "--format", "xml"])
assert result.exit_code != 0
feat: add dashboard agent usage stats (#814) ## Description Add a clear dashboard view for per-agent token usage so end users can see Cursor, Claude, Codex, and other detected clients with before/after token counts, tokens saved, and savings percentages. The stats API now exposes a stable `agent_usage` object that the dashboard renders near the top of the session view. Fixes # ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made ### New Files **Tests:** - `tests/test_dashboard_agent_usage.py` — Covers agent classification, exact per-request aggregation, and aggregate fallback behavior. ### Modified Files - `headroom/proxy/server.py` — Adds per-agent usage aggregation to `/stats` with before tokens, after tokens, output tokens, saved tokens, savings percentage, source, providers, and models. - `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent Usage panel with totals, coverage status, per-agent token-flow bars, request counts, before/after tokens, saved tokens, and share of savings. ## Testing - [x] Unit tests pass: `.venv312/bin/pytest tests/test_dashboard_agent_usage.py` - [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py tests/test_dashboard_agent_usage.py` - [x] Diff whitespace check passes: `git diff --check origin/main...HEAD` - [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured Chrome headless screenshot of `/dashboard` - [x] New tests added for new functionality ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing relevant unit tests pass locally with my changes - [ ] I have made corresponding changes to the documentation - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes The agent usage panel uses exact request-log data when available. If detailed request logs are empty, it falls back to aggregate provider/model request counts and labels the coverage as aggregate fallback so users are not misled.
2026-06-12 22:12:22 +03:00
def test_parse_perf_line_preserves_blank_client_field(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
logs_dir = tmp_path / "logs"
logs_dir.mkdir()
monkeypatch.setattr(analyzer, "LOG_DIR", logs_dir)
(logs_dir / "proxy.log").write_text(
"2026-06-10 10:00:00,000 - headroom.proxy - INFO - [req-blank] PERF "
"model=gpt-5 msgs=1 tok_before=100 tok_after=50 tok_saved=50 "
"cache_read=0 cache_write=0 cache_hit_pct=0 opt_ms=1 transforms=test client=\n",
encoding="utf-8",
)
report = analyzer.parse_log_files(last_n_hours=0)
assert len(report.perf_records) == 1
assert report.perf_records[0].client == ""
feat: measure and surface token throughput (tokens/sec) through the proxy (#983) ## Description This PR implements measuring and surfacing token throughput (tokens/second) through the proxy in the `headroom perf` CLI/analyzer and the dashboard UI. It tracks multiple throughput metrics—Input (wall-clock/active), Compression, Forward, and Generation throughput—supporting both rolling percentiles (p50/p95) and current (last 5 minutes) metrics. Closes #959 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [x] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **Proxy Instrumentation (`headroom/proxy/outcome.py`)**: Added `total_ms`, `tok_out`, and `ttfb_ms` to the structured `PERF` logging payload. - **Log Parsing & Computations (`headroom/perf/analyzer.py`)**: Updated log parsing to read `STAGE_TIMINGS` and correlation fields from `PERF`, computing active/wall-clock throughputs for input, compression, forward, and generation stages. - **API Exposing (`headroom/proxy/server.py`)**: Exposes calculated rolling throughput percentiles and last-5-minute averages under the `throughput` field in `/stats`. - **Dashboard UI Layout (`headroom/dashboard/templates/dashboard.html`)**: Refactored the dashboard grid layout from 3 columns to 4 columns to house the new throughput hero card showing real-time token performance. - **Verification Tests (`tests/test_cli_perf_format.py`)**: Added test coverage specifically targeting token throughput log parser extraction, stage correlation, math correctness, and edge-case handling (empty fields, division by zero). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $env:PYTHONPATH="c:\Users\hp\Desktop\Headroom_oss"; .venv\Scripts\pytest tests/test_cli_perf_format.py ============================= test session starts ============================= platform win32 -- Python 3.11.15, pytest-9.1.0, pluggy-1.6.0 -- C:\Users\hp\Desktop\Headroom_oss\.venv\Scripts\python.exe cachedir: .pytest_cache rootdir: C:\Users\hp\Desktop\Headroom_oss configfile: pyproject.toml plugins: anyio-4.13.0 collecting ... collected 14 items tests/test_cli_perf_format.py::test_build_perf_summary_totals_and_pct PASSED [ 7%] tests/test_cli_perf_format.py::test_build_perf_summary_by_model_and_transform PASSED [ 14%] tests/test_cli_perf_format.py::test_build_perf_summary_empty_report_no_zero_division PASSED [ 21%] tests/test_cli_perf_format.py::test_perf_records_as_dicts_roundtrips_fields PASSED [ 28%] tests/test_cli_perf_format.py::test_perf_json_format PASSED [ 35%] tests/test_cli_perf_format.py::test_perf_json_raw_is_array PASSED [ 42%] tests/test_cli_perf_format.py::test_perf_json_raw_preserves_client_field PASSED [ 50%] tests/test_cli_perf_format.py::test_parse_perf_line_preserves_client_field PASSED [ 57%] tests/test_cli_perf_format.py::test_perf_csv_by_model PASSED [ 64%] tests/test_cli_perf_format.py::test_perf_csv_raw_per_record PASSED [ 71%] tests/test_cli_perf_format.py::test_perf_text_default_unchanged PASSED [ 78%] tests/test_cli_perf_format.py::test_perf_rejects_unknown_format PASSED [ 85%] tests/test_cli_perf_format.py::test_parse_perf_line_preserves_blank_client_field PASSED [ 92%] tests/test_cli_perf_format.py::test_throughput_parsing_and_calculations PASSED [100%] ============================== warnings summary =============================== .venv\Lib\site-packages\_pytest\config\__init__.py:1464 C:\Users\hp\Desktop\Headroom_oss\.venv\Lib\site-packages\_pytest\config\__init__.py:1464: PytestConfigWarning: Unknown config option: asyncio_mode self._warn_or_fail_if_strict(f"Unknown config option: {key}\n") .venv\Lib\site-packages\opentelemetry\util\_importlib_metadata.py:32 C:\Users\hp\Desktop\Headroom_oss\.venv\Lib\site-packages\opentelemetry\util\_importlib_metadata.py:32: DeprecationWarning: SelectableGroups dict interface is deprecated. Use select. return EntryPoints(ep for group_eps in eps.values() for ep in group_eps) -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html ======================= 14 passed, 2 warnings in 4.77s ======================== ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.11.15 - Exact command / steps: Run the pytest suite against the newly created token throughput parsing routines: `$env:PYTHONPATH="c:\Users\hp\Desktop\Headroom_oss"; .venv\Scripts\pytest tests/test_cli_perf_format.py` - Observed result: The suite executes 14 tests successfully, including the newly added `test_throughput_parsing_and_calculations` verification test verifying mathematical precision and fallback logic. - Not tested: None (all metrics are fully covered by unit tests in `test_cli_perf_format.py`) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes - Backwards compatibility: Older log outputs lacking `tok_out` or `ttfb_ms` parse cleanly and fallback defaults prevent parser crashes. --------- Co-authored-by: Antigravity Agent <agent@antigravity.local>
2026-06-17 20:12:38 +05:30
def test_throughput_parsing_and_calculations(monkeypatch, tmp_path):
logs_dir = tmp_path / "logs"
logs_dir.mkdir()
monkeypatch.setattr(analyzer, "LOG_DIR", logs_dir)
log_content = (
'2026-06-10 10:00:00,000 - headroom.proxy - INFO - [req1] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression_first_stage": 100.0, "upstream_connect": 50.0}}\n'
"2026-06-10 10:00:01,000 - headroom.proxy - INFO - [req1] PERF model=gpt-5 msgs=1 tok_before=1000 tok_after=400 tok_saved=600 opt_ms=10 total_ms=500 tok_out=500 ttfb_ms=100 transforms=test client=codex\n"
'2026-06-10 10:00:02,000 - headroom.proxy - INFO - [req2] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression": 200.0, "upstream_connect": 50.0}}\n'
"2026-06-10 10:00:03,000 - headroom.proxy - INFO - [req2] PERF model=gpt-5 msgs=1 tok_before=2000 tok_after=1000 tok_saved=1000 opt_ms=20 total_ms=1000 tok_out=1000 ttfb_ms=200 transforms=test client=codex\n"
"2026-06-10 10:00:05,000 - headroom.proxy - INFO - [req3] PERF model=gpt-5 msgs=1 tok_before=1500 tok_after=500 tok_saved=1000 opt_ms=15 total_ms=600 tok_out=600 ttfb_ms=150 transforms=test client=codex\n"
'2026-06-10 10:00:06,000 - headroom.proxy - INFO - [req4] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression_first_stage": 150.0, "upstream_connect": 50.0}}\n'
"2026-06-10 10:00:07,000 - headroom.proxy - INFO - [req4] PERF model=gpt-5 msgs=1 tok_before=1200 tok_after=300 tok_saved=900 opt_ms=12 total_ms=400 tok_out=400 ttfb_ms=80 transforms=test client=codex\n"
'2026-06-10 10:00:08,000 - headroom.proxy - INFO - [req5] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression_first_stage": 50.0, "upstream_connect": 50.0}}\n'
"2026-06-10 10:00:09,000 - headroom.proxy - INFO - [req5] PERF model=gpt-5 msgs=1 tok_before=800 tok_after=200 tok_saved=600 opt_ms=5 total_ms=300 tok_out=300 ttfb_ms=50 transforms=test client=codex\n"
)
(logs_dir / "proxy.log").write_text(log_content, encoding="utf-8")
report = analyzer.parse_log_files(last_n_hours=0)
assert len(report.perf_records) == 5
assert report.perf_records[0].request_id == "req1"
assert report.perf_records[0].total_ms == 500.0
assert report.perf_records[0].tokens_out == 500
assert report.perf_records[0].ttfb_ms == 100.0
assert report.perf_records[0].stages == {
"compression_first_stage": 100.0,
"upstream_connect": 50.0,
}
assert report.perf_records[2].request_id == "req3"
assert report.perf_records[2].stages == {}
summary = build_perf_summary(report)
assert "throughput" in summary
tp = summary["throughput"]
rolling = tp["rolling"]
assert rolling["input_wall_clock"] > 0
assert rolling["input_active_p50"] == 2500.0
assert rolling["compression_p50"] == 10000.0
def test_throughput_empty_and_percentiles():
from headroom.perf.analyzer import (
PerfReport,
_calculate_throughput_stats,
_percentile,
calculate_throughput,
)
# Empty percentiles
assert _percentile([], 0.5) == 0.0
# Percentiles boundary checks
assert _percentile([10.0], 0.5) == 10.0
assert _percentile([10.0, 20.0], 0.5) == 15.0
assert _percentile([10.0, 20.0], 0.0) == 10.0
assert _percentile([10.0, 20.0], 1.0) == 20.0
assert _percentile([10.0, 20.0], 1.5) == 20.0
# Empty calculate_throughput
empty_report = PerfReport()
tp = calculate_throughput(empty_report)
assert tp["rolling"]["input_wall_clock"] == 0.0
assert tp["current"]["input_wall_clock"] == 0.0
# _calculate_throughput_stats with empty records
stats = _calculate_throughput_stats([], 10.0)
assert stats["input_wall_clock"] == 0.0