fix: parse nested image generation tasks
This commit is contained in:
@@ -9,6 +9,97 @@ import httpx
|
|||||||
from app.integrations.model_router import ModelRouter
|
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:
|
class OpenAICompatibleGateway:
|
||||||
"""Adapter for independently configured OpenAI-compatible model-pool items."""
|
"""Adapter for independently configured OpenAI-compatible model-pool items."""
|
||||||
|
|
||||||
@@ -75,7 +166,11 @@ class OpenAICompatibleGateway:
|
|||||||
json=payload,
|
json=payload,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
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(
|
async def edit_image(
|
||||||
self,
|
self,
|
||||||
@@ -128,8 +223,8 @@ class OpenAICompatibleGateway:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
created = response.json()
|
created = response.json()
|
||||||
task_id = created.get("task_id") if isinstance(created, dict) else None
|
task_id = extract_task_id(created)
|
||||||
if not task_id:
|
if task_id is None:
|
||||||
return created
|
return created
|
||||||
return await self._poll_aigc_media(client, item, task_id, created)
|
return await self._poll_aigc_media(client, item, task_id, created)
|
||||||
|
|
||||||
@@ -148,7 +243,7 @@ class OpenAICompatibleGateway:
|
|||||||
params={"task_id": task_id},
|
params={"task_id": task_id},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
status = response.json()
|
status = unwrap_media_status(response.json())
|
||||||
if not status.get("is_final"):
|
if not status.get("is_final"):
|
||||||
continue
|
continue
|
||||||
if status.get("state") != "success":
|
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)
|
output = _find_generated_image(result)
|
||||||
if not output:
|
if not output:
|
||||||
|
from app.integrations.openai_compatible import safe_response_diagnostic
|
||||||
|
|
||||||
return RuntimeSettingsTestResult(
|
return RuntimeSettingsTestResult(
|
||||||
target=target,
|
target=target,
|
||||||
ok=False,
|
ok=False,
|
||||||
message="请求已结束,但响应中没有找到图片地址或图片数据。",
|
message="平台请求已结束,但返回体中没有找到可识别的任务 ID、图片地址或图片数据。",
|
||||||
|
details={"response_diagnostic": safe_response_diagnostic(result)},
|
||||||
)
|
)
|
||||||
verification = await _verify_generated_image(output)
|
verification = await _verify_generated_image(output)
|
||||||
details = {
|
details = {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from app.domain.models import (
|
|||||||
StructureAnalysis,
|
StructureAnalysis,
|
||||||
StyleDirection,
|
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:
|
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:", "")
|
mime = header.split(";", 1)[0].replace("data:", "")
|
||||||
return base64.b64decode(encoded), mime
|
return base64.b64decode(encoded), mime
|
||||||
if not url:
|
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:
|
async with httpx.AsyncClient(timeout=90, follow_redirects=True) as client:
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
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 (
|
from app.runtime_settings import (
|
||||||
RuntimeSettingsTestResult,
|
RuntimeSettingsTestResult,
|
||||||
_verify_generated_image,
|
_verify_generated_image,
|
||||||
@@ -234,3 +239,44 @@ async def test_generated_image_url_rejects_html_placeholder(monkeypatch) -> None
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="不是可识别的图片"):
|
with pytest.raises(ValueError, match="不是可识别的图片"):
|
||||||
await _verify_generated_image(("url", "https://cdn.example.com/not-image"))
|
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