Initial commit: llm-router — smart OpenAI/Anthropic/Ollama router
A stdlib pre-router in front of Ollama with LiteLLM backend: - auto model selection by content/tools/modality, with fallbacks - OpenAI /v1, Anthropic /v1/messages, and Ollama-native /api/* endpoints - Whisper-shaped /v1/audio/transcriptions + in-chat audio - key-based fleet policies (e.g. force a client onto uncensored models) - optional Bearer auth; launchd/systemd service install - benchmark harnesses (speed, quality, agentic tool use) with sample results Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
Local model router (stdlib-only; runs on any Python 3.9+, incl. 3.14).
|
||||
|
||||
An OpenAI-compatible endpoint that picks the best local model per request and
|
||||
forwards straight to Ollama's own OpenAI API. Point every client at
|
||||
http://localhost:8080/v1
|
||||
- model "auto" -> router decides (modality -> heuristics -> tiny classifier)
|
||||
- any real name -> passed through unchanged (manual override always wins)
|
||||
On upstream failure the router retries once with a mapped fallback model.
|
||||
Response headers x-router-model / x-router-decided-by report the pick.
|
||||
"""
|
||||
import os, re, json, time, base64, urllib.request, urllib.error
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
OLLAMA = os.environ.get("ROUTER_OLLAMA", "http://127.0.0.1:11434").rstrip("/") # client target; NOT OLLAMA_HOST (that's the server bind addr, e.g. 0.0.0.0)
|
||||
UPSTREAM = os.environ.get("ROUTER_UPSTREAM", OLLAMA + "/v1").rstrip("/")
|
||||
HOST = os.environ.get("ROUTER_HOST", "0.0.0.0") # 0.0.0.0 = reachable on the LAN
|
||||
PORT = int(os.environ.get("ROUTER_PORT", "8080"))
|
||||
API_KEY = os.environ.get("ROUTER_API_KEY", "").strip() # if set, require Bearer token
|
||||
AUDIO_MODEL = os.environ.get("ROUTER_AUDIO_MODEL", "gemma4:e4b") # audio only works on official Gemma 4
|
||||
|
||||
M = {
|
||||
"fast": "qwen3:8b",
|
||||
"general": "qwen3:14b",
|
||||
"code": "glm-4.7-flash",
|
||||
"code_heavy": "qwen3-coder:30b",
|
||||
"reason": "qwen3.6:35b-a3b",
|
||||
"agentic": "glm-4.7-flash", # tool-carrying requests (general agent): best hard-agentic (7/7, only one to recover from tool errors)
|
||||
"vision": "qwen3-vl:8b",
|
||||
"vision_heavy": "qwen3-vl:30b-a3b-instruct",
|
||||
}
|
||||
CLASSIFIER = "qwen3:8b"
|
||||
SHORT_CHARS = 240
|
||||
|
||||
# Uncensored fleet. A request routes here if it asks for model "auto-uncensored"
|
||||
# OR carries the dedicated famapp key (ROUTER_UNCENSORED_KEY) — in the latter case
|
||||
# EVERY request from that client is forced uncensored regardless of model asked for.
|
||||
UNCENSORED = {
|
||||
"general": "gemma4-uncensored:26b",
|
||||
"vision": "gemma4-uncensored:26b", # has vision
|
||||
"code": "glm-uncensored-code:30b",
|
||||
"fast": "gemma-e4b-uncensored",
|
||||
}
|
||||
UNCENSORED_KEY = os.environ.get("ROUTER_UNCENSORED_KEY", "").strip()
|
||||
|
||||
FALLBACK = {
|
||||
"glm-4.7-flash": "qwen3-coder:30b",
|
||||
"qwen3-coder:30b": "glm-4.7-flash",
|
||||
"gemma4:26b": "qwen3.6:35b-a3b",
|
||||
"qwen3.6:35b-a3b": "gpt-oss:20b",
|
||||
"gpt-oss:20b": "qwen3:14b",
|
||||
"qwen3-vl:30b-a3b-instruct": "qwen3-vl:8b",
|
||||
"qwen3:14b": "qwen3:8b",
|
||||
"qwen3-vl:8b": "gemma4:12b",
|
||||
# uncensored models still downloading fall back to the one that's ready
|
||||
"glm-uncensored-code:30b": "gemma4-uncensored:26b",
|
||||
"gemma-e4b-uncensored": "gemma4-uncensored:26b",
|
||||
}
|
||||
|
||||
CODE_RE = re.compile(r"```|\b(def |class |function |import |#include|refactor|debug|"
|
||||
r"stack ?trace|traceback|compile|unit test|npm |pip |git |bug|"
|
||||
r"exception|null pointer|segfault)\b|\.(py|js|ts|tsx|go|rs|java|"
|
||||
r"cpp|cc|c|rb|sh|sql)\b", re.I)
|
||||
CODE_HEAVY_RE = re.compile(r"\b(repo|repository|codebase|multiple files|entire project|"
|
||||
r"across files|whole project)\b", re.I)
|
||||
REASON_RE = re.compile(r"\b(prove|theorem|step[- ]by[- ]step|reason through|derive|"
|
||||
r"calculate|logic puzzle|why does|explain why|work through)\b", re.I)
|
||||
OCR_RE = re.compile(r"\b(ocr|document|invoice|receipt|table|form|handwrit|extract text|"
|
||||
r"scan|parse this (image|page|pdf))\b", re.I)
|
||||
|
||||
|
||||
def last_user_text(messages):
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "user":
|
||||
c = m.get("content")
|
||||
if isinstance(c, str):
|
||||
return c
|
||||
if isinstance(c, list):
|
||||
return " ".join(p.get("text", "") for p in c
|
||||
if isinstance(p, dict) and p.get("type") == "text")
|
||||
return ""
|
||||
|
||||
|
||||
def has_image(messages):
|
||||
for m in messages:
|
||||
c = m.get("content")
|
||||
if isinstance(c, list):
|
||||
for p in c:
|
||||
if isinstance(p, dict) and p.get("type") in ("image_url", "input_image", "image"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_audio(messages):
|
||||
for m in messages:
|
||||
c = m.get("content")
|
||||
if isinstance(c, list):
|
||||
for p in c:
|
||||
if isinstance(p, dict) and p.get("type") in ("input_audio", "audio"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def to_native_messages(messages):
|
||||
# OpenAI content -> Ollama native: text flattened, audio/image base64 into `images`
|
||||
out = []
|
||||
for m in messages:
|
||||
role, c = m.get("role", "user"), m.get("content")
|
||||
if isinstance(c, str):
|
||||
out.append({"role": role, "content": c})
|
||||
continue
|
||||
text_parts, media = [], []
|
||||
if isinstance(c, list):
|
||||
for p in c:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
t = p.get("type")
|
||||
if t == "text":
|
||||
text_parts.append(p.get("text", ""))
|
||||
elif t in ("input_audio", "audio"):
|
||||
d = (p.get("input_audio") or p.get("audio") or {}).get("data")
|
||||
if d:
|
||||
media.append(d)
|
||||
elif t in ("image_url", "image", "input_image"):
|
||||
url = (p.get("image_url") or {}).get("url", "") if isinstance(p.get("image_url"), dict) else ""
|
||||
media.append(url.split(",", 1)[-1] if url.startswith("data:") else url)
|
||||
msg = {"role": role, "content": " ".join(tp for tp in text_parts if tp)}
|
||||
if media:
|
||||
msg["images"] = media
|
||||
out.append(msg)
|
||||
return out
|
||||
|
||||
|
||||
def parse_multipart(body, boundary):
|
||||
# minimal multipart/form-data parser -> {name: (filename_or_None, bytes)}
|
||||
fields = {}
|
||||
for part in body.split(b"--" + boundary.encode()):
|
||||
if part in (b"", b"--", b"--\r\n", b"\r\n"):
|
||||
continue
|
||||
if part.startswith(b"\r\n"):
|
||||
part = part[2:]
|
||||
if b"\r\n\r\n" not in part:
|
||||
continue
|
||||
head, _, data = part.partition(b"\r\n\r\n")
|
||||
if data.endswith(b"\r\n"):
|
||||
data = data[:-2]
|
||||
head_text = head.decode("utf-8", "replace")
|
||||
nm = re.search(r'name="([^"]*)"', head_text)
|
||||
if not nm:
|
||||
continue
|
||||
fm = re.search(r'filename="([^"]*)"', head_text)
|
||||
fields[nm.group(1)] = (fm.group(1) if fm else None, data)
|
||||
return fields
|
||||
|
||||
|
||||
def total_len(messages):
|
||||
n = 0
|
||||
for m in messages:
|
||||
c = m.get("content")
|
||||
if isinstance(c, str):
|
||||
n += len(c)
|
||||
elif isinstance(c, list):
|
||||
for p in c:
|
||||
if isinstance(p, dict):
|
||||
n += len(p.get("text", ""))
|
||||
return n
|
||||
|
||||
|
||||
def classify(text):
|
||||
payload = json.dumps({
|
||||
"model": CLASSIFIER,
|
||||
"prompt": ("Classify the user's task into exactly one label from: "
|
||||
"code, reason, vision, general. Reply with only the label.\n\n"
|
||||
"Task: " + text[:2000]),
|
||||
"stream": False, "keep_alive": -1,
|
||||
"options": {"temperature": 0, "num_predict": 3},
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(OLLAMA + "/api/generate", data=payload,
|
||||
headers={"content-type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
out = json.loads(r.read()).get("response", "").strip().lower()
|
||||
for lab in ("code", "reason", "vision", "general"):
|
||||
if lab in out:
|
||||
return lab
|
||||
except Exception:
|
||||
pass
|
||||
return "general"
|
||||
|
||||
|
||||
def choose_model(body):
|
||||
messages = body.get("messages", [])
|
||||
text = last_user_text(messages)
|
||||
if has_image(messages):
|
||||
return M["vision_heavy"] if OCR_RE.search(text) else M["vision"]
|
||||
if body.get("tools"): # tool-carrying -> agentic (code agent vs general agent)
|
||||
return M["code_heavy"] if CODE_RE.search(text) else M["agentic"]
|
||||
if CODE_RE.search(text):
|
||||
return M["code_heavy"] if CODE_HEAVY_RE.search(text) else M["code"]
|
||||
if REASON_RE.search(text):
|
||||
return M["reason"]
|
||||
if total_len(messages) < SHORT_CHARS:
|
||||
return M["fast"]
|
||||
lab = classify(text)
|
||||
return {"code": M["code"], "reason": M["reason"],
|
||||
"vision": M["vision"], "general": M["general"]}[lab]
|
||||
|
||||
|
||||
def choose_uncensored(body):
|
||||
# same content heuristics, but only ever selects from the uncensored fleet
|
||||
messages = body.get("messages", [])
|
||||
text = last_user_text(messages)
|
||||
if has_image(messages):
|
||||
return UNCENSORED["vision"]
|
||||
if CODE_RE.search(text):
|
||||
return UNCENSORED["code"]
|
||||
if total_len(messages) < SHORT_CHARS:
|
||||
return UNCENSORED["fast"]
|
||||
return UNCENSORED["general"]
|
||||
|
||||
|
||||
def _open_upstream(body):
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(UPSTREAM + "/chat/completions", data=data,
|
||||
headers={"content-type": "application/json"})
|
||||
return urllib.request.urlopen(req, timeout=600)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args): # access log for EVERY request (incl 401/404)
|
||||
ua = self.headers.get("user-agent", "-")[:40]
|
||||
auth = "auth" if self.headers.get("authorization") else "noauth"
|
||||
print(f"[access] {time.strftime('%H:%M:%S')} {self.address_string()} "
|
||||
f"{auth} \"{ua}\" {fmt % args}", flush=True)
|
||||
|
||||
def _authed(self):
|
||||
if not API_KEY:
|
||||
return True
|
||||
auth = self.headers.get("authorization", "")
|
||||
return auth.startswith("Bearer ") and auth[7:].strip() == API_KEY
|
||||
|
||||
def _json(self, code, obj, extra=None):
|
||||
payload = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(payload)))
|
||||
for k, v in (extra or {}).items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _text(self, code, s):
|
||||
data = s.encode()
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", "text/plain")
|
||||
self.send_header("content-length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _proxy_ollama(self, path, data=None):
|
||||
# read-only native Ollama endpoints for `ollama launch` probing.
|
||||
# NOTE: only metadata paths are wired up; inference stays on authed /v1/*.
|
||||
req = urllib.request.Request(OLLAMA + path, data=data,
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST" if data is not None else "GET")
|
||||
try:
|
||||
up = urllib.request.urlopen(req, timeout=30)
|
||||
body, code = up.read(), 200
|
||||
ct = up.headers.get("content-type", "application/json")
|
||||
up.close()
|
||||
except urllib.error.HTTPError as e:
|
||||
body, code, ct = e.read(), e.code, "application/json"
|
||||
except Exception as e:
|
||||
return self._json(502, {"error": f"ollama unreachable: {e}"})
|
||||
self.send_response(code)
|
||||
self.send_header("content-type", ct)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.rstrip("/") == "/healthz":
|
||||
return self._json(200, {"ok": True})
|
||||
if self.path.rstrip("/") == "/v1/models":
|
||||
if not self._authed():
|
||||
return self._json(401, {"error": "unauthorized"})
|
||||
seen = list(dict.fromkeys(M.values()))
|
||||
return self._json(200, {"object": "list",
|
||||
"data": [{"id": "auto", "object": "model"}] +
|
||||
[{"id": m, "object": "model"} for m in seen]})
|
||||
# native Ollama probe endpoints (read-only) so `ollama launch` accepts this host
|
||||
if self.path in ("/", ""):
|
||||
return self._text(200, "Ollama is running")
|
||||
if self.path.rstrip("/") in ("/api/version", "/api/tags", "/api/ps"):
|
||||
return self._proxy_ollama(self.path)
|
||||
return self._json(404, {"error": "not found"})
|
||||
|
||||
def _proxy_anthropic(self, path):
|
||||
# transparent proxy for Anthropic Messages API (Claude Code) -> LiteLLM /v1/messages
|
||||
if not self._authed():
|
||||
return self._json(401, {"error": "unauthorized"})
|
||||
try:
|
||||
length = int(self.headers.get("content-length", 0) or 0)
|
||||
body = self.rfile.read(length)
|
||||
except Exception as e:
|
||||
return self._json(400, {"error": f"bad request: {e}"})
|
||||
url = UPSTREAM + path[len("/v1"):] # /v1/messages -> <litellm>/v1/messages
|
||||
headers = {"content-type": "application/json"}
|
||||
for h in ("authorization", "anthropic-version", "anthropic-beta"):
|
||||
v = self.headers.get(h)
|
||||
if v:
|
||||
headers[h] = v
|
||||
try:
|
||||
up = urllib.request.urlopen(
|
||||
urllib.request.Request(url, data=body, headers=headers), timeout=600)
|
||||
except urllib.error.HTTPError as e:
|
||||
data = e.read()
|
||||
self.send_response(e.code)
|
||||
self.send_header("content-type", e.headers.get("content-type", "application/json"))
|
||||
self.send_header("content-length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
return
|
||||
except Exception as e:
|
||||
return self._json(502, {"error": f"upstream unreachable: {e}"})
|
||||
ctype = up.headers.get("content-type", "application/json")
|
||||
if "event-stream" in ctype:
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", ctype)
|
||||
self.send_header("cache-control", "no-cache")
|
||||
self.send_header("connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
chunk = up.read(2048)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
up.close()
|
||||
else:
|
||||
data = up.read()
|
||||
up.close()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", ctype)
|
||||
self.send_header("content-length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _handle_transcription(self, translate=False):
|
||||
# OpenAI /v1/audio/transcriptions (Whisper-shaped) backed by Gemma 4
|
||||
ct = self.headers.get("content-type", "")
|
||||
m = re.search(r"boundary=([^;]+)", ct)
|
||||
if "multipart/form-data" not in ct or not m:
|
||||
return self._json(400, {"error": "expected multipart/form-data with a boundary"})
|
||||
boundary = m.group(1).strip().strip('"')
|
||||
length = int(self.headers.get("content-length", 0) or 0)
|
||||
fields = parse_multipart(self.rfile.read(length), boundary)
|
||||
if "file" not in fields or fields["file"][1] is None:
|
||||
return self._json(400, {"error": "missing 'file' form field (the audio)"})
|
||||
audio_b64 = base64.b64encode(fields["file"][1]).decode()
|
||||
resp_format = (fields.get("response_format", (None, b"json"))[1] or b"json").decode().strip()
|
||||
prompt = ("Transcribe the speech in this audio and translate it into English. "
|
||||
"Output only the English text, nothing else." if translate else
|
||||
"Transcribe the speech in this audio verbatim. "
|
||||
"Output only the transcription text, nothing else.")
|
||||
native = {"model": AUDIO_MODEL, "stream": False, "keep_alive": "30m",
|
||||
"options": {"temperature": 0},
|
||||
"messages": [{"role": "user", "content": prompt, "images": [audio_b64]}]}
|
||||
print(f"[router] stt -> {AUDIO_MODEL}", flush=True)
|
||||
try:
|
||||
req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(native).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=600) as r:
|
||||
d = json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return self._json(e.code, {"error": f"stt upstream {e.code}: {e.read().decode()[:300]}"})
|
||||
except Exception as e:
|
||||
return self._json(502, {"error": f"stt upstream: {e}"})
|
||||
text = ((d.get("message") or {}).get("content", "") or "").strip()
|
||||
if resp_format == "text":
|
||||
return self._text(200, text)
|
||||
return self._json(200, {"text": text})
|
||||
|
||||
def _handle_audio(self, body):
|
||||
# OpenAI request carrying audio -> Ollama native /api/chat (images field), Gemma 4
|
||||
model = AUDIO_MODEL
|
||||
native = {"model": model, "messages": to_native_messages(body.get("messages", [])),
|
||||
"stream": False, "keep_alive": "30m", "options": {}}
|
||||
if isinstance(body.get("options"), dict):
|
||||
native["options"].update(body["options"])
|
||||
if body.get("max_tokens"):
|
||||
native["options"]["num_predict"] = body["max_tokens"]
|
||||
if body.get("temperature") is not None:
|
||||
native["options"]["temperature"] = body["temperature"]
|
||||
print(f"[router] audio -> {model}", flush=True)
|
||||
try:
|
||||
req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(native).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=600) as r:
|
||||
d = json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
return self._json(e.code, {"error": f"audio upstream {e.code}: {e.read().decode()[:300]}"})
|
||||
except Exception as e:
|
||||
return self._json(502, {"error": f"audio upstream: {e}"})
|
||||
content = (d.get("message") or {}).get("content", "") or ""
|
||||
pc, ec = d.get("prompt_eval_count", 0) or 0, d.get("eval_count", 0) or 0
|
||||
cid, created = "chatcmpl-audio", int(time.time())
|
||||
hdr = {"x-router-model": model, "x-router-initial-model": model, "x-router-decided-by": "audio"}
|
||||
if body.get("stream"):
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "text/event-stream")
|
||||
self.send_header("cache-control", "no-cache")
|
||||
self.send_header("connection", "close")
|
||||
for k, v in hdr.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
first = {"id": cid, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": content},
|
||||
"finish_reason": None}]}
|
||||
last = {"id": cid, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}
|
||||
self.wfile.write(f"data: {json.dumps(first)}\n\n".encode())
|
||||
self.wfile.write(f"data: {json.dumps(last)}\n\n".encode())
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
else:
|
||||
self._json(200, {"id": cid, "object": "chat.completion", "created": created, "model": model,
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": content},
|
||||
"finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": pc, "completion_tokens": ec,
|
||||
"total_tokens": pc + ec}}, extra=hdr)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.rstrip("/")
|
||||
if path == "/v1/audio/transcriptions":
|
||||
return self._handle_transcription()
|
||||
if path == "/v1/audio/translations":
|
||||
return self._handle_transcription(translate=True)
|
||||
if path.startswith("/v1/messages"):
|
||||
return self._proxy_anthropic(path)
|
||||
if path == "/api/show": # ollama launch model probe (read-only)
|
||||
length = int(self.headers.get("content-length", 0) or 0)
|
||||
return self._proxy_ollama("/api/show", self.rfile.read(length) or b"{}")
|
||||
if path != "/v1/chat/completions":
|
||||
return self._json(404, {"error": "not found"})
|
||||
if not self._authed():
|
||||
return self._json(401, {"error": "unauthorized: missing/invalid Bearer token"})
|
||||
try:
|
||||
length = int(self.headers.get("content-length", 0))
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
except Exception as e:
|
||||
return self._json(400, {"error": f"bad request: {e}"})
|
||||
|
||||
if has_audio(body.get("messages", [])): # OpenAI audio -> native Ollama path
|
||||
return self._handle_audio(body)
|
||||
|
||||
requested = (body.get("model") or "auto").strip()
|
||||
auth = self.headers.get("authorization", "")
|
||||
token = auth[7:].strip() if auth.startswith("Bearer ") else ""
|
||||
force_uncensored = bool(UNCENSORED_KEY) and token == UNCENSORED_KEY
|
||||
if force_uncensored or requested in ("auto-uncensored", "uncensored"):
|
||||
model, decided_by = choose_uncensored(body), "uncensored"
|
||||
elif requested not in ("auto", "router", "router/auto"):
|
||||
model, decided_by = requested, "client"
|
||||
else:
|
||||
model, decided_by = choose_model(body), "router"
|
||||
|
||||
initial_model = model # the routing decision, before any fallback
|
||||
stream = bool(body.get("stream"))
|
||||
tried = []
|
||||
while True:
|
||||
body["model"] = model
|
||||
tried.append(model)
|
||||
print(f"[router] {decided_by:6s} -> {model}"
|
||||
f"{' (stream)' if stream else ''}", flush=True)
|
||||
try:
|
||||
up = _open_upstream(body)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
nxt = FALLBACK.get(model)
|
||||
if nxt and nxt not in tried:
|
||||
print(f"[router] {model} failed ({e.code}); fallback -> {nxt}", flush=True)
|
||||
model, decided_by = nxt, "fallback"
|
||||
continue
|
||||
detail = e.read().decode(errors="replace")[:500]
|
||||
return self._json(e.code, {"error": f"upstream {e.code}: {detail}",
|
||||
"model": model})
|
||||
except Exception as e:
|
||||
nxt = FALLBACK.get(model)
|
||||
if nxt and nxt not in tried:
|
||||
model, decided_by = nxt, "fallback"
|
||||
continue
|
||||
return self._json(502, {"error": f"upstream unreachable: {e}", "model": model})
|
||||
|
||||
hdr = {"x-router-model": model, "x-router-initial-model": initial_model,
|
||||
"x-router-decided-by": decided_by}
|
||||
if stream:
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "text/event-stream")
|
||||
self.send_header("cache-control", "no-cache")
|
||||
self.send_header("connection", "close")
|
||||
for k, v in hdr.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
try:
|
||||
while True:
|
||||
chunk = up.read(2048)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
self.wfile.flush()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
up.close()
|
||||
else:
|
||||
data = up.read()
|
||||
up.close()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(data)))
|
||||
for k, v in hdr.items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f">> llm-router on http://{HOST}:{PORT}/v1 (upstream {UPSTREAM}, "
|
||||
f"auth {'ON' if API_KEY else 'OFF'})", flush=True)
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
Reference in New Issue
Block a user