Ctrl K
ComunidadGuías…

butler/transport/provider_health.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Provider circuit breaker + failover ordering (CC Switch subset)."""

from __future__ import annotations

import os
import threading
import time
from dataclasses import dataclass
from typing import Any

from butler.defaults.env_defaults import (
    PROVIDER_CIRCUIT_DEFAULT,
    PROVIDER_CIRCUIT_FAILURES,
    PROVIDER_CIRCUIT_OPEN_SECONDS,
    PROVIDER_FAILOVER_DEFAULT,
)
from butler.utilities.env_parse import env_truthy, float_env, int_env
from butler.transport.fallback import FallbackEntry

_STATE_LOCK = threading.Lock()
_STATE: dict[str, "_CircuitState"] = {}


@dataclass
class _CircuitState:
    failures: int = 0
    last_failure: float = 0.0
    open_until: float = 0.0


def provider_circuit_enabled() -> bool:
    return bool(env_truthy("BUTLER_PROVIDER_CIRCUIT", default=PROVIDER_CIRCUIT_DEFAULT))


def _failure_threshold() -> int:
    return int(
        int_env(
            "BUTLER_PROVIDER_CIRCUIT_FAILURES",
            PROVIDER_CIRCUIT_FAILURES,
            min=2,
        )
    )


def _open_seconds() -> float:
    return float(
        float_env(
            "BUTLER_PROVIDER_CIRCUIT_OPEN_SECONDS",
            float(PROVIDER_CIRCUIT_OPEN_SECONDS),
            min=30.0,
            warn_on_clamp=False,
        )
    )


def provider_key(provider: str, model: str) -> str:
    return f"{provider or '?'}:{model or '?'}"


def is_circuit_open(provider: str, model: str) -> bool:
    if not provider_circuit_enabled():
        return False
    key = provider_key(provider, model)
    now = time.time()
    with _STATE_LOCK:
        st = _STATE.get(key)
        if st is None:
            return False
        if st.open_until and now < st.open_until:
            return True
        if st.open_until and now >= st.open_until:
            st.open_until = 0.0
            st.failures = 0
        return False


def record_provider_success(provider: str, model: str) -> None:
    if not provider_circuit_enabled():
        return
    key = provider_key(provider, model)
    with _STATE_LOCK:
        st = _STATE.get(key)
        if st is not None:
            st.failures = 0
            st.open_until = 0.0


def record_provider_failure(provider: str, model: str) -> None:
    if not provider_circuit_enabled():
        return
    key = provider_key(provider, model)
    now = time.time()
    with _STATE_LOCK:
        st = _STATE.get(key)
        if st is None:
            st = _CircuitState()
            _STATE[key] = st
        st.failures += 1
        st.last_failure = now
        if st.failures >= _failure_threshold():
            st.open_until = now + _open_seconds()


def filter_fallback_chain(chain: list[FallbackEntry]) -> list[FallbackEntry]:
    """Drop entries whose circuit is open; preserve order."""
    if not provider_circuit_enabled():
        return chain
    out: list[FallbackEntry] = []
    for entry in chain:
        if is_circuit_open(entry.provider, entry.model):
            continue
        out.append(entry)
    return out or chain


def failover_list_from_env() -> list[tuple[str, str]]:
    raw = os.getenv("BUTLER_PROVIDER_FAILOVER", PROVIDER_FAILOVER_DEFAULT).strip()
    if not raw:
        return []
    pairs: list[tuple[str, str]] = []
    for part in raw.split(","):
        piece = part.strip()
        if not piece:
            continue
        if "/" in piece:
            prov, model = piece.split("/", 1)
            pairs.append((prov.strip(), model.strip()))
        else:
            pairs.append((piece, ""))
    return pairs


def format_circuit_diagnostic_lines() -> list[str]:
    if not provider_circuit_enabled():
        return []
    snap = health_snapshot()
    if not snap:
        return []
    lines = ["供应商熔断:"]
    for row in snap[:6]:
        key = row.get("provider_model") or "?"
        open_flag = row.get("open")
        fails = row.get("failures", 0)
        lines.append(f"  {key}: failures={fails} open={open_flag}")
    return lines


def health_snapshot() -> list[dict[str, Any]]:
    now = time.time()
    with _STATE_LOCK:
        items = []
        for key, st in sorted(_STATE.items()):
            items.append(
                {
                    "provider_model": key,
                    "failures": st.failures,
                    "open": bool(st.open_until and now < st.open_until),
                    "open_until": st.open_until if st.open_until else None,
                }
            )
    return items