Outbound network proxy#
Every httpx client in the process resolves proxies with the same semantics —
httpx's own env semantics — and the hardened clients receive them via mounts=
so that hardening and proxying compose. Product-facing documentation lives in
docs/server/configuration.md.
1. Why one resolver#
The failure this prevents is a process with two different proxy semantics depending on which code path a request happens to take. Three outbound paths coexist, and if each resolves proxies on its own they disagree:
| Path | Who uses it | Proxy semantics without a shared resolver |
|---|---|---|
Hardened client (providers/utils/http_client.py) |
anthropic, openai_codex, google_gemini_cli streaming | Only HTTPS_PROXY/HTTP_PROXY (uppercase) read by a hand-written resolver. ALL_PROXY ignored, NO_PROXY ignored (no bypass list), lowercase vars ignored — because the client always receives an explicit transport=, so httpx skips its own env handling (allow_env_proxies = trust_env and transport is None). |
| SDK / ad-hoc raw httpx | OpenAI-compat chat (openai SDK inside openai_completions / openai_responses), OAuth flows, token refresh, model listing, "test provider" button |
Full httpx env semantics: lowercase beats uppercase, ALL_PROXY honoured, NO_PROXY honoured. |
| CLI subprocess | claude_code, codex CLI, gemini CLI | Inherits the shell env; the external CLI does its own proxy handling. |
The consequences of that divergence are concrete: the same provider behaves
differently between "test provider" (raw httpx), actual chat (SDK), and the
Anthropic path (hardened); a user with a proxy plus a NO_PROXY whitelist —
say a mainland-direct API endpoint — has that whitelist silently ignored on the
hardened path; and the connection hardening (TCP keepalive, force-IPv4,
generous streaming timeouts) covers only the hand-written providers while the
openai SDK builds a fresh default client per call.
A socks proxy makes the divergence visible immediately: a shell with
ALL_PROXY=socks5://127.0.0.1:7891 crashes every API-routed provider with
"Using SOCKS proxy, but the 'socksio' package is not installed" while
CLI-backed providers keep working.
2. How OpenClaw does it#
Source: references/openclaw/src/infra/net/proxy-env.ts, proxy-fetch.ts,
src/infra/net/proxy/, and https://docs.openclaw.ai/cli/proxy/.
- One canonical env resolver (
proxy-env.ts) that deliberately mirrors undiciEnvHttpProxyAgentsemantics: lowercase vars take precedence over uppercase; HTTPS requests preferhttps_proxythen fall back tohttp_proxy;ALL_PROXYis a fallback fed in explicitly. A fullNO_PROXYmatcher (comma/whitespace split, case-insensitive,*, leading-dot,*., subdomain suffix, optional:port, bracketed IPv6, plus their own IPv4-CIDR extension) gates every proxy decision — it is a reimplementation kept in sync with undici because undici doesn't export its matcher. - One explicit override:
--proxy-urlflag /proxy.proxyUrlconfig /OPENCLAW_PROXY_URLenv, with optional--proxy-ca-file, implemented as amakeProxyFetch(proxyUrl)wrapper over undiciProxyAgent, plus a managed-proxy lifecycle (validation, TLS options, active-state tracking). - Provider HTTP helpers all route through these helpers; the SSRF guard
(
fetch-guard.ts) composes with the samematchesNoProxy.
The direction is the same one taken here — a single resolver with standard env semantics plus a first-party override. OpenClaw goes further with proxy lifecycle validation and SSRF gating, which this design does not need.
3. Resolution order#
OPENPROGRAM_PROXY_URL— explicit first-party override. When set, all traffic goes through it (any scheme httpx supports:http://,https://,socks5://).NO_PROXYbypasses are still honoured.- Standard environment variables, parsed by httpx's own
get_environment_proxies():http_proxy/HTTP_PROXY,https_proxy/HTTPS_PROXY,all_proxy/ALL_PROXY,no_proxy/NO_PROXY. Using httpx's parser rather than a reimplementation guarantees the hardened path and every plainhttpx.AsyncClient()in the process agree byte-for-byte — the exact property whose absence causes the divergence in §1. Note this delegates to urllib'sgetproxies(), so on macOS/Windows the OS-level proxy settings apply when no env vars are set — same as any Python process. - Built-in loopback bypass:
localhost/127.0.0.1/[::1]never go through a proxy,NO_PROXYor not. Local services (the worker, a localhost ollama, a local OpenAI-compatible endpoint) break behind forward proxies like Clash, which refuse loopback CONNECTs — a failure mode that presents as a spurious 502.
4. Mechanics#
providers/utils/http_proxy.pyexposesget_proxy_mounts()returning the httpx mount map (pattern -> proxy URL or None-for-bypass), with theOPENPROGRAM_PROXY_URLoverride folded in. Its single consumer ishttp_client.py.providers/utils/http_client.py::build_async_clientbuilds one hardenedAsyncHTTPTransportper mount entry (same socket options / IPv4 / keepalive as the default transport, plusproxy=), passes them asmounts=, and keeps the hardened no-proxy transport as the default.trust_envstays default-True so TLS env vars (SSL_CERT_FILE, …) keep working; httpx skips its own env-proxy pass because an explicittransport=is present.- The openai SDK paths (
openai_completions.py,openai_responses.py) passhttp_client=get_shared_async_client("openai-sdk")— SDK requests get the same proxy semantics AND the keepalive/IPv4 hardening, and reuse one connection pool per event loop instead of a fresh client per call. The SDK does not close externally-supplied clients; lifecycle stays withaclose_current_loop_clients(). - One-shot raw httpx clients (OAuth flows, token refresh, marketplace,
model listing) are plain
httpx.AsyncClient()s on purpose: their env semantics are already identical by construction, and they don't need streaming hardening.OPENPROGRAM_PROXY_URLdoes not apply to them, which is an accepted limit rather than an oversight. socksiois a hard dependency (httpx[socks]) so a socksALL_PROXYnever kills client construction.openprogram rescuecarries a proxy probe: it reports the resolved proxy configuration and fails with an exact fix when a socks proxy is configured but socksio is missing.- The test suite is proxy-isolated:
tests/conftest.pystrips the proxy env vars and pins urllib's OS-settings fallback to env-only, so a developer's Clash/system proxy can't hijack the integration tests' localhost requests. Live smoke tests opt back into the real network withOPENPROGRAM_TEST_LIVE=1 pytest -m slow.
5. Deliberately not built#
- A
proxy.urlconfig key / CLI flag (OpenClaw has one) — env vars cover today's users; the override env var is the cheap 90%. Add the config key when someone needs per-profile proxies. - Proxy validation / lifecycle management (OpenClaw's managed proxy) and
--proxy-ca-file— TLS-intercepting corporate proxies already work via httpx's standardSSL_CERT_FILE/REQUESTS_CA_BUNDLEhandling (trust_env=True). - An SSRF guard tied to proxy decisions — OpenProgram is a local tool, not a hosted gateway.
6. Invariants#
- Any new provider HTTP code MUST get its client from
build_async_client/get_shared_async_client— never constructhttpx.AsyncClientwith hand-rolled proxy kwargs. - Never pass
proxy=orproxies=directly; proxy selection lives inget_proxy_mounts()only. get_proxy_mounts()uses httpx's parser. If httpx ever privatises or movesget_environment_proxies, mirror its semantics — do not invent new ones, since divergent semantics is the problem described in §1.tests/test_http_proxy.pypins the resolution rules (override precedence, NO_PROXY bypass, per-URL transport selection).