feat: enable/disable OCG keys via Dashboard — disabled keys removed from router pool instantly

This commit is contained in:
hmo
2026-08-03 11:45:16 +08:00
parent f25a15f3bf
commit a83660ebd3
4 changed files with 141 additions and 38 deletions
+22
View File
@@ -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
# ============================================================
+54 -1
View File
@@ -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:
+11 -5
View File
@@ -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-AgentMozilla/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 — 代理核心",
+54 -32
View File
@@ -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='<span style="color:var(--red)">\u2717 代理未运行</span>';
if(pp)pp.innerHTML='<span style="color:var(--dim);font-size:18px">'+esc(prd.error||'无法连接 :19878')+'</span>';
}else{
if(pl)pl.innerHTML='<span style="color:var(--green);font-weight:600">当前路由: '+esc(prd.current_key||'—')+'</span>'
+'<span style="color:var(--dim)">'+prd.total_requests+' 请求</span>'
+'<span style="color:var(--dim)">'+prd.pool_healthy+'/'+prd.pool_size+' healthy</span>';
if(pp){
var ph='';
for(var i=0;i<prd.pool.length;i++){
var k=prd.pool[i];
var mp=k.monthly_pct!=null?k.monthly_pct:0;
var wp=k.weekly_pct!=null?k.weekly_pct:0;
var ok=k.healthy&&!k.in_cooldown;
var edge=ok?'var(--green)':'var(--red)';
var bg=ok?'var(--card)':(k.in_cooldown?'rgba(251,140,0,.1)':'rgba(229,57,53,.06)');
ph+='<div style="background:'+bg+';border:1px solid '+edge+';border-radius:6px;padding:6px 10px;min-width:120px;font-size:17px">'
+'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">'
+'<strong style="font-size:16px">'+esc(k.key_id||'?')+'</strong>'
+(k.hits>0?'<span style="font-size:14px;color:var(--accent)">'+k.hits+' hits</span>':'')
+'</div>'
+'<div style="font-size:14px;color:var(--dim)">M '+mp+'% \u00b7 W '+wp+'%</div>'
+(k.in_cooldown?'<div style="font-size:13px;color:var(--yellow);margin-top:1px">冷却中</div>':'')
+(k.error?'<div style="font-size:13px;color:var(--red);margin-top:1px">\u2717 '+esc(k.error)+'</div>':'')
+'</div>';
}
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='<span style="color:var(--red)">\u2717 代理未运行</span>';
if(pp)pp.innerHTML='<span style="color:var(--dim);font-size:18px">'+esc(prd.error||'无法连接 :19878')+'</span>';
}else{
if(pl)pl.innerHTML='<span style="color:var(--green);font-weight:600">当前路由: '+esc(prd.current_key||'—')+'</span>'
+'<span style="color:var(--dim)">'+prd.total_requests+' 请求</span>'
+'<span style="color:var(--dim)">'+prd.pool_healthy+'/'+prd.pool_size+' healthy</span>';
if(pp){
var ph='';
for(var i=0;i<prd.pool.length;i++){
var k=prd.pool[i];
var mp=k.monthly_pct!=null?k.monthly_pct:0;
var wp=k.weekly_pct!=null?k.weekly_pct:0;
var ok=k.healthy&&!k.in_cooldown;
var edge=ok?'var(--green)':'var(--red)';
var bg=ok?'var(--card)':(k.in_cooldown?'rgba(251,140,0,.1)':'rgba(229,57,53,.06)');
ph+='<div style="background:'+bg+';border:1px solid '+edge+';border-radius:6px;padding:6px 10px;min-width:150px;font-size:17px">'
+'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">'
+'<strong style="font-size:16px">'+esc(k.key_id||'?')+'</strong>'
+(k.hits>0?'<span style="font-size:14px;color:var(--accent)">'+k.hits+' hits</span>':'')
+'</div>'
+'<div style="font-size:14px;color:var(--dim)">M '+mp+'% \u00b7 W '+wp+'%</div>'
+(k.in_cooldown?'<div style="font-size:13px;color:var(--yellow);margin-top:1px">冷却中</div>':'')
+(k.error?'<div style="font-size:13px;color:var(--red);margin-top:1px">\u2717 '+esc(k.error)+'</div>':'')
+'<button onclick="toggleOcgKey(\''+esc(k.key_id)+'\',true)" style="margin-top:4px;font-size:13px;padding:1px 10px;border:1px solid var(--green);background:var(--card);color:var(--green);border-radius:4px;cursor:pointer">启用</button>'
+'<button onclick="toggleOcgKey(\''+esc(k.key_id)+'\',false)" style="margin-left:6px;font-size:13px;padding:1px 10px;border:1px solid var(--red);background:var(--card);color:var(--red);border-radius:4px;cursor:pointer">停用</button>'
+'</div>';
}
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 '即将重置';