SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%

LLM gateway: wait-and-retry on 503/429 while the local server loads or swaps models; one LLM job at a time in production

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent 0f06429

2 changed files +20 −4

modified deploy/ai-atlas.mld.json +1 −1
@@ -66,7 +66,7 @@
66 66 "AIA_ADMIN_TOKEN": "{{ADMIN_TOKEN}}",
67 67 "AIA_LLM_BASE_URL": "https://www.llm-api.io/v1",
68 68 "AIA_LLM_API_KEY": "{{LLM_KEY}}",
69 − "AIA_WORKER_CONCURRENCY": "2",
69 + "AIA_WORKER_CONCURRENCY": "1",
70 70 "AIA_LOG_JSON": "1",
71 71 "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin",
72 72 "PYTHONUNBUFFERED": "1"
modified src/aiatlas/services/llm/gateway.py +19 −3
@@ -2,6 +2,7 @@
2 2 schemas, full cost accounting in `llm_jobs`. Deterministic processing always runs before any call here."""
3 3 from __future__ import annotations
4 4
5 +import asyncio
5 6 import json
6 7 import logging
7 8 import re
@@ -67,16 +68,31 @@ class OpenAICompatEngine(Engine):
67 68 return httpx.AsyncClient(base_url=self.base_url, headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
68 69 timeout=httpx.Timeout(self.timeout_s, connect=30))
69 70
71 + async def _post(self, client: httpx.AsyncClient, path: str, body: dict[str, Any]) -> httpx.Response:
72 + """Local inference servers answer 503/429 while they load or swap a model: wait and retry instead of failing the job."""
73 + delay = 15.0
74 + for attempt in range(6):
75 + r = await client.post(path, json=body)
76 + if r.status_code in (503, 429, 502, 504) and attempt < 5:
77 + retry_after = r.headers.get("retry-after")
78 + wait = min(120.0, float(retry_after)) if retry_after and retry_after.replace(".", "", 1).isdigit() else delay
79 + log.info("llm server busy, retrying", extra={"status": r.status_code, "wait": wait, "path": path})
80 + await asyncio.sleep(wait)
81 + delay = min(120.0, delay * 1.7)
82 + continue
83 + return r
84 + return r
85 +
70 86 async def complete(self, *, model: str, system: str, user: str, max_tokens: int, temperature: float, json_mode: bool) -> tuple[str, dict[str, Any]]:
71 87 body: dict[str, Any] = {"model": model, "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
72 88 "max_tokens": max_tokens, "temperature": temperature, "stream": False}
73 89 if json_mode:
74 90 body["response_format"] = {"type": "json_object"}
75 91 async with self._client() as client:
76 − r = await client.post("/chat/completions", json=body)
92 + r = await self._post(client, "/chat/completions", body)
77 93 if r.status_code == 400 and json_mode:
78 94 body.pop("response_format")
79 − r = await client.post("/chat/completions", json=body)
95 + r = await self._post(client, "/chat/completions", body)
80 96 r.raise_for_status()
81 97 data = r.json()
82 98 choice = (data.get("choices") or [{}])[0]
@@ -85,7 +101,7 @@ class OpenAICompatEngine(Engine):
85 101
86 102 async def embed(self, *, model: str, texts: list[str]) -> list[list[float]]:
87 103 async with self._client() as client:
88 − r = await client.post("/embeddings", json={"model": model, "input": texts})
104 + r = await self._post(client, "/embeddings", {"model": model, "input": texts})
89 105 r.raise_for_status()
90 106 data = r.json()
91 107 items = sorted(data.get("data") or [], key=lambda d: d.get("index", 0))
92 108