From d91c1378345eca570ff508d2513d52841703fc37 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 2 Aug 2026 09:30:23 +0800 Subject: [PATCH] fix: parse nested image generation tasks --- .../api/app/integrations/openai_compatible.py | 103 +++++++++++++++++- services/api/app/runtime_settings.py | 5 +- services/api/app/services/design_pipeline.py | 5 +- services/api/tests/test_image_gateway.py | 46 ++++++++ 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/services/api/app/integrations/openai_compatible.py b/services/api/app/integrations/openai_compatible.py index 2dcf0a3..de4e0a5 100644 --- a/services/api/app/integrations/openai_compatible.py +++ b/services/api/app/integrations/openai_compatible.py @@ -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": diff --git a/services/api/app/runtime_settings.py b/services/api/app/runtime_settings.py index d580200..b0f1485 100644 --- a/services/api/app/runtime_settings.py +++ b/services/api/app/runtime_settings.py @@ -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 = { diff --git a/services/api/app/services/design_pipeline.py b/services/api/app/services/design_pipeline.py index 4399e1d..23569c4 100644 --- a/services/api/app/services/design_pipeline.py +++ b/services/api/app/services/design_pipeline.py @@ -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() diff --git a/services/api/tests/test_image_gateway.py b/services/api/tests/test_image_gateway.py index 08dbba2..1ec36b7 100644 --- a/services/api/tests/test_image_gateway.py +++ b/services/api/tests/test_image_gateway.py @@ -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")