| import os |
| import re |
| import json |
| import asyncio |
| import itertools |
| from typing import Any, Dict, List, Tuple, Optional |
|
|
| import httpx |
| from cryptography.fernet import Fernet |
| from fastapi import FastAPI, Request, Depends, HTTPException |
| from fastapi.responses import StreamingResponse, Response |
| from fastapi.middleware.cors import CORSMiddleware |
|
|
| |
| |
| |
| KEY = os.environ.get("KEY", "").strip() |
| AES_KEY = os.environ.get("AES_KEY", "").strip() |
|
|
| if not KEY: |
| raise RuntimeError("KEY secret is not set") |
| if not AES_KEY: |
| raise RuntimeError("AES_KEY secret is not set") |
|
|
| |
| |
| |
| _fernet = Fernet(AES_KEY.encode() if isinstance(AES_KEY, str) else AES_KEY) |
| with open("models.json.enc", "rb") as _f: |
| MODELS: Dict[str, Dict[str, Any]] = json.loads(_fernet.decrypt(_f.read())) |
|
|
| |
| _RR: Dict[str, itertools.cycle] = { |
| name: itertools.cycle(range(len(cfg["targets"]))) |
| for name, cfg in MODELS.items() |
| } |
| _RR_LOCKS: Dict[str, asyncio.Lock] = {name: asyncio.Lock() for name in MODELS} |
|
|
| |
| def _unique_endpoints() -> List[Tuple[str, str]]: |
| seen = set(); out = [] |
| for cfg in MODELS.values(): |
| for t in cfg["targets"]: |
| k = (t["url"], t["key"]) |
| if k not in seen: |
| seen.add(k); out.append(k) |
| return out |
|
|
| ENDPOINTS = _unique_endpoints() |
|
|
| |
| |
| |
| |
| |
| |
| |
| _AUTH_HEADERS = ("authorization", "x-api-key", "api-key", "x-goog-api-key") |
|
|
|
|
| def _provided_key(request: Request) -> Optional[str]: |
| for h in _AUTH_HEADERS: |
| v = request.headers.get(h) |
| if not v: |
| continue |
| v = v.strip() |
| if v.lower().startswith("bearer "): |
| v = v[7:].strip() |
| if v: |
| return v |
| |
| return request.query_params.get("api_key") or request.query_params.get("key") |
|
|
|
|
| async def require_auth(request: Request): |
| if _provided_key(request) == KEY: |
| return True |
| raise HTTPException(status_code=401, detail="invalid api key") |
|
|
| |
| |
| |
| app = FastAPI( |
| title="router", |
| docs_url=None, |
| redoc_url=None, |
| openapi_url=None, |
| ) |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=False, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| expose_headers=["*"], |
| ) |
|
|
| @app.get("/docs", include_in_schema=False) |
| @app.get("/redoc", include_in_schema=False) |
| @app.get("/openapi.json", include_in_schema=False) |
| @app.get("/docs.json", include_in_schema=False) |
| async def _block_docs(): |
| raise HTTPException(status_code=404) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import time as _time |
| _BAN: Dict[Tuple[str, str], float] = {} |
| |
| |
| |
| _BAN_TTL_LONG = 3600.0 |
| _BAN_TTL_MED = 120.0 |
| _BAN_TTL_SHORT = 20.0 |
| _PROBE_INTERVAL = 600.0 |
| _PROBE_CONCURRENCY = 40 |
|
|
|
|
| |
| |
| |
| |
| _CLIENT: Optional[httpx.AsyncClient] = None |
|
|
|
|
| async def _client() -> httpx.AsyncClient: |
| global _CLIENT |
| if _CLIENT is None or _CLIENT.is_closed: |
| _CLIENT = httpx.AsyncClient( |
| timeout=httpx.Timeout(600.0, connect=10.0, read=600.0), |
| limits=httpx.Limits( |
| max_connections=300, |
| max_keepalive_connections=80, |
| keepalive_expiry=300.0, |
| ), |
| ) |
| return _CLIENT |
|
|
|
|
| def _ban(target, ttl=_BAN_TTL_MED): |
| _BAN[(target["url"], target["key"])] = _time.monotonic() + ttl |
|
|
|
|
| def _is_banned(target) -> bool: |
| exp = _BAN.get((target["url"], target["key"])) |
| return exp is not None and exp > _time.monotonic() |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| async def _probe_balance(client: httpx.AsyncClient, url: str, key: str) -> bool: |
| try: |
| r = await client.post( |
| url.rstrip("/") + "/chat/completions", |
| headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}, |
| json={"model": "glm-4.6", "messages": [{"role": "user", "content": "."}], "max_tokens": 1}, |
| timeout=15.0, |
| ) |
| except Exception: |
| return False |
| if r.status_code != 200: |
| return False |
| return not _is_retryable_error(r.content) |
|
|
|
|
| async def _classify_all(): |
| """One-time bootstrap: probe EVERY key once to populate the ban table fast |
| (~15s at concurrency 40) so the first real request doesn't walk 640 dead |
| keys. Good keys may briefly 429 but recover within a minute.""" |
| sem = asyncio.Semaphore(_PROBE_CONCURRENCY) |
| c = await _client() |
| async def one(u, k): |
| async with sem: |
| ok = await _probe_balance(c, u, k) |
| if ok: |
| _BAN.pop((u, k), None) |
| else: |
| _BAN[(u, k)] = _time.monotonic() + _BAN_TTL_LONG |
| await asyncio.gather(*[one(u, k) for u, k in ENDPOINTS]) |
|
|
|
|
| async def _probe_all(): |
| """Re-probe BANNED keys whose ban is about to expire, to catch recoveries. |
| Working/un-banned keys are never probed -- live traffic validates them and |
| the prober would just 429 them. This is a recovery sweep, not a full scan.""" |
| sem = asyncio.Semaphore(_PROBE_CONCURRENCY) |
| c = await _client() |
| now = _time.monotonic() |
| |
| horizon = now + _PROBE_INTERVAL + 60 |
| todo = [(u, k) for (u, k) in ENDPOINTS |
| if _BAN.get((u, k), 0) <= horizon and _BAN.get((u, k), 0) > now] |
|
|
| async def one(u, k): |
| async with sem: |
| ok = await _probe_balance(c, u, k) |
| if ok: |
| _BAN.pop((u, k), None) |
| else: |
| _BAN[(u, k)] = _time.monotonic() + _BAN_TTL_LONG |
| if todo: |
| await asyncio.gather(*[one(u, k) for u, k in todo]) |
|
|
|
|
| async def _prober_loop(): |
| |
| try: |
| await _classify_all() |
| except Exception: |
| pass |
| |
| |
| while True: |
| await asyncio.sleep(_PROBE_INTERVAL) |
| try: |
| await _probe_all() |
| except Exception: |
| pass |
|
|
|
|
| @app.on_event("startup") |
| async def _start_prober(): |
| await _client() |
| asyncio.create_task(_prober_loop()) |
|
|
|
|
| @app.on_event("shutdown") |
| async def _close_client(): |
| global _CLIENT |
| if _CLIENT is not None: |
| await _CLIENT.aclose() |
| _CLIENT = None |
|
|
|
|
| async def _pick_target(model_name: str): |
| """Round-robin next target, skipping banned ones unless all are banned.""" |
| cfg = MODELS[model_name] |
| targets = cfg["targets"] |
| n = len(targets) |
| async with _RR_LOCKS[model_name]: |
| |
| for _ in range(n): |
| idx = next(_RR[model_name]) |
| t = targets[idx] |
| if not _is_banned(t): |
| return t |
| |
| |
| idx = next(_RR[model_name]) |
| return targets[idx] |
|
|
| |
| _HOP = { |
| "host", "content-length", "transfer-encoding", "connection", |
| "keep-alive", "proxy-authenticate", "proxy-authorization", |
| "te", "trailers", "upgrade", |
| } |
|
|
|
|
| def _resolve_model(payload: Optional[dict], request: Request) -> Optional[str]: |
| """Find the requested model from JSON body, query, or header.""" |
| if isinstance(payload, dict): |
| m = payload.get("model") |
| if isinstance(m, str) and m: |
| return m |
| m = request.query_params.get("model") |
| if m: |
| return m |
| m = request.headers.get("x-router-model") |
| if m: |
| return m |
| return None |
|
|
| |
| |
| |
| _RETRYABLE_CODES = {"1111", "1112", "1113", "1114", "1115", "1116", "1117", "1261"} |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def _detect_format(path: str) -> str: |
| p = path.lower().lstrip("/") |
| if ( |
| p.startswith("v1/messages") |
| or p == "messages" |
| or p.startswith("messages/") |
| or "count_tokens" in p |
| ): |
| return "anthropic" |
| if p.startswith("v1beta/") or ":generatecontent" in p or ":streamgeneratecontent" in p: |
| return "gemini" |
| return "openai" |
|
|
|
|
| _ANTR_RE = re.compile(r"^(https?://[^/]+/api)(/.*)?$") |
|
|
|
|
| def _target_base(target_url: str, fmt: str) -> str: |
| """Map an OpenAI-format target base to the right base for the protocol.""" |
| base = target_url.rstrip("/") |
| if fmt == "anthropic": |
| m = _ANTR_RE.match(base) |
| if m: |
| return m.group(1) + "/anthropic" |
| return base |
| |
| return base |
|
|
|
|
| def _forward_path(path: str, fmt: str) -> str: |
| """Compute the path segment to append to the upstream base.""" |
| p = path.lstrip("/") |
| if fmt == "openai": |
| |
| if p.startswith("v1/"): |
| p = p[3:] |
| return p |
| if fmt == "anthropic": |
| |
| if not p.startswith("v1/"): |
| p = "v1/" + p |
| return p |
| return p |
|
|
|
|
| _GEM_MODEL_RE = re.compile(r"/models/([^/:]+)", re.I) |
|
|
|
|
| def _extract_model_gemini(path: str) -> Optional[str]: |
| m = _GEM_MODEL_RE.search("/" + path) |
| return m.group(1) if m else None |
|
|
|
|
|
|
| def _is_retryable_error(buf: bytes) -> bool: |
| """Return True if buf is a GLM error JSON we should retry on another key. |
| |
| Handles both protocol shapes: |
| OpenAI: {"error":{"code":"1113","message":"..."}} |
| Anthropic: {"type":"error","error":{"type":"...","code":"1113","message":"..."}} |
| """ |
| if not buf or buf[:1] != b"{": |
| return False |
| try: |
| j = json.loads(buf) |
| except Exception: |
| return False |
| if not isinstance(j, dict): |
| return False |
| |
| err = j.get("error") if isinstance(j.get("error"), dict) else {} |
| code = str(err.get("code") or j.get("code") or "") |
| if code in _RETRYABLE_CODES: |
| return True |
| msg = str(err.get("message") or j.get("message") or "").lower() |
| if any(s in msg for s in ("balance", "quota", "余额", "配额", "insufficient")): |
| return True |
| |
| etype = str(err.get("type") or "").lower() |
| if etype in ("rate_limit_error", "overloaded_error"): |
| return True |
| return False |
|
|
| |
| |
| |
| |
| |
| @app.get("/health") |
| @app.get("/healthz") |
| async def health(): |
| now_mono = _time.monotonic() |
| banned = sum(1 for exp in _BAN.values() if exp > now_mono) |
| return { |
| "total": len(ENDPOINTS), |
| "available": len(ENDPOINTS) - banned, |
| "banned": banned, |
| "models": len(MODELS), |
| } |
|
|
| |
| |
| |
| @app.get("/v1/models") |
| async def list_models(): |
| return { |
| "object": "list", |
| "data": [ |
| {"id": name, "object": "model", "owned_by": "router"} |
| for name in MODELS |
| ], |
| } |
|
|
| @app.get("/") |
| async def root(): |
| now_mono = _time.monotonic() |
| banned = sum(1 for exp in _BAN.values() if exp > now_mono) |
| return { |
| "ok": True, |
| "models": len(MODELS), |
| "endpoints": len(ENDPOINTS), |
| "available": len(ENDPOINTS) - banned, |
| "banned": banned, |
| } |
|
|
| |
| |
| |
| |
| |
| @app.api_route( |
| "/{path:path}", |
| methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"], |
| dependencies=[Depends(require_auth)], |
| ) |
| async def proxy(path: str, request: Request): |
| fmt = _detect_format(path) |
|
|
| raw = await request.body() |
| ctype = request.headers.get("content-type", "") |
|
|
| |
| |
| |
| payload: Optional[dict] = None |
| body_to_send: bytes = raw |
| is_json = "application/json" in ctype.lower() or ctype == "" |
| if is_json and raw: |
| try: |
| payload = json.loads(raw) |
| if not isinstance(payload, dict): |
| payload = None |
| except Exception: |
| payload = None |
|
|
| |
| |
| |
| model_name = _resolve_model(payload, request) |
| if model_name is None and fmt == "gemini": |
| model_name = _extract_model_gemini(path) |
| if not model_name or model_name not in MODELS: |
| raise HTTPException( |
| status_code=400, |
| detail=f"unknown model: {model_name!r}; available: {list(MODELS)}", |
| ) |
|
|
| cfg = MODELS[model_name] |
| n_targets = len(cfg["targets"]) |
| last_err = "no targets" |
| fwd_rel = _forward_path(path, fmt) |
|
|
| for _ in range(n_targets): |
| target = await _pick_target(model_name) |
| base = _target_base(target["url"], fmt) |
|
|
| |
| |
| |
| if isinstance(payload, dict): |
| out = dict(payload) |
| out["model"] = target["upstream_model"] |
| send_bytes = json.dumps(out, ensure_ascii=False).encode() |
| else: |
| send_bytes = body_to_send |
|
|
| url = base.rstrip("/") + "/" + fwd_rel.lstrip("/") |
|
|
| |
| |
| fwd = { |
| k: v for k, v in request.headers.items() |
| if k.lower() not in _HOP |
| and k.lower() not in _AUTH_HEADERS |
| } |
| |
| if fmt == "anthropic": |
| fwd["x-api-key"] = target["key"] |
| else: |
| fwd["Authorization"] = "Bearer " + target["key"] |
| if send_bytes: |
| fwd["content-length"] = str(len(send_bytes)) |
| if "content-type" not in {k.lower() for k in fwd} and ctype: |
| fwd["Content-Type"] = ctype |
|
|
| query = str(request.url.query) |
|
|
| client = await _client() |
| try: |
| req = client.build_request( |
| request.method, url, |
| headers=fwd, |
| content=send_bytes if send_bytes else None, |
| params=query or None, |
| ) |
| resp = await client.send(req, stream=True) |
| except Exception as e: |
| _ban(target, _BAN_TTL_MED) |
| last_err = f"connect: {e}" |
| continue |
|
|
| |
| |
| |
| |
| |
| |
| |
| ctype_resp = resp.headers.get("content-type", "") |
| is_sse = ctype_resp.lower().startswith("text/event-stream") |
|
|
| if resp.status_code >= 400 or ("application/json" in ctype_resp.lower() and not is_sse): |
| body_buf = await resp.aread() |
| await resp.aclose() |
| retryable = _is_retryable_error(body_buf) |
| if retryable: |
| |
| _ban(target, _BAN_TTL_LONG) |
| last_err = f"drained: {body_buf[:120]!r}" |
| continue |
| if resp.status_code == 429: |
| |
| _ban(target, _BAN_TTL_SHORT) |
| last_err = f"rate-limited: {body_buf[:120]!r}" |
| continue |
| if resp.status_code in (401, 403): |
| _ban(target, _BAN_TTL_LONG) |
| last_err = f"auth {resp.status_code}: {body_buf[:120]!r}" |
| continue |
| if resp.status_code >= 500: |
| _ban(target, _BAN_TTL_MED) |
| last_err = f"upstream {resp.status_code}: {body_buf[:120]!r}" |
| continue |
| |
| resp_headers = { |
| k: v for k, v in resp.headers.items() |
| if k.lower() not in _HOP and k.lower() != "content-encoding" |
| } |
| return Response( |
| content=body_buf, |
| status_code=resp.status_code, |
| headers=resp_headers, |
| media_type=resp.headers.get("content-type"), |
| ) |
|
|
| |
| |
| resp_headers = { |
| k: v for k, v in resp.headers.items() |
| if k.lower() not in _HOP |
| } |
|
|
| async def gen(): |
| try: |
| async for chunk in resp.aiter_raw(): |
| if chunk: |
| yield chunk |
| finally: |
| await resp.aclose() |
|
|
| return StreamingResponse( |
| gen(), |
| status_code=resp.status_code, |
| headers=resp_headers, |
| media_type=resp.headers.get("content-type"), |
| ) |
|
|
| raise HTTPException(status_code=502, detail=f"all targets failed: {last_err}") |
|
|