joy-cli/scripts/manhuang/probe-e2e-capability.py
feibisi 50f5089459 feat(manhuang): scout 号池 health 同步 + e2e --call 接 endpoints
sync-codex-health 拉 health/alive/endpoints(key 文件 600);
e2e --from-endpoints 真调中转;余额不足单独 break;refresh 不覆盖 call 结果。
2026-07-13 09:51:37 +08:00

299 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""端到端能力探针(最小):出口 path + 中转 + 可选真实 completion。
不代替号池 health只回答「今天链路是否可调用」。
检查:
E0 system proxy ONscutil
E1 Clash controller + mode=rule
E2 生产 DIRECT 覆盖(串线 risk != crit
E3 具名中转至少 1 ok
E4 可选JOY_E2E_BASE_URL + JOY_E2E_API_KEY 发 /v1/chat/completions
写入:~/.cache/manhuang/e2e/status.json
用法:
python3 scripts/manhuang/probe-e2e-capability.py
JOY_E2E_BASE_URL=https://... JOY_E2E_API_KEY=sk-... python3 ... --call
"""
from __future__ import annotations
import argparse
import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
OUT = Path.home() / ".cache" / "manhuang" / "e2e" / "status.json"
NET = Path.home() / ".cache" / "manhuang" / "net-path" / "status.json"
RELAYS = Path.home() / ".cache" / "manhuang" / "relays" / "status.json"
def _now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _load(p: Path) -> dict[str, Any]:
if not p.is_file():
return {}
try:
d = json.loads(p.read_text(encoding="utf-8"))
return d if isinstance(d, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def _sys_proxy_on() -> tuple[bool, dict[str, Any]]:
try:
import importlib.util
pp = ROOT / "scripts/manhuang/probe-proxy-path.py"
spec = importlib.util.spec_from_file_location("_pp", pp)
if not spec or not spec.loader:
return False, {"error": "no_probe"}
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
sp = mod.probe_system_proxy()
return bool(sp.get("enabled")), sp
except Exception as e:
return False, {"error": str(e)[:120]}
def _chat_probe(base: str, key: str, model: str, timeout: float = 25.0) -> dict[str, Any]:
url = base.rstrip("/") + "/v1/chat/completions"
body = json.dumps({
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 4,
}).encode()
req = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
"User-Agent": "joy-manhuang-e2e/1",
},
)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read()
ms = int((time.time() - t0) * 1000)
try:
data = json.loads(raw.decode())
except json.JSONDecodeError:
return {"ok": False, "http": r.status, "ms": ms, "error": "bad_json"}
# 有 choices 或 error 字段
if data.get("choices") or data.get("id"):
return {"ok": True, "http": r.status, "ms": ms, "model": model}
if data.get("error"):
return {"ok": False, "http": r.status, "ms": ms, "error": str(data.get("error"))[:160]}
return {"ok": True, "http": r.status, "ms": ms, "note": "no_choices"}
except urllib.error.HTTPError as e:
ms = int((time.time() - t0) * 1000)
try:
err = e.read().decode()[:160]
except Exception:
err = str(e)
return {"ok": False, "http": e.code, "ms": ms, "error": err}
except Exception as e:
return {"ok": False, "http": 0, "ms": int((time.time() - t0) * 1000), "error": str(e)[:160]}
def run(*, do_call: bool) -> dict[str, Any]:
checks: list[dict[str, Any]] = []
np = _load(NET)
rel = _load(RELAYS)
# E0
sp_on, sp = _sys_proxy_on()
checks.append({
"id": "E0_sys_proxy",
"ok": sp_on,
"detail": "ON" if sp_on else "OFF — 本机可能上不了外网",
})
# E1 controller
prim = np.get("primary") if isinstance(np.get("primary"), dict) else {}
ctrl_ok = bool(prim.get("ok"))
mode = str(prim.get("mode") or "")
checks.append({
"id": "E1_controller",
"ok": ctrl_ok and mode.lower() == "rule",
"detail": f"ok={ctrl_ok} mode={mode or ''}",
})
# E2 serial
risk = str((np.get("n4_n5_isolation") or {}).get("risk") or "")
checks.append({
"id": "E2_serial",
"ok": risk != "crit",
"detail": f"serial_risk={risk or ''}",
})
# E3 relays
rc = rel.get("counts") if isinstance(rel.get("counts"), dict) else {}
rel_ok_n = int(rc.get("ok") or 0)
checks.append({
"id": "E3_relays",
"ok": rel_ok_n >= 1,
"detail": f"relays_ok={rel_ok_n}/{rc.get('n') or 0}",
})
# E4 optional call
call: dict[str, Any] | None = None
base = os.environ.get("JOY_E2E_BASE_URL") or os.environ.get("OPENAI_BASE_URL") or ""
key = os.environ.get("JOY_E2E_API_KEY") or os.environ.get("OPENAI_API_KEY") or ""
model = os.environ.get("JOY_E2E_MODEL") or "gpt-4o-mini"
if do_call:
if base and key:
call = _chat_probe(base, key, model)
call = {k: v for k, v in call.items() if k != "key"}
call["base_host"] = base.split("/")[2] if "://" in base else base[:40]
err_s = str(call.get("error") or "")
bal_fail = "INSUFFICIENT" in err_s.upper() or "balance" in err_s.lower() or "余额" in err_s
call["insufficient_balance"] = bal_fail
if bal_fail:
detail = f"http={call.get('http')} 账户余额不足 host={call.get('base_host')}"
else:
detail = f"http={call.get('http')} ms={call.get('ms')} err={err_s or ''}"
checks.append({
"id": "E4_completion",
"ok": bool(call.get("ok")),
"detail": detail,
"insufficient_balance": bal_fail,
})
else:
checks.append({
"id": "E4_completion",
"ok": False,
"detail": "缺 JOY_E2E_BASE_URL / JOY_E2E_API_KEY 或 --from-endpoints",
})
else:
checks.append({
"id": "E4_completion",
"ok": True,
"detail": "skipped未 --call",
"skipped": True,
})
hard = [c for c in checks if not c.get("skipped")]
fail = [c for c in hard if not c.get("ok")]
bal_only = (
len(fail) == 1
and fail[0].get("id") == "E4_completion"
and fail[0].get("insufficient_balance")
)
if any(c["id"] in ("E1_controller", "E2_serial") and not c["ok"] for c in hard):
level = "crit"
elif fail:
level = "warn"
else:
level = "ok"
# 路径通但中转没钱:整体 ok=False但 headline 说清楚
ok = len(fail) == 0
if ok:
headline = "端到端 OK"
next_s = "正常"
elif bal_only:
headline = "路径通 · 中转余额不足INSUFFICIENT_BALANCE"
next_s = "给 tokenx24/中转充值,或换 JOY_E2E_* 有额端点;号池仍看 codex health"
else:
headline = f"端到端失败 ×{len(fail)}: " + ",".join(c["id"] for c in fail)
next_s = "clashctl ensure-direct / 查中转 / clashctl e2e --call --from-endpoints"
status = {
"ts": _now(),
"ok": ok,
"level": level,
"headline": headline,
"next": next_s,
"path_id": np.get("path_id"),
"checks": checks,
"call": call,
"selected": prim.get("selected"),
"mode": mode,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
tmp = OUT.with_suffix(".tmp")
tmp.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.rename(OUT)
return status
def _load_endpoint_creds() -> tuple[str, str, str]:
"""从 ~/data/codex-tokens/api-endpoints.json 取第一个 alive 端点(不打印 key"""
p = Path.home() / "data" / "codex-tokens" / "api-endpoints.json"
if not p.is_file():
return "", "", ""
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return "", "", ""
eps = data.get("endpoints") if isinstance(data, dict) else data
if not isinstance(eps, list):
return "", "", ""
# 优先 alive再 degraded
ordered = sorted(
[x for x in eps if isinstance(x, dict)],
key=lambda x: 0 if str(x.get("status") or "") == "alive" else 1,
)
for item in ordered:
base = str(item.get("base_url") or item.get("url") or "").rstrip("/")
key = str(item.get("api_key") or item.get("key") or item.get("token") or "")
if not base or not key:
continue
# base 可能已带 /v1
if base.endswith("/v1"):
base = base[: -len("/v1")]
model = str(item.get("model") or item.get("default_model") or "") or (
(item.get("models") or [None])[0] if isinstance(item.get("models"), list) else ""
) or "gpt-4o-mini"
return base, key, str(model)
return "", "", ""
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--call", action="store_true", help="发真实 chat completion需 env key")
ap.add_argument(
"--from-endpoints",
action="store_true",
help="--call 时从 ~/data/codex-tokens/api-endpoints.json 取 base+keymode 600",
)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
if args.call and args.from_endpoints:
base, key, model = _load_endpoint_creds()
if base and key:
os.environ.setdefault("JOY_E2E_BASE_URL", base)
os.environ.setdefault("JOY_E2E_API_KEY", key)
if model:
os.environ.setdefault("JOY_E2E_MODEL", model)
# 绝不 print key
host = base.split("/")[2] if "://" in base else base[:40]
print(f"[e2e] using endpoint host={host} model={model or 'default'}")
else:
print("[e2e] --from-endpoints: no alive endpoint with key", file=__import__("sys").stderr)
st = run(do_call=args.call)
if args.json:
print(json.dumps(st, ensure_ascii=False, indent=2))
else:
print(f"[{st.get('level')}] {st.get('headline')}")
print(f" wrote {OUT}")
for c in st.get("checks") or []:
mark = "ok" if c.get("ok") else "FAIL"
skip = " (skip)" if c.get("skipped") else ""
print(f" [{mark}] {c.get('id')}: {c.get('detail')}{skip}")
return 0 if st.get("ok") else 1
if __name__ == "__main__":
raise SystemExit(main())