296 lines
12 KiB
Python
Executable File
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()
|