From 19c33845553d113a5df609be05bf64f2be5b15f8 Mon Sep 17 00:00:00 2001 From: guix4ever <111418839+guix4ever@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:52:29 +0800 Subject: [PATCH] fix(llm): make request timeout configurable --- .env.example | 2 ++ config.example.toml | 1 + docs/configuration.md | 1 + src/everos/component/llm/client.py | 1 + src/everos/component/llm/factory.py | 1 + src/everos/config/default.toml | 4 ++- src/everos/config/settings.py | 2 ++ src/everos/templates/env.template | 2 ++ .../test_component/test_llm/test_client.py | 28 +++++++++++++++++++ .../test_component/test_llm/test_factory.py | 28 +++++++++++++++++++ tests/unit/test_config/test_settings.py | 23 +++++++++++++++ 11 files changed, 92 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index ad02bee04..140dd4f7c 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ EVEROS_LLM__MODEL=openai/gpt-4.1-mini EVEROS_LLM__API_KEY= EVEROS_LLM__BASE_URL=https://openrouter.ai/api/v1 +# Per-request timeout in seconds (default 60): +# EVEROS_LLM__TIMEOUT_SECONDS=60 # ─── Multimodal LLM (independent from [llm]; vision/audio capable) ──── diff --git a/config.example.toml b/config.example.toml index f8043127f..03caa21d9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -24,6 +24,7 @@ model = "gpt-4.1-mini" api_key = "sk-..." base_url = "https://api.openai.com/v1" +timeout_seconds = 60.0 # ── Embedding ───────────────────────────────────────── [embedding] diff --git a/docs/configuration.md b/docs/configuration.md index 98fdf3479..34f08c501 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,6 +106,7 @@ everos init --root /data/everos | `model` | string | `"gpt-4.1-mini"` | No | LLM model identifier. | | `api_key` | string | — | **Yes** | API key for the LLM provider. | | `base_url` | string | — | No | Custom endpoint URL (OpenAI-compatible). | +| `timeout_seconds` | float | `60.0` | No | Per-request timeout in seconds. Must be greater than zero. | ### `[multimodal]` diff --git a/src/everos/component/llm/client.py b/src/everos/component/llm/client.py index 846dcf1c8..6f265c042 100644 --- a/src/everos/component/llm/client.py +++ b/src/everos/component/llm/client.py @@ -51,6 +51,7 @@ def get_llm_client() -> LLMClient: model=llm_cfg.model, api_key=api_key, base_url=llm_cfg.base_url, + timeout=llm_cfg.timeout_seconds, ) ) logger.info("llm_client_built", model=llm_cfg.model) diff --git a/src/everos/component/llm/factory.py b/src/everos/component/llm/factory.py index 5dff064aa..f96b4801e 100644 --- a/src/everos/component/llm/factory.py +++ b/src/everos/component/llm/factory.py @@ -42,4 +42,5 @@ def build_llm_provider(settings: LLMSettings) -> LLMClient: model=settings.model, api_key=settings.api_key.get_secret_value(), base_url=settings.base_url, + timeout=settings.timeout_seconds, ) diff --git a/src/everos/config/default.toml b/src/everos/config/default.toml index 68fab81f1..fde3c924f 100644 --- a/src/everos/config/default.toml +++ b/src/everos/config/default.toml @@ -52,11 +52,13 @@ cache_size_kb = 2048 [llm] # Provider-agnostic OpenAI-protocol client config. Override via env: -# EVEROS_LLM__MODEL, EVEROS_LLM__API_KEY, EVEROS_LLM__BASE_URL +# EVEROS_LLM__MODEL, EVEROS_LLM__API_KEY, EVEROS_LLM__BASE_URL, +# EVEROS_LLM__TIMEOUT_SECONDS # Or set the field directly in this file (/everos.toml). model = "openai/gpt-4.1-mini" api_key = "" base_url = "https://openrouter.ai/api/v1" +timeout_seconds = 60.0 [multimodal] # Independent LLM for multimodal parsing (everalgo-parser); must accept diff --git a/src/everos/config/settings.py b/src/everos/config/settings.py index 5ae2c8da8..e934b24f0 100644 --- a/src/everos/config/settings.py +++ b/src/everos/config/settings.py @@ -127,11 +127,13 @@ class LLMSettings(BaseModel): EVEROS_LLM__MODEL EVEROS_LLM__API_KEY EVEROS_LLM__BASE_URL + EVEROS_LLM__TIMEOUT_SECONDS """ model: str = "gpt-4.1-mini" api_key: SecretStr | None = None base_url: str | None = None + timeout_seconds: float = Field(default=60.0, gt=0) class MultimodalSettings(BaseModel): diff --git a/src/everos/templates/env.template b/src/everos/templates/env.template index ad02bee04..140dd4f7c 100755 --- a/src/everos/templates/env.template +++ b/src/everos/templates/env.template @@ -29,6 +29,8 @@ EVEROS_LLM__MODEL=openai/gpt-4.1-mini EVEROS_LLM__API_KEY= EVEROS_LLM__BASE_URL=https://openrouter.ai/api/v1 +# Per-request timeout in seconds (default 60): +# EVEROS_LLM__TIMEOUT_SECONDS=60 # ─── Multimodal LLM (independent from [llm]; vision/audio capable) ──── diff --git a/tests/unit/test_component/test_llm/test_client.py b/tests/unit/test_component/test_llm/test_client.py index eb7427e36..0e568f385 100644 --- a/tests/unit/test_component/test_llm/test_client.py +++ b/tests/unit/test_component/test_llm/test_client.py @@ -5,6 +5,7 @@ import importlib import pytest +from everalgo.llm.config import LLMConfig from pydantic import SecretStr from everos.component.llm import LLMNotConfiguredError @@ -23,6 +24,7 @@ def _patch_settings( *, api_key: str | None, base_url: str | None, + timeout_seconds: float = 60.0, ) -> None: """Stub the ``load_settings`` reference bound inside the client module.""" cfg = Settings( @@ -30,6 +32,7 @@ def _patch_settings( model="gpt-4.1-mini", api_key=SecretStr(api_key) if api_key is not None else None, base_url=base_url, + timeout_seconds=timeout_seconds, ) ) monkeypatch.setattr(_client_mod, "load_settings", lambda: cfg) @@ -62,3 +65,28 @@ def test_returns_singleton_when_configured(monkeypatch: pytest.MonkeyPatch) -> N assert first is sentinel assert first is second + + +def test_passes_configured_timeout_to_everalgo_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _reset_singleton(monkeypatch) + _patch_settings( + monkeypatch, + api_key="sk-test", + base_url="https://example.test", + timeout_seconds=135.0, + ) + sentinel = object() + received_timeouts: list[float] = [] + + def fake_build_client(cfg: LLMConfig) -> object: + received_timeouts.append(cfg.timeout) + return sentinel + + monkeypatch.setattr(_client_mod, "build_client", fake_build_client) + + client = _client_mod.get_llm_client() + + assert client is sentinel + assert received_timeouts == [135.0] diff --git a/tests/unit/test_component/test_llm/test_factory.py b/tests/unit/test_component/test_llm/test_factory.py index 4329fceb7..2478b5de7 100644 --- a/tests/unit/test_component/test_llm/test_factory.py +++ b/tests/unit/test_component/test_llm/test_factory.py @@ -2,6 +2,8 @@ from __future__ import annotations +import importlib + import pytest from pydantic import SecretStr @@ -9,6 +11,8 @@ from everos.component.llm.openai_provider import OpenAIProvider from everos.config.settings import LLMSettings +_factory_mod = importlib.import_module("everos.component.llm.factory") + def test_raises_when_api_key_missing() -> None: s = LLMSettings(model="m", api_key=None, base_url="https://x") @@ -26,3 +30,27 @@ def test_builds_openai_provider() -> None: s = LLMSettings(model="m", api_key=SecretStr("k"), base_url="https://x") p = build_llm_provider(s) assert isinstance(p, OpenAIProvider) + + +def test_passes_configured_timeout_to_provider( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + sentinel = object() + + def fake_provider(**kwargs: object) -> object: + captured.update(kwargs) + return sentinel + + monkeypatch.setattr(_factory_mod, "OpenAIProvider", fake_provider) + settings = LLMSettings( + model="m", + api_key=SecretStr("k"), + base_url="https://x", + timeout_seconds=135.0, + ) + + provider = _factory_mod.build_llm_provider(settings) + + assert provider is sentinel + assert captured["timeout"] == 135.0 diff --git a/tests/unit/test_config/test_settings.py b/tests/unit/test_config/test_settings.py index fb4fbb17f..c51eca0ea 100644 --- a/tests/unit/test_config/test_settings.py +++ b/tests/unit/test_config/test_settings.py @@ -76,6 +76,21 @@ def test_env_overrides_toml(monkeypatch: pytest.MonkeyPatch) -> None: assert s.sqlite.synchronous == "NORMAL" +def test_llm_timeout_env_overrides_everos_toml( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + root = tmp_path / "myroot" + root.mkdir() + (root / "everos.toml").write_text( + "[llm]\ntimeout_seconds = 90.0\n", encoding="utf-8" + ) + monkeypatch.setenv("EVEROS_LLM__TIMEOUT_SECONDS", "135.0") + + settings = Settings(_everos_root=root) + + assert settings.llm.timeout_seconds == 135.0 + + def test_init_args_override_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("EVEROS_SQLITE__BUSY_TIMEOUT_MS", "10000") from everos.config.settings import SqliteSettings @@ -98,6 +113,13 @@ def test_negative_busy_timeout_rejected() -> None: Settings.model_validate({"sqlite": {"busy_timeout_ms": -1}}) +def test_non_positive_llm_timeout_rejected() -> None: + from pydantic import ValidationError + + with pytest.raises(ValidationError): + Settings.model_validate({"llm": {"timeout_seconds": 0}}) + + def test_load_settings_is_cached() -> None: a = load_settings() b = load_settings() @@ -118,6 +140,7 @@ def test_embedding_rerank_defaults() -> None: assert s.rerank.api_key.get_secret_value() == "" assert s.rerank.timeout_seconds == 30.0 assert s.llm.api_key.get_secret_value() == "" + assert s.llm.timeout_seconds == 60.0 def test_resolve_root_default(monkeypatch: pytest.MonkeyPatch) -> None: