fix(ai-outfit): per-resolution timeout + _to_int default fallback

- §8 dynamic timeout: add resolution_timeout() (512/1K/2K/4K ->
  180/240/360/600s); generate() uses it for the POST and image download.
  timeout_seconds now defaults to 0 = auto-by-resolution; an explicit
  positive value overrides. Validation allows 0, rejects negatives.
- _to_int now returns the supplied default on parse failure (was 0), so a
  bad connect_timeout_seconds falls back to 30 instead of failing as 0.
- Tests: +5 (resolution map, auto vs override timeout, connect fallback).
- tasks.md §19.1/§19.2: tick unit-test item, note runner is pure-logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 18:03:52 +08:00
co-authored by Claude Opus 4.8
parent 5b57a4d39a
commit 5ca615f7ae
3 changed files with 93 additions and 9 deletions
+34 -5
View File
@@ -28,6 +28,16 @@ SUPPORTED_API_TYPES = {
_BASE64_KEYS = {"image_base64", "base64", "b64_json", "data"}
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
# Default read timeout (seconds) per output resolution (docs/11-ai-outfit.md §8).
# Higher resolutions take longer to generate; a fixed timeout_seconds in the
# model config overrides this mapping.
RESOLUTION_TIMEOUTS = {"512": 180, "1K": 240, "2K": 360, "4K": 600}
def resolution_timeout(resolution, default=240):
"""Return the default read timeout in seconds for *resolution*."""
return RESOLUTION_TIMEOUTS.get(str(resolution).strip().upper(), default)
class AiImageServiceError(RuntimeError):
"""Base error for AI image generation service failures."""
@@ -45,7 +55,7 @@ class AiModelConfig:
model: str
api_key: str
api_type: str = API_AUTO
timeout_seconds: int = 240
timeout_seconds: int = 0 # 0 = 按分辨率自动(resolution_timeout)
connect_timeout_seconds: int = 30
extra_body: Any = field(default_factory=dict)
@@ -58,7 +68,7 @@ class AiModelConfig:
model=str(data.get("model", "") or ""),
api_key=str(data.get("api_key", "") or ""),
api_type=str(data.get("api_type", API_AUTO) or API_AUTO),
timeout_seconds=_to_int(data.get("timeout_seconds", 240), 240),
timeout_seconds=_parse_optional_timeout(data.get("timeout_seconds")),
connect_timeout_seconds=_to_int(data.get("connect_timeout_seconds", 30), 30),
extra_body=data.get("extra_body") or {},
)
@@ -84,7 +94,7 @@ def api_config_errors(config):
errors.append("缺少 api_key")
if cfg.api_type not in SUPPORTED_API_TYPES:
errors.append("api_type 不支持: {}".format(cfg.api_type))
if cfg.timeout_seconds <= 0:
if cfg.timeout_seconds < 0: # 0 = 按分辨率自动;负数 = 解析失败/非法
errors.append("timeout_seconds 必须大于 0")
if cfg.connect_timeout_seconds <= 0:
errors.append("connect_timeout_seconds 必须大于 0")
@@ -285,7 +295,12 @@ class ImageApiClient:
url = url.replace("{model}", self.config.model)
headers = {"Authorization": "Bearer {}".format(self.config.api_key)}
timeout = (self.config.connect_timeout_seconds, self.config.timeout_seconds)
read_timeout = (
self.config.timeout_seconds
if self.config.timeout_seconds > 0
else resolution_timeout(resolution)
)
timeout = (self.config.connect_timeout_seconds, read_timeout)
if api_type == API_IMAGES_EDITS:
data, files = build_multipart_fields(self.config, prompt, image_path, resolution)
@@ -310,7 +325,7 @@ class ImageApiClient:
image_bytes = extract_image_from_response(
payload,
session=self.session,
timeout=self.config.timeout_seconds,
timeout=read_timeout,
)
if not image_bytes:
raise AiImageServiceError("AI 响应中未找到图片")
@@ -327,7 +342,21 @@ def _to_int(value, default):
try:
return int(value or default)
except (TypeError, ValueError):
return default
def _parse_optional_timeout(value):
"""Parse an optional read timeout.
Absent/blank -> 0 (auto by resolution); an explicit positive value overrides
the per-resolution default; an unparseable value -> -1 so validation flags it.
"""
if value is None or value == "":
return 0
try:
return int(value)
except (TypeError, ValueError):
return -1
def _join_url(base_url, suffix):