feat(agent-runtime): 接入多服务商安全配置
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
"""人工确认正文只读,通过受控动作执行领域状态转换。"""
|
"""人工确认正文只读,通过受控动作执行领域状态转换。"""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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", "已取消"
|
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")
|
||||||
|
]
|
||||||
|
|||||||
@@ -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 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
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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>
|
<!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>
|
||||||
|
|||||||
Reference in New Issue
Block a user