CLIProxyAPI takes the OAuth logins of your Claude Code and Codex accounts and serves them back as an Anthropic- and OpenAI-compatible API. I run it on one Linux machine, bound to localhost, and reach it from my other devices over Tailscale. This is that setup on CLIProxyAPI v8, which moved enough config keys around that the older version of this guide stopped working.

Install CLIProxyAPI

Grab the linux_amd64 archive from the GitHub releases. I was on v8.0.62 when writing this. Extract it into a folder named after the version and link the binary into ~/.local/bin, so upgrading later means extracting a new folder and moving one symlink:

VERSION=8.0.62
mkdir -p ~/cliproxyapi/$VERSION
curl -fsSL "https://github.com/router-for-me/CLIProxyAPI/releases/download/v$VERSION/CLIProxyAPI_${VERSION}_linux_amd64.tar.gz" \
  | tar -xz -C ~/cliproxyapi/$VERSION
ln -sf ~/cliproxyapi/$VERSION/cli-proxy-api ~/.local/bin/cli-proxy-api
cli-proxy-api --help | head -1

The last line prints the version. Old versions stay in their folders, so rolling back is the same ln -sf pointed at the previous one.

Configure it

The config lives at ~/cliproxyapi/config.yaml, outside the versioned folders so it survives upgrades. v8 nests what used to be flat keys, and one change will bite anyone copying an old config. A top-level api-keys now means upstream provider keys. The keys your clients send go under access.api-keys instead. Put them at the top level and the proxy reads them as provider credentials, and your clients’ keys stop working.

config-version: 8
server:
  host: "127.0.0.1"
  port: 8317
management:
  allow-remote: false
  secret-key: "<management-key>"
access:
  api-keys:
    - "<client-api-key>"
routing:
  session-affinity: true
  session-affinity-rebind-on: "quota"
  cooldown:
    transient-error-cooldown-seconds: 5
oauth:
  auth-dir: "~/.cli-proxy-api"

secret-key protects the management page at /management.html. Write it in plaintext and the proxy hashes it in place on startup. transient-error-cooldown-seconds sits under routing.cooldown in v8.0.62, not at the top level where older configs had it. The two session-affinity lines matter once you have more than one account per provider, and they get their own section at the end.

Binding to 127.0.0.1 keeps the proxy off the LAN. Tailscale forwards to that listener later, which is also why allow-remote can stay false.

No model aliases are needed anymore. GET /v1/models lists the current Claude models directly, claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5 and claude-fable-5-2, and clients can send those names as they are.

The proxy watches config.yaml and the auth directory, so most edits apply without a restart. Changing server.host or server.port is the exception. The listener is already bound, so those need systemctl --user restart cliproxyapi.

Log in to Claude Code and Codex

cli-proxy-api --config ~/cliproxyapi/config.yaml --claude-login
cli-proxy-api --config ~/cliproxyapi/config.yaml --codex-login

Always pass --config. Without it the login falls back to the working directory to find the config, and the old version of this guide carried a warning to cd first for exactly that reason. Run each command once per account. Two Claude accounts means running --claude-login twice and signing in as a different account each time.

The OAuth callback goes to localhost on the machine running the proxy, so finish the browser step there. On a headless box, --no-browser prints the URL instead of opening it, and you forward the callback port over SSH. The service doesn’t need a restart afterwards. It notices the new file in the auth dir and starts routing to that account.

Run it as a user service

Save this as ~/.config/systemd/user/cliproxyapi.service:

[Unit]
Description=CLIProxyAPI
After=network-online.target
 
[Service]
ExecStart=%h/.local/bin/cli-proxy-api --config %h/cliproxyapi/config.yaml
Restart=always
RestartSec=10
 
[Install]
WantedBy=default.target

ExecStart points at the symlink, so an upgrade is a new symlink plus a restart. Then:

systemctl --user daemon-reload
systemctl --user enable --now cliproxyapi
loginctl enable-linger "$USER"

Don’t skip the linger line. Without it a user service stops when your last session ends and doesn’t start at boot, so the proxy dies the moment you log out of the SSH session you set it up from.

Reach it from other machines

The proxy only listens on 127.0.0.1. Tailscale Serve’s TCP forwarder puts that port on the tailnet:

tailscale serve --bg --tcp=8317 tcp://127.0.0.1:8317
tailscale serve status

It runs in the background and persists across reboots. tailscale serve --tcp=8317 off removes it.

Clients use the machine’s Tailscale IP, http://100.x.y.z:8317, which tailscale ip -4 prints. I used the MagicDNS name at first, but it has been unreliable, and the IP always works. The traffic is plain HTTP inside the WireGuard tunnel, so it’s encrypted even though the browser calls the page “Not secure”. An HTTPS Serve listener would need the MagicDNS name for its certificate, which brings back the problem.

Serve connects to the proxy from localhost, so the proxy treats remote requests as local. That’s why management.allow-remote: false still lets you open http://100.x.y.z:8317/management.html from another device and log in with the management key. The root URL returns API info, not the management page.

To check it from a client:

curl http://100.x.y.z:8317/v1/models
curl -H "Authorization: Bearer <client-api-key>" http://100.x.y.z:8317/v1/models

The first should return 401 and the second the model list. A 401 there is the good result. It means the request reached the proxy and only the key was missing.

Point the clients at it

Swap 100.x.y.z for 127.0.0.1 when the client runs on the same machine as the proxy.

Claude Code

export ANTHROPIC_BASE_URL="http://100.x.y.z:8317"
export ANTHROPIC_AUTH_TOKEN="<client-api-key>"

The base URL has no /v1. Claude Code adds the path itself.

Codex

Add a model provider to ~/.codex/config.toml. This base URL does end in /v1:

model_provider = "cliproxyapi"
 
# Codex App voice chat requires these. Omit them if voice chat is not needed.
experimental_realtime_webrtc_call_base_url = "http://100.x.y.z:8317/v1"
experimental_realtime_ws_base_url = "http://100.x.y.z:8317/v1"
 
[model_providers.cliproxyapi]
base_url = "http://100.x.y.z:8317/v1"
env_key = "CLIPROXY_API_KEY"
name = "OpenAI"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = true

env_key makes Codex read the key from the environment, so it never lands in the file. Export it from an untracked secrets file:

export CLIPROXY_API_KEY="<client-api-key>"

Restart Codex and run codex doctor. It should report config.toml parse: ok, the model with · cliproxyapi, and provider auth env var: CLIPROXY_API_KEY (present).

Why session affinity matters

With one account per provider none of this matters. Every request goes to the same place. Add a second Claude account and the default round-robin starts spreading requests across both, which is where it goes wrong.

The provider’s prompt cache is per account. A long coding session resends the same huge prefix on every turn, the system prompt and tools and conversation so far, and the cache is what makes that cheap and fast. Under round-robin the next turn can land on the other account, where the prefix was never cached, so you pay full price and wait longer on turn after turn. It also drains the usage windows unevenly, since a heavy session’s turns get spread across both accounts.

session-affinity: true binds a session to one account and keeps it there, so its cache keeps hitting. Subagents inherit the parent’s account by default, so a burst of subagents doesn’t scatter across accounts either.

The bind has to break sometimes, and session-affinity-rebind-on decides when. With "quota", a transient error like a 408, a 5xx or an empty stream fails over for that one request, and the session stays bound to its account. A blip shouldn’t cost you the cache. A quota or auth error, a 429, a 401/402/403 or an invalid_grant, means the account can’t serve the session anymore, so the session moves to another one. The default is "any", which moves the session on every error. A single 503 throws away a warm cache for no gain, so I’d always set "quota".

Troubleshooting

If requests return 401 with the right key, look at where the key sits in the config. It has to be under access.api-keys, not a top-level api-keys.

If a login seemed to work but the account never shows up, the auth file probably went somewhere else. Rerun the login with --config and check that the file lands in ~/.cli-proxy-api.

If the proxy disappears after you log out, linger isn’t enabled. loginctl show-user "$USER" -p Linger should say Linger=yes.

If one browser shows a 404 for the management page while curl and other browsers work, it’s that browser. Try a private window, or check its proxy settings and extensions.

Environment variables only reach clients that inherit them. Terminal-launched Codex and Claude Code pick up shell exports in a new shell, but GUI-launched apps never read shell startup files, so set the variables in the app’s own settings instead. Either way, start a fresh session after changing variables. Already-running sessions keep the old environment, which surfaces as Missing environment variable or stale-URL errors even though the settings look right.