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:
parent
9b50a99b39
commit
013779924f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue