KeyHunter parser v10 + 18 scanners + tests (synthetic fixtures only, no keys/db/tokens)
This commit is contained in:
295
keyfarm_rotator.py
Executable file
295
keyfarm_rotator.py
Executable file
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python
|
||||
"""keyfarm_rotator.py — multi-provider live-key gateway on 127.0.0.1:16433.
|
||||
|
||||
Pools WORKING/FREE/LIMITED keys from keyhunter data/keys.db every 120s and
|
||||
round-robins them across OpenAI-compatible upstreams. 401/402/429 -> cooldown,
|
||||
next key. OMP registers this as provider `keyfarm` (baseUrl /v1).
|
||||
|
||||
Providers: deepseek, openrouter, mistral, google (gemini openai-compat).
|
||||
Run: python keyfarm_rotator.py
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "keys.db")
|
||||
HOST, PORT = "127.0.0.1", 16436 # models.yml: hermes-deepseek (deepseek-pool-rotated)
|
||||
REFRESH = 120 # seconds between pool reloads
|
||||
COOLDOWN = 1800 # seconds a failing key sits out
|
||||
MAX_TRIES = 4 # keys tried per request
|
||||
|
||||
# Only OpenAI-compatible providers. Non-compat (anthropic, cohere) excluded.
|
||||
PROVIDERS = {
|
||||
"deepseek": ("https://api.deepseek.com/v1", "sk-", ("deepseek-chat", "deepseek-reasoner")),
|
||||
"openrouter": ("https://openrouter.ai/api/v1", "sk-or-", ("openrouter/",)),
|
||||
"mistral": ("https://api.mistral.ai/v1", "", ("mistral-",)),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "AI", ("gemini-",)),
|
||||
"dashscope": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "sk-", ("qwen-",)),
|
||||
"zai": ("https://api.z.ai/api/coding/paas/v4", "", ("glm-",)),
|
||||
"kimi": ("https://api.moonshot.cn/v1", "sk-", ("kimi-",)),
|
||||
"siliconflow":("https://api.siliconflow.cn/v1", "sk-", ("siliconflow-",)),
|
||||
"together": ("https://api.together.xyz/v1", "", ("together-",)),
|
||||
"deepinfra": ("https://api.deepinfra.com/v1/openai", "", ("deepinfra-",)),
|
||||
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvapi-", ("nvidia-",)),
|
||||
"groq": ("https://api.groq.com/openai/v1", "gsk_", ("groq-",)),
|
||||
"perplexity": ("https://api.perplexity.ai", "pplx-", ("perplexity-",)),
|
||||
"fireworks": ("https://api.fireworks.ai/inference/v1", "fw_", ("fireworks-",)),
|
||||
"cerebras": ("https://api.cerebras.ai/v1", "", ("cerebras-",)),
|
||||
"novita": ("https://api.novita.ai/v3/openai", "", ("novita-",)),
|
||||
"openai": ("https://api.openai.com/v1", "sk-", ("gpt-", "o1", "o3", "o4")),
|
||||
"xai": ("https://api.x.ai/v1", "xai-", ("grok-",)),
|
||||
}
|
||||
DB_PROV = {
|
||||
"deepseek": "DEEPSEEK", "openrouter": "OPENROUTER", "mistral": "MISTRAL",
|
||||
"google": "GOOGLE", "dashscope": "DASHSCOPE", "zai": "ZAI", "kimi": "KIMI",
|
||||
"siliconflow": "SILICONFLOW", "together": "TOGETHER",
|
||||
"deepinfra": "DEEPINFRA", "nvidia": "NVIDIA",
|
||||
"groq": "GROQ", "perplexity": "PERPLEXITY", "fireworks": "FIREWORKS",
|
||||
"cerebras": "CEREBRAS", "novita": "NOVITA", "openai": "OPENAI", "xai": "GROK",
|
||||
}
|
||||
EXTRA_HEADERS = { # openrouter wants these
|
||||
"openrouter": {"HTTP-Referer": "https://keyfarm.local", "X-Title": "keyfarm"},
|
||||
}
|
||||
|
||||
lock = threading.Lock()
|
||||
state = {p: {"keys": [], "idx": 0, "cooldowns": {}, "ok": 0, "fail": 0} for p in PROVIDERS}
|
||||
stats = {"last_refresh": None, "last_error": None}
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(time.strftime("[%H:%M:%S]") + " " + msg, flush=True)
|
||||
|
||||
|
||||
def refresh_pools():
|
||||
if not os.path.exists(DB):
|
||||
stats["last_error"] = "no keys.db"
|
||||
return
|
||||
db = sqlite3.connect(f"file:{DB}?mode=ro", uri=True, timeout=60)
|
||||
try:
|
||||
for p, prov_name in DB_PROV.items():
|
||||
prefix = PROVIDERS[p][1]
|
||||
rows = db.execute(
|
||||
"SELECT val FROM keys WHERE prov=? AND status IN "
|
||||
"('WORKING','FREE','LIMITED') AND val LIKE ?",
|
||||
(prov_name, prefix + "%")).fetchall()
|
||||
with lock:
|
||||
old = set(state[p]["keys"])
|
||||
dead = state[p].get("dead", set())
|
||||
now = time.time()
|
||||
cd = {k: t for k, t in state[p]["cooldowns"].items()
|
||||
if now - t < COOLDOWN}
|
||||
state[p]["cooldowns"] = cd
|
||||
fresh = [r[0] for r in rows
|
||||
if r[0] not in cd and r[0] not in dead]
|
||||
added = len(set(fresh) - old)
|
||||
state[p]["keys"] = fresh
|
||||
if added:
|
||||
log(f"pool {p}: +{added} -> {len(fresh)} live")
|
||||
stats["last_refresh"] = time.strftime("%H:%M:%S")
|
||||
except Exception as e:
|
||||
stats["last_error"] = repr(e)
|
||||
log(f"refresh ERR {e!r}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def pick_key(pool, tried):
|
||||
with lock:
|
||||
keys = [k for k in pool["keys"] if k not in tried]
|
||||
if not keys:
|
||||
return None
|
||||
pool["idx"] = (pool["idx"] + 1) % len(keys)
|
||||
return keys[pool["idx"] % len(keys)]
|
||||
|
||||
_DEAD_STREAKS = {}
|
||||
_DEAD_STREAK_LIMIT = 3
|
||||
|
||||
def bench(pool, key, dead=False, prov=None):
|
||||
"""Remove key from pool. dead=True marks DEAD in keys.db only after
|
||||
_DEAD_STREAK_LIMIT consecutive dead marks (one 401-like must not
|
||||
permanently kill a key for all consumers)."""
|
||||
with lock:
|
||||
if dead:
|
||||
pool["dead"] = pool.get("dead", set()) | {key}
|
||||
else:
|
||||
pool["cooldowns"][key] = time.time()
|
||||
_DEAD_STREAKS.pop(key, None)
|
||||
if key in pool["keys"]:
|
||||
pool["keys"].remove(key)
|
||||
if dead:
|
||||
streak_key = (prov, key) if prov else key
|
||||
n = _DEAD_STREAKS[streak_key] = _DEAD_STREAKS.get(streak_key, 0) + 1
|
||||
if n < _DEAD_STREAK_LIMIT:
|
||||
log(f"key dead-streak {n}/{_DEAD_STREAK_LIMIT} (not marked in DB yet)")
|
||||
return
|
||||
if prov and prov in DB_PROV:
|
||||
try:
|
||||
dbw = sqlite3.connect(DB, timeout=2)
|
||||
dbw.execute("UPDATE keys SET status='DEAD' WHERE prov=? AND val=?", (DB_PROV[prov], key))
|
||||
dbw.commit()
|
||||
dbw.close()
|
||||
except Exception as e:
|
||||
log(f"DB mark-dead ERR {e!r}")
|
||||
else:
|
||||
log(f"skip DB mark-dead: unknown prov={prov!r} (key benched in-memory only)")
|
||||
log(f"key {'DEAD' if dead else 'benched'} ({len(pool['keys'])} left)")
|
||||
|
||||
|
||||
def route(model):
|
||||
for p, (_, _, prefixes) in PROVIDERS.items():
|
||||
if any(model.startswith(x) for x in prefixes):
|
||||
return p
|
||||
# bare model names fall back by heuristics
|
||||
if any(m in model.lower() for m in ("kimi", "glm", "qwen")):
|
||||
return "openrouter" # often routed via OR
|
||||
return None
|
||||
|
||||
|
||||
def forward(provider, key, body_bytes, api_key_hdr):
|
||||
base = PROVIDERS[provider][0]
|
||||
req = urllib.request.Request(base + "/chat/completions", data=body_bytes,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Accept": body_accept(body_bytes),
|
||||
**EXTRA_HEADERS.get(provider, {}),
|
||||
}, method="POST")
|
||||
try:
|
||||
return urllib.request.urlopen(req, timeout=300)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e
|
||||
|
||||
|
||||
def body_accept(body_bytes):
|
||||
try:
|
||||
if json.loads(body_bytes).get("stream"):
|
||||
return "text/event-stream"
|
||||
except Exception:
|
||||
pass
|
||||
return "application/json"
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def _json(self, code, obj):
|
||||
raw = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/v1/models"):
|
||||
out = [{"id": m, "object": "model"} for m in models_list()]
|
||||
return self._json(200, {"object": "list", "data": out})
|
||||
# dashboard
|
||||
with lock:
|
||||
pools = {p: {"live": len(s["keys"]), "ok": s["ok"], "fail": s["fail"]}
|
||||
for p, s in state.items()}
|
||||
self._json(200, {"pools": pools, **stats})
|
||||
|
||||
def do_POST(self):
|
||||
if not self.path.startswith("/v1/chat/completions"):
|
||||
return self._json(404, {"error": "not found"})
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(n) if n else b"{}"
|
||||
try:
|
||||
model = json.loads(body).get("model", "")
|
||||
except Exception:
|
||||
return self._json(400, {"error": "bad json"})
|
||||
provider = route(model)
|
||||
if not provider or not state[provider]["keys"]:
|
||||
return self._json(503, {"error": f"no live keys for model {model!r}"})
|
||||
tried = set()
|
||||
for _ in range(MAX_TRIES):
|
||||
key = pick_key(state[provider], tried)
|
||||
if not key:
|
||||
break
|
||||
tried.add(key)
|
||||
resp = forward(provider, key, body, None)
|
||||
code = getattr(resp, "code", resp.status if hasattr(resp, "status") else 200)
|
||||
if code in (401, 402): # invalid / zero balance -> permanent DEAD
|
||||
bench(state[provider], key, dead=True, prov=provider)
|
||||
elif code == 429: # rate limit -> temp cooldown
|
||||
bench(state[provider], key, prov=provider)
|
||||
else:
|
||||
pass # fall through to stream
|
||||
if code in (401, 402, 429):
|
||||
with lock:
|
||||
state[provider]["fail"] += 1
|
||||
continue
|
||||
# success or upstream app-level error: stream through
|
||||
with lock:
|
||||
state[provider]["ok"] += 1
|
||||
try:
|
||||
self.send_response(code)
|
||||
ctype = resp.headers.get("Content-Type", "application/json")
|
||||
self.send_header("Content-Type", ctype)
|
||||
if resp.headers.get("Content-Length"):
|
||||
self.send_header("Content-Length", resp.headers["Content-Length"])
|
||||
else:
|
||||
self.send_header("Transfer-Encoding", "chunked")
|
||||
self.end_headers()
|
||||
if "chunked" in (resp.headers.get("Transfer-Encoding") or ""):
|
||||
self._pump_chunked(resp)
|
||||
else:
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
return
|
||||
except Exception as e:
|
||||
log(f"stream ERR {e!r}")
|
||||
return
|
||||
self._json(502, {"error": f"all {len(tried)} keys failed for {model!r}"})
|
||||
|
||||
def _pump_chunked(self, resp):
|
||||
# upstream chunked SSE -> forward raw (we already set chunked)
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n")
|
||||
self.wfile.flush()
|
||||
self.wfile.write(b"0\r\n\r\n")
|
||||
|
||||
|
||||
def models_list():
|
||||
return [
|
||||
"deepseek-chat", "deepseek-reasoner",
|
||||
"openrouter/deepseek/deepseek-chat",
|
||||
"openrouter/anthropic/claude-sonnet-4.6",
|
||||
"openrouter/openai/gpt-5.4-mini",
|
||||
"mistral-large-latest", "mistral-small-latest",
|
||||
"gemini-2.0-flash", "gemini-2.5-flash",
|
||||
]
|
||||
|
||||
|
||||
def refresher():
|
||||
while True:
|
||||
refresh_pools()
|
||||
time.sleep(REFRESH)
|
||||
|
||||
|
||||
def main():
|
||||
refresh_pools()
|
||||
threading.Thread(target=refresher, daemon=True).start()
|
||||
srv = http.server.ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
log(f"keyfarm rotator on {HOST}:{PORT}")
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user