token-tracker 0.2.4__tar.gz → 0.3.2__tar.gz
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.
- {token_tracker-0.2.4 → token_tracker-0.3.2}/PKG-INFO +1 -1
- {token_tracker-0.2.4 → token_tracker-0.3.2}/README.md +2 -1
- {token_tracker-0.2.4 → token_tracker-0.3.2}/pyproject.toml +1 -1
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/cost.py +12 -3
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/hooks.py +174 -48
- {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/PKG-INFO +1 -1
- {token_tracker-0.2.4 → token_tracker-0.3.2}/setup.cfg +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/__init__.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/__init__.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/claude.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/codex.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/rate_limits.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/registry.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/types.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/__init__.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/aggregator.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/blocks.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/cli.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/ui/__init__.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/src/ui/tables.py +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/SOURCES.txt +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/dependency_links.txt +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/entry_points.txt +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/requires.txt +0 -0
- {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/top_level.txt +0 -0
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
- **成本分析** — 按会话、日、周、月维度的等效成本统计,多 Agent 按来源分组展示
|
|
39
39
|
- **会话洞察** — 项目、模型、时长、消息数一览
|
|
40
40
|
- **零配置** — 自动检测已安装的 Agent,直接读取本地数据
|
|
41
|
+
- **隐私安全** — 数据纯本地存储,不采集、不上传任何用户信息,极轻量无后顾之忧
|
|
41
42
|
|
|
42
43
|
## 安装
|
|
43
44
|
|
|
@@ -48,7 +49,7 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/master/inst
|
|
|
48
49
|
或者通过 pip:
|
|
49
50
|
|
|
50
51
|
```bash
|
|
51
|
-
pip install token-tracker
|
|
52
|
+
pip install --force-reinstall token-tracker
|
|
52
53
|
tt setup
|
|
53
54
|
```
|
|
54
55
|
|
|
@@ -73,9 +73,18 @@ def _load_pricing() -> dict:
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def _fetch_and_cache() -> dict:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
import ssl
|
|
77
|
+
ctx = ssl.create_default_context()
|
|
78
|
+
try:
|
|
79
|
+
req = urllib.request.Request(LITELLM_URL, headers={"User-Agent": "token-tracker/0.1"})
|
|
80
|
+
with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
|
|
81
|
+
data = json.loads(resp.read().decode())
|
|
82
|
+
except ssl.SSLCertVerificationError:
|
|
83
|
+
ctx.check_hostname = False
|
|
84
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
85
|
+
req = urllib.request.Request(LITELLM_URL, headers={"User-Agent": "token-tracker/0.1"})
|
|
86
|
+
with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
|
|
87
|
+
data = json.loads(resp.read().decode())
|
|
79
88
|
|
|
80
89
|
try:
|
|
81
90
|
with open(CACHE_PATH, "w") as f:
|
|
@@ -10,7 +10,7 @@ CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
|
|
|
10
10
|
HOOK_SCRIPT_PATH = os.path.expanduser("~/.claude/tt-statusline.py")
|
|
11
11
|
CODEX_CONFIG = os.path.expanduser("~/.codex/config.toml")
|
|
12
12
|
CODEX_BACKUP = os.path.expanduser("~/.codex/tt-backup.json")
|
|
13
|
-
HOOK_VERSION = "1.
|
|
13
|
+
HOOK_VERSION = "1.4"
|
|
14
14
|
_BACKUP_KEY = "tokenTracker"
|
|
15
15
|
_PREV_SL_KEY = "previousStatusLine"
|
|
16
16
|
_SL_REGEX = re.compile(r'status_line\s*=\s*\[.*?\]', re.DOTALL)
|
|
@@ -25,12 +25,12 @@ CODEX_STATUS_LINE = [
|
|
|
25
25
|
|
|
26
26
|
HOOK_SCRIPT = r'''#!/usr/bin/env python3
|
|
27
27
|
"""Claude Code statusLine — 状态栏显示 + 数据持久化到 tt-status.json"""
|
|
28
|
-
__version__ = "1.
|
|
29
|
-
import json, os, sys, tempfile
|
|
28
|
+
__version__ = "1.4"
|
|
29
|
+
import json, os, re, subprocess, sys, tempfile
|
|
30
30
|
from datetime import datetime, timezone
|
|
31
31
|
|
|
32
32
|
STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
|
|
33
|
-
|
|
33
|
+
ANSI_RE = re.compile(r'\033\[[0-9;]*m')
|
|
34
34
|
C = {
|
|
35
35
|
"green": "\033[32m", "yellow": "\033[33m", "red": "\033[31m",
|
|
36
36
|
"cyan": "\033[36m", "blue": "\033[34m", "magenta": "\033[35m",
|
|
@@ -38,6 +38,24 @@ C = {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def vlen(s):
|
|
42
|
+
return len(ANSI_RE.sub("", s))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_width():
|
|
46
|
+
try:
|
|
47
|
+
return max(1, os.get_terminal_size(2).columns - 4)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
import fcntl, struct, termios
|
|
51
|
+
try:
|
|
52
|
+
with open('/dev/tty', 'r') as tty:
|
|
53
|
+
res = fcntl.ioctl(tty, termios.TIOCGWINSZ, b'\x00' * 8)
|
|
54
|
+
return max(1, struct.unpack('hh', res[:4])[1] - 4)
|
|
55
|
+
except Exception:
|
|
56
|
+
return 116
|
|
57
|
+
|
|
58
|
+
|
|
41
59
|
def color_by_pct(pct):
|
|
42
60
|
return C["green"] if pct < 50 else C["yellow"] if pct < 80 else C["red"]
|
|
43
61
|
|
|
@@ -48,69 +66,177 @@ def fmt_tokens(n):
|
|
|
48
66
|
return str(n)
|
|
49
67
|
|
|
50
68
|
|
|
51
|
-
def progress_bar(value):
|
|
52
|
-
filled_char, empty_char
|
|
69
|
+
def progress_bar(value, bar_width=8):
|
|
70
|
+
filled_char, empty_char = "█", "░"
|
|
53
71
|
if value is None:
|
|
54
|
-
return empty_char *
|
|
72
|
+
return empty_char * bar_width + " n/a"
|
|
55
73
|
pct = max(0.0, min(100.0, float(value)))
|
|
56
|
-
filled = round(pct / 100 *
|
|
57
|
-
return f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}{empty_char * (
|
|
74
|
+
filled = round(pct / 100 * bar_width)
|
|
75
|
+
return f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}{empty_char * (bar_width - filled)} {pct:.0f}%"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fmt_duration(seconds):
|
|
79
|
+
if seconds >= 86400:
|
|
80
|
+
d, rem = int(seconds // 86400), int(seconds % 86400)
|
|
81
|
+
return f"{d}d{rem // 3600}h"
|
|
82
|
+
if seconds >= 3600:
|
|
83
|
+
h, m = int(seconds // 3600), int((seconds % 3600) // 60)
|
|
84
|
+
return f"{h}h{m}m"
|
|
85
|
+
if seconds >= 60:
|
|
86
|
+
return f"{int(seconds // 60)}min"
|
|
87
|
+
return f"{int(seconds)}s"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def git_branch(cwd):
|
|
91
|
+
try:
|
|
92
|
+
branch = subprocess.check_output(
|
|
93
|
+
["git", "branch", "--show-current"], cwd=cwd,
|
|
94
|
+
stderr=subprocess.DEVNULL, text=True, timeout=2,
|
|
95
|
+
).strip()
|
|
96
|
+
except Exception:
|
|
97
|
+
return ""
|
|
98
|
+
if not branch:
|
|
99
|
+
return ""
|
|
100
|
+
try:
|
|
101
|
+
dirty = subprocess.check_output(
|
|
102
|
+
["git", "status", "--porcelain", "--untracked-files=no"], cwd=cwd,
|
|
103
|
+
stderr=subprocess.DEVNULL, text=True, timeout=2,
|
|
104
|
+
).strip()
|
|
105
|
+
if dirty:
|
|
106
|
+
branch += "*"
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
return branch
|
|
58
110
|
|
|
59
111
|
|
|
60
|
-
def save_data(data):
|
|
61
|
-
data["_received_at"] =
|
|
112
|
+
def save_data(data, now):
|
|
113
|
+
data["_received_at"] = now.isoformat()
|
|
114
|
+
tmp = None
|
|
62
115
|
try:
|
|
63
116
|
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(STATUS_FILE), suffix=".tmp")
|
|
64
117
|
with os.fdopen(fd, "w") as f:
|
|
65
118
|
json.dump(data, f)
|
|
66
119
|
os.replace(tmp, STATUS_FILE)
|
|
67
120
|
except OSError:
|
|
68
|
-
|
|
121
|
+
if tmp:
|
|
122
|
+
try:
|
|
123
|
+
os.unlink(tmp)
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
69
126
|
|
|
70
127
|
|
|
71
|
-
def render(data):
|
|
72
|
-
|
|
128
|
+
def render(data, now):
|
|
129
|
+
W = get_width()
|
|
130
|
+
ctx = data.get("context_window") or {}
|
|
131
|
+
bar_w = 8 if W >= 100 else 6 if W >= 60 else 4
|
|
73
132
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
parts.append(f"{C['cyan']}{os.path.basename(project)}{C['reset']}")
|
|
133
|
+
# --- Line 1: Project | 5h | 7d | CTX ---
|
|
134
|
+
line1 = []
|
|
77
135
|
|
|
78
|
-
|
|
79
|
-
|
|
136
|
+
project = (data.get("workspace") or {}).get("project_dir", "")
|
|
137
|
+
if project:
|
|
138
|
+
name = os.path.basename(project)
|
|
139
|
+
branch = git_branch(project)
|
|
140
|
+
if branch:
|
|
141
|
+
line1.append(f"{C['green']}{name}{C['reset']}({C['magenta']}{branch}{C['reset']})")
|
|
142
|
+
else:
|
|
143
|
+
line1.append(f"{C['green']}{name}{C['reset']}")
|
|
144
|
+
|
|
145
|
+
rl = data.get("rate_limits") or {}
|
|
146
|
+
rl_parts = []
|
|
80
147
|
for key, label in [("five_hour", "5h"), ("seven_day", "7d")]:
|
|
81
|
-
|
|
148
|
+
entry = rl.get(key) or {}
|
|
149
|
+
pct = entry.get("used_percentage")
|
|
82
150
|
if pct is not None:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
151
|
+
reset_str = ""
|
|
152
|
+
resets_at = entry.get("resets_at")
|
|
153
|
+
if resets_at:
|
|
154
|
+
remain = int(resets_at) - int(now.timestamp())
|
|
155
|
+
if remain > 0:
|
|
156
|
+
reset_str = f" {C['dim']}({fmt_duration(remain)}){C['reset']}"
|
|
157
|
+
rl_parts.append((
|
|
158
|
+
f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_w)}{reset_str}",
|
|
159
|
+
f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_w)}",
|
|
160
|
+
f"{C['blue']}{label}:{C['reset']}{pct:.0f}%",
|
|
161
|
+
))
|
|
162
|
+
|
|
163
|
+
ctx_parts = []
|
|
93
164
|
if ctx.get("used_percentage") is not None:
|
|
94
165
|
size = ctx.get("context_window_size", 0)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
166
|
+
ctx_parts = [
|
|
167
|
+
f"{C['blue']}{fmt_tokens(size)} Context:{C['reset']}{progress_bar(ctx['used_percentage'], bar_w)}",
|
|
168
|
+
f"{C['blue']}{fmt_tokens(size)} CTX:{C['reset']}{ctx['used_percentage']:.0f}%",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
# 尝试完整版(带进度条+reset time)
|
|
172
|
+
full = line1 + [p[0] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
|
|
173
|
+
candidate = " | ".join(full)
|
|
174
|
+
if vlen(candidate) <= W:
|
|
175
|
+
line1 = full
|
|
176
|
+
else:
|
|
177
|
+
# 去掉 reset time
|
|
178
|
+
no_reset = line1 + [p[1] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
|
|
179
|
+
candidate = " | ".join(no_reset)
|
|
180
|
+
if vlen(candidate) <= W:
|
|
181
|
+
line1 = no_reset
|
|
182
|
+
else:
|
|
183
|
+
# 去掉进度条,只留百分比
|
|
184
|
+
minimal = line1 + [p[2] for p in rl_parts] + (ctx_parts[1:2] if ctx_parts else [])
|
|
185
|
+
line1 = minimal
|
|
186
|
+
|
|
187
|
+
# --- Line 2: Tokens + Cache + Cost ---
|
|
188
|
+
line2 = []
|
|
98
189
|
|
|
99
190
|
total_in = ctx.get("total_input_tokens", 0)
|
|
100
191
|
total_out = ctx.get("total_output_tokens", 0)
|
|
101
|
-
|
|
192
|
+
curr_usage = (ctx.get("current_usage") or {})
|
|
193
|
+
turn_in_total = curr_usage.get("input_tokens", 0) + curr_usage.get("cache_creation_input_tokens", 0)
|
|
194
|
+
turn_out = curr_usage.get("output_tokens", 0)
|
|
195
|
+
turn_str = f" {C['dim']}(本轮: in {fmt_tokens(turn_in_total)}, out {fmt_tokens(turn_out)}){C['reset']}"
|
|
102
196
|
if total_in or total_out:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
197
|
+
tok_full = f"{C['peach']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{turn_str}"
|
|
198
|
+
tok_short = f"{C['peach']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{C['reset']}"
|
|
199
|
+
line2.append(tok_full)
|
|
200
|
+
cache_read = curr_usage.get("cache_read_input_tokens", 0)
|
|
201
|
+
if cache_read > 0:
|
|
202
|
+
line2.append(f"{C['cyan']}Cached: {fmt_tokens(cache_read)}{C['reset']}")
|
|
203
|
+
|
|
204
|
+
cost = data.get("cost") or {}
|
|
205
|
+
usd = cost.get("total_cost_usd")
|
|
206
|
+
if usd is not None:
|
|
207
|
+
line2.append(f"{C['magenta']}Cost: ${usd:.2f}{C['reset']}")
|
|
208
|
+
|
|
209
|
+
# 宽度不够时隐藏本轮数据
|
|
210
|
+
if vlen(" | ".join(line2)) > W and (total_in or total_out):
|
|
211
|
+
line2[0] = tok_short
|
|
212
|
+
if vlen(" | ".join(line2)) > W:
|
|
213
|
+
line2 = line2[1:]
|
|
214
|
+
|
|
215
|
+
# --- Line 3: Duration + Model ---
|
|
216
|
+
line3 = []
|
|
217
|
+
|
|
218
|
+
duration_ms = cost.get("total_duration_ms")
|
|
219
|
+
duration_part = ""
|
|
220
|
+
if duration_ms and duration_ms > 0:
|
|
221
|
+
duration_part = f"{C['dim']}{C['magenta']}会话时长: {fmt_duration(duration_ms / 1000)}{C['reset']}"
|
|
222
|
+
line3.append(duration_part)
|
|
223
|
+
|
|
224
|
+
model_name = (data.get("model") or {}).get("display_name", "")
|
|
106
225
|
if model_name:
|
|
107
|
-
effort = data.get("effort"
|
|
226
|
+
effort = (data.get("effort") or {}).get("level", "")
|
|
108
227
|
if effort:
|
|
109
|
-
model_name += f"
|
|
110
|
-
|
|
228
|
+
model_name += f"/{effort}"
|
|
229
|
+
fast = data.get("fast_mode")
|
|
230
|
+
model_name += f"/{'fast' if fast else 'nofast'}"
|
|
231
|
+
line3.append(f"{C['dim']}{C['magenta']}{model_name}{C['reset']}")
|
|
111
232
|
|
|
112
|
-
|
|
113
|
-
|
|
233
|
+
# 宽度不够时隐藏会话时长
|
|
234
|
+
if vlen(" | ".join(line3)) > W and duration_part:
|
|
235
|
+
line3 = [p for p in line3 if p != duration_part]
|
|
236
|
+
|
|
237
|
+
output = [" | ".join(line) for line in (line1, line2, line3) if line]
|
|
238
|
+
if output:
|
|
239
|
+
print("\n".join(output))
|
|
114
240
|
|
|
115
241
|
|
|
116
242
|
def main():
|
|
@@ -121,8 +247,10 @@ def main():
|
|
|
121
247
|
data = json.loads(raw)
|
|
122
248
|
except Exception:
|
|
123
249
|
return
|
|
124
|
-
|
|
125
|
-
|
|
250
|
+
|
|
251
|
+
now = datetime.now(timezone.utc)
|
|
252
|
+
save_data(data, now)
|
|
253
|
+
render(data, now)
|
|
126
254
|
|
|
127
255
|
|
|
128
256
|
if __name__ == "__main__":
|
|
@@ -218,9 +346,7 @@ def setup(auto: bool = False) -> None:
|
|
|
218
346
|
|
|
219
347
|
|
|
220
348
|
def _setup_claude() -> None:
|
|
221
|
-
|
|
222
|
-
f.write(HOOK_SCRIPT)
|
|
223
|
-
os.chmod(HOOK_SCRIPT_PATH, os.stat(HOOK_SCRIPT_PATH).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
349
|
+
update_hook()
|
|
224
350
|
|
|
225
351
|
settings: dict = {}
|
|
226
352
|
if os.path.exists(CLAUDE_SETTINGS):
|
|
@@ -232,7 +358,7 @@ def _setup_claude() -> None:
|
|
|
232
358
|
console.print(f"[yellow]检测到已有 statusLine,备份后替换[/yellow]")
|
|
233
359
|
settings.setdefault(_BACKUP_KEY, {})[_PREV_SL_KEY] = existing
|
|
234
360
|
|
|
235
|
-
settings["statusLine"] = {"type": "command", "command": HOOK_SCRIPT_PATH}
|
|
361
|
+
settings["statusLine"] = {"type": "command", "command": f"python3 {HOOK_SCRIPT_PATH}"}
|
|
236
362
|
|
|
237
363
|
with open(CLAUDE_SETTINGS, "w", encoding="utf-8") as f:
|
|
238
364
|
json.dump(settings, f, indent=2, ensure_ascii=False)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|