Spaces:
Sleeping
fix(latency): KI-099 — bound every per-turn LLM call
Browse filesKI-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 +61 -15
- backend/providers/nvidia_nim_llm.py +12 -0
- backend/providers/sarvam_llm.py +14 -3
- backend/translator.py +58 -16
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 687 |
-
|
| 688 |
-
#
|
| 689 |
-
#
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 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?"
|
|
@@ -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:
|
|
@@ -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 =
|
| 30 |
):
|
| 31 |
self.api_key = api_key or settings.SARVAM_API_KEY
|
| 32 |
self.model = model
|
| 33 |
-
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 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 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 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
|