2026-02-27 13:58:58 +08:00
|
|
|
|
"""Account Pool configuration (provider-agnostic)."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from src.core.logger import logger
|
2026-03-04 22:06:19 +08:00
|
|
|
|
from src.services.provider.pool.dimensions import get_preset_dimension, get_preset_names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class ScoringWeights:
|
|
|
|
|
|
"""Weights used by multi-score scheduling."""
|
|
|
|
|
|
|
|
|
|
|
|
lru: float = 0.3
|
|
|
|
|
|
latency: float = 0.25
|
|
|
|
|
|
health: float = 0.2
|
|
|
|
|
|
cost_remaining: float = 0.25
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class SchedulingPreset:
|
|
|
|
|
|
"""Single scheduling preset item with enable/disable and optional sub-config."""
|
|
|
|
|
|
|
|
|
|
|
|
preset: str
|
|
|
|
|
|
enabled: bool = True
|
|
|
|
|
|
mode: str | None = None
|
2026-02-27 13:58:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class UnschedulableRule:
|
|
|
|
|
|
"""Keyword-based temporary unschedule rule."""
|
|
|
|
|
|
|
|
|
|
|
|
keyword: str
|
|
|
|
|
|
duration_minutes: int = 5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class PoolConfig:
|
|
|
|
|
|
"""Parsed pool configuration for any Provider.
|
|
|
|
|
|
|
|
|
|
|
|
All transient state lives in Redis; this dataclass only holds
|
|
|
|
|
|
the *configuration* that controls pool behaviour.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# -- Sticky Session -------------------------------------------------------
|
|
|
|
|
|
sticky_session_ttl_seconds: int = 3600 # 1 hour
|
2026-03-03 17:24:22 +08:00
|
|
|
|
# Key 优先模式下号池整体优先级(None 时回退 provider_priority)
|
|
|
|
|
|
global_priority: int | None = None
|
2026-02-27 13:58:58 +08:00
|
|
|
|
|
|
|
|
|
|
# -- Load-Aware Selection -------------------------------------------------
|
|
|
|
|
|
load_threshold_percent: int = 80
|
|
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
# -- Scheduling (unified preset list) -------------------------------------
|
|
|
|
|
|
scheduling_presets: tuple[SchedulingPreset, ...] = (
|
2026-03-09 14:07:20 +08:00
|
|
|
|
SchedulingPreset(preset="cache_affinity", enabled=True),
|
2026-03-04 22:06:19 +08:00
|
|
|
|
)
|
|
|
|
|
|
# Derived from scheduling_presets at parse time (backward compat for consumers)
|
2026-02-27 13:58:58 +08:00
|
|
|
|
lru_enabled: bool = True
|
2026-03-04 22:06:19 +08:00
|
|
|
|
scheduling_mode: str = "lru" # lru | multi_score
|
|
|
|
|
|
|
|
|
|
|
|
scoring_weights: ScoringWeights = field(default_factory=ScoringWeights)
|
|
|
|
|
|
latency_window_seconds: int = 3600
|
|
|
|
|
|
latency_sample_limit: int = 50
|
2026-02-27 13:58:58 +08:00
|
|
|
|
|
|
|
|
|
|
# -- Rolling-Window Cost Tracking -----------------------------------------
|
|
|
|
|
|
cost_window_seconds: int = 18000 # 5 hours
|
|
|
|
|
|
cost_limit_per_key_tokens: int | None = None # None = unlimited
|
|
|
|
|
|
cost_soft_threshold_percent: int = 80
|
|
|
|
|
|
|
|
|
|
|
|
# -- Cooldown Defaults ----------------------------------------------------
|
|
|
|
|
|
rate_limit_cooldown_seconds: int = 300 # 429
|
|
|
|
|
|
overload_cooldown_seconds: int = 30 # 529
|
|
|
|
|
|
|
|
|
|
|
|
# -- OAuth Proactive Refresh ----------------------------------------------
|
|
|
|
|
|
proactive_refresh_seconds: int = 180 # 3 minutes before expiry
|
|
|
|
|
|
|
|
|
|
|
|
# -- Health Policy --------------------------------------------------------
|
|
|
|
|
|
health_policy_enabled: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
# -- Temporary Unschedulable Rules ----------------------------------------
|
|
|
|
|
|
unschedulable_rules: list[UnschedulableRule] = field(default_factory=list)
|
|
|
|
|
|
|
2026-03-06 13:13:01 +08:00
|
|
|
|
# -- Batch Operations -----------------------------------------------------
|
|
|
|
|
|
batch_concurrency: int = 8
|
|
|
|
|
|
|
2026-03-05 15:15:26 +08:00
|
|
|
|
# -- Quota Probing --------------------------------------------------------
|
|
|
|
|
|
probing_enabled: bool = False
|
|
|
|
|
|
probing_interval_minutes: int = 10
|
|
|
|
|
|
auto_remove_banned_keys: bool = False
|
|
|
|
|
|
|
2026-02-27 18:10:27 +08:00
|
|
|
|
# -- Stream Timeout Auto-Pause --------------------------------------------
|
|
|
|
|
|
stream_timeout_threshold: int = 3 # N timeouts within window trigger cooldown
|
|
|
|
|
|
stream_timeout_window_seconds: int = 1800 # 30 min counting window
|
|
|
|
|
|
stream_timeout_cooldown_seconds: int = 300 # 5 min cooldown
|
|
|
|
|
|
|
2026-02-27 13:58:58 +08:00
|
|
|
|
# -- Pluggable Strategies -------------------------------------------------
|
|
|
|
|
|
strategies: tuple[str, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_pool_config(provider_config: Any) -> PoolConfig | None:
|
|
|
|
|
|
"""Parse PoolConfig from ``Provider.config``.
|
|
|
|
|
|
|
|
|
|
|
|
Only looks for the explicit ``pool_advanced`` key. Returns ``None``
|
|
|
|
|
|
when the provider has no pool section configured, meaning the caller
|
|
|
|
|
|
should use the normal (non-pool) scheduling path.
|
|
|
|
|
|
"""
|
|
|
|
|
|
config_dict = provider_config if isinstance(provider_config, dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
raw_advanced = config_dict.get("pool_advanced")
|
|
|
|
|
|
if raw_advanced is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw_advanced, dict):
|
|
|
|
|
|
# Could be a pre-validated Pydantic model; grab its dict.
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_advanced = raw_advanced.model_dump() # type: ignore[union-attr]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"PoolConfig: advanced config type invalid ({}), falling back to defaults",
|
|
|
|
|
|
type(raw_advanced).__name__,
|
|
|
|
|
|
)
|
|
|
|
|
|
return PoolConfig()
|
|
|
|
|
|
|
|
|
|
|
|
rules: list[UnschedulableRule] = []
|
|
|
|
|
|
raw_rules = raw_advanced.get("unschedulable_rules")
|
|
|
|
|
|
if isinstance(raw_rules, list):
|
|
|
|
|
|
for r in raw_rules:
|
|
|
|
|
|
if isinstance(r, dict) and isinstance(r.get("keyword"), str):
|
|
|
|
|
|
rules.append(
|
|
|
|
|
|
UnschedulableRule(
|
|
|
|
|
|
keyword=r["keyword"],
|
|
|
|
|
|
duration_minutes=int(r.get("duration_minutes", 5)),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _int_or(key: str, default: int) -> int:
|
|
|
|
|
|
v = raw_advanced.get(key)
|
|
|
|
|
|
if v is None:
|
|
|
|
|
|
return default
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(v)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
def _bool_or(key: str, default: bool) -> bool:
|
|
|
|
|
|
v = raw_advanced.get(key)
|
|
|
|
|
|
if v is None:
|
|
|
|
|
|
return default
|
|
|
|
|
|
return bool(v)
|
|
|
|
|
|
|
|
|
|
|
|
def _opt_int(key: str) -> int | None:
|
|
|
|
|
|
v = raw_advanced.get(key)
|
|
|
|
|
|
if v is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(v)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
scoring_weights = _parse_scoring_weights(raw_advanced.get("scoring_weights"))
|
|
|
|
|
|
|
|
|
|
|
|
# Parse scheduling presets (new object-list format or legacy string-list)
|
|
|
|
|
|
presets = _parse_scheduling_presets_v2(
|
|
|
|
|
|
raw_advanced.get("scheduling_presets"),
|
|
|
|
|
|
legacy_mode=raw_advanced.get("scheduling_mode"),
|
|
|
|
|
|
legacy_lru=raw_advanced.get("lru_enabled"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Derive scheduling_mode and lru_enabled from the presets list
|
|
|
|
|
|
enabled = [p for p in presets if p.enabled]
|
|
|
|
|
|
lru_enabled = any(p.preset == "lru" for p in enabled)
|
|
|
|
|
|
non_lru_enabled = [p for p in enabled if p.preset != "lru"]
|
|
|
|
|
|
scheduling_mode = "multi_score" if non_lru_enabled else "lru"
|
|
|
|
|
|
|
|
|
|
|
|
strategies = list(_parse_strategies(raw_advanced.get("strategies")))
|
|
|
|
|
|
if scheduling_mode == "multi_score" and "multi_score" not in strategies:
|
|
|
|
|
|
strategies.append("multi_score")
|
|
|
|
|
|
|
2026-02-27 13:58:58 +08:00
|
|
|
|
return PoolConfig(
|
|
|
|
|
|
sticky_session_ttl_seconds=_int_or("sticky_session_ttl_seconds", 3600),
|
2026-03-03 17:24:22 +08:00
|
|
|
|
global_priority=_opt_int("global_priority"),
|
2026-02-27 13:58:58 +08:00
|
|
|
|
load_threshold_percent=_int_or("load_threshold_percent", 80),
|
2026-03-04 22:06:19 +08:00
|
|
|
|
scheduling_presets=presets,
|
|
|
|
|
|
lru_enabled=lru_enabled,
|
|
|
|
|
|
scheduling_mode=scheduling_mode,
|
|
|
|
|
|
scoring_weights=scoring_weights,
|
|
|
|
|
|
latency_window_seconds=_int_or("latency_window_seconds", 3600),
|
|
|
|
|
|
latency_sample_limit=_int_or("latency_sample_limit", 50),
|
2026-02-27 13:58:58 +08:00
|
|
|
|
cost_window_seconds=_int_or("cost_window_seconds", 18000),
|
|
|
|
|
|
cost_limit_per_key_tokens=_opt_int("cost_limit_per_key_tokens"),
|
|
|
|
|
|
cost_soft_threshold_percent=_int_or("cost_soft_threshold_percent", 80),
|
|
|
|
|
|
rate_limit_cooldown_seconds=_int_or("rate_limit_cooldown_seconds", 300),
|
|
|
|
|
|
overload_cooldown_seconds=_int_or("overload_cooldown_seconds", 30),
|
|
|
|
|
|
proactive_refresh_seconds=_int_or("proactive_refresh_seconds", 180),
|
|
|
|
|
|
health_policy_enabled=_bool_or("health_policy_enabled", True),
|
|
|
|
|
|
unschedulable_rules=rules,
|
2026-03-06 13:13:01 +08:00
|
|
|
|
batch_concurrency=max(1, min(_int_or("batch_concurrency", 8), 32)),
|
2026-03-05 15:15:26 +08:00
|
|
|
|
probing_enabled=_bool_or("probing_enabled", False),
|
|
|
|
|
|
probing_interval_minutes=max(1, min(_int_or("probing_interval_minutes", 10), 1440)),
|
|
|
|
|
|
auto_remove_banned_keys=_bool_or("auto_remove_banned_keys", False),
|
2026-02-27 18:10:27 +08:00
|
|
|
|
stream_timeout_threshold=_int_or("stream_timeout_threshold", 3),
|
|
|
|
|
|
stream_timeout_window_seconds=_int_or("stream_timeout_window_seconds", 1800),
|
|
|
|
|
|
stream_timeout_cooldown_seconds=_int_or("stream_timeout_cooldown_seconds", 300),
|
2026-03-04 22:06:19 +08:00
|
|
|
|
strategies=tuple(strategies),
|
2026-02-27 13:58:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 22:06:19 +08:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Internal parsers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _allowed_preset_names() -> set[str]:
|
|
|
|
|
|
return get_preset_names() | {"lru"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_preset_mode_meta(preset_name: str) -> tuple[tuple[str, ...], str | None]:
|
|
|
|
|
|
dim = get_preset_dimension(preset_name)
|
|
|
|
|
|
if dim is None or not dim.modes:
|
|
|
|
|
|
return (), None
|
|
|
|
|
|
|
|
|
|
|
|
modes = tuple(str(mode).strip().lower() for mode in dim.modes if str(mode).strip())
|
|
|
|
|
|
if not modes:
|
|
|
|
|
|
return (), None
|
|
|
|
|
|
|
|
|
|
|
|
raw_default = str(dim.default_mode or "").strip().lower()
|
|
|
|
|
|
default_mode = raw_default if raw_default in modes else modes[0]
|
|
|
|
|
|
return modes, default_mode
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-27 13:58:58 +08:00
|
|
|
|
def _parse_strategies(raw: Any) -> tuple[str, ...]:
|
|
|
|
|
|
"""Parse strategy names from config (list[str] -> tuple[str, ...])."""
|
|
|
|
|
|
if not isinstance(raw, list):
|
|
|
|
|
|
return ()
|
|
|
|
|
|
return tuple(str(s) for s in raw if isinstance(s, str) and s)
|
2026-03-04 22:06:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_scoring_weights(raw: Any) -> ScoringWeights:
|
|
|
|
|
|
"""Parse scoring weights with graceful fallback."""
|
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
|
return ScoringWeights()
|
|
|
|
|
|
|
|
|
|
|
|
def _float_or(value: Any, default: float) -> float:
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = float(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return default
|
|
|
|
|
|
return max(0.0, min(parsed, 1.0))
|
|
|
|
|
|
|
|
|
|
|
|
return ScoringWeights(
|
|
|
|
|
|
lru=_float_or(raw.get("lru"), 0.3),
|
|
|
|
|
|
latency=_float_or(raw.get("latency"), 0.25),
|
|
|
|
|
|
health=_float_or(raw.get("health"), 0.2),
|
|
|
|
|
|
cost_remaining=_float_or(raw.get("cost_remaining"), 0.25),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_scheduling_presets_v2(
|
|
|
|
|
|
raw: Any,
|
|
|
|
|
|
*,
|
|
|
|
|
|
legacy_mode: Any = None,
|
|
|
|
|
|
legacy_lru: Any = None,
|
|
|
|
|
|
) -> tuple[SchedulingPreset, ...]:
|
|
|
|
|
|
"""Parse scheduling presets, supporting both new and legacy formats.
|
|
|
|
|
|
|
|
|
|
|
|
New format::
|
|
|
|
|
|
|
|
|
|
|
|
[{"preset": "lru", "enabled": true},
|
|
|
|
|
|
{"preset": "free_team_first", "enabled": true, "mode": "free_only"},
|
|
|
|
|
|
...]
|
|
|
|
|
|
|
|
|
|
|
|
Legacy format::
|
|
|
|
|
|
|
|
|
|
|
|
["free_team_first", "recent_refresh"] (with separate scheduling_mode / lru_enabled)
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(raw, list) and raw:
|
|
|
|
|
|
first = raw[0]
|
|
|
|
|
|
if isinstance(first, dict):
|
|
|
|
|
|
return _parse_preset_object_list(raw)
|
|
|
|
|
|
if isinstance(first, str):
|
|
|
|
|
|
return _convert_legacy_string_list(raw, legacy_mode, legacy_lru)
|
|
|
|
|
|
|
|
|
|
|
|
# No presets at all: derive from legacy fields
|
|
|
|
|
|
return _build_from_legacy_fields(legacy_mode, legacy_lru)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_preset_object_list(raw: list[Any]) -> tuple[SchedulingPreset, ...]:
|
|
|
|
|
|
"""Parse new-format object list into SchedulingPreset tuple."""
|
|
|
|
|
|
allowed = _allowed_preset_names()
|
|
|
|
|
|
ordered: list[SchedulingPreset] = []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
for item in raw:
|
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
name = str(item.get("preset", "")).strip().lower()
|
|
|
|
|
|
if name not in allowed or name in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(name)
|
|
|
|
|
|
enabled = bool(item.get("enabled", True))
|
|
|
|
|
|
mode: str | None = None
|
|
|
|
|
|
modes, default_mode = _get_preset_mode_meta(name)
|
|
|
|
|
|
if modes:
|
|
|
|
|
|
raw_mode = str(item.get("mode", default_mode) or "").strip().lower()
|
|
|
|
|
|
mode = raw_mode if raw_mode in modes else default_mode
|
|
|
|
|
|
ordered.append(SchedulingPreset(preset=name, enabled=enabled, mode=mode))
|
|
|
|
|
|
return tuple(ordered) if ordered else (SchedulingPreset(preset="lru", enabled=True),)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _convert_legacy_string_list(
|
|
|
|
|
|
raw: list[Any],
|
|
|
|
|
|
legacy_mode: Any,
|
|
|
|
|
|
legacy_lru: Any,
|
|
|
|
|
|
) -> tuple[SchedulingPreset, ...]:
|
|
|
|
|
|
"""Convert legacy string list + mode/lru fields to new format."""
|
|
|
|
|
|
lru_enabled = legacy_lru if isinstance(legacy_lru, bool) else True
|
|
|
|
|
|
|
|
|
|
|
|
allowed_non_lru = _allowed_preset_names() - {"lru"}
|
|
|
|
|
|
items: list[SchedulingPreset] = [SchedulingPreset(preset="lru", enabled=lru_enabled)]
|
|
|
|
|
|
seen: set[str] = {"lru"}
|
|
|
|
|
|
for p in raw:
|
|
|
|
|
|
if not isinstance(p, str):
|
|
|
|
|
|
continue
|
|
|
|
|
|
name = p.strip().lower()
|
|
|
|
|
|
if name not in allowed_non_lru or name in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(name)
|
|
|
|
|
|
items.append(SchedulingPreset(preset=name, enabled=True))
|
|
|
|
|
|
return tuple(items)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_from_legacy_fields(legacy_mode: Any, legacy_lru: Any) -> tuple[SchedulingPreset, ...]:
|
|
|
|
|
|
"""Build presets from legacy scheduling_mode / lru_enabled only."""
|
2026-03-09 15:22:17 +08:00
|
|
|
|
# Explicit lru_enabled=True -> LRU; explicit lru_enabled=False -> cache_affinity.
|
|
|
|
|
|
# No legacy fields at all -> default to cache_affinity.
|
|
|
|
|
|
if isinstance(legacy_lru, bool):
|
|
|
|
|
|
if legacy_lru:
|
|
|
|
|
|
return (SchedulingPreset(preset="lru", enabled=True),)
|
|
|
|
|
|
return (SchedulingPreset(preset="cache_affinity", enabled=True),)
|
2026-03-09 14:07:20 +08:00
|
|
|
|
return (SchedulingPreset(preset="cache_affinity", enabled=True),)
|