feat(agent-runtime): 接入多服务商安全配置

This commit is contained in:
bruce
2026-09-18 16:43:48 +08:00
parent 1e56a55399
commit f8c0c2d411
20 changed files with 1087 additions and 31 deletions
+160 -2
View File
@@ -1,21 +1,35 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.test import SimpleTestCase, TestCase, override_settings
from django.urls import reverse
from common.exceptions import AgentConfigurationError, InvalidStateTransition, PermissionDenied
from common.exceptions import (
AgentConfigurationError,
InvalidStateTransition,
PermissionDenied,
ProviderConnectionError,
)
from .config import AgentRuntimeConfig
from .gateway import AgentExecutionResult, StubAgentRunnerGateway
from .models import ApprovalStatus, RunStatus, ToolCallStatus
from .models import ApprovalStatus, ModelProviderConfig, RunStatus, ToolCallStatus
from .orchestration import execute_run
from .providers import get_provider
from .secrets import decrypt_api_key, encrypt_api_key
from .services import (
ProviderVerificationResult,
create_run,
disconnect_provider,
finish_tool_call,
request_approval,
resolve_approval,
resolve_runtime_model_config,
save_verified_provider_config,
start_tool_call,
transition_run,
verify_provider_api_key,
)
@@ -146,3 +160,147 @@ class AgentRuntimeConfigTests(SimpleTestCase):
def test_complete_configuration_is_accepted_without_external_call(self):
config = AgentRuntimeConfig.from_settings().require_real_execution()
self.assertEqual(config.model, "test-model")
class FakeModelsResponse:
"""模拟只返回公开模型标识的服务商模型目录。"""
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self):
return b'{"data":[{"id":"model-a"},{"id":"model-b"}]}'
class ProviderVerificationTests(SimpleTestCase):
"""验证连接测试只访问模型目录,且凭据不会进入 URL。"""
def test_openai_compatible_verification_uses_bearer_header(self):
captured = {}
def opener(request, timeout):
captured["url"] = request.full_url
captured["authorization"] = request.get_header("Authorization")
captured["timeout"] = timeout
return FakeModelsResponse()
result = verify_provider_api_key(get_provider("deepseek"), "safe-test-key", opener=opener)
self.assertEqual(result.models, ("model-a", "model-b"))
self.assertEqual(captured["authorization"], "Bearer safe-test-key")
self.assertNotIn("safe-test-key", captured["url"])
self.assertEqual(captured["timeout"], 12)
def test_provider_requiring_extra_configuration_is_rejected_without_request(self):
with self.assertRaises(ProviderConnectionError) as context:
verify_provider_api_key(get_provider("azure_openai"), "safe-test-key")
self.assertEqual(context.exception.code, "extra_configuration_required")
@override_settings(MODEL_API_KEY_ENCRYPTION_KEY="test-only-model-key-32-characters-minimum")
class ModelProviderConfigTests(TestCase):
"""覆盖密钥加密、用户隔离、默认选择和页面不回显边界。"""
def setUp(self):
users = get_user_model().objects
self.alice = users.create_user(username="alice", password="safe-pass-123")
self.bob = users.create_user(username="bob", password="safe-pass-123")
self.provider = get_provider("openai")
self.result = ProviderVerificationResult(("model-a", "model-b"))
def test_api_key_round_trip_uses_ciphertext(self):
ciphertext = encrypt_api_key("safe-test-key")
self.assertNotIn("safe-test-key", ciphertext)
self.assertEqual(decrypt_api_key(ciphertext), "safe-test-key")
def test_verified_config_is_user_owned_and_resolves_for_runtime(self):
config = save_verified_provider_config(
self.alice,
self.provider,
"safe-test-key",
self.result,
model_id="model-a",
)
self.assertTrue(config.is_default)
runtime = resolve_runtime_model_config(self.alice)
self.assertEqual(runtime.api_key, "safe-test-key")
self.assertEqual(runtime.model, "model-a")
user_config = AgentRuntimeConfig.from_user(self.alice)
self.assertEqual(user_config.provider_code, "openai")
self.assertEqual(user_config.protocol, "openai_responses")
self.assertEqual(user_config.base_url, "https://api.openai.com/v1")
with self.assertRaises(AgentConfigurationError):
resolve_runtime_model_config(self.bob)
def test_disconnect_promotes_remaining_provider(self):
first = save_verified_provider_config(
self.alice, self.provider, "safe-test-key", self.result, model_id="model-a"
)
second = save_verified_provider_config(
self.alice,
get_provider("deepseek"),
"another-safe-key",
self.result,
model_id="model-b",
)
self.assertTrue(first.is_default)
self.assertFalse(second.is_default)
disconnect_provider(self.alice, "openai")
second.refresh_from_db()
self.assertTrue(second.is_default)
def test_provider_page_requires_login_and_lists_catalog(self):
response = self.client.get(reverse("agent_runtime:provider-list"))
self.assertEqual(response.status_code, 302)
self.client.force_login(self.alice)
response = self.client.get(reverse("agent_runtime:provider-list"))
self.assertContains(response, "阿里云百炼")
self.assertContains(response, "小米 MiMo")
self.assertContains(response, "共 23 个内置服务商")
@patch("agent_runtime.views.verify_provider_api_key")
def test_configure_page_saves_verified_key_without_rendering_it(self, verify):
verify.return_value = self.result
self.client.force_login(self.alice)
response = self.client.post(
reverse("agent_runtime:provider-configure", args=("openai",)),
{"api_key": "safe-test-key", "model_id": "model-a", "use_as_default": "on"},
)
self.assertRedirects(response, reverse("agent_runtime:provider-list"))
config = ModelProviderConfig.objects.get(owner=self.alice, provider_code="openai")
self.assertNotIn("safe-test-key", config.encrypted_api_key)
page = self.client.get(f"{reverse('agent_runtime:provider-list')}?provider=openai")
self.assertNotContains(page, "safe-test-key")
self.assertContains(page, config.key_hint)
@patch("agent_runtime.views.verify_provider_api_key")
def test_failed_reconfiguration_does_not_replace_existing_key(self, verify):
config = save_verified_provider_config(
self.alice, self.provider, "original-safe-key", self.result, model_id="model-a"
)
original_ciphertext = config.encrypted_api_key
verify.side_effect = ProviderConnectionError("invalid_api_key", "API Key 无效。")
self.client.force_login(self.alice)
response = self.client.post(
reverse("agent_runtime:provider-configure", args=("openai",)),
{"api_key": "incorrect-key", "model_id": "model-b"},
)
self.assertEqual(response.status_code, 400)
config.refresh_from_db()
self.assertEqual(config.encrypted_api_key, original_ciphertext)
self.assertEqual(decrypt_api_key(config.encrypted_api_key), "original-safe-key")
def test_connected_filter_never_exposes_another_users_provider(self):
save_verified_provider_config(
self.bob, self.provider, "bob-safe-key", self.result, model_id="model-a"
)
self.client.force_login(self.alice)
response = self.client.get(
reverse("agent_runtime:provider-list"), {"category": "connected"}
)
self.assertNotContains(response, "OpenAI")