Coverage for src/kimi_code_usage/main.py: 100%

657 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 20:05 +0800

1import argparse 

2import asyncio 

3import json 

4import os 

5import select as _select_module 

6import sys 

7 

8from dotenv import load_dotenv 

9from rich.console import Console, Group 

10from rich.live import Live 

11from rich.panel import Panel 

12from rich.text import Text 

13 

14from kimi_code_usage.config import AppConfig, ConfigResolver, save_theme 

15from kimi_code_usage.providers import DailyUsage, ProviderUsage, dispatch_all 

16 

17# --- i18n --- 

18LANG = os.getenv("LANG", "en") 

19IS_ZH = "zh" in LANG.lower() 

20 

21L_EN = { 

22 "title": "AI Quota Monitor", 

23 "weekly_limit": "Weekly Usage", 

24 "limit_fallback": "Limit", 

25 "remaining": "remaining", 

26 "countdown": "Countdown", 

27 "reset": "Reset", 

28 "no_data": "No usage data found or no providers configured.", 

29 "error_api": "API Error", 

30} 

31 

32L_ZH = { 

33 "title": "AI 用量配额监控", 

34 "weekly_limit": "周用量限额", 

35 "limit_fallback": "限额", 

36 "remaining": "剩余", 

37 "countdown": "重置倒计时", 

38 "reset": "重置时间", 

39 "no_data": "未找到用量数据,或未配置任何服务商。", 

40 "error_api": "API 错误", 

41} 

42 

43L = L_ZH if IS_ZH else L_EN 

44 

45def _get_visual_width(s: str) -> int: 

46 import unicodedata 

47 width = 0 

48 for char in s: 

49 if unicodedata.east_asian_width(char) in ("W", "F", "A"): 

50 width += 2 

51 else: 

52 width += 1 

53 return width 

54 

55def _get_localized_label(label: str, lang_zh: bool = IS_ZH) -> str: 

56 _L = L_ZH if lang_zh else L_EN 

57 

58 # 1. Dynamic translations first 

59 if lang_zh: 

60 translations = { 

61 "Credits": "额度", 

62 "Key Name": "密钥名称", 

63 "Rate Limit": "速率限制", 

64 "Usage": "周期已用", 

65 "周期已用": "周期已用", 

66 "Free Tier": "免费额度", 

67 "Limit Reset": "限额重置周期", 

68 "Expires At": "过期时间", 

69 "Is Provisioning": "配给密钥", 

70 "Management Key": "管理密钥", 

71 } 

72 if label in translations: 

73 return translations[label] 

74 else: 

75 translations = { 

76 "额度": "Credits", 

77 "密钥名称": "Key Name", 

78 "速率限制": "Rate Limit", 

79 "周期已用": "Usage", 

80 "Usage": "Usage", 

81 "免费额度": "Free Tier", 

82 "限额重置周期": "Limit Reset", 

83 "过期时间": "Expires At", 

84 "配给密钥": "Is Provisioning", 

85 "管理密钥": "Management Key", 

86 } 

87 if label in translations: 

88 return translations[label] 

89 

90 # 2. Standard rule replacements 

91 if label == "Weekly Usage": 

92 return _L["weekly_limit"] 

93 if "h Limit" in label: 

94 h = label.split("h")[0] 

95 return f"{h}小时限额" if lang_zh else label 

96 if "d Limit" in label: 

97 d = label.split("d")[0] 

98 return f"{d}天限额" if lang_zh else label 

99 if "mo Limit" in label: 

100 mo = label.split("mo")[0] 

101 return f"{mo}个月限额" if lang_zh else label 

102 if "m Limit" in label: 

103 m = label.split("m")[0] 

104 return f"{m}分钟限额" if lang_zh else label 

105 if "Limit" in label: 

106 return label.replace("Limit", _L["limit_fallback"]) 

107 return label 

108 

109 

110def _get_localized_text_value(text_val: str, lang_zh: bool) -> str: 

111 if not text_val: 

112 return text_val 

113 # OpenRouter period usage pattern 

114 if "Daily: $" in text_val or "今日: $" in text_val: 

115 import re 

116 floats = re.findall(r"\d+\.\d+", text_val) 

117 if len(floats) == 3: 

118 u_daily, u_weekly, u_monthly = float(floats[0]), float(floats[1]), float(floats[2]) 

119 if lang_zh: 

120 return f"今日: ${u_daily:.4f} | 本周: ${u_weekly:.4f} | 本月: ${u_monthly:.4f}" 

121 else: 

122 return f"Daily: ${u_daily:.4f} | Weekly: ${u_weekly:.4f} | Monthly: ${u_monthly:.4f}" 

123 

124 # Rate limit localization (e.g., Unlimited/10s <-> 无限制/10秒, 20 req/1s <-> 20次/1秒) 

125 if lang_zh: 

126 if text_val.startswith("Unlimited/"): 

127 interval = text_val.split("/", 1)[1] 

128 interval_zh = interval.replace("s", "秒").replace("m", "分钟").replace("h", "小时") 

129 return f"无限制/{interval_zh}" 

130 elif " req/" in text_val: 

131 reqs, interval = text_val.split(" req/", 1) 

132 interval_zh = interval.replace("s", "秒").replace("m", "分钟").replace("h", "小时") 

133 return f"{reqs}次/{interval_zh}" 

134 else: 

135 if text_val.startswith("无限制/"): 

136 interval = text_val.split("/", 1)[1] 

137 interval_en = interval.replace("秒", "s").replace("分钟", "m").replace("小时", "h") 

138 return f"Unlimited/{interval_en}" 

139 elif "次/" in text_val: 

140 reqs, interval = text_val.split("次/", 1) 

141 interval_en = interval.replace("秒", "s").replace("分钟", "m").replace("小时", "h") 

142 return f"{reqs} req/{interval_en}" 

143 

144 # Yes/No localization 

145 if lang_zh: 

146 if text_val == "Yes": 

147 return "是" 

148 if text_val == "No": 

149 return "否" 

150 else: 

151 if text_val == "是": 

152 return "Yes" 

153 if text_val == "否": 

154 return "No" 

155 

156 return text_val 

157 

158# (typing already imported at top) 

159 

160THEME_MAP = { 

161 # ── Classic Blue on Dark ── 

162 "blue-dark": { 

163 "title": "bold dodger_blue2", 

164 "label": "cornflower_blue", 

165 "meta": "grey62", 

166 "ok": "medium_spring_green", 

167 "warning": "gold1", 

168 "danger": "indian_red1", 

169 }, 

170 # ── Classic Blue on Light ── 

171 "blue-light": { 

172 "title": "bold blue", 

173 "label": "dark_blue", 

174 "meta": "grey35", 

175 "ok": "dark_green", 

176 "warning": "dark_orange", 

177 "danger": "red3", 

178 }, 

179 # ── Royal Sky Blue on Dark ── 

180 "sky-dark": { 

181 "title": "bold royal_blue1", 

182 "label": "sky_blue1", 

183 "meta": "grey58", 

184 "ok": "spring_green2", 

185 "warning": "orange1", 

186 "danger": "light_coral", 

187 }, 

188 # ── Salmon Saffron on Dark ── 

189 "salmon-dark": { 

190 "title": "bold light_salmon3", 

191 "label": "sandy_brown", 

192 "meta": "grey46", 

193 "ok": "dark_sea_green2", 

194 "warning": "gold3", 

195 "danger": "indian_red", 

196 }, 

197 # ── Turquoise Aquamarine on Dark ── 

198 "turquoise-dark": { 

199 "title": "bold turquoise2", 

200 "label": "medium_aquamarine", 

201 "meta": "grey53", 

202 "ok": "aquamarine1", 

203 "warning": "khaki1", 

204 "danger": "hot_pink", 

205 }, 

206 # ── Rose Pink on Light ── 

207 "pink-light": { 

208 "title": "bold deep_pink3", 

209 "label": "hot_pink3", 

210 "meta": "grey37", 

211 "ok": "dark_cyan", 

212 "warning": "dark_goldenrod", 

213 "danger": "red3", 

214 }, 

215 # ── Deep Purple on Dark ── 

216 "violet-dark": { 

217 "title": "bold medium_purple2", 

218 "label": "medium_purple1", 

219 "meta": "grey54", 

220 "ok": "spring_green3", 

221 "warning": "orange1", 

222 "danger": "plum1", 

223 }, 

224 # ── Warm Saffron Orange on Dark ── 

225 "amber-dark": { 

226 "title": "bold dark_orange", 

227 "label": "gold1", 

228 "meta": "grey54", 

229 "ok": "green_yellow", 

230 "warning": "gold3", 

231 "danger": "red1", 

232 }, 

233 # ── Mint Teal on Dark ── 

234 "mint-dark": { 

235 "title": "bold dark_cyan", 

236 "label": "light_sea_green", 

237 "meta": "grey54", 

238 "ok": "medium_spring_green", 

239 "warning": "khaki1", 

240 "danger": "indian_red1", 

241 }, 

242 # ── Monochrome ── 

243 "monochrome": { 

244 "title": "bold white", 

245 "label": "white", 

246 "meta": "grey46", 

247 "ok": "white", 

248 "warning": "grey74", 

249 "danger": "bold reverse", 

250 }, 

251 # ── Red-Green Blind Friendly ── 

252 "blind-deuteranopia": { 

253 "title": "bold dodger_blue1", 

254 "label": "cornflower_blue", 

255 "meta": "grey62", 

256 "ok": "dodger_blue1", 

257 "warning": "gold1", 

258 "danger": "dark_orange", 

259 }, 

260 # ── Blue-Yellow Blind Friendly ── 

261 "blind-tritanopia": { 

262 "title": "bold red1", 

263 "label": "light_coral", 

264 "meta": "grey62", 

265 "ok": "chartreuse3", 

266 "warning": "pale_green3", 

267 "danger": "bold red1", 

268 }, 

269} 

270 

271 

272def _handle_key(ch: str, idx: int, n: int) -> tuple[int, bool, bool, int | None, bool, bool, bool]: 

273 """Map a keypress to a TUI action. 

274 

275 Returns: 

276 (new_idx, should_quit, should_refresh, toggle_provider_num, lang_toggle, metric_toggle, days_toggle) 

277 """ 

278 if ch in ('q', 'Q', '\x03', '\x04'): # q Ctrl-C Ctrl-D 

279 return idx, True, False, None, False, False, False 

280 if ch in (']', 'n', '\t', '\x1b[C'): # ] n Tab → 

281 return (idx + 1) % n, False, False, None, False, False, False 

282 if ch in ('[', 'p', '\x1b[D'): # [ p ← 

283 return (idx - 1) % n, False, False, None, False, False, False 

284 if ch in ('r', 'R'): # r → refresh data 

285 return idx, False, True, None, False, False, False 

286 if ch in ('1', '2', '3', '4'): # 1-4 → toggle provider panel 

287 return idx, False, False, int(ch) - 1, False, False, False 

288 if ch in ('l', 'L'): # l → toggle language zh/en 

289 return idx, False, False, None, True, False, False 

290 if ch in ('m', 'M'): # m → toggle OpenRouter metric 

291 return idx, False, False, None, False, True, False 

292 if ch in ('d', 'D'): # d → toggle days window 

293 return idx, False, False, None, False, False, True 

294 return idx, False, False, None, False, False, False 

295 

296 

297def _format_tokens(n: float) -> str: 

298 if n >= 1_000_000: 

299 return f"{n/1_000_000:.1f}M" 

300 if n >= 1_000: 

301 return f"{n/1_000:.1f}K" 

302 return f"{n:.0f}" 

303 

304 

305def _model_short_name(model: str) -> str: 

306 if "/" in model: 

307 return model.rsplit("/", 1)[-1] 

308 return model 

309 

310 

311# --- OpenRouter metric helpers --- 

312OR_METRIC_SPEND = "spend" 

313OR_METRIC_REQUESTS = "requests" 

314OR_METRIC_TOKENS = "tokens" 

315VALID_OR_METRICS = (OR_METRIC_SPEND, OR_METRIC_REQUESTS, OR_METRIC_TOKENS) 

316 

317# --- OpenRouter days window helpers --- 

318DAYS_WINDOWS = (7, 14, 30, 60, 90) 

319 

320 

321def _parse_days_window(value) -> int: 

322 try: 

323 value = int(value) 

324 except (TypeError, ValueError): 

325 return 30 

326 return value if value in DAYS_WINDOWS else 30 

327 

328 

329def _next_days_window(days_window: int) -> int: 

330 try: 

331 idx = DAYS_WINDOWS.index(days_window) 

332 except ValueError: 

333 return 30 

334 return DAYS_WINDOWS[(idx + 1) % len(DAYS_WINDOWS)] 

335 

336 

337def _parse_or_metric(value: str | None) -> str: 

338 if value in VALID_OR_METRICS: 

339 return value 

340 return OR_METRIC_REQUESTS 

341 

342 

343def _next_or_metric(metric: str) -> str: 

344 order = (OR_METRIC_SPEND, OR_METRIC_REQUESTS, OR_METRIC_TOKENS) 

345 try: 

346 idx = order.index(metric) 

347 except ValueError: 

348 return OR_METRIC_REQUESTS 

349 return order[(idx + 1) % len(order)] 

350 

351 

352def _metric_label(metric: str, lang_zh: bool) -> str: 

353 labels = { 

354 OR_METRIC_SPEND: ("支出", "Spend"), 

355 OR_METRIC_REQUESTS: ("请求", "Requests"), 

356 OR_METRIC_TOKENS: ("Tokens", "Tokens"), 

357 } 

358 return labels.get(metric, labels[OR_METRIC_SPEND])[0 if lang_zh else 1] 

359 

360 

361def _metric_value_model(mu, metric: str) -> float: 

362 if metric == OR_METRIC_REQUESTS: 

363 return mu.requests 

364 if metric == OR_METRIC_TOKENS: 

365 return mu.prompt_tokens + mu.completion_tokens + mu.reasoning_tokens 

366 return mu.spend 

367 

368 

369def _metric_value_day(day, metric: str) -> float: 

370 if metric == OR_METRIC_SPEND: 

371 return day.total 

372 return sum(_metric_value_model(m, metric) for m in day.models) 

373 

374 

375def _format_metric_value(value: float, metric: str) -> str: 

376 if metric == OR_METRIC_SPEND: 

377 return f"${value:.2f}" 

378 if metric == OR_METRIC_TOKENS: 

379 return _format_tokens(value) 

380 return f"{value:,.0f}" 

381 

382 

383def _render_activity_totals(totals, lang_zh: bool, theme: dict) -> Text: 

384 text = Text() 

385 title = "活动" if lang_zh else "Activity" 

386 text.append(f" {title}\n", style=theme.get("label", "cornflower_blue")) 

387 parts = [] 

388 if totals.requests: 

389 req_label = "请求" if lang_zh else "Req" 

390 parts.append(f"{req_label}: {totals.requests:,.0f}") 

391 if totals.prompt_tokens or totals.completion_tokens: 

392 in_label = "输入" if lang_zh else "In" 

393 out_label = "输出" if lang_zh else "Out" 

394 tok = f"{in_label}: {_format_tokens(totals.prompt_tokens)} / {out_label}: {_format_tokens(totals.completion_tokens)}" 

395 if totals.reasoning_tokens: 

396 reason_label = "推理" if lang_zh else "reason" 

397 tok += f" (+ {_format_tokens(totals.reasoning_tokens)} {reason_label})" 

398 parts.append(tok) 

399 if totals.spend: 

400 spend_label = "支出" if lang_zh else "Spend" 

401 parts.append(f"{spend_label}: ${totals.spend:.2f}") 

402 text.append(" " + " | ".join(parts), style=theme.get("meta", "grey62")) 

403 return text 

404 

405 

406def _render_daily_chart(daily_activity, lang_zh: bool, theme: dict, metric: str = OR_METRIC_REQUESTS, days_window: int = 30) -> Text: 

407 if not daily_activity: 

408 return Text() 

409 metric = _parse_or_metric(metric) 

410 days_window = _parse_days_window(days_window) 

411 

412 from datetime import datetime, timedelta 

413 

414 # Build a contiguous date range of `days_window` days ending at the latest activity date. 

415 sorted_days = sorted(daily_activity, key=lambda d: d.date) 

416 latest_date = datetime.strptime(sorted_days[-1].date[:10], "%Y-%m-%d") 

417 start_date = latest_date - timedelta(days=days_window - 1) 

418 

419 day_lookup: dict[str, DailyUsage] = {} 

420 for d in sorted_days: 

421 key = d.date[:10] if len(d.date) >= 10 else d.date 

422 if key in day_lookup: 

423 existing = day_lookup[key] 

424 merged_models = existing.models + d.models 

425 day_lookup[key] = DailyUsage(date=key, models=merged_models, total=existing.total + d.total) 

426 else: 

427 day_lookup[key] = DailyUsage(date=key, models=list(d.models), total=d.total) 

428 

429 days: list[DailyUsage] = [] 

430 for i in range(days_window): 

431 date = (start_date + timedelta(days=i)).strftime("%Y-%m-%d") 

432 days.append(day_lookup.get(date, DailyUsage(date=date, models=[], total=0.0))) 

433 

434 # Chart dimensions inspired by tokentop: clamp column width to [1, 4] and 

435 # cap the total rendered width so the chart fits a typical terminal panel. 

436 height = 9 

437 top_models_limit = 6 

438 max_chart_width = 70 

439 col_width = 1 

440 if len(days) > 0: 

441 col_width = max_chart_width // len(days) 

442 if col_width > 4: 

443 col_width = 4 

444 if col_width < 1: 

445 col_width = 1 

446 # If the full window would exceed the max width, trim to the most recent days. 

447 if len(days) * col_width > max_chart_width: 

448 keep = max_chart_width // col_width 

449 days = days[-keep:] 

450 

451 max_total = max(_metric_value_day(d, metric) for d in days) or 1.0 

452 

453 # Find top models across all days for consistent coloring 

454 model_totals_all: dict[str, float] = {} 

455 for day in days: 

456 for model in day.models: 

457 model_totals_all[model.model] = model_totals_all.get(model.model, 0.0) + _metric_value_model(model, metric) 

458 top_models = _top_n_models(model_totals_all, top_models_limit) 

459 top_model_set = set(top_models) 

460 others_color_idx = len(top_models) % len(_model_colors(theme)) 

461 

462 # Pre-compute each column as color indices (bottom -> top) 

463 columns: list[list[int | None]] = [] 

464 for day in days: 

465 value_by_model: dict[str, float] = {} 

466 others_value = 0.0 

467 for model in day.models: 

468 v = _metric_value_model(model, metric) 

469 if model.model in top_model_set: 

470 value_by_model[model.model] = value_by_model.get(model.model, 0.0) + v 

471 else: 

472 others_value += v 

473 

474 segments: list[tuple[int, float]] = [] 

475 for i, name in enumerate(top_models): 

476 if v := value_by_model.get(name, 0.0): 

477 segments.append((i, v)) 

478 if others_value > 0: 

479 segments.append((others_color_idx, others_value)) 

480 

481 day_total = sum(v for _, v in segments) 

482 column: list[int | None] = [None] * height 

483 if day_total > 0 and max_total > 0: 

484 total_cells = max(1, min(height, int(round(day_total / max_total * height)))) 

485 exacts = [seg[1] / day_total * total_cells for seg in segments] 

486 counts = [int(x) for x in exacts] 

487 remainders = [exacts[i] - counts[i] for i in range(len(segments))] 

488 allocated = sum(counts) 

489 while allocated < total_cells: 

490 best = max(range(len(remainders)), key=lambda i: remainders[i]) 

491 counts[best] += 1 

492 remainders[best] = -1 

493 allocated += 1 

494 

495 cell_idx = 0 

496 for seg_idx, (cidx, _) in enumerate(segments): 

497 for _ in range(counts[seg_idx]): 

498 if cell_idx < height: 

499 column[cell_idx] = cidx 

500 cell_idx += 1 

501 columns.append(column) 

502 

503 text = Text() 

504 title_label = _metric_label(metric, lang_zh) 

505 title = f"每日{title_label}" if lang_zh else f"Daily {title_label.title()}" 

506 text.append(f" {title}\n\n", style=theme.get("label", "cornflower_blue")) 

507 

508 # Y-axis gutter 

509 y_labels = [max_total, max_total / 2, max_total / float(height)] 

510 y_label_strs = [_format_metric_value(v, metric) for v in y_labels] 

511 gutter_width = max(len(s) for s in y_label_strs) + 1 

512 gutter_fmt = f"{{:>{gutter_width}s}}" 

513 empty_gutter = " " * gutter_width 

514 # Indent the whole chart body to align with the title above 

515 chart_indent = " " 

516 

517 filled_glyph = "▐" + "█" * (col_width - 1) 

518 empty_glyph = " " * col_width 

519 

520 colors = _model_colors(theme) 

521 

522 # Render rows top -> bottom 

523 for row in range(height - 1, -1, -1): 

524 text.append(chart_indent, style=theme.get("meta", "grey62")) 

525 if row == height - 1: 

526 text.append(gutter_fmt.format(y_label_strs[0]), style=theme.get("meta", "grey62")) 

527 elif row == height // 2: 

528 text.append(gutter_fmt.format(y_label_strs[1]), style=theme.get("meta", "grey62")) 

529 elif row == 0: 

530 text.append(gutter_fmt.format(y_label_strs[2]), style=theme.get("meta", "grey62")) 

531 else: 

532 text.append(empty_gutter, style=theme.get("meta", "grey62")) 

533 

534 text.append("│", style=theme.get("meta", "grey62")) 

535 for col in columns: 

536 cidx = col[row] 

537 if cidx is not None: 

538 color = colors[cidx % len(colors)] 

539 text.append(filled_glyph, style=color) 

540 else: 

541 text.append(empty_glyph) 

542 text.append("\n") 

543 

544 # X-axis line 

545 text.append(chart_indent + empty_gutter + "└" + "─" * (col_width * len(days)) + "\n", style=theme.get("meta", "grey62")) 

546 

547 # Date labels: distribute evenly based on how many column slots a label 

548 # occupies. A date label is 5 chars; we want at least 2 chars of visual gap. 

549 if len(days) > 0: 

550 date_line = Text(chart_indent + empty_gutter + " ") 

551 label_len = len(short_date(days[0].date)) 

552 # Number of column slots needed per label (including desired gap) 

553 slot_gap = max(1, (label_len + 2 + col_width - 1) // col_width) 

554 

555 label_positions = set(range(0, len(days), slot_gap)) 

556 # Always include the last day if not already present and it fits. 

557 if len(days) > 1 and (len(days) - 1) % slot_gap != 0: 

558 label_positions.add(len(days) - 1) 

559 label_positions = sorted(label_positions) 

560 

561 max_line_chars = len(days) * col_width 

562 skip = 0 

563 for i, day in enumerate(days): 

564 if skip > 0: 

565 skip -= 1 

566 continue 

567 if i in label_positions: 

568 lbl = short_date(day.date) 

569 date_line.append(lbl, style=theme.get("meta", "grey62")) 

570 consumed_cols = max(1, (len(lbl) + col_width - 1) // col_width) 

571 # Pad to the column boundary, but never beyond the chart width 

572 current_pos = i * col_width + len(lbl) 

573 target_pos = min(i * col_width + consumed_cols * col_width, max_line_chars) 

574 padding = target_pos - current_pos 

575 if padding > 0: 

576 date_line.append(" " * padding) 

577 skip = consumed_cols - 1 

578 else: 

579 date_line.append(" " * col_width) 

580 text.append(date_line) 

581 text.append("\n") 

582 

583 # Legend 

584 if top_models: 

585 text.append("\n") 

586 text.append(" ", style=theme.get("meta", "grey62")) 

587 for i, model in enumerate(top_models[:8]): 

588 name = truncate(_model_short_name(model), 14) 

589 color = colors[i % len(colors)] 

590 text.append("█", style=color) 

591 text.append(f" {name} ", style=theme.get("meta", "grey62")) 

592 text.append("\n") 

593 

594 return text 

595 

596 

597def _model_colors(theme: dict) -> list[str]: 

598 return [ 

599 theme.get("ok", "medium_spring_green"), 

600 theme.get("warning", "gold1"), 

601 theme.get("danger", "indian_red1"), 

602 "dodger_blue1", 

603 "medium_purple1", 

604 "dark_orange", 

605 "turquoise2", 

606 "hot_pink", 

607 "spring_green2", 

608 "gold3", 

609 ] 

610 

611 

612def _top_n_models(model_totals: dict[str, float], n: int) -> list[str]: 

613 items = sorted(model_totals.items(), key=lambda x: (-x[1], x[0])) 

614 return [m for m, _ in items[:n]] 

615 

616 

617def short_date(date: str) -> str: 

618 if len(date) >= 10: 

619 return date[5:10] 

620 return date 

621 

622 

623def truncate(s: str, max_len: int) -> str: 

624 if len(s) > max_len: 

625 return s[:max_len - 3] + "..." 

626 return s 

627 

628 

629 

630def _render_top_models(top_models, lang_zh: bool, theme: dict, metric: str = OR_METRIC_REQUESTS, chart_width: int = 20) -> Text: 

631 if not top_models: 

632 return Text() 

633 metric = _parse_or_metric(metric) 

634 max_val = max(_metric_value_model(m, metric) for m in top_models) or 1.0 

635 text = Text() 

636 title = "Top 模型" if lang_zh else "Top Models" 

637 text.append(f" {title}\n", style=theme.get("label", "cornflower_blue")) 

638 colors = [ 

639 theme.get("ok", "medium_spring_green"), 

640 theme.get("warning", "gold1"), 

641 theme.get("danger", "indian_red1"), 

642 "dodger_blue1", 

643 "medium_purple1", 

644 "dark_orange", 

645 "turquoise2", 

646 ] 

647 for i, m in enumerate(top_models[:7]): 

648 val = _metric_value_model(m, metric) 

649 bar_len = int(val / max_val * chart_width) if max_val > 0 else 0 

650 name = _model_short_name(m.model) 

651 if len(name) > 22: 

652 name = name[:19] + "..." 

653 color = colors[i % len(colors)] 

654 text.append(f" {name:22} ", style=theme.get("meta", "grey62")) 

655 text.append(f"{_format_metric_value(val, metric):>8} ", style=theme.get("meta", "grey62")) 

656 text.append("█" * bar_len, style=color) 

657 text.append("░" * (chart_width - bar_len) + "\n", style="grey50") 

658 return text 

659 

660 

661 

662def _format_aggregated_results( 

663 results: dict[str, list[ProviderUsage]], 

664 errors: dict[str, str], 

665 order: list[str] | None = None, 

666 theme_name: str = "blue-dark", 

667 lang_zh: bool = IS_ZH, 

668 enabled_providers: set | None = None, 

669 or_metric: str = OR_METRIC_REQUESTS, 

670 days_window: int = 30, 

671) -> Text: 

672 _L = L_ZH if lang_zh else L_EN 

673 result = Text() 

674 if order is None: 

675 order = ["anthropic", "openai", "openrouter", "kimi"] 

676 

677 theme = THEME_MAP.get(theme_name) 

678 if not theme: 

679 theme = THEME_MAP["blue-dark"] 

680 

681 # First pass: pre-render all bodies and find global_max_width 

682 provider_bodies = {} 

683 error_msgs = {} 

684 global_max_width = 0 

685 

686 for p in order: 

687 p_items = results.get(p) 

688 if not p_items: 

689 continue 

690 

691 visual_widths = [_get_visual_width(_get_localized_label(r.label, lang_zh)) for r in p_items] 

692 # Include metadata labels if applicable to ensure correct alignment padding 

693 has_countdown = any(r.countdown for r in p_items) 

694 has_reset = any(r.reset_at for r in p_items) 

695 if has_countdown: 

696 visual_widths.append(_get_visual_width(_L['countdown'])) 

697 if has_reset: 

698 visual_widths.append(_get_visual_width(_L['reset'])) 

699 

700 max_visual_width = max(visual_widths) if visual_widths else 0 

701 max_visual_width = max(max_visual_width, 6) 

702 bar_width = 20 

703 

704 body_text = Text() 

705 for i, row in enumerate(p_items): 

706 if i > 0: 

707 body_text.append("\n") 

708 

709 has_structured = row.activity_totals or row.daily_activity or row.top_models 

710 

711 if has_structured: 

712 # Structured rows render only their visualizations, no generic label line 

713 if row.activity_totals: 

714 if i > 0: 

715 body_text.append("\n") 

716 body_text.append(_render_activity_totals(row.activity_totals, lang_zh, theme)) 

717 if row.daily_activity: 

718 if i > 0 or row.activity_totals: 

719 body_text.append("\n") 

720 body_text.append(_render_daily_chart(row.daily_activity, lang_zh, theme, metric=or_metric, days_window=days_window)) 

721 if row.top_models: 

722 if i > 0 or row.activity_totals or row.daily_activity: 

723 body_text.append("\n") 

724 body_text.append(_render_top_models(row.top_models, lang_zh, theme, metric=or_metric, chart_width=20)) 

725 continue 

726 

727 loc_label = _get_localized_label(row.label, lang_zh) 

728 label_v_width = _get_visual_width(loc_label) 

729 padding = " " * (max_visual_width - label_v_width) 

730 

731 body_text.append(f" {loc_label}{padding} ", style=theme["label"]) 

732 

733 if row.limit is not None and row.limit > 0: 

734 used_ratio = row.used / row.limit 

735 remaining_percent = max(0.0, 100.0 - (used_ratio * 100.0)) 

736 color = theme["danger"] if used_ratio > 0.9 else theme["warning"] if used_ratio > 0.7 else theme["ok"] 

737 filled = min(bar_width, int(used_ratio * bar_width)) 

738 

739 body_text.append("█" * filled, style=color) 

740 body_text.append("·" * (bar_width - filled), style="grey50") 

741 

742 if row.unit == "%": 

743 body_text.append(f" {used_ratio * 100:.0f}% {remaining_percent:.0f}% {_L['remaining']}", style="bold") 

744 elif row.unit == "$": 

745 body_text.append(f" ${row.used:.2f} / ${row.limit:.2f} ({remaining_percent:.0f}% {_L['remaining']})", style="bold") 

746 else: 

747 body_text.append(f" {row.used:,.0f} / {row.limit:,.0f} {row.unit} ({remaining_percent:.0f}% {_L['remaining']})", style="bold") 

748 else: 

749 if row.unit == "$": 

750 body_text.append(f" ${row.used:.2f}", style="bold") 

751 elif row.unit == "text": 

752 if row.text_value: 

753 loc_val = _get_localized_text_value(row.text_value, lang_zh) 

754 body_text.append(f" {loc_val}", style="bold") 

755 else: 

756 body_text.append(f" {row.used:,.0f} {row.unit}", style="bold") 

757 

758 if row.countdown: 

759 body_text.append("\n") 

760 meta_label = _L['countdown'] 

761 meta_padding = " " * (max_visual_width - _get_visual_width(meta_label)) 

762 body_text.append(f" {meta_label}{meta_padding} ", style=theme["label"]) 

763 body_text.append(row.countdown, style="bold") 

764 if row.reset_at: 

765 body_text.append("\n") 

766 meta_label = _L['reset'] 

767 meta_padding = " " * (max_visual_width - _get_visual_width(meta_label)) 

768 body_text.append(f" {meta_label}{meta_padding} ", style=theme["label"]) 

769 body_text.append(row.reset_at, style="bold") 

770 

771 provider_bodies[p] = body_text 

772 lines = body_text.plain.split("\n") 

773 max_line_width = max((_get_visual_width(line) for line in lines), default=0) 

774 if max_line_width > global_max_width: 

775 global_max_width = max_line_width 

776 

777 # 2. Process errors and unconfigured visible providers 

778 for p in order: 

779 if p in errors: 

780 err = errors[p] 

781 if err == "Not configured" and lang_zh: 

782 translated_err = "未配置" 

783 else: 

784 translated_err = err 

785 err_msg = f"{translated_err}" 

786 error_msgs[p] = err_msg 

787 err_width = _get_visual_width(err_msg) 

788 if err_width > global_max_width: 

789 global_max_width = err_width 

790 elif p not in results: 

791 if enabled_providers is not None and p not in enabled_providers: 

792 continue 

793 err_msg = " ⚠ 未配置" if lang_zh else " ⚠ Not configured" 

794 error_msgs[p] = err_msg 

795 err_width = _get_visual_width(err_msg) 

796 if err_width > global_max_width: 

797 global_max_width = err_width 

798 

799 max(50, global_max_width + 4) 

800 

801 # Second pass: construct final result with simple divider titles 

802 for p in order: 

803 body_text = provider_bodies.get(p) 

804 err_msg = error_msgs.get(p) 

805 

806 title_str = "Kimi" if p == "kimi" else p.capitalize() 

807 divider_line = f"───────── {title_str} ─────────" 

808 

809 if body_text: 

810 result.append(f"{divider_line}\n", style=theme["title"]) 

811 result.append("\n") # Empty line between divider and text 

812 result.append(body_text) 

813 

814 if err_msg: 

815 if not body_text: 

816 result.append(f"{divider_line}\n", style=theme["danger"]) 

817 result.append("\n") # Empty line between divider and error 

818 else: 

819 result.append("\n\n") # Empty line between body and error 

820 result.append(f"{err_msg}", style=theme["danger"]) 

821 

822 result.append("\n\n") # Empty line below provider section 

823 

824 return result 

825 

826 

827async def _interactive_mode(config: "AppConfig", initial_theme: str) -> None: 

828 """Live Rich TUI with keyboard theme cycling and provider panel toggling. 

829 

830 Keys: 

831 ←/→ or [ / ] — cycle theme 

832 1 / 2 / 3 / 4 — toggle provider panels 

833 r — refresh data 

834 q / Ctrl-C — quit 

835 """ 

836 import termios 

837 import tty 

838 

839 themes = list(THEME_MAP.keys()) 

840 try: 

841 idx = themes.index(initial_theme) 

842 except ValueError: 

843 idx = 0 

844 

845 results: dict[str, list[ProviderUsage]] = {} 

846 errors: dict[str, str] = {} 

847 

848 # Determine initial visible providers and language 

849 if config.visible_providers is not None: 

850 visible_providers: set = {p for p in config.visible_providers if p in config.provider_order} 

851 else: 

852 visible_providers: set = set(config.provider_order) 

853 

854 if config.language == "zh": 

855 lang_zh: bool = True 

856 elif config.language == "en": 

857 lang_zh: bool = False 

858 else: 

859 lang_zh: bool = IS_ZH 

860 

861 or_metric: str = _parse_or_metric(config.or_metric) 

862 days_window: int = _parse_days_window(config.days_window) 

863 

864 saved_notice: list = [None] # holds the saved theme name briefly, then None 

865 

866 _SHORT = { 

867 "anthropic": "Anthropic", "openai": "Openai", 

868 "openrouter": "Openrouter", "kimi": "Kimi", 

869 } 

870 

871 def _build_panel() -> Panel: 

872 visible_order = [p for p in config.provider_order if p in visible_providers] 

873 _L = L_ZH if lang_zh else L_EN 

874 body = _format_aggregated_results(results, errors, visible_order, themes[idx], lang_zh, enabled_providers=set(config.enabled_providers), or_metric=or_metric, days_window=days_window) 

875 

876 top_bar = Text() 

877 # Provider toggles 

878 for i, p in enumerate(config.provider_order, 1): 

879 short = _SHORT.get(p, p[:4].title()) 

880 if p in visible_providers: 

881 top_bar.append("● ", style="bold") 

882 top_bar.append(f"[{i}]{short} ", style="bold") 

883 else: 

884 top_bar.append("○ ", style="dim") 

885 top_bar.append(f"[{i}]{short} ", style="dim italic") 

886 # Theme name 

887 top_bar.append("│ ", style="dim") 

888 top_bar.append("主题: " if lang_zh else "theme: ", style="dim") 

889 top_bar.append(themes[idx], style="bold") 

890 top_bar.justify = "center" 

891 

892 hint = Text() 

893 # Keybindings grouped logically: navigation / view / data / persistence 

894 bindings = [ 

895 ("[q]", "退出" if lang_zh else "quit"), 

896 ("[r]", "刷新" if lang_zh else "refresh"), 

897 ("[←/→]", "主题" if lang_zh else "theme"), 

898 ("[1-4]", "面板" if lang_zh else "panels"), 

899 ("[l]", "英" if lang_zh else "ZH"), 

900 ("[m]", _metric_label(or_metric, lang_zh)), 

901 ("[d]", f"{days_window}d"), 

902 ("[⏎ ]", "保存" if lang_zh else "save"), 

903 ] 

904 for i, (key, label) in enumerate(bindings): 

905 if i > 0: 

906 hint.append(" ", style="dim") 

907 hint.append(key, style="bold") 

908 hint.append(f" {label}", style="dim") 

909 if saved_notice[0]: 

910 hint.append(f"{'已保存' if lang_zh else 'Saved'}: {saved_notice[0]}", style="bold green") 

911 hint.justify = "center" 

912 

913 panel_content = Group( 

914 top_bar, 

915 Text(""), 

916 body, 

917 hint 

918 ) 

919 

920 return Panel(panel_content, title=f"[bold]{_L['title']}[/bold]", subtitle=None, 

921 expand=True, padding=(1, 2, 1, 2)) 

922 

923 fd = sys.stdin.fileno() 

924 old_settings = termios.tcgetattr(fd) 

925 

926 def _read_char() -> str: 

927 # Prevent input buffering split by using os.read in raw terminal; fall back to stdin.read under tests 

928 is_mock = hasattr(sys.stdin.read, "mock") or hasattr(sys.stdin.read, "_mock_self") or "mock" in type(sys.stdin.read).__name__.lower() 

929 if not is_mock and hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): # pragma: no cover 

930 import os 

931 try: 

932 b = os.read(fd, 1) 

933 if b: 

934 return b.decode('utf-8', errors='ignore') 

935 except Exception: 

936 pass 

937 return sys.stdin.read(1) 

938 

939 try: 

940 tty.setcbreak(fd) 

941 results, errors = await dispatch_all(config) 

942 console = Console() 

943 with Live(_build_panel(), console=console, refresh_per_second=4) as live: 

944 running = True 

945 while running: 

946 rlist, _, _ = _select_module.select([sys.stdin], [], [], 0.15) 

947 if rlist: 

948 ch = _read_char() 

949 if not ch: # pragma: no cover 

950 continue 

951 

952 if ch == '\x1b': 

953 r2, _, _ = _select_module.select([sys.stdin], [], [], 0.05) 

954 if r2: 

955 ch2 = _read_char() 

956 if ch2 == '[': 

957 r3, _, _ = _select_module.select([sys.stdin], [], [], 0.05) 

958 if r3: 

959 ch3 = _read_char() 

960 ch = f'\x1b[{ch3}' 

961 

962 if saved_notice[0]: # clear notice on any new keypress 

963 saved_notice[0] = None 

964 if ch in ('\r', '\n'): # Enter → save settings to config file 

965 save_theme( 

966 themes[idx], 

967 language="zh" if lang_zh else "en", 

968 visible_providers=list(visible_providers), 

969 or_metric=or_metric, 

970 days_window=days_window 

971 ) 

972 saved_notice[0] = themes[idx] 

973 live.update(_build_panel()) 

974 # Flush any consecutive \r or \n (like \r\n from terminal) 

975 while True: 

976 r_extra, _, _ = _select_module.select([sys.stdin], [], [], 0.0) 

977 if r_extra: # pragma: no cover 

978 extra_ch = _read_char() 

979 if extra_ch in ('\r', '\n'): # pragma: no cover 

980 continue 

981 break 

982 continue 

983 idx, quit_flag, refresh_flag, toggle_num, lang_toggle, metric_toggle, days_toggle = _handle_key(ch, idx, len(themes)) 

984 if quit_flag: 

985 running = False 

986 elif refresh_flag: 

987 results, errors = await dispatch_all(config) 

988 elif toggle_num is not None: 

989 providers = list(config.provider_order) 

990 if toggle_num < len(providers): 

991 p = providers[toggle_num] 

992 if p in visible_providers: 

993 visible_providers.discard(p) 

994 else: 

995 visible_providers.add(p) 

996 elif lang_toggle: 

997 lang_zh = not lang_zh 

998 elif metric_toggle: 

999 or_metric = _next_or_metric(or_metric) 

1000 elif days_toggle: 

1001 days_window = _next_days_window(days_window) 

1002 live.update(_build_panel()) 

1003 await asyncio.sleep(0) 

1004 finally: 

1005 termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) 

1006 

1007 

1008async def main(): 

1009 load_dotenv() 

1010 parser = argparse.ArgumentParser(description="Multi-Provider AI Quota Tracker CLI") 

1011 parser.add_argument("--json", action="store_true") 

1012 parser.add_argument("--plain", action="store_true") 

1013 parser.add_argument("-i", "--interactive", action="store_true", 

1014 help="Interactive mode: ←/→ or [/] to cycle themes, r refresh, q quit") 

1015 parser.add_argument("--provider", help="Comma-separated providers to query (kimi,openai,anthropic,openrouter)") 

1016 parser.add_argument("--config", help="Custom configuration file path") 

1017 parser.add_argument("--theme", help="Specify color theme: blue-dark, blue-light, sky-dark, salmon-dark, turquoise-dark, pink-light, violet-dark, amber-dark, mint-dark, monochrome, blind-deuteranopia, blind-tritanopia") 

1018 args = parser.parse_args() 

1019 

1020 resolver = ConfigResolver(config_path=args.config) 

1021 config = resolver.resolve() 

1022 

1023 # Determine theme 

1024 theme_name = args.theme if args.theme else config.theme 

1025 

1026 # If --provider filter is provided, override enabled providers 

1027 allowed_providers = None 

1028 if args.provider: 

1029 allowed_providers = [p.strip().lower() for p in args.provider.split(",") if p.strip()] 

1030 # Filter config providers 

1031 for p in list(config.providers.keys()): 

1032 if p not in allowed_providers: 

1033 config.providers[p].api_key = None 

1034 

1035 # Interactive mode: hand off to TUI loop (handles its own dispatch) 

1036 if args.interactive: 

1037 await _interactive_mode(config, theme_name) 

1038 return 

1039 

1040 results, errors = await dispatch_all(config) 

1041 

1042 # Filter outputs 

1043 if allowed_providers: 

1044 results = {k: v for k, v in results.items() if k in allowed_providers} 

1045 errors = {k: v for k, v in errors.items() if k in allowed_providers} 

1046 

1047 if args.json: 

1048 output_data = {} 

1049 for p, items in results.items(): 

1050 output_data[p] = [ 

1051 { 

1052 "label": item.label, 

1053 "used": item.used, 

1054 "limit": item.limit, 

1055 "remaining": item.remaining, 

1056 "percent": item.percent, 

1057 "reset_at": item.reset_at, 

1058 "unit": item.unit 

1059 } 

1060 for item in items 

1061 ] 

1062 # Include errors in JSON if any 

1063 if errors: 

1064 output_data["errors"] = errors 

1065 print(json.dumps(output_data, ensure_ascii=False)) 

1066 elif args.plain: 

1067 if not results and not errors: 

1068 print(L["no_data"]) 

1069 else: 

1070 for p in config.provider_order: 

1071 items = results.get(p) 

1072 if items: 

1073 for item in items: 

1074 if item.limit is not None and item.limit > 0: 

1075 pct_used = item.used / item.limit * 100 

1076 print(f"{p.capitalize()} - {item.label}: {item.used}/{item.limit} ({pct_used:.0f}% used)") 

1077 else: 

1078 if item.unit == "text": 

1079 if item.text_value: 

1080 print(f"{p.capitalize()} - {item.label}: {item.text_value}") 

1081 else: 

1082 print(f"{p.capitalize()} - {item.label}") 

1083 else: 

1084 print(f"{p.capitalize()} - {item.label}: {item.used} ({item.unit})") 

1085 for p in config.provider_order: 

1086 err = errors.get(p) 

1087 if err: 

1088 print(f"{p.capitalize()} - Error: {err}", file=sys.stderr) 

1089 else: 

1090 console = Console() 

1091 if not results and not errors: 

1092 console.print(Panel(Text(L["no_data"], style="dim"), title=f"[bold]{L['title']}[/bold]")) 

1093 else: 

1094 display_order = config.provider_order 

1095 if config.visible_providers is not None: 

1096 display_order = [p for p in config.provider_order if p in config.visible_providers] 

1097 or_metric = _parse_or_metric(config.or_metric) 

1098 days_window = _parse_days_window(config.days_window) 

1099 console.print(Panel(_format_aggregated_results(results, errors, display_order, theme_name, enabled_providers=set(config.enabled_providers), or_metric=or_metric, days_window=days_window), title=f"[bold]{L['title']}[/bold]", expand=True, padding=(1, 2, 1, 2))) 

1100 

1101def run_cli(): 

1102 asyncio.run(main()) 

1103 

1104if __name__ == "__main__": 

1105 run_cli()