# `grok` Router alias — 이미지를 보내는 자리에서 고를 수 있게

2026-08-27 · 과제 #92 의 앞단

## 지시

> gpt sol vlm 은 성능이 안좋아. 다른곳에 사용되는 곳있어 찾아봐 /
> 무조건 gemini 3.1 pro 와 grok 최신 모델 둘을 사용해야해
>
> 아홉 전부 바꿔, codex 랑 순서 정하고 진행해 지금 교체보다 진행중인것은
> 마무리하고 순서를 정해서 원래 계획에 추가해 / 아 그리고 grok alias 도 만들어

이 판은 **alias 하나**만 만든다. 아홉 자리 교체는 1-H 를 끝낸 뒤다
(Codex 순서 판정 2026-08-27).

## 왜 alias 가 따로 필요한가

grok 판정 경로는 **이미 있었다**. `multiroll_gemini` 가 모델 이름 앞에
`openrouter:` 를 붙여 전용 클라이언트로 보낸다.

    multiroll_gemini.py:1163  if model.startswith(OPENROUTER_JUDGE_PREFIX):
                       :1172      return ask_openrouter_structured(...)

문제는 **그 prefix 를 푸는 코드가 그 모듈 안에만 있다**는 것이다. 이미지를
보내는 나머지 자리는 전부 `call_structured` → litellm Router 를 쓰므로 그
경로를 못 쓴다. Router 에 alias 로 올려야 `project_config` 로 어디서나
고를 수 있다.

두 길은 **섞이면 안 된다** — Router 는 `openrouter:` 를 모르고, 전용
클라이언트는 alias 를 모른다. 시험으로 못박았다.

## 등록

`llm_client.py::_build_router` 에 `claude-opus` 와 같은 **조건부 등록**.

```python
_grok_model = (getattr(settings, "grok_judge_model", "") or "").strip()
if settings.openrouter_api_key and _grok_model:
    model_list.append({
        "model_name": "grok",
        "litellm_params": {
            "model": f"openrouter/{_grok_model}",
            "api_key": settings.openrouter_api_key,
            "api_base": settings.openrouter_base_url,
            "max_tokens": 8000,
            "drop_params": True,
        },
    })
```

★**키가 없으면 등록하지 않는다.** 등록 안 된 alias 를 고르면 호출부가 그
자리에서 실패한다 — 즉 **배선하는 쪽이 fail-closed 를 스스로 정해야 한다.**
여기서 조용히 다른 모델로 갈아타면 사용자 결정을 몰래 어기는 것이 된다.

★**물리 모델을 핀한다.** `x-ai/grok-latest` 같은 rolling 이름은 어느 날
다른 모델이 되는데 기록엔 같아 보인다 — 지문·재현·provenance 가 다 무너진다.
설정값(`GROK_JUDGE_MODEL=x-ai/grok-4.6`)을 그대로 싣고, 새 판이 나오면
설정을 고쳐 올린다.

## 운반층 실측

산출물 `artifact/20260827_grok_vlm_probe/`.

Codex 관문: 「답이 왔다」가 아니라 (a) 그림에서만 답할 수 있는 양성/음성
대조 (b) strict schema (c) alias·물리 모델 기록 (d) GPT 되돌아옴 0회.

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

### ★ 첫 판정이 틀렸던 것

처음엔 "이미지가 보이나?" 를 물어 **X O X** 로 흔들렸다. 그것을 보고
「전용 클라이언트가 이미지를 버린다」고 판단했는데 **둘 다 똑같이 흔들렸다**
(각 1/3). 뿌리는 운반층이 아니라 **물음이 자가신고였던 것**이다 — 모델은
그림을 받고도 "못 본다"고 답한다. 그림에서만 나올 수 있는 값을 묻자 6/6
안정. `drop_params=True` 도 이걸로 무죄다(전용 경로엔 아예 없는데 같이
흔들렸다).

## 출력 상한 — 적어 둔 값과 나가는 값이 달랐다

처음엔 `litellm_params` 에 `max_tokens: 8000` 을 적고 그것이 상한이라고
보고했다. **거짓이었다** (2026-08-27 Codex BLOCK, 끝점 실측으로 확인).

    call_structured  → 요청마다 kwargs["max_tokens"] 를 넣는다 (:1058)
    Router           → 요청값을 배포값 위에 얹는다
    나가는 body      → max_tokens = 65,536      ← 적어 둔 8000 이 안 나간다

★**재는 자리를 틀렸던 것이 뿌리다.** Router 설정값은 「적어 둔 것」이고
 상한은 「나간 것」이다. 시험도 설정값을 보고 있어 초록이었다.

수리 = `MODEL_MAX_OUTPUT_TOKENS = {"grok": 8000}` 를 두고
`_sanitize_kwargs_for_model` 이 **나가는 kwargs 를 직접** 깎는다. 못 지키는
약속(`litellm_params` 의 `max_tokens`)은 지웠다 — 남겨 두면 다음 사람이
그걸 믿는다. 끝점 재측정: `max_tokens=8000`.

### 왜 grok 만 잡나

추론 모델이라 **사고 토큰이 출력 과금에 들어간다.** 실측 완성 토큰이
평균 3,532 · 최대 7,337 인데 상한이 65,536 이면 한 콜이 그 아홉 배까지
열려 있다. 공식 단가 output $6/M 기준:

| | 완성 토큰 | 출력 비용 |
|---|---|---|
| 실측 평균 | 3,532 | 약 $0.021 |
| 실측 최대 | 7,337 | 약 $0.044 |
| **상한 65,536** | — | **약 $0.39** |

## ★정정 — 「비용이 0 으로 남는다」는 틀렸다 (2026-08-27)

이 문서는 처음에 「litellm 이 OpenRouter 단가를 몰라 span `cost=$0.0000`」
이라고 적었다. **사실이 아니다.**

비용은 **기록된다.** `cost` 가 **딕셔너리**다:

    {'total_tokens': 0.038172, 'currency': 'USD'}

내 측정 코드가 스칼라만 받는 모양이라 딕셔너리를 0.0 으로 떨어뜨렸고,
그 0 을 「기록이 없다」로 읽었다. **재는 도구가 못 읽으면 「0」이 「없다」로
읽힌다** — 이 저장소가 여러 번 겪은 그 자리다.

Opik 실측(Codex 교차 확인): 중앙 Router 경로(`_completion`)는 span 마다
`prompt/completion token` + `cost` 가 있다. **0 인 것은 Router 밖
`openai_keys.llm_completion` 직접 경로**다(`usage` 자체가 None).

### 그래서 실제 과제는 이것이다

1. 새 grok 호출이 **중앙 Router 를 타거나**, direct 경로에도 실제 usage 를
   보존한다는 **끝점 증명**이 활성 켜기 전 관문이다.
2. DB `llm_call_log` 에는 **비용 칸이 아예 없다**(token 만) — 이건 별도
   계약이다. #92 에서 「billing ledger 완료」라고 주장하면 안 된다.
2. **reasoning effort 를 재 봐야 한다.** 양성·음성 probe 를
   `effort=low/minimal` 로 돌려 정확도가 유지되는 최소값을 고른다.
3. **판정기별 작은 schema cap** — alias 전역 8000 은 덮개일 뿐이다.
4. 이중화 첫 판은 **Gemini 1회 + Grok 1회** (2모델×2회 4콜은 근거 없다).

## 이 판의 UI 계약

★**사람이 화면에서 grok 을 고를 수는 없다.** `LLMConfigPanel` 의
`available_models` 타입이 `{openai, gemini}` 둘뿐이라 `xai` 묶음은 select
에 안 뜬다(anthropic 도 같다). `AVAILABLE_MODELS` 에 줄을 넣은 이유는
`projects.py:570` 이 그것을 **alias→provider 의 SOT** 로 읽기 때문이다 —
없으면 `startswith("gpt")` 추론으로 떨어져 grok 이 「gemini」로 잘못
표시된다. 시험도 그 계약으로 좁혔다.

## 이 판이 하지 않는 것

- 아홉 자리 교체 — 1-H 뒤. 목록도 **「이미지 bytes 가 request 에 실리는가」**
  기준으로 다시 세어야 한다(Codex: `space_set_bg_provider` 의
  `TEXT_MODEL_DEFAULT` 는 순수 text 라 대상이 아니고, 기본 OFF 라 목록에서
  빠진 실제 VLM 이 네 곳 더 있다).
- 이중 판정 집계 계약 — 업무별로 다르다(binary gate / ranking / 관찰 /
  geometry).
