fix(providers): support anthropic proxy v1 endpoints

This commit is contained in:
helix4u 2026-06-13 16:02:23 -06:00 committed by Teknium
parent 81e42335a1
commit 85e6232a07
8 changed files with 133 additions and 14 deletions

View file

@ -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 {

View file

@ -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"