Spaces:
Sleeping
docs: ADR-032 LLM chain architecture reference + KI-080..KI-087 doc sync
Browse filesAdds new architecture reference ADR consolidating KI-079..KI-087:
- 70-docs/60-decisions/ADR-032-llm-chain-architecture.md (NEW, 435 lines)
- TL;DR + ASCII data flow diagram
- Component reference (every ModelHealth field)
- Election algorithm pseudo-code + score formula
- Probe loop cadence + OpenRouter credits polling
- Per-provider credit signal table (Groq headers / OR endpoint / NIM rate-meter)
- Per-phase httpx timeout justification
- Failure classification table
- KI-079 escalation path
- Telemetry surface (brain_used variants)
- Performance characteristics + operational runbook
Updated docs:
- CLAUDE.md "LLM stack" section: KI-080..KI-087 full coverage
+ NIM-first preference (KI-087), credit gating (KI-085), per-phase
timeouts (KI-084), admin tab (KI-086), HF Space secrets (KI-081)
- 80-audit/ENTERPRISE_AUDIT.md: 5 new rows (KI-081, KI-084, KI-085,
KI-086, KI-087) appended to "Fixes shipped today" table
- backend/providers/README.md: new "Credit-aware election (KI-085)"
section after Chain budgets β per-provider signal sources,
election predicate, cold-start behaviour, link to ADR-032
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 70-docs/60-decisions/ADR-032-llm-chain-architecture.md +435 -0
- 80-audit/ENTERPRISE_AUDIT.md +5 -0
- CLAUDE.md +17 -12
- backend/providers/README.md +31 -1
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-032 β LLM Chain Architecture Reference
|
| 2 |
+
|
| 3 |
+
**Status:** Accepted β 2026-05-15
|
| 4 |
+
**Type:** Architecture reference (not a decision ADR)
|
| 5 |
+
**Owner:** Rohit Saraf
|
| 6 |
+
**Consolidates:** [ADR-019](ADR-019-nim-single-provider-consolidation.md), [ADR-026](ADR-026-provider-load-balancing.md) (superseded), [ADR-030](ADR-030-llm-driven-fact-find.md), [ADR-031](ADR-031-sticky-primary-election.md)
|
| 7 |
+
**Related KIs:** KI-079 (`87ee522`), KI-080 (`6159c54`), KI-081 (HF Space env), KI-084 (`119e0fd`), KI-085 (`8fc7979`)
|
| 8 |
+
|
| 9 |
+
> This is **not a decision ADR.** No alternatives or trade-offs are weighed here.
|
| 10 |
+
> ADR-032 is the single readable spec for how the LLM chain works in production
|
| 11 |
+
> after the KI-080 β KI-085 sweep. New decisions still ship as their own ADRs;
|
| 12 |
+
> this file is updated when the spec shifts.
|
| 13 |
+
|
| 14 |
+
## 1. TL;DR
|
| 15 |
+
|
| 16 |
+
Every LLM role (`brain` / `fast_brain` / `judge`) is a **candidate pool**, not a
|
| 17 |
+
hardcoded model. A background probe loop in `backend/llm_health.py` scores every
|
| 18 |
+
candidate every 300s and elects a sticky PRIMARY + provider-diverse BACKUP per
|
| 19 |
+
chain. `NimChainLLM.chat()` calls the elected PRIMARY exactly once per turn with
|
| 20 |
+
explicit `httpx` per-phase timeouts (`connect=2s, read=12s, write=2s, pool=2s`);
|
| 21 |
+
on real-time failure it falls to the elected BACKUP once. Election is gated by
|
| 22 |
+
**liveness AND credits** β each provider's credit signal (Groq response
|
| 23 |
+
headers / OpenRouter `/api/v1/credits` endpoint / NIM local rate-meter)
|
| 24 |
+
proactively excludes quota-exhausted candidates BEFORE the user hits a 429,
|
| 25 |
+
while a reactive 1-hour demotion absorbs any 429 that slips through. Result:
|
| 26 |
+
per-turn LLM call count is 1 (happy path), 2 (PRIMARY failover), or 3 (KI-079
|
| 27 |
+
fastβheavy escalation), with `_canonical_fallback` (KI-072 / KI-074 greedy
|
| 28 |
+
slot capture) as the always-available last bite.
|
| 29 |
+
|
| 30 |
+
## 2. Data flow
|
| 31 |
+
|
| 32 |
+
```
|
| 33 |
+
User chat turn
|
| 34 |
+
β
|
| 35 |
+
FastAPI /api/chat β backend/orchestrator.py
|
| 36 |
+
β
|
| 37 |
+
fact_find_brain.drive_fact_find()
|
| 38 |
+
β
|
| 39 |
+
asyncio.wait_for(_TIMEOUT_S=25s) wrapping NimChainLLM(FAST_BRAIN_CHAIN).chat()
|
| 40 |
+
β
|
| 41 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
β NimChainLLM.chat() β KI-080 election + KI-084/085 β
|
| 43 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
| 44 |
+
β β Election (backend/llm_health.py) β β
|
| 45 |
+
β β PRIMARY = highest score in chain β β
|
| 46 |
+
β β AND is_alive (probe < 600s) β β
|
| 47 |
+
β β AND has_credits > low_water (KI-085) β β
|
| 48 |
+
β β AND NOT in 1h demote window (KI-084) β β
|
| 49 |
+
β β BACKUP = next-best, cross-provider preferred β β
|
| 50 |
+
β β (same eligibility predicate) β β
|
| 51 |
+
β β score = (1/max(50, latency_ms)) * success β β
|
| 52 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
| 53 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
| 54 |
+
β β _call_one(PRIMARY) β KI-084 per-phase timeouts β β
|
| 55 |
+
β β httpx.Timeout(connect=2, read=12, write=2, β β
|
| 56 |
+
β β pool=2) β β
|
| 57 |
+
β β 12s read-deadline = NimChainLLM._ELECTED_CALL_ β β
|
| 58 |
+
β β TIMEOUT_S; outer wait_for is the hard ceiling. β β
|
| 59 |
+
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
| 60 |
+
β β β
|
| 61 |
+
β βββ success β report_success(latency_ms) β
|
| 62 |
+
β β β record_nim_call() (KI-085) β
|
| 63 |
+
β β β return LLMResult β
|
| 64 |
+
β β β
|
| 65 |
+
β βββ failure β _classify_error(e) β
|
| 66 |
+
β β β
|
| 67 |
+
β βββ Status429 / "RateLimit" β
|
| 68 |
+
β β β report_failure β demote 1h (KI-084) β
|
| 69 |
+
β β β
|
| 70 |
+
β βββ HTTPStatusError:5xx / ReadTimeout / β
|
| 71 |
+
β β TimeoutException / net errors β
|
| 72 |
+
β β β report_failure β demote 30s β
|
| 73 |
+
β β β
|
| 74 |
+
β βββ (credit state already updated from β
|
| 75 |
+
β response headers in groq/openrouter β
|
| 76 |
+
β client; KI-085) β
|
| 77 |
+
β β β
|
| 78 |
+
β _call_one(BACKUP) β same shape, same timeouts β
|
| 79 |
+
β β fail β trigger probe_all() refresh + walk β
|
| 80 |
+
β filter_chain order (final safety net, β
|
| 81 |
+
β budget-clipped per remaining total_budget)β
|
| 82 |
+
β β fail β raise RuntimeError β
|
| 83 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
β outer wait_for fires (25s) OR RuntimeError raised
|
| 85 |
+
β
|
| 86 |
+
fact_find_brain.drive_fact_find() catches asyncio.TimeoutError
|
| 87 |
+
β
|
| 88 |
+
[KI-079] escalation:
|
| 89 |
+
asyncio.wait_for(_TIMEOUT_S_ESCALATION=15s)
|
| 90 |
+
wrapping NimChainLLM(BRAIN_CHAIN).chat()
|
| 91 |
+
β same election + per-phase + credit gating, heavier pool
|
| 92 |
+
β success β reply prose to user, brain_used = fact_find_brain::continue
|
| 93 |
+
β fail β _canonical_fallback(session, user_text, reason="β¦")
|
| 94 |
+
(KI-072 / KI-074 greedy slot capture; never wedges)
|
| 95 |
+
β
|
| 96 |
+
user-facing reply
|
| 97 |
+
brain_used = fact_find_brain::continue
|
| 98 |
+
| fact_find_brain::complete
|
| 99 |
+
| fact_find_brain::fallback:<reason>
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## 3. Component reference β `llm_health._STATE`
|
| 103 |
+
|
| 104 |
+
Per-model state is a `ModelHealth` dataclass keyed by chain entry (model id with
|
| 105 |
+
optional `groq:` / `openrouter:` prefix). Fields the elector reads on every
|
| 106 |
+
call:
|
| 107 |
+
|
| 108 |
+
| Field | Type | Producer | Meaning |
|
| 109 |
+
|---|---|---|---|
|
| 110 |
+
| `status` | `'healthy' / 'degraded' / 'down' / 'unknown'` | probe loop (`_absorb_probe_result`) | Coarse health bucket; `'down'` β election-ineligible. |
|
| 111 |
+
| `latency_ms` | `int / None` | probe + `report_success` | Last observed latency; `None` β ineligible. Floor of 50ms in scoring. |
|
| 112 |
+
| `last_success_at` | ISO8601 | probe + chat | Wall-clock of last 2xx. |
|
| 113 |
+
| `last_failure_at` | ISO8601 | probe + chat | Wall-clock of last failure. |
|
| 114 |
+
| `tested_at` | ISO8601 | probe loop | Drives the `HEALTHY_PROBE_AGE_SEC = 600s` freshness gate. |
|
| 115 |
+
| `probe_history` | `list[dict]`, cap 5 | probe + chat | `[{ok, latency_ms, ts, src}]`. Powers `success_rate`. |
|
| 116 |
+
| `degraded_until_monotonic` | `float` (monotonic time) | `report_failure` | Sin-bin deadline; 30s transient, 3600s rate-limit (KI-084). |
|
| 117 |
+
| `credits_remaining` | `float / None` | KI-085 trackers | Tokens / USD / req-slots remaining. `None` β cold-start permissive. |
|
| 118 |
+
| `credits_unit` | `'tokens_day' / 'usd_balance' / 'requests_min'` | KI-085 trackers | Semantic of the number above. |
|
| 119 |
+
| `credits_reset_at` | `float / None` (monotonic) | KI-085 trackers | When the quota resets; past-now β signal treated as stale (permissive). |
|
| 120 |
+
| `credits_observed_at` | `float` (monotonic) | KI-085 trackers | Snapshot timestamp; surfaced in admin tab. |
|
| 121 |
+
| `credits_low_water` | `float` | KI-085 constants | Gate threshold. `credits_remaining > credits_low_water` β electable. |
|
| 122 |
+
|
| 123 |
+
Persisted snapshot: `40-data/llm_health.json` (atomic write).
|
| 124 |
+
|
| 125 |
+
## 4. Election algorithm
|
| 126 |
+
|
| 127 |
+
`backend/llm_health.py::_ranked_candidates(chain_name)` returns
|
| 128 |
+
election-eligible candidates sorted by score descending. Election runs every
|
| 129 |
+
`chat()` call (microsecond hot path; in-memory `_STATE` under a coarse lock).
|
| 130 |
+
|
| 131 |
+
```python
|
| 132 |
+
def is_electable(h, now_mono):
|
| 133 |
+
if h.degraded_until_monotonic > now_mono: return False # KI-084 sin-bin
|
| 134 |
+
if h.status == "down": return False
|
| 135 |
+
if age_of(h.tested_at) > 600s: return False # HEALTHY_PROBE_AGE_SEC
|
| 136 |
+
if h.latency_ms is None: return False
|
| 137 |
+
if not has_credits(h, now_mono): return False # KI-085
|
| 138 |
+
return True
|
| 139 |
+
|
| 140 |
+
def has_credits(h, now_mono):
|
| 141 |
+
if h.credits_reset_at is not None and now_mono >= h.credits_reset_at:
|
| 142 |
+
return True # quota already reset
|
| 143 |
+
if h.credits_remaining is None: # cold-start permissive
|
| 144 |
+
return True
|
| 145 |
+
return h.credits_remaining > h.credits_low_water
|
| 146 |
+
|
| 147 |
+
def score(h):
|
| 148 |
+
return (1 / max(50, h.latency_ms)) * success_rate(h.probe_history)
|
| 149 |
+
|
| 150 |
+
def get_primary(chain): return ranked[0].model
|
| 151 |
+
def get_backup(chain):
|
| 152 |
+
primary_provider = provider_of(ranked[0])
|
| 153 |
+
for h in ranked[1:]:
|
| 154 |
+
if provider_of(h) != primary_provider: return h.model # provider-diverse
|
| 155 |
+
return ranked[1].model # graceful degradation
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
**Score formula.** `score = (1 / max(50, latency_ms)) * success_rate`. The 50 ms
|
| 159 |
+
floor prevents a sub-millisecond outlier from dominating election; the rolling
|
| 160 |
+
`success_rate` over the last 5 probes is the stability signal. Both factors
|
| 161 |
+
matter β a very fast model that flakes 1-in-3 calls scores lower than a stable
|
| 162 |
+
model 2Γ slower.
|
| 163 |
+
|
| 164 |
+
**Provider-diverse BACKUP.** Mandatory, not advisory. A NIM-PRIMARY whose
|
| 165 |
+
underlying pool is throttled MUST fall to a non-NIM BACKUP, otherwise the second
|
| 166 |
+
call queues in the same throttle window. Iterate ranked candidates and return
|
| 167 |
+
the first whose `provider_of()` differs from PRIMARY's. If only one provider has
|
| 168 |
+
live candidates (regional outage), BACKUP gracefully degrades to the next-best
|
| 169 |
+
same-provider candidate.
|
| 170 |
+
|
| 171 |
+
**Cold-start fallback.** Before the first probe completes (process restart,
|
| 172 |
+
HF Space rebuild), `get_primary` / `get_backup` return `None`. `NimChainLLM.chat()`
|
| 173 |
+
catches that case and uses `chain[0]` as PRIMARY and `chain[1]` (preferring a
|
| 174 |
+
different provider) as BACKUP. The probe loop runs immediately on startup so
|
| 175 |
+
cold-start lasts at most a few seconds.
|
| 176 |
+
|
| 177 |
+
**Family exclusion.** Brain β judge family diversity (Qwen brain β Mistral judge)
|
| 178 |
+
is enforced by the caller via `exclude_families=[...]` on
|
| 179 |
+
`NimChainLLM.chat()`. The election then filters election-eligible candidates by
|
| 180 |
+
`_family_of()` before scoring. Families: `qwen`, `mistral`, `meta`, `openai`,
|
| 181 |
+
`deepseek`, `moonshot`, `minimax`, `nvidia`. A NIM-hosted GPT-OSS 120B and an
|
| 182 |
+
OpenRouter-hosted GPT-OSS 120B share the `openai` family and are NOT pickable
|
| 183 |
+
as brain β judge pair.
|
| 184 |
+
|
| 185 |
+
## 5. Probe loop
|
| 186 |
+
|
| 187 |
+
`backend/llm_health.py::background_probe_loop` ticks every
|
| 188 |
+
`PROBE_INTERVAL_SEC = 300s` (KI-084 β was 60s; raised because the prior cadence
|
| 189 |
+
burned ~30-50K tokens/day on Groq's free-tier and self-tripped the 100K daily
|
| 190 |
+
TPD cap).
|
| 191 |
+
|
| 192 |
+
Each tick:
|
| 193 |
+
1. `probe_all()` β parallel `httpx.post` to every chain entry with the prompt
|
| 194 |
+
`"Reply with exactly: ok"`, `max_tokens=1` (KI-084 β was 5; cuts probe-driven
|
| 195 |
+
token spend ~50Γ since we never read the body content), `timeout=8s`.
|
| 196 |
+
2. Status flip rules: 200 + non-empty content β `healthy` (or `degraded` if
|
| 197 |
+
latency > 5000ms); 3+ consecutive failures β `down`.
|
| 198 |
+
3. Append `(ok, latency_ms, ts, src='probe')` to `probe_history` (capped at 5).
|
| 199 |
+
4. `save()` atomic-write of `40-data/llm_health.json`.
|
| 200 |
+
|
| 201 |
+
Every `OPENROUTER_CREDITS_POLL_EVERY_N_TICKS = 2` ticks (i.e. 600s / 10min):
|
| 202 |
+
- `poll_openrouter_credits()` issues `GET https://openrouter.ai/api/v1/credits`,
|
| 203 |
+
parses `{total_credits, total_usage}`, and stamps every `openrouter:`-prefixed
|
| 204 |
+
candidate with `credits_unit="usd_balance"`, `credits_remaining = total_credits - total_usage`,
|
| 205 |
+
`credits_low_water = $0.05`.
|
| 206 |
+
|
| 207 |
+
Initial OpenRouter credits poll fires immediately on startup so the elector has
|
| 208 |
+
a non-`None` USD balance before the first chat call.
|
| 209 |
+
|
| 210 |
+
## 6. Credit signal sources
|
| 211 |
+
|
| 212 |
+
| Provider | Signal source | Header / endpoint | Stored unit | Low-water | Producer |
|
| 213 |
+
|---|---|---|---|---|---|
|
| 214 |
+
| **Groq** | Response headers on every successful chat | `x-ratelimit-remaining-tokens-day` (preferred β daily TPD is what bit us in KI-084) + `x-ratelimit-reset-tokens-day` for reset deadline | `tokens_day` | `5000` tokens (β one ~2K-input / ~400-output fact-find round-trip with margin) | `groq_llm.py::chat` β `llm_health.update_credits_from_groq` |
|
| 215 |
+
| **OpenRouter** | Dedicated account endpoint | `GET /api/v1/credits` β `{total_credits, total_usage}` (account-level USD balance) + opportunistic `x-ratelimit-remaining` from response headers as between-poll fallback | `usd_balance` (authoritative) / `requests_min` (header fallback) | `$0.05` USD (5Β’ safety margin β free-models charge $0 but the account-level signal still tells us if prepaid credits are gone) | `llm_health.poll_openrouter_credits` (10-min) + `update_credits_from_openrouter_headers` (per-call) |
|
| 216 |
+
| **NIM** | No clean header β local rate-meter | `_NIM_CALL_TIMES[model]` deque of monotonic timestamps over a 60s window | `requests_min` | `5.0` request slots; gate at `cap - headroom = 40 - 5 = 35` in-window calls | `nvidia_nim_llm.py::chat` β `llm_health.record_nim_call` |
|
| 217 |
+
|
| 218 |
+
For Groq specifically, daily TPD is the dominant signal β the minute-window
|
| 219 |
+
header is noisy and KI-084's 1h sin-bin already covers minute-window blips. We
|
| 220 |
+
deliberately ignore `x-ratelimit-remaining-tokens-min` to keep the elector
|
| 221 |
+
stable.
|
| 222 |
+
|
| 223 |
+
## 7. Per-phase httpx timeouts (KI-084)
|
| 224 |
+
|
| 225 |
+
`backend/providers/nvidia_nim_llm.py::NvidiaNimLLM.chat` uses an explicit
|
| 226 |
+
`httpx.Timeout` rather than the scalar `timeout=self.timeout`:
|
| 227 |
+
|
| 228 |
+
```python
|
| 229 |
+
client_timeout = httpx.Timeout(
|
| 230 |
+
connect=2.0, # TCP handshake must finish in 2s
|
| 231 |
+
read=self.timeout, # 12s for elected calls; 6s for legacy fast-brain calls
|
| 232 |
+
write=2.0, # request-body upload deadline
|
| 233 |
+
pool=2.0, # connection-pool checkout deadline
|
| 234 |
+
)
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
**Why each value.**
|
| 238 |
+
|
| 239 |
+
- `connect=2.0` β TCP handshake to `integrate.api.nvidia.com` is sub-100ms in
|
| 240 |
+
steady state; anything past 2s means the ingress is down and we want the
|
| 241 |
+
candidate demoted, not the chat call hanging.
|
| 242 |
+
- `read=self.timeout` (12.0 in the KI-080 elected path) β the wall-clock budget
|
| 243 |
+
for the upstream to produce a complete response. Matches
|
| 244 |
+
`NimChainLLM._ELECTED_CALL_TIMEOUT_S` so a stuck NIM pool can't burn the
|
| 245 |
+
outer `wait_for` ceiling.
|
| 246 |
+
- `write=2.0` β our request bodies are <10 KB; 2s is generous.
|
| 247 |
+
- `pool=2.0` β if every NIM HTTP/2 connection is in use and we can't even check
|
| 248 |
+
one out within 2s, fail fast so BACKUP gets called.
|
| 249 |
+
|
| 250 |
+
**Why per-phase, not scalar.** Pre-KI-084, `httpx` collapsed a scalar timeout to
|
| 251 |
+
a single read deadline. A stuck NIM pool could occupy the TCP connection past
|
| 252 |
+
the outer `asyncio.wait_for` cancellation β the BACKUP started but PRIMARY's
|
| 253 |
+
socket was still held, leaking a NIM concurrency slot. Explicit per-phase
|
| 254 |
+
deadlines guarantee the TCP connection itself releases independently.
|
| 255 |
+
|
| 256 |
+
## 8. Failure classification
|
| 257 |
+
|
| 258 |
+
`_classify_error(e)` in `nvidia_nim_llm.py` maps a raised exception to a stable
|
| 259 |
+
string consumed by `llm_health.report_failure`. The string drives the sin-bin
|
| 260 |
+
duration.
|
| 261 |
+
|
| 262 |
+
| Error class string | Source | Demote duration | Rationale |
|
| 263 |
+
|---|---|---|---|
|
| 264 |
+
| `Status429` | `HTTPStatusError.response.status_code == 429` | **3600s (1h)** β `DEGRADE_DURATION_LONG_S` | Free-tier daily quotas don't reset in 30s. KI-084. |
|
| 265 |
+
| `HTTPStatusError:503` / `:502` / `:500` | non-429 HTTP errors | 30s β `DEGRADED_WINDOW_SEC` | Upstream brownouts typically clear inside a minute. |
|
| 266 |
+
| `ReadTimeout` / `TimeoutException` | `httpx` per-phase or scalar timeout | 30s | TCP/upstream stall; next probe re-tests. |
|
| 267 |
+
| `ConnectError` / `ConnectTimeout` | DNS / TLS / TCP failures | 30s | Network blip; recover quickly. |
|
| 268 |
+
| Any other `Exception` class name | parse failures, unexpected payload shapes | 30s | Defensive same-window. |
|
| 269 |
+
|
| 270 |
+
The rate-limit detector
|
| 271 |
+
(`_is_rate_limit_error`) matches `"429"`, `"ratelimit"`, or `"rate_limit"` (case
|
| 272 |
+
insensitive). It deliberately does NOT match bare `"HTTPStatusError"` so a 503
|
| 273 |
+
falls to the short window, not the 1h window.
|
| 274 |
+
|
| 275 |
+
Side effect on every `report_failure`: a synthetic
|
| 276 |
+
`{"ok": False, "src": "chat"}` entry is appended to `probe_history` so the next
|
| 277 |
+
election's `success_rate` reflects the live failure before the next probe tick.
|
| 278 |
+
An async re-probe of the failed model is scheduled best-effort so a quota that
|
| 279 |
+
happened to reset early is picked up immediately.
|
| 280 |
+
|
| 281 |
+
## 9. Escalation path (KI-079)
|
| 282 |
+
|
| 283 |
+
`backend/fact_find_brain.py::drive_fact_find` wraps the FAST_BRAIN call in
|
| 284 |
+
`asyncio.wait_for(_TIMEOUT_S=25s)`. On `asyncio.TimeoutError`:
|
| 285 |
+
|
| 286 |
+
1. **Heavy-brain retry.** Log `KI-079: fast brain timeout β¦` and call
|
| 287 |
+
`get_brain_llm()` (BRAIN_CHAIN) wrapped in
|
| 288 |
+
`asyncio.wait_for(_TIMEOUT_S_ESCALATION=15s)`. The heavy chain uses a
|
| 289 |
+
different election (Qwen 80B primary in steady state), different fallback
|
| 290 |
+
ladder, and reaches OpenRouter + Groq earlier in the candidate pool β
|
| 291 |
+
realistic escalation success cases land in 3-8s.
|
| 292 |
+
2. **Canonical fallback.** If heavy also times out, returns
|
| 293 |
+
`_canonical_fallback(session, user_text, reason="timeout_after_escalation")`
|
| 294 |
+
which:
|
| 295 |
+
- Greedily applies `_normalize_for_slot` to every unfilled slot in priority
|
| 296 |
+
order (age β dependents β income_band β existing_cover β primary_goal β
|
| 297 |
+
location β parents_age β budget β name), with slot-specific trigger guards
|
| 298 |
+
to prevent cross-contamination (KI-072 / KI-074).
|
| 299 |
+
- Picks the next still-empty slot and returns the canonical question.
|
| 300 |
+
- Fact-find never wedges. A fully-dead network still walks the user through
|
| 301 |
+
fact-find via canonical questions.
|
| 302 |
+
|
| 303 |
+
Total worst-case wall-clock before canonical fallback: 25s (FAST) + 15s
|
| 304 |
+
(BRAIN escalation) = **40s**. The 25s FAST cap only fires when NIM is wedged AND
|
| 305 |
+
no cross-provider election candidate is electable, which is rare with KI-080 +
|
| 306 |
+
KI-085 in place.
|
| 307 |
+
|
| 308 |
+
## 10. Telemetry surface
|
| 309 |
+
|
| 310 |
+
`TurnResult.brain_used` is the single string downstream consumers (admin
|
| 311 |
+
analytics, eval harness) read to attribute outcomes. Emitted variants:
|
| 312 |
+
|
| 313 |
+
| brain_used | Meaning |
|
| 314 |
+
|---|---|
|
| 315 |
+
| `fact_find_brain::continue` | LLM brain succeeded, fact-find still in progress |
|
| 316 |
+
| `fact_find_brain::complete` | LLM brain succeeded, fact-find now complete |
|
| 317 |
+
| `fact_find_brain::fallback:timeout` | FAST_BRAIN_CHAIN exhausted, escalation flag NOT yet applied (pre-KI-079 leftover; should rarely appear) |
|
| 318 |
+
| `fact_find_brain::fallback:timeout_after_escalation` | FAST timed out AND heavy-brain (BRAIN_CHAIN) also timed out β canonical fallback fired |
|
| 319 |
+
| `fact_find_brain::fallback:llm_error_after_escalation` | FAST timed out, heavy-brain raised (non-timeout exception) β canonical fallback fired |
|
| 320 |
+
| `fact_find_brain::fallback:llm_error` | FAST raised a non-timeout exception (HTTP / parse / etc.) |
|
| 321 |
+
| `fact_find_brain::fallback:no_trailer` | Brain replied but the `<FF>{...}</FF>` block was missing or malformed |
|
| 322 |
+
| `fact_find_brain::fallback:empty_reply` | Brain replied with only a `<FF>` block β no prose |
|
| 323 |
+
|
| 324 |
+
In addition, every successful `NimChainLLM.chat` writes a JSONL record to
|
| 325 |
+
`40-data/llm_usage.jsonl` with `{role, chain_primary, served_model,
|
| 326 |
+
elected_primary, elected_backup, latency_ms, success, [fallback_phase]}` so the
|
| 327 |
+
admin tab can audit which candidate served each turn. The admin
|
| 328 |
+
`status_summary()` surface now also returns
|
| 329 |
+
`elections: {brain: {primary, backup}, fast_brain: {β¦}, judge: {β¦}}` and the
|
| 330 |
+
per-model `credits_remaining` / `credits_unit` / `credits_low_water` from
|
| 331 |
+
KI-085 so operators can see why a candidate is gated out.
|
| 332 |
+
|
| 333 |
+
## 11. Performance characteristics
|
| 334 |
+
|
| 335 |
+
**Per-turn LLM call count** (under normal conditions with at least one healthy
|
| 336 |
+
candidate per provider):
|
| 337 |
+
|
| 338 |
+
| Scenario | Calls | Wall-clock (steady state) |
|
| 339 |
+
|---|---|---|
|
| 340 |
+
| Happy path: elected PRIMARY succeeds | **1** | 2-6s (depends on which provider wins election; Groq LPU ~1s, NIM Qwen ~2-3s, NIM Nemotron ~1.6s) |
|
| 341 |
+
| PRIMARY fails real-time β BACKUP succeeds | **2** | 4-12s (PRIMARY's 12s read deadline + BACKUP latency) |
|
| 342 |
+
| Both fail β KI-079 escalation succeeds on heavy chain | **3** | 18-25s (FAST budget + 3-8s heavy escalation) |
|
| 343 |
+
| Total exhaustion β canonical fallback | (heavy chain attempted) | up to 40s + canonical reply |
|
| 344 |
+
|
| 345 |
+
**Probe-driven token spend** (KI-084 cadence). With 25 candidates Γ 1
|
| 346 |
+
token per probe Γ `300s` cadence = ~7K probe tokens/day on Groq's free-tier TPD
|
| 347 |
+
(well inside the 100K cap). Pre-KI-084 (60s cadence, 5 tokens per probe) was
|
| 348 |
+
~150K/day β tripped the cap on probe traffic alone.
|
| 349 |
+
|
| 350 |
+
**Expected p50 / p95 latency** in steady state with elected primaries (Groq
|
| 351 |
+
Llama-3.3-70B fast-brain / NIM Qwen 80B brain / Mistral Large 3 judge):
|
| 352 |
+
|
| 353 |
+
| Role | p50 | p95 |
|
| 354 |
+
|---|---|---|
|
| 355 |
+
| fast_brain (fact-find turn) | ~2.0s | ~6s (one BACKUP failover) |
|
| 356 |
+
| brain (synthesis / comparison) | ~3.5s | ~10s |
|
| 357 |
+
| judge (faithfulness Gate 4) | ~4.5s | ~12s |
|
| 358 |
+
|
| 359 |
+
## 12. Operational runbook
|
| 360 |
+
|
| 361 |
+
### Provider exhausts 0 credits (Groq daily TPD hits 100,000/100,000)
|
| 362 |
+
|
| 363 |
+
Sequence is fully automatic β no operator action required:
|
| 364 |
+
|
| 365 |
+
1. **Reactive** β next user chat turn that elects Groq raises `Status429` from
|
| 366 |
+
the upstream. `_classify_error` returns `"Status429"`.
|
| 367 |
+
`llm_health.report_failure` sets `degraded_until_monotonic = now + 3600s`
|
| 368 |
+
and appends a failed `probe_history` entry.
|
| 369 |
+
2. **Election re-runs** on the next call. `is_electable(groq)` returns False
|
| 370 |
+
(sin-bin). The elector picks the next-ranked non-Groq candidate (typically
|
| 371 |
+
NIM Qwen 80B for fast-brain).
|
| 372 |
+
3. **User-visible behaviour.** First post-exhaustion turn pays ONE failover
|
| 373 |
+
(PRIMARY=Groq raises 429 β BACKUP=NIM Qwen answers). Total wall-clock: 12s
|
| 374 |
+
read-deadline + NIM Qwen latency β 14-15s. User sees a real reply, not a
|
| 375 |
+
canonical fallback. Every subsequent turn for the next hour is a single
|
| 376 |
+
1-call NIM Qwen response (~2-3s).
|
| 377 |
+
4. **Proactive (KI-085) β what should have happened instead.** If
|
| 378 |
+
`update_credits_from_groq` had received an `x-ratelimit-remaining-tokens-day`
|
| 379 |
+
header showing < 5000 tokens on the previous successful call, election
|
| 380 |
+
would have excluded Groq BEFORE the 429-producing call. The user would
|
| 381 |
+
never have seen the 14-15s failover turn. KI-085 closes the one-turn
|
| 382 |
+
reactive gap that KI-084 alone leaves.
|
| 383 |
+
5. **Recovery.** At `T + 1h` the sin-bin expires; `is_electable(groq)`
|
| 384 |
+
becomes True again. If the daily quota actually reset by then, the next
|
| 385 |
+
probe (or the next chat turn) re-stamps `credits_remaining` from headers
|
| 386 |
+
and Groq re-enters election. If the daily quota did NOT reset, the next
|
| 387 |
+
chat turn re-raises 429, sin-bin extends another hour.
|
| 388 |
+
|
| 389 |
+
### Live diagnosis worked example (2026-05-15)
|
| 390 |
+
|
| 391 |
+
Production hit `Status429` on Groq with the response headers showing
|
| 392 |
+
`x-ratelimit-remaining-tokens-day: 546` and `total used: 99,454 / 100,000`. Pre-
|
| 393 |
+
KI-085: every user turn for the next hour paid the 14-15s reactive failover
|
| 394 |
+
because the elector had no proactive signal. Post-KI-085: the *previous*
|
| 395 |
+
successful Groq call stamped `credits_remaining = 99454 - <last_call_tokens>`,
|
| 396 |
+
the next call saw `credits_remaining < 5000` and election excluded Groq before
|
| 397 |
+
the 429-producing call. Fast-brain primary flipped to NIM Qwen 80B; user saw
|
| 398 |
+
2-3s natural-LLM replies throughout the quota-exhausted window.
|
| 399 |
+
|
| 400 |
+
### When manual intervention IS needed
|
| 401 |
+
|
| 402 |
+
- **Full NIM regional outage** (all NIM-hosted candidates returning 5xx for
|
| 403 |
+
>15 min). Probe loop marks every NIM candidate as `down` after 3 consecutive
|
| 404 |
+
fails. Cross-provider candidates (OpenRouter, Groq) keep serving. No
|
| 405 |
+
operator action; if both Groq and OpenRouter are also degraded, the canonical
|
| 406 |
+
fallback path takes over and the user still gets a coherent (if scripted)
|
| 407 |
+
reply. Page Rohit only if `_canonical_fallback` reason `:no_trailer` /
|
| 408 |
+
`:empty_reply` rates spike β that means the brain *is* responding but
|
| 409 |
+
malformed, which the chain logic can't auto-heal.
|
| 410 |
+
- **API key rotation.** `.env` (local) / HF Space environment secrets
|
| 411 |
+
(production) hold `NVIDIA_NIM_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY`.
|
| 412 |
+
KI-081 pushed the Groq + OpenRouter keys to the HF Space environment so the
|
| 413 |
+
chain works in production without code redeploys. Rotating any key requires:
|
| 414 |
+
(a) update local `.env`, (b) update HF Space secrets via Settings β Repository
|
| 415 |
+
secrets, (c) bounce the Space (`huggingface_hub.HfApi().restart_space()` or
|
| 416 |
+
manual restart).
|
| 417 |
+
- **OpenRouter wallet refill.** `poll_openrouter_credits()` will pick up the
|
| 418 |
+
new balance within 10 min. To accelerate, restart the Space.
|
| 419 |
+
|
| 420 |
+
## 13. Files touched (across KI-079 / KI-080 / KI-084 / KI-085)
|
| 421 |
+
|
| 422 |
+
- `backend/providers/nvidia_nim_llm.py` β `NimChainLLM.chat` election rewrite
|
| 423 |
+
(KI-080), `_classify_error` for Status429 (KI-084), per-phase httpx timeouts
|
| 424 |
+
(KI-084), `record_nim_call` hook on every NIM success (KI-085).
|
| 425 |
+
- `backend/llm_health.py` β KI-080 election + 60s probe (now 300s in KI-084),
|
| 426 |
+
KI-084 1h rate-limit demote + `DEGRADE_DURATION_LONG_S` + probe cadence /
|
| 427 |
+
max_tokens reduction, KI-085 `update_credits_from_groq` /
|
| 428 |
+
`update_credits_from_openrouter_headers` / `poll_openrouter_credits` /
|
| 429 |
+
`record_nim_call` + the `_has_credits` election predicate.
|
| 430 |
+
- `backend/providers/groq_llm.py` β `update_credits_from_groq` call after
|
| 431 |
+
successful HTTP (KI-085).
|
| 432 |
+
- `backend/providers/openrouter_llm.py` β `update_credits_from_openrouter_headers`
|
| 433 |
+
call after successful HTTP (KI-085).
|
| 434 |
+
- `backend/fact_find_brain.py` β `_TIMEOUT_S_ESCALATION = 15.0` + heavy-brain
|
| 435 |
+
retry path on FAST timeout (KI-079).
|
|
@@ -268,6 +268,11 @@ The full eval was sending **every** QA question to `needs_finder`. Sample bot an
|
|
| 268 |
| KI-078 | `078ff45` | LLM chain hardening: per-link timeout 12s β 6s so chain can try 3-4 candidates inside `total_budget_s=22s` instead of 1. Narrowed `except Exception` to re-raise `CancelledError`/`KeyboardInterrupt`/`SystemExit` so `asyncio.wait_for` actually bubbles. New `_fallback_reason` stamped on `FactFindOutcome` and surfaced as `fact_find_brain::fallback:timeout` / `:no_trailer` / `:empty_reply` / `:llm_error` in `TurnResult.brain_used` for production telemetry. |
|
| 269 |
| KI-079 | `87ee522` | Two-layer chain hardening for fact-find. (1) FAST_BRAIN_CHAIN reorder: Groq Llama-3.3-70B promoted from position #5 to #2 (right after Nemotron primary) so cross-provider fallback is reached in ~6-7s of budget instead of ~20s. (2) Heavy-chain escalation: when `drive_fact_find()` raises `TimeoutError` on fast-brain, orchestrator retries once on `BRAIN_CHAIN` (Qwen 80B primary, 35s budget) before falling to `_canonical_fallback`. Adds `fallback:timeout_after_escalation` / `:llm_error_after_escalation` to telemetry vocabulary. Now serves as the last-bite safety net once KI-080's primary election is in place. |
|
| 270 |
| **KI-080** | **`6159c54`** | **Sticky primary election for LLM chains ([ADR-031](../70-docs/60-decisions/ADR-031-sticky-primary-election.md)).** `NimChainLLM.chat()` refactored from "iterate every chain candidate sequentially per call" to "call the probe-elected PRIMARY once; on real-time failure, call the cross-provider BACKUP once and re-trigger the probe." `backend/llm_health.py` runs a 60s background probe loop that scores every candidate on (latency Γ success rate) and elects a sticky PRIMARY + provider-diverse BACKUP for each of `BRAIN_CHAIN` / `FAST_BRAIN_CHAIN` / `JUDGE_CHAIN`. **Per-turn LLM call count drops from 5-6 (sustained NIM degradation, every candidate queued + timed out) to 1 (most cases) or 2 (primary fails real-time β backup + re-probe).** ADR-026's `_balanced_brain_chain` (KI-025 50/50 NIM β Groq rotation) is deprecated β the probe-driven election dynamically picks the actually-faster candidate instead of a fixed 50/50 coin. Code retained as a bypassed branch behind a feature flag for one-release rollback. Cold-start fallback (no probes complete yet) uses `chain[0]` as the initial primary. KI-079 escalation still applies as the last bite if both primary AND backup fail in the same turn. Inline tests: 7/7 OK (cold-start, lowest-latency primary, cross-provider backup, demote-on-failure, score-update, primary-success makes 1 call, primary-fail/backup-success makes exactly 2 calls). `tests/test_routing_regression.py` β 15/15 pass. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
| D-001 | (multi) | ChromaDB HNSW bloat 3-layer prevention (ADR-029) |
|
| 272 |
| D-002 | (LaunchAgent edit) | Three silently-failing LaunchAgent scripts fixed |
|
| 273 |
| D-009 | `bcb7079` | Removed `tmp_*.py` debug files from repo root |
|
|
|
|
| 268 |
| KI-078 | `078ff45` | LLM chain hardening: per-link timeout 12s β 6s so chain can try 3-4 candidates inside `total_budget_s=22s` instead of 1. Narrowed `except Exception` to re-raise `CancelledError`/`KeyboardInterrupt`/`SystemExit` so `asyncio.wait_for` actually bubbles. New `_fallback_reason` stamped on `FactFindOutcome` and surfaced as `fact_find_brain::fallback:timeout` / `:no_trailer` / `:empty_reply` / `:llm_error` in `TurnResult.brain_used` for production telemetry. |
|
| 269 |
| KI-079 | `87ee522` | Two-layer chain hardening for fact-find. (1) FAST_BRAIN_CHAIN reorder: Groq Llama-3.3-70B promoted from position #5 to #2 (right after Nemotron primary) so cross-provider fallback is reached in ~6-7s of budget instead of ~20s. (2) Heavy-chain escalation: when `drive_fact_find()` raises `TimeoutError` on fast-brain, orchestrator retries once on `BRAIN_CHAIN` (Qwen 80B primary, 35s budget) before falling to `_canonical_fallback`. Adds `fallback:timeout_after_escalation` / `:llm_error_after_escalation` to telemetry vocabulary. Now serves as the last-bite safety net once KI-080's primary election is in place. |
|
| 270 |
| **KI-080** | **`6159c54`** | **Sticky primary election for LLM chains ([ADR-031](../70-docs/60-decisions/ADR-031-sticky-primary-election.md)).** `NimChainLLM.chat()` refactored from "iterate every chain candidate sequentially per call" to "call the probe-elected PRIMARY once; on real-time failure, call the cross-provider BACKUP once and re-trigger the probe." `backend/llm_health.py` runs a 60s background probe loop that scores every candidate on (latency Γ success rate) and elects a sticky PRIMARY + provider-diverse BACKUP for each of `BRAIN_CHAIN` / `FAST_BRAIN_CHAIN` / `JUDGE_CHAIN`. **Per-turn LLM call count drops from 5-6 (sustained NIM degradation, every candidate queued + timed out) to 1 (most cases) or 2 (primary fails real-time β backup + re-probe).** ADR-026's `_balanced_brain_chain` (KI-025 50/50 NIM β Groq rotation) is deprecated β the probe-driven election dynamically picks the actually-faster candidate instead of a fixed 50/50 coin. Code retained as a bypassed branch behind a feature flag for one-release rollback. Cold-start fallback (no probes complete yet) uses `chain[0]` as the initial primary. KI-079 escalation still applies as the last bite if both primary AND backup fail in the same turn. Inline tests: 7/7 OK (cold-start, lowest-latency primary, cross-provider backup, demote-on-failure, score-update, primary-success makes 1 call, primary-fail/backup-success makes exactly 2 calls). `tests/test_routing_regression.py` β 15/15 pass. |
|
| 271 |
+
| KI-081 | (no commit β HF Space env secrets) | Pushed `GROQ_API_KEY` + `OPENROUTER_API_KEY` to the HF Space repository secrets so the KI-080 cross-provider election candidates actually have working keys in production. Pre-KI-081 only `NVIDIA_NIM_API_KEY` was set on the Space; the elector would mark every Groq + OpenRouter candidate as `no_api_key` and election degraded to NIM-only candidates β defeating the cross-provider BACKUP invariant. |
|
| 272 |
+
| KI-084 | `119e0fd` | **LLM chain telemetry hardening + free-tier guards.** Four changes in one commit. (1) Probe cadence `PROBE_INTERVAL_SEC` raised 60s β **300s** β the prior cadence burned ~30-50K probe tokens/day on Groq alone, self-tripping Groq's 100K/day TPD free-tier cap. (2) `PROBE_MAX_TOKENS` cut 5 β **1** β same 200 envelope, ~50Γ less token spend per probe. (3) Explicit per-phase `httpx.Timeout(connect=2, read=self.timeout, write=2, pool=2)` on every chat call β previously `timeout=self.timeout` collapsed to a single read deadline so a stuck NIM pool could occupy the TCP connection past `asyncio.wait_for` cancellation, leaking NIM concurrency slots. (4) New `_classify_error` surfaces HTTP status codes explicitly (`Status429` vs `HTTPStatusError:503`); rate-limit failures get a **1-hour** sin-bin (`DEGRADE_DURATION_LONG_S = 3600s`) instead of the 30s transient window β free-tier daily quotas don't reset in 30 seconds. |
|
| 273 |
+
| KI-085 | `8fc7979` | **Proactive credit tracking β closes the reactive-only gap KI-084 leaves.** KI-084 demotes a candidate for 1h AFTER a 429 hits, costing one user-facing failover turn per dead quota. KI-085 promotes `llm_health` from liveness-only to liveness-AND-credits so election excludes quota-exhausted candidates BEFORE the user gets stuck behind a 429. Three signal sources: (1) Groq response headers `x-ratelimit-remaining-tokens-day` + `x-ratelimit-reset-tokens-day` (low-water 5K tokens); (2) OpenRouter `/api/v1/credits` polled every 10 min from probe loop, plus per-call header fallback (low-water $0.05); (3) NIM local 60s rate-meter, gate at 35-of-40 req/min (headroom 5). Election adds `_has_credits(h, now_mono)` to eligibility predicate. Admin `status_summary` extended with `credits_remaining` / `credits_unit` / `credits_low_water` per model. 11/11 inline tests pass + routing_regression 15/15. |
|
| 274 |
+
| KI-086 | `d90f8c0` (bundled with KI-087) | **Admin "LLM Health & Credits" tab.** New `GET /api/admin/llm-health` endpoint returns `{chains, candidates, recent_turns, snapshot_ts}` JSON: per-chain elected PRIMARY + BACKUP with snapshots, per-candidate health grid with credits + degraded-until, last 20 turn outcomes from `40-data/llm_usage.jsonl`. Same `_check_admin` IP-allowlist + password gate as other admin endpoints. Frontend extends the existing "LLM Chain" tab in `frontend/public/admin/llm-control.html` with three sections: (A) per-chain election cards, (B) candidate health table, (C) recent turns table. Auto-polls every 30s while tab is active. Operator now sees at-a-glance which LLM is in use where, why a candidate is gated out, and how the election state evolves. |
|
| 275 |
+
| **KI-087** | **`d90f8c0`** | **NIM-first election preference.** Pre-KI-087 election scored purely by `latency Γ success_rate`, which consistently favoured Groq's 161ms LPU TTFT over NIM's 500ms-1s β so every probe round elected Groq as PRIMARY across all 3 chains. Result: every chat call hit Groq first, burned Groq's 100K daily TPD inside 50 turns, then started returning 429s. KI-087 changes election so it prefers ANY eligible NIM candidate over ALL non-NIM candidates. Within the NIM pool the standard score still picks the fastest healthy NIM model. Only when the NIM pool is empty does election fall through to Groq / OpenRouter as PRIMARY. BACKUP rule unchanged in spirit: cross-provider against PRIMARY. Rationale: NIM is the strategic free provider (ADR-019, no daily cap, 110+ models, single-key, $0); Groq has 100K daily TPD; OpenRouter charges real USD. Both should serve as emergency fallback only. |
|
| 276 |
| D-001 | (multi) | ChromaDB HNSW bloat 3-layer prevention (ADR-029) |
|
| 277 |
| D-002 | (LaunchAgent edit) | Three silently-failing LaunchAgent scripts fixed |
|
| 278 |
| D-009 | `bcb7079` | Removed `tmp_*.py` debug files from repo root |
|
|
@@ -18,18 +18,23 @@ This file is read by Claude Code (and any compatible AI tool) at the start of a
|
|
| 18 |
- **Hands-free was removed entirely** in KI-027. Anything in the codebase still referring to it is stale.
|
| 19 |
- **Bot TTS plays via the in-DOM `<audio>` element** inside `Message` (autoplay-on-mount via ref'd `useEffect`). Never use `new Audio(url).play()` β those detached instances are invisible to `document.querySelectorAll("audio").pause()` in the barge-in handler.
|
| 20 |
|
| 21 |
-
## LLM stack (ADR-019 + ADR-026 β ADR-031
|
| 22 |
-
|
| 23 |
-
Every LLM role is a `NimChainLLM`
|
| 24 |
-
|
| 25 |
-
- **Probe-driven sticky primary election (KI-080, [ADR-031](70-docs/60-decisions/ADR-031-sticky-primary-election.md)).** All three chains (`BRAIN_CHAIN`, `FAST_BRAIN_CHAIN`, `JUDGE_CHAIN`) elect a sticky PRIMARY + provider-diverse BACKUP from a background probe. `backend/llm_health.py`
|
| 26 |
-
- **KI-
|
| 27 |
-
- **
|
| 28 |
-
- **
|
| 29 |
-
- **KI-
|
| 30 |
-
- **
|
| 31 |
-
- **
|
| 32 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
## Fact-find loop (ADR-030, supersedes ADR-027) β KI-070
|
| 35 |
|
|
|
|
| 18 |
- **Hands-free was removed entirely** in KI-027. Anything in the codebase still referring to it is stale.
|
| 19 |
- **Bot TTS plays via the in-DOM `<audio>` element** inside `Message` (autoplay-on-mount via ref'd `useEffect`). Never use `new Audio(url).play()` β those detached instances are invisible to `document.querySelectorAll("audio").pause()` in the barge-in handler.
|
| 20 |
|
| 21 |
+
## LLM stack (ADR-019 + ADR-026 β ADR-031 + ADR-032) β KI-080 β KI-087
|
| 22 |
+
|
| 23 |
+
Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model. End-to-end spec: [ADR-032](70-docs/60-decisions/ADR-032-llm-chain-architecture.md). Chains preserve brain β judge family diversity (Qwen brain β Mistral judge) so failovers can't accidentally produce circular grading.
|
| 24 |
+
|
| 25 |
+
- **Probe-driven sticky primary election (KI-080, [ADR-031](70-docs/60-decisions/ADR-031-sticky-primary-election.md)).** All three chains (`BRAIN_CHAIN`, `FAST_BRAIN_CHAIN`, `JUDGE_CHAIN`) elect a sticky PRIMARY + provider-diverse BACKUP from a background probe. `backend/llm_health.py` scores every candidate on `(1 / max(50, latency_ms)) * success_rate` and writes the current election to process state. `NimChainLLM.chat()` calls PRIMARY once; on real-time failure it falls to BACKUP (cross-provider by construction) and triggers an immediate probe refresh. **Per-turn LLM call count: 1 (most cases) or 2 (PRIMARY fails real-time β BACKUP).** Pre-KI-080 worst case was 5-6 NIM calls per turn, all queued and timing out.
|
| 26 |
+
- **NIM-first election preference (KI-087, `d90f8c0`).** Election prefers ANY eligible NIM candidate over ALL non-NIM candidates. Within the NIM pool, score still picks the fastest healthy NIM model. Only when the NIM pool is empty (every NIM model down, throttled, or quota-exhausted) does election fall through to Groq / OpenRouter as PRIMARY. Rationale: NIM is the strategic free provider ($0, no daily cap, 110+ models); Groq has 100K tokens/day; OpenRouter charges real USD. Both serve as emergency fallback only.
|
| 27 |
+
- **Probe cadence + per-phase timeouts (KI-084, `119e0fd`).** Probe loop ticks at `PROBE_INTERVAL_SEC = 300s` (was 60s οΏ½οΏ½οΏ½ raised so probe-driven token spend stays inside Groq's 100K/day free-tier cap). Probe `max_tokens` cut `5 β 1`. Every chat call uses explicit `httpx.Timeout(connect=2, read=12, write=2, pool=2)` so a stuck NIM pool releases its TCP socket independently of the outer `asyncio.wait_for`, preventing NIM concurrency-slot leaks across PRIMARY β BACKUP. Rate-limit failures (HTTP 429 / `RateLimit` body) get a **1h sin-bin** (`DEGRADE_DURATION_LONG_S = 3600s`) instead of the 30s transient window β free-tier daily quotas don't reset in 30 seconds.
|
| 28 |
+
- **Proactive credit gating (KI-085, `8fc7979`).** Election is gated by `is_alive AND has_credits` so quota-exhausted candidates are excluded BEFORE the user hits a 429. Signal sources: Groq response headers (`x-ratelimit-remaining-tokens-day`, low-water 5,000 tokens); OpenRouter `GET /api/v1/credits` polled every 10 min (low-water $0.05); NIM local 60-second rate-meter (gate at 35-of-40 req/min, headroom 5). Closes the one-turn reactive gap KI-084 alone leaves.
|
| 29 |
+
- **HF Space secrets (KI-081, no commit; HF Space env secrets push).** `GROQ_API_KEY` + `OPENROUTER_API_KEY` pushed to the Space repository secrets so KI-080 cross-provider election candidates have working keys in production. Pre-KI-081 only `NVIDIA_NIM_API_KEY` was set on the Space; the elector marked Groq + OR as `no_api_key` and election degraded to NIM-only candidates.
|
| 30 |
+
- **Admin telemetry (KI-086, `d90f8c0`).** `GET /api/admin/llm-health` returns `{chains, candidates, recent_turns, snapshot_ts}` with per-chain elected primary/backup, per-candidate health + credits + degraded-until, and last 20 turn outcomes. Surfaced in the admin "LLM Chain" tab with auto-refresh every 30s.
|
| 31 |
+
- **KI-025's 50/50 NIM β Groq rotation ([ADR-026](70-docs/60-decisions/ADR-026-provider-load-balancing.md)) is deprecated** β `_balanced_brain_chain` retained behind a feature flag for one-release rollback; the probe-driven election picks the actually-faster candidate dynamically.
|
| 32 |
+
- **Cold-start fallback.** Before the first probe completes (process restart, HF Space rebuild), `chain[0]` is the initial primary and `chain[1]` (preferring a different provider) is the initial backup. The probe loop runs immediately on startup; OpenRouter credits are polled on startup so the elector has a non-None USD balance before the first chat call.
|
| 33 |
+
- **Brain / fast-brain / judge primaries in steady state** are typically **NIM Qwen 3-Next 80B** (heavy brain, KI-087 NIM-first), **NIM Nemotron Nano 30B** (fast brain), and **Mistral Large 3 675B** (judge). Not hardcoded β the elected primary follows live `latency Γ success_rate Γ credits_available` with NIM-first preference.
|
| 34 |
+
- **KI-079 escalation as last bite (`87ee522`).** If both PRIMARY and BACKUP fail in a single fact-find turn, orchestrator retries once on `BRAIN_CHAIN` (heavy brain, `_TIMEOUT_S_ESCALATION = 15s`, 35s chain budget) before falling to `_canonical_fallback` (KI-072 / KI-074 greedy slot capture). Worst-case wall-clock before canonical: 25s FAST + 15s heavy = 40s.
|
| 35 |
+
- **Chain budgets:** brain 20s Γ 35s total, fast-brain 12s Γ 22s total, judge 30s Γ 75s total. With KI-080 only PRIMARY + BACKUP consume budget in the common case β leaves headroom for KI-079 escalation. KI-084 per-phase httpx timeouts are nested inside these budgets.
|
| 36 |
+
- **STT/TTS/Translator** = Sarvam (Saarika v2.5 / Bulbul v2 / Sarvam-M). **Embeddings** = local BGE-small-en-v1.5.
|
| 37 |
+
- **Provider keys.** `NVIDIA_NIM_API_KEY` + `GROQ_API_KEY` + `OPENROUTER_API_KEY` required in `.env` (local) and HF Space environment (production β KI-081).
|
| 38 |
|
| 39 |
## Fact-find loop (ADR-030, supersedes ADR-027) β KI-070
|
| 40 |
|
|
@@ -31,7 +31,37 @@ Every external model is fronted by a small typed client here. The orchestrator a
|
|
| 31 |
| Fast brain | 12 | 22 | `nvidia_nim_llm.py::get_fast_brain_llm` |
|
| 32 |
| Judge | 30 | 75 | `nvidia_nim_llm.py::get_judge_llm` |
|
| 33 |
|
| 34 |
-
Per-link timeout is dynamically clipped to remaining budget.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
## Related
|
| 37 |
|
|
|
|
| 31 |
| Fast brain | 12 | 22 | `nvidia_nim_llm.py::get_fast_brain_llm` |
|
| 32 |
| Judge | 30 | 75 | `nvidia_nim_llm.py::get_judge_llm` |
|
| 33 |
|
| 34 |
+
Per-link timeout is dynamically clipped to remaining budget. KI-084 explicit per-phase `httpx.Timeout(connect=2, read=<per-link>, write=2, pool=2)` is nested inside.
|
| 35 |
+
|
| 36 |
+
## Credit-aware election (KI-085)
|
| 37 |
+
|
| 38 |
+
Beyond liveness, election in `backend/llm_health.py` is gated on `credits_remaining > credits_low_water` per candidate so quota-exhausted providers are excluded BEFORE producing a user-facing 429. KI-087 further prefers NIM as primary; Groq/OpenRouter serve as emergency fallback.
|
| 39 |
+
|
| 40 |
+
### Per-provider signal sources
|
| 41 |
+
|
| 42 |
+
| Provider | Producer | Signal | Unit | Low-water |
|
| 43 |
+
|---|---|---|---|---|
|
| 44 |
+
| **Groq** | `update_credits_from_groq` from `groq_llm.py::chat` | `x-ratelimit-remaining-tokens-day` header + `x-ratelimit-reset-tokens-day` for reset | `tokens_day` | **5,000** (one fact-find round-trip ~2.4K + margin) |
|
| 45 |
+
| **OpenRouter** | `poll_openrouter_credits` (every 10 min) + `update_credits_from_openrouter_headers` per-call fallback | `GET /api/v1/credits` β `{total_credits, total_usage}` | `usd_balance` | **$0.05** USD |
|
| 46 |
+
| **NIM** | `record_nim_call` from `NimChainLLM._try` | Local 60s deque of monotonic timestamps per model | `requests_min` (remaining in current 60s window) | **5.0** slots (gate at 35/40 req/min) |
|
| 47 |
+
|
| 48 |
+
### Election predicate
|
| 49 |
+
|
| 50 |
+
In `_is_election_eligible(h, now_mono)`:
|
| 51 |
+
|
| 52 |
+
```python
|
| 53 |
+
if h.credits_reset_at is not None and now_mono >= h.credits_reset_at:
|
| 54 |
+
return True # quota already reset
|
| 55 |
+
if h.credits_remaining is None: # cold-start permissive
|
| 56 |
+
return True
|
| 57 |
+
return h.credits_remaining > h.credits_low_water
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### Cold-start
|
| 61 |
+
|
| 62 |
+
`credits_remaining = None` is permissive β fresh process restarts don't grind to a halt before the first call has stamped credit state. OpenRouter poll fires on startup so the USD balance is non-None within seconds.
|
| 63 |
+
|
| 64 |
+
See [ADR-032](../../70-docs/60-decisions/ADR-032-llm-chain-architecture.md) Β§6 for the full table and Β§12 for the operational runbook.
|
| 65 |
|
| 66 |
## Related
|
| 67 |
|