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})
|
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
|
# Main
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ _state_lock = threading.Lock()
|
|||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def load_accounts():
|
def load_accounts():
|
||||||
"""从 accounts.json 读取所有 OCG key。"""
|
"""从 accounts.json 读取所有 OCG key。enabled=false 的 key 直接跳过(不在路由池中)。"""
|
||||||
keys = []
|
keys = []
|
||||||
if not ACCOUNTS_FILE.exists():
|
if not ACCOUNTS_FILE.exists():
|
||||||
log.error("accounts.json not found: %s", ACCOUNTS_FILE)
|
log.error("accounts.json not found: %s", ACCOUNTS_FILE)
|
||||||
@@ -90,6 +90,10 @@ def load_accounts():
|
|||||||
kid = a.get("key_id", "")
|
kid = a.get("key_id", "")
|
||||||
if not kid:
|
if not kid:
|
||||||
continue
|
continue
|
||||||
|
# 显式停用的 key 不加入路由池
|
||||||
|
if a.get("enabled", True) is False:
|
||||||
|
log.info("key %s disabled in accounts.json, skipped", kid)
|
||||||
|
continue
|
||||||
keys.append({
|
keys.append({
|
||||||
"key_id": kid,
|
"key_id": kid,
|
||||||
"label": a.get("label", kid),
|
"label": a.get("label", kid),
|
||||||
@@ -250,6 +254,18 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|||||||
self._serve_status()
|
self._serve_status()
|
||||||
return
|
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":
|
if path == "/v1/models":
|
||||||
self._serve_models()
|
self._serve_models()
|
||||||
@@ -263,6 +279,43 @@ class RouterHandler(BaseHTTPRequestHandler):
|
|||||||
# ── 其他请求尝试透传 ──
|
# ── 其他请求尝试透传 ──
|
||||||
self._proxy_any()
|
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 ──
|
# ── Status API ──
|
||||||
def _serve_status(self):
|
def _serve_status(self):
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
"1. 确保 systemd 服务 ocg-router 在运行 (systemctl status ocg-router)",
|
"1. 确保 systemd 服务 ocg-router 在运行 (systemctl status ocg-router)",
|
||||||
"2. OMO 配置指向 http://192.168.1.246:19878/v1(已在 oh-my-openagent.jsonc 和 config.json 配置好)",
|
"2. OMO 配置指向 http://192.168.1.246:19878/v1(已在 oh-my-openagent.jsonc 和 config.json 配置好)",
|
||||||
"3. 本区块自动每 10 秒刷新,显示当前路由 key 和池状态",
|
"3. 本区块自动每 10 秒刷新,显示当前路由 key 和池状态",
|
||||||
"4. 如果代理挂了,Dashboard 会显示「代理未运行」并给出错误信息"
|
"4. 如果代理挂了,Dashboard 会显示「代理未运行」并给出错误信息",
|
||||||
|
"5. 每个 key 卡片有「启用/停用」按钮 — 停用的 key 立即从路由池移除(不再参与路由,也不出现在 /api/status 池中),启用后立即恢复。状态写入 accounts.json 的 enabled 字段"
|
||||||
],
|
],
|
||||||
"troubleshooting": [
|
"troubleshooting": [
|
||||||
"代理未运行 → ssh 246 systemctl restart ocg-router",
|
"代理未运行 → 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": "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": "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": "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": [
|
"dependencies": [
|
||||||
"ocg_router.py — 代理核心,systemd 常驻 (:19878)",
|
"ocg_router.py — 代理核心,systemd 常驻 (:19878)",
|
||||||
@@ -46,8 +50,9 @@
|
|||||||
],
|
],
|
||||||
"architecture": {
|
"architecture": {
|
||||||
"flow": "OMO(Windows) → ocg-router(:19878, 246) → 按 usage_stats.json 选最空闲 key → opencode.ai/zen/go/v1",
|
"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",
|
"key_selection": "健康度 score = rolling%*0.6 + weekly%*0.3 + monthly%*0.1。score 最低的 key 被选中。订阅取消 score=999,故障冷却期内 score=888,用量采集失败(error) score=500(降权但可用)",
|
||||||
"failover": "402/429/5xx → 标记 key 故障 5 分钟 → 自动重试下一个 key",
|
"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)"],
|
"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"
|
"port": "19878"
|
||||||
},
|
},
|
||||||
@@ -79,7 +84,8 @@
|
|||||||
"known_issues": [
|
"known_issues": [
|
||||||
"Cloudflare 1010: opencode.ai API 需要真实 User-Agent(Mozilla/5.0),代理已内置",
|
"Cloudflare 1010: opencode.ai API 需要真实 User-Agent(Mozilla/5.0),代理已内置",
|
||||||
"阿里云到 opencode.ai: 数据中心 IP 可能被 Cloudflare WAF 拦截,所以代理必须跑在 246(家庭宽带 IP)",
|
"阿里云到 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": [
|
"related_files": [
|
||||||
"gateway/scripts/ocg_router.py — 代理核心",
|
"gateway/scripts/ocg_router.py — 代理核心",
|
||||||
|
|||||||
@@ -497,6 +497,11 @@ document.getElementById('rdp-status-txt').textContent=fOn?'Connected':e4.rdp_ena
|
|||||||
ci.appendChild(pr);
|
ci.appendChild(pr);
|
||||||
}
|
}
|
||||||
try{
|
try{
|
||||||
|
await _loadProxyStatus();
|
||||||
|
}catch(prE){}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
async function _loadProxyStatus(){
|
||||||
var prr=await fetch('/api/proxy/status');var prd=await prr.json();
|
var prr=await fetch('/api/proxy/status');var prd=await prr.json();
|
||||||
var pl=document.getElementById('proxy-status-line');
|
var pl=document.getElementById('proxy-status-line');
|
||||||
var pp=document.getElementById('proxy-pool');
|
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 ok=k.healthy&&!k.in_cooldown;
|
||||||
var edge=ok?'var(--green)':'var(--red)';
|
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)');
|
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">'
|
+'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">'
|
||||||
+'<strong style="font-size:16px">'+esc(k.key_id||'?')+'</strong>'
|
+'<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>':'')
|
+(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>'
|
+'<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.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>':'')
|
+(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>';
|
+'</div>';
|
||||||
}
|
}
|
||||||
pp.innerHTML=ph;
|
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){
|
function _fmtCountdown(sec){
|
||||||
if(sec==null)return '—';
|
if(sec==null)return '—';
|
||||||
|
|||||||
Reference in New Issue
Block a user