CodexをローカルLLMで駆動する
はじめに
本記事では、ローカルLLMを用いてCodex CLIを駆動するための方法についてまとめる。
背景
私は生成AIのベンチマーク評価をすることが趣味の一つなのだが、最近はLLMとClaude CodeやCodexなどのハーネスを組み合わせた際のエージェント性能を評価することが多い。
一般的なハーネスはOpenAI Compatible、すなわちChat Completion APIで動くものが殆どで、それ以外としてClaude CodeはAnthropicのMessages APIで動く。
私がベンチマーク評価でよく使う推論エンジンであるllama.cppはChat Completion APIとMessages APIのいずれにも対応しており、ほぼすべてのハーネスと連結できるのだが、Codexは動かすことができない。なぜならCodexはResponses形式のAPIを要求しているためである。
そのため、今までCodexとローカルLLMを組み合わせたベンチマーク評価をこれまでしてこなかったわけだが、最近ChatGPTのサブスクに加入しCodex Appを多用することになったため、ローカルLLMと組み合わせたベンチマーク評価をしたくなったのがこの取り組みの背景である。
少々前置きが長くなってしまったが、ここから本題に入る。
LiteLLMのプロキシ
LiteLLM等のプロキシを使うことでAPIの形式を変換できる可能性はあるが、基本的にプロキシは速度の低下を招く可能性が高いのと、サーバーをもう一つ起動しないといけないため、できれば使用したくはない。ということでプロキシサーバーを使うのは最終手段として考え、まずは推論エンジンそのもので対応するものを探す。
llama.cpp
念の為、llama.cppが本当にCodexのLLMバックエンドサーバとして使えないか確認する。
結論から言うと、llama.cppではうまくいかなかったため今後の対応を期待する。以下は参考程度の備忘録メモである。
llama.cppは一応Responses APIに対応しているとのことだが、実際にCodexから使ってみると下記のエラーが出て使えなかった。

どうやらCodexのバージョンを0.87.0以前にすればllama.cppからでも使えるようだが、ベンチマーク評価は最新のバージョンで実行したいため、一旦llama.cppで動かすのは諦めることにする。
LM Studio
XでLM Studioを使えば、ナチュラルにResponses形式のAPIに対応している、というコメントをいただいたので試してみる。
まず前提条件として、私はRTX 5090あるいはMac Studio上でLLMを動かしているのだが、どちらもリモートPCでありGUIを極力使いたくない。
そのため、LM StudioのGUIアプリではなくlms(LM Studio's CLI)を使った環境構築について以下では記載する。
lmsの導入
- インストール
$ curl -fsSL https://lmstudio.ai/install.sh | bash
- daemonの起動
$ source ~/.bashrc # Macの場合は $ source ~/.zshrc
$ lms daemon up
- バージョン確認
$ lms version
__ __ ___ ______ ___ _______ ____
/ / / |/ / / __/ /___ _____/ (_)__ / ___/ / / _/
/ /__/ /|_/ / _\ \/ __/ // / _ / / _ \ / /__/ /___/ /
/____/_/ /_/ /___/\__/\_,_/\_,_/_/\___/ \___/____/___/
lms is LM Studio's CLI utility for your models, server, and inference runtime.
CLI commit: 0b2a176
Docs: https://lmstudio.ai/docs/developer
Join our Discord: https://discord.gg/lmstudio
Contribute: https://github.com/lmstudio-ai/lms
ggufのロード
lmsが起動できたら次にモデルをロードするのだが、極力llama-serverと同じggufモデルを同じ感覚で使用したかったため、GitHub Copilot + gpt-5.4に専用のシェルスクリプトを作成してもらった。
ggufのファイルはllama.cppで用いるものとまったく同じであり、PATH等は適宜置き換えて使用すること。
シェルスクリプトの大まかな流れとしては下記の通り。
-
GGUF を LM Studio に登録(元ファイルは残したいなら -l で symlink)
-
ロード
-
OpenAI互換サーバー起動
というわけで、以下ではRTX 5090上で../models/Qwen3.6-27B-UD-Q4_K_XL.ggufのモデルをQwen3.6-27B-UD-Q4_K_XLというalias名でロードする用に作成した実行コマンドとシェルスクリプトを記載する。
- 実行コマンド
$ ./lms-load-and-serve.sh ../models/Qwen3.6-27B-UD-Q4_K_XL.gguf Qwen3.6-27B-UD-Q4_K_XL
USER PID ACCESS COMMAND
8080/tcp: gosrum 2762964 F.... python3
Using model key: local/Qwen3.6-27B-UD-Q4_K_XL
Using identifier: Qwen3.6-27B-UD-Q4_K_XL
Model file already imported: /home/gosrum/.lmstudio/models/local/Qwen3.6-27B-UD-Q4_K_XL/Qwen3.6-27B-UD-Q4_K_XL.gguf
Loading model with context=200000 parallel=2 gpu=max
Model loaded successfully in 2.67s.
(16.40 GiB)
To use the model in the API/SDK, use the identifier "Qwen3.6-27B-UD-Q4_K_XL".
LM Studio backend is already running on port 8081
Reasoning mode: OFF
No-think proxy is serving on 0.0.0.0:8080 -> 127.0.0.1:8081
Ready.
OpenAI-compatible endpoint: http://localhost:8080/v1
Loaded model identifier: Qwen3.6-27B-UD-Q4_K_XL
Example request:
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen3.6-27B-UD-Q4_K_XL",
"messages": [{"role":"user","content":"こんにちは"}],
"temperature": 0.7,
"top_p": 0.8,
"top_k": 20,
"presence_penalty": 1.5
}'
- シェルスクリプト
詳細
#!/usr/bin/env bash
set -euo pipefail
fuser -kvn tcp 8080
usage() {
cat <<'EOF'
Usage:
./lms-load-and-serve.sh <gguf-path> <identifier>
Example:
./lms-load-and-serve.sh ../models/Qwen3.6-27B-UD-Q4_K_XL.gguf Qwen3.6-27B-UD-Q4_K_XL
LMS_DISABLE_THINKING=0 ./lms-load-and-serve.sh ../models/Qwen3.6-27B-UD-Q4_K_XL.gguf Qwen3.6-27B-UD-Q4_K_XL
Environment variables:
MODEL_USER=local Import destination user name
MODEL_REPO=<gguf-name> Import destination repo name
LMS_IMPORT_MODE=symlink symlink | hard-link | copy | move
LMS_BIND=0.0.0.0 Public bind address
LMS_PORT=8080 Public API port
LMS_DISABLE_THINKING=1 1 = reasoning off, 0 = reasoning on
LMS_BACKEND_PORT=8081 Internal LM Studio port when proxy is enabled
LMS_GPU=max lms load --gpu value
LMS_CONTEXT_LENGTH=200000 lms load --context-length value
LMS_PARALLEL=2 lms load --parallel value
Notes:
- Re-running the script reuses an already imported model key.
- The model key defaults to local/<gguf file name without extension>.
- If the same identifier is already loaded, it is unloaded before reloading.
- Default is reasoning off.
- To enable reasoning, run with LMS_DISABLE_THINKING=0.
- With LMS_DISABLE_THINKING=1, the public endpoint stays on LMS_PORT and a
local proxy injects an empty assistant <think> block into chat requests.
EOF
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
resolve_lmstudio_home() {
printf '%s\n' "${LMSTUDIO_HOME:-$HOME/.lmstudio}"
}
resolve_lms_bin() {
local lmstudio_home
lmstudio_home="$(resolve_lmstudio_home)"
if [[ -x "${lmstudio_home}/bin/lms" ]]; then
printf '%s\n' "${lmstudio_home}/bin/lms"
return
fi
if command -v lms >/dev/null 2>&1; then
command -v lms
return
fi
die "lms is not in PATH and ${lmstudio_home}/bin/lms does not exist"
}
resolve_models_folder() {
local lmstudio_home settings_json downloads_folder
lmstudio_home="$(resolve_lmstudio_home)"
settings_json="${lmstudio_home}/settings.json"
if [[ -f "$settings_json" ]]; then
downloads_folder="$(
sed -n 's/.*"downloadsFolder"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$settings_json" \
| head -n 1
)"
if [[ -n "$downloads_folder" ]]; then
printf '%s\n' "$downloads_folder"
return
fi
fi
printf '%s\n' "${lmstudio_home}/models"
}
python_bin() {
if command -v python3 >/dev/null 2>&1; then
printf '%s\n' python3
elif command -v python >/dev/null 2>&1; then
printf '%s\n' python
else
die "python3 or python is required"
fi
}
auto_name_repo() {
local file_name=$1
sed -E 's/(\.Q[^.]{1,5})?\.[^.]+$//' <<<"$file_name"
}
json_field() {
local json=$1
local field=$2
sed -n "s/.*\"${field}\":\\([^,}]*\\).*/\\1/p" <<<"$json" | tr -d '"'
}
wait_for_url() {
local url=$1
local label=$2
local attempt
for attempt in $(seq 1 40); do
if curl -fsS -o /dev/null "$url" >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
die "${label} did not become ready: ${url}"
}
stop_pid_file() {
local pid_file=$1
local label=$2
local pid
if [[ ! -f "$pid_file" ]]; then
return 0
fi
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1; then
printf 'Stopping %s (PID %s)\n' "$label" "$pid"
kill "$pid"
fi
rm -f "$pid_file"
}
write_no_think_proxy() {
local proxy_script=$1
mkdir -p "$(dirname "$proxy_script")"
cat >"$proxy_script" <<'PY'
#!/usr/bin/env python3
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
listen_host = sys.argv[1]
listen_port = int(sys.argv[2])
backend_port = int(sys.argv[3])
backend_base = f"http://127.0.0.1:{backend_port}"
def ensure_no_think(payload):
if not isinstance(payload, dict):
return payload
messages = payload.get("messages")
if isinstance(messages, list):
if len(messages) > 0 and isinstance(messages[-1], dict) and messages[-1].get("role") == "assistant":
return payload
messages.append({"role": "assistant", "content": "<think>\n\n</think>\n\n"})
return payload
class ProxyHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format, *args):
return
def do_GET(self):
self.forward()
def do_POST(self):
self.forward()
def do_DELETE(self):
self.forward()
def do_OPTIONS(self):
self.forward()
def do_PATCH(self):
self.forward()
def forward(self):
body = None
content_length = int(self.headers.get("Content-Length", "0") or "0")
if content_length > 0:
body = self.rfile.read(content_length)
headers = {key: value for key, value in self.headers.items() if key.lower() != "host"}
content_type = headers.get("Content-Type", "")
if (
self.command == "POST"
and body is not None
and "application/json" in content_type
and self.path == "/v1/chat/completions"
):
try:
payload = json.loads(body.decode("utf-8"))
payload = ensure_no_think(payload)
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Length"] = str(len(body))
except Exception:
pass
request = Request(f"{backend_base}{self.path}", data=body, headers=headers, method=self.command)
try:
with urlopen(request, timeout=300) as response:
response_body = response.read()
self.send_response(response.status)
for key, value in response.headers.items():
if key.lower() in {"transfer-encoding", "connection", "content-length"}:
continue
self.send_header(key, value)
self.send_header("Content-Length", str(len(response_body)))
self.end_headers()
self.wfile.write(response_body)
except HTTPError as error:
response_body = error.read()
self.send_response(error.code)
for key, value in error.headers.items():
if key.lower() in {"transfer-encoding", "connection", "content-length"}:
continue
self.send_header(key, value)
self.send_header("Content-Length", str(len(response_body)))
self.end_headers()
self.wfile.write(response_body)
except URLError as error:
response_body = str(error).encode("utf-8")
self.send_response(502)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(response_body)))
self.end_headers()
self.wfile.write(response_body)
if __name__ == "__main__":
server = ThreadingHTTPServer((listen_host, listen_port), ProxyHandler)
server.serve_forever()
PY
chmod +x "$proxy_script"
}
start_no_think_proxy() {
local bind_address=$1
local public_port=$2
local backend_port=$3
local lmstudio_home proxy_script proxy_pid_file proxy_log py
lmstudio_home="$(resolve_lmstudio_home)"
proxy_script="${lmstudio_home}/.internal/lms-no-think-proxy.py"
proxy_pid_file="${lmstudio_home}/.internal/lms-no-think-proxy.pid"
proxy_log="${lmstudio_home}/.internal/lms-no-think-proxy.log"
py="$(python_bin)"
write_no_think_proxy "$proxy_script"
stop_pid_file "$proxy_pid_file" "no-think proxy"
nohup "$py" "$proxy_script" "$bind_address" "$public_port" "$backend_port" \
>"$proxy_log" 2>&1 &
echo "$!" >"$proxy_pid_file"
wait_for_url "http://127.0.0.1:${public_port}/v1/models" "no-think proxy"
}
if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then
usage
exit 0
fi
if [[ $# -ne 2 ]]; then
usage >&2
exit 1
fi
gguf_input=$1
identifier=$2
[[ -f "$gguf_input" ]] || die "model file not found: $gguf_input"
lms_bin="$(resolve_lms_bin)"
gguf_path="$(cd "$(dirname "$gguf_input")" && pwd -P)/$(basename "$gguf_input")"
gguf_file_name="$(basename "$gguf_path")"
default_model_repo="$(auto_name_repo "$gguf_file_name")"
model_user="${MODEL_USER:-local}"
model_repo="${MODEL_REPO:-$default_model_repo}"
model_key="${model_user}/${model_repo}"
models_folder="$(resolve_models_folder)"
target_path="${models_folder}/${model_user}/${model_repo}/${gguf_file_name}"
exact_model_path="${model_key}/${gguf_file_name}"
bind_address="${LMS_BIND:-0.0.0.0}"
public_port="${LMS_PORT:-8080}"
disable_thinking="${LMS_DISABLE_THINKING:-1}"
gpu_ratio="${LMS_GPU:-max}"
context_length="${LMS_CONTEXT_LENGTH:-200000}"
parallel_count="${LMS_PARALLEL:-2}"
import_mode="${LMS_IMPORT_MODE:-symlink}"
if [[ "$disable_thinking" != "0" ]]; then
backend_port="${LMS_BACKEND_PORT:-8081}"
backend_bind="127.0.0.1"
else
backend_port="$public_port"
backend_bind="$bind_address"
fi
case "$import_mode" in
symlink)
import_flag="-l"
;;
hard-link)
import_flag="-L"
;;
copy)
import_flag="-c"
;;
move)
import_flag=""
;;
*)
die "unsupported LMS_IMPORT_MODE: $import_mode"
;;
esac
printf 'Using model key: %s\n' "$model_key"
printf 'Using identifier: %s\n' "$identifier"
downloaded_models_json="$("$lms_bin" ls --json)"
if [[ -e "$target_path" ]]; then
printf 'Model file already imported: %s\n' "$target_path"
elif grep -F "\"modelKey\":\"$model_key\"" <<<"$downloaded_models_json" >/dev/null; then
printf 'Model already imported: %s\n' "$model_key"
else
import_cmd=("$lms_bin" import -y --user-repo "$model_key")
if [[ -n "$import_flag" ]]; then
import_cmd+=("$import_flag")
fi
import_cmd+=("$gguf_path")
printf 'Importing model from: %s\n' "$gguf_path"
"${import_cmd[@]}"
fi
loaded_models_json="$("$lms_bin" ps --json)"
if grep -F "\"identifier\":\"$identifier\"" <<<"$loaded_models_json" >/dev/null; then
printf 'Unloading existing model instance: %s\n' "$identifier"
"$lms_bin" unload "$identifier"
fi
load_cmd=(
"$lms_bin" load --exact "$exact_model_path"
--identifier "$identifier"
--gpu "$gpu_ratio"
--context-length "$context_length"
--parallel "$parallel_count"
-y
)
printf 'Loading model with context=%s parallel=%s gpu=%s\n' \
"$context_length" "$parallel_count" "$gpu_ratio"
"${load_cmd[@]}"
server_status_json="$("$lms_bin" server status --json 2>/dev/null || true)"
server_running="$(json_field "$server_status_json" "running")"
current_port="$(json_field "$server_status_json" "port")"
if [[ "$server_running" == "true" ]]; then
if [[ "$current_port" == "$backend_port" ]]; then
printf 'LM Studio backend is already running on port %s\n' "$current_port"
else
printf 'Restarting LM Studio backend from port %s to %s\n' \
"${current_port:-unknown}" "$backend_port"
"$lms_bin" server stop
server_running="false"
fi
fi
if [[ "$server_running" != "true" ]]; then
start_cmd=("$lms_bin" server start --bind "$backend_bind" --port "$backend_port")
printf 'Starting LM Studio backend on %s:%s\n' "$backend_bind" "$backend_port"
"${start_cmd[@]}"
fi
wait_for_url "http://127.0.0.1:${backend_port}/v1/models" "LM Studio backend"
if [[ "$disable_thinking" != "0" ]]; then
start_no_think_proxy "$bind_address" "$public_port" "$backend_port"
printf 'Reasoning mode: OFF\n'
printf 'No-think proxy is serving on %s:%s -> 127.0.0.1:%s\n' \
"$bind_address" "$public_port" "$backend_port"
else
stop_pid_file "$(resolve_lmstudio_home)/.internal/lms-no-think-proxy.pid" "no-think proxy"
printf 'Reasoning mode: ON\n'
fi
cat <<EOF
Ready.
OpenAI-compatible endpoint: http://localhost:${public_port}/v1
Loaded model identifier: ${identifier}
Example request:
curl http://localhost:${public_port}/v1/chat/completions \\
-H "Content-Type: application/json" \\
-d '{
"model": "${identifier}",
"messages": [{"role":"user","content":"こんにちは"}],
"temperature": 0.7,
"top_p": 0.8,
"top_k": 20,
"presence_penalty": 1.5
}'
EOF
以上で、lmsの推論サーバーが立ち上がった。
- モデルのアンロード
モデルをアンロードするときは下記のコマンドを実行すれば良い。
$ lms unload --all
Codex CLIからlmsサーバーのローカルLLMを呼び出す
Codex CLIから上記のローカルLLM APIを呼び出す方法を示す。
- Codexのインストール
$ npm i -g @openai/codex@latest
- configファイルの修正
~/.codex/config.tomlに下記を追記する。
$ code ~/.codex/config.toml
[model_providers.lms]
name = "lms API"
base_url = "http://localhost:8080/v1"
wire_api = "responses"
stream_idle_timeout_ms = 10000000
wire_api = "responses"にすることで、CodexがResponses APIとしてLM Studioのエンドポイントを呼び出すようになる。
また、モデル名はlmsでロードしたときのidentifierと一致させる必要がある。今回の例ではQwen3.6-27B-UD-Q4_K_XLとしてロードしているため、Codex側でも同じ名前を指定する。
- 実行
$ codex --model Qwen3.6-27B-UD-Q4_K_XL -c model_provider=lms --search --dangerously-bypass-approvals-and-sandbox
以上で、ローカルLLMからCodexを駆動できるようになった。
使ってみた感想
実際に動かしてみると、Codexのハーネス自体はローカルLLMでも問題なく動くことが確認できた。
一方で、実用性はモデルの性能と速度にかなり依存する。特にCodexはツール呼び出しや差分確認を繰り返すため、推論が遅いモデルではかなり待ち時間が長くなる。
また、コーディングエージェントとして使う場合は、単純なチャット性能だけでなく、指示追従、ツール利用、長いコンテキストでの安定性が重要になる。動作確認ができたからといって、すぐに商用モデルと同じ感覚で使えるわけではない点には注意が必要である。
とはいえ、ローカルLLMでCodexを動かせるようになると、モデルごとのエージェント性能を同じハーネス上で比較しやすくなる。個人的にはここが一番大きなメリットだと感じている。
まとめ
本記事では、ローカルLLMでCodex CLIを駆動する方法として、
- llama.cppで試した結果
- LM Studio CLIでGGUFモデルをロードする方法
- LM StudioのOpenAI互換エンドポイントを立てる方法
- Codex側の
~/.codex/config.toml設定 - Codex CLIからローカルLLMを呼び出す方法
を備忘録的にまとめた。
現時点ではllama.cpp単体での利用はうまくいかなかったが、LM Studioを使うことでCodexからローカルLLMを呼び出せることが確認できた。
今後はCodexとローカルLLMを組み合わせたときのベンチマーク評価も実施したい。
最後まで読んでいただきありがとうございました。今後も面白い使い方や便利な活用法を見つけたら、Xや記事で発信していこうと思います。
Discussion