feat: enable/disable OCG keys via Dashboard — disabled keys removed from router pool instantly
This commit is contained in:
@@ -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
|
||||
# ============================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 — 代理核心",
|
||||
|
||||
@@ -497,6 +497,11 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
|
||||
ci.appendChild(pr);
|
||||
}
|
||||
try{
|
||||
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');
|
||||
@@ -516,7 +521,7 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
|
||||
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">'
|
||||
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>':'')
|
||||
@@ -524,13 +529,30 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
|
||||
+'<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;
|
||||
}
|
||||
}
|
||||
}catch(prE){}
|
||||
}catch(e){}
|
||||
}
|
||||
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 '—';
|
||||
|
||||
Reference in New Issue
Block a user