"""`grok` Router alias — 이미지를 보내는 자리에서 고를 수 있게 (2026-08-27).

사용자 지시: 「gpt sol vlm 은 성능이 안좋아 … 무조건 gemini 3.1 pro 와
grok 최신 모델 둘을 사용해야해」 + 「grok alias 도 만들어」.

## 왜 alias 여야 하나

grok 판정 경로는 이미 있었다 — `multiroll_gemini` 가 `openrouter:` 를 앞에
붙여 `ask_openrouter_structured` 로 보낸다. 그런데 **그 prefix 를 푸는 코드가
그 모듈 안에만 있다**(`multiroll_gemini.py:1163,1172,1610`). 이미지를 보내는
나머지 자리는 전부 `call_structured` 를 쓰므로 그 경로를 못 쓴다.

## 운반층 실측 (artifact/20260827_grok_vlm_probe/)

Codex 관문 그대로 태웠다 — 「답이 왔다」가 아니라 **그림에서만 답할 수 있는
물음**으로.

    나가는 payload   parts=['text','image_url'] · img_b64=1,764,486B
                     → `drop_params=True` 가 이미지를 안 버린다
    양성 대조 A      1명 · 자전거 · 유리 상점 (2/2 일치)
    양성 대조 B      32명 · 강의실 · 기타      (2/2 일치)
    음성 대조        people=-1 "No image is attached." — 안 지어낸다
    여러 장          3장 4.41MB · index 0/1/2 순서 유지
    되돌아옴         0회 (`enable_fallback=False`)
    기록             model_group=grok · model=x-ai/grok-4.6 · op:<스텝> 태그

★**앞선 probe 가 X O X 로 흔들렸던 것은 운반층이 아니라 물음 탓이었다.**
 "이미지가 보이나?" 는 **자가신고**라 받고도 아니라고 답한다. 그림에서만
 나올 수 있는 값을 물으니 6/6 안정.

★**비용이 0 으로 남는다** — litellm 이 OpenRouter 단가를 모른다(span
 `cost=$0.0000`). 완성 토큰은 평균 3,532·최대 7,337 로 무겁다. 배선하는
 판에서 따로 다뤄야 한다.
"""
from __future__ import annotations

import pytest

import app.modules.llm.llm_client as lc


@pytest.fixture(autouse=True)
def _restore_router():
    """`_build_router` 는 전역 `_router` 를 갈아끼운다 — 뒤를 치운다."""
    yield
    lc._invalidate_router()


def _aliases(monkeypatch, **over) -> dict:
    """설정을 바꿔 Router 를 새로 짓고 alias→litellm_params 를 돌려준다."""
    from app.core.config import settings

    for k, v in over.items():
        monkeypatch.setattr(settings, k, v, raising=False)
    r = lc._build_router()
    out: dict = {}
    for m in r.model_list:
        out.setdefault(m["model_name"], m["litellm_params"])
    return out


# ── 등록 갈래 ────────────────────────────────────────────────────────

def test_the_alias_exists_when_the_key_and_the_model_are_both_set(monkeypatch):
    got = _aliases(monkeypatch, openrouter_api_key="sk-test",
                   grok_judge_model="x-ai/grok-4.6")
    assert "grok" in got, "키와 모델이 다 있는데 alias 가 없다"
    assert got["grok"]["model"] == "openrouter/x-ai/grok-4.6"
    assert got["grok"]["api_key"] == "sk-test"


def test_no_key_means_no_alias(monkeypatch):
    """★키가 없으면 **등록하지 않는다** — `claude-opus` 와 같은 형태.

    등록 안 된 alias 를 고르면 호출부가 그 자리에서 실패한다. 즉
    **배선하는 쪽이 fail-closed 를 스스로 정해야 한다** — 여기서 조용히
    다른 모델로 갈아타면 사용자 결정을 몰래 어기는 것이 된다.
    """
    got = _aliases(monkeypatch, openrouter_api_key="",
                   grok_judge_model="x-ai/grok-4.6")
    assert "grok" not in got


def test_an_empty_model_slot_means_no_alias(monkeypatch):
    """키만 있고 모델이 비면 `openrouter/` 하나만 남아 못 쓴다."""
    for blank in ("", "   "):
        got = _aliases(monkeypatch, openrouter_api_key="sk-test",
                       grok_judge_model=blank)
        assert "grok" not in got, f"모델이 {blank!r} 인데 등록됐다"


# ── 신원 ─────────────────────────────────────────────────────────────

def test_the_physical_model_is_pinned_not_rolling(monkeypatch):
    """★**rolling 이름을 박으면 지문·재현이 무너진다** (Codex 2026-08-27).

    `x-ai/grok-latest` 같은 것은 어느 날 다른 모델이 된다 — 그러면 같은
    지문의 옛 판정과 새 판정이 다른 모델 것인데 기록엔 같아 보인다.
    설정값을 그대로 실어 보내고, 새 판이 나오면 설정을 고쳐 올린다.
    """
    from app.core.config import settings

    assert "latest" not in (settings.grok_judge_model or "").lower(), (
        "설정이 rolling 이름을 가리킨다")
    got = _aliases(monkeypatch, openrouter_api_key="sk-test",
                   grok_judge_model="x-ai/grok-9.9")
    assert got["grok"]["model"].endswith("x-ai/grok-9.9"), (
        "설정을 안 따르고 어딘가에 모델 이름이 박혀 있다")


def test_the_catalog_maps_the_alias_to_its_provider():
    """★**이 목록은 화면 선택지가 아니다** (2026-08-27 Codex 지적).

    처음엔 「UI 목록에 없다 — 사람이 못 고른다」고 썼는데 거짓 양성이었다.
    `LLMConfigPanel` 의 `available_models` 타입이 `{openai, gemini}` 둘뿐이라
    `xai` 묶음은 select 에 아예 안 뜬다(anthropic 도 같다) — 이 시험이
    초록이어도 사람은 못 고른다.

    실제 계약은 `projects.py:570` 이다: 이 목록이 **alias→provider 의 SOT**
    이고, 없으면 `startswith("gpt")` 추론으로 떨어져 grok 이 「gemini」로
    잘못 표시된다. 그것을 잰다.
    """
    entry = [m for m in lc.AVAILABLE_MODELS if m["alias"] == "grok"]
    assert entry, "provider 표에 없다 — API 가 grok 을 gemini 로 표시한다"
    assert entry[0]["provider"] == "xai"

    # 끝점에서 확인 — `projects.py` 가 만드는 것과 같은 표를 다시 만든다.
    alias_provider = {m["alias"]: m.get("provider", "gemini")
                      for m in lc.AVAILABLE_MODELS}
    assert alias_provider.get("grok") == "xai"
    fallback = "openai" if "grok".startswith("gpt") else "gemini"
    assert alias_provider["grok"] != fallback, (
        "표가 없어도 같은 값이 나오면 이 줄은 아무것도 안 지킨다")


# ── 출력 상한 — 나가는 kwargs 에서 잰다 ──────────────────────────────

class _FakeMsg:
    def __init__(self, content):
        self.content = content


class _FakeResp:
    def __init__(self, content):
        self.choices = [type("C", (), {"message": _FakeMsg(content)})()]
        self.usage = None


def _kwargs_that_left(monkeypatch, caller, **over):
    """**공개 함수를 실제로 태우고** `_completion` 이 받은 kwargs 를 돌려준다.

    ★★sanitizer 를 직접 부르면 안 된다 (2026-08-27 Codex BLOCK).
     `call_structured` 가 sanitizer 호출을 빼거나 `max_tokens` 대입을
     그 뒤로 옮겨도 그런 시험은 초록이다 — **이번 결함의 mutation 을
     못 잡는다.** 그래서 대역은 Router 경계(`_completion`)에만 둔다.
    """
    seen: dict = {}

    def fake_completion(binding, model, kwargs):
        seen["model"] = model
        seen["kwargs"] = dict(kwargs)
        return _FakeResp('{"x": "ok"}')

    monkeypatch.setattr(lc, "_completion", fake_completion)
    monkeypatch.setattr(lc, "_get_router_binding", lambda: object())
    from app.core.config import settings

    monkeypatch.setattr(settings, "openrouter_api_key", "sk-test",
                        raising=False)
    monkeypatch.setattr(settings, "grok_judge_model", "x-ai/grok-4.6",
                        raising=False)
    caller(**over)
    return seen


_SCHEMA = {"type": "object", "additionalProperties": False,
           "properties": {"x": {"type": "string"}}, "required": ["x"]}


def _structured(**over):
    kw = dict(step="probe", system_prompt="s", user_prompt="u",
              response_schema=_SCHEMA,
              project_config={"probe": {"model": "grok"}},
              enable_fallback=False)
    kw.update(over)
    return lc.call_structured(**kw)


def _text(**over):
    kw = dict(step="probe", system_prompt="s", user_prompt="u",
              project_config={"probe": {"model": "grok"}})
    kw.update(over)
    return lc.call_text(**kw)


def _multiturn(**over):
    kw = dict(step="probe", messages=[{"role": "user", "content": "u"}],
              response_schema=_SCHEMA,
              project_config={"probe": {"model": "grok"}})
    kw.update(over)
    return lc.call_multiturn(**kw)


def test_the_cap_lands_on_what_actually_leaves(monkeypatch):
    """★★**`litellm_params` 의 `max_tokens` 는 나가지 않는다** (Codex BLOCK).

    처음엔 배포 설정에 `max_tokens: 8000` 을 적고 그것이 상한이라고 했다.
    끝점 실측: 나가는 body 의 `max_tokens` 는 **65,536** 이었다 —
    호출부가 요청마다 값을 넣고 Router 가 요청값을 배포값 위에 얹는다.

    ★재는 자리를 틀렸던 것이 뿌리다. Router 설정값은 「적어 둔 것」이고
     상한은 「나간 것」이다. 그래서 여기서는 **공개 함수를 실제로 태우고**
     Router 가 받은 kwargs 를 본다.
    """
    seen = _kwargs_that_left(monkeypatch, _structured)
    assert seen["model"] == "grok"
    assert seen["kwargs"]["max_tokens"] == lc.MODEL_MAX_OUTPUT_TOKENS["grok"]
    assert seen["kwargs"]["max_tokens"] < 65536


def test_the_other_two_entry_points_are_capped_too(monkeypatch):
    """★세 `_do_call` 을 덮는다고 말했으면 **셋 다 태워야** 한다."""
    for caller in (_text, _multiturn):
        seen = _kwargs_that_left(monkeypatch, caller)
        assert seen["kwargs"]["max_tokens"] == (
            lc.MODEL_MAX_OUTPUT_TOKENS["grok"]), (
            f"{caller.__name__} 이 상한을 안 받았다")


def test_a_smaller_request_is_not_raised_to_the_cap(monkeypatch):
    """상한은 **덮개**지 기본값이 아니다 — 더 작게 부르면 그대로 둔다."""
    seen = _kwargs_that_left(monkeypatch, _structured, max_tokens=1200)
    assert seen["kwargs"]["max_tokens"] == 1200


def test_other_aliases_keep_the_global_ceiling(monkeypatch):
    """★grok 만 잡는다 — 다른 모델의 상한을 같이 내리면 답이 잘린다.

    ★[2026-09-17] 이 시험이 `max_tokens` 라는 **칸 이름**을 잠그고 있어서
     e4d960ec(OpenAI 추론 모델은 max_tokens 를 거부한다 — 문에서 옮긴다)
     이후 `gpt` 에서 KeyError 로 섰다. 이 시험이 막으려던 것은
     **상한이 같이 내려가는 것**이지 칸 이름이 아니다. 그래서 칸은 둘 중
     실제로 나간 쪽을 본다 — 하나도 없으면 상한이 아예 안 실린 것이라
     여전히 실패고, 둘 다 있으면 한쪽만 읽힐 수 있으니 그것도 실패다.

    ★값을 전역 상한과 **같은지**로 잠그지 않는 이유: a65701b8 이후 상한은
     모델마다 **제 최대**로 내려간다(gemini-3.1-pro 는 65,536). 그런데 그
     최대는 litellm 모델표에서 읽히고, 표는 앞선 시험이 router 를 세웠는지에
     따라 올라와 있기도 없기도 하다 — 이 시험만 단독으로 돌리면 128,000,
     파일 전체로 돌리면 65,536 이 나갔다(2026-09-17 실측). 프로덕션은 표가
     늘 올라와 있어 안 흔들리지만, 시험이 그 흔들림을 타면 순서에 따라
     빨강·초록이 갈린다. 그래서 **범위**로 잠근다 — grok 상한(8,000)까지
     내려가지 않았고 전역 상한을 넘지 않았다.
    """
    from app.core.config import settings

    for alias in ("gpt", "gemini-pro", "gemini-flash"):
        seen = _kwargs_that_left(
            monkeypatch, _structured,
            project_config={"probe": {"model": alias}})
        kw = seen["kwargs"]
        keys = [k for k in ("max_tokens", "max_completion_tokens") if k in kw]
        assert keys, f"{alias} 에 출력 상한이 아예 안 실렸다 — {sorted(kw)}"
        assert len(keys) == 1, (
            f"{alias} 가 상한을 두 칸으로 보낸다 — 한쪽만 읽히면 잘린다: {keys}")
        got = kw[keys[0]]
        assert got > lc.MODEL_MAX_OUTPUT_TOKENS["grok"], (
            f"{alias} 가 grok 상한까지 내려갔다 ({keys[0]}={got:,})")
        assert got <= settings.llm_max_output_tokens, (
            f"{alias} 가 전역 상한을 넘겼다 ({keys[0]}={got:,})")


def test_the_deployment_no_longer_claims_a_ceiling_it_cannot_enforce(
        monkeypatch):
    """★못 지키는 약속을 설정에 적어 두면 다음 사람이 그걸 믿는다."""
    got = _aliases(monkeypatch, openrouter_api_key="sk-test",
                   grok_judge_model="x-ai/grok-4.6")
    assert "max_tokens" not in got["grok"], (
        "배포 설정에 상한이 남아 있다 — 나가지 않는 값이다")


# ── 두 경로가 안 섞인다 ──────────────────────────────────────────────

def test_the_router_alias_and_the_multiroll_prefix_stay_distinct():
    """★**같은 모델로 가는 길이 둘인데 서로를 모른다.**

    `multiroll` 은 `openrouter:` 를 앞에 붙인 문자열을 자기 모듈 안에서만
    푼다. Router alias 는 그 prefix 를 안 쓴다 — 둘을 섞어 부르면 한쪽은
    alias 를 못 찾고, 다른 쪽은 prefix 를 모델 이름의 일부로 읽는다.
    """
    from app.modules.pipeline.multiroll_gemini import (
        OPENROUTER_JUDGE_PREFIX,
    )

    assert not OPENROUTER_JUDGE_PREFIX.startswith("grok")
    assert "grok" not in OPENROUTER_JUDGE_PREFIX
    ui = {m["alias"] for m in lc.AVAILABLE_MODELS}
    assert not any(a.startswith(OPENROUTER_JUDGE_PREFIX) for a in ui), (
        "prefix 붙은 문자열이 alias 목록에 섞였다 — Router 가 못 푼다")


def test_the_alias_only_adds_itself(monkeypatch):
    """grok 을 더해도 쓰던 alias 는 그대로다.

    ★고정 이름 목록(`gpt`, `gemini-pro` …)으로 재면 **키가 없는 CI 에서
     빨개진다** — 그 alias 들이 애초에 안 실리기 때문이다(Codex 지적).
     기준선을 그 자리에서 만들어 **차이만** 본다.
    """
    base = set(_aliases(monkeypatch, openrouter_api_key="",
                        grok_judge_model=""))
    lc._invalidate_router()
    after = set(_aliases(monkeypatch, openrouter_api_key="sk-test",
                         grok_judge_model="x-ai/grok-4.6"))
    assert after - base == {"grok"}, f"grok 말고 더 늘었다: {after - base}"
    assert not base - after, f"기존 alias 가 사라졌다: {base - after}"
