---
ai_assisted: true
title: "codex CLI에 로컬 Qwen 물리기 — ollama·vLLM 배선기"
description: "codex를 ollama·vLLM 로컬 모델에 연결. ollama는 config.toml 하나, vLLM은 developer 롤 오류를 패치나 프록시로 해결"
pubDate: 2026-07-02
category: "AI"
subcategory: "Agent"
tags: ["codex", "LLM", "vLLM", "ollama", "Qwen", "gemma", "terminal-bench"]
serial: 32
draft: false
published: true
---

OpenAI의 `codex` CLI는 기본적으로 OpenAI 클라우드를 보지만, 두 가지 스위치만 맞추면 같은 바이너리가 로컬 ollama나 vLLM과 그대로 이야기합니다. ollama는 `config.toml` 하나로 끝나고, vLLM은 딱 하나(`developer` 롤)만 걸리는데 이걸 서버(chat template 패치)에서 풀 수도 있고 엣지(작은 프록시)에서 풀 수도 있습니다. 이 글은 가장 짧은 동작 경로를 훑고, vLLM에서 왜 그 400이 나는지 원인을 짚습니다.


## 배선 한 컷

`codex`는 요청을 OpenAI `/v1/responses` 형식으로 보냅니다. 이 엔드포인트를 서빙하는 백엔드라면 어디든 호스트가 될 수 있습니다.

```
[ollama]       codex ─(/v1/responses)──────────────────▶ ollama:11434
[vLLM 직결]     codex ─(/v1/responses)──────────────────▶ vLLM:8000      (chat_template 패치 시)
[vLLM 프록시]   codex ─(/v1/responses)─▶ proxy.py ─(교정)─▶ vLLM:8000      (서버가 공유일 때)
```

세 경로 모두 두 가지만 지키면 됩니다.

1. **커스텀 provider + `wire_api = "responses"`.** codex 0.137+는 Responses API를 강제하고, 0.142+는 `openai_base_url`만 오버라이드하면 기본 provider의 WebSocket 트랜스포트로 새 나갑니다. provider를 명시해야 안전합니다.
2. **격리된 `CODEX_HOME`.** 전역 `~/.codex` 대신 이 레포의 `.codex/`를 codex의 집으로 지정해서 개인 훅·trust·로그가 실험에 섞이지 않게 합니다.

나머지는 이 둘의 변주입니다.

## 5분 빠른시작: ollama

ollama는 프록시가 필요 없습니다. codex가 보내는 `developer` 롤을 그대로 받고 `/v1/responses`를 바로 서빙합니다.

**준비**: `codex --version`이 0.137 이상이고, 도구 호출을 학습한 모델이 하나 있으면 됩니다. 예: `ollama pull gemma4:12b`.

`.codex/config.toml`에 모델 이름만 넣습니다.

```toml
model = "gemma4:12b"
model_provider = "local_ollama"

[model_providers.local_ollama]
name = "Local Ollama"
base_url = "http://localhost:11434/v1"
wire_api = "responses"
```

그리고 `CODEX_HOME`을 이 격리 설정으로 지정해 codex를 실행합니다.

```bash
CODEX_HOME="$PWD/.codex" codex exec \
  --skip-git-repo-check --ephemeral \
  --dangerously-bypass-approvals-and-sandbox \
  "Reply with exactly the single word: PONG"
```

`PONG`이 돌아오면 배선이 맞은 겁니다. 실제 코딩을 시키려면 `-C /tmp/work` 같은 작업 디렉토리를 붙이고 프롬프트만 바꾸면 됩니다.

한 가지 주의점은 모델 크기입니다. `qwen3:0.6b` 정도로도 배선 확인은 되지만, 실제로 파일을 쓰고 명령을 실행하려면 `gemma4:12b`급 이상의 도구 호출 학습 모델이 필요합니다.

## 더 짧은 길: ollama의 codex 런처

최근 ollama에는 에이전트 런처가 붙어 있습니다. 서브커맨드 없이 `ollama`만 치면 코딩 에이전트 메뉴(OpenCode, Hermes, **Codex**, Cline, Qwen Code 등)가 뜨고, `Launch Codex (gemma4:12b)`를 고르면 `config.toml` 편집 없이 ollama가 서빙하는 모델에 이미 물린 codex 0.142.5가 실행됩니다.

![ollama 프롬프트](/images/codex-local-qwen/01-launch.png)

![에이전트 메뉴에서 Launch Codex 하이라이트](/images/codex-local-qwen/02-menu.png)

![codex trust 다이얼로그](/images/codex-local-qwen/03-trust.png)

무설정의 대가로 포기하는 것들:

- **전역 설정, 격리 없음.** 런처는 `CODEX_HOME`을 잡지 않습니다. codex는 `~/.codex/config.toml`, 개인 훅, 개인 trust store를 그대로 읽습니다. 실행 cwd에 프로젝트-로컬 `.codex/config.toml`이 함께 있다면 codex는 provider 관련 키를 조용히 무시하며 다음 경고를 냅니다.

  ```
  Ignored unsupported project-local config keys in <cwd>/.codex/config.toml:
    model_provider, model_providers.
  If you want these settings to apply, manually set them in your user-level config.toml.
  ```

  이 경로에서 provider 결정은 사용자 수준의 결정입니다. 앞 절에서 만든 프로젝트-로컬 `.codex/`는 더 이상 진실의 원천이 아닙니다. 의도된 경계입니다. 프로젝트가 config 파일 하나 넣는 것만으로 codex의 백엔드를 조용히 바꿀 수 없게 하기 위해서입니다.

  ![프로젝트-로컬 config 무시 경고와 YOLO 모드로 시작한 codex](/images/codex-local-qwen/04-warning.png)

- **덮어쓰기는 이론이 아니며, codex 스키마 한정입니다.** 실측 사례 하나에서 런처가 `~/.codex/config.toml`을 존재하지 않는 태그(`gemma4:12b`)와 `wire_api = "responses"`(ollama의 chat-completions 형상과 부정합)로 교체했고, 다른 프로젝트의 다음 codex 호출이 출력 없이 매달렸습니다. 위험은 codex 스키마 자체에서 옵니다. codex는 활성 provider를 최상위 `model_provider =` 한 키로 표현하므로 파일 단위 템플릿 교체가 곧 활성 라우팅 교체입니다. 등록과 활성이 분리된 설계(예: Claude Code의 `~/.claude.json`은 `providerProfiles[]` 목록 + 별도 활성 포인터 `activeProviderProfileId`)를 쓰는 다른 에이전트는 같은 세션에서도 무사했고, 런처는 Ollama를 그 목록에 **비활성 프로파일로만** 얹었습니다. 런처를 돌리기 전에 정상 config를 스냅샷으로 남겨 두세요. 예: `cp ~/.codex/config.toml ~/.codex/config.openai.toml`, 스왑되면 `cp` 한 줄로 원복.

- **YOLO 모드가 기본.** 세션이 승인·샌드박스를 해제한 채로 열립니다. 개인 노트북에선 편하지만 공용 머신에선 부담입니다.

- **ollama 전용.** 런처는 ollama 런타임만 겨냥합니다. vLLM은 아래 배선이 그대로 필요합니다.

![ollama gemma4:12b로 parser.py + pytest 작업을 완료하는 codex](/images/codex-local-qwen/05-result.png)

써도 되는 상황: 단독 노트북에 ollama 모델 하나 쓰고 실험을 서로 격리할 필요가 없을 때. 격리 `CODEX_HOME` 흐름은 프로젝트별 샌드박스, 모델 비교, vLLM/프록시가 필요할 때 유지합니다.

## vLLM: developer 롤을 넘는 두 갈래

바닐라 vLLM에 codex를 곧장 붙이면 첫 요청이 `400 "Unexpected message role."`으로 죽습니다. 여기서 문제의 롤이 바로 `developer`인데, codex가 시스템 지시를 실어 보내는 채널입니다. 깔끔한 해결은 두 갈래입니다.

**A. chat template을 패치한다 (서버를 직접 관리할 때).** 서버가 내 것이면 Jinja 템플릿에 `developer` 분기를 추가하고 `--chat-template`로 넘깁니다. 프록시 없이 근본해결입니다.

```jinja
{%- elif message.role == "developer" %}
    {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
```

**B. 앞에 작은 프록시를 세운다 (공유 vLLM일 때).** 재기동할 수 없는 공유 vLLM이라면 앞단에 엣지 프록시를 놓고 `developer` 항목을 `instructions`(선두 `system`)로 재라벨링합니다. 나머지 요청은 그대로 흘려보냅니다.

```bash
# 터미널 A — 프록시
python proxy.py --vllm http://<서버IP>:8000/v1 \
  --model Qwen/Qwen3.6-35B-A3B-FP8 --port 8731

# 터미널 B — codex를 프록시로
CODEX_HOME="$PWD/.codex" codex exec \
  -c model_providers.local_vllm.base_url="http://localhost:8731/v1" \
  -c model_providers.local_vllm.wire_api="responses" \
  -c model_provider="local_vllm" \
  -m Qwen/Qwen3.6-35B-A3B-FP8 \
  --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox \
  "이 레포 구조 요약해줘"
```

프록시의 역할은 일부러 좁게 잡았습니다. `/v1/responses`의 모든 필드는 그대로 통과시키고, `developer` 항목만 손대며, codex 0.142+가 바디에 `model`을 빼먹었을 때만 채워 넣습니다. 사용량·툴 호출·SSE 스트림은 전부 vLLM이 만들어낸 그대로 흘려보냅니다.

한 방에 하고 싶으면 `run_codex.py`가 프록시 기동 + codex 실행 + CODEX_HOME 격리를 묶어 줍니다.

```bash
python run_codex.py \
  --vllm http://<서버IP>:8000/v1 \
  --model Qwen/Qwen3.6-35B-A3B-FP8 \
  --cwd . --prompt "이 레포 구조 요약해줘" --mode cli
```

정상 실행이면 `[turns=N errors=0]`와 함께 codex의 최종 응답이 찍힙니다.

## vLLM은 왜 developer 롤을 거부하나

거부는 vLLM의 파이썬 코드가 아니라 모델의 Jinja chat template에서 납니다. `system`·`user`·`assistant`·`tool`을 토큰으로 바꾸는 그 파일입니다.

```jinja
{%- if message.role == "system" %} ...
{%- elif message.role == "user" %} ...
{%- elif message.role == "assistant" %} ...
{%- elif message.role == "tool" %} ...
{%- else %}{{- raise_exception('Unexpected message role.') }}
```

400 바디의 문자열("Unexpected message role.", 마침표까지)이 이 `raise_exception` 문자열과 정확히 일치합니다. 실패가 나는 곳이 템플릿 계층이라 두 해법이 각자 알맞은 층에서 동작합니다. **A**는 템플릿에 새 분기를 가르치고, **B**는 항목이 템플릿에 닿기 전에 라벨을 바꿉니다. 기존 트래픽(system/user/assistant/tool)은 어느 쪽이든 바이트 동일합니다. 패치된 템플릿은 `developer` 분기에서만 갈라지므로 공유 워크로드에 영향이 없습니다.

vLLM을 최신으로 올릴 수 있다면 `chat_utils`가 `developer`를 네이티브로 인지하는 버전을 쓰는 것도 서버측 해법입니다.

## SDK 모드 (선택)

Python `openai-codex` SDK도 배선 방식은 같습니다. `config`의 `model_providers`에 커스텀 provider를 넣고 프록시 포트를 가리키면 됩니다.

```python
thread = codex.thread_start(
    model=model,
    model_provider="vllmresp",
    config={"model_providers": {"vllmresp": {
        "name": "vllmresp",
        "base_url": f"http://localhost:{port}/v1",
        "wire_api": "responses",
    }}},
    approval_mode=ApprovalMode.auto_review,
    sandbox=Sandbox.full_access,
    ephemeral=True,
    cwd=cwd,
)
```

SDK를 쓸 때 알아둘 두 가지가 있습니다. prerelease라 `uv pip install --prerelease=allow openai-codex`로 설치해야 하고, SDK가 자체 codex 바이너리(현재 0.137)를 번들해서 시스템 `codex`(0.142+)와 버전이 다를 수 있습니다. CLI 모드는 시스템 codex를, SDK 모드는 번들 codex를 씁니다. 하나를 골라 유지하는 편이 편합니다.

## terminal-bench에 로컬 모델로 돌리기

같은 배선을 terminal-bench에 적용하려면 기본 `CodexAgent`를 상속해서 컨테이너 안 `~/.codex/config.toml`에 커스텀 provider를 주입합니다. Mac + Colima 환경이라면 두 가지만 잡아 주면 됩니다. 컨테이너에서 보는 로컬 LLM URL(`host.docker.internal:11434`)과 Docker 소켓 경로입니다.

```bash
export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"
OPENAI_API_KEY=ollama-dummy PYTHONPATH=. tb run \
  -p /tmp/tbcore -t hello-world \
  --agent-import-path tb_codex_local:CodexLocalAgent \
  -m local/gemma4:12b --n-concurrent 1 --no-livestream
```

`codex-cli + gemma4:12b` 실측:

| 태스크 | 난이도 | 결과 |
|--------|--------|------|
| hello-world | easy | PASS (~1분, 2/2) |
| heterogeneous-dates | medium | PASS (~10분, 3/3, `--global-agent-timeout-sec 3600` 필요) |

`heterogeneous-dates`는 기본 360초 캡 안에서 정답을 다 계산하고도 파일 저장 직전에 시간이 끊깁니다. 캡만 늘리면 통과합니다. 12B 로컬의 추론 속도가 병목이지 역량 문제가 아닙니다.

## 함정 표

| 증상 | 해결 |
|---|---|
| `developer` 롤 **400** | vLLM인데 프록시·템플릿 패치 둘 다 안 거친 경우. codex가 프록시(`localhost:8731`)를 보는지 확인. ollama는 이 에러가 안 납니다. |
| `unexpected status 200 OK ... ws://` | codex 0.142+가 WebSocket 트랜스포트로 새는 것. 커스텀 provider를 `wire_api="responses"`로 명시하세요. `openai_base_url` 단독은 안 됩니다. |
| `model is required` (ollama 400) | codex 0.142+가 바디에서 `model`을 뺀 경우. 프록시에 `--model`을 주면 주입합니다(`run_codex.py`는 자동). |
| `wire_api=chat` 오류 | codex 0.137+는 chat을 드롭하고 Responses만 씁니다. 백엔드가 `/v1/responses`를 서빙하는지 확인(ollama·vLLM 둘 다 지원). |
| codex가 파일을 안 씀 | 모델이 너무 작습니다. `gemma4:12b`급 이상의 도구 호출 학습 모델을 쓰세요. |
| `openai-codex` import 실패 | prerelease라 `uv pip install --prerelease=allow openai-codex` 필요. CLI 경로는 SDK 없이 됩니다. |
| `docker ... Connection aborted, FileNotFoundError` | Colima에서 발생. `export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock"`. |

## 마무리

두 가지가 전체 배선을 지탱합니다. 커스텀 provider를 `wire_api = "responses"`로 명시하는 것, 그리고 `CODEX_HOME`을 격리하는 것. ollama는 이 두 개면 끝입니다. vLLM은 한 가지가 더 있는데, `developer` 롤을 어디에 앉힐지입니다. 그 자리를 chat template에 마련할지, 몇십 줄짜리 프록시에 맡길지가 선택지입니다. 프록시 방식은 익혀 둘 가치가 있습니다. `/v1/responses` 스키마 전체를 통째로 흘려보내고 딱 한 필드만 손대는 설계라서, codex가 버전업해도 잘 깨지지 않기 때문입니다.

전체 코드와 terminal-bench 어댑터는 [github.com/ysys143/codex_qwen](https://github.com/ysys143/codex_qwen)에 있습니다.
