rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
86105c9
·
1 Parent(s): d59cfe2

fix(admin): KI-122 — clear LLM availability + in-use indicators

Browse files

The admin LLM Chain panel showed a contradictory state — a chain's
elected primary marked HEALTHY 100% while a red banner declared "all
candidates credit-exhausted". The KI-116 reset-window fix mirrored
`is_credit_eligible` but still left a hole: when one chain member had
credits_reset_at=None (e.g. OpenRouter's prepaid wallet always stamps
None) AND credits_remaining<=low_water, while peer NIM candidates had
no credit signal yet (cold start), the banner loop registered
any_signal=True on the gated member and never broke out via the
permissive NIM peer. Banner fired despite the elected NIM primary
being perfectly callable.

Backend (`backend/admin.py::_chain_summary`):
- New helpers `_candidate_available_for_calls` + `_candidate_credit_
exhausted_strict` produce per-member booleans the wire payload
surfaces directly.
- New strict banner rule: `chain_credit_exhausted` is true ONLY when
ZERO members are available_for_calls AND at least one is strictly
credit-exhausted (credits<=low_water AND a future reset_at). Members
with reset_at=None are treated as permissive — wallet=$0 with healthy
probes is the free-tier OpenRouter signal, not a real outage.
- Wire payload adds `current_primary`, `current_primary_available`,
`chain_members[]` (one row per chain entry with availability +
credit_exhausted + is_current_primary), and `last_probe_at` +
`last_probe_age_seconds` for the operator's staleness signal.

Frontend (`frontend/public/admin/llm-control.html`):
- Top of each chain card now reads "Currently in use: <model> ● IN USE"
in a green callout so the operator doesn't have to infer the live
model from the PRIMARY detail row.
- Full chain member list rendered with per-row pills (● IN USE / ●
AVAILABLE / ● GATED / ● UNAVAILABLE) so every wired-up model is
visible at a glance with its current callability.
- Chain head shows "probed Ns ago" so probe-loop staleness is obvious.
- Banner remains backend-gated by the strict rule — no more false
positives when an exhausted Groq backup co-exists with a healthy
NIM primary.

Tests:
- Adds TestStrictChainCreditExhausted covering: healthy NIM + exhausted
Groq (banner stays down), all reset_at=None members (banner stays
down), all in-window strictly exhausted (banner fires), and the new
wire fields are populated.
- All 39 existing tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/admin.py CHANGED
@@ -687,49 +687,131 @@ def _candidate_snapshot(model: str, health, chain_membership: list[str],
687
  }
688
 
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  def _chain_summary(role: str, chains: dict[str, list[str]],
691
  state: dict, now_mono: float) -> dict:
692
  """Per-chain block for Section A. Includes elected primary/backup +
693
- `all_credit_exhausted` so the frontend can render a banner when every
694
- candidate in the chain is gated out by credits/quota."""
695
  chain = chains.get(role) or []
696
  primary = llm_health.get_primary(role)
697
  backup = llm_health.get_backup(role)
698
 
699
- # all_credit_exhausted: every chain member with a non-None credit signal
700
- # is at-or-below its low-water mark. Chains with no signal at all are
701
- # NOT flagged exhausted (cold-start should be permissive — election will
702
- # try them and surface a real failure if any).
703
  #
704
- # KI-116 (2026-05-15) mirror the `is_credit_eligible` reset-window logic.
705
- # When credits_reset_at has elapsed, the LAST observed credits_remaining
706
- # is stale (e.g., NIM 40-RPM cap from a spike 10 minutes ago says
707
- # `remaining=0` but the 60s window has long since cycled). The elector
708
- # treats elapsed-reset as permissive; the banner must match or the UI
709
- # contradicts itself ("HEALTHY 100% success" + "credit-exhausted" at the
710
- # same time, which user reported on the live admin panel).
711
- any_signal = False
712
- all_exhausted = True
 
 
 
 
 
 
 
 
 
713
  for m in chain:
714
  h = state.get(m)
715
- if h is None or h.credits_remaining is None:
716
- continue
717
- # If the credit signal's reset window has elapsed, skip — the snapshot
718
- # is stale and the elector will try this candidate next call.
719
- if h.credits_reset_at is not None and now_mono >= h.credits_reset_at:
720
- all_exhausted = False
721
- break
722
- any_signal = True
723
- if h.credits_remaining > (h.credits_low_water or 0.0):
724
- all_exhausted = False
725
- break
726
- chain_credit_exhausted = bool(any_signal and all_exhausted)
 
 
 
 
 
 
 
 
 
 
 
727
 
728
  return {
729
  "role": role,
730
  "chain": chain,
 
731
  "elected_primary": primary,
732
  "elected_backup": backup,
 
 
 
 
 
 
733
  "primary_snapshot": _candidate_snapshot(
734
  primary, state.get(primary), [role], now_mono,
735
  ) if primary else None,
@@ -737,6 +819,10 @@ def _chain_summary(role: str, chains: dict[str, list[str]],
737
  backup, state.get(backup), [role], now_mono,
738
  ) if backup else None,
739
  "chain_credit_exhausted": chain_credit_exhausted,
 
 
 
 
740
  }
741
 
742
 
 
687
  }
688
 
689
 
690
+ def _candidate_available_for_calls(h, now_mono: float) -> bool:
691
+ """KI-122 — Is this candidate AVAILABLE RIGHT NOW for real chat traffic?
692
+
693
+ This is the operator-facing definition of "live and usable":
694
+ - probe says healthy or degraded (NOT 'down' / 'unknown')
695
+ - has not been sin-binned by report_failure() in the last
696
+ DEGRADED_WINDOW_SEC / DEGRADE_DURATION_LONG_S window
697
+ - credits gate (`_has_credits`) passes — either no signal, OR
698
+ credits_reset_at has elapsed (stale snapshot), OR credits_remaining
699
+ is above the candidate's low_water.
700
+
701
+ Returns False for None / unknown candidates."""
702
+ if h is None:
703
+ return False
704
+ if h.status in ("down", "unknown"):
705
+ return False
706
+ if h.degraded_until_monotonic and h.degraded_until_monotonic > now_mono:
707
+ return False
708
+ if not llm_health._has_credits(h, now_mono):
709
+ return False
710
+ return True
711
+
712
+
713
+ def _candidate_credit_exhausted_strict(h, now_mono: float) -> bool:
714
+ """KI-122 — STRICT credit-exhausted rule for a single candidate.
715
+
716
+ True ONLY when ALL three hold:
717
+ (1) credits_remaining is NOT None (we have a real signal)
718
+ (2) credits_remaining <= credits_low_water
719
+ (3) credits_reset_at is set AND in the FUTURE (we're inside an
720
+ active gating window — stale or absent reset means the
721
+ snapshot is no longer authoritative).
722
+
723
+ This is intentionally stricter than `not _has_credits()` because the
724
+ banner is louder than the elector. The elector falls through cheaply
725
+ on a single bad candidate; the banner falsely scaring the operator
726
+ when one quota-exhausted backup co-exists with a perfectly healthy
727
+ primary is the bug we're fixing here."""
728
+ if h is None:
729
+ return False
730
+ if h.credits_remaining is None:
731
+ return False
732
+ if h.credits_remaining > (h.credits_low_water or 0.0):
733
+ return False
734
+ if h.credits_reset_at is None:
735
+ # No scheduled reset (e.g. OpenRouter usd_balance — prepaid wallet).
736
+ # Could still be a real "wallet empty" signal, BUT we won't flag the
737
+ # chain banner on it alone because OpenRouter free-tier accounts
738
+ # report $0 even when calls succeed. The probe / chat success
739
+ # signal is the authoritative truth — fall through to the probe
740
+ # status check in `_candidate_available_for_calls` instead.
741
+ return False
742
+ if now_mono >= h.credits_reset_at:
743
+ # Reset window has elapsed — snapshot is stale, treat as permissive.
744
+ return False
745
+ return True
746
+
747
+
748
  def _chain_summary(role: str, chains: dict[str, list[str]],
749
  state: dict, now_mono: float) -> dict:
750
  """Per-chain block for Section A. Includes elected primary/backup +
751
+ `chain_credit_exhausted` so the frontend can render a banner when every
752
+ candidate in the chain is genuinely unusable."""
753
  chain = chains.get(role) or []
754
  primary = llm_health.get_primary(role)
755
  backup = llm_health.get_backup(role)
756
 
757
+ # KI-122 (2026-05-15) STRICT chain_credit_exhausted rule.
 
 
 
758
  #
759
+ # Earlier rules (KI-085, KI-116) tried to skip cold-start candidates
760
+ # and elapsed-reset snapshots but still left a hole: when one chain
761
+ # member had a None reset_at + low credits (typical for OpenRouter's
762
+ # usd_balance) while sibling NIM candidates had `credits_remaining=None`
763
+ # (no signal yet), the loop registered `any_signal=True` on the bad
764
+ # OpenRouter row but never broke out via a fresh NIM peer — so the
765
+ # banner fired despite the elected NIM primary being HEALTHY · 100%.
766
+ #
767
+ # New rule: banner fires ONLY when EVERY chain member is BOTH
768
+ # (a) not available_for_calls (down / sin-binned / credit-gated), AND
769
+ # (b) at least one of those failures is a credit-exhaustion signal
770
+ # (otherwise it's a "chain entirely down" situation, which
771
+ # deserves a different banner — handled by the elected_primary
772
+ # == None path in the frontend).
773
+ members_info: list[dict] = []
774
+ any_available = False
775
+ any_credit_exhausted = False
776
+ last_probe_at_iso: Optional[str] = None
777
  for m in chain:
778
  h = state.get(m)
779
+ available = _candidate_available_for_calls(h, now_mono)
780
+ credit_exhausted = _candidate_credit_exhausted_strict(h, now_mono)
781
+ if available:
782
+ any_available = True
783
+ if credit_exhausted:
784
+ any_credit_exhausted = True
785
+ # Track the most-recent probe timestamp across the chain.
786
+ ts = getattr(h, "tested_at", None) if h is not None else None
787
+ if ts and (last_probe_at_iso is None or ts > last_probe_at_iso):
788
+ last_probe_at_iso = ts
789
+ members_info.append({
790
+ "model": m,
791
+ "available_for_calls": available,
792
+ "credit_exhausted": credit_exhausted,
793
+ "is_current_primary": bool(primary and m == primary),
794
+ "is_current_backup": bool(backup and m == backup),
795
+ })
796
+ # Banner fires only when: zero available members AND at least one
797
+ # member is strictly credit-exhausted. If everyone is just 'down'
798
+ # (probe failures) the banner is the wrong message — the operator
799
+ # needs the "no eligible candidate" state, which the frontend already
800
+ # renders via `elected_primary == None`.
801
+ chain_credit_exhausted = bool((not any_available) and any_credit_exhausted)
802
 
803
  return {
804
  "role": role,
805
  "chain": chain,
806
+ "chain_members": members_info,
807
  "elected_primary": primary,
808
  "elected_backup": backup,
809
+ # KI-122 — explicit "currently in use" name + a boolean the
810
+ # frontend uses to render the "● IN USE" pill without re-deriving.
811
+ "current_primary": primary,
812
+ "current_primary_available": _candidate_available_for_calls(
813
+ state.get(primary) if primary else None, now_mono,
814
+ ),
815
  "primary_snapshot": _candidate_snapshot(
816
  primary, state.get(primary), [role], now_mono,
817
  ) if primary else None,
 
819
  backup, state.get(backup), [role], now_mono,
820
  ) if backup else None,
821
  "chain_credit_exhausted": chain_credit_exhausted,
822
+ # KI-122 — operator-facing staleness signal. ISO string + a
823
+ # convenience seconds-ago value so the UI can render "Probed Ns ago".
824
+ "last_probe_at": last_probe_at_iso,
825
+ "last_probe_age_seconds": _probe_age_seconds(last_probe_at_iso),
826
  }
827
 
828
 
frontend/public/admin/llm-control.html CHANGED
@@ -406,6 +406,63 @@
406
  font-size: 12px;
407
  }
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  /* Status badges — three colors: ✓ green / ⚠ amber / ✗ red */
410
  .health-badge {
411
  display: inline-block;
@@ -1439,13 +1496,70 @@
1439
  var badge = createEl('span', { className: 'role-badge', text: (ROLE_LABELS[chainBlock.role] || chainBlock.role).toUpperCase() });
1440
  badge.style.background = ROLE_COLORS[chainBlock.role] || '#444';
1441
  head.appendChild(badge);
1442
- var title = createEl('span', { text: 'Election' });
1443
  title.style.fontWeight = '600';
1444
  title.style.fontSize = '13px';
1445
  head.appendChild(title);
 
 
 
 
 
 
 
 
 
 
1446
  card.appendChild(head);
1447
 
1448
- // Render PRIMARY + BACKUP rows.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1449
  ['primary', 'backup'].forEach(function (role) {
1450
  var snap = chainBlock[role + '_snapshot'];
1451
  var name = chainBlock['elected_' + role];
@@ -1453,8 +1567,8 @@
1453
  var label = createEl('div', { className: 'role-label', text: role.toUpperCase() });
1454
  block.appendChild(label);
1455
  if (!name || !snap) {
1456
- var none = createEl('div', { className: 'none-elected', text: 'none elected (no eligible candidate)' });
1457
- block.appendChild(none);
1458
  card.appendChild(block);
1459
  return;
1460
  }
@@ -1487,6 +1601,11 @@
1487
  card.appendChild(block);
1488
  });
1489
 
 
 
 
 
 
1490
  if (chainBlock.chain_credit_exhausted) {
1491
  var banner = createEl('div', { className: 'credit-banner' });
1492
  banner.textContent = 'All candidates for ' + (ROLE_LABELS[chainBlock.role] || chainBlock.role).toUpperCase() +
 
406
  font-size: 12px;
407
  }
408
 
409
+ /* KI-122 — "Currently in use" header + per-member availability pills */
410
+ .llm-health-chain .in-use-line {
411
+ display: flex;
412
+ align-items: center;
413
+ gap: 8px;
414
+ flex-wrap: wrap;
415
+ padding: 8px 10px;
416
+ margin-bottom: 12px;
417
+ background: rgba(63, 185, 80, 0.06);
418
+ border: 1px solid rgba(63, 185, 80, 0.25);
419
+ border-radius: 6px;
420
+ }
421
+ .llm-health-chain .in-use-line .role-label {
422
+ margin-bottom: 0;
423
+ color: var(--text);
424
+ }
425
+ .llm-health-chain .in-use-line .model-name {
426
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace;
427
+ font-size: 12px;
428
+ word-break: break-all;
429
+ }
430
+ .llm-health-chain .chain-members-list {
431
+ display: flex;
432
+ flex-direction: column;
433
+ gap: 4px;
434
+ margin: 6px 0 12px;
435
+ padding: 8px 10px;
436
+ background: #050709;
437
+ border: 1px solid var(--border);
438
+ border-radius: 6px;
439
+ }
440
+ .llm-health-chain .chain-member-row {
441
+ display: flex;
442
+ align-items: center;
443
+ gap: 8px;
444
+ font-size: 11px;
445
+ }
446
+ .llm-health-chain .chain-member-row .model-name {
447
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace;
448
+ word-break: break-all;
449
+ }
450
+ .avail-pill {
451
+ display: inline-block;
452
+ padding: 2px 8px;
453
+ border-radius: 999px;
454
+ font-size: 10px;
455
+ font-weight: 700;
456
+ letter-spacing: 0.04em;
457
+ border: 1px solid transparent;
458
+ white-space: nowrap;
459
+ }
460
+ .avail-pill.pill-inuse { background: rgba(63, 185, 80, 0.20); color: var(--green); border-color: rgba(63, 185, 80, 0.55); }
461
+ .avail-pill.pill-available { background: rgba(63, 185, 80, 0.10); color: var(--green); border-color: rgba(63, 185, 80, 0.35); }
462
+ .avail-pill.pill-gated { background: rgba(139, 149, 164, 0.15);color: var(--muted); border-color: rgba(139, 149, 164, 0.40); }
463
+ .avail-pill.pill-degraded { background: rgba(210, 153, 34, 0.15); color: var(--yellow); border-color: rgba(210, 153, 34, 0.45); }
464
+ .avail-pill.pill-down { background: rgba(248, 81, 73, 0.15); color: var(--red); border-color: rgba(248, 81, 73, 0.45); }
465
+
466
  /* Status badges — three colors: ✓ green / ⚠ amber / ✗ red */
467
  .health-badge {
468
  display: inline-block;
 
1496
  var badge = createEl('span', { className: 'role-badge', text: (ROLE_LABELS[chainBlock.role] || chainBlock.role).toUpperCase() });
1497
  badge.style.background = ROLE_COLORS[chainBlock.role] || '#444';
1498
  head.appendChild(badge);
1499
+ var title = createEl('span', { text: 'Chain status' });
1500
  title.style.fontWeight = '600';
1501
  title.style.fontSize = '13px';
1502
  head.appendChild(title);
1503
+ // KI-122 — staleness signal at the chain head so the operator can
1504
+ // see whether they're looking at fresh probe data or stale state.
1505
+ if (chainBlock.last_probe_age_seconds != null) {
1506
+ var stale = createEl('span', {
1507
+ className: 'small muted',
1508
+ text: '· probed ' + fmtDurationShort(chainBlock.last_probe_age_seconds) + ' ago',
1509
+ });
1510
+ stale.style.marginLeft = 'auto';
1511
+ head.appendChild(stale);
1512
+ }
1513
  card.appendChild(head);
1514
 
1515
+ // KI-122 top-of-card "Currently in use" line so the operator
1516
+ // doesn't have to infer the live model from the PRIMARY row.
1517
+ var inUse = createEl('div', { className: 'in-use-line' });
1518
+ var inUseLabel = createEl('span', { className: 'role-label', text: 'Currently in use:' });
1519
+ inUse.appendChild(inUseLabel);
1520
+ if (chainBlock.current_primary) {
1521
+ var pillCls = chainBlock.current_primary_available ? 'pill-inuse' : 'pill-degraded';
1522
+ var pillText = chainBlock.current_primary_available ? '● IN USE' : '● ELECTED BUT GATED';
1523
+ var pill = createEl('span', { className: 'avail-pill ' + pillCls, text: pillText });
1524
+ inUse.appendChild(pill);
1525
+ var nm = createEl('span', { className: 'model-name', text: chainBlock.current_primary });
1526
+ inUse.appendChild(nm);
1527
+ } else {
1528
+ var none = createEl('span', { className: 'avail-pill pill-down', text: '● NONE ELECTED' });
1529
+ inUse.appendChild(none);
1530
+ }
1531
+ card.appendChild(inUse);
1532
+
1533
+ // KI-122 — full chain member list with per-model availability pills,
1534
+ // so the operator can see at a glance every model that's wired up
1535
+ // and which ones are actually callable RIGHT NOW.
1536
+ var members = chainBlock.chain_members || [];
1537
+ if (members.length) {
1538
+ var listLabel = createEl('div', { className: 'role-label', text: 'Chain members (' + members.length + ')' });
1539
+ listLabel.style.marginTop = '10px';
1540
+ card.appendChild(listLabel);
1541
+ var list = createEl('div', { className: 'chain-members-list' });
1542
+ members.forEach(function (mi) {
1543
+ var row = createEl('div', { className: 'chain-member-row' });
1544
+ var p;
1545
+ if (mi.is_current_primary) {
1546
+ p = createEl('span', { className: 'avail-pill pill-inuse', text: '● IN USE' });
1547
+ } else if (mi.available_for_calls) {
1548
+ p = createEl('span', { className: 'avail-pill pill-available', text: '● AVAILABLE' });
1549
+ } else if (mi.credit_exhausted) {
1550
+ p = createEl('span', { className: 'avail-pill pill-gated', text: '● GATED' });
1551
+ } else {
1552
+ p = createEl('span', { className: 'avail-pill pill-down', text: '● UNAVAILABLE' });
1553
+ }
1554
+ row.appendChild(p);
1555
+ row.appendChild(createEl('span', { className: 'model-name', text: mi.model }));
1556
+ list.appendChild(row);
1557
+ });
1558
+ card.appendChild(list);
1559
+ }
1560
+
1561
+ // Render PRIMARY + BACKUP detail rows (kept for the per-model
1562
+ // latency/success/credit telemetry the operator drills into).
1563
  ['primary', 'backup'].forEach(function (role) {
1564
  var snap = chainBlock[role + '_snapshot'];
1565
  var name = chainBlock['elected_' + role];
 
1567
  var label = createEl('div', { className: 'role-label', text: role.toUpperCase() });
1568
  block.appendChild(label);
1569
  if (!name || !snap) {
1570
+ var noneEl = createEl('div', { className: 'none-elected', text: 'none elected (no eligible candidate)' });
1571
+ block.appendChild(noneEl);
1572
  card.appendChild(block);
1573
  return;
1574
  }
 
1601
  card.appendChild(block);
1602
  });
1603
 
1604
+ // KI-122 — banner is now backend-gated by the STRICT exhausted rule
1605
+ // (`chain_credit_exhausted` true only when zero members are
1606
+ // available_for_calls AND at least one is strictly credit-exhausted).
1607
+ // This prevents the historic "HEALTHY 100% + credit-exhausted banner"
1608
+ // contradiction the operator reported.
1609
  if (chainBlock.chain_credit_exhausted) {
1610
  var banner = createEl('div', { className: 'credit-banner' });
1611
  banner.textContent = 'All candidates for ' + (ROLE_LABELS[chainBlock.role] || chainBlock.role).toUpperCase() +
tests/test_credits_election.py CHANGED
@@ -245,5 +245,138 @@ class TestElectionCreditGate(unittest.TestCase):
245
  "Cold-start (None credits) must NOT gate out a healthy candidate.")
246
 
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  if __name__ == "__main__":
249
  unittest.main(verbosity=2)
 
245
  "Cold-start (None credits) must NOT gate out a healthy candidate.")
246
 
247
 
248
+ class TestStrictChainCreditExhausted(unittest.TestCase):
249
+ """KI-122 (2026-05-15) — strict banner rule.
250
+
251
+ Banner fires ONLY when zero chain members are available_for_calls AND at
252
+ least one has a credit-exhaustion signal that's strictly in-window
253
+ (credits_remaining<=low_water AND credits_reset_at is in the future).
254
+
255
+ Pre-KI-122 holes the new rule must close:
256
+ H1) one healthy NIM primary (credits=None) + one credit-low Groq backup
257
+ → banner USED to fire because `any_signal=True` on Groq + skip on
258
+ NIM's None signal. Must NOT fire.
259
+ H2) every chain member has reset_at=None (e.g. OpenRouter wallet) and
260
+ credits below water → banner USED to fire. Must NOT fire by itself
261
+ (probe success is the authoritative truth; wallet-zero with
262
+ successful probes means free-tier models).
263
+ H3) every member is genuinely gated (low + future reset) AND not
264
+ available → banner SHOULD fire.
265
+ """
266
+
267
+ def setUp(self) -> None:
268
+ _fresh_state()
269
+ # Import locally so test discovery doesn't load admin until needed.
270
+ from backend.admin import _chain_summary
271
+ self._chain_summary = _chain_summary
272
+
273
+ def _build(self, chain_models, model_states):
274
+ """Inject states + run _chain_summary against a mocked chain."""
275
+ for m, h in model_states.items():
276
+ llm_health._STATE[m] = h
277
+ now_mono = time.monotonic()
278
+ with mock.patch.object(
279
+ llm_health, "_chain_for", return_value=list(chain_models),
280
+ ):
281
+ return self._chain_summary(
282
+ "brain", {"brain": list(chain_models)}, llm_health._STATE, now_mono,
283
+ )
284
+
285
+ def test_healthy_nim_plus_exhausted_groq_does_not_fire_banner(self) -> None:
286
+ """H1 — NIM primary HEALTHY/None-credits + Groq backup credit-low.
287
+ Banner must STAY DOWN because NIM is available_for_calls."""
288
+ nim = "qwen/qwen3-next-80b-a3b-instruct"
289
+ groq = "groq:llama-3.3-70b-versatile"
290
+ nh = _healthy_now(nim)
291
+ nh.latency_ms = 300
292
+ # NIM has NO credit signal (None) — cold start permissive.
293
+ gh = _healthy_now(groq)
294
+ gh.latency_ms = 100
295
+ gh.credits_remaining = 100.0
296
+ gh.credits_unit = "tokens_day"
297
+ gh.credits_low_water = GROQ_TOKENS_LOW_WATER
298
+ gh.credits_reset_at = time.monotonic() + 3600.0 # future reset
299
+ gh.credits_observed_at = time.monotonic()
300
+ block = self._build([nim, groq], {nim: nh, groq: gh})
301
+ self.assertFalse(
302
+ block["chain_credit_exhausted"],
303
+ "Banner must NOT fire when a healthy cold-start NIM primary "
304
+ "shares a chain with one credit-exhausted Groq backup.",
305
+ )
306
+ # Sanity: NIM should be in_use, Groq should be gated.
307
+ names = {mi["model"]: mi for mi in block["chain_members"]}
308
+ self.assertTrue(names[nim]["available_for_calls"])
309
+ self.assertTrue(names[nim]["is_current_primary"])
310
+ self.assertFalse(names[groq]["available_for_calls"])
311
+ self.assertTrue(names[groq]["credit_exhausted"])
312
+
313
+ def test_credits_reset_at_none_does_not_fire_banner(self) -> None:
314
+ """H2 — every member has reset_at=None + credits below water (typical
315
+ OpenRouter usd_balance free-tier shape). Banner must NOT fire on the
316
+ reset_at=None signal alone; the strict rule requires an in-window reset."""
317
+ m1 = "openrouter:openai/gpt-oss-120b"
318
+ m2 = "openrouter:meta-llama/llama-3.3-70b-instruct:free"
319
+ h1 = _healthy_now(m1)
320
+ h1.latency_ms = 800
321
+ h1.credits_remaining = 0.0
322
+ h1.credits_unit = "usd_balance"
323
+ h1.credits_low_water = 0.05
324
+ h1.credits_reset_at = None # OpenRouter wallet: no scheduled reset
325
+ h1.credits_observed_at = time.monotonic()
326
+ h2 = _healthy_now(m2)
327
+ h2.latency_ms = 900
328
+ h2.credits_remaining = 0.0
329
+ h2.credits_unit = "usd_balance"
330
+ h2.credits_low_water = 0.05
331
+ h2.credits_reset_at = None
332
+ h2.credits_observed_at = time.monotonic()
333
+ block = self._build([m1, m2], {m1: h1, m2: h2})
334
+ self.assertFalse(
335
+ block["chain_credit_exhausted"],
336
+ "Banner must NOT fire when credits_reset_at is None on every "
337
+ "member — usd_balance=$0 with healthy probes is a free-tier "
338
+ "signal, not a real outage.",
339
+ )
340
+
341
+ def test_all_in_window_exhausted_fires_banner(self) -> None:
342
+ """H3 — every chain member has credits<=low_water AND a future reset
343
+ AND is consequently NOT available_for_calls. Banner SHOULD fire."""
344
+ m1 = "groq:llama-3.3-70b-versatile"
345
+ m2 = "groq:meta-llama/llama-4-scout-17b-16e-instruct"
346
+ future = time.monotonic() + 3600.0
347
+ h1 = _healthy_now(m1)
348
+ h1.latency_ms = 100
349
+ h1.credits_remaining = 100.0
350
+ h1.credits_unit = "tokens_day"
351
+ h1.credits_low_water = GROQ_TOKENS_LOW_WATER
352
+ h1.credits_reset_at = future
353
+ h1.credits_observed_at = time.monotonic()
354
+ h2 = _healthy_now(m2)
355
+ h2.latency_ms = 110
356
+ h2.credits_remaining = 50.0
357
+ h2.credits_unit = "tokens_day"
358
+ h2.credits_low_water = GROQ_TOKENS_LOW_WATER
359
+ h2.credits_reset_at = future
360
+ h2.credits_observed_at = time.monotonic()
361
+ block = self._build([m1, m2], {m1: h1, m2: h2})
362
+ self.assertTrue(
363
+ block["chain_credit_exhausted"],
364
+ "Banner SHOULD fire when every chain member is strictly "
365
+ "credit-exhausted with an in-window future reset.",
366
+ )
367
+
368
+ def test_chain_block_carries_current_primary_and_last_probe(self) -> None:
369
+ """KI-122 — verify the new wire fields are populated."""
370
+ nim = "qwen/qwen3-next-80b-a3b-instruct"
371
+ nh = _healthy_now(nim)
372
+ nh.latency_ms = 250
373
+ block = self._build([nim], {nim: nh})
374
+ self.assertEqual(block["current_primary"], nim)
375
+ self.assertTrue(block["current_primary_available"])
376
+ self.assertIsNotNone(block["last_probe_at"])
377
+ self.assertIsNotNone(block["last_probe_age_seconds"])
378
+ self.assertGreaterEqual(block["last_probe_age_seconds"], 0.0)
379
+
380
+
381
  if __name__ == "__main__":
382
  unittest.main(verbosity=2)