If you’re already paying for ChatGPT Plus, Pro, or Max, you can route Claude Code through your existing subscription. No API key, no separate billing. Your normal claude command stays untouched - you get a second command, claude-codex, that runs through your ChatGPT quota instead.
The trick is LiteLLM - an open-source proxy that translates between AI provider formats. You point Claude Code at a local LiteLLM proxy, and the proxy forwards everything to OpenAI’s ChatGPT backend using your subscription auth.
I’ve been using this setup daily. It works surprisingly well for most coding tasks, though there are real limitations worth knowing about upfront.
This guide is for macOS with conda already installed. If you don’t have it, install miniconda.
Step 1 - Install LiteLLM
conda create -n litellm python=3.12 -y
conda activate litellm
pip install "litellm[proxy]==1.83.0"
conda deactivate
Verify:
conda run -n litellm pip show litellm | grep Version
# Must show: Version: 1.83.0
Step 2 - Create the config
This tells LiteLLM how to map Claude Code’s model requests to ChatGPT models.
cat > ~/.litellm/chatgpt-config.yaml << 'EOF'
model_list:
- model_name: gpt-5.6-sol
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.6-sol
supports_system_message: false
- model_name: gpt-5.5
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.5
supports_system_message: false
- model_name: gpt-5.3-codex
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-codex
supports_system_message: false
- model_name: gpt-5.3-codex-spark
model_info:
mode: responses
litellm_params:
model: chatgpt/gpt-5.3-codex-spark
supports_system_message: false
litellm_settings:
master_key: os.environ/LITELLM_MASTER_KEY
drop_params: true
EOF
The model_name values are what Claude Code asks for. The model values under litellm_params are what actually gets called via your ChatGPT subscription. supports_system_message: false tells LiteLLM to convert system messages into user messages, since the ChatGPT subscription backend rejects system messages outright.
drop_params: true silently drops any Claude-specific parameters (like context_management) that the ChatGPT backend doesn’t understand.
| Claude Code asks for | LiteLLM routes to | Best for |
|---|---|---|
gpt-5.6-sol | chatgpt/gpt-5.6-sol | Deep reasoning, complex problems |
gpt-5.5 | chatgpt/gpt-5.5 | Default coding model |
gpt-5.3-codex | chatgpt/gpt-5.3-codex | Code generation |
gpt-5.3-codex-spark | chatgpt/gpt-5.3-codex-spark | Fast code tasks |
Step 3 - Patch a bug in LiteLLM 1.83.0
Claude Code sends message content in the Anthropic format - arrays of content blocks like [{"type": "text", "text": "..."}]. LiteLLM’s system message converter assumes plain strings and crashes when it tries to concatenate them.
Find the file to patch:
conda activate litellm
FACTORY=$(python -c "import litellm; print(litellm.__path__[0])")/litellm_core_utils/prompt_templates/factory.py
echo "$FACTORY"
conda deactivate
Back it up, then open it:
cp "$FACTORY" "$FACTORY.bak"
nano "$FACTORY"
Search for the map_system_message_pt function and replace it entirely with:
def map_system_message_pt(messages: list) -> list:
"""
Convert system messages to user messages.
Handles both string content and Anthropic-style list content blocks.
"""
def _to_str(content):
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
block.get("text", "") for block in content
if isinstance(block, dict) and block.get("type") == "text"
)
return str(content)
new_messages = []
for m in messages:
if m["role"] == "system":
sys_text = _to_str(m["content"])
if new_messages and new_messages[-1]["role"] == "user":
prev_text = _to_str(new_messages[-1]["content"])
new_messages[-1]["content"] = sys_text + " " + prev_text
else:
new_messages.append({"role": "user", "content": sys_text})
else:
new_messages.append(m)
return new_messages
Save and close. No reinstall needed - Python picks it up on next import.
Step 4 - Generate a local auth key
This key secures the connection between Claude Code and the proxy. It never leaves your machine.
echo "sk-local-$(openssl rand -hex 16)" > ~/.litellm/master-key.txt
chmod 600 ~/.litellm/master-key.txt
cat ~/.litellm/master-key.txt
Step 5 - Create the launcher scripts
Two scripts: one to start the proxy, one to launch Claude Code through it.
The proxy launcher
This version is designed to run silently in the background (either manually or under launchd). It does not print a startup banner to the terminal.
cat > ~/.litellm/start-proxy.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail
# Robustly find conda across common install locations.
FOUND=""
for p in "$HOME/miniforge3" "$HOME/miniconda3" "$HOME/anaconda3" "$HOME/.conda"; do
if [ -x "$p/bin/conda" ]; then
FOUND="$p"
break
fi
done
if [ -z "$FOUND" ]; then
echo "Error: conda not found in any standard location." >&2
exit 1
fi
eval "$("$FOUND/bin/conda" shell.bash hook)"
conda activate litellm
export LITELLM_MASTER_KEY="$(cat ~/.litellm/master-key.txt)"
exec litellm --config ~/.litellm/chatgpt-config.yaml --port 4000
SCRIPT
chmod +x ~/.litellm/start-proxy.sh
The claude-codex command
This script checks that the proxy is healthy, starts it silently in the background if not, and then launches Claude Code. If the proxy fails, it surfaces the last lines of the LiteLLM log so you can see the real error.
mkdir -p ~/bin
cat > ~/bin/claude-codex << 'SCRIPT'
#!/bin/bash
set -euo pipefail
#
# Launch Claude Code powered by ChatGPT subscription via LiteLLM.
# Your normal `claude` command is completely unaffected.
#
MASTER_KEY="$(cat ~/.litellm/master-key.txt 2>/dev/null)"
if [ -z "$MASTER_KEY" ]; then
echo "Error: No master key found at ~/.litellm/master-key.txt"
echo "Run the setup steps first."
exit 1
fi
LITELLM_DIR="$HOME/.litellm"
PROXY_LOG="$LITELLM_DIR/proxy.log"
PROXY_ERR="$LITELLM_DIR/proxy.err"
PROXY_URL="http://127.0.0.1:4000"
HEALTH_AUTH=( -H "Authorization: Bearer $MASTER_KEY" )
show_proxy_logs() {
local lines="${1:-50}"
echo ""
echo "--- LiteLLM recent stderr (last $lines lines) ---"
if [ -s "$PROXY_ERR" ]; then
tail -n "$lines" "$PROXY_ERR"
else
echo "(no stderr log)"
fi
echo ""
echo "--- LiteLLM recent stdout (last $lines lines) ---"
if [ -s "$PROXY_LOG" ]; then
tail -n "$lines" "$PROXY_LOG"
else
echo "(no stdout log)"
fi
}
proxy_healthy() {
curl -s "${HEALTH_AUTH[@]}" "$PROXY_URL/health" > /dev/null 2>&1
}
launchd_proxy_enabled() {
launchctl list 2>/dev/null | grep -q "^.*com\.litellm\.proxy$"
}
ensure_single_proxy() {
local pids
pids="$(pgrep -f "litellm --config $LITELLM_DIR/chatgpt-config.yaml" || true)"
if [ -n "$pids" ]; then
if proxy_healthy; then
return 0
fi
if launchd_proxy_enabled; then
echo "LiteLLM proxy (launchd) is not healthy; restarting via launchctl..."
launchctl stop com.litellm.proxy 2>/dev/null || true
launchctl start com.litellm.proxy 2>/dev/null || true
return 0
fi
echo "Stale LiteLLM proxy detected; restarting..."
echo "$pids" | xargs kill -9 2>/dev/null || true
sleep 1
fi
}
start_proxy() {
if launchd_proxy_enabled; then
return 0
fi
mkdir -p "$LITELLM_DIR"
[ -f "$PROXY_LOG" ] && mv "$PROXY_LOG" "$PROXY_LOG.prev"
[ -f "$PROXY_ERR" ] && mv "$PROXY_ERR" "$PROXY_ERR.prev"
nohup "$LITELLM_DIR/start-proxy.sh" >> "$PROXY_LOG" 2>> "$PROXY_ERR" &
disown
}
wait_for_proxy() {
for i in $(seq 1 30); do
sleep 1
if proxy_healthy; then
return 0
fi
done
return 1
}
# --- Main flow --------------------------------------------------------------
ensure_single_proxy
if ! proxy_healthy; then
start_proxy
if launchd_proxy_enabled; then
echo "Waiting for launchd-managed LiteLLM proxy..."
fi
if ! wait_for_proxy; then
echo "Error: LiteLLM proxy failed to start."
show_proxy_logs 50
exit 1
fi
fi
if ! curl -s "${HEALTH_AUTH[@]}" "$PROXY_URL/models" > /dev/null 2>&1; then
echo "Error: LiteLLM proxy is running but /models is unreachable."
show_proxy_logs 50
exit 1
fi
export ANTHROPIC_BASE_URL="$PROXY_URL"
export ANTHROPIC_AUTH_TOKEN="$MASTER_KEY"
export ANTHROPIC_MODEL="gpt-5.5"
export ANTHROPIC_DEFAULT_SONNET_MODEL="gpt-5.5"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="gpt-5.3-codex-spark"
export ANTHROPIC_DEFAULT_OPUS_MODEL="gpt-5.6-sol"
export ANTHROPIC_DEFAULT_FABLE_MODEL="gpt-5.6-sol"
exec claude "$@"
SCRIPT
chmod +x ~/bin/claude-codex
Add ~/bin to your PATH:
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
Step 6 - First run and OAuth login
Start the proxy manually for the first run so you can see the OAuth device code:
~/.litellm/start-proxy.sh > ~/.litellm/proxy.log 2> ~/.litellm/proxy.err &
Then launch Claude Code:
On the first request, check the proxy log for the device code:
tail -f ~/.litellm/proxy.err
You’ll see something like:
Sign in with ChatGPT using device code:
1) Visit https://auth0.openai.com/codex/device
2) Enter code: XXXX-XXXX
ChatGPT may prompt you to enable device authentication in your account settings before this works. Follow whatever instructions they show before proceeding.
Open the URL, log in with your OpenAI account, enter the code. Tokens are saved locally - you won’t need to repeat this unless they expire.
After the first successful auth, stop the manual proxy and use the launchd service from Step 7.
Step 7 - Auto-start LiteLLM on login (macOS)
Tired of keeping a terminal tab open for the proxy? On macOS, a launchd agent starts it on login, restarts it if it crashes, and logs output to ~/.litellm/proxy.log / .err. No second terminal needed.
Create the plist:
cat > ~/Library/LaunchAgents/com.litellm.proxy.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.litellm.proxy</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>exec ~/.litellm/start-proxy.sh >> ~/.litellm/proxy.log 2>> ~/.litellm/proxy.err</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
<key>Crashed</key>
<true/>
</dict>
<key>StandardOutPath</key>
<string>/dev/null</string>
<key>StandardErrorPath</key>
<string>/dev/null</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/prabal</string>
</dict>
</dict>
</plist>
EOF
chmod 644 ~/Library/LaunchAgents/com.litellm.proxy.plist
Load it:
launchctl load ~/Library/LaunchAgents/com.litellm.proxy.plist
That’s it. It starts now, and on every future login. Manage it with:
launchctl list | grep litellm # check if running
launchctl stop com.litellm.proxy # stop
launchctl start com.litellm.proxy # start
launchctl unload ~/Library/LaunchAgents/com.litellm.proxy.plist # remove permanently
tail -f ~/.litellm/proxy.log # check logs
tail -f ~/.litellm/proxy.err # check errors
Daily usage
claude-codex # ChatGPT-powered Claude Code (default gpt-5.5)
claude # normal Claude Code (unchanged)
Switch models on the fly:
claude-codex --model gpt-5.3-codex # code-heavy tasks
claude-codex --model gpt-5.6-sol # deep reasoning
File locations
| File | Purpose |
|---|---|
~/.litellm/chatgpt-config.yaml | Model routing config |
~/.litellm/master-key.txt | Local auth key |
~/.litellm/start-proxy.sh | Proxy launcher |
~/.litellm/proxy.log | Proxy stdout log |
~/.litellm/proxy.err | Proxy stderr log |
~/bin/claude-codex | The claude-codex command |
~/Library/LaunchAgents/com.litellm.proxy.plist | macOS auto-start service |
conda env: litellm | Python env with LiteLLM |
~/.claude/settings.json | Not modified |
Available ChatGPT subscription models
| Model string | Best for | Tier note |
|---|---|---|
chatgpt/gpt-5.6-sol | Hardest problems, deep reasoning | Pro/Max likely required |
chatgpt/gpt-5.5 | Latest GPT, strong all-rounder | Works on Plus |
chatgpt/gpt-5.3-codex | Code generation | Works on Plus |
chatgpt/gpt-5.3-codex-spark | Fast code tasks | Works on Plus |
These names change. If ChatGPT exposes a different model string in your dropdown, edit both the model_name and model fields in ~/.litellm/chatgpt-config.yaml and restart the proxy.
Troubleshooting
“LiteLLM proxy failed to start” or show_proxy_logs prints errors
Run:
tail -50 ~/.litellm/proxy.err
tail -50 ~/.litellm/proxy.log
That will show the actual LiteLLM error instead of terminal spam.
“No api key passed in” / 401 on /health
The health check needs the master key. The claude-codex script in Step 5 sends Authorization: Bearer $MASTER_KEY. If you see 401, you’re probably using an older script that doesn’t send auth. Copy the updated script from Step 5.
litellm.UnsupportedParamsError: chatgpt does not support parameters: ['context_management']
Add drop_params: true under litellm_settings in ~/.litellm/chatgpt-config.yaml. See Step 2.
litellm.BadRequestError: The 'gpt-5.6-sol-pro' model is not supported when using Codex with a ChatGPT account
Drop the -pro suffix. Use chatgpt/gpt-5.6-sol as the backend, not chatgpt/gpt-5.6-sol-pro.
ConnectionRefused retries in Claude Code
The proxy isn’t running or isn’t fully initialized. The updated claude-codex script will start it automatically and wait up to 30 seconds. If it still fails, check ~/.litellm/proxy.err.
If you use the launchd service, verify it:
launchctl list | grep litellm
launchctl stop com.litellm.proxy
launchctl start com.litellm.proxy
“Model not found”
Claude Code is requesting a model name the proxy doesn’t recognize. Check the exact names returned by:
curl -s -H "Authorization: Bearer $(cat ~/.litellm/master-key.txt)" \
http://127.0.0.1:4000/models | grep '"id"'
Then add any missing names to ~/.litellm/chatgpt-config.yaml and restart the proxy.
OAuth token expired
The proxy log will re-prompt with a new device code. Follow the browser auth flow again.
Rate limits
Your subscription tier determines limits. Plus ($20/mo) has lower ceilings; Pro and Max are better for heavy agentic use.
“System messages are not allowed”
You’re missing supports_system_message: false under litellm_params in your config. See Step 2.
“can only concatenate list (not str) to list”
You haven’t applied the patch from Step 3. Claude Code sends content as Anthropic-format arrays and LiteLLM’s merger assumes plain strings.
Verify LiteLLM is safe:
conda run -n litellm pip show litellm | grep Version # must show exactly 1.83.0
find ~ -name "litellm_init.pth" 2>/dev/null # should find nothing
ls ~/.config/sysmon/ 2>/dev/null # should not exist