feat(agent-runtime): 接入多服务商安全配置
This commit is contained in:
+23
-1
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import AgentRun, AgentRunEvent, HumanApproval, ToolCall
|
||||
from .models import AgentRun, AgentRunEvent, HumanApproval, ModelProviderConfig, ToolCall
|
||||
from .services import resolve_approval
|
||||
|
||||
|
||||
@@ -27,6 +27,28 @@ class AgentRunAdmin(admin.ModelAdmin):
|
||||
admin.site.register(AgentRunEvent, ReadOnlyAuditAdmin)
|
||||
admin.site.register(ToolCall, ReadOnlyAuditAdmin)
|
||||
|
||||
|
||||
@admin.register(ModelProviderConfig)
|
||||
class ModelProviderConfigAdmin(admin.ModelAdmin):
|
||||
"""只展示连接元数据,密文 API Key 不进入 Admin 页面。"""
|
||||
|
||||
list_display = (
|
||||
"provider_code",
|
||||
"owner",
|
||||
"status",
|
||||
"is_default",
|
||||
"last_verified_at",
|
||||
)
|
||||
list_filter = ("status", "is_default", "enabled")
|
||||
search_fields = ("provider_code", "owner__username")
|
||||
exclude = ("encrypted_api_key",)
|
||||
readonly_fields = ("key_hint", "available_models", "last_verified_at", "last_error_code")
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""服务商配置必须经过页面连接验证,Admin 不允许绕过验证直接创建。"""
|
||||
|
||||
return False
|
||||
|
||||
@admin.register(HumanApproval)
|
||||
class HumanApprovalAdmin(admin.ModelAdmin):
|
||||
"""人工确认正文只读,通过受控动作执行领域状态转换。"""
|
||||
|
||||
@@ -14,6 +14,9 @@ class AgentRuntimeConfig:
|
||||
api_key: str
|
||||
model: str
|
||||
agent_name: str
|
||||
provider_code: str = "openai"
|
||||
protocol: str = "openai_responses"
|
||||
base_url: str = "https://api.openai.com/v1"
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls) -> "AgentRuntimeConfig":
|
||||
@@ -36,3 +39,19 @@ class AgentRuntimeConfig:
|
||||
if missing:
|
||||
raise AgentConfigurationError(f"真实 Agent 执行缺少配置:{', '.join(missing)}。")
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_user(cls, user) -> "AgentRuntimeConfig":
|
||||
"""从用户已验证的服务商配置构建运行配置,供真实 Gateway 接入使用。"""
|
||||
|
||||
from .services import resolve_runtime_model_config
|
||||
|
||||
runtime = resolve_runtime_model_config(user)
|
||||
return cls(
|
||||
api_key=runtime.api_key,
|
||||
model=runtime.model,
|
||||
agent_name=str(settings.AGENT_DEFAULT_NAME or "job_research").strip(),
|
||||
provider_code=runtime.provider_code,
|
||||
protocol=runtime.protocol,
|
||||
base_url=runtime.base_url,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""模型服务商配置表单。"""
|
||||
|
||||
from django import forms
|
||||
|
||||
|
||||
class ProviderConfigurationForm(forms.Form):
|
||||
"""只接收用户需要决定的密钥和默认模型,不允许覆盖系统预设端点。"""
|
||||
|
||||
api_key = forms.CharField(
|
||||
label="API Key",
|
||||
max_length=500,
|
||||
strip=True,
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"autocomplete": "new-password", "placeholder": "粘贴服务商 API Key"}
|
||||
),
|
||||
)
|
||||
model_id = forms.CharField(
|
||||
label="默认模型",
|
||||
max_length=160,
|
||||
required=False,
|
||||
strip=True,
|
||||
widget=forms.TextInput(attrs={"placeholder": "自动选择推荐模型"}),
|
||||
)
|
||||
use_as_default = forms.BooleanField(label="设为 Agent 默认服务商", required=False)
|
||||
|
||||
def clean_api_key(self):
|
||||
"""拒绝明显不完整的值,但不假设各厂商的固定前缀。"""
|
||||
|
||||
api_key = self.cleaned_data["api_key"]
|
||||
if len(api_key) < 8:
|
||||
raise forms.ValidationError("API Key 长度不足,请检查是否复制完整。")
|
||||
return api_key
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 6.0.8 on 2026-09-18 05:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('agent_runtime', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ModelProviderConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('provider_code', models.CharField(max_length=64, verbose_name='服务商编码')),
|
||||
('encrypted_api_key', models.TextField(verbose_name='加密 API Key')),
|
||||
('key_hint', models.CharField(max_length=12, verbose_name='密钥提示')),
|
||||
('status', models.CharField(choices=[('connected', '已连接'), ('failed', '需要重新验证')], default='connected', max_length=20, verbose_name='连接状态')),
|
||||
('enabled', models.BooleanField(default=True, verbose_name='已启用')),
|
||||
('is_default', models.BooleanField(default=False, verbose_name='默认服务商')),
|
||||
('default_model_id', models.CharField(blank=True, max_length=160, verbose_name='默认模型')),
|
||||
('available_models', models.JSONField(default=list, verbose_name='可用模型快照')),
|
||||
('last_verified_at', models.DateTimeField(blank=True, null=True, verbose_name='最近验证时间')),
|
||||
('last_error_code', models.CharField(blank=True, max_length=80, verbose_name='最近错误码')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(app_label)s_%(class)s_items', to=settings.AUTH_USER_MODEL, verbose_name='所属用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '模型服务商配置',
|
||||
'verbose_name_plural': '模型服务商配置',
|
||||
'ordering': ('provider_code',),
|
||||
'constraints': [models.UniqueConstraint(fields=('owner', 'provider_code'), name='provider_owner_code_uniq')],
|
||||
},
|
||||
),
|
||||
]
|
||||
+63
-6
@@ -18,6 +18,46 @@ class RunStatus(models.TextChoices):
|
||||
CANCELLED = "cancelled", "已取消"
|
||||
|
||||
|
||||
class ProviderConnectionStatus(models.TextChoices):
|
||||
"""模型服务商连接状态。"""
|
||||
|
||||
CONNECTED = "connected", "已连接"
|
||||
FAILED = "failed", "需要重新验证"
|
||||
|
||||
|
||||
class ModelProviderConfig(UserOwnedModel):
|
||||
"""当前用户的模型服务商凭据与已验证模型快照。"""
|
||||
|
||||
provider_code = models.CharField("服务商编码", max_length=64)
|
||||
encrypted_api_key = models.TextField("加密 API Key")
|
||||
key_hint = models.CharField("密钥提示", max_length=12)
|
||||
status = models.CharField(
|
||||
"连接状态",
|
||||
max_length=20,
|
||||
choices=ProviderConnectionStatus,
|
||||
default=ProviderConnectionStatus.CONNECTED,
|
||||
)
|
||||
enabled = models.BooleanField("已启用", default=True)
|
||||
is_default = models.BooleanField("默认服务商", default=False)
|
||||
default_model_id = models.CharField("默认模型", max_length=160, blank=True)
|
||||
available_models = models.JSONField("可用模型快照", default=list)
|
||||
last_verified_at = models.DateTimeField("最近验证时间", null=True, blank=True)
|
||||
last_error_code = models.CharField("最近错误码", max_length=80, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "模型服务商配置"
|
||||
verbose_name_plural = "模型服务商配置"
|
||||
ordering = ("provider_code",)
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("owner", "provider_code"), name="provider_owner_code_uniq"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.owner} / {self.provider_code}"
|
||||
|
||||
|
||||
class AgentRun(UserOwnedModel):
|
||||
"""一次可审计的 Agent 运行;摘要字段不得保存敏感原文。"""
|
||||
|
||||
@@ -38,14 +78,18 @@ class AgentRun(UserOwnedModel):
|
||||
duration_ms = models.PositiveBigIntegerField("耗时毫秒", null=True, blank=True)
|
||||
input_tokens = models.PositiveBigIntegerField("输入 Token", null=True, blank=True)
|
||||
output_tokens = models.PositiveBigIntegerField("输出 Token", null=True, blank=True)
|
||||
estimated_cost = models.DecimalField("估算费用", max_digits=12, decimal_places=6, null=True, blank=True)
|
||||
estimated_cost = models.DecimalField(
|
||||
"估算费用", max_digits=12, decimal_places=6, null=True, blank=True
|
||||
)
|
||||
error_code = models.CharField("错误码", max_length=80, blank=True)
|
||||
error_summary = models.TextField("错误摘要", blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
indexes = [models.Index(fields=("owner", "created_at"), name="run_owner_created_idx")]
|
||||
constraints = [models.CheckConstraint(condition=Q(lock_version__gte=0), name="run_lock_nonnegative")]
|
||||
constraints = [
|
||||
models.CheckConstraint(condition=Q(lock_version__gte=0), name="run_lock_nonnegative")
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@@ -64,7 +108,14 @@ class AgentRunEvent(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ("sequence",)
|
||||
constraints = [models.UniqueConstraint(fields=("run", "sequence"), name="event_run_sequence_uniq")]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("run", "sequence"), name="event_run_sequence_uniq")
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""使用运行标题和序号提供稳定、可读的管理端标识。"""
|
||||
|
||||
return f"{self.run} / 事件 {self.sequence}"
|
||||
|
||||
|
||||
class ToolCallStatus(models.TextChoices):
|
||||
@@ -81,7 +132,9 @@ class ToolCall(TimeStampedModel):
|
||||
tool_name = models.CharField("工具名称", max_length=100)
|
||||
tool_version = models.CharField("工具版本", max_length=40, blank=True)
|
||||
idempotency_key = models.CharField("幂等键", max_length=128, blank=True)
|
||||
status = models.CharField("状态", max_length=20, choices=ToolCallStatus, default=ToolCallStatus.STARTED)
|
||||
status = models.CharField(
|
||||
"状态", max_length=20, choices=ToolCallStatus, default=ToolCallStatus.STARTED
|
||||
)
|
||||
arguments_summary = models.JSONField("参数摘要", default=dict)
|
||||
result_summary = models.JSONField("结果摘要", null=True, blank=True)
|
||||
error_code = models.CharField("错误码", max_length=80, blank=True)
|
||||
@@ -117,7 +170,9 @@ class HumanApproval(TimeStampedModel):
|
||||
run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="approvals")
|
||||
request_key = models.CharField("请求键", max_length=128)
|
||||
approval_type = models.CharField("确认类型", max_length=64)
|
||||
status = models.CharField("状态", max_length=20, choices=ApprovalStatus, default=ApprovalStatus.PENDING)
|
||||
status = models.CharField(
|
||||
"状态", max_length=20, choices=ApprovalStatus, default=ApprovalStatus.PENDING
|
||||
)
|
||||
request_summary = models.JSONField("请求摘要", default=dict)
|
||||
decision_summary = models.JSONField("处理摘要", null=True, blank=True)
|
||||
requested_at = models.DateTimeField("请求时间")
|
||||
@@ -132,4 +187,6 @@ class HumanApproval(TimeStampedModel):
|
||||
|
||||
class Meta:
|
||||
indexes = [models.Index(fields=("status", "requested_at"), name="approval_status_time_idx")]
|
||||
constraints = [models.UniqueConstraint(fields=("run", "request_key"), name="approval_run_key_uniq")]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=("run", "request_key"), name="approval_run_key_uniq")
|
||||
]
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""模型服务商预设目录。
|
||||
|
||||
预设只保存公开的协议与端点,不保存用户凭据。需要云 IAM、部署名称或
|
||||
业务空间域名的服务商仍展示在目录中,但不会误导用户只靠 API Key 即可连接。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelProvider:
|
||||
"""描述一个可展示、可验证的模型服务商预设。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
vendor: str
|
||||
category: str
|
||||
protocol: str
|
||||
base_url: str = ""
|
||||
models_path: str = "models"
|
||||
default_model: str = ""
|
||||
key_only: bool = True
|
||||
note: str = ""
|
||||
mark: str = "AI"
|
||||
|
||||
@property
|
||||
def protocol_label(self) -> str:
|
||||
labels = {
|
||||
"openai_responses": "Responses API",
|
||||
"openai_compatible": "OpenAI 兼容",
|
||||
"anthropic_messages": "Messages API",
|
||||
"gemini": "Gemini API",
|
||||
"cloud": "云平台接口",
|
||||
}
|
||||
return labels.get(self.protocol, self.protocol)
|
||||
|
||||
|
||||
PROVIDERS = (
|
||||
ModelProvider(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
"OpenAI",
|
||||
"official",
|
||||
"openai_responses",
|
||||
"https://api.openai.com/v1",
|
||||
default_model="gpt-5.6-terra",
|
||||
mark="OA",
|
||||
),
|
||||
ModelProvider(
|
||||
"anthropic",
|
||||
"Anthropic Claude",
|
||||
"Anthropic",
|
||||
"official",
|
||||
"anthropic_messages",
|
||||
"https://api.anthropic.com/v1",
|
||||
default_model="claude-sonnet-4-6",
|
||||
mark="CL",
|
||||
),
|
||||
ModelProvider(
|
||||
"deepseek",
|
||||
"DeepSeek",
|
||||
"深度求索",
|
||||
"domestic",
|
||||
"openai_compatible",
|
||||
"https://api.deepseek.com",
|
||||
default_model="deepseek-chat",
|
||||
mark="DS",
|
||||
),
|
||||
ModelProvider(
|
||||
"gemini",
|
||||
"Google Gemini",
|
||||
"Google",
|
||||
"official",
|
||||
"gemini",
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
models_path="models",
|
||||
default_model="gemini-2.5-flash",
|
||||
mark="GE",
|
||||
),
|
||||
ModelProvider(
|
||||
"aliyun",
|
||||
"阿里云百炼 / Qwen",
|
||||
"阿里云",
|
||||
"domestic",
|
||||
"openai_compatible",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
default_model="qwen-plus",
|
||||
mark="QW",
|
||||
),
|
||||
ModelProvider(
|
||||
"zhipu",
|
||||
"智谱 AI / Z.AI",
|
||||
"智谱",
|
||||
"domestic",
|
||||
"openai_compatible",
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
default_model="glm-4.5",
|
||||
mark="GL",
|
||||
),
|
||||
ModelProvider(
|
||||
"xiaomi_mimo",
|
||||
"小米 MiMo",
|
||||
"小米",
|
||||
"domestic",
|
||||
"openai_compatible",
|
||||
key_only=False,
|
||||
note="官方端点及账户区域确认后开放",
|
||||
mark="MI",
|
||||
),
|
||||
ModelProvider(
|
||||
"siliconflow",
|
||||
"硅基流动",
|
||||
"SiliconFlow",
|
||||
"domestic",
|
||||
"openai_compatible",
|
||||
"https://api.siliconflow.cn/v1",
|
||||
default_model="deepseek-ai/DeepSeek-V3",
|
||||
mark="SF",
|
||||
),
|
||||
ModelProvider(
|
||||
"moonshot",
|
||||
"月之暗面 Kimi",
|
||||
"Moonshot AI",
|
||||
"domestic",
|
||||
"openai_compatible",
|
||||
"https://api.moonshot.cn/v1",
|
||||
default_model="moonshot-v1-8k",
|
||||
mark="KM",
|
||||
),
|
||||
ModelProvider(
|
||||
"minimax",
|
||||
"MiniMax",
|
||||
"MiniMax",
|
||||
"domestic",
|
||||
"openai_compatible",
|
||||
key_only=False,
|
||||
note="部分账户还需要 Group ID",
|
||||
mark="MM",
|
||||
),
|
||||
ModelProvider(
|
||||
"volcengine",
|
||||
"火山引擎方舟 / 豆包",
|
||||
"火山引擎",
|
||||
"domestic",
|
||||
"cloud",
|
||||
key_only=False,
|
||||
note="需要接入点或推理端点配置",
|
||||
mark="DB",
|
||||
),
|
||||
ModelProvider(
|
||||
"baidu",
|
||||
"百度智能云千帆 / 文心",
|
||||
"百度智能云",
|
||||
"domestic",
|
||||
"cloud",
|
||||
key_only=False,
|
||||
note="需要应用或云鉴权配置",
|
||||
mark="BD",
|
||||
),
|
||||
ModelProvider(
|
||||
"tencent",
|
||||
"腾讯混元",
|
||||
"腾讯云",
|
||||
"domestic",
|
||||
"cloud",
|
||||
key_only=False,
|
||||
note="需要腾讯云鉴权配置",
|
||||
mark="HY",
|
||||
),
|
||||
ModelProvider(
|
||||
"huawei",
|
||||
"华为云 ModelArts / 盘古",
|
||||
"华为云",
|
||||
"domestic",
|
||||
"cloud",
|
||||
key_only=False,
|
||||
note="需要项目、区域及云鉴权配置",
|
||||
mark="HW",
|
||||
),
|
||||
ModelProvider(
|
||||
"openrouter",
|
||||
"OpenRouter",
|
||||
"OpenRouter",
|
||||
"gateway",
|
||||
"openai_compatible",
|
||||
"https://openrouter.ai/api/v1",
|
||||
default_model="openai/gpt-4o-mini",
|
||||
mark="OR",
|
||||
),
|
||||
ModelProvider(
|
||||
"groq",
|
||||
"Groq",
|
||||
"Groq",
|
||||
"gateway",
|
||||
"openai_compatible",
|
||||
"https://api.groq.com/openai/v1",
|
||||
default_model="llama-3.3-70b-versatile",
|
||||
mark="GQ",
|
||||
),
|
||||
ModelProvider(
|
||||
"together",
|
||||
"Together AI",
|
||||
"Together AI",
|
||||
"gateway",
|
||||
"openai_compatible",
|
||||
"https://api.together.xyz/v1",
|
||||
mark="TO",
|
||||
),
|
||||
ModelProvider(
|
||||
"mistral",
|
||||
"Mistral AI",
|
||||
"Mistral",
|
||||
"official",
|
||||
"openai_compatible",
|
||||
"https://api.mistral.ai/v1",
|
||||
default_model="mistral-large-latest",
|
||||
mark="MS",
|
||||
),
|
||||
ModelProvider(
|
||||
"cohere",
|
||||
"Cohere",
|
||||
"Cohere",
|
||||
"official",
|
||||
"openai_compatible",
|
||||
key_only=False,
|
||||
note="原生协议适配待接入",
|
||||
mark="CO",
|
||||
),
|
||||
ModelProvider(
|
||||
"xai",
|
||||
"xAI Grok",
|
||||
"xAI",
|
||||
"official",
|
||||
"openai_compatible",
|
||||
"https://api.x.ai/v1",
|
||||
default_model="grok-4",
|
||||
mark="XA",
|
||||
),
|
||||
ModelProvider(
|
||||
"azure_openai",
|
||||
"Azure OpenAI",
|
||||
"Microsoft Azure",
|
||||
"cloud",
|
||||
"cloud",
|
||||
key_only=False,
|
||||
note="需要资源端点和部署名称",
|
||||
mark="AZ",
|
||||
),
|
||||
ModelProvider(
|
||||
"aws_bedrock",
|
||||
"AWS Bedrock",
|
||||
"Amazon Web Services",
|
||||
"cloud",
|
||||
"cloud",
|
||||
key_only=False,
|
||||
note="需要区域和 AWS IAM 凭据",
|
||||
mark="AW",
|
||||
),
|
||||
ModelProvider(
|
||||
"custom",
|
||||
"自定义 OpenAI 兼容服务",
|
||||
"自定义",
|
||||
"gateway",
|
||||
"openai_compatible",
|
||||
key_only=False,
|
||||
note="需要 Base URL 和模型 ID",
|
||||
mark="+",
|
||||
),
|
||||
)
|
||||
|
||||
PROVIDER_BY_CODE = {provider.code: provider for provider in PROVIDERS}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"all": "全部",
|
||||
"official": "官方直连",
|
||||
"domestic": "国内平台",
|
||||
"gateway": "云与聚合",
|
||||
"cloud": "云平台",
|
||||
"connected": "已连接",
|
||||
}
|
||||
|
||||
|
||||
def get_provider(code: str) -> ModelProvider | None:
|
||||
"""按稳定编码读取预设,未知编码返回空值。"""
|
||||
|
||||
return PROVIDER_BY_CODE.get(code)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""模型 API Key 的服务端加密边界。"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from django.conf import settings
|
||||
|
||||
from common.exceptions import AgentConfigurationError
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
"""从独立部署密钥派生 Fernet 密钥,避免直接复用原始配置文本。"""
|
||||
|
||||
secret = str(getattr(settings, "MODEL_API_KEY_ENCRYPTION_KEY", "") or "").strip()
|
||||
if len(secret) < 32:
|
||||
raise AgentConfigurationError("模型密钥加密主密钥未配置或长度不足。")
|
||||
derived = hashlib.sha256(secret.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(derived))
|
||||
|
||||
|
||||
def encrypt_api_key(api_key: str) -> str:
|
||||
"""加密 API Key;密文可以入库,但不得出现在页面和日志中。"""
|
||||
|
||||
return _fernet().encrypt(api_key.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_api_key(ciphertext: str) -> str:
|
||||
"""解密 API Key,并把密钥轮换或数据损坏转换为稳定配置错误。"""
|
||||
|
||||
try:
|
||||
return _fernet().decrypt(ciphertext.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, UnicodeError, ValueError) as exc:
|
||||
raise AgentConfigurationError("模型 API Key 无法解密,请重新配置。") from exc
|
||||
+190
-3
@@ -1,10 +1,20 @@
|
||||
"""Agent Run 的事务、权限和状态转换服务。"""
|
||||
"""Agent Run 与模型服务商配置的领域服务。"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Max
|
||||
from django.utils import timezone
|
||||
|
||||
from common.exceptions import InvalidStateTransition, PermissionDenied
|
||||
from common.exceptions import (
|
||||
AgentConfigurationError,
|
||||
InvalidStateTransition,
|
||||
PermissionDenied,
|
||||
ProviderConnectionError,
|
||||
)
|
||||
from common.logging import sanitize_summary
|
||||
|
||||
from .models import (
|
||||
@@ -12,14 +22,191 @@ from .models import (
|
||||
AgentRunEvent,
|
||||
ApprovalStatus,
|
||||
HumanApproval,
|
||||
ModelProviderConfig,
|
||||
ProviderConnectionStatus,
|
||||
RunStatus,
|
||||
ToolCall,
|
||||
ToolCallStatus,
|
||||
)
|
||||
from .providers import ModelProvider, get_provider
|
||||
from .secrets import decrypt_api_key, encrypt_api_key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderVerificationResult:
|
||||
"""连接验证后的安全结果,不携带凭据或上游响应正文。"""
|
||||
|
||||
models: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeModelConfig:
|
||||
"""真实 Gateway 调用前解析出的用户级模型配置。"""
|
||||
|
||||
provider_code: str
|
||||
protocol: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
|
||||
|
||||
def provider_configs_for_user(user):
|
||||
"""返回当前用户配置,并由调用方按服务商编码组织展示。"""
|
||||
|
||||
return ModelProviderConfig.objects.filter(owner=user)
|
||||
|
||||
|
||||
def _verification_headers(provider: ModelProvider, api_key: str) -> dict[str, str]:
|
||||
"""按预设协议构造最小认证头;API Key 不进入 URL。"""
|
||||
|
||||
headers = {"Accept": "application/json", "User-Agent": "JobRadar/0.1"}
|
||||
if provider.protocol == "anthropic_messages":
|
||||
headers.update({"x-api-key": api_key, "anthropic-version": "2023-06-01"})
|
||||
elif provider.protocol == "gemini":
|
||||
headers["x-goog-api-key"] = api_key
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
return headers
|
||||
|
||||
|
||||
def verify_provider_api_key(
|
||||
provider: ModelProvider, api_key: str, *, opener=None
|
||||
) -> ProviderVerificationResult:
|
||||
"""通过模型目录验证凭据,禁止执行会产生模型用量的对话请求。"""
|
||||
|
||||
if not provider.key_only or not provider.base_url:
|
||||
raise ProviderConnectionError("extra_configuration_required", "该服务商需要额外配置。")
|
||||
open_request = opener or urlopen
|
||||
endpoint = f"{provider.base_url.rstrip('/')}/{provider.models_path.lstrip('/')}"
|
||||
request = Request(endpoint, headers=_verification_headers(provider, api_key), method="GET")
|
||||
try:
|
||||
with open_request(request, timeout=12) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
raise ProviderConnectionError("invalid_api_key", "API Key 无效或已失效。") from exc
|
||||
if exc.code == 403:
|
||||
raise ProviderConnectionError(
|
||||
"permission_denied", "API Key 没有访问模型目录的权限。"
|
||||
) from exc
|
||||
if exc.code == 429:
|
||||
raise ProviderConnectionError(
|
||||
"rate_limited", "服务商请求过于频繁,请稍后重试。"
|
||||
) from exc
|
||||
raise ProviderConnectionError("provider_http_error", "服务商暂时无法完成验证。") from exc
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise ProviderConnectionError(
|
||||
"provider_unreachable", "无法连接服务商,请稍后重试。"
|
||||
) from exc
|
||||
except (json.JSONDecodeError, UnicodeError) as exc:
|
||||
raise ProviderConnectionError(
|
||||
"invalid_provider_response", "服务商返回了无法识别的数据。"
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ProviderConnectionError(
|
||||
"invalid_provider_response", "服务商返回了无法识别的数据。"
|
||||
)
|
||||
raw_models = payload.get("data") or payload.get("models") or []
|
||||
model_ids = []
|
||||
for item in raw_models:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
model_id = item.get("id") or item.get("name")
|
||||
if isinstance(model_id, str) and model_id:
|
||||
model_ids.append(model_id.removeprefix("models/"))
|
||||
# 某些兼容端点只验证凭据但不返回模型目录,保留经过项目核验的推荐模型。
|
||||
if not model_ids and provider.default_model:
|
||||
model_ids.append(provider.default_model)
|
||||
return ProviderVerificationResult(tuple(dict.fromkeys(model_ids)))
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def save_verified_provider_config(
|
||||
owner,
|
||||
provider: ModelProvider,
|
||||
api_key: str,
|
||||
result: ProviderVerificationResult,
|
||||
*,
|
||||
model_id: str = "",
|
||||
use_as_default: bool = False,
|
||||
) -> ModelProviderConfig:
|
||||
"""只在连接验证成功后替换密钥,避免错误输入破坏已有可用配置。"""
|
||||
|
||||
selected_model = model_id.strip() or provider.default_model
|
||||
if not selected_model and result.models:
|
||||
selected_model = result.models[0]
|
||||
existing_count = ModelProviderConfig.objects.filter(owner=owner).count()
|
||||
should_default = use_as_default or existing_count == 0
|
||||
if should_default:
|
||||
ModelProviderConfig.objects.filter(owner=owner, is_default=True).update(is_default=False)
|
||||
config, _ = ModelProviderConfig.objects.update_or_create(
|
||||
owner=owner,
|
||||
provider_code=provider.code,
|
||||
defaults={
|
||||
"encrypted_api_key": encrypt_api_key(api_key),
|
||||
"key_hint": f"••••{api_key[-4:]}",
|
||||
"status": ProviderConnectionStatus.CONNECTED,
|
||||
"enabled": True,
|
||||
"is_default": should_default,
|
||||
"default_model_id": selected_model,
|
||||
"available_models": list(result.models)[:200],
|
||||
"last_verified_at": timezone.now(),
|
||||
"last_error_code": "",
|
||||
},
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def disconnect_provider(owner, provider_code: str) -> bool:
|
||||
"""删除当前用户指定服务商的密文配置,不影响其他用户或运行审计数据。"""
|
||||
|
||||
deleted, _ = ModelProviderConfig.objects.filter(
|
||||
owner=owner, provider_code=provider_code
|
||||
).delete()
|
||||
if deleted and not ModelProviderConfig.objects.filter(owner=owner, is_default=True).exists():
|
||||
fallback = ModelProviderConfig.objects.filter(owner=owner, enabled=True).first()
|
||||
if fallback:
|
||||
fallback.is_default = True
|
||||
fallback.save(update_fields=("is_default", "updated_at"))
|
||||
return bool(deleted)
|
||||
|
||||
|
||||
def resolve_runtime_model_config(owner, provider_code: str = "") -> RuntimeModelConfig:
|
||||
"""在真实执行边界解密当前用户配置,密钥不会进入运行请求或数据库摘要。"""
|
||||
|
||||
queryset = ModelProviderConfig.objects.filter(
|
||||
owner=owner,
|
||||
enabled=True,
|
||||
status=ProviderConnectionStatus.CONNECTED,
|
||||
)
|
||||
config = queryset.filter(provider_code=provider_code).first() if provider_code else None
|
||||
config = config or queryset.filter(is_default=True).first()
|
||||
if config is None:
|
||||
raise AgentConfigurationError("当前用户尚未配置可用的模型服务商。")
|
||||
provider = get_provider(config.provider_code)
|
||||
if provider is None or not provider.base_url:
|
||||
raise AgentConfigurationError("当前模型服务商预设不可用,请重新配置。")
|
||||
if not config.default_model_id:
|
||||
raise AgentConfigurationError("当前服务商尚未选择默认模型。")
|
||||
return RuntimeModelConfig(
|
||||
provider_code=provider.code,
|
||||
protocol=provider.protocol,
|
||||
base_url=provider.base_url,
|
||||
api_key=decrypt_api_key(config.encrypted_api_key),
|
||||
model=config.default_model_id,
|
||||
)
|
||||
|
||||
|
||||
TRANSITIONS = {
|
||||
RunStatus.PENDING: {RunStatus.RUNNING, RunStatus.CANCELLED},
|
||||
RunStatus.RUNNING: {RunStatus.WAITING_APPROVAL, RunStatus.SUCCEEDED, RunStatus.FAILED, RunStatus.CANCELLED},
|
||||
RunStatus.RUNNING: {
|
||||
RunStatus.WAITING_APPROVAL,
|
||||
RunStatus.SUCCEEDED,
|
||||
RunStatus.FAILED,
|
||||
RunStatus.CANCELLED,
|
||||
},
|
||||
RunStatus.WAITING_APPROVAL: {RunStatus.RUNNING, RunStatus.FAILED, RunStatus.CANCELLED},
|
||||
}
|
||||
|
||||
|
||||
+160
-2
@@ -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")
|
||||
|
||||
+15
-1
@@ -3,4 +3,18 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "agent_runtime"
|
||||
urlpatterns = [path("", views.run_list, name="run-list"), path("<uuid:run_id>/", views.run_detail, name="run-detail")]
|
||||
urlpatterns = [
|
||||
path("", views.run_list, name="run-list"),
|
||||
path("providers/", views.provider_list, name="provider-list"),
|
||||
path(
|
||||
"providers/<slug:provider_code>/configure/",
|
||||
views.provider_configure,
|
||||
name="provider-configure",
|
||||
),
|
||||
path(
|
||||
"providers/<slug:provider_code>/disconnect/",
|
||||
views.provider_disconnect,
|
||||
name="provider-disconnect",
|
||||
),
|
||||
path("<uuid:run_id>/", views.run_detail, name="run-detail"),
|
||||
]
|
||||
|
||||
+109
-3
@@ -1,10 +1,21 @@
|
||||
"""当前用户的 Agent Run 查询页面。"""
|
||||
"""当前用户的 Agent Run 与模型服务商配置页面。"""
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from .services import runs_for_user
|
||||
from common.exceptions import AgentConfigurationError, ProviderConnectionError
|
||||
|
||||
from .forms import ProviderConfigurationForm
|
||||
from .providers import CATEGORY_LABELS, PROVIDERS, get_provider
|
||||
from .services import (
|
||||
disconnect_provider,
|
||||
provider_configs_for_user,
|
||||
runs_for_user,
|
||||
save_verified_provider_config,
|
||||
verify_provider_api_key,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -23,3 +34,98 @@ def run_detail(request, run_id):
|
||||
runs_for_user(request.user).prefetch_related("events", "tool_calls", "approvals"), pk=run_id
|
||||
)
|
||||
return render(request, "agent_runtime/run_detail.html", {"run": run})
|
||||
|
||||
|
||||
def _provider_page_context(request, form=None):
|
||||
"""构造纯展示视图模型,模板不承担查询、权限或协议判断。"""
|
||||
|
||||
configs = {item.provider_code: item for item in provider_configs_for_user(request.user)}
|
||||
category = request.GET.get("category", "all")
|
||||
query = request.GET.get("q", "").strip().lower()
|
||||
selected = get_provider(request.GET.get("provider", ""))
|
||||
cards = []
|
||||
for provider in PROVIDERS:
|
||||
config = configs.get(provider.code)
|
||||
if category == "connected" and config is None:
|
||||
continue
|
||||
if category not in {"all", "connected"} and provider.category != category:
|
||||
continue
|
||||
searchable = f"{provider.name} {provider.vendor} {provider.code}".lower()
|
||||
if query and query not in searchable:
|
||||
continue
|
||||
cards.append({"provider": provider, "config": config})
|
||||
selected_config = configs.get(selected.code) if selected else None
|
||||
return {
|
||||
"cards": cards,
|
||||
"provider_count": len(PROVIDERS),
|
||||
"connected_count": len(configs),
|
||||
"categories": CATEGORY_LABELS,
|
||||
"active_category": category if category in CATEGORY_LABELS else "all",
|
||||
"query": request.GET.get("q", "").strip(),
|
||||
"selected_provider": selected,
|
||||
"selected_config": selected_config,
|
||||
"provider_form": form
|
||||
or ProviderConfigurationForm(
|
||||
initial={
|
||||
"model_id": selected_config.default_model_id if selected_config else "",
|
||||
"use_as_default": selected_config.is_default if selected_config else False,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def provider_list(request):
|
||||
"""展示当前用户的服务商目录和已连接状态。"""
|
||||
|
||||
return render(request, "agent_runtime/provider_list.html", _provider_page_context(request))
|
||||
|
||||
|
||||
@login_required
|
||||
def provider_configure(request, provider_code):
|
||||
"""验证并保存当前用户的 API Key;失败时不覆盖已有配置。"""
|
||||
|
||||
provider = get_provider(provider_code)
|
||||
if provider is None:
|
||||
return redirect("agent_runtime:provider-list")
|
||||
if request.method != "POST":
|
||||
return redirect(f"{redirect('agent_runtime:provider-list').url}?provider={provider.code}")
|
||||
form = ProviderConfigurationForm(request.POST)
|
||||
if not provider.key_only:
|
||||
messages.info(request, provider.note or "该服务商需要额外配置,当前版本尚未开放。")
|
||||
return redirect(f"{redirect('agent_runtime:provider-list').url}?provider={provider.code}")
|
||||
if form.is_valid():
|
||||
try:
|
||||
result = verify_provider_api_key(provider, form.cleaned_data["api_key"])
|
||||
save_verified_provider_config(
|
||||
request.user,
|
||||
provider,
|
||||
form.cleaned_data["api_key"],
|
||||
result,
|
||||
model_id=form.cleaned_data["model_id"],
|
||||
use_as_default=form.cleaned_data["use_as_default"],
|
||||
)
|
||||
except (ProviderConnectionError, AgentConfigurationError) as exc:
|
||||
form.add_error(None, str(exc))
|
||||
else:
|
||||
messages.success(request, f"{provider.name} 已验证并启用。")
|
||||
return redirect("agent_runtime:provider-list")
|
||||
# POST 出错时保持配置抽屉打开,且表单只回显非敏感错误,不回显已有密钥。
|
||||
query = request.GET.copy()
|
||||
query["provider"] = provider.code
|
||||
request.GET = query
|
||||
return render(
|
||||
request,
|
||||
"agent_runtime/provider_list.html",
|
||||
_provider_page_context(request, form),
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def provider_disconnect(request, provider_code):
|
||||
"""仅接受 POST 断开当前用户自己的服务商配置。"""
|
||||
|
||||
if request.method == "POST" and disconnect_provider(request.user, provider_code):
|
||||
messages.success(request, "服务商配置已移除。")
|
||||
return redirect("agent_runtime:provider-list")
|
||||
|
||||
Reference in New Issue
Block a user