fix: parse nested image generation tasks
This commit is contained in:
@@ -9,6 +9,97 @@ import httpx
|
||||
from app.integrations.model_router import ModelRouter
|
||||
|
||||
|
||||
DIAGNOSTIC_SCALAR_KEYS = {
|
||||
"code",
|
||||
"error",
|
||||
"error_code",
|
||||
"error_message",
|
||||
"id",
|
||||
"is_final",
|
||||
"message",
|
||||
"msg",
|
||||
"state",
|
||||
"status",
|
||||
"status_group",
|
||||
"task_id",
|
||||
"type",
|
||||
}
|
||||
|
||||
|
||||
def safe_response_diagnostic(payload: Any) -> dict[str, Any]:
|
||||
"""Describe provider payload shape without persisting images, URLs, or secrets."""
|
||||
|
||||
signals: dict[str, Any] = {}
|
||||
|
||||
def shape(value: Any, path: str = "root", depth: int = 0) -> Any:
|
||||
if depth > 4:
|
||||
return type(value).__name__
|
||||
if isinstance(value, dict):
|
||||
result: dict[str, Any] = {}
|
||||
for key, child in list(value.items())[:40]:
|
||||
child_path = f"{path}.{key}"
|
||||
if key in DIAGNOSTIC_SCALAR_KEYS and isinstance(child, (str, int, float, bool)):
|
||||
rendered = str(child)
|
||||
signals[child_path] = rendered[:500]
|
||||
if key in {"url", "result_url", "b64_json", "data", "inlineData", "inline_data"}:
|
||||
if isinstance(child, list):
|
||||
result[key] = {"type": "list", "length": len(child)}
|
||||
elif isinstance(child, dict):
|
||||
result[key] = {"type": "object", "keys": list(child)[:30]}
|
||||
elif isinstance(child, str):
|
||||
result[key] = {"type": "string", "length": len(child)}
|
||||
else:
|
||||
result[key] = type(child).__name__
|
||||
if isinstance(child, (dict, list)):
|
||||
nested = shape(child, child_path, depth + 1)
|
||||
result[f"{key}_shape"] = nested
|
||||
continue
|
||||
result[key] = shape(child, child_path, depth + 1)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return {
|
||||
"type": "list",
|
||||
"length": len(value),
|
||||
"items": [shape(item, f"{path}[{index}]", depth + 1) for index, item in enumerate(value[:3])],
|
||||
}
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, str):
|
||||
return {"type": "string", "length": len(value)}
|
||||
return type(value).__name__
|
||||
|
||||
return {"shape": shape(payload), "signals": signals}
|
||||
|
||||
|
||||
def extract_task_id(payload: Any) -> str | int | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
direct = payload.get("task_id")
|
||||
if direct not in (None, ""):
|
||||
return direct
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
nested = data.get("task_id")
|
||||
if nested not in (None, ""):
|
||||
return nested
|
||||
for key in ("task_ids", "任务ids"):
|
||||
candidates = data.get(key)
|
||||
if isinstance(candidates, list) and candidates:
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
|
||||
def unwrap_media_status(payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("AIGC 状态接口返回的不是 JSON 对象。")
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict) and any(
|
||||
key in data for key in ("state", "is_final", "result_url", "error", "progress")
|
||||
):
|
||||
return {**payload, **data}
|
||||
return payload
|
||||
|
||||
|
||||
class OpenAICompatibleGateway:
|
||||
"""Adapter for independently configured OpenAI-compatible model-pool items."""
|
||||
|
||||
@@ -75,7 +166,11 @@ class OpenAICompatibleGateway:
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
created = response.json()
|
||||
task_id = extract_task_id(created)
|
||||
if task_id is not None:
|
||||
return await self._poll_aigc_media(client, item, task_id, created)
|
||||
return created
|
||||
|
||||
async def edit_image(
|
||||
self,
|
||||
@@ -128,8 +223,8 @@ class OpenAICompatibleGateway:
|
||||
)
|
||||
response.raise_for_status()
|
||||
created = response.json()
|
||||
task_id = created.get("task_id") if isinstance(created, dict) else None
|
||||
if not task_id:
|
||||
task_id = extract_task_id(created)
|
||||
if task_id is None:
|
||||
return created
|
||||
return await self._poll_aigc_media(client, item, task_id, created)
|
||||
|
||||
@@ -148,7 +243,7 @@ class OpenAICompatibleGateway:
|
||||
params={"task_id": task_id},
|
||||
)
|
||||
response.raise_for_status()
|
||||
status = response.json()
|
||||
status = unwrap_media_status(response.json())
|
||||
if not status.get("is_final"):
|
||||
continue
|
||||
if status.get("state") != "success":
|
||||
|
||||
@@ -872,10 +872,13 @@ async def test_image_generation(values: dict[str, Any], model_id: str) -> Runtim
|
||||
)
|
||||
output = _find_generated_image(result)
|
||||
if not output:
|
||||
from app.integrations.openai_compatible import safe_response_diagnostic
|
||||
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=False,
|
||||
message="请求已结束,但响应中没有找到图片地址或图片数据。",
|
||||
message="平台请求已结束,但返回体中没有找到可识别的任务 ID、图片地址或图片数据。",
|
||||
details={"response_diagnostic": safe_response_diagnostic(result)},
|
||||
)
|
||||
verification = await _verify_generated_image(output)
|
||||
details = {
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.domain.models import (
|
||||
StructureAnalysis,
|
||||
StyleDirection,
|
||||
)
|
||||
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
||||
from app.integrations.openai_compatible import OpenAICompatibleGateway, safe_response_diagnostic
|
||||
|
||||
|
||||
def crop_plan_preview(preview: bytes, region: PlanRegion | None) -> bytes:
|
||||
@@ -302,7 +302,8 @@ async def generated_image_bytes(payload: dict[str, Any]) -> tuple[bytes, str]:
|
||||
mime = header.split(";", 1)[0].replace("data:", "")
|
||||
return base64.b64decode(encoded), mime
|
||||
if not url:
|
||||
raise ValueError("生图模型返回了成功响应,但没有图片地址或图片数据。")
|
||||
diagnostic = json.dumps(safe_response_diagnostic(payload), ensure_ascii=False)
|
||||
raise ValueError(f"平台返回体中没有可识别的图片结果。脱敏诊断:{diagnostic}")
|
||||
async with httpx.AsyncClient(timeout=90, follow_redirects=True) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
||||
from app.integrations.openai_compatible import (
|
||||
extract_task_id,
|
||||
safe_response_diagnostic,
|
||||
unwrap_media_status,
|
||||
)
|
||||
from app.runtime_settings import (
|
||||
RuntimeSettingsTestResult,
|
||||
_verify_generated_image,
|
||||
@@ -234,3 +239,44 @@ async def test_generated_image_url_rejects_html_placeholder(monkeypatch) -> None
|
||||
|
||||
with pytest.raises(ValueError, match="不是可识别的图片"):
|
||||
await _verify_generated_image(("url", "https://cdn.example.com/not-image"))
|
||||
|
||||
|
||||
def test_response_diagnostic_keeps_errors_but_redacts_payloads() -> None:
|
||||
diagnostic = safe_response_diagnostic(
|
||||
{
|
||||
"code": 402,
|
||||
"error": {"message": "insufficient balance"},
|
||||
"data": [{"b64_json": "secret-image-bytes", "url": "https://private.example"}],
|
||||
}
|
||||
)
|
||||
|
||||
rendered = str(diagnostic)
|
||||
assert "insufficient balance" in rendered
|
||||
assert "secret-image-bytes" not in rendered
|
||||
assert "https://private.example" not in rendered
|
||||
|
||||
|
||||
def test_extract_task_id_accepts_aggregator_nested_response() -> None:
|
||||
payload = {
|
||||
"code": 200,
|
||||
"data": {"task_id": 91584074, "task_ids": [91584074]},
|
||||
"msg": "Task created successfully",
|
||||
}
|
||||
|
||||
assert extract_task_id(payload) == 91584074
|
||||
|
||||
|
||||
def test_unwrap_media_status_accepts_nested_data() -> None:
|
||||
payload = {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"state": "success",
|
||||
"is_final": True,
|
||||
"result_url": "https://cdn.example.com/result.png",
|
||||
},
|
||||
}
|
||||
|
||||
status = unwrap_media_status(payload)
|
||||
|
||||
assert status["is_final"] is True
|
||||
assert status["result_url"].endswith("result.png")
|
||||
|
||||
Reference in New Issue
Block a user