fix(agent): LLM traffic honours CIDR and wildcard NO_PROXY entries like the platform adapters do
Three answers to "is this host in NO_PROXY": process_bootstrap used the stdlib proxy_bypass_environment (no CIDR, no `*.`), gateway/platforms/base.py had a full matcher (should_bypass_proxy) and a second suffix-only one (is_host_excluded_by_no_proxy, used by Slack). Live-verified: with NO_PROXY=10.0.0.0/8 Telegram bypassed the proxy while the LLM call to a 10.x endpoint went through it. The full matcher moves to the leaf module agent/proxy_bypass.py (stdlib only, importable at early boot); both base.py functions are one-line forwarders and process_bootstrap._get_proxy_for_base_url uses it (passing host:port so port-qualified entries match). The six-key proxy env scan is also shared.
This commit is contained in:
@@ -15,10 +15,10 @@ import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils import base_url_hostname, normalize_proxy_url
|
||||
from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy
|
||||
|
||||
|
||||
_OPENAI_CLS_CACHE = None
|
||||
@@ -288,18 +288,18 @@ class _SafeWriter:
|
||||
|
||||
def _get_proxy_from_env() -> Optional[str]:
|
||||
"""First configured proxy URL from HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (any case), or None."""
|
||||
keys = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
|
||||
return next((normalize_proxy_url(v) for k in keys if (v := os.environ.get(k, "").strip())), None)
|
||||
value = first_proxy_env_value()
|
||||
return normalize_proxy_url(value) if value else None
|
||||
|
||||
|
||||
def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
|
||||
"""Env-configured proxy unless NO_PROXY excludes this base URL."""
|
||||
"""Env-configured proxy unless NO_PROXY excludes this base URL (same matcher as the
|
||||
gateway adapters: CIDR, ``*.`` wildcards and host:port entries all count)."""
|
||||
proxy = _get_proxy_from_env()
|
||||
host = base_url_hostname(base_url) if proxy and base_url else ""
|
||||
try:
|
||||
return None if host and urllib.request.proxy_bypass_environment(host) else proxy
|
||||
except Exception:
|
||||
if not (proxy and base_url):
|
||||
return proxy
|
||||
raw = base_url.strip()
|
||||
return None if should_bypass_proxy(raw if "://" in raw else f"//{raw}") else proxy
|
||||
|
||||
|
||||
def _shared_transport_cls():
|
||||
|
||||
@@ -26,11 +26,12 @@ def first_proxy_env_value() -> str:
|
||||
|
||||
|
||||
def split_host_port(value: str) -> tuple[str, int | None]:
|
||||
"""``(host, port)`` from a URL, ``[v6]:port``, ``host:port`` or bare host; host lowercased."""
|
||||
"""``(host, port)`` from a URL (scheme optional: ``//host/path``), ``[v6]:port``,
|
||||
``host:port`` or bare host; host lowercased."""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return "", None
|
||||
if "://" in raw:
|
||||
if "://" in raw or raw.startswith("//"):
|
||||
parsed = urlsplit(raw)
|
||||
host, port = parsed.hostname or "", parsed.port
|
||||
elif raw.startswith("[") and "]" in raw:
|
||||
|
||||
@@ -67,7 +67,7 @@ def parse_retry_after_seconds(value_or_headers: Any) -> Optional[float]:
|
||||
# Free-text "reset" grammars providers put in error bodies, tried in order. One table so the
|
||||
# conversation loop's error context and the credential pool's cooldown agree on the same wait.
|
||||
_QUOTA_RESET_DELAY_RE = re.compile(r"quotaResetDelay[:\s\"]+(\d+(?:\.\d+)?)(ms|s)", re.IGNORECASE)
|
||||
# "Resets in 4hr 5min" (OpenCode Go weekly limits), "resets in 2 hours 5 minutes", "resets in 30s".
|
||||
# "Resets in 4hr 5min" (weekly usage limits), "resets in 2 hours 5 minutes", "resets in 30s".
|
||||
_RESETS_IN_RE = re.compile(
|
||||
r"resets?\s+in\s+"
|
||||
r"(?:(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b\s*)?"
|
||||
|
||||
@@ -20,6 +20,7 @@ from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from utils import normalize_proxy_url
|
||||
from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy as _should_bypass_proxy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -264,69 +265,10 @@ def _detect_macos_system_proxy() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _split_host_port(value: str) -> tuple[str, int | None]:
|
||||
"""``(host, port)`` from a URL, ``[v6]:port``, ``host:port`` or bare host; host lowercased."""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return "", None
|
||||
if "://" in raw:
|
||||
parsed = urlsplit(raw)
|
||||
host, port = parsed.hostname or "", parsed.port
|
||||
elif raw.startswith("[") and "]" in raw:
|
||||
host, _, rest = raw[1:].partition("]")
|
||||
port = int(rest[1:]) if rest.startswith(":") and rest[1:].isdigit() else None
|
||||
elif raw.count(":") == 1 and raw.rpartition(":")[2].isdigit():
|
||||
host, _, port_s = raw.rpartition(":")
|
||||
port = int(port_s)
|
||||
else:
|
||||
host, port = raw.strip("[]"), None
|
||||
return host.lower().rstrip("."), port
|
||||
|
||||
|
||||
def _no_proxy_entries() -> list[str]:
|
||||
return [
|
||||
part.strip() for key in ("NO_PROXY", "no_proxy")
|
||||
for part in os.environ.get(key, "").split(",") if part.strip()]
|
||||
|
||||
|
||||
def _ip_or_none(value: str, parse=ipaddress.ip_address):
|
||||
"""``parse(value)`` or None on ``ValueError`` (``parse`` is ip_address / ip_network)."""
|
||||
try:
|
||||
return parse(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _no_proxy_entry_matches(entry: str, host: str, port: int | None = None) -> bool:
|
||||
token = str(entry or "").strip().lower()
|
||||
if not token:
|
||||
return False
|
||||
if token == "*":
|
||||
return True
|
||||
token_host, token_port = _split_host_port(token)
|
||||
if not token_host or (token_port is not None and (port is None or token_port != port)):
|
||||
return False
|
||||
host_ip = _ip_or_none(host)
|
||||
network = _ip_or_none(token_host, lambda v: ipaddress.ip_network(v, strict=False))
|
||||
if network is not None: # CIDR or bare IP literal (a /32 / /128 network)
|
||||
return host_ip is not None and host_ip in network
|
||||
if token_host.startswith("*."):
|
||||
return host.endswith(token_host[1:])
|
||||
if token_host.startswith("."):
|
||||
return host == token_host[1:] or host.endswith(token_host)
|
||||
return host == token_host or host.endswith(f".{token_host}")
|
||||
|
||||
|
||||
def should_bypass_proxy(target_hosts: str | list[str] | tuple[str, ...] | set[str] | None) -> bool:
|
||||
"""True when NO_PROXY/no_proxy matches at least one target host (exact hosts, domain /
|
||||
wildcard suffixes, IP literals, CIDR ranges, optional host:port entries, ``*``)."""
|
||||
entries = _no_proxy_entries()
|
||||
if not entries or not target_hosts:
|
||||
return False
|
||||
candidates = [target_hosts] if isinstance(target_hosts, str) else list(target_hosts)
|
||||
return any(
|
||||
host and any(_no_proxy_entry_matches(entry, host, port) for entry in entries)
|
||||
for host, port in map(_split_host_port, map(str, candidates)))
|
||||
return _should_bypass_proxy(target_hosts)
|
||||
|
||||
|
||||
def resolve_proxy_url(
|
||||
@@ -347,8 +289,7 @@ def resolve_proxy_url(
|
||||
if not value:
|
||||
if not gateway_trust_env(): # only the explicit per-platform var is honored
|
||||
return None
|
||||
keys = ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
|
||||
value = next((v for k in keys if (v := (os.environ.get(k) or "").strip())), "")
|
||||
value = first_proxy_env_value()
|
||||
proxy = normalize_proxy_url(value or _detect_macos_system_proxy())
|
||||
return None if proxy and should_bypass_proxy(target_hosts) else proxy
|
||||
|
||||
@@ -412,21 +353,9 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
|
||||
|
||||
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
|
||||
"""Return True when ``hostname`` matches a ``NO_PROXY`` entry (comma/whitespace
|
||||
separated; leading-dot and ``*.`` entries match the apex domain and subdomains)."""
|
||||
if no_proxy_value is None:
|
||||
no_proxy_value = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or ""
|
||||
lower_hostname = hostname.lower()
|
||||
for entry in re.split(r"[\s,]+", no_proxy_value.strip()):
|
||||
normalized = entry.strip().lower()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized == "*":
|
||||
return True
|
||||
normalized = normalized[2:] if normalized.startswith("*.") else normalized.removeprefix(".")
|
||||
if lower_hostname == normalized or lower_hostname.endswith(f".{normalized}"):
|
||||
return True
|
||||
return False
|
||||
"""Return True when ``hostname`` matches a ``NO_PROXY`` entry (``no_proxy_value`` overrides the
|
||||
environment); same matcher as :func:`should_bypass_proxy`."""
|
||||
return _should_bypass_proxy(hostname, no_proxy_value=no_proxy_value)
|
||||
|
||||
|
||||
import dataclasses
|
||||
|
||||
Reference in New Issue
Block a user