From 85e6232a0716fd6df5f061d0e1eae55ff40d1e2c Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:02:23 -0600 Subject: [PATCH] fix(providers): support anthropic proxy v1 endpoints --- agent/anthropic_adapter.py | 3 + agent/auxiliary_client.py | 3 +- hermes_cli/models.py | 67 ++++++++++++++++--- hermes_cli/runtime_provider.py | 4 +- tests/agent/test_anthropic_adapter.py | 9 +++ .../test_auxiliary_transport_autodetect.py | 2 + .../test_detect_api_mode_for_url.py | 7 +- tests/hermes_cli/test_model_validation.py | 52 ++++++++++++++ 8 files changed, 133 insertions(+), 14 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 8476ef67f57..3a2d3f68e17 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -751,6 +751,9 @@ def build_anthropic_client( from httpx import Timeout normalized_base_url = _normalize_base_url_text(base_url) + if normalized_base_url: + import re as _re + normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/")) _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0 kwargs = { "timeout": Timeout(timeout=float(_read_timeout), connect=10.0), diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index f3dbe8d9dfd..01ea45d7be2 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1144,7 +1144,8 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool: normalized = (base_url or "").strip().lower().rstrip("/") if not normalized: return False - if normalized.endswith("/anthropic"): + path = urlparse(normalized).path.rstrip("/") + if path.endswith("/anthropic") or path.endswith("/anthropic/v1"): return True hostname = base_url_hostname(normalized) if hostname == "api.anthropic.com": diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 9b473f05606..afab5bac32d 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -9,6 +9,7 @@ from __future__ import annotations import json import os +import urllib.parse import urllib.request import urllib.error import time @@ -1690,15 +1691,36 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]: def _get_custom_base_url() -> str: """Get the custom endpoint base_url from config.yaml.""" + model_cfg = _get_model_config_dict() + return str(model_cfg.get("base_url", "")).strip() + + +def _get_model_config_dict() -> dict[str, Any]: + """Return the main model config mapping, or an empty dict.""" try: from hermes_cli.config import load_config config = load_config() model_cfg = config.get("model", {}) if isinstance(model_cfg, dict): - return str(model_cfg.get("base_url", "")).strip() + return model_cfg except Exception: pass - return "" + return {} + + +def _base_url_looks_like_anthropic_messages(base_url: str) -> bool: + normalized = str(base_url or "").strip().lower().rstrip("/") + if not normalized: + return False + path = urllib.parse.urlparse(normalized).path.rstrip("/") + return path.endswith("/anthropic") or path.endswith("/anthropic/v1") + + +def _anthropic_models_url(base_url: Optional[str] = None) -> str: + endpoint = str(base_url or "https://api.anthropic.com").strip().rstrip("/") + if endpoint.endswith("/v1"): + return endpoint + "/models" + return endpoint + "/v1/models" def curated_models_for_provider( @@ -2218,8 +2240,21 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) except Exception: pass if normalized == "anthropic": - live = _fetch_anthropic_models() + model_cfg = _get_model_config_dict() + cfg_provider = normalize_provider(str(model_cfg.get("provider", "") or "")) + if cfg_provider == "anthropic": + cfg_base_url = str(model_cfg.get("base_url", "") or "").strip() + cfg_api_key = str(model_cfg.get("api_key", "") or "").strip() + else: + cfg_base_url = "" + cfg_api_key = "" + live = _fetch_anthropic_models( + base_url=cfg_base_url or None, + api_key=cfg_api_key or None, + ) if live: + if cfg_base_url: + return live # The live /v1/models dump lags newly-routed curated aliases # (e.g. claude-fable-5, which is reachable on Anthropic before it # is enumerated by the models endpoint). Surface curated entries @@ -2288,13 +2323,16 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) if normalized == "custom": base_url = _get_custom_base_url() if base_url: + model_cfg = _get_model_config_dict() # Try common API key env vars for custom endpoints api_key = ( - os.getenv("CUSTOM_API_KEY", "") + str(model_cfg.get("api_key", "") or "").strip() + or os.getenv("CUSTOM_API_KEY", "") or os.getenv("OPENAI_API_KEY", "") or os.getenv("OPENROUTER_API_KEY", "") ) - live = fetch_api_models(api_key, base_url) + api_mode = "anthropic_messages" if _base_url_looks_like_anthropic_messages(base_url) else None + live = fetch_api_models(api_key, base_url, api_mode=api_mode) if live: return live # Bedrock uses live discovery keyed by the resolved AWS region so that @@ -2543,18 +2581,24 @@ def clear_provider_models_cache(provider: Optional[str] = None) -> None: pass -def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: +def _fetch_anthropic_models( + timeout: float = 5.0, + *, + base_url: Optional[str] = None, + api_key: Optional[str] = None, +) -> Optional[list[str]]: """Fetch available models from the Anthropic /v1/models endpoint. Uses resolve_anthropic_token() to find credentials (env vars or - Claude Code auto-discovery). Returns sorted model IDs or None. + Claude Code auto-discovery) unless api_key is provided explicitly. + Returns sorted model IDs or None. """ try: from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token except ImportError: return None - token = resolve_anthropic_token() + token = (api_key or "").strip() or resolve_anthropic_token() if not token: return None @@ -2569,7 +2613,7 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: def _do_request(h: dict[str, str]): req = urllib.request.Request( - "https://api.anthropic.com/v1/models", + _anthropic_models_url(base_url), headers=h, ) with urllib.request.urlopen(req, timeout=timeout) as resp: @@ -3759,7 +3803,10 @@ def validate_requested_model( # tokens. (The api_mode=="anthropic_messages" branch below handles the # Messages-API transport case separately.) if normalized == "anthropic": - anthropic_models = _fetch_anthropic_models() + anthropic_models = _fetch_anthropic_models( + base_url=base_url or None, + api_key=api_key or None, + ) if anthropic_models is not None: if requested_for_lookup in set(anthropic_models): return { diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 5b675074c5e..909cbe07a08 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import os import re +from urllib.parse import urlparse from typing import Any, Dict, Optional logger = logging.getLogger(__name__) @@ -93,7 +94,8 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]: return "codex_responses" if hostname == "api.openai.com": return "codex_responses" - if normalized.endswith("/anthropic"): + path = urlparse(normalized).path.rstrip("/") + if path.endswith("/anthropic") or path.endswith("/anthropic/v1"): return "anthropic_messages" if hostname == "api.kimi.com" and "/coding" in normalized: return "anthropic_messages" diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 79c9c286ecf..2a2f236b9a3 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -113,6 +113,15 @@ class TestBuildAnthropicClient: "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" } + def test_custom_base_url_strips_trailing_v1(self): + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "sk-ant-api03-x", + base_url="https://proxy.example.com/anthropic/v1", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["base_url"] == "https://proxy.example.com/anthropic" + def test_azure_anthropic_endpoint_keeps_context_1m_beta(self): with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: build_anthropic_client( diff --git a/tests/agent/test_auxiliary_transport_autodetect.py b/tests/agent/test_auxiliary_transport_autodetect.py index eccb03de0d6..dcbf75d7885 100644 --- a/tests/agent/test_auxiliary_transport_autodetect.py +++ b/tests/agent/test_auxiliary_transport_autodetect.py @@ -39,6 +39,8 @@ def _clean_env(monkeypatch): ("https://api.moonshot.ai/v1", False, "Moonshot legacy"), ("https://api.minimax.io/anthropic", True, "MiniMax /anthropic"), ("https://litellm.example.com/v1/anthropic", True, "/anthropic suffix"), + ("https://litellm.example.com/anthropic/v1", True, "/anthropic/v1 base"), + ("https://litellm.example.com/anthropic/v1/models", False, "/anthropic/v1 subpath"), ("https://api.anthropic.com", True, "native Anthropic"), ("https://api.anthropic.com/v1", True, "native Anthropic /v1"), ("https://openrouter.ai/api/v1", False, "OpenRouter"), diff --git a/tests/hermes_cli/test_detect_api_mode_for_url.py b/tests/hermes_cli/test_detect_api_mode_for_url.py index f758570ea58..e9ee41dea71 100644 --- a/tests/hermes_cli/test_detect_api_mode_for_url.py +++ b/tests/hermes_cli/test_detect_api_mode_for_url.py @@ -56,13 +56,16 @@ class TestAnthropicMessagesDetection: def test_trailing_slash_tolerated(self): assert _detect_api_mode_for_url("https://api.minimax.io/anthropic/") == "anthropic_messages" + def test_versioned_anthropic_base_url_tolerated(self): + assert _detect_api_mode_for_url("https://proxy.example.com/anthropic/v1") == "anthropic_messages" + def test_uppercase_path_tolerated(self): assert _detect_api_mode_for_url("https://API.MINIMAX.IO/Anthropic") == "anthropic_messages" - def test_anthropic_in_middle_of_path_does_not_match(self): + def test_anthropic_endpoint_subpath_does_not_match(self): # The helper requires ``/anthropic`` as the path SUFFIX, not anywhere. # Protects against false positives on e.g. /anthropic/v1/models. - assert _detect_api_mode_for_url("https://api.example.com/anthropic/v1") is None + assert _detect_api_mode_for_url("https://api.example.com/anthropic/v1/models") is None class TestDefaultCase: diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 89465b6c6c7..f5d356055c3 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -215,6 +215,58 @@ class TestProviderModelIds: patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]): assert provider_model_ids("copilot-acp") == ["gpt-5.4", "claude-sonnet-4.6"] + def test_anthropic_provider_uses_configured_base_url_for_live_catalog(self): + class _Resp: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{"data": [{"id": "enterprise-claude"}]}' + + with patch( + "hermes_cli.config.load_config", + return_value={ + "model": { + "provider": "anthropic", + "base_url": "http://localhost:6655/anthropic/v1", + "api_key": "proxy-key", + } + }, + ), patch( + "hermes_cli.models.urllib.request.urlopen", + return_value=_Resp(), + ) as mock_urlopen: + assert provider_model_ids("anthropic") == ["enterprise-claude"] + + req = mock_urlopen.call_args[0][0] + assert req.full_url == "http://localhost:6655/anthropic/v1/models" + assert req.get_header("X-api-key") == "proxy-key" + + def test_custom_provider_passes_anthropic_mode_for_versioned_proxy_catalog(self): + with patch( + "hermes_cli.config.load_config", + return_value={ + "model": { + "provider": "custom", + "base_url": "http://localhost:6655/anthropic/v1", + "api_key": "proxy-key", + } + }, + ), patch( + "hermes_cli.models.fetch_api_models", + return_value=["enterprise-claude"], + ) as mock_fetch: + assert provider_model_ids("custom") == ["enterprise-claude"] + + mock_fetch.assert_called_once_with( + "proxy-key", + "http://localhost:6655/anthropic/v1", + api_mode="anthropic_messages", + ) + # -- fetch_api_models --------------------------------------------------------