| """Unified router: one retry loop for every model and provider. # module docstring start |
| |
| Each target is a plain dict {url, key, upstream_model}. The router picks one # how it works |
| via round-robin, swaps the model field, forwards, and cools down bad targets. # load distribution |
| No provider-specific branches — subnet nodes and API keys look identical. # module docstring end |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from typing import Optional |
|
|
| from fastapi.responses import JSONResponse, Response, StreamingResponse |
|
|
| from ..models.config import ModelConfig |
| from .forwarder import SSEFilter, clean_response_headers, forward, make_client, swap_model |
| from .health import HealthTracker |
| from .round_robin import RoundRobin |
| from .billing.gate import extract_usage as gate_extract_usage |
| from .billing.usage_watcher import UsageWatcher |
|
|
| logger = logging.getLogger("router") |
|
|
| MAX_ATTEMPTS = 5 |
| RETRY_STATUSES = {401, 402, 403, 429, 500, 502, 503, 504} |
| COOLDOWN = 60.0 |
|
|
|
|
| def extract_model(body_bytes: bytes) -> Optional[str]: |
| if not body_bytes: |
| return None |
| try: |
| parsed = json.loads(body_bytes) |
| except Exception: |
| return None |
| if isinstance(parsed, dict): |
| return parsed.get("model") |
| return None |
|
|
|
|
| def target_id(target: dict) -> str: |
| """Unique key for the health tracker: url + key combo.""" |
| return f"{target['url']}::{target['key']}" |
|
|
|
|
| class Router: |
| def __init__(self, config: ModelConfig, client=None): |
| self.config = config |
| self.client = client or make_client() |
| self.health = HealthTracker(cooldown_seconds=COOLDOWN) |
| self.pools = {} |
| self._target_keys = {} |
| for name, cfg in config.models.items(): |
| targets = cfg.get("targets", []) |
| if targets: |
| self.pools[name] = RoundRobin(targets) |
| self._target_keys[name] = [target_id(t) for t in targets] |
|
|
| def pick_target(self, exact_name: str) -> Optional[dict]: |
| """Pick a healthy target. Returns None if the pool is empty OR fully cooled down.""" |
| rr = self.pools.get(exact_name) |
| if rr is None or len(rr) == 0: |
| return None |
| total = len(rr) |
| for _ in range(total): |
| t = rr.next() |
| if t is None: |
| return None |
| if not self.health.is_down(target_id(t)): |
| return t |
| return None |
|
|
| async def handle(self, method, path, body_bytes, client_headers, query_params, |
| user_id=None, is_admin=False): |
| model = extract_model(body_bytes) |
| if model is None: |
| return JSONResponse( |
| {"error": {"message": "missing 'model' field in request body", "type": "invalid_request"}}, |
| status_code=400, |
| ) |
|
|
| exact, targets = self.config.resolve(model) |
| if exact is None: |
| available = list(self.config.models.keys()) |
| return JSONResponse( |
| {"error": {"message": f"model '{model}' not found. Available: {available}", |
| "type": "model_not_found"}}, |
| status_code=404, |
| ) |
|
|
| attempts = 0 |
| last_error = None |
| while attempts < MAX_ATTEMPTS: |
| target = self.pick_target(exact) |
| if target is None: |
| healthy = self.health.healthy_count(self._target_keys.get(exact, [])) |
| return JSONResponse( |
| {"error": { |
| "message": f"no targets available for '{exact}' " |
| f"(pool={len(self.pools.get(exact, []))}, healthy={healthy})", |
| "type": "server_error"}}, |
| status_code=503, |
| ) |
|
|
| upstream = target.get("upstream_model") |
| swapped = swap_model(body_bytes, upstream) if upstream else body_bytes |
| tid = target_id(target) |
|
|
| logger.info("%s %s model=%s target=%s attempt=%d", |
| method, path, exact, target["url"], attempts + 1) |
|
|
| try: |
| response = await forward(self.client, target, method, path, swapped, |
| client_headers, query_params) |
| except Exception as exc: |
| logger.warning("forward failed: %s -> %s", tid, exc) |
| self.health.mark_down(tid) |
| last_error = str(exc) |
| attempts += 1 |
| continue |
|
|
| if response.status_code in RETRY_STATUSES: |
| body_preview = b"" |
| try: |
| body_preview = await response.aread() |
| except Exception: |
| pass |
| await response.aclose() |
| logger.warning("upstream %d: %s -> %s", |
| response.status_code, tid, body_preview[:200]) |
| self.health.mark_down(tid) |
| last_error = f"upstream returned {response.status_code}" |
| attempts += 1 |
| continue |
|
|
| self.health.mark_up(tid) |
| ctype = response.headers.get("content-type", "") |
| is_stream = "text/event-stream" in ctype |
| resp_headers = clean_response_headers(response) |
| billing_on = self._billing_on() |
| meter = user_id if (billing_on and not is_admin and user_id) else None |
|
|
| if is_stream: |
| sse = SSEFilter() |
| watcher = UsageWatcher() if meter else None |
|
|
| async def gen(): |
| try: |
| async for chunk in response.aiter_raw(): |
| if chunk: |
| filtered = sse.feed(chunk) |
| if watcher: |
| watcher.feed(filtered) |
| if filtered: |
| yield filtered |
| tail = sse.flush() |
| if tail: |
| if watcher: |
| watcher.feed(tail) |
| yield tail |
| except Exception as exc: |
| logger.error("stream error: %s", exc) |
| finally: |
| if watcher and watcher.has_usage: |
| await self._burn(meter, exact, watcher.tok_in, watcher.tok_out) |
| await response.aclose() |
|
|
| return StreamingResponse(gen(), status_code=response.status_code, |
| headers=resp_headers, media_type="text/event-stream") |
|
|
| raw = await response.aread() |
| await response.aclose() |
| if meter: |
| await self._burn_from_body(meter, exact, raw) |
| return Response(content=raw, status_code=response.status_code, |
| headers=resp_headers, media_type=ctype or None) |
|
|
| return JSONResponse( |
| {"error": {"message": f"all {MAX_ATTEMPTS} attempts failed: {last_error}", |
| "type": "upstream_error"}}, |
| status_code=502, |
| ) |
|
|
| @staticmethod |
| def _billing_on() -> bool: |
| """Check if billing is enabled (gist store + wallet present).""" |
| from .billing import gate |
| return gate.is_enabled() |
|
|
| async def _burn_from_body(self, user_id: str, model_name: str, body: bytes) -> None: |
| """Parse usage from a buffered JSON response body and charge the user.""" |
| try: |
| parsed = json.loads(body) |
| except Exception: |
| return |
| tok_in, tok_out = gate_extract_usage(parsed) |
| if tok_in or tok_out: |
| await self._burn(user_id, model_name, tok_in, tok_out) |
|
|
| async def _burn(self, user_id: str, model_name: str, tok_in: int, tok_out: int) -> None: |
| """Charge a user for a request. Best-effort — never raises.""" |
| from .billing import gate |
| try: |
| await gate.burn(user_id, self.config.models, model_name, tok_in, tok_out) |
| except Exception as exc: |
| logger.warning("burn failed for %s: %s", user_id, exc) |
|
|