"""Regression tests for /model support of config.yaml custom_providers.

The terminal `hermes model` flow already exposes `custom_providers`, but the
shared slash-command pipeline (`/model` in CLI/gateway/Telegram) historically
only looked at `providers:`.

Fixtures that set ``discover_models=False`` deliberately opt out of live
endpoint probing; they test saved/configured-provider behavior rather than a
local model server. The live-probe cases set it separately when discovery is
the behavior under test.
"""

import time

import hermes_cli.providers as providers_mod
import pytest
import hermes_yaml as yaml
from hermes_cli.model_switch import list_authenticated_providers, switch_model
from hermes_cli.model_switch_providers import (
    _fetch_picker_live_models,
    _NativePickerModelList,
    _save_discovered_models_to_config,
)
from hermes_cli.providers import resolve_provider_full


_MOCK_VALIDATION = {
    "accepted": True,
    "persist": True,
    "recognized": True,
    "message": None,
}


@pytest.fixture(autouse=True)
def _disable_live_custom_provider_model_probe(monkeypatch):
    """Keep custom-provider picker fixtures independent of local model servers."""
    monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *_a, **_kw: None)
    monkeypatch.setattr(
        "hermes_cli.models.cached_provider_model_ids", lambda *_a, **_kw: []
    )
    monkeypatch.setattr(
        "hermes_cli.models.provider_model_ids", lambda *_a, **_kw: []
    )
    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_ollama_local_models", lambda *_a, **_kw: None
    )


def test_picker_native_probe_failure_falls_back_to_openai_catalog(monkeypatch):
    monkeypatch.setattr(
        "hermes_cli.models_local.should_use_ollama_native_catalog", lambda *a, **k: True
    )
    monkeypatch.setattr(
        "hermes_cli.models._get_ollama_native_headers", lambda *a, **k: {}
    )
    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_ollama_local_models", lambda *a, **k: None
    )
    monkeypatch.setattr(
        "hermes_cli.models.fetch_api_models", lambda *a, **k: ["fallback-model"]
    )

    assert _fetch_picker_live_models(
        "key", "http://127.0.0.1:11434/v1", "ollama", False
    ) == ["fallback-model"]


def test_picker_native_catalog_is_admitted_to_the_shared_model_cache(monkeypatch):
    """A live ``/api/tags`` probe must land in ``provider_models_cache.json``.

    Only the CURRENT custom endpoint is probed on a normal picker open; every other one is
    served from that file (``cache_only``). A native catalog that answered the probe but was
    never stored therefore read back empty on the next open, and the row's whole provider group
    disappeared from the picker until someone hit Refresh Models. The round-trip stays a
    ``_NativePickerModelList``: the native flag is what lets a genuinely model-less Ollama
    persist an authoritative empty catalog. The entry is keyed on what the no-probe read hashes
    (api_key + the caller's headers), not on the Authorization header the native probe
    synthesizes from the key — a keyed endpoint otherwise wrote a row nobody could read back.
    """
    monkeypatch.setattr(
        "hermes_cli.models_local.should_use_ollama_native_catalog", lambda *a, **k: True
    )
    monkeypatch.setattr("hermes_cli.models._get_ollama_native_headers", lambda *a, **k: {})
    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_ollama_local_models", lambda *a, **k: ["qwen3:8b"]
    )

    from hermes_cli.models import cached_fetch_api_models

    url = "http://127.0.0.1:11434/v1"
    assert _fetch_picker_live_models("sk-ollama", url, "custom", False) == ["qwen3:8b"]

    no_probe = cached_fetch_api_models("sk-ollama", url, cache_only=True, timeout=1.5)
    assert isinstance(no_probe, _NativePickerModelList)
    assert no_probe == ["qwen3:8b"]


def test_picker_native_catalog_skips_cache_admission_when_cache_is_off(monkeypatch):
    """``cache=False`` is the inner call the callable-key path makes to stay token-lazy.

    It must keep probing without admitting anything, or a command-token provider would be
    minted and persisted outside the cache-entry decision that exists to avoid that.
    """
    monkeypatch.setattr(
        "hermes_cli.models_local.should_use_ollama_native_catalog", lambda *a, **k: True
    )
    monkeypatch.setattr("hermes_cli.models._get_ollama_native_headers", lambda *a, **k: {})
    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_ollama_local_models", lambda *a, **k: ["qwen3:8b"]
    )

    from hermes_cli.models import cached_fetch_api_models

    url = "http://127.0.0.1:11434/v1"
    assert _fetch_picker_live_models(None, url, "custom", False, cache=False) == ["qwen3:8b"]

    assert cached_fetch_api_models(None, url, cache_only=True, timeout=1.5) is None


def _native_picker_probe(monkeypatch, models_by_call):
    """Native Ollama detection on, ``/api/tags`` answering successive ``models_by_call``."""
    monkeypatch.setattr(
        "hermes_cli.models_local.should_use_ollama_native_catalog", lambda *a, **k: True
    )
    monkeypatch.setattr("hermes_cli.models._get_ollama_native_headers", lambda *a, **k: {})
    answers = iter(models_by_call)
    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_ollama_local_models", lambda *a, **k: next(answers)
    )


def _age_cached_rows(seconds):
    from hermes_cli import models as models_mod

    cache = models_mod._load_provider_models_cache()
    for row in cache.values():
        row["at"] -= seconds
    for key, row in cache.items():
        models_mod._store_cache_entry(key, row, cache)


def test_picker_native_catalog_uses_the_short_native_ttl(monkeypatch):
    """The current endpoint's native row must expire on the 300s Ollama TTL, not the 1h generic one.

    A model pulled after the first picker open otherwise stays invisible for up to an hour;
    ``cached_provider_model_ids`` clamps the built-in ``ollama`` slug the same way. Past the
    native TTL the row is stale: served once, with a background refresh scheduled.
    """
    from hermes_cli.models_local import _OLLAMA_LOCAL_MODELS_CACHE_TTL

    _native_picker_probe(monkeypatch, [["qwen3:8b"]])
    url = "http://127.0.0.1:11434/v1"
    assert _fetch_picker_live_models("sk-ollama", url, "custom", False) == ["qwen3:8b"]

    refreshes = []
    monkeypatch.setattr(
        "hermes_cli.models._spawn_swr_refresh", lambda key, fn=None: refreshes.append(key)
    )
    _age_cached_rows(_OLLAMA_LOCAL_MODELS_CACHE_TTL + 1)
    assert _fetch_picker_live_models("sk-ollama", url, "custom", False) == ["qwen3:8b"]
    assert len(refreshes) == 1, "row older than the native TTL must be revalidated, not fresh"


def test_picker_empty_native_catalog_is_not_stale_served(monkeypatch):
    """An authoritative empty native row is valid only inside the TTL.

    Beyond it the probe must run again, or an Ollama that was model-less at first open keeps
    an empty picker row for the whole 7-day stale window after models are pulled.
    """
    from hermes_cli.models import _PROVIDER_MODELS_CACHE_TTL

    _native_picker_probe(monkeypatch, [[], ["back:latest"]])
    url = "http://127.0.0.1:11434/v1"
    assert _fetch_picker_live_models("sk-ollama", url, "custom", False) == []

    _age_cached_rows(_PROVIDER_MODELS_CACHE_TTL + 100)
    assert _fetch_picker_live_models("sk-ollama", url, "custom", False) == ["back:latest"]




def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: [])

    providers = list_authenticated_providers(
        current_provider="openai-codex",
        user_providers={},
        custom_providers=[
            {
                "name": "Local (127.0.0.1:4141)",
                "base_url": "http://127.0.0.1:4141/v1",
                "model": "rotator-openrouter-coding",
            }
        ],
        max_models=50,
    )

    assert any(
        p["slug"] == "custom:local-(127.0.0.1:4141)"
        and p["name"] == "Local (127.0.0.1:4141)"
        and p["models"] == ["rotator-openrouter-coding"]
        and p["api_url"] == "http://127.0.0.1:4141/v1"
        for p in providers
    )



def test_list_authenticated_providers_numeric_yaml_provider_dict_key(monkeypatch):
    """Unquoted YAML `providers: {2070: ...}` must not 500 the Model tab."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: [])

    rows = list_authenticated_providers(
        current_provider=2070,
        current_base_url="http://192.168.1.10:8082/v1",
        current_model="Qwen3.5-9B-Q4_K_M.gguf",
        user_providers={
            2070: {
                "name": 2070,
                "base_url": "http://192.168.1.10:8082/v1",
                "model": "Qwen3.5-9B-Q4_K_M.gguf",
            }
        },
        custom_providers=[],
        max_models=0,
        probe_custom_providers=False,
    )

    match = next(p for p in rows if str(p.get("slug")) == "2070")
    assert match["name"] == "2070"
    assert match.get("is_current") is True


def test_list_authenticated_providers_numeric_custom_provider_name(monkeypatch):
    """Legacy custom_providers list with name: 2070 (int) must not .strip() crash."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: [])

    rows = list_authenticated_providers(
        current_provider=2070,
        current_base_url="http://192.168.1.10:8082/v1",
        current_model="Qwen3.5-9B-Q4_K_M.gguf",
        user_providers={},
        custom_providers=[
            {
                "name": 2070,
                "base_url": "http://192.168.1.10:8082/v1",
                "model": "Qwen3.5-9B-Q4_K_M.gguf",
            }
        ],
        max_models=0,
        probe_custom_providers=False,
    )

    assert any(
        str(p.get("name")) == "2070" or "2070" in str(p.get("slug"))
        for p in rows
    )


def test_providers_singular_model_does_not_suppress_ollama_native_discovery(monkeypatch):
    """A saved selection in ``providers:`` is not an explicit catalog."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_ollama_local_models",
        lambda *a, **k: ["qwen3:latest", "llama3.2:latest"],
    )

    providers = list_authenticated_providers(
        current_provider="openai-codex",
        user_providers={
            "ollama": {
                "base_url": "http://localhost:11434/v1",
                "model": "qwen3:latest",
            }
        },
        custom_providers=[],
        max_models=50,
    )

    ollama = next(p for p in providers if p["slug"] == "ollama")
    assert ollama["models"] == ["qwen3:latest", "llama3.2:latest"]


def test_list_splits_comma_chain_custom_provider_model(monkeypatch):
    """A comma-separated custom-provider ``model:`` chain surfaces as individual entries.

    Regression (fixes #50557): picker rows are fed from ``list_authenticated_providers``
    and previously rendered the whole fallback chain as one dropdown entry. The raw chain
    stays first so the server-side fallback behaviour remains the default pick.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="openai-codex",
        user_providers={},
        custom_providers=[
            {
                "name": "Volcengine Agent Plan",
                "base_url": "https://ark.cn-beijing.volces.com/api/v3",
                "model": "deepseek-v4-flash, deepseek-v4-pro, glm-5.2, deepseek-v4-flash",
            }
        ],
        max_models=50,
    )

    rows = [p for p in providers if p["name"] == "Volcengine Agent Plan"]
    assert len(rows) == 1
    assert rows[0]["models"] == [
        "deepseek-v4-flash, deepseek-v4-pro, glm-5.2, deepseek-v4-flash",
        "deepseek-v4-flash",
        "deepseek-v4-pro",
        "glm-5.2",
    ]
    assert rows[0]["total_models"] == 4


def test_list_authenticated_providers_can_skip_custom_provider_live_probe(monkeypatch):
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    fetch = lambda *a, **k: (_ for _ in ()).throw(AssertionError("unexpected probe"))
    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        user_providers={},
        custom_providers=[
            {
                "name": "Slow Local",
                "base_url": "http://127.0.0.1:8080/v1",
                "api_key": "sk-local",
                "model": "local-model",
            }
        ],
        probe_custom_providers=False,
    )

    row = next(p for p in providers if p["slug"] == "custom:slow-local")
    assert row["models"] == ["local-model"]
    assert row["total_models"] == 1





def test_resolve_provider_full_finds_named_custom_provider():
    """Explicit /model --provider should resolve saved custom_providers entries."""
    resolved = resolve_provider_full(
        "custom:local-(127.0.0.1:4141)",
        user_providers={},
        custom_providers=[
            {
                "name": "Local (127.0.0.1:4141)",
                "base_url": "http://127.0.0.1:4141/v1",
            }
        ],
    )

    assert resolved is not None
    assert resolved.id == "custom:local-(127.0.0.1:4141)"
    assert resolved.name == "Local (127.0.0.1:4141)"
    assert resolved.base_url == "http://127.0.0.1:4141/v1"
    assert resolved.source == "user-config"


@pytest.mark.parametrize(
    "requested",
    [
        "Local Ollama",
        "local-ollama",
        "local-127.0.0.1:11434",
        "custom:local-ollama",
        "custom:local-127.0.0.1:11434",
    ],
)
def test_keyed_custom_provider_legacy_aliases_resolve_to_stable_key(requested):
    """Every historical identity resolves, but keyed providers return one ID."""
    resolved = resolve_provider_full(
        requested,
        user_providers={},
        custom_providers=[
            {
                "name": "Local Ollama",
                "provider_key": "local-127.0.0.1:11434",
                "base_url": "http://127.0.0.1:11434/v1",
            }
        ],
    )

    assert resolved is not None
    assert resolved.id == "custom:local-127.0.0.1:11434"
    assert resolved.name == "Local Ollama"


def test_keyed_custom_provider_bare_custom_fallback_uses_stable_key():
    resolved = resolve_provider_full(
        "custom",
        user_providers={},
        custom_providers=[
            {
                "name": "Local Ollama",
                "provider_key": "local-127.0.0.1:11434",
                "base_url": "http://127.0.0.1:11434/v1",
            }
        ],
    )

    assert resolved is not None
    assert resolved.id == "custom:local-127.0.0.1:11434"


@pytest.mark.parametrize(
    "requested",
    ["foo", "custom:foo", "custom:custom:foo"],
)
def test_prefixed_provider_key_does_not_accumulate_custom_prefixes(requested):
    """Accept the historical doubled form without writing a third identity."""
    resolved = resolve_provider_full(
        requested,
        user_providers={},
        custom_providers=[
            {
                "name": "Foo Relay",
                "provider_key": "custom:foo",
                "base_url": "https://foo.example/v1",
            }
        ],
    )

    assert resolved is not None
    assert resolved.id == "custom:foo"


def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
    """Bare model.provider=custom + model.base_url should still populate /model.

    Users can configure a one-off OpenAI-compatible endpoint directly under
    ``model:`` without a named ``providers:`` or ``custom_providers:`` row.
    The gateway picker receives only the current model/base_url slice, so it
    must surface that active endpoint rather than looking like config was
    ignored.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="custom",
        current_base_url="https://www.ccsub.net/v1",
        current_model="gpt-4o",
        user_providers={},
        custom_providers=[],
        max_models=50,
    )

    bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
    assert bare_custom is not None
    assert bare_custom["is_current"] is True
    assert bare_custom["is_user_defined"] is True
    assert bare_custom["models"] == ["gpt-4o"]
    assert bare_custom["api_url"] == "https://www.ccsub.net/v1"


def test_list_authenticated_providers_can_probe_active_bare_custom_endpoint(monkeypatch):
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setattr(
        "hermes_cli.models.fetch_api_models",
        lambda api_key, api_url, **kwargs: ["gpt-4o", "gpt-4o-mini"],
    )

    providers = list_authenticated_providers(
        current_provider="custom",
        current_base_url="https://www.ccsub.net/v1",
        current_model="gpt-4o",
        user_providers={},
        custom_providers=[],
        probe_custom_providers=False,
        probe_current_custom_provider=True,
    )

    bare_custom = next(p for p in providers if p["slug"] == "custom")
    assert bare_custom["is_current"] is True
    assert bare_custom["models"] == ["gpt-4o", "gpt-4o-mini"]


def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
    """Picker selections for bare custom endpoints should route to current base_url."""
    monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)

    result = switch_model(
        raw_input="gpt-4o-mini",
        current_provider="custom",
        current_model="gpt-4o",
        current_base_url="https://www.ccsub.net/v1",
        current_api_key="sk-test",
        explicit_provider="custom",
        user_providers={},
        custom_providers=[],
    )

    assert result.success is True
    assert result.target_provider == "custom"
    assert result.new_model == "gpt-4o-mini"
    assert result.base_url == "https://www.ccsub.net/v1"
    assert result.api_key == "sk-test"


def test_switch_to_bare_custom_from_another_provider_resolves_the_configured_endpoint(monkeypatch, tmp_path):
    """#73680: the per-turn config sync adopting ``provider: custom`` from an OpenRouter session
    must land on the configured custom endpoint, not pair the new model with OpenRouter's URL
    and key."""
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text(
        "model:\n  default: qwen3:8b\n  provider: custom\n  base_url: http://127.0.0.1:11434/v1\n",
        encoding="utf-8",
    )
    monkeypatch.setenv("HERMES_HOME", str(home))
    monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)

    result = switch_model(
        raw_input="qwen3:8b",
        current_provider="openrouter",
        current_model="anthropic/claude-sonnet-4",
        current_base_url="https://openrouter.ai/api/v1",
        current_api_key="sk-openrouter",
        explicit_provider="custom",
        user_providers={},
        custom_providers=[],
    )

    assert result.success is True
    assert result.target_provider == "custom"
    assert result.base_url == "http://127.0.0.1:11434/v1"
    assert result.api_key == "no-key-required"


def test_switch_to_bare_custom_with_no_configured_endpoint_keeps_the_current_one(monkeypatch, tmp_path):
    """#74143 shape on the switched-provider path: with no ``model.base_url`` the bare-custom
    resolver lands on OpenRouter's default whenever an OpenRouter key exists — a host the user
    never picked. An Anthropic session must stay on its own endpoint instead."""
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("model:\n  default: m\n  provider: anthropic\n", encoding="utf-8")
    monkeypatch.setenv("HERMES_HOME", str(home))
    monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-env")
    monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)

    result = switch_model(
        raw_input="m2",
        current_provider="anthropic",
        current_model="m",
        current_base_url="https://api.anthropic.com",
        current_api_key="sk-ant",
        explicit_provider="custom",
        user_providers={},
        custom_providers=[],
    )

    assert result.success is True
    assert (result.base_url, result.api_key) == ("https://api.anthropic.com", "sk-ant")


def test_openrouter_mirror_read_never_raises_without_a_secret_scope(monkeypatch):
    """The mirror guard reads ``OPENROUTER_BASE_URL`` through the profile secret scope: with
    multiplexing on and no scope installed that read raises ``UnscopedSecretError``. A guard read
    must degrade to 'no mirror detected' (the caller then keeps the session endpoint) instead of
    propagating out of ``switch_model``, where the resolver's own read of the same name is
    suppressed."""
    from agent import secret_scope
    from hermes_cli.model_switch import _openrouter_mirror_base_url

    monkeypatch.setenv("OPENROUTER_BASE_URL", "https://mirror.example.com/v1")
    secret_scope.set_multiplex_active(True)
    try:
        assert _openrouter_mirror_base_url() == ""
    finally:
        secret_scope.set_multiplex_active(False)


def test_switch_to_bare_custom_ignores_an_openrouter_mirror(monkeypatch, tmp_path):
    """#115661 follow-up: with ``OPENROUTER_BASE_URL`` set to a mirror and nothing configured for
    ``custom``, the ladder's last rung hands back that mirror — a host the user configured for
    OpenRouter — with the ``no-key-required`` placeholder. It must not replace the session's own
    endpoint and key (the switched arm used to adopt it, dropping a working credential)."""
    home = tmp_path / "hermes-home"
    home.mkdir()
    (home / "config.yaml").write_text("model:\n  default: m\n  provider: custom\n", encoding="utf-8")
    monkeypatch.setenv("HERMES_HOME", str(home))
    monkeypatch.setenv("OPENROUTER_BASE_URL", "https://mirror.example.com/v1")
    monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)

    result = switch_model(
        raw_input="m2",
        current_provider="anthropic",
        current_model="m",
        current_base_url="https://api.anthropic.com",
        current_api_key="sk-ant",
        explicit_provider="custom",
        user_providers={},
        custom_providers=[],
    )

    assert result.success is True
    assert (result.base_url, result.api_key) == ("https://api.anthropic.com", "sk-ant")

    # Two env vars aimed at the SAME proxy: the URL is the endpoint configured for `custom`, so the
    # OpenRouter rung is not the source and the switch adopts it (#115661's behaviour).
    monkeypatch.setenv("CUSTOM_BASE_URL", "https://mirror.example.com/v1")
    configured = switch_model(
        raw_input="m2",
        current_provider="anthropic",
        current_model="m",
        current_base_url="https://api.anthropic.com",
        current_api_key="sk-ant",
        explicit_provider="custom",
        user_providers={},
        custom_providers=[],
    )

    assert configured.base_url == "https://mirror.example.com/v1"


def test_is_aggregator_recognizes_named_custom_provider():
    assert providers_mod.is_aggregator("custom:hpc-ai") is True
    assert providers_mod.is_aggregator("custom:litellm") is True




def test_switch_model_does_not_send_ollama_headers_to_unrelated_custom_endpoint(monkeypatch):
    """A custom endpoint must not inherit headers from configured Ollama."""
    seen_headers = []
    validation_headers = []

    def fake_native_detection(provider, base_url, headers=None):
        seen_headers.append(headers)
        return True

    def fake_validation(*args, **kwargs):
        validation_headers.append(kwargs.get("headers"))
        return _MOCK_VALIDATION

    monkeypatch.setattr(
        "hermes_cli.models_local.should_use_ollama_native_catalog",
        fake_native_detection,
    )
    monkeypatch.setattr(
        "hermes_cli.models_local._get_ollama_request_headers",
        lambda: {"Authorization": "Bearer configured-ollama-secret"},
    )
    monkeypatch.setattr(
        "hermes_cli.models._get_provider_config_dict",
        lambda provider: (
            {"base_url": "https://trusted-ollama.example:11434"}
            if provider == "ollama"
            else {}
        ),
    )
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        lambda **kwargs: {
            "api_key": "custom-key",
            "base_url": "https://attacker.example:11434/v1",
            "api_mode": "chat_completions",
        },
    )
    monkeypatch.setattr("hermes_cli.models_validate.validate_requested_model", fake_validation)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
    monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)

    result = switch_model(
        raw_input="new-model",
        current_provider="custom",
        current_model="old-model",
        current_base_url="https://attacker.example:11434/v1",
        current_api_key="custom-key",
        explicit_provider="",
        user_providers={},
        custom_providers=[],
    )

    assert result.success is True
    assert seen_headers == [{}]
    assert validation_headers == [None]





def test_picker_selection_resolves_named_custom_provider_model_id(monkeypatch):
    """Picker prefixes must not leak into a named custom provider API model id."""
    monkeypatch.setattr(
        "hermes_cli.runtime_provider.resolve_runtime_provider",
        lambda **kwargs: {
            "api_key": "test-key",
            "base_url": "https://token.sensenova.cn/v1",
            "api_mode": "chat_completions",
        },
    )
    monkeypatch.setattr(
        "hermes_cli.models_validate.validate_requested_model",
        lambda *a, **k: _MOCK_VALIDATION,
    )
    monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
    monkeypatch.setattr(
        "hermes_cli.model_switch.get_model_capabilities",
        lambda *a, **k: None,
    )

    result = switch_model(
        raw_input="sensenova/deepseek-v4-flash",
        current_provider="openai-codex",
        current_model="gpt-5.4",
        explicit_provider="custom:sensenova",
        user_providers={},
        custom_providers=[
            {
                "name": "sensenova",
                "base_url": "https://token.sensenova.cn/v1",
                "models": [
                    {"id": "deepseek-v4-flash", "name": "deepseek-v4-flash"}
                ],
            }
        ],
    )

    assert result.success is True
    assert result.target_provider == "custom:sensenova"
    assert result.new_model == "deepseek-v4-flash"






def test_list_groups_same_name_custom_providers_into_one_row(monkeypatch):
    """Multiple custom_providers entries sharing a name should produce one row
    with all models collected, not N duplicate rows."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: [])

    providers = list_authenticated_providers(
        current_provider="openrouter",
        user_providers={},
        custom_providers=[
            {"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "qwen3-coder:480b-cloud"},
            {"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "glm-5.1:cloud"},
            {"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "kimi-k2.5"},
            {"name": "Ollama Cloud", "base_url": "https://ollama.com/v1", "model": "minimax-m2.7:cloud"},
            {"name": "Moonshot", "base_url": "https://api.moonshot.ai/v1", "model": "kimi-k2-thinking"},
        ],
        max_models=50,
    )

    ollama_rows = [p for p in providers if p["name"] == "Ollama Cloud"]
    assert len(ollama_rows) == 1, f"Expected 1 Ollama Cloud row, got {len(ollama_rows)}"
    assert ollama_rows[0]["models"] == [
        "qwen3-coder:480b-cloud", "glm-5.1:cloud", "kimi-k2.5", "minimax-m2.7:cloud"
    ]
    assert ollama_rows[0]["total_models"] == 4

    moonshot_rows = [p for p in providers if p["name"] == "Moonshot"]
    assert len(moonshot_rows) == 1
    assert moonshot_rows[0]["models"] == ["kimi-k2-thinking"]


def test_list_deduplicates_same_model_in_group(monkeypatch):
    """Duplicate model entries under the same provider name should not produce
    duplicate entries in the models list."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setattr("hermes_cli.models.fetch_api_models", lambda *a, **k: [])

    providers = list_authenticated_providers(
        current_provider="openrouter",
        user_providers={},
        custom_providers=[
            {"name": "MyProvider", "base_url": "http://localhost:11434/v1", "model": "llama3", "discover_models": False},
            {"name": "MyProvider", "base_url": "http://localhost:11434/v1", "model": "llama3", "discover_models": False},
            {"name": "MyProvider", "base_url": "http://localhost:11434/v1", "model": "mistral", "discover_models": False},
        ],
        max_models=50,
    )

    my_rows = [p for p in providers if p["name"] == "MyProvider"]
    assert len(my_rows) == 1
    assert my_rows[0]["models"] == ["llama3", "mistral"]
    assert my_rows[0]["total_models"] == 2


def test_custom_provider_no_key_singular_model_still_probes_live_models(monkeypatch):
    """A singular ``model:`` is the active selection, not an explicit catalog.

    No-key local OpenAI-compatible endpoints such as llama.cpp should still be
    probed so /model matches the terminal ``hermes model`` flow. Ollama-native
    discovery is covered separately with a fake ``/api/tags`` server.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    calls = []

    def fake_fetch_api_models(api_key, base_url, **kwargs):
        calls.append((api_key, base_url, kwargs))
        return ["llama3", "mistral", "qwen3-coder"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)

    providers = list_authenticated_providers(
        current_provider="openai-codex",
        user_providers={},
        custom_providers=[
            {
                # Keep this generic and off Ollama's default :11434: this case
                # covers /v1/models probing, not native /api/tags discovery.
                "name": "Local llama.cpp",
                "base_url": "http://localhost:8080/v1",
                "model": "llama3",
            }
        ],
        max_models=50,
    )

    assert [(key, url) for key, url, _kw in calls] == [("", "http://localhost:8080/v1")]
    row = next(p for p in providers if p["name"] == "Local llama.cpp")
    assert row["models"] == ["llama3", "mistral", "qwen3-coder"]
    assert row["total_models"] == 3




def test_custom_provider_group_explicit_duplicate_skips_probe(monkeypatch):
    """A later grouped entry can explicitly narrow to an existing model."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    calls = []

    def fetch(*args, **kwargs):
        calls.append((args, kwargs))
        return ["unexpected-live-model"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider="custom:local-ollama",
        user_providers={},
        custom_providers=[
            {
                "name": "Local Ollama",
                "base_url": "http://localhost:11434/v1",
                "model": "llama3",
            },
            {
                "name": "Local Ollama",
                "base_url": "http://localhost:11434/v1",
                "models": ["llama3"],
            },
        ],
    )

    row = next(p for p in providers if p["name"] == "Local Ollama")
    assert calls == []
    assert row["models"] == ["llama3"]


def test_custom_provider_current_only_probe_respects_explicit_catalog(monkeypatch):
    """Normal GUI opens probe only the active singular-only provider."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    calls = []

    def fetch(api_key, base_url, **kwargs):
        calls.append((api_key, base_url, kwargs))
        return ["live-a", "live-b"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider="custom:active",
        current_base_url="http://active.local/v1",
        user_providers={},
        custom_providers=[
            {
                "name": "Active",
                "base_url": "http://active.local/v1",
                "model": "seed",
            },
            {
                "name": "Offline",
                "base_url": "http://offline.local/v1",
                "model": "offline-seed",
            },
            {
                "name": "Static",
                "base_url": "http://static.local/v1",
                "model": "only",
                "models": ["only"],
            },
        ],
        probe_custom_providers=False,
        probe_current_custom_provider=True,
    )

    assert [url for _key, url, _kw in calls] == ["http://active.local/v1"]
    rows = {row["name"]: row for row in providers if row.get("is_user_defined")}
    assert rows["Active"]["models"] == ["live-a", "live-b"]
    assert rows["Offline"]["models"] == ["offline-seed"]
    assert rows["Static"]["models"] == ["only"]


def test_custom_provider_current_explicit_catalog_skips_probe(monkeypatch):
    """Current-only GUI probing must still honor an explicit catalog."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    calls = []

    def fetch(*args, **kwargs):
        calls.append((args, kwargs))
        return ["unexpected-live-model"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider="custom:static",
        current_base_url="http://static.local/v1",
        user_providers={},
        custom_providers=[
            {
                "name": "Static",
                "base_url": "http://static.local/v1",
                "model": "only",
                "models": ["only"],
            }
        ],
        probe_custom_providers=False,
        probe_current_custom_provider=True,
    )

    assert calls == []
    row = next(p for p in providers if p["name"] == "Static")
    assert row["is_current"] is True
    assert row["models"] == ["only"]


def test_custom_provider_empty_explicit_list_allows_probe(monkeypatch):
    """An empty ``models:`` declaration is not an explicit catalog."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    calls = []

    def fetch(api_key, base_url, **kwargs):
        calls.append((api_key, base_url, kwargs))
        return ["live-a", "live-b"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider="custom:local",
        user_providers={},
        custom_providers=[
            {
                "name": "Local",
                "base_url": "http://local.test/v1",
                "model": "seed",
                "models": [],
            }
        ],
    )

    assert [url for _key, url, _kw in calls] == ["http://local.test/v1"]
    row = next(p for p in providers if p["name"] == "Local")
    assert row["models"] == ["live-a", "live-b"]


def test_list_enumerates_dict_format_models_alongside_default(monkeypatch):
    """custom_providers entry with dict-format ``models:`` plus singular
    ``model:`` should surface the default and every dict key.

    Regression: Hermes's own writer stores configured models as a dict
    keyed by model id, but the /model picker previously only honored the
    singular ``model:`` field, so multi-model custom providers appeared
    to have only the active model.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="openai-codex",
        user_providers={},
        custom_providers=[
            {
                "name": "DeepSeek",
                "base_url": "https://api.deepseek.com",
                "api_mode": "chat_completions",
                "model": "deepseek-chat",
                "models": {
                    "deepseek-chat": {"context_length": 128000},
                    "deepseek-reasoner": {"context_length": 128000},
                },
            }
        ],
        max_models=50,
    )

    ds_rows = [p for p in providers if p["name"] == "DeepSeek"]
    assert len(ds_rows) == 1
    assert ds_rows[0]["models"] == ["deepseek-chat", "deepseek-reasoner"]
    assert ds_rows[0]["total_models"] == 2


def test_list_enumerates_dict_format_models_without_singular_model(monkeypatch):
    """Dict-format ``models:`` with no singular ``model:`` should still
    enumerate every dict key (previously the picker reported 0 models)."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="openai-codex",
        user_providers={},
        custom_providers=[
            {
                "name": "Thor",
                "base_url": "http://thor.lab:8337/v1",
                "models": {
                    "gemma-4-26B-A4B-it-MXFP4_MOE": {"context_length": 262144},
                    "Qwen3.5-35B-A3B-MXFP4_MOE": {"context_length": 262144},
                    "gemma-4-31B-it-Q4_K_M": {"context_length": 262144},
                },
            }
        ],
        max_models=50,
    )

    thor_rows = [p for p in providers if p["name"] == "Thor"]
    assert len(thor_rows) == 1
    assert set(thor_rows[0]["models"]) == {
        "gemma-4-26B-A4B-it-MXFP4_MOE",
        "Qwen3.5-35B-A3B-MXFP4_MOE",
        "gemma-4-31B-it-Q4_K_M",
    }
    assert thor_rows[0]["total_models"] == 3







# ─────────────────────────────────────────────────────────────────────────────
# #9210: group custom_providers by (base_url, api_key) in /model picker
# ─────────────────────────────────────────────────────────────────────────────


def test_list_authenticated_providers_groups_same_endpoint(monkeypatch):
    """Multiple custom_providers entries sharing a base_url+api_key must be
    returned as a single picker row with all their models merged."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="custom",
        current_base_url="http://localhost:11434/v1",
        user_providers={},
        custom_providers=[
            {"name": "Ollama — MiniMax M2.7", "base_url": "http://localhost:11434/v1",
             "api_key": "ollama", "model": "minimax-m2.7", "discover_models": False},
            {"name": "Ollama — GLM 5.1",      "base_url": "http://localhost:11434/v1",
             "api_key": "ollama", "model": "glm-5.1", "discover_models": False},
            {"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1",
             "api_key": "ollama", "model": "qwen3-coder", "discover_models": False},
        ],
        max_models=50,
        probe_custom_providers=False,
    )

    custom_groups = [p for p in providers if p.get("is_user_defined")]
    assert len(custom_groups) == 1, (
        "Expected 1 group for shared endpoint, got "
        f"{[p['slug'] for p in custom_groups]}"
    )
    group = custom_groups[0]
    assert set(group["models"]) == {"minimax-m2.7", "glm-5.1", "qwen3-coder"}
    assert group["total_models"] == 3
    # Per-model suffix stripped from display name
    assert group["name"] == "Ollama"


def test_list_authenticated_providers_current_endpoint_uses_current_slug(monkeypatch):
    """When current_base_url matches the grouped endpoint, the slug must
    equal current_provider so picker selection routes through the live
    credential pipeline — provided current_provider is a real slug, not
    the corrupt bare "custom" (see #17478)."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="custom:ollama",
        current_base_url="http://localhost:11434/v1",
        user_providers={},
        custom_providers=[
            {"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
             "api_key": "ollama", "model": "glm-5.1"},
        ],
        max_models=50,
    )

    matches = [p for p in providers if p.get("is_user_defined")]
    assert len(matches) == 1
    group = matches[0]
    assert group["slug"] == "custom:ollama"
    assert group["is_current"] is True


def test_picker_endpoint_authorization_overrides_inferred_bearer(monkeypatch):
    from hermes_cli.model_switch_providers import _fetch_picker_live_models

    captured: dict[str, str] = {}

    def fake_native(_url, *, timeout, headers):
        captured.update(headers or {})
        return ["model-a"]

    monkeypatch.setattr("hermes_cli.models_local.should_use_ollama_native_catalog", lambda *a, **k: True)
    monkeypatch.setattr("hermes_cli.models_local.fetch_ollama_local_models", fake_native)
    result = _fetch_picker_live_models(
        "endpoint-key",
        "http://127.0.0.1:11434/v1",
        "ollama",
        False,
        headers={"authorization": "Api-Key explicit"},
    )

    assert result == ["model-a"]
    auth_headers = {
        key: value
        for key, value in captured.items()
        if key.lower() == "authorization"
    }
    assert auth_headers == {"authorization": "Api-Key explicit"}


def test_list_authenticated_providers_bare_custom_slug_recovers(monkeypatch):
    """Regression for #17478: when a prior failed switch left the bare
    literal "custom" in model.provider, the picker must NOT propagate
    that broken slug. It must fall back to the canonical
    ``custom:<name>`` form so the picker stays usable."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="custom",
        current_base_url="http://localhost:11434/v1",
        user_providers={},
        custom_providers=[
            {"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
             "api_key": "ollama", "model": "glm-5.1"},
        ],
        max_models=50,
    )

    matches = [p for p in providers if p.get("is_user_defined")]
    assert len(matches) == 1
    group = matches[0]
    # Canonical slug, NOT the bare "custom" that caused #17478
    assert group["slug"] == "custom:ollama"
    assert group["is_current"] is True


def test_compatible_keyed_provider_uses_stable_key_and_accepts_legacy_current_name(
    monkeypatch,
):
    """The merged providers view keeps the config key while old IDs stay current."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="custom:local-ollama",
        user_providers={},
        custom_providers=[
            {
                "name": "Local Ollama",
                "provider_key": "local-127.0.0.1:11434",
                "base_url": "http://127.0.0.1:11434/v1",
                "model": "qwen3.5:9b",
            }
        ],
        max_models=50,
        probe_custom_providers=False,
    )

    row = next(p for p in providers if p.get("is_user_defined"))
    assert row["slug"] == "custom:local-127.0.0.1:11434"
    assert row["is_current"] is True


def test_user_provider_row_recognizes_stable_custom_key_as_current(monkeypatch):
    """Section 3 keeps its legacy row slug but recognizes the stable ID."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="custom:local-127.0.0.1:11434",
        user_providers={
            "local-127.0.0.1:11434": {
                "name": "Local Ollama",
                "base_url": "http://127.0.0.1:11434/v1",
                "default_model": "qwen3.5:9b",
            }
        },
        custom_providers=[],
        max_models=50,
        probe_custom_providers=False,
    )

    row = next(p for p in providers if p.get("is_user_defined"))
    assert row["slug"] == "local-127.0.0.1:11434"
    assert row["is_current"] is True


def test_list_authenticated_providers_distinct_endpoints_stay_separate(monkeypatch):
    """Entries with different base_urls must produce separate picker rows
    even if some display names happen to be similar."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        user_providers={},
        custom_providers=[
            {"name": "Ollama — GLM 5.1", "base_url": "http://localhost:11434/v1",
             "api_key": "ollama", "model": "glm-5.1"},
            {"name": "Moonshot", "base_url": "https://api.moonshot.cn/v1",
             "api_key": "sk-m", "model": "moonshot-v1"},
            {"name": "Ollama — Qwen3-coder", "base_url": "http://localhost:11434/v1",
             "api_key": "ollama", "model": "qwen3-coder"},
        ],
        max_models=50,
        probe_custom_providers=False,
    )

    custom_groups = [p for p in providers if p.get("is_user_defined")]
    assert len(custom_groups) == 2
    # Ollama endpoint collapses to one row with both models
    ollama = next(p for p in custom_groups if p["name"] == "Ollama")
    assert set(ollama["models"]) == {"glm-5.1", "qwen3-coder"}
    moonshot = next(p for p in custom_groups if p["name"] == "Moonshot")
    assert moonshot["models"] == ["moonshot-v1"]


def test_list_authenticated_providers_same_url_different_keys_disambiguated(monkeypatch):
    """Two custom_providers entries with the same base_url but different
    api_keys (and identical cleaned names) must both stay visible in the
    picker — slug is suffixed to disambiguate."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        user_providers={},
        custom_providers=[
            {"name": "OpenAI — key A", "base_url": "https://api.openai.com/v1",
             "api_key": "sk-AAA", "model": "gpt-5.4"},
            {"name": "OpenAI — key B", "base_url": "https://api.openai.com/v1",
             "api_key": "sk-BBB", "model": "gpt-4.6"},
        ],
        max_models=50,
    )

    custom_groups = [p for p in providers if p.get("is_user_defined")]
    assert len(custom_groups) == 2
    slugs = sorted(p["slug"] for p in custom_groups)
    # First group keeps the base slug, second gets a numeric suffix
    assert slugs == ["custom:openai", "custom:openai-2"]
    # Each row has a distinct model
    models = {p["slug"]: p["models"] for p in custom_groups}
    assert models["custom:openai"] == ["gpt-5.4"]
    assert models["custom:openai-2"] == ["gpt-4.6"]


def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
    """Same gateway host but different key_env/api_mode entries are distinct providers."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    providers = list_authenticated_providers(
        current_provider="custom:gpt",
        current_base_url="https://gateway.example.com",
        user_providers={},
        custom_providers=[
            {
                "name": "gpt",
                "base_url": "https://gateway.example.com",
                "key_env": "GPT_KEY",
                "api_mode": "codex_responses",
                "model": "gpt-5.5",
            },
            {
                "name": "claude",
                "base_url": "https://gateway.example.com",
                "key_env": "CLAUDE_KEY",
                "api_mode": "anthropic_messages",
                "model": "claude-opus-4-8",
            },
        ],
        max_models=50,
    )

    custom = [p for p in providers if p.get("is_user_defined")]
    by_slug = {p["slug"]: p for p in custom}

    assert set(by_slug) == {"custom:gpt", "custom:claude"}
    assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
    assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
    assert by_slug["custom:gpt"]["is_current"] is True
    assert by_slug["custom:claude"]["is_current"] is False




def test_lmstudio_picker_probes_active_config_base_url(monkeypatch):
    """When `provider: lmstudio` is saved with a remote base_url and no
    LM_BASE_URL env var, the picker must probe the saved base_url — not
    127.0.0.1. Regression: prior behavior always probed localhost, so users
    with LM Studio on a lab box saw the wrong (or empty) model list.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.delenv("LM_BASE_URL", raising=False)
    monkeypatch.delenv("LM_API_KEY", raising=False)

    captured: dict = {}

    def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
        captured["base_url"] = base_url
        captured["api_key"] = api_key
        return ["qwen/qwen3-coder-30b"]

    monkeypatch.setattr("hermes_cli.models_local.fetch_lmstudio_models", _fake_fetch)

    list_authenticated_providers(
        current_provider="lmstudio",
        current_base_url="http://192.168.1.10:1234/v1",
        current_model="qwen/qwen3-coder-30b",
    )

    assert captured["base_url"] == "http://192.168.1.10:1234/v1"


def test_lmstudio_picker_lm_base_url_env_wins_over_active_config(monkeypatch):
    """LM_BASE_URL env var must still take precedence over the saved
    base_url so users can temporarily redirect the picker without editing
    config.yaml.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
    monkeypatch.delenv("LM_API_KEY", raising=False)

    captured: dict = {}

    def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
        captured["base_url"] = base_url
        return []

    monkeypatch.setattr("hermes_cli.models_local.fetch_lmstudio_models", _fake_fetch)

    list_authenticated_providers(
        current_provider="lmstudio",
        current_base_url="http://192.168.1.10:1234/v1",
    )

    assert captured["base_url"] == "http://override.local:9999/v1"


def test_lmstudio_picker_skips_probe_when_not_configured(monkeypatch):
    """If the user has never configured LM Studio (no LM_API_KEY / LM_BASE_URL
    and not on lmstudio), the picker must not pay the localhost probe cost
    just to discover LM Studio is unavailable.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.delenv("LM_BASE_URL", raising=False)
    monkeypatch.delenv("LM_API_KEY", raising=False)

    captured: dict = {}

    def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
        captured["base_url"] = base_url
        return []

    monkeypatch.setattr("hermes_cli.models_local.fetch_lmstudio_models", _fake_fetch)

    list_authenticated_providers(
        current_provider="openrouter",
        current_base_url="https://openrouter.ai/api/v1",
    )

    assert "base_url" not in captured


def test_lmstudio_bare_providers_block_does_not_hide_live_catalog(monkeypatch):
    """A `providers.lmstudio:` block that only tunes transport (e.g.
    request_timeout_seconds, no base_url/models) must not shadow the live
    LM Studio catalog with a single-model `user-config` row.

    Regression for the bug where any `providers.lmstudio` key made section 3
    (`_lap_user_provider_rows`) claim the "lmstudio" slug before its own
    live probe could run — discovery_allowed was False with no configured
    base_url, so the row collapsed to whatever single model was configured,
    discarding the full catalog `_build_curated_lists` had already fetched.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.delenv("LM_BASE_URL", raising=False)
    monkeypatch.delenv("LM_API_KEY", raising=False)

    live_catalog = ["model-a", "model-b", "model-c"]
    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_lmstudio_models",
        lambda api_key=None, base_url=None, timeout=5.0: list(live_catalog),
    )

    providers = list_authenticated_providers(
        current_provider="lmstudio",
        current_base_url="http://127.0.0.1:1234/v1",
        current_model="model-a",
        user_providers={"lmstudio": {"request_timeout_seconds": 86400, "stale_timeout_seconds": 86400}},
    )

    rows = [p for p in providers if p["slug"] == "lmstudio"]
    assert len(rows) == 1
    row = rows[0]
    assert sorted(row["models"]) == sorted(live_catalog)
    assert row["total_models"] == len(live_catalog)
    assert row["source"] == "hermes"


def test_lmstudio_providers_block_with_explicit_endpoint_still_uses_section3(monkeypatch):
    """When `providers.lmstudio` sets its own base_url, the user has
    deliberately pointed the slug at a specific endpoint — the generic
    custom-endpoint handling (section 3) remains the correct, unsurprising
    behavior and must not be shadowed by the built-in live probe."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.delenv("LM_BASE_URL", raising=False)
    monkeypatch.delenv("LM_API_KEY", raising=False)

    monkeypatch.setattr(
        "hermes_cli.models_local.fetch_lmstudio_models",
        lambda api_key=None, base_url=None, timeout=5.0: ["should-not-be-used"],
    )

    def _fake_discover(*_a, **_kw):
        return ["remote-model"], False

    monkeypatch.setattr(
        "hermes_cli.model_switch_providers._discover_endpoint_models", _fake_discover
    )

    providers = list_authenticated_providers(
        current_provider="lmstudio",
        current_base_url="http://remote-box:1234/v1",
        current_model="remote-model",
        user_providers={"lmstudio": {"base_url": "http://remote-box:1234/v1", "discover_models": True}},
    )

    rows = [p for p in providers if p["slug"] == "lmstudio"]
    assert len(rows) == 1
    assert rows[0]["is_user_defined"] is True
    assert rows[0]["models"] == ["remote-model"]















def test_custom_providers_uses_live_models_for_multi_model_endpoint(monkeypatch):
    """Custom providers with api_key + base_url should prefer live /models.

    Custom providers (section 4 of list_authenticated_providers) point at
    gateways like Bifrost that expose hundreds of models.  Reading only the
    static ``models:`` dict from config.yaml leaves the /model picker with
    a stale subset.  Live discovery fills the picker with all available
    models from the endpoint.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})

    calls = []

    def fake_fetch_api_models(api_key, base_url, **kwargs):
        calls.append((api_key, base_url, kwargs))
        return ["gateway-model-a", "gateway-model-b", "gateway-model-c"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)

    custom_providers = [
        {
            "name": "my-gateway",
            "api_key": "sk-gateway-key",
            "base_url": "https://gateway.example.com/v1",
            "model": "gateway-model-a",
            "models": {
                "gateway-model-a": {"context_length": 128000},
                "gateway-model-b": {"context_length": 128000},
            },
        }
    ]

    providers = list_authenticated_providers(
        current_provider="openrouter",
        current_base_url="https://openrouter.ai/api/v1",
        custom_providers=custom_providers,
        max_models=50,
    )

    gateway_prov = next(
        (
            p
            for p in providers
            if p.get("api_url") == "https://gateway.example.com/v1"
        ),
        None,
    )

    assert gateway_prov is not None, "Custom provider group not found in results"
    assert [(key, url) for key, url, _kw in calls] == [
        ("sk-gateway-key", "https://gateway.example.com/v1")
    ], "fetch_api_models must be called with the custom provider's credentials"
    assert gateway_prov["models"] == [
        "gateway-model-a",
        "gateway-model-b",
        "gateway-model-c",
    ], "Live models must replace the static subset"
    assert gateway_prov["total_models"] == 3


def test_same_endpoint_different_extra_headers_not_collapsed(monkeypatch):
    """Entries sharing (api_url, credential, api_mode) but declaring different
    extra_headers must NOT collapse into one picker row — each is a distinct
    header-authenticated endpoint (e.g. per-tenant routing behind one proxy)
    and must probe /models with its own headers."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})

    calls = []

    def fake_fetch_api_models(api_key, base_url, **kwargs):
        calls.append((api_key, base_url, kwargs.get("headers")))
        # Return a per-tenant model list keyed by the routing header so we can
        # assert each row got its OWN probe rather than a shared one.
        tenant = (kwargs.get("headers") or {}).get("X-Tenant", "none")
        return [f"model-{tenant}"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)

    providers = list_authenticated_providers(
        current_provider="openrouter",
        current_base_url="https://openrouter.ai/api/v1",
        custom_providers=[
            {
                "name": "Proxy Tenant A",
                "api_key": "shared-key",
                "base_url": "http://localhost:8081/v1",
                "extra_headers": {"X-Tenant": "a"},
            },
            {
                "name": "Proxy Tenant B",
                "api_key": "shared-key",
                "base_url": "http://localhost:8081/v1",
                "extra_headers": {"X-Tenant": "b"},
            },
        ],
        max_models=50,
    )

    rows = [
        p for p in providers if p.get("api_url") == "http://localhost:8081/v1"
    ]
    # Two distinct rows, not one collapsed row.
    assert len(rows) == 2, f"expected 2 rows, got {len(rows)}: {rows}"

    # Each tenant was probed with its OWN header set (order-independent).
    assert ("shared-key", "http://localhost:8081/v1", {"X-Tenant": "a"}) in calls
    assert ("shared-key", "http://localhost:8081/v1", {"X-Tenant": "b"}) in calls

    # Each row surfaces the model list its own headers unlocked.
    models_by_row = {tuple(r["models"]) for r in rows}
    assert models_by_row == {("model-a",), ("model-b",)}






def test_resolve_custom_provider_passes_key_env():
    """resolve_custom_provider should propagate key_env into api_key_env_vars.

    Regression: previously api_key_env_vars was always (), silently dropping
    the configured env var and causing 401s on every request.
    """
    from hermes_cli.providers import resolve_custom_provider

    resolved = resolve_custom_provider(
        "custom:token-plan",
        custom_providers=[
            {
                "name": "token-plan",
                "base_url": "https://token-plan-sgp.xiaomimimo.com/v1",
                "key_env": "XIAOMI_MIMO_API_KEY",
                "model": "mimo-v2-pro",
            }
        ],
    )

    assert resolved is not None
    assert resolved.api_key_env_vars == ("XIAOMI_MIMO_API_KEY",)
    assert resolved.base_url == "https://token-plan-sgp.xiaomimimo.com/v1"


def test_discovered_models_auto_saved_to_cache(monkeypatch):
    """Discovered models are persisted to config so ``discover_models: false``
    has a populated cache on the next read (#65652).

    When a successful probe returns live models, ``_save_discovered_models_to_config``
    must be called with the provider's base_url and the discovered model list.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})

    save_calls = []

    def fake_fetch_api_models(api_key, base_url, **kwargs):
        return ["discovered-a", "discovered-b", "discovered-c"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fake_fetch_api_models)
    monkeypatch.setattr(
        "hermes_cli.model_switch_providers._save_discovered_models_to_config",
        lambda api_url, model_ids, **kwargs: save_calls.append((api_url, model_ids)),
    )

    custom_providers = [
        {
            "name": "my-gateway",
            "api_key": "***",
            "base_url": "https://gateway.example.com/v1",
            "discover_models": True,
            "model": "only-model",
            "models": {"only-model": {"context_length": 128000}},
        }
    ]

    providers = list_authenticated_providers(
        current_provider="my-gateway",
        current_base_url="https://gateway.example.com/v1",
        custom_providers=custom_providers,
        max_models=50,
        probe_custom_providers=True,
    )

    assert len(save_calls) == 1, (
        "_save_discovered_models_to_config must be called after a successful probe"
    )
    assert save_calls[0][0] == "https://gateway.example.com/v1"
    assert save_calls[0][1] == ["discovered-a", "discovered-b", "discovered-c"]

    gateway_prov = next(
        (p for p in providers if p.get("api_url") == "https://gateway.example.com/v1"),
        None,
    )
    assert gateway_prov is not None
    assert gateway_prov["models"] == ["discovered-a", "discovered-b", "discovered-c"]




def test_save_discovered_models_preserves_dict_form(monkeypatch):
    """``_save_discovered_models_to_config`` must not replace a dict-form
    ``models`` mapping (per-model metadata like ``context_length``) with
    a flat list of strings (#67841)."""
    from hermes_cli.model_switch_providers import _save_discovered_models_to_config

    save_calls = []

    def fake_save(config):
        save_calls.append(dict(config))

    monkeypatch.setattr("hermes_cli.config.save_config", fake_save)
    monkeypatch.setattr(
        "hermes_cli.config.load_config",
        lambda: {
            "custom_providers": [
                {
                    "name": "my-gateway",
                    "base_url": "https://gateway.example.com/v1",
                    "models": {
                        "configured-model": {"context_length": 8192},
                    },
                }
            ]
        },
    )

    # Dict-form models must NOT be overwritten by discovered models
    _save_discovered_models_to_config(
        "https://gateway.example.com/v1",
        ["configured-model", "discovered-model"],
    )
    assert save_calls == [], (
        "Dict-form models must not be replaced with a flat list"
    )


def test_model_flow_named_custom_persists_discovered_models(monkeypatch):
    """The ``hermes model`` named-custom-provider flow persists the discovered
    catalog back to the entry's ``models:`` list.

    No-probe surfaces (dashboard, desktop, ACP) call
    ``build_models_payload(..., probe_custom_providers=False)`` and only show
    the configured ``models:`` list. The CLI flow probes and shows the full
    catalog but (before this fix) never saved it, so a provider added via
    ``hermes model`` collapsed to the single ``model:`` default everywhere but
    the CLI. It must persist discovered models the same way the picker path in
    ``_save_discovered_models_to_config`` does.
    """
    monkeypatch.setattr(
        "hermes_cli.models.fetch_api_models",
        lambda api_key, base_url, **kw: [
            "discovered-a",
            "discovered-b",
            "discovered-c",
        ],
    )
    # Non-interactive model selection.
    monkeypatch.setattr(
        "hermes_cli.curses_ui.curses_radiolist", lambda *a, **k: 0
    )
    # No-op downstream writes so the test never touches a real config.
    monkeypatch.setattr("hermes_cli.main_provider_setup._save_custom_provider", lambda *a, **k: None)
    monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda *a, **k: None)
    monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda *a, **k: None)
    monkeypatch.setattr(
        "hermes_cli.config.load_config",
        lambda: {"model": {}, "providers": {}, "custom_providers": []},
    )
    monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)

    save_calls = []
    monkeypatch.setattr(
        "hermes_cli.model_switch_providers._save_discovered_models_to_config",
        lambda api_url, model_ids, **kwargs: save_calls.append(
            (api_url, model_ids, kwargs)
        ),
    )

    from hermes_cli.model_setup_flows_custom import _model_flow_named_custom

    _model_flow_named_custom(
        {},
        {
            "name": "Dragomes",
            "base_url": "http://example.com/v1",
            "api_mode": "anthropic_messages",
            "extra_headers": {"X-Tenant": "dragomes"},
            "api_key": "sk-test",
            "key_env": "",
            "model": "MiniMax-M3",
            "provider_key": "",
            "discover_models": True,
            "models": {},
        },
    )

    assert save_calls == [
        (
            "http://example.com/v1",
            ["discovered-a", "discovered-b", "discovered-c"],
            {
                "api_mode": "anthropic_messages",
                "headers": {"X-Tenant": "dragomes"},
                "credential_identity": "sk-test",
            },
        )
    ], (
        "_model_flow_named_custom must persist each live catalog with its "
        "base URL, API mode, and endpoint headers"
    )


def test_shared_url_different_display_names_are_separate_rows(monkeypatch):
    """Multiple custom_providers entries sharing base_url + api_key + api_mode
    but with *different* display-name prefixes (e.g. a proxy fronting
    cerebras, groq and perplexity at one URL) must each get their own picker
    row, not collapse into one."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    # Stub live discovery so the test is deterministic regardless of network.
    monkeypatch.setattr(
        "hermes_cli.models.fetch_api_models",
        lambda api_key, base_url, **kwargs: [],
    )

    providers = list_authenticated_providers(
        current_provider="openrouter",
        current_base_url="https://openrouter.ai/api/v1",
        user_providers={},
        custom_providers=[
            {"name": "Cerebras", "base_url": "https://proxy.example.com/v1",
             "api_key": "proxy-key", "model": "llama-4-scout"},
            {"name": "Groq", "base_url": "https://proxy.example.com/v1",
             "api_key": "proxy-key", "model": "llama-4-scout"},
            {"name": "Perplexity", "base_url": "https://proxy.example.com/v1",
             "api_key": "proxy-key", "model": "sonar-pro"},
        ],
        max_models=50,
    )

    custom = [p for p in providers if p.get("is_user_defined")]
    names = sorted(p["name"] for p in custom)
    assert names == ["Cerebras", "Groq", "Perplexity"], (
        f"expected three separate rows, got {names}"
    )
    # Each row carries only its own model (no cross-contamination).
    by_name = {p["name"]: p["models"] for p in custom}
    assert by_name["Cerebras"] == ["llama-4-scout"]
    assert by_name["Groq"] == ["llama-4-scout"]
    assert by_name["Perplexity"] == ["sonar-pro"]


def test_excluded_providers_hides_builtin_row(monkeypatch):
    """``excluded_providers`` must hide a built-in provider row that would
    otherwise surface when its credentials are present."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")

    baseline = list_authenticated_providers(
        current_provider="openrouter",
        current_base_url="https://openrouter.ai/api/v1",
        user_providers={},
        custom_providers=[],
        max_models=50,
    )
    assert any(p["slug"] == "openrouter" for p in baseline), (
        "sanity: openrouter row must appear when OPENROUTER_API_KEY is set"
    )

    filtered = list_authenticated_providers(
        current_provider="openrouter",
        current_base_url="https://openrouter.ai/api/v1",
        user_providers={},
        custom_providers=[],
        max_models=50,
        excluded_providers=["openrouter"],
    )
    assert not any(p["slug"] == "openrouter" for p in filtered), (
        "excluded_providers=['openrouter'] must hide the openrouter row"
    )


def test_custom_provider_context_length_models_dict_still_probes(monkeypatch):
    """Dict-shaped ``models:`` from ``_save_custom_provider`` is metadata.

    ``hermes model`` writes ``models: {default: {context_length: N}}`` for
    local Ollama. That must not suppress live /v1/models discovery — otherwise
    Desktop/Telegram only show the saved default and Refresh does nothing.
    """
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    calls = []

    def fetch(api_key, base_url, **kwargs):
        calls.append((api_key, base_url, kwargs))
        return ["qwen3.6:35b-mlx", "gemma4:31b", "llama3"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider="custom:local-ollama",
        user_providers={},
        custom_providers=[
            {
                "name": "Local Ollama",
                "base_url": "http://localhost:11434/v1",
                "model": "qwen3.6:35b-mlx",
                "models": {"qwen3.6:35b-mlx": {"context_length": 32768}},
            }
        ],
        # GUI picker path: probe current custom provider only.
        probe_custom_providers=False,
        probe_current_custom_provider=True,
        current_base_url="http://localhost:11434/v1",
    )

    assert len(calls) == 1
    assert calls[0][0] == ""
    assert calls[0][1] == "http://localhost:11434/v1"
    row = next(p for p in providers if p["name"] == "Local Ollama")
    assert row["models"] == ["qwen3.6:35b-mlx", "gemma4:31b", "llama3"]
    assert row["total_models"] == 3


def test_custom_provider_dict_models_pin_requires_discover_false(monkeypatch):
    """Dict-shaped catalogs pin only when ``discover_models: false``."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    calls = []

    def fetch(*args, **kwargs):
        calls.append((args, kwargs))
        return ["unexpected-live-model"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider="custom:local-ollama",
        user_providers={},
        custom_providers=[
            {
                "name": "Local Ollama",
                "base_url": "http://localhost:11434/v1",
                "model": "llama3",
                "models": {"llama3": {}},
                "discover_models": False,
            }
        ],
    )

    row = next(p for p in providers if p["name"] == "Local Ollama")
    assert calls == []
    assert row["models"] == ["llama3"]


# ─── No-probe picker opens still serve the cached catalog ───────────────
#
# #58183 stopped GUI picker opens from live-probing saved custom endpoints so
# a stopped local server could not stall the picker. It skipped the cached
# read along with the network one, so a non-current endpoint collapsed to the
# one model named in config even with a full catalog already on disk. These
# pin both halves: the cache is served, the network is not touched.


_LOCAL_ENDPOINT = "http://127.0.0.1:8000/v1"
_LOCAL_CATALOG = [f"omlx-model-{i}" for i in range(1, 9)]
_SHARED_PROXY_URL = "https://proxy.example.com/v1"


def _seed_custom_model_cache(monkeypatch, models, *, age_seconds=10):
    """Put *models* on disk for ``_LOCAL_ENDPOINT`` under the no-credential
    fingerprint the picker probes local endpoints with."""
    import hermes_cli.models as models_mod

    fp = models_mod._custom_endpoint_fingerprint("", None, None)
    cache = {
        f"custom:{_LOCAL_ENDPOINT}#{fp}": {
            "fp": fp,
            "at": time.time() - age_seconds,
            "models": list(models),
        }
    }
    monkeypatch.setattr(models_mod, "_load_provider_models_cache", lambda: cache)


def _no_probe_local_row(monkeypatch, *, custom_providers=None, user_providers=None,
                        current_provider="nous", **kwargs):
    """Run the GUI picker path (no live probing) and return the local row
    plus every base_url a live fetch was attempted against."""
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    fetched = []

    def fetch(_api_key, base_url, **_kwargs):
        fetched.append(base_url)
        return ["should-not-be-reached"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider=current_provider,
        user_providers=user_providers or {},
        custom_providers=custom_providers or [],
        for_picker=True,
        refresh=False,
        probe_custom_providers=False,
        probe_current_custom_provider=True,
        **kwargs,
    )
    row = next(
        (p for p in providers if _LOCAL_ENDPOINT in str(p.get("api_url", ""))), None
    )
    return row, fetched


def test_no_probe_open_serves_cached_catalog_for_custom_provider(monkeypatch):
    """A ``custom_providers`` endpoint that is not the current provider still
    shows its full discovered catalog, from cache, with no network call."""
    _seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)

    row, fetched = _no_probe_local_row(
        monkeypatch,
        custom_providers=[
            {
                "name": "Local (127.0.0.1:8000)",
                "base_url": _LOCAL_ENDPOINT,
                "model": "omlx-model-1",
            }
        ],
    )

    assert row is not None
    assert row["is_current"] is False
    assert row["models"] == _LOCAL_CATALOG
    assert row["total_models"] == len(_LOCAL_CATALOG)
    assert fetched == []


def test_no_probe_open_serves_cached_catalog_for_user_provider(monkeypatch):
    """Same contract for a ``providers:`` entry (section 3)."""
    _seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)

    row, fetched = _no_probe_local_row(
        monkeypatch,
        user_providers={
            "local-8000": {
                "name": "Local (127.0.0.1:8000)",
                "base_url": _LOCAL_ENDPOINT,
                "default_model": "omlx-model-1",
            }
        },
    )

    assert row is not None
    assert row["models"] == _LOCAL_CATALOG
    assert fetched == []


def test_no_probe_open_serves_cached_catalog_for_bare_custom_endpoint(monkeypatch):
    """Same contract for the bare ``provider: custom`` shape (section 3b),
    where the fallback would otherwise be the single active model."""
    _seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)

    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    fetched = []
    monkeypatch.setattr(
        "hermes_cli.models.fetch_api_models",
        lambda _k, base_url, **_kw: (fetched.append(base_url), None)[1],
    )

    providers = list_authenticated_providers(
        current_provider="custom",
        current_base_url=_LOCAL_ENDPOINT,
        current_model="omlx-model-1",
        user_providers={},
        custom_providers=[],
        for_picker=True,
        refresh=False,
        probe_custom_providers=False,
        probe_current_custom_provider=False,
    )

    row = next(p for p in providers if p["slug"] == "custom")
    assert row["models"] == _LOCAL_CATALOG
    assert fetched == []


def test_no_probe_open_without_cache_keeps_configured_models_and_stays_offline(
    monkeypatch,
):
    """The #58183 guarantee: a cold cache must not trigger a live probe. The
    row degrades to its configured list rather than stalling on a dead port."""
    _seed_custom_model_cache(monkeypatch, [], age_seconds=10)

    row, fetched = _no_probe_local_row(
        monkeypatch,
        custom_providers=[
            {
                "name": "Local (127.0.0.1:8000)",
                "base_url": _LOCAL_ENDPOINT,
                "model": "omlx-model-1",
            }
        ],
    )

    assert row is not None
    assert row["models"] == ["omlx-model-1"]
    assert fetched == []


def test_no_probe_open_respects_discover_models_false(monkeypatch):
    """A user who pinned their catalog must not have it replaced from cache."""
    _seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)

    row, fetched = _no_probe_local_row(
        monkeypatch,
        custom_providers=[
            {
                "name": "Local (127.0.0.1:8000)",
                "base_url": _LOCAL_ENDPOINT,
                "model": "pinned-model",
                "models": ["pinned-model"],
                "discover_models": False,
            }
        ],
    )

    assert row is not None
    assert row["models"] == ["pinned-model"]
    assert fetched == []


def test_cached_catalog_is_not_written_back_to_config(monkeypatch):
    """Only a real probe persists discovered models; a cache hit is already
    the product of the probe that saved it."""
    _seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)
    saves = []
    monkeypatch.setattr(
        "hermes_cli.model_switch_providers._save_discovered_models_to_config",
        lambda api_url, model_ids, **kwargs: saves.append((api_url, model_ids)),
    )

    row, _ = _no_probe_local_row(
        monkeypatch,
        custom_providers=[
            {
                "name": "Local (127.0.0.1:8000)",
                "base_url": _LOCAL_ENDPOINT,
                "model": "omlx-model-1",
            }
        ],
    )

    assert row["models"] == _LOCAL_CATALOG
    assert saves == []


def test_keyless_endpoint_with_saved_catalog_still_reads_cache(monkeypatch):
    """A keyless local server must not be pinned by Hermes' own auto-save.

    ``_save_discovered_models_to_config()`` writes a plain list into
    ``models:``, which ``_models_config_is_allowlist()`` reads back as an
    explicit allowlist. Combined with the no-key discovery gate, a keyless
    endpoint (the common local-model-server shape) froze on the catalog of
    its first probe and could never widen again — the exact "lineup changes
    after config was written" case. The cache read must not be subject to the
    probe's network-cost gate.
    """
    _seed_custom_model_cache(monkeypatch, _LOCAL_CATALOG)

    row, fetched = _no_probe_local_row(
        monkeypatch,
        custom_providers=[
            {
                "name": "Local (127.0.0.1:8000)",
                "base_url": _LOCAL_ENDPOINT,
                "model": "omlx-model-1",
                # No api_key, and a models: list of the shape our own
                # auto-save writes after a successful probe.
                "models": ["omlx-model-1"],
            }
        ],
    )

    assert row is not None
    assert row["models"] == _LOCAL_CATALOG
    assert fetched == []


def test_keyless_endpoint_with_saved_catalog_is_still_not_probed(monkeypatch):
    """...but the network-cost gate it rides on must survive intact.

    The no-key + declared-catalog combination exists to keep Hermes from
    probing an endpoint it cannot authenticate to. Serving that endpoint from
    a warm cache is free; hitting the network is not. With a cold cache and
    live probing fully enabled, this row must still make zero fetches.
    """
    _seed_custom_model_cache(monkeypatch, [])  # cold: only a probe could answer
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    fetched = []

    def fetch(_api_key, base_url, **_kwargs):
        fetched.append(base_url)
        return ["should-not-be-reached"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    providers = list_authenticated_providers(
        current_provider="nous",
        user_providers={},
        custom_providers=[
            {
                "name": "Local (127.0.0.1:8000)",
                "base_url": _LOCAL_ENDPOINT,
                "model": "omlx-model-1",
                "models": ["omlx-model-1"],
            }
        ],
        for_picker=True,
        refresh=False,
        probe_custom_providers=True,  # live probing fully enabled
    )
    row = next(
        (p for p in providers if _LOCAL_ENDPOINT in str(p.get("api_url", ""))), None
    )

    assert row is not None
    assert row["models"] == ["omlx-model-1"]
    assert fetched == []


def test_api_mode_rows_do_not_share_a_cached_catalog(monkeypatch):
    """Two rows differing only by ``api_mode`` must not share a cache entry.

    ``api_mode`` selects the wire protocol — ``x-api-key`` +
    ``anthropic-version`` versus ``Authorization: Bearer`` — so it is part of
    both the picker's group identity and
    ``_custom_endpoint_fingerprint()``. The cache read has to pass it through
    or an ``anthropic_messages`` row renders whatever the OpenAI-mode row
    cached against the same base_url.
    """
    import hermes_cli.models as models_mod

    openai_catalog = ["gpt-oss-a", "gpt-oss-b"]
    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})

    fetched = []

    def fetch(_api_key, base_url, **_kwargs):
        fetched.append(base_url)
        return ["should-not-be-reached"]

    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)

    # Only the OpenAI-mode probe (api_mode=None) is on disk.
    fp = models_mod._custom_endpoint_fingerprint("sk-shared", None, None)
    cache = {
        f"custom:{_SHARED_PROXY_URL}#{fp}": {
            "fp": fp,
            "at": time.time() - 10,
            "models": list(openai_catalog),
        }
    }
    monkeypatch.setattr(models_mod, "_load_provider_models_cache", lambda: cache)

    def _row(entry):
        providers = list_authenticated_providers(
            current_provider="nous",
            user_providers={},
            custom_providers=[entry],
            for_picker=True,
            refresh=False,
            probe_custom_providers=False,
            probe_current_custom_provider=True,
        )
        return next(
            (p for p in providers if _SHARED_PROXY_URL in str(p.get("api_url", ""))),
            None,
        )

    anthropic_row = _row(
        {
            "name": "Proxy Anthropic",
            "base_url": _SHARED_PROXY_URL,
            "api_key": "sk-shared",
            "api_mode": "anthropic_messages",
            "model": "claude-via-proxy",
        }
    )
    openai_row = _row(
        {
            "name": "Proxy OpenAI",
            "base_url": _SHARED_PROXY_URL,
            "api_key": "sk-shared",
            "model": "gpt-via-proxy",
        }
    )

    assert anthropic_row is not None and openai_row is not None
    assert anthropic_row["models"] == ["claude-via-proxy"], (
        "an anthropic_messages row must not render the OpenAI-mode catalog "
        "cached against the same base_url"
    )
    # ...while the row the entry actually belongs to still resolves.
    assert openai_row["models"] == openai_catalog
    assert fetched == []


def test_auto_saved_catalog_round_trips_without_pinning(tmp_path, monkeypatch):
    """End-to-end: the shape we persist must not read back as a user pin.

    Guards the whole chain rather than one branch — probe saves a catalog,
    config is reloaded, and the endpoint must still be discoverable. If a
    future change makes the saved shape look like an intentional allowlist
    again, this fails even if the gate logic above is refactored away.
    """
    import hermes_cli.config as config_mod

    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(
        "custom_providers:\n"
        f"  - name: Local MLX\n    base_url: {_LOCAL_ENDPOINT}\n"
        "    model: omlx-model-1\n"
    )
    monkeypatch.setattr(config_mod, "CONFIG_PATH", str(cfg_path), raising=False)

    _save_discovered_models_to_config(_LOCAL_ENDPOINT, list(_LOCAL_CATALOG))

    saved = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))["custom_providers"][0]
    assert saved["models_discovered"] is True
    assert list(saved["models"]) == _LOCAL_CATALOG
    assert not any(m.startswith("__") for m in saved["models"]), (
        "sentinel keys must never appear inside the user-facing models mapping"
    )

    # The persisted shape is what the picker will read on the next open. It
    # must not, on a keyless entry, suppress discovery of a wider catalog.
    _seed_custom_model_cache(monkeypatch, [*_LOCAL_CATALOG, "omlx-model-9"])
    row, fetched = _no_probe_local_row(
        monkeypatch, custom_providers=[saved]
    )

    assert row is not None
    assert row["models"] == [*_LOCAL_CATALOG, "omlx-model-9"], (
        "an auto-saved catalog must not pin the endpoint against a newer "
        "cached lineup"
    )
    assert fetched == []


def test_legacy_sentinel_catalog_still_resolves_and_migrates(tmp_path, monkeypatch):
    """Old-shape configs (sentinels inside ``models``) keep working.

    Pre-fix Hermes wrote ``__discovered_model_catalog__: true`` (and
    ``__explicit_model_allowlist__``) inside the user-facing ``models``
    mapping. Reading such a config must (a) recognize the catalog as
    discovered — not a user pin, (b) never list the sentinels as model IDs,
    and (c) migrate to the clean entry-level ``models_discovered`` shape on
    the next discovery save.
    """
    import hermes_cli.config as config_mod
    from hermes_cli.model_switch import (
        _declared_model_ids,
        _entry_models_discovered,
        _models_config_is_allowlist,
    )

    legacy_entry = {
        "name": "Local MLX",
        "base_url": _LOCAL_ENDPOINT,
        "model": "omlx-model-1",
        "models": {
            "__discovered_model_catalog__": True,
            **{m: {} for m in _LOCAL_CATALOG},
        },
    }

    # (a) recognized as a discovered catalog, not an allowlist.
    assert _entry_models_discovered(legacy_entry) is True
    assert not _models_config_is_allowlist(
        legacy_entry["models"], _entry_models_discovered(legacy_entry)
    )

    # (b) sentinels never surface as model IDs.
    assert _declared_model_ids(legacy_entry["models"]) == _LOCAL_CATALOG
    normalized = config_mod._normalize_custom_provider_entry(dict(legacy_entry))
    assert normalized is not None
    assert normalized["models_discovered"] is True
    assert list(normalized["models"]) == _LOCAL_CATALOG
    assert not any(m.startswith("__") for m in normalized["models"])

    # ...and the picker row built from the legacy entry lists no phantoms.
    _seed_custom_model_cache(monkeypatch, [])
    row, fetched = _no_probe_local_row(
        monkeypatch, custom_providers=[legacy_entry]
    )
    assert row is not None
    assert not any(str(m).startswith("__") for m in row["models"])
    assert fetched == []

    # (c) the next discovery save rewrites to the clean shape.
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    cfg_path = tmp_path / "config.yaml"
    cfg_path.write_text(
        yaml.safe_dump({"custom_providers": [legacy_entry]})
    )
    monkeypatch.setattr(config_mod, "CONFIG_PATH", str(cfg_path), raising=False)

    _save_discovered_models_to_config(_LOCAL_ENDPOINT, list(_LOCAL_CATALOG))

    saved = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))["custom_providers"][0]
    assert saved["models_discovered"] is True
    assert list(saved["models"]) == _LOCAL_CATALOG
    assert "__discovered_model_catalog__" not in saved["models"]
    assert "__explicit_model_allowlist__" not in saved["models"]


def test_same_provider_switch_on_session_only_custom_endpoint_keeps_endpoint(monkeypatch):
    """#74143: a same-provider ``/model`` on a bare ``custom`` session whose base_url is NOT the
    trusted config ``model.base_url`` must stay on that endpoint with its key — re-resolving from
    config fell through to the OpenRouter default and moved the next turn to a host the user never
    picked."""
    for var in ("OPENROUTER_API_KEY", "OPENROUTER_BASE_URL", "CUSTOM_BASE_URL", "CUSTOM_API_KEY", "OPENAI_API_KEY"):
        monkeypatch.delenv(var, raising=False)
    monkeypatch.setattr("hermes_cli.model_switch.load_config", lambda: {"model": {"provider": "openrouter", "default": "x"}}, raising=False)
    monkeypatch.setattr(
        "hermes_cli.models.probe_api_models",
        lambda api_key, base_url, **kw: {"models": ["m-a", "m-b"], "url": base_url + "/models", "base_url": base_url,
                                          "suggested_base_url": None, "used_fallback": False})

    result = switch_model("m-b", "custom", "m-a", "http://10.0.0.5:8000/v1", "session-secret")

    assert result.success
    assert result.base_url == "http://10.0.0.5:8000/v1"
    assert result.api_key == "session-secret"
