feat: add multi-image vision analysis
This commit is contained in:
+121
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user