# NousResearch/hermes-agent · Model validation fixes across multiple providers

## Open now

- Nothing open on GitHub.

A cluster of bugs and fixes centered on the /model command and picker incorrectly rejecting valid models for Gemini, Anthropic, Alibaba/DashScope, and MiniMax providers. Fixes address validation logic, unreachable /models endpoints, static catalog fallbacks, and provider-specific edge cases.

## TL;DR

- 20 member(s): 0 open issue(s), 8 closed issue(s), 0 open PR(s), 3 merged PR(s), 9 PR(s) closed without merging.
- A fix was merged to main in PR #12842. Verify it addresses your case.
- **Recommended:** read PR #12842 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-12842,pr-15136,pr-109195`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.09`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #7266: \[Bug\] coding.dashscope.aliyuncs.com 端点模型验证警告问题
- #11954: Bug: \`hermes model\` command fails to validate DashScope provider models
- #12153: Custom provider model validation fails when /v1/models endpoint is unavailable
- #12220: Support Bailian/DashScope endpoints that don't expose /v1/models
- #12272: fix(models): /model fails for Alibaba/DashScope coding endpoint — /models returns 404
- #12460: MiniMax provider model validation fails because /anthropic/models endpoint returns 404
- #12532: Gateway /model picker fails for Gemini and Anthropic providers (validate_requested_model rejects curated models)
- #13186: \[Bug\]: Regression: /model command validation blocks switching to Gemini models after native adapter update

## Prior & competing PRs

- **#12157: Fix custom provider model validation when /v1/models endpoint is unavailable**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#12153`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12287: fix(models): validate Alibaba coding-endpoint switches via catalog when /models 404s (#12272)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3f72b2fe1574fea279198f5e8f234ec386e945f3`, `#12272`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12842: fix(models): validate MiniMax models against static catalog**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/models.py`, `tests/test_minimax_model_validation.py`
- **#15136: fix(models): consolidated validation + picker — anthropic_messages, native Anthropic, Gemini prefix, OpenAI catalog**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `hermes_cli/model_switch.py`, `hermes_cli/models.py`, `scripts/release.py`, `tests/hermes_cli/test_model_validation.py`, `tests/hermes_cli/test_user_providers_model_switch.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -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 = [redacted:secret-assignment]"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 = [redacted:secret-assignment] "")
    +
         print(f"  Provider: {name}")
         print(f"  URL:      {base_url}")
         if saved_model:
    ```

    ```diff
    @@ -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
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -821,6 +821,7 @@ def switch_model(
                 target_provider,
                 api_key=[redacted:secret-assignment]
                 base_url=base_url,
    +            api_mode=api_mode or None,
             )
         except Exception as e:
             validation = {
    ```

    ```diff
    @@ -1239,6 +1240,15 @@ def list_authenticated_providers(
                         if m and m not in models_list:
                             models_list.append(m)
     
    +            # Official OpenAI API rows in providers: often have base_url but no
    +            # explicit models: dict — avoid a misleading zero count in /model.
    +            if not models_list:
    +                url_lower = str(api_url).strip().lower()
    +                if "api.openai.com" in url_lower:
    +                    fb = curated.get("openai") or []
    +                    if fb:
    +                        models_list = list(fb)
    +
                 # Try to probe /v1/models if URL is set (but don't block on it)
                 # For now just show what we know from config
                 results.append({
    ```
  - `hermes_cli/models.py`

    ```diff
    @@ -142,6 +142,18 @@ def _codex_curated_models() -> list[str]:
             "openai/gpt-5.4-pro",
             "openai/gpt-5.4-nano",
         ],
    +    # Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
    +    # provider_model_ids fallback when /v1/models is unavailable.
    +    "openai": [
    +        "gpt-5.4",
    +        "gpt-5.4-mini",
    +        "gpt-5-mini",
    +        "gpt-5.3-codex",
    +        "gpt-5.2-codex",
    +        "gpt-4.1",
    +        "gpt-4o",
    +        "gpt-4o-mini",
    +    ],
         "openai-codex": _codex_curated_models(),
         "copilot-acp": [
             "copilot-acp",
    ```

    ```diff
    @@ -1748,6 +1760,17 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
             live = fetch_ollama_cloud_models(force_refresh=force_refresh)
             if live:
                 return live
    +    if normalized == "openai":
    +        api_key = [redacted:secret-assignment]"OPENAI_API_KEY", "").strip()
    +        if api_key:
    +            base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
    +            base = base_raw or "https://api.openai.com/v1"
    +            try:
    +                live = fetch_api_models(api_key, base)
    +                if live:
    +                    return live
    +            except Exception:
    +                pass
         if normalized == "custom":
             base_url = _get_custom_base_url()
             if base_url:
    ```

    ```diff
    @@ -2220,8 +2243,15 @@ def probe_api_models(
         api_key: [redacted:secret-assignment]
         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 {
    ```

    ```diff
    @@ -2253,7 +2283,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:
             [redacted:secret-assignment]"Authorization"] = f"Bearer {api_key}"
         if normalized.startswith(COPILOT_BASE_URL):
             headers.update(copilot_default_headers())
    ```

    ```diff
    @@ -2318,13 +2351,14 @@ def fetch_api_models(
         api_key: [redacted:secret-assignment]
         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")
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -2452,6 +2486,7 @@ def validate_requested_model(
         *,
         api_key: [redacted:secret-assignment] = None,
         base_url: Optional[str] = None,
    +    api_mode: Optional[str] = None,
     ) -> dict[str, Any]:
         """
         Validate a ``/model`` value for the active provider.
    ```

    ```diff
    @@ -2493,7 +2528,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):
    ```

    ```diff
    @@ -2542,12 +2581,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,
             }
    ```

    ```diff
    @@ -2635,10 +2679,100 @@ def validate_requested_model(
                     ),
                 }
     
    +    # Native Anthropic provider: /v1/models requires x-api-key (or Bearer for
    +    # OAuth) plus anthropic-version headers.  The generic OpenAI-style probe
    +    # below uses plain Bearer auth and 401s against Anthropic, so dispatch to
    +    # the native fetcher which handles both API keys and Claude-Code OAuth
    +    # tokens.  (The api_mode=="anthropic_messages" branch below handles the
    +    # Messages-API transport case separately.)
    +    if normalized == "anthropic":
    +        anthropic_models = _fetch_anthropic_models()
    +        if anthropic_models is not None:
    +            if requested_for_lookup in set(anthropic_models):
    +                return {
    +                    "accepted": True,
    +                    "persist": True,
    +                    "recognized": True,
    +                    "message": None,
    +                }
    +            auto = get_close_matches(requested_for_lookup, anthropic_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]}`",
    +                }
    +            suggestions = get_close_matches(requested, anthropic_models, n=3, cutoff=0.5)
    +            suggestion_text = ""
    +            if suggestions:
    +                suggestion_text = "\n  Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
    +            # Accept anyway — Anthropic sometimes gates newer/preview models
    +            # (e.g. snapshot IDs, early-access releases) behind accounts
    +            # even though they aren't listed on /v1/models.
    +            return {
    +                "accepted": True,
    +                "persist": True,
    +                "recognized": False,
    +                "message": (
    +                    f"Note: `{requested}` was not found in Anthropic's /v1/models listing. "
    +                    f"It may still work if you have early-access or snapshot IDs."
    +                    f"{suggestion_text}"
    +                ),
    +            }
    +        # _fetch_anthropic_models returned None — no token resolvable or
    +        # network failure.  Fall through to the generic warning below.
    +
    +    # 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)
     
         if api_models is not None:
    +        # Gemini's OpenAI-compat /v1beta/openai/models endpoint returns IDs
    +        # prefixed with "models/" (e.g. "models/gemini-2.5-flash") — native
    +        # Gemini-API convention.  Our curated list and user input both use
    +        # the bare ID, so a direct set-membership check drops every known
    +        # Gemini model.  Strip the prefix before comparison.  See #12532.
    +        if normalized == "gemini":
    +            api_models = [
    +                m[len("models/"):] if isinstance(m, str) and m.startswith("models/") else m
    +                for m in api_models
    +            ]
             if requested_for_lookup in set(api_models):
                 # API confirmed the model exists
                 return {
    ```
  - `scripts/release.py`

    ```diff
    @@ -192,6 +192,9 @@
         "[redacted:email]": "LeonSGP43",
         "[redacted:email]": "vlwkaos",
         "[redacted:email]": "Tranquil-Flow",
    +    "[redacted:email]": "Wangshengyang2004",
    +    "[redacted:email]": "H-Ali13381",
    +    "[redacted:email]": "XieNBi",
         "[redacted:email]": "nummanali",
         "[redacted:email]": "whitehatjr1001",
         "[redacted:email]": "0xNyk",
    ```
  - `tests/hermes_cli/test_model_validation.py`

    ```diff
    @@ -566,8 +566,11 @@ def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self):
                     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"]
     
    ```
  - `tests/hermes_cli/test_user_providers_model_switch.py`

    ```diff
    @@ -197,6 +197,58 @@ def test_list_authenticated_providers_dict_models_dedupe_with_default(monkeypatc
         assert user_prov["models"].count("model-a") == 1
     
     
    +def test_openai_native_curated_catalog_is_non_empty():
    +    """Regression: built-in openai must have a static catalog for picker totals."""
    +    from hermes_cli.models import _PROVIDER_MODELS
    +
    +    assert _PROVIDER_MODELS.get("openai")
    +    assert len(_PROVIDER_MODELS["openai"]) >= 4
    +
    +
    +def test_list_authenticated_providers_openai_built_in_nonzero_total(monkeypatch):
    +    """Built-in openai row must not report total_models=0 when creds exist."""
    +    monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
    +    monkeypatch.setattr(
    +        "agent.models_dev.fetch_models_dev",
    +        lambda: {"openai": {"env": ["OPENAI_API_KEY"]}},
    +    )
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    providers = list_authenticated_providers(
    +        current_provider="",
    +        current_base_url="",
    +        user_providers={},
    +        custom_providers=[],
    +        max_models=50,
    +    )
    +    row = next((p for p in providers if p.get("slug") == "openai"), None)
    +    assert row is not None
    +    assert row["total_models"] > 0
    +
    +
    +def test_list_authenticated_providers_user_openai_official_url_fallback(monkeypatch):
    +    """User providers: api.openai.com with no models list uses native curated fallback."""
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
    +
    +    user_providers = {
    +        "openai-direct": {
    +            "name": "OpenAI Direct",
    +            "api": "https://api.openai.com/v1",
    +        }
    +    }
    +    providers = list_authenticated_providers(
    +        current_provider="",
    +        current_base_url="",
    +        user_providers=user_providers,
    +        custom_providers=[],
    +        max_models=50,
    +    )
    +    row = next((p for p in providers if p.get("slug") == "openai-direct"), None)
    +    assert row is not None
    +    assert row["total_models"] > 0
    +
    +
     def test_list_authenticated_providers_fallback_to_default_only(monkeypatch):
         """When no models array is provided, should fall back to default_model."""
         monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    ```
- **#109195: fix(cli): custom endpoints without GET /models no longer reject \`/model\` switches (#12220, salvage #96379)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/models_validate.py`, `tests/hermes_cli/test_model_validation.py`
- **#11967: fix(models): accept known DashScope models offline**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3f72b2fe1574fea279198f5e8f234ec386e945f3`, `#12287`, `#12272`
- **#12585: fix(models): accept Gemini + Anthropic in gateway /model picker (#12532)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7f26cea39`, `2303dd868`, `#15136`, `#12618`
- **#12829: fix(models): validate MiniMax models against static catalog**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#12842`, `#12611`, `#12460`, `#12399`, `#12547`
- **#13557: fix(gemini): validate native model switches against the AI Studio catalog**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3f72b2fe1574fea279198f5e8f234ec386e945f3`, `7f26cea390664c0cd39da27447f40556ee8664ac`, `#15136`, `#13186`, `#13952`
- **#13952: fix(cli): validate Gemini models against curated catalog instead of probing /models**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3f72b2fe1574fea279198f5e8f234ec386e945f3`
- **#94587: fix(cli): accept custom models without catalog listings**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#96379`
- **#96379: fix(cli): accept custom models without catalog listings**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `71c72e078a`, `#109195`

## Fix status

- Merged to main in PR #12842 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-12`.
- Subsystem: `hermes_cli`
