"""模型能力契约与动态 Fallback 候选解析。 本模块只负责“某模型是否满足本次调用”与“失败后候选如何排序”,不执行 Provider 请求、重试、日志或账务。业务入口后续只声明 :class:`ModelRequirements`,不得按模型名 复制能力判断。 """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime from typing import Any from apps.ai.generation_errors import provider_error_rule_errors from apps.ai.models import ModelConfig, ModelProvider from apps.ai.routing_policy import load_model_routing_policy @dataclass(frozen=True, slots=True) class ModelRequirements: """一次模型调用的最小能力需求;未使用的字段保持空值。""" capability: str operation: str features: frozenset[str] = field(default_factory=frozenset) reference_mode: str | None = None reference_images: int = 0 reference_videos: int = 0 reference_audios: int = 0 aspect_ratio: str | None = None resolution: str | None = None duration: int | None = None language: str | None = None public_voice: str | None = None char_count: int | None = None speed_ratio: float | None = None output_format: str | None = None def __post_init__(self) -> None: if not self.capability or not self.operation: raise ValueError("模型能力需求必须包含 capability 和 operation") for name in ("reference_images", "reference_videos", "reference_audios"): if getattr(self, name) < 0: raise ValueError(f"{name} 不能为负数") if self.reference_mode not in {None, "none", "single", "multiple"}: raise ValueError("reference_mode 只能是 none、single 或 multiple") if self.reference_mode == "single" and self.reference_images != 1: raise ValueError("reference_mode=single 时 reference_images 必须等于 1") if self.reference_mode == "multiple" and self.reference_images < 2: raise ValueError("reference_mode=multiple 时 reference_images 必须至少为 2") # Seedance 的视频编辑模式用 -1 表示“跟随参考视频时长”。 if self.duration is not None and self.duration <= 0 and self.duration != -1: raise ValueError("duration 必须大于 0,或为 -1(跟随参考视频)") if self.char_count is not None and self.char_count < 0: raise ValueError("char_count 不能为负数") if self.speed_ratio is not None and self.speed_ratio <= 0: raise ValueError("speed_ratio 必须大于 0") @dataclass(frozen=True, slots=True) class CapabilityMatch: matched: bool reasons: tuple[str, ...] = () def _dict(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} def _set(value: Any) -> set[str]: if not isinstance(value, (list, tuple, set, frozenset)): return set() return {str(item) for item in value if str(item)} def _positive_int(value: Any) -> int | None: if isinstance(value, bool): return None try: result = int(value) except (TypeError, ValueError): return None return result if result >= 0 else None def routing_metadata(model: ModelConfig) -> dict[str, Any]: return _dict(_dict(model.metadata).get("routing")) def capability_metadata(model: ModelConfig) -> dict[str, Any]: return _dict(_dict(model.metadata).get("capabilities")) def model_allows_fallback(model: ModelConfig) -> bool: """该模型失败后是否允许向外切换;缺失配置时保守关闭。""" return routing_metadata(model).get("fallback_on_failure") is True def model_is_fallback_candidate(model: ModelConfig) -> bool: """该模型是否允许被其他失败任务选中;缺失配置时保守关闭。""" return routing_metadata(model).get("fallback_candidate") is True def provider_fallback_priority(provider: ModelProvider) -> int: """读取供应商候选优先级;数字越小越优先,缺失或非法值统一排到普通供应商层。""" value = _dict(_dict(provider.metadata).get("routing")).get("fallback_priority", 100) if isinstance(value, bool): return 100 try: return int(value) except (TypeError, ValueError): return 100 def provider_metadata_errors(metadata: Any) -> tuple[str, ...]: """校验供应商路由与错误展示配置;供后台写入校验和运行前诊断共同复用。""" routing = _dict(_dict(metadata).get("routing")) errors = list(provider_error_rule_errors(metadata)) if "fallback_priority" in routing: value = routing["fallback_priority"] if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 1000: errors.append("routing.fallback_priority 必须是 0 到 1000 的整数,数字越小越优先") return tuple(errors) def model_metadata_errors(capability: str, metadata: Any) -> tuple[str, ...]: """校验模型路由开关与最小能力契约,缺少候选开关时允许保存。""" root = _dict(metadata) routing = _dict(root.get("routing")) errors: list[str] = [] for name in ("fallback_on_failure", "fallback_candidate"): if name in routing and not isinstance(routing[name], bool): errors.append(f"routing.{name} 必须是 true 或 false") if routing.get("fallback_candidate") is not True: return tuple(errors) capabilities = _dict(root.get("capabilities")) if not _set(capabilities.get("operations")): errors.append("capabilities.operations 至少配置一个操作") if capability == ModelConfig.Capability.IMAGE: modes = _set(capabilities.get("reference_modes")) if modes - {"none", "single", "multiple"}: errors.append("capabilities.reference_modes 只能包含 none、single、multiple") if capability == ModelConfig.Capability.VIDEO: if not _set(capabilities.get("resolutions")): errors.append("视频候选必须配置 capabilities.resolutions") durations = capabilities.get("durations") if not isinstance(durations, (list, tuple)) or not durations: errors.append("视频候选必须配置 capabilities.durations") if capability == ModelConfig.Capability.AUDIO: voice_map = capabilities.get("voice_map") if voice_map is not None and not isinstance(voice_map, dict): errors.append("配音 capabilities.voice_map 必须是公开音色到供应商音色 ID 的对象") return tuple(errors) def capability_metadata_errors(model: ModelConfig) -> tuple[str, ...]: """返回会导致模型无法安全进入候选池的中文配置问题。""" return model_metadata_errors(model.capability, model.metadata) def _check_limit( reasons: list[str], capabilities: dict[str, Any], field_name: str, required: int, label: str, ) -> None: if required <= 0: return maximum = _positive_int(capabilities.get(field_name)) if maximum is None or maximum < required: reasons.append(f"{label}上限不足:需要 {required},配置为 {maximum if maximum is not None else '缺失'}") def _video_pricing_available(model: ModelConfig, resolution: str | None) -> bool: if not resolution: return True pricing = _dict(_dict(model.metadata).get("pricing")) if not pricing: return False # 1080p/4k 必须有精确价格档;480p/720p 可使用现有 default 档。 tier = pricing.get(resolution) if resolution in {"1080p", "4k"} else pricing.get(resolution) or pricing.get("default") return isinstance(tier, dict) and bool(tier) def match_model_requirements(model: ModelConfig, requirements: ModelRequirements) -> CapabilityMatch: """纯函数式能力匹配;缺失配置一律保守排除,不按模型名猜测。""" reasons: list[str] = [] if model.capability != requirements.capability: reasons.append(f"能力大类不匹配:需要 {requirements.capability},模型为 {model.capability}") return CapabilityMatch(False, tuple(reasons)) capabilities = capability_metadata(model) operations = _set(capabilities.get("operations")) if requirements.operation not in operations: reasons.append(f"不支持操作 {requirements.operation}") missing_features = sorted(set(requirements.features) - _set(capabilities.get("features"))) if missing_features: reasons.append(f"缺少特性:{', '.join(missing_features)}") if requirements.reference_mode and requirements.reference_mode != "none": if requirements.reference_mode not in _set(capabilities.get("reference_modes")): reasons.append(f"不支持 {requirements.reference_mode} 参考图模式") _check_limit(reasons, capabilities, "max_reference_images", requirements.reference_images, "参考图片") _check_limit(reasons, capabilities, "max_reference_videos", requirements.reference_videos, "参考视频") _check_limit(reasons, capabilities, "max_reference_audios", requirements.reference_audios, "参考音频") for field_name, required, label in ( ("aspect_ratios", requirements.aspect_ratio, "画面比例"), ("resolutions", requirements.resolution, "分辨率"), ("languages", requirements.language, "语言"), ("output_formats", requirements.output_format, "输出格式"), ): # 视频编辑的 adaptive 比例由火山按输入视频决定,不是模型静态能力表中的比例档。 if required and required != "adaptive" and required not in _set(capabilities.get(field_name)): reasons.append(f"不支持{label} {required}") if requirements.duration is not None and requirements.duration != -1: durations = capabilities.get("durations") supported = set(durations) if isinstance(durations, (list, tuple, set, frozenset)) else set() if requirements.duration not in supported: reasons.append(f"不支持时长 {requirements.duration} 秒") if requirements.public_voice: voice_map = capabilities.get("voice_map") if not isinstance(voice_map, dict) or not voice_map.get(requirements.public_voice): reasons.append(f"缺少公开音色 {requirements.public_voice} 的供应商映射") if requirements.char_count is not None: max_chars = _positive_int(capabilities.get("max_chars")) if max_chars is None or requirements.char_count > max_chars: reasons.append( f"字符上限不足:需要 {requirements.char_count},配置为 {max_chars if max_chars is not None else '缺失'}" ) if requirements.speed_ratio is not None: speed_range = capabilities.get("speed_range") if not isinstance(speed_range, (list, tuple)) or len(speed_range) != 2: reasons.append("缺少合法的语速范围配置") else: try: minimum, maximum = float(speed_range[0]), float(speed_range[1]) except (TypeError, ValueError): reasons.append("语速范围配置不是数字") else: if not minimum <= requirements.speed_ratio <= maximum: reasons.append(f"不支持语速 {requirements.speed_ratio:g}") if requirements.capability == ModelConfig.Capability.VIDEO and not _video_pricing_available( model, requirements.resolution ): reasons.append(f"缺少分辨率 {requirements.resolution} 的视频价格配置") return CapabilityMatch(not reasons, tuple(reasons)) def _timestamp(value: datetime | None) -> float: return value.timestamp() if value is not None else 0.0 def _candidate_sort_key(model: ModelConfig) -> tuple[int, float, float, str]: return ( provider_fallback_priority(model.provider), -_timestamp(model.updated_at), -_timestamp(model.created_at), str(model.id), ) def resolve_fallback_candidates( *, primary_model: ModelConfig, requirements: ModelRequirements, attempted_model_ids: set[Any] | frozenset[Any] = frozenset(), excluded_provider_ids: set[Any] | frozenset[Any] = frozenset(), ) -> list[ModelConfig]: """在主模型明确失败后解析候选;不会替换或重排用户传入的主模型。""" if not model_allows_fallback(primary_model): return [] attempted = {str(value) for value in attempted_model_ids} attempted.add(str(primary_model.id)) excluded_providers = {str(value) for value in excluded_provider_ids} policy = load_model_routing_policy() remaining_model_slots = max(0, policy.max_models - len(attempted)) if remaining_model_slots == 0: return [] candidates = [] queryset = ModelConfig.objects.select_related("provider").filter( capability=requirements.capability, status=ModelConfig.Status.ACTIVE, provider__status=ModelProvider.Status.ACTIVE, ) for model in queryset: if str(model.id) in attempted or str(model.provider_id) in excluded_providers: continue if not model_is_fallback_candidate(model) or capability_metadata_errors(model): continue if match_model_requirements(model, requirements).matched: candidates.append(model) candidates.sort(key=_candidate_sort_key) return candidates[:remaining_model_slots] __all__ = [ "CapabilityMatch", "ModelRequirements", "capability_metadata", "capability_metadata_errors", "match_model_requirements", "model_allows_fallback", "model_is_fallback_candidate", "model_metadata_errors", "provider_fallback_priority", "provider_metadata_errors", "resolve_fallback_candidates", "routing_metadata", ]