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

docs: ADR-032 LLM chain architecture reference + KI-080..KI-087 doc sync

Browse files

Adds 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 ADDED
@@ -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).
80-audit/ENTERPRISE_AUDIT.md CHANGED
@@ -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 |
CLAUDE.md CHANGED
@@ -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 supersession) β€” KI-080
22
-
23
- Every LLM role is a `NimChainLLM` fallback chain, NOT a hardcoded single model. 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` runs a 60s probe loop that scores every candidate on (latency Γ— success rate) and writes the current election to process state. `NimChainLLM.chat()` no longer iterates every candidate per call β€” it 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 + re-probe).** Pre-KI-080 worst case under sustained NIM concurrency throttling was 5-6 NIM calls per turn, all queued and timing out.
26
- - **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` is retained in `backend/providers/nvidia_nim_llm.py` behind a feature flag for one-release rollback but is bypassed by default; the probe-driven election picks the actually-faster candidate dynamically instead of a fixed coin flip.
27
- - **Cold-start fallback.** Before the first probe completes (process restart, HF Space rebuild), `chain[0]` is the initial primary and `chain[1]` is the initial backup; the probe overwrites both within 60s.
28
- - **Brain / fast-brain / judge primaries in steady state** are currently **NIM Qwen 80B** (heavy brain), **NIM Nemotron Nano 30B** (fast brain, ~1.6s TTFT), **Mistral Large 3 675B** (judge β€” different family from brain β†’ non-circular grading). These are the typical probe winners but are no longer hardcoded β€” the elected primary follows live latency Γ— success scores.
29
- - **KI-079 escalation as last bite.** If both PRIMARY and BACKUP fail in a single fact-find turn, orchestrator retries once on `BRAIN_CHAIN` (heavy brain, 35s budget) before falling to `_canonical_fallback`. Final guardrail; fires after probe + backup are exhausted, not before.
30
- - **STT/TTS/Translator** = Sarvam (Saarika v2.5 / Bulbul v2 / Sarvam-M).
31
- - **Embeddings** = local BGE-small-en-v1.5 (`backend/providers/local_embeddings.py`). Voyage is configured in `.env` for ingest if needed but not on the hot path.
32
- - **Chain budgets:** brain 20s Γ— 35s total, fast-brain 12s Γ— 22s total, judge 30s Γ— 75s total. Per-link timeout is dynamically clipped to remaining budget. With KI-080, only PRIMARY + BACKUP consume the budget in the common case β€” leaves headroom for KI-079 escalation.
 
 
 
 
 
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
 
backend/providers/README.md CHANGED
@@ -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