448 lines
16 KiB
Python
448 lines
16 KiB
Python
from datetime import timedelta
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.models import Permission
|
|
from django.core.management import call_command
|
|
from django.utils import timezone
|
|
|
|
from apps.modelhub.crypto import decrypt_secret
|
|
from apps.modelhub.models import ModelDeployment, ModelInvocation, ModelProvider
|
|
from apps.modelhub.services import CompletionResult
|
|
from apps.modelhub.tasks import scrub_expired_model_invocation_diagnostics
|
|
|
|
|
|
def login_admin(client):
|
|
call_command("bootstrap_governance")
|
|
user = get_user_model().objects.get(username="admin")
|
|
client.force_login(user)
|
|
return user
|
|
|
|
|
|
def create_provider(client):
|
|
return client.post(
|
|
"/api/modelhub/providers",
|
|
data={
|
|
"name": "企业模型渠道",
|
|
"base_url": "https://models.example.com/v1/",
|
|
"api_key": "sk-secret-1234",
|
|
"default_timeout_seconds": 30,
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
|
|
def test_admin_manages_provider_without_exposing_secret(client, db):
|
|
login_admin(client)
|
|
|
|
response = create_provider(client)
|
|
|
|
assert response.status_code == 200
|
|
assert "api_key" not in response.json()
|
|
assert response.json()["credential_masked"] == "****1234"
|
|
provider = ModelProvider.objects.get()
|
|
assert provider.api_key_encrypted != "sk-secret-1234"
|
|
assert decrypt_secret(provider.api_key_encrypted) == "sk-secret-1234"
|
|
|
|
updated = client.patch(
|
|
f"/api/modelhub/providers/{provider.id}",
|
|
data={"name": "新渠道名称", "api_key": ""},
|
|
content_type="application/json",
|
|
)
|
|
assert updated.status_code == 200
|
|
provider.refresh_from_db()
|
|
assert decrypt_secret(provider.api_key_encrypted) == "sk-secret-1234"
|
|
|
|
replaced = client.patch(
|
|
f"/api/modelhub/providers/{provider.id}",
|
|
data={"api_key": "sk-new-secret-5678"},
|
|
content_type="application/json",
|
|
)
|
|
assert replaced.status_code == 200
|
|
provider.refresh_from_db()
|
|
assert decrypt_secret(provider.api_key_encrypted) == "sk-new-secret-5678"
|
|
assert replaced.json()["credential_masked"] == "****5678"
|
|
|
|
|
|
def test_model_test_runs_through_recorded_invocation(client, db, monkeypatch):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
deployment = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "支持模型",
|
|
"model_name": "support-model",
|
|
"structured_output_mode": "prompt_schema",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
assert deployment.status_code == 200
|
|
|
|
monkeypatch.setattr(
|
|
"apps.modelhub.services._request_completion",
|
|
lambda *args, **kwargs: CompletionResult("连接正常", 8, 4),
|
|
)
|
|
response = client.post(
|
|
f"/api/modelhub/deployments/{deployment.json()['id']}/test",
|
|
data={"prompt": "测试连接"},
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "succeeded"
|
|
assert response.json()["output_text"] == "连接正常"
|
|
assert response.json()["input_tokens"] == 8
|
|
assert response.json()["output_tokens"] == 4
|
|
|
|
|
|
def test_embedding_deployment_can_be_configured_but_uses_knowledge_search_for_test(client, db):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
deployment = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "知识向量模型",
|
|
"model_name": "embedding-model",
|
|
"capability": "embedding",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert deployment.status_code == 200
|
|
assert deployment.json()["capability"] == "embedding"
|
|
assert deployment.json()["structured_output_mode"] == "none"
|
|
|
|
stored_deployment = ModelDeployment.objects.get(id=deployment.json()["id"])
|
|
stored_deployment.structured_output_mode = "unconfigured"
|
|
stored_deployment.save(update_fields=["structured_output_mode"])
|
|
updated = client.patch(
|
|
f"/api/modelhub/deployments/{stored_deployment.id}",
|
|
data={"display_name": "知识向量模型(更新)"},
|
|
content_type="application/json",
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json()["structured_output_mode"] == "none"
|
|
|
|
response = client.post(
|
|
f"/api/modelhub/deployments/{deployment.json()['id']}/test",
|
|
data={"prompt": "测试连接"},
|
|
content_type="application/json",
|
|
)
|
|
assert response.status_code == 400
|
|
assert "对应能力入口" in response.json()["detail"]
|
|
|
|
rerank = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "知识重排模型",
|
|
"model_name": "rerank-model",
|
|
"capability": "rerank",
|
|
"structured_output_mode": "native_json_schema",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
assert rerank.status_code == 200
|
|
assert rerank.json()["capability"] == "rerank"
|
|
assert rerank.json()["structured_output_mode"] == "none"
|
|
|
|
|
|
def test_text_deployment_requires_explicit_structured_output_mode(client, db):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
|
|
response = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "未配置模型",
|
|
"model_name": "unconfigured-model",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"].startswith("structured_output_not_configured:")
|
|
assert "结构化输出" in response.json()["detail"]
|
|
|
|
|
|
def test_structured_output_test_passes_and_configuration_change_resets_it(
|
|
client,
|
|
db,
|
|
monkeypatch,
|
|
):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
deployment_response = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "原生结构化模型",
|
|
"model_name": "native-model",
|
|
"structured_output_mode": "native_json_schema",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
deployment_id = deployment_response.json()["id"]
|
|
monkeypatch.setattr(
|
|
"apps.modelhub.services._request_completion",
|
|
lambda *args, **kwargs: CompletionResult(
|
|
'{"status":"ok","items":[{"code":"A1","score":1}]}',
|
|
20,
|
|
10,
|
|
),
|
|
)
|
|
|
|
tested = client.post(
|
|
f"/api/modelhub/deployments/{deployment_id}/structured-output-test",
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert tested.status_code == 200
|
|
assert tested.json()["invocation_type"] == "structured_output_test"
|
|
assert tested.json()["status"] == "succeeded"
|
|
deployment = ModelDeployment.objects.get(id=deployment_id)
|
|
assert deployment.structured_output_test_status == "passed"
|
|
assert deployment.structured_output_verified_at is not None
|
|
|
|
updated = client.patch(
|
|
f"/api/modelhub/deployments/{deployment_id}",
|
|
data={"model_name": "native-model-v2"},
|
|
content_type="application/json",
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json()["structured_output_test_status"] == "untested"
|
|
assert updated.json()["structured_output_verified_at"] is None
|
|
|
|
deployment.structured_output_test_status = "passed"
|
|
deployment.save(update_fields=["structured_output_test_status"])
|
|
provider_updated = client.patch(
|
|
f"/api/modelhub/providers/{provider_id}",
|
|
data={"base_url": "https://models-v2.example.com/v1"},
|
|
content_type="application/json",
|
|
)
|
|
assert provider_updated.status_code == 200
|
|
deployment.refresh_from_db()
|
|
assert deployment.structured_output_test_status == "untested"
|
|
|
|
|
|
def test_non_chat_model_cannot_start_structured_output_test(client, db):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
deployment = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "知识重排模型",
|
|
"model_name": "rerank-structured-test",
|
|
"capability": "rerank",
|
|
"structured_output_mode": "none",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
response = client.post(
|
|
f"/api/modelhub/deployments/{deployment.json()['id']}/structured-output-test",
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "不适用" in response.json()["detail"]
|
|
|
|
|
|
def test_non_ai_admin_cannot_manage_modelhub(client, db):
|
|
user = get_user_model().objects.create_user(username="requester", password="password")
|
|
client.force_login(user)
|
|
|
|
response = client.get("/api/modelhub/providers")
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_unused_provider_and_deployment_are_physically_deleted(client, db):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
deployment = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "待删除模型",
|
|
"model_name": "unused-model",
|
|
"structured_output_mode": "prompt_schema",
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
impact = client.get(
|
|
f"/api/modelhub/deployments/{deployment.json()['id']}/deletion-impact"
|
|
)
|
|
deleted = client.delete(
|
|
f"/api/modelhub/deployments/{deployment.json()['id']}"
|
|
)
|
|
|
|
assert impact.status_code == 200
|
|
assert impact.json()["suggested_action"] == "delete"
|
|
assert deleted.json()["result"] == "deleted"
|
|
assert not ModelDeployment.objects.filter(id=deployment.json()["id"]).exists()
|
|
|
|
provider_deleted = client.delete(f"/api/modelhub/providers/{provider_id}")
|
|
assert provider_deleted.json()["result"] == "deleted"
|
|
assert not ModelProvider.objects.filter(id=provider_id).exists()
|
|
|
|
|
|
def test_provider_with_history_is_atomically_archived_and_restored_disabled(client, db):
|
|
admin = login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
deployment_id = client.post(
|
|
"/api/modelhub/deployments",
|
|
data={
|
|
"provider_id": provider_id,
|
|
"display_name": "历史模型",
|
|
"model_name": "history-model",
|
|
"structured_output_mode": "prompt_schema",
|
|
},
|
|
content_type="application/json",
|
|
).json()["id"]
|
|
ModelInvocation.objects.create(
|
|
deployment_id=deployment_id,
|
|
status="succeeded",
|
|
output_text="sensitive output",
|
|
created_by=admin,
|
|
)
|
|
|
|
response = client.delete(f"/api/modelhub/providers/{provider_id}")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["result"] == "archived"
|
|
provider = ModelProvider.objects.get(id=provider_id)
|
|
deployment = ModelDeployment.objects.get(id=deployment_id)
|
|
assert provider.archived_at is not None
|
|
assert provider.api_key_encrypted == ""
|
|
assert provider.image_egress_approved is False
|
|
assert deployment.archived_at is not None
|
|
assert deployment.archived_via_provider is True
|
|
assert deployment.enabled is False
|
|
|
|
restored = client.post(f"/api/modelhub/providers/{provider_id}/restore")
|
|
assert restored.status_code == 200
|
|
provider.refresh_from_db()
|
|
deployment.refresh_from_db()
|
|
assert provider.archived_at is None and provider.enabled is False
|
|
assert deployment.archived_at is None and deployment.enabled is False
|
|
assert deployment.structured_output_test_status == "untested"
|
|
|
|
|
|
def test_restore_name_conflict_is_409_without_partial_restore(client, db):
|
|
admin = login_admin(client)
|
|
archived = ModelProvider.objects.create(
|
|
name="可复用渠道",
|
|
base_url="https://old.example.com/v1",
|
|
archived_at=timezone.now(),
|
|
archived_by=admin,
|
|
)
|
|
ModelProvider.objects.create(
|
|
name="可复用渠道",
|
|
base_url="https://new.example.com/v1",
|
|
)
|
|
|
|
response = client.post(f"/api/modelhub/providers/{archived.id}/restore")
|
|
|
|
assert response.status_code == 409
|
|
assert response.json()["detail"].startswith("lifecycle_name_conflict:")
|
|
archived.refresh_from_db()
|
|
assert archived.archived_at is not None
|
|
|
|
|
|
def test_lifecycle_requires_superuser_even_with_model_manage(client, db):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
user = get_user_model().objects.create_user(username="model-manager", password="password")
|
|
user.user_permissions.add(Permission.objects.get(codename="model.manage"))
|
|
client.force_login(user)
|
|
|
|
assert client.get(
|
|
f"/api/modelhub/providers/{provider_id}/deletion-impact"
|
|
).status_code == 403
|
|
assert client.delete(f"/api/modelhub/providers/{provider_id}").status_code == 403
|
|
assert client.post(f"/api/modelhub/providers/{provider_id}/restore").status_code == 403
|
|
|
|
|
|
def test_lists_are_paginated_and_agent_options_only_return_effective_models(client, db):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
ready = ModelDeployment.objects.create(
|
|
provider_id=provider_id,
|
|
display_name="已验证模型",
|
|
model_name="ready-model",
|
|
structured_output_mode="prompt_schema",
|
|
structured_output_test_status="passed",
|
|
)
|
|
ModelDeployment.objects.create(
|
|
provider_id=provider_id,
|
|
display_name="未验证模型",
|
|
model_name="unverified-model",
|
|
structured_output_mode="prompt_schema",
|
|
)
|
|
|
|
providers = client.get("/api/modelhub/providers?page=1&page_size=1")
|
|
deployments = client.get("/api/modelhub/deployments?page=1&page_size=1")
|
|
options = client.get("/api/modelhub/deployment-options")
|
|
|
|
assert set(providers.json()) == {"items", "page", "page_size", "total", "total_pages"}
|
|
assert providers.json()["page_size"] == 1
|
|
assert deployments.json()["page_size"] == 1
|
|
assert [item["id"] for item in options.json()] == [ready.id]
|
|
|
|
|
|
def test_credential_can_be_explicitly_cleared_and_disables_provider(client, db):
|
|
login_admin(client)
|
|
provider_id = create_provider(client).json()["id"]
|
|
|
|
response = client.patch(
|
|
f"/api/modelhub/providers/{provider_id}",
|
|
data={"clear_api_key": True},
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["credential_set"] is False
|
|
assert response.json()["enabled"] is False
|
|
provider = ModelProvider.objects.get(id=provider_id)
|
|
assert provider.credential_updated_at is not None
|
|
|
|
|
|
def test_invocation_diagnostics_permission_and_retention_scrub(client, db, settings):
|
|
admin = login_admin(client)
|
|
settings.MODEL_INVOCATION_DIAGNOSTIC_RETENTION_DAYS = 90
|
|
invocation = ModelInvocation.objects.create(
|
|
status="succeeded",
|
|
input_snapshot={
|
|
"messages": [{"role": "user", "content": "sensitive input"}],
|
|
"structured_output": {
|
|
"mode": "native_json_schema",
|
|
"schema_hash": "abc",
|
|
"response_format_sent": True,
|
|
},
|
|
"output_schema": {"type": "object"},
|
|
},
|
|
output_text="sensitive output",
|
|
created_by=admin,
|
|
)
|
|
ModelInvocation.objects.filter(id=invocation.id).update(
|
|
created_at=timezone.now() - timedelta(days=91)
|
|
)
|
|
|
|
scrubbed = scrub_expired_model_invocation_diagnostics()
|
|
|
|
assert scrubbed == 1
|
|
invocation.refresh_from_db()
|
|
assert invocation.output_text == ""
|
|
assert "messages" not in invocation.input_snapshot
|
|
assert "output_schema" not in invocation.input_snapshot
|
|
assert invocation.input_snapshot["structured_output"]["schema_hash"] == "abc"
|
|
response = client.get(
|
|
f"/api/modelhub/invocations/{invocation.public_id}/diagnostics"
|
|
)
|
|
assert response.status_code == 410
|