mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-07-18 21:21:05 +02:00
fix: bound every LLM call with a per-request network timeout
A stalled or half-open connection (e.g. a flaky local proxy that keeps a socket ESTABLISHED but never sends data) could hang indexing forever with no error — litellm/httpx had no timeout applied. Add a default per-request timeout (120s, tunable via PAGEINDEX_LLM_TIMEOUT or set_llm_params(timeout=...)) so a hung call fails fast with a clear litellm Timeout, which the existing retry loops surface. Rides get_llm_params(), so it flows through both llm_completion and llm_acompletion automatically and is overridable per-index via llm_params_scope / IndexConfig(llm_params=...).
This commit is contained in:
parent
214098f489
commit
e00d360273
2 changed files with 62 additions and 4 deletions
|
|
@ -52,15 +52,44 @@ def _env_drop_params_default() -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Built-in per-request network timeout (seconds) for every litellm completion.
|
||||||
|
# Bounds a single in-flight call so a stalled / half-open connection (e.g. a
|
||||||
|
# flaky proxy that keeps a socket ESTABLISHED but never sends data) fails fast
|
||||||
|
# with a litellm Timeout — caught by the retry loops in index/utils.py — instead
|
||||||
|
# of hanging indefinitely. Generous enough for legitimate slow responses on
|
||||||
|
# large prompts; tune via PAGEINDEX_LLM_TIMEOUT / set_llm_params(timeout=…).
|
||||||
|
_DEFAULT_LLM_TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _env_llm_timeout_default():
|
||||||
|
"""Default per-request litellm timeout in seconds, from PAGEINDEX_LLM_TIMEOUT.
|
||||||
|
|
||||||
|
A missing or non-numeric value falls back to ``_DEFAULT_LLM_TIMEOUT``. A
|
||||||
|
value <= 0 means "no timeout" (returns None -> litellm's own default), so a
|
||||||
|
caller can explicitly opt out. Read once at import.
|
||||||
|
"""
|
||||||
|
raw = os.getenv("PAGEINDEX_LLM_TIMEOUT", str(_DEFAULT_LLM_TIMEOUT)).strip()
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return _DEFAULT_LLM_TIMEOUT
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
|
||||||
# Per-call kwargs PageIndex passes to every litellm completion. These are
|
# Per-call kwargs PageIndex passes to every litellm completion. These are
|
||||||
# PageIndex-OWNED and applied PER CALL — never written to litellm's shared module
|
# PageIndex-OWNED and applied PER CALL — never written to litellm's shared module
|
||||||
# globals, so they don't leak into other libraries sharing the litellm module.
|
# globals, so they don't leak into other libraries sharing the litellm module.
|
||||||
# Defaults preserve historical behavior: temperature=0 keeps structure
|
# Defaults preserve historical behavior: temperature=0 keeps structure
|
||||||
# extraction deterministic; drop_params=True lets a provider that rejects a param
|
# extraction deterministic; drop_params=True lets a provider that rejects a param
|
||||||
# (e.g. temperature on some local / reasoning models) succeed by dropping it.
|
# (e.g. temperature on some local / reasoning models) succeed by dropping it;
|
||||||
# Override/extend via set_llm_params(); the common drop_params case also has the
|
# timeout bounds a single hung request (see _env_llm_timeout_default). Override/
|
||||||
# PAGEINDEX_DROP_PARAMS env shortcut.
|
# extend via set_llm_params(); the common drop_params / timeout cases also have
|
||||||
_LLM_PARAMS: dict = {"temperature": 0, "drop_params": _env_drop_params_default()}
|
# the PAGEINDEX_DROP_PARAMS / PAGEINDEX_LLM_TIMEOUT env shortcuts.
|
||||||
|
_LLM_PARAMS: dict = {
|
||||||
|
"temperature": 0,
|
||||||
|
"drop_params": _env_drop_params_default(),
|
||||||
|
"timeout": _env_llm_timeout_default(),
|
||||||
|
}
|
||||||
|
|
||||||
# Per-call override, isolated per thread / async context — mirrors
|
# Per-call override, isolated per thread / async context — mirrors
|
||||||
# _MAX_CONCURRENCY_OVERRIDE below. Without this, set_llm_params() is the only
|
# _MAX_CONCURRENCY_OVERRIDE below. Without this, set_llm_params() is the only
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import pytest
|
||||||
|
|
||||||
from pageindex.config import (
|
from pageindex.config import (
|
||||||
IndexConfig,
|
IndexConfig,
|
||||||
|
_env_llm_timeout_default,
|
||||||
_env_max_concurrency_default,
|
_env_max_concurrency_default,
|
||||||
get_llm_params,
|
get_llm_params,
|
||||||
get_max_concurrency,
|
get_max_concurrency,
|
||||||
|
|
@ -164,6 +165,23 @@ def test_llm_acompletion_holds_the_shared_semaphore(monkeypatch):
|
||||||
assert state["peak"] == 3
|
assert state["peak"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_acompletion_passes_a_timeout_to_litellm(monkeypatch):
|
||||||
|
# A per-request timeout must reach litellm so a hung / half-open connection
|
||||||
|
# fails fast instead of stalling indexing forever. It rides get_llm_params(),
|
||||||
|
# so both the default and an override flow through automatically.
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_acompletion(**kwargs):
|
||||||
|
seen.update(kwargs)
|
||||||
|
return SimpleNamespace(
|
||||||
|
choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))]
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("litellm.acompletion", fake_acompletion)
|
||||||
|
asyncio.run(llm_acompletion("gpt-x", "hi"))
|
||||||
|
assert "timeout" in seen and seen["timeout"] == get_llm_params()["timeout"]
|
||||||
|
|
||||||
|
|
||||||
def test_run_async_propagates_scope_into_worker_thread():
|
def test_run_async_propagates_scope_into_worker_thread():
|
||||||
# When build_index runs inside an already-running loop, _run_async hops to a
|
# When build_index runs inside an already-running loop, _run_async hops to a
|
||||||
# worker thread. The max_concurrency_scope override must ride along (copied
|
# worker thread. The max_concurrency_scope override must ride along (copied
|
||||||
|
|
@ -205,6 +223,17 @@ def test_env_default_parsing(monkeypatch):
|
||||||
assert _env_max_concurrency_default() == 5
|
assert _env_max_concurrency_default() == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_llm_timeout_parsing(monkeypatch):
|
||||||
|
monkeypatch.delenv("PAGEINDEX_LLM_TIMEOUT", raising=False)
|
||||||
|
assert _env_llm_timeout_default() == 120
|
||||||
|
monkeypatch.setenv("PAGEINDEX_LLM_TIMEOUT", "45")
|
||||||
|
assert _env_llm_timeout_default() == 45
|
||||||
|
monkeypatch.setenv("PAGEINDEX_LLM_TIMEOUT", "garbage")
|
||||||
|
assert _env_llm_timeout_default() == 120
|
||||||
|
monkeypatch.setenv("PAGEINDEX_LLM_TIMEOUT", "0") # <=0 opts out of the timeout
|
||||||
|
assert _env_llm_timeout_default() is None
|
||||||
|
|
||||||
|
|
||||||
def test_index_config_max_concurrency_field():
|
def test_index_config_max_concurrency_field():
|
||||||
# Default is None → "use the global/env default"; explicit value overrides.
|
# Default is None → "use the global/env default"; explicit value overrides.
|
||||||
assert IndexConfig().max_concurrency is None
|
assert IndexConfig().max_concurrency is None
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue