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
+2
View File
@@ -19,6 +19,8 @@ POSTGRES_CONN_MAX_AGE=60
OPENAI_API_KEY=请替换为OpenAI_API_Key OPENAI_API_KEY=请替换为OpenAI_API_Key
OPENAI_MODEL= OPENAI_MODEL=
AGENT_DEFAULT_NAME=job_research AGENT_DEFAULT_NAME=job_research
# 至少 32 个随机字符;生产环境必须稳定保存,遗失后已有模型 API Key 无法解密。
MODEL_API_KEY_ENCRYPTION_KEY=请替换为独立生成的高强度随机密钥
# 生产安全配置 # 生产安全配置
DJANGO_SECURE_SSL_REDIRECT=true DJANGO_SECURE_SSL_REDIRECT=true
+4 -7
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
# 项目根目录。所有本地文件路径统一从这里派生,避免依赖启动目录。 # 项目根目录。所有本地文件路径统一从这里派生,避免依赖启动目录。
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -87,12 +86,7 @@ WSGI_APPLICATION = "JobRadar.wsgi.application"
ASGI_APPLICATION = "JobRadar.asgi.application" ASGI_APPLICATION = "JobRadar.asgi.application"
AUTH_PASSWORD_VALIDATORS = [ 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.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
@@ -120,6 +114,9 @@ LOGOUT_REDIRECT_URL = "accounts:login"
OPENAI_API_KEY = env("OPENAI_API_KEY", "") OPENAI_API_KEY = env("OPENAI_API_KEY", "")
OPENAI_MODEL = env("OPENAI_MODEL", "") OPENAI_MODEL = env("OPENAI_MODEL", "")
AGENT_DEFAULT_NAME = env("AGENT_DEFAULT_NAME", "job_research") AGENT_DEFAULT_NAME = env("AGENT_DEFAULT_NAME", "job_research")
# 用户在页面录入的模型 API Key 使用独立主密钥加密。该值不得与数据库一起保存,
# 生产部署必须稳定注入;轮换前需要先设计密文重加密流程。
MODEL_API_KEY_ENCRYPTION_KEY = env("MODEL_API_KEY_ENCRYPTION_KEY", "")
# 日志只输出可关联的结构化键值,不记录请求正文、Cookie 或认证头。 # 日志只输出可关联的结构化键值,不记录请求正文、Cookie 或认证头。
LOGGING = { LOGGING = {
+2 -2
View File
@@ -1,7 +1,7 @@
"""生产环境配置:关键安全参数和 PostgreSQL 凭据均必须显式注入。""" """生产环境配置:关键安全参数和 PostgreSQL 凭据均必须显式注入。"""
from .base import env, env_bool, env_list
from .base import * # noqa: F403 from .base import * # noqa: F403
from .base import env, env_bool, env_list
def required_env(name: str) -> str: def required_env(name: str) -> str:
@@ -14,6 +14,7 @@ def required_env(name: str) -> str:
SECRET_KEY = required_env("DJANGO_SECRET_KEY") SECRET_KEY = required_env("DJANGO_SECRET_KEY")
MODEL_API_KEY_ENCRYPTION_KEY = required_env("MODEL_API_KEY_ENCRYPTION_KEY")
DEBUG = False DEBUG = False
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS") ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS")
if not 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_HSTS_PRELOAD = env_bool("DJANGO_SECURE_HSTS_PRELOAD", default=False)
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY" X_FRAME_OPTIONS = "DENY"
+2 -3
View File
@@ -1,10 +1,10 @@
"""自动化测试配置:始终使用独立的内存 SQLite 数据库。""" """自动化测试配置:始终使用独立的内存 SQLite 数据库。"""
from .base import BASE_DIR
from .base import * # noqa: F403 from .base import * # noqa: F403
from .base import BASE_DIR
SECRET_KEY = "test-only-secret-key-not-for-production" SECRET_KEY = "test-only-secret-key-not-for-production"
MODEL_API_KEY_ENCRYPTION_KEY = "test-only-model-key-32-characters-minimum"
DEBUG = False DEBUG = False
ALLOWED_HOSTS = ["testserver", "localhost"] ALLOWED_HOSTS = ["testserver", "localhost"]
@@ -19,4 +19,3 @@ DATABASES = {
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
MEDIA_ROOT = BASE_DIR / ".test-media" MEDIA_ROOT = BASE_DIR / ".test-media"
+23 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin 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 from .services import resolve_approval
@@ -27,6 +27,28 @@ class AgentRunAdmin(admin.ModelAdmin):
admin.site.register(AgentRunEvent, ReadOnlyAuditAdmin) admin.site.register(AgentRunEvent, ReadOnlyAuditAdmin)
admin.site.register(ToolCall, 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) @admin.register(HumanApproval)
class HumanApprovalAdmin(admin.ModelAdmin): class HumanApprovalAdmin(admin.ModelAdmin):
"""人工确认正文只读,通过受控动作执行领域状态转换。""" """人工确认正文只读,通过受控动作执行领域状态转换。"""
+19
View File
@@ -14,6 +14,9 @@ class AgentRuntimeConfig:
api_key: str api_key: str
model: str model: str
agent_name: str agent_name: str
provider_code: str = "openai"
protocol: str = "openai_responses"
base_url: str = "https://api.openai.com/v1"
@classmethod @classmethod
def from_settings(cls) -> "AgentRuntimeConfig": def from_settings(cls) -> "AgentRuntimeConfig":
@@ -36,3 +39,19 @@ class AgentRuntimeConfig:
if missing: if missing:
raise AgentConfigurationError(f"真实 Agent 执行缺少配置:{', '.join(missing)}。") raise AgentConfigurationError(f"真实 Agent 执行缺少配置:{', '.join(missing)}。")
return self 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,
)
+32
View File
@@ -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
View File
@@ -18,6 +18,46 @@ class RunStatus(models.TextChoices):
CANCELLED = "cancelled", "已取消" 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): class AgentRun(UserOwnedModel):
"""一次可审计的 Agent 运行;摘要字段不得保存敏感原文。""" """一次可审计的 Agent 运行;摘要字段不得保存敏感原文。"""
@@ -38,14 +78,18 @@ class AgentRun(UserOwnedModel):
duration_ms = models.PositiveBigIntegerField("耗时毫秒", null=True, blank=True) duration_ms = models.PositiveBigIntegerField("耗时毫秒", null=True, blank=True)
input_tokens = models.PositiveBigIntegerField("输入 Token", null=True, blank=True) input_tokens = models.PositiveBigIntegerField("输入 Token", null=True, blank=True)
output_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_code = models.CharField("错误码", max_length=80, blank=True)
error_summary = models.TextField("错误摘要", blank=True) error_summary = models.TextField("错误摘要", blank=True)
class Meta: class Meta:
ordering = ("-created_at",) ordering = ("-created_at",)
indexes = [models.Index(fields=("owner", "created_at"), name="run_owner_created_idx")] 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): def __str__(self):
return self.title return self.title
@@ -64,7 +108,14 @@ class AgentRunEvent(models.Model):
class Meta: class Meta:
ordering = ("sequence",) 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): class ToolCallStatus(models.TextChoices):
@@ -81,7 +132,9 @@ class ToolCall(TimeStampedModel):
tool_name = models.CharField("工具名称", max_length=100) tool_name = models.CharField("工具名称", max_length=100)
tool_version = models.CharField("工具版本", max_length=40, blank=True) tool_version = models.CharField("工具版本", max_length=40, blank=True)
idempotency_key = models.CharField("幂等键", max_length=128, 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) arguments_summary = models.JSONField("参数摘要", default=dict)
result_summary = models.JSONField("结果摘要", null=True, blank=True) result_summary = models.JSONField("结果摘要", null=True, blank=True)
error_code = models.CharField("错误码", max_length=80, 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") run = models.ForeignKey(AgentRun, on_delete=models.PROTECT, related_name="approvals")
request_key = models.CharField("请求键", max_length=128) request_key = models.CharField("请求键", max_length=128)
approval_type = models.CharField("确认类型", max_length=64) 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) request_summary = models.JSONField("请求摘要", default=dict)
decision_summary = models.JSONField("处理摘要", null=True, blank=True) decision_summary = models.JSONField("处理摘要", null=True, blank=True)
requested_at = models.DateTimeField("请求时间") requested_at = models.DateTimeField("请求时间")
@@ -132,4 +187,6 @@ class HumanApproval(TimeStampedModel):
class Meta: class Meta:
indexes = [models.Index(fields=("status", "requested_at"), name="approval_status_time_idx")] 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")
]
+286
View File
@@ -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)
+34
View File
@@ -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
View File
@@ -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 import transaction
from django.db.models import Max from django.db.models import Max
from django.utils import timezone 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 common.logging import sanitize_summary
from .models import ( from .models import (
@@ -12,14 +22,191 @@ from .models import (
AgentRunEvent, AgentRunEvent,
ApprovalStatus, ApprovalStatus,
HumanApproval, HumanApproval,
ModelProviderConfig,
ProviderConnectionStatus,
RunStatus, RunStatus,
ToolCall, ToolCall,
ToolCallStatus, 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 = { TRANSITIONS = {
RunStatus.PENDING: {RunStatus.RUNNING, RunStatus.CANCELLED}, 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}, RunStatus.WAITING_APPROVAL: {RunStatus.RUNNING, RunStatus.FAILED, RunStatus.CANCELLED},
} }
+160 -2
View File
@@ -1,21 +1,35 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.test import SimpleTestCase, TestCase, override_settings from django.test import SimpleTestCase, TestCase, override_settings
from django.urls import reverse 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 .config import AgentRuntimeConfig
from .gateway import AgentExecutionResult, StubAgentRunnerGateway from .gateway import AgentExecutionResult, StubAgentRunnerGateway
from .models import ApprovalStatus, RunStatus, ToolCallStatus from .models import ApprovalStatus, ModelProviderConfig, RunStatus, ToolCallStatus
from .orchestration import execute_run from .orchestration import execute_run
from .providers import get_provider
from .secrets import decrypt_api_key, encrypt_api_key
from .services import ( from .services import (
ProviderVerificationResult,
create_run, create_run,
disconnect_provider,
finish_tool_call, finish_tool_call,
request_approval, request_approval,
resolve_approval, resolve_approval,
resolve_runtime_model_config,
save_verified_provider_config,
start_tool_call, start_tool_call,
transition_run, transition_run,
verify_provider_api_key,
) )
@@ -146,3 +160,147 @@ class AgentRuntimeConfigTests(SimpleTestCase):
def test_complete_configuration_is_accepted_without_external_call(self): def test_complete_configuration_is_accepted_without_external_call(self):
config = AgentRuntimeConfig.from_settings().require_real_execution() config = AgentRuntimeConfig.from_settings().require_real_execution()
self.assertEqual(config.model, "test-model") 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
View File
@@ -3,4 +3,18 @@ from django.urls import path
from . import views from . import views
app_name = "agent_runtime" 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
View File
@@ -1,10 +1,21 @@
"""当前用户的 Agent Run 查询页面。""" """当前用户的 Agent Run 与模型服务商配置页面。"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator 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 @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 runs_for_user(request.user).prefetch_related("events", "tool_calls", "approvals"), pk=run_id
) )
return render(request, "agent_runtime/run_detail.html", {"run": run}) 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")
+8
View File
@@ -19,3 +19,11 @@ class PermissionDenied(DomainError):
class AgentConfigurationError(DomainError): class AgentConfigurationError(DomainError):
"""真实 Agent 执行所需配置缺失或不合法。""" """真实 Agent 执行所需配置缺失或不合法。"""
class ProviderConnectionError(DomainError):
"""模型服务商凭据验证失败;消息不得包含上游原始响应。"""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
+2
View File
@@ -11,6 +11,8 @@ dependencies:
- psycopg[binary]==3.3.5 - psycopg[binary]==3.3.5
- pillow==12.2.0 - pillow==12.2.0
- openai-agents==0.22.0 - openai-agents==0.22.0
# 页面保存的模型 API Key 必须可逆加密,禁止明文入库或自制加密算法。
- cryptography==50.0.1
# 测试与代码质量依赖统一固定版本,保证本地和持续集成结果一致。 # 测试与代码质量依赖统一固定版本,保证本地和持续集成结果一致。
- pytest==9.1.1 - pytest==9.1.1
- pytest-django==4.14.0 - pytest-django==4.14.0
+1 -1
View File
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 %}&amp;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 }}&amp;category={{ active_category }}{% if query %}&amp;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
View File
@@ -1,5 +1,40 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <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> <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> <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> </html>