AgentEvalTool/backend/agenteval/services/model_configs.py
sinohqb e1e067bac4 feat(models): add analysis-default flag for campaign intelligence
Introduce ModelPurpose.ANALYSIS and a globally-unique is_analysis_default
marker on chat model configs so campaign analysis can resolve its model.
Service rejects disabled or non-chat configs; repo clears the previous
holder on set. Documented the analysis role in CONTEXT.md.
2026-08-03 01:46:51 +08:00

388 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Business rules and secret handling for model configurations."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from urllib.parse import urlparse
from cryptography.fernet import Fernet, InvalidToken
from sqlmodel import Session
from agenteval.config import get_settings
from agenteval.model_protocols import ProtocolAdapterError, get_protocol_adapter
from agenteval.models import ModelCapability, ModelModality, ModelPurpose
from agenteval.storage.db import ModelConfigDB, ScenarioDB
from agenteval.storage.model_config_repository import ModelConfigRepository
PURPOSE_CAPABILITIES: dict[str, ModelCapability] = {
ModelPurpose.GENERATOR.value: ModelCapability.CHAT,
ModelPurpose.JUDGE.value: ModelCapability.CHAT,
ModelPurpose.EMBEDDING.value: ModelCapability.EMBEDDING,
ModelPurpose.MODERATION.value: ModelCapability.MODERATION,
}
class ModelConfigError(ValueError):
pass
class ModelConfigNotFoundError(ModelConfigError):
pass
class ModelConfigInUseError(ModelConfigError):
def __init__(self, references: list[dict[str, str]]):
super().__init__("模型配置正在被场景引用")
self.references = references
class SecretKeyError(ModelConfigError):
pass
@dataclass(frozen=True)
class ModelRuntimeConfig:
id: str
name: str
provider: str
capability: ModelCapability
endpoint_url: str
model_name: str | None
api_key: str | None
updated_at: datetime | None
vendor_name: str = ""
input_modalities: tuple[str, ...] = (ModelModality.TEXT.value,)
output_modalities: tuple[str, ...] = (ModelModality.TEXT.value,)
context_window: int | None = None
max_output_tokens: int | None = None
supports_streaming: bool = False
supports_tool_calling: bool = False
supports_structured_output: bool = False
supports_reasoning: bool = False
region: str = ""
documentation_url: str | None = None
def snapshot(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"provider": self.provider,
"capability": self.capability.value,
"endpoint_url": self.endpoint_url,
"model_name": self.model_name,
"vendor_name": self.vendor_name,
"input_modalities": list(self.input_modalities),
"output_modalities": list(self.output_modalities),
"context_window": self.context_window,
"max_output_tokens": self.max_output_tokens,
"supports_streaming": self.supports_streaming,
"supports_tool_calling": self.supports_tool_calling,
"supports_structured_output": self.supports_structured_output,
"supports_reasoning": self.supports_reasoning,
"region": self.region,
"documentation_url": self.documentation_url,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class SecretCipher:
def __init__(self, key: str | None = None):
self._key = key if key is not None else get_settings().secret_key
def _fernet(self) -> Fernet:
if not self._key:
raise SecretKeyError("未配置 AGENTEVAL_SECRET_KEY无法保存或读取模型 API Key")
try:
return Fernet(self._key.encode("ascii"))
except (ValueError, UnicodeEncodeError) as exc:
raise SecretKeyError("AGENTEVAL_SECRET_KEY 不是有效的 Fernet Key") from exc
def encrypt(self, value: str | None) -> str | None:
if not value:
return None
return self._fernet().encrypt(value.encode("utf-8")).decode("ascii")
def decrypt(self, value: str | None) -> str | None:
if not value:
return None
try:
return self._fernet().decrypt(value.encode("ascii")).decode("utf-8")
except InvalidToken as exc:
raise SecretKeyError("模型 API Key 无法解密,请检查 AGENTEVAL_SECRET_KEY") from exc
class ModelConfigService:
def __init__(self, session: Session, cipher: SecretCipher | None = None):
self.session = session
self.repo = ModelConfigRepository(session)
self.cipher = cipher or SecretCipher()
@staticmethod
def validate_fields(provider: str, capability: str, endpoint_url: str, model_name: str | None) -> None:
try:
capability_value = ModelCapability(capability)
except ValueError as exc:
raise ModelConfigError(f"不支持的模型能力: {capability}") from exc
try:
adapter = get_protocol_adapter(provider)
except ProtocolAdapterError as exc:
raise ModelConfigError(str(exc)) from exc
if capability_value not in adapter.supported_capabilities:
raise ModelConfigError(f"{provider} 协议不支持 {capability_value.value} 能力")
parsed = urlparse(endpoint_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ModelConfigError("Endpoint 必须是有效的 HTTP 或 HTTPS URL")
if capability_value in {ModelCapability.CHAT, ModelCapability.EMBEDDING} and not model_name:
raise ModelConfigError("chat 和 embedding 配置必须填写模型名称")
@staticmethod
def validate_common(name: str, provider: str, enabled: bool, is_default: bool) -> None:
if not name.strip():
raise ModelConfigError("模型配置名称不能为空")
try:
get_protocol_adapter(provider)
except ProtocolAdapterError as exc:
raise ModelConfigError(str(exc)) from exc
if is_default and not enabled:
raise ModelConfigError("停用的模型配置不能设为默认")
@staticmethod
def validate_analysis_default(capability: str, enabled: bool, is_analysis_default: bool) -> None:
if not is_analysis_default:
return
if not enabled:
raise ModelConfigError("停用的模型配置不能设为分析默认")
if capability != ModelCapability.CHAT.value:
raise ModelConfigError("分析默认模型必须是 chat 能力")
@staticmethod
def normalize_modalities(modalities: list[str] | None) -> list[str]:
values = modalities if modalities is not None else [ModelModality.TEXT.value]
normalized: list[str] = []
for value in values:
try:
modality = ModelModality(value).value
except ValueError as exc:
raise ModelConfigError(f"不支持的模型模态: {value}") from exc
if modality not in normalized:
normalized.append(modality)
if not normalized:
raise ModelConfigError("输入和输出模态至少选择一项")
return normalized
@staticmethod
def validate_metadata(
context_window: int | None,
max_output_tokens: int | None,
documentation_url: str | None,
) -> None:
if context_window is not None and context_window <= 0:
raise ModelConfigError("上下文窗口必须大于 0")
if max_output_tokens is not None and max_output_tokens <= 0:
raise ModelConfigError("最大输出 Token 必须大于 0")
if documentation_url:
parsed = urlparse(documentation_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ModelConfigError("官方文档地址必须是有效的 HTTP 或 HTTPS URL")
def create(
self,
*,
name: str,
provider: str,
capability: str,
endpoint_url: str,
model_name: str | None,
api_key: str | None,
enabled: bool,
is_default: bool,
description: str,
vendor_name: str = "",
input_modalities: list[str] | None = None,
output_modalities: list[str] | None = None,
context_window: int | None = None,
max_output_tokens: int | None = None,
supports_streaming: bool = False,
supports_tool_calling: bool = False,
supports_structured_output: bool = False,
supports_reasoning: bool = False,
region: str = "",
documentation_url: str | None = None,
is_analysis_default: bool = False,
) -> ModelConfigDB:
name = name.strip()
input_modalities = self.normalize_modalities(input_modalities)
output_modalities = self.normalize_modalities(output_modalities)
self.validate_common(name, provider, enabled, is_default)
self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
self.validate_metadata(context_window, max_output_tokens, documentation_url)
self.validate_analysis_default(capability, enabled, is_analysis_default)
if self.repo.get_by_name(name):
raise ModelConfigError("模型配置名称已存在")
config = ModelConfigDB(
name=name,
provider=provider,
capability=capability,
endpoint_url=endpoint_url.strip(),
model_name=model_name.strip() if model_name else None,
vendor_name=vendor_name.strip(),
context_window=context_window,
max_output_tokens=max_output_tokens,
supports_streaming=supports_streaming,
supports_tool_calling=supports_tool_calling,
supports_structured_output=supports_structured_output,
supports_reasoning=supports_reasoning,
region=region.strip(),
documentation_url=documentation_url.strip() if documentation_url else None,
api_key_encrypted=self.cipher.encrypt(api_key),
enabled=enabled,
is_default=is_default,
is_analysis_default=is_analysis_default,
description=description.strip(),
)
config.set_modalities(input_modalities, output_modalities)
return self.repo.create(config)
def update(
self,
config_id: str,
*,
name: str,
provider: str,
capability: str,
endpoint_url: str,
model_name: str | None,
api_key: str | None,
clear_api_key: bool,
enabled: bool,
is_default: bool,
description: str,
vendor_name: str = "",
input_modalities: list[str] | None = None,
output_modalities: list[str] | None = None,
context_window: int | None = None,
max_output_tokens: int | None = None,
supports_streaming: bool = False,
supports_tool_calling: bool = False,
supports_structured_output: bool = False,
supports_reasoning: bool = False,
region: str = "",
documentation_url: str | None = None,
is_analysis_default: bool = False,
) -> ModelConfigDB:
config = self.require(config_id)
name = name.strip()
input_modalities = self.normalize_modalities(input_modalities)
output_modalities = self.normalize_modalities(output_modalities)
self.validate_common(name, provider, enabled, is_default)
self.validate_fields(provider, capability, endpoint_url.strip(), model_name)
self.validate_metadata(context_window, max_output_tokens, documentation_url)
self.validate_analysis_default(capability, enabled, is_analysis_default)
references = self.repo.list_references(config_id)
if references and not enabled:
raise ModelConfigError("模型配置正在被场景引用,不能停用")
for reference in references:
expected = PURPOSE_CAPABILITIES.get(reference.purpose)
if expected and expected.value != capability:
raise ModelConfigError(
f"模型配置正用于 {reference.purpose},能力不能改为 {capability}",
)
same_name = self.repo.get_by_name(name)
if same_name and same_name.id != config_id:
raise ModelConfigError("模型配置名称已存在")
config.name = name
config.provider = provider
config.capability = capability
config.endpoint_url = endpoint_url.strip()
config.model_name = model_name.strip() if model_name else None
config.vendor_name = vendor_name.strip()
config.set_modalities(input_modalities, output_modalities)
config.context_window = context_window
config.max_output_tokens = max_output_tokens
config.supports_streaming = supports_streaming
config.supports_tool_calling = supports_tool_calling
config.supports_structured_output = supports_structured_output
config.supports_reasoning = supports_reasoning
config.region = region.strip()
config.documentation_url = documentation_url.strip() if documentation_url else None
config.enabled = enabled
config.is_default = is_default
config.is_analysis_default = is_analysis_default
config.description = description.strip()
if clear_api_key:
config.api_key_encrypted = None
elif api_key:
config.api_key_encrypted = self.cipher.encrypt(api_key)
return self.repo.update(config)
def require(self, config_id: str) -> ModelConfigDB:
config = self.repo.get(config_id)
if not config:
raise ModelConfigNotFoundError("模型配置不存在")
return config
def resolve(
self,
config_id: str,
expected_capability: ModelCapability | None = None,
) -> ModelRuntimeConfig:
config = self.require(config_id)
capability = ModelCapability(config.capability)
if not config.enabled:
raise ModelConfigError(f"模型配置“{config.name}”已禁用")
if expected_capability and capability != expected_capability:
raise ModelConfigError(
f"模型配置“{config.name}”能力为 {capability.value},不能用于 {expected_capability.value}",
)
return ModelRuntimeConfig(
id=config.id or "",
name=config.name,
provider=config.provider,
capability=capability,
endpoint_url=config.endpoint_url,
model_name=config.model_name,
api_key=self.cipher.decrypt(config.api_key_encrypted),
updated_at=config.updated_at,
vendor_name=config.vendor_name,
input_modalities=tuple(config.get_input_modalities()),
output_modalities=tuple(config.get_output_modalities()),
context_window=config.context_window,
max_output_tokens=config.max_output_tokens,
supports_streaming=config.supports_streaming,
supports_tool_calling=config.supports_tool_calling,
supports_structured_output=config.supports_structured_output,
supports_reasoning=config.supports_reasoning,
region=config.region,
documentation_url=config.documentation_url,
)
def delete(self, config_id: str) -> None:
config = self.require(config_id)
references = []
for binding in self.repo.list_references(config_id):
scenario = self.session.get(ScenarioDB, binding.scenario_id)
references.append(
{
"scenario_id": binding.scenario_id,
"scenario_name": scenario.name if scenario else binding.scenario_id,
"purpose": binding.purpose,
}
)
if references:
raise ModelConfigInUseError(references)
self.repo.delete(config)
def validate_bindings(self, bindings: dict[str, str]) -> None:
for purpose, config_id in bindings.items():
expected = PURPOSE_CAPABILITIES.get(purpose)
if expected is None:
raise ModelConfigError(f"不支持的模型用途: {purpose}")
config = self.require(config_id)
if not config.enabled:
raise ModelConfigError(f"模型配置“{config.name}”已禁用")
capability = ModelCapability(config.capability)
if capability != expected:
raise ModelConfigError(
f"模型配置“{config.name}”能力为 {capability.value},不能用于 {expected.value}",
)