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

feat(llm-chain): KI-085 — proactive credit tracking; election gates by quota not just liveness

Browse files

WHY: KI-084 (commit 119e0fd) demotes a candidate for 1h AFTER the first
HTTP 429 — that's REACTIVE. Cost: one user-facing failure per dead quota
before the elector knows to skip it. KI-085 is PROACTIVE: every probe +
every real call updates our view of remaining quota from response headers
+ provider endpoints; election filters by `is_alive AND has_credits`
BEFORE suggesting a primary, so a quota-exhausted Groq is sidelined the
moment headers say `remaining_tokens_day < 5000`, not after a 429 hits.

SIGNAL SOURCES (priority order):
1) GROQ — response headers on every successful call.
Parse `x-ratelimit-remaining-tokens-day` (the one that bit us today;
free-tier daily TPD cap) + `x-ratelimit-reset-tokens-day` (duration
string `"1h2m"` / bare seconds / unix epoch — all three shapes
handled). Low-water 5000 tokens (≥ one fact-find ~2K-input + ~400-
output round-trip with margin).
2) OPENROUTER — dedicated GET /api/v1/credits endpoint, polled every
~10 min from the probe loop (piggybacked on a tick counter so we
don't need a second long-running task). Low-water 0.05 USD
(5¢ safety margin). Per-call response headers stamp a between-poll
`requests_min` signal as a finer-grained backup.
3) NIM — no clean header. Local 60s rate-meter per candidate (deque of
monotonic success timestamps, trimmed on read). 40 req/min free-tier
cap; gate at >= 35 in-window calls (low-water 5 remaining-slots).
Window auto-resets via `credits_reset_at` 60s after oldest call.

ELECTION GATE (`_is_election_eligible`):
Existing checks (status != down, fresh probe, not in degraded sin-bin)
PLUS new `_has_credits(h, now_mono)`:
- reset_at elapsed → permissive (signal stale; next call refreshes)
- credits_remaining is None → permissive (cold-start)
- else gate on credits_remaining > credits_low_water
Skipped candidates are logged via `logging.info("election: skipping
{model} — credits {x}/{unit} below water {y}")`.

WIRING:
- GroqLLM.chat() + OpenRouterLLM.chat() get new `chain_name=` kw +
plumb it from NimChainLLM._get_worker_for(self._chain_name).
- After resp.raise_for_status() on success, providers call
`llm_health.update_credits_from_*(chain, model, dict(resp.headers))`.
- NimChainLLM._try() success path bumps `record_nim_call()` for any
NIM-prefixed candidate (also covered in the post-reprobe path).
- background_probe_loop runs `poll_openrouter_credits()` once on
startup + every OPENROUTER_CREDITS_POLL_EVERY_N_TICKS (2) ticks.

PERSISTENCE: 5 new fields on ModelHealth (credits_remaining /
credits_unit / credits_reset_at / credits_observed_at / credits_low_water)
+ `_load_into_memory` defaults each one to tolerate pre-KI-085 records.
status_summary surfaces credits to the admin UI.

TESTING (tests/test_credits_election.py — 11 inline scenarios):
- Groq header parser: 546 tokens (gated) / 50000 tokens (eligible) /
missing (no-op) / malformed (no-op + log) / reset string variants
(1h2m / 45s / 30m / "120" / "" / None).
- NIM rate-meter: 30 calls (eligible, 10 remaining), 36 calls (gated,
4 remaining), 60s-elapse stale-reset (eligible again).
- Election integration: groq with credits=100 below water → elector
picks NIM despite slower latency; groq with credits=10000 → elector
picks groq; credits=None (cold start) → eligible.
- test_routing_regression suite (15 cases) — green.

EXPECTED IMPACT (vs KI-084-only baseline):
- Zero user-facing 429s from daily-quota exhaustion: elector knows to
skip the candidate the moment its headers drop below 5K tokens.
- Same elector responsiveness post-reset: reset_at elapse flips the
candidate back to "permissive — None credits" without waiting for a
fresh probe, so quota that resets at midnight UTC is available on
the very next election.
- No new traffic to providers: Groq + OpenRouter signals are scraped
from existing response headers; the OpenRouter credits poll adds
one cheap GET every 10 min.

backend/llm_health.py CHANGED
@@ -64,7 +64,9 @@ from __future__ import annotations
64
 
65
  import asyncio
66
  import json
 
67
  import os
 
68
  import threading
69
  import time
70
  from dataclasses import dataclass, field, asdict
@@ -73,6 +75,8 @@ from typing import Optional
73
 
74
  import httpx
75
 
 
 
76
  ROOT = Path(__file__).resolve().parent.parent
77
  HEALTH_FILE = ROOT / "40-data" / "llm_health.json"
78
  HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -106,6 +110,31 @@ DEGRADED_WINDOW_SEC = 30 # report_failure sidelines a model this long
106
  # bouncing back to the dead candidate on every chat turn.
107
  DEGRADE_DURATION_LONG_S = 3600.0
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  # Per-provider endpoints + env-var names. The chain entries embed the
110
  # provider via a prefix ('openrouter:<id>' / 'groq:<id>'); unprefixed entries
111
  # fall through to NIM. Keep these dicts in sync with the providers in
@@ -182,6 +211,17 @@ class ModelHealth:
182
  # KI-080 — set by report_failure(); model is excluded from election
183
  # while monotonic time < degraded_until_monotonic.
184
  degraded_until_monotonic: float = 0.0
 
 
 
 
 
 
 
 
 
 
 
185
 
186
 
187
  def _now_iso() -> str:
@@ -248,9 +288,15 @@ def _load_into_memory() -> None:
248
  raw = json.loads(HEALTH_FILE.read_text())
249
  for k, v in raw.get("models", {}).items():
250
  # Tolerate older schema (pre-KI-080 records missing
251
- # probe_history / degraded_until_monotonic).
 
252
  v.setdefault("probe_history", [])
253
  v.setdefault("degraded_until_monotonic", 0.0)
 
 
 
 
 
254
  _STATE[k] = ModelHealth(**v)
255
  except Exception:
256
  _STATE = {}
@@ -314,6 +360,8 @@ def _is_election_eligible(h: ModelHealth, now_mono: float) -> bool:
314
  - status is healthy (or degraded with a recent success)
315
  - last probe was within HEALTHY_PROBE_AGE_SEC
316
  - it is NOT currently in the degraded-window sin-bin
 
 
317
  """
318
  if h.degraded_until_monotonic > now_mono:
319
  return False
@@ -324,9 +372,34 @@ def _is_election_eligible(h: ModelHealth, now_mono: float) -> bool:
324
  return False
325
  if h.latency_ms is None:
326
  return False
 
 
 
 
 
 
327
  return True
328
 
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  def _ranked_candidates(chain_name: str) -> list[ModelHealth]:
331
  """Return the chain's election-eligible candidates, best-score first."""
332
  _load_into_memory()
@@ -491,6 +564,291 @@ async def _reprobe_one(model: str) -> None:
491
  pass
492
 
493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  # ---------------------------------------------------------------------------
495
  # Probing (mostly unchanged from pre-KI-080 — extended to record
496
  # probe_history + skip degraded-window models on the regular tick).
@@ -639,6 +997,10 @@ def status_summary() -> dict:
639
  "last_failure_at": h.last_failure_at,
640
  "last_error": h.last_error,
641
  "tested_at": h.tested_at,
 
 
 
 
642
  })
643
  summary["models"].sort(key=lambda x: (x["status"] != "healthy", x["model"]))
644
  if summary["models"]:
@@ -654,12 +1016,32 @@ def status_summary() -> dict:
654
 
655
  async def background_probe_loop() -> None:
656
  """Long-running task — probes every PROBE_INTERVAL_SEC (300s; KI-084).
657
- Started from main.py."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  while True:
659
  try:
660
  await probe_all()
661
  except Exception:
662
  pass # never let one bad probe kill the loop
 
 
 
 
 
 
663
  await asyncio.sleep(PROBE_INTERVAL_SEC)
664
 
665
 
 
64
 
65
  import asyncio
66
  import json
67
+ import logging
68
  import os
69
+ import re
70
  import threading
71
  import time
72
  from dataclasses import dataclass, field, asdict
 
75
 
76
  import httpx
77
 
78
+ logger = logging.getLogger(__name__)
79
+
80
  ROOT = Path(__file__).resolve().parent.parent
81
  HEALTH_FILE = ROOT / "40-data" / "llm_health.json"
82
  HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
 
110
  # bouncing back to the dead candidate on every chat turn.
111
  DEGRADE_DURATION_LONG_S = 3600.0
112
 
113
+ # KI-085 (2026-05-15) — proactive credit tracking. KI-084 demotes a candidate
114
+ # for 1h AFTER a 429 hits; that costs one user-facing failure per dead quota.
115
+ # KI-085 promotes llm_health to liveness+credits so election excludes
116
+ # quota-exhausted candidates BEFORE the user gets stuck behind a 429.
117
+ #
118
+ # Three signal sources:
119
+ # 1) GROQ — response headers (x-ratelimit-remaining-tokens-day etc.) on
120
+ # every successful Groq call. Low-water 5000 tokens (>= one fact-find
121
+ # ~2K-input + ~400-output round-trip with margin).
122
+ # 2) OPENROUTER — dedicated GET /api/v1/credits endpoint, polled every
123
+ # 10min from the probe loop. Low-water 0.05 USD (5¢ safety margin —
124
+ # OpenRouter free models charge $0 but the account-level signal still
125
+ # tells us if the user's prepaid credits are gone).
126
+ # 3) NIM — no clean header. Local rate-meter: count successful calls in
127
+ # the last 60s. Free tier is 40 req/min; gate at >=35 to stay clear.
128
+ GROQ_TOKENS_LOW_WATER = 5000.0 # tokens-per-day remaining
129
+ OPENROUTER_USD_LOW_WATER = 0.05 # USD balance remaining
130
+ NIM_REQ_PER_MIN_CAP = 40 # free-tier hard cap
131
+ NIM_REQ_PER_MIN_HEADROOM = 5 # gate at cap - headroom = 35
132
+ NIM_REQ_PER_MIN_LOW_WATER = 5.0 # below this remaining-in-window, gate
133
+
134
+ # OpenRouter credits poll cadence — every ~10 min, piggybacked on the
135
+ # probe loop tick counter. With PROBE_INTERVAL_SEC=300 that's every 2 ticks.
136
+ OPENROUTER_CREDITS_POLL_EVERY_N_TICKS = 2
137
+
138
  # Per-provider endpoints + env-var names. The chain entries embed the
139
  # provider via a prefix ('openrouter:<id>' / 'groq:<id>'); unprefixed entries
140
  # fall through to NIM. Keep these dicts in sync with the providers in
 
211
  # KI-080 — set by report_failure(); model is excluded from election
212
  # while monotonic time < degraded_until_monotonic.
213
  degraded_until_monotonic: float = 0.0
214
+ # KI-085 (2026-05-15) — proactive credit tracking. Stamped by
215
+ # update_credits_from_groq / update_credits_from_openrouter (response
216
+ # headers + account endpoint) and by the NIM local rate-meter. The
217
+ # elector gates on `credits_remaining is None OR > credits_low_water`
218
+ # so None (no signal yet) is permissive (cold-start = electable).
219
+ credits_remaining: Optional[float] = None # tokens / USD / req-slots
220
+ credits_unit: Optional[str] = None # "tokens_day" / "tokens_min" /
221
+ # "usd_balance" / "requests_min"
222
+ credits_reset_at: Optional[float] = None # monotonic time when quota resets
223
+ credits_observed_at: Optional[float] = None # monotonic time of snapshot
224
+ credits_low_water: float = 0.0 # below this, gated out
225
 
226
 
227
  def _now_iso() -> str:
 
288
  raw = json.loads(HEALTH_FILE.read_text())
289
  for k, v in raw.get("models", {}).items():
290
  # Tolerate older schema (pre-KI-080 records missing
291
+ # probe_history / degraded_until_monotonic; pre-KI-085
292
+ # records missing the five credits_* fields).
293
  v.setdefault("probe_history", [])
294
  v.setdefault("degraded_until_monotonic", 0.0)
295
+ v.setdefault("credits_remaining", None)
296
+ v.setdefault("credits_unit", None)
297
+ v.setdefault("credits_reset_at", None)
298
+ v.setdefault("credits_observed_at", None)
299
+ v.setdefault("credits_low_water", 0.0)
300
  _STATE[k] = ModelHealth(**v)
301
  except Exception:
302
  _STATE = {}
 
360
  - status is healthy (or degraded with a recent success)
361
  - last probe was within HEALTHY_PROBE_AGE_SEC
362
  - it is NOT currently in the degraded-window sin-bin
363
+ - KI-085 (2026-05-15): it has credits remaining above its low-water
364
+ mark, OR no credit signal yet (cold-start = permissive).
365
  """
366
  if h.degraded_until_monotonic > now_mono:
367
  return False
 
372
  return False
373
  if h.latency_ms is None:
374
  return False
375
+ if not _has_credits(h, now_mono):
376
+ logger.info(
377
+ "election: skipping %s — credits %s/%s below water %s",
378
+ h.model, h.credits_remaining, h.credits_unit, h.credits_low_water,
379
+ )
380
+ return False
381
  return True
382
 
383
 
384
+ def _has_credits(h: ModelHealth, now_mono: float) -> bool:
385
+ """KI-085 — credit-gate predicate for election eligibility.
386
+
387
+ Rules:
388
+ - If `credits_reset_at` has elapsed, treat the signal as stale and
389
+ permissive (next call will refresh). We don't auto-zero the
390
+ snapshot here so other readers (admin UI / status_summary) still
391
+ see the LAST observed value with its observed_at timestamp.
392
+ - If `credits_remaining is None` (no signal yet), return True —
393
+ cold-start must not penalize a fresh candidate.
394
+ - Otherwise gate on `credits_remaining > credits_low_water`.
395
+ """
396
+ if h.credits_reset_at is not None and now_mono >= h.credits_reset_at:
397
+ return True
398
+ if h.credits_remaining is None:
399
+ return True
400
+ return h.credits_remaining > h.credits_low_water
401
+
402
+
403
  def _ranked_candidates(chain_name: str) -> list[ModelHealth]:
404
  """Return the chain's election-eligible candidates, best-score first."""
405
  _load_into_memory()
 
564
  pass
565
 
566
 
567
+ # ---------------------------------------------------------------------------
568
+ # KI-085 — proactive credit tracking. Three signal sources:
569
+ # (1) Groq response headers (per-call, real-time)
570
+ # (2) OpenRouter dedicated /credits endpoint (10-min poll)
571
+ # (3) NIM local rate-meter (no clean header; count successes in last 60s)
572
+ # ---------------------------------------------------------------------------
573
+
574
+ # Match formats observed in the wild for Groq reset headers. Three shapes
575
+ # coexist on the Groq API:
576
+ # - duration string: "1h2m" / "30m" / "45s" / "1h2m30s"
577
+ # - bare seconds-from-now (float-ish): "60.5" / "3600"
578
+ # - epoch unix seconds (only when value is large enough): "1747326123"
579
+ _DURATION_RE = re.compile(r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$")
580
+
581
+
582
+ def _parse_reset_seconds(raw: Optional[str], now_mono: float) -> Optional[float]:
583
+ """Parse a Groq-style reset header into a monotonic deadline.
584
+
585
+ Returns the monotonic timestamp at which the quota resets (or None
586
+ when the value is missing/malformed). Accepts three shapes:
587
+ - "1h2m30s" / "30m" / "60s" → seconds offset from now
588
+ - "60.5" → seconds offset (bare numeric)
589
+ - "1747326123" → unix epoch (treated as absolute
590
+ wall-clock; converted to monotonic
591
+ relative to current time.time()).
592
+ """
593
+ if raw is None:
594
+ return None
595
+ s = raw.strip()
596
+ if not s:
597
+ return None
598
+ # Duration string ("1h2m" / "30m45s" / "45s")
599
+ m = _DURATION_RE.match(s)
600
+ if m and any(m.groups()):
601
+ h = int(m.group(1) or 0)
602
+ mn = int(m.group(2) or 0)
603
+ sec = float(m.group(3) or 0)
604
+ offset = h * 3600 + mn * 60 + sec
605
+ if offset > 0:
606
+ return now_mono + offset
607
+ return None
608
+ # Bare numeric — seconds-from-now or unix epoch
609
+ try:
610
+ v = float(s)
611
+ except ValueError:
612
+ return None
613
+ # Heuristic: > 1e9 means it's almost certainly a unix epoch (after 2001).
614
+ # Convert to seconds-from-now first, then to monotonic.
615
+ if v > 1e9:
616
+ offset = v - time.time()
617
+ if offset <= 0:
618
+ return now_mono # already reset
619
+ return now_mono + offset
620
+ if v <= 0:
621
+ return None
622
+ return now_mono + v
623
+
624
+
625
+ def update_credits_from_groq(chain_name: str, model: str, headers: dict) -> None:
626
+ """Stamp credits_remaining from a Groq response's x-ratelimit-* headers.
627
+
628
+ Called from GroqLLM.chat() after a successful HTTP response. The
629
+ `headers` dict is the response's headers (case-insensitive via httpx).
630
+ We prefer the DAILY tokens signal (`x-ratelimit-remaining-tokens-day`)
631
+ because Groq's free-tier daily TPD cap is what bit us in KI-084.
632
+
633
+ Missing header → no-op. Malformed value → log warning + no-op.
634
+ """
635
+ if not headers:
636
+ return
637
+ # httpx headers are case-insensitive; index defensively for plain dicts.
638
+ def _h(k: str) -> Optional[str]:
639
+ try:
640
+ v = headers.get(k)
641
+ except AttributeError:
642
+ return None
643
+ if v is not None:
644
+ return v
645
+ # Plain dict fallback — case-fold lookup.
646
+ for hk, hv in headers.items():
647
+ if hk.lower() == k.lower():
648
+ return hv
649
+ return None
650
+
651
+ remaining_raw = _h("x-ratelimit-remaining-tokens-day")
652
+ reset_raw = _h("x-ratelimit-reset-tokens-day")
653
+
654
+ if remaining_raw is None:
655
+ # No daily-tokens header — Groq sometimes only sends the minute
656
+ # window; that's not the signal we care about for KI-085 (KI-084's
657
+ # 1h sin-bin already covers minute-window blips).
658
+ return
659
+
660
+ try:
661
+ remaining = float(remaining_raw)
662
+ except (TypeError, ValueError):
663
+ logger.warning(
664
+ "update_credits_from_groq: malformed remaining value %r for %s",
665
+ remaining_raw, model,
666
+ )
667
+ return
668
+
669
+ now_mono = time.monotonic()
670
+ reset_at = _parse_reset_seconds(reset_raw, now_mono)
671
+
672
+ _load_into_memory()
673
+ with _STATE_LOCK:
674
+ h = _STATE.get(model) or ModelHealth(model=model)
675
+ h.credits_remaining = remaining
676
+ h.credits_unit = "tokens_day"
677
+ h.credits_reset_at = reset_at
678
+ h.credits_observed_at = now_mono
679
+ h.credits_low_water = GROQ_TOKENS_LOW_WATER
680
+ _STATE[model] = h
681
+
682
+
683
+ def update_credits_from_openrouter_headers(chain_name: str, model: str, headers: dict) -> None:
684
+ """OpenRouter sometimes surfaces per-call remaining credits on response
685
+ headers (`x-ratelimit-remaining` etc.). Lower fidelity than the
686
+ dedicated /credits endpoint but useful as a between-poll signal so the
687
+ elector reacts inside the 10-min poll window.
688
+
689
+ Header shape varies by model — we accept `x-ratelimit-remaining` (raw
690
+ count, no unit semantics) and treat it as request-slots so the gate
691
+ catches a near-empty bucket. Missing header → no-op.
692
+ """
693
+ if not headers:
694
+ return
695
+
696
+ def _h(k: str) -> Optional[str]:
697
+ try:
698
+ v = headers.get(k)
699
+ except AttributeError:
700
+ return None
701
+ if v is not None:
702
+ return v
703
+ for hk, hv in headers.items():
704
+ if hk.lower() == k.lower():
705
+ return hv
706
+ return None
707
+
708
+ remaining_raw = _h("x-ratelimit-remaining")
709
+ if remaining_raw is None:
710
+ return
711
+ try:
712
+ remaining = float(remaining_raw)
713
+ except (TypeError, ValueError):
714
+ logger.warning(
715
+ "update_credits_from_openrouter_headers: malformed remaining value %r for %s",
716
+ remaining_raw, model,
717
+ )
718
+ return
719
+
720
+ now_mono = time.monotonic()
721
+ _load_into_memory()
722
+ with _STATE_LOCK:
723
+ h = _STATE.get(model) or ModelHealth(model=model)
724
+ # Only stamp from headers if we DON'T already have a fresher
725
+ # account-level signal from the dedicated endpoint. usd_balance is
726
+ # the authoritative truth for OpenRouter; per-call requests_min is
727
+ # a between-poll approximation.
728
+ if h.credits_unit != "usd_balance":
729
+ h.credits_remaining = remaining
730
+ h.credits_unit = "requests_min"
731
+ h.credits_observed_at = now_mono
732
+ # Low-water: stay 5 slots above zero so a near-empty bucket
733
+ # gates out the candidate.
734
+ h.credits_low_water = float(NIM_REQ_PER_MIN_LOW_WATER)
735
+ _STATE[model] = h
736
+
737
+
738
+ # NIM local rate-meter (no clean header). Per-chain-entry deque of monotonic
739
+ # success timestamps; we trim to last 60s on each read.
740
+ _NIM_CALL_TIMES_LOCK = threading.Lock()
741
+ _NIM_CALL_TIMES: dict[str, list[float]] = {}
742
+
743
+
744
+ def record_nim_call(chain_name: str, model: str) -> None:
745
+ """Bump the local NIM rate-meter on a successful call. Also stamps
746
+ credits_remaining on the ModelHealth so the elector can gate.
747
+
748
+ NIM free tier = 40 req/min per API key. We gate at >= 35 in-window
749
+ calls (`NIM_REQ_PER_MIN_CAP - NIM_REQ_PER_MIN_HEADROOM`) so the
750
+ elector sidelines the candidate before we burn the cap.
751
+ """
752
+ if not model:
753
+ return
754
+ now_mono = time.monotonic()
755
+ cutoff = now_mono - 60.0
756
+ with _NIM_CALL_TIMES_LOCK:
757
+ times = _NIM_CALL_TIMES.get(model, [])
758
+ times = [t for t in times if t > cutoff]
759
+ times.append(now_mono)
760
+ _NIM_CALL_TIMES[model] = times
761
+ in_window = len(times)
762
+
763
+ remaining = max(0.0, float(NIM_REQ_PER_MIN_CAP - in_window))
764
+ # Window resets 60s after the OLDEST in-window call.
765
+ reset_at = (times[0] + 60.0) if times else (now_mono + 60.0)
766
+
767
+ _load_into_memory()
768
+ with _STATE_LOCK:
769
+ h = _STATE.get(model) or ModelHealth(model=model)
770
+ h.credits_remaining = remaining
771
+ h.credits_unit = "requests_min"
772
+ h.credits_reset_at = reset_at
773
+ h.credits_observed_at = now_mono
774
+ h.credits_low_water = float(NIM_REQ_PER_MIN_LOW_WATER)
775
+ _STATE[model] = h
776
+
777
+
778
+ async def poll_openrouter_credits() -> Optional[dict]:
779
+ """Hit GET https://openrouter.ai/api/v1/credits and stamp every
780
+ OpenRouter-prefixed candidate with the account-level USD balance.
781
+
782
+ Returns the parsed `{total_credits, total_usage}` dict on success, or
783
+ None on any failure (missing key / HTTP error / parse fail). Best-
784
+ effort: never raises. Called from background_probe_loop on a counter
785
+ every OPENROUTER_CREDITS_POLL_EVERY_N_TICKS ticks.
786
+ """
787
+ api_key = os.environ.get("OPENROUTER_API_KEY", "")
788
+ if not api_key:
789
+ return None
790
+ url = "https://openrouter.ai/api/v1/credits"
791
+ headers = {"Authorization": f"Bearer {api_key}"}
792
+ try:
793
+ async with httpx.AsyncClient(timeout=10.0) as client:
794
+ r = await client.get(url, headers=headers)
795
+ if r.status_code != 200:
796
+ logger.info(
797
+ "poll_openrouter_credits: HTTP %d — skipping update",
798
+ r.status_code,
799
+ )
800
+ return None
801
+ payload = r.json()
802
+ except Exception as e:
803
+ logger.info("poll_openrouter_credits: exception %s — skipping update", type(e).__name__)
804
+ return None
805
+
806
+ data = payload.get("data") or payload
807
+ try:
808
+ total_credits = float(data.get("total_credits", 0.0))
809
+ total_usage = float(data.get("total_usage", 0.0))
810
+ except (TypeError, ValueError):
811
+ logger.warning("poll_openrouter_credits: malformed payload %r", payload)
812
+ return None
813
+
814
+ remaining = max(0.0, total_credits - total_usage)
815
+ now_mono = time.monotonic()
816
+
817
+ _load_into_memory()
818
+ # Stamp every OpenRouter-prefixed candidate in every known chain.
819
+ with _STATE_LOCK:
820
+ for model in list(_STATE.keys()):
821
+ if not model.startswith("openrouter:"):
822
+ continue
823
+ h = _STATE[model]
824
+ h.credits_remaining = remaining
825
+ h.credits_unit = "usd_balance"
826
+ # OpenRouter credits don't auto-reset on a clock — they're a
827
+ # prepaid wallet. Use None to mean "no scheduled reset"; the
828
+ # elector treats None reset_at as a static gate (recheck on
829
+ # every election; refreshed by next poll).
830
+ h.credits_reset_at = None
831
+ h.credits_observed_at = now_mono
832
+ h.credits_low_water = OPENROUTER_USD_LOW_WATER
833
+ _STATE[model] = h
834
+ # Also seed entries that haven't been probed yet (chain entries
835
+ # discovered at import time but no probe completed).
836
+ for chain_model in _all_known_models():
837
+ if not chain_model.startswith("openrouter:"):
838
+ continue
839
+ if chain_model in _STATE:
840
+ continue
841
+ h = ModelHealth(model=chain_model)
842
+ h.credits_remaining = remaining
843
+ h.credits_unit = "usd_balance"
844
+ h.credits_observed_at = now_mono
845
+ h.credits_low_water = OPENROUTER_USD_LOW_WATER
846
+ _STATE[chain_model] = h
847
+
848
+ return {"total_credits": total_credits, "total_usage": total_usage,
849
+ "remaining": remaining}
850
+
851
+
852
  # ---------------------------------------------------------------------------
853
  # Probing (mostly unchanged from pre-KI-080 — extended to record
854
  # probe_history + skip degraded-window models on the regular tick).
 
997
  "last_failure_at": h.last_failure_at,
998
  "last_error": h.last_error,
999
  "tested_at": h.tested_at,
1000
+ # KI-085 — surface credits state for the admin UI.
1001
+ "credits_remaining": h.credits_remaining,
1002
+ "credits_unit": h.credits_unit,
1003
+ "credits_low_water": h.credits_low_water,
1004
  })
1005
  summary["models"].sort(key=lambda x: (x["status"] != "healthy", x["model"]))
1006
  if summary["models"]:
 
1016
 
1017
  async def background_probe_loop() -> None:
1018
  """Long-running task — probes every PROBE_INTERVAL_SEC (300s; KI-084).
1019
+ Started from main.py.
1020
+
1021
+ KI-085 (2026-05-15) — also polls OpenRouter's account-level credits
1022
+ endpoint every OPENROUTER_CREDITS_POLL_EVERY_N_TICKS ticks (10 min by
1023
+ default at the 300s probe cadence). Groq + NIM signals come from the
1024
+ chat hot path (response headers + local rate-meter respectively) so
1025
+ only OpenRouter needs an out-of-band poll.
1026
+ """
1027
+ tick = 0
1028
+ # Initial credits poll on startup so the elector has a non-None
1029
+ # account-level signal before the first chat call.
1030
+ try:
1031
+ await poll_openrouter_credits()
1032
+ except Exception:
1033
+ pass
1034
  while True:
1035
  try:
1036
  await probe_all()
1037
  except Exception:
1038
  pass # never let one bad probe kill the loop
1039
+ tick += 1
1040
+ if tick % OPENROUTER_CREDITS_POLL_EVERY_N_TICKS == 0:
1041
+ try:
1042
+ await poll_openrouter_credits()
1043
+ except Exception:
1044
+ pass
1045
  await asyncio.sleep(PROBE_INTERVAL_SEC)
1046
 
1047
 
backend/providers/groq_llm.py CHANGED
@@ -45,10 +45,14 @@ class GroqLLM(LLMProvider):
45
  model: str = DEFAULT_MODEL,
46
  api_key: Optional[str] = None,
47
  timeout: float = 120.0,
 
48
  ):
49
  self.api_key = api_key or getattr(settings, "GROQ_API_KEY", "")
50
  self.model = model
51
  self.timeout = timeout
 
 
 
52
  if not self.api_key:
53
  raise RuntimeError(
54
  "GROQ_API_KEY not set. Get a key at https://console.groq.com/keys "
@@ -100,6 +104,21 @@ class GroqLLM(LLMProvider):
100
  continue
101
  resp.raise_for_status()
102
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  payload = resp.json()
104
 
105
  choice = payload["choices"][0]
 
45
  model: str = DEFAULT_MODEL,
46
  api_key: Optional[str] = None,
47
  timeout: float = 120.0,
48
+ chain_name: str = "unknown",
49
  ):
50
  self.api_key = api_key or getattr(settings, "GROQ_API_KEY", "")
51
  self.model = model
52
  self.timeout = timeout
53
+ # KI-085 — chain_name plumbs through so update_credits_from_groq
54
+ # can route the response-header signal to the right chain state.
55
+ self.chain_name = chain_name
56
  if not self.api_key:
57
  raise RuntimeError(
58
  "GROQ_API_KEY not set. Get a key at https://console.groq.com/keys "
 
104
  continue
105
  resp.raise_for_status()
106
  break
107
+ # KI-085 (2026-05-15) — stamp credits_remaining from Groq's
108
+ # x-ratelimit-* headers BEFORE we drop the response. Groq is
109
+ # the highest-fidelity provider on this front: every successful
110
+ # call returns the daily-tokens remaining, so we get a
111
+ # continuously-updated election signal at zero extra cost.
112
+ try:
113
+ from backend import llm_health
114
+ chain_for_credits = (
115
+ f"groq:{self.model}" # chain entries are prefixed
116
+ )
117
+ llm_health.update_credits_from_groq(
118
+ self.chain_name, chain_for_credits, dict(resp.headers)
119
+ )
120
+ except Exception:
121
+ pass # credit tracking must never break the chat path
122
  payload = resp.json()
123
 
124
  choice = payload["choices"][0]
backend/providers/nvidia_nim_llm.py CHANGED
@@ -384,16 +384,22 @@ class NimChainLLM(LLMProvider):
384
  - 'openrouter:<model>' -> OpenRouterLLM
385
  - 'groq:<model>' -> GroqLLM
386
  - <anything else> -> NvidiaNimLLM (existing default)
 
 
 
 
387
  """
388
  if model_id.startswith("openrouter:"):
389
  return OpenRouterLLM(
390
  model=model_id[len("openrouter:"):],
391
  timeout=timeout,
 
392
  )
393
  if model_id.startswith("groq:"):
394
  return GroqLLM(
395
  model=model_id[len("groq:"):],
396
  timeout=timeout,
 
397
  )
398
  return NvidiaNimLLM(model=model_id, api_key=self.api_key, timeout=timeout)
399
 
@@ -578,6 +584,16 @@ class NimChainLLM(LLMProvider):
578
  llm_health.report_success(self._chain_name, model, latency_ms)
579
  except Exception:
580
  pass
 
 
 
 
 
 
 
 
 
 
581
  self.model = model
582
  self.name = f"nim-chain::{self._short_id(model)}"
583
  total_ms = int((time.time() - call_t0) * 1000)
@@ -650,6 +666,12 @@ class NimChainLLM(LLMProvider):
650
  )
651
  except Exception:
652
  pass
 
 
 
 
 
 
653
  self.model = model
654
  self.name = f"nim-chain::{self._short_id(model)}"
655
  total_ms = int((time.time() - call_t0) * 1000)
 
384
  - 'openrouter:<model>' -> OpenRouterLLM
385
  - 'groq:<model>' -> GroqLLM
386
  - <anything else> -> NvidiaNimLLM (existing default)
387
+
388
+ KI-085 — passes `chain_name=self._chain_name` so the credit
389
+ trackers in the provider clients route their response-header
390
+ signals to the right chain state.
391
  """
392
  if model_id.startswith("openrouter:"):
393
  return OpenRouterLLM(
394
  model=model_id[len("openrouter:"):],
395
  timeout=timeout,
396
+ chain_name=self._chain_name,
397
  )
398
  if model_id.startswith("groq:"):
399
  return GroqLLM(
400
  model=model_id[len("groq:"):],
401
  timeout=timeout,
402
+ chain_name=self._chain_name,
403
  )
404
  return NvidiaNimLLM(model=model_id, api_key=self.api_key, timeout=timeout)
405
 
 
584
  llm_health.report_success(self._chain_name, model, latency_ms)
585
  except Exception:
586
  pass
587
+ # KI-085 (2026-05-15) — NIM has no clean rate-limit header, so
588
+ # we maintain a local 60s rate-meter for every NIM-prefixed
589
+ # candidate (i.e. anything without an openrouter:/groq: prefix).
590
+ # Groq + OpenRouter stamp credits from response headers inside
591
+ # their own .chat() methods.
592
+ if not model.startswith(("openrouter:", "groq:")):
593
+ try:
594
+ llm_health.record_nim_call(self._chain_name, model)
595
+ except Exception:
596
+ pass
597
  self.model = model
598
  self.name = f"nim-chain::{self._short_id(model)}"
599
  total_ms = int((time.time() - call_t0) * 1000)
 
666
  )
667
  except Exception:
668
  pass
669
+ # KI-085 — bump NIM rate-meter on post-reprobe successes too.
670
+ if not model.startswith(("openrouter:", "groq:")):
671
+ try:
672
+ llm_health.record_nim_call(self._chain_name, model)
673
+ except Exception:
674
+ pass
675
  self.model = model
676
  self.name = f"nim-chain::{self._short_id(model)}"
677
  total_ms = int((time.time() - call_t0) * 1000)
backend/providers/openrouter_llm.py CHANGED
@@ -48,10 +48,13 @@ class OpenRouterLLM(LLMProvider):
48
  model: str = DEFAULT_MODEL,
49
  api_key: Optional[str] = None,
50
  timeout: float = 120.0,
 
51
  ):
52
  self.api_key = api_key or getattr(settings, "OPENROUTER_API_KEY", "")
53
  self.model = model
54
  self.timeout = timeout
 
 
55
  if not self.api_key:
56
  raise RuntimeError(
57
  "OPENROUTER_API_KEY not set. Get a key at https://openrouter.ai/keys "
@@ -109,6 +112,19 @@ class OpenRouterLLM(LLMProvider):
109
  continue
110
  resp.raise_for_status()
111
  break
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  payload = resp.json()
113
 
114
  choice = payload["choices"][0]
 
48
  model: str = DEFAULT_MODEL,
49
  api_key: Optional[str] = None,
50
  timeout: float = 120.0,
51
+ chain_name: str = "unknown",
52
  ):
53
  self.api_key = api_key or getattr(settings, "OPENROUTER_API_KEY", "")
54
  self.model = model
55
  self.timeout = timeout
56
+ # KI-085 — chain_name plumbs through to update_credits_*.
57
+ self.chain_name = chain_name
58
  if not self.api_key:
59
  raise RuntimeError(
60
  "OPENROUTER_API_KEY not set. Get a key at https://openrouter.ai/keys "
 
112
  continue
113
  resp.raise_for_status()
114
  break
115
+ # KI-085 (2026-05-15) — opportunistic between-poll signal from
116
+ # OpenRouter response headers. The authoritative truth (usd_balance)
117
+ # comes from poll_openrouter_credits() every 10 min; this is a
118
+ # finer-grained per-call backup that catches per-minute bucket
119
+ # exhaustion before the next poll tick.
120
+ try:
121
+ from backend import llm_health
122
+ chain_for_credits = f"openrouter:{self.model}"
123
+ llm_health.update_credits_from_openrouter_headers(
124
+ self.chain_name, chain_for_credits, dict(resp.headers)
125
+ )
126
+ except Exception:
127
+ pass
128
  payload = resp.json()
129
 
130
  choice = payload["choices"][0]
tests/test_credits_election.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """KI-085 (2026-05-15) — proactive credit tracking + election gate.
2
+
3
+ Tests:
4
+ 1. update_credits_from_groq parser variants (valid / threshold / missing / malformed)
5
+ 2. NIM local rate-meter increments + 60s reset
6
+ 3. _is_election_eligible gates by credits_remaining vs credits_low_water
7
+ 4. elect_primary_and_backup integration: credit-exhausted candidates skipped
8
+
9
+ Run:
10
+ .venv/bin/python -m unittest tests.test_credits_election -v
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ import unittest
17
+ from unittest import mock
18
+
19
+ from backend import llm_health
20
+ from backend.llm_health import (
21
+ GROQ_TOKENS_LOW_WATER,
22
+ ModelHealth,
23
+ NIM_REQ_PER_MIN_LOW_WATER,
24
+ _has_credits,
25
+ _is_election_eligible,
26
+ record_nim_call,
27
+ update_credits_from_groq,
28
+ )
29
+
30
+
31
+ def _fresh_state():
32
+ """Wipe in-memory state + mark loaded so tests don't trigger disk reads."""
33
+ llm_health._STATE.clear()
34
+ llm_health._STATE_LOADED = True
35
+ llm_health._NIM_CALL_TIMES.clear()
36
+
37
+
38
+ def _healthy_now(model: str) -> ModelHealth:
39
+ """Build a ModelHealth that passes every check EXCEPT credits — so
40
+ flipping credits is the only failure surface in these tests."""
41
+ h = ModelHealth(model=model)
42
+ h.status = "healthy"
43
+ h.latency_ms = 200
44
+ h.tested_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
45
+ return h
46
+
47
+
48
+ class TestGroqHeaderParser(unittest.TestCase):
49
+ def setUp(self) -> None:
50
+ _fresh_state()
51
+
52
+ def test_546_tokens_remaining_gates_out(self) -> None:
53
+ """546 < 5000 low water → credit-gate fails."""
54
+ model = "groq:llama-3.3-70b-versatile"
55
+ update_credits_from_groq("brain", model, {
56
+ "x-ratelimit-remaining-tokens-day": "546",
57
+ "x-ratelimit-reset-tokens-day": "1h2m",
58
+ })
59
+ h = llm_health._STATE[model]
60
+ self.assertEqual(h.credits_remaining, 546.0)
61
+ self.assertEqual(h.credits_unit, "tokens_day")
62
+ self.assertEqual(h.credits_low_water, GROQ_TOKENS_LOW_WATER)
63
+ self.assertFalse(_has_credits(h, time.monotonic()))
64
+
65
+ def test_50000_tokens_remaining_is_eligible(self) -> None:
66
+ """50K >> 5K low water → credit-gate passes."""
67
+ model = "groq:llama-3.3-70b-versatile"
68
+ update_credits_from_groq("brain", model, {
69
+ "x-ratelimit-remaining-tokens-day": "50000",
70
+ "x-ratelimit-reset-tokens-day": "5h",
71
+ })
72
+ h = llm_health._STATE[model]
73
+ self.assertEqual(h.credits_remaining, 50000.0)
74
+ self.assertTrue(_has_credits(h, time.monotonic()))
75
+
76
+ def test_missing_header_is_noop(self) -> None:
77
+ """No daily-tokens header → state untouched (credits_remaining stays None)."""
78
+ model = "groq:llama-3.3-70b-versatile"
79
+ update_credits_from_groq("brain", model, {})
80
+ self.assertNotIn(model, llm_health._STATE)
81
+
82
+ def test_malformed_value_is_noop(self) -> None:
83
+ """Garbage in the header → no-op + warning logged (not raised)."""
84
+ model = "groq:llama-3.3-70b-versatile"
85
+ update_credits_from_groq("brain", model, {
86
+ "x-ratelimit-remaining-tokens-day": "not-a-number",
87
+ })
88
+ # State remains absent because update bailed before stamping.
89
+ self.assertNotIn(model, llm_health._STATE)
90
+
91
+ def test_reset_string_parses_duration(self) -> None:
92
+ """`1h2m` → ~3720s offset; `45s` → ~45s offset; `30m` → ~1800s."""
93
+ now_mono = time.monotonic()
94
+ from backend.llm_health import _parse_reset_seconds
95
+ v = _parse_reset_seconds("1h2m", now_mono)
96
+ self.assertIsNotNone(v)
97
+ self.assertAlmostEqual(v - now_mono, 3720, delta=2)
98
+ v = _parse_reset_seconds("45s", now_mono)
99
+ self.assertAlmostEqual(v - now_mono, 45, delta=2)
100
+ v = _parse_reset_seconds("30m", now_mono)
101
+ self.assertAlmostEqual(v - now_mono, 1800, delta=2)
102
+ # Bare seconds-from-now
103
+ v = _parse_reset_seconds("120", now_mono)
104
+ self.assertAlmostEqual(v - now_mono, 120, delta=2)
105
+ # Empty / None
106
+ self.assertIsNone(_parse_reset_seconds("", now_mono))
107
+ self.assertIsNone(_parse_reset_seconds(None, now_mono))
108
+
109
+
110
+ class TestNimRateMeter(unittest.TestCase):
111
+ def setUp(self) -> None:
112
+ _fresh_state()
113
+
114
+ def test_30_calls_in_60s_eligible(self) -> None:
115
+ """30 successful calls → 40-30=10 remaining, > 5 low water → eligible."""
116
+ model = "qwen/qwen3-next-80b-a3b-instruct"
117
+ for _ in range(30):
118
+ record_nim_call("brain", model)
119
+ h = llm_health._STATE[model]
120
+ self.assertEqual(h.credits_remaining, 10.0)
121
+ self.assertEqual(h.credits_unit, "requests_min")
122
+ self.assertTrue(_has_credits(h, time.monotonic()))
123
+
124
+ def test_36_calls_in_60s_gated_out(self) -> None:
125
+ """36 successful calls → 40-36=4 remaining, < 5 low water → gated."""
126
+ model = "qwen/qwen3-next-80b-a3b-instruct"
127
+ for _ in range(36):
128
+ record_nim_call("brain", model)
129
+ h = llm_health._STATE[model]
130
+ self.assertEqual(h.credits_remaining, 4.0)
131
+ self.assertFalse(_has_credits(h, time.monotonic()))
132
+
133
+ def test_60s_window_resets(self) -> None:
134
+ """After 60s elapses, _has_credits flips back to True via stale-reset."""
135
+ model = "qwen/qwen3-next-80b-a3b-instruct"
136
+ for _ in range(36):
137
+ record_nim_call("brain", model)
138
+ h = llm_health._STATE[model]
139
+ self.assertFalse(_has_credits(h, time.monotonic()))
140
+ # Simulate 65 seconds passing: credits_reset_at is now in the past.
141
+ future_now = (h.credits_reset_at or time.monotonic()) + 5.0
142
+ self.assertTrue(_has_credits(h, future_now))
143
+
144
+
145
+ class TestElectionCreditGate(unittest.TestCase):
146
+ """Integration: elect_primary skips quota-exhausted candidates."""
147
+
148
+ def setUp(self) -> None:
149
+ _fresh_state()
150
+
151
+ def test_groq_below_water_skipped_in_election(self) -> None:
152
+ """Groq has fast probe latency but is below daily-tokens water →
153
+ elector falls through to next candidate."""
154
+ groq_model = "groq:llama-3.3-70b-versatile"
155
+ nim_model = "qwen/qwen3-next-80b-a3b-instruct"
156
+ # Both healthy + fresh probe + low latency. Groq is faster (100ms).
157
+ gh = _healthy_now(groq_model)
158
+ gh.latency_ms = 100
159
+ gh.credits_remaining = 100.0 # < 5000 low_water
160
+ gh.credits_unit = "tokens_day"
161
+ gh.credits_low_water = GROQ_TOKENS_LOW_WATER
162
+ gh.credits_observed_at = time.monotonic()
163
+ llm_health._STATE[groq_model] = gh
164
+
165
+ nh = _healthy_now(nim_model)
166
+ nh.latency_ms = 300
167
+ llm_health._STATE[nim_model] = nh
168
+
169
+ with mock.patch.object(
170
+ llm_health, "_chain_for", return_value=[groq_model, nim_model]
171
+ ):
172
+ primary = llm_health.get_primary("brain")
173
+ self.assertEqual(primary, nim_model,
174
+ "Quota-exhausted Groq should be skipped despite faster latency.")
175
+
176
+ def test_groq_above_water_picked_in_election(self) -> None:
177
+ """Groq has plenty of credits → elector picks it (fastest healthy)."""
178
+ groq_model = "groq:llama-3.3-70b-versatile"
179
+ nim_model = "qwen/qwen3-next-80b-a3b-instruct"
180
+ gh = _healthy_now(groq_model)
181
+ gh.latency_ms = 100
182
+ gh.credits_remaining = 10000.0 # >> 5000 low_water
183
+ gh.credits_unit = "tokens_day"
184
+ gh.credits_low_water = GROQ_TOKENS_LOW_WATER
185
+ gh.credits_observed_at = time.monotonic()
186
+ llm_health._STATE[groq_model] = gh
187
+
188
+ nh = _healthy_now(nim_model)
189
+ nh.latency_ms = 300
190
+ llm_health._STATE[nim_model] = nh
191
+
192
+ with mock.patch.object(
193
+ llm_health, "_chain_for", return_value=[groq_model, nim_model]
194
+ ):
195
+ primary = llm_health.get_primary("brain")
196
+ self.assertEqual(primary, groq_model,
197
+ "Healthy + credit-rich Groq should win election.")
198
+
199
+ def test_none_credits_is_permissive(self) -> None:
200
+ """Cold-start: a candidate with credits_remaining=None must be electable."""
201
+ model = "qwen/qwen3-next-80b-a3b-instruct"
202
+ h = _healthy_now(model)
203
+ # credits_remaining is None by default — leave it alone.
204
+ llm_health._STATE[model] = h
205
+ self.assertTrue(_is_election_eligible(h, time.monotonic()),
206
+ "Cold-start (None credits) must NOT gate out a healthy candidate.")
207
+
208
+
209
+ if __name__ == "__main__":
210
+ unittest.main(verbosity=2)