myccusage 1.2.1__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: myccusage
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Multi-Agent session usage analytics and DeepSeek-V4.1-Flash cost estimator
5
5
  Author-email: Richard Huang <RichardHuang0001@users.noreply.github.com>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: myccusage
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Multi-Agent session usage analytics and DeepSeek-V4.1-Flash cost estimator
5
5
  Author-email: Richard Huang <RichardHuang0001@users.noreply.github.com>
6
6
  License: MIT License
@@ -2,4 +2,4 @@
2
2
  myccusage: 多 AI 编程 Agent 本地会话用量分析与 DeepSeek-V4.1-Flash 等效计费工具。
3
3
  """
4
4
 
5
- __version__ = "1.2.1"
5
+ __version__ = "1.2.2"
@@ -14,6 +14,8 @@ import glob
14
14
  import sys
15
15
  import sqlite3
16
16
  import os
17
+ import time
18
+ import threading
17
19
  import shutil
18
20
  import concurrent.futures
19
21
  from datetime import datetime, timezone
@@ -21,6 +23,16 @@ from collections import OrderedDict
21
23
 
22
24
  WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"]
23
25
 
26
+ _AGENT_LOCKS = {}
27
+ _AGENT_LOCKS_MUTEX = threading.Lock()
28
+
29
+ def get_agent_lock(agent_type):
30
+ """获取指定 Agent 专用的互斥锁,避免同一 Agent 多个进程并发竞争本地 SQLite 数据库"""
31
+ with _AGENT_LOCKS_MUTEX:
32
+ if agent_type not in _AGENT_LOCKS:
33
+ _AGENT_LOCKS[agent_type] = threading.Lock()
34
+ return _AGENT_LOCKS[agent_type]
35
+
24
36
  SUPPORTED_AGENTS = {
25
37
  "agy": {"name": "Google Antigravity", "subcmd": "antigravity", "has_times": False},
26
38
  "claude": {"name": "Claude Code", "subcmd": "claude", "has_times": False},
@@ -388,28 +400,43 @@ def check_ccusage_installed():
388
400
  "安装完成后重新运行当前命令即可。"
389
401
  )
390
402
 
391
- def run_ccusage(args, capture_output=True, text=True):
403
+ def run_ccusage(args, capture_output=True, text=True, max_retries=3):
392
404
  """
393
405
  统一安全调用底层 ccusage CLI:
394
406
  - 优先追加 --offline 参数阻断冗余公网 LiteLLM 模型价格拉取 (提速 10x+)
407
+ - 若遇到 database is locked 错误,支持毫秒级退避重试 (解决 SQLite 读写瞬态争抢)
395
408
  - 若旧版本不支持 --offline 则自动优雅降级回退执行
396
409
  """
397
410
  check_ccusage_installed()
398
411
  cmd = ["ccusage"] + list(args)
399
412
  if "--offline" not in cmd:
400
413
  cmd.append("--offline")
401
- res = subprocess.run(cmd, capture_output=capture_output, text=text)
402
- if res.returncode != 0 and ("unknown" in (res.stderr or "").lower() or "unexpected" in (res.stderr or "").lower()):
403
- # 兼容旧版本 ccusage 不识别 --offline 的极端场景
404
- cmd_fallback = [arg for arg in cmd if arg != "--offline"]
405
- res = subprocess.run(cmd_fallback, capture_output=capture_output, text=text)
414
+
415
+ for attempt in range(max_retries):
416
+ res = subprocess.run(cmd, capture_output=capture_output, text=text)
417
+ stderr_lower = (res.stderr or "").lower()
418
+
419
+ # 检查是否为 SQLite 瞬态锁冲突,毫秒级退避重试
420
+ if res.returncode != 0 and ("database is locked" in stderr_lower or "code 5" in stderr_lower):
421
+ if attempt < max_retries - 1:
422
+ time.sleep(0.3 * (attempt + 1))
423
+ continue
424
+
425
+ # 检查是否为旧版本不识别 --offline
426
+ if res.returncode != 0 and ("unknown" in stderr_lower or "unexpected" in stderr_lower):
427
+ cmd_fallback = [arg for arg in cmd if arg != "--offline"]
428
+ res = subprocess.run(cmd_fallback, capture_output=capture_output, text=text)
429
+
430
+ return res
431
+
406
432
  return res
407
433
 
408
434
  def fetch_single_day_sessions(ccusage_subcmd, date_str, times_override):
409
435
  """获取指定日期的精确切片会话消耗(不含历史前日累积)"""
410
436
  res = run_ccusage([ccusage_subcmd, "session", "-s", date_str, "-u", date_str, "--json"])
411
437
  if res.returncode != 0:
412
- return []
438
+ # 失败时返回 None 而非 [],严格区分“提取错误”与“该日无数据”,防止缓存污染
439
+ return None
413
440
  try:
414
441
  data = json.loads(res.stdout)
415
442
  sessions = data.get("sessions", [])
@@ -419,7 +446,7 @@ def fetch_single_day_sessions(ccusage_subcmd, date_str, times_override):
419
446
  s["lastActivity"] = times_override[sid]
420
447
  return sessions
421
448
  except Exception:
422
- return []
449
+ return None
423
450
 
424
451
  def get_daily_data(agent_type, sort_by_tokens=False, force_refresh=False):
425
452
  """
@@ -434,71 +461,69 @@ def get_daily_data(agent_type, sort_by_tokens=False, force_refresh=False):
434
461
  ccusage_subcmd = info["subcmd"]
435
462
  titles, times_override = get_agent_metadata(agent_type)
436
463
 
437
- # 1. 获取活动日基准 (带 --offline 阻断网络挂起)
438
- res_daily = run_ccusage([ccusage_subcmd, "daily", "--json"])
439
- if res_daily.returncode != 0:
440
- raise RuntimeError(f"执行 ccusage {ccusage_subcmd} daily 失败: {res_daily.stderr}")
464
+ agent_lock = get_agent_lock(agent_type)
465
+ with agent_lock:
466
+ # 1. 获取活动日基准 (带 --offline 阻断网络挂起)
467
+ res_daily = run_ccusage([ccusage_subcmd, "daily", "--json"])
468
+ if res_daily.returncode != 0:
469
+ raise RuntimeError(f"执行 ccusage {ccusage_subcmd} daily 失败: {res_daily.stderr}")
441
470
 
442
- try:
443
- daily_json = json.loads(res_daily.stdout)
444
- except Exception as e:
445
- raise RuntimeError(f"无法解析 ccusage {ccusage_subcmd} daily 输出: {e}")
446
-
447
- daily_list = daily_json.get("daily", [])
448
- active_days = [d.get("date") for d in daily_list if d.get("date")]
449
- active_days.sort()
471
+ try:
472
+ daily_json = json.loads(res_daily.stdout)
473
+ except Exception as e:
474
+ raise RuntimeError(f"无法解析 ccusage {ccusage_subcmd} daily 输出: {e}")
450
475
 
451
- today_str = datetime.now().strftime("%Y-%m-%d")
476
+ daily_list = daily_json.get("daily", [])
477
+ active_days = [d.get("date") for d in daily_list if d.get("date")]
478
+ active_days.sort()
479
+ daily_tokens_map = {d.get("date"): d.get("totalTokens", 0) for d in daily_list if d.get("date")}
452
480
 
453
- # 2. 读取/写入本地缓存
454
- cache_dir = os.path.expanduser("~/.cache/myccusage")
455
- os.makedirs(cache_dir, exist_ok=True)
456
- cache_file = os.path.join(cache_dir, f"{agent_type}_daily.json")
481
+ today_str = datetime.now().strftime("%Y-%m-%d")
457
482
 
458
- cache = {}
459
- if not force_refresh and os.path.exists(cache_file):
460
- try:
461
- with open(cache_file, "r", encoding="utf-8") as f:
462
- cache = json.load(f)
463
- except Exception:
464
- cache = {}
465
-
466
- cache_dirty = force_refresh
467
- day_sessions_map = OrderedDict()
468
-
469
- # 识别未在缓存中的历史天数 (若有则并发拉取,避免冷启动单线程逐天挂起)
470
- uncached_history_days = [d for d in active_days if d != today_str and d not in cache]
471
- if uncached_history_days:
472
- max_workers = min(6, len(uncached_history_days))
473
- with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
474
- future_to_day = {
475
- pool.submit(fetch_single_day_sessions, ccusage_subcmd, d, times_override): d
476
- for d in uncached_history_days
477
- }
478
- for fut in concurrent.futures.as_completed(future_to_day):
479
- d = future_to_day[fut]
480
- try:
481
- cache[d] = fut.result()
482
- cache_dirty = True
483
- except Exception:
484
- cache[d] = []
483
+ # 2. 读取/写入本地缓存
484
+ cache_dir = os.path.expanduser("~/.cache/myccusage")
485
+ os.makedirs(cache_dir, exist_ok=True)
486
+ cache_file = os.path.join(cache_dir, f"{agent_type}_daily.json")
485
487
 
486
- for d_str in active_days:
487
- # 历史已结账日使用缓存,仅今日实时切片拉取 (秒级响应)
488
- if d_str != today_str and d_str in cache:
489
- day_sessions_map[d_str] = cache[d_str]
490
- elif d_str == today_str:
491
- s_list = fetch_single_day_sessions(ccusage_subcmd, d_str, times_override)
492
- day_sessions_map[d_str] = s_list
493
- else:
494
- day_sessions_map[d_str] = cache.get(d_str, [])
495
-
496
- if cache_dirty:
497
- try:
498
- with open(cache_file, "w", encoding="utf-8") as f:
499
- json.dump(cache, f)
500
- except Exception:
501
- pass
488
+ cache = {}
489
+ if os.path.exists(cache_file):
490
+ try:
491
+ with open(cache_file, "r", encoding="utf-8") as f:
492
+ cache = json.load(f)
493
+ except Exception:
494
+ cache = {}
495
+
496
+ cache_dirty = False
497
+ day_sessions_map = OrderedDict()
498
+
499
+ # 识别缺失或先前被污染(有Token却被记录为空列表)的历史天数,顺序安全拉取修复
500
+ uncached_history_days = [
501
+ d for d in active_days
502
+ if d != today_str and (d not in cache or (daily_tokens_map.get(d, 0) > 0 and len(cache.get(d, [])) == 0))
503
+ ]
504
+ for d in uncached_history_days:
505
+ s_list = fetch_single_day_sessions(ccusage_subcmd, d, times_override)
506
+ if s_list is not None:
507
+ cache[d] = s_list
508
+ cache_dirty = True
509
+
510
+ for d_str in active_days:
511
+ if d_str != today_str:
512
+ day_sessions_map[d_str] = cache.get(d_str, [])
513
+ else:
514
+ # 仅今日调用实时切片抓取
515
+ s_list = fetch_single_day_sessions(ccusage_subcmd, d_str, times_override)
516
+ if s_list is not None:
517
+ day_sessions_map[today_str] = s_list
518
+ else:
519
+ day_sessions_map[today_str] = cache.get(today_str, [])
520
+
521
+ if cache_dirty:
522
+ try:
523
+ with open(cache_file, "w", encoding="utf-8") as f:
524
+ json.dump(cache, f)
525
+ except Exception:
526
+ pass
502
527
 
503
528
  # 3. 统计与分层聚合
504
529
  grand_total = 0
@@ -664,18 +689,20 @@ def get_session_data(agent_type, sort_by_tokens=False, clean_args=None):
664
689
  ccusage_subcmd = info["subcmd"]
665
690
  titles, times_override = get_agent_metadata(agent_type)
666
691
 
667
- sub_args = [ccusage_subcmd, "session", "--json"]
668
- if clean_args:
669
- sub_args.extend(clean_args)
692
+ agent_lock = get_agent_lock(agent_type)
693
+ with agent_lock:
694
+ sub_args = [ccusage_subcmd, "session", "--json"]
695
+ if clean_args:
696
+ sub_args.extend(clean_args)
670
697
 
671
- res = run_ccusage(sub_args)
672
- if res.returncode != 0:
673
- raise RuntimeError(f"执行 ccusage {ccusage_subcmd} session 失败: {res.stderr}")
698
+ res = run_ccusage(sub_args)
699
+ if res.returncode != 0:
700
+ raise RuntimeError(f"执行 ccusage {ccusage_subcmd} session 失败: {res.stderr}")
674
701
 
675
- try:
676
- usage_data = json.loads(res.stdout)
677
- except Exception as e:
678
- raise RuntimeError(f"无法解析 ccusage {ccusage_subcmd} 输出: {e}")
702
+ try:
703
+ usage_data = json.loads(res.stdout)
704
+ except Exception as e:
705
+ raise RuntimeError(f"无法解析 ccusage {ccusage_subcmd} 输出: {e}")
679
706
 
680
707
  raw_sessions = usage_data.get("sessions", [])
681
708
  sessions = []
@@ -517,6 +517,19 @@
517
517
  });
518
518
  }
519
519
 
520
+ // 统一计价响应调度中心:模型切换或费率更新后,全局同步刷新当前激活视图的所有价格展示
521
+ function updateAllPricingDisplays() {
522
+ if (state.agent === 'all') {
523
+ if (state.allAgentsData) {
524
+ renderAllAgentsOverview(state.allAgentsData);
525
+ }
526
+ } else {
527
+ if (state.data) {
528
+ renderDashboard(state.data);
529
+ }
530
+ }
531
+ }
532
+
520
533
  function setPricingModel(modelId, notify = false) {
521
534
  const allModels = getAllPricingModels();
522
535
  if (!allModels[modelId]) {
@@ -536,12 +549,8 @@
536
549
  showToast(`已选择计价模型: ${model.name}`);
537
550
  }
538
551
 
539
- // 动态全站重算 (即时响应,零冗余开销)
540
- if (state.data) {
541
- renderDashboard(state.data);
542
- } else if (state.allAgentsData) {
543
- renderAllAgentsOverview(state.allAgentsData);
544
- }
552
+ // 动态全站重算 (即时响应,调度计价中心)
553
+ updateAllPricingDisplays();
545
554
  }
546
555
 
547
556
  // ------------------------------------------------------------------------
@@ -687,8 +696,7 @@
687
696
 
688
697
  if (state.pricingModel === currentId) {
689
698
  el.selectedPricingName.textContent = updatedModel.name;
690
- if (state.data) renderDashboard(state.data);
691
- else if (state.allAgentsData) renderAllAgentsOverview(state.allAgentsData);
699
+ updateAllPricingDisplays();
692
700
  }
693
701
 
694
702
  showToast(`✅ 已保存模型修改: ${updatedModel.name}`);
@@ -756,8 +764,7 @@
756
764
  const active = getActiveModel();
757
765
  renderManagerDetail(active.id);
758
766
  renderPricingDropdownMenu();
759
- if (state.data) renderDashboard(state.data);
760
- else if (state.allAgentsData) renderAllAgentsOverview(state.allAgentsData);
767
+ updateAllPricingDisplays();
761
768
  showToast('✨ 已恢复所有系统默认预设模型');
762
769
  }
763
770
 
@@ -1506,6 +1513,11 @@
1506
1513
  el.subTotalTokens.textContent = `全平台 7 大 Agent 累计消耗`;
1507
1514
  el.valTotalCost.textContent = formatKpiMainCost(model, grandCost);
1508
1515
  el.valTotalCostUsd.textContent = formatKpiSubCost(model, grandCost);
1516
+
1517
+ const costBadge = document.querySelector('.highlight-card .kpi-badge');
1518
+ if (costBadge) costBadge.textContent = model.name;
1519
+ const costTitle = document.querySelector('.highlight-card .kpi-title');
1520
+ if (costTitle) costTitle.textContent = `${model.name} 等效费用`;
1509
1521
  const hitRate = (gs.cacheHitRate !== undefined && gs.cacheHitRate !== null)
1510
1522
  ? gs.cacheHitRate
1511
1523
  : (gs.inputTokens + gs.cacheTokens > 0 ? ((gs.cacheTokens / (gs.inputTokens + gs.cacheTokens)) * 100).toFixed(1) : '0.0');
@@ -222,7 +222,7 @@
222
222
  <!-- 底部状态栏 -->
223
223
  <footer class="footer">
224
224
  <div class="footer-left">
225
- <span>myccusage v1.2.1</span>
225
+ <span>myccusage v1.2.2</span>
226
226
  <span class="divider">|</span>
227
227
  <span>本地缓存源: <code>~/.cache/myccusage/</code></span>
228
228
  </div>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "myccusage"
7
- version = "1.2.1"
7
+ version = "1.2.2"
8
8
  description = "Multi-Agent session usage analytics and DeepSeek-V4.1-Flash cost estimator"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
File without changes
File without changes
File without changes