Skip to content

Commit a69ddbd

Browse files
authored
LLM - Raise or remove LLM timeout when local endpoint detected #4225 (#4254)
1 parent aa60984 commit a69ddbd

5 files changed

Lines changed: 77 additions & 9 deletions

File tree

changedetectionio/blueprint/settings/llm.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,17 +189,19 @@ def llm_test():
189189
# stay on a small base cap (matching upstream's pre-existing behavior) and only
190190
# reasoning-capable endpoints (Ollama, openai_compatible) opt into the extra
191191
# headroom needed for chain-of-thought to complete.
192-
# Timeout: omit the override so the test inherits DEFAULT_TIMEOUT (60s, tunable
193-
# via LLM_TIMEOUT). A shorter test-only timeout falsely fails on cold-starting
194-
# cloud reasoning models (e.g. ollama.com hosting qwen3.5:397b takes ~60s on
195-
# first hit) even though the same call succeeds in production.
196-
from changedetectionio.llm.evaluator import apply_local_token_multiplier, get_llm_settings
192+
# Timeout: resolve it the same way production calls do — cloud gets
193+
# DEFAULT_TIMEOUT (300s, tunable via LLM_TIMEOUT), and local/self-hosted
194+
# endpoints (IANA-restricted api_base) get the relaxed 1800s local cap so
195+
# a cold-starting or slow-prefilling local model doesn't falsely fail the
196+
# test even though the same call would succeed in production (issue #4225).
197+
from changedetectionio.llm.evaluator import apply_local_token_multiplier, get_llm_settings, resolve_llm_timeout
197198
text, total_tokens, input_tokens, output_tokens = completion(
198199
model=model,
199200
messages=[{'role': 'user', 'content':
200201
'Respond with just the word: ready'}],
201202
api_key=llm_cfg.get('api_key') or None,
202203
api_base=api_base or None,
204+
timeout=resolve_llm_timeout(llm_cfg),
203205
max_tokens=apply_local_token_multiplier(200, llm_cfg),
204206
debug=get_llm_settings(datastore).debug,
205207
)

changedetectionio/llm/client.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@
1414
# _summary_max_tokens() and are NOT subject to this cap.
1515
_MAX_COMPLETION_TOKENS = 400
1616

17-
DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 60))
17+
# Default request timeout (seconds). Raised from 60 to 300 because even cloud
18+
# reasoning models can be slow on the first hit (issue #4225). Overridable via
19+
# LLM_TIMEOUT.
20+
DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 300))
21+
# Relaxed timeout for local / self-hosted endpoints (Ollama, vLLM, LM Studio,
22+
# llama.cpp on localhost or a LAN address). These run on modest hardware and can
23+
# spend many minutes on prompt prefill before the first token, so they get a much
24+
# longer deadline (Hermes-style, 30 min). Overridable via LLM_LOCAL_TIMEOUT; see
25+
# evaluator.resolve_llm_timeout() for how the endpoint is classified.
26+
DEFAULT_LOCAL_TIMEOUT = int(os.getenv('LLM_LOCAL_TIMEOUT', 1800))
1827
DEFAULT_RETRIES = 3
1928

2029

@@ -63,6 +72,9 @@ def completion(model: str, messages: list, api_key: str = None,
6372
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
6473
Token counts are 0 if the provider doesn't return usage data.
6574
Raises on network/auth errors — callers handle gracefully.
75+
76+
timeout: seconds for the request. Local endpoints get a longer value than cloud —
77+
see evaluator.resolve_llm_timeout().
6678
"""
6779
try:
6880
import litellm

changedetectionio/llm/evaluator.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
LLM_MODEL — model string (e.g. "gpt-4o-mini", "ollama/llama3.2")
1313
LLM_API_KEY — API key for cloud providers
1414
LLM_API_BASE — base URL for local/custom endpoints (e.g. http://localhost:11434)
15+
LLM_TIMEOUT — per-request timeout in seconds (default 300). When set, it
16+
applies to every endpoint (a single hard ceiling).
17+
LLM_LOCAL_TIMEOUT — timeout in seconds for local/self-hosted endpoints (api_base
18+
on a private/LAN address); default 1800. Applied automatically
19+
unless LLM_TIMEOUT is set. See resolve_llm_timeout().
1520
"""
1621

1722
import hashlib
@@ -184,6 +189,50 @@ def apply_local_token_multiplier(base_max_tokens: int, llm_cfg: dict) -> int:
184189
return base_max_tokens * multiplier
185190

186191

192+
def _is_local_llm_endpoint(llm_cfg: dict) -> bool:
193+
"""
194+
True when the configured `api_base` points at an IANA-restricted host
195+
(private / loopback / link-local / reserved) — i.e. a local or LAN
196+
self-hosted LLM (Ollama, vLLM, LM Studio, llama.cpp, ...).
197+
198+
Detection is purely by the api_base host, reusing the same IANA check the
199+
SSRF guard uses (is_private_hostname). Because it resolves DNS, docker
200+
service names (`http://ollama:11434`), `host.docker.internal`, and bare LAN
201+
IPs are all recognised. No api_base (cloud providers) → False.
202+
"""
203+
api_base = ((llm_cfg or {}).get('api_base') or '').strip()
204+
if not api_base:
205+
return False
206+
try:
207+
from urllib.parse import urlparse
208+
from changedetectionio.validate_url import is_private_hostname
209+
host = urlparse(api_base).hostname
210+
return bool(host) and is_private_hostname(host)
211+
except Exception:
212+
# Never let timeout resolution break an LLM call — fall back to "not local".
213+
return False
214+
215+
216+
def resolve_llm_timeout(llm_cfg: dict) -> int:
217+
"""
218+
Per-request timeout (seconds) for an LLM call.
219+
220+
Cloud providers get client.DEFAULT_TIMEOUT (300s, tunable via LLM_TIMEOUT).
221+
Local / self-hosted endpoints run on modest hardware and can spend many minutes
222+
on prompt prefill before the first token, so a 300s cap trips prematurely
223+
(issue #4225). When the api_base host is IANA-restricted (see
224+
_is_local_llm_endpoint) we grant client.DEFAULT_LOCAL_TIMEOUT (1800s, tunable
225+
via LLM_LOCAL_TIMEOUT) — mirroring how Hermes relaxes its timeouts for local
226+
endpoints. An explicit LLM_TIMEOUT always wins, even for local endpoints, for
227+
operators who want a single hard ceiling regardless.
228+
"""
229+
if os.getenv('LLM_TIMEOUT', '').strip():
230+
return llm_client.DEFAULT_TIMEOUT
231+
if _is_local_llm_endpoint(llm_cfg):
232+
return llm_client.DEFAULT_LOCAL_TIMEOUT
233+
return llm_client.DEFAULT_TIMEOUT
234+
235+
187236
# ---------------------------------------------------------------------------
188237
# Intent resolution
189238
# ---------------------------------------------------------------------------
@@ -469,6 +518,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
469518
],
470519
api_key=cfg.get('api_key'),
471520
api_base=cfg.get('api_base'),
521+
timeout=resolve_llm_timeout(cfg),
472522
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
473523
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
474524
debug=settings.debug,
@@ -617,6 +667,7 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
617667
],
618668
api_key=cfg.get('api_key'),
619669
api_base=cfg.get('api_base'),
670+
timeout=resolve_llm_timeout(cfg),
620671
max_tokens=apply_local_token_multiplier(
621672
_summary_max_tokens(diff, max_cap=settings.max_summary_tokens),
622673
cfg,
@@ -684,6 +735,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
684735
],
685736
api_key=cfg.get('api_key'),
686737
api_base=cfg.get('api_base'),
738+
timeout=resolve_llm_timeout(cfg),
687739
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
688740
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
689741
debug=settings.debug,
@@ -770,6 +822,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
770822
],
771823
api_key=cfg.get('api_key'),
772824
api_base=cfg.get('api_base'),
825+
timeout=resolve_llm_timeout(cfg),
773826
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
774827
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
775828
debug=settings.debug,

changedetectionio/processors/restock_diff/plugins/llm_restock.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
197197
return None
198198

199199
try:
200-
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens, get_llm_settings
200+
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens, get_llm_settings, resolve_llm_timeout
201201
from changedetectionio.llm import client as llm_client
202202
except ImportError as e:
203203
logger.debug(f"LLM restock fallback: LLM libraries not available ({e})")
@@ -236,6 +236,7 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
236236
],
237237
api_key=llm_cfg.get('api_key'),
238238
api_base=llm_cfg.get('api_base'),
239+
timeout=resolve_llm_timeout(llm_cfg),
239240
# 80 fits a {price, currency, availability} JSON answer comfortably for cloud
240241
# models. Local reasoning models burn most of that on chain-of-thought before
241242
# the JSON lands — the multiplier scales it up only when provider_kind says so.

changedetectionio/tests/llm/test_llm_restock_plugin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def test_llm_intent_appended_to_user_prompt(self):
213213
llm_restock.datastore = ds
214214

215215
captured = {}
216-
def fake_completion(model, messages, api_key, api_base, max_tokens):
216+
def fake_completion(model, messages, api_key, api_base, max_tokens, timeout=None):
217217
captured['messages'] = messages
218218
return ('{"price": 299.0, "currency": "USD", "availability": "instock"}', 50, 40, 10)
219219

@@ -237,7 +237,7 @@ def test_no_intent_prompt_unchanged(self):
237237
llm_restock.datastore = ds
238238

239239
captured = {}
240-
def fake_completion(model, messages, api_key, api_base, max_tokens):
240+
def fake_completion(model, messages, api_key, api_base, max_tokens, timeout=None):
241241
captured['messages'] = messages
242242
return ('{"price": 9.99, "currency": "USD", "availability": "instock"}', 20, 15, 5)
243243

0 commit comments

Comments
 (0)