feat: multi-provider usage — Kimi collector via CDP, unified /api/usage, frontend grouping
This commit is contained in:
@@ -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": []})
|
||||
"""读取 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:
|
||||
return jsonify(json.load(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:
|
||||
return jsonify({"ok": False, "error": f"read failed: {e}", "accounts": []})
|
||||
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"])
|
||||
|
||||
@@ -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,11 +404,26 @@ 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 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);
|
||||
@@ -435,8 +450,8 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
|
||||
html+=_renderUsageMetric('Weekly',a.weekly,barColor);
|
||||
html+=_renderUsageMetric('Monthly',a.monthly,barColor);
|
||||
}
|
||||
html+='</div>';
|
||||
}
|
||||
html+='</div>';}
|
||||
html+='</div>';}}
|
||||
if(!accts.length){html='<div style="color:var(--dim);padding:8px">未配置账号 (accounts.json 未就绪,或 cookies 未提取)</div>';}
|
||||
if(cards)cards.innerHTML=html;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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.json(Dashboard /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()
|
||||
Reference in New Issue
Block a user