fix(providers): support anthropic proxy v1 endpoints

This commit is contained in:
helix4u
2026-06-14 02:09:16 -07:00
committed by Teknium
parent 81e42335a1
commit 85e6232a07
8 changed files with 133 additions and 14 deletions
+3
View File
@@ -751,6 +751,9 @@ def build_anthropic_client(
from httpx import Timeout from httpx import Timeout
normalized_base_url = _normalize_base_url_text(base_url) 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 _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
kwargs = { kwargs = {
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0), "timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
+2 -1
View File
@@ -1144,7 +1144,8 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
normalized = (base_url or "").strip().lower().rstrip("/") normalized = (base_url or "").strip().lower().rstrip("/")
if not normalized: if not normalized:
return False return False
if normalized.endswith("/anthropic"): path = urlparse(normalized).path.rstrip("/")
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
return True return True
hostname = base_url_hostname(normalized) hostname = base_url_hostname(normalized)
if hostname == "api.anthropic.com": if hostname == "api.anthropic.com":
+57 -10
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json import json
import os import os
import urllib.parse
import urllib.request import urllib.request
import urllib.error import urllib.error
import time import time
@@ -1690,15 +1691,36 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
def _get_custom_base_url() -> str: def _get_custom_base_url() -> str:
"""Get the custom endpoint base_url from config.yaml.""" """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: try:
from hermes_cli.config import load_config from hermes_cli.config import load_config
config = load_config() config = load_config()
model_cfg = config.get("model", {}) model_cfg = config.get("model", {})
if isinstance(model_cfg, dict): if isinstance(model_cfg, dict):
return str(model_cfg.get("base_url", "")).strip() return model_cfg
except Exception: except Exception:
pass 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( def curated_models_for_provider(
@@ -2218,8 +2240,21 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
except Exception: except Exception:
pass pass
if normalized == "anthropic": 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 live:
if cfg_base_url:
return live
# The live /v1/models dump lags newly-routed curated aliases # The live /v1/models dump lags newly-routed curated aliases
# (e.g. claude-fable-5, which is reachable on Anthropic before it # (e.g. claude-fable-5, which is reachable on Anthropic before it
# is enumerated by the models endpoint). Surface curated entries # 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": if normalized == "custom":
base_url = _get_custom_base_url() base_url = _get_custom_base_url()
if base_url: if base_url:
model_cfg = _get_model_config_dict()
# Try common API key env vars for custom endpoints # Try common API key env vars for custom endpoints
api_key = ( 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("OPENAI_API_KEY", "")
or os.getenv("OPENROUTER_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: if live:
return live return live
# Bedrock uses live discovery keyed by the resolved AWS region so that # 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 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. """Fetch available models from the Anthropic /v1/models endpoint.
Uses resolve_anthropic_token() to find credentials (env vars or 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: try:
from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token
except ImportError: except ImportError:
return None return None
token = resolve_anthropic_token() token = (api_key or "").strip() or resolve_anthropic_token()
if not token: if not token:
return None return None
@@ -2569,7 +2613,7 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
def _do_request(h: dict[str, str]): def _do_request(h: dict[str, str]):
req = urllib.request.Request( req = urllib.request.Request(
"https://api.anthropic.com/v1/models", _anthropic_models_url(base_url),
headers=h, headers=h,
) )
with urllib.request.urlopen(req, timeout=timeout) as resp: 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 # tokens. (The api_mode=="anthropic_messages" branch below handles the
# Messages-API transport case separately.) # Messages-API transport case separately.)
if normalized == "anthropic": 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 anthropic_models is not None:
if requested_for_lookup in set(anthropic_models): if requested_for_lookup in set(anthropic_models):
return { return {
+3 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
import os import os
import re import re
from urllib.parse import urlparse
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -93,7 +94,8 @@ def _detect_api_mode_for_url(base_url: str) -> Optional[str]:
return "codex_responses" return "codex_responses"
if hostname == "api.openai.com": if hostname == "api.openai.com":
return "codex_responses" return "codex_responses"
if normalized.endswith("/anthropic"): path = urlparse(normalized).path.rstrip("/")
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
return "anthropic_messages" return "anthropic_messages"
if hostname == "api.kimi.com" and "/coding" in normalized: if hostname == "api.kimi.com" and "/coding" in normalized:
return "anthropic_messages" return "anthropic_messages"
+9
View File
@@ -113,6 +113,15 @@ class TestBuildAnthropicClient:
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" "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): def test_azure_anthropic_endpoint_keeps_context_1m_beta(self):
with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk:
build_anthropic_client( build_anthropic_client(
@@ -39,6 +39,8 @@ def _clean_env(monkeypatch):
("https://api.moonshot.ai/v1", False, "Moonshot legacy"), ("https://api.moonshot.ai/v1", False, "Moonshot legacy"),
("https://api.minimax.io/anthropic", True, "MiniMax /anthropic"), ("https://api.minimax.io/anthropic", True, "MiniMax /anthropic"),
("https://litellm.example.com/v1/anthropic", True, "/anthropic suffix"), ("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", True, "native Anthropic"),
("https://api.anthropic.com/v1", True, "native Anthropic /v1"), ("https://api.anthropic.com/v1", True, "native Anthropic /v1"),
("https://openrouter.ai/api/v1", False, "OpenRouter"), ("https://openrouter.ai/api/v1", False, "OpenRouter"),
@@ -56,13 +56,16 @@ class TestAnthropicMessagesDetection:
def test_trailing_slash_tolerated(self): def test_trailing_slash_tolerated(self):
assert _detect_api_mode_for_url("https://api.minimax.io/anthropic/") == "anthropic_messages" 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): def test_uppercase_path_tolerated(self):
assert _detect_api_mode_for_url("https://API.MINIMAX.IO/Anthropic") == "anthropic_messages" 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. # The helper requires ``/anthropic`` as the path SUFFIX, not anywhere.
# Protects against false positives on e.g. /anthropic/v1/models. # 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: class TestDefaultCase:
+52
View File
@@ -215,6 +215,58 @@ class TestProviderModelIds:
patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]): 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"] 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 -------------------------------------------------------- # -- fetch_api_models --------------------------------------------------------