fix(router/dashboard): 429按打满档位精确冷却+limitName兜底; mark_key_failed尊重显式cooldown(原死参数上限600s); weekly>=100不派单且healthy对齐; /api/usage三来源统一stale标记+前端置灰

This commit is contained in:
hmo
2026-08-26 12:20:47 +08:00
parent 13d8f8293f
commit 8c0c0d08bf
3 changed files with 859 additions and 724 deletions
+79 -32
View File
@@ -9,9 +9,10 @@ Flask app on :5803. Monitors agents across platforms via:
Auto-recovery: restarts local Windows agents after 3 consecutive offline checks.
"""
import random
import os, sys, re, json, time, socket, subprocess, logging, urllib.request, sqlite3, shutil, threading
from pathlib import Path
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from flask import Flask, jsonify, request, send_from_directory
# ---- Paths (platform-agnostic: auto-detect from script location) ----
@@ -611,14 +612,21 @@ def _augment_wechat_status(entry):
entry["qr_timestamp"] = None
entry["session_age_hours"] = 0
# 1. Fetch QR code (always available)
# 二维码【不再自动拉取】:避免 dashboard 每10秒轮询时自动刷新二维码(用户要求改手动获取)。
# 只从"手动获取缓存"读取——用户点"获取登录二维码"后由 /api/wechat/status 写入;
# 且仅当缓存未过期(二维码约5分钟有效)才返回,过期视为无 → 前端显示"获取登录二维码"按钮。
try:
req = urllib.request.Request("http://localhost:3001/login?token=mowechat_fixed_token_001")
html = urllib.request.urlopen(req, timeout=5).read().decode("utf-8", errors="replace")
m = re.search(r'qrcode\.makeCode\("([^"]+)"\)', html)
if m:
entry["qr_url"] = m.group(1)
entry["qr_timestamp"] = now.isoformat()
cache_file = str(_GATEWAY_DIR / "temp" / "wechat_qr_cache.json")
if os.path.exists(cache_file):
with open(cache_file, encoding="utf-8") as f:
qc = json.load(f)
qurl = qc.get("qr_url")
qts = qc.get("ts", "")
if qurl and qts:
age_sec = (now - datetime.fromisoformat(qts)).total_seconds()
if age_sec < 300: # 二维码约5分钟有效
entry["qr_url"] = qurl
entry["qr_timestamp"] = qts
except Exception:
pass
@@ -1376,6 +1384,27 @@ def api_rdp_toggle():
return jsonify({"ok": False, "error": str(e)})
@app.route("/api/rdp/progress")
def api_rdp_progress():
"""RDP enable 实时进度 — proxy to xmpp_bot on Windows (polls rdp_progress.json)."""
try:
data = _bridge_post("/rdp", {"action": "progress"}, timeout=8)
return jsonify(data)
except Exception as e:
return jsonify({"ok": False, "state": "unknown", "message": "progress 查询失败", "error": str(e), "steps": []})
@app.route("/api/rdp/enable_log")
def api_rdp_enable_log():
"""RDP enable 结构化日志 — proxy to xmpp_bot on Windows (for post-mortem debug)."""
try:
n = request.args.get("lines", 80, type=int)
data = _bridge_post("/rdp", {"action": "enable_log", "lines": n}, timeout=8)
return jsonify(data)
except Exception as e:
return jsonify({"ok": False, "error": str(e), "lines": []})
# ════════════════════════════════════════════════════════════
# OpenCode Go Usage Monitor — 4个账号用量配额监控 (246 本地采集)
# See gateway/scripts/specs/usage_monitor.json
@@ -1429,6 +1458,23 @@ def api_usage():
except Exception:
pass
# 新鲜度统一标记(2026-08-26):三种来源一律按 last_update_iso 算年龄
try:
_now_dt = datetime.now(timezone.utc)
for a in accounts:
lu = a.get("last_update_iso")
a["age_minutes"] = None
a["stale"] = True
if lu:
dt = datetime.fromisoformat(lu)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
age_min = (_now_dt - dt).total_seconds() / 60.0
a["age_minutes"] = int(round(age_min))
a["stale"] = age_min > 60
except Exception as _e:
log.warning("usage: stale-mark failed: %s", _e)
if not accounts:
return jsonify({"ok": False, "error": "no data yet",
"last_refresh_iso": None, "accounts": [], "providers": {}})
@@ -1462,17 +1508,17 @@ def api_usage_refresh():
if proc.returncode != 0 and proc.stderr:
log.warning(f"usage_collector stderr:\n{proc.stderr[-1000:]}")
# 2. Kimi 远程采集(SSH 到 Windows+ scp 回 246
try:
_collect_kimi_usage()
except Exception as e:
log.warning(f"usage: Kimi collection failed: {e}")
# 2. Kimi 远程采集(已禁用 by xxm 2026-08-22CDP弹窗骚扰)
# try:
# _collect_kimi_usage()
# except Exception as e:
# log.warning(f"usage: Kimi collection failed: {e}")
# 3. SenseNova 远程采集(SSH 到 WindowsCDP 刮 console 余量
try:
_collect_sensenova_usage()
except Exception as e:
log.warning(f"usage: SenseNova collection failed: {e}")
# 3. SenseNova 远程采集(已禁用 by xxm 2026-08-22
# try:
# _collect_sensenova_usage()
# except Exception as e:
# log.warning(f"usage: SenseNova collection failed: {e}")
except Exception as e:
log.error(f"usage: collector crash: {e}")
finally:
@@ -1646,7 +1692,7 @@ def api_keys():
# ── Auto-collect timer (every 5 min, keeps cookies alive) ──
_USAGE_AUTO_INTERVAL = 300
_USAGE_AUTO_INTERVAL = 1800
def _start_usage_auto_timer():
"""后台线程:OCG 每 5 分钟自动采集;Kimi/SenseNova 每 30 分钟采集(SSH 到 Windows)。"""
@@ -1666,22 +1712,16 @@ def _start_usage_auto_timer():
if proc.returncode != 0 and proc.stderr:
log.warning(f"usage auto-timer stderr:\n{proc.stderr[-800:]}")
# Kimi/SenseNova:每 30 分钟刷新一次(SSH 到 Windows + CDP,较重)
tick += 1
if tick % _KIMI_INTERVAL_MULT == 0:
try:
_collect_kimi_usage()
except Exception as e:
log.warning(f"usage auto-timer: Kimi failed: {e}")
try:
_collect_sensenova_usage()
except Exception as e:
log.warning(f"usage auto-timer: SenseNova failed: {e}")
# Kimi/SenseNova auto-timer disabled by xxm 2026-08-22 (CDP弹窗骚扰)
# tick += 1
# if tick % _KIMI_INTERVAL_MULT == 0:
# _collect_kimi_usage()
# _collect_sensenova_usage()
_usage_collector_running = False
except Exception as e:
log.error(f"usage auto-timer error: {e}")
_usage_collector_running = False
time.sleep(_USAGE_AUTO_INTERVAL)
time.sleep(_USAGE_AUTO_INTERVAL + random.randint(-240, 240))
t = threading.Thread(target=_loop, name="usage_auto_timer", daemon=True)
t.start()
@@ -1861,6 +1901,13 @@ def api_wechat_status():
if m:
result["qr_url"] = m.group(1)
result["qr_timestamp"] = now.isoformat()
# 手动获取到二维码 → 写入缓存,dashboard infra status 据此显示(避免自动拉取刷新)
try:
cache_file = str(_GATEWAY_DIR / "temp" / "wechat_qr_cache.json")
with open(cache_file, "w", encoding="utf-8") as _cf:
json.dump({"qr_url": m.group(1), "ts": now.isoformat()}, _cf, ensure_ascii=False)
except Exception:
pass
except Exception as e:
result["message"] = f"QR page fetch failed: {e}"
@@ -2006,7 +2053,7 @@ def api_wechat_trigger_login():
import urllib.request as _ur
try:
req = _ur.Request(
"http://127.0.0.1:3001/api/bot/login",
"http://127.0.0.1:3001/api/bot/login?token=mowechat_fixed_token_001",
data=b"",
headers={"Content-Type": "application/json"},
method="POST",
+49 -9
View File
@@ -45,7 +45,7 @@ ACCOUNTS_FILE = _SCRIPT_DIR / "usage_monitor" / "accounts.json"
FAIL_COOLDOWN_SEC = 300 # 5 分钟(key 自身故障)
UPSTREAM_COOLDOWN_SEC = 30 # 上游模型级故障(Router.Unavailable)短冷却:换 key 无意义
# 用量数据刷新间隔
USAGE_REFRESH_INTERVAL = 120 # 2 分钟
USAGE_REFRESH_INTERVAL = 1800 # 统一30分钟节奏(2026-08-26
# 最大重试次数(所有 key 耗尽)
MAX_RETRIES = 6
# 单次上游请求保底超时(秒):上游处理大请求(1MB+)可能要 60-90s+
@@ -154,12 +154,13 @@ def refresh_state():
global _keys, _key_usage
_keys = load_accounts()
_key_usage = load_usage()
# 合并 accounts.json 的订阅标记:采集失败时 usage 缺失该信息
# 以 accounts.json 的 renewal/subscribed 为准
# 合并 accounts.json 的订阅标记(仅兜底):usage_stats.json 是 CDP 实时检测
# 有该 key 的实时数据时以实时数据为准;只有用量数据缺失该 key 时才回退
# 到 accounts.json 的 renewal/subscribed 静态标记(2026-08-20 修复:不再覆盖实时检测)
for k in _keys:
kid = k["key_id"]
if not k.get("subscribed", True):
_key_usage.setdefault(kid, {})["subscribed"] = False
if kid not in _key_usage and not k.get("subscribed", True):
_key_usage[kid] = {"subscribed": False}
log.info("state refreshed: %d keys, %d with usage data", len(_keys), len(_key_usage))
@@ -181,6 +182,10 @@ def _key_health_score(kid):
if not usage.get("subscribed", True):
return 999
# weekly 打满:沉底与冷却同级,reset 前不派单(2026-08-26
if (usage.get("weekly_pct", 0) or 0) >= 100:
return 888
# 用量采集失败 → 降权但可用(不因采集问题误杀 key)
if usage.get("error"):
return 500
@@ -233,6 +238,8 @@ def pick_key_round_robin():
usage = _key_usage.get(kid, {})
if not usage.get("subscribed", True):
continue
if (usage.get("weekly_pct", 0) or 0) >= 100:
continue # weekly 打满(2026-08-26
# 选中:更新索引到下一个
_rr_index = (idx + 1) % n
return k
@@ -251,7 +258,11 @@ def mark_key_failed(kid, reason="", cooldown=None):
count = _key_fail_count[kid]
# 指数退避:base=2s, 每次失败 ×2,上限 600s(10分钟)
base = 2
cool = min(base * (2 ** (count - 1)), 600)
if cooldown and cooldown > 0:
# explicit cooldown wins over exponential-backoff cap (2026-08-26)
cool = max(int(cooldown), 1)
else:
cool = min(base * (2 ** (count - 1)), 600)
until = now + cool
_key_failures[kid] = until
if reason:
@@ -392,7 +403,9 @@ class RouterHandler(BaseHTTPRequestHandler):
"rolling_pct": usage.get("rolling_pct", None),
"weekly_pct": usage.get("weekly_pct", None),
"monthly_pct": usage.get("monthly_pct", None),
"healthy": usage.get("subscribed", True) and (time.time() >= fail_until),
"healthy": (usage.get("subscribed", True)
and (time.time() >= fail_until)
and not ((usage.get("weekly_pct", 0) or 0) >= 100)),
"in_cooldown": time.time() < fail_until,
"fail_reason": _key_failure_reason.get(kid, ""),
"error": usage.get("error", False),
@@ -602,7 +615,34 @@ class RouterHandler(BaseHTTPRequestHandler):
break
mark_key_failed(kid, last_error, cooldown=FAIL_COOLDOWN_SEC)
elif status in (402, 429):
mark_key_failed(kid, last_error, cooldown=60)
# 用 usage_stats 的 reset_in_sec 设定精确冷却时间
# 2026-08-26 定稿:只在打满档位取最远 reset;空集合按 limitName 分级兜底。
kid_usage = usage_snapshot.get(kid, {})
tiers = [kid_usage.get('rolling') or {},
kid_usage.get('weekly') or {},
kid_usage.get('monthly') or {}]
exhausted = [t['reset_in_sec'] for t in tiers
if t.get('usage_percent', 0) >= 100 and t.get('reset_in_sec', 0) > 0]
limit_name = ''
try:
eidx = last_error.find("{")
if eidx >= 0:
meta = (json.loads(last_error[eidx:]).get('error') or {}).get('metadata') or {}
limit_name = str(meta.get('limitName', '')).strip().lower()
except Exception:
limit_name = ''
if exhausted:
cooldown_sec = max(exhausted) + 1800 # 快照年龄补偿30分钟
basis = 'maxed-tiers'
elif limit_name in ('weekly', 'monthly'):
cooldown_sec = 86400
basis = 'limitName:' + limit_name
else:
cooldown_sec = 3600
basis = 'default'
log.warning("key %s 429, cooldown %.0fs (%s; limitName=%s)",
kid, cooldown_sec, basis, limit_name or 'unknown')
mark_key_failed(kid, last_error, cooldown=cooldown_sec)
elif status >= 500:
# 上游服务端错误(503/500/Internal/Router.Unavailable):
# 只是当前 key 的上游实例可能有问题,换下一个 key 重试(不冷却)。
@@ -633,7 +673,7 @@ class RouterHandler(BaseHTTPRequestHandler):
self._error_response(400, last_error)
else:
log.error("%s ALL KEYS EXHAUSTED (%.2fs): %s", model_name, elapsed, last_error)
self._error_response(502, f"All OCG keys exhausted: {last_error}")
self._error_response(429, f"Rate limited, retry later: {last_error}")
def _forward_request(self, api_key, body, is_stream):
"""转发单次请求到指定 key。返回 {ok, status, error}。
File diff suppressed because it is too large Load Diff