import base64 import io from cryptography.fernet import Fernet from django.contrib.admin.sites import AdminSite from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings 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, MultimodalImage, ResolvedModel, get_provider, resolve_api_type, ) from apps.ai.providers.openai_compatible import ( ChatCompletionsProvider, GeminiProvider, ImagesEditsProvider, ImagesGenerationProvider, ) from apps.ai.providers.utils import image_request_timeout, resolution_to_size TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii") class FakeResponse: def __init__(self, payload, content=b""): self.payload = payload self.content = content def json(self): return self.payload def raise_for_status(self): return None class FakeSession: def __init__(self, *responses): self.responses = list(responses) self.posts = [] self.gets = [] self.trust_env = True def post(self, url, **kwargs): self.posts.append({"url": url, **kwargs}) return self.responses.pop(0) def get(self, url, **kwargs): self.gets.append({"url": url, **kwargs}) return self.responses.pop(0) class ProviderRegistryTests(SimpleTestCase): def test_auto_api_type_resolves_chat_provider_from_url(self): url = "https://api.vectorengine.ai/v1/chat/completions" self.assertEqual(resolve_api_type("auto", url), "chat") self.assertIsInstance(get_provider("auto", url), ChatCompletionsProvider) class ProviderUtilsTests(SimpleTestCase): def test_resolution_to_size_normalizes_case(self): self.assertEqual(resolution_to_size("1k"), "1024x1024") self.assertEqual(resolution_to_size("512px"), "512x512") @override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180) def test_image_request_timeout_caps_read_timeout_to_deadline(self): self.assertEqual(image_request_timeout(30, 0, "4K"), (30, 180)) self.assertEqual(image_request_timeout(30, 120, "4K"), (30, 120)) class ChatCompletionsProviderTests(SimpleTestCase): def test_generate_text_builds_chat_payload_and_cleans_titles(self): session = FakeSession( FakeResponse( { "choices": [ {"message": {"content": "1. Red Dress\n2. Blue Coat"}} ] } ) ) provider = ChatCompletionsProvider(session=session) model = ResolvedModel( name="GPT-5.5 text", url="https://api.vectorengine.ai/v1", model="gpt-5.5", api_key="test-key", api_type="chat", ) result = provider.generate_text( "Generate titles", model, parameters={"temperature": 0.2}, ) self.assertEqual(result.text, "Red Dress") self.assertEqual(result.titles, ("Red Dress", "Blue Coat")) request = session.posts[0] self.assertEqual( request["url"], "https://api.vectorengine.ai/v1/chat/completions", ) self.assertEqual(request["headers"]["Authorization"], "Bearer test-key") self.assertEqual(request["json"]["model"], "gpt-5.5") self.assertFalse(request["json"]["stream"]) self.assertEqual(request["json"]["temperature"], 0.2) def test_parameters_cannot_override_core_chat_payload_fields(self): session = FakeSession( FakeResponse( { "choices": [ {"message": {"content": "1. Safe Title"}} ] } ) ) provider = ChatCompletionsProvider(session=session) model = ResolvedModel( name="GPT-5.5 text", url="https://api.vectorengine.ai/v1", model="gpt-5.5", api_key="test-key", api_type="chat", extra_body={ "model": "bad-extra-model", "stream": True, "temperature": 0.1, }, ) provider.generate_text( "Generate titles", model, parameters={ "model": "gpt-image-2", "n": 5, "messages": [], "stream": True, "size": "4096x4096", "temperature": 0.2, }, ) payload = session.posts[0]["json"] self.assertEqual(payload["model"], "gpt-5.5") self.assertFalse(payload["stream"]) self.assertEqual( payload["messages"], [{"role": "user", "content": [{"type": "text", "text": "Generate titles"}]}], ) self.assertNotIn("n", payload) self.assertNotIn("size", payload) self.assertEqual(payload["temperature"], 0.2) def test_generate_image_parses_chat_multimodal_data_url(self): generated = b"generated-image" encoded = base64.b64encode(generated).decode("ascii") session = FakeSession( FakeResponse( { "choices": [ { "message": { "content": [ {"type": "text", "text": "done"}, { "type": "image_url", "image_url": { "url": f"data:image/png;base64,{encoded}" }, }, ] } } ] } ) ) provider = ChatCompletionsProvider(session=session) model = ResolvedModel( name="Nano Banana 2", url="https://api.vectorengine.ai/v1/chat/completions", model="gemini-3.1-flash-image-preview", api_key="test-key", api_type="auto", ) result = provider.generate_image( "Generate product image", model, image=b"input-image", image_mime_type="image/jpeg", ) self.assertEqual(result.image, generated) content = session.posts[0]["json"]["messages"][0]["content"] self.assertEqual(content[0], {"type": "text", "text": "Generate product image"}) self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,")) def test_generate_image_sends_multiple_chat_images_in_order(self): encoded = base64.b64encode(b"generated-image").decode("ascii") session = FakeSession( FakeResponse({"data": [{"b64_json": encoded}]}) ) provider = ChatCompletionsProvider(session=session) model = ResolvedModel( name="Nano Banana 2", url="https://api.vectorengine.ai/v1/chat/completions", model="gemini-3.1-flash-image-preview", api_key="test-key", api_type="chat", ) provider.generate_image( "Generate product image", model, images=( MultimodalImage(b"main-image", "image/jpeg", "main.jpg"), MultimodalImage(b"reference-image", "image/png", "reference.png"), ), ) content = session.posts[0]["json"]["messages"][0]["content"] self.assertEqual(content[0]["text"], "Generate product image") self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,")) self.assertTrue(content[2]["image_url"]["url"].startswith("data:image/png;base64,")) @override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180) def test_generate_image_caps_post_and_download_timeouts(self): session = FakeSession( FakeResponse( { "choices": [ { "message": { "content": [ { "type": "image_url", "image_url": {"url": "https://cdn.example.com/out.png"}, } ] } } ] } ), FakeResponse({}, content=b"generated-image"), ) provider = ChatCompletionsProvider(session=session) model = ResolvedModel( name="Slow image", url="https://api.vectorengine.ai/v1/chat/completions", model="image-model", api_key="test-key", api_type="chat", timeout_seconds=0, connect_timeout_seconds=30, ) result = provider.generate_image("Generate product image", model, resolution="4K") self.assertEqual(result.image, b"generated-image") self.assertEqual(session.posts[0]["timeout"], (30, 180)) self.assertEqual(session.gets[0]["timeout"], 180) @override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180) def test_generate_text_does_not_use_image_deadline(self): session = FakeSession( FakeResponse({"choices": [{"message": {"content": "1. Red Dress"}}]}) ) provider = ChatCompletionsProvider(session=session) model = ResolvedModel( name="Slow text", url="https://api.vectorengine.ai/v1/chat/completions", model="text-model", api_key="test-key", api_type="chat", timeout_seconds=0, connect_timeout_seconds=30, ) provider.generate_text("Generate titles", model, resolution="4K") 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 ImagesGenerationProviderTests(SimpleTestCase): def test_generate_image_sends_multiple_json_images_in_order(self): encoded = base64.b64encode(b"generated-image").decode("ascii") session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]})) provider = ImagesGenerationProvider(session=session) model = ResolvedModel( name="JSON image provider", url="https://images.example.test/v1/images/generations", model="image-model", api_key="test-key", api_type="images", ) provider.generate_image( "Use the first image as the main product", model, images=( MultimodalImage(b"main-image", "image/jpeg", "main.jpg"), MultimodalImage(b"reference-image", "image/png", "reference.png"), ), ) image_urls = session.posts[0]["json"]["image_urls"] self.assertEqual(len(image_urls), 2) self.assertTrue(image_urls[0].startswith("data:image/jpeg;base64,")) self.assertTrue(image_urls[1].startswith("data:image/png;base64,")) class ImagesEditsProviderTests(SimpleTestCase): def test_generate_image_builds_multipart_request_and_parses_base64(self): generated = b"edited-image" encoded = base64.b64encode(generated).decode("ascii") session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]})) provider = ImagesEditsProvider(session=session) model = ResolvedModel( name="GPT Image 2", url="https://api.vectorengine.ai/v1/images/edits", model="gpt-image-2", api_key="test-key", api_type="images_edits", ) result = provider.generate_image( "Replace background", model, image=b"source-image", image_mime_type="image/png", image_filename="source.png", resolution="1K", ) self.assertEqual(result.image, generated) request = session.posts[0] self.assertEqual(request["url"], "https://api.vectorengine.ai/v1/images/edits") self.assertEqual(request["headers"]["Authorization"], "Bearer test-key") self.assertEqual(request["data"]["model"], "gpt-image-2") self.assertEqual(request["data"]["size"], "1024x1024") self.assertEqual( request["files"]["image"], ("source.png", b"source-image", "image/png"), ) def test_generate_image_sends_multiple_multipart_images_in_order(self): encoded = base64.b64encode(b"edited-image").decode("ascii") session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]})) provider = ImagesEditsProvider(session=session) model = ResolvedModel( name="GPT Image 2", url="https://api.vectorengine.ai/v1/images/edits", model="gpt-image-2", api_key="test-key", api_type="images_edits", ) provider.generate_image( "Use the first image as the main product", model, images=( MultimodalImage(b"main-image", "image/jpeg", "main.jpg"), MultimodalImage(b"reference-image", "image/png", "reference.png"), ), ) self.assertEqual( session.posts[0]["files"], [ ("image", ("main.jpg", b"main-image", "image/jpeg")), ("image", ("reference.png", b"reference-image", "image/png")), ], ) def test_parameters_cannot_override_core_images_edits_fields(self): generated = b"edited-image" encoded = base64.b64encode(generated).decode("ascii") session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]})) provider = ImagesEditsProvider(session=session) model = ResolvedModel( name="GPT Image 2", url="https://api.vectorengine.ai/v1/images/edits", model="gpt-image-2", api_key="test-key", api_type="images_edits", extra_body={ "model": "bad-extra-model", "n": "9", "size": "4096x4096", "temperature": 0.3, }, ) provider.generate_image( "Replace background", model, image=b"source-image", resolution="1k", parameters={ "model": "bad-parameter-model", "n": "3", "size": "1x1", "temperature": 0.7, }, ) data = session.posts[0]["data"] self.assertEqual(data["model"], "gpt-image-2") self.assertEqual(data["n"], "1") self.assertEqual(data["size"], "1024x1024") self.assertEqual(data["temperature"], 0.7) def test_images_edits_requires_input_image(self): provider = ImagesEditsProvider(session=FakeSession()) model = ResolvedModel( name="GPT Image 2", url="https://api.vectorengine.ai/v1/images/edits", model="gpt-image-2", api_key="test-key", api_type="images_edits", ) with self.assertRaises(AiCapabilityError): provider.generate_image("Replace background", model) 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): def test_api_key_is_encrypted_and_resolved_model_decrypts_it(self): model = AiModel( name="GPT-5.5 text", url="https://api.vectorengine.ai/v1", model="gpt-5.5", api_type=AiModel.ApiType.CHAT, capabilities=["text"], ) model.set_api_key("sk-test-secret") model.save() self.assertNotIn("sk-test-secret", model.api_key_encrypted) self.assertTrue(model.api_key_encrypted.startswith("fernet:")) self.assertEqual(model.get_api_key(), "sk-test-secret") resolved = model.to_resolved_model() self.assertEqual(resolved.api_key, "sk-test-secret") self.assertEqual(resolved.capabilities, frozenset({"text"})) def test_capabilities_accept_dict_shape(self): model = AiModel( name="Vision image", url="https://api.vectorengine.ai/v1/images/edits", model="gpt-image-2", api_type=AiModel.ApiType.IMAGES_EDITS, capabilities={"image": True, "vision": True, "text": False}, ) self.assertEqual(model.capabilities_set(), frozenset({"image", "vision"})) @override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) class AliasResolutionTests(TestCase): def setUp(self): self.text_model = self.create_model( name="GPT-5.5 text", model="gpt-5.5", api_type=AiModel.ApiType.CHAT, capabilities=["text", "vision"], ) self.image_model = self.create_model( name="GPT Image 2", url="https://api.vectorengine.ai/v1/images/edits", model="gpt-image-2", api_type=AiModel.ApiType.IMAGES_EDITS, capabilities=["image", "vision"], ) def create_model( self, *, name, model, capabilities, url="https://api.vectorengine.ai/v1", api_type=AiModel.ApiType.CHAT, ): ai_model = AiModel( name=name, url=url, model=model, api_type=api_type, capabilities=capabilities, ) ai_model.set_api_key("sk-test-secret") ai_model.save() return ai_model def test_resolve_default_title_alias(self): ModelAlias.objects.create( operation_type=ModelAlias.OperationType.TITLE, alias="title-standard", ai_model=self.text_model, is_default=True, ) resolved = resolve_alias(ModelAlias.OperationType.TITLE) self.assertEqual(resolved.model, "gpt-5.5") self.assertEqual(resolved.api_key, "sk-test-secret") def test_resolve_named_image_alias(self): ModelAlias.objects.create( operation_type=ModelAlias.OperationType.IMAGE, alias="image-edit", ai_model=self.image_model, ) resolved = resolve_alias(ModelAlias.OperationType.IMAGE, "image-edit") 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, alias="bad-title", ai_model=self.image_model, ) with self.assertRaises(ModelCapabilityError): resolve_alias(ModelAlias.OperationType.TITLE, "bad-title") def test_resolve_alias_ignores_inactive_aliases(self): ModelAlias.objects.create( operation_type=ModelAlias.OperationType.TITLE, alias="inactive-title", ai_model=self.text_model, is_active=False, ) with self.assertRaises(AliasNotFoundError): resolve_alias(ModelAlias.OperationType.TITLE, "inactive-title") def test_model_alias_save_rejects_second_default_for_operation(self): ModelAlias.objects.create( operation_type=ModelAlias.OperationType.TITLE, alias="title-standard", ai_model=self.text_model, is_default=True, ) with self.assertRaisesMessage(Exception, "Only one default alias"): ModelAlias.objects.create( operation_type=ModelAlias.OperationType.TITLE, alias="title-backup", ai_model=self.text_model, is_default=True, ) @override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) class AiModelsImportTests(TestCase): def test_import_cmbot_config_encrypts_keys_and_creates_default_aliases(self): result = import_ai_models_config( { "models": [ { "name": "Nano Banana 2", "url": "https://api.vectorengine.ai/v1/chat/completions", "model": "gemini-3.1-flash-image-preview", "api_key": "sk-image", "api_type": "auto", "timeout_seconds": 0, "connect_timeout_seconds": 30, "extra_body": {}, }, { "name": "GPT-5.5 text", "url": "https://api.vectorengine.ai/v1", "model": "gpt-5.5", "api_key": "sk-text", "api_type": "chat", "timeout_seconds": 0, "connect_timeout_seconds": 30, "extra_body": {}, }, ] }, create_default_aliases=True, ) self.assertEqual(result, {"created": 2, "updated": 0, "aliases": 2}) text_model = AiModel.objects.get(name="GPT-5.5 text") image_model = AiModel.objects.get(name="Nano Banana 2") self.assertEqual(text_model.capabilities_set(), frozenset({"text", "vision"})) self.assertEqual(image_model.capabilities_set(), frozenset({"image", "vision"})) self.assertNotIn("sk-text", text_model.api_key_encrypted) self.assertEqual(text_model.get_api_key(), "sk-text") title_alias = ModelAlias.objects.get( operation_type=ModelAlias.OperationType.TITLE, alias="title-standard", ) image_alias = ModelAlias.objects.get( operation_type=ModelAlias.OperationType.IMAGE, alias="image-hd", ) self.assertEqual(title_alias.ai_model, text_model) self.assertEqual(image_alias.ai_model, image_model) @override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) class AiModelAdminTests(TestCase): def test_admin_form_uses_write_only_api_key_field(self): request = RequestFactory().get("/admin/apps/ai/aimodel/add/") model_admin = AiModelAdmin(AiModel, AdminSite()) form_class = model_admin.get_form(request) self.assertIn("api_key", form_class.base_fields) self.assertNotIn("api_key_encrypted", form_class.base_fields) self.assertFalse(form_class.base_fields["api_key"].widget.render_value) def test_admin_form_encrypts_api_key_on_save(self): form_class = AiModelAdmin(AiModel, AdminSite()).form form = form_class( data={ "name": "GPT-5.5 text", "url": "https://api.vectorengine.ai/v1", "model": "gpt-5.5", "api_type": AiModel.ApiType.CHAT, "api_key": "sk-admin-secret", "capabilities": '["text"]', "timeout_seconds": 0, "connect_timeout_seconds": 30, "extra_body": "{}", "is_active": "on", } ) self.assertTrue(form.is_valid(), form.errors) ai_model = form.save() self.assertNotIn("sk-admin-secret", ai_model.api_key_encrypted) self.assertEqual(ai_model.get_api_key(), "sk-admin-secret") @override_settings(AI_KEY_ENCRYPTION_KEY="") def test_admin_form_reports_missing_encryption_key(self): form_class = AiModelAdmin(AiModel, AdminSite()).form form = form_class( data={ "name": "GPT-5.5 text", "url": "https://api.vectorengine.ai/v1", "model": "gpt-5.5", "api_type": AiModel.ApiType.CHAT, "api_key": "sk-admin-secret", "capabilities": '["text"]', "timeout_seconds": 0, "connect_timeout_seconds": 30, "extra_body": "{}", "is_active": "on", } ) self.assertFalse(form.is_valid()) self.assertIn("AI_KEY_ENCRYPTION_KEY is not configured", str(form.errors)) @override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) class AiConfigAuditAdminTests(TestCase): def setUp(self): self.site = AdminSite() self.request = RequestFactory().post("/admin/") self.request.user = get_user_model().objects.create_superuser( username="auditor", email="auditor@example.com", password="password", ) def create_ai_model( self, *, name="GPT-5.5 text", model="gpt-5.5", capabilities=None, api_type=AiModel.ApiType.CHAT, url="https://api.vectorengine.ai/v1", api_key="sk-test-secret", ): ai_model = AiModel( name=name, url=url, model=model, api_type=api_type, capabilities=capabilities or ["text"], ) ai_model.set_api_key(api_key) ai_model.save() return ai_model def test_aimodel_admin_create_writes_sanitized_audit_log(self): ai_model = AiModel( name="GPT-5.5 text", url="https://api.vectorengine.ai/v1", model="gpt-5.5", api_type=AiModel.ApiType.CHAT, capabilities=["text"], ) ai_model.set_api_key("sk-created-secret") AiModelAdmin(AiModel, self.site).save_model( self.request, ai_model, form=None, change=False, ) log = AiConfigAuditLog.objects.get() self.assertEqual(log.actor, self.request.user) self.assertEqual(log.action, AiConfigAuditLog.Action.CREATE) self.assertEqual(log.target_type, AiConfigAuditLog.TargetType.AI_MODEL) self.assertIn("api_key", log.changed_fields) self.assertEqual(log.changes["api_key"], {"old": "empty", "new": "set"}) self.assertNotIn("sk-created-secret", str(log.changes)) self.assertNotIn("fernet:", str(log.changes)) def test_aimodel_admin_update_logs_field_and_key_changes(self): ai_model = self.create_ai_model() ai_model.url = "https://api.vectorengine.ai/v2" ai_model.set_api_key("sk-new-secret") AiModelAdmin(AiModel, self.site).save_model( self.request, ai_model, form=None, change=True, ) log = AiConfigAuditLog.objects.get() self.assertEqual(log.action, AiConfigAuditLog.Action.UPDATE) self.assertEqual(set(log.changed_fields), {"url", "api_key"}) self.assertEqual( log.changes["url"], { "old": "https://api.vectorengine.ai/v1", "new": "https://api.vectorengine.ai/v2", }, ) self.assertEqual(log.changes["api_key"], {"old": "set", "new": "set"}) self.assertNotIn("sk-new-secret", str(log.changes)) def test_model_alias_admin_update_logs_mapping_change(self): text_model = self.create_ai_model(name="Text model", model="gpt-5.5") image_model = self.create_ai_model( name="Image model", model="gpt-image-2", capabilities=["image"], api_type=AiModel.ApiType.IMAGES_EDITS, url="https://api.vectorengine.ai/v1/images/edits", ) alias = ModelAlias.objects.create( operation_type=ModelAlias.OperationType.IMAGE, alias="image-hd", ai_model=text_model, ) alias.ai_model = image_model ModelAliasAdmin(ModelAlias, self.site).save_model( self.request, alias, form=None, change=True, ) log = AiConfigAuditLog.objects.get() self.assertEqual(log.target_type, AiConfigAuditLog.TargetType.MODEL_ALIAS) self.assertEqual(log.changed_fields, ["ai_model"]) self.assertEqual( log.changes["ai_model"], {"old": text_model.id, "new": image_model.id}, ) def test_aimodel_admin_delete_writes_audit_log(self): ai_model = self.create_ai_model() target_id = ai_model.id AiModelAdmin(AiModel, self.site).delete_model(self.request, ai_model) log = AiConfigAuditLog.objects.get() self.assertEqual(log.action, AiConfigAuditLog.Action.DELETE) self.assertEqual(log.target_id, target_id) self.assertIn("api_key", log.changed_fields) self.assertEqual(log.changes["api_key"], {"old": "set", "new": "empty"}) def test_audit_log_admin_is_read_only(self): model_admin = AiConfigAuditLogAdmin(AiConfigAuditLog, self.site) self.assertFalse(model_admin.has_add_permission(self.request)) self.assertFalse(model_admin.has_change_permission(self.request)) self.assertFalse(model_admin.has_delete_permission(self.request)) self.assertIn("changes", model_admin.get_readonly_fields(self.request)) class AiGenerationSmokeCommandTests(TestCase): def test_recorded_title_smoke_runs_through_alias_and_provider_without_persisting(self): output = io.StringIO() call_command( "smoke_ai_generation", "title", "--recorded", "--prompt", "为测试商品生成标题", stdout=output, ) text = output.getvalue() self.assertIn("Smoke generation OK", text) self.assertIn("mode=recorded", text) self.assertIn("operation=title", text) self.assertIn("alias=t-104-recorded-title-", text) self.assertIn("model_used=recorded-title-model", text) self.assertIn("title_count=3", text) self.assertNotIn("sk-recorded-placeholder", text) self.assertNotIn("Bearer", text) self.assertEqual(AiModel.objects.count(), 0) self.assertEqual(ModelAlias.objects.count(), 0) def test_recorded_image_smoke_runs_through_alias_and_provider_without_persisting(self): output = io.StringIO() call_command( "smoke_ai_generation", "image", "--recorded", "--prompt", "生成测试图片", stdout=output, ) text = output.getvalue() self.assertIn("Smoke generation OK", text) self.assertIn("mode=recorded", text) self.assertIn("operation=image", text) self.assertIn("alias=t-105-recorded-image-", text) self.assertIn("model_used=recorded-image-model", text) self.assertIn("image_bytes=20", text) self.assertNotIn("sk-recorded-placeholder", text) self.assertNotIn("Bearer", text) self.assertEqual(AiModel.objects.count(), 0) self.assertEqual(ModelAlias.objects.count(), 0)