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
+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,
)