feat(agent-runtime): 接入多服务商安全配置
This commit is contained in:
@@ -19,6 +19,8 @@ POSTGRES_CONN_MAX_AGE=60
|
||||
OPENAI_API_KEY=请替换为OpenAI_API_Key
|
||||
OPENAI_MODEL=
|
||||
AGENT_DEFAULT_NAME=job_research
|
||||
# 至少 32 个随机字符;生产环境必须稳定保存,遗失后已有模型 API Key 无法解密。
|
||||
MODEL_API_KEY_ENCRYPTION_KEY=请替换为独立生成的高强度随机密钥
|
||||
|
||||
# 生产安全配置
|
||||
DJANGO_SECURE_SSL_REDIRECT=true
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 项目根目录。所有本地文件路径统一从这里派生,避免依赖启动目录。
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
@@ -87,12 +86,7 @@ WSGI_APPLICATION = "JobRadar.wsgi.application"
|
||||
ASGI_APPLICATION = "JobRadar.asgi.application"
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": (
|
||||
"django.contrib.auth.password_validation."
|
||||
"UserAttributeSimilarityValidator"
|
||||
)
|
||||
},
|
||||
{"NAME": ("django.contrib.auth.password_validation.UserAttributeSimilarityValidator")},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
@@ -120,6 +114,9 @@ LOGOUT_REDIRECT_URL = "accounts:login"
|
||||
OPENAI_API_KEY = env("OPENAI_API_KEY", "")
|
||||
OPENAI_MODEL = env("OPENAI_MODEL", "")
|
||||
AGENT_DEFAULT_NAME = env("AGENT_DEFAULT_NAME", "job_research")
|
||||
# 用户在页面录入的模型 API Key 使用独立主密钥加密。该值不得与数据库一起保存,
|
||||
# 生产部署必须稳定注入;轮换前需要先设计密文重加密流程。
|
||||
MODEL_API_KEY_ENCRYPTION_KEY = env("MODEL_API_KEY_ENCRYPTION_KEY", "")
|
||||
|
||||
# 日志只输出可关联的结构化键值,不记录请求正文、Cookie 或认证头。
|
||||
LOGGING = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""生产环境配置:关键安全参数和 PostgreSQL 凭据均必须显式注入。"""
|
||||
|
||||
from .base import env, env_bool, env_list
|
||||
from .base import * # noqa: F403
|
||||
from .base import env, env_bool, env_list
|
||||
|
||||
|
||||
def required_env(name: str) -> str:
|
||||
@@ -14,6 +14,7 @@ def required_env(name: str) -> str:
|
||||
|
||||
|
||||
SECRET_KEY = required_env("DJANGO_SECRET_KEY")
|
||||
MODEL_API_KEY_ENCRYPTION_KEY = required_env("MODEL_API_KEY_ENCRYPTION_KEY")
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS")
|
||||
if not ALLOWED_HOSTS:
|
||||
@@ -45,4 +46,3 @@ SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = env_bool("DJANGO_SECURE_HSTS_PRELOAD", default=False)
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""自动化测试配置:始终使用独立的内存 SQLite 数据库。"""
|
||||
|
||||
from .base import BASE_DIR
|
||||
from .base import * # noqa: F403
|
||||
|
||||
from .base import BASE_DIR
|
||||
|
||||
SECRET_KEY = "test-only-secret-key-not-for-production"
|
||||
MODEL_API_KEY_ENCRYPTION_KEY = "test-only-model-key-32-characters-minimum"
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = ["testserver", "localhost"]
|
||||
|
||||
@@ -19,4 +19,3 @@ DATABASES = {
|
||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
MEDIA_ROOT = BASE_DIR / ".test-media"
|
||||
|
||||
|
||||
+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")
|
||||
|
||||
@@ -19,3 +19,11 @@ class PermissionDenied(DomainError):
|
||||
|
||||
class AgentConfigurationError(DomainError):
|
||||
"""真实 Agent 执行所需配置缺失或不合法。"""
|
||||
|
||||
|
||||
class ProviderConnectionError(DomainError):
|
||||
"""模型服务商凭据验证失败;消息不得包含上游原始响应。"""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
@@ -11,6 +11,8 @@ dependencies:
|
||||
- psycopg[binary]==3.3.5
|
||||
- pillow==12.2.0
|
||||
- openai-agents==0.22.0
|
||||
# 页面保存的模型 API Key 必须可逆加密,禁止明文入库或自制加密算法。
|
||||
- cryptography==50.0.1
|
||||
# 测试与代码质量依赖统一固定版本,保证本地和持续集成结果一致。
|
||||
- pytest==9.1.1
|
||||
- pytest-django==4.14.0
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}API 服务商 · JobRadar{% endblock %}
|
||||
{% block content %}
|
||||
<div class="provider-layout{% if selected_provider %} has-drawer{% endif %}">
|
||||
<section class="provider-main">
|
||||
<header class="page-heading">
|
||||
<div><h1>API 服务商</h1><p>连接模型服务,为不同 Agent 选择合适的模型</p></div>
|
||||
<a class="primary-button" href="?provider=custom">+ 自定义服务商</a>
|
||||
</header>
|
||||
<div class="provider-tools">
|
||||
<form class="provider-search" method="get">
|
||||
<label class="sr-only" for="provider-search">搜索服务商</label>
|
||||
<input id="provider-search" name="q" value="{{ query }}" placeholder="⌕ 搜索服务商">
|
||||
<input type="hidden" name="category" value="{{ active_category }}">
|
||||
</form>
|
||||
<nav class="filter-tabs" aria-label="服务商分类">
|
||||
{% for code, label in categories.items %}<a href="?category={{ code }}{% if query %}&q={{ query|urlencode }}{% endif %}" {% if active_category == code %}aria-current="page"{% endif %}>{{ label }}</a>{% endfor %}
|
||||
</nav>
|
||||
<span class="security-note">▣ 密钥仅在服务端加密保存</span>
|
||||
</div>
|
||||
<div class="provider-summary"><strong>共 {{ provider_count }} 个内置服务商</strong><span>{{ connected_count }} 个已连接</span></div>
|
||||
<div class="provider-grid">
|
||||
{% for card in cards %}
|
||||
<article class="provider-card{% if selected_provider.code == card.provider.code %} selected{% endif %}">
|
||||
<a class="card-link" href="?provider={{ card.provider.code }}&category={{ active_category }}{% if query %}&q={{ query|urlencode }}{% endif %}" aria-label="配置 {{ card.provider.name }}"></a>
|
||||
<div class="provider-card-head"><span class="provider-mark mark-{{ card.provider.category }}">{{ card.provider.mark }}</span><span><strong>{{ card.provider.name }}</strong><small>{{ card.provider.vendor }}</small></span><span class="card-menu" aria-hidden="true">⋮</span></div>
|
||||
<div class="provider-tags"><span>{{ card.provider.protocol_label }}</span><span>{% if card.provider.key_only %}仅 API Key{% else %}需额外配置{% endif %}</span></div>
|
||||
<p class="connection-status {% if card.config %}connected{% endif %}"><span aria-hidden="true">●</span>{% if card.config %}{{ card.config.get_status_display }}{% else %}未配置{% endif %}{% if card.config.is_default %}<em>默认</em>{% endif %}</p>
|
||||
</article>
|
||||
{% empty %}<div class="empty-panel"><h2>没有匹配的服务商</h2><p>请调整搜索词或分类条件。</p></div>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% if selected_provider %}
|
||||
<aside class="provider-drawer" aria-labelledby="drawer-title">
|
||||
<div class="drawer-header"><div><h2 id="drawer-title">配置{{ selected_provider.name }}</h2><p>系统已预置接口地址和协议</p></div><a class="drawer-close" href="{% url 'agent_runtime:provider-list' %}" aria-label="关闭">×</a></div>
|
||||
<dl class="preset-details"><div><dt>接口协议</dt><dd>{{ selected_provider.protocol_label }}</dd></div><div><dt>接口地址</dt><dd>{% if selected_provider.base_url %}由系统预置{% else %}需要额外配置{% endif %}</dd></div></dl>
|
||||
{% if selected_provider.key_only %}
|
||||
<form class="provider-form" method="post" action="{% url 'agent_runtime:provider-configure' selected_provider.code %}?provider={{ selected_provider.code }}">
|
||||
{% csrf_token %}
|
||||
{% if provider_form.non_field_errors %}<div class="form-alert">{{ provider_form.non_field_errors }}</div>{% endif %}
|
||||
<div class="field-row"><label for="{{ provider_form.api_key.id_for_label }}">API Key</label>{{ provider_form.api_key }}{% if selected_config %}<small>当前密钥:{{ selected_config.key_hint }};重新保存将覆盖旧密钥。</small>{% endif %}{{ provider_form.api_key.errors }}</div>
|
||||
<div class="key-safety">▣ 密钥加密存储,保存后不可查看</div>
|
||||
{% if selected_config %}<div class="connection-ok"><strong>✓ 已通过连接验证</strong><span>最近验证:{{ selected_config.last_verified_at|date:"Y-m-d H:i" }}</span></div>{% endif %}
|
||||
<div class="field-row"><label for="{{ provider_form.model_id.id_for_label }}">模型选择</label>{{ provider_form.model_id }}<small>留空时自动使用服务商推荐模型。</small>{{ provider_form.model_id.errors }}</div>
|
||||
<label class="checkbox-row">{{ provider_form.use_as_default }} <span>设为 Agent 默认服务商</span></label>
|
||||
<details class="advanced-settings"><summary>高级设置(可选)</summary><p>接口地址和协议由系统预设,当前版本不允许用户覆盖。</p></details>
|
||||
<div class="drawer-actions">{% if selected_config %}<button class="danger-button" form="disconnect-form" type="submit">移除配置</button>{% endif %}<a class="secondary-button" href="{% url 'agent_runtime:provider-list' %}">取消</a><button class="primary-button" type="submit">测试并保存</button></div>
|
||||
</form>
|
||||
{% if selected_config %}<form id="disconnect-form" method="post" action="{% url 'agent_runtime:provider-disconnect' selected_provider.code %}">{% csrf_token %}</form>{% endif %}
|
||||
{% else %}
|
||||
<div class="coming-soon"><span aria-hidden="true">◇</span><h3>需要额外配置</h3><p>{{ selected_provider.note }}</p><p>后续将根据该平台的地域、部署或 IAM 要求提供专用表单。</p></div>
|
||||
<div class="drawer-actions"><a class="secondary-button" href="{% url 'agent_runtime:provider-list' %}">关闭</a></div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
+37
-2
@@ -1,5 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{% block title %}JobRadar{% endblock %}</title><link rel="stylesheet" href="/static/css/app.css"></head>
|
||||
<body><a class="skip" href="#main">跳到主要内容</a><header><strong>JobRadar · 职途雷达</strong>{% if user.is_authenticated %}<nav><a href="{% url 'agent_runtime:run-list' %}">运行记录</a><a href="{% url 'accounts:profile' %}">个人资料</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button>退出</button></form></nav>{% endif %}</header><main id="main">{% for message in messages %}<p class="message">{{ message }}</p>{% endfor %}{% block content %}{% endblock %}</main></body>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{% block title %}JobRadar{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
</head>
|
||||
<body class="{% if user.is_authenticated %}app-shell{% else %}public-shell{% endif %}">
|
||||
<a class="skip" href="#main">跳到主要内容</a>
|
||||
{% if user.is_authenticated %}
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<a class="brand" href="{% url 'agent_runtime:run-list' %}">
|
||||
<span class="brand-mark" aria-hidden="true">◎</span>
|
||||
<span><strong>JobRadar</strong><small>职途雷达</small></span>
|
||||
</a>
|
||||
<nav class="side-nav">
|
||||
<p class="nav-group">工作台</p>
|
||||
<a href="{% url 'agent_runtime:run-list' %}"><span aria-hidden="true">◫</span> Agent 运行记录</a>
|
||||
<p class="nav-group">设置</p>
|
||||
<a href="{% url 'agent_runtime:provider-list' %}"><span aria-hidden="true">⚙</span> API 服务商</a>
|
||||
<a href="{% url 'accounts:profile' %}"><span aria-hidden="true">○</span> 个人资料</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<span class="avatar">{{ user.username|first|upper }}</span>
|
||||
<span><strong>{{ user.username }}</strong><small>个人工作区</small></span>
|
||||
<form method="post" action="{% url 'accounts:logout' %}">
|
||||
{% csrf_token %}<button class="icon-button" title="退出登录">↗</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
{% else %}
|
||||
<header class="public-header"><strong>JobRadar · 职途雷达</strong></header>
|
||||
{% endif %}
|
||||
<main id="main" class="main-content">
|
||||
{% for message in messages %}<p class="message">{{ message }}</p>{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user