From a83660ebd3ed64ea42e585a8f26543fd51abb9c0 Mon Sep 17 00:00:00 2001 From: hmo Date: Mon, 3 Aug 2026 11:45:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20enable/disable=20OCG=20keys=20via=20Das?= =?UTF-8?q?hboard=20=E2=80=94=20disabled=20keys=20removed=20from=20router?= =?UTF-8?q?=20pool=20instantly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/scripts/dashboard.py | 22 ++++++ gateway/scripts/ocg_router.py | 55 ++++++++++++++- gateway/scripts/specs/ocg_router.json | 16 +++-- gateway/scripts/templates/dashboard.html | 86 +++++++++++++++--------- 4 files changed, 141 insertions(+), 38 deletions(-) diff --git a/gateway/scripts/dashboard.py b/gateway/scripts/dashboard.py index 90df577..4923882 100644 --- a/gateway/scripts/dashboard.py +++ b/gateway/scripts/dashboard.py @@ -1895,6 +1895,28 @@ def api_proxy_status(): return jsonify({"ok": False, "error": str(e), "proxy_running": False}) +@app.route("/api/proxy/toggle-key", methods=["POST"]) +def api_proxy_toggle_key(): + """启用/停用某个 OCG key(转发到 router :19878,立即生效)。""" + try: + payload = request.get_json(force=True, silent=True) or {} + kid = payload.get("key_id", "") + enabled = bool(payload.get("enabled", True)) + if not kid: + return jsonify({"ok": False, "error": "key_id required"}) + req = urllib.request.Request( + "http://127.0.0.1:19878/api/keys/toggle", + data=json.dumps({"key_id": kid, "enabled": enabled}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + data = json.loads(resp.read().decode("utf-8")) + return jsonify(data) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}) + + # ============================================================ # Main # ============================================================ diff --git a/gateway/scripts/ocg_router.py b/gateway/scripts/ocg_router.py index 42d3ad0..7af2cd3 100644 --- a/gateway/scripts/ocg_router.py +++ b/gateway/scripts/ocg_router.py @@ -76,7 +76,7 @@ _state_lock = threading.Lock() # ═══════════════════════════════════════════════════════════════ def load_accounts(): - """从 accounts.json 读取所有 OCG key。""" + """从 accounts.json 读取所有 OCG key。enabled=false 的 key 直接跳过(不在路由池中)。""" keys = [] if not ACCOUNTS_FILE.exists(): log.error("accounts.json not found: %s", ACCOUNTS_FILE) @@ -90,6 +90,10 @@ def load_accounts(): kid = a.get("key_id", "") if not kid: continue + # 显式停用的 key 不加入路由池 + if a.get("enabled", True) is False: + log.info("key %s disabled in accounts.json, skipped", kid) + continue keys.append({ "key_id": kid, "label": a.get("label", kid), @@ -250,6 +254,18 @@ class RouterHandler(BaseHTTPRequestHandler): self._serve_status() return + # ── Key 启用/停用 ── + if path == "/api/keys/toggle" and self.command == "POST": + self._toggle_key() + return + + # ── 立即刷新状态(accounts.json 被外部修改后调用)── + if path == "/api/reload" and self.command == "POST": + with _state_lock: + refresh_state() + self._json_response({"ok": True, "keys": len(_keys)}) + return + # ── 模型列表 ── if path == "/v1/models": self._serve_models() @@ -263,6 +279,43 @@ class RouterHandler(BaseHTTPRequestHandler): # ── 其他请求尝试透传 ── self._proxy_any() + # ── Key 启用/停用 ── + def _toggle_key(self): + """POST /api/keys/toggle {key_id, enabled} → 写 accounts.json 并立即 reload。""" + try: + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) if content_length > 0 else b"" + req_data = json.loads(body) if body else {} + kid = req_data.get("key_id", "") + enabled = bool(req_data.get("enabled", True)) + if not kid: + self._error_response(400, "key_id required") + return + if not ACCOUNTS_FILE.exists(): + self._error_response(500, "accounts.json not found") + return + with open(str(ACCOUNTS_FILE), "r", encoding="utf-8") as f: + data = json.load(f) + found = False + for a in data.get("accounts", []): + if a.get("key_id") == kid: + a["enabled"] = enabled + found = True + break + if not found: + self._error_response(404, f"key {kid} not found") + return + with open(str(ACCOUNTS_FILE), "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + # 立即 reload,使配置生效 + with _state_lock: + refresh_state() + log.info("key %s enabled=%s (via API), pool now %d keys", kid, enabled, len(_keys)) + self._json_response({"ok": True, "key_id": kid, "enabled": enabled, "pool_size": len(_keys)}) + except Exception as e: + log.error("toggle key failed: %s", e) + self._error_response(500, str(e)) + # ── Status API ── def _serve_status(self): with _state_lock: diff --git a/gateway/scripts/specs/ocg_router.json b/gateway/scripts/specs/ocg_router.json index b84bf11..08f4b04 100644 --- a/gateway/scripts/specs/ocg_router.json +++ b/gateway/scripts/specs/ocg_router.json @@ -17,7 +17,8 @@ "1. 确保 systemd 服务 ocg-router 在运行 (systemctl status ocg-router)", "2. OMO 配置指向 http://192.168.1.246:19878/v1(已在 oh-my-openagent.jsonc 和 config.json 配置好)", "3. 本区块自动每 10 秒刷新,显示当前路由 key 和池状态", - "4. 如果代理挂了,Dashboard 会显示「代理未运行」并给出错误信息" + "4. 如果代理挂了,Dashboard 会显示「代理未运行」并给出错误信息", + "5. 每个 key 卡片有「启用/停用」按钮 — 停用的 key 立即从路由池移除(不再参与路由,也不出现在 /api/status 池中),启用后立即恢复。状态写入 accounts.json 的 enabled 字段" ], "troubleshooting": [ "代理未运行 → ssh 246 systemctl restart ocg-router", @@ -33,7 +34,10 @@ {"method": "GET", "path": "http://192.168.1.246:19878/v1/models", "returns": "OpenAI 兼容模型列表", "proxied_to": "ocg_router.py → opencode.ai"}, {"method": "POST", "path": "http://192.168.1.246:19878/v1/chat/completions", "returns": "OpenAI 兼容响应 (含 stream)", "proxied_to": "ocg_router.py → opencode.ai"}, {"method": "GET", "path": "http://192.168.1.246:19878/api/status", "returns": "代理状态 {current_key, pool, hits}", "proxied_to": "ocg_router.py internal"}, - {"method": "GET", "path": "/api/proxy/status", "returns": "代理状态(通过 dashboard 代理)", "proxied_to": "dashboard.py → 19878"} + {"method": "POST", "path": "http://192.168.1.246:19878/api/keys/toggle", "returns": "{ok, key_id, enabled, pool_size}", "proxied_to": "ocg_router.py — 写 accounts.json enabled 字段并立即 reload"}, + {"method": "POST", "path": "http://192.168.1.246:19878/api/reload", "returns": "{ok, keys}", "proxied_to": "ocg_router.py — 立即重载 accounts.json/usage_stats.json"}, + {"method": "GET", "path": "/api/proxy/status", "returns": "代理状态(通过 dashboard 代理)", "proxied_to": "dashboard.py → 19878"}, + {"method": "POST", "path": "/api/proxy/toggle-key", "returns": "{ok, key_id, enabled, pool_size}", "proxied_to": "dashboard.py → 19878 /api/keys/toggle"} ], "dependencies": [ "ocg_router.py — 代理核心,systemd 常驻 (:19878)", @@ -46,8 +50,9 @@ ], "architecture": { "flow": "OMO(Windows) → ocg-router(:19878, 246) → 按 usage_stats.json 选最空闲 key → opencode.ai/zen/go/v1", - "key_selection": "健康度 score = rolling%*0.6 + weekly%*0.3 + monthly%*0.1。score 最低的 key 被选中。订阅取消/采集错误的 key score=999,故障冷却期内 score=888", - "failover": "402/429/5xx → 标记 key 故障 5 分钟 → 自动重试下一个 key", + "key_selection": "健康度 score = rolling%*0.6 + weekly%*0.3 + monthly%*0.1。score 最低的 key 被选中。订阅取消 score=999,故障冷却期内 score=888,用量采集失败(error) score=500(降权但可用)", + "failover": "401/402/403/429/5xx → 标记 key 故障 5 分钟 → 自动重试下一个 key", + "enable_disable": "accounts.json 每 key 支持 enabled 字段(默认 true)。enabled=false 的 key 不加入路由池:不出现在 /v1/models、/api/status、路由候选。Dashboard 卡片按钮或 POST /api/keys/toggle 可切换,立即生效", "providers": ["ocg-key1 (staymo7777@gmail.com)", "ocg-key2 (hua65111@gmail.com)", "ocg-key3 (staymo@163.com — 订阅已取消, 不可用)", "ocg-key4 (staywithmo@163.com)", "ocg-key5 (damnedmoon@163.com)", "ocg-key6 (ycdennismo@163.com)"], "port": "19878" }, @@ -79,7 +84,8 @@ "known_issues": [ "Cloudflare 1010: opencode.ai API 需要真实 User-Agent(Mozilla/5.0),代理已内置", "阿里云到 opencode.ai: 数据中心 IP 可能被 Cloudflare WAF 拦截,所以代理必须跑在 246(家庭宽带 IP)", - "key3 订阅已取消: key3 在池中但 score=999(不可用),自动跳过" + "key3 订阅已取消: accounts.json 已标 subscribed=false,不会出现在路由池", + "用量采集失败(usage error): 只降权不排除 — key 仍可用,只是无法按用量排序(2026-08-03 修复,避免采集超时误杀所有 key)" ], "related_files": [ "gateway/scripts/ocg_router.py — 代理核心", diff --git a/gateway/scripts/templates/dashboard.html b/gateway/scripts/templates/dashboard.html index 4641e45..eb1de4f 100644 --- a/gateway/scripts/templates/dashboard.html +++ b/gateway/scripts/templates/dashboard.html @@ -497,41 +497,63 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena ci.appendChild(pr); } try{ - var prr=await fetch('/api/proxy/status');var prd=await prr.json(); - var pl=document.getElementById('proxy-status-line'); - var pp=document.getElementById('proxy-pool'); - if(!prd.ok||prd.proxy_running===false){ - if(pl)pl.innerHTML='\u2717 代理未运行'; - if(pp)pp.innerHTML=''+esc(prd.error||'无法连接 :19878')+''; - }else{ - if(pl)pl.innerHTML='当前路由: '+esc(prd.current_key||'—')+'' - +''+prd.total_requests+' 请求' - +''+prd.pool_healthy+'/'+prd.pool_size+' healthy'; - if(pp){ - var ph=''; - for(var i=0;i' - +'
' - +''+esc(k.key_id||'?')+'' - +(k.hits>0?''+k.hits+' hits':'') - +'
' - +'
M '+mp+'% \u00b7 W '+wp+'%
' - +(k.in_cooldown?'
冷却中
':'') - +(k.error?'
\u2717 '+esc(k.error)+'
':'') - +''; - } - pp.innerHTML=ph; - } - } + await _loadProxyStatus(); }catch(prE){} }catch(e){} } +async function _loadProxyStatus(){ + var prr=await fetch('/api/proxy/status');var prd=await prr.json(); + var pl=document.getElementById('proxy-status-line'); + var pp=document.getElementById('proxy-pool'); + if(!prd.ok||prd.proxy_running===false){ + if(pl)pl.innerHTML='\u2717 代理未运行'; + if(pp)pp.innerHTML=''+esc(prd.error||'无法连接 :19878')+''; + }else{ + if(pl)pl.innerHTML='当前路由: '+esc(prd.current_key||'—')+'' + +''+prd.total_requests+' 请求' + +''+prd.pool_healthy+'/'+prd.pool_size+' healthy'; + if(pp){ + var ph=''; + for(var i=0;i' + +'
' + +''+esc(k.key_id||'?')+'' + +(k.hits>0?''+k.hits+' hits':'') + +'
' + +'
M '+mp+'% \u00b7 W '+wp+'%
' + +(k.in_cooldown?'
冷却中
':'') + +(k.error?'
\u2717 '+esc(k.error)+'
':'') + +'' + +'' + +''; + } + pp.innerHTML=ph; + } + } +} +function toggleOcgKey(kid,enabled){ + if(!kid)return; + var action=enabled?'启用':'停用'; + if(!confirm('确认'+action+' key '+kid+'?'+(enabled?'':'停用后该 key 将不再参与 OCG 路由。')))return; + fetch('/api/proxy/toggle-key',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({key_id:kid,enabled:enabled})}) + .then(function(r){return r.json();}) + .then(function(d){ + if(d.ok){ + _loadProxyStatus(); + if(window.showToast)showToast('key '+kid+' 已'+action); + }else{ + alert('操作失败: '+(d.error||'未知错误')); + } + }) + .catch(function(e){alert('请求失败: '+e);}); +} function _fmtCountdown(sec){ if(sec==null)return '—'; if(sec<=0)return '即将重置';