mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(proxy): keep recent stats request rows (#1922)
## Description `/stats.recent_requests` was built from the request-log tail, but then filtered out any row whose compact token fields were incomplete. That made the dashboard/API summary diverge from the raw request counters: `requests.by_model` could show many recent requests for a model while `recent_requests` only showed the few rows with complete compression-token accounting. This fixes the stats payload so the compact `recent_requests` table mirrors the latest request-log rows without masking unknown token accounting as measured zero. Missing/non-finite compact numeric fields now remain `null`, and each row exposes `token_accounting_status` plus `has_exact_tokens` so API clients and the dashboard can distinguish complete, partial, and missing token accounting. Closes #1914 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring ## Changes Made - Removed the token-completeness filter from `_build_recent_request_payload()`. - Preserved unknown compact recent-request numeric fields as `null`. - Added `token_accounting_status` and `has_exact_tokens` to compact recent request rows. - Updated the dashboard recent-requests table to render unknown token/latency fields as `unknown`. - Hardened `build_session_summary()` against token-incomplete request-log rows and surfaced an `unknown_token_accounting` bucket. - Added TypeScript SDK fields for the compact recent-request stats contract. - Added a regression test covering missing, partial, and complete token-accounting rows. ## Testing - [x] Focused stats regression passes - [x] Adjacent summary/MCP tests pass - [x] TypeScript SDK typecheck/tests pass - [x] Ruff check passes - [x] Ruff format check passes - [x] Diff whitespace check passes - [ ] Full suite not run ### Test Output ```text $ uv run --no-project ... pytest tests/test_proxy_stats_recent_requests.py -q 4 passed, 1 warning $ uv run --no-project ... pytest tests/test_proxy_dashboard_stats_cache.py::test_session_summary_uses_generic_cli_filtering_keys tests/test_proxy_dashboard_stats_cache.py::test_session_summary_surfaces_codex_ws_counters tests/test_ccr_mcp_server.py -q 19 passed, 1 skipped $ npm run typecheck tsc --noEmit $ npm test 296 passed, 33 skipped $ uv run --no-project --with ruff ruff check headroom/proxy/server.py headroom/proxy/cost.py headroom/ccr/mcp_server.py tests/test_proxy_stats_recent_requests.py All checks passed! $ uv run --no-project --with ruff ruff format --check headroom/proxy/server.py headroom/proxy/cost.py headroom/ccr/mcp_server.py tests/test_proxy_stats_recent_requests.py 4 files already formatted $ git diff --check clean ``` ## Verification - Principal engineer review: no blockers after the final finite-number/accounting-status alignment. - Senior developer review: no blockers; implementation and focused coverage look solid for #1914. - Architect/design review: original API/UI contract blocker resolved; unknowns remain visible as `null`/`unknown` instead of measured zero. ## Notes The normal editable `uv run pytest ...` path is blocked locally by the known native build issue in `esaxx-rs` (`fatal error: 'cstdint' file not found`) while building the Rust extension on this machine. I validated the Python-only stats path with a temporary `headroom._core` import stub and `HEADROOM_REQUIRE_RUST_CORE=false`; no repository files were changed for that stub. ## Review Readiness - [x] I have performed a self-review - [ ] This PR is ready for human review
This commit is contained in:
parent
10ed14e7f6
commit
bd8de9f382
6 changed files with 204 additions and 36 deletions
|
|
@ -130,6 +130,7 @@ def _format_session_summary(
|
|||
"too_small": "Too small (< 500 tokens)",
|
||||
"passthrough": "Passthrough (token counting)",
|
||||
"no_compressible_content": "No compressible content (user/assistant only)",
|
||||
"unknown_token_accounting": "Unknown token accounting",
|
||||
}
|
||||
for key, count in uncomp.items():
|
||||
label = reason_labels.get(key, key)
|
||||
|
|
|
|||
|
|
@ -1307,12 +1307,12 @@
|
|||
<div class="px-4 py-3 min-w-0">
|
||||
<span class="px-2 py-0.5 bg-border rounded text-xs truncate" x-text="truncateModel(req.model)"></span>
|
||||
</div>
|
||||
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(req.input_tokens_optimized)"></div>
|
||||
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatNumber(req.output_tokens || 0)"></div>
|
||||
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatOptionalNumber(req.input_tokens_optimized)"></div>
|
||||
<div class="px-4 py-3 text-right font-mono tabular-nums" x-text="formatOptionalNumber(req.output_tokens)"></div>
|
||||
<div class="px-4 py-3 text-right">
|
||||
<span class="text-accent font-mono tabular-nums" x-text="req.savings_percent.toFixed(0) + '%'"></span>
|
||||
<span class="text-accent font-mono tabular-nums" x-text="formatOptionalPercent(req.savings_percent)"></span>
|
||||
</div>
|
||||
<div class="px-4 py-3 text-right font-mono tabular-nums text-gray-400" x-text="(req.total_latency_ms || 0).toFixed(0) + 'ms'"></div>
|
||||
<div class="px-4 py-3 text-right font-mono tabular-nums text-gray-400" x-text="formatOptionalMs(req.total_latency_ms)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded detail row -->
|
||||
|
|
@ -1321,19 +1321,19 @@
|
|||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-xs">
|
||||
<div>
|
||||
<div class="text-gray-500 uppercase tracking-wide mb-1">Original Tokens</div>
|
||||
<div class="font-mono" x-text="formatNumber(req.input_tokens_original)"></div>
|
||||
<div class="font-mono" x-text="formatOptionalNumber(req.input_tokens_original)"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 uppercase tracking-wide mb-1">Compressed Tokens</div>
|
||||
<div class="font-mono" x-text="formatNumber(req.input_tokens_optimized)"></div>
|
||||
<div class="font-mono" x-text="formatOptionalNumber(req.input_tokens_optimized)"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 uppercase tracking-wide mb-1">Tokens Removed</div>
|
||||
<div class="font-mono text-accent" x-text="formatNumber(req.tokens_saved)"></div>
|
||||
<div class="font-mono text-accent" x-text="formatOptionalNumber(req.tokens_saved)"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-500 uppercase tracking-wide mb-1">Optimization Time</div>
|
||||
<div class="font-mono" x-text="(req.optimization_latency_ms || 0).toFixed(0) + 'ms'"></div>
|
||||
<div class="font-mono" x-text="formatOptionalMs(req.optimization_latency_ms)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Transforms Applied -->
|
||||
|
|
@ -2054,12 +2054,28 @@
|
|||
|
||||
// --- Formatting ---
|
||||
|
||||
hasNumber(n) {
|
||||
return typeof n === 'number' && Number.isFinite(n);
|
||||
},
|
||||
|
||||
formatNumber(n) {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
return n.toString();
|
||||
},
|
||||
|
||||
formatOptionalNumber(n) {
|
||||
return this.hasNumber(n) ? this.formatNumber(n) : 'unknown';
|
||||
},
|
||||
|
||||
formatOptionalPercent(n) {
|
||||
return this.hasNumber(n) ? n.toFixed(0) + '%' : 'unknown';
|
||||
},
|
||||
|
||||
formatOptionalMs(n) {
|
||||
return this.hasNumber(n) ? n.toFixed(0) + 'ms' : 'unknown';
|
||||
},
|
||||
|
||||
formatCurrency(n) {
|
||||
if (n < 0) return '-' + this.formatCurrency(-n);
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from __future__ import annotations
|
|||
|
||||
import importlib.util
|
||||
import logging
|
||||
import math
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
|
@ -472,23 +473,50 @@ def build_session_summary(
|
|||
"too_small": 0,
|
||||
"passthrough": 0,
|
||||
"no_compressible_content": 0,
|
||||
"unknown_token_accounting": 0,
|
||||
}
|
||||
|
||||
def _entry_has_number(entry: Any, attr: str) -> bool:
|
||||
value = getattr(entry, attr, None)
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
)
|
||||
|
||||
def _entry_number(entry: Any, attr: str) -> int | float:
|
||||
value = getattr(entry, attr, 0)
|
||||
return value if _entry_has_number(entry, attr) else 0
|
||||
|
||||
if proxy.logger:
|
||||
for entry in proxy.logger._logs:
|
||||
if entry.model and "count_tokens" in entry.model:
|
||||
uncompressed_reasons["passthrough"] += 1
|
||||
continue
|
||||
if entry.tokens_saved > 0:
|
||||
tokens_saved = _entry_number(entry, "tokens_saved")
|
||||
input_tokens_original = _entry_number(entry, "input_tokens_original")
|
||||
input_tokens_optimized = _entry_number(entry, "input_tokens_optimized")
|
||||
has_complete_token_accounting = all(
|
||||
_entry_has_number(entry, attr)
|
||||
for attr in (
|
||||
"input_tokens_original",
|
||||
"input_tokens_optimized",
|
||||
"tokens_saved",
|
||||
"savings_percent",
|
||||
)
|
||||
)
|
||||
if tokens_saved > 0 and has_complete_token_accounting:
|
||||
compressed_requests.append(
|
||||
{
|
||||
"savings_pct": round(entry.savings_percent, 1),
|
||||
"tokens_saved": entry.tokens_saved,
|
||||
"original": entry.input_tokens_original,
|
||||
"optimized": entry.input_tokens_optimized,
|
||||
"savings_pct": round(_entry_number(entry, "savings_percent"), 1),
|
||||
"tokens_saved": tokens_saved,
|
||||
"original": input_tokens_original,
|
||||
"optimized": input_tokens_optimized,
|
||||
}
|
||||
)
|
||||
elif entry.input_tokens_original > 0:
|
||||
elif not has_complete_token_accounting:
|
||||
uncompressed_reasons["unknown_token_accounting"] += 1
|
||||
elif input_tokens_original > 0:
|
||||
# Categorize why it wasn't compressed
|
||||
transforms = entry.transforms_applied or []
|
||||
if not transforms:
|
||||
|
|
@ -496,7 +524,7 @@ def build_session_summary(
|
|||
uncompressed_reasons["prefix_frozen"] += 1
|
||||
elif all("excluded" in t or "protected" in t for t in transforms):
|
||||
uncompressed_reasons["no_compressible_content"] += 1
|
||||
elif entry.input_tokens_original < 500:
|
||||
elif input_tokens_original < 500:
|
||||
uncompressed_reasons["too_small"] += 1
|
||||
else:
|
||||
uncompressed_reasons["prefix_frozen"] += 1
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import contextlib
|
|||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
|
@ -2964,29 +2965,63 @@ def create_app(config: ProxyConfig | None = None) -> FastAPI:
|
|||
|
||||
RECENT_REQUEST_LOG_WINDOW = 100
|
||||
|
||||
def _is_recent_request_number(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
)
|
||||
|
||||
def _recent_request_optional_number(log: dict[str, Any], key: str) -> int | float | None:
|
||||
value = log.get(key)
|
||||
return value if _is_recent_request_number(value) else None
|
||||
|
||||
def _recent_request_token_accounting_status(log: dict[str, Any]) -> str:
|
||||
token_fields = (
|
||||
"input_tokens_original",
|
||||
"input_tokens_optimized",
|
||||
"tokens_saved",
|
||||
"savings_percent",
|
||||
)
|
||||
present = [_is_recent_request_number(log.get(field)) for field in token_fields]
|
||||
if all(present):
|
||||
return "complete"
|
||||
if any(present):
|
||||
return "partial"
|
||||
return "missing"
|
||||
|
||||
def _build_recent_request_payload(limit: int = RECENT_REQUEST_LOG_WINDOW) -> dict[str, Any]:
|
||||
recent_request_logs = proxy.logger.get_recent(limit) if proxy.logger else []
|
||||
dashboard_recent_requests = [
|
||||
{
|
||||
"request_id": log.get("request_id"),
|
||||
"timestamp": log.get("timestamp"),
|
||||
"provider": log.get("provider"),
|
||||
"model": log.get("model"),
|
||||
"input_tokens_original": log.get("input_tokens_original"),
|
||||
"input_tokens_optimized": log.get("input_tokens_optimized"),
|
||||
"output_tokens": log.get("output_tokens"),
|
||||
"tokens_saved": log.get("tokens_saved"),
|
||||
"savings_percent": log.get("savings_percent"),
|
||||
"optimization_latency_ms": log.get("optimization_latency_ms"),
|
||||
"total_latency_ms": log.get("total_latency_ms"),
|
||||
"transforms_applied": log.get("transforms_applied", []),
|
||||
"waste_signals": log.get("waste_signals"),
|
||||
"tool_schema_saved_tokens": _tool_schema_saved_from_tags(log.get("tags")),
|
||||
}
|
||||
for log in recent_request_logs
|
||||
if log.get("input_tokens_original") is not None
|
||||
and log.get("input_tokens_optimized") is not None
|
||||
][-10:]
|
||||
dashboard_recent_requests = []
|
||||
for log in recent_request_logs:
|
||||
token_accounting_status = _recent_request_token_accounting_status(log)
|
||||
dashboard_recent_requests.append(
|
||||
{
|
||||
"request_id": log.get("request_id"),
|
||||
"timestamp": log.get("timestamp"),
|
||||
"provider": log.get("provider"),
|
||||
"model": log.get("model"),
|
||||
"input_tokens_original": _recent_request_optional_number(
|
||||
log, "input_tokens_original"
|
||||
),
|
||||
"input_tokens_optimized": _recent_request_optional_number(
|
||||
log, "input_tokens_optimized"
|
||||
),
|
||||
"output_tokens": _recent_request_optional_number(log, "output_tokens"),
|
||||
"tokens_saved": _recent_request_optional_number(log, "tokens_saved"),
|
||||
"savings_percent": _recent_request_optional_number(log, "savings_percent"),
|
||||
"optimization_latency_ms": _recent_request_optional_number(
|
||||
log, "optimization_latency_ms"
|
||||
),
|
||||
"total_latency_ms": _recent_request_optional_number(log, "total_latency_ms"),
|
||||
"has_exact_tokens": token_accounting_status == "complete",
|
||||
"token_accounting_status": token_accounting_status,
|
||||
"transforms_applied": log.get("transforms_applied", []),
|
||||
"waste_signals": log.get("waste_signals"),
|
||||
"tool_schema_saved_tokens": _tool_schema_saved_from_tags(log.get("tags")),
|
||||
}
|
||||
)
|
||||
dashboard_recent_requests = dashboard_recent_requests[-10:]
|
||||
return {
|
||||
"request_logs": recent_request_logs[-10:],
|
||||
"recent_requests": dashboard_recent_requests,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,15 @@ export interface RequestMetrics {
|
|||
model: string;
|
||||
stream: boolean;
|
||||
mode: string;
|
||||
inputTokensOriginal?: number | null;
|
||||
inputTokensOptimized?: number | null;
|
||||
outputTokens?: number | null;
|
||||
tokensSaved?: number | null;
|
||||
savingsPercent?: number | null;
|
||||
optimizationLatencyMs?: number | null;
|
||||
totalLatencyMs?: number | null;
|
||||
hasExactTokens?: boolean;
|
||||
tokenAccountingStatus?: "complete" | "partial" | "missing";
|
||||
tokensInputBefore: number;
|
||||
tokensInputAfter: number;
|
||||
tokensOutput?: number | null;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,85 @@ def test_stats_refreshes_recent_requests_when_cached() -> None:
|
|||
assert second_payload["request_logs"][-1]["model"] == "claude-sonnet"
|
||||
|
||||
|
||||
def test_stats_recent_requests_includes_token_incomplete_requests() -> None:
|
||||
app = create_app(
|
||||
ProxyConfig(
|
||||
optimize=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,
|
||||
http2=False,
|
||||
)
|
||||
)
|
||||
logger = FakeRequestLogger()
|
||||
app.state.proxy.logger = logger
|
||||
|
||||
logger.logs = [
|
||||
FakeLogEntry(
|
||||
{
|
||||
"request_id": "req-haiku-1",
|
||||
"timestamp": "2026-07-09T10:00:00Z",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku",
|
||||
"transforms_applied": [],
|
||||
}
|
||||
),
|
||||
FakeLogEntry(
|
||||
{
|
||||
"request_id": "req-haiku-2",
|
||||
"timestamp": "2026-07-09T10:01:00Z",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-haiku",
|
||||
"input_tokens_original": None,
|
||||
"input_tokens_optimized": None,
|
||||
"output_tokens": None,
|
||||
"tokens_saved": 0,
|
||||
"savings_percent": 0.0,
|
||||
"transforms_applied": [],
|
||||
}
|
||||
),
|
||||
FakeLogEntry(
|
||||
{
|
||||
"request_id": "req-sonnet-1",
|
||||
"timestamp": "2026-07-09T10:02:00Z",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet",
|
||||
"input_tokens_original": 200,
|
||||
"input_tokens_optimized": 120,
|
||||
"output_tokens": 40,
|
||||
"tokens_saved": 80,
|
||||
"savings_percent": 40.0,
|
||||
"transforms_applied": ["smart_crusher"],
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
|
||||
response = client.get("/stats")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [req["model"] for req in payload["recent_requests"]] == [
|
||||
"claude-haiku",
|
||||
"claude-haiku",
|
||||
"claude-sonnet",
|
||||
]
|
||||
assert payload["recent_requests"][0]["input_tokens_optimized"] is None
|
||||
assert payload["recent_requests"][0]["token_accounting_status"] == "missing"
|
||||
assert payload["recent_requests"][0]["has_exact_tokens"] is False
|
||||
assert payload["recent_requests"][1]["output_tokens"] is None
|
||||
assert payload["recent_requests"][1]["token_accounting_status"] == "partial"
|
||||
assert payload["recent_requests"][1]["tokens_saved"] == 0
|
||||
assert payload["recent_requests"][2]["token_accounting_status"] == "complete"
|
||||
assert payload["recent_requests"][2]["has_exact_tokens"] is True
|
||||
assert payload["summary"]["uncompressed_requests"]["unknown_token_accounting"] == 2
|
||||
assert payload["request_logs"][-1]["model"] == "claude-sonnet"
|
||||
|
||||
|
||||
def test_agent_usage_totals_use_proxy_only_savings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("HEADROOM_REQUIRE_RUST_CORE", "false")
|
||||
monkeypatch.setattr(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue