feat: 接入模型动态 Fallback 与调用审计
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
"""模型能力契约与动态 Fallback 候选解析。
|
||||
|
||||
本模块只负责“某模型是否满足本次调用”与“失败后候选如何排序”,不执行 Provider
|
||||
请求、重试、日志或账务。业务入口后续只声明 :class:`ModelRequirements`,不得按模型名
|
||||
复制能力判断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
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")
|
||||
if self.duration is not None and self.duration <= 0:
|
||||
raise ValueError("duration 必须大于 0")
|
||||
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"))
|
||||
if "fallback_priority" not in routing:
|
||||
return ()
|
||||
value = routing["fallback_priority"]
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 1000:
|
||||
return ("routing.fallback_priority 必须是 0 到 1000 的整数,数字越小越优先",)
|
||||
return ()
|
||||
|
||||
|
||||
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, "输出格式"),
|
||||
):
|
||||
if required and required not in _set(capabilities.get(field_name)):
|
||||
reasons.append(f"不支持{label} {required}")
|
||||
|
||||
if requirements.duration is not None:
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user