Repository navigation
Expand file tree
/
Copy pathgateway.py
More file actions
executable file
·116 lines (98 loc) · 3.89 KB
/
Copy pathgateway.py
File metadata and controls
executable file
·116 lines (98 loc) · 3.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#!/usr/bin/env python3
"""Small streaming gateway that pools heterogeneous Dynamo deployments."""
import argparse
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
def load_backends(path: Path) -> list[dict]:
config = json.loads(path.read_text())
return [
{**backend, "active": 0, "requests": 0, "failures": 0}
for backend in config["gateway"]["backends"]
]
def create_app(backends: list[dict]) -> FastAPI:
client: httpx.AsyncClient
lock = asyncio.Lock()
@asynccontextmanager
async def lifespan(_: FastAPI):
nonlocal client
client = httpx.AsyncClient(timeout=None)
yield
await client.aclose()
app = FastAPI(title="Heterogeneous Inference Gateway", lifespan=lifespan)
async def choose_backend() -> dict:
async with lock:
backend = min(backends, key=lambda item: (item["active"] + 1) / item["weight"])
backend["active"] += 1
backend["requests"] += 1
return backend
async def release(backend: dict, response: httpx.Response):
try:
async for chunk in response.aiter_raw():
yield chunk
except Exception:
backend["failures"] += 1
raise
finally:
await response.aclose()
async with lock:
backend["active"] -= 1
@app.get("/health")
async def health():
checks = await asyncio.gather(
*(client.get(f"{backend['url']}/health", timeout=3) for backend in backends),
return_exceptions=True,
)
status = {
backend["name"]: not isinstance(check, Exception) and check.is_success
for backend, check in zip(backends, checks)
}
return JSONResponse(status, status_code=200 if all(status.values()) else 503)
@app.get("/gateway/status")
async def gateway_status():
return [{key: value for key, value in backend.items() if key != "url"} for backend in backends]
@app.api_route("/{path:path}", methods=["GET", "POST"])
async def proxy(path: str, request: Request):
backend = await choose_backend()
headers = {key: value for key, value in request.headers.items() if key.lower() != "host"}
try:
outbound = client.build_request(
request.method,
f"{backend['url']}/{path}",
params=request.query_params,
headers=headers,
content=await request.body(),
)
response = await client.send(outbound, stream=True)
except Exception as error:
async with lock:
backend["active"] -= 1
backend["failures"] += 1
raise HTTPException(status_code=502, detail=f"{backend['name']}: {error}") from error
response_headers = {
key: value
for key, value in response.headers.items()
if key.lower() not in {"content-length", "transfer-encoding", "connection"}
}
response_headers["x-inference-backend"] = backend["name"]
return StreamingResponse(
release(backend, response),
status_code=response.status_code,
headers=response_headers,
media_type=response.headers.get("content-type"),
)
return app
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=Path(__file__).resolve().parent.parent / "config.json")
args = parser.parse_args()
config = json.loads(args.config.read_text())
gateway = config["gateway"]
uvicorn.run(create_app(load_backends(args.config)), host="0.0.0.0", port=gateway["port"])
if __name__ == "__main__":
main()