From 91ece5c2fc9dc6ef60878cca5c7dab51e40708b0 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 2 Jun 2026 15:04:51 -0400 Subject: [PATCH] perf(model-metadata): resolve localhost to IPv4 in detect_local_server_type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, `localhost` resolves to both ::1 (IPv6) and 127.0.0.1 (IPv4). httpx tries IPv6 first, hanging 2 sec per probe when the server binds IPv4 only. detect_local_server_type() is called 3+ times during init, each with a new httpx.Client, compounding to ~14s of dead time. Replace localhost with 127.0.0.1 inside the function before connecting. The function is only called for local endpoints (callers guard with is_local_endpoint()), so IPv6 loopback adds no diagnostic value. Measured: 19.9s → 4.0s on Windows with a local proxy on 127.0.0.1:8317. (cherry picked from commit a075d3194bba2e0fa1aa13852862190839bfecef) --- agent/model_metadata.py | 11 +++- tests/agent/test_model_metadata_local_ctx.py | 60 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 15bc00db8b1..f7c3fcf395e 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -648,10 +648,19 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: import httpx normalized = _normalize_base_url(base_url) + + # Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack. + # Applied to ``normalized`` before deriving server/LM Studio URLs AND + # before the cache lookup, so localhost and 127.0.0.1 share a cache entry. + normalized = normalized.replace("://localhost:", "://127.0.0.1:") + normalized = normalized.replace("://localhost/", "://127.0.0.1/") + if normalized.endswith("://localhost"): + normalized = normalized[:-len("localhost")] + "127.0.0.1" + server_url = normalized if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _lmstudio_server_root(normalized) cached = _endpoint_probe_path_cache.get(server_url) if cached is not None: diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py index 2c069aaf6a4..879ca4bd264 100644 --- a/tests/agent/test_model_metadata_local_ctx.py +++ b/tests/agent/test_model_metadata_local_ctx.py @@ -507,6 +507,66 @@ class TestDetectLocalServerTypeAuth: assert client_mock.get.call_args_list[0].args[0] == "http://localhost:1234/api/v1/models" +class TestDetectLocalServerTypeLocalhostIPv4: + """detect_local_server_type should resolve localhost to 127.0.0.1.""" + + def test_localhost_resolved_to_ipv4(self): + """Probes should use 127.0.0.1, not localhost, to avoid IPv6 timeout.""" + from agent.model_metadata import detect_local_server_type + + resp = MagicMock() + resp.status_code = 200 + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://localhost:8317/v1") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "localhost" not in url, f"Probe URL still uses localhost: {url}" + assert "127.0.0.1" in url + + def test_non_localhost_urls_unchanged(self): + """Non-localhost URLs should not be modified.""" + from agent.model_metadata import detect_local_server_type + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.status_code = 404 + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://192.168.1.100:8080") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "192.168.1.100" in url + + def test_127_0_0_1_urls_unchanged(self): + """URLs already using 127.0.0.1 should pass through unchanged.""" + from agent.model_metadata import detect_local_server_type + + client_mock = MagicMock() + client_mock.__enter__ = lambda s: client_mock + client_mock.__exit__ = MagicMock(return_value=False) + resp = MagicMock() + resp.status_code = 404 + client_mock.get.return_value = resp + + with patch("httpx.Client", return_value=client_mock): + detect_local_server_type("http://127.0.0.1:8317") + + for call in client_mock.get.call_args_list: + url = call[0][0] + assert "127.0.0.1" in url + + class TestFetchEndpointModelMetadataLmStudio: """fetch_endpoint_model_metadata should use LM Studio's native models endpoint."""