refactor: 移除 Provider/Endpoint 级别的 timeout 配置,改用全局环境变量

- 废弃 Provider.timeout 和 ProviderEndpoint.timeout 字段(保留数据库列以兼容)
- 非流式请求超时由 HTTP_REQUEST_TIMEOUT 环境变量控制(默认 300 秒)
- 流式请求首字节超时由 STREAM_FIRST_BYTE_TIMEOUT 环境变量控制(默认 30 秒)
- 移除前端表单中的超时配置输入框
- 简化 settings.py 中的 TTFB 超时解析逻辑
- 更新 API 请求/响应模型,移除 timeout 字段
- 更新导入导出功能,不再包含 timeout 配置
This commit is contained in:
fawney19
2026-01-16 13:08:39 +08:00
parent e6a31a61db
commit 50343d5459
20 changed files with 44 additions and 132 deletions

View File

@@ -65,7 +65,6 @@ async def list_provider_endpoints(
- `api_format`: API 格式
- `base_url`: 基础 URL
- `custom_path`: 自定义路径
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数
- `is_active`: 是否活跃
- `total_keys`: Key 总数
@@ -103,7 +102,6 @@ async def create_provider_endpoint(
- `base_url`: 基础 URL
- `custom_path`: 自定义路径(可选)
- `header_rules`: 请求头规则列表(可选,支持 set/drop/rename 操作)
- `timeout`: 超时时间(秒,默认 300
- `max_retries`: 最大重试次数(默认 2
- `config`: 额外配置(可选)
- `proxy`: 代理配置(可选)
@@ -139,7 +137,6 @@ async def get_endpoint(
- `api_format`: API 格式
- `base_url`: 基础 URL
- `custom_path`: 自定义路径
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数
- `is_active`: 是否活跃
- `total_keys`: Key 总数
@@ -170,7 +167,6 @@ async def update_endpoint(
- `base_url`: 基础 URL
- `custom_path`: 自定义路径
- `header_rules`: 请求头规则列表
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数
- `is_active`: 是否活跃
- `config`: 额外配置
@@ -306,7 +302,6 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
base_url=self.endpoint_data.base_url,
custom_path=self.endpoint_data.custom_path,
header_rules=self.endpoint_data.header_rules,
timeout=self.endpoint_data.timeout,
max_retries=self.endpoint_data.max_retries,
is_active=True,
config=self.endpoint_data.config,

View File

@@ -317,14 +317,14 @@ async def test_model(
logger.error(f"[test-model] Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
# 构建请求配置timeout 从 Provider 读取)
# 构建请求配置
endpoint_config = {
"api_key": api_key_value,
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
"timeout": provider.timeout or TimeoutDefaults.HTTP_REQUEST,
"timeout": TimeoutDefaults.HTTP_REQUEST,
}
try:

View File

@@ -140,7 +140,6 @@ async def create_provider(request: Request, db: Session = Depends(get_db)):
- `provider_priority`: 提供商优先级(数字越小优先级越高,默认 100
- `is_active`: 是否启用(默认 true
- `concurrent_limit`: 并发限制(可选)
- `timeout`: 请求超时(秒,可选)
- `max_retries`: 最大重试次数(可选)
- `proxy`: 代理配置(可选)
- `config`: 额外配置信息JSON可选
@@ -176,7 +175,6 @@ async def update_provider(provider_id: str, request: Request, db: Session = Depe
- `provider_priority`: 提供商优先级
- `is_active`: 是否启用
- `concurrent_limit`: 并发限制
- `timeout`: 请求超时(秒)
- `max_retries`: 最大重试次数
- `proxy`: 代理配置
- `config`: 额外配置信息JSON
@@ -293,7 +291,6 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
provider_priority=validated_data.provider_priority,
is_active=validated_data.is_active,
concurrent_limit=validated_data.concurrent_limit,
timeout=validated_data.timeout,
max_retries=validated_data.max_retries,
proxy=validated_data.proxy.model_dump() if validated_data.proxy else None,
config=validated_data.config,

View File

@@ -302,7 +302,6 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
quota_reset_day=provider.quota_reset_day,
quota_last_reset_at=provider.quota_last_reset_at,
quota_expires_at=provider.quota_expires_at,
timeout=provider.timeout,
max_retries=provider.max_retries,
proxy=provider.proxy,
total_endpoints=total_endpoints,

View File

@@ -774,7 +774,6 @@ class AdminExportConfigAdapter(AdminApiAdapter):
"api_format": ep.api_format,
"base_url": ep.base_url,
"header_rules": ep.header_rules,
"timeout": ep.timeout,
"max_retries": ep.max_retries,
"is_active": ep.is_active,
"custom_path": ep.custom_path,
@@ -855,7 +854,6 @@ class AdminExportConfigAdapter(AdminApiAdapter):
"provider_priority": provider.provider_priority,
"is_active": provider.is_active,
"concurrent_limit": provider.concurrent_limit,
"timeout": provider.timeout,
"max_retries": provider.max_retries,
"proxy": provider.proxy,
"config": provider.config,
@@ -1006,7 +1004,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_provider.concurrent_limit = prov_data.get(
"concurrent_limit"
)
existing_provider.timeout = prov_data.get("timeout", existing_provider.timeout)
existing_provider.max_retries = prov_data.get(
"max_retries", existing_provider.max_retries
)
@@ -1031,7 +1028,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
provider_priority=prov_data.get("provider_priority", 100),
is_active=prov_data.get("is_active", True),
concurrent_limit=prov_data.get("concurrent_limit"),
timeout=prov_data.get("timeout"),
max_retries=prov_data.get("max_retries"),
proxy=prov_data.get("proxy"),
config=prov_data.get("config"),
@@ -1064,7 +1060,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
"base_url", existing_ep.base_url
)
existing_ep.header_rules = ep_data.get("header_rules")
existing_ep.timeout = ep_data.get("timeout", 300)
existing_ep.max_retries = ep_data.get("max_retries", 2)
existing_ep.is_active = ep_data.get("is_active", True)
existing_ep.custom_path = ep_data.get("custom_path")
@@ -1079,7 +1074,6 @@ class AdminImportConfigAdapter(AdminApiAdapter):
api_format=ep_data["api_format"],
base_url=ep_data["base_url"],
header_rules=ep_data.get("header_rules"),
timeout=ep_data.get("timeout", 300),
max_retries=ep_data.get("max_retries", 2),
is_active=ep_data.get("is_active", True),
custom_path=ep_data.get("custom_path"),

View File

@@ -477,7 +477,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 配置 HTTP 超时
# 注意read timeout 用于检测连接断开,不是整体请求超时
# 整体请求超时由 asyncio.wait_for 控制,使用 provider.timeout
# 整体请求超时由 asyncio.wait_for 控制,使用全局配置
timeout_config = httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout, # 使用全局配置,用于检测连接断开
@@ -485,8 +485,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
pool=config.http_pool_timeout,
)
# provider.timeout 作为整体请求超时(建立连接 + 获取首字节
request_timeout = float(provider.timeout or 300)
# 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = config.stream_first_byte_timeout
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool
@@ -528,7 +528,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# provider.timeout 控制整体超时,避免上游长时间无响应
# stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except asyncio.TimeoutError:
@@ -717,7 +717,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool
request_timeout = float(provider.timeout or 300)
# 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
)

View File

@@ -490,8 +490,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
pool=config.http_pool_timeout,
)
# provider.timeout 作为整体请求超时(建立连接 + 获取首字节
request_timeout = float(provider.timeout or 300)
# 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = config.stream_first_byte_timeout
logger.debug(
f" └─ [{self.request_id}] 发送流式请求: "
@@ -539,7 +539,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# provider.timeout 控制整体超时,避免上游长时间无响应
# stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except asyncio.TimeoutError:
@@ -1633,7 +1633,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool
request_timeout = float(provider.timeout or 300)
# 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
)

View File

@@ -214,16 +214,14 @@ class TimeoutDefaults:
"""超时配置默认值(秒)
超时配置说明:
- 全局默认值和 Provider 默认值统一为 120 秒
- 120 秒是 LLM API 的合理默认值:
* 大多数请求在 30 秒内完成
* 复杂推理(如 Claude extended thinking可能需要 60-90 秒
* 120 秒足够覆盖大部分场景,同时避免线程池被长时间占用
- 如需更长超时,可在 Provider 级别单独配置
- 非流式请求超时由环境变量 HTTP_REQUEST_TIMEOUT 控制(默认 300 秒
- 流式请求首字节超时由环境变量 STREAM_FIRST_BYTE_TIMEOUT 控制(默认 30 秒)
- 此处的常量仅用于无法访问 config 的场景(如模型查询测试)
"""
# HTTP 请求默认超时(与 Provider 默认值保持一致
HTTP_REQUEST = 120 # 2分钟
# HTTP 请求默认超时(用于模型查询等测试场景
# 与 config.http_request_timeout 默认值保持一致
HTTP_REQUEST = 300 # 5分钟
# 数据库连接池获取超时
DB_POOL = 30

View File

@@ -144,6 +144,8 @@ class Config:
self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "60.0"))
self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0"))
self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0"))
# HTTP_REQUEST_TIMEOUT: 非流式请求整体超时(秒),默认 300 秒
self.http_request_timeout = float(os.getenv("HTTP_REQUEST_TIMEOUT", "300.0"))
# HTTP 连接池配置
# HTTP_MAX_CONNECTIONS: 最大连接数,影响并发能力
@@ -167,10 +169,9 @@ class Config:
# STREAM_PREFETCH_LINES: 预读行数,用于检测嵌套错误
# STREAM_STATS_DELAY: 统计记录延迟(秒),等待流完全关闭
# STREAM_FIRST_BYTE_TIMEOUT: 首字节超时(秒),等待首字节超过此时间触发故障转移
# 范围: 10-120 秒,默认 30 秒(必须小于 http_write_timeout 避免竞态)
self.stream_prefetch_lines = int(os.getenv("STREAM_PREFETCH_LINES", "5"))
self.stream_stats_delay = float(os.getenv("STREAM_STATS_DELAY", "0.1"))
self.stream_first_byte_timeout = self._parse_ttfb_timeout()
self.stream_first_byte_timeout = float(os.getenv("STREAM_FIRST_BYTE_TIMEOUT", "30.0"))
# 请求体读取超时(秒)
# REQUEST_BODY_TIMEOUT: 等待客户端发送完整请求体的超时时间
@@ -289,39 +290,6 @@ class Config:
# 最小 10 个保活连接,最大不超过 max_connections
return max(10, min(keepalive, self.http_max_connections))
def _parse_ttfb_timeout(self) -> float:
"""
解析 TTFB 超时配置,带错误处理和范围限制
TTFB (Time To First Byte) 用于检测慢响应的 Provider超时触发故障转移。
此值必须小于 http_write_timeout避免竞态条件。
Returns:
超时时间(秒),范围 10-120默认 30
"""
default_timeout = 30.0
min_timeout = 10.0
max_timeout = 120.0 # 必须小于 http_write_timeout (默认 60s) 的 2 倍
raw_value = os.getenv("STREAM_FIRST_BYTE_TIMEOUT", str(default_timeout))
try:
timeout = float(raw_value)
except ValueError:
# 延迟导入避免循环依赖Config 初始化时 logger 可能未就绪)
self._ttfb_config_warning = (
f"无效的 STREAM_FIRST_BYTE_TIMEOUT 配置 '{raw_value}',使用默认值 {default_timeout}"
)
return default_timeout
# 范围限制
clamped = max(min_timeout, min(max_timeout, timeout))
if clamped != timeout:
self._ttfb_config_warning = (
f"STREAM_FIRST_BYTE_TIMEOUT={timeout}秒超出范围 [{min_timeout}-{max_timeout}]"
f"已调整为 {clamped}"
)
return clamped
def _validate_pool_config(self) -> None:
"""验证连接池配置是否安全"""
total_per_worker = self.db_pool_size + self.db_max_overflow
@@ -369,10 +337,6 @@ class Config:
if hasattr(self, "_pool_config_warning") and self._pool_config_warning:
logger.warning(self._pool_config_warning)
# TTFB 超时配置警告
if hasattr(self, "_ttfb_config_warning") and self._ttfb_config_warning:
logger.warning(self._ttfb_config_warning)
# 管理员密码检查(必须在环境变量中设置)
if hasattr(self, "_missing_admin_password") and self._missing_admin_password:
logger.error("必须设置 ADMIN_PASSWORD 环境变量!")

View File

@@ -88,7 +88,6 @@ class CreateProviderRequest(BaseModel):
is_active: Optional[bool] = Field(True, description="是否启用")
concurrent_limit: Optional[int] = Field(None, ge=0, description="并发限制")
# 请求配置(从 Endpoint 迁移)
timeout: Optional[int] = Field(300, ge=1, le=600, description="请求超时(秒)")
max_retries: Optional[int] = Field(2, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
@@ -159,7 +158,6 @@ class UpdateProviderRequest(BaseModel):
is_active: Optional[bool] = None
concurrent_limit: Optional[int] = Field(None, ge=0)
# 请求配置(从 Endpoint 迁移)
timeout: Optional[int] = Field(None, ge=1, le=600, description="请求超时(秒)")
max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
config: Optional[Dict[str, Any]] = None

View File

@@ -403,7 +403,6 @@ class ProviderUpdate(BaseModel):
api_format: Optional[str] = None
base_url: Optional[str] = None
headers: Optional[dict] = None
timeout: Optional[int] = Field(None, ge=1, le=600)
max_retries: Optional[int] = Field(None, ge=0, le=10)
priority: Optional[int] = None
weight: Optional[float] = Field(None, gt=0)
@@ -423,7 +422,6 @@ class ProviderResponse(BaseModel):
api_format: str
base_url: str
headers: Optional[dict]
timeout: int
max_retries: int
priority: int
weight: float

View File

@@ -552,11 +552,10 @@ class Provider(Base):
concurrent_limit = Column(Integer, nullable=True) # 并发请求限制
# 请求配置(从 Endpoint 迁移,作为全局默认值)
# 超时 300 秒对于 LLM API 是合理的默认值
# - 大多数请求在 30 秒内完成
# - 复杂推理(如 Claude thinking可能需要 60-120 秒
# - 300 秒足够覆盖极端场景(如超长上下文、复杂工具调用
timeout = Column(Integer, default=300, nullable=True) # 请求超时(秒)
# [已废弃] timeout 字段不再使用,超时由环境变量控制
# - 非流式请求: HTTP_REQUEST_TIMEOUT默认 300
# - 流式首字节: STREAM_FIRST_BYTE_TIMEOUT默认 30 秒
timeout = Column(Integer, default=300, nullable=True) # [已废弃] 请求超时(秒
max_retries = Column(Integer, default=2, nullable=True) # 最大重试次数
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password, enabled}
@@ -604,7 +603,7 @@ class ProviderEndpoint(Base):
# 请求配置
header_rules = Column(JSON, nullable=True) # 请求头规则 [{action, key, value, from, to}]
timeout = Column(Integer, default=300) # 超时(秒)
timeout = Column(Integer, default=300) # [已废弃] 超时(秒),由环境变量控制
max_retries = Column(Integer, default=2) # 最大重试次数
# 状态

View File

@@ -37,7 +37,6 @@ class ProviderEndpointCreate(BaseModel):
description="请求头规则列表,支持 set/drop/rename 操作",
)
timeout: int = Field(default=300, ge=10, le=600, description="超时时间(秒)")
max_retries: int = Field(default=2, ge=0, le=10, description="最大重试次数")
# 额外配置
@@ -81,7 +80,6 @@ class ProviderEndpointUpdate(BaseModel):
description="请求头规则列表,支持 set/drop/rename 操作",
)
timeout: Optional[int] = Field(default=None, ge=10, le=600, description="超时时间(秒)")
max_retries: Optional[int] = Field(default=None, ge=0, le=10, description="最大重试次数")
is_active: Optional[bool] = Field(default=None, description="是否启用")
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置")
@@ -117,7 +115,6 @@ class ProviderEndpointResponse(BaseModel):
default=None, description="请求头规则列表"
)
timeout: int
max_retries: int
# 状态
@@ -619,7 +616,6 @@ class ProviderWithEndpointsSummary(BaseModel):
quota_expires_at: Optional[datetime] = Field(default=None, description="配额过期时间")
# 请求配置(从 Endpoint 迁移)
timeout: Optional[int] = Field(default=300, description="请求超时(秒)")
max_retries: Optional[int] = Field(default=2, description="最大重试次数")
proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置")

View File

@@ -1703,11 +1703,9 @@ class UsageService:
Returns:
请求状态列表
"""
from src.models.database import ProviderEndpoint
now = datetime.now(timezone.utc)
# 构建基础查询,包含端点的 timeout 配置
# 构建基础查询
query = db.query(
Usage.id,
Usage.status,
@@ -1722,8 +1720,7 @@ class UsageService:
Usage.first_byte_time_ms, # 首字时间 (TTFB)
Usage.created_at,
Usage.provider_endpoint_id,
ProviderEndpoint.timeout.label("endpoint_timeout"),
).outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
)
# 管理员轮询:可附带 provider 与上游 key 名称(注意:不要在普通用户接口暴露上游 key 信息)
if include_admin_fields:
@@ -1751,8 +1748,8 @@ class UsageService:
timeout_ids = []
for r in records:
if r.status in ("pending", "streaming") and r.created_at:
# 使用端点配置的超时时间,若无则使用默认值
timeout_seconds = r.endpoint_timeout or default_timeout_seconds
# 使用全局配置的超时时间
timeout_seconds = default_timeout_seconds
# 处理时区:如果 created_at 没有时区信息,假定为 UTC
created_at = r.created_at