token-tracker 0.2.4__tar.gz → 0.3.1__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.
Files changed (25) hide show
  1. {token_tracker-0.2.4 → token_tracker-0.3.1}/PKG-INFO +1 -1
  2. {token_tracker-0.2.4 → token_tracker-0.3.1}/README.md +2 -1
  3. {token_tracker-0.2.4 → token_tracker-0.3.1}/pyproject.toml +1 -1
  4. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/analyzer/cost.py +12 -3
  5. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/hooks.py +109 -40
  6. {token_tracker-0.2.4 → token_tracker-0.3.1}/token_tracker.egg-info/PKG-INFO +1 -1
  7. {token_tracker-0.2.4 → token_tracker-0.3.1}/setup.cfg +0 -0
  8. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/__init__.py +0 -0
  9. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/adapters/__init__.py +0 -0
  10. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/adapters/claude.py +0 -0
  11. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/adapters/codex.py +0 -0
  12. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/adapters/rate_limits.py +0 -0
  13. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/adapters/registry.py +0 -0
  14. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/adapters/types.py +0 -0
  15. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/analyzer/__init__.py +0 -0
  16. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/analyzer/aggregator.py +0 -0
  17. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/analyzer/blocks.py +0 -0
  18. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/cli.py +0 -0
  19. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/ui/__init__.py +0 -0
  20. {token_tracker-0.2.4 → token_tracker-0.3.1}/src/ui/tables.py +0 -0
  21. {token_tracker-0.2.4 → token_tracker-0.3.1}/token_tracker.egg-info/SOURCES.txt +0 -0
  22. {token_tracker-0.2.4 → token_tracker-0.3.1}/token_tracker.egg-info/dependency_links.txt +0 -0
  23. {token_tracker-0.2.4 → token_tracker-0.3.1}/token_tracker.egg-info/entry_points.txt +0 -0
  24. {token_tracker-0.2.4 → token_tracker-0.3.1}/token_tracker.egg-info/requires.txt +0 -0
  25. {token_tracker-0.2.4 → token_tracker-0.3.1}/token_tracker.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Summary: Track token usage across local AI agents (Claude Code, Codex)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: rich>=13.7
@@ -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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "token-tracker"
7
- version = "0.2.4"
7
+ version = "0.3.1"
8
8
  description = "Track token usage across local AI agents (Claude Code, Codex)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -73,9 +73,18 @@ def _load_pricing() -> dict:
73
73
 
74
74
 
75
75
  def _fetch_and_cache() -> dict:
76
- req = urllib.request.Request(LITELLM_URL, headers={"User-Agent": "token-tracker/0.1"})
77
- with urllib.request.urlopen(req, timeout=10) as resp:
78
- data = json.loads(resp.read().decode())
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.1"
13
+ HOOK_VERSION = "1.2"
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,8 +25,8 @@ 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.1"
29
- import json, os, sys, tempfile
28
+ __version__ = "1.2"
29
+ import json, os, subprocess, sys, tempfile
30
30
  from datetime import datetime, timezone
31
31
 
32
32
  STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
@@ -57,60 +57,129 @@ def progress_bar(value):
57
57
  return f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}{empty_char * (width - filled)} {pct:.0f}%"
58
58
 
59
59
 
60
- def save_data(data):
61
- data["_received_at"] = datetime.now(timezone.utc).isoformat()
60
+ def fmt_duration(seconds):
61
+ if seconds >= 86400:
62
+ d, rem = int(seconds // 86400), int(seconds % 86400)
63
+ return f"{d}d{rem // 3600}h"
64
+ if seconds >= 3600:
65
+ h, m = int(seconds // 3600), int((seconds % 3600) // 60)
66
+ return f"{h}h{m}m"
67
+ if seconds >= 60:
68
+ return f"{int(seconds // 60)}min"
69
+ return f"{int(seconds)}s"
70
+
71
+
72
+ def git_branch(cwd):
73
+ try:
74
+ branch = subprocess.check_output(
75
+ ["git", "branch", "--show-current"], cwd=cwd,
76
+ stderr=subprocess.DEVNULL, text=True, timeout=2,
77
+ ).strip()
78
+ except Exception:
79
+ return ""
80
+ if not branch:
81
+ return ""
82
+ try:
83
+ dirty = subprocess.check_output(
84
+ ["git", "status", "--porcelain", "--untracked-files=no"], cwd=cwd,
85
+ stderr=subprocess.DEVNULL, text=True, timeout=2,
86
+ ).strip()
87
+ if dirty:
88
+ branch += "*"
89
+ except Exception:
90
+ pass
91
+ return branch
92
+
93
+
94
+ def save_data(data, now):
95
+ data["_received_at"] = now.isoformat()
96
+ tmp = None
62
97
  try:
63
98
  fd, tmp = tempfile.mkstemp(dir=os.path.dirname(STATUS_FILE), suffix=".tmp")
64
99
  with os.fdopen(fd, "w") as f:
65
100
  json.dump(data, f)
66
101
  os.replace(tmp, STATUS_FILE)
67
102
  except OSError:
68
- pass
103
+ if tmp:
104
+ try:
105
+ os.unlink(tmp)
106
+ except OSError:
107
+ pass
69
108
 
70
109
 
71
- def render(data):
72
- parts = []
110
+ def render(data, now):
111
+ ctx = data.get("context_window") or {}
73
112
 
74
- project = data.get("workspace", {}).get("project_dir", "")
75
- if project:
76
- parts.append(f"{C['cyan']}{os.path.basename(project)}{C['reset']}")
113
+ # --- Line 1: Project | 5h | 7d | CTX ---
114
+ line1 = []
77
115
 
78
- rl = data.get("rate_limits", {})
79
- has_rl = False
116
+ project = (data.get("workspace") or {}).get("project_dir", "")
117
+ if project:
118
+ name = os.path.basename(project)
119
+ branch = git_branch(project)
120
+ if branch:
121
+ line1.append(f"{C['green']}{name}{C['reset']}({C['magenta']}{branch}{C['reset']})")
122
+ else:
123
+ line1.append(f"{C['green']}{name}{C['reset']}")
124
+
125
+ rl = data.get("rate_limits") or {}
80
126
  for key, label in [("five_hour", "5h"), ("seven_day", "7d")]:
81
- pct = rl.get(key, {}).get("used_percentage")
127
+ entry = rl.get(key) or {}
128
+ pct = entry.get("used_percentage")
82
129
  if pct is not None:
83
- has_rl = True
84
- parts.append(f"{C['blue']}{label}:{C['reset']}{progress_bar(pct)}")
85
-
86
- if not has_rl:
87
- cost = data.get("cost", {})
88
- usd = cost.get("total_cost_usd")
89
- if usd is not None:
90
- parts.append(f"{C['blue']}Cost:{C['reset']}{C['peach']}${usd:.2f}{C['reset']}")
130
+ reset_str = ""
131
+ resets_at = entry.get("resets_at")
132
+ if resets_at:
133
+ remain = int(resets_at) - int(now.timestamp())
134
+ if remain > 0:
135
+ reset_str = f" {C['dim']}({fmt_duration(remain)}){C['reset']}"
136
+ line1.append(f"{C['blue']}{label}:{C['reset']}{progress_bar(pct)}{reset_str}")
91
137
 
92
- ctx = data.get("context_window", {})
93
138
  if ctx.get("used_percentage") is not None:
94
139
  size = ctx.get("context_window_size", 0)
95
- pct = ctx["used_percentage"]
96
- cc = C["green"] if pct < 40 else C["yellow"] if pct < 60 else C["red"]
97
- parts.append(f"{cc}{fmt_tokens(size)} CTX: {pct:.0f}%{C['reset']}")
140
+ line1.append(f"{C['blue']}{fmt_tokens(size)} Context:{C['reset']}{progress_bar(ctx['used_percentage'])}")
141
+
142
+ # --- Line 2: Tokens + Cache + Cost ---
143
+ line2 = []
98
144
 
99
145
  total_in = ctx.get("total_input_tokens", 0)
100
146
  total_out = ctx.get("total_output_tokens", 0)
101
- cache = ctx.get("current_usage", {}).get("cache_read_input_tokens", 0)
147
+ curr_usage = (ctx.get("current_usage") or {})
148
+ turn_in_total = curr_usage.get("input_tokens", 0) + curr_usage.get("cache_creation_input_tokens", 0)
149
+ turn_out = curr_usage.get("output_tokens", 0)
102
150
  if total_in or total_out:
103
- parts.append(f"{C['peach']}Tokens: {fmt_tokens(total_in)} {fmt_tokens(total_out)}↓ cached:{fmt_tokens(cache)}{C['reset']}")
104
-
105
- model_name = data.get("model", {}).get("display_name", "")
151
+ tok = f"{C['peach']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}"
152
+ tok += f" {C['dim']}(本轮: in {fmt_tokens(turn_in_total)}, out {fmt_tokens(turn_out)})"
153
+ tok += C['reset']
154
+ line2.append(tok)
155
+ cache_read = curr_usage.get("cache_read_input_tokens", 0)
156
+ if cache_read > 0:
157
+ line2.append(f"{C['cyan']}Cached: {fmt_tokens(cache_read)}{C['reset']}")
158
+
159
+ cost = data.get("cost") or {}
160
+ usd = cost.get("total_cost_usd")
161
+ if usd is not None:
162
+ line2.append(f"{C['magenta']}Cost: ${usd:.2f}{C['reset']}")
163
+
164
+ # --- Line 3: Duration + Model ---
165
+ line3 = []
166
+
167
+ duration_ms = cost.get("total_duration_ms")
168
+ if duration_ms and duration_ms > 0:
169
+ line3.append(f"{C['dim']}{C['magenta']}会话时长: {fmt_duration(duration_ms / 1000)}{C['reset']}")
170
+
171
+ model_name = (data.get("model") or {}).get("display_name", "")
106
172
  if model_name:
107
- effort = data.get("effort", {}).get("level", "")
173
+ effort = (data.get("effort") or {}).get("level", "")
108
174
  if effort:
109
- model_name += f"{C['dim']}/{effort}{C['reset']}"
110
- parts.append(f"{C['magenta']}{model_name}{C['reset']}")
175
+ model_name += f"/{effort}"
176
+ fast = data.get("fast_mode")
177
+ model_name += f"/{'fast' if fast else 'nofast'}"
178
+ line3.append(f"{C['dim']}{C['magenta']}{model_name}{C['reset']}")
111
179
 
112
- if parts:
113
- print(" | ".join(parts))
180
+ output = [" | ".join(line) for line in (line1, line2, line3) if line]
181
+ if output:
182
+ print("\n".join(output))
114
183
 
115
184
 
116
185
  def main():
@@ -121,8 +190,10 @@ def main():
121
190
  data = json.loads(raw)
122
191
  except Exception:
123
192
  return
124
- save_data(data)
125
- render(data)
193
+
194
+ now = datetime.now(timezone.utc)
195
+ save_data(data, now)
196
+ render(data, now)
126
197
 
127
198
 
128
199
  if __name__ == "__main__":
@@ -218,9 +289,7 @@ def setup(auto: bool = False) -> None:
218
289
 
219
290
 
220
291
  def _setup_claude() -> None:
221
- with open(HOOK_SCRIPT_PATH, "w", encoding="utf-8") as f:
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)
292
+ update_hook()
224
293
 
225
294
  settings: dict = {}
226
295
  if os.path.exists(CLAUDE_SETTINGS):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.2.4
3
+ Version: 0.3.1
4
4
  Summary: Track token usage across local AI agents (Claude Code, Codex)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: rich>=13.7
File without changes
File without changes