Spaces:
Sleeping
feat(llm-chain): KI-084 — cut probe token consumption 50× + 429-demote for 1h + per-phase httpx timeouts
Browse filesLive 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 +81 -17
- backend/providers/groq_llm.py +9 -1
- backend/providers/nvidia_nim_llm.py +43 -3
- backend/providers/openrouter_llm.py +10 -1
|
@@ -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
|
|
|
|
| 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
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 77 |
-
#
|
| 78 |
-
#
|
| 79 |
-
#
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 85 |
-
# the last
|
|
|
|
| 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
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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() +
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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()
|
|
@@ -77,7 +77,15 @@ class GroqLLM(LLMProvider):
|
|
| 77 |
"Content-Type": "application/json",
|
| 78 |
}
|
| 79 |
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|
|
@@ -141,7 +141,21 @@ class NvidiaNimLLM(LLMProvider):
|
|
| 141 |
"Content-Type": "application/json",
|
| 142 |
}
|
| 143 |
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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,
|
| 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
|
|
@@ -85,7 +85,16 @@ class OpenRouterLLM(LLMProvider):
|
|
| 85 |
"X-Title": "Insurance Bot",
|
| 86 |
}
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|