Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ tt status # 过去 5h 实时面板
tt weekly # 周报
tt monthly # 月报
tt sessions # 最近 20 条会话明细(tt sessions <n> 改条数、--sort 改排序)
tt quota # 第三方配额提供者诊断
tt theme # 查看 / 切换配色主题(show / list / set / preview)
tt unsetup # 卸载并恢复安装前的配置
tt --version # 查看版本(-v / -V 同义)
Expand Down Expand Up @@ -136,6 +137,128 @@ tt monthly --theme nord # 任意报表临时换主题渲染(不持久化、
- 切换持久化到 `~/.config/token-tracker/config.json`;优先级 `--theme` 参数 > `TT_THEME` 环境变量 > 配置文件 > 自动。
- 终端支持 truecolor 用精确配色;不支持的(如 macOS 自带 Terminal.app)自动降级到 **256 色近似**。

## 第三方配额对接

通过 API Key 使用第三方平台(如 OpenCode 等)时,CC 不自动注入配额数据。Token Tracker 提供可扩展的 Provider 架构,通过外部脚本对接第三方平台的套餐用量。

### 配置方式

在 `~/.claude/tt-config.json` 中配置配额提供者:

```json
{
"rate_provider": {
"type": "script",
"command": "python ~/.claude/tt-opencode-quota.py",
"cache_ttl": 60,
"timeout": 10
}
}
```

| 字段 | 默认值 | 说明 |
|------|--------|------|
| `type` | — | 固定为 `script` |
| `command` | — | 要执行的命令 |
| `cache_ttl` | `60` | 缓存秒数,避免频繁调用脚本 |
| `timeout` | `10` | 脚本执行超时秒数 |

### 脚本输出格式

自定义脚本需要输出标准 JSON 格式到 stdout:

```json
{
"five_hour": {
"used_percentage": 31.5,
"resets_at": 1718457600
},
"seven_day": {
"used_percentage": 12.3,
"resets_at": 1718889600
},
"monthly": {
"used_percentage": 8.7,
"resets_at": 1719753600
},
"source": "OpenCode"
}
```

各窗口字段均可选。`source` 标识来源,显示在 `tt status` 和状态栏中。

### opencode 配置样例

创建 `~/.claude/tt-opencode-quota.py`:

```python
#!/usr/bin/env python3
"""opencode 用量查询脚本:GET workspace 页面 HTML,正则抠出 inline script 的三项用量。"""
import json
import re
import time
import urllib.request
import ssl
import sys

WORKSPACE_ID = "wrk_xxxxxxxxxxxxxxxxxxxxxxxx"
AUTH_COOKIE = "Fe26.2**xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
PAGE_URL = f"https://opencode.ai/workspace/{WORKSPACE_ID}/go"


def fetch_usage():
req = urllib.request.Request(PAGE_URL, method="GET")
req.add_header("cookie", f"oc_locale=zh; auth={AUTH_COOKIE.strip()}")
req.add_header("user-agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36")
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode()


def parse(html):
secs = re.findall(r"resetInSec:(\d+)", html)
pcts = re.findall(r"usagePercent:([\d.]+)", html)
if len(secs) < 3 or len(pcts) < 3:
raise ValueError("用量字段解析失败,cookie 可能已过期或页面结构变化")
return secs[:3], pcts[:3]


def main():
now = int(time.time())
try:
html = fetch_usage()
secs, pcts = parse(html)
rolling_sec, weekly_sec, monthly_sec = (int(s) for s in secs)
rolling_pct, weekly_pct, monthly_pct = (float(p) for p in pcts)
print(json.dumps({
"five_hour": {"used_percentage": round(rolling_pct, 1), "resets_at": now + rolling_sec},
"seven_day": {"used_percentage": round(weekly_pct, 1), "resets_at": now + weekly_sec},
"monthly": {"used_percentage": round(monthly_pct, 1), "resets_at": now + monthly_sec},
"source": "OpenCode",
}, ensure_ascii=False))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)


if __name__ == "__main__":
main()
```

> `auth` cookie 是 Fe26.2 加密、会过期,失效后需重新从浏览器复制覆盖。

### 诊断命令

配置完成后,使用诊断命令验证:

```bash
tt quota # 查看配额状态和提供者信息
tt quota --debug # 详细调试信息(含脱敏后的配置内容)
```

数据优先级:官方注入配额(CC 订阅模式)> 第三方提供者 > 官方仅模型信息(降级)。CC 状态栏在检测到无官方配额时自动走 provider 链式查询。

## 高级

### 首次运行向导
Expand Down
118 changes: 118 additions & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ tt status # last-5h real-time panel
tt weekly # weekly report
tt monthly # monthly report
tt sessions # last 20 session details (tt sessions <n> to change count, --sort to change order)
tt quota # diagnose third-party quota provider
tt theme # view / switch color theme (show / list / set / preview)
tt unsetup # uninstall and restore previous config
tt --version # show version (-v / -V)
Expand Down Expand Up @@ -136,6 +137,123 @@ tt monthly --theme nord # render any report in a theme temporarily (no persist,
- Choice persists to `~/.config/token-tracker/config.json`; priority: `--theme` flag > `TT_THEME` env var > config file > auto.
- Truecolor terminals get exact colors; terminals without truecolor (e.g. macOS Terminal.app) fall back to a **256-color approximation**.

## Third-Party Quota Integration

When using API keys with third-party platforms (OpenCode, etc.), CC won't inject quota data automatically. Token Tracker provides an extensible Provider architecture to fetch plan usage via external scripts.

### Configuration

Configure the quota provider in `~/.claude/tt-config.json`:

```json
{
"rate_provider": {
"type": "script",
"command": "python ~/.claude/tt-opencode-quota.py",
"cache_ttl": 60,
"timeout": 10
}
}
```

| Field | Default | Description |
|-------|---------|-------------|
| `type` | — | Must be `script` |
| `command` | — | Command to execute |
| `cache_ttl` | `60` | Cache TTL in seconds |
| `timeout` | `10` | Script timeout in seconds |

### Script Output Format

The script must output JSON to stdout:

```json
{
"five_hour": {
"used_percentage": 31.5,
"resets_at": 1718457600
},
"seven_day": {
"used_percentage": 12.3,
"resets_at": 1718889600
},
"monthly": {
"used_percentage": 8.7,
"resets_at": 1719753600
},
"source": "OpenCode"
}
```

All window fields are optional. `source` identifies the provider, displayed in `tt status` and the status line.

### opencode Example

Create `~/.claude/tt-opencode-quota.py`:

```python
#!/usr/bin/env python3
"""OpenCode quota fetcher — GET workspace page, regex-extract usage from inline script."""
import json, re, time, urllib.request, ssl, sys

WORKSPACE_ID = "wrk_xxxxxxxxxxxxxxxxxxxxxxxx"
AUTH_COOKIE = "Fe26.2**xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
PAGE_URL = f"https://opencode.ai/workspace/{WORKSPACE_ID}/go"


def fetch_usage():
req = urllib.request.Request(PAGE_URL)
req.add_header("cookie", f"oc_locale=en; auth={AUTH_COOKIE.strip()}")
req.add_header("user-agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36")
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.read().decode()


def parse(html):
secs = re.findall(r"resetInSec:(\d+)", html)
pcts = re.findall(r"usagePercent:([\d.]+)", html)
if len(secs) < 3 or len(pcts) < 3:
raise ValueError("Failed to parse usage fields; cookie may be expired")
return secs[:3], pcts[:3]


def main():
now = int(time.time())
try:
html = fetch_usage()
secs, pcts = parse(html)
rolling_s, weekly_s, monthly_s = (int(s) for s in secs)
rolling_p, weekly_p, monthly_p = (float(p) for p in pcts)
print(json.dumps({
"five_hour": {"used_percentage": round(rolling_p, 1), "resets_at": now + rolling_s},
"seven_day": {"used_percentage": round(weekly_p, 1), "resets_at": now + weekly_s},
"monthly": {"used_percentage": round(monthly_p, 1), "resets_at": now + monthly_s},
"source": "OpenCode",
}))
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)


if __name__ == "__main__":
main()
```

> The `auth` cookie uses Fe26.2 encryption and expires. Re-copy from browser when it stops working.

### Diagnostic Command

Verify the setup with:

```bash
tt quota # show quota status and provider info
tt quota --debug # detailed debug output (masked config)
```

Data priority: Official CC quota (subscription) > third-party provider > official metadata only (fallback). When no official quota is detected, the CC status line automatically queries the configured provider.

## Advanced

### First-run wizard
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
- **报表柱状图全窗口显示 + 暗色基线**(2026-06-22,已提交 `b3f4e8a`):daily Daily Trend 改固定最近 30 天全显示(去前导裁剪 + 中段空段「·」压缩),monthly Weekly Trend 改固定最近 30 个日历周(原 20、且只显示有数据周 → 现全窗口,缺失/没用过的周补 0)。无可见高度的天/周(空白、或量极小不足一格)最底行画一格更暗基线 ▁(`_darken(_S.peach,0.4)`+dim),与真实矮柱(dim peach)拉开、一眼可辨、时间轴连续。Model Trend 与 Project Trend 一致过滤占比 ≤2% 长尾项。`test_tables.py` 补 frozen `now`(固定 30 周窗口测试稳定)、mock `week` 改 monday ISO 日期对齐 `aggregate_weekly` 真实契约。
- **`tt sessions` 取数纠错 + codex 补真实 duration**(2026-06-22,已提交 `25fbfb7`):原「全量按 cost 倒序取前 20」致史上高 cost 会话恒久霸榜、新会话和低成本 agent(codex)永远进不了榜 → 改为**先按时间取最近 N 条、再按 cost(或 `--sort`)展示**。过滤口径从活跃时长 `active_minutes` 改为会话跨度 `duration_minutes`(cc + codex 统一)。codex 每会话仅 1 条汇总 entry、原 duration 恒为 0 被全滤;`UsageEntry` 加 `session_end` 字段、`adapters/codex.py` 取末事件 timestamp 填入、`aggregate_sessions` 据此算真实跨度(claude 留 None 走多条 entry 原逻辑)。补 `session_end` 解析 + 单条 entry 真实跨度两组用例。
- **国产模型识别 + cost(七家)**(2026-06-24):`cost.py` `_fallback_pricing` 补 30 个国产 model 内置价 + `_FAMILY_FALLBACK` 加 10 条系列前缀兜底,`format.py` `MODEL_SHORT` 配套短名,覆盖 Kimi/Moonshot、智谱 GLM、阿里 Qwen、火山 Doubao、DeepSeek、MiniMax、小米 MiMo。**定价口径**:国产按各家中国站人民币价 ÷7.1 折 USD(与 CC/Codex 同口径);**GLM 例外**用 z.ai 国际站官方 USD(中国站含缓存完整价是 SPA、官方页抓不到);阶梯定价模型(Qwen3-Coder / Doubao)统一取 0-32K 基础档。七家官方页 2026-06 核实并修正草案(`kimi-k2-instruct`/`turbo` 已 EOL → 改 K2.7/K2.6/K2.5;DeepSeek 进 V4、chat/reasoner 映射 v4-flash;小米 MiMo 与 DeepSeek 同价、Pro 主攻 agentic 编程);已下线旧 id / 未来新版本走系列兜底不归零。新增 `_cny`/`_usd` 汇率 helper(汇率常量 `_CNY_PER_USD=7.1` 集中)。**另补国外**:xAI Grok 官方定价(`grok-4.3` / `grok-build-0.1` / `grok-code-fast-1`,litellm bare 零收录会归 $0;2026-05-15 退役 slug 靠系列兜底按官方路由对齐 grok-4.3 / build-0.1)+ Gemini 8 个短名(litellm 价已对、只补显示名不入兜底)。`test_cost.py` 共 +10 用例。
- **第三方配额对接 Provider 框架**(2026-06-25,已提交 `900e599`):新增 `providers/` 包(`base.py` / `script.py` / `__init__.py`),`RateProvider` ABC + `ProviderRateData` + 注册表 + 缓存;`ScriptProvider` 执行外部脚本获取配额;`rate_limits.py` `load_rate_limits()` 改为链式查询(官方 → provider → 降级);`types.py` `RateLimits` 扩充 `monthly_pct` / `monthly_resets_at`;`cli.py` 加 `tt quota` 诊断命令;`hooks.py` CC 状态栏无官方配额时自动走 provider(`HOOK_VERSION` 1.7→1.8)。新增 `tests/test_providers.py` 共 19 用例。`uv.lock` 无关变更未提交。

## 待办 / 计划

Expand Down
20 changes: 20 additions & 0 deletions src/token_tracker/adapters/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 预导入所有 provider 模块,触发 @register_provider 装饰器执行
from . import script
from .base import (
ProviderRateData,
RateProvider,
create_provider,
get_cached_data,
register_provider,
set_cached_data,
)

__all__ = [
"RateProvider",
"ProviderRateData",
"register_provider",
"create_provider",
"get_cached_data",
"set_cached_data",
"script",
]
86 changes: 86 additions & 0 deletions src/token_tracker/adapters/providers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import hashlib
import json
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path


@dataclass
class ProviderRateData:
"""统一的配额数据格式,所有提供者都输出此格式"""
five_hour_pct: float | None = None
five_hour_resets_at: int | None = None
seven_day_pct: float | None = None
seven_day_resets_at: int | None = None
monthly_pct: float | None = None
monthly_resets_at: int | None = None
source: str = ""


class RateProvider(ABC):
"""配额提供者抽象基类"""

@abstractmethod
def get_limits(self) -> ProviderRateData | None:
pass

@classmethod
@abstractmethod
def from_config(cls, config: dict) -> "RateProvider":
pass

def _cache_key(self) -> str:
config_str = json.dumps(self.__dict__, sort_keys=True)
return f"{self.__class__.__name__}:{hashlib.md5(config_str.encode()).hexdigest()[:8]}"


_provider_registry: dict[str, type[RateProvider]] = {}


def register_provider(type_name: str):
"""提供者注册装饰器"""
def decorator(cls: type[RateProvider]) -> type[RateProvider]:
_provider_registry[type_name] = cls
return cls
return decorator


def create_provider(config: dict) -> RateProvider | None:
"""根据配置创建提供者实例"""
provider_type = config.get("type")
if not provider_type or provider_type not in _provider_registry:
return None
return _provider_registry[provider_type].from_config(config)


def get_cache_dir() -> Path:
"""获取缓存目录"""
return Path.home() / ".cache" / "token-tracker"


def get_cached_data(cache_key: str, ttl_seconds: int) -> dict | None:
"""读取缓存数据"""
cache_file = get_cache_dir() / f"quota_{cache_key}.json"
if not cache_file.exists():
return None
try:
with open(cache_file, encoding="utf-8") as f:
data = json.load(f)
if time.time() - data.get("_timestamp", 0) < ttl_seconds:
return data.get("payload")
except (json.JSONDecodeError, OSError):
pass
return None


def set_cached_data(cache_key: str, payload: dict) -> None:
"""写入缓存数据"""
cache_dir = get_cache_dir()
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / f"quota_{cache_key}.json"
try:
with open(cache_file, "w", encoding="utf-8") as f:
json.dump({"_timestamp": time.time(), "payload": payload}, f)
except OSError:
pass
Loading