fix: fail fast on gpt-image-2 timeouts
This commit is contained in:
@@ -24,6 +24,7 @@ PRODUCT_VIEW_MODEL=gpt-image-2
|
||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||
IMAGE_API_KEY=
|
||||
IMAGE_MODEL=gpt-image-2
|
||||
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||
GPT_IMAGE_MODEL=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
||||
|
||||
103
api/main.py
103
api/main.py
@@ -104,6 +104,7 @@ IMAGE_MODEL = GPT_IMAGE_MODEL
|
||||
PRODUCT_VIEW_MODEL = GPT_IMAGE_MODEL
|
||||
SUBJECT_ASSET_IMAGE_MODEL = GPT_IMAGE_MODEL
|
||||
SUBJECT_ASSET_IMAGE_MODELS = [GPT_IMAGE_MODEL]
|
||||
IMAGE_REQUEST_TIMEOUT_SECONDS = max(15, min(180, int(os.getenv("IMAGE_REQUEST_TIMEOUT_SECONDS", "60"))))
|
||||
PRODUCT_ASSET_MAX_SIDE = max(1024, int(os.getenv("PRODUCT_ASSET_MAX_SIDE", "1600")))
|
||||
PRODUCT_ASSET_MIN_LONG_SIDE = max(512, int(os.getenv("PRODUCT_ASSET_MIN_LONG_SIDE", "900")))
|
||||
PRODUCT_ASSET_MIN_SHORT_SIDE = max(320, int(os.getenv("PRODUCT_ASSET_MIN_SHORT_SIDE", "600")))
|
||||
@@ -3516,6 +3517,13 @@ def _image_failure_message(kind: str, attempts: int, last_err: str, capacity_see
|
||||
f"{kind} failed after {attempts} attempts: gpt-image-2 上游负载饱和,"
|
||||
f"已自动退避重试仍失败,请稍后点重试。最后错误:{last_err}"
|
||||
)
|
||||
if "timeout" in last_err.lower():
|
||||
return (
|
||||
f"{kind} failed after {attempts} attempts: gpt-image-2 图片网关响应超时"
|
||||
f"(单次 {IMAGE_REQUEST_TIMEOUT_SECONDS}s),模型未更改。"
|
||||
f"请检查 {IMAGE_BASE_URL or LLM_BASE_URL or 'image gateway'} 的 gpt-image-2 上游渠道或稍后重试。"
|
||||
f"最后错误:{last_err}"
|
||||
)
|
||||
if _image_is_transport_error(last_err):
|
||||
return (
|
||||
f"{kind} failed after {attempts} attempts: 图片网关网络/DNS 连接失败,"
|
||||
@@ -3542,6 +3550,38 @@ def _image_endpoint(path: str) -> str:
|
||||
return f"{base}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _image_generation_response(prompt: str, model: str) -> dict:
|
||||
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||
r = client.post(
|
||||
_image_endpoint("/images/generations"),
|
||||
headers={"Authorization": f"Bearer {IMAGE_API_KEY}"},
|
||||
json={"model": model, "prompt": prompt, "n": 1},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _image_should_retry(
|
||||
attempt: int,
|
||||
total_attempts: int,
|
||||
status_code: int,
|
||||
body: str,
|
||||
last_err: str,
|
||||
next_mode_changed: bool = False,
|
||||
) -> bool:
|
||||
if attempt >= total_attempts - 1:
|
||||
return False
|
||||
if next_mode_changed and status_code not in (401, 403):
|
||||
if status_code == 0 and _image_is_transport_error(last_err):
|
||||
return False
|
||||
return True
|
||||
if status_code in (400, 401, 403, 404):
|
||||
return False
|
||||
if status_code == 0 and _image_is_transport_error(last_err):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _prepare_image_edit_bytes(image_path: Path, max_side: int) -> bytes:
|
||||
import io as _io
|
||||
from PIL import Image as _PILImage
|
||||
@@ -3590,14 +3630,16 @@ def _image_edit_call(
|
||||
resp_data: dict = {}
|
||||
effective_mode = "edit"
|
||||
capacity_seen = False
|
||||
attempts_done = 0
|
||||
for attempt, current_mode in enumerate(plan):
|
||||
attempts_done = attempt + 1
|
||||
current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
|
||||
status_code = 0
|
||||
body = ""
|
||||
retry_after: str | None = None
|
||||
try:
|
||||
if current_mode == "edit":
|
||||
with ai_http_client(timeout=120) as client:
|
||||
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||
r = client.post(
|
||||
_image_endpoint("/images/edits"),
|
||||
headers={
|
||||
@@ -3616,8 +3658,7 @@ def _image_edit_call(
|
||||
r.raise_for_status()
|
||||
resp_data = r.json()
|
||||
else:
|
||||
resp = image_llm().images.generate(model=current_model, prompt=prompt, n=1)
|
||||
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
||||
resp_data = _image_generation_response(prompt, current_model)
|
||||
if resp_data.get("data"):
|
||||
effective_mode = current_mode
|
||||
model = current_model # 记录实际成功的 model
|
||||
@@ -3636,19 +3677,22 @@ def _image_edit_call(
|
||||
except Exception as e:
|
||||
last_err = f"{type(e).__name__}: {e} · model={current_model}"
|
||||
|
||||
if attempt < len(plan) - 1:
|
||||
next_mode_changed = attempt < len(plan) - 1 and plan[attempt + 1] != current_mode
|
||||
if _image_should_retry(attempt, len(plan), status_code, body, last_err, next_mode_changed):
|
||||
tag = f"retry {attempt + 1}/{len(plan)} → {GPT_IMAGE_MODEL}"
|
||||
delay = _image_retry_delay(attempt, status_code, body, retry_after)
|
||||
print(f"[image edit {tag}, sleep {delay:.0f}s] {last_err}", flush=True)
|
||||
_time.sleep(delay)
|
||||
else:
|
||||
break
|
||||
|
||||
data_arr = resp_data.get("data", [])
|
||||
if not data_arr:
|
||||
raise RuntimeError(_image_failure_message("image edit", len(plan), last_err, capacity_seen))
|
||||
raise RuntimeError(_image_failure_message("image edit", attempts_done, last_err, capacity_seen))
|
||||
item = data_arr[0]
|
||||
b64 = item.get("b64_json")
|
||||
if not b64 and item.get("url"):
|
||||
with ai_http_client(timeout=120) as client:
|
||||
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||
image_resp = client.get(item["url"])
|
||||
image_resp.raise_for_status()
|
||||
return image_resp.content, effective_mode
|
||||
@@ -3666,35 +3710,51 @@ def _image_text_call(
|
||||
"""Text-only image generation. 生图模型强制使用 gpt-image-2。"""
|
||||
import base64 as b64lib
|
||||
import time as _time
|
||||
import httpx
|
||||
if not IMAGE_API_KEY:
|
||||
raise RuntimeError("IMAGE_API_KEY 或 LLM_API_KEY 未配置")
|
||||
models_cycle = [GPT_IMAGE_MODEL]
|
||||
last_err = ""
|
||||
resp_data: dict = {}
|
||||
capacity_seen = False
|
||||
attempts_done = 0
|
||||
for attempt in range(max_attempts):
|
||||
attempts_done = attempt + 1
|
||||
current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
|
||||
status_code = 0
|
||||
body = ""
|
||||
retry_after: str | None = None
|
||||
try:
|
||||
resp = image_llm().images.generate(model=current_model, prompt=prompt, n=1)
|
||||
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
||||
resp_data = _image_generation_response(prompt, current_model)
|
||||
if resp_data.get("data"):
|
||||
b64 = resp_data["data"][0].get("b64_json")
|
||||
item = resp_data["data"][0]
|
||||
b64 = item.get("b64_json")
|
||||
if b64:
|
||||
return b64lib.b64decode(b64), "text"
|
||||
if item.get("url"):
|
||||
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||
image_resp = client.get(item["url"])
|
||||
image_resp.raise_for_status()
|
||||
return image_resp.content, "text"
|
||||
err_obj = resp_data.get("error") or {}
|
||||
last_err = f"empty data · {err_obj.get('code', '')} · {str(err_obj.get('message', ''))[:200]} · model={current_model}"
|
||||
except httpx.HTTPStatusError as e:
|
||||
body = e.response.text
|
||||
status_code = e.response.status_code
|
||||
retry_after = e.response.headers.get("retry-after")
|
||||
capacity_seen = capacity_seen or _image_is_capacity_error(status_code, body)
|
||||
last_err = f"HTTP {status_code}: {body[:200]} · model={current_model}"
|
||||
except Exception as e:
|
||||
last_err = f"{type(e).__name__}: {e} · model={current_model}"
|
||||
body = str(e)
|
||||
status_code = 429 if "429" in body or "saturated" in body.lower() or "饱和" in body else 0
|
||||
capacity_seen = capacity_seen or _image_is_capacity_error(status_code, body)
|
||||
if attempt < max_attempts - 1:
|
||||
delay = _image_retry_delay(attempt, status_code, body)
|
||||
if _image_should_retry(attempt, max_attempts, status_code, body, last_err):
|
||||
delay = _image_retry_delay(attempt, status_code, body, retry_after)
|
||||
print(f"[image text retry {attempt + 1}/{max_attempts} → {GPT_IMAGE_MODEL}, sleep {delay:.0f}s] {last_err}", flush=True)
|
||||
_time.sleep(delay)
|
||||
raise RuntimeError(_image_failure_message("image text", max_attempts, last_err, capacity_seen))
|
||||
else:
|
||||
break
|
||||
raise RuntimeError(_image_failure_message("image text", attempts_done, last_err, capacity_seen))
|
||||
|
||||
|
||||
def _image_path_to_data_url(path: Path) -> str:
|
||||
@@ -4054,6 +4114,7 @@ def health() -> dict:
|
||||
"product_view": PRODUCT_VIEW_MODEL,
|
||||
"image": IMAGE_MODEL,
|
||||
"image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default",
|
||||
"image_request_timeout_seconds": IMAGE_REQUEST_TIMEOUT_SECONDS,
|
||||
"ai_proxy_configured": bool(AI_HTTP_PROXY),
|
||||
"image_fallbacks": [GPT_IMAGE_MODEL],
|
||||
"subject_image": SUBJECT_ASSET_IMAGE_MODEL,
|
||||
@@ -4394,7 +4455,9 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
||||
last_err = ""
|
||||
effective_mode = req.mode
|
||||
capacity_seen = False
|
||||
attempts_done = 0
|
||||
for attempt, current_mode in enumerate(plan):
|
||||
attempts_done = attempt + 1
|
||||
status_code = 0
|
||||
body = ""
|
||||
retry_after: str | None = None
|
||||
@@ -4402,7 +4465,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
||||
if current_mode == "edit":
|
||||
if img_bytes_in is None:
|
||||
raise RuntimeError("edit mode reference image missing")
|
||||
with ai_http_client(timeout=120) as client:
|
||||
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||
r = client.post(
|
||||
_image_endpoint("/images/edits"),
|
||||
headers={
|
||||
@@ -4415,8 +4478,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
||||
resp_data = r.json()
|
||||
else:
|
||||
# text-only
|
||||
resp = image_llm().images.generate(model=model, prompt=full_prompt, n=1)
|
||||
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
||||
resp_data = _image_generation_response(full_prompt, model)
|
||||
|
||||
if resp_data.get("data"):
|
||||
effective_mode = current_mode
|
||||
@@ -4442,22 +4504,25 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
||||
except Exception as e:
|
||||
last_err = f"{type(e).__name__}: {e}"
|
||||
|
||||
if attempt < len(plan) - 1:
|
||||
next_mode_changed = attempt < len(plan) - 1 and plan[attempt + 1] != current_mode
|
||||
if _image_should_retry(attempt, len(plan), status_code, body, last_err, next_mode_changed):
|
||||
next_mode = plan[attempt + 1]
|
||||
tag = f"fallback → {next_mode}" if next_mode != current_mode else f"retry {attempt + 1}/{len(plan)}"
|
||||
print(f"[image gen {tag}] {last_err}", flush=True)
|
||||
_time.sleep(_image_retry_delay(attempt, status_code, body, retry_after))
|
||||
else:
|
||||
break
|
||||
|
||||
data_arr = resp_data.get("data", [])
|
||||
if not data_arr:
|
||||
raise HTTPException(503 if capacity_seen else 500, _image_failure_message("image gen", len(plan), last_err, capacity_seen))
|
||||
raise HTTPException(503 if capacity_seen else 500, _image_failure_message("image gen", attempts_done, last_err, capacity_seen))
|
||||
|
||||
item = data_arr[0]
|
||||
b64 = item.get("b64_json")
|
||||
if b64:
|
||||
out_bytes = b64lib.b64decode(b64)
|
||||
elif item.get("url"):
|
||||
with ai_http_client(timeout=120) as client:
|
||||
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||
image_resp = client.get(item["url"])
|
||||
image_resp.raise_for_status()
|
||||
out_bytes = image_resp.content
|
||||
|
||||
Reference in New Issue
Block a user