English · Русский
Local WARP bridge. openrot keeps a Cloudflare WARP connection alive and serves a loopback HTTP bridge that forwards opencode traffic through it. One config file, one moving part: WARP.
Huge thanks to opencode for existing. This project is built around it: openrot serves a loopback bridge so that opencode (or any OpenAI-compatible client) keeps working on the free
opencode/*providers, with automatic 429 self-rotation.
opencode ──HTTP──▶ openrot bridge (127.0.0.1:7891)
│
▼
WARP SOCKS5 (127.0.0.1:40000)
│
▼
bridge_upstream (https://opencode.ai/inference)
The free opencode providers rate-limit by session and IP. openrot keeps you on
a WARP connection and puts a local bridge in front of opencode that rotates the
WARP IP the moment the upstream answers 429 — long sessions keep going
without manual intervention. Nothing is hosted server-side; everything runs on
your machine.
Standalone binary (macOS / Linux) — downloads the prebuilt release, installs
the warp-cli prerequisite and brings WARP up:
curl -fsSL https://raw.githubusercontent.com/vispar-tech/openrot/main/install.sh | bashDocker (WARP still runs on the host) — installs prerequisites and runs the image from GHCR:
curl -fsSL https://raw.githubusercontent.com/vispar-tech/openrot/main/install-docker.sh | bashThe first method installs the standalone binary to
~/.local/lib/openrot(symlink in~/.local/bin/openrot). If your distro already has warp-cli, you can skip starting it:OPENROT_SKIP_WARP=1 ... | bash.
- warp-cli (Cloudflare WARP client) — required at runtime; it runs on the
host, not inside the container:
- macOS:
brew install --cask cloudflare-warp - Linux: Cloudflare's package repo
openrot warp installprints the same instructions
- macOS:
- Python 3.14+ and Poetry — only when running from source
poetry install
poetry run openrot --help# 1. connect WARP (once; warp-cli must be installed)
poetry run openrot warp on
# 2. start the local bridge (127.0.0.1:7891); Ctrl-C stops it
poetry run openrot start bridge
# ...or run it as a background daemon
poetry run openrot start bridge --daemon
# 3. status / rotate / stop
poetry run openrot status
poetry run openrot rotate
poetry run openrot stop| Command | Description |
|---|---|
openrot start bridge [--daemon] |
Start the opencode bridge (foreground, or background with --daemon) |
openrot stop [-y|--yes] |
Disconnect WARP and stop the bridge daemon |
openrot status [--json] [-v|--verbose] |
Show WARP and bridge status (--verbose = full diagnostics table) |
openrot rotate |
Rotate the WARP IP (reconnects and verifies the address actually changed) |
openrot warp on|off|install |
Connect / disconnect WARP; install prints warp-cli install instructions |
openrot warp status [--json] |
Show WARP status and egress IP |
openrot logs [-n N] [--follow/--no-follow] |
Tail the openrot log file (default: follow) |
openrot run -- <cmd> |
Run a command with proxy env vars pointing at WARP (starts WARP if needed) |
openrot config |
Open the config file in $EDITOR (default vim) |
openrot provider [--action on|off|auto] [--config PATH] |
Toggle the opencode provider in opencode.jsonc |
openrot self-update [-y|--yes] |
Update the openrot binary to the latest release |
openrot --version |
Print the version and exit |
openrot warp off disconnects WARP now, and openrot status reports
disconnected — but while the bridge is running, its health loop reconnects on
the next check: within health_interval (default 30s). With no bridge running,
nothing reconnects it. openrot stop disconnects WARP and stops the bridge
daemon, so the stack stays down.
opencode talks to openrot through the loopback bridge — no launcher, no config magic. Bring the bridge up, then point opencode at it by merging one key into its config:
openrot start bridge # WARP + bridge, foreground; Ctrl-C to stop
openrot start bridge --daemon # or run it as a persistent background daemonMerge this into ~/.config/opencode/opencode.json. The override is scoped to
opencode's built-in opencode provider — the one serving the default free
opencode/* models — so those models route through the bridge with 429
self-rotation, and nothing else changes:
{ "provider": { "opencode": { "options": { "baseURL": "http://127.0.0.1:7891/v1" } } } }bridge_port defaults to 7891. opencode only ever sees plain loopback HTTP —
no TLS, no proxy env vars, nothing to restore; the bridge itself opens the
upstream leg over TLS through WARP. A background bridge daemon is stopped with
openrot stop; its logs go to ~/.config/openrot/openrot.log (see
openrot logs).
openrot status # bridge: running (http://127.0.0.1:7891/v1)
curl -s http://127.0.0.1:7891/v1/modelsA proxy tunnel routes opencode's traffic opaquely, so it cannot see the
provider returning an HTTP 429 (the request is already inside a TLS CONNECT
tunnel). The local bridge is a loopback OpenAI-compatible reverse proxy
(baseURL) that forwards each request through the WARP SOCKS5 connection to
bridge_upstream and watches the upstream status. On a 429 whose body is a
FreeUsageLimitError it rotates the WARP IP (openrot rotate) and retries the
request; other configured retry statuses wait out Retry-After first. Retries
are bounded by bridge_retry_attempts.
The upstream base and port are configurable — see bridge_port (default
7891), bridge_upstream (default https://opencode.ai/inference; the incoming
request path, e.g. /v1/chat/completions, is appended to it), or the env
overrides OPENROT_BRIDGE_PORT and OPENROT_UPSTREAM.
Set bridge_direct: true (or OPENROT_BRIDGE_DIRECT=1) to run the bridge as a
plain pass-through: WARP is not started, requests go upstream from the host's
own address, and every request/response is still logged. Useful when you only
want to observe opencode's traffic — IP rotation and 429 self-rotation are
unavailable in this mode (a usage-limit 429 is treated like any other rate
limit: wait out Retry-After and retry).
The bridge normalizes every upstream request to the shape the free gateway accepts (the same rules as the official opencode client):
- the four session headers (
X-Opencode-Session,X-Opencode-Session-Id,X-Session-Affinity,X-Session-Id) are set to one canonicalses_id — a client-provided canonical session is kept for prompt-cache affinity, any other value is deterministically re-encoded into a canonical one; X-Opencode-Project(40 hex) andX-Opencode-Client: cliare added when missing;- the
User-Agentis set to an opencode one (a non-opencode one is replaced); - the legacy
X-Opencode-Requestheader is stripped, never injected; - missing
Authorizationfalls back toBearer public; - in chat bodies: streaming and
stream_options.include_usageare forced on, recognised tool names are replaced with their genuine definitions, and the core tools (shell,edit,glob,grep,read) are added if missing; unknown client tools are passed through untouched; - opencode's title-generation system prompt — sent at the start of every new
chat — is swapped for a reworded equivalent (
core/data/title_system.txt). The gateway fingerprints the original by itstask/rules/examplesmarkers together (no single line trips it) and answers403 FreeTierError, so without the swap the first request of every new chat fails while the real one immediately after it succeeds. The replacement is idempotent and leaves unrelated system prompts untouched.
# via the bridge: headers are normalized automatically
curl -s http://127.0.0.1:7891/v1/chat/completions \
-H 'Content-Type: application/json' -d '{"model":"big-pickle","messages":[{"role":"user","content":"hi"}]}'
# hitting the upstream directly
curl -s https://opencode.ai/inference/v1/chat/completions \
-H 'X-Opencode-Session: ses_<canonical>' \
-H 'Content-Type: application/json' -d '{"model":"big-pickle","messages":[{"role":"user","content":"hi"}]}'Stored in ~/.config/openrot/config.yaml.
Overridden by OPENROT_DIR (base dir) and OPENROT_CONFIG (full config path).
health_interval: 30 # seconds between WARP health checks (health loop)
active_level: none # none | warp (written by start/stop)
bridge_port: 7891 # loopback port for the opencode bridge
bridge_upstream: "https://opencode.ai/inference" # request path is appended to this base
bridge_retry_statuses: [429] # upstream statuses that trigger a retry
bridge_retry_attempts: 1 # max retries per request
bridge_max_concurrent: 3 # max parallel upstream requests
bridge_min_interval: 0.5 # min seconds between upstream request starts
bridge_direct: false # true = pass-through without WARP (logging only)| Variable | Effect |
|---|---|
OPENROT_DIR |
base directory (default ~/.config/openrot) |
OPENROT_CONFIG |
full path of the config file |
OPENROT_LISTEN |
bridge bind address (default 127.0.0.1; use e.g. 0.0.0.0 in Docker) |
OPENROT_BRIDGE_PORT |
→ bridge_port |
OPENROT_UPSTREAM |
→ bridge_upstream |
OPENROT_BRIDGE_RETRY_STATUSES |
→ bridge_retry_statuses (comma-separated ints) |
OPENROT_BRIDGE_RETRY_ATTEMPTS |
→ bridge_retry_attempts |
OPENROT_BRIDGE_MAX_CONCURRENT |
→ bridge_max_concurrent |
OPENROT_BRIDGE_MIN_INTERVAL |
→ bridge_min_interval |
OPENROT_BRIDGE_DIRECT |
→ bridge_direct (boolean) |
OPENROT_WARP |
warp-cli binary name/path (default warp-cli) |
OPENROT_WARP_HOST / OPENROT_WARP_PORT |
WARP SOCKS5 address (default 127.0.0.1:40000) |
OPENROT_WARP_ROTATE_DELAY / OPENROT_WARP_ROTATE_ATTEMPTS |
rotate pacing: seconds between attempts / max attempts (defaults 10 / 5) |
OPENROT_DEBUG_REQ |
verbose per-request bridge logging (secrets in auth headers are masked) plus raw incoming/outgoing body dumps to debug/ |
Runtime paths, all under the base directory: config.yaml,
openrot.log, openrot-bridge.daemon.pid, debug/ (raw body dumps, written
only when OPENROT_DEBUG_REQ=1).
Both installers auto-install warp-cli and bring WARP up on the host in proxy
mode.
Standalone binary (PyInstaller build, no Python needed):
./install.sh # from a checkout
make installer # equivalentEnvironment overrides: OPENROT_BIN_URL, OPENROT_VERSION, OPENROT_PREFIX,
OPENROT_SKIP_WARP. On macOS you'll be prompted to grant WARP a system
extension / VPN permission the first time.
Docker (not auto-installed; the script prints instructions):
./install-docker.sh
make install-docker # equivalentEnvironment overrides: OPENROT_IMAGE, OPENROT_BRIDGE_PORT,
OPENROT_CONFIG_DIR, OPENROT_WARP_HOST, NETWORK.
WARP runs on the host, not in the container. warp-cli needs a TUN device
and the OS network stack, so it can't tunnel inside Docker. The container
reaches the host-side WARP through its SOCKS5 proxy; the image runs the bridge
(bridge-only — that is all openrot runs).
Before make docker-run, start WARP once on the host in proxy mode:
poetry run openrot warp onmake docker-run # builds image and runs openrot, wired to the host WARP
make docker-sh # interactive shell in the image (same config mount)By default the container uses bridge networking and points at the host's WARP via
OPENROT_WARP_HOST=host.docker.internal (host port 40000):
- macOS (Docker Desktop): works out of the box.
- Linux: the Makefile adds
--add-host host.docker.internal:host-gateway. - On Linux you can instead run
NETWORK=host make docker-runto share the host loopback, so WARP answers at127.0.0.1:40000directly.
docker build -t openrot .
docker run --rm -it \
-p 7891:7891 \
--add-host host.docker.internal:host-gateway \
-e OPENROT_WARP_HOST=host.docker.internal \
-v $HOME/.config/openrot:/root/.config/openrot \
openrot- Config and data are shared read-write via the
-v $HOME/.config/openrot:...mount, so your config is the same on the host and in the container.OPENROT_DIRis already set to/root/.config/openrotinside the image (seeDockerfile). - Port: the bridge listens on
7891; map a different host port with-p $PORT_BRIDGE:7891. - Listen address: the bridge binds
127.0.0.1by default; the image setsOPENROT_LISTEN=0.0.0.0so the published port is reachable from the host (override with-e OPENROT_LISTEN=127.0.0.1to lock down). - WARP address:
OPENROT_WARP_HOST(default127.0.0.1) andOPENROT_WARP_PORT(default40000) point openrot at the host-side WARP SOCKS5 proxy. In the container setOPENROT_WARP_HOSTto the host.
If the host WARP is not reachable, the bridge still starts — the health loop
keeps retrying the connect every health_interval — and openrot status
reports connectivity: FAIL until WARP is up.
- Tests:
poetry run pytest(ormake check— ruff + mypy + coverage). - Coverage standard: keep the local report above 85 %; the CI gate is set lower (75 %) as a safety floor.
status shows connectivity: FAIL (WARP not forwarding traffic)
Check warp-cli: openrot warp status. If warp-cli is missing, run
openrot warp install and then openrot warp on.
I ran openrot warp off, but it reconnects by itself
Expected while the bridge runs: the health loop reconnects within
health_interval (default 30s). Use openrot stop to shut the whole stack
down, or stop the bridge first.
bridge port 127.0.0.1:7891 is already in use; not starting
Another openrot (or a stale daemon) holds the port. Run openrot stop, or
change bridge_port / OPENROT_BRIDGE_PORT.
The bridge is up but upstream requests return 502
The upstream leg could not be fetched — usually WARP is down or unreachable.
openrot status --verbose prints the diagnostics table; openrot warp on
brings WARP back. Set OPENROT_DEBUG_REQ=1 for per-request logs and follow
them with openrot logs.
In Docker the container cannot reach WARP
The container must point at the host: OPENROT_WARP_HOST=host.docker.internal
with bridge networking (the Makefile does this), or run with
NETWORK=host make docker-run on Linux so host-loopback WARP at
127.0.0.1:40000 is visible directly.