feat(ai): enforce direct image edit contract
This commit is contained in:
+47
-5
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user