| """HTTP forwarding helpers — target dicts + shared connection pool + SSE filter.""" |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from typing import Dict |
| from urllib.parse import urlparse |
|
|
| import httpx |
|
|
| logger = logging.getLogger("router.forwarder") |
|
|
| |
| HOP_BY_HOP = { |
| "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", |
| "te", "trailers", "transfer-encoding", "upgrade", "host", "content-length", |
| "content-encoding", |
| } |
|
|
| |
| RESPONSE_STRIP = { |
| "content-length", "transfer-encoding", "connection", |
| "content-encoding", "keep-alive", |
| } |
|
|
|
|
| def clean_headers(headers) -> Dict[str, str]: |
| return {k: v for k, v in headers.items() if k.lower() not in HOP_BY_HOP} |
|
|
|
|
| def clean_response_headers(response) -> Dict[str, str]: |
| return {k: v for k, v in response.headers.items() if k.lower() not in RESPONSE_STRIP} |
|
|
|
|
| def swap_model(body_bytes: bytes, new_model: str) -> bytes: |
| """Rewrite the JSON 'model' field. Passes through non-JSON bodies unchanged.""" |
| if not body_bytes: |
| return body_bytes |
| try: |
| parsed = json.loads(body_bytes) |
| except Exception: |
| return body_bytes |
| if isinstance(parsed, dict): |
| parsed["model"] = new_model |
| return json.dumps(parsed).encode("utf-8") |
| return body_bytes |
|
|
|
|
| def make_client() -> httpx.AsyncClient: |
| """Shared connection pool with split timeouts (long read for slow LLMs).""" |
| return httpx.AsyncClient( |
| timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=5.0), |
| limits=httpx.Limits(max_connections=200, max_keepalive_connections=50), |
| ) |
|
|
|
|
| |
| |
| _shared_client: httpx.AsyncClient | None = None |
|
|
|
|
| def set_shared_client(client: httpx.AsyncClient) -> None: |
| """Store the shared httpx client so other modules (auth) can access it.""" |
| global _shared_client |
| _shared_client = client |
|
|
|
|
| def get_shared_client() -> httpx.AsyncClient: |
| """Return the shared client. Falls back to a fresh one if not yet wired.""" |
| if _shared_client is None: |
| return make_client() |
| return _shared_client |
|
|
|
|
| async def forward( |
| client: httpx.AsyncClient, |
| target: dict, |
| method: str, |
| path: str, |
| body_bytes: bytes, |
| client_headers: dict, |
| query_params: dict, |
| ) -> httpx.Response: |
| """Open a streaming upstream connection. Caller owns response.aclose().""" |
| |
| suffix = path[3:] if path.startswith("v1/") else path.lstrip("/") |
| url = f"{target['url'].rstrip('/')}/{suffix}" |
| headers = clean_headers(client_headers) |
| headers["authorization"] = f"Bearer {target['key']}" |
| headers["host"] = urlparse(target["url"]).netloc |
| req = client.build_request(method, url, content=body_bytes, headers=headers, params=query_params) |
| return await client.send(req, stream=True) |
|
|
|
|
| class SSEFilter: |
| """Buffers raw bytes and emits only 'data:' lines + blank separators. # docstring start |
| |
| Filters out SSE comments (':'), 'event:', 'id:', 'retry:' lines that some # why we filter |
| clients (notably pi) can't parse. # docstring end |
| """ |
|
|
| def __init__(self): |
| self._buf = b"" |
|
|
| def feed(self, chunk: bytes) -> bytes: |
| """Process a chunk. Returns the filtered bytes ready to forward.""" |
| self._buf += chunk |
| out = b"" |
| while b"\n" in self._buf: |
| line, self._buf = self._buf.split(b"\n", 1) |
| if self._keep(line): |
| out += line + b"\n" |
| return out |
|
|
| def flush(self) -> bytes: |
| """Emit any buffered partial line if it qualifies.""" |
| if self._buf and self._keep(self._buf): |
| result = self._buf |
| self._buf = b"" |
| return result |
| self._buf = b"" |
| return b"" |
|
|
| @staticmethod |
| def _keep(line: bytes) -> bool: |
| """Should this line be forwarded? data: lines and blank separators only.""" |
| stripped = line.strip() |
| return stripped.startswith(b"data:") or stripped == b"" |
|
|