diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 152e536fd..e35bb3192 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -4,6 +4,7 @@ Pure utility functions with no AIAgent dependency. Used by ContextCompressor and run_agent.py for pre-flight context checks. """ +import ipaddress import logging import re import time @@ -51,6 +52,13 @@ _OLLAMA_TAG_PATTERN = re.compile( ) +# Tailscale's CGNAT range (RFC 6598). `ipaddress.is_private` excludes this +# block, so without an explicit check Ollama reached over Tailscale (e.g. +# `http://100.77.243.5:11434`) wouldn't be treated as local and its stream +# read / stale timeouts wouldn't get auto-bumped. Built once at import time. +_TAILSCALE_CGNAT = ipaddress.IPv4Network("100.64.0.0/10") + + def _strip_provider_prefix(model: str) -> str: """Strip a recognised provider prefix from a model string. @@ -283,7 +291,15 @@ def _is_known_provider_base_url(base_url: str) -> bool: def is_local_endpoint(base_url: str) -> bool: - """Return True if base_url points to a local machine (localhost / RFC-1918 / WSL).""" + """Return True if base_url points to a local machine. + + Recognises loopback (``localhost``, ``127.0.0.0/8``, ``::1``), + container-internal DNS names (``host.docker.internal`` et al.), + RFC-1918 private ranges (``10/8``, ``172.16/12``, ``192.168/16``), + link-local, and Tailscale CGNAT (``100.64.0.0/10``). Tailscale CGNAT + is included so remote-but-trusted Ollama boxes reached over a + Tailscale mesh get the same timeout auto-bumps as localhost Ollama. + """ normalized = _normalize_base_url(base_url) if not normalized: return False @@ -298,14 +314,17 @@ def is_local_endpoint(base_url: str) -> bool: # Docker / Podman / Lima internal DNS names (e.g. host.docker.internal) if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES): return True - # RFC-1918 private ranges and link-local - import ipaddress + # RFC-1918 private ranges, link-local, and Tailscale CGNAT try: addr = ipaddress.ip_address(host) - return addr.is_private or addr.is_loopback or addr.is_link_local + if addr.is_private or addr.is_loopback or addr.is_link_local: + return True + if isinstance(addr, ipaddress.IPv4Address) and addr in _TAILSCALE_CGNAT: + return True except ValueError: pass # Bare IP that looks like a private range (e.g. 172.26.x.x for WSL) + # or Tailscale CGNAT (100.64.x.x–100.127.x.x). parts = host.split(".") if len(parts) == 4: try: @@ -316,6 +335,8 @@ def is_local_endpoint(base_url: str) -> bool: return True if first == 192 and second == 168: return True + if first == 100 and 64 <= second <= 127: + return True except ValueError: pass return False diff --git a/tests/agent/test_local_stream_timeout.py b/tests/agent/test_local_stream_timeout.py index 8184dd2d4..0252633f3 100644 --- a/tests/agent/test_local_stream_timeout.py +++ b/tests/agent/test_local_stream_timeout.py @@ -106,3 +106,25 @@ class TestIsLocalEndpoint: ]) def test_remote_endpoints(self, url): assert is_local_endpoint(url) is False + + @pytest.mark.parametrize("url", [ + "http://100.64.0.0:11434", # lower bound of CGNAT block + "http://100.64.0.1:11434/v1", # lower bound +1 + "http://100.77.243.5:11434", # representative Tailscale host + "https://100.100.100.100:443", # Tailscale MagicDNS anchor + "https://100.127.255.254:443", # upper bound -1 + "http://100.127.255.255:11434", # upper bound of CGNAT block + ]) + def test_tailscale_cgnat_is_local(self, url): + """Tailscale 100.64.0.0/10 should be treated as local for timeout bumps.""" + assert is_local_endpoint(url) is True + + @pytest.mark.parametrize("url", [ + "http://100.63.255.255:11434", # just below CGNAT block + "http://100.128.0.1:11434", # just above CGNAT block + "http://100.200.0.1:11434", # well outside CGNAT + "http://99.64.0.1:11434", # first octet wrong + ]) + def test_near_but_not_cgnat_is_remote(self, url): + """Hosts adjacent to but outside 100.64.0.0/10 must not match.""" + assert is_local_endpoint(url) is False