proxytool 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
proxytool/main.py ADDED
@@ -0,0 +1,558 @@
1
+ """FastAPI middleware: sits between the agent client and the Qwen server.
2
+
3
+ - POST /v1/chat/completions : sanitize the outbound request, repair leaked
4
+ tool calls in the inbound response (both streaming SSE and non-streaming).
5
+ - Everything else: byte pass-through.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import time
13
+ from collections.abc import AsyncGenerator
14
+ from typing import Any
15
+
16
+ import httpx
17
+ from fastapi import FastAPI, Request
18
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
19
+
20
+ import proxytool as _pkg
21
+ from proxytool import trace
22
+ from proxytool.conduit import UpstreamConduit
23
+ from proxytool.loopguard import ABORT_TEXT, NUDGE_TEXT, LoopGuard
24
+ from proxytool.settings import get_settings
25
+ from toolpatch import StreamHealer, extract_tool_calls, normalize_chat_request
26
+ from toolpatch.logging import get_logger
27
+ from toolpatch.request import Backend
28
+
29
+ _log = get_logger("proxytool.main")
30
+
31
+
32
+ def _can_emit(obj: object) -> bool:
33
+ """True only for a configured stdlib logger (no-op loggers drop lines)."""
34
+ import logging as _std_logging
35
+
36
+ return obj is not None and isinstance(obj, _std_logging.Logger)
37
+
38
+
39
+ settings = get_settings()
40
+ # When the app module is imported without the CLI (e.g. `uvicorn
41
+ # proxytool.main:app`), no one has configured app logging yet; apply the
42
+ # env-driven default exactly once. A CLI configure call has already set the
43
+ # "proxytool" logger level, so this is a no-op then.
44
+ if _pkg._logging.getLogger("proxytool").level == _pkg._logging.NOTSET:
45
+ _pkg.configure_logging()
46
+ trace.attach_logger(_log)
47
+ trace.enable(settings.trace)
48
+ conduit = UpstreamConduit(
49
+ settings.upstream_url,
50
+ timeout=settings.upstream_timeout,
51
+ api_key=settings.upstream_api_key,
52
+ )
53
+ loop_guard = LoopGuard(
54
+ nudge_after=settings.loop_nudge,
55
+ abort_after=settings.loop_abort,
56
+ )
57
+ app = FastAPI(title="llama-middleware proxy")
58
+
59
+ if _can_emit(_log):
60
+ _log.debug("middleware initialized: upstream=%s backend=%s",
61
+ settings.upstream_url, settings.backend)
62
+
63
+ PASS_HEADERS_OUT = ("content-type", "x-request-id")
64
+
65
+ _UPSTREAM_MODELS: list[str] | None = None # lazily discovered, cached
66
+
67
+ # ---- optional multi-backend routing -----------------------------------------
68
+ # When LTP_UPSTREAM_<NAME> vars are present, requests are routed per-model
69
+ # through BackendRouter; each routed backend gets its own conduit. With no
70
+ # LTP_UPSTREAM_* vars, everything flows through the single `conduit` above
71
+ # (LMW_UPSTREAM_URL), preserving single-backend behavior byte-for-byte.
72
+
73
+ from proxytool.routing import BackendRouter, backends_from_env # noqa: E402
74
+
75
+ _ROUTER: BackendRouter | None = None
76
+ _CONDUITS: dict[str, UpstreamConduit] = {}
77
+
78
+
79
+ def get_router() -> BackendRouter | None:
80
+ """Build the router from env once (LTP_UPSTREAM_*); None = single-backend."""
81
+ global _ROUTER
82
+ if _ROUTER is None:
83
+ declared = backends_from_env()
84
+ if len(declared) <= 1:
85
+ _ROUTER = BackendRouter() # empty, stays None forever in single mode
86
+ else:
87
+ router = BackendRouter()
88
+ for name, backend in declared.items():
89
+ router.register(backend)
90
+ _CONDUITS[name] = UpstreamConduit(
91
+ backend.url,
92
+ timeout=settings.upstream_timeout,
93
+ api_key=settings.upstream_api_key,
94
+ )
95
+ _ROUTER = router
96
+ trace.log("MW.ROUTER", {n: b.url for n, b in declared.items()})
97
+ return _ROUTER if _ROUTER.backends else None
98
+
99
+
100
+ def conduit_for(backend_name: str | None) -> UpstreamConduit:
101
+ """Conduit for the routed backend, or the default single conduit."""
102
+ if backend_name and backend_name in _CONDUITS:
103
+ return _CONDUITS[backend_name]
104
+ return conduit
105
+
106
+
107
+ def _request_tool_names(payload: dict[str, Any]) -> set[str] | None:
108
+ tools = payload.get("tools")
109
+ if not isinstance(tools, list) or not tools:
110
+ return None
111
+ names = set()
112
+ for t in tools:
113
+ fn = t.get("function") or {}
114
+ name = fn.get("name")
115
+ if isinstance(name, str):
116
+ names.add(name)
117
+ return names or None
118
+
119
+
120
+ class _SSEParser:
121
+ """Stateful SSE frame parser for chunked upstream streams.
122
+
123
+ HTTP chunks are *not* guaranteed to end on an SSE frame boundary: a
124
+ `data:` JSON payload can be split across two ``aiter_bytes()`` chunks,
125
+ and a multibyte UTF-8 character can be split mid-sequence. Bytes are
126
+ accumulated until a complete ``\n``-terminated line exists and only
127
+ whole lines are decoded, so both cases resolve before ``json.loads``
128
+ ever sees a payload (”data events carry the none-None frame dict).
129
+ """
130
+
131
+ def __init__(self) -> None:
132
+ self._buf = b""
133
+
134
+ def feed(self, chunk: bytes) -> list[tuple[str, dict[str, Any] | None]]:
135
+ out: list[tuple[str, dict[str, Any] | None]] = []
136
+ self._buf += chunk
137
+ while b"\n" in self._buf:
138
+ line, self._buf = self._buf.split(b"\n", 1)
139
+ out.append(_sse_line_event(line))
140
+ return out
141
+
142
+ def finish(self) -> list[tuple[str, dict[str, Any] | None]]:
143
+ """Drain a trailing unterminated line (tolerates sloppy servers)."""
144
+ if not self._buf:
145
+ return []
146
+ line, self._buf = self._buf, b""
147
+ return [_sse_line_event(line)]
148
+
149
+
150
+ def _sse_line_event(line_bytes: bytes) -> tuple[str, dict[str, Any] | None]:
151
+ """Parse one complete SSE line into a (kind, data-or-None) tuple."""
152
+ line = line_bytes.decode("utf-8", errors="replace").rstrip("\r")
153
+ body = None
154
+ if line.startswith("data: "):
155
+ body = line[6:]
156
+ elif line.startswith("data:"):
157
+ body = line[5:].lstrip()
158
+ if body is None:
159
+ return ("raw" if line.strip() else "comment", None)
160
+ if body == "[DONE]":
161
+ return ("done", None)
162
+ try:
163
+ return ("data", json.loads(body))
164
+ except json.JSONDecodeError:
165
+ return ("raw", None)
166
+
167
+
168
+ async def _resolve_model(model: str) -> str:
169
+ """Map a client-facing model id to one the upstream actually serves.
170
+
171
+ Order: LMW_MODEL_MAP → upstream /v1/models membership → single loaded
172
+ model. mlx_lm.server resolves unknown model ids against HF (401/404
173
+ "Repository Not Found"), so an unmapped alias must never be forwarded
174
+ raw when the real id is discoverable.
175
+ """
176
+ mapped = settings.model_map.get(model)
177
+ if mapped:
178
+ return mapped
179
+ global _UPSTREAM_MODELS
180
+ if _UPSTREAM_MODELS is None:
181
+ try:
182
+ upstream = await conduit.passthrough("GET", "v1/models", {}, b"")
183
+ data = upstream.json().get("data", [])
184
+ _UPSTREAM_MODELS = [m.get("id", "") for m in data if isinstance(m, dict)]
185
+ except Exception:
186
+ _UPSTREAM_MODELS = [] # unreachable/mock — keep alias as-is
187
+ trace.log("MW.MODELS", _UPSTREAM_MODELS)
188
+ known = [m for m in _UPSTREAM_MODELS if m]
189
+ if model in known:
190
+ return model
191
+ best = _pick_by_alias(model, known)
192
+ return best or model
193
+
194
+
195
+ def _pick_by_alias(alias: str, known: list[str]) -> str | None:
196
+ """Best upstream id for an unmapped alias, or None to pass through.
197
+
198
+ Scores each upstream id by token overlap with the alias (e.g.
199
+ ``qwen-mlx`` → tokens {qwen, mlx}). Ties prefer the artifact the server
200
+ was actually launched with — a filesystem path beats an HF repo id,
201
+ which matters when the cache contains several Qwen mlx snapshots.
202
+ """
203
+ tokens = {t for t in re.split(r"[-/_ .]", alias.lower()) if t}
204
+ if not tokens:
205
+ return None
206
+ scored: list[tuple[int, int, str]] = []
207
+ for mid in known:
208
+ low = mid.lower()
209
+ overlap = sum(1 for t in tokens if t in re.split(r"[-/_ .]", low))
210
+ if overlap == 0:
211
+ continue
212
+ path_like = 1 if (mid.startswith("/") or "snapshots/" in mid) else 0
213
+ scored.append((overlap, path_like, mid))
214
+ if not scored:
215
+ return None
216
+ scored.sort(reverse=True)
217
+ top_overlap, top_path, _ = scored[0]
218
+ winners = [s for s in scored if (s[0], s[1]) == (top_overlap, top_path)]
219
+ if len(winners) != 1:
220
+ return None # genuinely ambiguous — don't guess
221
+ return winners[0][2]
222
+
223
+
224
+ @app.get("/health")
225
+ async def health() -> dict[str, str]:
226
+ return {"status": "ok", "upstream": settings.upstream_url}
227
+
228
+
229
+ def _resolve_backend(payload: dict[str, Any]) -> Backend:
230
+ """Backend decision: payload heuristics first, else LMW_BACKEND env (gguf default).
231
+
232
+ A model id mentioning mlx, or an explicit mlx-community/HF repo id,
233
+ implies mlx; anything else defers to settings.backend.
234
+ """
235
+ model = str(payload.get("model") or "")
236
+ if "mlx" in model.lower() or model.startswith("mlx-community/"):
237
+ return "mlx"
238
+ resolved: Backend = "mlx" if settings.backend == "mlx" else "gguf"
239
+ return resolved
240
+
241
+
242
+ @app.post("/v1/chat/completions")
243
+ async def chat_completions(request: Request):
244
+ body = await request.body()
245
+ try:
246
+ payload = json.loads(body)
247
+ except json.JSONDecodeError:
248
+ return JSONResponse({"error": "invalid JSON body"}, status_code=400)
249
+
250
+ trace.log("CLIENT->MW", payload)
251
+ # Route to a multi-backend upstream by the client's model id (no-op in
252
+ # single-backend mode). Routing happens before sanitization because the
253
+ # sanitized payload's model may have been remapped by LMW_MODEL_MAP.
254
+ chat_conduit = conduit
255
+ router = get_router()
256
+ if router is not None:
257
+ client_model = str(payload.get("model") or "")
258
+ decision = router.route(client_model)
259
+ chat_conduit = conduit_for(decision.backend.name)
260
+ trace.log("MW.ROUTE", {"model": client_model,
261
+ "backend": decision.backend.name,
262
+ "matched_by": decision.matched_by})
263
+ backend = _resolve_backend(payload)
264
+ payload, notes = normalize_chat_request(payload, backend=backend)
265
+ if backend == "mlx":
266
+ payload.pop("tool_choice", None)
267
+ notes.append("tool_choice stripped (mlx backend)")
268
+ # Remap the client-facing model id to what the upstream actually serves.
269
+ # mlx_lm.server resolves unknown model ids against HF ("Repository Not
270
+ # Found"); llama-server ignores names, but remapping helps logs too.
271
+ model = str(payload.get("model") or "qwen")
272
+ payload["model"] = await _resolve_model(model)
273
+ if notes:
274
+ trace.log("MW.NOTES", notes)
275
+ trace.log("MW->UPSTREAM", payload)
276
+
277
+ # LoopGuard: identical consecutive prompts make llama-server replay the
278
+ # same continuation forever. Nudge the model, then cut the loop off.
279
+ decision = loop_guard.check(LoopGuard.request_key(payload))
280
+ trace.log("MW.GUARD", {"action": decision.action, "count": decision.count})
281
+ if decision.action == "abort":
282
+ return _loop_guard_abort(decision.count, stream=bool(payload.get("stream")))
283
+ if decision.action == "nudge":
284
+ payload["messages"] = [
285
+ *payload["messages"],
286
+ {"role": "user", "content": NUDGE_TEXT},
287
+ ]
288
+ trace.log("MW->UPSTREAM", payload)
289
+
290
+ if payload.get("stream"):
291
+ return await _streaming(
292
+ request=request, payload=payload,
293
+ client_headers={k.lower(): v for k, v in request.headers.items()},
294
+ conduit=chat_conduit,
295
+ )
296
+ return await _non_streaming(payload=payload, client_headers=
297
+ {k.lower(): v for k, v in request.headers.items()}, conduit=chat_conduit)
298
+
299
+
300
+ async def _non_streaming(payload: dict[str, Any],
301
+ client_headers: dict[str, str] | None = None,
302
+ conduit: UpstreamConduit | None = None):
303
+ conduit = conduit or globals()["conduit"]
304
+ assert conduit is not None
305
+ upstream = await conduit.forward_json(
306
+ "/v1/chat/completions", payload, headers=client_headers
307
+ )
308
+ if upstream.status_code >= 400:
309
+ trace.log("UPSTREAM->MW", {"error": upstream.text[:500]})
310
+ return JSONResponse(
311
+ {"error": {"message": f"upstream error (HTTP {upstream.status_code}): "
312
+ f"{upstream.text[:500]}",
313
+ "type": "upstream_error",
314
+ "code": upstream.status_code}},
315
+ status_code=upstream.status_code,
316
+ )
317
+ try:
318
+ data = upstream.json()
319
+ except (json.JSONDecodeError, UnicodeDecodeError):
320
+ return _as_response(upstream, upstream.content)
321
+
322
+ trace.log("UPSTREAM->MW", data)
323
+ tool_names = _request_tool_names(payload)
324
+ _repair_response(data, tool_names)
325
+ trace.log("MW->CLIENT", data)
326
+ return JSONResponse(content=data, status_code=upstream.status_code)
327
+
328
+
329
+ def _repair_response(data: dict[str, Any], tool_names: set[str] | None) -> bool:
330
+ """Rewrite leaked tool calls inside data in place. True if repaired."""
331
+ repaired = False
332
+ for choice in data.get("choices", []) or []:
333
+ message = choice.get("message") or {}
334
+ content = message.get("content")
335
+ if not isinstance(content, str) or not content:
336
+ continue
337
+ ex = extract_tool_calls(content, tool_names, allow_unterminated_hermes=True)
338
+ if ex is None:
339
+ continue
340
+ message["content"] = ex.content
341
+ existing = message.get("tool_calls") or []
342
+ message["tool_calls"] = existing + ex.tool_calls
343
+ choice["message"] = message
344
+ repaired = True
345
+ return repaired
346
+
347
+
348
+ async def _streaming(request: Request, payload: dict[str, Any],
349
+ client_headers: dict[str, str] | None = None,
350
+ conduit: UpstreamConduit | None = None):
351
+ conduit = conduit or globals()["conduit"]
352
+ assert conduit is not None
353
+ tool_names = _request_tool_names(payload)
354
+ upstream = await conduit.forward_stream(
355
+ "/v1/chat/completions", payload, headers=client_headers
356
+ )
357
+ if upstream.status_code >= 400:
358
+ body = await upstream.aread()
359
+ await upstream.aclose()
360
+ return _upstream_error_sse(upstream.status_code, body)
361
+
362
+ async def gen() -> AsyncGenerator[bytes]:
363
+ healer = StreamHealer(tool_names=tool_names)
364
+ sse = _SSEParser() # reassembles frames split across HTTP chunks
365
+ meta: dict[str, Any] = {}
366
+ usage: dict[str, Any] | None = None
367
+ upstream_finish: str | None = None
368
+ terminated = False # a finish_reason has been forwarded downstream
369
+ first_role: str | None = None # upstream role delta, forwarded once
370
+
371
+ def _ev_json(ev: dict[str, Any],
372
+ finish_reason: str | None = None,
373
+ usage_: dict[str, Any] | None = None) -> bytes:
374
+ nonlocal first_role
375
+ r, first_role = first_role, None
376
+ return _event_json(ev, meta, finish_reason, usage_, role=r)
377
+ try:
378
+ async for chunk in upstream.aiter_bytes():
379
+ for kind, data in sse.feed(chunk):
380
+ if kind in ("raw", "done"):
381
+ continue
382
+ if data is None:
383
+ continue
384
+ if not meta:
385
+ # Forward id/created/model so clients correlating
386
+ # chunks to the request don't see a different object.
387
+ meta = {
388
+ k: data[k]
389
+ for k in ("id", "created", "model", "system_fingerprint")
390
+ if k in data
391
+ }
392
+ if data.get("usage"):
393
+ usage = data["usage"]
394
+ choice = (data.get("choices") or [{}])[0]
395
+ if choice.get("finish_reason"):
396
+ upstream_finish = choice["finish_reason"]
397
+ delta = choice.get("delta") or {}
398
+ if first_role is None and delta.get("role"):
399
+ first_role = delta["role"]
400
+ text = delta.get("content")
401
+ if not text:
402
+ continue
403
+ for ev in healer.feed(text):
404
+ if ev["type"] == "tool_calls" and ev.get("stop"):
405
+ yield _ev_json(ev, "tool_calls", usage)
406
+ terminated = True
407
+ else:
408
+ yield _ev_json(ev)
409
+ for ev in healer.finish():
410
+ if ev["type"] == "tool_calls" and ev.get("stop"):
411
+ yield _ev_json(ev, "tool_calls", usage)
412
+ terminated = True
413
+ else:
414
+ yield _ev_json(ev)
415
+ if not terminated:
416
+ # Repaired/replayed streams still need OpenAI-protocol
417
+ # termination: without a finish_reason chunk, opencode treats
418
+ # every response as an unterminated turn and re-prompts.
419
+ yield _ev_json({"type": "control"}, upstream_finish or "stop", usage)
420
+ yield b"data: [DONE]\n\n"
421
+ finally:
422
+ await upstream.aclose()
423
+
424
+ return StreamingResponse(
425
+ gen(), media_type="text/event-stream", status_code=upstream.status_code
426
+ )
427
+
428
+
429
+ def _event_json(
430
+ ev: dict[str, Any],
431
+ meta: dict[str, Any],
432
+ finish_reason: str | None = None,
433
+ usage: dict[str, Any] | None = None,
434
+ role: str | None = None,
435
+ ) -> bytes:
436
+ """Render one StreamHealer event as an OpenAI-compatible SSE chunk."""
437
+ if ev["type"] == "tool_calls":
438
+ delta: dict[str, Any] = {"tool_calls": ev["tool_calls"]}
439
+ elif ev["type"] == "content":
440
+ delta = {"content": ev.get("text", "")}
441
+ else:
442
+ delta = {}
443
+ # OpenAI streams start with a role-bearing first delta; clients use it to
444
+ # initialize the assistant message, so the first emitted chunk carries it.
445
+ if role is not None:
446
+ delta = {"role": role, **delta}
447
+ out: dict[str, Any] = {
448
+ **meta,
449
+ "choices": [
450
+ {
451
+ "delta": delta,
452
+ "index": 0,
453
+ "finish_reason": finish_reason,
454
+ }
455
+ ],
456
+ }
457
+ if usage is not None:
458
+ out["usage"] = usage
459
+ return f"data: {json.dumps(out, separators=(',', ':'))}\n\n".encode()
460
+
461
+
462
+ def _upstream_error_sse(status: int, body: bytes) -> StreamingResponse:
463
+ """Convert an upstream error into a single clean OpenAI-style SSE stream.
464
+
465
+ Without this, error pages / SSE keepalives from the upstream leak raw into
466
+ the client (opencode renders the garbled text or hangs waiting for deltas).
467
+ """
468
+ detail = body.decode("utf-8", errors="replace").strip()[:500]
469
+ msg = f"upstream error (HTTP {status}): {detail}"
470
+ chunk = {
471
+ "choices": [
472
+ {
473
+ "delta": {"content": msg},
474
+ "index": 0,
475
+ "finish_reason": "stop",
476
+ }
477
+ ]
478
+ }
479
+ payload = (
480
+ b"data: " + json.dumps(chunk, separators=(",", ":")).encode() + b"\n\ndata: [DONE]\n\n"
481
+ )
482
+ return StreamingResponse(
483
+ iter([payload]),
484
+ media_type="text/event-stream",
485
+ status_code=200,
486
+ )
487
+
488
+
489
+ def _loop_guard_abort(count: int, stream: bool) -> Response | StreamingResponse:
490
+ """Return a clean, short completion explaining the cut-off."""
491
+ text = ABORT_TEXT.format(count=count)
492
+ created = int(time.time())
493
+ if stream:
494
+ first = {
495
+ "id": "loopguard-abort",
496
+ "created": created,
497
+ "model": "loopguard",
498
+ "choices": [
499
+ {"delta": {"role": "assistant", "content": text},
500
+ "index": 0, "finish_reason": None}
501
+ ],
502
+ }
503
+ last = {
504
+ "id": "loopguard-abort",
505
+ "created": created,
506
+ "model": "loopguard",
507
+ "choices": [
508
+ {"delta": {}, "index": 0, "finish_reason": "stop"}
509
+ ],
510
+ }
511
+ sse = "".join(
512
+ f"data: {json.dumps(c, separators=(',', ':'))}\n\n" for c in (first, last)
513
+ ) + "data: [DONE]\n\n"
514
+ return StreamingResponse(
515
+ iter([sse.encode()]), media_type="text/event-stream", status_code=200
516
+ )
517
+ return JSONResponse(
518
+ {
519
+ "id": "loopguard-abort",
520
+ "created": created,
521
+ "model": "loopguard",
522
+ "choices": [
523
+ {
524
+ "message": {"role": "assistant", "content": text},
525
+ "index": 0,
526
+ "finish_reason": "stop",
527
+ }
528
+ ],
529
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
530
+ },
531
+ status_code=200,
532
+ )
533
+
534
+
535
+ @app.get("/v1/models")
536
+ @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
537
+ async def passthrough(request: Request, path: str = "v1/models"):
538
+ content = await request.body()
539
+ headers = {k.lower(): v for k, v in request.headers.items() if k.lower() != "host"}
540
+ try:
541
+ upstream = await conduit.passthrough(request.method, path, headers, content)
542
+ except httpx.ConnectError:
543
+ return JSONResponse({"error": f"upstream unreachable: {settings.upstream_url}"},
544
+ status_code=502)
545
+ return _as_response(upstream, upstream.content)
546
+
547
+
548
+ def _as_response(upstream: httpx.Response, content: bytes) -> Response:
549
+ headers = {}
550
+ for h in PASS_HEADERS_OUT:
551
+ if h in upstream.headers:
552
+ headers[h] = upstream.headers[h]
553
+ return Response(
554
+ content=content,
555
+ status_code=upstream.status_code,
556
+ headers=headers,
557
+ media_type=upstream.headers.get("content-type"),
558
+ )
proxytool/routing.py ADDED
@@ -0,0 +1,106 @@
1
+ """Backend routing table: which upstream does a request hit.
2
+
3
+ Backends can be declared on the CLI via repeatable ``--upstream NAME=URL``
4
+ flags or through ``LTP_UPSTREAM_<NAME>`` / ``LTP_UPSTREAM_DEFAULT`` env vars
5
+ (environment wins unless the flag is passed explicitly). Each backend carries
6
+ an optional comma-separated model list — ``--upstream gguf=http://...:8181
7
+ --upstream-models gguf=qwen-gguf,qwen`` — and the client-facing ``model`` id is
8
+ matched against it first, then against ``LTP_DEFAULT_BACKEND`` / the flag
9
+ default. Exactly one registered backend receives everything.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from dataclasses import dataclass, field
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Backend:
20
+ name: str
21
+ url: str
22
+ models: tuple[str, ...] = () # client-facing model ids this backend serves
23
+ default: bool = False
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RouteDecision:
28
+ backend: Backend
29
+ matched_by: str # "model-list" | "default" | "only-backend"
30
+
31
+
32
+ @dataclass
33
+ class BackendRouter:
34
+ backends: dict[str, Backend] = field(default_factory=dict)
35
+
36
+ def register(self, backend: Backend) -> None:
37
+ if backend.name in self.backends:
38
+ raise ValueError(f"duplicate backend name: {backend.name}")
39
+ self.backends[backend.name] = backend
40
+
41
+ @property
42
+ def default(self) -> Backend | None:
43
+ defaults = [b for b in self.backends.values() if b.default]
44
+ if len(defaults) == 1:
45
+ return defaults[0]
46
+ if defaults:
47
+ raise ValueError("multiple backends flagged default")
48
+ return next(iter(self.backends.values()), None)
49
+
50
+ def route(self, model: str | None) -> RouteDecision:
51
+ model = (model or "").strip().lower()
52
+ candidates = [b for b in self.backends.values() if b.models]
53
+ for b in candidates:
54
+ if model in {m.lower() for m in b.models}:
55
+ return RouteDecision(b, "model-list")
56
+ d = self.default
57
+ if d is None:
58
+ raise RuntimeError("no backends registered")
59
+ if len(self.backends) == 1:
60
+ return RouteDecision(d, "only-backend")
61
+ return RouteDecision(d, "default")
62
+
63
+
64
+ def backends_from_env() -> dict[str, Backend]:
65
+ """Build backends from LTP_UPSTREAM_<NAME> / _MODELS / _DEFAULT env vars."""
66
+ out: dict[str, Backend] = {}
67
+ for var, val in os.environ.items():
68
+ if not var.startswith("LTP_UPSTREAM_"):
69
+ continue
70
+ rest = var[len("LTP_UPSTREAM_"):]
71
+ if rest.endswith("_MODELS"):
72
+ continue
73
+ if rest == "DEFAULT":
74
+ continue
75
+ name = rest.lower().replace("-", "_")
76
+ models_var = f"LTP_UPSTREAM_{rest}_MODELS"
77
+ models = tuple(
78
+ m.strip() for m in os.environ.get(models_var, "").split(",") if m.strip()
79
+ )
80
+ out[name] = Backend(
81
+ name=name,
82
+ url=val.rstrip("/"),
83
+ models=models,
84
+ default=os.environ.get("LTP_DEFAULT_BACKEND", "").lower() == name,
85
+ )
86
+ return out
87
+
88
+
89
+ def backends_from_args(upstreams: list[str], models: dict[str, str],
90
+ default_backend: str | None) -> dict[str, Backend]:
91
+ """Build backends from repeatable --upstream NAME=URL and --upstream-models NAME=a,b."""
92
+ out: dict[str, Backend] = {}
93
+ for spec in upstreams:
94
+ name, _, url = spec.partition("=")
95
+ name = name.strip().lower()
96
+ if not name or not url:
97
+ raise ValueError(f"--upstream expects NAME=URL, got: {spec!r}")
98
+ out[name] = Backend(
99
+ name=name,
100
+ url=url.rstrip("/"),
101
+ models=tuple(
102
+ m.strip() for m in models.get(name, "").split(",") if m.strip()
103
+ ),
104
+ default=(default_backend or "").lower() == name,
105
+ )
106
+ return out