rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
14ee008
·
1 Parent(s): 6a47549

feat(llm-chain): KI-088 — cap NIM outbound concurrency at 2 + serialise probe + drop inner retry

Browse files

Live 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 CHANGED
@@ -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 = [
backend/llm_health.py CHANGED
@@ -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
- # Probe all in parallel — they hit different NIM pools so concurrency is fine
989
- results = await asyncio.gather(
990
- *[probe_one(client, m, "") for m in models],
991
- return_exceptions=True,
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):
backend/providers/nvidia_nim_llm.py CHANGED
@@ -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
- attempts = 4
160
- delay = 1.0
161
- for attempt in range(attempts):
162
  resp = await client.post(url, headers=headers, json=body)
163
- if resp.status_code == 429 or (500 <= resp.status_code < 600):
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]