A self-hosted, zero-cost OpenAI-compatible gateway for the OpenCode free tier, fronted by rotating egress addresses. No API key, no spend.
Both halves of that recording are real, not a mockup. The top command goes
straight to opencode.ai/zen/v1 and gets the refusal the free tier hands out by
default. The bottom one goes through this gateway and gets an answer, with no
upstream API key configured anywhere in it. What the gateway removes is the
refusal. What the rotating pool removes is the per-address quota ceiling, which
is a separate problem and a separate cause.
Linux only. The rotating pool needs privileged containers and
/dev/net/tun, which Docker Desktop on macOS and Windows does not provide. There is no macOS or Windows path in this repository today. What that rules out, and what it does not.
git clone https://github.com/ebouther/opencode-unlimited-proxy.git
cd opencode-unlimited-proxy/stack
# Cloudflare's terms have to be accepted explicitly before the WARP nodes start.
# Read them first: https://www.cloudflare.com/application/terms/
cp .env.example .env && sed -i 's/WARP_ACCEPT_TOS=0/WARP_ACCEPT_TOS=1' .env
docker compose up -d --buildLinux, Docker Compose v2, privileged containers, roughly 150 MB per node. The
first up builds the WARP and tinyproxy images, so give it a few minutes.
| What | Where |
|---|---|
| OpenAI-compatible API | http://127.0.0.1:8080/v1 |
| Web dashboard | http://127.0.0.1:8081 (admin / changeme-changeme — change it) |
| Proxy pool | 127.0.0.1:18090 … 18090 + nodes - 1 |
Then point a client at it. One entry each, written out in full below:
| Client | What to enter |
|---|---|
| OpenCode CLI | a provider on http://127.0.0.1:8080/v1, key local-test-key |
| 9router | OpenAI-compatible node, base http://127.0.0.1:8080/v1, key local-test-key, prefix oc2, API type chat |
| OmniRoute | OpenAI-compatible node on http://127.0.0.1:8080/v1, then <prefix>/<model> from a combo |
Two ways to narrow that down before committing to any of it: the 60-second probe tests the upstream claim with no Docker at all, and if you already have proxies, two services are enough — no WARP, no Cloudflare terms.
Does
- Serves OpenCode's anonymous free-tier models through an OpenAI-compatible API, with no key and no cost — twelve at the time of writing, listed in OpenCode CLI below.
- Reproduces the complete client fingerprint the free tier gates on — including
the two most people miss: a tool named exactly
read, and forcedstream: true. Seedocs/free-tier-gates.md. - Multiplies your usable budget across N egress addresses, and fails over automatically when one address goes unhealthy or runs dry.
Does not
- Remove the upstream quota. It appears to be metered per egress address, so
the ceiling is roughly
N × per-address quota, not infinity — andNis the number of distinct addresses you can actually obtain, not the number of containers you started. With WARP that is roughly the size of the regional address pool, about seven from one French colo. Beyond that, only egress you control will do it. That per-address model is inferred from observed behaviour rather than measured at upstream — see the quota axis. - Guarantee that any address is accepted. WARP is a shared consumer network: addresses repeat, are geolocated unexpectedly, and can be refused outright.
- Carry a warranty against upstream changes. The free-tier fingerprint is not a published contract and has already changed twice (≈2026-10-11, ≈2026-10-13).
- Carry any affiliation with OpenCode, Cloudflare, or the upstream projects.
Rotating egress to reach more of a free tier than a single address allows may breach OpenCode's terms of service. That call is yours, on your own infrastructure, at your own cost.
Three WARP containers, three CONNECT proxies, one OpenAI-compatible gateway.
The load-bearing detail is not the number of containers, it is the number of
distinct egress addresses. Usage is metered per source address — three nodes
behind one address are one budget, not three. (That metering model is inferred
from this stack's own behaviour rather than measured at upstream; see
Not a gate: the quota axis
for what was actually observed and how to falsify it.) WARP hands out addresses
from a small per-region pool and assigns them semi-randomly, so nodes collide by
default. Measured here on a French colo: 104.28.194.129-.132,
104.28.226.128-.130 and 185.137.228.16 — about seven addresses for six
nodes, which is barely enough to be worth de-duplicating at all.
That is what ofu-dedupe is for. Once per pass it measures each node's real
egress address through that node's own proxy, asks one colliding node to
re-register, and repeats until the addresses are distinct. A node that cannot
be given its own address is drained: its CONNECT proxy stops listening, the
engine's health check drops it, and the pool stops pretending to be bigger
than it is.
Two constraints shape it, both learned the hard way:
- Changing address costs a WARP registration. A plain
disconnect/connectdoes not move the node. Cloudflare rate-limits the registration API with HTTP 429, so rotations are metered globally — one at a time, at least 90 seconds apart, at most two per pass. A pool that churns registrations ends up crash-looping, which is worse than a collision. - The rotator's own address report cannot be trusted.
rotator.pycaches the address it saw at startup and only refreshes it when its rotation succeeds. If a rotation half-succeeds, the cache goes stale and the cluster believes two nodes are distinct when they are not. Soofu-dedupemeasures the egress itself and never asks.
The result: three nodes, three distinct addresses, and the pool then sits still — no rotation, no rate-limit pressure, nothing to babysit.
Using your own proxies? Put your endpoints in the
proxiesarray ofconfig.json, thendocker compose up -d config-init opencode-engineto skip the WARP,tinyproxyandofu-dedupeservices entirely. That is also the only way past WARP's address-space ceiling: more buckets than WARP can supply means egress you control.
| Piece | Role |
|---|---|
ghcr.io/jasonxu114514/opencode2api (GPL-3.0) |
The engine. Mints the client identity/session headers that the free tier requires, serves /v1/*, and round-robins over a proxy pool. |
Cloudflare WARP namespace per node (stack/warp/) |
Gives each node its own egress address, re-established on a rotation interval. |
tinyproxy per node (stack/tinyproxy/) |
A CONNECT proxy published on the host, sharing its WARP namespace. |
stack/config.json |
Pool definition, anonymous: true, free_only: true, empty key lists. |
opencode CLI ─┐
9router ──────┼─► opencode2api :8080 ──► http://127.0.0.1:18090 ┐ CONNECT ┐
OmniRoute ────┘ (identity + retry) http://127.0.0.1:18091 ─┤ proxy ├─► opencode.ai/inference
… one per WARP node │ (tiny) │ (free tier, Bearer public)
└ WARP ───┘
/v1/models→ the live free catalogue (12 models on 2026-10-16). The count is upstream's to change; query the endpoint rather than trusting any number written here.What is the answer to life, the universe, and everything?→42via CLI (opencode run), via 9router (oc2/*), and via OmniRoute (9r/snowbuddy-free).- 5/5 consecutive single-turn completions → HTTP 200.
- Sustained agent session: ~57
/v1/chat/completionin an hour, 47 ×200, 11 ×499(client-side cancellations), 0 upstream429.
Not measured: multi-day runs, thousands of requests, behaviour under sustained saturation. Treat the throughput numbers above as a smoke test, not a capacity guarantee.
The cheapest way to find out whether this repository is telling the truth is to ask upstream directly. No Docker, no WARP, no terms to accept, nothing left running afterwards:
git clone https://github.com/ebouther/opencode-unlimited-proxy.git
cd opencode-unlimited-proxy
./scripts/gate-probe.pyThat is the load-bearing half of the whole bundle — the conditions the free tier refuses requests without, each probed in isolation. The run is paired with controls: shapes that must be refused, and one that must be accepted. It exits non-zero rather than reporting anything if the baseline itself is refused.
That is not ceremony. A run where every probe comes back 403 proves nothing, and a probe that cannot tell you that is worse than no probe.
If the baseline comes back 429 where it expects 200, that is your address's
quota, not a gate and not a broken install. Wait, or send the probe through one
of the nodes once the pool is up:
./scripts/gate-probe.py --proxy http://127.0.0.1:18090If that passes and you want the rotating pool behind it, carry on.
- Linux (native). The WARP nodes create a TUN device and route through it, so
each one needs
/dev/net/tunplusprivilegedandNET_ADMIN. Docker Desktop on macOS and Windows runs its containers in a Linux VM that does not hand those out the way a Linux host does, and WARP will not come up without them. - Docker Engine + Docker Compose v2 (
docker compose, notdocker-compose). - Privileged containers are required: WARP creates a TUN device and routes
through it, so each node runs
privileged+NET_ADMIN. - ~150 MB RAM per node and outbound TCP 443/7844 to Cloudflare.
There is no supported path today. This section says exactly what stops it, rather than suggesting something that will fail on you.
Two pieces of the default stack are Linux-only:
- The WARP nodes. Each one creates a TUN device and routes through it, which
needs
/dev/net/tunand privileged containers. - The engine's route to the pool. It runs with
network_mode: hostso thathttp://127.0.0.1:18090resolves to a published proxy port. On Docker Desktop that host namespace belongs to the VM, so anything listening on your127.0.0.1is not on the engine's127.0.0.1.
The engine image itself is not the problem: it is published for both amd64
and arm64, so it runs natively on Apple Silicon. What does not work is how this
stack reaches the internet, not the gateway in the middle.
If you want to close the gap, the piece to rewrite is the network path, not
the engine. Putting the engine on an ordinary bridge network, addressing the pool
by service name instead of 127.0.0.1, and listening on 0.0.0.0 inside the
container while publishing to 127.0.0.1 on the host: that configuration was
built and measured end to end on Linux, answering correctly through the pool with
the loopback-only binding preserved. It has never been run on Docker Desktop, and
that is the honest gap.
Until someone runs it there, your realistic options are a Linux host, or a remote
proxy reachable by hostname — proxies accepts any URL, so
http://user:pass@your-proxy:3128 is a valid entry. That path is untested here,
so treat it as a lead rather than a recipe.
The four commands in get it running bring up the default six-node pool. This section is everything that varies from that.
A different pool size, before the first start:
python3 generate-compose.py --nodes 6docker-compose.yml is generated and CI checks it against
generate-compose.py, so editing that file by hand will fail the
generated files are in sync job. Change the generator, not the output, and
start only the services you need rather than deleting them.
Already have your own proxies? Skip the WARP nodes entirely: put them in the
proxies array of config.json, then start only the two services that do not
touch Cloudflare —
docker compose up -d config-init opencode-engineNo WARP, no tinyproxy, no ofu-dedupe, and you never touch Cloudflare's
terms.
Then verify:
../scripts/smoke.shsmoke.sh checks the bundle end to end and exits non-zero if anything is off. A
drained node is not a fault: ofu-dedupe drains the surplus when WARP cannot give
every node its own address, and a drained node stops listening on purpose. So the
pool shrinks as addresses run out, and 2/2 serving, 4 drained is a healthy
result rather than a degraded one. The check that carries the meaning is that
every node still serving holds a distinct address, since two nodes sharing one
address share one quota bucket.
To check upstream — whether the free-tier fingerprint is still what this repository says it is, independently of anything running locally:
../scripts/gate-probe.py # baseline + controls, see --helpBoth listeners bind to 127.0.0.1 by default, which is every documented use
of this stack. To reach it from another machine, put it behind your own TLS
terminator or a VPN rather than widening the bind: the API is anonymous by
design (anonymous: true, see above), so anything that can reach :8080 can
spend your quota.
Two defaults in stack/config.json are the upstream's and are meant to be
changed before this is exposed anywhere:
| Setting | Shipped value | Why it matters |
|---|---|---|
server_keys |
["local-test-key"] |
The only thing stopping unbilled traffic |
webui.username / webui.password |
admin / changeme-changeme |
The dashboard is the admin surface |
Set both in stack/config.json, then docker compose up -d — config-init
re-seeds the volume from that file on every start, so an edit that is not
followed by a restart is silently discarded.
The list above is a snapshot, not a contract. The engine asks OpenCode for the
live catalogue on every request, so /v1/models is always the authoritative answer.
See the model list moves.
space-bunny-free is the default above for one measured reason: it reports
reasoning_tokens: 0, so a tight max_tokens budget goes to the answer instead
of to thinking. That is a property worth having whichever you pick.
What I have actually measured, on the anonymous path only:
| single turn | multi-turn | |
|---|---|---|
| All twelve | answered | — |
space-bunny-free |
yes | yes, correct |
ll-alpha-free |
yes | yes, but only at max_tokens ≥ 300 |
glm-5.3-flash-free |
yes | one correct answer, then 429, then 502 on every node |
big-pickle |
yes | spends the budget on reasoning |
beam-free |
yes | not tested; reasoning model, see Troubleshooting |
The remaining eight are untested on multi-turn, so "default" here means a safe starting point and not a ranking. Nothing in this repository tells you which model is best, and if you find that out it would be worth writing up.
opencode run "23*17 ?"Add an OpenAI-compatible connection:
| Field | Value |
|---|---|
| Base URL | http://127.0.0.1:8080/v1 |
| API key | local-test-key |
| Model prefix | oc2, or anything you like |
| API type | chat |
Then reference a model as <prefix>/<model>, so oc2/space-bunny-free. Measured end
to end through 9router: HTTP 200.
API type is the field lists like this usually skip, and it is not cosmetic. Ten of
the twelve free models are chat-native, which is what chat is for. The two
muse-spark-*-contributor-free models are responses-native upstream, so pointing a
chat-typed route at those is what converts a loud ModelProtocolUnsupported refusal
into a quiet empty answer.
Expect 9router to mark the connection unavailable from its own connectivity test.
That was the state of a working node here: testStatus: unavailable, a 403 from
upstream, and ordinary routed use through the same node working fine. The likely
reason is that the test request does not carry the client fingerprint the free tier
gates on. Check it with a real request rather than the test badge:
curl -s http://127.0.0.1:3000/v1/chat/completions \
-H "Authorization: Bearer <your-9router-key>" -H 'Content-Type: application/json' \
-d '{"model":"oc2/space-bunny-free","messages":[{"role":"user","content":"23*17 ? Reply with just the number."}]}'Add an OpenAI-compatible node pointing at http://127.0.0.1:8080/v1, with
local-test-key as the key, then reference it as <node-prefix>/<model> from a
combo.
This one is given at the HTTP level only: the base URL, the key and the prefix are what the gateway speaks, and they are verified. It has not been clicked through OmniRoute's own interface, so treat the field names as the ones to look for rather than a screenshot's worth of certainty.
Every model name in this file is a snapshot, not a contract. Upstream adds and
retires anonymous models, and this repository does not control that. During the
last round of work here, cat-jam-free was retired and beam-free appeared on
the same afternoon, with no announcement either way.
So treat /v1/models as the truth and any list here as a convenience:
curl -s http://127.0.0.1:8080/v1/models -H "Authorization: Bearer local-test-key" \
| jq -r '.data[].id'Listing models explicitly in a client config only populates the model picker at startup. It is not a filter. A name upstream has retired will fail to resolve, and a new one will not appear until you add it yourself.
If a model you expected is gone, the fix is a different name, not a different configuration.
The pool is generated. Resize it and re-apply:
cd stack
python3 generate-compose.py --nodes 10 # ports 18090-18099
# or start from another base port:
python3 generate-compose.py --nodes 10 --base-port 19090
docker compose up -d --buildgenerate-compose.py rewrites both docker-compose.yml and the proxies
array in stack/config.json.
Nodes no longer rotate on a timer by default: ofu-dedupe owns rotation, and
it only rotates to break a collision. Left to churn on their own, the nodes
simply undo each other's work every couple of minutes and burn WARP
registrations until Cloudflare starts answering 429. The per-node interval is
still there if you want it (--node-rotation 300), but a de-duplicated pool
does not need it.
More nodes is not automatically more buckets. WARP only offers a limited set
of addresses per region, and a colo hands out a small one. Measured on the
default six-node pool, one French colo yielded five usable addresses, so the
sixth node spends its life drained and the pool runs 5/5 — distinct, but not
six. ofu-dedupe drains the surplus instead of pretending otherwise, and
revives it the moment the address pool frees up. Watch the pool: line in
docker logs ofu-dedupe for what you actually have, and
../scripts/smoke.sh asserts it (a drained node is reported as drained, not as
broken, since its proxy is stopped on purpose). Scaling past the WARP ceiling
means bringing your own egress: fill the proxies array in config.json with
your own endpoints and docker compose up -d config-init opencode-engine. The
WARP, tinyproxy and ofu-dedupe services are not started at all, and the
engine keeps working unchanged.
- Dashboard (
http://127.0.0.1:8081): request distribution, per-proxy health and counters, error rates. - Logs:
docker logs ofu-dedupe # pool line: live / distinct docker logs ofu-engine | grep -iE 'proxy became|rate limit|unavailable' docker compose -f stack/docker-compose.yml logs -f tinyproxy-a
- Pool state —
ofu-dedupeprints one line per pass:Apool: 6/6 nodes live, 6 distinct addresses | alztrk-warp-a=104.28.194.131 ...<-- collisionssuffix means it is still working on it;DRAINEDin the log means a node lost its proxy on purpose. A drain is reversible and the arbiter reverses it on its own: each drain records the address the node was holding, and as soon as nobody holds that address any more the node is put back. Watch forrevived, back in the pool holding <ip>to know it happened. - Probe a single node:
curl -s -x http://127.0.0.1:18090 https://api.ipify.org; echo
- Undo a drain by hand — normally unnecessary, since the arbiter revives a
node once its address frees up. Useful if you have just replaced a node and
want it back immediately:
A drained node's marker also says which address it was holding, which is what the next pass compares against:
docker exec ofu-dedupe rm /markers/b && docker logs -f ofu-proxy-b
docker exec ofu-dedupe cat /markers/b
Signs that a node is exhausted: the engine logs proxy became unavailable, the
dashboard flips it to Unavailable, and completions start failing after the
configured retries.
| Symptom | Cause / fix |
|---|---|
proxyconnect tcp: dial tcp 127.0.0.1:1809x: connect: connection refused |
That node's proxy is down. docker compose ps, then docker compose up -d tinyproxy-a. |
no healthy anonymous proxies available |
Every node failed its health check. Check WARP registration: docker logs ofu-warp-a. |
Edits to stack/config.json seem ignored |
The engine reads /var/lib/opencode2api/config.json from its named volume and prefers it over your file. config-init re-seeds it on docker compose up -d; to force it: docker compose down && docker volume rm stack_ofu-engine && docker compose up -d. |
rename … device or resource busy at startup |
You bind-mounted the config path directly; it must be seeded by config-init instead. |
SSL_ERROR_RX_RECORD_TOO_LONG in the browser |
You are on https:// and/or localhost. Use http://127.0.0.1:8081. |
FreeTierError (403) |
Check the message, not just the type — at least four different causes share it. can only be used from within OpenCode is a gate and ./scripts/gate-probe.py says which one. not available in your country is a region block that no header fixes. See the gates doc. |
FreeUsageLimitError (429) |
Not a gate. The address ran out of budget — nothing in the fingerprint will fix it. See the quota axis. Widen the pool or wait. |
[400] … messages[N].reasoning on multi-turn chats |
Known conversion bug in the upstream engine on reasoning models. It was observed on cat-jam-free, which has since been retired off the tier, so the model that triggered it is gone but the class of model is not: ll-alpha-free and beam-free both reason today. I have not seen this 400 reproduce on them, so treat it as a watch item rather than a known outcome. Use space-bunny-free for multi-turn, or disable the reasoning field client-side. |
| A reasoning model returns HTTP 200 with empty content | The budget went to reasoning, not the answer. This is not a gateway fault — the request succeeded. big-pickle spent 55 of 60 tokens on reasoning across 4 of 6 probes and returned nothing; at max_tokens: 200 it was 0 of 4. ll-alpha-free on multi-turn returned 200 with empty content at max_tokens: 120, and the correct answer at 300. beam-free reasons as well. The reasoning tokens are not hidden: /v1/messages returns them as explicit thinking blocks and /v1/responses reports incomplete_details. Raise max_tokens, or pick a model that does not think aloud (space-bunny-free reports reasoning_tokens: 0). |
Node restarts in a loop, bind: address already in use |
Something else holds 8080/8081. The engine uses host networking; free the ports. |
Publishing this repository is fine. Operating it is where the risk lives. The two are separate questions, and the second one is not about licences.
MIT for this repository's own files. The engine
(ghcr.io/jasonxu114514/opencode2api, GPL-3.0) is not redistributed
here, only referenced as a public container image, so no GPL obligation attaches
to these files. stack/warp/ is MIT code from alztrk/opencode-ip-rotator with
its licence text preserved. tinyproxy (GPL-2.0) is installed from Alpine at
build time, not redistributed. See NOTICE.
Using the name "opencode" is nominative, to state compatibility. No affiliation with or endorsement by OpenCode is claimed or implied.
The default egress source is the Cloudflare WARP client. It is a consumer VPN product under the Cloudflare Application Terms of Service, and those terms say things that matter here:
§3 — VPN Profile: "the Application operates similarly to a VPN product or service, the Application is not designed to hide your identity from the Internet properties you access from your device."
§2 — Scope of License: use is licensed "on the device for which it is provided and that you own or control", is non-transferable, and must not "create derivative works".
§6 — User Responsibilities: "Cloudflare may not be used to misrepresent or to act on behalf of others", and must not "create an undue burden on the Application or the networks". A complaint to Cloudflare can result in termination "without notice".
Rotating egress to appear as several different users is outside what §3 says the product is for. Running several instances of the consumer client in containers sits outside the "one device you control" model in §2. Neither is clearly permitted, and neither is clearly forbidden — that is the honest reading.
What this means in practice: the account can be closed, usage can be limited or
blocked, and complaints go to a dedicated abuse mailbox
(warpabuse@cloudflare.com). Cloudflare's liability for consumer products is
capped, and disputes go to arbitration. A breach of terms is a contract matter,
not a criminal one — and publishing this repository is not the breach; running it
is.
Because of that, the WARP terms are never accepted for you: the nodes refuse
to start unless you set WARP_ACCEPT_TOS=1 in stack/.env, which is an
explicit act of yours.
This bundle makes no assumption about where the proxies come from. config.json
takes any HTTP/SOCKS proxy list. If you have your own egress — a residential
pool, a datacentre fleet, a company gateway — use that and start only
docker compose up -d config-init opencode-engine. Cloudflare's terms then have
no bearing on your setup, and neither do their rotators' limits.
Not verified here, and this is a real contract question. OpenCode's terms for the free tier have not been read or checked for this repository, and the honest answer is that only you can decide whether your use complies.
The distinction this repository draws, so that the reasoning is at least visible rather than hidden in the implementation:
| What the bundle does | What it deliberately does not do |
|---|---|
| Makes a gateway send the request shape the official client sends, so it is treated as one | Does not forge, replay or otherwise abuse a paid entitlement |
| Publishes the exact conditions, with a probe that can falsify them | Does not obscure that the point of those conditions is to refuse non-official callers |
| Ships a pool of distinct egress addresses | Does not pretend those addresses are anything other than what they are |
The second row is the awkward one and deserves to be said plainly: the reason the free tier refuses third-party callers is that it is meant for the official client. Working around that refusal is the whole function of this bundle. Some operators in this ecosystem draw the line there — one maintainer declined to help with an essentially identical problem on the grounds that it breaches Anomaly's terms. That is a reasonable position and you may hold it too.
The egress pool is a second, separate question. Quota is believed to be bucketed per source address (see the quota axis section — inferred, not measured), which is why several addresses mean more headroom. If it is not, the pool adds nothing on that axis and you are left with the request-shape workaround alone.
Decide for yourself, at the scale you intend to run. A local single-user gateway is a different proposition from a hosted service with many users, and the line between them is not one this repository can draw for you.
- Per-egress quota is still finite; a wider pool widens it, never removes it.
- WARP offers a limited set of addresses per region (about seven from one French
colo).
ofu-dedupemakes the pool match what is actually available and drains the overflow — it cannot invent addresses. More buckets than that means egress you control, not WARP. - Every address change costs a WARP registration, and Cloudflare rate-limits registrations. The pool therefore converges slowly on purpose; a collision that resolves in a few minutes is cheaper than a crash loop.
- WARP is a shared consumer network: addresses can repeat, be geolocated unexpectedly, or be blocked outright.
- First run builds two images (
ubu-based WARP image, Alpine tinyproxy) — needs internet. config-initoverwrites the engine config in the volume on everyup. Local edits made through the dashboard are lost on the nextup -d.stack/warp/is third-party MIT code; seeNOTICE.
stack/
docker-compose.yml generated — do not hand-edit
config.json engine config + proxy pool
generate-compose.py pool generator (resize + proxy list)
warp/ Cloudflare WARP node image (vendored, MIT, see warp/LICENSE)
tinyproxy/ CONNECT proxy image + drain gate
dedupe/ address de-duplicator (keeps the pool honest)
scripts/smoke.sh one-command verification (needs the stack running)
scripts/gate-probe.py isolates each upstream gate; needs no stack running
tests/ unit tests, standard library only
docs/free-tier-gates.md the four upstream gates, plus the quota axis
docs/gate-timeline.md dated record of the gates moving, and what it cost
NOTICE third-party attribution
python3 -m unittest discover -s tests -v # 126 tests, no dependencies, no stackThe tests cover the pool generator, the de-duplicator's decision logic, and the shipped configuration — the three places where a silent mistake costs you a pool or exposes something you did not mean to. None of them need Docker, network, or a running stack.
Contributions are welcome; CONTRIBUTING.md explains the
conventions and, more importantly, how to tell a measured claim from an inferred
one. SECURITY.md covers the threat model, which is short: this
is a local single-user gateway and both listeners bind loopback by default.
CI runs the tests, byte-compiles every Python file, parses and shellchecks every
shell script, validates the compose file, and asserts that docker-compose.yml
still matches what generate-compose.py generates. That last check is why the
generated file is safe to commit: a hand edit fails CI instead of failing on
your machine later.
Two conventions worth knowing before a first patch:
stack/docker-compose.ymlis generated. Changestack/generate-compose.pyand re-run it. Editing the compose file directly is caught by CI.- A drain writes the address into its marker.
ofu-dedupecannot measure a drained node — stopping its proxy is what draining means — so the marker is the only surviving record of what that node was holding, and therefore the only way the arbiter can notice the address has become free and bring it back. If you touch drain logic, keep that invariant.
This repository's own files: MIT (LICENSE).
Vendored/third-party: see NOTICE — notably stack/warp/ (MIT, © alztrk) and
the engine image ghcr.io/jasonxu114514/opencode2api (GPL-3.0).
