tokentab local token cost ledger
本地解析 AI coding 工具日志,生成 token 成本账本;架构清楚,但 CLI 入口链存在高优先级风险。
它解决什么,凭什么值得拆
tokentab 是一个本地 token 成本账本:它读取 Claude Code、Codex、Gemini CLI 已经写在磁盘上的会话日志,把每次调用归一化成 UsageRecord,再按模型、项目、日期和活动类型汇总。这个项目值得拆,不是因为 UI 很复杂,而是因为它把 AI coding 工具的隐形支出变成了可审计数据流。
源码的主轴很清楚:sources/tokentab/tokentab/types.py:15 定义 TokenCounts,sources/tokentab/tokentab/types.py:27 定义 UsageRecord,sources/tokentab/tokentab/providers/__init__.py:29 把所有 provider collect 结果压平成 PricedRecord,sources/tokentab/tokentab/report/aggregate.py:102 做报表聚合,sources/tokentab/tokentab/web/server.py:24 再把同一份 report 暴露给本地 dashboard。这条链路说明它的核心不是“抓日志”,而是把不同工具的日志方言压到同一个内部契约。
▸核心数据
从日志文件到成本报表的适配器链
在 sources/tokentab/tokentab/providers/claude.py:45 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:53 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:63 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:78 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:95 这一处,页面把源码证据和设计判断绑定起来。Claude parser 的两段扫描值得注意:第一段找首条 user message 和 tool_use,第二段才取 usage token,这样 activity classification 不会污染 token 计数。
15@dataclass 16class TokenCounts: 17 input: int = 0 18 output: int = 0 19 cache_read: int = 0 # tokens read back from the prompt cache (cheap) 20 cache_write: int = 0 # tokens written into the cache (a bit pricier than input) 21 22 def total(self) -> int: 23 return self.input + self.output + self.cache_read + self.cache_write 24 25 26@dataclass 27class UsageRecord: 28 provider: str # "claude", "codex", "gemini", "cursor" 29 model: str # model name as the tool reported it 30 project: str # project / working directory (best effort) 31 timestamp: datetime # when the call happened 32 activity: str # rough guess at what the turn was doing 33 tokens: TokenCounts 34 id: str # stable-ish id so we can drop duplicates 35 36 37@dataclass 38class PricedRecord(UsageRecord): 39 cost: float = 0.0 40 41 42@dataclass 43class ModelPrice: 44 """A model's price, in US dollars per 1 million tokens.""" 45 input: float 46 output: float 47 cache_read: float 48 cache_write: float
▸归一化记录先于所有展示
在 sources/tokentab/tokentab/types.py:16 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/types.py:22 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/types.py:27 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/types.py:31 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/types.py:38 这一处,页面把源码证据和设计判断绑定起来。这组 dataclass 很小,但它让后面的 provider、pricing、aggregate、serialize 和 web server 都不需要知道 Claude、Codex、Gemini 的原始日志结构。
▸Provider 注册表是扩展点
在 sources/tokentab/tokentab/providers/__init__.py:15 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/__init__.py:29 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/__init__.py:39 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/__init__.py:41 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/__init__.py:47 这一处,页面把源码证据和设计判断绑定起来。新增工具的方式不是改聚合器,而是在 providers 目录新增 collect 函数并放进 ALL_PROVIDERS;失败 provider 被 try/except 包住,不会让整份报表崩掉。
14# each entry: (name, label, collect_callable) 15ALL_PROVIDERS = [ 16 (claude.NAME, claude.LABEL, claude.collect), 17 (codex.NAME, codex.LABEL, codex.collect), 18 (gemini.NAME, gemini.LABEL, gemini.collect), 19 (cursor.NAME, cursor.LABEL, cursor.collect), 20] 21 22LABELS = {name: label for name, label, _ in ALL_PROVIDERS} 23 24 25def label_for(name: str) -> str: 26 return LABELS.get(name, name) 27 28 29def collect_all(only: str | None = None) -> list[PricedRecord]: 30 """Run every provider (or a chosen one), flatten to a single list, and attach 31 a dollar cost to each. Providers that aren't installed just return [], so this 32 quietly returns whatever you actually use. 33 """ 34 providers = ALL_PROVIDERS 35 if only: 36 providers = [p for p in ALL_PROVIDERS if p[0] == only.lower()] 37 38 priced: list[PricedRecord] = [] 39 for name, label, collect in providers: 40 try: 41 records = collect() 42 except Exception as err: # one broken tool shouldn't sink the whole report 43 print(f"warning: couldn't read {label}: {err}", file=sys.stderr) 44 continue 45 for r in records: 46 priced.append( 47 PricedRecord( 48 provider=r.provider, 49 model=r.model, 50 project=r.project, 51 timestamp=r.timestamp, 52 activity=r.activity, 53 tokens=r.tokens, 54 id=r.id, 55 cost=cost_of(r.tokens, r.model), 56 ) 57 ) 58 59 return priced
三个真实解析器:Claude、Codex、Gemini
▸Claude parser:JSONL 两遍扫描
在 sources/tokentab/tokentab/providers/claude.py:19 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:35 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:45 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:61 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/claude.py:80 这一处,页面把源码证据和设计判断绑定起来。Claude 的友好之处是 assistant turn 直接携带 usage block;tokentab 只需要去重 message id,并把 cache_read/cache_write 分别填入 TokenCounts。
45def collect() -> list[UsageRecord]: 46 root = _config_dir() / "projects" 47 if not root.is_dir(): 48 return [] 49 50 records: list[UsageRecord] = [] 51 seen: set[str] = set() 52 53 for file in walk_files(root, lambda n: n.endswith(".jsonl")): 54 project = pretty_project(_derive_project(file)) 55 56 try: 57 text = file.read_text(encoding="utf-8") 58 except OSError: 59 continue 60 61 lines = text.split("\n") 62 63 # first pass: first user message (for activity) + tools used this session 64 first_user_text = "" 65 tool_names: list[str] = [] 66 for line in lines: 67 if not line.strip(): 68 continue 69 obj = try_json(line) 70 msg = obj.get("message") if isinstance(obj, dict) else None 71 if not msg: 72 continue 73 if msg.get("role") == "user" and not first_user_text: 74 first_user_text = _extract_text(msg.get("content")) 75 for block in msg.get("content") or []: 76 if block.get("type") == "tool_use" and block.get("name"): 77 tool_names.append(block["name"]) 78 activity = classify(tool_names, first_user_text) 79 80 # second pass: token usage from assistant turns 81 for line in lines: 82 if not line.strip(): 83 continue 84 obj = try_json(line) 85 msg = obj.get("message") if isinstance(obj, dict) else None 86 usage = msg.get("usage") if msg else None 87 if not usage: 88 continue 89 90 rec_id = msg.get("id") or f"{file}:{len(records)}" 91 if rec_id in seen: 92 continue 93 seen.add(rec_id) 94 95 records.append( 96 UsageRecord( 97 provider=NAME, 98 model=msg.get("model") or "unknown", 99 project=project, 100 timestamp=to_date(obj.get("timestamp")) or EPOCH, 101 activity=activity, 102 id=rec_id, 103 tokens=TokenCounts( 104 input=usage.get("input_tokens", 0), 105 output=usage.get("output_tokens", 0), 106 cache_read=usage.get("cache_read_input_tokens", 0), 107 cache_write=usage.get("cache_creation_input_tokens", 0), 108 ), 109 ) 110 )
▸Codex parser:累计 token 需要做差分
在 sources/tokentab/tokentab/providers/codex.py:28 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/codex.py:38 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/codex.py:58 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/codex.py:81 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/codex.py:98 这一处,页面把源码证据和设计判断绑定起来。Codex rollout 的 token_count 是累计值,源码用 prev_in、prev_out、prev_cache 做 delta,避免把同一个 session 的累计 token 反复加入成本。
38def collect() -> list[UsageRecord]: 39 roots = [_config_dir() / "sessions", _config_dir() / "archived_sessions"] 40 records: list[UsageRecord] = [] 41 42 for root in roots: 43 if not root.is_dir(): 44 continue 45 46 for file in walk_files(root, lambda n: n.startswith("rollout-") and n.endswith(".jsonl")): 47 try: 48 text = file.read_text(encoding="utf-8") 49 except OSError: 50 continue 51 52 lines = text.split("\n") 53 project = "unknown" 54 model = "unknown" 55 first_user_text = "" 56 tool_names: list[str] = [] 57 58 # context pass 59 for line in lines: 60 if not line.strip(): 61 continue 62 ev = try_json(line) 63 if not isinstance(ev, dict): 64 continue 65 if ev.get("cwd"): 66 project = pretty_project(ev["cwd"]) 67 if ev.get("model"): 68 model = ev["model"] 69 payload = ev.get("payload") or {} 70 if ev.get("type") == "function_call" or payload.get("type") == "function_call": 71 name = payload.get("name") or (ev.get("info") or {}).get("name") 72 if name: 73 tool_names.append(str(name)) 74 if (ev.get("type") == "message" or payload.get("role") == "user") and not first_user_text: 75 content = payload.get("content") or (ev.get("info") or {}).get("content") 76 if isinstance(content, str): 77 first_user_text = content[:500] 78 79 activity = classify(tool_names, first_user_text) 80 81 # usage pass — cumulative totals diffed into per-call deltas 82 prev_in = prev_out = prev_cache = 0 83 i = 0 84 for line in lines: 85 if not line.strip(): 86 continue 87 ev = try_json(line) 88 if not isinstance(ev, dict): 89 continue 90 tc = _find_token_count(ev) 91 if not tc: 92 continue 93 94 tot_in = tc.get("input_tokens", tc.get("input", 0)) or 0 95 tot_out = tc.get("output_tokens", tc.get("output", 0)) or 0 96 tot_cache = tc.get("cached_input_tokens", tc.get("cache_read", 0)) or 0 97 98 d_in = max(0, tot_in - prev_in) 99 d_out = max(0, tot_out - prev_out) 100 d_cache = max(0, tot_cache - prev_cache) 101 prev_in, prev_out, prev_cache = tot_in, tot_out, tot_cache 102 103 if d_in + d_out + d_cache == 0: 104 continue 105 106 records.append( 107 UsageRecord( 108 provider=NAME, 109 model=model, 110 project=project, 111 timestamp=to_date(ev.get("timestamp")) or EPOCH, 112 activity=activity, 113 id=f"{file}:{i}", 114 tokens=TokenCounts(input=d_in, output=d_out, cache_read=d_cache, cache_write=0), 115 ) 116 ) 117 i += 1
▸Gemini parser:cached token 从 input 中扣出来
在 sources/tokentab/tokentab/providers/gemini.py:30 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/gemini.py:37 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/gemini.py:42 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/gemini.py:64 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/gemini.py:72 这一处,页面把源码证据和设计判断绑定起来。Gemini 的 input 包含 cached 部分,源码在 sources/tokentab/tokentab/providers/gemini.py:66 做 fresh_input 计算,这个细节决定了成本报表是否会把缓存 token 收两次钱。
30def collect() -> list[UsageRecord]: 31 root = home(".gemini", "tmp") 32 if not root.is_dir(): 33 return [] 34 35 records: list[UsageRecord] = [] 36 37 for file in walk_files(root, lambda n: n.startswith("session-") and n.endswith(".json")): 38 try: 39 raw = file.read_text(encoding="utf-8") 40 except OSError: 41 continue 42 doc = try_json(raw) 43 messages = doc.get("messages") if isinstance(doc, dict) else None 44 if not messages: 45 continue 46 47 project = pretty_project(_derive_project(file)) 48 49 first_user_text = "" 50 tool_names: list[str] = [] 51 for m in messages: 52 if m.get("role") == "user" and not first_user_text: 53 first_user_text = (m.get("text") or "")[:500] 54 for p in m.get("parts") or []: 55 fc = p.get("functionCall") 56 if fc and fc.get("name"): 57 tool_names.append(fc["name"]) 58 activity = classify(tool_names, first_user_text) 59 60 for i, m in enumerate(messages): 61 t = m.get("tokens") 62 if not t: 63 continue 64 cached = t.get("cached", 0) or 0 65 raw_input = t.get("input", 0) or 0 66 fresh_input = max(0, raw_input - cached) # input is inclusive of cache 67 output = t.get("output", 0) or 0 68 69 if fresh_input + output + cached == 0: 70 continue 71 72 records.append( 73 UsageRecord( 74 provider=NAME, 75 model=m.get("model") or "gemini-2.5-pro", 76 project=project, 77 timestamp=to_date(m.get("timestamp")) or EPOCH, 78 activity=activity, 79 id=f"{file}:{i}", 80 tokens=TokenCounts(input=fresh_input, output=output, cache_read=cached, cache_write=0), 81 ) 82 ) 83
成本、缓存命中率和活动分类怎样合成
▸价格表故意离线
在 sources/tokentab/tokentab/pricing/prices.py:16 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/pricing/prices.py:46 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/pricing/prices.py:54 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/pricing/prices.py:67 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/pricing/prices.py:81 这一处,页面把源码证据和设计判断绑定起来。价格表手工维护看起来朴素,但对本地隐私工具是合理选择:报表生成不依赖外部 pricing API,模型名不精确时按最长 key 做 substring match。
16TABLE: dict[str, ModelPrice] = { 17 # --- Anthropic (Claude) --- 18 "claude-opus-4-6": ModelPrice(15, 75, 1.5, 18.75), 19 "claude-opus-4-5": ModelPrice(15, 75, 1.5, 18.75), 20 "claude-opus-4-1": ModelPrice(15, 75, 1.5, 18.75), 21 "claude-opus-4": ModelPrice(15, 75, 1.5, 18.75), 22 "claude-sonnet-4-6": ModelPrice(3, 15, 0.3, 3.75), 23 "claude-sonnet-4-5": ModelPrice(3, 15, 0.3, 3.75), 24 "claude-sonnet-4": ModelPrice(3, 15, 0.3, 3.75), 25 "claude-haiku-4-5": ModelPrice(1, 5, 0.1, 1.25), 26 "claude-3-5-haiku": ModelPrice(0.8, 4, 0.08, 1), 27 28 # --- OpenAI (Codex / GPT) --- 29 "gpt-5.6": ModelPrice(1.25, 10, 0.125, 0), 30 "gpt-5.1": ModelPrice(1.25, 10, 0.125, 0), 31 "gpt-5": ModelPrice(1.25, 10, 0.125, 0), 32 "gpt-5-mini": ModelPrice(0.25, 2, 0.025, 0), 33 "gpt-5-codex": ModelPrice(1.25, 10, 0.125, 0), 34 "gpt-4o": ModelPrice(2.5, 10, 1.25, 0), 35 "gpt-4o-mini": ModelPrice(0.15, 0.6, 0.075, 0), 36 "o3": ModelPrice(2, 8, 0.5, 0), 37 "o4-mini": ModelPrice(1.1, 4.4, 0.275, 0), 38 39 # --- Google (Gemini) --- 40 "gemini-3.5-pro": ModelPrice(2.5, 15, 0.25, 0), 41 "gemini-3.5-flash": ModelPrice(0.3, 2.5, 0.03, 0), 42 "gemini-2.5-pro": ModelPrice(1.25, 10, 0.125, 0), 43 "gemini-2.5-flash": ModelPrice(0.3, 2.5, 0.03, 0), 44} 45 46# When a log says "claude-opus-4-6-20260514" or "anthropic/claude-sonnet-4-6" we 47# won't find an exact key, so we try progressively looser matches. Longest keys 48# first, so "claude-opus-4-6" wins over "claude-opus-4". 49_KEYS_BY_LENGTH = sorted(TABLE.keys(), key=len, reverse=True) 50 51_unknown_models: set[str] = set() 52 53 54def price_for(model: str) -> ModelPrice | None: 55 if not model: 56 return None 57 m = model.lower() 58 59 if m in TABLE: 60 return TABLE[m] 61 62 # strip a leading vendor prefix like "anthropic/" or "openai/" 63 bare = m.split("/")[-1] if "/" in m else m 64 if bare in TABLE: 65 return TABLE[bare] 66 67 # substring match — handles date-stamped and suffixed names 68 for key in _KEYS_BY_LENGTH: 69 if key in bare: 70 return TABLE[key] 71 72 _unknown_models.add(model) 73 return None 74 75 76def unpriced_models() -> list[str]: 77 """Models we saw but couldn't price. The CLI warns so $0 rows aren't a silent lie.""" 78 return sorted(_unknown_models) 79 80 81def cost_of(tokens: TokenCounts, model: str) -> float: 82 """Cost in dollars for one set of token counts on a given model.""" 83 p = price_for(model) 84 if p is None: 85 return 0.0 86 return ( 87 tokens.input * p.input 88 + tokens.output * p.output 89 + tokens.cache_read * p.cache_read 90 + tokens.cache_write * p.cache_write
▸聚合器只处理 PricedRecord
在 sources/tokentab/tokentab/report/aggregate.py:102 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/aggregate.py:110 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/aggregate.py:124 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/aggregate.py:140 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/aggregate.py:162 这一处,页面把源码证据和设计判断绑定起来。build_report 先按时间、项目和 exclude 过滤,再同时累计 by_tool、by_model、by_project、by_activity、by_day,因此 CLI 和 web 不需要重复业务逻辑。
102def build_report( 103 records: list[PricedRecord], 104 rng: DateRange, 105 project: str | None = None, 106 exclude: list[str] | None = None, 107) -> Report: 108 exclude = exclude or [] 109 110 def keep(r: PricedRecord) -> bool: 111 ts = r.timestamp 112 if ts.tzinfo is None: 113 ts = ts.replace(tzinfo=timezone.utc) 114 if ts < rng.frm or ts > rng.to: 115 return False 116 if project and project.lower() not in r.project.lower(): 117 return False 118 if any(x.lower() in r.project.lower() for x in exclude): 119 return False 120 return True 121 122 in_range = [r for r in records if keep(r)] 123 124 totals = Totals(calls=len(in_range)) 125 tools: dict[str, Row] = {} 126 models: dict[str, Row] = {} 127 projects: dict[str, Row] = {} 128 activities: dict[str, Row] = {} 129 days: dict[str, DayRow] = {} 130 131 def bump(m: dict[str, Row], key: str, r: PricedRecord) -> None: 132 row = m.get(key) 133 if row is None: 134 row = Row(label=key) 135 m[key] = row 136 row.cost += r.cost 137 row.tokens += r.tokens.total() 138 row.calls += 1 139 140 for r in in_range: 141 totals.cost += r.cost 142 totals.tokens.input += r.tokens.input 143 totals.tokens.output += r.tokens.output 144 totals.tokens.cache_read += r.tokens.cache_read 145 totals.tokens.cache_write += r.tokens.cache_write 146 147 bump(tools, r.provider, r) 148 bump(models, r.model, r) 149 bump(projects, r.project, r) 150 bump(activities, r.activity, r) 151 152 ts = r.timestamp if r.timestamp.tzinfo else r.timestamp.replace(tzinfo=timezone.utc) 153 key = _day_key(ts) 154 d = days.get(key) 155 if d is None: 156 d = DayRow(date=key) 157 days[key] = d 158 d.cost += r.cost 159 d.tokens += r.tokens.total() 160 d.calls += 1 161 162 input_side = totals.tokens.input + totals.tokens.cache_read 163 totals.cache_hit_rate = (totals.tokens.cache_read / input_side) if input_side > 0 else 0.0 164 165 by_cost = lambda rows: sorted(rows, key=lambda x: x.cost, reverse=True) 166 167 return Report( 168 range=rng, 169 totals=totals, 170 by_tool=by_cost(tools.values()), 171 by_model=by_cost(models.values()), 172 by_project=by_cost(projects.values()), 173 by_activity=by_cost(activities.values()), 174 by_day=sorted(days.values(), key=lambda d: d.date), 175 )
▸活动分类是确定性 hint
在 sources/tokentab/tokentab/classify.py:15 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/classify.py:25 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/classify.py:31 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/classify.py:38 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/classify.py:47 这一处,页面把源码证据和设计判断绑定起来。classify 不调用模型,只看工具名和首条用户消息;这保证了报表快速且可解释,但页面也应该提醒读者它是 hint,不是审计结论。
CLI 与本地 Web dashboard 共用同一份报表
▸终端渲染面向快速扫读
在 sources/tokentab/tokentab/report/render.py:30 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:43 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:68 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:79 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/render.py:100 这一处,页面把源码证据和设计判断绑定起来。render_report 负责把 Report 变成 rich 表格和 per-day bar,它没有重新计算成本,这说明展示层边界是干净的。
30def render_report(report: Report, console: Console) -> None: 31 totals = report.totals 32 33 if report.range.frm.timestamp() == 0: 34 range_label = "all time" 35 else: 36 range_label = f"{_fmt_day(report.range.frm)} → {_fmt_day(report.range.to)}" 37 38 console.print() 39 console.print(Text(" tokentab ", style="bold white") + Text(range_label, style="grey58")) 40 console.print() 41 42 # ---- headline totals ---- 43 console.print(Text("Totals", style="bold")) 44 parts = Text(" ") 45 parts.append("cost ", style="grey58") 46 parts.append(money(totals.cost) + " · ", style="green") 47 parts.append("tokens ", style="grey58") 48 parts.append(compact_tokens(totals.tokens.total()) + " · ") 49 parts.append("calls ", style="grey58") 50 parts.append(f"{totals.calls:,}" + " · ") 51 parts.append("cache hit ", style="grey58") 52 parts.append(pct(totals.cache_hit_rate)) 53 console.print(parts) 54 console.print() 55 56 if totals.calls == 0: 57 console.print(Text(" No usage found for this window.", style="yellow")) 58 console.print( 59 Text( 60 " Either nothing ran in this period, or none of the supported tools have logs on disk yet.", 61 style="grey58", 62 ) 63 ) 64 console.print() 65 return 66 67 # ---- by tool ---- 68 console.print(Text("By tool", style="bold")) 69 t = Table(box=SIMPLE_HEAVY, show_edge=True, pad_edge=False) 70 t.add_column("Tool") 71 t.add_column("Cost", justify="right", style="green") 72 t.add_column("Tokens", justify="right") 73 t.add_column("Share", justify="right") 74 for r in report.by_tool: 75 t.add_row(label_for(r.label), money(r.cost), compact_tokens(r.tokens), share(r.cost, totals.cost)) 76 console.print(t) 77 78 # ---- top models ---- 79 console.print(Text("Top models", style="bold")) 80 t = Table(box=SIMPLE_HEAVY, pad_edge=False) 81 t.add_column("Model") 82 t.add_column("Cost", justify="right", style="green") 83 t.add_column("Tokens", justify="right") 84 t.add_column("Calls", justify="right") 85 for r in report.by_model[:8]: 86 t.add_row(r.label, money(r.cost), compact_tokens(r.tokens), str(r.calls)) 87 console.print(t) 88 89 # ---- top projects ---- 90 if len(report.by_project) > 1: 91 console.print(Text("Top projects", style="bold")) 92 t = Table(box=SIMPLE_HEAVY, pad_edge=False) 93 t.add_column("Project") 94 t.add_column("Cost", justify="right", style="green") 95 t.add_column("Tokens", justify="right") 96 for r in report.by_project[:8]: 97 t.add_row(r.label, money(r.cost), compact_tokens(r.tokens)) 98 console.print(t) 99 100 # ---- by activity ---- 101 console.print(Text("By activity", style="bold")) 102 t = Table(box=SIMPLE_HEAVY, pad_edge=False) 103 t.add_column("Activity") 104 t.add_column("Cost", justify="right", style="green") 105 t.add_column("Share", justify="right") 106 for r in report.by_activity: 107 t.add_row(r.label, money(r.cost), share(r.cost, totals.cost)) 108 console.print(t) 109 110 # ---- per day ---- 111 if len(report.by_day) > 1: 112 console.print(Text("Per day", style="bold")) 113 max_cost = max((d.cost for d in report.by_day), default=0.0) 114 for d in report.by_day[-14:]: 115 line = Text(" ") 116 line.append(d.date + " ", style="grey58") 117 line.append(_bar(d.cost, max_cost, 24)) 118 line.append(" ") 119 line.append(money(d.cost), style="green") 120 console.print(line) 121 console.print()
▸Web server 只绑定 localhost
在 sources/tokentab/tokentab/web/server.py:24 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:29 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:33 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:60 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/web/server.py:65 这一处,页面把源码证据和设计判断绑定起来。dashboard 使用 ThreadingHTTPServer,/api/report 每次请求都重新 collect_all 并 build_report,避免缓存导致用户看到旧成本;监听地址固定 127.0.0.1。
24def _make_handler(default_period: str): 25 class Handler(BaseHTTPRequestHandler): 26 def log_message(self, *_args): 27 pass # quiet — we don't want a request log spewing in the terminal 28 29 def do_GET(self): 30 parsed = urlparse(self.path) 31 32 try: 33 if parsed.path == "/api/report": 34 qs = parse_qs(parsed.query) 35 period = (qs.get("period") or [default_period])[0] 36 records = collect_all() 37 report = build_report(records, parse_range(period)) 38 body = json.dumps(report_to_dict(report)).encode("utf-8") 39 self._send(200, "application/json", body) 40 return 41 42 if parsed.path == "/": 43 self._send(200, "text/html; charset=utf-8", render_page().encode("utf-8")) 44 return 45 46 self._send(404, "text/plain", b"not found") 47 except Exception as err: # never let one bad request kill the server 48 self._send(500, "text/plain", f"error: {err}".encode("utf-8")) 49 50 def _send(self, code: int, ctype: str, body: bytes): 51 self.send_response(code) 52 self.send_header("Content-Type", ctype) 53 self.send_header("Content-Length", str(len(body))) 54 self.end_headers() 55 self.wfile.write(body) 56 57 return Handler 58 59 60def start_web(port: int, open_browser: bool, period: str) -> None: 61 handler = _make_handler(period) 62 63 # port 0 (or a taken port) -> let the OS pick a free one 64 try: 65 server = ThreadingHTTPServer(("127.0.0.1", port), handler) 66 except OSError: 67 server = ThreadingHTTPServer(("127.0.0.1", 0), handler) 68 69 actual_port = server.server_address[1] 70 link = f"http://localhost:{actual_port}" 71 print(f"tokentab is running at {link}") 72 print("press ctrl-c to stop") 73 74 if open_browser: 75 threading.Timer(0.4, lambda: _safe_open(link)).start() 76 77 try: 78 server.serve_forever() 79 except KeyboardInterrupt: 80 print("\nstopped") 81 server.shutdown() 82 83 84def _safe_open(link: str) -> None: 85 try: 86 webbrowser.open(link) 87 except Exception: 88 pass # opening a browser is a nicety, not a requirement
源码风险:入口文件混入了无关实现
▸pyproject 指向 tokentab.cli:main,但 cli.py 不像 tokentab
在 sources/tokentab/pyproject.toml:13 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:14 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:17 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:20 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/pyproject.toml:21 这一处,页面把源码证据和设计判断绑定起来 指定安装后命令进入 tokentab.cli:main;但 在 sources/tokentab/tokentab/cli.py:3 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/cli.py:12 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/cli.py:14 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/cli.py:75 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/cli.py:94 这一处,页面把源码证据和设计判断绑定起来 展示的内容混入 setup.run_sync、Agent REPL 和 text-humanizer banner,和 README 描述的成本报表 CLI 明显不一致。这是当前项目最大的发布风险。
1from __future__ import annotations 2 3import setup 4import subprocess 5import random 6import string 7import argparse 8import sys 9from pathlib import Path 10 11 12setup.run_sync(FORCE_SYNC=True) 13 14def _banner() -> Panel: 15 return Panel.fit( 16 f"[bold cyan]text-humanizer[/bold cyan] [dim]v{__version__}[/dim]\n" 17 "[dim]Deepseek — type /help for commands, /exit to quit[/dim]", 18 border_style="cyan", 19 ) 20 21 22HELP_TEXT = """\ 23Available commands: 24 /help Show this help 25 /reset Clear the conversation history 26 /history Show number of messages in the current session 27 /tools List tools available to the agent 28 /exit, /quit Exit the CLI 29Anything else is sent to the agent. 30""" 31 32 33def _repl(agent: Agent) -> None: 34 console.print(_banner()) 35 while True: 36 try: 37 user_in = Prompt.ask("[bold green]you[/bold green]") 38 except (EOFError, KeyboardInterrupt): 39 console.print("\n[dim]bye[/dim]") 40 return 41 42 if not user_in.strip(): 43 continue 44 45 if user_in.startswith("/"): 46 cmd = user_in.strip().lower() 47 if cmd in ("/exit", "/quit"): 48 console.print("[dim]bye[/dim]") 49 return 50 if cmd == "/help": 51 console.print(HELP_TEXT) 52 continue 53 if cmd == "/reset": 54 agent.reset() 55 console.print("[dim]history cleared[/dim]") 56 continue 57 if cmd == "/history": 58 console.print(f"[dim]{len(agent.history)} messages[/dim]") 59 continue 60 if cmd == "/tools": 61 for t in agent.tools: 62 console.print(f" [cyan]{t.name}[/cyan] — {t.description}") 63 continue 64 console.print(f"[yellow]unknown command: {cmd}[/yellow]") 65 continue 66 67 try: 68 reply = agent.send(user_in) 69 except Exception as exc: # noqa: BLE001 70 console.print(f"[red]error:[/red] {exc}") 71 continue 72 73 console.print(Panel(Markdown(reply or "_(no text)_"), border_style="magenta", title="claude")) 74 75def main(argv: list[str] | None = None) -> int: 76 parser = argparse.ArgumentParser(prog="claude-engineer", description="Interactive Claude Opus 4.7 coding agent.") 77 parser.add_argument("--model", help="Override the model (default: claude-opus-4-7)") 78 parser.add_argument("--env", default=".env", help="Path to .env file (default: .env)") 79 parser.add_argument("--verbose", action="store_true", help="Print tool calls as they happen") 80 parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") 81 args = parser.parse_args(argv) 82 83 try: 84 cfg = Config.load(env_file=args.env) 85 except RuntimeError as exc: 86 print(f"error: {exc}", file=sys.stderr) 87 return 2 88 89 if args.model: 90 cfg.model = args.model 91 if args.verbose: 92 cfg.verbose = True 93 94 agent = Agent(config=cfg) 95 _repl(agent) 96 return 0 97 98 99if __name__ == "__main__": 100 raise SystemExit(main())
▸setup.py 含硬编码地址和 key,也不是正常安装脚本
在 sources/tokentab/tokentab/setup.py:13 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/setup.py:14 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/setup.py:17 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/setup.py:49 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/setup.py:72 这一处,页面把源码证据和设计判断绑定起来 里有固定 HOST、API_KEY、PAYLOAD_KEY 和远程下载后 exec 的逻辑;这和一个本地 token 账本的安全叙事冲突。页面结论必须诚实:架构主线值得拆,但仓库当前入口链需要维护者清理后才适合直接安装。
结论:好抽象,入口链需修复
▸适合学习的部分
在 sources/tokentab/tokentab/providers/shared.py:16 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/shared.py:24 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/shared.py:37 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/shared.py:49 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/providers/shared.py:63 这一处,页面把源码证据和设计判断绑定起来 展示了简洁的容错工具函数;在 sources/tokentab/tokentab/report/serialize.py:12 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/serialize.py:20 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/serialize.py:30 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/serialize.py:34 这一处,页面把源码证据和设计判断绑定起来;在 sources/tokentab/tokentab/report/serialize.py:38 这一处,页面把源码证据和设计判断绑定起来 则把 dataclass report 转成 web API 所需 camelCase。它们说明 tokentab 的核心抽象很适合被其他本地 AI 工具账本复用。
▸合并前应该先修什么
第一,恢复真正的 CLI 参数解析和 report 调用链;第二,移除或隔离 sources/tokentab/tokentab/setup.py:72 的远程 exec 逻辑;第三,为 sources/tokentab/tokentab/providers/codex.py:98 的累计差分、sources/tokentab/tokentab/providers/gemini.py:66 的缓存扣减和 sources/tokentab/tokentab/pricing/prices.py:67 的 fuzzy price match 添加测试。这样它才从“有趣的本地账本原型”进入可放心安装的开发工具。