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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user