diff --git a/CLAUDE.md b/CLAUDE.md index b96ab68..41d6f8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,11 @@ lmproto/deepseek/openai_compat/ ← Extends base via inheritance + extra fields ### Config (`config/`) -TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. +TOML load/store (`ConfigStore`): `[providers]` tagged union presets, `[database]` section. Default path via platformdirs. Providers may set `timeout` as a float or `{ connect, read }` (`HttpTimeoutConfig`); omitted → product defaults (10s connect / 600s read). ### Runtime (`runtime/`) -`create_client(provider)` maps config `Provider` → protocol client. OpenAI → `lmproto.openai.OpenAIClient` (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`. +`create_client(provider)` maps config `Provider` → protocol client. `provider_to_openai_config` normalizes `timeout` via `normalize_http_timeout` into `OpenAIConfig.timeout` for `niquests.AsyncSession`. OpenAI → `lmproto.openai.OpenAIClient` (Responses-capable); openai-compatible / deepseek(openai convention) → chat-completions clients; anthropic and deepseek anthropic convention raise `ProviderNotSupportedError`. ### Memory (`memory/`) diff --git a/README.md b/README.md index 29407b5..5466c73 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,9 @@ Minimal shape: preset = "openai-compatible" url = "https://api.openai.com/v1" access_key_or_token = "sk-..." +# Optional HTTP timeouts (seconds). Default: connect=10, read=600. +# timeout = 120 +# timeout = { connect = 10, read = 600 } [providers.local.models] "gpt-4o-mini" = { text = true } @@ -138,6 +141,8 @@ confirm_destructive = true max_context_tokens = 200000 ``` +Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_command`, PTY, policy confirm) are separate. + Supported provider presets today: `openai` (default if `preset` is omitted; default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted), `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet. If `[database]` is omitted (or SQLite `url` is unset/empty), chat uses a durable file under the user data dir (e.g. `~/.local/share/plyngent/chat.db` on Linux). Set `url = ":memory:"` for a true in-memory SQLite (CLI warns; no file; useful for tests). diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 1d50199..684eb23 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -33,6 +33,9 @@ max_context_tokens = 200000 preset = "openai-compatible" url = "https://api.openai.com/v1" access_key_or_token = "sk-replace-me" +# HTTP timeouts for LLM API calls (seconds). Default if omitted: connect=10, read=600. +# timeout = 120 +# timeout = { connect = 10, read = 600 } [providers.openai_compat.models] "gpt-4o-mini" = { text = true, cost_factor = 1.0 } diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index d2188dd..bbbfa70 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -9,6 +9,7 @@ from .models import AgentConfig as AgentConfig from .models import AnthropicProvider as AnthropicProvider from .models import DatabaseConfig as DatabaseConfig from .models import DeepseekProvider as DeepseekProvider +from .models import HttpTimeoutConfig as HttpTimeoutConfig from .models import ModelConfig as ModelConfig from .models import OpenAICompatibleProvider as OpenAICompatibleProvider from .models import OpenAIProvider as OpenAIProvider diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 71de8b3..8e59492 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -50,6 +50,23 @@ class ModelConfig(Struct, omit_defaults=True): cost_factor: float = 1.0 +class HttpTimeoutConfig(Struct, omit_defaults=True): + """Per-provider HTTP timeouts (seconds) for the LLM API client. + + TOML examples:: + + timeout = 120 + timeout = { connect = 10, read = 600 } + + ``connect`` bounds TCP/TLS setup; ``read`` bounds idle wait between response + bytes (SSE streams can run longer than *read* as long as chunks keep arriving). + Omitted fields use product defaults (10s connect / 600s read). + """ + + connect: float | None = None + read: float | None = None + + class ProviderConfig(Struct, tag_field="preset", omit_defaults=True): """Base config for all LLM providers. @@ -60,6 +77,8 @@ class ProviderConfig(Struct, tag_field="preset", omit_defaults=True): access_key_or_token: str url: str = "" models: dict[str, ModelConfig] = field(default_factory=dict) + # float = single timeout for the session; table = connect/read split; omit = defaults. + timeout: float | HttpTimeoutConfig | None = None def _default_openai_models() -> dict[str, ModelConfig]: diff --git a/src/plyngent/lmproto/openai_compatible/client.py b/src/plyngent/lmproto/openai_compatible/client.py index 20ae148..224c3db 100644 --- a/src/plyngent/lmproto/openai_compatible/client.py +++ b/src/plyngent/lmproto/openai_compatible/client.py @@ -113,6 +113,7 @@ class BaseOpenAIClient: self.session = niquests.AsyncSession( base_url=config.base_url, auth=BearerTokenAuth(config.access_key_or_token), + timeout=config.timeout, ) self.encoder = msgspec.json.Encoder() self.decoder = msgspec.json.Decoder(ChatCompletionResponse) diff --git a/src/plyngent/lmproto/openai_compatible/config.py b/src/plyngent/lmproto/openai_compatible/config.py index 1de9c46..d57b05d 100644 --- a/src/plyngent/lmproto/openai_compatible/config.py +++ b/src/plyngent/lmproto/openai_compatible/config.py @@ -1,7 +1,18 @@ +from __future__ import annotations + from dataclasses import dataclass +# Product defaults when provider omits ``timeout`` (connect vs read idle). +DEFAULT_HTTP_CONNECT_TIMEOUT = 10.0 +DEFAULT_HTTP_READ_TIMEOUT = 600.0 + +type HttpTimeout = float | tuple[float, float] + @dataclass class OpenAIConfig: access_key_or_token: str base_url: str = "https://api.openai.com/v1" + # Passed to ``niquests.AsyncSession(timeout=...)``. + # ``float`` = single timeout; ``(connect, read)`` = split; factory always sets a concrete value. + timeout: HttpTimeout = (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT) diff --git a/src/plyngent/runtime/__init__.py b/src/plyngent/runtime/__init__.py index 070e800..aae4778 100644 --- a/src/plyngent/runtime/__init__.py +++ b/src/plyngent/runtime/__init__.py @@ -1,3 +1,5 @@ +from .client_factory import InvalidHttpTimeoutError as InvalidHttpTimeoutError from .client_factory import ProviderNotSupportedError as ProviderNotSupportedError from .client_factory import create_client as create_client +from .client_factory import normalize_http_timeout as normalize_http_timeout from .client_factory import provider_to_openai_config as provider_to_openai_config diff --git a/src/plyngent/runtime/client_factory.py b/src/plyngent/runtime/client_factory.py index 677b0e3..a76a537 100644 --- a/src/plyngent/runtime/client_factory.py +++ b/src/plyngent/runtime/client_factory.py @@ -1,9 +1,11 @@ from __future__ import annotations +import math from typing import TYPE_CHECKING from plyngent.config.models import ( DeepseekProvider, + HttpTimeoutConfig, OpenAICompatibleProvider, OpenAIProvider, Provider, @@ -11,6 +13,11 @@ from plyngent.config.models import ( from plyngent.lmproto.deepseek import DeepseekOpenAIClient from plyngent.lmproto.openai import OpenAIClient from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig +from plyngent.lmproto.openai_compatible.config import ( + DEFAULT_HTTP_CONNECT_TIMEOUT, + DEFAULT_HTTP_READ_TIMEOUT, + HttpTimeout, +) if TYPE_CHECKING: from collections.abc import Mapping @@ -29,6 +36,44 @@ class ProviderNotSupportedError(NotImplementedError): """Raised when a provider preset cannot be turned into a runtime client.""" +class InvalidHttpTimeoutError(ValueError): + """Raised when a provider ``timeout`` value is not usable for HTTP clients.""" + + +def normalize_http_timeout(timeout: float | HttpTimeoutConfig | None) -> HttpTimeout: + """Normalize TOML/provider timeout into a niquests session timeout. + + * ``None`` → product defaults ``(connect=10, read=600)`` + * ``float`` / ``int`` → single timeout (niquests applies to the request) + * :class:`HttpTimeoutConfig` → ``(connect, read)`` with defaults for omitted fields + + All finite values must be ``> 0``. + """ + if timeout is None: + return (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT) + if isinstance(timeout, bool): + # ``bool`` is an ``int`` subclass; reject before the numeric branch. + msg = "timeout must be a positive number or { connect, read }, not a boolean" + raise InvalidHttpTimeoutError(msg) + if isinstance(timeout, int | float): + value = float(timeout) + if not math.isfinite(value) or value <= 0: + msg = f"timeout must be a finite number > 0, got {timeout!r}" + raise InvalidHttpTimeoutError(msg) + return value + + # Remaining union member: HttpTimeoutConfig + connect = DEFAULT_HTTP_CONNECT_TIMEOUT if timeout.connect is None else float(timeout.connect) + read = DEFAULT_HTTP_READ_TIMEOUT if timeout.read is None else float(timeout.read) + if not math.isfinite(connect) or connect <= 0: + msg = f"timeout.connect must be a finite number > 0, got {timeout.connect!r}" + raise InvalidHttpTimeoutError(msg) + if not math.isfinite(read) or read <= 0: + msg = f"timeout.read must be a finite number > 0, got {timeout.read!r}" + raise InvalidHttpTimeoutError(msg) + return (connect, read) + + def provider_to_openai_config(provider: OpenAIProvider | OpenAICompatibleProvider | DeepseekProvider) -> OpenAIConfig: """Map a provider config entry to :class:`OpenAIConfig`.""" if isinstance(provider, OpenAIProvider): @@ -40,9 +85,14 @@ def provider_to_openai_config(provider: OpenAIProvider | OpenAICompatibleProvide if not base_url: msg = "openai-compatible provider requires a non-empty url" raise ProviderNotSupportedError(msg) + try: + http_timeout = normalize_http_timeout(provider.timeout) + except InvalidHttpTimeoutError as exc: + raise ProviderNotSupportedError(str(exc)) from exc return OpenAIConfig( access_key_or_token=provider.access_key_or_token, base_url=base_url, + timeout=http_timeout, ) diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index 8d40c9a..eba4789 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -83,6 +83,46 @@ access_key_or_token = "sk-test" provider = config.providers["oai"] assert isinstance(provider, OpenAIProvider) assert set(provider.models) == {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"} + assert provider.timeout is None + + +def test_provider_timeout_float_from_toml(tmp_path: Path) -> None: + path = tmp_path / "timeout-float.toml" + _ = path.write_text( + """ +[providers.local] +preset = "openai-compatible" +url = "https://example.com/v1" +access_key_or_token = "sk-test" +timeout = 90 +models = { "m" = { text = true } } +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + provider = config.providers["local"] + assert isinstance(provider, OpenAICompatibleProvider) + assert provider.timeout == 90 + + +def test_provider_timeout_table_from_toml(tmp_path: Path) -> None: + from plyngent.config import HttpTimeoutConfig + + path = tmp_path / "timeout-table.toml" + _ = path.write_text( + """ +[providers.oai] +access_key_or_token = "sk-test" +timeout = { connect = 5, read = 120 } +""", + encoding="utf-8", + ) + config = plyngent.config.load(path) + provider = config.providers["oai"] + assert isinstance(provider, OpenAIProvider) + assert isinstance(provider.timeout, HttpTimeoutConfig) + assert provider.timeout.connect == 5 + assert provider.timeout.read == 120 def test_deepseek_explicit_models_override_defaults() -> None: diff --git a/tests/test_runtime/test_http_timeout.py b/tests/test_runtime/test_http_timeout.py new file mode 100644 index 0000000..76a229e --- /dev/null +++ b/tests/test_runtime/test_http_timeout.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import math + +import pytest + +from plyngent.config.models import ( + HttpTimeoutConfig, + OpenAICompatibleProvider, + OpenAIProvider, +) +from plyngent.lmproto.openai_compatible.config import ( + DEFAULT_HTTP_CONNECT_TIMEOUT, + DEFAULT_HTTP_READ_TIMEOUT, +) +from plyngent.runtime import ( + InvalidHttpTimeoutError, + ProviderNotSupportedError, + create_client, + normalize_http_timeout, + provider_to_openai_config, +) + + +def test_normalize_none_uses_product_defaults() -> None: + assert normalize_http_timeout(None) == ( + DEFAULT_HTTP_CONNECT_TIMEOUT, + DEFAULT_HTTP_READ_TIMEOUT, + ) + + +def test_normalize_float() -> None: + assert normalize_http_timeout(120) == 120.0 + assert normalize_http_timeout(0.5) == 0.5 + + +def test_normalize_table_partial_and_full() -> None: + assert normalize_http_timeout(HttpTimeoutConfig()) == ( + DEFAULT_HTTP_CONNECT_TIMEOUT, + DEFAULT_HTTP_READ_TIMEOUT, + ) + assert normalize_http_timeout(HttpTimeoutConfig(connect=5.0)) == ( + 5.0, + DEFAULT_HTTP_READ_TIMEOUT, + ) + assert normalize_http_timeout(HttpTimeoutConfig(read=30.0)) == ( + DEFAULT_HTTP_CONNECT_TIMEOUT, + 30.0, + ) + assert normalize_http_timeout(HttpTimeoutConfig(connect=3.0, read=90.0)) == (3.0, 90.0) + + +@pytest.mark.parametrize( + "bad", + [ + 0, + -1, + math.nan, + math.inf, + True, # bool is int subclass but rejected + HttpTimeoutConfig(connect=0), + HttpTimeoutConfig(read=-5), + HttpTimeoutConfig(connect=math.nan), + ], +) +def test_normalize_rejects_invalid(bad: float | HttpTimeoutConfig) -> None: + with pytest.raises(InvalidHttpTimeoutError): + _ = normalize_http_timeout(bad) + + +def test_provider_to_openai_config_default_timeout() -> None: + provider = OpenAIProvider(access_key_or_token="sk-test") + config = provider_to_openai_config(provider) + assert config.timeout == (DEFAULT_HTTP_CONNECT_TIMEOUT, DEFAULT_HTTP_READ_TIMEOUT) + + +def test_provider_to_openai_config_float_timeout() -> None: + provider = OpenAICompatibleProvider( + access_key_or_token="sk-test", + url="https://example.com/v1", + timeout=45.0, + ) + config = provider_to_openai_config(provider) + assert config.timeout == 45.0 + + +def test_provider_to_openai_config_table_timeout() -> None: + provider = OpenAIProvider( + access_key_or_token="sk-test", + timeout=HttpTimeoutConfig(connect=2.0, read=99.0), + ) + config = provider_to_openai_config(provider) + assert config.timeout == (2.0, 99.0) + + +def test_invalid_timeout_via_create_client() -> None: + provider = OpenAICompatibleProvider( + access_key_or_token="sk-test", + url="https://example.com/v1", + timeout=0, + ) + with pytest.raises(ProviderNotSupportedError, match="timeout"): + _ = create_client(provider) + + +def test_client_session_receives_timeout() -> None: + from plyngent.lmproto.openai_compatible import OpenAICompatibleClient + + provider = OpenAICompatibleProvider( + access_key_or_token="sk-test", + url="https://example.com/v1", + timeout=HttpTimeoutConfig(connect=7.0, read=11.0), + ) + client = create_client(provider) + assert isinstance(client, OpenAICompatibleClient) + assert client.session.timeout == (7.0, 11.0)