core/runtime: configurable provider HTTP request timeouts

This commit is contained in:
2026-07-20 18:08:27 +08:00
parent 71b9f91e14
commit 767578723e
11 changed files with 250 additions and 2 deletions
+2 -2
View File
@@ -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/`)
+5
View File
@@ -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).
+3
View File
@@ -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 }
+1
View File
@@ -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
+19
View File
@@ -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]:
@@ -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)
@@ -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)
+2
View File
@@ -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
+50
View File
@@ -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,
)
+40
View File
@@ -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:
+116
View File
@@ -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)