Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
67 changes: 57 additions & 10 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import json
import os
import urllib.parse
import urllib.request
import urllib.error
import time
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import os
import re
from urllib.parse import urlparse
from typing import Any, Dict, Optional

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions tests/agent/test_anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ def test_custom_base_url(self):
"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(
Expand Down
2 changes: 2 additions & 0 deletions tests/agent/test_auxiliary_transport_autodetect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
7 changes: 5 additions & 2 deletions tests/hermes_cli/test_detect_api_mode_for_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,16 @@ def test_dashscope_anthropic_endpoint(self):
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:
Expand Down
52 changes: 52 additions & 0 deletions tests/hermes_cli/test_model_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,58 @@ def test_copilot_acp_reuses_copilot_catalog(self):
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 --------------------------------------------------------

Expand Down
Loading