|
#!/usr/bin/env python3 |
|
"""Transparent proxy in front of oMLX that swaps the resident large model. |
|
|
|
On a machine with unified memory, only one large model fits at a time. Metal |
|
caps the working set well below installed RAM (`max_recommended_working_set_size` |
|
-- 37.44GB on a 48GB Apple silicon box) and oMLX rejects any prompt that would |
|
push past ~90% of that. oMLX will not evict to make room: it loads the second |
|
model *alongside* the first and then fails the prefill. So switching has to be |
|
explicit, and doing it by hand means being sat at the machine -- which defeats |
|
the point of reaching it from a laptop, a phone, or a cron job. |
|
|
|
This sits in front of oMLX and does the switch in the request path. A request |
|
naming a large model that is not the resident one triggers unload/pin/load |
|
before being forwarded. Every other request is proxied untouched, including |
|
streaming responses. Point clients at this port instead of oMLX's; nothing else |
|
changes. |
|
|
|
Requires nothing outside the standard library. |
|
|
|
Setup |
|
----- |
|
1. List the models that must not coexist: |
|
|
|
export OMLX_BIG_MODELS="Model-A,Model-B,Model-C" |
|
|
|
Names must match oMLX's model ids exactly. Small models (embeddings, a 4B, |
|
a vision model) are left out -- they coexist happily and are proxied without |
|
any swap. With the variable unset the router still works, purely as a |
|
transparent proxy. |
|
|
|
2. Run it: |
|
|
|
OMLX_BIG_MODELS="..." ./omlx-router.py |
|
|
|
It listens on :8010 and forwards to oMLX on :8000 by default. Both are |
|
configurable (see the environment variables below). |
|
|
|
3. Point every client at the router's port instead of oMLX's. To serve it over |
|
TLS on a tailnet: |
|
|
|
tailscale serve --bg --https=9443 http://127.0.0.1:8010 |
|
|
|
Environment |
|
----------- |
|
OMLX_UPSTREAM oMLX base URL (http://127.0.0.1:8000) |
|
OMLX_ROUTER_HOST bind address (0.0.0.0) |
|
OMLX_ROUTER_PORT listen port (8010) |
|
OMLX_BIG_MODELS comma-separated ids (empty: no swapping) |
|
OMLX_API_KEY admin key (else ~/.omlx/settings.json) |
|
OMLX_ROUTER_DRAIN_TIMEOUT seconds to wait for |
|
in-flight requests (900) |
|
""" |
|
from __future__ import annotations |
|
|
|
import collections |
|
import http.client |
|
import json |
|
import logging |
|
import os |
|
import threading |
|
import time |
|
import urllib.error |
|
import urllib.request |
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
|
from pathlib import Path |
|
|
|
UPSTREAM = os.environ.get("OMLX_UPSTREAM", "http://127.0.0.1:8000").rstrip("/") |
|
LISTEN_HOST = os.environ.get("OMLX_ROUTER_HOST", "0.0.0.0") |
|
LISTEN_PORT = int(os.environ.get("OMLX_ROUTER_PORT", "8010")) |
|
|
|
# The models that cannot be resident at the same time. Anything not named here |
|
# -- embeddings, small instruct models, a vision model -- coexists fine and is |
|
# proxied without ever triggering a swap. |
|
BIG_MODELS = {m.strip() for m in os.environ.get("OMLX_BIG_MODELS", "").split(",") |
|
if m.strip()} |
|
|
|
SWAP_PATHS = ("/v1/chat/completions", "/v1/completions", "/v1/responses") |
|
HOP_BY_HOP = { |
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", |
|
"te", "trailers", "transfer-encoding", "upgrade", "content-length", |
|
} |
|
|
|
log = logging.getLogger("omlx-router") |
|
_swap_lock = threading.Lock() |
|
|
|
# Requests currently being served upstream, per model. A swap must not unload a |
|
# model that is mid-generation: oMLX aborts those requests outright |
|
# ("Aborted N requests: model is being unloaded"), so a long prefill started |
|
# from one machine would be killed by someone else switching model on another. |
|
_inflight: dict[str, int] = collections.defaultdict(int) |
|
_inflight_lock = threading.Condition() |
|
DRAIN_TIMEOUT = float(os.environ.get("OMLX_ROUTER_DRAIN_TIMEOUT", "900")) |
|
|
|
|
|
def _enter(model: str | None) -> None: |
|
if not model: |
|
return |
|
with _inflight_lock: |
|
_inflight[model] += 1 |
|
|
|
|
|
def _leave(model: str | None) -> None: |
|
if not model: |
|
return |
|
with _inflight_lock: |
|
_inflight[model] -= 1 |
|
if _inflight[model] <= 0: |
|
_inflight.pop(model, None) |
|
_inflight_lock.notify_all() |
|
|
|
|
|
def _wait_drain(model: str, timeout: float = DRAIN_TIMEOUT) -> bool: |
|
"""Block until `model` has no in-flight requests. False if it timed out.""" |
|
deadline = time.monotonic() + timeout |
|
with _inflight_lock: |
|
while _inflight.get(model, 0) > 0: |
|
remaining = deadline - time.monotonic() |
|
if remaining <= 0: |
|
return False |
|
log.info("waiting for %d in-flight request(s) on %s to finish", |
|
_inflight[model], model) |
|
_inflight_lock.wait(min(remaining, 5.0)) |
|
return True |
|
|
|
|
|
def api_key() -> str: |
|
env = os.environ.get("OMLX_API_KEY") |
|
if env: |
|
return env |
|
try: |
|
cfg = json.loads((Path.home() / ".omlx" / "settings.json").read_text()) |
|
return cfg.get("auth", {}).get("api_key", "") |
|
except OSError: |
|
return "" |
|
|
|
|
|
def _admin_session() -> str | None: |
|
"""Log in to the admin API and return the session cookie. |
|
|
|
oMLX's admin endpoints take a session cookie, not the bearer token the |
|
OpenAI-compatible routes use. |
|
""" |
|
body = json.dumps({"api_key": api_key()}).encode() |
|
req = urllib.request.Request( |
|
f"{UPSTREAM}/admin/api/login", data=body, |
|
headers={"Content-Type": "application/json"}, |
|
) |
|
try: |
|
with urllib.request.urlopen(req, timeout=15) as resp: |
|
cookie = resp.headers.get("Set-Cookie", "") |
|
return cookie.split(";", 1)[0] if cookie else None |
|
except (urllib.error.URLError, TimeoutError): |
|
return None |
|
|
|
|
|
def _admin(path: str, cookie: str, method: str = "GET", payload: dict | None = None): |
|
req = urllib.request.Request( |
|
f"{UPSTREAM}{path}", method=method, |
|
data=json.dumps(payload).encode() if payload is not None else None, |
|
headers={"Content-Type": "application/json", "Cookie": cookie}, |
|
) |
|
try: |
|
with urllib.request.urlopen(req, timeout=300) as resp: |
|
return json.loads(resp.read().decode() or "{}") |
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: |
|
log.warning("admin %s %s failed: %s", method, path, exc) |
|
return None |
|
|
|
|
|
def active_big() -> str | None: |
|
"""Which large model oMLX currently treats as default. |
|
|
|
`/health` is unauthenticated and cheap. The router sets `is_default` |
|
whenever it pins, so this is an accurate proxy for "the resident large |
|
model" without an admin login on every request. If the model has since |
|
idled out, oMLX reloads it on demand -- still no swap needed. |
|
""" |
|
try: |
|
with urllib.request.urlopen(f"{UPSTREAM}/health", timeout=10) as resp: |
|
return json.loads(resp.read().decode()).get("default_model") |
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): |
|
return None |
|
|
|
|
|
def ensure_resident(target: str) -> None: |
|
"""Make `target` the one resident large model, if it is not already.""" |
|
with _swap_lock: |
|
if active_big() == target: |
|
return |
|
cookie = _admin_session() |
|
if not cookie: |
|
log.error("cannot reach admin API; forwarding without swap") |
|
return |
|
for other in BIG_MODELS - {target}: |
|
# Let anything already generating on the outgoing model finish |
|
# before pulling it out from under them. |
|
if not _wait_drain(other): |
|
log.warning("%s still busy after drain timeout; unloading anyway", other) |
|
# Unpin first or the unload is refused. |
|
_admin(f"/admin/api/models/{other}/settings", cookie, "PUT", |
|
{"is_pinned": False, "is_default": False}) |
|
_admin(f"/admin/api/models/{other}/unload", cookie, "POST") |
|
log.info("unloaded %s", other) |
|
_admin(f"/admin/api/models/{target}/settings", cookie, "PUT", |
|
{"is_pinned": True, "is_default": True}) |
|
log.info("pinned %s", target) |
|
|
|
|
|
class Handler(BaseHTTPRequestHandler): |
|
protocol_version = "HTTP/1.1" |
|
server_version = "omlx-router" |
|
|
|
def log_message(self, fmt, *args): |
|
log.info("%s - %s", self.address_string(), fmt % args) |
|
|
|
def _relay(self, method: str) -> None: |
|
tracked: str | None = None |
|
length = int(self.headers.get("Content-Length") or 0) |
|
body = self.rfile.read(length) if length else None |
|
|
|
# The only thing this proxy actually inspects. |
|
if method == "POST" and self.path in SWAP_PATHS and body: |
|
try: |
|
model = json.loads(body).get("model") |
|
except (json.JSONDecodeError, UnicodeDecodeError): |
|
model = None |
|
if model in BIG_MODELS: |
|
ensure_resident(model) |
|
tracked = model |
|
|
|
headers = {k: v for k, v in self.headers.items() |
|
if k.lower() not in HOP_BY_HOP} |
|
_enter(tracked) |
|
req = urllib.request.Request( |
|
UPSTREAM + self.path, data=body, headers=headers, method=method) |
|
try: |
|
resp = urllib.request.urlopen(req, timeout=1800) |
|
except urllib.error.HTTPError as e: |
|
resp = e |
|
except (urllib.error.URLError, TimeoutError) as e: |
|
_leave(tracked) |
|
self.send_error(502, f"upstream unreachable: {e}") |
|
return |
|
|
|
try: |
|
with resp: |
|
self.send_response(resp.status) |
|
for k, v in resp.headers.items(): |
|
if k.lower() not in HOP_BY_HOP: |
|
self.send_header(k, v) |
|
self.send_header("Transfer-Encoding", "chunked") |
|
self.end_headers() |
|
# `read(n)` blocks until n bytes or EOF, which batches SSE |
|
# tokens into lumps and makes the client feel laggy. For event |
|
# streams read a line at a time so tokens forward as they land. |
|
ctype = resp.headers.get("Content-Type") or "" |
|
read = resp.readline if "text/event-stream" in ctype else ( |
|
lambda: resp.read(8192)) |
|
try: |
|
while chunk := read(): |
|
self.wfile.write(b"%X\r\n%s\r\n" % (len(chunk), chunk)) |
|
self.wfile.flush() |
|
except (BrokenPipeError, ConnectionResetError): |
|
return |
|
except (http.client.IncompleteRead, http.client.HTTPException, |
|
ValueError, OSError) as exc: |
|
# Upstream cut the body short -- usually because the model |
|
# was unloaded mid-generation. Terminate the chunked stream |
|
# cleanly so the client sees a truncated response rather |
|
# than a chunk-decode error. |
|
log.warning("upstream body truncated (%s): %s", |
|
type(exc).__name__, exc) |
|
try: |
|
self.wfile.write(b"0\r\n\r\n") |
|
self.wfile.flush() |
|
except (BrokenPipeError, ConnectionResetError, ValueError): |
|
pass |
|
finally: |
|
_leave(tracked) |
|
|
|
def do_GET(self): self._relay("GET") |
|
def do_POST(self): self._relay("POST") |
|
def do_PUT(self): self._relay("PUT") |
|
def do_DELETE(self): self._relay("DELETE") |
|
def do_PATCH(self): self._relay("PATCH") |
|
def do_OPTIONS(self):self._relay("OPTIONS") |
|
|
|
|
|
def main() -> None: |
|
logging.basicConfig( |
|
level=logging.INFO, |
|
format="%(asctime)s %(levelname)s %(message)s", |
|
) |
|
srv = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler) |
|
srv.daemon_threads = True |
|
log.info("omlx-router on %s:%s -> %s", LISTEN_HOST, LISTEN_PORT, UPSTREAM) |
|
if BIG_MODELS: |
|
log.info("swapping between: %s", ", ".join(sorted(BIG_MODELS))) |
|
else: |
|
log.warning("OMLX_BIG_MODELS is empty: proxying without swapping") |
|
srv.serve_forever() |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |