"""OpenAI 키 슬롯 브로커 — 1차 키가 죽으면 보조 키로 전환한다 (2026-07-30).

## 왜 있는가

2026-07-29 23:25 `billing_hard_limit_reached` 로 OpenAI 호출이 전부 400 이
되어 콘티 재시도 → 최종 샷 체인이 **전면 중단**됐다. 복구는 사용자가 임시
키를 손으로 넘겨 러너 프로세스 환경변수로만 싣는 수동 조치였다. 단일 키가
장시간 무인 실행의 단일 장애점이라 슬롯을 둘로 늘린다.

## 계약

- **전환 트리거는 키 수준 실패만이다.** billing hard limit / quota 소진 /
  401·403 인증. 일반 4xx·5xx·타임아웃·단순 rate limit(429) 은 **건드리지
  않는다** — 거기서 키를 바꾸면 멀쩡한 보조 키를 태우고 진짜 원인을 가린다.
- **조용한 전환 금지.** 전환은 WARNING 으로 남긴다(어느 슬롯이 왜 죽었고
  어디로 넘어갔는지). 슬롯이 다 떨어지면 전환하지 않고 원래 예외를 올린다 —
  빈 결과로 흘려보내지 않는다.
- **키 값은 어디에도 기록하지 않는다.** 로그에 남기는 것은 슬롯 이름뿐이다.
- 전환은 **프로세스 전역**이고 되돌아가지 않는다. 죽은 키로 매 호출마다
  다시 찔러 보면 그 자체가 비용이고 지연이다. 새 프로세스는 다시 1차부터.

## 쓰는 쪽

- LiteLLM Router: `active_key()` 로 deployment 를 만들고, 전환이 일어나면
  `register_switch_hook` 로 등록한 콜백이 Router 를 무효화한다.
- 직접 client: `openai_client()` 가 돌려주는 프록시를 그대로 쓰면 된다.
  `client.images.edit(...)` / `client.responses.create(...)` 처럼 기존
  호출 형태가 그대로 유지되고, 키 수준 실패일 때만 다음 슬롯으로 1회
  재시도한다.
"""
from __future__ import annotations

import logging
import threading
from typing import Any, Callable, List, Optional, Tuple

logger = logging.getLogger(__name__)

# 슬롯 정의 — (표시 이름, settings 필드). 순서가 곧 우선순위다.
SLOT_FIELDS: Tuple[Tuple[str, str], ...] = (
    ("primary", "openai_api_key"),
    ("secondary", "openai_api_key_secondary"),
)

# 키 수준 실패로 인정하는 OpenAI 오류 코드. 구조화된 필드를 우선 보고,
# 없을 때만 메시지에서 찾는다.
_KEY_LEVEL_CODES = frozenset({
    "billing_hard_limit_reached",
    "insufficient_quota",
    # 2026-07-31 실측: 크레딧 소진 응답의 `code` 가 이 이름으로 세분됐고
    # 옛 이름은 `type` 에 남았다(code=credit_balance_exhausted /
    # type=insufficient_quota). `code` 만 읽던 판정이 이 실패를 그냥
    # 통과시켜 보조 키를 눈앞에 두고 전환하지 않았다.
    "credit_balance_exhausted",
    "invalid_api_key",
    "account_deactivated",
    "billing_not_active",
    "organization_restricted",
})
# 구조화 코드가 없는 응답을 위한 보조 마커(소문자 비교).
_KEY_LEVEL_MARKERS = (
    "billing_hard_limit_reached",
    "billing hard limit",
    "insufficient_quota",
    "exceeded your current quota",
    # ★LiteLLM 을 거치면 구조화 필드가 전부 지워진다 — 2026-07-31 실측에서
    # 같은 실패가 code='429' / type='throttling_error' / body=None /
    # response 본문 없음으로 도착했다. 그 경로에서 크레딧 소진을 알아볼
    # 단서는 예외 메시지 문구뿐이다.
    "no credits remaining",
    "incorrect api key",
    "invalid_api_key",
    "account is not active",
)
# 인증 계열 상태코드 — 키를 바꾸면 풀릴 수 있는 것만.
_KEY_LEVEL_STATUS = frozenset({401, 403})

_lock = threading.RLock()
_active_index = 0
_switch_hooks: List[Callable[[], None]] = []


# ─────────────────────────────────────────────────────────────────────
# 슬롯 조회
# ─────────────────────────────────────────────────────────────────────
def available_slots() -> List[Tuple[str, str]]:
    """설정된 (슬롯 이름, 키) 목록 — 빈 슬롯은 빠진다.

    ★환경변수 ``OPENAI_API_KEY`` 는 **최후 슬롯**으로 편입한다 (2026-08-01).
    이전에는 존재 판정만 환경변수를 인정하고 실제 키 조회는 인정하지 않아
    **약속과 값이 갈라졌다** — ``has_openai_key()`` 는 True 인데
    ``active_key()`` 가 ``""`` 라서 preflight 만 통과하고 호출은 빈 키로 나갔다
    (직접 재현: ``OpenAIClient._api_key == ''``). 실효 키의 SOT 는 하나여야
    한다. 설정 슬롯이 우선이고, 같은 키면 슬롯을 늘리지 않는다(헛 전환 방지).
    """
    import os

    from app.core.config import settings

    out: List[Tuple[str, str]] = []
    for name, field in SLOT_FIELDS:
        key = str(getattr(settings, field, "") or "").strip()
        if key:
            out.append((name, key))
    env_key = str(os.environ.get("OPENAI_API_KEY", "") or "").strip()
    if env_key and env_key not in {k for _, k in out}:
        out.append(("env", env_key))
    return out


def active_slot() -> Optional[str]:
    slots = available_slots()
    if not slots:
        return None
    with _lock:
        idx = min(_active_index, len(slots) - 1)
    return slots[idx][0]


def active_key() -> str:
    """현재 활성 슬롯의 키. 슬롯이 하나도 없으면 빈 문자열."""
    slots = available_slots()
    if not slots:
        return ""
    with _lock:
        idx = min(_active_index, len(slots) - 1)
    return slots[idx][1]


def active_slot_and_key() -> Tuple[Optional[str], str]:
    """활성 슬롯과 그 키를 **한 값으로** 읽는다.

    [2026-08-01 A5] 슬롯과 키를 따로 읽으면 그 사이에 일어난 전환이 둘을
    어긋나게 한다 — "primary 슬롯에 보조 키가 박힌 클라이언트"가 캐시에
    들어가면 그 뒤로는 영구히 잘못된 짝을 쓴다.
    """
    slots = available_slots()
    if not slots:
        return None, ""
    with _lock:
        idx = min(_active_index, len(slots) - 1)
    return slots[idx][0], slots[idx][1]


def slot_count() -> int:
    return len(available_slots())


def reset() -> None:
    """활성 슬롯을 1차로 되돌린다(테스트·명시 재기동 전용)."""
    global _active_index
    with _lock:
        _active_index = 0


def register_switch_hook(fn: Callable[[], None]) -> None:
    """슬롯 전환 직후 호출될 콜백 — 캐시된 클라이언트·Router 무효화용."""
    with _lock:
        if fn not in _switch_hooks:
            _switch_hooks.append(fn)


# ─────────────────────────────────────────────────────────────────────
# 실패 판정
# ─────────────────────────────────────────────────────────────────────
def _response_payload(exc: BaseException) -> Optional[dict]:
    """예외에 실린 응답 본문(JSON) — 없거나 JSON 이 아니면 None."""
    resp = getattr(exc, "response", None)
    if resp is None:
        return None
    try:
        payload = resp.json()
    except Exception:  # noqa: BLE001 — 본문이 JSON 이 아닐 수 있다
        return None
    return payload if isinstance(payload, dict) else None


def _error_codes(exc: BaseException) -> Tuple[str, ...]:
    """예외에서 OpenAI 오류 식별자를 전부 뽑는다 — `code` 와 `type` 둘 다.

    ★둘 다 봐야 한다. 2026-07-31 실측에서 크레딧 소진 응답은
    code=`credit_balance_exhausted` / type=`insufficient_quota` 로 왔고,
    이 모듈이 알던 이름은 **type 쪽에만** 있었다. `code` 하나만 읽던 옛
    구현은 그래서 키 수준 실패를 놓쳤다.

    본문 모양도 두 가지다 — `{"error": {...}}` 로 감싼 것과 평면 dict
    (`{"message":…, "type":…, "code":…}`). 둘 다 받는다.
    """
    out: List[str] = []

    def _add(v: Any) -> None:
        if isinstance(v, str) and v and v not in out:
            out.append(v)

    _add(getattr(exc, "code", None))
    _add(getattr(exc, "type", None))
    for src in (getattr(exc, "body", None), _response_payload(exc)):
        if not isinstance(src, dict):
            continue
        err = src.get("error")
        for d in ((err if isinstance(err, dict) else {}), src):
            _add(d.get("code"))
            _add(d.get("type"))
    return tuple(out)


def _error_code(exc: BaseException) -> str:
    """가장 구체적인 식별자 하나(없으면 빈 문자열)."""
    codes = _error_codes(exc)
    return codes[0] if codes else ""


def _has_key_level_code(exc: BaseException) -> bool:
    return any(c in _KEY_LEVEL_CODES for c in _error_codes(exc))


def _is_openai_error(exc: BaseException) -> bool:
    """이 예외가 OpenAI 호출에서 났는가.

    Gemini 쪽 401 로 OpenAI 키를 갈아 끼우는 헛수고를 막는 가드다.
    litellm 예외는 `llm_provider` 를 실어 주고, openai SDK 예외는 모듈
    경로로 알 수 있다.
    """
    provider = getattr(exc, "llm_provider", None)
    if isinstance(provider, str) and provider:
        return provider.lower() in ("openai", "azure", "openai_like")
    module = type(exc).__module__ or ""
    if module.startswith("openai"):
        return True
    # provider 를 못 읽는 래핑 예외 — 코드가 OpenAI 고유면 인정한다.
    return _has_key_level_code(exc)


def is_key_level_failure(exc: BaseException) -> bool:
    """키를 바꾸면 풀릴 수 있는 실패인가.

    ★단순 rate limit(429)은 **아니다** — 그건 기존 재시도·backoff 의 몫이다.
    quota 소진이 429 로 오는 경우는 코드(`insufficient_quota` /
    `credit_balance_exhausted`)나 메시지 문구로 잡는다. 그 둘은 재시도해도
    풀리지 않으므로 backoff 에 맡기면 실행 전체가 그대로 죽는다.
    """
    if not _is_openai_error(exc):
        return False
    if _has_key_level_code(exc):
        return True
    status = getattr(exc, "status_code", None)
    if status is None:
        status = getattr(getattr(exc, "response", None), "status_code", None)
    if isinstance(status, int) and status in _KEY_LEVEL_STATUS:
        return True
    # ★문자열로 실린 본문도 같은 마커로 본다. 구조화 필드가 없는 응답
    # (proxy·plain text)에서는 본문 문구가 유일한 단서다 — `str(exc)` 만 보면
    # 그 경우를 그대로 삼킨다(실측: body='insufficient_quota' 인데 False).
    parts = [str(exc)]
    raw_body = getattr(exc, "body", None)
    if isinstance(raw_body, str):
        parts.append(raw_body)
    text = "\n".join(parts).lower()
    return any(m in text for m in _KEY_LEVEL_MARKERS)


# ─────────────────────────────────────────────────────────────────────
# 전환
# ─────────────────────────────────────────────────────────────────────
def failover_on(exc: BaseException, *, where: str = "",
                attempted_slot: Optional[str] = None) -> bool:
    """키 수준 실패면 다음 슬롯으로 전환한다. 재시도할 슬롯이 있으면 True.

    False 를 돌려주면 호출자는 **원래 예외를 그대로 올려야 한다** — 여기서
    조용히 삼키면 실패가 사라진 것처럼 보인다.

    ``attempted_slot`` (2026-08-01 A5, Codex 코드 리뷰 BLOCKING 5): **이 호출이
    실제로 실패한 슬롯**. 전에는 전역 활성 index 만 보고 판단해서, 같은 슬롯을
    잡은 두 호출이 나란히 실패하면 먼저 도착한 쪽이 0→1 로 옮기고 뒤이어
    도착한 쪽은 이미 1이 된 전역을 읽어 1→2 로 넘기려 했다. 슬롯이 2개면 그
    자리에서 "소진"으로 판정되어 **보조 키를 한 번도 못 써 본 요청이 그대로
    버려진다**(4스레드 재현: 3건 유실). 전환은 "지금 전역이 어디인가"가 아니라
    "이 호출이 어디서 실패했는가"를 기준으로 판단해야 한다.

    다른 호출이 이미 옮겨 놓았으면 **또 옮기지 않고** True 만 돌려준다 —
    옮길 필요가 없을 뿐 재시도할 슬롯은 있기 때문이다.
    """
    global _active_index

    if not is_key_level_failure(exc):
        return False
    slots = available_slots()
    names = [n for n, _ in slots]
    with _lock:
        cur = min(_active_index, max(0, len(slots) - 1))
        if attempted_slot is not None and attempted_slot in names:
            attempted = names.index(attempted_slot)
        else:
            # 슬롯 이름을 못 주거나 설정이 바뀌어 사라졌으면 전역 기준
            # (기존 동작과 동일 — 단일 호출 경로에서는 둘이 같다).
            attempted = cur
        if cur > attempted:
            # 경합 — 다른 호출이 이미 이 슬롯을 버렸다. 그 결과를 쓴다.
            logger.info(
                "OpenAI 키 전환 생략 — %s 는 이미 %s 로 옮겨져 있다 "
                "(지점 %s). 이 호출은 옮기지 않고 재시도한다.",
                attempted_slot, names[cur] if names else "없음", where or "?",
            )
            return True
        if attempted + 1 >= len(slots):
            logger.error(
                "OpenAI 키 슬롯 소진 — 남은 대체 키 없음 (슬롯 %d개, 현재 %s, "
                "지점 %s): %s",
                len(slots), slots[attempted][0] if slots else "없음",
                where or "?", str(exc)[:200],
            )
            return False
        _active_index = attempted + 1
        nxt = slots[_active_index][0]
        prev = slots[attempted][0]
        hooks = list(_switch_hooks)
    logger.warning(
        "OpenAI 키 전환: %s → %s (사유=%s · 지점=%s). 이 프로세스는 이후 %s "
        "슬롯을 쓴다.",
        prev, nxt, (_error_code(exc) or str(exc)[:120]), where or "?", nxt,
    )
    for hook in hooks:
        try:
            hook()
        except Exception:  # noqa: BLE001 — 무효화 실패가 전환을 막지 않는다
            logger.exception("OpenAI 키 전환 훅 실패")
    return True


# ─────────────────────────────────────────────────────────────────────
# 직접 client — failover 프록시
# ─────────────────────────────────────────────────────────────────────
from app.core.send_ledger import GRAIN_SLOT as _GRAIN_SLOT
from app.core.send_ledger import record_send as _record_send


class _BoundPath:
    """`client.images.edit` 같은 중첩 접근을 호출 시점까지 미뤄 둔다."""

    __slots__ = ("_owner", "_path")

    def __init__(self, owner: "FailoverOpenAIClient", path: Tuple[str, ...]):
        self._owner = owner
        self._path = path

    def __getattr__(self, name: str) -> "_BoundPath":
        return _BoundPath(self._owner, self._path + (name,))

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        return self._owner._invoke(self._path, args, kwargs)


class FailoverOpenAIClient:
    """OpenAI 클라이언트 얼굴을 그대로 쓰되, 키 수준 실패에만 슬롯을 옮긴다.

    사용법은 기존과 같다 — `client.images.edit(...)`,
    `client.responses.create(...)`. 그 외 동작은 원본 클라이언트에 위임한다.
    """

    def __init__(self, **client_kwargs: Any):
        self._client_kwargs = client_kwargs
        self._cache: dict = {}
        self._cache_lock = threading.Lock()

    def _raw(self) -> Tuple[str, Any]:
        """(슬롯 이름, 그 슬롯 키로 만든 클라이언트) — 반드시 짝으로 돌려준다.

        [A5] 호출자가 나중에 `active_slot()` 을 다시 읽으면, 그사이 다른
        스레드가 옮긴 슬롯을 자기가 쓴 슬롯으로 착각한다.
        """
        from openai import OpenAI

        slot_name, key = active_slot_and_key()
        slot = slot_name or "none"
        with self._cache_lock:
            hit = self._cache.get(slot)
            if hit is not None:
                return slot, hit
            client = OpenAI(api_key=key, **self._client_kwargs)
            self._cache[slot] = client
            return slot, client

    def _invoke(self, path: Tuple[str, ...], args: tuple, kwargs: dict) -> Any:
        from app.core.research_call_budget import reserve_current_research_call

        where = ".".join(path)
        # 슬롯 수만큼만 시도한다 — 무한 재시도가 아니라 "남은 키를 순서대로".
        for _ in range(max(1, slot_count())):
            slot, target = self._raw()
            for part in path:
                target = getattr(target, part)
            # ★★★**여기가 나가는 자리다.** 슬롯 failover 는 이 loop 안에서
            #  돌므로 **슬롯 진입이 둘**이 될 수 있다 — 「몇 번 나갔나」를
            #  막으려면 나가는 자리에서 세야 한다 (GROUNDING-V2 §8.5).
            #  ★「물리 전송」이라고 부르지 않는다 (2026-09-20 Codex) —
            #   SDK 안 재시도는 **이 아래**라 여기서 안 보인다. 이 자리가
            #   아는 것은 **슬롯 진입 수**이고 실제 요청 수는 미확인이다.
            #  ★예산이 안 깔린 스레드에서는 **아무 일도 안 한다**. 그래서
            #   이미지·다른 스텝 호출부는 영향을 안 받는다.
            #  ★정지 확인이 예산보다 먼저 불린다(그 함수 안에서).
            reserve_current_research_call(source=where)
            # ★**관측 단위를 스스로 밝힌다** (2026-09-20 ②). 여기는 키
            #  슬롯 진입이라 primary→secondary 로 옮기면 **두 번** 찍힌다.
            #  그런데 OpenAI SDK 자체에 `max_retries` 가 있어 **그 아래는
            #  안 보인다** — 이것만 세고 「물리 전송 총수」라고 부르면 안
            #  된다. 그래서 `slot_entry` 로 적는다.
            #  ★종류는 **경로**로 가른다(`images.*` 인가). 글자를 훑어
            #   뜻을 짐작하는 것이 아니라, 호출 경로가 곧 그 호출의 신원
            #   이다.
            _send = _record_send(
                kind=("image" if path and path[0] == "images" else "llm"),
                granularity=_GRAIN_SLOT,
                source=f"openai_keys.{where}",
                model=str(kwargs.get("model") or ""),
            )
            try:
                _out = target(*args, **kwargs)
            except Exception as exc:  # noqa: BLE001
                if _send is not None:
                    # ★「청구 안 됨」이 아니라 **그 진입이 예외로 끝났다**.
                    _send.failed(f"{where} raised")
                if not failover_on(exc, where=where, attempted_slot=slot):
                    raise
            else:
                if _send is not None:
                    _send.ok()
                return _out
        # 여기 오면 마지막 슬롯도 실패했고 failover_on 이 False 를 냈어야 한다.
        raise RuntimeError(f"OpenAI 호출 실패 — 모든 키 슬롯 소진 ({where})")

    def __getattr__(self, name: str) -> Any:
        if name.startswith("_"):
            raise AttributeError(name)
        return _BoundPath(self, (name,))


def openai_client(**client_kwargs: Any) -> FailoverOpenAIClient:
    """failover 가 붙은 OpenAI 클라이언트.

    `OpenAI(...)` 를 직접 만들던 자리를 이걸로 바꾸면 그 지점이 자동으로
    2슬롯을 쓴다. `api_key` 는 브로커가 정하므로 넘기지 않는다.
    """
    client_kwargs.pop("api_key", None)
    return FailoverOpenAIClient(**client_kwargs)


# ─────────────────────────────────────────────────────────────────────
# Router 를 거치지 않는 직접 litellm 호출
# ─────────────────────────────────────────────────────────────────────
def _is_openai_model(model: str) -> bool:
    """litellm 자신에게 provider 를 묻는다 — 이름 규칙을 우리가 흉내 내지 않는다.

    판정할 수 없으면(가짜 litellm 주입 등) **키를 끼우지 않는다** — 모르는 채로
    OpenAI 키를 남의 provider 에 넘기는 것이 더 나쁘다.
    """
    try:
        import litellm

        return litellm.get_llm_provider(model)[1] == "openai"
    except Exception:  # noqa: BLE001
        return False


def llm_completion(*, model: str, **kwargs: Any):
    """Router 를 거치지 않는 **직접 litellm 호출**의 유일한 경로.

    [2026-08-01] production 10개 파일이 ``litellm.completion(...)`` 을 직접
    불렀고(13 호출) 전부 기본 모델이 OpenAI 인데 **``api_key=`` 를 하나도 넘기지
    않았다.** 그러면 litellm 이 ``os.environ["OPENAI_API_KEY"]`` 로 키를 정하는데,
    이 브로커는 그 환경변수를 한 번도 건드리지 않는다 — 즉 보조 슬롯으로
    전환돼도 그 호출들은 계속 죽은 1차 키를 쓴다.

    환경변수 동기화만으로는 부족하다. 그 경로들은 ``failover_on`` 을 부르지 않아
    **전환이 일어날 계기 자체가 없다.** 그래서 여기서 둘 다 한다 — ①활성 슬롯의
    키를 명시 전달하고 ②키 수준 실패면 다음 슬롯으로 재시도한다.

    ★이 함수는 ``llm_client`` 가 아니라 브로커에 있다. 소비자들은 테스트에서
    **가짜 litellm 모듈을 sys.modules 에 주입**해 격리하는데, Router 를 최상단에서
    import 하는 모듈을 끌어오면 그 격리가 깨진다(실측: ImportError 로 34건 실패).
    여기서는 함수 안에서 lazy import 하므로 그 계약이 그대로 유지된다.

    OpenAI 가 아닌 모델에는 아무것도 끼우지 않는다. 그 경우 ``failover_on`` 이
    False 를 돌려주므로 첫 실패에서 그대로 올라간다 — 호출자의 기존
    ``num_retries=0`` 계약이 유지된다.
    """
    import litellm

    # ★Opik 콜백을 여기서도 보장한다 (Codex 리뷰 2026-08-07). 콜백 등록은
    #  `llm_client._init_opik` 이 하는데 그것은 **Router 를 지을 때만** 불린다.
    #  이 함수는 Router 를 안 거치고 `litellm.completion` 을 직접 부르므로,
    #  Router 가 먼저 서지 않은 실행(격리 스크립트·단독 엔드포인트)에서는
    #  호출이 Opik 없이 나간다. 초기화는 한 번만 실제로 돈다.
    try:
        from app.modules.llm.llm_client import _init_opik
        _init_opik()
    except Exception:  # noqa: BLE001 — 기록 준비가 호출을 막지 않는다
        pass

    # ★★★**물리 전송 자리 셋이 여기 있다** (Codex 2026-08-31). 중앙 둘
    #  (`llm_client._completion` · `_invoke`)에는 문이 있는데 이 직접 경로만
    #  없었다 — 그래서 상한을 걸 수 없는 갈래가 남았다. 같은 문을 단다.
    #  ★`research_calls_armed()` 밖에서는 **아무 일도 안 한다** — 팔을 안 든
    #   호출부는 한 글자도 안 바뀐다.
    from app.core.research_call_budget import reserve_current_research_call

    _where = f"openai_keys.llm_completion[{model}]"
    if not _is_openai_model(model):
        reserve_current_research_call(source=_where)
        return litellm.completion(model=model, **kwargs)

    last: Optional[BaseException] = None
    for _ in range(max(1, slot_count())):
        # ★슬롯과 키를 한 값으로 읽는다 — 따로 읽으면 그사이 전환이 "이 슬롯에서
        # 실패했다"는 판단을 남의 슬롯 것으로 만든다(A5 와 같은 함정).
        slot, key = active_slot_and_key()
        if not key:
            # 브로커에 키가 없으면 끼우지 않는다 — 빈 문자열을 넘기면 호출자의
            # 환경변수 preflight 계약을 우리가 조용히 뒤집는다.
            reserve_current_research_call(source=_where)
            return litellm.completion(model=model, **kwargs)
        try:
            # ★슬롯 loop **안**이다 — 논리 하나가 물리 둘이 될 수 있으므로
            #  나가는 자리마다 센다.
            reserve_current_research_call(source=_where)
            return litellm.completion(model=model, api_key=key, **kwargs)
        except Exception as exc:  # noqa: BLE001
            last = exc
            if not failover_on(
                exc, where=f"litellm.completion[{model}]", attempted_slot=slot,
            ):
                raise
    assert last is not None
    raise last


def has_openai_key() -> bool:
    """OpenAI 키가 **하나라도** 있는가 — 존재 판단의 단일 권위.

    [2026-08-01] 이 판단이 19곳에 흩어져 있었다. 8곳은 환경변수만 읽었고
    11곳은 ``settings.openai_api_key``(=**1차 슬롯 필드**)로 게이트했다. 그래서
    보조 슬롯만 구성된 상태에서 브로커는 ``active_slot=secondary`` 를 정확히
    보는데도 소비자가 **그 앞에서** "키 없음"으로 막았다(직접 재현).

    ★환경변수는 ``available_slots`` 이 최후 슬롯으로 편입하므로 여기서 따로
    보지 않는다. 따로 보면 "있다"고 답한 뒤 ``active_key()`` 가 빈 값을 주는
    분열이 생긴다(Codex 재리뷰 BLOCKING-1 실측).
    """
    return bool((active_key() or "").strip())


def call_with_key_failover(fn: Callable[[str], Any], *, where: str = "",
                           fixed_key: Optional[str] = None) -> Any:
    """``fn(api_key)`` 를 활성 슬롯 키로 부르고, 키 수준 실패면 다음 슬롯으로.

    [2026-08-01 Codex 재리뷰 BLOCKING-2] raw urllib 로 OpenAI 를 직접 부르는
    소비자 5곳이 활성 키를 **한 번 복사**했을 뿐 실패를 브로커에 알리지 않았다.
    ``OpenAIClient`` 는 심지어 **생성 시점**에 복사해 이후 전환이 닿지도 않았다.
    그 경로가 그 프로세스의 첫 OpenAI 호출이면 보조 키가 있어도 전환 계기가
    없다(직접 재현: Authorization 이 ``Bearer KEY-A`` 한 번뿐, 슬롯 primary 유지).

    ``fixed_key`` 가 주어지면 **그 키로 한 번만** 부른다 — 호출자가 명시한 키는
    고정 계약이므로 우리가 다른 키로 바꿔 부르지 않는다.

    ``fn`` 이 올리는 예외는 브로커가 OpenAI 호출임을 알아볼 수 있어야 한다
    (``llm_provider`` 표시). urllib ``HTTPError`` 는 provider 를 싣지 않아
    일반 판정기가 남의 provider 실패와 구분하지 못한다.
    """
    if fixed_key:
        return fn(fixed_key)

    last: Optional[BaseException] = None
    for _ in range(max(1, slot_count())):
        slot, key = active_slot_and_key()
        try:
            return fn(key)
        except Exception as exc:  # noqa: BLE001
            last = exc
            if not failover_on(exc, where=where, attempted_slot=slot):
                raise
    assert last is not None
    raise last


def mark_openai_failure(exc: BaseException, *, status: Any = None,
                        body: str = "") -> BaseException:
    """raw HTTP 예외를 브로커가 읽을 수 있는 모양으로 표시해 돌려준다.

    urllib 은 ``llm_provider`` 도 ``status_code`` 도 싣지 않는다. 그대로 두면
    ``_is_openai_error`` 가 "OpenAI 호출인지 모름"으로 판정해 **문구 마커까지
    가 보지도 못한다.** 표시만 붙이면 기존 판정 계약(코드·상태·문구)이 그대로
    적용된다 — 판정 규칙을 여기서 다시 쓰지 않는다.
    """
    try:
        exc.llm_provider = "openai"  # type: ignore[attr-defined]
        if isinstance(status, int):
            exc.status_code = status  # type: ignore[attr-defined]
        if body:
            # ★판정기는 `body` 를 **dict** 로 읽는다(error.code / type). 문자열로
            # 실으면 코드가 본문에 있어도 못 찾는다 — 실측으로 그래서 quota 가
            # 전환을 못 일으켰다. JSON 이면 파싱해 싣고, 아니면 문자열로 둔다
            # (그 경우 메시지 마커 경로가 받는다).
            import json as _json

            try:
                parsed = _json.loads(body)
            except Exception:  # noqa: BLE001
                parsed = None
            exc.body = parsed if isinstance(parsed, dict) else body
    except Exception:  # noqa: BLE001 — 표시 실패가 호출을 막지 않는다
        pass
    return exc
