304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""模型路由策略的集中读取与校验。
|
||
|
||
本模块只负责把 Django settings 中的 ``MODEL_ROUTING_POLICY`` 转成不可变配置对象,
|
||
不执行重试、Fallback、Provider 调用或账务操作。业务入口后续只消费这里返回的策略,
|
||
不得各自维护 timeout / sleep / attempts 常量。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Mapping, Sequence
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from django.conf import settings
|
||
|
||
|
||
class RoutingPolicyConfigurationError(ValueError):
|
||
"""模型路由策略格式或取值不合法。"""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class TextRoutingPolicy:
|
||
retry_delays: tuple[float, ...]
|
||
request_timeout: float
|
||
stream_timeout: float
|
||
total_timeout: float
|
||
retry_after_cap: float
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class RequestRoutingPolicy:
|
||
retry_delays: tuple[float, ...]
|
||
request_timeout: float
|
||
total_timeout: float
|
||
retry_after_cap: float
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class VideoRoutingPolicy:
|
||
submit_retry_delays: tuple[float, ...]
|
||
submit_timeout: float
|
||
submit_total_timeout: float
|
||
poll_request_timeout: float
|
||
generation_timeout: float
|
||
retry_after_cap: float
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PostprocessRoutingPolicy:
|
||
retry_delays: tuple[float, ...]
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ModelRoutingPolicy:
|
||
max_models: int
|
||
max_calls: int
|
||
jitter_ratio: float
|
||
text: TextRoutingPolicy
|
||
image: RequestRoutingPolicy
|
||
audio: RequestRoutingPolicy
|
||
video: VideoRoutingPolicy
|
||
postprocess: PostprocessRoutingPolicy
|
||
|
||
|
||
_LABELS = {
|
||
"max_models": "最多尝试模型数量",
|
||
"max_calls": "最多真实模型调用次数",
|
||
"jitter_ratio": "重试随机抖动比例",
|
||
"retry_delays": "重试等待时间列表",
|
||
"request_timeout": "单次调用超时",
|
||
"stream_timeout": "单次流式调用超时",
|
||
"total_timeout": "逻辑任务总时限",
|
||
"retry_after_cap": "Retry-After 最长等待时间",
|
||
"submit_retry_delays": "视频提交重试等待时间列表",
|
||
"submit_timeout": "视频单次提交超时",
|
||
"submit_total_timeout": "视频提交阶段总时限",
|
||
"poll_request_timeout": "视频单次轮询超时",
|
||
"generation_timeout": "视频成片等待总时限",
|
||
}
|
||
|
||
|
||
def _error(path: str, message: str) -> RoutingPolicyConfigurationError:
|
||
key = path.rsplit(".", 1)[-1].split("[", 1)[0]
|
||
label = _LABELS.get(key, path)
|
||
return RoutingPolicyConfigurationError(f"{path}({label}){message}")
|
||
|
||
|
||
def _mapping(value: Any, path: str) -> Mapping[str, Any]:
|
||
if not isinstance(value, Mapping):
|
||
raise _error(path, f"必须是对象,当前值:{value!r}")
|
||
return value
|
||
|
||
|
||
def _required(source: Mapping[str, Any], key: str, path: str) -> Any:
|
||
if key not in source:
|
||
raise _error(f"{path}.{key}", "为必填配置")
|
||
return source[key]
|
||
|
||
|
||
def _integer(value: Any, path: str, *, minimum: int, maximum: int) -> int:
|
||
if isinstance(value, bool) or not isinstance(value, int):
|
||
raise _error(path, f"必须是整数,当前值:{value!r}")
|
||
if not minimum <= value <= maximum:
|
||
raise _error(path, f"必须在 {minimum}~{maximum} 之间,当前值:{value!r}")
|
||
return value
|
||
|
||
|
||
def _number(value: Any, path: str, *, minimum: float, maximum: float) -> float:
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
raise _error(path, f"必须是数字,当前值:{value!r}")
|
||
result = float(value)
|
||
if not minimum <= result <= maximum:
|
||
raise _error(path, f"必须在 {minimum:g}~{maximum:g} 之间,当前值:{value!r}")
|
||
return result
|
||
|
||
|
||
def _delays(value: Any, path: str) -> tuple[float, ...]:
|
||
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
||
raise _error(path, f"必须是数字列表,当前值:{value!r}")
|
||
return tuple(
|
||
_number(item, f"{path}[{index}]", minimum=0, maximum=3600)
|
||
for index, item in enumerate(value)
|
||
)
|
||
|
||
|
||
def _ensure_not_greater(*, smaller: float, larger: float, smaller_path: str, larger_path: str) -> None:
|
||
if smaller > larger:
|
||
raise _error(
|
||
smaller_path,
|
||
f"不得大于 {larger_path}(任务总时限),当前为 {smaller:g} > {larger:g}",
|
||
)
|
||
|
||
|
||
def _field_number(
|
||
source: Mapping[str, Any],
|
||
key: str,
|
||
path: str,
|
||
*,
|
||
minimum: float = 0,
|
||
maximum: float = 86400,
|
||
) -> float:
|
||
return _number(_required(source, key, path), f"{path}.{key}", minimum=minimum, maximum=maximum)
|
||
|
||
|
||
def _field_delays(source: Mapping[str, Any], key: str, path: str) -> tuple[float, ...]:
|
||
return _delays(_required(source, key, path), f"{path}.{key}")
|
||
|
||
|
||
def _validate_budget(
|
||
*,
|
||
total: float,
|
||
total_path: str,
|
||
bounded_values: Mapping[str, float],
|
||
delays: tuple[float, ...] = (),
|
||
delays_path: str = "",
|
||
) -> None:
|
||
for value_path, value in bounded_values.items():
|
||
_ensure_not_greater(
|
||
smaller=value,
|
||
larger=total,
|
||
smaller_path=value_path,
|
||
larger_path=total_path,
|
||
)
|
||
for index, delay in enumerate(delays):
|
||
_ensure_not_greater(
|
||
smaller=delay,
|
||
larger=total,
|
||
smaller_path=f"{delays_path}[{index}]",
|
||
larger_path=total_path,
|
||
)
|
||
|
||
|
||
def _request_policy(raw: Any, path: str) -> RequestRoutingPolicy:
|
||
source = _mapping(raw, path)
|
||
retry_delays = _field_delays(source, "retry_delays", path)
|
||
request_timeout = _field_number(source, "request_timeout", path, minimum=1)
|
||
total_timeout = _field_number(source, "total_timeout", path, minimum=1)
|
||
retry_after_cap = _field_number(source, "retry_after_cap", path, maximum=3600)
|
||
_validate_budget(
|
||
total=total_timeout,
|
||
total_path=f"{path}.total_timeout",
|
||
bounded_values={
|
||
f"{path}.request_timeout": request_timeout,
|
||
f"{path}.retry_after_cap": retry_after_cap,
|
||
},
|
||
delays=retry_delays,
|
||
delays_path=f"{path}.retry_delays",
|
||
)
|
||
return RequestRoutingPolicy(
|
||
retry_delays=retry_delays,
|
||
request_timeout=request_timeout,
|
||
total_timeout=total_timeout,
|
||
retry_after_cap=retry_after_cap,
|
||
)
|
||
|
||
|
||
def _text_policy(raw: Any) -> TextRoutingPolicy:
|
||
path = "MODEL_ROUTING_POLICY.text"
|
||
source = _mapping(raw, path)
|
||
retry_delays = _field_delays(source, "retry_delays", path)
|
||
request_timeout = _field_number(source, "request_timeout", path, minimum=1)
|
||
stream_timeout = _field_number(source, "stream_timeout", path, minimum=1)
|
||
total_timeout = _field_number(source, "total_timeout", path, minimum=1)
|
||
retry_after_cap = _field_number(source, "retry_after_cap", path, maximum=3600)
|
||
_validate_budget(
|
||
total=total_timeout,
|
||
total_path=f"{path}.total_timeout",
|
||
bounded_values={
|
||
f"{path}.request_timeout": request_timeout,
|
||
f"{path}.stream_timeout": stream_timeout,
|
||
f"{path}.retry_after_cap": retry_after_cap,
|
||
},
|
||
delays=retry_delays,
|
||
delays_path=f"{path}.retry_delays",
|
||
)
|
||
return TextRoutingPolicy(
|
||
retry_delays=retry_delays,
|
||
request_timeout=request_timeout,
|
||
stream_timeout=stream_timeout,
|
||
total_timeout=total_timeout,
|
||
retry_after_cap=retry_after_cap,
|
||
)
|
||
|
||
|
||
def _video_policy(raw: Any) -> VideoRoutingPolicy:
|
||
path = "MODEL_ROUTING_POLICY.video"
|
||
source = _mapping(raw, path)
|
||
retry_delays = _field_delays(source, "submit_retry_delays", path)
|
||
submit_timeout = _field_number(source, "submit_timeout", path, minimum=1)
|
||
submit_total_timeout = _field_number(source, "submit_total_timeout", path, minimum=1)
|
||
poll_request_timeout = _field_number(source, "poll_request_timeout", path, minimum=1)
|
||
generation_timeout = _field_number(source, "generation_timeout", path, minimum=1)
|
||
retry_after_cap = _field_number(source, "retry_after_cap", path, maximum=3600)
|
||
_validate_budget(
|
||
total=submit_total_timeout,
|
||
total_path=f"{path}.submit_total_timeout",
|
||
bounded_values={
|
||
f"{path}.submit_timeout": submit_timeout,
|
||
f"{path}.retry_after_cap": retry_after_cap,
|
||
},
|
||
delays=retry_delays,
|
||
delays_path=f"{path}.submit_retry_delays",
|
||
)
|
||
_validate_budget(
|
||
total=generation_timeout,
|
||
total_path=f"{path}.generation_timeout",
|
||
bounded_values={f"{path}.poll_request_timeout": poll_request_timeout},
|
||
)
|
||
return VideoRoutingPolicy(
|
||
submit_retry_delays=retry_delays,
|
||
submit_timeout=submit_timeout,
|
||
submit_total_timeout=submit_total_timeout,
|
||
poll_request_timeout=poll_request_timeout,
|
||
generation_timeout=generation_timeout,
|
||
retry_after_cap=retry_after_cap,
|
||
)
|
||
|
||
|
||
def load_model_routing_policy(raw_policy: Any | None = None) -> ModelRoutingPolicy:
|
||
"""读取并校验模型路由策略,返回不可变对象。
|
||
|
||
``raw_policy`` 主要供测试和启动检查注入;省略时读取 Django settings。
|
||
配置错误统一抛出带中文字段含义、错误值和合法范围的异常。
|
||
"""
|
||
|
||
raw = settings.MODEL_ROUTING_POLICY if raw_policy is None else raw_policy
|
||
source = _mapping(raw, "MODEL_ROUTING_POLICY")
|
||
|
||
path = "MODEL_ROUTING_POLICY"
|
||
max_models = _integer(_required(source, "max_models", path), f"{path}.max_models", minimum=1, maximum=10)
|
||
max_calls = _integer(_required(source, "max_calls", path), f"{path}.max_calls", minimum=1, maximum=20)
|
||
if max_calls < max_models:
|
||
raise _error(
|
||
"MODEL_ROUTING_POLICY.max_calls",
|
||
f"不得小于 max_models(最多尝试模型数量),当前为 {max_calls} < {max_models}",
|
||
)
|
||
jitter_ratio = _field_number(source, "jitter_ratio", path, maximum=1)
|
||
postprocess_path = f"{path}.postprocess"
|
||
postprocess_source = _mapping(_required(source, "postprocess", path), postprocess_path)
|
||
|
||
return ModelRoutingPolicy(
|
||
max_models=max_models,
|
||
max_calls=max_calls,
|
||
jitter_ratio=jitter_ratio,
|
||
text=_text_policy(_required(source, "text", path)),
|
||
image=_request_policy(_required(source, "image", path), f"{path}.image"),
|
||
audio=_request_policy(_required(source, "audio", path), f"{path}.audio"),
|
||
video=_video_policy(_required(source, "video", path)),
|
||
postprocess=PostprocessRoutingPolicy(
|
||
retry_delays=_field_delays(postprocess_source, "retry_delays", postprocess_path)
|
||
),
|
||
)
|
||
|
||
|
||
__all__ = [
|
||
"ModelRoutingPolicy",
|
||
"PostprocessRoutingPolicy",
|
||
"RequestRoutingPolicy",
|
||
"RoutingPolicyConfigurationError",
|
||
"TextRoutingPolicy",
|
||
"VideoRoutingPolicy",
|
||
"load_model_routing_policy",
|
||
]
|