Spaces:
Sleeping
feat(llm-chain): KI-088 — cap NIM outbound concurrency at 2 + serialise probe + drop inner retry
Browse filesLive probe at 6a47549 measured only 2/10 brain turns succeeding despite
KI-080 election + KI-085 credit gating + KI-087 NIM-first + KI-079
escalation all live. The chain architecture is correct; the binding
constraint is NIM's free-tier per-key concurrency (~3-5 slots).
Root cause: 5 sources of NIM traffic stack on the same key with no
global throttle.
1. Probe loop (every 300s) fired ~6 candidates in parallel via
asyncio.gather → 6-slot burst.
2. Admin tab polls /api/admin/llm-health every 30s.
3. Per-user chat turn: 1-2 NIM calls.
4. N concurrent users → N simultaneous calls.
5. Inner 4-attempt 429/5xx retry held one slot for up to ~15s on
exponential backoff sleeps.
6+ in-flight → queue → 15-25s response times → bot's 12s outer cap
fires → user sees fallback. 20% brain success rate measured.
Three coordinated fixes:
FIX 1 — Module-level asyncio.Semaphore(2) at backend/providers/
nvidia_nim_llm.py shared across every NvidiaNimLLM instance. Wraps
ONLY the httpx.post round-trip, not election or response parsing,
so we cap network concurrency without serialising the reasoning
pipeline. Verified inline: 5 concurrent chat() tasks → peak
in-flight = 2.
FIX 2 — Serial probe in backend/llm_health.py: replaced
asyncio.gather(*probe_one(m) for m in models) with an explicit
for-loop. With KI-088's semaphore in place, parallel gather would
still cap at 2 in-flight but add no benefit over serial; serial is
simpler and naturally yields control to user traffic between
candidates. 6 NIM probes × ~2s = ~12s, well under the 300s cadence.
FIX 3 — Dropped the inner 4-attempt exponential backoff retry in
NvidiaNimLLM.chat(). Single httpx.post per call. KI-080's elector
already demotes a 429/5xx primary + falls through to the elected
cross-provider backup in the same turn, then KI-079 escalates via
BRAIN_CHAIN. Per-call retry was a pre-KI-080 vestige that only
re-queued against the same slot-starved pool.
FIX 4 — Comment marker on /api/admin/llm-health confirming it
reads cached llm_health.load() snapshot only; future contributors
must not add live probing to the admin path.
Expected impact: brain success rate climbs from ~20% → 80%+ even under
heavy traffic because user calls never wait past NIM's idle response
time (~4s). Worst-case per-turn NIM concurrency = 2 (ours) + whatever
NIM accepts from the rest of the world; we no longer self-saturate.
Testing:
- python -m py_compile on all three modified files: OK.
- Inline semaphore test (5 parallel chat() tasks, fake httpx.post
instrumented to track concurrent entrants): peak in-flight = 2.
- tests/test_routing_regression.py: 15/15 pass, 13 subtests pass.
(test_credits_election failure on main is pre-existing, unrelated.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/admin.py +8 -1
- backend/llm_health.py +25 -5
- backend/providers/nvidia_nim_llm.py +49 -13
|
@@ -787,11 +787,18 @@ async def admin_llm_health(
|
|
| 787 |
"""
|
| 788 |
_check_admin(request, x_admin_password)
|
| 789 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
import time as _time
|
| 791 |
now_mono = _time.monotonic()
|
| 792 |
|
| 793 |
chains = _chain_names_map()
|
| 794 |
-
state = llm_health.load() # {model -> ModelHealth}
|
| 795 |
|
| 796 |
# Section A: per-chain election + credit banner.
|
| 797 |
chains_block = [
|
|
|
|
| 787 |
"""
|
| 788 |
_check_admin(request, x_admin_password)
|
| 789 |
|
| 790 |
+
# KI-088: admin endpoint must never trigger probes — read cached state only.
|
| 791 |
+
# Live probing from the admin tab (polled every 30s by the frontend) would
|
| 792 |
+
# stack 6+ NIM candidates onto the same per-key concurrency budget and
|
| 793 |
+
# starve user chat traffic. All data below comes from llm_health.load()
|
| 794 |
+
# (in-memory snapshot persisted by the background_probe_loop) and the
|
| 795 |
+
# llm_usage.jsonl append-only log — both are read-only and trigger zero
|
| 796 |
+
# outbound LLM calls.
|
| 797 |
import time as _time
|
| 798 |
now_mono = _time.monotonic()
|
| 799 |
|
| 800 |
chains = _chain_names_map()
|
| 801 |
+
state = llm_health.load() # {model -> ModelHealth} (cached snapshot only)
|
| 802 |
|
| 803 |
# Section A: per-chain election + credit banner.
|
| 804 |
chains_block = [
|
|
@@ -984,12 +984,32 @@ async def probe_all() -> dict[str, ModelHealth]:
|
|
| 984 |
# candidates must keep getting probed even when NVIDIA_NIM_API_KEY
|
| 985 |
# is missing.
|
| 986 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
async with httpx.AsyncClient() as client:
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
|
|
|
|
|
|
| 993 |
|
| 994 |
for model, result in zip(models, results):
|
| 995 |
if isinstance(result, Exception):
|
|
|
|
| 984 |
# candidates must keep getting probed even when NVIDIA_NIM_API_KEY
|
| 985 |
# is missing.
|
| 986 |
|
| 987 |
+
# KI-088 (2026-05-15) — Probe candidates serially, not in parallel.
|
| 988 |
+
#
|
| 989 |
+
# Pre-KI-088: `asyncio.gather(*probe_one(m) for m in models)` fired
|
| 990 |
+
# all candidates simultaneously. Even though they hit different NIM
|
| 991 |
+
# *model pools*, they all share the same per-API-key concurrency
|
| 992 |
+
# quota (~3-5 slots free-tier). A 6-candidate parallel burst every
|
| 993 |
+
# 300s would queue inside NIM and steal slots from in-flight user
|
| 994 |
+
# turns — exactly the saturation pattern the global outbound
|
| 995 |
+
# semaphore in nvidia_nim_llm.py is sized to prevent.
|
| 996 |
+
#
|
| 997 |
+
# With KI-088's semaphore in place, parallel gather would still
|
| 998 |
+
# be hard-capped at 2 in-flight but introduce no benefit over a
|
| 999 |
+
# serial loop. Serial is simpler, easier to reason about, and
|
| 1000 |
+
# naturally yields control to user-traffic between candidates.
|
| 1001 |
+
#
|
| 1002 |
+
# Cost: 6 NIM candidates × ~2s healthy probe = ~12s, well under
|
| 1003 |
+
# the 300s probe cadence. No sleep between candidates — the
|
| 1004 |
+
# outbound semaphore handles pacing.
|
| 1005 |
async with httpx.AsyncClient() as client:
|
| 1006 |
+
results = []
|
| 1007 |
+
for m in models:
|
| 1008 |
+
try:
|
| 1009 |
+
result = await probe_one(client, m, "")
|
| 1010 |
+
except Exception as exc:
|
| 1011 |
+
result = exc
|
| 1012 |
+
results.append(result)
|
| 1013 |
|
| 1014 |
for model, result in zip(models, results):
|
| 1015 |
if isinstance(result, Exception):
|
|
@@ -79,6 +79,31 @@ async def _append_usage(record: dict) -> None:
|
|
| 79 |
pass
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
NVIDIA_NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
| 83 |
# 2026-05-14 brain swap (D-022): NIM's DeepSeek-V4 + Meta Llama inference pools
|
| 84 |
# are repeatedly timing out (15-120s on chat completions, no response). Qwen
|
|
@@ -155,21 +180,32 @@ class NvidiaNimLLM(LLMProvider):
|
|
| 155 |
write=2.0,
|
| 156 |
pool=2.0,
|
| 157 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 159 |
-
|
| 160 |
-
delay = 1.0
|
| 161 |
-
for attempt in range(attempts):
|
| 162 |
resp = await client.post(url, headers=headers, json=body)
|
| 163 |
-
|
| 164 |
-
if attempt == attempts - 1:
|
| 165 |
-
resp.raise_for_status()
|
| 166 |
-
ra = resp.headers.get("Retry-After")
|
| 167 |
-
wait = float(ra) if ra and ra.replace(".", "").isdigit() else delay
|
| 168 |
-
await asyncio.sleep(wait)
|
| 169 |
-
delay *= 2
|
| 170 |
-
continue
|
| 171 |
-
resp.raise_for_status()
|
| 172 |
-
break
|
| 173 |
payload = resp.json()
|
| 174 |
|
| 175 |
choice = payload["choices"][0]
|
|
|
|
| 79 |
pass
|
| 80 |
|
| 81 |
|
| 82 |
+
# KI-088 (2026-05-15) — Global outbound NIM concurrency cap.
|
| 83 |
+
#
|
| 84 |
+
# Live probe at commit 6a47549 measured 20% brain-turn success despite
|
| 85 |
+
# KI-080 election + KI-085 credit gating + KI-087 NIM-first + KI-079
|
| 86 |
+
# escalation all live. The architecture is correct; the binding constraint
|
| 87 |
+
# is NIM's free-tier per-key concurrency (~3-5 slots).
|
| 88 |
+
#
|
| 89 |
+
# We have 5 sources of NIM traffic that all stack on the same key with no
|
| 90 |
+
# global throttle: probe loop (6-slot parallel burst every 300s), admin
|
| 91 |
+
# tab polling (every 30s), per-user chat turn (1-2 calls), concurrent
|
| 92 |
+
# users (N × 1-2), and the inner 4-attempt retry loop (holds a slot up
|
| 93 |
+
# to 15s on 429/5xx backoff).
|
| 94 |
+
#
|
| 95 |
+
# 6+ in-flight → queue → 15-25s response times → bot's 12s outer cap
|
| 96 |
+
# fires → user sees fallback.
|
| 97 |
+
#
|
| 98 |
+
# This module-level semaphore is shared across ALL NvidiaNimLLM instances
|
| 99 |
+
# in the process. It wraps ONLY the actual httpx.post — not election,
|
| 100 |
+
# response parsing, or usage logging — so it serialises the NIM round-
|
| 101 |
+
# trip without serialising the whole reasoning pipeline. With cap=2,
|
| 102 |
+
# our own in-flight count never exceeds NIM's idle concurrency budget,
|
| 103 |
+
# so probes + admin + users + retries cannot starve each other.
|
| 104 |
+
_NIM_OUTBOUND_SEMAPHORE = asyncio.Semaphore(2)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
NVIDIA_NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
| 108 |
# 2026-05-14 brain swap (D-022): NIM's DeepSeek-V4 + Meta Llama inference pools
|
| 109 |
# are repeatedly timing out (15-120s on chat completions, no response). Qwen
|
|
|
|
| 180 |
write=2.0,
|
| 181 |
pool=2.0,
|
| 182 |
)
|
| 183 |
+
# KI-088 (2026-05-15) — drop the inner 4-attempt exponential
|
| 184 |
+
# backoff retry. The previous loop held a NIM concurrency slot
|
| 185 |
+
# for up to ~15s on 429/5xx while sleeping between attempts,
|
| 186 |
+
# which directly contributed to the queueing that the outer
|
| 187 |
+
# semaphore is now sized to prevent.
|
| 188 |
+
#
|
| 189 |
+
# Retry was a pre-KI-080 vestige that predated the chain
|
| 190 |
+
# election architecture. With KI-080 in place, NimChainLLM
|
| 191 |
+
# already handles 429/5xx failover by:
|
| 192 |
+
# (1) catching the exception in _try(),
|
| 193 |
+
# (2) calling report_failure() so the elector demotes the
|
| 194 |
+
# slot-blocked candidate for ~30s, and
|
| 195 |
+
# (3) falling through to the elected backup (cross-provider
|
| 196 |
+
# by preference) within the same turn.
|
| 197 |
+
# KI-079 then provides one more bite via BRAIN_CHAIN if both
|
| 198 |
+
# elected models fail. Per-call retries inside this method
|
| 199 |
+
# would only re-queue against the same slot-starved pool,
|
| 200 |
+
# multiplying the queueing problem the semaphore fixes.
|
| 201 |
+
#
|
| 202 |
+
# The semaphore wraps ONLY the HTTP round-trip — not response
|
| 203 |
+
# parsing or usage logging — so we cap concurrent network
|
| 204 |
+
# traffic without serialising the rest of the pipeline.
|
| 205 |
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 206 |
+
async with _NIM_OUTBOUND_SEMAPHORE:
|
|
|
|
|
|
|
| 207 |
resp = await client.post(url, headers=headers, json=body)
|
| 208 |
+
resp.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
payload = resp.json()
|
| 210 |
|
| 211 |
choice = payload["choices"][0]
|