feat: add multi-image vision analysis

This commit is contained in:
QiuSW
2026-07-16 14:13:19 +08:00
parent 0ea65d4df4
commit 5325eacdfe
29 changed files with 1013 additions and 51 deletions
+121 -2
View File
@@ -11,8 +11,18 @@ from apps.ai.admin import AiConfigAuditLogAdmin, AiModelAdmin, ModelAliasAdmin
from apps.ai.aliases import AliasNotFoundError, ModelCapabilityError, resolve_alias
from apps.ai.importers import import_ai_models_config
from apps.ai.models import AiConfigAuditLog, AiModel, ModelAlias
from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type
from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider
from apps.ai.providers import (
AiCapabilityError,
MultimodalImage,
ResolvedModel,
get_provider,
resolve_api_type,
)
from apps.ai.providers.openai_compatible import (
ChatCompletionsProvider,
GeminiProvider,
ImagesEditsProvider,
)
from apps.ai.providers.utils import image_request_timeout, resolution_to_size
@@ -255,6 +265,85 @@ class ChatCompletionsProviderTests(SimpleTestCase):
self.assertEqual(session.posts[0]["timeout"], (30, 600))
def test_analyze_images_builds_ordered_payload_and_preserves_full_text(self):
session = FakeSession(
FakeResponse(
{
"choices": [
{
"message": {
"content": "第一张是正面图。\n第二张是细节图。"
}
}
]
}
)
)
provider = ChatCompletionsProvider(session=session)
model = ResolvedModel(
name="Vision text",
url="https://api.vectorengine.ai/v1",
model="vision-model",
api_key="test-key",
api_type="chat",
)
result = provider.analyze_images(
"比较两张商品图",
model,
images=(
MultimodalImage(b"first-image", "image/jpeg"),
MultimodalImage(b"second-image", "image/png"),
),
parameters={"temperature": 0.2, "messages": []},
)
self.assertEqual(result.text, "第一张是正面图。\n第二张是细节图。")
self.assertEqual(result.titles, ())
content = session.posts[0]["json"]["messages"][0]["content"]
self.assertEqual(content[0], {"type": "text", "text": "比较两张商品图"})
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,"))
self.assertTrue(content[2]["image_url"]["url"].startswith("data:image/png;base64,"))
self.assertEqual(session.posts[0]["json"]["temperature"], 0.2)
def test_gemini_analyze_images_builds_ordered_inline_data(self):
session = FakeSession(
FakeResponse(
{
"candidates": [
{"content": {"parts": [{"text": "多图分析结果"}]}}
]
}
)
)
provider = GeminiProvider(session=session)
model = ResolvedModel(
name="Gemini vision",
url="https://gemini.example.com",
model="gemini-vision",
api_key="test-key",
api_type="gemini",
)
result = provider.analyze_images(
"理解这些图片",
model,
images=(
MultimodalImage(b"one", "image/webp"),
MultimodalImage(b"two", "image/jpeg"),
),
)
self.assertEqual(result.text, "多图分析结果")
parts = session.posts[0]["json"]["contents"][0]["parts"]
self.assertEqual(parts[0], {"text": "理解这些图片"})
self.assertEqual(parts[1]["inlineData"]["mimeType"], "image/webp")
self.assertEqual(parts[2]["inlineData"]["mimeType"], "image/jpeg")
self.assertEqual(
session.posts[0]["json"]["generationConfig"]["responseModalities"],
["TEXT"],
)
class ImagesEditsProviderTests(SimpleTestCase):
def test_generate_image_builds_multipart_request_and_parses_base64(self):
@@ -344,6 +433,13 @@ class ImagesEditsProviderTests(SimpleTestCase):
with self.assertRaises(AiCapabilityError):
provider.generate_text("Generate title", model)
with self.assertRaises(AiCapabilityError):
provider.analyze_images(
"Analyze image",
model,
images=(MultimodalImage(b"source-image"),),
)
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY)
class AiModelEncryptionTests(TestCase):
@@ -440,6 +536,29 @@ class AliasResolutionTests(TestCase):
self.assertEqual(resolved.model, "gpt-image-2")
self.assertIn("image", resolved.capabilities)
def test_resolve_vision_alias_requires_text_and_vision_capabilities(self):
ModelAlias.objects.create(
operation_type=ModelAlias.OperationType.VISION,
alias="vision-standard",
ai_model=self.text_model,
is_default=True,
)
resolved = resolve_alias(ModelAlias.OperationType.VISION)
self.assertEqual(resolved.model, "gpt-5.5")
self.assertTrue({"text", "vision"}.issubset(resolved.capabilities))
def test_resolve_vision_alias_rejects_vision_model_without_text(self):
ModelAlias.objects.create(
operation_type=ModelAlias.OperationType.VISION,
alias="bad-vision",
ai_model=self.image_model,
)
with self.assertRaises(ModelCapabilityError):
resolve_alias(ModelAlias.OperationType.VISION, "bad-vision")
def test_resolve_alias_rejects_capability_mismatch(self):
ModelAlias.objects.create(
operation_type=ModelAlias.OperationType.TITLE,