fix: verify and preview generated test images

This commit is contained in:
Codex
2026-08-02 00:05:13 +08:00
parent 922f8f4f2a
commit 0eb2a86f36
7 changed files with 195 additions and 17 deletions
+27
View File
@@ -1660,6 +1660,33 @@ textarea:focus-visible,
color: var(--warning) !important; color: var(--warning) !important;
} }
.model-generation-result {
grid-column: 2 / -1;
display: flex;
align-items: flex-start;
gap: 12px;
}
.model-generation-result .model-test-message {
grid-column: auto;
flex: 1;
}
.model-generation-result a {
display: block;
flex: 0 0 96px;
}
.model-generation-result img {
display: block;
width: 96px;
height: 72px;
object-fit: cover;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-muted);
}
.model-routing { .model-routing {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
+23 -4
View File
@@ -452,7 +452,7 @@ export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCen
type ModelPoolPanelProps = { type ModelPoolPanelProps = {
draft: Record<string, unknown>; draft: Record<string, unknown>;
tests: Record<string, { ok: boolean; message: string }>; tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
busy: string | null; busy: string | null;
onChange: (key: string, value: unknown) => void; onChange: (key: string, value: unknown) => void;
onTest: (modelId: string) => void; onTest: (modelId: string) => void;
@@ -768,7 +768,7 @@ type ModelGroupProps = {
description: string; description: string;
category: ModelCategory; category: ModelCategory;
models: ModelPoolItem[]; models: ModelPoolItem[];
tests: Record<string, { ok: boolean; message: string }>; tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
busy: string | null; busy: string | null;
onAdd: () => void; onAdd: () => void;
onEdit: (model: ModelPoolItem) => void; onEdit: (model: ModelPoolItem) => void;
@@ -800,6 +800,9 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
const result = tests[target]; const result = tests[target];
const generationTarget = `generation:${model.id}`; const generationTarget = `generation:${model.id}`;
const generationResult = tests[generationTarget]; const generationResult = tests[generationTarget];
const generationImageUrl = typeof generationResult?.details?.result_url === "string"
? generationResult.details.result_url
: null;
return ( return (
<article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}> <article className={`model-row ${!model.enabled ? "disabled" : ""}`} key={model.id}>
<div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}> <div className={`model-state ${result?.ok ? "ok" : result ? "failed" : "untested"}`}>
@@ -820,7 +823,11 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
</button> </button>
{model.capabilities.includes("image_generation") ? ( {model.capabilities.includes("image_generation") ? (
<button className="button button-secondary compact" type="button" onClick={() => onTrial(model.id)} disabled={Boolean(busy) || !model.enabled} title="会消耗一次模型调用额度"> <button className="button button-secondary compact" type="button" onClick={() => onTrial(model.id)} disabled={Boolean(busy) || !model.enabled} title="会消耗一次模型调用额度">
{busy === generationTarget ? <ArrowClockwiseIcon className="spin" size={14} /> : <ImageIcon size={14} />} {busy === generationTarget
? <ArrowClockwiseIcon className="spin" size={14} />
: generationResult?.ok
? <CheckCircleIcon size={14} weight="fill" />
: <ImageIcon size={14} />}
</button> </button>
) : null} ) : null}
@@ -832,7 +839,19 @@ function ModelGroup({ title, description, category, models, tests, busy, onAdd,
</button> </button>
</div> </div>
{result ? <p className={`model-test-message ${result.ok ? "ok" : "failed"}`}>{result.message}</p> : <p className="model-test-message"> ID</p>} {result ? <p className={`model-test-message ${result.ok ? "ok" : "failed"}`}>{result.message}</p> : <p className="model-test-message"> ID</p>}
{generationResult ? <p className={`model-test-message ${generationResult.ok ? "ok" : "failed"}`}>{generationResult.message}</p> : null} {generationResult ? (
<div className="model-generation-result">
<p className={`model-test-message ${generationResult.ok ? "ok" : "failed"}`}>{generationResult.message}</p>
{generationResult.ok && generationImageUrl ? (
<a href={generationImageUrl} target="_blank" rel="noreferrer" title="打开原始测试图片">
{/* eslint-disable-next-line @next/next/no-img-element -- runtime model URLs are not known to Next Image. */}
<img src={generationImageUrl} alt={`${model.name} 真实试生成结果`} />
</a>
) : null}
</div>
) : model.capabilities.includes("image_generation") ? (
<p className="model-test-message"></p>
) : null}
</article> </article>
); );
})} })}
+1 -1
View File
@@ -40,7 +40,7 @@ export type SettingsResponse = {
categories: SettingCategory[]; categories: SettingCategory[];
values: Record<string, unknown>; values: Record<string, unknown>;
configured: Record<string, boolean>; configured: Record<string, boolean>;
tests: Record<string, { ok: boolean; message: string }>; tests: Record<string, { ok: boolean; message: string; details?: Record<string, unknown> }>;
readiness: SettingsReadiness; readiness: SettingsReadiness;
}; };
+1 -1
View File
@@ -40,7 +40,7 @@
每个模型提供两种测试: 每个模型提供两种测试:
- “验证连接”只检查地址、API Key、模型列表、协议和路径,不生图、不扣费。部分聚合平台不会在 `/v1/models` 中公开媒体模型 ID,此时不能据此判定模型不可用。 - “验证连接”只检查地址、API Key、模型列表、协议和路径,不生图、不扣费。部分聚合平台不会在 `/v1/models` 中公开媒体模型 ID,此时不能据此判定模型不可用。
- “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。其成功结果是生图模型端到端可用性的最终依据,并会自动将该模型标记为已通过测试。 - “试生成”会在用户确认后真实生成一张低成本测试图并消耗一次调用额度。只有结果文件可以实际下载、通过图片格式与有效字节校验后才算成功,设置页会显示图片缩略图;成功结果会自动将该模型标记为已通过测试。
总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式: 总调度模型必须由用户从大语言模型池中指定。空间理解和生图各自支持两种方式:
+82 -9
View File
@@ -1,4 +1,5 @@
import base64 import base64
import binascii
import hashlib import hashlib
import json import json
import os import os
@@ -353,6 +354,7 @@ class EncryptedSettingsStore:
document["tests"][result.target] = { document["tests"][result.target] = {
"ok": result.ok, "ok": result.ok,
"message": result.message, "message": result.message,
"details": result.details,
"fingerprint": self.test_fingerprint(result.target, values or self.merged_values()), "fingerprint": self.test_fingerprint(result.target, values or self.merged_values()),
} }
self.save_document(document) self.save_document(document)
@@ -486,15 +488,19 @@ class EncryptedSettingsStore:
if mode == "manual": if mode == "manual":
selected_id = str(values.get(selected_key, "")) selected_id = str(values.get(selected_key, ""))
selected = models.get(selected_id) selected = models.get(selected_id)
test_target = f"generation:{selected_id}" if capability == "image_generation" else f"model:{selected_id}"
if not selected_id: if not selected_id:
model_missing.append(f"手动选择{label}模型") model_missing.append(f"手动选择{label}模型")
elif not selected or capability not in selected.get("capabilities", []): elif not selected or capability not in selected.get("capabilities", []):
model_missing.append(f"{label}模型已删除、已停用或能力不匹配") model_missing.append(f"{label}模型已删除、已停用或能力不匹配")
elif not tests.get(f"model:{selected_id}", {}).get("ok"): elif not tests.get(test_target, {}).get("ok"):
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过测试") requirement = "真实试生成" if capability == "image_generation" else "连接测试"
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过{requirement}")
elif mode == "auto": elif mode == "auto":
if not any(tests.get(f"model:{item['id']}", {}).get("ok") for item in eligible): prefix = "generation" if capability == "image_generation" else "model"
model_untested.append(f"{label}自动路由没有已测试的候选模型") 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: else:
model_missing.append(f"{label}路由方式无效") model_missing.append(f"{label}路由方式无效")
@@ -871,13 +877,22 @@ async def test_image_generation(values: dict[str, Any], model_id: str) -> Runtim
ok=False, ok=False,
message="请求已结束,但响应中没有找到图片地址或图片数据。", 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": if output[0] == "url":
details["result_url"] = output[1] details["result_url"] = output[1]
return RuntimeSettingsTestResult( return RuntimeSettingsTestResult(
target=target, target=target,
ok=True, 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, details=details,
) )
except (httpx.HTTPError, RuntimeError, ValueError) as exc: 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"): if row.get("url"):
return "url", str(row["url"]) return "url", str(row["url"])
if row.get("b64_json"): 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 []: for candidate in payload.get("candidates", []) if isinstance(payload.get("candidates"), list) else []:
parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else [] parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else []
for part in parts: for part in parts:
if isinstance(part, dict) and (part.get("inlineData", {}).get("data") or part.get("inline_data", {}).get("data")): if not isinstance(part, dict):
return "inline", "" 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 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: async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult:
mode = values.get("gpu_mode", "disabled") mode = values.get("gpu_mode", "disabled")
if mode == "disabled": if mode == "disabled":
+60 -1
View File
@@ -1,7 +1,11 @@
import pytest import pytest
from app.integrations.openai_compatible import OpenAICompatibleGateway 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: 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 [item.target for item in store.recorded] == ["generation:image", "model:image"]
assert store.recorded[1].ok is True assert store.recorded[1].ok is True
assert "真实生图端到端验证" in store.recorded[1].message 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"))
+1 -1
View File
@@ -76,7 +76,7 @@ def test_readiness_requires_configuration_and_successful_tests(tmp_path: Path) -
) )
assert store.public_response().readiness.ready is False 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")) store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok"))
assert store.public_response().readiness.ready is True assert store.public_response().readiness.ready is True