tokenfishing 0.9.0__py3-none-any.whl
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.
- tokenfishing/__init__.py +18 -0
- tokenfishing/__main__.py +8 -0
- tokenfishing/aggregate.py +568 -0
- tokenfishing/config.py +97 -0
- tokenfishing/i18n.py +148 -0
- tokenfishing/parser.py +208 -0
- tokenfishing/paths.py +90 -0
- tokenfishing/plan_usage.py +298 -0
- tokenfishing/popup.py +566 -0
- tokenfishing/render.py +242 -0
- tokenfishing/state.py +225 -0
- tokenfishing/statusline.py +331 -0
- tokenfishing/themes.py +1336 -0
- tokenfishing-0.9.0.dist-info/METADATA +523 -0
- tokenfishing-0.9.0.dist-info/RECORD +19 -0
- tokenfishing-0.9.0.dist-info/WHEEL +5 -0
- tokenfishing-0.9.0.dist-info/entry_points.txt +3 -0
- tokenfishing-0.9.0.dist-info/licenses/LICENSE +21 -0
- tokenfishing-0.9.0.dist-info/top_level.txt +1 -0
tokenfishing/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Claude 사용량을 도트 화면으로 보여주는 항상-위 팝업."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.9.0"
|
|
4
|
+
|
|
5
|
+
DEBUG = False
|
|
6
|
+
"""진단 로그를 stderr로 낼 것인가. `--debug` 만 이 값을 켠다.
|
|
7
|
+
|
|
8
|
+
한때 환경변수(TOKENFISHING_DEBUG)로 켰는데, 셸에 한 번 설정해 두면 그 뒤로
|
|
9
|
+
계속 따라다녀서 원치 않는 로그가 계속 떴다. 스위치는 하나면 된다.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def debug(message: str) -> None:
|
|
14
|
+
"""DEBUG 일 때만 stderr로. 팝업이 조용히 실패한 이유를 남기는 통로다."""
|
|
15
|
+
if DEBUG:
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
print(f"[tokenfishing] {message}", file=sys.stderr, flush=True)
|
tokenfishing/__main__.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""5시간 윈도우 집계와 burn rate. UsageEntry만 알고 JSONL은 모른다.
|
|
2
|
+
|
|
3
|
+
겹치는 세션 처리 — 이 프로젝트의 유일한 알고리즘 판단:
|
|
4
|
+
|
|
5
|
+
한도는 **계정 단위**로 걸린다. 세션 파일이 여러 개인 건 트랜스크립트 레이아웃의
|
|
6
|
+
사정이지 청구의 사정이 아니다. 따라서 sessionId별로 윈도우를 따로 세지 않고,
|
|
7
|
+
모든 파일의 엔트리를 하나의 시간축에 합친 뒤 그 위에서 5시간 블록을 자른다.
|
|
8
|
+
동시에 돌던 두 세션은 자동으로 같은 블록에 들어간다.
|
|
9
|
+
|
|
10
|
+
반례가 나오면(예: 세션별로 세야 한다면) 항목별 비율로
|
|
11
|
+
드러난다. 그때 이 결정만 갈아끼우면 된다.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime, timedelta, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import plan_usage, statusline
|
|
23
|
+
from .parser import UsageEntry, parse_file
|
|
24
|
+
from .paths import session_files
|
|
25
|
+
|
|
26
|
+
WINDOW = timedelta(hours=5)
|
|
27
|
+
BURN_SPAN = timedelta(hours=1)
|
|
28
|
+
|
|
29
|
+
FLOOR_TO_HOUR = False
|
|
30
|
+
"""블록 시작을 정시로 내림할 것인가. **공식 UI와 대조해 False로 확정.**
|
|
31
|
+
|
|
32
|
+
한때 True였다. claude-monitor가 첫 요청 13:36에 대해 session_start=13:00을 내놓길래
|
|
33
|
+
따라갔었다. 그런데 Claude 설정의 사용량 화면(공식)과 맞춰보니 실제 세션 시작은
|
|
34
|
+
**15:17**이었다 — 정시가 아니다. 레퍼런스를 근거로 삼은 게 오답이었다.
|
|
35
|
+
|
|
36
|
+
교훈: claude-monitor는 대조군이지 정답지가 아니다. 저쪽도 JSONL만 보고 추측한다.
|
|
37
|
+
둘이 일치한다고 맞는 게 아니다. 진짜 정답은 공식 UI뿐이다."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Window:
|
|
42
|
+
"""5시간 블록 하나. start는 이 블록 첫 요청 시각."""
|
|
43
|
+
|
|
44
|
+
start: datetime
|
|
45
|
+
entries: int
|
|
46
|
+
input_tokens: int
|
|
47
|
+
output_tokens: int
|
|
48
|
+
cache_creation_tokens: int
|
|
49
|
+
cache_read_tokens: int
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def end(self) -> datetime:
|
|
53
|
+
return self.start + WINDOW
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def catch(self) -> int:
|
|
57
|
+
"""5시간 한도가 실제로 세는 값. Totals.catch와 같은 정의."""
|
|
58
|
+
return self.input_tokens + self.output_tokens
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def total_tokens(self) -> int:
|
|
62
|
+
"""네 항목의 합. cache_read가 실측에서 나머지를 압도한다(약 200배).
|
|
63
|
+
게임 층에서 다르게 세고 싶으면 항목을 직접 골라 써라."""
|
|
64
|
+
return (
|
|
65
|
+
self.input_tokens
|
|
66
|
+
+ self.output_tokens
|
|
67
|
+
+ self.cache_creation_tokens
|
|
68
|
+
+ self.cache_read_tokens
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def is_active(self, now: datetime) -> bool:
|
|
72
|
+
return self.start <= now < self.end
|
|
73
|
+
|
|
74
|
+
def time_to_reset(self, now: datetime) -> timedelta:
|
|
75
|
+
return max(self.end - now, timedelta(0))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def collect_entries(files: Iterable[Path] | None = None) -> list[UsageEntry]:
|
|
79
|
+
"""모든 세션 파일을 읽어 시간순 UsageEntry 목록으로.
|
|
80
|
+
|
|
81
|
+
requestId로 전역 dedup한다. 실측 데이터에는 파일 간 중복이 없었지만,
|
|
82
|
+
세션을 재개/포크하면 이전 기록이 새 파일로 복사될 수 있다. 그러면 합계가
|
|
83
|
+
조용히 2배가 된다 — 2줄로 막을 수 있는 실패 모드라 막아둔다.
|
|
84
|
+
"""
|
|
85
|
+
seen: dict[str, UsageEntry] = {}
|
|
86
|
+
for f in session_files() if files is None else files:
|
|
87
|
+
for e in parse_file(f).entries:
|
|
88
|
+
seen.setdefault(e.request_id, e)
|
|
89
|
+
return sorted(seen.values(), key=lambda e: e.timestamp)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class Totals:
|
|
94
|
+
"""임의의 엔트리 묶음의 합. 창 개념이 없는 집계(주간·모델별·전체)에 쓴다."""
|
|
95
|
+
|
|
96
|
+
requests: int
|
|
97
|
+
input_tokens: int
|
|
98
|
+
output_tokens: int
|
|
99
|
+
cache_creation_tokens: int
|
|
100
|
+
cache_read_tokens: int
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def catch(self) -> int:
|
|
104
|
+
"""5시간 한도가 실제로 세는 값."""
|
|
105
|
+
return self.input_tokens + self.output_tokens
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def total_tokens(self) -> int:
|
|
109
|
+
return (
|
|
110
|
+
self.input_tokens + self.output_tokens
|
|
111
|
+
+ self.cache_creation_tokens + self.cache_read_tokens
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def totals_of(entries: Iterable[UsageEntry]) -> Totals:
|
|
116
|
+
rows = list(entries)
|
|
117
|
+
return Totals(
|
|
118
|
+
len(rows),
|
|
119
|
+
sum(e.input_tokens for e in rows),
|
|
120
|
+
sum(e.output_tokens for e in rows),
|
|
121
|
+
sum(e.cache_creation_tokens for e in rows),
|
|
122
|
+
sum(e.cache_read_tokens for e in rows),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _window(start: datetime, rows: list[UsageEntry]) -> Window:
|
|
127
|
+
"""엔트리 묶음 하나를 창으로. 합산은 totals_of 한 곳에만 둔다."""
|
|
128
|
+
t = totals_of(rows)
|
|
129
|
+
return Window(
|
|
130
|
+
start, t.requests, t.input_tokens, t.output_tokens,
|
|
131
|
+
t.cache_creation_tokens, t.cache_read_tokens,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def model_breakdown(entries: Iterable[UsageEntry]) -> list[tuple[str, Totals]]:
|
|
136
|
+
"""모델별 사용량. 조업량 많은 순.
|
|
137
|
+
|
|
138
|
+
추측이 하나도 안 들어간다 — 이미 파싱해 둔 model 필드를 묶기만 한다.
|
|
139
|
+
"""
|
|
140
|
+
buckets: dict[str, list[UsageEntry]] = {}
|
|
141
|
+
for e in entries:
|
|
142
|
+
buckets.setdefault(e.model, []).append(e)
|
|
143
|
+
pairs = [(m, totals_of(rows)) for m, rows in buckets.items()]
|
|
144
|
+
return sorted(pairs, key=lambda p: p[1].catch, reverse=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
WEEKLY_RESET_ENV = "TOKENFISHING_WEEKLY_RESET_DAY"
|
|
148
|
+
DEFAULT_WEEKLY_RESET_DAY = 1
|
|
149
|
+
"""주간 한도가 리셋되는 요일 (월=0 … 일=6). 기본 화요일.
|
|
150
|
+
|
|
151
|
+
공식 사용량 화면의 "(화) 오전 12:00에 재설정"에서 가져왔다. 계정마다 다를 수 있어서
|
|
152
|
+
TOKENFISHING_WEEKLY_RESET_DAY로 바꿀 수 있다. 시각은 로컬 자정."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def weekly_reset_day() -> int:
|
|
156
|
+
raw = os.environ.get(WEEKLY_RESET_ENV, "").strip()
|
|
157
|
+
if raw.isdigit() and 0 <= int(raw) <= 6:
|
|
158
|
+
return int(raw)
|
|
159
|
+
return DEFAULT_WEEKLY_RESET_DAY
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def weekly_start(now: datetime) -> datetime:
|
|
163
|
+
"""직전 주간 리셋 시각 (로컬 자정)."""
|
|
164
|
+
local = now.astimezone()
|
|
165
|
+
midnight = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
166
|
+
days_since = (midnight.weekday() - weekly_reset_day()) % 7
|
|
167
|
+
return (midnight - timedelta(days=days_since)).astimezone(timezone.utc)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def weekly_end(now: datetime) -> datetime:
|
|
171
|
+
return weekly_start(now) + timedelta(days=7)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def weekly_totals(entries: Iterable[UsageEntry], now: datetime) -> Totals:
|
|
175
|
+
start = weekly_start(now)
|
|
176
|
+
return totals_of(e for e in entries if start <= e.timestamp <= now)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
RESET_ENV = "TOKENFISHING_RESET_AT"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def app_reset_floor(entries: list[UsageEntry], now: datetime) -> datetime | None:
|
|
183
|
+
"""앱 기록으로 추정한 리셋 시각. 확실치 않으면 None.
|
|
184
|
+
|
|
185
|
+
앱은 사용률만 15분마다 남기고 창 경계는 안 남긴다. 그래서 "창이 비어 있던
|
|
186
|
+
마지막 시각"(plan_usage.window_floor) 뒤의 첫 요청을 창의 시작으로 본다.
|
|
187
|
+
그 첫 요청이 Claude Code 밖(웹·모바일)에서 일어났으면 몇 분 늦게 잡힌다 —
|
|
188
|
+
실측에서 약 7분 차이였다. **추정이므로 상태줄의 resets_at보다 뒤에 선다.**
|
|
189
|
+
|
|
190
|
+
이미 지난 창이 나오면 하한을 잘못 잡은 것이므로 버린다.
|
|
191
|
+
"""
|
|
192
|
+
floor = plan_usage.window_floor(plan_usage.samples(), now)
|
|
193
|
+
if floor is None:
|
|
194
|
+
return None
|
|
195
|
+
after = next((e.timestamp for e in entries if e.timestamp > floor), None)
|
|
196
|
+
if after is None:
|
|
197
|
+
return None
|
|
198
|
+
reset_at = after + WINDOW
|
|
199
|
+
return reset_at if reset_at > now else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class Official:
|
|
204
|
+
"""공식 소스에서 뽑은 값 한 벌. **항목마다 출처가 다르다.**
|
|
205
|
+
|
|
206
|
+
소스 두 개는 강점이 갈린다:
|
|
207
|
+
|
|
208
|
+
상태줄 훅 resets_at이 서버가 준 정확한 값이다. 사용률도 공식.
|
|
209
|
+
단 훅 설치와 Claude Code 재시작이 필요하고, Pro/Max에서
|
|
210
|
+
세션 첫 응답 뒤에야 들어온다
|
|
211
|
+
앱 기록 설치도 재시작도 필요 없고 웹·모바일 사용까지 반영된다.
|
|
212
|
+
단 15분 간격이라 창 경계는 추정할 수밖에 없다
|
|
213
|
+
|
|
214
|
+
그래서 소스를 통째로 고르지 않고 **항목별로** 고른다. 한때는 앱 기록을
|
|
215
|
+
통째로 1순위에 두고, 그쪽이 창 경계를 못 구하면 사용률까지 같이 버렸다.
|
|
216
|
+
경계와 사용률은 서로 독립인데도 그랬다 — 앱이 몇 시간 꺼져 있으면 공식
|
|
217
|
+
사용률을 손에 들고도 화면 전체가 어림으로 떨어졌다.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
reset_at: datetime | None = None
|
|
221
|
+
reset_exact: bool = False
|
|
222
|
+
"""리셋 시각이 서버가 준 값인가. False면 추정이라 화면에 ~를 붙인다."""
|
|
223
|
+
|
|
224
|
+
used_percentage: float | None = None
|
|
225
|
+
weekly_percentage: float | None = None
|
|
226
|
+
weekly_reset_at: datetime | None = None
|
|
227
|
+
|
|
228
|
+
captured_at: datetime | None = None
|
|
229
|
+
"""사용률을 **언제 받아둔 값인가.**
|
|
230
|
+
|
|
231
|
+
공식 수치라고 해서 지금 값인 건 아니다. 훅은 Claude Code가 상태줄을 그릴 때만
|
|
232
|
+
돌고, 앱 기록은 앱이 켜져 있을 때만 갱신된다. 그래서 그 기기에서 한동안
|
|
233
|
+
Claude Code를 안 쓰면 몇 시간 전 사용률이 그대로 남아 있다 — 창이 아직
|
|
234
|
+
안 끝났으므로 유효성 검사도 통과한다.
|
|
235
|
+
|
|
236
|
+
기기 두 대에서 같은 계정을 쓰는데 사용률이 서로 다르게 나오는 원인이 이것이다.
|
|
237
|
+
값 자체는 계정 기준이라 맞지만 **찍힌 시각이 다르다.** 그래서 나이를 같이
|
|
238
|
+
들고 다니고, 오래된 값은 화면에서 오래됐다고 밝힌다."""
|
|
239
|
+
|
|
240
|
+
source: str = "none"
|
|
241
|
+
"""사용률이 어디서 왔나: "hook" | "app" | "none".
|
|
242
|
+
|
|
243
|
+
none이면 화면이 어림값으로 떨어진다. **왜 떨어졌는지 화면에 밝힌다** —
|
|
244
|
+
"어림"만 뜨고 이유가 없으면 훅이 없는 건지 앱이 꺼진 건지 알 수가 없다.
|
|
245
|
+
실제로 재현이 안 되는 어림 스크린샷을 받고 나서 넣었다."""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def resolve_official(entries: list[UsageEntry], now: datetime) -> Official:
|
|
249
|
+
"""항목별로 가장 믿을 만한 출처를 골라 한 벌로 묶는다."""
|
|
250
|
+
hook = official_limits(now)
|
|
251
|
+
rows = plan_usage.samples()
|
|
252
|
+
sample = plan_usage.latest(rows)
|
|
253
|
+
|
|
254
|
+
# --- 리셋 시각: 서버가 준 값 > 손으로 꽂은 값 > 앱 기록 추정 ---
|
|
255
|
+
reset_at, reset_exact = None, False
|
|
256
|
+
if hook is not None:
|
|
257
|
+
reset_at, reset_exact = hook["reset_at"], True
|
|
258
|
+
elif (pin := pinned_reset(now)) is not None and now < pin:
|
|
259
|
+
reset_at, reset_exact = pin, True
|
|
260
|
+
else:
|
|
261
|
+
reset_at = app_reset_floor(entries, now)
|
|
262
|
+
|
|
263
|
+
# --- 5시간 사용률: 둘 중 **더 최근에 찍힌 쪽** ---
|
|
264
|
+
#
|
|
265
|
+
# 예전에는 훅을 무조건 1순위로 뒀는데, 훅 값은 Claude Code가 상태줄을 그릴 때만
|
|
266
|
+
# 갱신된다. 그 기기에서 Claude Code를 몇 시간 안 쓰면 낡은 사용률이 남아 있고,
|
|
267
|
+
# 창이 아직 안 끝났으니 유효성 검사도 통과해 버린다. 그동안 앱이 켜져 있었다면
|
|
268
|
+
# 앱 기록이 훨씬 최신인데도 낡은 훅 값을 썼다.
|
|
269
|
+
#
|
|
270
|
+
# 앱 샘플 값 자체에는 손대지 않는다. 조업량으로 한도를 역산해 뒤처진 만큼
|
|
271
|
+
# 보정하는 코드가 한때 있었는데, 못 보는 웹·모바일 사용이 눈금을 망가뜨려
|
|
272
|
+
# 100%로 튀었다 (근거는 plan_usage 모듈 도크스트링).
|
|
273
|
+
hook_pct = plan_usage.clean_pct(hook["used_percentage"]) if hook else None
|
|
274
|
+
hook_at = hook["captured_at"] if hook else None
|
|
275
|
+
app_pct = plan_usage.clean_pct(sample.five_hour) if sample is not None else None
|
|
276
|
+
app_at = sample.at if sample is not None else None
|
|
277
|
+
|
|
278
|
+
used, source, captured_at = None, "none", None
|
|
279
|
+
if hook_pct is not None and (
|
|
280
|
+
app_pct is None or app_at is None or hook_at is None or hook_at >= app_at
|
|
281
|
+
):
|
|
282
|
+
used, source, captured_at = hook_pct, "hook", hook_at
|
|
283
|
+
elif app_pct is not None:
|
|
284
|
+
used, source, captured_at = app_pct, "app", app_at
|
|
285
|
+
|
|
286
|
+
# --- 주간: 사용률과 같은 출처를 따라간다 ---
|
|
287
|
+
# 5시간은 훅, 주간은 앱처럼 섞으면 두 숫자가 서로 다른 시점을 가리킨다.
|
|
288
|
+
weekly = hook["weekly"] if hook else {}
|
|
289
|
+
if source == "app" and sample is not None:
|
|
290
|
+
weekly_pct = plan_usage.clean_pct(sample.seven_day)
|
|
291
|
+
else:
|
|
292
|
+
weekly_pct = plan_usage.clean_pct(weekly.get("used_percentage"))
|
|
293
|
+
if weekly_pct is None and sample is not None:
|
|
294
|
+
weekly_pct = plan_usage.clean_pct(sample.seven_day)
|
|
295
|
+
|
|
296
|
+
weekly_reset = None
|
|
297
|
+
if weekly.get("resets_at") is not None:
|
|
298
|
+
try:
|
|
299
|
+
weekly_reset = datetime.fromtimestamp(
|
|
300
|
+
float(weekly["resets_at"]), timezone.utc
|
|
301
|
+
)
|
|
302
|
+
except (TypeError, ValueError, OSError):
|
|
303
|
+
weekly_reset = None
|
|
304
|
+
|
|
305
|
+
return Official(
|
|
306
|
+
reset_at, reset_exact, used, weekly_pct, weekly_reset, captured_at, source
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def official_limits(now: datetime) -> dict | None:
|
|
311
|
+
"""상태줄 훅이 받아둔 공식 사용량. 없으면 None.
|
|
312
|
+
|
|
313
|
+
Claude Code가 상태줄 명령에 넘기는 `rate_limits`를 그대로 저장한 값이다.
|
|
314
|
+
추정이 아니라 계정 기준 공식 수치라, 웹·모바일 사용량까지 반영돼 있다.
|
|
315
|
+
창이 이미 지났으면 무시한다 (Claude Code도 지난 창은 빼고 보낸다).
|
|
316
|
+
"""
|
|
317
|
+
saved = statusline.load()
|
|
318
|
+
if not saved:
|
|
319
|
+
return None
|
|
320
|
+
five = (saved.get("rate_limits") or {}).get("five_hour") or {}
|
|
321
|
+
resets = five.get("resets_at")
|
|
322
|
+
if resets is None:
|
|
323
|
+
return None
|
|
324
|
+
try:
|
|
325
|
+
reset_at = datetime.fromtimestamp(float(resets), timezone.utc)
|
|
326
|
+
except (TypeError, ValueError, OSError):
|
|
327
|
+
return None
|
|
328
|
+
if reset_at <= now:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
captured_at = datetime.fromisoformat(saved["captured_at"])
|
|
333
|
+
except (KeyError, TypeError, ValueError):
|
|
334
|
+
captured_at = None
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
"reset_at": reset_at,
|
|
338
|
+
"captured_at": captured_at,
|
|
339
|
+
"used_percentage": five.get("used_percentage"),
|
|
340
|
+
"weekly": (saved.get("rate_limits") or {}).get("seven_day") or {},
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def pinned_reset(now: datetime) -> datetime | None:
|
|
345
|
+
"""수동으로 꽂은 리셋 시각. 없으면 None.
|
|
346
|
+
|
|
347
|
+
상태줄 훅을 못 쓰는 경우(구독이 아니거나 훅 설치 전)를 위한 수단이다.
|
|
348
|
+
훅이 있으면 그쪽이 우선한다.
|
|
349
|
+
|
|
350
|
+
JSONL만으로는 윈도우 경계를 알 수 없는 경우가 있다 — claude.ai 웹이나 모바일에서
|
|
351
|
+
쓴 사용량도 같은 5시간 한도를 먹지만 여기엔 흔적이 안 남는다. 그런 사용이 창을
|
|
352
|
+
열었으면 우리가 보는 첫 요청은 창의 시작이 아니다. 실측된 사례다:
|
|
353
|
+
공식 세션 시작 15:17, 그날 Claude Code 첫 요청은 13:36.
|
|
354
|
+
|
|
355
|
+
그래서 사용자가 진짜 값을 꽂을 수 있게 한다. Claude 설정 > 사용량에 뜨는
|
|
356
|
+
"N시간 M분 후 재설정"을 시계 시각으로 바꿔 넣으면 된다:
|
|
357
|
+
|
|
358
|
+
TOKENFISHING_RESET_AT=05:17 로컬 시각, 다음 도래분
|
|
359
|
+
TOKENFISHING_RESET_AT=2026-09-04T20:17:00+00:00 ISO 순간
|
|
360
|
+
|
|
361
|
+
ponytail: 설정 파일 대신 환경변수 하나. 값이 하나뿐이고 수명도 짧다.
|
|
362
|
+
"""
|
|
363
|
+
raw = os.environ.get(RESET_ENV, "").strip()
|
|
364
|
+
if not raw:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
if ":" in raw and len(raw) <= 5: # "HH:MM"
|
|
369
|
+
hh, mm = (int(p) for p in raw.split(":"))
|
|
370
|
+
local = now.astimezone()
|
|
371
|
+
reset = local.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
|
372
|
+
if reset <= local: # 이미 지났으면 내일 그 시각
|
|
373
|
+
reset += timedelta(days=1)
|
|
374
|
+
return reset.astimezone(timezone.utc)
|
|
375
|
+
parsed = datetime.fromisoformat(raw)
|
|
376
|
+
except ValueError:
|
|
377
|
+
return None
|
|
378
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def anchored_window(entries: Iterable[UsageEntry], reset_at: datetime) -> Window:
|
|
382
|
+
"""리셋 시각이 확실할 때, 그 창 [reset-5h, reset)만으로 집계한다.
|
|
383
|
+
|
|
384
|
+
블록을 이어붙이지 않으므로 보이지 않는 사용량 때문에 경계가 밀리는 문제가 없다.
|
|
385
|
+
"""
|
|
386
|
+
start = reset_at - WINDOW
|
|
387
|
+
return _window(start, [e for e in entries if start <= e.timestamp < reset_at])
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _block_start(ts: datetime) -> datetime:
|
|
391
|
+
if not FLOOR_TO_HOUR:
|
|
392
|
+
return ts
|
|
393
|
+
return ts.replace(minute=0, second=0, microsecond=0)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def build_windows(entries: Iterable[UsageEntry]) -> list[Window]:
|
|
397
|
+
"""시간순 엔트리를 5시간 블록으로 자른다.
|
|
398
|
+
|
|
399
|
+
블록은 첫 요청에서 시작해 정확히 5시간 지속한다. 그 안에 안 들어가는 첫 엔트리가
|
|
400
|
+
다음 블록을 연다. 유휴 시간에 대한 별도 규칙은 두지 않는다 — 5시간이 지나면
|
|
401
|
+
어차피 다음 엔트리가 새 블록을 열기 때문에 규칙을 더 만들 이유가 없다.
|
|
402
|
+
|
|
403
|
+
블록 시작은 FLOOR_TO_HOUR에 따라 정시로 내림한다(레퍼런스와 맞춤).
|
|
404
|
+
"""
|
|
405
|
+
windows: list[Window] = []
|
|
406
|
+
start: datetime | None = None
|
|
407
|
+
bucket: list[UsageEntry] = []
|
|
408
|
+
|
|
409
|
+
for e in sorted(entries, key=lambda x: x.timestamp):
|
|
410
|
+
if start is None or e.timestamp >= start + WINDOW:
|
|
411
|
+
if bucket:
|
|
412
|
+
windows.append(_window(start, bucket))
|
|
413
|
+
start, bucket = _block_start(e.timestamp), []
|
|
414
|
+
bucket.append(e)
|
|
415
|
+
if bucket:
|
|
416
|
+
windows.append(_window(start, bucket))
|
|
417
|
+
return windows
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def current_window(windows: Iterable[Window], now: datetime) -> Window | None:
|
|
421
|
+
"""지금 활성인 블록. 마지막 블록이 이미 만료됐으면 None (리셋된 상태)."""
|
|
422
|
+
for w in windows:
|
|
423
|
+
if w.is_active(now):
|
|
424
|
+
return w
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def burn_rate(
|
|
429
|
+
entries: Iterable[UsageEntry], now: datetime, span: timedelta = BURN_SPAN
|
|
430
|
+
) -> float:
|
|
431
|
+
"""최근 span(기본 1시간) 동안의 분당 토큰. 활성 세션 전부에서 모은다."""
|
|
432
|
+
since = now - span
|
|
433
|
+
recent = totals_of(e for e in entries if since <= e.timestamp <= now)
|
|
434
|
+
return recent.total_tokens / (span.total_seconds() / 60)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@dataclass(frozen=True, slots=True)
|
|
438
|
+
class Snapshot:
|
|
439
|
+
"""Phase 1이 내놓아야 하는 숫자 세 개."""
|
|
440
|
+
|
|
441
|
+
window: Window | None
|
|
442
|
+
tokens_per_minute: float
|
|
443
|
+
now: datetime
|
|
444
|
+
pinned: bool = False
|
|
445
|
+
"""리셋 시각이 확정값인가. False면 JSONL로 추정한 값이라 틀릴 수 있다."""
|
|
446
|
+
|
|
447
|
+
used_percentage: float | None = None
|
|
448
|
+
"""5시간 창 사용률(0~100). 공식 수치가 있을 때만 채워진다. 추정하지 않는다."""
|
|
449
|
+
|
|
450
|
+
official_source: str = "none"
|
|
451
|
+
"""사용률의 출처: "hook" | "app" | "none". 화면이 이유를 밝히는 데 쓴다."""
|
|
452
|
+
|
|
453
|
+
official_age_min: int | None = None
|
|
454
|
+
"""공식 사용률을 받아둔 지 몇 분 됐나. None이면 공식 수치가 없다는 뜻.
|
|
455
|
+
|
|
456
|
+
값이 크면 그 기기에서 Claude Code를 한동안 안 쓴 것이다 — 숫자는 계정
|
|
457
|
+
기준이라 맞지만 그 시점 기준이라, 다른 기기와 안 맞아 보이는 이유가 된다."""
|
|
458
|
+
|
|
459
|
+
weekly_percentage: float | None = None
|
|
460
|
+
weekly_reset_at: datetime | None = None
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def total_tokens(self) -> int:
|
|
464
|
+
return self.window.total_tokens if self.window else 0
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def time_to_reset(self) -> timedelta | None:
|
|
468
|
+
return self.window.time_to_reset(self.now) if self.window else None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def snapshot(entries: Iterable[UsageEntry], now: datetime | None = None) -> Snapshot:
|
|
472
|
+
"""창 하나와 그 안의 숫자들.
|
|
473
|
+
|
|
474
|
+
출처 우선순위는 여기 없다 — resolve_official이 **항목별로** 이미 골라뒀다.
|
|
475
|
+
여기서 남은 판단은 하나뿐이다: 리셋 시각을 아는가.
|
|
476
|
+
|
|
477
|
+
안다 그 창 [reset-5h, reset)만 센다. 블록을 이어붙이지 않으므로
|
|
478
|
+
보이지 않는 사용량 때문에 경계가 밀리지 않는다
|
|
479
|
+
모른다 JSONL 블록으로 추정한다. 웹·모바일 사용이 있으면 어긋난다
|
|
480
|
+
|
|
481
|
+
사용률은 이 판단과 무관하다. 창 경계를 몰라도 공식 사용률은 공식이다.
|
|
482
|
+
"""
|
|
483
|
+
now = now or datetime.now(timezone.utc)
|
|
484
|
+
entries = list(entries)
|
|
485
|
+
official = resolve_official(entries, now)
|
|
486
|
+
|
|
487
|
+
return Snapshot(
|
|
488
|
+
window=(
|
|
489
|
+
anchored_window(entries, official.reset_at)
|
|
490
|
+
if official.reset_at is not None
|
|
491
|
+
else current_window(build_windows(entries), now)
|
|
492
|
+
),
|
|
493
|
+
tokens_per_minute=burn_rate(entries, now),
|
|
494
|
+
now=now,
|
|
495
|
+
pinned=official.reset_exact,
|
|
496
|
+
used_percentage=official.used_percentage,
|
|
497
|
+
official_source=official.source,
|
|
498
|
+
official_age_min=(
|
|
499
|
+
None if official.captured_at is None
|
|
500
|
+
else max(0, int((now - official.captured_at).total_seconds() // 60))
|
|
501
|
+
),
|
|
502
|
+
weekly_percentage=official.weekly_percentage,
|
|
503
|
+
weekly_reset_at=official.weekly_reset_at,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _main() -> None:
|
|
508
|
+
import sys
|
|
509
|
+
from collections import Counter
|
|
510
|
+
|
|
511
|
+
# Windows 콘솔이 cp949라 한글/기호에서 죽는다. 숫자를 못 보면 의미가 없다.
|
|
512
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
513
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
514
|
+
|
|
515
|
+
entries = collect_entries()
|
|
516
|
+
snap = snapshot(entries)
|
|
517
|
+
prov = Counter(e.provenance for e in entries)
|
|
518
|
+
|
|
519
|
+
print(f"요청 {len(entries)}개, 윈도우 {len(build_windows(entries))}개")
|
|
520
|
+
print()
|
|
521
|
+
if snap.used_percentage is not None:
|
|
522
|
+
print(f"사용률 {snap.used_percentage:.0f}% (공식 수치)")
|
|
523
|
+
if snap.window is None:
|
|
524
|
+
print("활성 윈도우 없음 (마지막 블록이 이미 리셋됨)")
|
|
525
|
+
else:
|
|
526
|
+
w = snap.window
|
|
527
|
+
print(f"현재 윈도우 {w.start:%Y-%m-%d %H:%M} ~ {w.end:%H:%M} UTC, 요청 {w.entries}개")
|
|
528
|
+
print(f" input {w.input_tokens:>14,}")
|
|
529
|
+
print(f" output {w.output_tokens:>14,}")
|
|
530
|
+
print(f" cache_w {w.cache_creation_tokens:>14,}")
|
|
531
|
+
print(f" cache_r {w.cache_read_tokens:>14,}")
|
|
532
|
+
print(f" 합계 {w.total_tokens:>14,}")
|
|
533
|
+
rest = snap.time_to_reset
|
|
534
|
+
assert rest is not None
|
|
535
|
+
print(f"리셋까지 {int(rest.total_seconds() // 3600)}시간 "
|
|
536
|
+
f"{int(rest.total_seconds() % 3600 // 60)}분")
|
|
537
|
+
print(f"burn rate {snap.tokens_per_minute:,.0f} 토큰/분 (최근 1시간)")
|
|
538
|
+
|
|
539
|
+
# --- 주간 (공식 화면의 "주간 한도"에 대응) ---
|
|
540
|
+
now = snap.now
|
|
541
|
+
wk = weekly_totals(entries, now)
|
|
542
|
+
days_left = (weekly_end(now) - now)
|
|
543
|
+
print()
|
|
544
|
+
print(f"주간 {weekly_start(now).astimezone():%m-%d %H:%M} 부터, 요청 {wk.requests}개")
|
|
545
|
+
print(f" 조업량 {wk.catch:>14,} (input+output)")
|
|
546
|
+
print(f" 전체 {wk.total_tokens:>14,} (캐시 포함)")
|
|
547
|
+
print(f" 다음 리셋까지 {int(days_left.total_seconds() // 86400)}일 "
|
|
548
|
+
f"{int(days_left.total_seconds() % 86400 // 3600)}시간")
|
|
549
|
+
|
|
550
|
+
# --- 모델별 ---
|
|
551
|
+
print()
|
|
552
|
+
print("모델별 (조업량 기준)")
|
|
553
|
+
total_catch = sum(t.catch for _, t in model_breakdown(entries)) or 1
|
|
554
|
+
for model, t in model_breakdown(entries):
|
|
555
|
+
print(f" {model:<28}{t.catch:>12,} {100 * t.catch / total_catch:5.1f}% "
|
|
556
|
+
f"요청 {t.requests}")
|
|
557
|
+
|
|
558
|
+
# --- 전체 누적 ---
|
|
559
|
+
life = totals_of(entries)
|
|
560
|
+
print()
|
|
561
|
+
print(f"전체 누적 요청 {life.requests:,}개 · 조업량 {life.catch:,} · "
|
|
562
|
+
f"전체 {life.total_tokens:,}")
|
|
563
|
+
print()
|
|
564
|
+
print(f"provenance {dict(prov)}")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
if __name__ == "__main__":
|
|
568
|
+
_main()
|
tokenfishing/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""팝업에서 토글한 선택을 기억한다. 파일 하나, 키 두 개.
|
|
2
|
+
|
|
3
|
+
mode "catch" 쓸수록 화면이 채워진다
|
|
4
|
+
"depletion" 쓸수록 화면이 비어 간다
|
|
5
|
+
theme 화면 컨셉 키 ("fishing", "village", ...). themes 모듈 참고
|
|
6
|
+
fishing_spot 낚시 테마일 때만 쓰는 배경 키 ("sea", "pier", ...). 다른
|
|
7
|
+
테마에서는 무시된다 — 낚시로 돌아왔을 때를 위해 기억만 해 둔다.
|
|
8
|
+
lang 화면에 쓸 언어 "ko" | "en". 없으면 시스템 로케일을 따른다
|
|
9
|
+
learned_limit 공식 사용률에서 역산한 이 계정의 5시간 한도
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import i18n, themes
|
|
20
|
+
|
|
21
|
+
CONFIG_PATH = Path.home() / ".claude" / "tokenfishing-config.json"
|
|
22
|
+
|
|
23
|
+
CATCH = "catch"
|
|
24
|
+
DEPLETION = "depletion"
|
|
25
|
+
MODES = (CATCH, DEPLETION)
|
|
26
|
+
|
|
27
|
+
MODE_LABELS = {CATCH: "축적", DEPLETION: "고갈"}
|
|
28
|
+
|
|
29
|
+
MIN_PCT_TO_LEARN = 10.0
|
|
30
|
+
"""이 사용률 아래에서는 한도를 역산하지 않는다.
|
|
31
|
+
|
|
32
|
+
3%일 때 나눗셈을 하면 작은 오차가 크게 튄다. 어느 정도 찬 뒤에 재는 게 안정적이다."""
|
|
33
|
+
DEFAULTS = {
|
|
34
|
+
"mode": CATCH, "theme": themes.DEFAULT,
|
|
35
|
+
"fishing_spot": themes.DEFAULT_SPOT,
|
|
36
|
+
"lang": None, "learned_limit": None,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load() -> dict:
|
|
41
|
+
data = dict(DEFAULTS)
|
|
42
|
+
try:
|
|
43
|
+
saved = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
saved = {}
|
|
46
|
+
if isinstance(saved, dict):
|
|
47
|
+
if saved.get("mode") in MODES:
|
|
48
|
+
data["mode"] = saved["mode"]
|
|
49
|
+
if saved.get("theme") in themes.THEMES:
|
|
50
|
+
data["theme"] = saved["theme"]
|
|
51
|
+
if saved.get("fishing_spot") in themes.FISHING_SPOTS:
|
|
52
|
+
data["fishing_spot"] = saved["fishing_spot"]
|
|
53
|
+
if saved.get("lang") in i18n.CHOICES:
|
|
54
|
+
data["lang"] = saved["lang"]
|
|
55
|
+
limit = saved.get("learned_limit")
|
|
56
|
+
if isinstance(limit, (int, float)) and limit > 0:
|
|
57
|
+
data["learned_limit"] = int(limit)
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def learn_limit(settings: dict, catch: int, used_percentage: float | None) -> bool:
|
|
62
|
+
"""공식 사용률을 봤을 때 한도를 역산해 기억한다. 바뀌었으면 True.
|
|
63
|
+
|
|
64
|
+
표에 박아둔 근사치보다 이 값이 낫다 — 이 계정에서 실제로 관측된 값이고,
|
|
65
|
+
플랜을 고를 필요도 없다. 나중에 훅이 잠깐 끊겨도 마지막으로 배운 눈금으로
|
|
66
|
+
그릴 수 있다.
|
|
67
|
+
|
|
68
|
+
한도는 수요에 따라 움직이므로 마지막 관측으로 계속 덮어쓴다.
|
|
69
|
+
"""
|
|
70
|
+
if used_percentage is None or used_percentage < MIN_PCT_TO_LEARN or catch <= 0:
|
|
71
|
+
return False
|
|
72
|
+
limit = int(catch / (used_percentage / 100))
|
|
73
|
+
previous = settings.get("learned_limit")
|
|
74
|
+
if previous and abs(limit - previous) / previous < 0.02:
|
|
75
|
+
return False # 잔떨림으로 매번 파일을 쓰지 않는다
|
|
76
|
+
settings["learned_limit"] = limit
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save(data: dict) -> None:
|
|
81
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
fd, tmp = tempfile.mkstemp(dir=CONFIG_PATH.parent, suffix=".tmp")
|
|
83
|
+
try:
|
|
84
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
85
|
+
json.dump(data, f, ensure_ascii=False)
|
|
86
|
+
os.replace(tmp, CONFIG_PATH)
|
|
87
|
+
except BaseException:
|
|
88
|
+
Path(tmp).unlink(missing_ok=True)
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def next_in(values: tuple[str, ...], current: str) -> str:
|
|
93
|
+
"""토글용. 다음 값으로 돌린다."""
|
|
94
|
+
try:
|
|
95
|
+
return values[(values.index(current) + 1) % len(values)]
|
|
96
|
+
except ValueError:
|
|
97
|
+
return values[0]
|