fix(agent): fail fast on custom-provider /models auth errors

- Short-circuit the candidate waterfall on HTTP 401/403: an auth wall
  proves the endpoint family exists, so probing the alternate URL just
  doubles the wasted wait (the reported endpoint takes ~10s to return
  401 without a key).
- Stream the probe so 4xx never downloads a slow error body; responses
  are closed on every exit path.
- Regression tests: single-call assertion on 401/403 (fails on main),
  negative-cache reuse, 404 waterfall preserved, no .json() on 4xx.

Fixes #69905

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Josh Tsai 2026-07-23 16:35:30 +08:00 committed by kshitij
parent 9b50a99b39
commit 013779924f
2 changed files with 91 additions and 1 deletions

View File

@ -1185,8 +1185,22 @@ def fetch_endpoint_model_metadata(
for candidate in candidates:
url = candidate.rstrip("/") + "/models"
response = None
try:
response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
response = requests.get(
url,
headers=headers,
timeout=(5, 10),
verify=_resolve_requests_verify(),
stream=True,
)
if response.status_code in (401, 403):
logger.debug(
"Model metadata probe received HTTP %s from %s; stopping candidate probing",
response.status_code,
url,
)
break
response.raise_for_status()
payload = response.json()
cache: Dict[str, Dict[str, Any]] = {}
@ -1236,6 +1250,9 @@ def fetch_endpoint_model_metadata(
return cache
except Exception as exc:
last_error = exc
finally:
if response is not None:
response.close()
if last_error:
logger.debug("Failed to fetch model metadata from %s/models: %s", normalized, last_error)

View File

@ -489,6 +489,79 @@ class TestCodexOAuthContextLength:
# =========================================================================
# Custom endpoint model metadata
# =========================================================================
class TestFetchEndpointModelMetadata:
def setup_method(self):
import agent.model_metadata as mm
mm._endpoint_model_metadata_cache.clear()
mm._endpoint_model_metadata_cache_time.clear()
@pytest.mark.parametrize("status_code", [401, 403])
def test_auth_failure_stops_after_first_candidate(self, status_code):
import agent.model_metadata as mm
response = MagicMock()
response.status_code = status_code
response.raise_for_status.side_effect = RuntimeError(str(status_code))
with patch("agent.model_metadata.requests.get", return_value=response) as mock_get:
result = mm.fetch_endpoint_model_metadata("https://custom.example/v1")
assert result == {}
mock_get.assert_called_once()
assert mock_get.call_args.kwargs["stream"] is True
response.raise_for_status.assert_not_called()
response.json.assert_not_called()
response.close.assert_called_once()
def test_auth_failure_empty_result_is_cached(self):
import agent.model_metadata as mm
response = MagicMock()
response.status_code = 401
response.raise_for_status.side_effect = RuntimeError("401")
with patch("agent.model_metadata.requests.get", return_value=response) as mock_get:
first = mm.fetch_endpoint_model_metadata("https://custom.example/v1")
second = mm.fetch_endpoint_model_metadata("https://custom.example/v1")
assert first == second == {}
mock_get.assert_called_once()
response.close.assert_called_once()
def test_not_found_still_tries_alternate_candidate(self):
import agent.model_metadata as mm
not_found = MagicMock()
not_found.status_code = 404
not_found.raise_for_status.side_effect = RuntimeError("404")
success = MagicMock()
success.status_code = 200
success.json.return_value = {
"data": [{"id": "test/model", "context_length": 32768}]
}
with patch(
"agent.model_metadata.requests.get",
side_effect=[not_found, success],
) as mock_get:
result = mm.fetch_endpoint_model_metadata("https://custom.example/v1")
assert result["test/model"]["context_length"] == 32768
assert mock_get.call_count == 2
assert [call.args[0] for call in mock_get.call_args_list] == [
"https://custom.example/v1/models",
"https://custom.example/models",
]
assert all(call.kwargs["stream"] is True for call in mock_get.call_args_list)
not_found.json.assert_not_called()
not_found.close.assert_called_once()
success.close.assert_called_once()
# =========================================================================
# Nous Portal context-window resolution (provider="nous")
# =========================================================================