Files
hack/tools/scripts/reconcile_newapi.py
T

701 lines
29 KiB
Python

#!/usr/bin/env python3
"""Reconcile all hunted/vaulted keys against NewAPI.
For every key in (a) the vault usable_keys/*/keys.json and (b) all
results/*/usable.txt buckets:
* re-verify DIRECT against the provider (real chat completion),
* match to an existing NewAPI channel by name suffix == key[-6:],
* classify: CHANNEL_OK / NEEDS_CREATE / NEEDS_FIX / DEAD / NO_BALANCE.
NewAPI masks the key field on read, so dedup is ALWAYS by channel name
suffix (convention: "<ProviderName>_<last6>").
Run:
python3 reconcile_newapi.py --audit # verify + write report (no writes)
python3 reconcile_newapi.py --apply # create missing + revive/fix + disable dead
python3 reconcile_newapi.py --providers minimax,zhipu
python3 reconcile_newapi.py --apply --only-disabled # revive existing disabled chans
"""
import argparse, json, os, re, sys, time, urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import newapi_client as n
H = Path(__file__).resolve().parent / "llm-key-hunter"
VAULT = H / "usable_keys"
RESULTS = H / "results"
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36")
DIRECT = urllib.request.build_opener()
def vcfg(base, path, models, extra=None):
return dict(base=base, path=path, models=models, extra=extra or {})
# ── provider canonical config ────────────────────────────────────
# verify: direct endpoint + model candidates to try.
# chan: how to build the NewAPI channel.
PROVIDERS = {
"minimax": dict(
verify=vcfg("https://api.minimaxi.com/v1", "/text/chatcompletion_v2",
["MiniMax-M2.5", "MiniMax-M2.7", "MiniMax-M3", "abab6.5s-chat"]),
chan=dict(prefix="MiniMax", ctype=n.TYPE_OPENAI,
base="https://api.minimaxi.com",
models="MiniMax-M2.5,MiniMax-M2.7,MiniMax-M3,abab6.5s-chat")),
"deepseek": dict(
verify=vcfg("https://api.deepseek.com", "/chat/completions",
["deepseek-chat", "deepseek-reasoner"]),
chan=dict(prefix="DeepSeek", ctype=n.TYPE_OPENAI,
base="https://api.deepseek.com",
models="deepseek-chat,deepseek-reasoner")),
"kimi": dict(
verify=vcfg("https://api.moonshot.cn/v1", "/chat/completions",
["moonshot-v1-8k", "moonshot-v1-32k"]),
chan=dict(prefix="Kimi", ctype=n.TYPE_OPENAI,
base="https://api.moonshot.cn",
models="moonshot-v1-8k,moonshot-v1-32k,moonshot-v1-128k")),
"siliconflow": dict(
verify=vcfg("https://api.siliconflow.cn", "/v1/chat/completions",
["Qwen/Qwen2.5-7B-Instruct", "deepseek-ai/DeepSeek-V3"]),
chan=dict(prefix="SiliconFlow", ctype=n.TYPE_OPENAI,
base="https://api.siliconflow.cn",
models="Qwen/Qwen2.5-7B-Instruct,deepseek-ai/DeepSeek-V3")),
"zhipu": dict(
# try coding first (paid models), then paas (free glm-4-flash)
verify=[vcfg("https://open.bigmodel.cn/api/coding/paas/v4", "/chat/completions",
["glm-4.7-flash", "glm-4.5-air", "glm-4-flash", "glm-5.2"]),
vcfg("https://open.bigmodel.cn/api/paas/v4", "/chat/completions",
["glm-4-flash", "glm-4.5-flash"])]),
"volcanoark": None, # custom 3-plan probe, handled below
"longcat": dict(
verify=vcfg("https://api.longcat.chat/openai/v1", "/chat/completions",
["LongCat-2.0", "Borai/LongCat-2.0"]),
chan=dict(prefix="LongCat", ctype=n.TYPE_OPENAI,
base="https://api.longcat.chat/openai",
models="Borai/LongCat-2.0,LongCat-2.0")),
"dashscope": dict(
verify=vcfg("https://dashscope.aliyuncs.com/compatible-mode/v1",
"/chat/completions", ["qwen-plus", "qwen-turbo", "qwen-max"]),
chan=dict(prefix="DashScope", ctype=n.TYPE_OPENAI,
base="https://dashscope.aliyuncs.com/compatible-mode/v1",
models="qwen-plus,qwen-turbo,qwen-max,qwen3-coder-plus")),
"freemodel": dict(
verify=vcfg("https://api.freemodel.dev/v1", "/chat/completions",
["freemodel_workbuddy/FreeModel", "FreeModel", "gpt-5.6-sol"]),
chan=dict(prefix="FreeModel", ctype=n.TYPE_OPENAI,
base="https://api.freemodel.dev/v1",
models="freemodel_workbuddy/FreeModel,FreeModel,gpt-5.6-sol,gpt-5.6-luna,gpt-5.6-terra,claude-opus-5")),
"xunfei": dict(
verify=vcfg("https://maas-coding-api.cn-huabei-1.xf-yun.com/v2",
"/chat/completions", ["auto", "xopkimik26"]),
chan=dict(prefix="Xunfei", ctype=n.TYPE_CUSTOM,
base="https://maas-coding-api.cn-huabei-1.xf-yun.com/v2/chat/completions",
models="auto,xopkimik26")),
"mimo": dict(
verify=vcfg("https://api.xiaomimimo.com/v1", "/chat/completions",
["mimo-v2.5", "MiMo-7B"]),
chan=dict(prefix="MiMo", ctype=n.TYPE_OPENAI,
base="https://api.xiaomimimo.com/v1",
models="mimo-v2.5,MiMo-7B")),
"codingplan": None, # volcano coding plan keys (ark-code-latest)
}
# ── HTTP + classification (mirrors hunt rules) ──────────────────
def http(base, path, key, body, extra=None, timeout=25):
url = base + path
headers = {"Authorization": "Bearer " + key, "User-Agent": UA,
"Accept": "application/json, */*", "Content-Type": "application/json"}
if extra:
headers.update(extra)
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with DIRECT.open(req, timeout=timeout) as r:
return r.getcode(), r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
try:
return e.code, e.read().decode("utf-8", "replace")
except Exception:
return e.code, ""
except Exception as e:
return 0, f"net:{type(e).__name__}"
def msg_of(body):
try:
j = json.loads(body)
except Exception:
return (body or "")[:160]
if not isinstance(j, dict):
return str(j)[:160]
err = j.get("error")
if isinstance(err, dict):
return str(err.get("message", ""))[:160]
br = j.get("base_resp") or {}
if isinstance(br, dict) and br.get("status_msg"):
return str(br.get("status_msg"))[:160]
return str(j.get("message", ""))[:160]
def classify(code, body):
"""Return (verdict, detail). verdict in USABLE/DEAD/NO_BALANCE/NO_ACCESS/UNKNOWN."""
if code == 200:
try:
j = json.loads(body)
except Exception:
return "UNKNOWN", "200 non-json"
if isinstance(j, dict) and j.get("choices"):
return "USABLE", "200 choices"
br = j.get("base_resp") if isinstance(j, dict) else None
if isinstance(br, dict) and br.get("status_code") is not None:
bc = br["status_code"]
if bc == 0:
return "USABLE", "200 choices"
if bc in (1001, 1003, 1004, 1014, 1111, 2049):
return "DEAD", f"{bc} {br.get('status_msg','')}"
if bc == 2056:
return "NO_BALANCE", f"{bc} token plan capped"
if bc in (1002, 1008, 1013, 1026, 1038, 1045, 1063, 1064, 1082):
return "NO_BALANCE", f"{bc} {br.get('status_msg','')}"
return "NO_ACCESS", f"{bc} {br.get('status_msg','')}"
return "UNKNOWN", "200 no choices"
if code == 401:
return "DEAD", f"401 {msg_of(body)}"
if code in (402, 429):
return "NO_BALANCE", f"{code} {msg_of(body)}"
if code == 400:
m = msg_of(body).lower()
if any(w in m for w in ("invalid", "unauthor", "api key", "authentication",
"incorrect", "does not exist", "apikey")):
return "DEAD", f"400 {msg_of(body)}"
if any(w in m for w in ("balance", "quota", "insufficient", "limit",
"payment", "arrears", "exhaust", "credit",
"not activated", "not enabled", "not subscribe",
"未开通", "未激活")):
return "NO_BALANCE", f"400 {msg_of(body)}"
return "NO_ACCESS", f"400 {msg_of(body)}"
if code in (403, 404, 422, 451):
return "NO_ACCESS", f"{code} {msg_of(body)}"
if code in (500, 502, 503, 520, 524, 0):
return "UNKNOWN", f"{code} {msg_of(body)}"
return "UNKNOWN", f"{code} {msg_of(body)}"
def try_cfg(key, cfg):
"""Try each model in cfg; return (verdict, detail, model_that_worked)."""
last = "no model"
for model in cfg["models"]:
code, body = http(cfg["base"], cfg["path"], key,
{"model": model, "messages": [{"role": "user", "content": "ok"}],
"max_tokens": 8, "temperature": 0.01},
extra=cfg.get("extra"))
v, d = classify(code, body)
if v in ("USABLE", "DEAD", "NO_BALANCE"):
return v, f"{model} {d}", (model if v == "USABLE" else None)
last = f"{model} {d}"
return "UNKNOWN", last, None
# ── zhipu: try coding then paas; return endpoint that works ──────
def verify_zhipu(key):
coding = vcfg("https://open.bigmodel.cn/api/coding/paas/v4", "/chat/completions",
["glm-4.7-flash", "glm-4.5-air", "glm-4-flash", "glm-5.2"])
paas = vcfg("https://open.bigmodel.cn/api/paas/v4", "/chat/completions",
["glm-4-flash", "glm-4.5-flash"])
# coding first
v, d, m = try_cfg(key, coding)
if v == "USABLE":
return v, f"coding {d}", ("coding", m)
if v == "DEAD":
return v, f"coding {d}", None
# try paas (free tier may still work even if coding has no balance)
v2, d2, m2 = try_cfg(key, paas)
if v2 == "USABLE":
return v2, f"paas {d2}", ("paas", m2)
if v2 == "DEAD":
return v2, f"paas {d2}", None
# neither usable: prefer the more informative verdict
if v == "NO_BALANCE" or v2 == "NO_BALANCE":
return "NO_BALANCE", f"coding={d} | paas={d2}", None
return v2 or v, f"coding={d} | paas={d2}", None
# ── volcano: probe all 3 plans + PAYGO models ────────────────────
VOLC_PAYGO_MODELS = [
"doubao-seed-1-6-250615", "doubao-seed-1-6-flash-250615",
"doubao-1-5-lite-32k-250115", "doubao-1-5-pro-32k-250115",
"doubao-seed-2-1-pro-260628", "doubao-seed-2-0-code-preview-260215",
"doubao-seed-1-8-251228", "doubao-seed-code-preview-251028",
"deepseek-v4-pro-260425",
]
def verify_volcano(key):
"""Probe CODING, AGENT, PAYGO. Return (verdict, detail, planinfo).
planinfo = dict(coding=bool, agent=bool, paygo=[models])"""
info = dict(coding=False, agent=False, paygo=[])
dead = False
nobal = False
parts = []
# CODING plan
code, body = http("https://ark.cn-beijing.volces.com/api/coding",
"/chat/completions", key,
{"model": "ark-code-latest",
"messages": [{"role": "user", "content": "ok"}],
"max_tokens": 8})
v, d = classify(code, body)
if v == "USABLE":
info["coding"] = True; parts.append("coding OK")
elif v == "DEAD":
dead = True; parts.append(f"coding DEAD:{d}")
elif v == "NO_BALANCE":
nobal = True; parts.append(f"coding nobal:{d}")
# AGENT plan
code, body = http("https://ark.cn-beijing.volces.com/api/plan/v3",
"/chat/completions", key,
{"model": "ark-code-latest",
"messages": [{"role": "user", "content": "ok"}],
"max_tokens": 8})
v, d = classify(code, body)
if v == "USABLE":
info["agent"] = True; parts.append("agent OK")
elif v == "DEAD":
dead = True; parts.append(f"agent DEAD:{d}")
elif v == "NO_BALANCE":
nobal = True; parts.append(f"agent nobal:{d}")
# PAYGO: sweep model list
for model in VOLC_PAYGO_MODELS:
code, body = http("https://ark.cn-beijing.volces.com/api/v3",
"/chat/completions", key,
{"model": model,
"messages": [{"role": "user", "content": "ok"}],
"max_tokens": 8})
v, d = classify(code, body)
if v == "USABLE":
info["paygo"].append(model)
elif v == "DEAD":
dead = True
break
if info["paygo"]:
parts.append("paygo=" + ",".join(info["paygo"]))
if info["coding"] or info["agent"] or info["paygo"]:
return "USABLE", "; ".join(parts), info
if dead and not (info["coding"] or info["agent"]):
return "DEAD", "; ".join(parts), info
if nobal:
return "NO_BALANCE", "; ".join(parts), info
return "UNKNOWN", "; ".join(parts), info
def verify_key(prov, key):
"""Dispatch. Returns (verdict, detail, extra) where extra is endpoint/plan info."""
if prov == "zhipu":
return verify_zhipu(key)
if prov == "volcanoark":
return verify_volcano(key)
if prov == "codingplan":
code, body = http("https://ark.cn-beijing.volces.com/api/coding",
"/chat/completions", key,
{"model": "ark-code-latest",
"messages": [{"role": "user", "content": "ok"}],
"max_tokens": 8})
v, d = classify(code, body)
return v, f"coding {d}", None
cfg = PROVIDERS.get(prov)
if cfg and cfg.get("verify"):
v, d, m = try_cfg(key, cfg["verify"])
return v, d, m
return "UNKNOWN", "no verifier", None
# ── load vault + bucket keys ────────────────────────────────────
NORM = {
"minimax": "minimax", "deepseek": "deepseek", "kimi": "kimi",
"moonshot": "kimi", "siliconflow": "siliconflow", "zhipuai": "zhipu",
"zhipu": "zhipu", "bigmodel": "zhipu", "volcanoark": "volcanoark",
"volcano": "volcanoark", "volcengine": "volcanoark", "doubao": "volcanoark",
"longcat": "longcat", "dashscope": "dashscope", "qwen": "dashscope",
"freemodel": "freemodel", "xunfei": "xunfei", "iflytek": "xunfei",
"spark": "xunfei", "codingplan": "codingplan", "opencode": "codingplan",
"mimo": "mimo", "xiaomimimo": "mimo",
}
def norm(label):
return NORM.get(label.lower().replace(" ", "").replace("_", "").replace("-", ""))
def load_all_keys():
"""Returns list of dicts: {prov, key, source, origin}."""
seen = set()
out = []
# vault
for d in sorted(VAULT.iterdir()):
kj = d / "keys.json"
if not d.is_dir() or not kj.exists():
continue
try:
data = json.loads(kj.read_text())
except Exception:
continue
for e in data:
k = (e.get("key") or "").strip()
if not k or k in seen:
continue
seen.add(k)
out.append(dict(prov=d.name, key=k, source=e.get("source", ""),
origin="vault", detail=e.get("detail", "")))
# buckets
for f in sorted(RESULTS.glob("*/usable.txt")):
bucket = f.parent.name
for line in f.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
p, k, src = _parse_bucket_line(line, bucket)
if not p or not k or k in seen:
continue
seen.add(k)
out.append(dict(prov=p, key=k, source=src,
origin=bucket, detail=""))
return out
# key patterns by provider, ordered most-specific first
_KEY_PATTERNS = [
("minimax", re.compile(r"sk-cp-[A-Za-z0-9_\-]{40,140}")),
("longcat", re.compile(r"ak_[A-Za-z0-9_-]{20,60}")),
("freemodel", re.compile(r"fe_oa_[0-9a-fA-F]{20,60}")),
("mimo", re.compile(r"sk-cx-[A-Za-z0-9]{40,60}")),
("siliconflow", re.compile(r"sk-[A-Za-z0-9]{48}\b")),
("deepseek", re.compile(r"sk-[0-9a-fA-F]{32}\b")),
("dashscope", re.compile(r"sk-[0-9a-fA-F]{32}\b")),
("codingplan", re.compile(r"sk-sp-[0-9a-fA-F]{32}\b")),
("kimi", re.compile(r"sk-[A-Za-z0-9]{40,60}\b")),
("xunfei", re.compile(r"\b[0-9a-fA-F]{32}:[A-Za-z0-9]{20,40}\b")),
("zhipu", re.compile(r"\b[0-9a-fA-F]{32}\.[A-Za-z0-9]{16}\b")),
("volcanoark", re.compile(
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")),
]
def _parse_bucket_line(line, bucket):
"""Return (prov, key, src) handling tab/pipe/colon/concatenated formats."""
# explicit tab: provider<TAB>key<TAB>url
if "\t" in line:
parts = line.split("\t")
if len(parts) >= 2:
p = norm(parts[0])
if p:
return p, parts[1].strip(), (parts[2] if len(parts) > 2 else "")
# pipe-separated
if "|" in line:
parts = [x.strip() for x in line.split("|")]
first = parts[0]
p = norm(first)
if p and len(parts) >= 2:
return p, parts[1], (parts[2] if len(parts) > 2 else "")
# no provider prefix -> first field is the key
key = first
p = _provider_from_bucket(bucket, key) or _provider_from_key(key)
return p, key, (parts[1] if len(parts) > 1 else "")
# colon-separated (xunfei apikey:apisecret)
if re.match(r"^[0-9a-fA-F]{32}:[A-Za-z0-9]{20,40}", line):
key = line.split("|")[0]
return "xunfei", key, ""
# concatenated: provider label directly followed by key (all_deep/minimax_deep)
for label, p in [("zhipu_coding", "zhipu"), ("zhipu_paas", "zhipu"),
("minimax", "minimax"), ("siliconflow", "siliconflow"),
("deepseek", "deepseek"), ("longcat", "longcat"),
("dashscope", "dashscope"), ("volcano", "volcanoark")]:
if line.startswith(label):
rest = line[len(label):]
m = _key_regex_for(p).search(rest)
if m:
key = m.group(0)
src_m = re.search(r"https?://\S+", rest[m.end():])
return p, key, (src_m.group(0) if src_m else "")
# last resort: regex the whole line for a known key shape
for p, rx in _KEY_PATTERNS:
m = rx.search(line)
if m:
return p, m.group(0), ""
return None, None, ""
def _key_regex_for(p):
for pp, rx in _KEY_PATTERNS:
if pp == p:
return rx
return re.compile(r".+")
def _provider_from_bucket(bucket, key):
b = bucket.lower()
mapping = {
"minimax_deep": "minimax", "deepseek": "deepseek",
"deepseek_v2": "deepseek", "deepseek_deep": "deepseek",
"deepseek_recheck": "deepseek", "kimi": "kimi", "kimi_v2": "kimi",
"freemodel": "freemodel", "xunfei": "xunfei",
"codingplan": "codingplan", "cn_coding": "longcat",
"all_deep": None,
}
if b in mapping and mapping[b]:
return mapping[b]
return _provider_from_key(key)
def _provider_from_key(key):
for p, rx in _KEY_PATTERNS:
if rx.match(key):
return p
return None
# ── load existing NewAPI channels, index by name suffix ─────────
CACHE = Path("/tmp/chans.json")
def find_existing(key, by_suffix):
"""Match a key to existing channels using last-6 and last-4 suffixes.
New channels use last6; legacy channels use last4 (case-insensitive)."""
found = []
for n in (6, 4):
if len(key) >= n:
found.extend(by_suffix.get(key[-n:].lower(), []))
# dedup by id, preserve order
seen = set()
out = []
for c in found:
if c["id"] not in seen:
seen.add(c["id"])
out.append(c)
return out
def load_channels(force=False):
"""Returns dict: suffix(lower) -> [chans]. Uses /tmp/chans.json cache
(full scan is ~2min); rescans if cache missing or --refresh."""
all_chans = []
if CACHE.exists() and not force:
try:
all_chans = json.loads(CACHE.read_text())
except Exception:
all_chans = []
if not all_chans:
for cid in range(1, 601):
try:
ch = n.get_channel(cid)
except Exception:
continue
if ch and ch.get("id"):
all_chans.append(ch)
CACHE.write_text(json.dumps(all_chans))
by_suffix = {}
for ch in all_chans:
name = ch.get("name", "")
if "_" not in name:
continue
suf = name.rsplit("_", 1)[-1].lower()
# strip trailing -<word> tags (e.g. FreeModel 59e5eb-Anthropic)
base = suf.split("-")[0]
for s in {suf, base}:
if s:
by_suffix.setdefault(s, []).append(ch)
return by_suffix, all_chans
# ── channel builders (for --apply) ──────────────────────────────
def base_channel(name, ctype, base, key, models, test_model=None):
return {
"name": name, "type": ctype, "key": key,
"base_url": base, "models": models,
"model_mapping": "", "group": "default", "groups": ["default"],
"auto_ban": 1, "priority": 0, "weight": 0,
"test_model": test_model or (models.split(",")[0] if models else ""),
}
def build_channel(prov, key, verdict_detail, extra):
suf = key[-6:]
if prov == "zhipu":
kind = extra[0] if extra else "paas"
m = extra[1] if extra and len(extra) > 1 else "glm-4-flash"
if kind == "coding":
return base_channel(
f"ZhipuCoding_{suf}", n.TYPE_CUSTOM,
"https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
key, "glm-4.7-flash,glm-4.5-air,glm-4-flash,glm-5.2",
test_model=m)
return base_channel(
f"ZhipuPaaS_{suf}", n.TYPE_CUSTOM,
"https://open.bigmodel.cn/api/paas/v4/chat/completions",
key, "glm-4-flash,glm-4.5-flash", test_model=m or "glm-4-flash")
if prov == "volcanoark":
info = extra or {}
chans = []
if info.get("coding"):
chans.append(base_channel(
f"VolcanoCoding_{suf}", n.TYPE_ANTHROPIC,
"https://ark.cn-beijing.volces.com/api/coding",
key, "ark-code-latest", test_model="ark-code-latest"))
if info.get("agent"):
chans.append(base_channel(
f"VolcanoAgent_{suf}", n.TYPE_CUSTOM,
"https://ark.cn-beijing.volces.com/api/plan/v3/chat/completions",
key, "ark-code-latest", test_model="ark-code-latest"))
for model in info.get("paygo", []):
chans.append(base_channel(
f"VolcanoPAYGO_{suf}_{model.split('-')[-1][:6]}",
n.TYPE_CUSTOM,
"https://ark.cn-beijing.volces.com/api/v3/chat/completions",
key, model, test_model=model))
return chans if chans else None
if prov == "codingplan":
return base_channel(
f"VolcanoCoding_{suf}", n.TYPE_ANTHROPIC,
"https://ark.cn-beijing.volces.com/api/coding",
key, "ark-code-latest", test_model="ark-code-latest")
cfg = PROVIDERS.get(prov)
if not cfg or not cfg.get("chan"):
return None
c = cfg["chan"]
return base_channel(f"{c['prefix']}_{suf}", c["ctype"], c["base"],
key, c["models"])
# ── main ────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--audit", action="store_true")
ap.add_argument("--apply", action="store_true")
ap.add_argument("--providers", default="")
ap.add_argument("--workers", type=int, default=24)
ap.add_argument("--only-disabled", action="store_true",
help="only re-test keys whose existing channel is disabled")
args = ap.parse_args()
if not args.audit and not args.apply:
args.audit = True
print("loading keys + channels...")
keys = load_all_keys()
by_suffix, all_chans = load_channels()
print(f" {len(keys)} unique keys, {len(all_chans)} channels")
provf = {p.strip() for p in args.providers.split(",") if p.strip()}
if provf:
keys = [k for k in keys if k["prov"] in provf]
print(f" filtered to {provf}: {len(keys)} keys")
# determine which keys to verify
targets = []
for k in keys:
k["_existing"] = find_existing(k["key"], by_suffix)
existing = k["_existing"]
if args.only_disabled:
if existing and any(c.get("status") != 1 for c in existing):
targets.append(k)
else:
targets.append(k)
if args.only_disabled:
print(f" {len(targets)} keys with a disabled existing channel")
print(f"verifying {len(targets)} keys direct (workers={args.workers})...")
results = []
def work(k):
v, d, extra = verify_key(k["prov"], k["key"])
return k, v, d, extra
done = 0
with ThreadPoolExecutor(max_workers=args.workers) as ex:
futs = [ex.submit(work, k) for k in targets]
for fut in as_completed(futs):
k, v, d, extra = fut.result()
results.append((k, v, d, extra))
done += 1
if done % 25 == 0 or done == len(targets):
print(f" {done}/{len(targets)} verified")
results.sort(key=lambda r: (r[0]["prov"], r[0]["key"][-6:]))
# classify against NewAPI
report = dict(usable_no_channel=[], usable_channel_disabled=[],
usable_channel_ok=[], dead=[], no_balance=[],
unknown=[], to_create=[], to_revive=[], to_disable=[])
for k, v, d, extra in results:
existing = k["_existing"]
rec = dict(prov=k["prov"], suffix=k["key"][-6:], origin=k["origin"],
source=k["source"], verdict=v, detail=d,
existing=[(c["id"], c.get("name"), c.get("status"))
for c in existing])
if v == "USABLE":
if not existing:
report["usable_no_channel"].append(rec)
ch = build_channel(k["prov"], k["key"], d, extra)
chs = ch if isinstance(ch, list) else ([ch] if ch else [])
for c in chs:
report["to_create"].append(c)
elif any(c.get("status") != 1 for c in existing):
report["usable_channel_disabled"].append(rec)
for c in existing:
if c.get("status") != 1:
report["to_revive"].append(c["id"])
else:
report["usable_channel_ok"].append(rec)
elif v == "DEAD":
report["dead"].append(rec)
for c in existing:
if c.get("status") == 1:
report["to_disable"].append(c["id"])
elif v == "NO_BALANCE":
report["no_balance"].append(rec)
else:
report["unknown"].append(rec)
out = RESULTS / "reconcile.json"
out.write_text(json.dumps(report, indent=2, ensure_ascii=False))
print("\n=== RECONCILE SUMMARY ===")
print(f" USABLE, no channel: {len(report['usable_no_channel'])}")
print(f" USABLE, channel disabled: {len(report['usable_channel_disabled'])}")
print(f" USABLE, channel OK: {len(report['usable_channel_ok'])}")
print(f" DEAD (401/invalid): {len(report['dead'])}")
print(f" NO_BALANCE: {len(report['no_balance'])}")
print(f" UNKNOWN: {len(report['unknown'])}")
print(f"\n actions: create={len(report['to_create'])} "
f"revive={len(report['to_revive'])} disable={len(report['to_disable'])}")
print(f"\nreport -> {out}")
if not args.apply:
print("\n(audit only; pass --apply to execute changes)")
return
# ── APPLY ──
print("\n=== APPLYING ===")
# revive
for cid in sorted(set(report["to_revive"])):
ok, msg = n.update_channel(cid, status=1)
print(f" revive {cid}: {'OK' if ok else msg}")
# create
for ch in report["to_create"]:
ok, msg = n.add_channel(ch)
print(f" create {ch['name']}: {'OK '+msg if ok else 'FAIL '+msg}")
# disable dead
for cid in sorted(set(report["to_disable"])):
ok, msg = n.update_channel(cid, status=2)
print(f" disable {cid}: {'OK' if ok else msg}")
print("done.")
if __name__ == "__main__":
main()