feat(ai): enforce direct image edit contract

This commit is contained in:
chengma
2026-07-20 17:52:59 +08:00
parent 61db0f4ed6
commit 2a2cbad7bd
11 changed files with 382 additions and 94 deletions
+47 -5
View File
@@ -69,10 +69,10 @@ class AITests(TempDirMixin, unittest.TestCase):
"name": "Image",
"category": "image",
"enabled": True,
"url": "https://example.invalid/v1/chat/completions",
"url": "https://example.invalid/v1",
"model": "image-model",
"api_key": "sk-image-secret",
"api_type": "auto",
"api_type": "images_edits",
"connect_timeout_seconds": 1,
"timeout_seconds": 1,
"extra_body": {},
@@ -378,9 +378,13 @@ class AITests(TempDirMixin, unittest.TestCase):
b64_image = base64.b64encode(generated.getvalue()).decode("ascii")
def fake_urlopen(request, timeout=None):
body = json.loads(request.data.decode("utf-8"))
self.assertEqual("image-model", body["model"])
self.assertIn("目标分辨率:512", body["messages"][0]["content"][0]["text"])
body = request.data
self.assertIn(b'name="model"', body)
self.assertIn(b"image-model", body)
self.assertIn(b'name="image[]"', body)
self.assertIn(b'name="n"', body)
self.assertIn(b"\r\n1\r\n", body)
self.assertIn("multipart/form-data", request.headers["Content-type"])
return _Response({"data": [{"b64_json": b64_image}]})
with mock.patch("app.ai.urllib.request.urlopen", side_effect=fake_urlopen):
@@ -399,6 +403,44 @@ class AITests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_image_edit_body_keeps_repeated_image_fields_in_order(self):
with self.make_temp_dir() as temp_dir:
first = os.path.join(temp_dir, "primary.jpg")
second = os.path.join(temp_dir, "reference.png")
with open(first, "wb") as fh:
fh.write(b"first-image")
with open(second, "wb") as fh:
fh.write(b"second-image")
body, content_type = ai._image_edit_body(
{"model": "image-model", "extra_body": {"quality": "high"}},
"生成商品图",
[first, second],
"1k",
)
self.assertIn("multipart/form-data", content_type)
self.assertEqual(2, body.count(b'name="image[]"'))
self.assertLess(body.index(b"primary.jpg"), body.index(b"reference.png"))
self.assertIn(b'name="n"', body)
self.assertIn(b"\r\n1\r\n", body)
self.assert_removed(temp_dir)
def test_direct_image_response_only_accepts_openai_data_fields(self):
encoded = base64.b64encode(b"image-bytes").decode("ascii")
self.assertEqual(encoded, ai._find_image_ref({"data": [{"b64_json": encoded}]}))
self.assertEqual(
"https://images.example.com/new.png",
ai._find_image_ref({"data": [{"url": "https://images.example.com/new.png"}]}),
)
self.assertIsNone(ai._find_image_ref({"choices": [{"image_url": "x"}]}))
with self.assertRaisesRegex(ai.AIError, "OpenAI 图片编辑接口"):
ai._extract_image_bytes({"message": {"url": "https://bad.example/x"}}, {}, {})
with self.assertRaisesRegex(ai.AIError, "只允许 http/https"):
ai._extract_image_bytes({"data": [{"url": "file:///tmp/image.png"}]}, {}, {})
def test_cmhub_gen_title_uses_alias_and_emits_metadata(self):
with self.make_temp_dir() as temp_dir:
cfg, key_path = self._cmhub_config(temp_dir)
+34
View File
@@ -635,6 +635,40 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
),
)
def test_image_model_config_check_keeps_legacy_models_but_blocks_them_for_generation(self):
with self.make_temp_dir() as temp_dir:
models_path = os.path.join(temp_dir, "ai_models.json")
config = appconfig.default_ai_models_config()
legacy_image = config["models"][1]
legacy_image.update(
{
"url": "https://legacy.example.com/v1/chat/completions",
"model": "legacy-image",
"api_key": "sk-legacy-secret",
"api_type": "auto",
}
)
appconfig.save_ai_models_config(config, path=models_path)
loaded = appconfig.get_model("Nano Banana 2", path=models_path)
self.assertEqual("auto", loaded["api_type"])
result = appconfig.check_image_model_config("Nano Banana 2", path=models_path)
self.assertFalse(result["ok"])
self.assertTrue(result["check_only"])
self.assertIn("OpenAI 图片编辑接口", result["error"])
self.assertNotIn("sk-legacy-secret", result["error"])
appconfig.update_ai_model(
"Nano Banana 2",
path=models_path,
api_type="images_edits",
)
self.assertTrue(
appconfig.check_image_model_config("Nano Banana 2", path=models_path)["ok"]
)
self.assert_removed(temp_dir)
def test_ai_model_test_uses_resolved_base_url(self):
with self.make_temp_dir() as temp_dir:
models_path = os.path.join(temp_dir, "ai_models.json")
+74 -1
View File
@@ -2232,6 +2232,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
models_path = cfg["ai_models_path"]
statuses = []
appconfig.save_ai_models_config(
{
"models": [
@@ -2264,7 +2265,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
path=models_path,
)
tab = SettingsTab(config=cfg, ai_models_path=models_path)
tab = SettingsTab(
config=cfg,
ai_models_path=models_path,
status_callback=statuses.append,
)
self.addCleanup(tab.close)
self.assertEqual(2, tab.model_combo.count())
@@ -2523,6 +2528,74 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_settings_tab_image_model_only_offers_image_edit_and_checks_locally(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
models_path = cfg["ai_models_path"]
statuses = []
appconfig.save_ai_models_config(
{
"models": [
{
"name": "Text A",
"category": "text",
"enabled": True,
"url": "https://example.invalid/v1",
"model": "text-model",
"api_key": "sk-text",
"api_type": "chat",
"connect_timeout_seconds": 30,
"timeout_seconds": 0,
"extra_body": {},
},
{
"name": "Legacy Image",
"category": "image",
"enabled": True,
"url": "https://example.invalid/v1/chat/completions",
"model": "image-model",
"api_key": "sk-image",
"api_type": "auto",
"connect_timeout_seconds": 30,
"timeout_seconds": 0,
"extra_body": {},
},
]
},
path=models_path,
)
tab = SettingsTab(
config=cfg,
ai_models_path=models_path,
status_callback=statuses.append,
)
self.addCleanup(tab.close)
tab.model_combo.setCurrentIndex(tab.model_combo.findData("Legacy Image"))
self.assertIn("当前图片模型不支持 OpenAI 图片编辑接口", tab.model_combo.currentText())
self.assertEqual("auto", tab.api_type_combo.currentData())
self.assertEqual("检查图片配置", tab.test_connection_button.text())
self.assertIn("当前不支持", tab.api_type_combo.currentText())
class FakeSignal:
def connect(self, callback):
self.callback = callback
class FakeThread:
def __init__(self):
self.finished = FakeSignal()
def start(self):
pass
with mock.patch("app.gui.run_worker", return_value=FakeThread()):
tab.test_connection()
self.assertTrue(tab.test_worker.check_image_config)
self.assertIn("正在检查图片模型配置", statuses[-1])
self.assert_removed(temp_dir)
def test_settings_tab_saves_role_generation_path_and_port_config(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)