#!/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: "_"). 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: providerkeyurl 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 - 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()