diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 96ae9adc8..c07b327e0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2900,11 +2900,16 @@ def _model_flow_named_custom(config, provider_info): name = provider_info["name"] base_url = provider_info["base_url"] + api_mode = provider_info.get("api_mode", "") api_key = provider_info.get("api_key", "") key_env = provider_info.get("key_env", "") saved_model = provider_info.get("model", "") provider_key = (provider_info.get("provider_key") or "").strip() + # Resolve key from env var if api_key not set directly + if not api_key and key_env: + api_key = os.environ.get(key_env, "") + print(f" Provider: {name}") print(f" URL: {base_url}") if saved_model: @@ -2912,7 +2917,10 @@ def _model_flow_named_custom(config, provider_info): print() print("Fetching available models...") - models = fetch_api_models(api_key, base_url, timeout=8.0) + models = fetch_api_models( + api_key, base_url, timeout=8.0, + api_mode=api_mode or None, + ) if models: default_idx = 0 diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index bfbb7ccc3..1bb8592a6 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -821,6 +821,7 @@ def switch_model( target_provider, api_key=api_key, base_url=base_url, + api_mode=api_mode or None, ) except Exception as e: validation = { diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 887b33ec7..72a4223b7 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2220,8 +2220,15 @@ def probe_api_models( api_key: Optional[str], base_url: Optional[str], timeout: float = 5.0, + api_mode: Optional[str] = None, ) -> dict[str, Any]: - """Probe an OpenAI-compatible ``/models`` endpoint with light URL heuristics.""" + """Probe a ``/models`` endpoint with light URL heuristics. + + For ``anthropic_messages`` mode, uses ``x-api-key`` and + ``anthropic-version`` headers (Anthropic's native auth) instead of + ``Authorization: Bearer``. The response shape (``data[].id``) is + identical, so the same parser works for both. + """ normalized = (base_url or "").strip().rstrip("/") if not normalized: return { @@ -2253,7 +2260,10 @@ def probe_api_models( tried: list[str] = [] headers: dict[str, str] = {"User-Agent": _HERMES_USER_AGENT} - if api_key: + if api_key and api_mode == "anthropic_messages": + headers["x-api-key"] = api_key + headers["anthropic-version"] = "2023-06-01" + elif api_key: headers["Authorization"] = f"Bearer {api_key}" if normalized.startswith(COPILOT_BASE_URL): headers.update(copilot_default_headers()) @@ -2318,13 +2328,14 @@ def fetch_api_models( api_key: Optional[str], base_url: Optional[str], timeout: float = 5.0, + api_mode: Optional[str] = None, ) -> Optional[list[str]]: """Fetch the list of available model IDs from the provider's ``/models`` endpoint. Returns a list of model ID strings, or ``None`` if the endpoint could not be reached (network error, timeout, auth failure, etc.). """ - return probe_api_models(api_key, base_url, timeout=timeout).get("models") + return probe_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode).get("models") # --------------------------------------------------------------------------- @@ -2452,6 +2463,7 @@ def validate_requested_model( *, api_key: Optional[str] = None, base_url: Optional[str] = None, + api_mode: Optional[str] = None, ) -> dict[str, Any]: """ Validate a ``/model`` value for the active provider. @@ -2493,7 +2505,11 @@ def validate_requested_model( } if normalized == "custom": - probe = probe_api_models(api_key, base_url) + # Try probing with correct auth for the api_mode. + if api_mode == "anthropic_messages": + probe = probe_api_models(api_key, base_url, api_mode=api_mode) + else: + probe = probe_api_models(api_key, base_url) api_models = probe.get("models") if api_models is not None: if requested_for_lookup in set(api_models): @@ -2542,12 +2558,17 @@ def validate_requested_model( f"Note: could not reach this custom endpoint's model listing at `{probe.get('probed_url')}`. " f"Hermes will still save `{requested}`, but the endpoint should expose `/models` for verification." ) + if api_mode == "anthropic_messages": + message += ( + "\n Many Anthropic-compatible proxies do not implement the Models API " + "(GET /v1/models). The model name has been accepted without verification." + ) if probe.get("suggested_base_url"): message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`" return { - "accepted": False, - "persist": False, + "accepted": api_mode == "anthropic_messages", + "persist": True, "recognized": False, "message": message, } @@ -2635,6 +2656,41 @@ def validate_requested_model( ), } + # Anthropic Messages API: many proxies don't implement /v1/models. + # Try probing with correct auth; if it fails, accept with a warning. + if api_mode == "anthropic_messages": + api_models = fetch_api_models(api_key, base_url, api_mode=api_mode) + if api_models is not None: + if requested_for_lookup in set(api_models): + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } + auto = get_close_matches(requested_for_lookup, api_models, n=1, cutoff=0.9) + if auto: + return { + "accepted": True, + "persist": True, + "recognized": True, + "corrected_model": auto[0], + "message": f"Auto-corrected `{requested}` → `{auto[0]}`", + } + # Probe failed or model not found — accept anyway (proxy likely + # doesn't implement the Anthropic Models API). + return { + "accepted": True, + "persist": True, + "recognized": False, + "message": ( + f"Note: could not verify `{requested}` against this endpoint's " + f"model listing. Many Anthropic-compatible proxies do not " + f"implement GET /v1/models. The model name has been accepted " + f"without verification." + ), + } + # Probe the live API to check if the model actually exists api_models = fetch_api_models(api_key, base_url) diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 913733028..80c7d2502 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -566,8 +566,11 @@ class TestValidateApiFallback: base_url="http://localhost:8000", ) + # Unreachable /models on a custom endpoint no longer hard-rejects — + # the model is persisted with a warning so Cloudflare-protected / + # proxy endpoints that don't expose /models still work. See #12950. assert result["accepted"] is False - assert result["persist"] is False + assert result["persist"] is True assert "http://localhost:8000/v1/models" in result["message"] assert "http://localhost:8000/v1" in result["message"]