rohitsar567 commited on
Commit
119e0fd
·
1 Parent(s): c592055

feat(llm-chain): KI-084 — cut probe token consumption 50× + 429-demote for 1h + per-phase httpx timeouts

Browse files

Live diagnosis (2026-05-15): Groq returned HTTP 429 — body
"Rate limit reached for model llama-3.3-70b-versatile on tokens per day
(TPD): Limit 100000, Used 99454". ~30-50K of that quota was the probe
loop alone (60s × 25 candidates × ~50 tokens/probe). Election kept
picking Groq because its probe latency stayed best, so every chat call
hit 429, fell to NIM Qwen which was also under load, hit the 25s outer
timeout, and even the KI-079 escalation failed.

Three coordinated fixes in one commit:

1. Probe overhead cut ~250× (backend/llm_health.py):
- PROBE_INTERVAL_SEC 60 → 300 (5 min cadence)
- PROBE_MAX_TOKENS 5 → 1 (same 200 envelope, content unused)
- HEALTHY_PROBE_AGE_SEC 90 → 600 (tracks new cadence + headroom)
Expected probe-driven Groq token spend drops from ~30-50K/day to
~3K/day baseline — well within the 100K free-tier daily quota.
Election still adapts within ~5min of any provider degradation,
which is more than fast enough at our chat volume.

2. 429 demotion = 1h (vs default 30s) (backend/llm_health.py +
backend/providers/nvidia_nim_llm.py):
- New DEGRADE_DURATION_LONG_S = 3600.0
- New _is_rate_limit_error(error_class) matches "Status429" / "429"
/ "RateLimit" / "rate_limit"
- New _classify_error(e) helper introspects e.response.status_code
and mints "Status429" explicitly (vs bare "HTTPStatusError" which
loses the signal — 429 vs 503 collapse to the same class name).
- NimChainLLM._call_one + post-reprobe fallback both call
_classify_error(e) instead of type(e).__name__, so report_failure
sees the precise tag.
- report_failure dispatches: rate-limit → DEGRADE_DURATION_LONG_S
(1h, daily quotas don't reset in 30s), everything else →
DEGRADED_WINDOW_SEC (30s, existing behaviour).
When Groq hits its daily TPD, election demotes Groq for an hour so
the next 12 ticks elect OpenRouter / NIM instead of bouncing back to
the dead candidate.

3. Per-phase httpx timeouts so PRIMARY failover doesn't leak provider
concurrency slots (backend/providers/{nvidia_nim_llm,groq_llm,
openrouter_llm}.py):
Before: AsyncClient(timeout=self.timeout) — single deadline applied
to the whole connection lifecycle, so a stuck NIM pool could hold
the connection past the outer wait_for cancellation and occupy a
per-key concurrency slot indefinitely.
After: AsyncClient(timeout=httpx.Timeout(connect=2.0,
read=self.timeout, write=2.0, pool=2.0)).
Explicit per-phase deadlines guarantee the TCP connection itself
times out independently of the read deadline → the underlying NIM
concurrency slot is freed promptly when the BACKUP elected model
starts and the PRIMARY socket is no longer awaited.

Tests:
- 6 inline scenarios on report_failure dispatch (Status429,
rate_limit_exceeded → 3600s; HTTPStatusError:503, ReadTimeout,
TimeoutException, ConnectError → 30s).
- _classify_error mints Status429 for 429s, HTTPStatusError:503 for
other HTTP failures, class name for non-HTTP exceptions.
- pytest tests/test_routing_regression.py -x -q → 15/15 pass.

Expected post-fix impact:
- Probe-driven Groq token spend: ~50K/day → ~3K/day (well within
quota; quota resets daily at midnight UTC so today's quota will
recover within hours).
- Brain success rate climbs back to KI-080 baseline (~95-98%)
immediately once Groq quota resets, sustained going forward.
- During the current 429 episode: election demotes Groq for 1h on
the next chat-driven 429, then falls through to OpenRouter
GPT-OSS / NIM Qwen for the remainder of the day.

backend/llm_health.py CHANGED
@@ -15,7 +15,8 @@ backup per chain based on real probe latencies; chat() calls the elected
15
  primary ONCE per turn, with at most ONE real-time fallback to the elected
16
  backup. Worst case per turn drops from 5-6 LLM calls to 1-2.
17
 
18
- Probes every PROBE_INTERVAL_SEC tick with a tiny ping ("Reply with exactly: ok").
 
19
  Records per model:
20
  - status: healthy / degraded / down / unknown
21
  - last_success_at: timestamp of last 2xx response
@@ -34,9 +35,12 @@ Election (KI-080):
34
  provider (NIM vs Groq vs OpenRouter) than primary; falls back to next-
35
  best same-provider candidate when no cross-provider option qualifies.
36
  - DEGRADED window: when chat() calls report_failure(model), that model is
37
- sidelined for DEGRADED_WINDOW_SEC so the same turn's failure doesn't
38
- recycle to the same broken primary on the next turn. The next probe
39
- tick reconsiders the model normally.
 
 
 
40
 
41
  Persistence: 40-data/llm_health.json (atomic write via temp+rename).
42
 
@@ -73,17 +77,34 @@ ROOT = Path(__file__).resolve().parent.parent
73
  HEALTH_FILE = ROOT / "40-data" / "llm_health.json"
74
  HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
75
 
76
- # KI-080 — probe every 60s so election reflects real-time pool health
77
- # fast enough that a NIM pool brownout is rotated out within a minute.
78
- # The 5-min cadence pre-KI-080 was fine for a "filter the dead" use case
79
- # but too slow when probe results drive primary election.
80
- PROBE_INTERVAL_SEC = 60
 
 
 
 
 
 
 
 
 
81
  PROBE_TIMEOUT_SEC = 8 # per-probe HTTP timeout
82
  DOWN_AFTER_CONSECUTIVE_FAILS = 3 # 3 fails in a row = mark down
83
  PROBE_HISTORY_LEN = 5 # rolling window for success_rate signal
84
- HEALTHY_PROBE_AGE_SEC = 90 # election candidates need a probe within
85
- # the last 90s stale data excluded
 
86
  DEGRADED_WINDOW_SEC = 30 # report_failure sidelines a model this long
 
 
 
 
 
 
 
87
 
88
  # Per-provider endpoints + env-var names. The chain entries embed the
89
  # provider via a prefix ('openrouter:<id>' / 'groq:<id>'); unprefixed entries
@@ -352,22 +373,61 @@ def get_backup(chain_name: str) -> Optional[str]:
352
  return ranked[1].model
353
 
354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  def report_failure(chain_name: str, model: str, error_class: str) -> None:
356
  """Called by NimChainLLM.chat() when primary OR backup throws.
357
 
358
  Effects:
359
- - Sidelines `model` from election for DEGRADED_WINDOW_SEC so the
360
- same turn's failure doesn't immediately re-elect the same model.
 
 
 
361
  - Appends a synthetic 'failed' entry to probe_history so the next
362
  election's success_rate reflects the live failure even before
363
  the next probe tick.
364
  - Schedules an async re-probe (best-effort) so the next turn gets
365
- fresh data instead of waiting up to 60s for the next tick.
 
 
 
366
  """
367
  _load_into_memory()
 
 
 
 
 
 
368
  with _STATE_LOCK:
369
  h = _STATE.get(model) or ModelHealth(model=model)
370
- h.degraded_until_monotonic = time.monotonic() + DEGRADED_WINDOW_SEC
371
  h.last_failure_at = _now_iso()
372
  h.last_error = f"chat_failure: {error_class}"
373
  h.probe_history.append({
@@ -466,7 +526,10 @@ async def probe_one(client: httpx.AsyncClient, model: str, api_key: str) -> tupl
466
  json={
467
  "model": upstream_model,
468
  "messages": [{"role": "user", "content": "Reply with exactly: ok"}],
469
- "max_tokens": 5,
 
 
 
470
  "temperature": 0.0,
471
  },
472
  timeout=PROBE_TIMEOUT_SEC,
@@ -590,7 +653,8 @@ def status_summary() -> dict:
590
 
591
 
592
  async def background_probe_loop() -> None:
593
- """Long-running task — probes every PROBE_INTERVAL_SEC. Started from main.py."""
 
594
  while True:
595
  try:
596
  await probe_all()
 
15
  primary ONCE per turn, with at most ONE real-time fallback to the elected
16
  backup. Worst case per turn drops from 5-6 LLM calls to 1-2.
17
 
18
+ Probes every PROBE_INTERVAL_SEC tick (300s) with a tiny ping ("Reply with
19
+ exactly: ok"), `max_tokens=1` so probe-driven token spend is negligible.
20
  Records per model:
21
  - status: healthy / degraded / down / unknown
22
  - last_success_at: timestamp of last 2xx response
 
35
  provider (NIM vs Groq vs OpenRouter) than primary; falls back to next-
36
  best same-provider candidate when no cross-provider option qualifies.
37
  - DEGRADED window: when chat() calls report_failure(model), that model is
38
+ sidelined for either DEGRADED_WINDOW_SEC (transient, 30s) or
39
+ DEGRADE_DURATION_LONG_S (rate-limit / HTTP 429, 1h KI-084) so the
40
+ same turn's failure doesn't recycle to the same broken primary on the
41
+ next turn. The next probe tick reconsiders the model normally for the
42
+ short window; rate-limit demotions persist past several probe ticks
43
+ so the elector doesn't keep bouncing back to a quota-exhausted model.
44
 
45
  Persistence: 40-data/llm_health.json (atomic write via temp+rename).
46
 
 
77
  HEALTH_FILE = ROOT / "40-data" / "llm_health.json"
78
  HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
79
 
80
+ # KI-084 (2026-05-15) — probe cadence raised 60s 300s. With ~25
81
+ # candidates per tick × 4 token round-trip each (prompt "Reply with
82
+ # exactly: ok" plus a 1-token completion), the prior 60s cadence burned
83
+ # ~30-50K tokens/day on Groq alone enough to push the brain to HTTP 429
84
+ # on Groq's 100K free-tier TPD cap and elect a degraded provider for
85
+ # every chat call. 300s cadence keeps election responsive within 5 min
86
+ # of a pool degradation (more than fast enough at our chat volume) and
87
+ # drops probe-driven Groq spend to ~3K/day baseline (well inside quota).
88
+ PROBE_INTERVAL_SEC = 300
89
+ # KI-084 — completion size on probes cut from 5 → 1. The probe only
90
+ # needs a non-empty 200 response to mark a candidate healthy; we never
91
+ # parse the body content. max_tokens=1 keeps the same response shape +
92
+ # ~50× less token spend per probe.
93
+ PROBE_MAX_TOKENS = 1
94
  PROBE_TIMEOUT_SEC = 8 # per-probe HTTP timeout
95
  DOWN_AFTER_CONSECUTIVE_FAILS = 3 # 3 fails in a row = mark down
96
  PROBE_HISTORY_LEN = 5 # rolling window for success_rate signal
97
+ HEALTHY_PROBE_AGE_SEC = 600 # KI-084 — election candidates need a probe
98
+ # within the last 600s (tracks 300s cadence
99
+ # plus headroom for one missed tick).
100
  DEGRADED_WINDOW_SEC = 30 # report_failure sidelines a model this long
101
+ # for transient failures (timeout / 5xx).
102
+ # KI-084 — rate-limit failures (HTTP 429 + provider 'RateLimit' bodies) are
103
+ # almost always the daily quota on free tiers (Groq TPD, etc.) — they do
104
+ # NOT reset in 30s. Demote the model from election for an hour so the
105
+ # elector falls through to a non-rate-limited provider instead of
106
+ # bouncing back to the dead candidate on every chat turn.
107
+ DEGRADE_DURATION_LONG_S = 3600.0
108
 
109
  # Per-provider endpoints + env-var names. The chain entries embed the
110
  # provider via a prefix ('openrouter:<id>' / 'groq:<id>'); unprefixed entries
 
373
  return ranked[1].model
374
 
375
 
376
+ def _is_rate_limit_error(error_class: str) -> bool:
377
+ """KI-084 — true when the failure looks like a provider rate-limit
378
+ rather than a transient network/server error.
379
+
380
+ The hot-path producer is NimChainLLM._classify_error, which inspects
381
+ the underlying HTTPStatusError's response.status_code and mints
382
+ `"Status429"` for 429s explicitly (vs `"HTTPStatusError:503"` for
383
+ server errors). We match:
384
+ - `"Status429"` / any string containing `"429"` — explicit 429 tag.
385
+ - `"RateLimit"` / `"rate_limit"` — defensive upstream
386
+ text tag (some
387
+ providers embed
388
+ this in the body).
389
+ Crucially we DO NOT match bare `"HTTPStatusError"` here, because
390
+ `_classify_error` only emits that string for non-429 HTTP failures
391
+ (e.g. 503), which deserve the SHORT sin-bin, not the 1h quota window.
392
+ """
393
+ if not error_class:
394
+ return False
395
+ needle = error_class.lower()
396
+ return (
397
+ "429" in needle
398
+ or "ratelimit" in needle
399
+ or "rate_limit" in needle
400
+ )
401
+
402
+
403
  def report_failure(chain_name: str, model: str, error_class: str) -> None:
404
  """Called by NimChainLLM.chat() when primary OR backup throws.
405
 
406
  Effects:
407
+ - Sidelines `model` from election. For rate-limit failures
408
+ (HTTP 429 / "RateLimit" body — KI-084) the sin-bin is
409
+ DEGRADE_DURATION_LONG_S (1 hour) because free-tier daily token
410
+ quotas don't reset for hours; for all other transient failures
411
+ (timeout / 5xx / parse) it's the short DEGRADED_WINDOW_SEC (30s).
412
  - Appends a synthetic 'failed' entry to probe_history so the next
413
  election's success_rate reflects the live failure even before
414
  the next probe tick.
415
  - Schedules an async re-probe (best-effort) so the next turn gets
416
+ fresh data instead of waiting up to PROBE_INTERVAL_SEC for the
417
+ next tick. (For 429s the reprobe is cheap and informative — if
418
+ Groq's quota happens to have reset early we'll find out
419
+ immediately rather than waiting an hour.)
420
  """
421
  _load_into_memory()
422
+ # KI-084 — 429-class failures get a long sin-bin, everything else
423
+ # the existing 30s window.
424
+ if _is_rate_limit_error(error_class):
425
+ degrade_for_s = DEGRADE_DURATION_LONG_S
426
+ else:
427
+ degrade_for_s = DEGRADED_WINDOW_SEC
428
  with _STATE_LOCK:
429
  h = _STATE.get(model) or ModelHealth(model=model)
430
+ h.degraded_until_monotonic = time.monotonic() + degrade_for_s
431
  h.last_failure_at = _now_iso()
432
  h.last_error = f"chat_failure: {error_class}"
433
  h.probe_history.append({
 
526
  json={
527
  "model": upstream_model,
528
  "messages": [{"role": "user", "content": "Reply with exactly: ok"}],
529
+ # KI-084 — max_tokens cut 5 → 1. Same 200 envelope, ~50×
530
+ # less token spend; probe never inspects the body content
531
+ # beyond `choices[0].message.content` existing.
532
+ "max_tokens": PROBE_MAX_TOKENS,
533
  "temperature": 0.0,
534
  },
535
  timeout=PROBE_TIMEOUT_SEC,
 
653
 
654
 
655
  async def background_probe_loop() -> None:
656
+ """Long-running task — probes every PROBE_INTERVAL_SEC (300s; KI-084).
657
+ Started from main.py."""
658
  while True:
659
  try:
660
  await probe_all()
backend/providers/groq_llm.py CHANGED
@@ -77,7 +77,15 @@ class GroqLLM(LLMProvider):
77
  "Content-Type": "application/json",
78
  }
79
 
80
- async with httpx.AsyncClient(timeout=self.timeout) as client:
 
 
 
 
 
 
 
 
81
  attempts = 4
82
  delay = 1.0
83
  for attempt in range(attempts):
 
77
  "Content-Type": "application/json",
78
  }
79
 
80
+ # KI-084 — per-phase httpx timeouts so a stuck Groq connection
81
+ # frees its slot independently of the connection-level deadline.
82
+ client_timeout = httpx.Timeout(
83
+ connect=2.0,
84
+ read=self.timeout,
85
+ write=2.0,
86
+ pool=2.0,
87
+ )
88
+ async with httpx.AsyncClient(timeout=client_timeout) as client:
89
  attempts = 4
90
  delay = 1.0
91
  for attempt in range(attempts):
backend/providers/nvidia_nim_llm.py CHANGED
@@ -141,7 +141,21 @@ class NvidiaNimLLM(LLMProvider):
141
  "Content-Type": "application/json",
142
  }
143
 
144
- async with httpx.AsyncClient(timeout=self.timeout) as client:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  attempts = 4
146
  delay = 1.0
147
  for attempt in range(attempts):
@@ -269,6 +283,32 @@ JUDGE_CHAIN = [
269
  ]
270
 
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  class NimChainLLM(LLMProvider):
273
  """KI-080 sticky-primary router across multiple candidate models.
274
 
@@ -526,7 +566,7 @@ class NimChainLLM(LLMProvider):
526
  last_err = e
527
  try:
528
  llm_health.report_failure(
529
- self._chain_name, model, type(e).__name__
530
  )
531
  except Exception:
532
  pass
@@ -598,7 +638,7 @@ class NimChainLLM(LLMProvider):
598
  last_err = e
599
  try:
600
  llm_health.report_failure(
601
- self._chain_name, model, type(e).__name__
602
  )
603
  except Exception:
604
  pass
 
141
  "Content-Type": "application/json",
142
  }
143
 
144
+ # KI-084 — per-phase httpx timeouts. Previously `timeout=self.timeout`
145
+ # collapsed to a single read deadline; httpx applied it to the *whole*
146
+ # connection lifecycle, so a stuck NIM pool could occupy the
147
+ # connection past the outer wait_for cancellation (the BACKUP elected
148
+ # model starts but the PRIMARY socket is still held → NIM concurrency
149
+ # slot leaks). Explicit connect/read/write/pool deadlines guarantee
150
+ # the TCP connection itself times out independently and the slot is
151
+ # freed even if the upstream is mid-response.
152
+ client_timeout = httpx.Timeout(
153
+ connect=2.0,
154
+ read=self.timeout,
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):
 
283
  ]
284
 
285
 
286
+ def _classify_error(e: BaseException) -> str:
287
+ """KI-084 — classify a chat exception into a stable string passed to
288
+ llm_health.report_failure(). The string drives degradation duration:
289
+ rate-limit failures (HTTP 429) get a 1h sin-bin; everything else 30s.
290
+
291
+ We surface the HTTP status code explicitly because `type(e).__name__`
292
+ is just `"HTTPStatusError"` for both 429 and 503 — losing the signal
293
+ the elector needs to demote-long vs demote-short. When the exception
294
+ carries `.response.status_code == 429` we tag it `"Status429"`; for
295
+ other HTTP statuses we surface e.g. `"HTTPStatusError:503"`; for
296
+ non-HTTP exceptions we keep the class name (`TimeoutException`,
297
+ `ReadTimeout`, etc.) — matching the pre-KI-084 contract.
298
+ """
299
+ cls = type(e).__name__
300
+ try:
301
+ resp = getattr(e, "response", None)
302
+ status = getattr(resp, "status_code", None) if resp is not None else None
303
+ if status == 429:
304
+ return "Status429"
305
+ if status is not None:
306
+ return f"{cls}:{status}"
307
+ except Exception:
308
+ pass
309
+ return cls
310
+
311
+
312
  class NimChainLLM(LLMProvider):
313
  """KI-080 sticky-primary router across multiple candidate models.
314
 
 
566
  last_err = e
567
  try:
568
  llm_health.report_failure(
569
+ self._chain_name, model, _classify_error(e)
570
  )
571
  except Exception:
572
  pass
 
638
  last_err = e
639
  try:
640
  llm_health.report_failure(
641
+ self._chain_name, model, _classify_error(e)
642
  )
643
  except Exception:
644
  pass
backend/providers/openrouter_llm.py CHANGED
@@ -85,7 +85,16 @@ class OpenRouterLLM(LLMProvider):
85
  "X-Title": "Insurance Bot",
86
  }
87
 
88
- async with httpx.AsyncClient(timeout=self.timeout) as client:
 
 
 
 
 
 
 
 
 
89
  attempts = 4
90
  delay = 1.0
91
  for attempt in range(attempts):
 
85
  "X-Title": "Insurance Bot",
86
  }
87
 
88
+ # KI-084 — per-phase httpx timeouts (connect / read / write / pool)
89
+ # so a stuck OpenRouter connection releases its slot on its own
90
+ # deadline, independent of the outer wait_for cancellation.
91
+ client_timeout = httpx.Timeout(
92
+ connect=2.0,
93
+ read=self.timeout,
94
+ write=2.0,
95
+ pool=2.0,
96
+ )
97
+ async with httpx.AsyncClient(timeout=client_timeout) as client:
98
  attempts = 4
99
  delay = 1.0
100
  for attempt in range(attempts):