apiarium commited on
Commit
6154d6a
·
0 Parent(s):

glm router: encrypted multi-harness proxy (683 keys)

Browse files

KEY FIX: GLM returns drained-key errors (code 1113) with HTTP 429 status,
not real rate-limits. The proxy was short-banning (20s) drained keys so
every request re-walked ~640 dead keys. Now body is inspected: 1113 ->
1h ban, genuine 429 -> 20s. glm-4.5-air now 0.5s, glm-4.6 ~3-5s (its
own reasoning floor).

Shared connection pool, bootstrap classify-all on startup + recovery-only
sweeps, tiered ban TTLs, multi-harness (OpenAI+Anthropic+Gemini paths),
round-robin + per-key retry, encrypted models.json (AES_KEY), instant
/health, / and /v1/models public, docs blocked.

Files changed (7) hide show
  1. .gitattributes +35 -0
  2. .gitignore +8 -0
  3. Dockerfile +7 -0
  4. README.md +9 -0
  5. app.py +591 -0
  6. models.json.enc +0 -0
  7. requirements.txt +4 -0
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ models.json
6
+ *.tmp
7
+ .env
8
+ test_*.py
Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY app.py models.json.enc ./
6
+ EXPOSE 7860
7
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: glm
3
+ emoji: 🐝
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
app.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import asyncio
5
+ import itertools
6
+ from typing import Any, Dict, List, Tuple, Optional
7
+
8
+ import httpx
9
+ from cryptography.fernet import Fernet
10
+ from fastapi import FastAPI, Request, Depends, HTTPException
11
+ from fastapi.responses import StreamingResponse, Response
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+
14
+ # ----------------------------------------------------------------------------
15
+ # Config from secrets
16
+ # ----------------------------------------------------------------------------
17
+ KEY = os.environ.get("KEY", "").strip()
18
+ AES_KEY = os.environ.get("AES_KEY", "").strip()
19
+
20
+ if not KEY:
21
+ raise RuntimeError("KEY secret is not set")
22
+ if not AES_KEY:
23
+ raise RuntimeError("AES_KEY secret is not set")
24
+
25
+ # ----------------------------------------------------------------------------
26
+ # Decrypt models.json at runtime. The plaintext is never on disk.
27
+ # ----------------------------------------------------------------------------
28
+ _fernet = Fernet(AES_KEY.encode() if isinstance(AES_KEY, str) else AES_KEY)
29
+ with open("models.json.enc", "rb") as _f:
30
+ MODELS: Dict[str, Dict[str, Any]] = json.loads(_fernet.decrypt(_f.read()))
31
+
32
+ # Per-model round-robin state
33
+ _RR: Dict[str, itertools.cycle] = {
34
+ name: itertools.cycle(range(len(cfg["targets"])))
35
+ for name, cfg in MODELS.items()
36
+ }
37
+ _RR_LOCKS: Dict[str, asyncio.Lock] = {name: asyncio.Lock() for name in MODELS}
38
+
39
+ # Unique (url, key) endpoints across all models, for /health probing
40
+ def _unique_endpoints() -> List[Tuple[str, str]]:
41
+ seen = set(); out = []
42
+ for cfg in MODELS.values():
43
+ for t in cfg["targets"]:
44
+ k = (t["url"], t["key"])
45
+ if k not in seen:
46
+ seen.add(k); out.append(k)
47
+ return out
48
+
49
+ ENDPOINTS = _unique_endpoints()
50
+
51
+ # ----------------------------------------------------------------------------
52
+ # Auth -- accept the key from any header a harness might send:
53
+ # Authorization: Bearer KEY (OpenAI SDK, LiteLLM, ...)
54
+ # x-api-key: KEY (Anthropic SDK, Claude Code)
55
+ # api-key: KEY (Azure-style)
56
+ # x-goog-api-key: KEY (Google AI SDK)
57
+ # ----------------------------------------------------------------------------
58
+ _AUTH_HEADERS = ("authorization", "x-api-key", "api-key", "x-goog-api-key")
59
+
60
+
61
+ def _provided_key(request: Request) -> Optional[str]:
62
+ for h in _AUTH_HEADERS:
63
+ v = request.headers.get(h)
64
+ if not v:
65
+ continue
66
+ v = v.strip()
67
+ if v.lower().startswith("bearer "):
68
+ v = v[7:].strip()
69
+ if v:
70
+ return v
71
+ # allow ?api_key= / ?key= query too
72
+ return request.query_params.get("api_key") or request.query_params.get("key")
73
+
74
+
75
+ async def require_auth(request: Request):
76
+ if _provided_key(request) == KEY:
77
+ return True
78
+ raise HTTPException(status_code=401, detail="invalid api key")
79
+
80
+ # ----------------------------------------------------------------------------
81
+ # App -- docs/redoc/openapi fully disabled and explicitly blocked
82
+ # ----------------------------------------------------------------------------
83
+ app = FastAPI(
84
+ title="router",
85
+ docs_url=None,
86
+ redoc_url=None,
87
+ openapi_url=None,
88
+ )
89
+
90
+ # CORS -- browser harnesses (Continue, web UIs, etc.) send preflight OPTIONS.
91
+ app.add_middleware(
92
+ CORSMiddleware,
93
+ allow_origins=["*"],
94
+ allow_credentials=False,
95
+ allow_methods=["*"],
96
+ allow_headers=["*"],
97
+ expose_headers=["*"],
98
+ )
99
+
100
+ @app.get("/docs", include_in_schema=False)
101
+ @app.get("/redoc", include_in_schema=False)
102
+ @app.get("/openapi.json", include_in_schema=False)
103
+ @app.get("/docs.json", include_in_schema=False)
104
+ async def _block_docs():
105
+ raise HTTPException(status_code=404)
106
+
107
+ # ----------------------------------------------------------------------------
108
+ # Helpers
109
+ # ----------------------------------------------------------------------------
110
+ # ----------------------------------------------------------------------------
111
+ # Cooldown / circuit-breaker: when a target fails with a retryable error,
112
+ # it is banned for BAN_TTL seconds. _pick_target skips banned targets and
113
+ # only falls back to them if every target is currently banned (so a cold
114
+ # start or all-banned state still serves the request).
115
+ # ----------------------------------------------------------------------------
116
+ import time as _time
117
+ _BAN: Dict[Tuple[str, str], float] = {}
118
+ # Tiered bans: 1113 = dead account (rarely recovers); 429 = transient;
119
+ # connect/5xx = probably transient. Long bans stop the prober from
120
+ # re-testing confirmed-dead keys every cycle.
121
+ _BAN_TTL_LONG = 3600.0 # 1113 insufficient balance / 401 / 403
122
+ _BAN_TTL_MED = 120.0 # connect error / 5xx
123
+ _BAN_TTL_SHORT = 20.0 # 429 rate limit (recovers fast)
124
+ _PROBE_INTERVAL = 600.0 # re-probe every 10 min (cheaper than 4)
125
+ _PROBE_CONCURRENCY = 40 # fast first-cycle classification (~15s for 683)
126
+
127
+
128
+ # Shared connection pool -- ONE persistent client reused by every request
129
+ # and by the prober. This eliminates the TLS handshake cost (~200-400ms)
130
+ # that a per-request httpx.AsyncClient pays on every retry. Keep-alive keeps
131
+ # the TCP+TLS connection to api.z.ai / bigmodel.cn warm.
132
+ _CLIENT: Optional[httpx.AsyncClient] = None
133
+
134
+
135
+ async def _client() -> httpx.AsyncClient:
136
+ global _CLIENT
137
+ if _CLIENT is None or _CLIENT.is_closed:
138
+ _CLIENT = httpx.AsyncClient(
139
+ timeout=httpx.Timeout(600.0, connect=10.0, read=600.0),
140
+ limits=httpx.Limits(
141
+ max_connections=300,
142
+ max_keepalive_connections=80,
143
+ keepalive_expiry=300.0,
144
+ ),
145
+ )
146
+ return _CLIENT
147
+
148
+
149
+ def _ban(target, ttl=_BAN_TTL_MED):
150
+ _BAN[(target["url"], target["key"])] = _time.monotonic() + ttl
151
+
152
+
153
+ def _is_banned(target) -> bool:
154
+ exp = _BAN.get((target["url"], target["key"]))
155
+ return exp is not None and exp > _time.monotonic()
156
+
157
+
158
+ # ----------------------------------------------------------------------------
159
+ # Background balance prober. /models lies -- it returns 200 even for drained
160
+ # keys. Only a real 1-token chat reveals the 1113 (insufficient balance)
161
+ # error. This task continuously classifies every key, banning drained ones
162
+ # and keeping the good pool hot so requests hit a good key on the first try.
163
+ # Cost: ~1 token per good key per cycle (drained keys error out at 0 cost).
164
+ # ----------------------------------------------------------------------------
165
+ async def _probe_balance(client: httpx.AsyncClient, url: str, key: str) -> bool:
166
+ try:
167
+ r = await client.post(
168
+ url.rstrip("/") + "/chat/completions",
169
+ headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"},
170
+ json={"model": "glm-4.6", "messages": [{"role": "user", "content": "."}], "max_tokens": 1},
171
+ timeout=15.0,
172
+ )
173
+ except Exception:
174
+ return False
175
+ if r.status_code != 200:
176
+ return False
177
+ return not _is_retryable_error(r.content)
178
+
179
+
180
+ async def _classify_all():
181
+ """One-time bootstrap: probe EVERY key once to populate the ban table fast
182
+ (~15s at concurrency 40) so the first real request doesn't walk 640 dead
183
+ keys. Good keys may briefly 429 but recover within a minute."""
184
+ sem = asyncio.Semaphore(_PROBE_CONCURRENCY)
185
+ c = await _client()
186
+ async def one(u, k):
187
+ async with sem:
188
+ ok = await _probe_balance(c, u, k)
189
+ if ok:
190
+ _BAN.pop((u, k), None)
191
+ else:
192
+ _BAN[(u, k)] = _time.monotonic() + _BAN_TTL_LONG
193
+ await asyncio.gather(*[one(u, k) for u, k in ENDPOINTS])
194
+
195
+
196
+ async def _probe_all():
197
+ """Re-probe BANNED keys whose ban is about to expire, to catch recoveries.
198
+ Working/un-banned keys are never probed -- live traffic validates them and
199
+ the prober would just 429 them. This is a recovery sweep, not a full scan."""
200
+ sem = asyncio.Semaphore(_PROBE_CONCURRENCY)
201
+ c = await _client()
202
+ now = _time.monotonic()
203
+ # Keys banned within the next PROBE_INTERVAL + buffer are candidates.
204
+ horizon = now + _PROBE_INTERVAL + 60
205
+ todo = [(u, k) for (u, k) in ENDPOINTS
206
+ if _BAN.get((u, k), 0) <= horizon and _BAN.get((u, k), 0) > now]
207
+
208
+ async def one(u, k):
209
+ async with sem:
210
+ ok = await _probe_balance(c, u, k)
211
+ if ok:
212
+ _BAN.pop((u, k), None) # recovered -> available again
213
+ else:
214
+ _BAN[(u, k)] = _time.monotonic() + _BAN_TTL_LONG
215
+ if todo:
216
+ await asyncio.gather(*[one(u, k) for u, k in todo])
217
+
218
+
219
+ async def _prober_loop():
220
+ # Bootstrap: classify all keys once so the ban table is populated.
221
+ try:
222
+ await _classify_all()
223
+ except Exception:
224
+ pass
225
+ # Steady state: only sweep recovering banned keys, never touch working
226
+ # ones (live traffic validates those).
227
+ while True:
228
+ await asyncio.sleep(_PROBE_INTERVAL)
229
+ try:
230
+ await _probe_all()
231
+ except Exception:
232
+ pass
233
+
234
+
235
+ @app.on_event("startup")
236
+ async def _start_prober():
237
+ await _client() # warm the connection pool
238
+ asyncio.create_task(_prober_loop())
239
+
240
+
241
+ @app.on_event("shutdown")
242
+ async def _close_client():
243
+ global _CLIENT
244
+ if _CLIENT is not None:
245
+ await _CLIENT.aclose()
246
+ _CLIENT = None
247
+
248
+
249
+ async def _pick_target(model_name: str):
250
+ """Round-robin next target, skipping banned ones unless all are banned."""
251
+ cfg = MODELS[model_name]
252
+ targets = cfg["targets"]
253
+ n = len(targets)
254
+ async with _RR_LOCKS[model_name]:
255
+ # First pass: find the next non-banned target.
256
+ for _ in range(n):
257
+ idx = next(_RR[model_name])
258
+ t = targets[idx]
259
+ if not _is_banned(t):
260
+ return t
261
+ # Everything is banned -- return the round-robin pick anyway so the
262
+ # request still goes out. Ban timestamps will have started expiring.
263
+ idx = next(_RR[model_name])
264
+ return targets[idx]
265
+
266
+ # hop-by-hop / router-only headers we never copy upstream or downstream
267
+ _HOP = {
268
+ "host", "content-length", "transfer-encoding", "connection",
269
+ "keep-alive", "proxy-authenticate", "proxy-authorization",
270
+ "te", "trailers", "upgrade",
271
+ }
272
+
273
+
274
+ def _resolve_model(payload: Optional[dict], request: Request) -> Optional[str]:
275
+ """Find the requested model from JSON body, query, or header."""
276
+ if isinstance(payload, dict):
277
+ m = payload.get("model")
278
+ if isinstance(m, str) and m:
279
+ return m
280
+ m = request.query_params.get("model")
281
+ if m:
282
+ return m
283
+ m = request.headers.get("x-router-model")
284
+ if m:
285
+ return m
286
+ return None
287
+
288
+ # GLM/Zhipu per-key error codes that mean "this key is unusable, try another":
289
+ # 1111/1112 invalid or expired key, 1113 insufficient balance,
290
+ # 1114/1115/1116/1117 quota / resource-pack exhausted.
291
+ _RETRYABLE_CODES = {"1111", "1112", "1113", "1114", "1115", "1116", "1117", "1261"}
292
+
293
+
294
+ # ----------------------------------------------------------------------------
295
+ # Protocol / harness detection
296
+ # ----------------------------------------------------------------------------
297
+ # GLM exposes two native protocol surfaces:
298
+ # OpenAI-compatible: <host>/api/paas/v4/<path> (and /api/coding/paas/v4)
299
+ # Anthropic-compatible: <host>/api/anthropic/<path>
300
+ # We detect which harness is calling us from the request path and route to the
301
+ # matching upstream base, so OpenAI SDK, Anthropic SDK / Claude Code, LiteLLM,
302
+ # LangChain, Cline, Continue, aider, etc. all work with zero translation.
303
+
304
+ def _detect_format(path: str) -> str:
305
+ p = path.lower().lstrip("/")
306
+ if (
307
+ p.startswith("v1/messages")
308
+ or p == "messages"
309
+ or p.startswith("messages/")
310
+ or "count_tokens" in p
311
+ ):
312
+ return "anthropic"
313
+ if p.startswith("v1beta/") or ":generatecontent" in p or ":streamgeneratecontent" in p:
314
+ return "gemini"
315
+ return "openai"
316
+
317
+
318
+ _ANTR_RE = re.compile(r"^(https?://[^/]+/api)(/.*)?$")
319
+
320
+
321
+ def _target_base(target_url: str, fmt: str) -> str:
322
+ """Map an OpenAI-format target base to the right base for the protocol."""
323
+ base = target_url.rstrip("/")
324
+ if fmt == "anthropic":
325
+ m = _ANTR_RE.match(base)
326
+ if m:
327
+ return m.group(1) + "/anthropic"
328
+ return base
329
+ # openai + gemini (gemini falls back to openai base, best-effort)
330
+ return base
331
+
332
+
333
+ def _forward_path(path: str, fmt: str) -> str:
334
+ """Compute the path segment to append to the upstream base."""
335
+ p = path.lstrip("/")
336
+ if fmt == "openai":
337
+ # base already ends in /v4; drop a leading v1/ from the harness path
338
+ if p.startswith("v1/"):
339
+ p = p[3:]
340
+ return p
341
+ if fmt == "anthropic":
342
+ # upstream anthropic base has no version; the harness's /v1/ stays
343
+ if not p.startswith("v1/"):
344
+ p = "v1/" + p
345
+ return p
346
+ return p # gemini passthrough
347
+
348
+
349
+ _GEM_MODEL_RE = re.compile(r"/models/([^/:]+)", re.I)
350
+
351
+
352
+ def _extract_model_gemini(path: str) -> Optional[str]:
353
+ m = _GEM_MODEL_RE.search("/" + path)
354
+ return m.group(1) if m else None
355
+
356
+
357
+
358
+ def _is_retryable_error(buf: bytes) -> bool:
359
+ """Return True if buf is a GLM error JSON we should retry on another key.
360
+
361
+ Handles both protocol shapes:
362
+ OpenAI: {"error":{"code":"1113","message":"..."}}
363
+ Anthropic: {"type":"error","error":{"type":"...","code":"1113","message":"..."}}
364
+ """
365
+ if not buf or buf[:1] != b"{":
366
+ return False
367
+ try:
368
+ j = json.loads(buf)
369
+ except Exception:
370
+ return False
371
+ if not isinstance(j, dict):
372
+ return False
373
+ # Both shapes carry the GLM code under error.code or top-level code.
374
+ err = j.get("error") if isinstance(j.get("error"), dict) else {}
375
+ code = str(err.get("code") or j.get("code") or "")
376
+ if code in _RETRYABLE_CODES:
377
+ return True
378
+ msg = str(err.get("message") or j.get("message") or "").lower()
379
+ if any(s in msg for s in ("balance", "quota", "余额", "配额", "insufficient")):
380
+ return True
381
+ # Anthropic rate_limit_error / overloaded -> try another key.
382
+ etype = str(err.get("type") or "").lower()
383
+ if etype in ("rate_limit_error", "overloaded_error"):
384
+ return True
385
+ return False
386
+
387
+ # ----------------------------------------------------------------------------
388
+ # Health -- instant. Reads the ban table maintained by the background
389
+ # prober. No outbound calls (the old version fired 683 probes per request
390
+ # and starved the event loop).
391
+ # ----------------------------------------------------------------------------
392
+ @app.get("/health")
393
+ @app.get("/healthz")
394
+ async def health():
395
+ now_mono = _time.monotonic()
396
+ banned = sum(1 for exp in _BAN.values() if exp > now_mono)
397
+ return {
398
+ "total": len(ENDPOINTS),
399
+ "available": len(ENDPOINTS) - banned,
400
+ "banned": banned,
401
+ "models": len(MODELS),
402
+ }
403
+
404
+ # ----------------------------------------------------------------------------
405
+ # Model list
406
+ # ----------------------------------------------------------------------------
407
+ @app.get("/v1/models")
408
+ async def list_models():
409
+ return {
410
+ "object": "list",
411
+ "data": [
412
+ {"id": name, "object": "model", "owned_by": "router"}
413
+ for name in MODELS
414
+ ],
415
+ }
416
+
417
+ @app.get("/")
418
+ async def root():
419
+ now_mono = _time.monotonic()
420
+ banned = sum(1 for exp in _BAN.values() if exp > now_mono)
421
+ return {
422
+ "ok": True,
423
+ "models": len(MODELS),
424
+ "endpoints": len(ENDPOINTS),
425
+ "available": len(ENDPOINTS) - banned,
426
+ "banned": banned,
427
+ }
428
+
429
+ # ----------------------------------------------------------------------------
430
+ # Transparent catch-all proxy. Mounted under /{path:path} so harnesses that
431
+ # include /v1/ (OpenAI/Anthropic) AND those that drop it both work. Public
432
+ # routes above (/health, /v1/models, /) are matched before this catch-all.
433
+ # ----------------------------------------------------------------------------
434
+ @app.api_route(
435
+ "/{path:path}",
436
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
437
+ dependencies=[Depends(require_auth)],
438
+ )
439
+ async def proxy(path: str, request: Request):
440
+ fmt = _detect_format(path)
441
+
442
+ raw = await request.body()
443
+ ctype = request.headers.get("content-type", "")
444
+
445
+ # Parse body only if JSON -- otherwise forward bytes verbatim (multipart,
446
+ # binary, etc.). Tools / messages / stream / thinking all live in JSON
447
+ # and are passed through untouched.
448
+ payload: Optional[dict] = None
449
+ body_to_send: bytes = raw
450
+ is_json = "application/json" in ctype.lower() or ctype == ""
451
+ if is_json and raw:
452
+ try:
453
+ payload = json.loads(raw)
454
+ if not isinstance(payload, dict):
455
+ payload = None
456
+ except Exception:
457
+ payload = None
458
+
459
+ # Model resolution works across all harnesses:
460
+ # OpenAI/Anthropic: "model" in JSON body
461
+ # Gemini: model in URL path (v1beta/models/<model>:generateContent)
462
+ model_name = _resolve_model(payload, request)
463
+ if model_name is None and fmt == "gemini":
464
+ model_name = _extract_model_gemini(path)
465
+ if not model_name or model_name not in MODELS:
466
+ raise HTTPException(
467
+ status_code=400,
468
+ detail=f"unknown model: {model_name!r}; available: {list(MODELS)}",
469
+ )
470
+
471
+ cfg = MODELS[model_name]
472
+ n_targets = len(cfg["targets"])
473
+ last_err = "no targets"
474
+ fwd_rel = _forward_path(path, fmt)
475
+
476
+ for _ in range(n_targets):
477
+ target = await _pick_target(model_name)
478
+ base = _target_base(target["url"], fmt)
479
+
480
+ # Build outbound body: swap ONLY the model field when JSON. For Gemini
481
+ # the model lives in the URL path; since upstream_model == model here
482
+ # we forward the path verbatim.
483
+ if isinstance(payload, dict):
484
+ out = dict(payload)
485
+ out["model"] = target["upstream_model"]
486
+ send_bytes = json.dumps(out, ensure_ascii=False).encode()
487
+ else:
488
+ send_bytes = body_to_send
489
+
490
+ url = base.rstrip("/") + "/" + fwd_rel.lstrip("/")
491
+
492
+ # Forward every client header transparently except hop-by-hop + the
493
+ # auth headers we must override for the upstream.
494
+ fwd = {
495
+ k: v for k, v in request.headers.items()
496
+ if k.lower() not in _HOP
497
+ and k.lower() not in _AUTH_HEADERS
498
+ }
499
+ # Anthropic upstream wants x-api-key, OpenAI/Gemini want Bearer.
500
+ if fmt == "anthropic":
501
+ fwd["x-api-key"] = target["key"]
502
+ else:
503
+ fwd["Authorization"] = "Bearer " + target["key"]
504
+ if send_bytes:
505
+ fwd["content-length"] = str(len(send_bytes))
506
+ if "content-type" not in {k.lower() for k in fwd} and ctype:
507
+ fwd["Content-Type"] = ctype
508
+
509
+ query = str(request.url.query)
510
+
511
+ client = await _client()
512
+ try:
513
+ req = client.build_request(
514
+ request.method, url,
515
+ headers=fwd,
516
+ content=send_bytes if send_bytes else None,
517
+ params=query or None,
518
+ )
519
+ resp = await client.send(req, stream=True)
520
+ except Exception as e:
521
+ _ban(target, _BAN_TTL_MED)
522
+ last_err = f"connect: {e}"
523
+ continue
524
+
525
+ # Classify the upstream response. The key insight: GLM serves
526
+ # balance-drained errors (code 1113) with HTTP 429 status, NOT a real
527
+ # rate-limit. So we must inspect the body before choosing a ban TTL:
528
+ # 1113/quota in body -> dead account, LONG ban (don't re-walk it)
529
+ # genuine 429 -> transient, SHORT ban
530
+ # 5xx / connect err -> transient, MED ban
531
+ # 401/403 -> dead auth, LONG ban
532
+ ctype_resp = resp.headers.get("content-type", "")
533
+ is_sse = ctype_resp.lower().startswith("text/event-stream")
534
+
535
+ if resp.status_code >= 400 or ("application/json" in ctype_resp.lower() and not is_sse):
536
+ body_buf = await resp.aread()
537
+ await resp.aclose()
538
+ retryable = _is_retryable_error(body_buf)
539
+ if retryable:
540
+ # 1113 balance / quota / drained -> dead key, don't revisit
541
+ _ban(target, _BAN_TTL_LONG)
542
+ last_err = f"drained: {body_buf[:120]!r}"
543
+ continue
544
+ if resp.status_code == 429:
545
+ # genuine rate limit (no 1113) -> recovers fast
546
+ _ban(target, _BAN_TTL_SHORT)
547
+ last_err = f"rate-limited: {body_buf[:120]!r}"
548
+ continue
549
+ if resp.status_code in (401, 403):
550
+ _ban(target, _BAN_TTL_LONG)
551
+ last_err = f"auth {resp.status_code}: {body_buf[:120]!r}"
552
+ continue
553
+ if resp.status_code >= 500:
554
+ _ban(target, _BAN_TTL_MED)
555
+ last_err = f"upstream {resp.status_code}: {body_buf[:120]!r}"
556
+ continue
557
+ # Non-retryable 4xx (bad request, unknown model, ...) -> return
558
+ resp_headers = {
559
+ k: v for k, v in resp.headers.items()
560
+ if k.lower() not in _HOP and k.lower() != "content-encoding"
561
+ }
562
+ return Response(
563
+ content=body_buf,
564
+ status_code=resp.status_code,
565
+ headers=resp_headers,
566
+ media_type=resp.headers.get("content-type"),
567
+ )
568
+
569
+ # Pass through response headers (content-type, x-request-id,
570
+ # anthropic-*, caching headers, etc.) untouched.
571
+ resp_headers = {
572
+ k: v for k, v in resp.headers.items()
573
+ if k.lower() not in _HOP
574
+ }
575
+
576
+ async def gen():
577
+ try:
578
+ async for chunk in resp.aiter_raw():
579
+ if chunk:
580
+ yield chunk
581
+ finally:
582
+ await resp.aclose()
583
+
584
+ return StreamingResponse(
585
+ gen(),
586
+ status_code=resp.status_code,
587
+ headers=resp_headers,
588
+ media_type=resp.headers.get("content-type"),
589
+ )
590
+
591
+ raise HTTPException(status_code=502, detail=f"all targets failed: {last_err}")
models.json.enc ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ httpx>=0.27
4
+ cryptography>=42