Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.dev.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
# App-specific variables keep self-host `.env` release credentials from leaking
# into direct `mix` and `mix test` runs.

# Optional standard proxy environment variables for outbound traffic.
# http_proxy=http://proxy.example.com:3128
# https_proxy=http://proxy.example.com:3128
# no_proxy=localhost,127.0.0.1,.example.internal

CODEX_POOLER_DEV_POSTGRES_HOST=localhost
CODEX_POOLER_DEV_POSTGRES_PORT=5433
CODEX_POOLER_DEV_POSTGRES_DB=codex_pooler_dev
Expand Down
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
CODEX_POOLER_IMAGE=ghcr.io/icoretech/codex-pooler
CODEX_POOLER_IMAGE_TAG=latest
CODEX_POOLER_HTTP_PORT=4000
# Optional standard proxy environment variables for outbound traffic.
# http_proxy=http://proxy.example.com:3128
# https_proxy=http://proxy.example.com:3128
# no_proxy=localhost,127.0.0.1,.example.internal

PHX_HOST=localhost
OBAN_MODE=all
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2165,6 +2165,13 @@ the database:
- `CODEX_POOLER_UPSTREAM_SECRET_KEY` and
`CODEX_POOLER_UPSTREAM_SECRET_KEY_VERSION`, upstream secret encryption root
and version; the key must be 32 raw bytes or base64-encoded 32 bytes
- `http_proxy` and `https_proxy`, optional `http://` proxy URLs selected by the
outbound target scheme, including WebSocket traffic; optional URL credentials
use HTTP Basic proxy authentication
- `no_proxy`, an optional comma-separated bypass list supporting exact hosts,
domain suffixes, IP addresses, CIDR ranges, optional ports, and `*`; lowercase variables
take precedence over the uppercase `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
variants

Operational controls such as file limits, ingress trust, gateway diagnostics,
route-class admission, circuit thresholds, metrics auth, operator email, model
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2029,6 +2029,10 @@ user-agent 历史。
- `CODEX_POOLER_UPSTREAM_SECRET_KEY` 和
`CODEX_POOLER_UPSTREAM_SECRET_KEY_VERSION`,上游密钥加密根和版本;key 必须是
32 raw bytes 或 base64-encoded 32 bytes
- `http_proxy` 和 `https_proxy`,按出站目标协议选择的可选 `http://` 代理 URL,
同时适用于 WebSocket;URL 中可选的凭据使用 HTTP Basic 代理认证
- `no_proxy`,可选的逗号分隔绕过列表,支持精确主机、域名后缀、IP 地址、CIDR、
可选端口和 `*`;小写变量优先于大写的 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY`

文件限制、入口信任、网关诊断、路由类别准入、熔断阈值、指标认证、运营者邮箱、
模型元数据、上游超时、OpenAI 价格 catalog URL 和 SMTP 投递等运营控制项位于
Expand Down
3 changes: 3 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ config :codex_pooler,
CodexPooler.Gateway.Transports.Websocket.NativeCompactionTrace,
mode: native_compaction_trace_mode

config :codex_pooler, CodexPooler.Platform.OutboundHTTP,
proxy_config: CodexPooler.Platform.OutboundHTTP.proxy_config_from_env!()

if System.get_env("PHX_SERVER") in ~w(true 1) do
config :codex_pooler, CodexPoolerWeb.Endpoint, server: true
end
Expand Down
18 changes: 18 additions & 0 deletions docs-site/src/content/docs/getting-started/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ as `https://localhost`; the port mapping is the local URL to open.

Set release environment variables for values the app needs before it can read database settings.

Set `http_proxy` and `https_proxy` to `http://` proxy URLs to route outbound
HTTP, HTTPS, and WebSocket traffic by target scheme. Credentials embedded in a
proxy URL are sent as HTTP Basic proxy authentication. Use the comma-separated
`no_proxy` list for exact hosts, domain suffixes, IP addresses, CIDR ranges,
optional ports, or `*`. Lowercase variables take precedence over their uppercase variants.

<table>
<thead>
<tr>
Expand All @@ -51,6 +57,18 @@ Set release environment variables for values the app needs before it can read da
<td><code>CODEX_POOLER_HTTP_PORT</code></td>
<td>Local host port, default <code>4000</code></td>
</tr>
<tr>
<td><code>http_proxy</code> / <code>HTTP_PROXY</code></td>
<td>Optional proxy for HTTP and WS targets; lowercase takes precedence</td>
</tr>
<tr>
<td><code>https_proxy</code> / <code>HTTPS_PROXY</code></td>
<td>Optional HTTP CONNECT proxy for HTTPS and WSS targets; lowercase takes precedence</td>
</tr>
<tr>
<td><code>no_proxy</code> / <code>NO_PROXY</code></td>
<td>Optional comma-separated proxy bypass list; lowercase takes precedence</td>
</tr>
<tr>
<td><code>DATABASE_URL</code></td>
<td>Postgres connection URL</td>
Expand Down
2 changes: 1 addition & 1 deletion lib/codex_pooler/alerts/delivery/webhook_delivery.ex
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ defmodule CodexPooler.Alerts.Delivery.WebhookDelivery do
decode_body: false,
receive_timeout: @receive_timeout_ms,
retry: false,
finch: OutboundHTTP.pool_options()
finch: OutboundHTTP.pool_options_for_url(url)
)
rescue
exception in [
Expand Down
2 changes: 1 addition & 1 deletion lib/codex_pooler/catalog/openai_pricing_importer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ defmodule CodexPooler.Catalog.OpenAIPricingImporter do
decode_body: false,
receive_timeout: :timer.seconds(30),
retry: false,
finch: OutboundHTTP.pool_options()
finch: OutboundHTTP.pool_options_for_url(url)
) do
{:ok, %{status: status, body: body}} when status in 200..299 and is_binary(body) ->
{:ok, body}
Expand Down
2 changes: 1 addition & 1 deletion lib/codex_pooler/catalog/sync/discovery.ex
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ defmodule CodexPooler.Catalog.Sync.Discovery do
case Req.get(url,
retry: false,
receive_timeout: 30_000,
finch: OutboundHTTP.pool_options(),
finch: OutboundHTTP.pool_options_for_url(url),
headers:
CloudflareCookies.request_headers(url, model_catalog_headers(identity, token))
)
Expand Down
22 changes: 20 additions & 2 deletions lib/codex_pooler/gateway/operational_settings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,32 @@ defmodule CodexPooler.Gateway.OperationalSettings do
Finch HTTP/1 pool options for gateway Req requests, built by
`OutboundHTTP.pool_options/1` from this snapshot's idle bound: dispatch
through `TransportEnvelope.req_timeout_options/1` and the file bridge upload
PUT. Callers outside the gateway use `OutboundHTTP.pool_options/0`, which
reads the same Instance Setting.
PUT. Callers outside the gateway use `OutboundHTTP.pool_options_for_url/2`,
which reads the same Instance Setting. The URL-aware helpers include the
proxy selected for the target and apply `no_proxy`.
"""
@spec upstream_http_pool_options() :: OutboundHTTP.pool_options()
def upstream_http_pool_options do
OutboundHTTP.pool_options(current().upstream_conn_max_idle_time_ms)
end

@spec upstream_http_pool_options(keyword()) :: OutboundHTTP.pool_options()
def upstream_http_pool_options(conn_opts) when is_list(conn_opts) do
OutboundHTTP.pool_options_with_conn_opts(
current().upstream_conn_max_idle_time_ms,
conn_opts
)
end

@spec upstream_http_pool_options(String.t(), keyword()) :: OutboundHTTP.pool_options()
def upstream_http_pool_options(url, conn_opts) when is_binary(url) and is_list(conn_opts) do
OutboundHTTP.pool_options_for_url(
url,
current().upstream_conn_max_idle_time_ms,
conn_opts
)
end

@spec firewall_enabled?(t()) :: boolean()
def firewall_enabled?(%__MODULE__{firewall_allowlist: allowlist}), do: allowlist != []

Expand Down
21 changes: 14 additions & 7 deletions lib/codex_pooler/gateway/payloads/transport_envelope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,22 @@ defmodule CodexPooler.Gateway.Payloads.TransportEnvelope do
# `pool_max_idle_time` stays unset: stopping an idle per-origin pool can race
# a request that has just looked it up, and stale connections are already
# dropped at checkout.
@spec req_timeout_options(TimeoutConfig.t() | timeout_settings()) :: keyword()
def req_timeout_options(timeouts) do
@spec req_timeout_options(TimeoutConfig.t() | timeout_settings(), String.t() | nil) :: keyword()
def req_timeout_options(timeouts, url \\ nil) do
pool_options =
if is_binary(url) do
OperationalSettings.upstream_http_pool_options(url,
transport_opts: [timeout: timeouts.connect_timeout_ms]
)
else
OperationalSettings.upstream_http_pool_options(
transport_opts: [timeout: timeouts.connect_timeout_ms]
)
end

[
receive_timeout: timeouts.receive_timeout_ms,
finch:
[
pool_timeout: timeouts.pool_timeout_ms,
conn_opts: [transport_opts: [timeout: timeouts.connect_timeout_ms]]
] ++ OperationalSettings.upstream_http_pool_options()
finch: [pool_timeout: timeouts.pool_timeout_ms] ++ pool_options
]
end

Expand Down
8 changes: 4 additions & 4 deletions lib/codex_pooler/gateway/transports/file_bridge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ defmodule CodexPooler.Gateway.Transports.FileBridge do
with {:ok, body, byte_size} <- readable_file_stream(path) do
upload_url
|> upload_request()
|> Req.put(upload_req_options(body, content_type, byte_size))
|> Req.put(upload_req_options(upload_url, body, content_type, byte_size))
|> normalize_upload_response(opts)
end
rescue
Expand Down Expand Up @@ -214,7 +214,7 @@ defmodule CodexPooler.Gateway.Transports.FileBridge do
retry: false,
headers: headers(identity, token, forwarded_headers(opts))
]
|> Keyword.merge(TransportEnvelope.req_timeout_options(timeouts))
|> Keyword.merge(TransportEnvelope.req_timeout_options(timeouts, url))

url
|> Req.post(request_options)
Expand Down Expand Up @@ -267,7 +267,7 @@ defmodule CodexPooler.Gateway.Transports.FileBridge do
path == root or String.starts_with?(path, root <> "/")
end

defp upload_req_options(body, content_type, byte_size) do
defp upload_req_options(upload_url, body, content_type, byte_size) do
configured_upload_req_options()
|> Keyword.merge(
body: body,
Expand All @@ -278,7 +278,7 @@ defmodule CodexPooler.Gateway.Transports.FileBridge do
],
redirect: false,
retry: false,
finch: OperationalSettings.upstream_http_pool_options()
finch: OperationalSettings.upstream_http_pool_options(upload_url, [])
)
end

Expand Down
4 changes: 2 additions & 2 deletions lib/codex_pooler/gateway/transports/upstream_dispatch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ defmodule CodexPooler.Gateway.Transports.UpstreamDispatch do
])
)
]
|> Keyword.merge(TransportEnvelope.req_timeout_options(timeouts))
|> Keyword.merge(TransportEnvelope.req_timeout_options(timeouts, url))

result = Req.post(url, request_options)
CloudflareCookies.store_from_result(url, result)
Expand Down Expand Up @@ -462,7 +462,7 @@ defmodule CodexPooler.Gateway.Transports.UpstreamDispatch do
retry: false,
headers: upstream_header_list
]
|> Keyword.merge(TransportEnvelope.req_timeout_options(timeouts))
|> Keyword.merge(TransportEnvelope.req_timeout_options(timeouts, url))

request_options =
if streaming_request?(payload, opts) do
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule CodexPooler.Gateway.Transports.Websocket.UpstreamWebsocketSession.ConnectionUpgrade do
@moduledoc false

alias CodexPooler.Platform.OutboundHTTP

@type request_caller :: {pid(), reference()} | nil
@type upgrade_response :: %{status: non_neg_integer() | nil, headers: Mint.Types.headers()}
@connect_ready_tag :upstream_websocket_connect_ready
Expand Down Expand Up @@ -37,28 +39,47 @@ defmodule CodexPooler.Gateway.Transports.Websocket.UpstreamWebsocketSession.Conn
end

defp connect_websocket(%{connect_scheme: :http} = target, timeouts, request_caller) do
connect_in_task(target, timeouts, request_caller)
proxy_options = OutboundHTTP.proxy_options_for_url(target.uri)
connect_in_task(target, timeouts, request_caller, proxy_options)
end

defp connect_websocket(%{connect_scheme: :https} = target, timeouts, request_caller) do
raw_target = %{target | connect_scheme: :http}
proxy_options = OutboundHTTP.proxy_options_for_url(target.uri)

if proxy_options == [] do
raw_target = %{target | connect_scheme: :http}

with {:ok, raw_conn} <- connect_in_task(raw_target, timeouts, request_caller) do
upgrade_tls_connection(raw_conn, target, timeouts, request_caller)
with {:ok, raw_conn} <- connect_in_task(raw_target, timeouts, request_caller, []) do
upgrade_tls_connection(raw_conn, target, timeouts, request_caller)
end
else
# Mint performs CONNECT and the TLS handshake as one proxied connection.
connect_in_task(target, timeouts, request_caller, proxy_options)
end
end

defp connect_in_task(target, timeouts, request_caller) do
defp connect_in_task(target, timeouts, request_caller, proxy_options) do
parent = self()

{:ok, connect_pid} =
Task.start(fn ->
parent_monitor = Process.monitor(parent)

result =
Mint.HTTP.connect(target.connect_scheme, target.host, target.port,
connect_options =
[
protocols: [:http1],
transport_opts: websocket_transport_opts(target, timeouts)
]

connect_options =
connect_options ++ proxy_options_with_connect_timeout(proxy_options, timeouts)

result =
Mint.HTTP.connect(
target.connect_scheme,
target.host,
target.port,
connect_options
)

send(parent, {@connect_ready_tag, self(), result})
Expand All @@ -82,6 +103,16 @@ defmodule CodexPooler.Gateway.Transports.Websocket.UpstreamWebsocketSession.Conn
await_connect(connect_pid, connect_monitor, timeouts, request_caller)
end

defp proxy_options_with_connect_timeout(proxy_options, timeouts) do
Enum.map(proxy_options, fn
{:proxy, {scheme, host, port, _opts}} ->
{:proxy, {scheme, host, port, [transport_opts: [timeout: timeouts.connect_timeout_ms]]}}

option ->
option
end)
end

defp upgrade_tls_connection(raw_conn, target, timeouts, request_caller) do
watcher = start_tls_upgrade_caller_watcher(raw_conn, request_caller)

Expand Down Expand Up @@ -326,7 +357,14 @@ defmodule CodexPooler.Gateway.Transports.Websocket.UpstreamWebsocketSession.Conn
path = websocket_path(uri)

{:ok,
%{connect_scheme: connect_scheme, ws_scheme: ws_scheme, host: host, port: port, path: path}}
%{
connect_scheme: connect_scheme,
ws_scheme: ws_scheme,
host: host,
port: port,
path: path,
uri: uri
}}
else
_invalid -> {:error, :invalid_upstream_websocket_url}
end
Expand Down
Loading