🎡

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で動かすのは諦めることにする。

https://unsloth.ai/docs/jp/ji-ben/codex

https://zenn.dev/edna_startup/scraps/e5f7e294b2ede3

LM Studio

XでLM Studioを使えば、ナチュラルにResponses形式のAPIに対応している、というコメントをいただいたので試してみる。

https://x.com/kis/status/2053300771903750173

まず前提条件として、私は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等は適宜置き換えて使用すること。

シェルスクリプトの大まかな流れとしては下記の通り。

  1. GGUF を LM Studio に登録(元ファイルは残したいなら -l で symlink)

  2. ロード

  3. 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
  }'
  • シェルスクリプト
詳細
lms-load-and-serve.sh
#!/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
~/.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を駆動できるようになった。

https://x.com/gosrum/status/2053327293825839550

使ってみた感想

実際に動かしてみると、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