"""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 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, ) -> 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) 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, 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, ) -> 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) 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.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}", )