feat: multi-provider usage — Kimi collector via CDP, unified /api/usage, frontend grouping

This commit is contained in:
hmo
2026-07-19 19:46:08 +08:00
parent 94f5f9ec92
commit 7a43fec2b1
4 changed files with 420 additions and 38 deletions
+40 -9
View File
@@ -1195,15 +1195,46 @@ _usage_collector_running = False
@app.route("/api/usage")
def api_usage():
"""直读本地 usage_stats.json(秒级响应,不依赖 Windows bot"""
if not _USAGE_STATS_FILE.exists():
return jsonify({"ok": False, "error": "no data yet (collect never ran)",
"last_refresh_iso": None, "accounts": []})
try:
with open(str(_USAGE_STATS_FILE), "r", encoding="utf-8") as f:
return jsonify(json.load(f))
except Exception as e:
return jsonify({"ok": False, "error": f"read failed: {e}", "accounts": []})
"""读取 usage_stats.json,合并 Kimi 数据(如果存在)。"""
accounts = []
providers = {}
# 1. 主 OCG 数据
if _USAGE_STATS_FILE.exists():
try:
with open(str(_USAGE_STATS_FILE), "r", encoding="utf-8") as f:
data = json.load(f)
for a in data.get("accounts", []):
a["provider"] = a.get("provider", "opencode_go")
accounts.append(a)
providers["opencode_go"] = providers.get("opencode_go", 0) + 1
except Exception as e:
log.warning(f"usage: read failed: {e}")
# 2. Kimi 数据
kimi_file = TEMP_DIR / "usage_stats_kimi.json"
if kimi_file.exists():
try:
with open(str(kimi_file), "r", encoding="utf-8") as f:
kimi_data = json.load(f)
for a in kimi_data.get("accounts", []):
a["provider"] = a.get("provider", "kimi")
accounts.append(a)
providers["kimi"] = providers.get("kimi", 0) + 1
except Exception:
pass
if not accounts:
return jsonify({"ok": False, "error": "no data yet",
"last_refresh_iso": None, "accounts": [], "providers": {}})
return jsonify({
"ok": True,
"last_refresh_iso": max((a.get("last_update_iso","") for a in accounts), default=None),
"account_count": len(accounts),
"collected_count": sum(1 for a in accounts if a.get("error") is None),
"providers": providers,
"accounts": accounts,
})
@app.route("/api/usage/refresh", methods=["POST"])
+44 -29
View File
@@ -368,7 +368,7 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
var us=document.getElementById('usage-section');
if(!us){
us=document.createElement('div');us.id='usage-section';us.className='ps';us.style.marginTop='16px';
us.innerHTML='<h2>OpenCode Go Usage<span class="help-btn" onclick="showModuleHelp(\'usage_monitor\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'usage_monitor\',\'ai\')" title="AI Spec">§</span></h2>'
us.innerHTML='<h2>API 用量<span class="help-btn" onclick="showModuleHelp(\'usage_monitor\',\'human\')" title="使用说明">?</span><span class="help-btn ai" onclick="showModuleHelp(\'usage_monitor\',\'ai\')" title="AI Spec">§</span></h2>'
+'<div id="usage-cards" style="display:flex;flex-wrap:wrap;gap:10px;"></div>'
+'<div style="margin-top:8px;display:flex;gap:8px;align-items:center">'
+'<button class="btn s" id="btn-usage-refresh">Refresh Now</button>'
@@ -404,39 +404,54 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
lu.textContent='上次采集: '+d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())
+' '+pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds())+' CST';
}
// Render cards via innerHTML rebuild (simple data cards, no flicker risk)
// Render cards grouped by provider
var html='';
var accts=e5.accounts||[];
// Group by provider
var groups={},order=[];
for(var i=0;i<accts.length;i++){
var a=accts[i];
var maxPct=0;
if(a.rolling&&a.rolling.usage_percent!=null)maxPct=Math.max(maxPct,a.rolling.usage_percent);
if(a.weekly&&a.weekly.usage_percent!=null)maxPct=Math.max(maxPct,a.weekly.usage_percent);
if(a.monthly&&a.monthly.usage_percent!=null)maxPct=Math.max(maxPct,a.monthly.usage_percent);
var edge='var(--border)';
var barColor='var(--accent)';
if(maxPct>=95){edge='#e53935';barColor='#e53935';}
else if(maxPct>=80){edge='#fb8c00';barColor='#fb8c00';}
html+='<div style="background:var(--card);border:1px solid '+edge+';border-radius:8px;padding:10px 12px;min-width:200px;flex:1;max-width:260px">'
+'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">'
+'<strong style="font-size:14px">'+esc(a.label||a.key_id||a.workspace_id||'?')+'</strong>';
if(a.session_expired){
html+='<span style="font-size:11px;color:#e53935;background:rgba(229,57,53,.1);padding:2px 6px;border-radius:4px">SESSION EXPIRED</span>';
}else if(a.error){
html+='<span style="font-size:11px;color:var(--dim)" title="'+esc(a.error)+'">⚠</span>';
}else{
html+='<span style="font-size:11px;color:var(--dim)">'+maxPct+'% max</span>';
}
html+='</div>';
if(a.session_expired||a.error){
html+='<div style="font-size:12px;color:var(--dim);padding:4px 0">'+esc(a.error||'请重新登录此账号')+'</div>';
}else{
html+=_renderUsageMetric('5h rolling',a.rolling,barColor);
html+=_renderUsageMetric('Weekly',a.weekly,barColor);
html+=_renderUsageMetric('Monthly',a.monthly,barColor);
}
html+='</div>';
var p=a.provider||'other';
if(!groups[p]){groups[p]=[];order.push(p);}
groups[p].push(a);
}
// Provider names
var pn={opencode_go:'OpenCode Go',kimi:'Kimi'};
for(var gi=0;gi<order.length;gi++){
var p=order[gi];
if(groups[p].length>0){
html+='<div style="font-size:19px;color:var(--accent);margin:8px 0 4px;font-weight:600">'+esc(pn[p]||p)+'</div>';
html+='<div style="display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px">';
var ag=groups[p];
for(var i=0;i<ag.length;i++){var a=ag[i];
var maxPct=0;
if(a.rolling&&a.rolling.usage_percent!=null)maxPct=Math.max(maxPct,a.rolling.usage_percent);
if(a.weekly&&a.weekly.usage_percent!=null)maxPct=Math.max(maxPct,a.weekly.usage_percent);
if(a.monthly&&a.monthly.usage_percent!=null)maxPct=Math.max(maxPct,a.monthly.usage_percent);
var edge='var(--border)';
var barColor='var(--accent)';
if(maxPct>=95){edge='#e53935';barColor='#e53935';}
else if(maxPct>=80){edge='#fb8c00';barColor='#fb8c00';}
html+='<div style="background:var(--card);border:1px solid '+edge+';border-radius:8px;padding:10px 12px;min-width:200px;flex:1;max-width:260px">'
+'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">'
+'<strong style="font-size:14px">'+esc(a.label||a.key_id||a.workspace_id||'?')+'</strong>';
if(a.session_expired){
html+='<span style="font-size:11px;color:#e53935;background:rgba(229,57,53,.1);padding:2px 6px;border-radius:4px">SESSION EXPIRED</span>';
}else if(a.error){
html+='<span style="font-size:11px;color:var(--dim)" title="'+esc(a.error)+'">⚠</span>';
}else{
html+='<span style="font-size:11px;color:var(--dim)">'+maxPct+'% max</span>';
}
html+='</div>';
if(a.session_expired||a.error){
html+='<div style="font-size:12px;color:var(--dim);padding:4px 0">'+esc(a.error||'请重新登录此账号')+'</div>';
}else{
html+=_renderUsageMetric('5h rolling',a.rolling,barColor);
html+=_renderUsageMetric('Weekly',a.weekly,barColor);
html+=_renderUsageMetric('Monthly',a.monthly,barColor);
}
html+='</div>';}
html+='</div>';}}
if(!accts.length){html='<div style="color:var(--dim);padding:8px">未配置账号 (accounts.json 未就绪,或 cookies 未提取)</div>';}
if(cards)cards.innerHTML=html;
}
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
usage_collector_kimi.py Kimi 用量采集CDP 浏览器自动化
==========================================================
连接 Chrome CDP proxy (localhost:3456)打开 Kimi 控制台
提取用量数据保存到 usage_stats_kimi.json
调用方式: python usage_collector_kimi.py
"""
import json, os, sys, time, urllib.request, urllib.error
from pathlib import Path
from datetime import datetime, timezone
CDP = "http://localhost:3456"
KIMI_CONSOLE = "https://www.kimi.com/code/console"
SCRIPT_DIR = Path(__file__).resolve().parent
GATEWAY_DIR = SCRIPT_DIR.parent
TEMP_DIR = GATEWAY_DIR / "temp"
os.makedirs(str(TEMP_DIR), exist_ok=True)
OUTPUT_FILE = TEMP_DIR / "usage_stats_kimi.json"
KEY_ID = "key7"
PROVIDER = "kimi"
def cdp_get(path, timeout=10):
url = f"{CDP}{path}"
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.loads(r.read().decode())
except Exception as e:
return {"error": str(e)}
def cdp_post(path, body="", timeout=15):
url = f"{CDP}{path}"
try:
data = body.encode("utf-8") if isinstance(body, str) else body
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("Content-Type", "text/plain")
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read().decode()
except Exception as e:
return f"ERROR: {e}"
def cdp_eval(target_id, js, timeout=10):
return cdp_post(f"/eval?target={target_id}", js, timeout)
def main():
print("[kimi] Starting collection...")
# 1. Find or create a Kimi tab
targets = cdp_get("/targets") or []
kimi_tab = None
for t in targets:
url = t.get("url", "")
if "kimi.com" in url:
kimi_tab = t
break
if kimi_tab:
target_id = kimi_tab.get("targetId") or kimi_tab.get("id")
print(f"[kimi] Found existing tab: {target_id}")
# Navigate to console if needed
cur_url = kimi_tab.get("url", "")
if "code/console" not in cur_url:
print(f"[kimi] Navigating to console...")
cdp_get(f"/navigate?target={target_id}&url={KIMI_CONSOLE}")
time.sleep(3)
else:
print(f"[kimi] Opening new tab...")
result = cdp_get(f"/new?url={KIMI_CONSOLE}")
if isinstance(result, dict):
target_id = result.get("targetId") or result.get("id", "")
else:
target_id = str(result)
print(f"[kimi] New tab: {target_id}")
time.sleep(4)
if not target_id:
print("[kimi] ERROR: no target ID")
return
# 2. Wait for page load + extract data
print("[kimi] Extracting usage data...")
# Simple extraction: get all text, parse with Python
js_get_text = "document.body ? document.body.innerText : ''"
raw_text = cdp_eval(target_id, js_get_text)
# CDP eval returns JSON: {"value":"text content"} — unwrap it
try:
parsed = json.loads(raw_text) if isinstance(raw_text, str) else raw_text
text = parsed.get("value", "") if isinstance(parsed, dict) else str(raw_text)
except (json.JSONDecodeError, AttributeError):
text = str(raw_text)
# Parse the text in Python
import re
# Find all "N%" followed by "N 小时后重置" patterns
# The page has two: first = 本周用量, second = 频率明细
pct_pattern = re.findall(r'(\d+)%', text)
hour_pattern = re.findall(r'(\d+)\s*小时后重置', text)
weekly_pct = int(pct_pattern[0]) if len(pct_pattern) > 0 else 0
weekly_hours = int(hour_pattern[0]) if len(hour_pattern) > 0 else 0
rate_pct = int(pct_pattern[1]) if len(pct_pattern) > 1 else 0
rate_hours = int(hour_pattern[1]) if len(hour_pattern) > 1 else 0
print(f"[kimi] Found {len(pct_pattern)} pct values, {len(hour_pattern)} hour values")
print(f"[kimi] Weekly: {weekly_pct}% / {weekly_hours}h reset | Rate: {rate_pct}% / {rate_hours}h reset")
# Also take a screenshot for debugging
try:
cdp_get(f"/screenshot?target={target_id}&file={TEMP_DIR}/kimi_console.png")
print("[kimi] Screenshot saved")
except:
pass
# Build output with Python-parsed data
wp = weekly_pct or 0
wh = weekly_hours or 0
rp = rate_pct or 0
rh = rate_hours or 0
output = {
"ok": True,
"last_refresh_iso": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"provider": PROVIDER,
"accounts": [{
"key_id": KEY_ID,
"label": "key7 (Kimi)",
"provider": PROVIDER,
"workspace_id": "kimi",
"rolling": {
"usage_percent": rp,
"reset_in_sec": rh * 3600,
"status": "ok" if rp < 80 else ("warn" if rp < 95 else "rate-limited"),
},
"weekly": {
"usage_percent": wp,
"reset_in_sec": wh * 3600,
"status": "ok" if wp < 80 else ("warn" if wp < 95 else "rate-limited"),
},
"monthly": None,
"session_expired": False,
"last_update_iso": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"error": None,
}],
}
with open(str(OUTPUT_FILE), "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"[kimi] Output saved to {OUTPUT_FILE}")
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
usage_collector.py Provider 用量采集主控
=============================================
编排所有 provider 的采集合并到统一的 usage_stats.json
Provider:
- opencode_go: 本地 HTTP/CDP 采集usage_collector_ocg.py
- kimi: SSH 远程调用 Windows 采集器usage_collector_kimi.py
输出: gateway/temp/usage_stats.jsonDashboard /api/usage 读取
"""
import json, os, sys, subprocess
from pathlib import Path
from datetime import datetime, timezone
SCRIPT_DIR = Path(__file__).resolve().parent
GATEWAY_DIR = SCRIPT_DIR.parent
TEMP_DIR = GATEWAY_DIR / "temp"
LOGS_DIR = GATEWAY_DIR / "logs"
os.makedirs(str(TEMP_DIR), exist_ok=True)
os.makedirs(str(LOGS_DIR), exist_ok=True)
OUTPUT_FILE = TEMP_DIR / "usage_stats.json"
WINDOWS_HOST = "192.168.1.16"
WINDOWS_PYTHON = r"C:\Users\hmo\AppData\Local\Programs\Python\Python310\python.exe"
WINDOWS_SCRIPT = r"D:\F\NewI\opencode\daily-workspace\projects\AgentsMeeting\gateway\scripts\usage_collector_kimi.py"
def log(msg):
ts = datetime.now().strftime("%H:%M:%S")
line = f"[{ts}] {msg}"
print(line, flush=True)
def collect_opencode_go():
"""运行本地 OCG 采集器。"""
log("OCG: starting...")
ocg_script = SCRIPT_DIR / "usage_collector_ocg.py"
if not ocg_script.exists():
# Fallback: old name
ocg_script = SCRIPT_DIR / "usage_collector.py"
try:
r = subprocess.run(
[sys.executable, str(ocg_script)],
capture_output=True, text=True, timeout=120,
cwd=str(SCRIPT_DIR),
)
if r.returncode != 0 and r.stderr:
log(f"OCG: FAILED (rc={r.returncode})")
log(f"OCG stderr: {r.stderr[-500:]}")
else:
log(f"OCG: done (rc={r.returncode})")
except Exception as e:
log(f"OCG: EXCEPTION: {e}")
def collect_kimi():
"""SSH 到 Windows 运行 Kimi 采集器,scp 结果回来。"""
log("Kimi: SSH to Windows...")
try:
r = subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
"-o", "BatchMode=yes", f"hmo@{WINDOWS_HOST}",
f'"{WINDOWS_PYTHON}" "{WINDOWS_SCRIPT}"'],
capture_output=True, text=True, timeout=45,
)
for line in r.stdout.splitlines()[-5:]:
log(f"Kimi: {line.strip()}")
if r.returncode != 0:
log(f"Kimi: SSH FAILED (rc={r.returncode}): {r.stderr[-200:]}")
# Try scp alternative
r2 = subprocess.run(
["scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
f"hmo@{WINDOWS_HOST}:{WINDOWS_SCRIPT}", "/dev/null"],
capture_output=True, timeout=10,
)
if r2.returncode == 0:
log("Kimi: SSH failed but scp works — check script path")
except subprocess.TimeoutExpired:
log("Kimi: SSH TIMEOUT")
except Exception as e:
log(f"Kimi: SSH EXCEPTION: {e}")
# Try to scp the output file from Windows
kimi_output = TEMP_DIR / "usage_stats_kimi.json"
try:
r = subprocess.run(
["scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10",
f"hmo@{WINDOWS_HOST}:" + WINDOWS_SCRIPT.replace("usage_collector_kimi.py", "") + "../temp/usage_stats_kimi.json",
str(kimi_output)],
capture_output=True, text=True, timeout=15,
)
if kimi_output.exists():
log(f"Kimi: output scp'd ({kimi_output.stat().st_size} bytes)")
else:
log("Kimi: scp failed or file empty")
except Exception as e:
log(f"Kimi: scp EXCEPTION: {e}")
def merge_all():
"""合并所有 provider 的数据到 usage_stats.json。"""
accounts = []
# 1. Read existing OCG cache (from usage_collector_ocg output)
# The OCG collector writes directly to usage_stats.json or a cache
cache_file = TEMP_DIR / "usage_stats.json"
if cache_file.exists():
try:
with open(str(cache_file), "r", encoding="utf-8") as f:
cached = json.load(f)
for a in cached.get("accounts", []):
a["provider"] = a.get("provider", "opencode_go")
accounts.append(a)
log(f"Merge: {len(accounts)} OCG accounts from cache")
except Exception as e:
log(f"Merge: failed to read cache: {e}")
# 2. Read Kimi output
kimi_file = TEMP_DIR / "usage_stats_kimi.json"
if kimi_file.exists():
try:
with open(str(kimi_file), "r", encoding="utf-8") as f:
kimi_data = json.load(f)
for a in kimi_data.get("accounts", []):
a["provider"] = a.get("provider", "kimi")
# Check if this key already exists (update instead of duplicate)
existing = [i for i, x in enumerate(accounts) if x.get("key_id") == a.get("key_id")]
if existing:
accounts[existing[0]] = a
else:
accounts.append(a)
log(f"Merge: +{len(kimi_data.get('accounts',[]))} Kimi accounts")
except Exception as e:
log(f"Merge: failed to read Kimi data: {e}")
# 3. Count by provider
providers = {}
for a in accounts:
p = a.get("provider", "unknown")
providers[p] = providers.get(p, 0) + 1
output = {
"ok": True,
"last_refresh_iso": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"account_count": len(accounts),
"collected_count": sum(1 for a in accounts if a.get("error") is None),
"providers": providers,
"accounts": accounts,
}
with open(str(OUTPUT_FILE), "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
log(f"Merge: saved {len(accounts)} accounts ({providers}) to {OUTPUT_FILE}")
def main():
log("=== Multi-provider collection started ===")
# Run collectors in sequence (OCG takes longest, Kimi needs CDP sync)
collect_opencode_go()
collect_kimi()
# Merge
merge_all()
log("=== Collection complete ===")
if __name__ == "__main__":
main()