fix: verify and preview generated test images
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -353,6 +354,7 @@ class EncryptedSettingsStore:
|
||||
document["tests"][result.target] = {
|
||||
"ok": result.ok,
|
||||
"message": result.message,
|
||||
"details": result.details,
|
||||
"fingerprint": self.test_fingerprint(result.target, values or self.merged_values()),
|
||||
}
|
||||
self.save_document(document)
|
||||
@@ -486,15 +488,19 @@ class EncryptedSettingsStore:
|
||||
if mode == "manual":
|
||||
selected_id = str(values.get(selected_key, ""))
|
||||
selected = models.get(selected_id)
|
||||
test_target = f"generation:{selected_id}" if capability == "image_generation" else f"model:{selected_id}"
|
||||
if not selected_id:
|
||||
model_missing.append(f"手动选择{label}模型")
|
||||
elif not selected or capability not in selected.get("capabilities", []):
|
||||
model_missing.append(f"{label}模型已删除、已停用或能力不匹配")
|
||||
elif not tests.get(f"model:{selected_id}", {}).get("ok"):
|
||||
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过测试")
|
||||
elif not tests.get(test_target, {}).get("ok"):
|
||||
requirement = "真实试生成" if capability == "image_generation" else "连接测试"
|
||||
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过{requirement}")
|
||||
elif mode == "auto":
|
||||
if not any(tests.get(f"model:{item['id']}", {}).get("ok") for item in eligible):
|
||||
model_untested.append(f"{label}自动路由没有已测试的候选模型")
|
||||
prefix = "generation" if capability == "image_generation" else "model"
|
||||
if not any(tests.get(f"{prefix}:{item['id']}", {}).get("ok") for item in eligible):
|
||||
requirement = "真实试生成" if capability == "image_generation" else "连接测试"
|
||||
model_untested.append(f"{label}自动路由没有已通过{requirement}的候选模型")
|
||||
else:
|
||||
model_missing.append(f"{label}路由方式无效")
|
||||
|
||||
@@ -871,13 +877,22 @@ async def test_image_generation(values: dict[str, Any], model_id: str) -> Runtim
|
||||
ok=False,
|
||||
message="请求已结束,但响应中没有找到图片地址或图片数据。",
|
||||
)
|
||||
details = {"model_id": item["model_id"], "output_kind": output[0]}
|
||||
verification = await _verify_generated_image(output)
|
||||
details = {
|
||||
"model_id": item["model_id"],
|
||||
"output_kind": output[0],
|
||||
"verified_image": True,
|
||||
**verification,
|
||||
}
|
||||
if output[0] == "url":
|
||||
details["result_url"] = output[1]
|
||||
return RuntimeSettingsTestResult(
|
||||
target=target,
|
||||
ok=True,
|
||||
message=f"模型“{item.get('name', item['model_id'])}”已完成一次真实生图,端到端配置可用。",
|
||||
message=(
|
||||
f"模型“{item.get('name', item['model_id'])}”已生成并成功下载校验一张"
|
||||
f" {verification['image_format'].upper()} 图片({verification['byte_size'] // 1024} KB),端到端配置可用。"
|
||||
),
|
||||
details=details,
|
||||
)
|
||||
except (httpx.HTTPError, RuntimeError, ValueError) as exc:
|
||||
@@ -893,15 +908,73 @@ def _find_generated_image(payload: dict[str, Any]) -> tuple[str, str] | None:
|
||||
if row.get("url"):
|
||||
return "url", str(row["url"])
|
||||
if row.get("b64_json"):
|
||||
return "inline", ""
|
||||
return "inline", str(row["b64_json"])
|
||||
for candidate in payload.get("candidates", []) if isinstance(payload.get("candidates"), list) else []:
|
||||
parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else []
|
||||
for part in parts:
|
||||
if isinstance(part, dict) and (part.get("inlineData", {}).get("data") or part.get("inline_data", {}).get("data")):
|
||||
return "inline", ""
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
inline_data = part.get("inlineData") or part.get("inline_data") or {}
|
||||
if isinstance(inline_data, dict) and inline_data.get("data"):
|
||||
return "inline", str(inline_data["data"])
|
||||
return None
|
||||
|
||||
|
||||
async def _verify_generated_image(output: tuple[str, str]) -> dict[str, Any]:
|
||||
kind, value = output
|
||||
content_type = ""
|
||||
if kind == "url":
|
||||
async with httpx.AsyncClient(timeout=45, follow_redirects=True) as client:
|
||||
response = await client.get(value)
|
||||
response.raise_for_status()
|
||||
image_bytes = response.content
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
elif kind == "inline":
|
||||
encoded = value.split(",", 1)[1] if value.startswith("data:") and "," in value else value
|
||||
try:
|
||||
image_bytes = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ValueError("平台返回了无法解码的 Base64 图片数据") from exc
|
||||
else:
|
||||
raise ValueError(f"不支持的图片输出类型:{kind}")
|
||||
|
||||
if len(image_bytes) < 1024:
|
||||
raise ValueError(f"平台返回的图片文件过小({len(image_bytes)} 字节),不能视为有效生图")
|
||||
if len(image_bytes) > 25 * 1024 * 1024:
|
||||
raise ValueError("平台返回的测试图片超过 25 MB,已拒绝继续处理")
|
||||
image_format = _detect_image_format(image_bytes)
|
||||
if not image_format:
|
||||
description = content_type or "未知内容类型"
|
||||
raise ValueError(f"结果地址可以访问,但下载内容不是可识别的图片({description})")
|
||||
if content_type and not content_type.startswith("image/") and content_type != "application/octet-stream":
|
||||
raise ValueError(f"结果地址返回了非图片内容类型:{content_type}")
|
||||
return {
|
||||
"byte_size": len(image_bytes),
|
||||
"content_type": content_type or f"image/{image_format}",
|
||||
"image_format": image_format,
|
||||
}
|
||||
|
||||
|
||||
def _detect_image_format(content: bytes) -> str:
|
||||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "png"
|
||||
if content.startswith(b"\xff\xd8\xff"):
|
||||
return "jpeg"
|
||||
if content.startswith((b"GIF87a", b"GIF89a")):
|
||||
return "gif"
|
||||
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
|
||||
return "webp"
|
||||
if content.startswith(b"BM"):
|
||||
return "bmp"
|
||||
if len(content) >= 12 and content[4:8] == b"ftyp":
|
||||
brand = content[8:12]
|
||||
if brand in {b"avif", b"avis"}:
|
||||
return "avif"
|
||||
if brand in {b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"}:
|
||||
return "heic"
|
||||
return ""
|
||||
|
||||
|
||||
async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||||
mode = values.get("gpu_mode", "disabled")
|
||||
if mode == "disabled":
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
||||
from app.runtime_settings import RuntimeSettingsTestResult, test_model_pool_item as run_model_pool_test
|
||||
from app.runtime_settings import (
|
||||
RuntimeSettingsTestResult,
|
||||
_verify_generated_image,
|
||||
test_model_pool_item as run_model_pool_test,
|
||||
)
|
||||
|
||||
|
||||
def image_values(profile: str, model_id: str) -> dict:
|
||||
@@ -175,3 +179,58 @@ async def test_generation_endpoint_records_definitive_model_result(monkeypatch)
|
||||
assert [item.target for item in store.recorded] == ["generation:image", "model:image"]
|
||||
assert store.recorded[1].ok is True
|
||||
assert "真实生图端到端验证" in store.recorded[1].message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_image_url_must_download_as_real_image(monkeypatch) -> None:
|
||||
png_bytes = b"\x89PNG\r\n\x1a\n" + b"x" * 2048
|
||||
|
||||
class FakeResponse:
|
||||
content = png_bytes
|
||||
headers = {"content-type": "image/png"}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, url: str) -> FakeResponse:
|
||||
assert url == "https://cdn.example.com/verified.png"
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.runtime_settings.httpx.AsyncClient", lambda **kwargs: FakeClient())
|
||||
|
||||
details = await _verify_generated_image(("url", "https://cdn.example.com/verified.png"))
|
||||
|
||||
assert details["image_format"] == "png"
|
||||
assert details["byte_size"] == len(png_bytes)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generated_image_url_rejects_html_placeholder(monkeypatch) -> None:
|
||||
class FakeResponse:
|
||||
content = b"<html><body>not an image</body></html>" + b"x" * 2048
|
||||
headers = {"content-type": "text/html"}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
class FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, url: str) -> FakeResponse:
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.runtime_settings.httpx.AsyncClient", lambda **kwargs: FakeClient())
|
||||
|
||||
with pytest.raises(ValueError, match="不是可识别的图片"):
|
||||
await _verify_generated_image(("url", "https://cdn.example.com/not-image"))
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_readiness_requires_configuration_and_successful_tests(tmp_path: Path) -
|
||||
)
|
||||
assert store.public_response().readiness.ready is False
|
||||
|
||||
for target in ("infrastructure", "storage", "model:planner", "model:designer"):
|
||||
for target in ("infrastructure", "storage", "model:planner", "model:designer", "generation:designer"):
|
||||
store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok"))
|
||||
|
||||
assert store.public_response().readiness.ready is True
|
||||
|
||||
Reference in New Issue
Block a user