diff --git a/agent/model_metadata.py b/agent/model_metadata.py index b911544811f..8f6d2594410 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -636,6 +636,24 @@ def is_local_endpoint(base_url: str) -> bool: return False +def _localhost_to_ipv4(url: str) -> str: + """Rewrite a ``localhost`` host to ``127.0.0.1`` in a probe URL. + + On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1`` + first and pays a ~2s IPv6 connect timeout before falling back to IPv4 + when the local server only listens on IPv4 (LM Studio, Ollama defaults). + Probing the IPv4 loopback directly skips that penalty. Non-localhost + URLs pass through unchanged. + """ + if not url: + return url + url = url.replace("://localhost:", "://127.0.0.1:") + url = url.replace("://localhost/", "://127.0.0.1/") + if url.endswith("://localhost"): + url = url[:-len("localhost")] + "127.0.0.1" + return url + + def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: """Detect which local server is running at base_url by probing known endpoints. @@ -652,10 +670,7 @@ def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]: # 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" + normalized = _localhost_to_ipv4(normalized) server_url = normalized if server_url.endswith("/v1"): @@ -1393,7 +1408,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option import httpx bare_model = _strip_provider_prefix(model) - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1454,7 +1469,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") - except Exception: return None - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1531,7 +1546,7 @@ def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "" """Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``.""" import httpx - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] @@ -1650,10 +1665,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str model = _strip_provider_prefix(model) # Strip /v1 suffix to get the server root - server_url = base_url.rstrip("/") + server_url = _localhost_to_ipv4(base_url.rstrip("/")) if server_url.endswith("/v1"): server_url = server_url[:-3] - lmstudio_url = _lmstudio_server_root(base_url) + lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url)) headers = _auth_headers(api_key) diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py index ccda0a94392..1fcc24ecf82 100644 --- a/tests/agent/test_model_metadata_local_ctx.py +++ b/tests/agent/test_model_metadata_local_ctx.py @@ -465,7 +465,7 @@ class TestQueryLocalContextLengthLmStudio: result = _query_local_context_length("publisher/model-a", "http://localhost:1234/api/v1") assert result == 32768 - assert client_mock.get.call_args_list[0].args[0] == "http://localhost:1234/api/v1/models" + assert client_mock.get.call_args_list[0].args[0] == "http://127.0.0.1:1234/api/v1/models" class TestDetectLocalServerTypeAuth: diff --git a/tests/agent/test_probe_cache_followups.py b/tests/agent/test_probe_cache_followups.py index faf849b71a4..5dd6ace7bd3 100644 --- a/tests/agent/test_probe_cache_followups.py +++ b/tests/agent/test_probe_cache_followups.py @@ -87,6 +87,41 @@ class TestOllamaApiShowCaching: assert client.post.call_count == 1 +class TestLocalhostIPv4SiblingSites: + """#37595 widened: every probe helper rewrites localhost→127.0.0.1, + not just detect_local_server_type.""" + + def test_helper_rewrites_all_forms(self): + from agent.model_metadata import _localhost_to_ipv4 + + assert _localhost_to_ipv4("http://localhost:1234/v1") == "http://127.0.0.1:1234/v1" + assert _localhost_to_ipv4("http://localhost/v1") == "http://127.0.0.1/v1" + assert _localhost_to_ipv4("http://localhost") == "http://127.0.0.1" + # Non-localhost passes through untouched. + assert _localhost_to_ipv4("http://192.168.1.10:8080") == "http://192.168.1.10:8080" + assert _localhost_to_ipv4("https://api.openai.com/v1") == "https://api.openai.com/v1" + assert _localhost_to_ipv4("") == "" + + def test_ollama_api_show_probes_ipv4(self): + from agent.model_metadata import _query_ollama_api_show + + client = _client_mock(_mock_show_response(131072)) + with patch("httpx.Client", return_value=client): + _query_ollama_api_show("llama3", "http://localhost:11434") + + assert client.post.call_args[0][0].startswith("http://127.0.0.1:11434") + + def test_query_ollama_num_ctx_probes_ipv4(self): + from agent.model_metadata import query_ollama_num_ctx + + client = _client_mock(_mock_show_response(131072)) + with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"), \ + patch("httpx.Client", return_value=client): + query_ollama_num_ctx("llama3", "http://localhost:11434") + + assert client.post.call_args[0][0].startswith("http://127.0.0.1:11434") + + class TestContextCacheKeyNormalization: def test_trailing_slash_variants_share_one_entry(self, tmp_path, monkeypatch): from agent import model_metadata