pennyrouter 0.3.19 → 0.3.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/package.json +2 -1
- package/src/cli.js +13 -0
- package/src/launch.js +60 -4
- package/src/launch.test.js +8 -0
- package/standalone/.env.example +8 -0
- package/standalone/README-original.md +79 -0
- package/standalone/README.md +60 -0
- package/standalone/claude-proxy.sh +220 -0
- package/standalone/codex-proxy.sh +208 -0
- package/standalone/delta.py +41 -0
- package/standalone/demo_example.md +7 -0
- package/standalone/hud.py +122 -0
- package/standalone/install.sh +58 -0
- package/standalone/pane.py +430 -0
- package/standalone/penny-compare +318 -0
- package/standalone/penny-compare-viewer +10 -0
- package/standalone/penny-session +103 -0
- package/standalone/pricing.py +139 -0
- package/standalone/report.py +137 -0
- package/standalone/usage.py +575 -0
- package/standalone/viewer.py +232 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write and display the final report for a penny-compare run."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BUCKETS = ("cache_read", "cache_write", "write_5m", "write_1h", "fresh_input",
|
|
16
|
+
"output", "reasoning_output", "total")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_json(path: Path) -> dict:
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(path.read_text())
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_calls(path: Path, since: float) -> list[dict]:
|
|
27
|
+
calls = []
|
|
28
|
+
try:
|
|
29
|
+
lines = path.read_text().splitlines()
|
|
30
|
+
except OSError:
|
|
31
|
+
return calls
|
|
32
|
+
for line in lines:
|
|
33
|
+
try:
|
|
34
|
+
call = json.loads(line)
|
|
35
|
+
except ValueError:
|
|
36
|
+
continue
|
|
37
|
+
# recorded_at is generated locally when the pane accepts this call. The panes follow
|
|
38
|
+
# transcripts continuously, so it also gives reset a single clock across both formats.
|
|
39
|
+
if float(call.get("recorded_at") or 0) >= since:
|
|
40
|
+
calls.append(call)
|
|
41
|
+
return calls
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def summarize(meta: dict, calls: list[dict]) -> dict:
|
|
45
|
+
totals = {bucket: sum(int(call.get(bucket) or 0) for call in calls) for bucket in BUCKETS}
|
|
46
|
+
totals["cost"] = sum(float(call.get("cost") or 0) for call in calls)
|
|
47
|
+
return {**meta, "call_count": len(calls), "totals": totals, "calls": calls}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def metric(saved: float, baseline: float, unit: str = "") -> str:
|
|
51
|
+
pct = (saved / baseline * 100) if baseline else 0.0
|
|
52
|
+
if saved >= 0:
|
|
53
|
+
return f"{unit}{saved:,.2f} saved ({pct:,.1f}%)" if unit else f"{saved:,.0f} saved ({pct:,.1f}%)"
|
|
54
|
+
extra = abs(saved)
|
|
55
|
+
return f"{unit}{extra:,.2f} extra ({abs(pct):,.1f}%)" if unit else f"{extra:,.0f} extra ({abs(pct):,.1f}%)"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def centered(lines: list[str]) -> str:
|
|
59
|
+
size = shutil.get_terminal_size((100, 30))
|
|
60
|
+
width = min(max(len(line) for line in lines) + 8, max(40, size.columns - 2))
|
|
61
|
+
available = width - 6
|
|
62
|
+
lines = [line if len(line) <= available else line[:available - 1] + "…" for line in lines]
|
|
63
|
+
top = max(0, (size.lines - len(lines) - 4) // 2)
|
|
64
|
+
left = max(0, (size.columns - width) // 2)
|
|
65
|
+
pad = " " * left
|
|
66
|
+
border = "═" * (width - 2)
|
|
67
|
+
body = [pad + "╔" + border + "╗"]
|
|
68
|
+
for line in lines:
|
|
69
|
+
inner = line.center(width - 2)
|
|
70
|
+
body.append(pad + "║" + inner + "║")
|
|
71
|
+
body.append(pad + "╚" + border + "╝")
|
|
72
|
+
return "\033[2J\033[H" + ("\n" * top) + "\033[1m" + "\n".join(body) + "\033[0m"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> int:
|
|
76
|
+
state = Path(sys.argv[1])
|
|
77
|
+
reports = Path(sys.argv[2])
|
|
78
|
+
session = sys.argv[3]
|
|
79
|
+
try:
|
|
80
|
+
started = (state / "started").stat().st_mtime
|
|
81
|
+
except OSError:
|
|
82
|
+
started = 0.0
|
|
83
|
+
try:
|
|
84
|
+
reset = (state / "reset").stat().st_mtime
|
|
85
|
+
except OSError:
|
|
86
|
+
reset = 0.0
|
|
87
|
+
measured_from = max(started, reset)
|
|
88
|
+
|
|
89
|
+
left = summarize(read_json(state / "pane-left.json"),
|
|
90
|
+
read_calls(state / "calls-left.jsonl", measured_from))
|
|
91
|
+
right = summarize(read_json(state / "pane-right.json"),
|
|
92
|
+
read_calls(state / "calls-right.jsonl", measured_from))
|
|
93
|
+
native = next((side for side in (left, right) if side.get("role") == "NATIVE"), left)
|
|
94
|
+
penny = next((side for side in (left, right) if side.get("role") == "PENNY"), right)
|
|
95
|
+
native_totals, penny_totals = native["totals"], penny["totals"]
|
|
96
|
+
savings = {
|
|
97
|
+
"cost": native_totals["cost"] - penny_totals["cost"],
|
|
98
|
+
"tokens": native_totals["total"] - penny_totals["total"],
|
|
99
|
+
}
|
|
100
|
+
savings["cost_percent"] = savings["cost"] / native_totals["cost"] * 100 if native_totals["cost"] else 0.0
|
|
101
|
+
savings["token_percent"] = savings["tokens"] / native_totals["total"] * 100 if native_totals["total"] else 0.0
|
|
102
|
+
|
|
103
|
+
finished = datetime.now(timezone.utc)
|
|
104
|
+
report = {
|
|
105
|
+
"created_at": finished.isoformat(),
|
|
106
|
+
"measured_from_epoch": measured_from,
|
|
107
|
+
"pricing_profile": left.get("pricing_profile", "standard"),
|
|
108
|
+
"left": left, "right": right, "savings_native_minus_penny": savings,
|
|
109
|
+
}
|
|
110
|
+
reports.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
destination = reports / f"compare-{finished.strftime('%Y%m%d-%H%M%S-%f')}.json"
|
|
112
|
+
destination.write_text(json.dumps(report, indent=2) + "\n")
|
|
113
|
+
|
|
114
|
+
lines = [
|
|
115
|
+
"PENNY COMPARISON",
|
|
116
|
+
"",
|
|
117
|
+
metric(savings["cost"], native_totals["cost"], "$"),
|
|
118
|
+
metric(savings["tokens"], native_totals["total"]),
|
|
119
|
+
"",
|
|
120
|
+
f"NATIVE {native['call_count']} calls {native_totals['total']:,} tokens ${native_totals['cost']:,.2f}",
|
|
121
|
+
f"PENNY {penny['call_count']} calls {penny_totals['total']:,} tokens ${penny_totals['cost']:,.2f}",
|
|
122
|
+
"",
|
|
123
|
+
f"Report: {destination}",
|
|
124
|
+
"Press Enter to quit",
|
|
125
|
+
]
|
|
126
|
+
sys.stdout.write(centered(lines))
|
|
127
|
+
sys.stdout.flush()
|
|
128
|
+
try:
|
|
129
|
+
input()
|
|
130
|
+
except (EOFError, KeyboardInterrupt):
|
|
131
|
+
pass
|
|
132
|
+
subprocess.run(["tmux", "kill-session", "-t", session], check=False)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|