rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
0e3c560
·
1 Parent(s): d37a6cf

fix(latency): KI-099 — bound every per-turn LLM call

Browse files

KI-098 audit surfaced 5 holes where a per-turn LLM call could hang the
user-facing chat for 60-120s. This commit installs explicit budgets:

backend/orchestrator.py:
- extract_profile_updates → asyncio.wait_for(12s) + TimeoutError handler
- main brain pick.provider.chat → asyncio.wait_for(30s)
- faithfulness cross-check retry → asyncio.wait_for(20s) + skip-retry
on timeout (rare-path fallback; safer to skip than hang)

backend/providers/sarvam_llm.py:
- Monolithic timeout=60.0 → httpx.Timeout(connect=2, read=20, write=2,
pool=2) mirroring KI-084 on NIM. Constructor kept backward compatible.

backend/translator.py:
- asyncio.wait_for(20s) around both Sarvam .chat() calls. On timeout,
passthrough original text (callers already tolerate via KI-004).
Third callsite (back-translate) routes through translate_to_english
so it inherits the wrap.

backend/providers/nvidia_nim_llm.py:
- Budget guard before the synchronous probe_all() refresh inside chat().
If we've already burned 40% of total_budget_s, skip the probe and
raise immediately. This was the MAIN amplifier — a saturated chain
walking 10 candidates at 8s each = 60-80s wall time on the hot turn.
Next turn benefits from the background probe loop's 300s cadence.

Tests: 31/31 pass (routing_regression + credits_election + name_persistence).

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

backend/orchestrator.py CHANGED
@@ -12,6 +12,8 @@ For each user turn:
12
 
13
  from __future__ import annotations
14
 
 
 
15
  import re
16
  import time
17
  from dataclasses import dataclass, field
@@ -546,7 +548,20 @@ async def handle_turn(
546
  extracted = None # eval mode OR fact-find turn — bypass LLM extractor
547
  else:
548
  from backend.profile_extractor import extract_profile_updates
549
- extracted = await extract_profile_updates(user_text, session.profile)
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  if extracted:
551
  for field_name, new_value in extracted.items():
552
  # KI-094 — never let the LLM extractor CLEAR a filled field.
@@ -596,6 +611,13 @@ async def handle_turn(
596
  "profile-chunk upsert failed (session=%s): %s: %s",
597
  session_id, type(e).__name__, str(e)[:200],
598
  )
 
 
 
 
 
 
 
599
  except Exception as e:
600
  # KI-006 — log profile-extraction failures (extractor LLM down,
601
  # malformed model output, etc.). The chat ships unaffected.
@@ -636,7 +658,15 @@ async def handle_turn(
636
  # responses with low voice latency. The judge model (Meta Llama-4 Maverick)
637
  # in faithfulness.py is from a different company, architecture, and
638
  # training corpus — the brain does not mark its own homework.
639
- llm_result = await pick.provider.chat(messages=messages, temperature=0.2, max_tokens=1500)
 
 
 
 
 
 
 
 
640
 
641
  raw = llm_result.text
642
  reply = strip_think_tags(raw)
@@ -683,21 +713,37 @@ async def handle_turn(
683
  if not gate1_failure:
684
  try:
685
  secondary = NvidiaNimLLM(model=NIM_JUDGE_MODEL)
686
- second = await secondary.chat(messages=messages, temperature=0.1, max_tokens=1500)
687
- second_reply = strip_think_tags(second.text)
688
- # Cross-check brain was NIM_JUDGE_MODEL pass its id so the
689
- # judge for THIS retry also excludes that model+family.
690
- second_verdict = await check_faithfulness(
691
- reply=second_reply, chunks=chunks, user_text=user_text, run_llm_judge=True,
692
- brain_model_used=getattr(second, "model", None) or NIM_JUDGE_MODEL,
693
- )
694
- if second_verdict.passed:
695
- reply = second_reply
696
- pick = BrainPick(secondary, f"crosscheck-rescued-by-maverick")
697
- verdict = second_verdict
698
- else:
699
  blocked = True
700
  reply = verdict.suggested_reply or "I don't have grounded evidence for that. Could you rephrase?"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
  except Exception:
702
  blocked = True
703
  reply = verdict.suggested_reply or "I don't have grounded evidence for that. Could you rephrase?"
 
12
 
13
  from __future__ import annotations
14
 
15
+ import asyncio
16
+ import logging
17
  import re
18
  import time
19
  from dataclasses import dataclass, field
 
548
  extracted = None # eval mode OR fact-find turn — bypass LLM extractor
549
  else:
550
  from backend.profile_extractor import extract_profile_updates
551
+ # KI-098 outer budget cap. extract_profile_updates calls a
552
+ # NIM LLM which can hang up to 120s on its default timeout when
553
+ # the upstream chain is in synchronous probe_all() refresh. Skip
554
+ # the merge on timeout rather than stall the user-facing turn.
555
+ try:
556
+ extracted = await asyncio.wait_for(
557
+ extract_profile_updates(user_text, session.profile),
558
+ timeout=12.0,
559
+ )
560
+ except asyncio.TimeoutError:
561
+ extracted = None
562
+ logging.warning(
563
+ "extractor timeout, skipping merge (session=%s)", session_id,
564
+ )
565
  if extracted:
566
  for field_name, new_value in extracted.items():
567
  # KI-094 — never let the LLM extractor CLEAR a filled field.
 
611
  "profile-chunk upsert failed (session=%s): %s: %s",
612
  session_id, type(e).__name__, str(e)[:200],
613
  )
614
+ except asyncio.TimeoutError:
615
+ # KI-098 — explicit timeout path (in case any awaited call inside the
616
+ # try-block other than extract_profile_updates raises TimeoutError).
617
+ extracted = None
618
+ logging.warning(
619
+ "extractor timeout, skipping merge (session=%s)", session_id,
620
+ )
621
  except Exception as e:
622
  # KI-006 — log profile-extraction failures (extractor LLM down,
623
  # malformed model output, etc.). The chat ships unaffected.
 
658
  # responses with low voice latency. The judge model (Meta Llama-4 Maverick)
659
  # in faithfulness.py is from a different company, architecture, and
660
  # training corpus — the brain does not mark its own homework.
661
+ # KI-098 outer budget cap on the main brain call. Provider default
662
+ # timeouts (120s) can hang the user-facing turn when the upstream chain
663
+ # is in synchronous probe_all() refresh. 30s is generous enough for
664
+ # normal MoE responses but short enough to fall through to the chain's
665
+ # error path before the user gives up.
666
+ llm_result = await asyncio.wait_for(
667
+ pick.provider.chat(messages=messages, temperature=0.2, max_tokens=1500),
668
+ timeout=30.0,
669
+ )
670
 
671
  raw = llm_result.text
672
  reply = strip_think_tags(raw)
 
713
  if not gate1_failure:
714
  try:
715
  secondary = NvidiaNimLLM(model=NIM_JUDGE_MODEL)
716
+ # KI-098 outer budget cap on the cross-check retry. This is
717
+ # a RARE fallback for hallucination-blocked replies; skipping
718
+ # it on timeout is strictly safer than hanging the turn 60-120s
719
+ # while the upstream chain refreshes via probe_all().
720
+ try:
721
+ second = await asyncio.wait_for(
722
+ secondary.chat(messages=messages, temperature=0.1, max_tokens=1500),
723
+ timeout=20.0,
724
+ )
725
+ except asyncio.TimeoutError:
726
+ logging.warning(
727
+ "crosscheck retry timeout, skipping retry (session=%s)", session_id,
728
+ )
729
  blocked = True
730
  reply = verdict.suggested_reply or "I don't have grounded evidence for that. Could you rephrase?"
731
+ second = None
732
+ if second is not None:
733
+ second_reply = strip_think_tags(second.text)
734
+ # Cross-check brain was NIM_JUDGE_MODEL — pass its id so the
735
+ # judge for THIS retry also excludes that model+family.
736
+ second_verdict = await check_faithfulness(
737
+ reply=second_reply, chunks=chunks, user_text=user_text, run_llm_judge=True,
738
+ brain_model_used=getattr(second, "model", None) or NIM_JUDGE_MODEL,
739
+ )
740
+ if second_verdict.passed:
741
+ reply = second_reply
742
+ pick = BrainPick(secondary, f"crosscheck-rescued-by-maverick")
743
+ verdict = second_verdict
744
+ else:
745
+ blocked = True
746
+ reply = verdict.suggested_reply or "I don't have grounded evidence for that. Could you rephrase?"
747
  except Exception:
748
  blocked = True
749
  reply = verdict.suggested_reply or "I don't have grounded evidence for that. Could you rephrase?"
backend/providers/nvidia_nim_llm.py CHANGED
@@ -662,6 +662,18 @@ class NimChainLLM(LLMProvider):
662
  # touched this turn. This is the pre-KI-080 graceful-degradation
663
  # behaviour preserved for the (rare) double-failure case.
664
  try:
 
 
 
 
 
 
 
 
 
 
 
 
665
  await llm_health.probe_all()
666
  refreshed = llm_health.filter_chain(allowed_chain)
667
  for model in refreshed:
 
662
  # touched this turn. This is the pre-KI-080 graceful-degradation
663
  # behaviour preserved for the (rare) double-failure case.
664
  try:
665
+ # KI-099 — bound the probe-refresh cost. On a hot user-facing turn, a
666
+ # 60-80s probe walk through ~10 candidates is unacceptable. If we've
667
+ # already spent half the call's total_budget_s before reaching here,
668
+ # skip the probe refresh and raise immediately — the next turn will
669
+ # benefit from the existing probe cache or the background probe loop
670
+ # (300s cadence) will refresh it.
671
+ elapsed = time.time() - call_t0
672
+ if elapsed > self.total_budget_s * 0.4:
673
+ raise RuntimeError(
674
+ f"NimChainLLM budget exhausted before probe-refresh ({elapsed:.1f}s > {self.total_budget_s * 0.4:.1f}s); "
675
+ f"skipping probe_all + raising"
676
+ )
677
  await llm_health.probe_all()
678
  refreshed = llm_health.filter_chain(allowed_chain)
679
  for model in refreshed:
backend/providers/sarvam_llm.py CHANGED
@@ -10,7 +10,7 @@ Auth: header `api-subscription-key: <SARVAM_API_KEY>` (Sarvam) or
10
 
11
  from __future__ import annotations
12
 
13
- from typing import Optional
14
 
15
  import httpx
16
 
@@ -18,6 +18,15 @@ from backend.config import settings
18
  from backend.providers.base import ChatMessage, LLMProvider, LLMResult
19
 
20
 
 
 
 
 
 
 
 
 
 
21
  class SarvamLLM(LLMProvider):
22
  name = "sarvam-m"
23
  model = settings.SARVAM_LLM_MODEL
@@ -26,11 +35,13 @@ class SarvamLLM(LLMProvider):
26
  self,
27
  api_key: Optional[str] = None,
28
  model: str = settings.SARVAM_LLM_MODEL,
29
- timeout: float = 60.0,
30
  ):
31
  self.api_key = api_key or settings.SARVAM_API_KEY
32
  self.model = model
33
- self.timeout = timeout
 
 
34
  if not self.api_key:
35
  raise RuntimeError("SARVAM_API_KEY not set in .env")
36
 
 
10
 
11
  from __future__ import annotations
12
 
13
+ from typing import Optional, Union
14
 
15
  import httpx
16
 
 
18
  from backend.providers.base import ChatMessage, LLMProvider, LLMResult
19
 
20
 
21
+ # KI-099 — split httpx.Timeout mirrors KI-084 on NIM. Connect/write/pool
22
+ # tight; read still generous since Sarvam can legitimately stream long.
23
+ # Replaces the prior monolithic timeout=60.0 which let a stalled socket
24
+ # hold a slot past any outer cancellation — amplified by Sarvam's 3x
25
+ # per-turn call pattern (EN-translate inbound, Indic-translate outbound,
26
+ # Hinglish back-translate check).
27
+ _SARVAM_TIMEOUT = httpx.Timeout(connect=2.0, read=20.0, write=2.0, pool=2.0)
28
+
29
+
30
  class SarvamLLM(LLMProvider):
31
  name = "sarvam-m"
32
  model = settings.SARVAM_LLM_MODEL
 
35
  self,
36
  api_key: Optional[str] = None,
37
  model: str = settings.SARVAM_LLM_MODEL,
38
+ timeout: Optional[Union[float, httpx.Timeout]] = None,
39
  ):
40
  self.api_key = api_key or settings.SARVAM_API_KEY
41
  self.model = model
42
+ # KI-099 — default to the split-Timeout; only override when caller
43
+ # explicitly passes a value (preserves backward-compat for tests).
44
+ self.timeout = timeout if timeout is not None else _SARVAM_TIMEOUT
45
  if not self.api_key:
46
  raise RuntimeError("SARVAM_API_KEY not set in .env")
47
 
backend/translator.py CHANGED
@@ -17,10 +17,23 @@ Why this is better than either model alone:
17
 
18
  from __future__ import annotations
19
 
 
 
 
20
  from backend.providers.base import ChatMessage
21
  from backend.providers.sarvam_llm import SarvamLLM
22
 
23
 
 
 
 
 
 
 
 
 
 
 
24
  _TRANSLATE_TO_EN_SYSTEM = """You are a precise translator from Hindi / Hinglish / code-switched Indian English to clean standard English.
25
 
26
  RULES:
@@ -52,14 +65,29 @@ async def translate_to_english(text: str, sarvam: SarvamLLM | None = None) -> st
52
  if not text.strip():
53
  return text
54
  sarvam = sarvam or SarvamLLM()
55
- res = await sarvam.chat(
56
- messages=[
57
- ChatMessage(role="system", content=_TRANSLATE_TO_EN_SYSTEM),
58
- ChatMessage(role="user", content=text),
59
- ],
60
- temperature=0.0,
61
- max_tokens=400,
62
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  out = res.text.strip()
64
  # Strip <think> tags if Sarvam-M went into reasoning mode
65
  from backend.persona import strip_think_tags
@@ -79,14 +107,28 @@ async def translate_to_indic(
79
  if not english.strip():
80
  return english
81
  sarvam = sarvam or SarvamLLM()
82
- res = await sarvam.chat(
83
- messages=[
84
- ChatMessage(role="system", content=_TRANSLATE_TO_INDIC_SYSTEM),
85
- ChatMessage(role="user", content=english),
86
- ],
87
- temperature=0.2,
88
- max_tokens=600,
89
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  out = res.text.strip()
91
  from backend.persona import strip_think_tags
92
  return strip_think_tags(out) or english
 
17
 
18
  from __future__ import annotations
19
 
20
+ import asyncio
21
+ import logging
22
+
23
  from backend.providers.base import ChatMessage
24
  from backend.providers.sarvam_llm import SarvamLLM
25
 
26
 
27
+ # KI-099 — outer per-call wait_for. Even with KI-099's split httpx.Timeout
28
+ # in sarvam_llm.py, an outer asyncio.wait_for guarantees the orchestrator
29
+ # slot can't be held by a wedged Sarvam call past this ceiling. 20s
30
+ # matches the inner read timeout — if httpx hasn't returned by then,
31
+ # something is genuinely wedged.
32
+ _SARVAM_CALL_TIMEOUT = 20.0
33
+
34
+ _log = logging.getLogger(__name__)
35
+
36
+
37
  _TRANSLATE_TO_EN_SYSTEM = """You are a precise translator from Hindi / Hinglish / code-switched Indian English to clean standard English.
38
 
39
  RULES:
 
65
  if not text.strip():
66
  return text
67
  sarvam = sarvam or SarvamLLM()
68
+ try:
69
+ # KI-099 — outer wait_for caps end-to-end Sarvam latency at 20s even
70
+ # if httpx's inner read-timeout fails to fire. On timeout we
71
+ # passthrough the original text — callers in orchestrator.py /
72
+ # translation_check.py already tolerate the original text (they
73
+ # log + degrade gracefully, see KI-004 path).
74
+ res = await asyncio.wait_for(
75
+ sarvam.chat(
76
+ messages=[
77
+ ChatMessage(role="system", content=_TRANSLATE_TO_EN_SYSTEM),
78
+ ChatMessage(role="user", content=text),
79
+ ],
80
+ temperature=0.0,
81
+ max_tokens=400,
82
+ ),
83
+ timeout=_SARVAM_CALL_TIMEOUT,
84
+ )
85
+ except asyncio.TimeoutError:
86
+ _log.warning(
87
+ "sarvam translate_to_english wait_for timed out after %.1fs — passthrough",
88
+ _SARVAM_CALL_TIMEOUT,
89
+ )
90
+ return text
91
  out = res.text.strip()
92
  # Strip <think> tags if Sarvam-M went into reasoning mode
93
  from backend.persona import strip_think_tags
 
107
  if not english.strip():
108
  return english
109
  sarvam = sarvam or SarvamLLM()
110
+ try:
111
+ # KI-099 — see translate_to_english for rationale. On timeout we
112
+ # passthrough the English; orchestrator.py treats an empty/equal
113
+ # Indic reply as "no cascade translation" and falls back to the
114
+ # English reply unchanged (existing behaviour for empty reply_indic).
115
+ res = await asyncio.wait_for(
116
+ sarvam.chat(
117
+ messages=[
118
+ ChatMessage(role="system", content=_TRANSLATE_TO_INDIC_SYSTEM),
119
+ ChatMessage(role="user", content=english),
120
+ ],
121
+ temperature=0.2,
122
+ max_tokens=600,
123
+ ),
124
+ timeout=_SARVAM_CALL_TIMEOUT,
125
+ )
126
+ except asyncio.TimeoutError:
127
+ _log.warning(
128
+ "sarvam translate_to_indic wait_for timed out after %.1fs — passthrough",
129
+ _SARVAM_CALL_TIMEOUT,
130
+ )
131
+ return english
132
  out = res.text.strip()
133
  from backend.persona import strip_think_tags
134
  return strip_think_tags(out) or english