From 23b81ab243d386b62eecddf27d46cf7cee6f6cc6 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 16:00:55 +0800 Subject: [PATCH] fix(cli): send User-Agent in /v1/models probe to pass Cloudflare 1010 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom Claude proxies fronted by Cloudflare with Browser Integrity Check enabled (e.g. `packyapi.com`) reject requests with the default `Python-urllib/*` signature, returning HTTP 403 "error code: 1010". `probe_api_models` swallowed that in its blanket `except Exception: continue`, so `validate_requested_model` returned the misleading "Could not reach the API to validate ``" error even though the endpoint is reachable and lists the requested model. Advertise the probe request as `hermes-cli/` so Cloudflare treats it as a first-party client. This mirrors the pattern already used by `agent/gemini_native_adapter.py` and `agent/anthropic_adapter.py`, which set a descriptive UA for the same reason. Reproduction (pre-fix): python3 -c " import urllib.request req = urllib.request.Request( 'https://www.packyapi.com/v1/models', headers={'Authorization': 'Bearer sk-...'}) urllib.request.urlopen(req).read() " urllib.error.HTTPError: HTTP Error 403: Forbidden (body: b'error code: 1010') Any non-urllib UA (Mozilla, curl, reqwest) returns 200 with the OpenAI-compatible models listing. Tested on macOS (Python 3.11). No cross-platform concerns — the change is a single header addition to an existing `urllib.request.Request`. --- hermes_cli/models.py | 8 ++- tests/hermes_cli/test_model_validation.py | 60 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 3526e0bb11..6413c35fdf 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -16,6 +16,12 @@ from difflib import get_close_matches from pathlib import Path from typing import Any, NamedTuple, Optional +from hermes_cli import __version__ as _HERMES_VERSION + +# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity +# Check (error 1010) don't reject the default ``Python-urllib/*`` signature. +_HERMES_USER_AGENT = f"hermes-cli/{_HERMES_VERSION}" + COPILOT_BASE_URL = "https://api.githubcopilot.com" COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models" COPILOT_EDITOR_VERSION = "vscode/1.104.1" @@ -1768,7 +1774,7 @@ def probe_api_models( candidates.append((alternate_base, True)) tried: list[str] = [] - headers: dict[str, str] = {} + headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} if api_key: headers["Authorization"] = f"Bearer {api_key}" if normalized.startswith(COPILOT_BASE_URL): diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 1ddf6ab639..65405d909f 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -540,3 +540,63 @@ class TestValidateCodexAutoCorrection: assert result["recognized"] is False assert result.get("corrected_model") is None assert "not found" in result["message"] + + +# -- probe_api_models — Cloudflare UA mitigation -------------------------------- + +class TestProbeApiModelsUserAgent: + """Probing custom /v1/models must send a Hermes User-Agent. + + Some custom Claude proxies (e.g. ``packyapi.com``) sit behind Cloudflare with + Browser Integrity Check enabled. The default ``Python-urllib/3.x`` signature + is rejected with HTTP 403 ``error code: 1010``, which ``probe_api_models`` + swallowed into ``{"models": None}``, surfacing to users as a misleading + "Could not reach the ... API to validate ..." error — even though the + endpoint is reachable and the listing exists. + """ + + def _make_mock_response(self, body: bytes): + from unittest.mock import MagicMock + mock_resp = MagicMock() + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + mock_resp.read = MagicMock(return_value=body) + return mock_resp + + def test_probe_sends_hermes_user_agent(self): + from unittest.mock import patch + + body = b'{"data":[{"id":"claude-opus-4.7"}]}' + with patch( + "hermes_cli.models.urllib.request.urlopen", + return_value=self._make_mock_response(body), + ) as mock_urlopen: + result = probe_api_models("sk-test", "https://example.com/v1") + + assert result["models"] == ["claude-opus-4.7"] + # The urlopen call receives a Request object as its first positional arg + req = mock_urlopen.call_args[0][0] + ua = req.get_header("User-agent") # urllib title-cases header names + assert ua, "probe_api_models must send a User-Agent header" + assert ua.startswith("hermes-cli/"), ( + f"User-Agent must advertise hermes-cli, got {ua!r}" + ) + # Must not fall back to urllib's default — that's what Cloudflare 1010 blocks. + assert not ua.startswith("Python-urllib") + + def test_probe_user_agent_sent_without_api_key(self): + """UA must be present even for endpoints that don't need auth.""" + from unittest.mock import patch + + body = b'{"data":[]}' + with patch( + "hermes_cli.models.urllib.request.urlopen", + return_value=self._make_mock_response(body), + ) as mock_urlopen: + probe_api_models(None, "https://example.com/v1") + + req = mock_urlopen.call_args[0][0] + ua = req.get_header("User-agent") + assert ua and ua.startswith("hermes-cli/") + # No Authorization was set, but UA must still be present. + assert req.get_header("Authorization") is None