|
12 | 12 | LLM_MODEL — model string (e.g. "gpt-4o-mini", "ollama/llama3.2") |
13 | 13 | LLM_API_KEY — API key for cloud providers |
14 | 14 | 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(). |
15 | 20 | """ |
16 | 21 |
|
17 | 22 | import hashlib |
@@ -184,6 +189,50 @@ def apply_local_token_multiplier(base_max_tokens: int, llm_cfg: dict) -> int: |
184 | 189 | return base_max_tokens * multiplier |
185 | 190 |
|
186 | 191 |
|
| 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 | + |
187 | 236 | # --------------------------------------------------------------------------- |
188 | 237 | # Intent resolution |
189 | 238 | # --------------------------------------------------------------------------- |
@@ -469,6 +518,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None: |
469 | 518 | ], |
470 | 519 | api_key=cfg.get('api_key'), |
471 | 520 | api_base=cfg.get('api_base'), |
| 521 | + timeout=resolve_llm_timeout(cfg), |
472 | 522 | max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg), |
473 | 523 | extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget), |
474 | 524 | debug=settings.debug, |
@@ -617,6 +667,7 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> |
617 | 667 | ], |
618 | 668 | api_key=cfg.get('api_key'), |
619 | 669 | api_base=cfg.get('api_base'), |
| 670 | + timeout=resolve_llm_timeout(cfg), |
620 | 671 | max_tokens=apply_local_token_multiplier( |
621 | 672 | _summary_max_tokens(diff, max_cap=settings.max_summary_tokens), |
622 | 673 | cfg, |
@@ -684,6 +735,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None: |
684 | 735 | ], |
685 | 736 | api_key=cfg.get('api_key'), |
686 | 737 | api_base=cfg.get('api_base'), |
| 738 | + timeout=resolve_llm_timeout(cfg), |
687 | 739 | max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg), |
688 | 740 | extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget), |
689 | 741 | debug=settings.debug, |
@@ -770,6 +822,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') -> |
770 | 822 | ], |
771 | 823 | api_key=cfg.get('api_key'), |
772 | 824 | api_base=cfg.get('api_base'), |
| 825 | + timeout=resolve_llm_timeout(cfg), |
773 | 826 | max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg), |
774 | 827 | extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget), |
775 | 828 | debug=settings.debug, |
|
0 commit comments