codex CLI에 로컬 Qwen 물리기 — ollama·vLLM 배선기
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 (서버가 공유일 때)세 경로 모두 두 가지만 지키면 됩니다.
- 커스텀 provider +
wire_api = "responses". codex 0.137+는 Responses API를 강제하고, 0.142+는openai_base_url만 오버라이드하면 기본 provider의 WebSocket 트랜스포트로 새 나갑니다. provider를 명시해야 안전합니다. - 격리된
CODEX_HOME. 전역~/.codex대신 이 레포의.codex/를 codex의 집으로 지정해서 개인 훅·trust·로그가 실험에 섞이지 않게 합니다.
나머지는 이 둘의 변주입니다.
5분 빠른시작: ollama
ollama는 프록시가 필요 없습니다. codex가 보내는 developer 롤을 그대로 받고 /v1/responses를 바로 서빙합니다.
준비: codex --version이 0.137 이상이고, 도구 호출을 학습한 모델이 하나 있으면 됩니다. 예: ollama pull gemma4:12b.
.codex/config.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를 실행합니다.
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가 실행됩니다.



무설정의 대가로 포기하는 것들:
전역 설정, 격리 없음. 런처는
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의 백엔드를 조용히 바꿀 수 없게 하기 위해서입니다.
덮어쓰기는 이론이 아니며, 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 모델 하나 쓰고 실험을 서로 격리할 필요가 없을 때. 격리 CODEX_HOME 흐름은 프로젝트별 샌드박스, 모델 비교, vLLM/프록시가 필요할 때 유지합니다.
vLLM: developer 롤을 넘는 두 갈래
바닐라 vLLM에 codex를 곧장 붙이면 첫 요청이 400 "Unexpected message role."으로 죽습니다. 여기서 문제의 롤이 바로 developer인데, codex가 시스템 지시를 실어 보내는 채널입니다. 깔끔한 해결은 두 갈래입니다.
A. chat template을 패치한다 (서버를 직접 관리할 때). 서버가 내 것이면 Jinja 템플릿에 developer 분기를 추가하고 --chat-template로 넘깁니다. 프록시 없이 근본해결입니다.
{%- elif message.role == "developer" %}
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}B. 앞에 작은 프록시를 세운다 (공유 vLLM일 때). 재기동할 수 없는 공유 vLLM이라면 앞단에 엣지 프록시를 놓고 developer 항목을 instructions(선두 system)로 재라벨링합니다. 나머지 요청은 그대로 흘려보냅니다.
# 터미널 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 격리를 묶어 줍니다.
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을 토큰으로 바꾸는 그 파일입니다.
{%- 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를 넣고 프록시 포트를 가리키면 됩니다.
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 소켓 경로입니다.
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-livestreamcodex-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에 있습니다.