Files
keyhunter-parser/keyfarm_rotator.py

296 lines
12 KiB
Python
Executable File

#!/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()