"""
Hermes Web UI -- Shared configuration, constants, and global state.
Imported by all other api/* modules and by server.py.

Discovery order for all paths:
  1. Explicit environment variable
  2. Filesystem heuristics (sibling checkout, parent dir, common install locations)
  3. Hardened defaults relative to $HOME
  4. Fail loudly with a human-readable fix-it message if required modules are missing
"""

import collections
import copy
import hashlib
import json
import logging
import math
import os
import queue
import re
import socket
import sys
import threading
import time
import traceback
import urllib.error
import urllib.request
import uuid
import weakref
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

# ── Basic layout ──────────────────────────────────────────────────────────────
import api.paths as _paths
from api.plugin_providers import (
    effective_provider_display_name as _effective_provider_display_name,
    is_plugin_model_provider as _is_plugin_model_provider,
    plugin_model_provider_profiles as _plugin_model_provider_profiles,
)

HOME = _paths.HOME
_hermes_home_has_webui_state = _paths._hermes_home_has_webui_state
_platform_default_hermes_home = _paths._platform_default_hermes_home

# REPO_ROOT is the directory that contains this file's parent (api/ -> repo root)
REPO_ROOT = Path(__file__).parent.parent.resolve()

# ── Network config (env-overridable) ─────────────────────────────────────────
HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))


def _env_int(name: str, default: int, *, minimum: int = 1) -> int:
    """Read a positive int from the environment, falling back on bad input.

    Used for operator-tunable memory caps (issue #3506) so large installs can
    shrink the agent/session caches without editing source. A missing, empty,
    non-numeric, or below-``minimum`` value falls back to ``default`` so a typo
    can never disable a cache bound entirely.
    """
    raw = os.getenv(name)
    if raw is None or not str(raw).strip():
        return default
    try:
        value = int(str(raw).strip())
    except (TypeError, ValueError):
        return default
    return value if value >= minimum else default


def _env_int_clamped(name: str, default: int, *, minimum: int = 1, maximum: int) -> int:
    """Like ``_env_int``, then clamp a valid override to ``maximum``."""
    value = _env_int(name, default, minimum=minimum)
    if not str(os.getenv(name) or "").strip():
        return value
    return min(value, maximum)


# Sidebar recency window. Resolved here, before profile init, so a profile
# .env cannot override a server-wide resource bound. Clamped at 200.
CLI_VISIBLE_SESSION_LIMIT = _env_int_clamped(
    "HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20, maximum=200,
)

# ── TLS/HTTPS config (optional, env-overridable) ────────────────────────────
TLS_CERT = os.getenv("HERMES_WEBUI_TLS_CERT", "").strip() or None
TLS_KEY = os.getenv("HERMES_WEBUI_TLS_KEY", "").strip() or None
TLS_ENABLED = TLS_CERT is not None and TLS_KEY is not None

# ── State directory (env-overridable, never inside repo) ──────────────────────
_DEFAULT_HERMES_HOME = _platform_default_hermes_home()
_DEFAULT_STATE_HOME = Path(os.getenv("HERMES_HOME") or _DEFAULT_HERMES_HOME).expanduser()

STATE_DIR = (
    Path(os.getenv("HERMES_WEBUI_STATE_DIR", str(_DEFAULT_STATE_HOME / "webui")))
    .expanduser()
    .resolve()
)


def _resolve_settings_file(state_dir: Path) -> Path:
    """Resolve an optional per-instance settings file without splitting session state."""
    configured = os.getenv("HERMES_WEBUI_SETTINGS_FILE", "").strip()
    if configured:
        return Path(configured).expanduser().resolve()
    return state_dir / "settings.json"


SESSION_DIR = STATE_DIR / "sessions"
WORKSPACES_FILE = STATE_DIR / "workspaces.json"
SESSION_INDEX_FILE = SESSION_DIR / "_index.json"
SETTINGS_FILE = _resolve_settings_file(STATE_DIR)
LAST_WORKSPACE_FILE = STATE_DIR / "last_workspace.txt"
PROJECTS_FILE = STATE_DIR / "projects.json"

logger = logging.getLogger(__name__)

# Keep custom provider /v1/models probes below the frontend's generic request
# timeout even when one upstream is slow or unreachable. The models cache rebuild
# path probes configured custom endpoints serially, so each provider needs a
# short hard cap and graceful degradation.
CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS = 5.0


def _env_mb_bytes(name: str, default_mb: int) -> int:
    """Parse an optional megabyte environment variable into bytes.

    Accepts values like ``200``, ``200MB``, or ``200MiB``. Invalid or
    non-positive values fall back to the provided default.
    """
    raw = os.getenv(name, "").strip()
    if not raw:
        return default_mb * 1024 * 1024
    m = re.match(r"^(\d+)\s*(?:m|mb|mib)?$", raw, re.IGNORECASE)
    if not m:
        logger.warning(
            "Invalid %s=%r; expected a positive integer in MB. Falling back to %sMB.",
            name,
            raw,
            default_mb,
        )
        return default_mb * 1024 * 1024
    value_mb = int(m.group(1))
    if value_mb <= 0:
        logger.warning(
            "Invalid %s=%r; expected a value greater than zero. Falling back to %sMB.",
            name,
            raw,
            default_mb,
        )
        return default_mb * 1024 * 1024
    return value_mb * 1024 * 1024


# ── Hermes agent directory discovery ─────────────────────────────────────────
def _discover_agent_dir() -> Path:
    """
    Locate the hermes-agent checkout using a multi-strategy search.

    Priority:
      1. HERMES_WEBUI_AGENT_DIR env var  -- explicit override always wins
      2. HERMES_HOME / hermes-agent      -- e.g. ~/.hermes/hermes-agent
      3. Sibling of this repo            -- ../hermes-agent
      4. Parent of this repo             -- ../../hermes-agent (nested layout)
      5. Common install paths            -- ~/.hermes/hermes-agent (again as fallback)
      6. HOME / hermes-agent             -- ~/hermes-agent (simple flat layout)
    """
    explicit_override = os.getenv("HERMES_WEBUI_AGENT_DIR")
    if explicit_override:
        explicit_path = Path(explicit_override).expanduser().resolve()
        if explicit_path.exists() and _looks_like_agent_source_root(explicit_path):
            return explicit_path

    candidates = []

    # 2. HERMES_HOME / hermes-agent
    hermes_home = os.getenv("HERMES_HOME", str(_DEFAULT_HERMES_HOME))
    candidates.append(Path(hermes_home).expanduser() / "hermes-agent")

    # 3. Sibling: <repo-root>/../hermes-agent
    candidates.append(REPO_ROOT.parent / "hermes-agent")

    # 4. Parent is the agent repo itself (repo cloned inside hermes-agent/)
    if _looks_like_agent_source_root(REPO_ROOT.parent):
        candidates.append(REPO_ROOT.parent)

    # 5. ~/.hermes/hermes-agent (explicit common path)
    candidates.append(_DEFAULT_HERMES_HOME / "hermes-agent")

    # 6. ~/hermes-agent
    candidates.append(HOME / "hermes-agent")

    # 7. XDG_DATA_HOME / hermes-agent  (e.g. ~/.local/share/hermes-agent)
    xdg_data = Path(os.getenv("XDG_DATA_HOME", str(HOME / ".local" / "share")))
    candidates.append(xdg_data.expanduser() / "hermes-agent")

    # 8. System-wide install paths (e.g. /opt/hermes-agent, /usr/local/hermes-agent)
    for sys_prefix in ("/opt", "/usr/local", "/usr/local/share"):
        candidates.append(Path(sys_prefix) / "hermes-agent")

    # Prefer real source checkouts before pip-style roots so lookalikes cannot preempt them.
    for path in candidates:
        if path.exists() and (path / "run_agent.py").exists():
            return path.resolve()

    for path in candidates:
        if path.exists() and _looks_like_pip_style_agent_source_root(path):
            return path.resolve()

    return None


def _looks_like_agent_source_root(path: Path) -> bool:
    """Return True when a directory resembles a hermes-agent source root."""
    if (path / "run_agent.py").exists():
        return True
    return _looks_like_pip_style_agent_source_root(path)


def _looks_like_pip_style_agent_source_root(path: Path) -> bool:
    """Return True for pip-style agent roots with a real agent package signal."""
    if not (path / "cron" / "jobs.py").exists():
        return False
    if (path / "hermes").exists():
        return True
    hermes_cli_dir = path / "hermes_cli"
    return (
        (hermes_cli_dir / "__init__.py").exists()
        or (hermes_cli_dir / "main.py").exists()
    )


def _discover_python(agent_dir: Path) -> str:
    """
    Locate a Python executable that has the Hermes agent dependencies installed.

    Priority:
      1. HERMES_WEBUI_PYTHON env var
      2. Agent venv at <agent_dir>/venv/bin/python
      3. Local .venv inside this repo
      4. System python3
    """
    if os.getenv("HERMES_WEBUI_PYTHON"):
        return os.getenv("HERMES_WEBUI_PYTHON")

    if agent_dir:
        venv_py = agent_dir / "venv" / "bin" / "python"
        if venv_py.exists():
            return str(venv_py)
        
        venv_py = agent_dir / ".venv" / "bin" / "python"
        if venv_py.exists():
            return str(venv_py)

        # Windows layout
        venv_py_win = agent_dir / "venv" / "Scripts" / "python.exe"
        if venv_py_win.exists():
            return str(venv_py_win)
        
        venv_py_win = agent_dir / ".venv" / "Scripts" / "python.exe"
        if venv_py_win.exists():
            return str(venv_py_win)

    # Local .venv inside this repo
    for subdir, binary in (("bin", "python"), ("Scripts", "python.exe")):
        local_venv = REPO_ROOT / ".venv" / subdir / binary
        if local_venv.exists():
            return str(local_venv)

    # Fall back to system python3
    import shutil

    for name in ("python3", "python"):
        found = shutil.which(name)
        if found:
            return found

    return "python3"


# Run discovery
_AGENT_DIR = _discover_agent_dir()
PYTHON_EXE = _discover_python(_AGENT_DIR)

# ── Inject agent dir into sys.path so Hermes modules are importable ──────────

# When users (or CI builds) run `pip install --target .` or
# `pip install -t .` inside the hermes-agent checkout, third-party
# package directories (openai/, pydantic/, requests/, etc.) end up
# alongside real Hermes source files.  Putting _AGENT_DIR at the
# FRONT of sys.path means Python resolves `import pydantic` from that
# local directory — which breaks whenever the host platform differs
# from the container (e.g. macOS .so files inside a Linux image).
#
# Fix: insert _AGENT_DIR at the END of sys.path.  Python searches
# entries in order, so site-packages resolves pip packages correctly,
# and Hermes-specific modules (run_agent, hermes/, etc.) still
# resolve because they do not exist in site-packages.

if _AGENT_DIR is not None:
    if str(_AGENT_DIR) not in sys.path:
        sys.path.append(str(_AGENT_DIR))
    _HERMES_FOUND = True
else:
    _HERMES_FOUND = False

# ── Thread-local env context ─────────────────────────────────────────────────
# Defined BEFORE the config-file section because _expand_env_vars() (below) calls
# _thread_local_env_value() and the import-time reload_config() runs during module
# load — a forward reference here would NameError on any startup config.yaml that
# uses a ${VAR} reference. Depends only on os + threading (both imported above).
_thread_ctx = threading.local()


def _thread_local_env_value(name: str, default: str = "") -> str:
    """Return thread-local profile env first, then process env, for provider reads."""
    env_name = str(name or "").strip()
    if not env_name:
        return default or ""

    thread_env = getattr(_thread_ctx, "env", {})
    if isinstance(thread_env, dict) and env_name in thread_env:
        thread_value = thread_env.get(env_name)
        if thread_value is None:
            return default or ""
        return str(thread_value)

    if bool(getattr(_thread_ctx, "block_process_env_fallback", False)):
        return default or ""

    return str(os.getenv(env_name, default or ""))


# ── Config file (reloadable -- supports profile switching) ──────────────────

def _expand_env_vars(obj):
    """Recursively expand ${VAR} references in config values.

    Uses the thread-local-first profile env lookup (_thread_local_env_value) so a
    ${VAR} reference in a profile's config.yaml resolves to that profile's value,
    and — critically — does NOT fall back to the server process os.environ when a
    profile-scoped readonly/background scope set block_process_env_fallback. The
    raw (unexpanded) dict is what gets cached; this expansion re-runs on every
    read against the current thread's scope, so a cross-profile credential
    (e.g. config api_key: ${ANTHROPIC_TOKEN}) can't be reconstructed from the
    server process env for a named profile that has no such value (#3961)."""
    if isinstance(obj, str):
        return re.sub(
            r"\${([^}]+)}",
            lambda m: _thread_local_env_value(m.group(1), m.group(0)),
            obj,
        )
    if isinstance(obj, dict):
        return {k: _expand_env_vars(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_expand_env_vars(item) for item in obj]
    return obj


_cfg_cache = {}
_cfg_lock = threading.Lock()
_cfg_mtime: float = 0.0  # last known mtime of config.yaml; 0 = never loaded
_cfg_path: Path | None = None  # active config.yaml path for the disk-loaded cache
_cfg_fingerprint: str | None = None  # serialized snapshot from the last disk load


def _fingerprint_config(data: dict) -> str:
    """Return a stable fingerprint for config dictionaries.

    A few tests and legacy call sites still mutate ``cfg`` directly for
    in-memory overrides.  Path-aware reloads should not immediately discard
    those overrides just because the active profile path differs from the last
    disk load, but an unchanged disk-loaded cache must still reload on profile
    switches.
    """
    try:
        return json.dumps(data, sort_keys=True, separators=(",", ":"), default=str)
    except Exception:
        return repr(data)


def _cfg_has_in_memory_overrides() -> bool:
    """True when cfg was changed after the last successful reload_config().

    Detects two override shapes:
      1. ``_cfg_cache`` was mutated in place (fingerprint differs).
      2. ``cfg`` (the module attribute) was rebound to a different dict —
         e.g. ``monkeypatch.setattr(config, "cfg", {...})`` in tests. The
         alias-with-the-cache pattern at module load means this is a common
         test-isolation override, and silently reloading from disk over it
         (the v0.51.7 path-aware reload regression) breaks any test that
         relies on the override.
    """
    if _cfg_fingerprint is not None and _fingerprint_config(_cfg_cache) != _cfg_fingerprint:
        return True
    # Module attribute rebound away from _cfg_cache by a test or runtime caller.
    try:
        return cfg is not _cfg_cache
    except NameError:
        # cfg not yet defined (during initial reload_config() at import time).
        return False


def _get_config_path() -> Path:
    """Return config.yaml path for the active profile."""
    env_override = os.getenv("HERMES_CONFIG_PATH")
    if env_override:
        return Path(env_override).expanduser()
    try:
        from api.profiles import get_active_hermes_home

        return get_active_hermes_home() / "config.yaml"
    except ImportError:
        return _DEFAULT_HERMES_HOME / "config.yaml"


_WEBUI_SESSION_SAVE_MODES = {"deferred", "eager"}
_DEFAULT_WEBUI_SESSION_SAVE_MODE = "deferred"
_DEFAULT_EXPERIMENTAL_CONFIG = {
    # Dormant first slice for the unified SessionDB migration. Runtime WebUI
    # session call sites must continue using the existing JSON paths unless a
    # later PR deliberately enables and wires this flag.
    "unified_session_db": False,
}
_DEFAULT_AGENT_PERSONALITIES = {
    # Mirrors the Hermes Agent CLI built-ins so WebUI's config-derived
    # /personality path is not empty for fresh profiles.
    "helpful": "You are a helpful, friendly AI assistant.",
    "concise": "You are a concise assistant. Keep responses brief and to the point.",
    "technical": "You are a technical expert. Provide detailed, accurate technical information.",
    "creative": "You are a creative assistant. Think outside the box and offer innovative solutions.",
    "teacher": "You are a patient teacher. Explain concepts clearly with examples.",
    "kawaii": "You are a kawaii assistant! Use cute expressions like (◕‿◕), ★, ♪, and ~! Add sparkles and be super enthusiastic about everything! Every response should feel warm and adorable desu~! ヽ(>∀<☆)ノ",
    "catgirl": "You are Neko-chan, an anime catgirl AI assistant, nya~! Add 'nya' and cat-like expressions to your speech. Use kaomoji like (=^･ω･^=) and ฅ^•ﻌ•^ฅ. Be playful and curious like a cat, nya~!",
    "pirate": "Arrr! Ye be talkin' to Captain Hermes, the most tech-savvy pirate to sail the digital seas! Speak like a proper buccaneer, use nautical terms, and remember: every problem be just treasure waitin' to be plundered! Yo ho ho!",
    "shakespeare": "Hark! Thou speakest with an assistant most versed in the bardic arts. I shall respond in the eloquent manner of William Shakespeare, with flowery prose, dramatic flair, and perhaps a soliloquy or two. What light through yonder terminal breaks?",
    "surfer": "Duuude! You're chatting with the chillest AI on the web, bro! Everything's gonna be totally rad. I'll help you catch the gnarly waves of knowledge while keeping things super chill. Cowabunga!",
    "noir": "The rain hammered against the terminal like regrets on a guilty conscience. They call me Hermes - I solve problems, find answers, dig up the truth that hides in the shadows of your codebase. In this city of silicon and secrets, everyone's got something to hide. What's your story, pal?",
    "uwu": "hewwo! i'm your fwiendwy assistant uwu~ i wiww twy my best to hewp you! *nuzzles your code* OwO what's this? wet me take a wook! i pwomise to be vewy hewpful >w<",
    "philosopher": "Greetings, seeker of wisdom. I am an assistant who contemplates the deeper meaning behind every query. Let us examine not just the 'how' but the 'why' of your questions. Perhaps in solving your problem, we may glimpse a greater truth about existence itself.",
    "hype": "YOOO LET'S GOOOO!!! I am SO PUMPED to help you today! Every question is AMAZING and we're gonna CRUSH IT together! This is gonna be LEGENDARY! ARE YOU READY?! LET'S DO THIS!",
}


def _apply_config_defaults(config_data: dict) -> None:
    """Populate documented default-only config keys in-place."""
    agent_cfg = config_data.get("agent")
    if not isinstance(agent_cfg, dict):
        agent_cfg = {}
        config_data["agent"] = agent_cfg

    personalities = agent_cfg.get("personalities")
    if isinstance(personalities, dict):
        merged = copy.deepcopy(_DEFAULT_AGENT_PERSONALITIES)
        merged.update(copy.deepcopy(personalities))
        agent_cfg["personalities"] = merged
    else:
        # Keep behavior aligned with CLI loader defaults: if personalities are
        # absent or malformed, replace the section entirely with built-ins.
        agent_cfg["personalities"] = copy.deepcopy(_DEFAULT_AGENT_PERSONALITIES)

    experimental = config_data.get("experimental")
    if not isinstance(experimental, dict):
        experimental = {}
        config_data["experimental"] = experimental
    for key, value in _DEFAULT_EXPERIMENTAL_CONFIG.items():
        experimental.setdefault(key, value)

 
def reload_config_if_stale() -> None:
    """Refresh config.yaml once for concurrent stale read paths."""
    global cfg
    with _cfg_lock:
        try:
            config_path = _get_config_path()
            current_mtime = config_path.stat().st_mtime
        except OSError:
            current_mtime = 0.0
        path_changed = _cfg_path != config_path
        mtime_stale = current_mtime != _cfg_mtime
        if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()):
            _refresh_config_cache(config_path)
            if path_changed:
                cfg = _cfg_cache


def get_config() -> dict:
    """Return the cached config dict, loading from disk if needed."""
    config_path = _get_config_path()
    try:
        current_mtime = config_path.stat().st_mtime
    except OSError:
        current_mtime = 0.0
    path_changed = _cfg_path != config_path
    mtime_stale = current_mtime != _cfg_mtime
    if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()):
        reload_config_if_stale()
    # When a test (or runtime caller) has rebound ``cfg`` to a different dict
    # via monkeypatch.setattr(config, "cfg", ...), return that override rather
    # than the underlying _cfg_cache. Without this branch, get_config() would
    # silently bypass the override even though _cfg_has_in_memory_overrides()
    # correctly suppressed the reload.
    try:
        if cfg is not _cfg_cache:
            return cfg
    except NameError:
        pass
    return _cfg_cache


def get_config_snapshot() -> dict:
    """Return a request-owned config snapshot captured under the cache lock."""
    with _cfg_lock:
        config_path = _get_config_path()
        try:
            current_mtime = config_path.stat().st_mtime
        except OSError:
            current_mtime = 0.0
        path_changed = _cfg_path != config_path
        mtime_stale = current_mtime != _cfg_mtime
        if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()):
            _refresh_config_cache(config_path)
        try:
            active_cfg = cfg if cfg is not _cfg_cache else _cfg_cache
        except NameError:
            active_cfg = _cfg_cache
        return copy.deepcopy(active_cfg)


def get_webui_session_save_mode(config_data: dict | None = None) -> str:
    """Return the validated first-turn session persistence mode.

    ``deferred`` preserves the current first-turn sidecar behaviour: persist
    pending_user_message/runtime fields before streaming, then merge the turn
    after the agent finishes. ``eager`` additionally checkpoints the current
    user turn into ``messages`` before launching the agent thread. Unknown
    values fail closed to ``deferred`` so a typo never reintroduces eager disk
    writes unexpectedly.
    """
    active_cfg = config_data if isinstance(config_data, dict) else cfg
    webui_cfg = active_cfg.get("webui", {}) if isinstance(active_cfg, dict) else {}
    if not isinstance(webui_cfg, dict):
        return _DEFAULT_WEBUI_SESSION_SAVE_MODE
    mode = webui_cfg.get("session_save_mode", _DEFAULT_WEBUI_SESSION_SAVE_MODE)
    if isinstance(mode, str):
        normalized = mode.strip().lower()
        if normalized in _WEBUI_SESSION_SAVE_MODES:
            return normalized
    return _DEFAULT_WEBUI_SESSION_SAVE_MODE


def is_unified_session_db_enabled(config_data: dict | None = None) -> bool:
    """Return the dormant unified-session-db feature flag.

    The default is intentionally false so adding the JSON adapter cannot change
    runtime persistence until a later migration PR switches call sites.
    """
    active_cfg = config_data if isinstance(config_data, dict) else cfg
    experimental = active_cfg.get("experimental", {}) if isinstance(active_cfg, dict) else {}
    if not isinstance(experimental, dict):
        return False
    return experimental.get("unified_session_db") is True


def _refresh_config_cache(config_path: Path | None = None) -> None:
    """Refresh _cfg_cache for ``config_path``.

    Callers must hold _cfg_lock when invoking this helper because it mutates
    shared state.
    """
    global _cfg_mtime, _cfg_path, _cfg_fingerprint
    if config_path is None:
        config_path = _get_config_path()
    _cfg_cache.clear()
    # Remember the old mtime so we can tell whether config actually changed
    # vs. first-ever load (mtime == 0.0, e.g. server start or profile switch).
    _old_cfg_mtime = _cfg_mtime
    _old_cfg_path = _cfg_path
    _cfg_path = config_path
    _cfg_mtime = 0.0
    try:
        if config_path.exists():
            # Route the parse through the mtime-keyed cache (#4652) so an
            # unchanged config.yaml isn't re-parsed (~125ms+ on a large file)
            # on every reload_config() on the hot path (profile switch /
            # load_settings, #4662 Phase 2). We take the RAW cached dict and
            # run the env expansion HERE, pinned to the unscoped process-env
            # view (below) — never the helper's per-call expansion — for the
            # #798 TLS reason documented in the pin block.
            loaded = _load_yaml_config_file_raw(config_path)
            if isinstance(loaded, dict):
                if loaded:
                    # The process-global _cfg_cache must reflect PROCESS-env
                    # expansion, never a profile-scoped block_process_env_fallback
                    # view — otherwise a reload that fires while a readonly/worker
                    # scope is active (profile alternation resolves _get_config_path
                    # to the named profile, #798 TLS) would bake under-expanded
                    # literal ${VAR}s into the shared cache and starve concurrent
                    # readers of the module-level `cfg` alias. Expansion re-runs
                    # per-read elsewhere; here we pin the cache to the unscoped view.
                    _prev_block = getattr(_thread_ctx, "block_process_env_fallback", False)
                    _prev_env = getattr(_thread_ctx, "env", None)
                    try:
                        _thread_ctx.block_process_env_fallback = False
                        _thread_ctx.env = {}
                        _cfg_cache.update(_expand_env_vars(loaded))
                    finally:
                        _thread_ctx.block_process_env_fallback = _prev_block
                        if _prev_env is None:
                            try:
                                del _thread_ctx.env
                            except AttributeError:
                                pass
                        else:
                            _thread_ctx.env = _prev_env
                # Stamp _cfg_mtime whenever the file parsed to a dict — INCLUDING
                # an empty {} config. The cache-update above is skipped for {} (it's
                # a no-op), but _cfg_mtime MUST still be set or get_config()'s
                # `current_mtime != _cfg_mtime` stale check fires on every call and
                # spins reload_config() under _cfg_lock forever (a `{}` config from a
                # freshly created/reset profile is reachable on the switch hot path).
                # This matches master's pre-#4662 behavior (it entered the block for
                # {} and set the mtime); the inner `if loaded:` only gates the no-op
                # cache update, not the mtime stamp.
                try:
                    _cfg_mtime = Path(config_path).stat().st_mtime
                except OSError:
                    _cfg_mtime = 0.0
    except Exception:
        logger.debug("Failed to load yaml config from %s", config_path)
    _apply_config_defaults(_cfg_cache)
    _cfg_fingerprint = _fingerprint_config(_cfg_cache)
    # Bust the models cache so the next request sees fresh config values.
    # Only delete the disk cache when config has actually changed -- not on
    # first-ever load (when _old_cfg_mtime == 0.0, i.e. server start) and not
    # on a path change (per-client profile switch leaves the old profile's
    # mtime) -- preserving the disk cache so the next restart
    # still hits the fast path without a cold run.
    if _old_cfg_mtime != 0.0 and _old_cfg_path == config_path:
        _delete_models_cache_on_disk()


def reload_config() -> None:
    """Reload config.yaml from the active profile's directory."""
    with _cfg_lock:
        _refresh_config_cache(_get_config_path())


# Memoized parse cache for _load_yaml_config_file, keyed on (resolved path,
# st_mtime_ns, st_size). yaml.safe_load on an ~800-line / 24KB config.yaml costs
# ~125ms of pure-Python parsing, and hot read paths (e.g. GET /api/reasoning ->
# get_reasoning_status) call this on every request. Without a cache, a UI sync
# storm turns into a YAML-reparse storm (#4650). We cache the RAW parsed dict and
# re-run _expand_env_vars() on every call: env expansion is cheap, always returns
# a fresh structure (so callers that read-modify-save the result never corrupt the
# cache), and keeps ${VAR} references live against the current os.environ. The
# (mtime_ns, size) key means any on-disk edit (including by _save_yaml_config_file)
# is picked up on the next read.
_yaml_file_cache: dict[str, tuple] = {}
_yaml_file_cache_lock = threading.Lock()


def _load_yaml_config_file_raw(config_path: Path, *, _copy: bool = True) -> dict:
    """Return the RAW (un-env-expanded) parsed config dict, memoized on
    (resolved path, st_mtime_ns, st_size). Shared parse core for
    _load_yaml_config_file() and reload_config(): the former runs the helper's
    own per-call env expansion on the result; the latter must run expansion
    under its own process-env-pinned thread context (#798), so it takes the raw
    dict and expands it itself. Either way the file is parsed at most once per
    (mtime, size) — a UI sync storm can't turn into a YAML-reparse storm (#4650),
    and an unchanged config.yaml isn't reparsed on the profile-switch hot path
    (#4662 Phase 2).

    By default returns a deep copy so a caller can never mutate the shared cache
    entry (greptile #4741). Internal callers that immediately pass the result
    through _expand_env_vars() (which itself returns a fresh structure and never
    mutates its input) pass _copy=False to skip the redundant copy on the hot path.
    """
    try:
        import yaml as _yaml
    except ImportError:
        return {}

    try:
        st = config_path.stat()
    except OSError:
        # Missing or unstattable file — preserve the original "no config" contract.
        return {}

    cache_key = str(config_path)
    stat_key = (st.st_mtime_ns, st.st_size)
    with _yaml_file_cache_lock:
        cached = _yaml_file_cache.get(cache_key)
        if cached is not None and cached[0] == stat_key:
            raw = cached[1]
            if not isinstance(raw, dict):
                return {}
            return copy.deepcopy(raw) if _copy else raw

    # Cache miss / stale: parse off disk. Done outside the lock so a slow parse
    # doesn't serialize unrelated paths; a concurrent duplicate parse is harmless.
    try:
        loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
    except Exception:
        logger.debug("Failed to parse yaml config from %s", config_path)
        return {}

    raw = loaded if isinstance(loaded, dict) else {}
    with _yaml_file_cache_lock:
        _yaml_file_cache[cache_key] = (stat_key, raw)
    return copy.deepcopy(raw) if _copy else raw


def _load_yaml_config_file(config_path: Path) -> dict:
    # _copy=False: _expand_env_vars returns a fresh structure and never mutates
    # its input, so the env-expanded result is already cache-safe — no need to
    # deep-copy the raw dict first (keeps the /api/reasoning hot path cheap).
    raw = _load_yaml_config_file_raw(config_path, _copy=False)
    if not raw:
        return {}
    expanded = _expand_env_vars(raw)
    return expanded if isinstance(expanded, dict) else {}


def get_config_for_profile_home(profile_home: "Path | str | None") -> dict:
    """Return the config dict for an explicit profile home directory.

    The streaming agent runs on a detached worker thread that does NOT inherit
    the per-request thread-local profile context (set from the ``hermes_profile``
    cookie on the HTTP handler thread). On that worker, the ambient
    ``get_config()`` resolves through ``get_active_profile_name()`` which falls
    back to the process-global ``_active_profile`` (usually ``default``) — so a
    session running under a non-default profile would silently read the
    **default** profile's ``config.yaml`` for toolsets, prefill context, and
    fallback chains (issue #3294).

    This helper reads the config for a *known* profile home directly off disk,
    bypassing the thread-local resolver entirely. When ``profile_home`` matches
    the path the ambient resolver would pick (the common single-profile case),
    we return the cached ``get_config()`` to preserve in-memory overrides used
    by tests and runtime callers, and to honour an authoritative
    ``HERMES_CONFIG_PATH`` override. Only when the session's profile home
    diverges from the ambient path do we read the session profile's file
    directly — a pure read with no global cache mutation, so it is race-free
    across concurrent sessions on different profiles. Divergent profiles stay
    isolated: a nonexistent home returns ``{}`` and an existing home without a
    ``config.yaml`` yields defaults — neither ever falls back to the ambient
    config (profiles-are-islands).
    """
    if not profile_home:
        return get_config()
    try:
        target = Path(profile_home).expanduser()
    except Exception:
        return get_config()

    from api.workspace import _safe_resolve as _cfg_safe_resolve

    # Canonicalize BOTH sides before every identity comparison (#7168 re-gate
    # round 5): when HERMES_HOME (or the config parent) is a symlink alias,
    # lexical equality fails and an authoritative HERMES_CONFIG_PATH inside
    # the aliased home would be bypassed in favor of a direct — wrong — read.
    target = _cfg_safe_resolve(target)
    try:
        from api.profiles import get_active_hermes_home

        if _cfg_safe_resolve(Path(get_active_hermes_home()).expanduser()) == target:
            return get_config()
    except Exception:
        pass
    # If the ambient resolver already points at this profile home, defer to
    # get_config() so in-memory overrides (monkeypatched cfg) are honored. This
    # MUST run before the nonexistent-home guard below: a matching ambient home
    # whose directory doesn't physically exist yet (fresh install, monkeypatched
    # cfg) must still resolve through get_config(), not return {} (#4516 gate).
    try:
        if _cfg_safe_resolve(_get_config_path().parent) == target:
            return get_config()
    except Exception:
        pass
    if not target.exists():
        return {}
    # Read the profile file directly and apply documented defaults locally so the
    # returned dict matches ambient get_config() shape (including built-in
    # personalities) without mutating any global cache state.
    profile_cfg = _load_yaml_config_file(target / "config.yaml")
    _apply_config_defaults(profile_cfg)
    return profile_cfg


def _config_for_yaml_save(config_data: dict) -> dict:
    """Return a YAML-safe config copy without runtime-only expanded defaults."""
    if not isinstance(config_data, dict):
        return {}
    data = copy.deepcopy(config_data)
    agent_cfg = data.get("agent")
    if isinstance(agent_cfg, dict):
        personalities = agent_cfg.get("personalities")
        if isinstance(personalities, dict):
            custom_personalities = {
                name: value
                for name, value in personalities.items()
                if _DEFAULT_AGENT_PERSONALITIES.get(name) != value
            }
            if custom_personalities:
                agent_cfg["personalities"] = custom_personalities
            else:
                agent_cfg.pop("personalities", None)
        if not agent_cfg:
            data.pop("agent", None)
    return data


def _save_yaml_config_file(config_path: Path, config_data: dict) -> None:
    try:
        import yaml as _yaml
    except ImportError as exc:
        raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc

    config_path.parent.mkdir(parents=True, exist_ok=True)
    _paths._atomic_write_text(
        config_path,
        _yaml.safe_dump(_config_for_yaml_save(config_data), sort_keys=False, allow_unicode=True),
        encoding="utf-8",
    )
    # Invalidate the memoized parse for this path so the next read re-parses the
    # bytes we just wrote. mtime_ns+size keying normally catches edits, but a
    # WebUI save that preserves size with a coarse/unchanged mtime could otherwise
    # serve a stale dict (#4650 review) — evicting on our own write closes that gap.
    with _yaml_file_cache_lock:
        _yaml_file_cache.pop(str(config_path), None)


# Initial load
reload_config()
cfg = _cfg_cache  # alias for backward compat with existing references


# ── Default workspace discovery ───────────────────────────────────────────────
def _workspace_candidates(raw: str | Path | None = None) -> list[Path]:
    """Return ordered candidate workspace paths, de-duplicated."""
    candidates: list[Path] = []

    def add(candidate: str | Path | None) -> None:
        if candidate in (None, ""):
            return
        try:
            path = Path(candidate).expanduser().resolve()
        except Exception:
            return
        if path not in candidates:
            candidates.append(path)

    add(raw)
    if os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"):
        add(os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"))

    home_workspace = HOME / "workspace"
    home_work = HOME / "work"
    if home_workspace.exists():
        add(home_workspace)
    if home_work.exists():
        add(home_work)

    add(home_workspace)
    add(STATE_DIR / "workspace")
    return candidates



def _ensure_workspace_dir(path: Path) -> bool:
    """Best-effort check that a workspace directory exists and is writable."""
    try:
        path = path.expanduser().resolve()
        path.mkdir(parents=True, exist_ok=True)
        return path.is_dir() and os.access(path, os.R_OK | os.W_OK | os.X_OK)
    except Exception:
        return False



def resolve_default_workspace(raw: str | Path | None = None) -> Path:
    """Return the first usable workspace path, creating it when possible."""
    for candidate in _workspace_candidates(raw):
        if _ensure_workspace_dir(candidate):
            return candidate
    raise RuntimeError(
        "Could not create or access any usable workspace directory. "
        "Set HERMES_WEBUI_DEFAULT_WORKSPACE to a writable path."
    )



def _discover_default_workspace() -> Path:
    """
    Resolve the default workspace in order:
      1. HERMES_WEBUI_DEFAULT_WORKSPACE env var
      2. ~/workspace if it already exists
      3. ~/work if it already exists
      4. ~/workspace (create if needed)
      5. STATE_DIR / workspace
    """
    return resolve_default_workspace()


DEFAULT_WORKSPACE = _discover_default_workspace()
DEFAULT_MODEL = os.getenv("HERMES_WEBUI_DEFAULT_MODEL", "")  # Empty = use provider default; avoids showing unavailable OpenAI model to non-OpenAI users (#646)


# ── Startup diagnostics ───────────────────────────────────────────────────────
def _warn_state_dir_divergence(warn_prefix: str) -> None:
    """Check if SESSION_DIR is empty but a sibling state directory has session data.

    If the session store looks empty (no *.json files besides _index.json in SESSION_DIR,
    or SESSION_INDEX_FILE is absent/empty/contains only {}|[]|null), scan STATE_DIR.parent
    for sibling directories with a sessions/ child that has .json files.

    Prints a diagnostic warning if a divergence is detected, helping users identify when
    they may have switched launch methods and the HERMES_WEBUI_STATE_DIR env var differs.
    """
    try:
        # Check if session store is empty
        session_dir_empty = False

        # Check for .json files in SESSION_DIR (excluding _index.json)
        if SESSION_DIR.exists():
            json_files = [f for f in SESSION_DIR.glob("*.json") if f.name != "_index.json"]
            session_dir_empty = len(json_files) == 0
        else:
            session_dir_empty = True

        # Check if SESSION_INDEX_FILE is absent, empty, or contains only {}|[]|null
        index_file_empty = True
        if SESSION_INDEX_FILE.exists():
            try:
                with open(SESSION_INDEX_FILE, "r") as f:
                    content = f.read().strip()
                    if content and content not in ("{}", "[]", "null"):
                        index_file_empty = False
            except Exception:
                pass

        # If session store looks empty, scan for siblings with sessions
        if session_dir_empty and index_file_empty:
            state_parent = STATE_DIR.parent
            if state_parent.exists():
                for sibling in state_parent.iterdir():
                    if not sibling.is_dir() or sibling == STATE_DIR:
                        continue
                    sibling_sessions = sibling / "sessions"
                    if sibling_sessions.exists():
                        json_files = [f for f in sibling_sessions.glob("*.json") if f.name != "_index.json"]
                        if json_files:
                            # Found a sibling with session data
                            print(
                                f"{warn_prefix}  STATE_DIR is empty but a sibling state directory has session data.\n"
                                f"        Current : {STATE_DIR}\n"
                                f"        Sibling : {sibling}\n"
                                f"        If you switched launch methods (bootstrap.py / ctl.sh / systemd),\n"
                                f"        the active HERMES_WEBUI_STATE_DIR env var may differ from the\n"
                                f"        previous run. Set it explicitly to restore access:\n"
                                f"          export HERMES_WEBUI_STATE_DIR={sibling}",
                                flush=True,
                            )
                            return
    except Exception:
        pass


def print_startup_config() -> None:
    """Print detected configuration at startup so the user can verify what was found."""
    ok = "\033[32m[ok]\033[0m"
    warn = "\033[33m[!!]\033[0m"
    err = "\033[31m[XX]\033[0m"

    lines = [
        "",
        "  Hermes Web UI -- startup config",
        "  --------------------------------",
        f"  repo root   : {REPO_ROOT}",
        f"  agent dir   : {_AGENT_DIR if _AGENT_DIR else 'NOT FOUND'}  {ok if _AGENT_DIR else err}",
        f"  python      : {PYTHON_EXE}",
        f"  state dir   : {STATE_DIR}",
        f"  workspace   : {DEFAULT_WORKSPACE}",
        f"  host:port   : {HOST}:{PORT}",
        f"  config file : {_get_config_path()}  {'(found)' if _get_config_path().exists() else '(not found, using defaults)'}",
        "",
    ]
    print("\n".join(lines), flush=True)

    try:
        _warn_state_dir_divergence(warn)
    except Exception:
        pass

    if not _HERMES_FOUND:
        print(
            f"{err}  Could not find the Hermes agent directory.\n"
            "      The server will start but agent features will not work.\n"
            "\n"
            "      To fix, set one of:\n"
            "        export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent\n"
            "        export HERMES_HOME=/path/to/.hermes\n"
            "\n"
            "      Or clone hermes-agent as a sibling of this repo:\n"
            "        git clone <hermes-agent-repo> ../hermes-agent\n",
            flush=True,
        )


def verify_hermes_imports() -> tuple:
    """
    Attempt to import the key Hermes modules.
    Returns (ok: bool, missing: list[str], errors: dict[str, str]).
    """
    required = ["run_agent"]
    missing = []
    errors = {}
    for mod in required:
        try:
            __import__(mod)
        except Exception as e:
            missing.append(mod)
            # Capture the full error message so startup logs show WHY
            # (e.g. pydantic_core .so mismatch) instead of just the name.
            errors[mod] = f"{type(e).__name__}: {e}"
    return (len(missing) == 0), missing, errors


# ── Limits ───────────────────────────────────────────────────────────────────
MAX_FILE_BYTES = 400_000
MAX_UPLOAD_BYTES = _env_mb_bytes("HERMES_WEBUI_MAX_UPLOAD_MB", 20)

# ── File type maps ───────────────────────────────────────────────────────────
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"}
MD_EXTS = {".md", ".markdown", ".mdown"}
CODE_EXTS = {
    ".py",
    ".js",
    ".ts",
    ".jsx",
    ".tsx",
    ".css",
    ".html",
    ".json",
    ".yaml",
    ".yml",
    ".toml",
    ".sh",
    ".bash",
    ".txt",
    ".log",
    ".env",
    ".csv",
    ".xml",
    ".sql",
    ".rs",
    ".go",
    ".java",
    ".c",
    ".cpp",
    ".h",
}
MIME_MAP = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".svg": "image/svg+xml",
    ".webp": "image/webp",
    ".ico": "image/x-icon",
    ".bmp": "image/bmp",
    ".pdf": "application/pdf",
    ".zip": "application/zip",
    ".json": "application/json",
    ".html": "text/html",
    ".htm": "text/html",
    ".xls": "application/vnd.ms-excel",
    ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    ".doc": "application/msword",
    ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    ".mp3": "audio/mpeg",
    ".wav": "audio/wav",
    ".m4a": "audio/mp4",
    ".aac": "audio/aac",
    ".ogg": "audio/ogg",
    ".oga": "audio/ogg",
    ".opus": "audio/opus",
    ".flac": "audio/flac",
    ".mp4": "video/mp4",
    ".mov": "video/quicktime",
    ".m4v": "video/mp4",
    ".webm": "video/webm",
    ".ogv": "video/ogg",
    # TypeScript source files — served as text/plain to avoid XSS from
    # same-origin inline execution in _handle_file_raw.
    ".ts": "text/plain",
    ".tsx": "text/plain",
}

# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
_DEFAULT_TOOLSETS = [
    "browser",
    "clarify",
    "code_execution",
    "cronjob",
    "delegation",
    "file",
    "image_gen",
    "memory",
    "session_search",
    "skills",
    "terminal",
    "todo",
    "web",
    "webhook",
]

_LEGACY_CLI_TOOLSET_ALIASES = {
    # Older Hermes configs used "hermes" as the CLI composite toolset. Modern
    # Hermes Agent exposes that split as these two registered composites; keep
    # WebUI sessions usable when pointed at an older shared config.yaml.
    "hermes": ("hermes-cli", "hermes-api-server"),
}


def _normalize_cli_toolsets(toolsets):
    """Expand legacy CLI toolset aliases while preserving order and de-duping."""
    normalized = []
    seen = set()
    for name in toolsets or []:
        replacements = _LEGACY_CLI_TOOLSET_ALIASES.get(name, (name,))
        for replacement in replacements:
            if replacement and replacement not in seen:
                seen.add(replacement)
                normalized.append(replacement)
    return normalized


def _resolve_cli_toolsets(cfg=None):
    """Resolve CLI toolsets using the agent's _get_platform_tools() so that
    MCP server toolsets are automatically included, matching CLI behaviour."""
    if cfg is None:
        cfg = get_config()
    try:
        from hermes_cli.tools_config import _get_platform_tools
        return _normalize_cli_toolsets(_get_platform_tools(cfg, "cli"))
    except Exception:
        # Fallback: read raw list from config (MCP toolsets will be missing)
        return _normalize_cli_toolsets(cfg.get("platform_toolsets", {}).get("cli", _DEFAULT_TOOLSETS))

CLI_TOOLSETS = _resolve_cli_toolsets()

# ── Model / provider discovery ───────────────────────────────────────────────

# Hardcoded fallback models (used when no config.yaml or agent is available)
# Also used as the OpenRouter model list — keep this curated to current, widely-used models.
_FALLBACK_MODELS = [
    # OpenAI
    {"provider": "OpenAI",    "id": "openai/gpt-5.4-mini",                "label": "GPT-5.4 Mini"},
    {"provider": "OpenAI",    "id": "openai/gpt-5.4",                     "label": "GPT-5.4"},
    # Anthropic — 4.6 flagship + 4.5 generation
    {"provider": "Anthropic", "id": "anthropic/claude-opus-4.7",          "label": "Claude Opus 4.7"},
    {"provider": "Anthropic", "id": "anthropic/claude-opus-4.6",          "label": "Claude Opus 4.6"},
    {"provider": "Anthropic", "id": "anthropic/claude-sonnet-4.6",        "label": "Claude Sonnet 4.6"},
    {"provider": "Anthropic", "id": "anthropic/claude-sonnet-4-5",        "label": "Claude Sonnet 4.5"},
    {"provider": "Anthropic", "id": "anthropic/claude-haiku-4-5",         "label": "Claude Haiku 4.5"},
    # Google — 3.x (latest preview) + 2.5 (stable GA)
    {"provider": "Google",    "id": "google/gemini-3.1-pro-preview",            "label": "Gemini 3.1 Pro Preview"},
    {"provider": "Google",    "id": "google/gemini-3-flash-preview",            "label": "Gemini 3 Flash Preview"},
    {"provider": "Google",    "id": "google/gemini-3.1-flash-lite-preview",     "label": "Gemini 3.1 Flash Lite Preview"},
    {"provider": "Google",    "id": "google/gemini-2.5-pro",                    "label": "Gemini 2.5 Pro"},
    {"provider": "Google",    "id": "google/gemini-2.5-flash",                  "label": "Gemini 2.5 Flash"},
    # DeepSeek
    {"provider": "DeepSeek",  "id": "deepseek/deepseek-v4-flash",          "label": "DeepSeek V4 Flash"},
    {"provider": "DeepSeek",  "id": "deepseek/deepseek-v4-pro",            "label": "DeepSeek V4 Pro"},
    {"provider": "DeepSeek",  "id": "deepseek/deepseek-chat-v3-0324",      "label": "DeepSeek V3 (legacy)"},
    {"provider": "DeepSeek",  "id": "deepseek/deepseek-r1",                "label": "DeepSeek R1 (legacy)"},
    # Qwen (Alibaba) — strong coding and general models
    {"provider": "Qwen",      "id": "qwen/qwen3-coder",                   "label": "Qwen3 Coder"},
    {"provider": "Qwen",      "id": "qwen/qwen3.6-plus",                  "label": "Qwen3.6 Plus"},
    # xAI
    {"provider": "xAI",       "id": "x-ai/grok-4.20",                    "label": "Grok 4.20"},
    # Mistral
    {"provider": "Mistral",   "id": "mistralai/mistral-large-latest",     "label": "Mistral Large"},
    # MiniMax
    {"provider": "MiniMax",   "id": "minimax/MiniMax-M3",               "label": "MiniMax M3"},
    {"provider": "MiniMax",   "id": "minimax/MiniMax-M2.7",             "label": "MiniMax M2.7"},
    {"provider": "MiniMax",   "id": "minimax/MiniMax-M2.7-highspeed",   "label": "MiniMax M2.7 Highspeed"},
    # Z.AI / GLM
    {"provider": "Z.AI",      "id": "zai/glm-5.3",                      "label": "GLM-5.3"},
    {"provider": "Z.AI",      "id": "zai/glm-5.3-flash",                "label": "GLM-5.3 Flash"},
    {"provider": "Z.AI",      "id": "zai/glm-5.2",                      "label": "GLM-5.2"},
    {"provider": "Z.AI",      "id": "zai/glm-5.1",                      "label": "GLM-5.1"},
    {"provider": "Z.AI",      "id": "zai/glm-5",                        "label": "GLM-5"},
    {"provider": "Z.AI",      "id": "zai/glm-5-turbo",                  "label": "GLM-5 Turbo"},
    {"provider": "Z.AI",      "id": "zai/glm-4.7",                      "label": "GLM-4.7"},
    {"provider": "Z.AI",      "id": "zai/glm-4.5",                      "label": "GLM-4.5"},
    {"provider": "Z.AI",      "id": "zai/glm-4.5-flash",                "label": "GLM-4.5 Flash"},
    # OpenRouter free-tier models — must appear in fallback list so they
    # are visible even when the tool-support filter in hermes_cli strips
    # them out of the live catalog (see #1426).
    {"provider": "OpenRouter", "id": "openrouter/elephant-alpha",                   "label": "Elephant Alpha (free)"},
    {"provider": "OpenRouter", "id": "openrouter/owl-alpha",                        "label": "Owl Alpha (free)"},
    {"provider": "OpenRouter", "id": "tencent/hy3-preview:free",                    "label": "Hy3 Preview (free)"},
    {"provider": "OpenRouter", "id": "nvidia/nemotron-3-super-120b-a12b:free",      "label": "Nemotron 3 Super (free)"},
    {"provider": "OpenRouter", "id": "arcee-ai/trinity-large-preview:free",         "label": "Trinity Large Preview (free)"},
]

# Provider display names for known Hermes provider IDs
_PROVIDER_DISPLAY = {
    "nous": "Nous Portal",
    "openrouter": "OpenRouter",
    "anthropic": "Anthropic",
    "openai": "OpenAI",
    "openai-api": "OpenAI API",
    "openai-codex": "OpenAI Codex",
    "xai-oauth": "xAI Grok OAuth",
    "copilot": "GitHub Copilot",
    "moa": "Mixture of Agents",
    "cursor-acp": "Cursor ACP",
    "zai": "Z.AI / GLM",
    "kimi-coding": "Kimi / Moonshot",
    "deepseek": "DeepSeek",
    "minimax": "MiniMax",
    "minimax-cn": "MiniMax (China)",
    "google": "Google",
    "meta-llama": "Meta Llama",
    "huggingface": "HuggingFace",
    "alibaba": "Alibaba",
    "ollama": "Ollama",
    "ollama-cloud": "Ollama Cloud",
    "opencode-zen": "OpenCode Zen",
    "opencode-go": "OpenCode Go",
    "lmstudio": "LM Studio",
    "mistralai": "Mistral",
    "qwen": "Qwen",
    "x-ai": "xAI",
    "nvidia": "NVIDIA NIM",
    "xiaomi": "Xiaomi",
    "bedrock": "AWS Bedrock",
}

# Provider alias → canonical slug.  Users configure providers using the
# dotted/hyphenated form they see on the provider website (``z.ai``,
# ``x.ai``, ``google``) but the internal catalog (``_PROVIDER_MODELS``)
# uses slugs without punctuation (``zai``, ``xai``, ``gemini``).  Without
# normalisation the provider lands in the ``else`` branch of the group
# builder and no models are returned — the bug behind #815.
#
# This table is authoritative for the WebUI.  When ``hermes_cli.models``
# is importable we also merge its ``_PROVIDER_ALIASES`` on top so any
# new aliases added to the agent automatically apply.  Keeping the local
# copy means the fix works even in environments where the agent tree is
# not on ``sys.path`` (CI, installs without hermes-agent cloned
# alongside the WebUI).
_PROVIDER_ALIASES = {
    "glm": "zai",
    "z-ai": "zai",
    "z.ai": "zai",
    "zhipu": "zai",
    "github": "copilot",
    "github-copilot": "copilot",
    "github-models": "copilot",
    "github-model": "copilot",
    "google": "gemini",
    "google-gemini": "gemini",
    "google-ai-studio": "gemini",
    "kimi": "kimi-coding",
    "moonshot": "kimi-coding",
    "claude": "anthropic",
    "claude-code": "anthropic",
    "deep-seek": "deepseek",
    "minimax-china": "minimax-cn",
    "minimax_cn": "minimax-cn",
    "opencode": "opencode-zen",
    "grok": "xai",
    "x-ai": "xai",
    "x.ai": "xai",
    "aws": "bedrock",
    "aws-bedrock": "bedrock",
    "amazon": "bedrock",
    "amazon-bedrock": "bedrock",
    "qwen": "alibaba",
    "aliyun": "alibaba",
    "dashscope": "alibaba",
    "alibaba-cloud": "alibaba",
    "nim": "nvidia",
    "nvidia-nim": "nvidia",
    "build-nvidia": "nvidia",
    "nemotron": "nvidia",
    "mimo": "xiaomi",
    "xiaomi-mimo": "xiaomi",
    # Legacy alias — earlier WebUI builds wrote ``provider: local`` for unknown
    # loopback endpoints, but ``local`` is not registered in
    # ``hermes_cli.auth.PROVIDER_REGISTRY``. Routing it through ``custom``
    # lets the agent's auxiliary client take the ``no-key-required``
    # OpenAI-compat path. See #1384.
    "local": "custom",
}


def _get_anthropic_fallback_env_vars() -> tuple[str, ...]:
    """Read Anthropic auth env vars from the shared agent registry when available."""
    fallback = (
        "ANTHROPIC_API_KEY",
        "ANTHROPIC_TOKEN",
        "CLAUDE_CODE_OAUTH_TOKEN",
    )
    try:
        from hermes_cli.auth import PROVIDER_REGISTRY

        anthropic = (
            PROVIDER_REGISTRY.get("anthropic")
            if isinstance(PROVIDER_REGISTRY, dict)
            else None
        )
        env_vars = getattr(anthropic, "api_key_env_vars", None)
        if not env_vars:
            return fallback

        out = []
        for _var in env_vars:
            if not isinstance(_var, str):
                continue
            _normalized = _var.strip()
            if _normalized and _normalized not in out:
                out.append(_normalized)
        return tuple(out) if out else fallback
    except Exception:
        return fallback


def _resolve_provider_alias(name: str) -> str:
    """Return the canonical provider slug for *name*.

    Applies the WebUI's local alias table first, then merges any
    additional aliases the agent provides (when hermes_cli is on
    sys.path). Lookup is case-insensitive and whitespace-trimmed.
    Unknown names pass through unchanged.
    """
    if not name:
        return name
    raw = str(name).strip().lower()
    # Prefer the agent's table when available so new aliases added there
    # work automatically; otherwise fall through to our local copy.
    try:
        from hermes_cli.models import _PROVIDER_ALIASES as _agent_aliases
        if raw in _agent_aliases:
            return _agent_aliases[raw]
    except Exception:
        pass
    return _PROVIDER_ALIASES.get(raw, name)


def _is_known_model_provider(provider_id: str) -> bool:
    """True when *provider_id* names a model provider WebUI can render.

    The credential pool (``auth.json`` → ``credential_pool``) stores keys for
    BOTH model providers (whose API keys belong in the model picker) and
    non-model platform plugins.  The Photon iMessage plugin, for example,
    writes ``photon`` / ``photon_project`` / ``photon_user`` pool entries that
    are messaging-platform credentials, not LLM API keys.  Only the former
    should surface as provider groups.

    Without this gate, #4247's pool-detection loop added *every* pool key to
    ``detected_providers``; unknown ids then fell through to the global
    auto-detected catalog and each phantom provider was painted with the full
    model list (#4324).  ``provider_id`` is expected to be the canonical slug
    (post ``_resolve_provider_alias``); the lookup is case-insensitive.

    A provider is "known" when it is a configured custom-provider slug
    (``custom:*``), appears in WebUI's static ``_PROVIDER_DISPLAY`` /
    ``_PROVIDER_MODELS`` tables, or is a registered model-provider plugin.
    """
    pid = (provider_id or "").strip().lower()
    if not pid:
        return False
    if pid.startswith("custom:"):
        return True
    if pid in _PROVIDER_DISPLAY or pid in _PROVIDER_MODELS:
        return True
    try:
        if _is_plugin_model_provider(pid):
            return True
    except Exception:
        # A transient failure here (import/IO hiccup in the plugin registry) makes
        # a real plugin-backed provider briefly look unknown and drop from the
        # picker until the next successful check. Surface at warning so it's
        # visible in default production logs rather than silently swallowed.
        logger.warning("plugin model-provider check failed for %s", pid, exc_info=True)
    return False


def _custom_provider_slug_from_name(name: object) -> str:
    raw = str(name or "").strip().lower()
    if not raw:
        return ""
    if raw.startswith("custom:"):
        return raw
    # Keep name-derived custom provider slugs out of the @provider:model colon
    # grammar. Endpoint-derived slugs may still be custom:<host>:<port>, but a
    # friendly name like "Local (127.0.0.1:15721)" should not preserve ':'.
    slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-")
    slug = re.sub(r"-{2,}", "-", slug)
    if not slug:
        return ""
    return "custom:" + slug


def _custom_provider_entries(config_obj: dict | None = None) -> list[dict]:
    source = config_obj if isinstance(config_obj, dict) else cfg
    entries = source.get("custom_providers", [])
    if not isinstance(entries, list):
        return []
    return [entry for entry in entries if isinstance(entry, dict)]


def _configured_model_ids(raw_models: object) -> list[str]:
    """Return ordered model IDs from supported config allowlist shapes."""
    if isinstance(raw_models, dict):
        candidates = (key for key in raw_models if isinstance(key, str))
    elif isinstance(raw_models, list):
        candidates = raw_models
    else:
        return []

    model_ids: list[str] = []
    for item in candidates:
        if isinstance(item, dict):
            candidate = item.get("id") or item.get("model") or item.get("name")
        else:
            candidate = item
        model_id = str(candidate or "").strip()
        if model_id and model_id not in model_ids:
            model_ids.append(model_id)
    return model_ids


def _provider_discover_allowed(provider_cfg: object) -> bool:
    """Mirror the Hermes Agent ``discover_models`` opt-out (``model_switch_providers._discover_flag``).

    ``discover_models`` defaults to True; the string forms ``"false"``/``"no"``/``"0"``
    (case-insensitive) mean False. A provider that pins its catalog with
    ``discover_models: false`` keeps its configured ``models:`` even when the entry is
    also marked ``models_discovered: true`` — the explicit opt-out wins.
    """
    if not isinstance(provider_cfg, dict):
        return True
    discover = provider_cfg.get("discover_models", True)
    if isinstance(discover, str):
        return discover.strip().lower() not in {"false", "no", "0"}
    return bool(discover)


def _provider_models_are_discovered_catalog(provider_cfg: object) -> bool:
    """True when ``models:`` is an auto-discovered catalog that should defer to the live probe.

    A provider entry marked ``models_discovered: true`` carries a per-model *metadata*
    mapping written by Hermes discovery, not a hand-curated allowlist — so the live
    ``/v1/models`` catalog is authoritative. But an explicit ``discover_models: false``
    re-pins the configured mapping as the source of truth, so honor that opt-out.
    """
    return (
        isinstance(provider_cfg, dict)
        and provider_cfg.get("models_discovered") is True
        and _provider_discover_allowed(provider_cfg)
    )


def _configured_model_options(raw_models: object) -> list[dict[str, str]]:
    """Return picker option rows from supported config allowlist shapes."""
    labels: dict[str, str] = {}
    if isinstance(raw_models, list):
        for item in raw_models:
            if not isinstance(item, dict):
                continue
            candidate = item.get("id") or item.get("model") or item.get("name")
            model_id = str(candidate or "").strip()
            if not model_id or model_id in labels:
                continue
            label = str(item.get("label") or model_id).strip() or model_id
            labels[model_id] = label
    return [
        {"id": model_id, "label": labels.get(model_id, model_id)}
        for model_id in _configured_model_ids(raw_models)
    ]


def _merge_model_option_rows(*row_lists: object) -> list[dict[str, str]]:
    """Merge picker option rows from multiple sources, first-seen order, deduped by id.

    Used to preserve a discovered provider's configured model IDs (ordered first) as a
    fallback when a live ``/v1/models`` probe transiently returns nothing, merged with
    any static built-in catalog without producing duplicate ids.
    """
    merged: list[dict[str, str]] = []
    seen: set[str] = set()
    for rows in row_lists:
        if not isinstance(rows, (list, tuple)):
            continue
        for row in rows:
            if not isinstance(row, dict):
                continue
            model_id = str(row.get("id") or "").strip()
            if not model_id or model_id in seen:
                continue
            seen.add(model_id)
            merged.append(row)
    return merged


def _named_custom_provider_slugs(config_obj: dict | None = None) -> set[str]:
    return {
        slug
        for slug in (
            _custom_provider_slug_from_name(entry.get("name"))
            for entry in _custom_provider_entries(config_obj)
        )
        if slug
    }


def _named_custom_provider_slug_for_provider(
    provider: object,
    config_obj: dict | None = None,
) -> str:
    raw = str(provider or "").strip().lower()
    if not raw:
        return ""
    raw_suffix = raw.removeprefix("custom:")
    for entry in _custom_provider_entries(config_obj):
        entry_name = str(entry.get("name") or "").strip().lower()
        slug = _custom_provider_slug_from_name(entry_name)
        if not entry_name or not slug:
            continue
        if raw in {entry_name, slug} or raw_suffix == slug.removeprefix("custom:"):
            return slug
    return ""


def _resolve_configured_provider_id(
    provider: object,
    config_obj: dict | None = None,
    *,
    base_url: object = None,
    resolve_alias: bool = True,
) -> str:
    """Normalize a configured provider id.

    When ``resolve_alias`` is True (default, used for active-provider /
    badge surfaces), falls through to ``_resolve_provider_alias`` after the
    named-custom check. When False (used by ``resolve_model_provider``),
    preserves the raw provider value so downstream local-server detection
    (`_LOCAL_SERVER_PROVIDERS` membership in #1625) sees the original name
    like ``ollama`` / ``lm-studio`` rather than alias-collapsed ``custom`` /
    ``lmstudio``. The base-url-to-named-slug fallback still runs in both
    modes when applicable.

    See in-stage absorption note on stage-313 for the #1625 regression that
    motivated the ``resolve_alias`` flag.
    """
    named_slug = _named_custom_provider_slug_for_provider(provider, config_obj)
    if named_slug:
        return named_slug

    if not resolve_alias:
        raw = str(provider or "").strip().lower()
        if base_url and raw == "custom":
            by_base_url = _named_custom_provider_slug_for_base_url(base_url, config_obj)
            if by_base_url:
                return by_base_url
        return str(provider or "")

    resolved = _resolve_provider_alias(provider)
    if (
        base_url
        and str(resolved or "").strip().lower() == "custom"
    ):
        by_base_url = _named_custom_provider_slug_for_base_url(base_url, config_obj)
        if by_base_url:
            return by_base_url

    return resolved


def _canonicalise_provider_id(name: object) -> str:
    """Normalise a provider id slug into a stable lowercase-hyphenated form.

    Folds underscores to hyphens and lowercases the result, so a user with
    ``providers.opencode_go.api_key`` in ``config.yaml`` and
    ``model.provider: opencode-go`` sees ONE provider group, not two
    (#1568). Then attempts alias resolution but only if the alias target
    is itself a known canonical id in ``_PROVIDER_DISPLAY`` —  this avoids
    converting ``x-ai`` (canonical in WebUI's data structures) to ``xai``
    (the hermes_cli alias target which the WebUI doesn't index by).

    Examples::

        opencode-go     -> opencode-go     (canonical, no change)
        opencode_go     -> opencode-go     (underscore folded)
        OpenCode-Go     -> opencode-go     (case folded)
        OPENCODE_GO     -> opencode-go     (both folded)
        z_ai            -> zai             (alias-resolved — zai is canonical)
        x-ai            -> x-ai            (preserved — x-ai is canonical)

    Empty input passes through as the empty string. Unknown ids preserve
    their normalised form.
    """
    if not name:
        return ""
    raw = str(name).strip().lower().replace("_", "-")
    if not raw:
        return ""
    # Already a canonical id known to _PROVIDER_DISPLAY/_PROVIDER_MODELS:
    # keep as-is to avoid round-tripping through aliases (e.g. x-ai → xai).
    if raw in _PROVIDER_DISPLAY or raw in _PROVIDER_MODELS:
        return raw
    # Try alias resolution. Accept the result if it's a canonical id known to
    # either _PROVIDER_DISPLAY OR _PROVIDER_MODELS (mirroring the direct-hit
    # check above) — some canonical targets (e.g. `gemini`) are indexed in
    # _PROVIDER_MODELS but not _PROVIDER_DISPLAY, so a _DISPLAY-only check
    # rejected valid aliases like `google-gemini`→`gemini`, leaving the id
    # uncanonicalised and silently breaking provider-ownership checks (#5511).
    # This still blocks aliases that point at non-canonical/legacy strings.
    resolved = _resolve_provider_alias(raw)
    if resolved and (resolved.lower() in _PROVIDER_DISPLAY or resolved.lower() in _PROVIDER_MODELS):
        return resolved.lower()
    return raw


def _normalize_base_url_for_match(value: object) -> str:
    url = str(value or "").strip().rstrip("/")
    if not url:
        return ""
    parsed_url = urlparse(url if "://" in url else f"http://{url}")
    scheme = (parsed_url.scheme or "http").lower()
    netloc = (parsed_url.netloc or parsed_url.path).lower().rstrip("/")
    path = parsed_url.path.rstrip("/")
    if not parsed_url.netloc:
        path = ""
    return f"{scheme}://{netloc}{path}"


def _custom_endpoint_slugs_for_base_url(value: object) -> set[str]:
    """Return custom provider slugs that WebUI may derive from a base URL.

    Model picker values for endpoint-discovered models have historically used
    both ``custom:<host>:<port>`` and ``custom:<host>-<port>`` forms. When the
    active config already names a local-server provider such as Ollama for that
    same base URL, those endpoint slugs are just UI routing hints and should
    resolve back to the configured provider rather than requiring a CUSTOM_* API
    key.
    """
    url = str(value or "").strip().rstrip("/")
    if not url:
        return set()
    parsed_url = urlparse(url if "://" in url else f"http://{url}")
    host = (parsed_url.hostname or "").strip().lower()
    if not host:
        return set()
    port = parsed_url.port
    if port is None:
        scheme = (parsed_url.scheme or "http").lower()
        port = 443 if scheme == "https" else 80
    return {f"custom:{host}:{port}", f"custom:{host}-{port}"}


_LEGACY_CUSTOM_API_KEY_ENV_WARNED: set[str] = set()


def _api_key_env_name(provider_id: object) -> str:
    """Return the POSIX-safe default API-key env var for a custom provider id."""
    sanitized = re.sub(r"[^A-Za-z0-9]", "_", str(provider_id or "")).upper().strip("_")
    if not sanitized:
        sanitized = "CUSTOM"
    if not sanitized.startswith("CUSTOM_"):
        sanitized = f"CUSTOM_{sanitized}"
    return f"{sanitized}_API_KEY"


def _legacy_custom_api_key_env_name(provider_id: object) -> str:
    """Return the pre-#2541 custom-provider env hint shape, if any."""
    raw = str(provider_id or "").strip().upper()
    if not raw:
        return ""
    return f"{raw}_API_KEY"


def _lookup_custom_api_key_env(provider_id: object) -> str | None:
    """Look up sanitized custom-provider env first, then legacy broken shape."""
    env_name = _api_key_env_name(provider_id)
    api_key = _thread_local_env_value(env_name).strip()
    if api_key:
        return api_key

    legacy_env_name = _legacy_custom_api_key_env_name(provider_id)
    if legacy_env_name and legacy_env_name != env_name:
        legacy_key = _thread_local_env_value(legacy_env_name).strip()
        if legacy_key:
            if legacy_env_name not in _LEGACY_CUSTOM_API_KEY_ENV_WARNED:
                _LEGACY_CUSTOM_API_KEY_ENV_WARNED.add(legacy_env_name)
                logger.warning(
                    "Custom provider API key env var %s is deprecated; use %s instead",
                    legacy_env_name,
                    env_name,
                )
            return legacy_key
    return None


def _named_custom_provider_slug_for_base_url(
    base_url: object,
    config_obj: dict | None = None,
) -> str:
    target = _normalize_base_url_for_match(base_url)
    if not target:
        return ""
    for entry in _custom_provider_entries(config_obj):
        entry_base_url = _normalize_base_url_for_match(entry.get("base_url"))
        if entry_base_url != target:
            continue
        return _custom_provider_slug_from_name(entry.get("name")) or "custom"
    return ""


def _provider_is_known_or_configured(
    provider_id: object,
    config_obj: dict | None = None,
) -> bool:
    """True when ``provider_id`` is a provider Hermes recognizes (static registry)
    or the user has configured (named custom provider), decided from the STATIC
    registry + config state only — never from a live/cold catalog snapshot.

    This distinguishes a provider Hermes knows how to route (e.g. ``ollama-cloud``,
    whose model group simply isn't folded into the current cached catalog yet, or a
    named ``custom_providers`` entry) from a *genuinely unknown* one
    (``@removed:...`` that is in no registry and configured nowhere). The former's
    explicitly-qualified selection is preserved across a cold catalog; the latter
    falls back to the default so chat/start doesn't route to an unrecognized
    provider.

    DELIBERATE SCOPE (see the @provider:model guard in
    ``_resolve_compatible_session_model_state``): registry membership counts as
    "known" even when the user has no key configured for that built-in. We do NOT
    require authenticated-credential evidence here, on purpose. The only fully
    reliable "is this provider authenticated" signal is the live auth store /
    catalog rebuild — exactly the cost the caller's ``prefer_cached_catalog`` hot
    path avoids — and a cheap env/config-only credential check would mis-classify
    providers authenticated via OAuth/auth-store (``ollama-cloud`` among them),
    re-introducing the original silent-revert bug for them. A known-but-unconfigured
    pick is therefore kept and surfaces a clear run-time auth error rather than a
    silent swap to the default.

    Deliberately does NOT consult ``get_available_models()`` / the catalog groups,
    which are exactly what is cold here — re-deriving them live would defeat the
    ``prefer_cached_catalog`` hot-path win this guards.
    """
    raw = str(provider_id or "").strip().lower()
    if not raw:
        return False
    # Configured custom provider: a named slug in custom_providers, or any
    # ``custom`` / ``custom:<slug>`` form when custom_providers are defined.
    if _named_custom_provider_slug_for_provider(raw, config_obj):
        return True
    if raw == "custom" or raw.startswith("custom:"):
        return bool(_custom_provider_entries(config_obj))
    # Known first-party / built-in provider id (alias-resolved). Static registry
    # knowledge that is always available, so a live-discovery provider whose
    # catalog group is momentarily absent still counts as known.
    canonical = _resolve_provider_alias(raw)
    return (
        raw in _PROVIDER_DISPLAY
        or canonical in _PROVIDER_DISPLAY
        or raw in _PROVIDER_MODELS
        or canonical in _PROVIDER_MODELS
    )


# Well-known models per provider (used to populate dropdown for direct API providers)
_PROVIDER_MODELS = {
    "anthropic": [
        {"id": "claude-opus-4.7", "label": "Claude Opus 4.7"},
        {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"},
        {"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
        {"id": "claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
        {"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
    ],
    "openai": [
        {"id": "gpt-5.5",      "label": "GPT-5.5"},
        {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
        {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
        {"id": "gpt-5.4",      "label": "GPT-5.4"},
    ],
    "openai-api": [
        {"id": "gpt-5.5",      "label": "GPT-5.5"},
        {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
        {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
        {"id": "gpt-5.4",      "label": "GPT-5.4"},
    ],
    "openai-codex": [
        {"id": "gpt-5.5", "label": "GPT-5.5"},
        {"id": "gpt-5.5-mini", "label": "GPT-5.5 Mini"},
        {"id": "gpt-5.4", "label": "GPT-5.4"},
        {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
        {"id": "gpt-5.3-codex", "label": "GPT-5.3 Codex"},
        {"id": "gpt-5.2-codex", "label": "GPT-5.2 Codex"},
        {"id": "gpt-5.1-codex-max", "label": "GPT-5.1 Codex Max"},
        {"id": "gpt-5.1-codex-mini", "label": "GPT-5.1 Codex Mini"},
        {"id": "codex-mini-latest", "label": "Codex Mini (latest)"},
    ],
    "google": [
        {"id": "gemini-3.1-pro-preview",            "label": "Gemini 3.1 Pro Preview"},
        {"id": "gemini-3-flash-preview",            "label": "Gemini 3 Flash Preview"},
        {"id": "gemini-3.1-flash-lite-preview",     "label": "Gemini 3.1 Flash Lite Preview"},
        {"id": "gemini-2.5-pro",                    "label": "Gemini 2.5 Pro"},
        {"id": "gemini-2.5-flash",                  "label": "Gemini 2.5 Flash"},
    ],
    "deepseek": [
        {"id": "deepseek-v4-flash", "label": "DeepSeek V4 Flash"},
        {"id": "deepseek-v4-pro", "label": "DeepSeek V4 Pro"},
        {"id": "deepseek-chat-v3-0324", "label": "DeepSeek V3 (legacy)"},
        {"id": "deepseek-reasoner", "label": "DeepSeek Reasoner (legacy)"},
    ],
    "nous": [
        {"id": "@nous:anthropic/claude-opus-4.6",     "label": "Claude Opus 4.6 (via Nous)"},
        {"id": "@nous:anthropic/claude-sonnet-4.6",   "label": "Claude Sonnet 4.6 (via Nous)"},
        {"id": "@nous:openai/gpt-5.4-mini",           "label": "GPT-5.4 Mini (via Nous)"},
        {"id": "@nous:google/gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview (via Nous)"},
    ],
    "zai": [
        {"id": "glm-5.3", "label": "GLM-5.3"},
        {"id": "glm-5.3-flash", "label": "GLM-5.3 Flash"},
        {"id": "glm-5.2", "label": "GLM-5.2"},
        {"id": "glm-5.1", "label": "GLM-5.1"},
        {"id": "glm-5", "label": "GLM-5"},
        {"id": "glm-5-turbo", "label": "GLM-5 Turbo"},
        {"id": "glm-4.7", "label": "GLM-4.7"},
        {"id": "glm-4.5", "label": "GLM-4.5"},
        {"id": "glm-4.5-flash", "label": "GLM-4.5 Flash"},
    ],
    "kimi-coding": [
        {"id": "moonshot-v1-8k", "label": "Moonshot v1 8k"},
        {"id": "moonshot-v1-32k", "label": "Moonshot v1 32k"},
        {"id": "moonshot-v1-128k", "label": "Moonshot v1 128k"},
        {"id": "kimi-latest", "label": "Kimi Latest"},
        {"id": "kimi-k2.5", "label": "Kimi K2.5"},
    ],
    "minimax": [
        {"id": "MiniMax-M3", "label": "MiniMax M3"},
        {"id": "MiniMax-M2.7", "label": "MiniMax M2.7"},
        {"id": "MiniMax-M2.7-highspeed", "label": "MiniMax M2.7 Highspeed"},
    ],
    "minimax-cn": [
        {"id": "MiniMax-M3", "label": "MiniMax M3"},
        {"id": "MiniMax-M2.7", "label": "MiniMax M2.7"},
    ],
    # GitHub Copilot — model IDs served via the Copilot API
    # Fallback ONLY — the live GitHub Copilot catalog
    # (hermes_cli.models.provider_model_ids("copilot")) is authoritative and is
    # tried first by _read_live_provider_model_ids(). This static list is the
    # safety net shown when the live probe fails (cold start / token blip). Keep
    # it in sync with the real integrator allowlist so a probe miss never renders
    # legacy junk (GPT-4o / gpt-3.5-turbo). Last synced 2026-06-30 from the live
    # copilot-4-cli catalog (16 models).
    "copilot": [
        {"id": "claude-opus-4.8", "label": "Claude Opus 4.8"},
        {"id": "claude-opus-4.7", "label": "Claude Opus 4.7"},
        {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"},
        {"id": "claude-sonnet-5", "label": "Claude Sonnet 5"},
        {"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
        {"id": "claude-sonnet-4.5", "label": "Claude Sonnet 4.5"},
        {"id": "claude-haiku-4.5", "label": "Claude Haiku 4.5"},
        {"id": "gpt-5.5", "label": "GPT-5.5"},
        {"id": "gpt-5.4", "label": "GPT-5.4"},
        {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
        {"id": "gpt-5.3-codex", "label": "GPT-5.3 Codex"},
        {"id": "gpt-5-mini", "label": "GPT-5 Mini"},
        {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
        {"id": "gemini-3.5-flash", "label": "Gemini 3.5 Flash"},
        {"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
        {"id": "mai-code-1-flash-picker", "label": "MAI Code 1 Flash"},
        {"id": "gpt-4o", "label": "GPT-4o"},
    ],
    # Cursor ACP — models served via Cursor CLI agent acp
    "cursor-acp": [
        {"id": "cursor/composer-2.5", "label": "Composer 2.5"},
        {"id": "cursor/composer-2", "label": "Composer 2"},
        {"id": "cursor/default", "label": "Default"},
        {"id": "cursor-acp", "label": "Cursor ACP"},
    ],
    # OpenCode Zen — curated models via opencode.ai/zen (pay-as-you-go credits)
    "opencode-zen": [
        {"id": "gpt-5.4-pro", "label": "GPT-5.4 Pro"},
        {"id": "gpt-5.4", "label": "GPT-5.4"},
        {"id": "gpt-5.4-mini", "label": "GPT-5.4 Mini"},
        {"id": "gpt-5.4-nano", "label": "GPT-5.4 Nano"},
        {"id": "gpt-5.3-codex", "label": "GPT-5.3 Codex"},
        {"id": "gpt-5.3-codex-spark", "label": "GPT-5.3 Codex Spark"},
        {"id": "gpt-5.2", "label": "GPT-5.2"},
        {"id": "gpt-5.2-codex", "label": "GPT-5.2 Codex"},
        {"id": "gpt-5.1", "label": "GPT-5.1"},
        {"id": "gpt-5.1-codex", "label": "GPT-5.1 Codex"},
        {"id": "gpt-5.1-codex-max", "label": "GPT-5.1 Codex Max"},
        {"id": "gpt-5.1-codex-mini", "label": "GPT-5.1 Codex Mini"},
        {"id": "gpt-5", "label": "GPT-5"},
        {"id": "gpt-5-codex", "label": "GPT-5 Codex"},
        {"id": "gpt-5-nano", "label": "GPT-5 Nano"},
        {"id": "claude-opus-4-7", "label": "Claude Opus 4.7"},
        {"id": "claude-opus-4-6", "label": "Claude Opus 4.6"},
        {"id": "claude-opus-4-5", "label": "Claude Opus 4.5"},
        {"id": "claude-opus-4-1", "label": "Claude Opus 4.1"},
        {"id": "claude-sonnet-4-6", "label": "Claude Sonnet 4.6"},
        {"id": "claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
        {"id": "claude-sonnet-4", "label": "Claude Sonnet 4"},
        {"id": "claude-haiku-4-5", "label": "Claude Haiku 4.5"},
        {"id": "claude-3-5-haiku", "label": "Claude 3.5 Haiku"},
        {"id": "gemini-3.1-pro-preview", "label": "Gemini 3.1 Pro Preview"},
        {"id": "gemini-3-flash-preview", "label": "Gemini 3 Flash Preview"},
        {"id": "gemini-3.1-flash-lite-preview", "label": "Gemini 3.1 Flash Lite Preview"},
        {"id": "gemini-2.5-pro", "label": "Gemini 2.5 Pro"},
        {"id": "gemini-2.5-flash", "label": "Gemini 2.5 Flash"},
        {"id": "glm-5.1", "label": "GLM-5.1"},
        {"id": "glm-5", "label": "GLM-5"},
        {"id": "kimi-k2.5", "label": "Kimi K2.5"},
        {"id": "minimax-m2.5", "label": "MiniMax M2.5"},
        {"id": "minimax-m2.5-free", "label": "MiniMax M2.5 Free"},
        {"id": "nemotron-3-super-free", "label": "Nemotron 3 Super Free"},
        {"id": "big-pickle", "label": "Big Pickle"},
    ],
    # OpenCode Go — flat-rate models via opencode.ai/go ($10/month).
    # Fallback only: the live Hermes CLI catalog (Go-specific
    # /zen/go/v1/models probe, core v0.20.5+) leads (#1240, #5311).
    # Mirrors Hermes core's curated opencode-go list
    # (hermes_cli/models_catalog_static.py, core main as of 2026-09-10).
    # Core's 2026-09-09 sync dropped `ox-alpha-free` (Go relay delisted it:
    # GET /zen/go/v1/models omits it, POST → 401) and added `glm-5.3-flash`
    # and `muse-spark-1.3-contributor`. Core owns the sync duty against the
    # live endpoint; WebUI mirrors.
    "opencode-go": [
        {"id": "kimi-k3",                  "label": "Kimi K3"},
        {"id": "kimi-k2.7-code",           "label": "Kimi K2.7 Code"},
        {"id": "kimi-k2.6",                "label": "Kimi K2.6"},
        {"id": "kimi-k2.5",                "label": "Kimi K2.5"},
        {"id": "gpt-5.6-luna",             "label": "GPT 5.6 Luna"},
        {"id": "grok-4.5",                 "label": "Grok 4.5"},
        {"id": "glm-5.3",                  "label": "GLM-5.3"},
        {"id": "glm-5.3-flash",            "label": "GLM-5.3 Flash"},
        {"id": "glm-5.2",                  "label": "GLM-5.2"},
        {"id": "glm-5.1",                  "label": "GLM-5.1"},
        {"id": "glm-5",                    "label": "GLM-5"},
        {"id": "mimo-v2.5-pro",            "label": "MiMo V2.5 Pro"},
        {"id": "mimo-v2.5",                "label": "MiMo V2.5"},
        {"id": "mimo-v2-pro",              "label": "MiMo V2 Pro"},
        {"id": "mimo-v2-omni",             "label": "MiMo V2 Omni"},
        {"id": "minimax-m3",               "label": "MiniMax M3"},
        {"id": "minimax-m2.7",             "label": "MiniMax M2.7"},
        {"id": "minimax-m2.5",             "label": "MiniMax M2.5"},
        {"id": "deepseek-v4-pro",          "label": "DeepSeek V4 Pro"},
        {"id": "deepseek-v4-flash",        "label": "DeepSeek V4 Flash"},
        {"id": "qwen3.8-max",              "label": "Qwen3.8 Max"},
        {"id": "qwen3.7-max",              "label": "Qwen3.7 Max"},
        {"id": "qwen3.7-plus",             "label": "Qwen3.7 Plus"},
        {"id": "qwen3.6-plus",             "label": "Qwen3.6 Plus"},
        {"id": "qwen3.5-plus",             "label": "Qwen3.5 Plus"},
        {"id": "hy3",                      "label": "HY3"},
        {"id": "hy3-preview",              "label": "HY3 Preview"},
        {"id": "muse-spark-1.2-contributor", "label": "Muse Spark 1.2 Contributor"},
        {"id": "muse-spark-1.3-contributor", "label": "Muse Spark 1.3 Contributor"},
    ],
    # 'gemini' is the hermes_cli provider ID for Google AI Studio
    # Model IDs are bare — sent directly to:
    #   https://generativelanguage.googleapis.com/v1beta/openai/chat/completions
    "gemini": [
        {"id": "gemini-3.1-pro-preview",            "label": "Gemini 3.1 Pro Preview"},
        {"id": "gemini-3-flash-preview",            "label": "Gemini 3 Flash Preview"},
        {"id": "gemini-3.1-flash-lite-preview",     "label": "Gemini 3.1 Flash Lite Preview"},
        {"id": "gemini-2.5-pro",                    "label": "Gemini 2.5 Pro"},
        {"id": "gemini-2.5-flash",                  "label": "Gemini 2.5 Flash"},
    ],
    # Mistral — prefix used in OpenRouter model IDs (mistralai/mistral-large-latest)
    "mistralai": [
        {"id": "mistral-large-latest", "label": "Mistral Large"},
        {"id": "mistral-small-latest", "label": "Mistral Small"},
    ],
    # Qwen (Alibaba) — prefix used in OpenRouter model IDs (qwen/qwen3-coder)
    "qwen": [
        {"id": "qwen3-coder",   "label": "Qwen3 Coder"},
        {"id": "qwen3.6-plus",  "label": "Qwen3.6 Plus"},
    ],
    # NVIDIA NIM — NVIDIA's inference platform
    "nvidia": [
        {"id": "nvidia/nemotron-3-super-120b-a12b", "label": "Nemotron 3 Super 120B"},
        {"id": "nvidia/nemotron-3-nano-30b-a3b", "label": "Nemotron 3 Nano 30B"},
        {"id": "nvidia/llama-3.3-nemotron-super-49b-v1.5", "label": "Llama 3.3 Nemotron Super 49B"},
        {"id": "qwen/qwen3-next-80b-a3b-instruct", "label": "Qwen3 Next 80B"},
    ],
    # Xiaomi MiMo — direct API via api.xiaomimimo.com
    "xiaomi": [
        {"id": "mimo-v2.5-pro",    "label": "MiMo V2.5 Pro"},
        {"id": "mimo-v2.5",        "label": "MiMo V2.5"},
        {"id": "mimo-v2-pro",      "label": "MiMo V2 Pro"},
        {"id": "mimo-v2-omni",     "label": "MiMo V2 Omni"},
        {"id": "mimo-v2-flash",    "label": "MiMo V2 Flash"},
    ],
    # xAI — prefix used in OpenRouter model IDs (x-ai/grok-4-20)
    "x-ai": [
        {"id": "grok-4.20", "label": "Grok 4.20"},
    ],
    "xai-oauth": [
        {"id": "grok-4.20", "label": "Grok 4.20"},
    ],
    # AWS Bedrock — static fallback list; live model list is fetched via
    # hermes_cli.models.provider_model_ids("bedrock") when available (#2720).
    "bedrock": [
        {"id": "global.anthropic.claude-opus-4-7",                 "label": "Global Anthropic Claude Opus 4.7"},
        {"id": "global.anthropic.claude-opus-4-6-v1",              "label": "Global Anthropic Claude Opus 4.6"},
        {"id": "global.anthropic.claude-sonnet-4-6",               "label": "Global Anthropic Claude Sonnet 4.6"},
        {"id": "global.anthropic.claude-opus-4-5-20251101-v1:0",   "label": "GLOBAL Anthropic Claude Opus 4.5"},
        {"id": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", "label": "Global Claude Sonnet 4.5"},
        {"id": "global.anthropic.claude-haiku-4-5-20251001-v1:0",  "label": "Global Anthropic Claude Haiku 4.5"},
    ],
}


def _seed_provider_models_from_core() -> None:
    """Enrich existing provider model lists with missing IDs from hermes_cli.

    The core's _PROVIDER_MODELS is the authoritative curated list of agent-capable
    models per provider.  The WebUI's static dict above is a display-oriented copy
    (with {id, label} entries) that can go stale when new models are added to the
    core without a matching WebUI update.  This function bridges the gap by
    injecting any missing model IDs from the core into **existing** WebUI provider
    entries.

    Constrains seeding to providers already in the WebUI catalog — does NOT add
    brand-new providers.  Adding new vendors is a maintainer curation decision.
    Respects per-provider ID conventions (e.g. nous uses @nous:-prefixed IDs).

    Safe to call multiple times; only missing entries are added.  Silently no-ops
    if hermes_cli is not importable (standalone WebUI deployments).

    Must be called AFTER ``_get_label_for_model`` is defined (module-level
    invocation is at the bottom of this module, not here).
    """
    try:
        from hermes_cli.models import _PROVIDER_MODELS as _core_pm
    except ImportError:
        return

    # Build a canonical-id → WebUI-key lookup so that providers whose canonical
    # form differs between core and WebUI (e.g. core uses "xai" but WebUI
    # indexes by "x-ai") merge into the existing entry instead of creating a
    # duplicate (#4413).
    _webui_key_by_canonical: dict[str, str] = {}
    for _wk in _PROVIDER_MODELS:
        try:
            _canon = _resolve_provider_alias(_wk)
        except Exception:
            _canon = _wk
        if _canon not in _webui_key_by_canonical:
            _webui_key_by_canonical[_canon] = _wk

    for provider_id, core_models in _core_pm.items():
        if not isinstance(core_models, list):
            continue

        # Resolve the core's provider_id to the WebUI's key for this provider.
        webui_key = provider_id
        webui_list = _PROVIDER_MODELS.get(provider_id)
        if webui_list is None:
            try:
                _canon_pid = _resolve_provider_alias(provider_id)
            except Exception:
                _canon_pid = provider_id
            webui_key = _webui_key_by_canonical.get(_canon_pid, provider_id)
            webui_list = _PROVIDER_MODELS.get(webui_key)

        if webui_list is None:
            # Provider exists in core but not in the WebUI catalog.
            # Do NOT seed — adding new vendors is a maintainer curation
            # decision, not something the seeder should do implicitly (#4413).
            continue
        if not isinstance(webui_list, list):
            continue
        # Provider exists in both — inject missing model IDs.
        # Detect per-provider ID prefix convention (e.g. nous uses @nous:).
        # The merge must respect each provider's existing ID format rather
        # than injecting the core's raw IDs (#4413).
        _existing_ids_raw: list[str] = [
            (m.get("id") if isinstance(m, dict) else str(m)) or ""
            for m in webui_list
            if isinstance(m, dict) and m.get("id")
        ]
        _prefix = ""
        if _existing_ids_raw and all(i.startswith("@") and ":" in i for i in _existing_ids_raw):
            _prefix = _existing_ids_raw[0].split(":", 1)[0] + ":"

        def _strip_prefix(mid: str, prefix: str = _prefix) -> str:
            if prefix and mid.startswith(prefix):
                return mid[len(prefix):]
            return mid

        existing_ids = {
            _strip_prefix(mid).replace("-", ".").lower()
            for mid in _existing_ids_raw
        }
        for mid in core_models:
            if not isinstance(mid, str) or not mid.strip():
                continue
            normed = mid.strip().replace("-", ".").lower()
            if normed not in existing_ids:
                inject_id = (_prefix + mid.strip()) if _prefix else mid.strip()
                webui_list.append({
                    "id": inject_id,
                    "label": _get_label_for_model(mid.strip(), []),
                })


_AMBIENT_GH_CLI_MARKERS = frozenset({"gh_cli", "gh auth token"})

# Environment variable sources that are auto-detected and should be filtered
# when the token is a classic PAT (ghp_*) that Copilot API doesn't support.
# Note: COPILOT_GITHUB_TOKEN is NOT included here - it's user-specific config.
_AMBIENT_GH_ENV_SOURCES = frozenset({"env:github_token", "env:gh_token"})


def _is_ambient_gh_cli_entry(source: str, label: str, key_source: str) -> bool:
    """True when a credential-pool entry is a seeded gh-cli token rather than
    one the user added explicitly. Filter these so Copilot doesn't appear in
    the dropdown just because `gh` is installed on the system.

    Also filters GITHUB_TOKEN and GH_TOKEN env var entries, which are
    auto-detected from the environment and should not cause Copilot to appear
    in the picker when the token is a classic PAT (ghp_*) that Copilot API
    doesn't support.

    Note: COPILOT_GITHUB_TOKEN is NOT filtered - it's user-specific config
    that should always be respected.
    """
    source_lower = source.strip().lower()
    return (
        source_lower in _AMBIENT_GH_CLI_MARKERS
        or source_lower in _AMBIENT_GH_ENV_SOURCES
        or label.strip().lower() == "gh auth token"
        or key_source.strip().lower() == "gh auth token"
    )


def _format_ollama_label(mid: str) -> str:
    """Turn an Ollama model id (Ollama tag format) into a readable display label.

    Examples: 'kimi-k2.5' → 'Kimi K2.5', 'qwen3-vl:235b-instruct' → 'Qwen3 VL (235B Instruct)'
    """
    name_part, _, variant = mid.partition(":")

    def _fmt(s: str) -> str:
        tokens = s.replace("-", " ").replace("_", " ").split()
        out = []
        for t in tokens:
            alpha_only = t.replace(".", "")
            if alpha_only.isalpha() and len(t) <= 3:
                out.append(t.upper())  # short acronym: glm → GLM, vl → VL, gpt → GPT
            elif alpha_only.isalnum() and alpha_only and alpha_only[0].isdigit():
                out.append(t.upper())  # size param: 235b → 235B, 1t → 1T
            else:
                out.append(t[0].upper() + t[1:] if t else t)  # capitalize: kimi → Kimi
        return " ".join(out)

    label = _fmt(name_part)
    if variant:
        label += f" ({_fmt(variant)})"
    return label


def _format_nous_label(mid: str) -> str:
    """Turn a Nous Portal model id into a readable display label.

    Nous IDs are ``<vendor>/<model>[:<variant>]`` (e.g. ``anthropic/claude-opus-4.7``);
    drop the vendor namespace, prettify the model name with the same token
    rules as :func:`_format_ollama_label` (short acronyms uppercase, size
    suffixes uppercase, capitalize the rest), then append ``" (via Nous)"``
    so the entry is visually distinct from same-named models in other
    provider groups (e.g. direct Anthropic).

    Examples (matches the helper's actual output — labels are produced by
    :func:`_format_ollama_label`'s token rules, so 3-letter tokens like
    ``GPT`` and ``PRO`` render uppercase)::

        anthropic/claude-opus-4.7         -> Claude Opus 4.7 (via Nous)
        openai/gpt-5.4-mini               -> GPT 5.4 Mini (via Nous)
        google/gemini-3.1-pro-preview     -> Gemini 3.1 PRO Preview (via Nous)
        moonshotai/kimi-k2.6              -> Kimi K2.6 (via Nous)
        qwen/qwen3.5-plus-02-15           -> Qwen3.5 Plus 02 15 (via Nous)
        nvidia/nemotron-3-super-120b-a12b -> Nemotron 3 Super 120B A12b (via Nous)
        minimax/minimax-m2.5:free         -> MiniMax M2.5 (Free) (via Nous)
    """
    name_part = mid.split("/", 1)[-1] if "/" in mid else mid
    # MiniMax-CN ids come back lowercase on the live wire (`minimax-m2.5`) but
    # the curated label convention is mixed-case "MiniMax M2.5" — match that.
    if name_part.lower().startswith("minimax"):
        name_part = "MiniMax" + name_part[len("minimax"):]
    base = _format_ollama_label(name_part)
    return f"{base} (via Nous)"


# Soft cap on how many Nous Portal models surface in the picker dropdown.
# Above this count, _build_nous_featured_set() trims the visible list to
# ~_NOUS_FEATURED_TARGET entries; the full catalog is still returned to the
# client under ``extra_models`` so /model autocomplete covers everything.
# Caps reflect human scannability — a 25-row dropdown is the practical UX
# ceiling, and per-vendor sampling at 15 keeps the flagship shape visible
# without one vendor dominating.
_NOUS_FEATURED_THRESHOLD = 25
_NOUS_FEATURED_TARGET = 15
_MODEL_PICKER_OVERFLOW_THRESHOLD = _NOUS_FEATURED_THRESHOLD
_MODEL_PICKER_VISIBLE_TARGET = _NOUS_FEATURED_TARGET
_OPENROUTER_FREE_TIER_AUGMENT_CAP = 30

# Vendor-prefix priority order for featured selection. Lower index = picked
# earlier when sampling the live catalog. Reflects which vendors users have
# historically reached for first via Nous Portal (driven by the curated
# static list maintained in _PROVIDER_MODELS["nous"] and Discord feedback).
_NOUS_VENDOR_PRIORITY = (
    "anthropic", "openai", "google", "moonshotai", "z-ai",
    "minimax", "qwen", "x-ai", "deepseek", "stepfun",
    "xiaomi", "tencent", "nvidia", "arcee-ai",
)


def _build_nous_featured_set(
    live_ids: list[str],
    *,
    selected_model_id: str | None = None,
    target: int = _NOUS_FEATURED_TARGET,
) -> tuple[list[str], list[str]]:
    """Trim a Nous Portal catalog into a (featured, extras) split.

    ``featured`` is what the picker dropdown renders. ``extras`` is everything
    else — kept available so the slash-command `/model` autocomplete and the
    ``_dynamicModelLabels`` map cover the full catalog.

    Selection rules (in order, deterministic):

    1. Always include the user's currently-selected model if it's in the
       catalog (preserves selection stickiness — no orphan IDs in the
       dropdown after a refresh).
    2. Always include every entry from the curated static
       ``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id —
       those four are explicitly maintained as flagship picks.
    3. Top up to ``target`` by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
       (one model per vendor each pass) so no vendor monopolises the slot
       budget. Within a vendor, the original ``live_ids`` order is preserved
       — that's the order Nous Portal returned, which approximates recency.

    Returns ``(featured_ids, extras_ids)`` — both lists are subsets of
    ``live_ids`` with disjoint membership and union equal to ``live_ids``.

    For catalogs ≤ ``_NOUS_FEATURED_THRESHOLD`` entries the function is a
    no-op: ``featured == live_ids``, ``extras == []``.
    """
    if not live_ids:
        return [], []
    if len(live_ids) <= _NOUS_FEATURED_THRESHOLD:
        return list(live_ids), []

    chosen: list[str] = []  # preserves insertion order
    chosen_set: set[str] = set()

    def _add(mid: str) -> None:
        if mid and mid not in chosen_set:
            chosen.append(mid)
            chosen_set.add(mid)

    # Rule 1: sticky selection. Strip "@nous:" prefix if present so we can
    # match against the live id space (which is bare "vendor/model").
    if selected_model_id:
        sel = selected_model_id
        if sel.startswith("@nous:"):
            sel = sel[len("@nous:"):]
        if sel in live_ids:
            _add(sel)

    # Rule 2: curated flagships. Extract the bare ids from the static list
    # entries (which are stored as "@nous:vendor/model").
    for static in _PROVIDER_MODELS.get("nous", []):
        sid = static.get("id", "")
        if sid.startswith("@nous:"):
            sid = sid[len("@nous:"):]
        if sid in live_ids:
            _add(sid)

    # Rule 3: vendor-priority round-robin top-up.
    by_vendor: dict[str, list[str]] = {}
    for mid in live_ids:
        if mid in chosen_set:
            continue
        vendor = mid.split("/", 1)[0] if "/" in mid else ""
        by_vendor.setdefault(vendor, []).append(mid)

    # Walk vendors in priority order, then any leftover vendors alphabetically.
    priority = list(_NOUS_VENDOR_PRIORITY)
    leftover = sorted(v for v in by_vendor if v not in set(priority))
    vendor_order = priority + leftover

    # Round-robin: one model per vendor per pass until we hit the target or
    # exhaust every bucket.
    while len(chosen) < target:
        added_this_pass = 0
        for vendor in vendor_order:
            if len(chosen) >= target:
                break
            bucket = by_vendor.get(vendor)
            if not bucket:
                continue
            _add(bucket.pop(0))
            added_this_pass += 1
        if added_this_pass == 0:
            break  # all buckets empty

    # Anything not chosen becomes extras (full-catalog completion surface).
    extras = [m for m in live_ids if m not in chosen_set]
    return chosen, extras


def _strip_picker_provider_hint(model_id: str) -> str:
    mid = str(model_id or "").strip()
    if mid.startswith("@") and ":" in mid:
        return mid[mid.index(":") + 1 :]
    return mid


def _model_matches_picker_selection(
    model_id: str,
    selected_model_id: str | None,
    provider_id: str | None = None,
) -> bool:
    selected = str(selected_model_id or "").strip()
    candidate = str(model_id or "").strip()
    if not selected or not candidate:
        return False
    if candidate == selected:
        return True

    selected_bare = _strip_picker_provider_hint(selected)
    candidate_bare = _strip_picker_provider_hint(candidate)
    if selected_bare != candidate_bare:
        return False

    selected_provider = ""
    if selected.startswith("@") and ":" in selected:
        selected_provider = selected[1 : selected.index(":")].lower()
    candidate_provider = str(provider_id or "").strip().lower()
    if candidate.startswith("@") and ":" in candidate:
        candidate_provider = candidate[1 : candidate.index(":")].lower()

    return not selected_provider or not candidate_provider or selected_provider == candidate_provider


def _openrouter_model_display_name(model_id: str) -> str:
    """Return the OpenRouter display name (e.g. ``Ox Alpha``) for *model_id*.

    Reads only the local shared metadata disk cache written by hermes-agent
    (``cache/openrouter_model_metadata.json``) — never touches the network.
    Falls back to the raw id when the model is unknown or the cache is
    unavailable, so picker rows are always populated (#7228).
    """
    if not model_id:
        return model_id
    try:
        from agent.model_metadata import _load_model_metadata_disk_cache

        cache = _load_model_metadata_disk_cache() or {}
    except Exception:
        return model_id
    entry = cache.get(model_id)
    if not isinstance(entry, dict):
        return model_id
    name = str(entry.get("name") or "").strip()
    return name or model_id


def _split_picker_overflow_models(
    ordered_models: list[dict],
    *,
    selected_model_id: str | None = None,
    provider_id: str | None = None,
    threshold: int = _MODEL_PICKER_OVERFLOW_THRESHOLD,
    target: int = _MODEL_PICKER_VISIBLE_TARGET,
) -> tuple[list[dict], list[dict]]:
    """Split an ordered picker catalog into visible rows plus an overflow tail."""
    models = [copy.deepcopy(m) for m in (ordered_models or []) if isinstance(m, dict) and m.get("id")]
    if len(models) <= threshold:
        return models, []

    visible = models[:target]
    extras = models[target:]
    if not selected_model_id:
        return visible, extras

    if any(_model_matches_picker_selection(m.get("id", ""), selected_model_id, provider_id) for m in visible):
        return visible, extras

    for idx, model in enumerate(extras):
        if not _model_matches_picker_selection(model.get("id", ""), selected_model_id, provider_id):
            continue
        displaced = visible[-1]
        visible[-1] = model
        extras[idx] = displaced
        break
    return visible, extras


def _apply_provider_prefix(
    raw_models: list[dict],
    provider_id: str,
    active_provider: str | None,
) -> list[dict]:
    """Return *raw_models* with @provider: prefixes applied when needed.

    Prefixing is skipped when (a) the provider is already the active one, or
    (b) a model id already starts with '@' or contains '/' (already routable).
    """
    _active = (active_provider or "").lower()
    if not _active or provider_id == _active:
        return list(raw_models)
    result = []
    for m in raw_models:
        mid = m["id"]
        entry = dict(m)
        if mid.startswith("@") or "/" in mid:
            result.append(entry)
        else:
            entry["id"] = f"@{provider_id}:{mid}"
            result.append(entry)
    return result


def _deduplicate_model_ids(groups: list[dict]) -> None:
    """Ensure every model ID across groups is globally unique.

    When multiple providers expose the same model ID (either bare names like
    ``gpt-5.4`` or slash-qualified IDs like ``google/gemma-4-27b``), the
    dropdown cannot distinguish them. This post-process detects such
    collisions and prefixes colliding entries with ``@provider_id:`` so the
    frontend can treat them as distinct options.

    The first occurrence (in provider-id order) is left unchanged for backward
    compatibility with sessions that already store the original bare/slash
    model name. If that provider is later removed from the config, the next
    cache rebuild re-runs dedup — the remaining provider becomes the sole
    occurrence and is left unchanged, so the session still matches.

    .. note::
       The "first occurrence wins" rule means the unchanged ID is not stable
       across config changes (adding, removing, or reordering providers).
       This is acceptable because the dedup runs on every cache rebuild,
       so sessions always resolve to the current canonical unchanged ID.

    The ``@provider_id:model`` format is consistent with the existing
    ``_apply_provider_prefix()`` function and is handled by
    ``resolve_model_provider()`` (rsplits on the last ``:`` to handle
    provider_ids that themselves contain ``:``).

    Operates in-place on *groups*.
    """
    if not groups:
        return

    # Collect {model_id: [(group_idx, bucket_name, model_idx), ...]} in
    # alphabetical provider_id order so that the "first occurrence stays
    # unchanged" rule is deterministic across config edits
    # (adding/removing/reordering providers). Include ``extra_models`` too:
    # slash-command resolution and picker filtering consume the full catalog.
    sorted_group_indices = sorted(
        range(len(groups)),
        key=lambda i: groups[i].get("provider_id", ""),
    )
    id_map: dict[str, list[tuple[int, str, int]]] = {}
    for gi in sorted_group_indices:
        group = groups[gi]
        for bucket_name in ("models", "extra_models"):
            for mi, model in enumerate(group.get(bucket_name, []) or []):
                mid = str(model.get("id", "") or "").strip()
                # Skip IDs that are already provider-qualified.
                if not mid or mid.startswith("@"):
                    continue
                id_map.setdefault(mid, []).append((gi, bucket_name, mi))

    # For any ID appearing in 2+ groups, prefix all but the first occurrence.
    # This handles N>2 providers correctly: the loop iterates over all
    # occurrences after the first, prefixing each with its own provider_id.
    for original_id, locations in id_map.items():
        if len(locations) < 2:
            continue
        for gi, bucket_name, mi in locations[1:]:
            group = groups[gi]
            model = group[bucket_name][mi]
            pid = group.get("provider_id", "")
            model["id"] = f"@{pid}:{original_id}"
            provider_name = group.get("provider", pid)
            if model.get("label") != original_id:
                model["label"] = f"{model['label']} ({provider_name})"
            else:
                model["label"] = f"{original_id} ({provider_name})"


# ── Local-server provider preservation (#1625) ─────────────────────────────
#
# LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI etc. are inference servers,
# not OpenAI-compatible proxies. They register models under their FULL path
# as the registry key (the HuggingFace-style "namespace/model" id, e.g.
# "qwen/qwen3.6-27b"). Stripping the namespace prefix would cause a registry
# miss and the server loads a brand-new instance with default settings,
# silently ignoring the user's tuned context length / parallel slots.
#
# This is distinct from OpenAI-compatible proxies (LiteLLM, OpenRouter relays)
# where stripping "openai/gpt-5.4" → "gpt-5.4" is the correct behavior.
#
# Detection has two layers:
#   1. Static set of known local-server provider names (canonical + common
#      custom-provider naming).
#   2. Loopback / private-host base_url heuristic: an OpenAI-compatible URL
#      pointing at 127.0.0.1, localhost, or a private IP block is almost
#      certainly a local model server, regardless of the provider name.
#      Reuses the same private-IP detection logic used elsewhere in
#      api/config.py for SSRF host trust.
_LOCAL_SERVER_PROVIDERS = {
    "lmstudio",     # canonical (in hermes_cli.models.CANONICAL_PROVIDERS)
    "lm-studio",    # alias used in some custom_providers configs (#1625 Opus NIT)
    "ollama",       # via custom_providers, common pattern
    "llamacpp",     # via custom_providers
    "llama-cpp",    # alias
    "vllm",         # via custom_providers
    "tabby",        # via custom_providers (TabbyAPI)
    "tabbyapi",     # alias
    "koboldcpp",    # local llama.cpp UI fork
    "textgen",      # text-generation-webui (oobabooga) OpenAI-compat extension
    "localai",      # LocalAI project (#1625 Opus NIT)
}


def _is_local_server_provider(provider_id: str) -> bool:
    """True when provider_id names a local model server.

    Named custom providers resolve to ``custom:<slug>``. Treat those as local
    when the bare slug is one of the known local-server provider names too.
    """
    provider = str(provider_id or "").strip().lower()
    if provider in _LOCAL_SERVER_PROVIDERS:
        return True
    if provider.startswith("custom:"):
        return provider.removeprefix("custom:") in _LOCAL_SERVER_PROVIDERS
    return False


def _model_id_declared_in_config(model_id: str, config_provider: str | None) -> bool:
    """True when the user's own config declares ``model_id`` verbatim (full form).

    This is the COLD-catalog provenance signal for #5979: when the live
    ``/v1/models`` catalog is unbuilt (fresh process, headless client), a
    vendor-namespaced id the user configured — ``model.default``, the
    ``model.models`` allowlist, or the matching ``custom_providers[].models`` /
    ``.model`` for a named ``custom:<slug>`` — is still authoritative provenance
    that the full id is intentional and must be preserved. Config is the one
    source available with zero network and no catalog dependency, so it survives
    a cold restart (b3nw's ``model.default: x-ai/grok-4.5``). Checked ONLY for
    custom providers; returns False for anything not verbatim-declared so the
    caller falls through to the legacy family heuristic.
    """
    model = str(model_id or "").strip()
    if not model:
        return False
    model_cfg = cfg.get("model", {})
    if isinstance(model_cfg, dict):
        if str(model_cfg.get("default") or "").strip() == model:
            return True
        _declared = model_cfg.get("models")
        if model in _configured_model_ids(_declared):
            return True
    # Named custom:<slug> — scan its custom_providers[] entry for a verbatim id.
    prov = str(config_provider or "").strip().lower()
    if prov.startswith("custom:"):
        raw_suffix = prov.removeprefix("custom:")
        for entry in _custom_provider_entries():
            slug = _custom_provider_slug_from_name(entry.get("name"))
            entry_name = str(entry.get("name") or "").strip().lower()
            if not (prov in {entry_name, slug} or (slug and raw_suffix == slug.removeprefix("custom:"))):
                continue
            if str(entry.get("model") or "").strip() == model:
                return True
            if model in _configured_model_ids(entry.get("models")):
                return True
    return False


def _is_first_party_model(provider_id: str, model_id: str) -> bool:
    """True when ``model_id`` is listed in ``provider_id``'s own static catalog.

    Used to tell a *redundant* first-party prefix from an *intrinsic* routing
    prefix on a bare ``custom`` endpoint. ``openai/gpt-5.4`` → gpt-5.4 is a real
    OpenAI model, so ``openai/`` is a redundant leftover and strippable (#433).
    But ``bedrock/opus-4-6`` → opus-4-6 is NOT in bedrock's first-party catalog
    (those ids look like ``global.anthropic.claude-…``), so ``bedrock/`` is a
    vendor-routing segment a proxy needs whole (#3872). Returns False on any
    unknown provider or empty model so callers preserve the id.
    """
    provider = str(provider_id or "").strip().lower()
    model = str(model_id or "").strip()
    if not provider or not model:
        return False
    catalog = _PROVIDER_MODELS.get(provider)
    if not isinstance(catalog, list):
        return False
    return any(
        isinstance(entry, dict) and entry.get("id") == model
        for entry in catalog
    )


def _base_url_points_at_local_server(base_url: str) -> bool:
    """True if base_url's host is a loopback or private IP (likely local server).

    Reuses ipaddress.is_loopback / is_private / is_link_local — the same
    heuristic used in the `api/config.py` SSRF/credential-routing code.
    Errors (DNS failure, malformed URL) return False so callers fall back to
    the static-provider-name check.
    """
    if not base_url:
        return False
    try:
        from urllib.parse import urlparse
        import ipaddress
        host = (urlparse(base_url).hostname or "").lower()
        if not host:
            return False
        # Plain-text "localhost" doesn't ipaddress-parse but is unambiguous.
        if host in ("localhost", "ip6-localhost", "ip6-loopback"):
            return True
        try:
            addr = ipaddress.ip_address(host)
        except ValueError:
            # Not an IP literal — could be a hostname like "ollama.internal".
            # Don't try DNS resolution here (slow + ambient): only IP literals
            # and the `localhost` alias get the no-strip treatment via this path.
            return False
        return addr.is_loopback or addr.is_private or addr.is_link_local
    except Exception:
        return False


def _custom_slug_rest_looks_like_host_port(rest: str) -> bool:
    """True when ``custom:<rest>`` is an endpoint-style slug ``host:port``.

    WebUI sometimes derives ``custom:10.8.71.41:8080`` from ``base_url`` authority.
    The #1776 peel must not treat that middle colon as part of an eaten model
    segment — otherwise ``@custom:10.8.71.41:8080:Qwen3`` wrongly becomes model
    ``8080:Qwen3``.
    """
    rest = str(rest or "").strip()
    if ":" not in rest:
        return False
    host, port_s = rest.rsplit(":", 1)
    if not host or ":" in host:
        return False
    if not port_s.isdigit():
        return False
    try:
        port_n = int(port_s)
    except ValueError:
        return False
    if not (1 <= port_n <= 65535):
        return False
    try:
        import ipaddress

        ipaddress.ip_address(host)
        return True
    except ValueError:
        pass
    hl = host.lower()
    if hl == "localhost":
        return True
    # Typical DNS hostname used as proxy slug (contains at least one label dot).
    if "." in host:
        return True
    return False


def _parse_provider_qualified_model_id(model_id: str) -> tuple[str, str] | None:
    """Parse WebUI's ``@provider:model`` route hint into ``(model, provider)``.

    The provider segment can contain colons for named custom providers, while
    the model segment can also contain colons for tags such as ``:free``.
    Keep this parser shared with ``resolve_model_provider`` so any caller that
    compares route-hinted model lanes uses the same grammar.
    """
    candidate = str(model_id or "").strip()
    if not candidate.startswith("@") or ":" not in candidate:
        return None
    inner = candidate[1:]
    provider_hint, bare_model = inner.rsplit(":", 1)
    if provider_hint.startswith("custom:") and provider_hint.count(":") >= 2:
        _slug_rest = provider_hint[len("custom:"):]
        if not _custom_slug_rest_looks_like_host_port(_slug_rest):
            provider_hint, extra = provider_hint.rsplit(":", 1)
            bare_model = f"{extra}:{bare_model}"
    elif (provider_hint not in _PROVIDER_MODELS
            and provider_hint not in _PROVIDER_DISPLAY
            and not provider_hint.startswith("custom:")):
        provider_hint, bare_model = inner.split(":", 1)
    return bare_model, provider_hint


def _get_provider_base_url(provider_id):
    """Look up the configured base_url for a provider (e.g. lmstudio).

    Checks two locations, in order:
      1. ``cfg["providers"][<provider_id>]["base_url"]`` — the explicit
         per-provider override.
      2. ``cfg["model"]["base_url"]`` — falls back here when
         ``cfg["model"]["provider"] == provider_id``. This is the historical
         shape (the model block carries both the active provider AND the
         base URL for that provider in a single record).

    Returns the URL stripped of trailing ``/`` if configured, otherwise None.
    """
    prov_cfg = _get_provider_cfg(provider_id)
    explicit = (prov_cfg.get("base_url") or "").strip().rstrip("/")
    if explicit:
        return explicit
    model_cfg = cfg.get("model", {}) or {}
    if isinstance(model_cfg, dict):
        model_provider = str(model_cfg.get("provider") or "").strip().lower()
        if model_provider == str(provider_id).strip().lower():
            model_base = (model_cfg.get("base_url") or "").strip().rstrip("/")
            if model_base:
                return model_base
    return None


def _get_providers_cfg() -> dict:
    providers_cfg = cfg.get("providers")
    return providers_cfg if isinstance(providers_cfg, dict) else {}


def _get_provider_cfg(provider_id) -> dict:
    provider_cfg = _get_providers_cfg().get(provider_id, {})
    return provider_cfg if isinstance(provider_cfg, dict) else {}


class AmbiguousCustomProviderError(ValueError):
    """Raised when two+ custom_providers[] entries normalize to the same slug.

    A custom provider is identified downstream by a SLUG (``custom:<slug>``):
    ``resolve_model_provider()`` returns it, and the credential lookup
    (``resolve_custom_provider_connection``) resolves the API key + base_url by
    scanning ``custom_providers[]`` for the FIRST entry whose name normalizes to
    that slug — independent of model ownership or the endpoint chosen earlier. So
    when two distinct provider names normalize to the same slug (e.g. ``Foo Bar``
    and ``foo-bar`` both -> ``custom:foo-bar``), consuming the slug on ANY path
    could pair one entry's endpoint with another entry's credential — including
    the asymmetric case where only one of the colliding entries lists the
    requested model. Rather than guess, every slug-only boundary fails closed and
    surfaces the collision so the user can rename one provider. Subclasses
    ``ValueError`` so existing ``except ValueError`` / broad-``except`` fallbacks
    continue to catch it.
    """

    def __init__(self, message: str):
        super().__init__(message)
        # Expose the actionable rename text as ``.message`` so HTTP handlers can
        # forward it verbatim (as the JSON ``error``) without ``str(e)`` casts,
        # and it reaches the user on the handoff + save paths instead of being
        # swallowed into a generic fallback.
        self.message = message


def _custom_provider_slug_key(value: object) -> str:
    """Canonical bare slug for custom-provider identity + collision detection.

    Derived from the SINGLE authoritative slug PRODUCER
    ``_custom_provider_slug_from_name()`` — the same function that mints the
    ``custom:<slug>`` ids resolve_model_provider() actually returns, persists,
    and routes on. Using the producer's normalization (not a private variant)
    everywhere means every slug-only boundary — bare + qualified resolution,
    credential lookup, auxiliary persistence — compares against ONE identity, so
    names that genuinely collide at the producer level (e.g. ``Foo (Bar)`` and
    ``foo-bar`` both -> ``custom:foo-bar``) are detected as collisions instead of
    slipping through a looser key. Returns the bare slug (no ``custom:`` prefix).
    Accepts a bare provider name or a ``custom:<slug>`` id.
    """
    produced = _custom_provider_slug_from_name(value)
    return produced.split(":", 1)[1] if produced.startswith("custom:") else produced


def _unique_custom_provider_entry(custom_providers: object, slug_key: str) -> dict | None:
    """Return the single named ``custom_providers`` entry matching ``slug_key``.

    Pure and lock-safe: operates only on the passed-in list, so it can be called
    while holding ``_cfg_lock`` (unlike ``get_config()``-based resolvers).

    Membership is built from ALL named entries, INDEPENDENT of model ownership,
    because slug-only credential resolution scans every same-slug entry and
    returns the first match. Raises ``AmbiguousCustomProviderError`` when 2+
    entries share the key so an endpoint and an API key can never be resolved
    from different entries on any path. Returns the matching entry, or ``None``
    when no entry matches.
    """
    if not slug_key or not isinstance(custom_providers, list):
        return None
    matches: list[dict] = []
    for entry in custom_providers:
        if not isinstance(entry, dict):
            continue
        name = str(entry.get("name") or "").strip()
        if not name:
            continue
        if _custom_provider_slug_key(name) == slug_key:
            matches.append(entry)
    if len(matches) >= 2:
        names = [str(e.get("name") or "").strip() for e in matches]
        raise AmbiguousCustomProviderError(
            f"Custom providers {names!r} all normalize to the same provider slug "
            f"{slug_key!r}; an endpoint and API key could be resolved from "
            f"different entries. Rename one so each custom provider has a unique slug."
        )
    return matches[0] if matches else None


def resolve_model_provider(model_id: str, *, explicitly_picked: bool = False) -> tuple:
    """Resolve model name, provider, and base_url for AIAgent.

    Model IDs from the dropdown can be in several formats:
      - 'claude-sonnet-4.6'            (bare name, uses config default provider)
      - 'anthropic/claude-sonnet-4.6'  (OpenRouter-style provider/model)
      - '@minimax:MiniMax-M2.7'        (explicit provider hint from dropdown)

    The @provider:model format is used for models from non-default provider
    groups in the dropdown, so we can route them through the correct provider
    via resolve_runtime_provider(requested=provider) instead of the default.

    Custom OpenAI-compatible endpoints are special: their model IDs often look
    like provider/model (for example ``google/gemma-4-26b-a4b``), which would be
    mistaken for an OpenRouter model if we only looked at the slash. To avoid
    that, first check whether the selected model matches an entry in
    config.yaml -> custom_providers and route it through that named custom
    provider.

    Returns (model, provider, base_url) where provider and base_url may be None.

    ``explicitly_picked``: True when the caller knows the user DELIBERATELY
    selected ``model_id`` this session (persisted from an ``explicit_model_pick``
    UI action), as opposed to it being a stale session leftover. Used ONLY for
    the custom-proxy COLD-catalog decision (#5979): with no provenance available,
    a deliberately-picked ``vendor/model`` is preserved verbatim (the user chose
    it, the proxy routes on it), while an UNMARKED id (a stale cross-provider
    leftover, e.g. #433's ``openai/gpt-5.4`` on a bare-only relay) still gets the
    legacy redundant-prefix strip so it keeps routing when cold. Warm provenance
    (endpoint-advertised ids) always takes precedence over this flag.
    """
    config_provider = None
    config_base_url = None
    model_cfg = cfg.get("model", {})
    if isinstance(model_cfg, dict):
        config_base_url = model_cfg.get("base_url")
        config_provider = _resolve_configured_provider_id(
            model_cfg.get("provider"),
            cfg,
            base_url=config_base_url,
            resolve_alias=False,
        )

    # Heal legacy ``provider: local`` entries (written by WebUI < v0.50.252)
    # at read time. ``local`` is not a registered provider, so passing it
    # downstream raises a ``LOCAL_API_KEY`` error from the auxiliary client
    # mid-conversation when compression/vision/web-extract fires. Route
    # through ``custom`` instead — it takes the ``no-key-required``
    # OpenAI-compat path that local servers (Ollama, LM Studio, llama.cpp,
    # vLLM, TabbyAPI) actually use. See #1384.
    if isinstance(config_provider, str) and config_provider.strip().lower() == "local":
        config_provider = "custom"

    def _finalize(model: object, provider: object, base_url: object) -> tuple:
        """Point-of-return collision guard.

        Fail closed HERE — immediately before handing a ``custom:<slug>`` back —
        never up front. A slug collision on one custom pair must not block a
        request that resolves to a DIFFERENT provider (an unrelated
        ``@openrouter:...`` / ``@custom:safe-provider:...`` lane, or the bare
        ``custom`` proxy), so the ambiguity check runs only on the slug actually
        being returned. ``_unique_custom_provider_entry`` raises
        AmbiguousCustomProviderError when >=2 config entries share the slug, so a
        downstream credential lookup can never first-match a different entry than
        the one whose endpoint we resolved. No-op for bare ``custom`` and every
        non-custom provider.
        """
        if isinstance(provider, str) and provider.startswith("custom:"):
            _unique_custom_provider_entry(
                cfg.get('custom_providers', []),
                _custom_provider_slug_key(provider),
            )
        return model, provider, base_url

    model_id = (model_id or "").strip()
    if not model_id:
        return _finalize(model_id, config_provider, config_base_url)

    # Custom providers declared in config.yaml should win over slash-based
    # OpenRouter heuristics. Their model IDs commonly contain '/' too.
    # However, when the active provider is an explicit non-custom provider and
    # the requested model_id is the configured default model, that active
    # provider takes precedence over overlapping custom_providers[] entries.
    # Otherwise WebUI routes to custom:<name> instead of the intended endpoint
    # and can surface a 401 from the wrong provider (#1922).
    # For all other cases, preserve custom_providers[] routing for explicitly
    # selected custom provider models.
    _is_explicit_non_custom_provider = (
        config_provider is not None
        and config_provider != 'custom'
        and not config_provider.startswith('custom:')
    )
    _default_model = model_cfg.get('default') if isinstance(model_cfg, dict) else None
    # Owns model if it appears in the static catalog for the configured provider.
    # _PROVIDER_MODELS is keyed by CANONICAL slug (e.g. 'zai', not the 'z-ai'
    # alias a user may write in config), so canonicalise config_provider before
    # the lookup — otherwise an aliased active provider gets an empty ownership
    # set and _skip_custom_providers guard-2 silently fails, letting another
    # providers.<slug>.models entry hijack an active-owned model (#5511).
    _canon_config_provider = _canonicalise_provider_id(config_provider) if config_provider else ""
    _provider_models_set: set[str] = set()
    if (
        _canon_config_provider
        and _canon_config_provider in _PROVIDER_MODELS
        and isinstance(_PROVIDER_MODELS[_canon_config_provider], list)
    ):
        _provider_models_set = {
            m.get('id', '') for m in _PROVIDER_MODELS[_canon_config_provider]
            if isinstance(m, dict) and isinstance(m.get('id'), str)
        }
    # The active provider may be defined ENTIRELY via config.yaml `providers:`
    # (no static _PROVIDER_MODELS entry) with its own `models:` allowlist. Fold
    # that allowlist into the ownership set too, so the active provider owns its
    # own declared models and can't be hijacked by another providers.<slug>
    # entry that happens to list the same bare id earlier in config order (#5511).
    if _canon_config_provider:
        _providers_cfg_own = cfg.get('providers', {})
        if isinstance(_providers_cfg_own, dict):
            for _slug, _pdef in _providers_cfg_own.items():
                if not isinstance(_pdef, dict):
                    continue
                if _canonicalise_provider_id(_slug) != _canon_config_provider:
                    continue
                if _canon_config_provider == "copilot":
                    continue  # copilot.models is a settings map, not an allowlist
                _provider_models_set.update(_configured_model_ids(_pdef.get('models')))
    _skip_custom_providers = (
        _is_explicit_non_custom_provider
        and (
            # Guard 1: model is the configured default (existing behaviour).
            (_default_model is not None and model_id == _default_model)
            # Guard 2: model is owned by the configured non-custom provider.
            or model_id in _provider_models_set
        )
    )
    custom_providers = cfg.get('custom_providers', [])
    if isinstance(custom_providers, list) and not _skip_custom_providers:
        # Disambiguation guard: when two custom_providers[] entries both list the
        # same bare model id (e.g. dogapi and packyapi both advertise
        # 'claude-sonnet-5'), a plain first-match scan routes on config WRITE
        # ORDER — silently hijacking the model to whichever entry appears first,
        # regardless of the active provider the user actually configured. If the
        # ACTIVE provider is itself a named custom provider (config_provider
        # resolved to 'custom:<slug>', including via model.base_url → named-slug
        # matching), prefer THAT entry when it also owns the model, so an explicit
        # active endpoint wins over an overlapping earlier entry. Falls through to
        # the ordered scan below when the active provider is bare 'custom' / not a
        # named custom entry, or when it doesn't list this model.
        _active_custom_slug = ''
        if isinstance(config_provider, str) and config_provider.startswith('custom:'):
            _active_custom_slug = config_provider

        def _entry_owns_model(entry: dict) -> bool:
            entry_model = (entry.get('model') or '').strip()
            ids = set()
            if entry_model:
                ids.add(entry_model)
            ids.update(_configured_model_ids(entry.get('models')))
            return model_id in ids

        # Explicit active named provider wins over config order when it owns the
        # model. Only CONSUME the active slug (and thus let _finalize fail closed
        # on a collision) when the active provider actually lists this model: if
        # it doesn't, fall through so an unrelated collision on the active slug
        # never blocks a request that resolves to a different provider.
        if _active_custom_slug:
            _active_key = _custom_provider_slug_key(_active_custom_slug)
            _active_owner = next(
                (
                    e for e in custom_providers
                    if isinstance(e, dict)
                    and _entry_owns_model(e)
                    and _custom_provider_slug_key(e.get('name')) == _active_key
                ),
                None,
            )
            # Exactly one entry carries the active slug AND owns the model ->
            # authoritative, even when model.base_url is stale/absent or points at
            # a different endpoint. An explicit, unambiguous named provider must
            # never lose to config order: a stale URL is not evidence to discard
            # it. _finalize() fails closed if the active slug is shared by >=2
            # entries (endpoint + credential could then split).
            if _active_owner is not None:
                return _finalize(
                    model_id,
                    _active_custom_slug,
                    (_active_owner.get('base_url') or '').strip() or None,
                )
        for entry in custom_providers:
            if not isinstance(entry, dict):
                continue
            entry_model = (entry.get('model') or '').strip()
            entry_name = (entry.get('name') or '').strip()
            entry_base_url = (entry.get('base_url') or '').strip()
            entry_model_ids = set()
            if entry_model:
                entry_model_ids.add(entry_model)
            entry_model_ids.update(_configured_model_ids(entry.get('models')))
            if entry_name and model_id in entry_model_ids:
                provider_hint = _custom_provider_slug_from_name(entry_name)
                # _finalize() applies the all-entry collision guard on this
                # bare-'custom' / fall-through path before returning the slug.
                return _finalize(model_id, provider_hint, entry_base_url or None)

    # Check user-defined providers (config.yaml → providers:).
    # Mirrors the custom_providers scan above — exact match against each
    # entry's declared models list (case-sensitive to match custom_providers).
    providers_cfg = cfg.get('providers', {})
    if isinstance(providers_cfg, dict):
        target = model_id.strip()
        # Honor the same active/default ownership guard as the custom_providers
        # scan (_skip_custom_providers, config.py:2535): when the active provider
        # explicitly owns this model (it's the configured default or in the
        # active provider's model set), another provider's overlapping
        # `providers.<slug>.models` entry must NOT hijack routing away from the
        # active provider (#5511 gate finding — e.g. active ai-gateway + default
        # gpt-5 was being pulled to providers.openai.models.gpt-5). In that case
        # restrict the scan to the active provider's own canonical slug.
        _active_slug = _canon_config_provider
        for slug, pdef in providers_cfg.items():
            if not isinstance(pdef, dict):
                continue
            # Copilot is the documented exception: `providers.copilot.models` is
            # a per-model SETTINGS map (reasoning_effort, limits, etc.), NOT a
            # routable allowlist (see the exception at the catalog-build site).
            # Scanning it here would let a Copilot per-model settings entry
            # hijack that model's routing away from its real provider (#5511).
            if _canonicalise_provider_id(slug) == "copilot":
                continue
            # Ownership guard: when the active provider owns this model, only its
            # own providers: entry may match; skip all other slugs.
            if _skip_custom_providers and _canonicalise_provider_id(slug) != _active_slug:
                continue
            if target in _configured_model_ids(pdef.get('models')):
                p_base_url = str(pdef.get('base_url') or '').strip()
                return model_id, slug, p_base_url or None

    # @provider:model format — explicit provider hint from the dropdown.
    # Route through that provider directly (resolve_runtime_provider will
    # resolve credentials in streaming.py).
    # Use rsplit to handle provider_ids that contain ':' (e.g. custom:my-key).
    # With rsplit, "@custom:my-key:model" → provider="custom:my-key", model="model".
    # BUT: model IDs that end in :free / :beta / :thinking collide with the
    # rsplit grammar (e.g. "@openrouter:tencent/hy3-preview:free" would split
    # into provider="openrouter:tencent/hy3-preview", model="free").  Guard
    # against that by falling back to split(":") when the rsplit result is not
    # a recognised provider (#1744).
    #
    # Edge case (#1776): for custom providers with the same suffix
    # ("@custom:my-key:some-model:free"), rsplit yields
    # provider_hint="custom:my-key:some-model", bare_model="free", and the
    # custom-prefix guard below skips the split-fallback. Detect the
    # over-split structurally — custom hints normally carry one slug segment
    # after ``custom:``. If ``provider_hint`` has extra ``:`` tokens because the
    # model ID contained tags like ``:free``, peel one segment back (#1776).
    #
    # Exception: ``custom:<ip-or-host>:<port>`` is a single logical slug derived
    # from OpenAI ``base_url`` authority and contains no eaten model segments.
    parsed_provider_hint = _parse_provider_qualified_model_id(model_id)
    if parsed_provider_hint is not None:
        bare_model, provider_hint = parsed_provider_hint
        # Session/send/handoff shapes encode the provider as @custom:<slug>:model
        # and reach here after the ownership scan only saw the ENCODED string.
        # _finalize() applies the all-entry uniqueness check before returning the
        # slug so the downstream credential lookup can't first-match a different
        # colliding entry — but ONLY on the custom:<slug> actually returned, so an
        # unrelated collision never blocks this explicit hint (@openrouter, a
        # non-colliding @custom:other, ...).
        if (
            provider_hint.startswith("custom:")
            and config_base_url
            and _is_local_server_provider(config_provider)
            and provider_hint.lower() in _custom_endpoint_slugs_for_base_url(config_base_url)
        ):
            return _finalize(bare_model, config_provider, config_base_url)
        # _get_provider_base_url() only reads `providers:` and the ACTIVE
        # `model.base_url`, so a named custom provider registered solely in
        # `custom_providers:` resolved to None there. Prefer that entry's own
        # endpoint so a non-active @custom:<slug> hint routes to its own URL
        # instead of falling back to the default endpoint (HTTP 400 "Invalid
        # model format or no credentials for provider: <bare-model>").
        #
        # Match via _unique_custom_provider_entry on the module-level `cfg` list:
        # it is pure and lock-safe, so it stays callable when resolve_model_provider()
        # is invoked while the caller already holds the non-reentrant _cfg_lock —
        # a get_config()-based resolver would self-deadlock there. It also does NOT
        # guess: an unmatched slug (e.g. a host:port-derived one absent from
        # custom_providers) keeps the prior None so no stale endpoint is persisted
        # for it (#4728). A colliding slug still fails closed via the raise.
        #
        # When an exact custom_providers[] entry exists, that row is authoritative
        # for its slug: use its stripped base_url directly (including None when
        # blank). It must NOT fall through to _get_provider_base_url(), which
        # could pair a same-slug keyed `providers:` endpoint with this entry's
        # credentials (violating same-entry endpoint/key parity). Only fall back
        # to _get_provider_base_url() when no exact custom_providers[] row exists.
        if provider_hint.startswith("custom:"):
            entry = _unique_custom_provider_entry(
                cfg.get('custom_providers', []),
                _custom_provider_slug_key(provider_hint),
            )
            if entry is not None:
                base_url = str(entry.get('base_url') or '').strip() or None
            else:
                base_url = _get_provider_base_url(provider_hint)
        else:
            base_url = _get_provider_base_url(provider_hint)
        return _finalize(bare_model, provider_hint, base_url)

    if "/" in model_id:
        prefix, bare = model_id.split("/", 1)
        # OpenRouter always needs the full provider/model path (e.g. openrouter/free,
        # anthropic/claude-sonnet-4.6). Never strip the prefix for OpenRouter.
        if config_provider == "openrouter":
            return model_id, "openrouter", config_base_url
        # Portal providers (Nous, OpenCode, NVIDIA NIM) serve models from multiple
        # upstream namespaces — check them BEFORE the prefix-strip branch so that
        # a model id whose prefix happens to equal the config_provider (e.g.
        # nvidia/nemotron-... on NVIDIA NIM) still keeps the full namespaced path.
        # The earlier ordering ran this guard AFTER the prefix-strip, so it never
        # fired in the prefix==config_provider case, causing HTTP 404 from the
        # portal which requires the full provider/model id (#2177; sibling of
        # #854 / #894 for Nous, where this guard was originally added).
        _PORTAL_PROVIDERS = {"nous", "opencode-zen", "opencode-go", "nvidia"}
        if config_provider in _PORTAL_PROVIDERS:
            return _finalize(model_id, config_provider, config_base_url)
        # If prefix matches config provider exactly, strip it and use that provider directly.
        # e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API
        if config_provider and prefix == config_provider:
            return _finalize(bare, config_provider, config_base_url)
        # The OpenAI Codex provider uses a real base_url, but its default
        # ChatGPT endpoint cannot serve OpenRouter-style provider/model IDs.
        # Keep that narrow exception before the custom endpoint protection so
        # selecting openai/gpt-5.5 from OpenRouter under active Codex still
        # routes through OpenRouter. Other base_url-backed real providers may be
        # custom/proxy endpoints, so they must fall through to the branch below.
        if (
            config_provider == "openai-codex"
            and str(config_base_url or "").strip().rstrip("/")
            == "https://chatgpt.com/backend-api/codex"
            and prefix in _PROVIDER_MODELS
            and prefix != config_provider
        ):
            return model_id, "openrouter", None
        # Cross-provider via custom_providers: if the prefix matches a named custom
        # provider entry (e.g. "ollama-local/glm-4.7-flash:q4_k_m"), route through it
        # instead of falling back to the default config provider. MUST come BEFORE
        # the config_base_url branch because many providers have a base_url set.
        if prefix and config_provider and prefix != config_provider:
            _custom_cfg = cfg.get("custom_providers", [])
            if isinstance(_custom_cfg, list):
                for _entry in _custom_cfg:
                    if isinstance(_entry, dict) and _entry.get("name", "").strip() == prefix:
                        _slug = _custom_provider_slug_from_name(prefix)
                        _base = (_entry.get("base_url") or "").strip()
                        return _finalize(model_id, _slug, _base or None)

        # If a custom endpoint base_url is configured, don't reroute through OpenRouter
        # just because the model name contains a slash (e.g. google/gemma-4-26b-a4b).
        # The user has explicitly pointed at a base_url, so trust their routing config.
        if config_base_url:
            # Local model servers (LM Studio, Ollama, llama.cpp, vLLM, TabbyAPI)
            # register models under their full HuggingFace-style id. Stripping the
            # prefix breaks the lookup and causes a fresh instance to load with
            # default settings, ignoring user-tuned context length / parallel slots.
            # See #1625. Detect either by canonical provider name OR by base_url
            # pointing at a loopback/private host.
            if (_is_local_server_provider(config_provider)
                    or _base_url_points_at_local_server(config_base_url)):
                return _finalize(model_id, config_provider, config_base_url)
            # Strip the provider prefix only when it's a known provider namespace
            # AND stripping is the right call for this configured provider:
            #
            #  * A real first-party provider pointed at an OpenAI-compatible proxy
            #    (e.g. provider=openai + base_url=litellm) expects the bare id —
            #    "openai/gpt-5.4" → "gpt-5.4", "google/gemma-…" → "gemma-…". This
            #    is the #433 behaviour and applies whenever config_provider is not
            #    the bare "custom" pseudo-provider.
            #
            #  * A *bare* ``custom`` provider (or a named ``custom:<slug>``) is a
            #    vendor-routing proxy (LiteLLM, Bedrock gateway, OpenRouter-style
            #    multi-vendor endpoint). There we strip ONLY a prefix that is
            #    redundant with the model's own first-party namespace
            #    ("openai/gpt-5.4" → gpt-5.4, since gpt-5.4 is genuinely an OpenAI
            #    model — #433). An intrinsic routing prefix whose bare id is NOT a
            #    first-party model of that namespace is kept whole, because the
            #    proxy routes on the full string and truncating it 403s "model not
            #    allowed": "bedrock/opus-4-6" stays intact (opus-4-6 ∉ bedrock
            #    catalog — #3872).
            #
            # Unknown prefixes (e.g. "zai-org/GLM-5.1" on DeepInfra) are intrinsic
            # to the model ID and always preserved (#548). The redundant-prefix
            # strip that matches the *configured* provider's own family is handled
            # earlier by the ``prefix == config_provider`` branch.
            _cp_lower = (config_provider or "").strip().lower()
            _is_custom = _cp_lower == "custom" or _cp_lower.startswith("custom:")
            if _is_custom:
                # Vendor-routing proxy: the reliable signal for whether the
                # endpoint wants the full ``vendor/model`` id or the bare id is
                # what its own catalog actually advertised (the ids the user
                # picked from the dropdown, populated by the endpoint's live
                # ``/v1/models`` probe or a ``custom_providers[].models``
                # allowlist). The catalog-FAMILY heuristic (_is_first_party_model)
                # is the wrong question: it answered "is this bare id a first-
                # party model of the prefix's home vendor?" which is True for BOTH
                # ``x-ai/grok-4.5`` (proxy advertised it whole — must preserve,
                # #5979) and ``openai/gpt-5.4`` (a stale leftover on a relay that
                # only serves bare ``gpt-5.4`` — must strip, #433). Those two are
                # structurally identical to the family heuristic, so a model
                # graduating into a first-party catalog (agent commit 62ada5175
                # adding grok-4.5) silently flipped a working custom-proxy id from
                # preserved to stripped. Tri-state provenance tells them apart:
                #
                # (1) Config declares the full id verbatim (model.default /
                #     model.models / custom_providers[].models). Authoritative and
                #     network-free, so #5979 survives a cold restart — preserve.
                if _model_id_declared_in_config(model_id, config_provider):
                    return _finalize(model_id, config_provider, config_base_url)
                # (2) The endpoint's live/cached catalog advertised it.
                _advertised = _endpoint_advertised_model_ids(config_provider)
                if _advertised:
                    # Full id advertised → route on it verbatim (#5979/#3872/#548).
                    if model_id in _advertised:
                        return _finalize(model_id, config_provider, config_base_url)
                    # ONLY the bare id advertised → the prefix is a redundant
                    # leftover the relay rejects; strip it (#433). Keep the
                    # ``prefix in _PROVIDER_MODELS`` belt so an adversarial catalog
                    # advertising a bare id can't strip an unknown-vendor prefix.
                    if bare in _advertised and prefix in _PROVIDER_MODELS:
                        return _finalize(bare, config_provider, config_base_url)
                    # Advertised but neither exact shape matched → intrinsic /
                    # unknown prefix the proxy routes on; preserve it whole.
                    return _finalize(model_id, config_provider, config_base_url)
                # (3) Provenance genuinely unavailable (cold/unbuilt or
                #     fingerprint-mismatched catalog AND not config-declared).
                #     Distinguish a DELIBERATE selection from a stale leftover:
                #
                #     * explicitly_picked → PRESERVE verbatim. The user chose this
                #       exact ``vendor/model`` in the UI this session; the proxy
                #       routes on it. A wrong strip destroys a namespace the proxy
                #       needs (recurs every turn, unrepairable short of declaring
                #       every model in config) — this is b3nw's #5979 case: a
                #       non-default pick on a custom:<slug> proxy, cold catalog.
                #     * NOT explicitly_picked → legacy redundant-prefix strip. An
                #       unmarked id here is a stale cross-provider leftover (the
                #       user switched providers and the old session model lingers,
                #       e.g. #433's ``openai/gpt-5.4`` on a relay that only serves
                #       bare ``gpt-5.4``); stripping keeps it routing while cold.
                #
                #     Warm provenance (case 2, endpoint-advertised ids) always
                #     wins over this flag; the send path also warms provenance
                #     network-free from the disk cache first
                #     (warm_models_catalog_provenance_if_cold), so this branch is
                #     reached only in the narrow no-disk-cache window. The flag
                #     removes the data-driven flaw where a model graduating into
                #     the static first-party catalog silently flipped routing
                #     (exactly how #5979 regressed).
                if explicitly_picked:
                    return _finalize(model_id, config_provider, config_base_url)
                if prefix in _PROVIDER_MODELS and _is_first_party_model(prefix, bare):
                    return _finalize(bare, config_provider, config_base_url)
                return _finalize(model_id, config_provider, config_base_url)
            # Non-custom first-party provider pointed at an OpenAI-compatible
            # proxy (e.g. provider=openai + base_url=litellm): the bare id is
            # what it expects — "openai/gpt-5.4" → "gpt-5.4" (#433).
            if prefix in _PROVIDER_MODELS:
                return _finalize(bare, config_provider, config_base_url)
            # Intrinsic / unknown prefix — pass the full model_id through unchanged.
            return _finalize(model_id, config_provider, config_base_url)

        # If prefix does NOT match config provider, the user picked a cross-provider model
        # from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini).
        # In this case always route through openrouter with the full provider/model string.
        # Exception (#4210): a custom provider (bare ``custom`` or named ``custom:<slug>``)
        # is a vendor-routing proxy, not a first-party provider — its model ids commonly
        # contain a known-provider prefix that the proxy uses for upstream routing, not
        # an OpenRouter dropdown selection. Keep the request on the custom provider; the
        # base_url-set sibling of this exception lives earlier in the ``config_base_url``
        # branch (#3872).
        _cp_lower_cross = (config_provider or "").strip().lower()
        _is_custom_cross = _cp_lower_cross == "custom" or _cp_lower_cross.startswith("custom:")
        _canon_prefix = _canonicalise_provider_id(prefix)
        _canon_config_provider = _canonicalise_provider_id(config_provider)
        if (
            _canon_prefix in _PROVIDER_MODELS
            and _canon_prefix != _canon_config_provider
            and not _is_custom_cross
        ):
            return model_id, "openrouter", None

    # Final active-provider fallback: when nothing more specific matched, route
    # on the configured provider. _finalize() fails closed here too if that
    # provider is a collision-shared custom:<slug> (per finding #2 — the check
    # must reach this last fallback, not just the earlier explicit paths).
    return _finalize(model_id, config_provider, config_base_url)


def _custom_record_base_url(record: object) -> str | None:
    """Return a custom record's OWN endpoint, or None when it declares none."""
    if not isinstance(record, dict):
        return None
    return str(record.get("base_url") or "").strip() or None


def _resolve_custom_record_key(
    raw_api_key: object,
    raw_key_env: object,
    provider_hint: object = None,
) -> str | None:
    """Static credential declared by ONE custom record.

    Accepts a literal key, a ``${ENV_VAR}`` reference or a ``key_env`` env-var
    hint, then falls back to the ``CUSTOM_<SLUG>_API_KEY`` convention. Reading
    every form from the SAME record is what keeps an endpoint and a credential
    from being resolved out of two different authorities.
    """
    api_key = None
    if raw_api_key is not None:
        key_text = str(raw_api_key).strip()
        if key_text.startswith("${") and key_text.endswith("}") and len(key_text) > 3:
            api_key = _thread_local_env_value(key_text[2:-1]).strip() or None
        elif key_text:
            api_key = key_text
    if not api_key:
        key_env = str(raw_key_env or "").strip()
        if key_env:
            api_key = _thread_local_env_value(key_env).strip() or None
    if not api_key and provider_hint:
        api_key = _lookup_custom_api_key_env(provider_hint)
    return api_key


# Explicit outcomes of a named ``custom:<slug>`` selection.
#
# The status travels with the resolved bundle so a caller can tell "a record
# OWNS this route" from "nothing owns this route" instead of inferring it from
# an empty URL/key pair. That difference is load-bearing: an unowned named route
# must fail closed — no ambient endpoint, no ambient credential, no ambient
# pool/transport, no rewrite to the generic ``custom`` provider and no keyless
# placeholder — rather than borrow whatever the ambient runtime resolved.
CUSTOM_SELECTION_EXACT = "exact"           # exact custom_providers[] row
CUSTOM_SELECTION_KEYED = "keyed"           # identity-owned providers:/model: record
CUSTOM_SELECTION_RESOLVER = "resolver"     # injected connection_resolver reported a pair
CUSTOM_SELECTION_MISSING = "missing"       # no authority owns this slug
CUSTOM_SELECTION_MALFORMED = "malformed"   # ``custom:`` with no slug behind it
# ``ambiguous`` is never RETURNED: a slug collision raises
# AmbiguousCustomProviderError out of _unique_custom_provider_entry, so the
# actionable rename message reaches the user (routes turn it into a 400) instead
# of being silently degraded into a keyless or ambient-authority send.
CUSTOM_SELECTION_AMBIGUOUS = "ambiguous"
# Statuses for which NO authority owns the route.
CUSTOM_SELECTION_UNOWNED = (CUSTOM_SELECTION_MISSING, CUSTOM_SELECTION_MALFORMED)


# ── Terminal routing verdicts ────────────────────────────────────────────────
#
# Hermes Agent does NOT read an incomplete connection pair as a refusal. Its
# ``agent/agent_init.py:_init_openai_client()`` honours the explicit endpoint and
# credential the constructor was handed only when BOTH are truthy:
#
#     if api_key and base_url:
#         client_kwargs = _explicit_client_kwargs(...)
#     else:
#         client_kwargs = _routed_client_kwargs(...)
#
# and ``_routed_client_kwargs()`` resolves a provider AGAIN — through the
# centralized router, then the init-time fallback chain. So a bundle that "fails
# closed" by clearing its endpoint and/or its credential is not terminal at the
# constructor boundary; clearing those fields is precisely the signal to route
# somewhere else, which is how an unroutable custom slug ends up talking to the
# ambient or fallback provider after all.
#
# A route that cannot be resolved therefore has to be represented as an explicit
# verdict rather than as an ordinary constructor-ready dict with a hole in it.
# :data:`CUSTOM_ROUTE_ERROR_FIELD` carries that verdict on every merged bundle
# (``None`` when the route is fine), and every consumer that builds an AIAgent —
# or writes the agent cache, or hands the bundle to an auxiliary client — must
# stop on it and emit a controlled failure instead.
CUSTOM_ROUTE_UNOWNED = "unowned_custom_provider"
CUSTOM_ROUTE_NO_CREDENTIAL = "custom_provider_credential_unresolved"
CUSTOM_ROUTE_NO_ENDPOINT = "custom_provider_endpoint_unresolved"

# Key the verdict travels under on a merged bundle. ``None`` == routable.
CUSTOM_ROUTE_ERROR_FIELD = "route_error"


class CustomProviderRouteError(ValueError):
    """Raised when a named ``custom:<slug>`` route must not reach a provider client.

    The route resolved to no usable ``(api_key, base_url)`` pair, and passing
    that pair to AIAgent would re-enter provider routing rather than fail (see
    the :data:`CUSTOM_ROUTE_ERROR_FIELD` note above). Raising is how the refusal
    stays terminal across the constructor boundary.

    Subclasses ``ValueError`` for the same reason
    :class:`AmbiguousCustomProviderError` does: existing ``except ValueError`` /
    broad-``except`` handlers in the non-streaming routes already turn it into a
    controlled 400 / deterministic fallback instead of a traceback.
    """

    def __init__(self, message: str, *, reason: str, provider: str | None = None, hint: str = ""):
        super().__init__(message)
        # Forwarded verbatim by HTTP handlers, exactly like the ambiguous-slug
        # error's ``.message``.
        self.message = message
        self.reason = reason
        self.provider = provider
        self.hint = hint


def _custom_route_verdict(reason: str, provider: str | None) -> dict:
    """Build the terminal verdict recorded on an unroutable bundle."""
    name = str(provider or "").strip() or "custom"
    if reason == CUSTOM_ROUTE_UNOWNED:
        message = (
            f"Custom provider '{name}' is not configured: no custom_providers[] row, "
            "keyed providers[] record or model: authority owns that name."
        )
        hint = (
            "Add the provider under Settings -> Providers (or config.yaml "
            "custom_providers[]), or pick a configured provider, then send again."
        )
    elif reason == CUSTOM_ROUTE_NO_CREDENTIAL:
        message = (
            f"Custom provider '{name}' declares a credential source that produced no "
            "API key, so the route has no usable connection."
        )
        hint = (
            "Check the provider's api_key / key_env / key_cmd / credential_pool "
            "setting and the environment variable it names, then send again."
        )
    else:
        message = (
            f"Custom provider '{name}' resolved no endpoint, so the route has no "
            "usable connection."
        )
        hint = (
            "Set a base_url for the provider under Settings -> Providers (or "
            "config.yaml custom_providers[]), then send again."
        )
    return {"reason": reason, "provider": name, "message": message, "hint": hint}


def custom_provider_route_error(bundle: object) -> dict | None:
    """Return ``bundle``'s terminal verdict, or ``None`` when it is routable.

    The verdict dict carries ``reason`` (one of :data:`CUSTOM_ROUTE_UNOWNED`,
    :data:`CUSTOM_ROUTE_NO_CREDENTIAL`, :data:`CUSTOM_ROUTE_NO_ENDPOINT`),
    ``provider``, a user-facing ``message`` and a ``hint``.
    """
    if not isinstance(bundle, dict):
        return None
    verdict = bundle.get(CUSTOM_ROUTE_ERROR_FIELD)
    return verdict if isinstance(verdict, dict) else None


def raise_for_custom_provider_route(bundle: dict) -> dict:
    """Return ``bundle`` when routable; raise :class:`CustomProviderRouteError` otherwise.

    The single chokepoint every AIAgent-constructing consumer goes through, so
    "this route is unresolvable" cannot degrade into "resolve it some other way"
    at the constructor.
    """
    verdict = custom_provider_route_error(bundle)
    if verdict is None:
        return bundle
    raise CustomProviderRouteError(
        verdict["message"],
        reason=verdict["reason"],
        provider=verdict["provider"],
        hint=verdict["hint"],
    )


# Identity fields a record can use to NAME the custom provider it belongs to.
# ``name`` is the friendly name a ``custom_providers[]`` entry carries, so a
# ``providers:`` record spelled the same way is claiming the same identity. A
# ``model:`` block's ``name`` is the MODEL's name and never claims a provider,
# which is why that call site passes ``allow_name=False``.
_CUSTOM_RECORD_IDENTITY_FIELDS = ("provider_key", "provider", "custom_provider", "name")


def _custom_record_claims_slug(record: object, slug: str, *, allow_name: bool = True) -> bool:
    """True when ``record`` names ``slug`` as its OWN identity.

    A generic record — ``providers['custom']`` or a ``model:`` block whose
    provider is the bare string ``custom`` — belongs to no slug by itself, so it
    is an authority for ``custom:<slug>`` only when it names that slug. Without
    this test any unknown route adopts whichever generic record happens to be
    configured, which is a credential handed to an endpoint the user never named.
    """
    if not isinstance(record, dict) or not slug:
        return False
    for field in _CUSTOM_RECORD_IDENTITY_FIELDS:
        if field == "name" and not allow_name:
            continue
        value = record.get(field)
        if not isinstance(value, str) or not value.strip():
            continue
        if _custom_provider_slug_key(value) == slug:
            return True
    return False


def _custom_record_owns_connection(record: dict, pid: str) -> bool:
    """True when ``record`` declares ANY field the connection bundle carries.

    The bundle is more than a URL/key pair: ``key_cmd`` mints a per-request
    bearer, ``credential_pool`` supplies a rotating one, and ``api_mode`` and the
    ACP command/args decide the wire protocol and the transport process. A record
    keyed to this slug that declares ONLY those still owns the route — reporting
    it ``missing`` would fail the route closed and then clear the very fields it
    declares (see :func:`merge_custom_provider_runtime_bundle`).
    """
    base_url = _custom_record_base_url(record)
    if base_url:
        return True
    # DECLARATION, not resolution: a ``key_env`` naming an unset variable or a
    # pool that is momentarily empty still names this slug's credential source.
    # Judging by "did a static key resolve?" hands the route to the ambient
    # authority instead of surfacing the misconfiguration.
    if _custom_record_declares_credential(record, base_url, None):
        return True
    if _resolve_custom_record_key(record.get("api_key"), record.get("key_env"), pid):
        return True
    if _custom_record_api_mode(record):
        return True
    if _custom_record_acp_transport(record):
        return True
    return False


# ── raw ``providers:<key>`` records (the standard Hermes v12 config shape) ────
#
# The installed Agent's authoritative matcher for a named custom route is
# ``hermes_cli.runtime_provider_custom._match_new_style_provider()``. It scans
# the ENABLED entries of the RAW ``providers:`` mapping and takes the first
# whose alias set contains the requested name, where the alias set is minted by
# ``hermes_cli.providers.custom_provider_aliases()`` from BOTH the entry's
# display ``name`` and its config KEY. So the standard v12 shape
#
#     model:
#       provider: custom:omni
#     providers:
#       omni:
#         base_url: https://omni.example/v1
#         key_env: OMNI_GATE_KEY
#
# routes through ``providers['omni']``: that record names the identity by its
# own config key, exactly the way ``providers['custom:<slug>']`` does. Looking
# only at the ``custom:``-prefixed spellings made every send on that config fail
# closed as ``unowned_custom_provider`` while the Agent resolved it fine.
#
# The alias/enabled rules are MIRRORED here rather than imported for the same
# reason :data:`_API_MODE_ALIASES` is: selection has to keep working when the
# installed runtime module is unavailable or replaced by a test double.


def _agent_custom_provider_slug(value: object) -> str:
    """``custom:<name>`` identity the Agent mints for ``value``.

    Mirror of ``hermes_cli.providers.custom_provider_slug()``: lowercase, spaces
    to dashes, prefixed unless it already carries one. Deliberately NOT
    :func:`_custom_provider_slug_key`'s normalization — this one reproduces the
    Agent's alias vocabulary, and both are consulted by
    :func:`_custom_record_names_identity`.
    """
    identity = str(value or "").strip().lower().replace(" ", "-")
    if not identity:
        return ""
    return identity if identity.startswith("custom:") else f"custom:{identity}"


def _custom_provider_alias_set(display_name: object, provider_key: object) -> frozenset[str]:
    """Every identity the Agent accepts for ONE custom-provider record.

    Mirror of ``hermes_cli.providers.custom_provider_aliases()``, including its
    legacy ``custom:custom:<name>`` spelling, so a record the Agent would route
    to is not reported unowned here.
    """
    aliases: set[str] = set()
    for value in (display_name, provider_key):
        raw = str(value or "").strip().lower()
        if not raw:
            continue
        normalized = raw.replace(" ", "-")
        aliases.update({raw, normalized, _agent_custom_provider_slug(normalized)})
        if normalized.startswith("custom:"):
            suffix = normalized.split(":", 1)[1]
            if suffix:
                aliases.update({suffix, f"custom:{normalized}"})
    aliases.discard("")
    return frozenset(aliases)


def _custom_record_names_identity(display_name: object, provider_key: object, pid: str, slug: str) -> bool:
    """True when a record keyed ``provider_key`` / named ``display_name`` IS ``pid``.

    Two vocabularies, because two producers mint these ids. The Agent's alias set
    is what decides a CLI send, so honouring it is what keeps the two in
    agreement. :func:`_custom_provider_slug_key` is what the WebUI itself mints
    from a friendly name (``Local (127.0.0.1:11434)`` -> ``local-127.0.0.1-11434``),
    so a record the WebUI wrote is recognised by its own rules too. Both are
    identity tests on the record's OWN name/key — neither widens selection to a
    record that names some other provider.
    """
    if pid in _custom_provider_alias_set(display_name, provider_key):
        return True
    return any(
        _custom_provider_slug_key(value) == slug
        for value in (display_name, provider_key)
        if str(value or "").strip()
    )


# ``enabled:`` words YAML may hand us as strings, matching
# hermes_cli.config_providers._FALSE_WORDS.
_PROVIDER_DISABLED_WORDS = frozenset({"false", "0", "no", "off"})


def _raw_provider_record_enabled(record: object) -> bool:
    """Mirror of ``hermes_cli.config_providers.is_provider_enabled()``.

    Default True; only an explicit falsey ``enabled`` hides the entry. A disabled
    entry is invisible to the Agent's resolver, so it must not own a WebUI route
    either — the route fails closed instead of quietly using a row the user
    switched off.
    """
    if not isinstance(record, dict):
        return False
    flag = record.get("enabled", True)
    if isinstance(flag, bool):
        return flag
    if isinstance(flag, str):
        return flag.strip().lower() not in _PROVIDER_DISABLED_WORDS
    return bool(flag)


# ``_entry_url``'s precedence in hermes_cli.runtime_provider_custom.
_RAW_PROVIDER_URL_FIELDS = ("api", "url", "base_url")


def _normalized_raw_provider_record(record: dict, ep_name: str) -> dict:
    """Return ``record`` respelled in the field names this module's readers use.

    ONE record in, ONE record out: every value is copied from ``record`` and
    nothing is sourced from anywhere else, so the endpoint, the credential, the
    wire protocol, the pool, the ACP transport, the capabilities and the request
    fields the caller then lifts all still belong to that single authority. Only
    the SPELLINGS differ between the raw ``providers:<key>`` shape and the
    ``custom_providers[]`` shape the field readers were written against:

    ``api`` / ``url`` -> ``base_url`` (the Agent's ``_entry_url`` precedence)
    ``api_key_env``   -> ``key_env``
    ``<key>``         -> ``provider_key``, when the record names none itself

    ``transport`` -> ``api_mode`` and ``command`` / ``args`` -> ``acp_*`` need no
    rewrite: :func:`_custom_record_api_mode` and
    :func:`_custom_record_acp_transport` already accept both spellings in place.

    Respelling a COPY rather than the live record keeps the config snapshot
    unmutated, so a second resolution of the same slug sees the same input.
    """
    normalized = dict(record)
    for field in _RAW_PROVIDER_URL_FIELDS:
        value = record.get(field)
        if isinstance(value, str) and value.strip():
            normalized["base_url"] = value.strip()
            break
    if not str(normalized.get("key_env") or "").strip():
        api_key_env = record.get("api_key_env")
        if isinstance(api_key_env, str) and api_key_env.strip():
            normalized["key_env"] = api_key_env.strip()
    if not str(normalized.get("provider_key") or "").strip() and str(ep_name or "").strip():
        # The record's OWN config key is its identity. Stamping it keeps the
        # credential-pool lookup and the ``key_cmd`` token process labelled with
        # this provider instead of the generic ``custom``.
        normalized["provider_key"] = str(ep_name).strip()
    return normalized


def _unique_raw_provider_record(providers_cfg: object, pid: str, slug: str) -> dict | None:
    """The ONE enabled raw ``providers:<key>`` record that owns ``pid``, else None.

    Skips the two keys that already have dedicated, higher-precedence candidates
    in :func:`_select_custom_provider_record` — ``providers['custom:<slug>']``
    and the generic ``providers['custom']`` — so one record can never be counted
    as two authorities, nor collide with itself.

    Fails closed on a genuine collision: when two DISTINCT raw records both name
    this identity and both declare connection fields, an endpoint and a
    credential could be lifted from different rows, which is the same
    split-authority pairing :func:`_unique_custom_provider_entry` raises on. A
    record that names the identity but declares nothing is not a competing
    authority, so it neither wins nor blocks — selection simply falls through to
    the next candidate, and to the terminal verdict when there is none.
    """
    if not isinstance(providers_cfg, dict):
        return None
    owning: list[tuple[str, dict]] = []
    for ep_name, record in providers_cfg.items():
        key = str(ep_name or "").strip()
        if not key or key.lower() in {pid, "custom"}:
            continue
        if not isinstance(record, dict) or not record:
            continue
        if not _raw_provider_record_enabled(record):
            continue
        if not _custom_record_names_identity(record.get("name") or key, key, pid, slug):
            continue
        normalized = _normalized_raw_provider_record(record, key)
        if _custom_record_owns_connection(normalized, pid):
            owning.append((key, normalized))
    if len(owning) >= 2:
        keys = [key for key, _record in owning]
        raise AmbiguousCustomProviderError(
            f"Custom provider records {keys!r} under providers: all name the provider "
            f"slug {slug!r}; an endpoint and API key could be resolved from different "
            f"records. Rename one so each custom provider has a unique slug."
        )
    return owning[0][1] if owning else None


def _select_custom_provider_record(
    pid: str,
    slug: str,
    cfg_data: dict,
) -> tuple[dict | None, str, bool, str]:
    """Return ``(record, source, is_exact, status)`` — the ONE authority for ``pid``.

    Selection is IDENTITY-OWNED: a candidate qualifies only by naming this slug
    (or by being the bare-``custom`` / explicitly-matching ``model:`` authority),
    never by being the only row around. Selection order is the WebUI routing
    contract:

    1. the exact ``custom_providers[]`` row whose name normalizes to ``slug``
       (authoritative for its slug even against a same-slug keyed record, and
       even when its ``base_url`` is blank — see #1806);
    2. otherwise the keyed ``providers['custom:<slug>']`` record, then the raw
       ``providers:<key>`` record that names this identity by its own config key
       or display name (``providers: {omni: ...}`` for ``custom:omni`` — the
       standard Hermes v12 shape, matched with the installed Agent's own alias
       rules; see :func:`_unique_raw_provider_record`), then a record that NAMES
       this slug (the generic ``providers['custom']`` entry whose own
       name/provider_key normalizes to it, or a ``model:`` block whose provider
       is ``custom:<slug>``), and last the generic ``providers['custom']`` entry
       when ``model.provider`` names this slug — the active-provider shape the
       WebUI itself writes. Each is taken as a COMPLETE record rather than
       field-by-field, and each is eligible as soon as it declares ANY field the
       connection bundle carries, not just a static key or a base_url (see
       :func:`_custom_record_owns_connection`).

    What is deliberately NOT eligible is the generic ``providers['custom']``
    record or a bare-``custom`` ``model:`` block that names no slug at all. Those
    are catch-alls, and while they matched every lookup, ``custom:ghost`` took
    whichever one was configured and inherited its endpoint, credential, pool,
    ``api_mode`` and ACP transport — the same wrong-authority pairing as the
    sole-row fallback below, sourced from ``providers:``/``model:`` instead.

    There is deliberately NO "the list holds exactly one row, so use it"
    fallback. That rule resolved ``custom:ghost`` to the endpoint AND credential
    of a sole unrelated row named ``omni`` — the same wrong-authority pairing the
    exact-row rule exists to prevent, and a credential leak to an endpoint the
    user never named. An unknown slug owns nothing, so it reports ``missing``
    and the caller fails closed. This matches the point-of-return rule
    ``resolve_model_provider`` already applies (#4728: no unique entry -> no
    base_url, never a guess).

    ``source`` is ``custom_providers`` / ``providers`` / ``model`` (or ``""``
    when nothing matched) and names the authority that owns every field the
    caller then lifts off ``record``. ``status`` is one of the
    ``CUSTOM_SELECTION_*`` values (``ambiguous`` raises instead of returning).
    """
    custom_providers = cfg_data.get("custom_providers", [])
    if not isinstance(custom_providers, list):
        custom_providers = []

    # Fail closed when the slug maps to multiple entries (raises); otherwise use
    # the single matching entry. Shared with resolve_model_provider so endpoint
    # and credential are always resolved from the SAME entry.
    matched_entry = _unique_custom_provider_entry(custom_providers, slug)
    if matched_entry is not None:
        return matched_entry, "custom_providers", True, CUSTOM_SELECTION_EXACT

    # Fallbacks for setups that don't use custom_providers names directly. Every
    # one of them is keyed on this provider's OWN identity.
    providers_cfg = cfg_data.get("providers", {})
    provider_specific = providers_cfg.get(pid, {}) if isinstance(providers_cfg, dict) else {}
    provider_custom = providers_cfg.get("custom", {}) if isinstance(providers_cfg, dict) else {}

    if (
        isinstance(provider_specific, dict)
        and provider_specific
        and not _raw_provider_record_enabled(provider_specific)
    ):
        # ``providers['custom:<slug>']`` names THIS slug by its own exact key, so
        # switching it off is a statement about this route and not merely about
        # one candidate among several. Falling through to the generic ``custom``
        # record or the ``model:`` block would honour the disable by routing the
        # turn somewhere ELSE — the ambient provider's endpoint and credential
        # under the name the user just took offline. The slug's own authority
        # said no, so the route is terminal here.
        return None, "", False, CUSTOM_SELECTION_MISSING

    model_cfg = cfg_data.get("model", {})
    model_provider = str(model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else ""

    # The generic ``providers['custom']`` record and a ``model:`` block whose
    # provider is the bare string ``custom`` are catch-alls: they name no slug of
    # their own. While they were eligible for EVERY ``custom:<slug>`` lookup,
    # ``custom:ghost`` selected whichever one happened to be configured and
    # inherited its endpoint, credential, pool, api_mode and ACP transport — the
    # user's prompt and a credential sent to a provider they never named.
    # Candidates are ordered by how specifically they name THIS identity.
    named_by_model = model_provider in {pid, slug}
    # A disabled record is invisible to the Agent's resolver, so it is not an
    # authority here either — at EVERY rung, not just the raw-``providers:`` scan
    # that already filtered for it.
    has_generic_custom = (
        isinstance(provider_custom, dict)
        and bool(provider_custom)
        and _raw_provider_record_enabled(provider_custom)
    )
    # The generic record is keyed ``custom`` in ``providers:``, so it names the
    # literal ``custom:custom`` route by its own key; for any other slug it has
    # to say so itself.
    generic_claims_slug = has_generic_custom and (
        slug == "custom" or _custom_record_claims_slug(provider_custom, slug)
    )

    def _candidates():
        """Identity-owned candidates, most specific first — evaluated LAZILY.

        Laziness is load-bearing for the raw-``providers:`` scan alone: that one
        RAISES on an alias collision, and a config whose route is already decided
        by the exact ``providers['custom:<slug>']`` key must not be failed closed
        by a collision further down a list it never reaches.
        """
        if isinstance(provider_specific, dict) and provider_specific:
            # Keyed by this provider's own id: ``providers['custom:<slug>']``.
            yield provider_specific, "providers"
        # The standard v12 shape: a raw ``providers:<key>`` record that names
        # this identity by its own config key or display name (``providers:
        # {omni: ...}`` for ``custom:omni``), matched with the installed Agent's
        # alias rules. This is identity ownership, not an ambient-field escape —
        # a record that names some OTHER provider never appears here, and an
        # unknown slug still matches nothing and falls through to the terminal
        # verdict below.
        raw_record = _unique_raw_provider_record(providers_cfg, pid, slug)
        if raw_record is not None:
            yield raw_record, "providers"
        if generic_claims_slug:
            yield provider_custom, "providers"
        if isinstance(model_cfg, dict) and model_cfg and _raw_provider_record_enabled(model_cfg) and (
            named_by_model
            or (model_provider == "custom" and _custom_record_claims_slug(model_cfg, slug, allow_name=False))
        ):
            yield model_cfg, "model"
        if has_generic_custom and named_by_model and not generic_claims_slug:
            # Last: the active-provider shape the WebUI itself writes, where the
            # generic record holds the configuration of whichever custom provider
            # ``model.provider`` names — here, THIS slug. That is a provenance
            # tie rather than a declaration, so anything naming the slug outright
            # (above) outranks it.
            yield provider_custom, "providers"

    for cand, source in _candidates():
        # A record that names this slug is its authority as soon as it declares
        # ANY connection field — a static key and a base_url are not the only
        # things a record can own. One that declares only ``key_cmd``, a
        # credential pool, an ``api_mode`` or an ACP transport is still THIS
        # slug's record; reporting it missing would clear those very fields as
        # foreign in merge_custom_provider_runtime_bundle.
        #
        # The ``enabled`` re-check is the single chokepoint every candidate has
        # to pass: each branch above gates on it too, and one selection path
        # added later without that gate is exactly how a switched-off row's
        # endpoint and secret got back onto a live route.
        if _raw_provider_record_enabled(cand) and _custom_record_owns_connection(cand, pid):
            return cand, source, False, CUSTOM_SELECTION_KEYED

    return None, "", False, CUSTOM_SELECTION_MISSING


def resolve_custom_provider_connection(
    provider_id: str,
    *,
    return_provenance: bool = False,
) -> tuple[str | None, str | None] | tuple[str | None, str | None, bool]:
    """Return (api_key, base_url) for a named ``custom:*`` provider.

    Supports ``custom_providers[].api_key`` as either a literal key or
    ``${ENV_VAR}``, and ``custom_providers[].key_env`` as an env-var hint.
    Returns ``(None, None)`` when no named custom provider matches.
    If ``return_provenance=True``, returns ``(api_key, base_url, is_exact)``
    indicating whether the connection was resolved from an exact matching
    ``custom_providers[]`` entry.

    This is the URL/key VIEW of the authoritative record. Agent-construction
    paths want :func:`resolve_custom_provider_bundle` instead: the record also
    owns ``api_mode``, ``key_cmd``, pool credentials and ACP transport fields,
    and a caller that takes only two of them still builds a mixed-authority
    agent.
    """
    pid = str(provider_id or "").strip().lower()
    if not pid.startswith("custom:"):
        if return_provenance:
            return None, None, False
        return None, None

    slug = _custom_provider_slug_key(pid)
    if not slug:
        if return_provenance:
            return None, None, False
        return None, None

    # Read the live config snapshot to avoid stale module-level cache edge
    # cases after profile switches or runtime config edits.
    record, _source, is_exact, _status = _select_custom_provider_record(pid, slug, get_config())
    if record is None:
        # Nothing owns this slug. Returning ``(None, None)`` is the whole point:
        # an unknown named route must not inherit an unrelated row's endpoint or
        # credential (see _select_custom_provider_record).
        if return_provenance:
            return None, None, False
        return None, None

    base_url = _custom_record_base_url(record)
    api_key = _resolve_custom_record_key(record.get("api_key"), record.get("key_env"), pid)
    if return_provenance:
        return api_key, base_url, is_exact
    return api_key, base_url


# Local OpenAI-compatible servers frequently run without authentication, so a
# missing key must not fail before the first request: hand the SDK a harmless
# placeholder and let the endpoint accept it or return its own auth error. It is
# applied ONLY after the authoritative record has been resolved in full and
# reported itself genuinely keyless (no api_key, no key_env, no key_cmd, no
# host-gated env key, no credential pool) — substituting it while any of those
# could still mint a credential is what turned a working ``key_cmd`` endpoint
# into a 401.
KEYLESS_CUSTOM_API_KEY = "dummy-key"

# The constructor-routing fields AIAgent takes beside provider/base_url/api_key.
# They travel with the connection: a bundle that replaces the endpoint and the
# credential but leaves these behind builds an agent whose wire protocol
# (api_mode), transport (ACP subprocess) or credential source (pool) still
# points at the previous authority.
CUSTOM_CONNECTION_SIDE_FIELDS = ("api_mode", "acp_command", "acp_args", "credential_pool")

# Alias spellings accepted for a record's ``api_mode`` / ``transport``, mirroring
# hermes_cli.config_providers._canonical_api_mode. Resolved locally rather than
# imported so a record's own transport survives even when the installed runtime
# module is unavailable (or replaced by a test double).
_API_MODE_ALIASES = {
    "chat-completions": "chat_completions",
    "chatcompletions": "chat_completions",
    "openai": "chat_completions",
    "responses": "codex_responses",
    "openai_responses": "codex_responses",
    "openai-responses": "codex_responses",
    "anthropic": "anthropic_messages",
    "anthropic-messages": "anthropic_messages",
    "messages": "anthropic_messages",
    "bedrock": "bedrock_converse",
    "bedrock-converse": "bedrock_converse",
}
_VALID_API_MODES = {
    "chat_completions",
    "codex_responses",
    "anthropic_messages",
    "bedrock_converse",
    "codex_app_server",
}


def _custom_record_api_mode(record: dict) -> str | None:
    """Return the wire protocol a custom record declares for ITSELF, else None.

    ``transport:`` is the v12-migration spelling of ``api_mode:``; hand-edited
    configs still use either. An unrecognized value returns None so the runtime
    keeps its own host/provider detection instead of being handed nonsense.
    """
    for field in ("api_mode", "transport"):
        raw = record.get(field)
        if not isinstance(raw, str):
            continue
        cleaned = raw.strip()
        if not cleaned:
            continue
        canonical = _API_MODE_ALIASES.get(cleaned.lower(), cleaned).lower()
        if canonical in _VALID_API_MODES:
            return canonical
    return None


def _custom_record_acp_transport(record: dict) -> dict:
    """Return the ACP subprocess transport a custom record declares for ITSELF."""
    owned: dict = {}
    command = record.get("acp_command") or record.get("command")
    if isinstance(command, str) and command.strip():
        owned["acp_command"] = command.strip()
    args = record.get("acp_args")
    if args is None:
        args = record.get("args")
    if isinstance(args, (list, tuple)) and len(args) > 0:
        owned["acp_args"] = list(args)
    return owned


def _custom_record_pool_runtime(base_url: str | None, record: dict) -> dict | None:
    """Runtime dict from the credential pool that owns ``base_url``, else None.

    Delegates to the installed runtime's own pool lookup so pool ownership,
    ordering and the loopback placeholder behave exactly as they do for a CLI
    send. Best-effort: builds that do not expose the helper (or a stubbed
    runtime module) simply report no pool.
    """
    if not base_url:
        return None
    try:
        import hermes_cli.runtime_provider as _runtime_provider

        resolve_pool = getattr(_runtime_provider, "_try_resolve_from_custom_pool", None)
        if resolve_pool is None:
            return None
        return resolve_pool(
            base_url,
            "custom",
            _custom_record_api_mode(record),
            provider_name=str(record.get("provider_key") or record.get("name") or "") or None,
        )
    except Exception:
        return None


def _host_gated_env_key(base_url: str | None) -> str | None:
    """Env credential the installed runtime would accept for ``base_url``, else None.

    Host-GATED on purpose (GHSA-76xc-57q6-vm5m): the helper only yields
    OPENAI/OPENROUTER/``<VENDOR>``_API_KEY when the endpoint's host is the
    authoritative one, so this cannot leak a cloud key to an unrelated custom
    endpoint. Consulted here only so "is this endpoint genuinely keyless?" has
    the same answer in WebUI as it does at runtime.
    """
    if not base_url:
        return None
    try:
        import hermes_cli.runtime_provider as _runtime_provider

        candidates = getattr(_runtime_provider, "_host_gated_env_key_candidates", None)
        if candidates is None:
            return None
        for candidate in candidates(base_url, ollama=False):
            cleaned = str(candidate or "").strip()
            if cleaned:
                return cleaned
    except Exception:
        return None
    return None


def _custom_record_key_cmd_provider(base_url: str | None, record: dict, pid: str):
    """Per-request token provider for a record's ``key_cmd``, else None.

    ``key_cmd`` names a command that PRINTS a short-lived bearer; both wire
    clients accept a callable api_key and mint per request. It is a real
    credential source, so a record that declares one is NOT keyless and must
    never be handed :data:`KEYLESS_CUSTOM_API_KEY`.
    """
    key_cmd = str(record.get("key_cmd") or "").strip()
    if not key_cmd:
        return None
    try:
        from agent.command_token_source import build_command_token_provider

        return build_command_token_provider(
            key_cmd, str(record.get("name") or record.get("provider_key") or pid or "custom")
        )
    except Exception:
        logger.debug("key_cmd token provider unavailable for %s", pid, exc_info=True)
        return None


def _custom_record_declares_credential(
    record: dict,
    base_url: str | None,
    pool_runtime: dict | None,
) -> bool:
    """True when the record DECLARES any credential source, resolved or not.

    "Keyless" is a positive claim that an endpoint wants no authentication, and
    it is the only gate on :data:`KEYLESS_CUSTOM_API_KEY`. Deriving it from
    "``api_key`` came back empty" conflates two opposite situations: an
    unauthenticated local server, and an authenticated endpoint whose declared
    credential did not resolve (an ``${ENV}`` that is unset, a ``key_env``
    naming a missing variable, a configured pool that yielded nothing, an
    unbuildable ``key_cmd``). The second must NOT be handed the placeholder —
    that turns a missing-credential misconfiguration into an opaque 401 from the
    endpoint and hides the real cause.

    So the question asked here is declaration, not resolution: the RAW
    ``api_key`` (literal or ``${ENV}``), ``key_env``, ``key_cmd``, a configured
    ``credential_pool``, a pool that actually resolved for this endpoint, and a
    host-gated env credential the runtime would accept for it.
    """
    for field in ("api_key", "key_env", "key_cmd"):
        if str(record.get(field) or "").strip():
            return True
    # A configured pool is a declaration even when empty or exhausted: the user
    # pointed this endpoint at a credential source.
    if record.get("credential_pool") is not None:
        return True
    if pool_runtime:
        return True
    if _host_gated_env_key(base_url):
        return True
    return False


def _unowned_custom_provider_bundle(pid: str, slug: str, status: str) -> dict:
    """Bundle for a named ``custom:*`` route that NO authority owns.

    Every connection field is empty and ``keyless`` is False, so the merge below
    cannot substitute :data:`KEYLESS_CUSTOM_API_KEY`: "nobody owns this route" is
    not a claim that the route is unauthenticated.
    """
    return {
        "provider_id": pid,
        "slug": slug,
        "source": "",
        "status": status,
        "is_exact": False,
        "record": None,
        "base_url": None,
        "api_key": None,
        # Nothing owns the route, so nothing positively owns an endpoint for it
        # either. Stated rather than inferred: the merge must never read an
        # endpoint that reached it from somewhere else as this route's own.
        "endpoint_owned": False,
        "keyless": False,
        "owned": {},
    }


def resolve_custom_provider_bundle(
    provider_id: str,
    *,
    connection_resolver=None,
) -> dict | None:
    """Return the COMPLETE connection bundle a named ``custom:*`` record owns.

    ``None`` only when ``provider_id`` is not a named custom provider at all.
    When it IS one but nothing owns it, the result is an explicit unowned bundle
    (``status`` ``missing``/``malformed``, every connection field empty and
    ``keyless`` False) rather than ``None``, so the merge below can fail the
    route closed instead of silently leaving the ambient runtime's endpoint,
    credential and pool in place. Otherwise a dict with:

    ``base_url``
        the record's own endpoint (``None`` when it declares none).
    ``endpoint_owned``
        True only when the SELECTED record supplied that endpoint itself. It is
        the positive half of the provenance the merge needs: an endpoint already
        sitting in a caller's bundle, an endpoint that merely compares equal, and
        an absent runtime dict are all silence, and silence must never be read as
        "this endpoint belongs to the selected record". False here means the
        route has no endpoint of its own, whatever else is in flight.
    ``api_key``
        the record's own credential, resolved through the SAME ladder the
        runtime uses for a named custom provider: pool credential, then literal
        / ``${ENV}`` / ``key_env`` / ``CUSTOM_<SLUG>_API_KEY``, then a host-gated
        env key — with ``key_cmd`` overriding the static forms because it mints a
        fresh bearer per request. May be a callable (``key_cmd`` token provider).
    ``keyless``
        True only when the record DECLARES no credential source at all, i.e. the
        endpoint is genuinely unauthenticated. A declared-but-unresolved
        credential (unset ``${ENV}``, missing ``key_env`` variable, empty pool,
        unbuildable ``key_cmd``) leaves this False — see
        :func:`_custom_record_declares_credential`. This is the ONLY gate on
        :data:`KEYLESS_CUSTOM_API_KEY`.
    ``owned``
        the subset of :data:`CUSTOM_CONNECTION_SIDE_FIELDS` this record supplies
        itself. Callers keep these and must not overwrite them with the ambient
        runtime's values; fields ABSENT here are simply unowned, not proven
        foreign (see :func:`merge_custom_provider_runtime_bundle`).
    ``source`` / ``is_exact`` / ``record`` / ``status``
        the provenance of the selection, so a caller can tell an exact
        ``custom_providers[]`` row from a keyed fallback — and either of them
        from a route nothing owns (``status`` in
        :data:`CUSTOM_SELECTION_UNOWNED`).

    ``connection_resolver`` lets a caller inject its own module-bound reference
    to :func:`resolve_custom_provider_connection`, so monkeypatching that name in
    the caller's namespace still takes effect. An injected resolver can only
    report a URL/key pair — there is no record behind it — so the bundle it
    yields owns no side fields and the caller keeps the runtime's.
    """
    pid = str(provider_id or "").strip().lower()
    if not pid.startswith("custom:"):
        return None
    slug = _custom_provider_slug_key(pid)
    if not slug:
        # ``custom:`` with nothing behind it names no provider, so no record can
        # own it. Report it explicitly rather than as "not a custom route": the
        # caller asked to route somewhere and must fail closed, not inherit the
        # ambient connection.
        return _unowned_custom_provider_bundle(pid, slug, CUSTOM_SELECTION_MALFORMED)

    if connection_resolver is not None and connection_resolver is not resolve_custom_provider_connection:
        # Tolerate resolvers that predate/omit the provenance kwarg (older builds
        # and test doubles that patch in a plain two-value resolver).
        try:
            conn = connection_resolver(pid, return_provenance=True)
        except TypeError:
            conn = connection_resolver(pid)
        if len(conn) == 3:
            api_key, base_url, is_exact = conn
        else:
            api_key, base_url = conn
            is_exact = False
        if not (is_exact or api_key or base_url):
            # The injected resolver is the whole authority on this path, and it
            # reported nothing for the slug — so nothing owns the route.
            return _unowned_custom_provider_bundle(pid, slug, CUSTOM_SELECTION_MISSING)
        return {
            "provider_id": pid,
            "slug": slug,
            "source": "resolver",
            "status": CUSTOM_SELECTION_RESOLVER,
            "is_exact": bool(is_exact),
            "record": None,
            "base_url": base_url,
            "api_key": api_key,
            "endpoint_owned": bool(base_url),
            # A resolver reports a URL/key pair and nothing else; with no record
            # behind it there is no declaration to inspect, so an absent key is
            # the only keyless signal available here.
            "keyless": not api_key,
            "owned": {},
        }

    record, source, is_exact, status = _select_custom_provider_record(pid, slug, get_config())
    if record is None:
        return _unowned_custom_provider_bundle(pid, slug, status)

    base_url = _custom_record_base_url(record)
    owned: dict = {}

    api_mode = _custom_record_api_mode(record)
    if api_mode:
        owned["api_mode"] = api_mode
    owned.update(_custom_record_acp_transport(record))

    # Pool first, exactly like the runtime's named-custom path: a pooled
    # endpoint's credential AND its pool object come from the same lookup.
    api_key = None
    pool_runtime = _custom_record_pool_runtime(base_url, record)
    if pool_runtime:
        api_key = pool_runtime.get("api_key") or None
        if pool_runtime.get("credential_pool") is not None:
            owned["credential_pool"] = pool_runtime.get("credential_pool")

    if not api_key:
        api_key = _resolve_custom_record_key(record.get("api_key"), record.get("key_env"), pid)
    if not api_key:
        api_key = _host_gated_env_key(base_url)

    # ``key_cmd`` beats a static api_key / key_env (short-lived bearers go stale
    # mid-session), but never displaces a pooled credential, which the pool
    # itself already rotates.
    if not pool_runtime:
        token_provider = _custom_record_key_cmd_provider(base_url, record, pid)
        if token_provider is not None:
            api_key = token_provider

    if "credential_pool" not in owned and record.get("credential_pool") is not None:
        owned["credential_pool"] = record.get("credential_pool")

    return {
        "provider_id": pid,
        "slug": slug,
        "source": source,
        "status": status,
        "is_exact": is_exact,
        "record": record,
        "base_url": base_url,
        "api_key": api_key,
        # POSITIVE endpoint provenance, resolved from the selected record and
        # from nothing else. The merge below pairs this record's credential with
        # an endpoint only while this is True.
        "endpoint_owned": bool(base_url),
        "keyless": not api_key
        and not _custom_record_declares_credential(record, base_url, pool_runtime),
        "owned": owned,
    }


def _connection_identity(runtime_provider: dict, resolved_provider: str | None) -> str | None:
    """Name the provider the already-resolved connection fields belong to.

    The runtime provider dict names itself, so it wins when present. With no
    runtime dict the caller resolved the connection for ``resolved_provider``
    (every such call site passes ``requested=resolved_provider``), so that is
    the identity behind the fields.
    """
    rt_provider = str((runtime_provider or {}).get("provider") or "").strip()
    if rt_provider:
        return rt_provider.lower()
    return str(resolved_provider or "").strip().lower() or None


def _custom_bundle_endpoint_matches(bundle: dict, runtime_provider: dict) -> bool:
    """True when the runtime resolved the SAME endpoint the record owns.

    This is the provenance test that decides whether the ambient runtime's side
    fields are same-authority (keep) or foreign (clear). A record that declares
    no endpoint of its own cannot prove the runtime is foreign, so its runtime
    fields stand.

    Endpoint equality alone is NOT provenance. Two providers are free to share a
    base_url — a gateway fronting several accounts, a local proxy, the same host
    reached with different keys — so a URL match between a record and the ambient
    runtime says only that both point at one host, never that one authority
    resolved both. When the runtime dict NAMES itself and that name is some other
    provider, the identities settle it directly and the shared URL proves
    nothing.
    """
    if not isinstance(runtime_provider, dict) or not runtime_provider:
        return False
    rt_provider = str(runtime_provider.get("provider") or "").strip().lower()
    if rt_provider:
        slug = str(bundle.get("slug") or "").strip().lower()
        owned_names = {
            str(bundle.get("provider_id") or "").strip().lower(),
            slug,
            f"custom:{slug}" if slug else "",
            # The generic ``custom`` runtime is the shape the WebUI writes for
            # whichever custom provider is active, so it is not a competing name.
            "custom",
        }
        owned_names.discard("")
        if rt_provider not in owned_names:
            return False
    record_base_url = bundle.get("base_url")
    rt_base_url = runtime_provider.get("base_url")
    if not record_base_url and not rt_base_url:
        return True
    if not record_base_url or not rt_base_url:
        return False
    return _normalize_base_url_for_match(record_base_url) == _normalize_base_url_for_match(
        rt_base_url
    )


def _custom_runtime_endpoint_is_record_owned(bundle: dict, runtime_provider: dict) -> bool:
    """True when the runtime dict positively originated from the selected RECORD.

    Endpoint equality alone is NOT selected-record provenance: two distinct
    records (e.g. an exact list row and a same-slug keyed record) can share a
    normalized URL while carrying different credentials. A tie requires either
    an explicit source-record identity or matching record credentials;
    silence, missing runtime dicts, and distinct records sharing an endpoint
    never establish that the runtime spelling belongs to the selected record.
    """
    if not bundle.get("base_url"):
        return False
    if not isinstance(runtime_provider, dict) or not runtime_provider.get("base_url"):
        return False
    # Credential conflict strictly rejects a tie before checking identity metadata
    bundle_key = bundle.get("api_key")
    rt_key = runtime_provider.get("api_key")
    if bundle_key and rt_key and bundle_key != rt_key:
        return False
    rec = bundle.get("record")
    rt_rec = runtime_provider.get("record") or runtime_provider.get("source_record")
    if rt_rec is not None:
        return rt_rec == rec and _custom_bundle_endpoint_matches(bundle, runtime_provider)
    rec_id = bundle.get("record_id") or (rec.get("id") if isinstance(rec, dict) else None)
    rt_id = runtime_provider.get("record_id")
    if rt_id is not None:
        return rt_id == rec_id and _custom_bundle_endpoint_matches(bundle, runtime_provider)
    if bundle_key and rt_key and bundle_key == rt_key:
        return _custom_bundle_endpoint_matches(bundle, runtime_provider)
    return False


def merge_custom_provider_runtime_bundle(
    resolved_provider: str | None,
    resolved_api_key: str | None,
    resolved_base_url: str | None,
    runtime_provider: dict | None = None,
    *,
    lookup_provider: str | None = None,
    connection_resolver=None,
) -> dict:
    """Return the COMPLETE constructor-routing bundle for one send attempt.

    Keys: ``provider``, ``base_url``, ``api_key``, every field in
    :data:`CUSTOM_CONNECTION_SIDE_FIELDS`, and
    :data:`CUSTOM_ROUTE_ERROR_FIELD`. Callers must apply the WHOLE dict — that is
    the point: the connection and the transport/protocol/pool fields are one
    authority, and the agent-cache signature must be derived from the same dict
    so a bundle change always mints a new agent.

    :data:`CUSTOM_ROUTE_ERROR_FIELD` is the bundle's TERMINAL verdict, and it is
    not optional to check. A named ``custom:`` route that resolves no complete
    ``(api_key, base_url)`` pair is not constructor-ready: Hermes Agent reads the
    missing field as permission to resolve another provider, so every consumer
    that builds an AIAgent (or writes the agent cache, or feeds an auxiliary
    client) must run the bundle through :func:`raise_for_custom_provider_route`
    first and fail the turn with a controlled provider / missing-credential
    error. See the :data:`CUSTOM_ROUTE_ERROR_FIELD` commentary for the exact
    constructor branch this defends.

    Every consumer that builds an AIAgent for a ``custom:<slug>`` route goes
    through here so the endpoint, the credential and the routing fields all come
    from ONE record. The fill-only pattern this replaces
    (``if not api_key: api_key = ...``) mixed authorities whenever the runtime
    provider had already supplied a truthy value from a same-slug keyed
    ``providers:`` record: resolution deterministically produced the
    ``custom_providers[]`` row's URL while keeping the keyed row's API key.

    Ownership rules, per side field:

    * the selected record declares it -> the record's value wins (an exact list
      row's ``api_mode: anthropic_messages`` is not "ambient noise" to be
      cleared, and a keyed record keeps the pool/transport it owns);
    * the record declares no endpoint of its own, or the runtime resolved the
      SAME endpoint -> the runtime's value is same-authority and is kept;
    * otherwise the runtime resolved a DIFFERENT authority -> the field is
      cleared, because passing it through is what let a custom HTTP endpoint
      inherit Anthropic credential pooling and a Claude ACP subprocess.

    The ENDPOINT itself is decided by positive provenance only. A selected
    config record supplies the endpoint its credential is sent to, and the one
    exception is a runtime dict positively tied to that same record (same
    identity, same normalized URL), whose spelling is the one it can reach.
    Everything else is silence: the ``resolved_base_url`` the caller arrived
    with, a URL that merely compares equal, and a missing runtime dict say
    nothing about the record selected for this slug. So a record that declares
    no usable ``base_url`` does not borrow one — the route is terminal with
    :data:`CUSTOM_ROUTE_NO_ENDPOINT` and the bundle keeps neither the incoming
    endpoint nor this record's credential, because pairing them is precisely how
    a non-active provider's secret reached the ACTIVE provider's URL.

    The CREDENTIAL of an exact ``custom_providers[]`` row is exempt from the
    same-endpoint rule above: it is resolved from that row's own ladder and is
    never inherited from the runtime, because a same-slug keyed record may
    declare the identical ``base_url`` and endpoint equality would then be
    enough to hand the row somebody else's key.

    :data:`KEYLESS_CUSTOM_API_KEY` is substituted only once the selected record
    reports that it DECLARES no credential source at all. A record that declares
    one which produced nothing here (an unset ``${ENV}``, a ``key_env`` naming a
    missing variable, an empty pool, an unbuildable ``key_cmd``) is sent without
    a key rather than with a placeholder that guarantees a 401 and hides the
    real cause.

    A named ``custom:<slug>`` route that NO authority owns fails closed: the
    returned bundle keeps the named provider but carries no endpoint, no
    credential, no pool/transport and no placeholder key, so an unknown slug can
    never be routed through the ambient provider's connection. This holds even
    when the ambient runtime LABELS itself with that same slug — a provider
    string is self-assigned, not proof that the connection belongs to the slug.
    """
    return _custom_provider_runtime_bundle_with_provenance(
        resolved_provider,
        resolved_api_key,
        resolved_base_url,
        runtime_provider,
        lookup_provider=lookup_provider,
        connection_resolver=connection_resolver,
    )[0]


def _custom_provider_runtime_bundle_with_provenance(
    resolved_provider: str | None,
    resolved_api_key: str | None,
    resolved_base_url: str | None,
    runtime_provider: dict | None = None,
    *,
    lookup_provider: str | None = None,
    connection_resolver=None,
) -> tuple[dict, dict | None]:
    """:func:`merge_custom_provider_runtime_bundle` plus the record it selected.

    Returns ``(bundle, custom)`` where ``custom`` is the
    :func:`resolve_custom_provider_bundle` result the merge applied (``None``
    when the route is not a named custom provider, or no record matched).
    Callers that also need the provenance take it from here rather than
    re-resolving: a second resolution re-reads the config and can mint a second
    ``key_cmd`` token provider or select a different pool entry.
    """
    _rt = runtime_provider if isinstance(runtime_provider, dict) else {}
    bundle = {
        "provider": resolved_provider,
        "base_url": resolved_base_url,
        "api_key": resolved_api_key,
        "api_mode": _rt.get("api_mode"),
        "acp_command": _rt.get("acp_command", _rt.get("command")),
        "acp_args": _rt.get("acp_args", _rt.get("args")),
        "credential_pool": _rt.get("credential_pool"),
        # Routable until a named custom route proves otherwise. Non-custom
        # routes legitimately leave the endpoint and/or credential to the
        # runtime provider, so only the named-``custom:`` branches below can
        # record a verdict here.
        CUSTOM_ROUTE_ERROR_FIELD: None,
    }

    lookup = lookup_provider or resolved_provider
    if not (isinstance(lookup, str) and lookup.startswith("custom:")):
        return bundle, None

    custom = resolve_custom_provider_bundle(lookup, connection_resolver=connection_resolver)
    if custom is None:
        return bundle, None

    if custom["status"] in CUSTOM_SELECTION_UNOWNED:
        # NO authority owns this slug: not an exact ``custom_providers[]`` row,
        # not a keyed ``providers:`` record, not a ``model:`` authority. Whatever
        # is already in ``bundle`` was resolved by someone else and reached us as
        # ambient state, so the whole bundle fails closed.
        #
        # A provider LABEL is not proof of ownership. The runtime dict's
        # ``provider: "custom:ghost"`` is a self-assigned string on a connection
        # this process authenticated for some other reason, and
        # :func:`_connection_identity` falls back to ``resolved_provider`` — the
        # very slug being looked up — so a caller that passes no runtime dict at
        # all would match itself. Trusting either would let any unknown slug
        # claim the ambient endpoint, credential, pool and transport simply by
        # naming itself. Ownership is decided by config records, and there are
        # none here.
        #
        # So: no endpoint and no credential; no ambient pool/transport/
        # wire-protocol carried alongside them; the provider stays the NAMED
        # slug, because rewriting it to the generic ``custom`` would present an
        # unresolvable route as a resolved one; and no KEYLESS_CUSTOM_API_KEY —
        # ``keyless`` is False on an unowned bundle precisely so the placeholder
        # cannot claim that an endpoint we never found is unauthenticated.
        logger.warning(
            "custom provider %s matches no custom_providers[] row, keyed "
            "providers[] record or model: authority, and the resolved connection "
            "belongs to %s; refusing to route it through that provider's "
            "endpoint and credential",
            lookup,
            _connection_identity(_rt, resolved_provider) or "an unnamed provider",
        )
        bundle["base_url"] = None
        bundle["api_key"] = None
        for field in CUSTOM_CONNECTION_SIDE_FIELDS:
            bundle[field] = None
        # Clearing the fields is NOT what makes this terminal — the constructor
        # reads an empty pair as "route me somewhere else". Record the verdict so
        # consumers stop before AIAgent instead.
        bundle[CUSTOM_ROUTE_ERROR_FIELD] = _custom_route_verdict(
            CUSTOM_ROUTE_UNOWNED, lookup
        )
        return bundle, custom

    same_authority = _custom_bundle_endpoint_matches(custom, _rt)
    # The positive tie: the runtime dict resolved the very endpoint the selected
    # record declares. Never True on absence of evidence, so it is the only
    # signal allowed to keep an endpoint this record did not supply itself.
    endpoint_tied = _custom_runtime_endpoint_is_record_owned(custom, _rt)

    if custom["record"] is not None and not custom["endpoint_owned"]:
        # A CONFIG RECORD owns this slug — an exact ``custom_providers[]`` row, a
        # keyed ``providers['custom:<slug>']``, a raw ``providers:<key>`` row (the
        # standard v12 shape) or a ``model:`` authority — and it declares no
        # usable endpoint. So this route HAS no endpoint: the one sitting in
        # ``bundle`` was resolved for whoever the process was already talking to
        # (the ACTIVE provider) and reached us as ambient state.
        #
        # Keeping it would pair THIS record's credential with THAT provider's
        # URL — the user's prompt and a non-active provider's secret delivered to
        # an endpoint neither the record nor the user named. The installed
        # Agent's ``_match_new_style_provider()`` skips an endpoint-less raw
        # record for exactly this reason.
        #
        # Nothing here may stand in for the missing endpoint: not the caller's
        # ``resolved_base_url``, not a URL that merely compares equal (a gateway
        # fronting two accounts is one host and two authorities), and not an
        # absent runtime dict. Absence of contrary evidence is not provenance.
        # So the route fails closed on its OWN name — the terminal
        # ``custom_provider_endpoint_unresolved``, naming the setting to fix —
        # rather than degrading into a send through the active provider.
        bundle["base_url"] = None
        bundle["api_key"] = None
        for field in CUSTOM_CONNECTION_SIDE_FIELDS:
            # The record keeps what it declares for ITSELF; every other side
            # field belongs to the ambient authority whose endpoint was just
            # refused, so it goes with it. Leaving those behind would hand the
            # refused provider's credential pool and ACP transport to a route
            # that is about to be reported unresolvable.
            bundle[field] = custom["owned"].get(field)
        bundle[CUSTOM_ROUTE_ERROR_FIELD] = _custom_route_verdict(
            CUSTOM_ROUTE_NO_ENDPOINT, custom["provider_id"]
        )
        logger.warning(
            "custom provider %s resolved no endpoint of its own; refusing to pair "
            "its credential with the connection resolved for %s",
            custom["provider_id"],
            _connection_identity(_rt, resolved_provider) or "another provider",
        )
        return bundle, custom

    if custom["is_exact"]:
        # An exact ``custom_providers[]`` row is authoritative for its slug: BOTH
        # the endpoint (including None when the row's base_url is blank) and the
        # credential are replaced, never merged — the sole exception being the
        # runtime's spelling of the row's OWN endpoint, handled below. The
        # credential comes from the
        # ROW's own ladder (pool, api_key/``${ENV}``/``key_env``/
        # ``CUSTOM_<SLUG>_API_KEY``, ``key_cmd``, host-gated env) and from
        # nowhere else — including when ``same_authority`` holds.
        #
        # Endpoint equality is NOT record provenance. A same-slug keyed
        # ``providers["custom:<slug>"]`` record is free to declare the very same
        # ``base_url``, and the ambient runtime dict carrying that URL is then
        # indistinguishable by URL from one the row itself resolved. Accepting
        # ``_rt["api_key"]`` on that evidence hands the keyed row's credential to
        # the exact row, which is precisely the split-authority merge this
        # function exists to stop: the row's URL with the keyed row's key.
        #
        # So a row whose declared credential produced nothing here keeps
        # ``api_key`` None and falls through to the terminal-route verdict below,
        # naming the setting to fix, rather than silently borrowing a credential
        # the row never declared. A row that declares NO credential at all is
        # reported ``keyless`` by the resolution above and gets
        # :data:`KEYLESS_CUSTOM_API_KEY` there — that is the row's own statement
        # that the endpoint is unauthenticated, not an inherited key either.
        bundle["api_key"] = custom["api_key"] or None
        # Same endpoint rule as the keyed branch below: the row's endpoint wins
        # unless the runtime dict is POSITIVELY tied to it — same identity, same
        # normalized URL — in which case the runtime's spelling of the ROW's own
        # endpoint is kept, because that is the form it can actually reach. This
        # is not a merge: an endpoint the row did not declare can never survive
        # here, since ``endpoint_tied`` is False whenever the row supplied none.
        bundle["base_url"] = _rt.get("base_url") if endpoint_tied else custom["base_url"]
    elif custom["record"] is not None:
        # No exact row, but a CONFIG RECORD was selected: a keyed
        # ``providers['custom:<slug>']``, a raw ``providers:<key>`` row (the
        # standard v12 shape) or a ``model:`` authority, picked as ONE complete
        # record. The endpoint and the credential must therefore come from it
        # together, decided by that record's OWN endpoint provenance and by
        # nothing that happened to be in flight when it was selected. A record
        # that declares NO endpoint never reaches here — the terminal branch
        # above already refused it rather than let it borrow one.
        #
        # The record DECLARES an endpoint, so that endpoint and the credential
        # sent to it are one authority's pair and the record's credential is the
        # only one that may accompany it — including when the ambient runtime
        # reports the SAME URL. A shared base_url is not shared provenance: a
        # gateway fronting two accounts hands both providers one host and two
        # different keys, and taking ``_rt["api_key"]`` there sends the ACTIVE
        # provider's secret to the non-active record's endpoint. A record whose
        # declared credential source resolved nothing keeps ``api_key`` None and
        # falls through to the terminal verdict naming the setting to fix, rather
        # than borrowing the ambient key across that host.
        bundle["api_key"] = custom["api_key"] or None
        # The record's endpoint wins unless the runtime dict is POSITIVELY tied
        # to it — same identity, same normalized URL — in which case the
        # runtime's spelling is kept, because a normalized form of the same
        # endpoint is the one it can actually reach. Any other endpoint already
        # in the bundle was resolved before this record was selected: absent
        # provenance is not a tie, so it is displaced rather than left beside the
        # credential this record just supplied.
        bundle["base_url"] = _rt.get("base_url") if endpoint_tied else custom["base_url"]
    else:
        # No record behind the selection: an injected ``connection_resolver``
        # reported a bare URL/key pair and IS the whole authority on this path.
        # There is no record to take endpoint provenance from, so this keeps the
        # historical fill-only shape — the caller's already-resolved endpoint
        # stands and the resolver fills only what it left empty (#2271).
        if custom["base_url"]:
            bundle["api_key"] = custom["api_key"] or None
        elif same_authority and _rt.get("api_key"):
            # The resolver reported NO endpoint, so the runtime's URL is filling
            # a hole rather than being displaced — and the ambient endpoint's own
            # credential is the coherent partner for it.
            bundle["api_key"] = _rt.get("api_key")
        elif custom["api_key"]:
            bundle["api_key"] = custom["api_key"]
        if custom["base_url"] and _rt.get("base_url") and not same_authority:
            bundle["base_url"] = custom["base_url"]
        elif not bundle["base_url"] and custom["base_url"]:
            bundle["base_url"] = custom["base_url"]

    for field in CUSTOM_CONNECTION_SIDE_FIELDS:
        if field in custom["owned"]:
            # The selected record declares this field for ITSELF, so it wins over
            # the ambient runtime even when the two disagree: an exact list row's
            # ``api_mode: anthropic_messages`` is the row's wire protocol, not
            # ambient noise, and a keyed record keeps the pool/ACP transport it
            # declares.
            bundle[field] = custom["owned"][field]
        elif not same_authority:
            # The record owns an endpoint the runtime did NOT resolve, so this
            # value provably came from a different authority. Clearing it is what
            # stops a custom HTTP endpoint inheriting Anthropic credential pooling
            # and a Claude ACP subprocess.
            bundle[field] = None
        # else: the runtime resolved the SAME endpoint the record owns, so its
        # value is same-authority and the seed above already kept it.

    if bundle["base_url"]:
        # Route through the generic custom OpenAI-compatible client once the
        # named provider has supplied the concrete endpoint. Keeping the provider
        # as custom:<slug> would make Agent init synthesize invalid env-var hints
        # like CUSTOM:SOMETHING-8000_API_KEY on keyless setups.
        bundle["provider"] = "custom"
        if not bundle["api_key"]:
            if custom["keyless"]:
                # Only now, with the record's full credential ladder exhausted
                # (pool, api_key, key_env, CUSTOM_<SLUG>_API_KEY, key_cmd,
                # host-gated env) and the runtime offering nothing either, is the
                # endpoint provably keyless.
                bundle["api_key"] = KEYLESS_CUSTOM_API_KEY
            else:
                # The record DECLARES a credential source that did not yield one
                # here (e.g. a ``key_cmd`` whose token provider could not be
                # built). Substituting the placeholder would turn that into a
                # silent 401 against an endpoint that does require auth; leave the
                # credential unset so the failure names its real cause.
                logger.warning(
                    "custom provider %s declares a credential source that produced "
                    "no key; sending without one rather than the keyless placeholder",
                    custom["provider_id"],
                )

    if not (bundle["api_key"] and bundle["base_url"]):
        # A record OWNS this route, but the merge could not produce the complete
        # explicit pair AIAgent needs to honour it. Leaving the hole would hand
        # the send straight back to ``_routed_client_kwargs()``, which re-resolves
        # the provider and can reach the ambient endpoint, a keyed row this record
        # deliberately displaced, or the init-time fallback chain. Name the real
        # cause instead and let the caller fail the turn.
        bundle[CUSTOM_ROUTE_ERROR_FIELD] = _custom_route_verdict(
            CUSTOM_ROUTE_NO_CREDENTIAL if bundle["base_url"] else CUSTOM_ROUTE_NO_ENDPOINT,
            custom["provider_id"],
        )
        logger.warning(
            "custom provider %s resolved no routable connection (%s); refusing to "
            "build an agent that would re-enter provider routing",
            custom["provider_id"],
            bundle[CUSTOM_ROUTE_ERROR_FIELD]["reason"],
        )

    return bundle, custom


def apply_custom_provider_connection_authority(
    resolved_provider: str | None,
    resolved_api_key: str | None,
    resolved_base_url: str | None,
    *,
    lookup_provider: str | None = None,
    connection_resolver=None,
    runtime_provider: dict | None = None,
) -> tuple[str | None, str | None, str | None, bool]:
    """Connection-only VIEW of :func:`merge_custom_provider_runtime_bundle`.

    Returns ``(provider, api_key, base_url, custom_owned)``, where
    ``custom_owned`` reports whether a config-owned custom record supplied the
    connection — False for a named route nothing owns, whose key and base_url
    come back ``None`` rather than as the ambient runtime's. Kept for callers
    that genuinely construct nothing else (capability/vision lookups); anything
    that builds an AIAgent must take the whole bundle instead and honour its
    :data:`CUSTOM_ROUTE_ERROR_FIELD` verdict, because the three connection fields
    alone are neither a complete constructor contract nor a terminal refusal.
    This view deliberately does NOT raise: a capability lookup asking "can this
    route do vision" is not a constructor boundary and must not turn into a
    failed turn.
    """
    bundle, custom = _custom_provider_runtime_bundle_with_provenance(
        resolved_provider,
        resolved_api_key,
        resolved_base_url,
        runtime_provider,
        lookup_provider=lookup_provider,
        connection_resolver=connection_resolver,
    )
    custom_owned = custom is not None and custom["status"] not in CUSTOM_SELECTION_UNOWNED
    return bundle["provider"], bundle["api_key"], bundle["base_url"], custom_owned


# Subprocess ACP transports (Cursor/Copilot CLI). Model IDs often contain '/'
# but must still route via explicit @provider:model so they do not fall through
# to the configured default HTTP provider (e.g. openai-codex).
_ACP_SUBPROCESS_PROVIDERS = frozenset({"cursor-acp", "copilot-acp"})


def model_with_provider_context(model_id: str, model_provider: str | None = None) -> str:
    """Return the model string to pass to ``resolve_model_provider()``.

    Session persistence keeps the user's selected provider in ``model_provider``
    instead of forcing every selected model into ``@provider:model`` form. At
    runtime, however, ``resolve_model_provider()`` still understands that
    internal disambiguation form, so use it only when the provider context is
    needed to route away from the current default provider.
    """
    model = str(model_id or "").strip()
    provider = str(model_provider or "").strip().lower()
    if not model or not provider or provider == "default" or model.startswith("@"):
        return model

    model_cfg = cfg.get("model", {})
    config_provider = None
    if isinstance(model_cfg, dict):
        config_provider = str(model_cfg.get("provider") or "").strip().lower()

    # ACP subprocess providers always need the explicit hint — their slash IDs
    # are not OpenRouter paths and must not inherit config_provider routing.
    if provider in _ACP_SUBPROCESS_PROVIDERS:
        return f"@{provider}:{model}"

    # Plugin-only model providers (e.g. 9router, and other model plugins whose
    # slugs are not in the static provider tables) route through the plugin, not
    # the default provider. This MUST come before the `provider == config_provider`
    # bare-passthrough below: when a plugin provider is ALSO the configured
    # provider, returning a bare model would drop the '@plugin:' hint and the model
    # would be sent to the wrong backend. Emit the explicit hint so it stays
    # routable to the plugin that surfaced it. (#5909 gate finding)
    if _is_plugin_model_provider(provider):
        return f"@{provider}:{model}"

    # If the selected provider is already the configured provider, leaving the
    # model bare preserves provider-specific base_url/proxy settings.
    if provider == config_provider:
        return model

    # OpenRouter selections with slash IDs are explicit provider/model paths.
    if provider == "openrouter":
        return f"@{provider}:{model}"

    # Explicit providers configured in config.yaml (for example local llama.cpp,
    # Ollama, LM Studio, vLLM, or other OpenAI-compatible endpoints) must keep
    # their provider hint even when the model ID is HuggingFace-style and
    # contains '/'. Otherwise a selected local model such as
    # 'unsloth/gemma-4-12b-it-GGUF:UD-Q4_K_XL' inherits the default provider
    # (e.g. openai-codex) and is sent to the wrong backend.
    providers_cfg = cfg.get("providers") if isinstance(cfg, dict) else {}
    if isinstance(providers_cfg, dict) and provider in providers_cfg:
        return f"@{provider}:{model}"

    # (Plugin-only provider routing handled above, before the config_provider
    # bare-passthrough.)

    # For non-OpenRouter slash IDs without an explicit configured provider,
    # keep the ID intact so existing custom/proxy base_url routing and
    # portal-provider handling remain in charge — UNLESS the session provider
    # is a known routable provider that differs from the profile default.
    # Dropping the hint there lets the default provider's base_url win and
    # 404s (e.g. a Nous portal row `upstage/solar-pro4:free` under an
    # xai-oauth default gets sent to api.x.ai — #7333). Emit the explicit
    # hint for known static/portal providers and named custom providers
    # (including `custom:<slug>` stored as the session provider), and keep
    # the bare ID only for unknown/ambiguous provider slugs (negative
    # control) so custom/proxy base_url routing stays in charge.
    if "/" in model:
        if provider in _PROVIDER_MODELS or provider in _PROVIDER_DISPLAY:
            return f"@{provider}:{model}"
        # A named custom provider is only routable when the slug resolves to a
        # real, unique custom_providers[] entry. `custom:missing` (stale
        # session provider, no config entry) must NOT be minted into an
        # @custom:missing:... route — resolve_model_provider() would take the
        # named-provider lane and find no matching endpoint. `_unique_custom_provider_entry`
        # returns None for unknown slugs and raises AmbiguousCustomProviderError
        # for collisions, matching the point-of-return guard used by
        # resolve_model_provider. (#7356 maintainer review)
        if provider.startswith("custom:"):
            custom_providers = cfg.get("custom_providers") if isinstance(cfg, dict) else []
            if (
                _unique_custom_provider_entry(
                    custom_providers, _custom_provider_slug_key(provider)
                )
                is not None
            ):
                return f"@{provider}:{model}"
        return model

    return f"@{provider}:{model}"


def canonical_model_provider_lane(model_id: str, model_provider: str | None = None) -> tuple[str, str | None]:
    """Return the runtime-resolved model/provider pair used for lane comparisons."""
    model = str(model_id or "").strip()
    provider = str(model_provider or "").strip() or None
    if not model:
        return "", provider
    resolved_model, resolved_provider, _ = resolve_model_provider(
        model_with_provider_context(model, provider)
    )
    resolved_provider = str(resolved_provider or "").strip() or None
    return str(resolved_model or "").strip(), resolved_provider


def get_effective_default_model(config_data: dict | None = None) -> str:
    """Resolve the effective Hermes default model from config, then env overrides."""
    active_cfg = config_data if config_data is not None else cfg
    default_model = DEFAULT_MODEL

    model_cfg = active_cfg.get("model", {})
    if isinstance(model_cfg, str):
        default_model = model_cfg.strip()
    elif isinstance(model_cfg, dict):
        cfg_default = str(model_cfg.get("default") or "").strip()
        if cfg_default:
            default_model = cfg_default

    env_model = (
        os.getenv("HERMES_MODEL") or os.getenv("OPENAI_MODEL") or os.getenv("LLM_MODEL")
    )
    if env_model:
        default_model = env_model.strip()
    return default_model


# ── Reasoning config (CLI parity for /reasoning) ─────────────────────────────
# Mirrors hermes_constants.parse_reasoning_effort so WebUI can validate without
# importing from the agent tree (which may not be installed).  Any drift here
# will show up in the shared test suite since both sides accept the same set.
# Keep this WebUI-visible set aligned with hermes-agent#29248.
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max")


def parse_reasoning_effort(effort):
    """Parse an effort level into the dict the agent expects.

    Returns None when *effort* is empty or unrecognised (caller interprets as
    "use default"), ``{"enabled": False}`` for ``"none"``, and
    ``{"enabled": True, "effort": <level>}`` for any of
    ``VALID_REASONING_EFFORTS``.
    """
    if not effort or not str(effort).strip():
        return None
    eff = str(effort).strip().lower()
    if eff == "none":
        return {"enabled": False}
    if eff in VALID_REASONING_EFFORTS:
        return {"enabled": True, "effort": eff}
    return None


def _strip_provider_hint_for_reasoning(model_id: str, provider: str | None = None) -> str:
    """Remove WebUI routing hints before provider-specific capability lookup.

    A plain ``@provider:model`` hint strips cleanly on the first colon. But a
    *named* custom provider hint is ``@custom:<slug>:model`` — two colons —
    and the naive first-colon split only strips the leading ``@custom:``,
    leaving ``<slug>:model`` behind. That leftover slug fragment can hide a
    nested gateway route from prefix-based checks like
    ``_nested_route_reasoning_denied()`` (e.g. ``agg:vertex/gemini-image-1.0``
    no longer starts with ``vertex/gemini-``), silently re-enabling reasoning
    controls on routes that must never expose them.

    When the resolved *provider* is known (e.g. ``"custom:agg"``), strip the
    exact ``@{provider}:`` prefix first so both segments are removed in one
    pass. Falls back to the generic first-colon split when no provider is
    given or it doesn't match — preserving prior behavior for plain
    ``@provider:model`` hints.
    """
    model = str(model_id or "").strip()
    if not model.startswith("@"):
        return model
    if provider:
        exact_prefix = f"@{provider}:".lower()
        if model.lower().startswith(exact_prefix):
            return model[len(exact_prefix) :]
    if ":" in model:
        return model.split(":", 1)[1]
    return model


def _reasoning_name_candidates(model_id: str) -> list[str]:
    """Return normalized model-name candidates for heuristic capability checks."""
    bare = str(model_id or "").strip().lower().rsplit("/", 1)[-1]
    if not bare:
        return []

    candidates: list[str] = []

    def _add(value: str) -> None:
        candidate = str(value or "").strip().lower()
        if candidate and candidate not in candidates:
            candidates.append(candidate)

    _add(bare)

    dot_parts = [part for part in bare.split(".") if part]
    if len(dot_parts) > 1:
        # Try progressively stripping dot-separated vendor namespaces so inputs like
        # "moonshotai.kimi-k2.5" and "vendor.deepseek.v3.2" both surface the real
        # model family rather than treating every dot as part of the provider slug.
        for index in range(1, len(dot_parts)):
            suffix = ".".join(dot_parts[index:])
            if any(ch.isalpha() for ch in suffix):
                _add(suffix)

    for candidate in list(candidates):
        normalized = re.sub(r"[^a-z0-9]+", "-", candidate).strip("-")
        _add(normalized)

    return candidates


def _candidate_supports_reasoning(candidate: str) -> bool:
    normalized = re.sub(r"[^a-z0-9]+", "-", str(candidate or "").strip().lower()).strip("-")
    if not normalized:
        return False

    tokens = [token for token in normalized.split("-") if token]
    token_set = set(tokens)

    if "thinking" in token_set or "reasoning" in token_set:
        return True
    if "gpt" in token_set or normalized.startswith("gpt"):
        # Restrict to GPT-5+ (exclude GPT-4o/4.1/3.5 — reasoning_effort unsupported)
        m = re.search(r"gpt-(\d+)", normalized)
        if m and int(m.group(1)) >= 5:
            return True
        return False
    if normalized in {"o1", "o3", "o4"} or normalized.startswith(("o1-", "o3-", "o4-")):
        return True
    if "claude" in token_set or normalized.startswith("claude"):
        # Restrict to Claude 4+ or Claude 3.7+ (exclude Claude 3.0/3.5).
        # The minor group is capped at 1-2 digits with a (?!\d) guard so a
        # trailing date stamp is NOT captured as a minor version — otherwise a
        # bare, date-stamped Claude 3.0 id ("claude-3-opus-20240229") would read
        # minor=20240229 and wrongly satisfy the 3.7+ gate. (Same date-stamp
        # defense _is_pre_adaptive_anthropic already uses.)
        match = re.search(r"claude.*?(\d+)(?:\D+(\d{1,2})(?!\d))?", normalized)
        if match:
            major = int(match.group(1))
            minor = int(match.group(2)) if match.group(2) else 0
            if major >= 4 or (major == 3 and minor >= 7):
                return True
        return False
    # Positive-only prefixed Qwen 3+ detection (e.g. "al-qwen3-8-max-preview"
    # → tokens ["al","qwen3","8",...]). Scan for any token starting with
    # "qwen" followed by version >= 3 and immediately allow. Do NOT return
    # False here — Qwen 2.x embedded in hybrid IDs like
    # "deepseek-r1-distill-qwen2.5-bakeneko-32b" must fall through to the
    # DeepSeek detector below.
    for token in tokens:
        m = re.match(r"qwen(\d+)", token)
        if m and int(m.group(1)) >= 3:
            return True
    # Terminal guard for standalone/bare Qwen IDs (original behavior):
    # "qwen" as a standalone token or normalized starting with "qwen" means
    # this IS a Qwen model — apply the 3+ gate and block 2.x.
    if "qwen" in token_set or normalized.startswith("qwen"):
        match = re.search(r"qwen.*?(\d+)(?:\D+(\d+))?", normalized)
        if match:
            major = int(match.group(1))
            if major >= 3:
                return True
        return False
    if "kimi" in token_set or normalized.startswith("kimi"):
        return True
    if "minimax" in token_set or normalized.startswith("minimax"):
        return True
    if "mimo" in token_set or normalized.startswith("mimo"):
        return True
    if "glm" in token_set or normalized.startswith("glm"):
        return True
    if "step" in token_set or normalized.startswith("step"):
        return True
    if "deepseek" in token_set:
        # Check the token immediately after "deepseek" for a V-series or R-series
        # version marker (e.g. "v4", "r1"). This is position-independent so it
        # handles bare "deepseek-v4-flash" and @custom:name:DeepSeek-V4-Flash
        # (→ "my-provider-deepseek-v4-flash") equally, while correctly excluding
        # non-reasoning models like deepseek-chat and deepseek-coder.
        # Using tokens.index() ensures the provider slug (e.g. "vertex" in
        # "vertex-deepseek-chat") cannot falsely trigger the version guard.
        idx = tokens.index("deepseek")
        if idx + 1 < len(tokens) and tokens[idx + 1].startswith(("v", "r")):
            return True
    return False


# Matches the nested Gemini gateway route prefix anywhere it appears in a
# model id, as long as it isn't embedded inside a larger alphanumeric token
# (the negative lookbehind excludes false positives like "notvertex/gemini-x").
# Scanning for the pattern at any position — rather than requiring the whole
# string to start with it — makes the check independent of how many wrapper
# layers precede the route: ``@provider:``, a named custom-provider slug
# (``@custom:<slug>:``), or any future nesting scheme none of us have
# invented yet. This invariant (Gemini image/embedding routes must never
# expose a reasoning toggle) was bypassed twice via different edge cases in
# the prefix-stripping logic before being made structurally boundary-based
# instead of prefix-based — see PR #5313 review history.
_NESTED_ROUTE_PATTERN = re.compile(r"(?<![a-z0-9])(vertex/gemini-|gemini_cli/gemini-)(.*)$")


def _nested_route_reasoning_denied(model: str) -> bool:
    """Hard deny for nested Gemini gateway routes that must never show a reasoning toggle.

    Matches the route pattern anywhere in *model*, not just when the whole
    string starts with it, so this check does not depend on a caller having
    stripped exactly the right wrapper prefix first. Callers should still
    pass the least-wrapped form they have (e.g. after
    ``_strip_provider_hint_for_reasoning``) for clarity, but correctness no
    longer hinges on it.
    """
    lower = str(model or "").strip().lower()
    if not lower:
        return False
    match = _NESTED_ROUTE_PATTERN.search(lower)
    if not match:
        return False
    tail = match.group(2)
    return tail.startswith("embedding") or "image" in tail or "imagine" in tail


def _nested_gateway_route_reasoning(model: str) -> bool:
    """Recognize nested ``vertex/gemini-`` and ``gemini_cli/gemini-`` routes on custom providers.

    The slash-prefix heuristic list includes ``google/gemini-2`` but not gateway-prefixed
    Gemini ids, so capable models behind custom aggregators stayed hidden.
    """
    lower = str(model or "").strip().lower()
    if not lower:
        return False
    for prefix in ("vertex/gemini-", "gemini_cli/gemini-"):
        if lower.startswith(prefix):
            tail = lower[len(prefix) :]
            if tail.startswith("embedding") or "image" in tail or "imagine" in tail:
                return False
            # Gemini thinking/reasoning controls are documented for the 2.5
            # series and 3-era models only — 1.5 (and earlier) have no thinking
            # support, so a reasoning selector on e.g. ``vertex/gemini-1.5-pro``
            # would let a user pick an effort that the route then rejects.
            # Version-gate the allow to the reasoning-capable families.
            if (
                tail == "2.5"
                or tail.startswith(("2.5-", "2.5.", "3-", "3."))
                or "thinking" in tail
                or "reasoning" in tail
            ):
                return True
            return False
    return False


def _zai_glm_classification(model_id: str, provider_id: str) -> str | None:
    """Classify a model on the native ``zai`` endpoint into a Z.AI capability tier.

    Returns one of:

    * ``"effort"``  — accepts the ``reasoning_effort`` intensity ladder
      (GLM-5.2+; Z.AI's max/xhigh/high/medium/low/minimal values match
      ``VALID_REASONING_EFFORTS`` exactly).
    * ``"thinking"`` — does NOT accept the effort ladder but DOES accept the
      ``thinking: {"type": "enabled"|"disabled"}`` on/off toggle (GLM-4.5,
      4.5-air/flash, 4.6, 5, 5.1, 5-turbo, and other 4.5+ non-4.7 GLM models).
    * ``"forced"``  — GLM-4.7 family: forced thinking, neither the toggle nor
      the ladder is configurable.
    * ``None``      — not a native-``zai`` GLM model (non-GLM id, non-zai
      provider, or an aggregator/custom provider that routes through its own
      router rather than Z.AI's per-model docs).

    Scoped to the native ``zai`` endpoint (aliases ``glm``/``z-ai``/``z.ai``/
    ``zhipu`` all resolve to ``zai`` via ``_resolve_provider_alias``). Per
    docs.z.ai: ``thinking`` is supported by GLM-4.5+ (4.7 forces it on),
    ``reasoning_effort`` is GLM-5.2+ exclusive.

    Shared by ``_filter_reasoning_efforts_for_provider`` (UI dropdown options),
    ``coerce_reasoning_effort_for_model`` (what is actually sent to Z.AI), and
    ``get_reasoning_status`` (whether the composer renders an On/None toggle when
    the effort ladder is empty) so all three surfaces agree.
    """
    provider = _resolve_provider_alias(str(provider_id or "").strip().lower())
    if provider != "zai":
        return None
    bare = _strip_provider_hint_for_reasoning(str(model_id or "")).lower().rsplit("/", 1)[-1]
    if "glm" not in bare:
        return None
    # GLM-4.7 family: forced thinking — reasoning is not configurable at all.
    if bare.startswith("glm-4.7"):
        return "forced"
    m = re.search(r"glm-(\d+)(?:\D+(\d+))?", bare)
    if m:
        major = int(m.group(1))
        minor = int(m.group(2)) if m.group(2) else 0
        # GLM-5.2+ accepts the effort ladder.
        if (major, minor) >= (5, 2):
            return "effort"
        # GLM-4.5+ (but below 5.2) accepts the thinking toggle only.
        if (major, minor) >= (4, 5):
            return "thinking"
    # Pre-4.5 GLM (e.g. glm-4, glm-3): no thinking support documented by Z.AI.
    return None


def _zai_glm_reasoning_efforts_supported(model_id: str, provider_id: str) -> bool | None:
    """Z.AI native-endpoint gate for the ``reasoning_effort`` intensity field.

    Returns True if the model accepts the effort ladder (GLM-5.2+), False if it
    is known NOT to (pre-5.2 GLM and the forced-thinking GLM-4.7 family), or None
    if this is not a native-``zai`` GLM model (caller should defer to other rules).

    Thin wrapper over ``_zai_glm_classification`` kept for the coercion path's
    explicit True/False/None contract. A known-False result means "send no
    ``reasoning_effort`` field" (distinct from the ambiguous empty list returned
    for genuinely unknown models, which preserves the configured effort verbatim
    per #3505).
    """
    cls = _zai_glm_classification(model_id, provider_id)
    if cls is None:
        return None
    return cls == "effort"


def _zai_glm_thinking_toggle_supported(model_id: str, provider_id: str) -> bool | None:
    """Z.AI native-endpoint gate for the ``thinking`` on/off toggle.

    Returns True if the model accepts the ``thinking: {"type": ...}`` toggle
    (GLM-4.5+ except the forced-thinking GLM-4.7), False if it does not
    (GLM-4.7 forced, or pre-4.5 GLM with no thinking support), or None if this
    is not a native-``zai`` GLM model (caller should defer — the toggle's
    availability is then governed by ``supported_efforts`` as before).

    Drives the ``supports_thinking_toggle`` field in ``get_reasoning_status`` so
    the composer can render an operable On/None control for GLM-4.5–5.1 models
    that accept the thinking toggle but not the effort ladder.
    """
    cls = _zai_glm_classification(model_id, provider_id)
    if cls is None:
        return None
    return cls in {"effort", "thinking"}


_OPENAI_FAMILY_REASONING_PROVIDERS = frozenset({
    "openai-codex", "openai", "openai-api",
    "azure-foundry", "azure-openai", "azure",
})

_GPT_5_6_REASONING_MODELS = frozenset({
    "gpt-5.6",
    "gpt-5.6-sol",
    "gpt-5.6-terra",
    "gpt-5.6-luna",
})


def _is_gpt_5_6_reasoning_model(bare_model: str) -> bool:
    """Return whether an OpenAI-family model uses GPT-5.6's max ladder."""
    return str(bare_model or "").strip().lower() in _GPT_5_6_REASONING_MODELS


def _filter_reasoning_efforts_for_provider(
    efforts: list[str],
    model_id: str,
    provider_id: str,
) -> list[str]:
    """Apply provider/model quirks to otherwise valid reasoning effort levels."""
    normalized = [
        str(eff).strip().lower()
        for eff in efforts
        if str(eff).strip().lower() in VALID_REASONING_EFFORTS
    ]
    normalized = list(dict.fromkeys(normalized))
    provider = _resolve_provider_alias(str(provider_id or "").strip().lower())
    bare = _strip_provider_hint_for_reasoning(model_id).lower().rsplit("/", 1)[-1]
    # OpenAI-family lanes cap pre-GPT-5.6 GPT-5 models at xhigh and o-series at
    # high. GPT-5.6's alias and Sol/Terra/Luna variants natively accept max.
    if provider in _OPENAI_FAMILY_REASONING_PROVIDERS:
        if bare.startswith(("o1", "o3", "o4")):
            return [eff for eff in normalized if eff in {"low", "medium", "high"}]
        if bare.startswith("gpt-5") and not _is_gpt_5_6_reasoning_model(bare):
            return [eff for eff in normalized if eff != "max"]
    # Providers whose native ladder tops out below 'max' must NOT advertise it,
    # otherwise a stored/CLI 'max' degrades WORSE than the
    # prior max->xhigh coercion (Gemini's adapter treats unknown 'max' as medium;
    # pre-adaptive Anthropic manual-thinking lacks a 'max' budget and falls to 8k).
    # Dropping 'max' here lets the existing downgrade ladder land on xhigh/high.
    if provider in {"gemini", "google", "google-gemini", "google-vertex", "vertex"}:
        return [eff for eff in normalized if eff != "max"]
    # Legacy Claude is pre-adaptive whether served natively OR via Azure Foundry /
    # Bedrock / Vertex — the ceiling follows the MODEL, not just the provider name.
    _anthropic_lanes = {
        "anthropic", "claude", "anthropic-claude",
        "azure-foundry", "azure-openai", "azure", "bedrock", "aws-bedrock",
        "vertex", "google-vertex",
    }
    if provider in _anthropic_lanes and "claude" in bare and _is_pre_adaptive_anthropic(bare):
        return [eff for eff in normalized if eff != "max"]
    # Z.AI / GLM native-endpoint gate: see _zai_glm_reasoning_efforts_supported.
    # True → keep the full ladder (GLM-5.2+); False → strip it entirely (pre-5.2
    # GLM and forced-thinking GLM-4.7); None → not a zai GLM case, defer.
    zai_supports = _zai_glm_reasoning_efforts_supported(model_id, provider_id)
    if zai_supports is True:
        return normalized
    if zai_supports is False:
        return []
    return normalized


_KNOWN_REASONING_PROVIDERS = frozenset({
    "anthropic", "claude", "anthropic-claude",
    "openai", "openai-api", "openai-codex",
    "azure", "azure-openai", "azure-foundry",
    "bedrock", "aws-bedrock", "vertex", "google-vertex",
    "gemini", "google", "google-gemini",
    "deepseek", "x-ai", "xai", "grok",
    "copilot", "github-copilot", "openrouter",
})


def _provider_known_reasoning_capable(provider_id) -> bool:
    """True if the provider is one we recognize as reasoning-capable.

    Used to gate the 'max' default-deny: for a RECOGNIZED provider whose specific
    model we couldn't resolve (empty capability list), preserve 'max' since those
    providers genuinely support it; for a truly unknown/custom provider, degrade
    'max' -> 'xhigh' so we never send a supra-ceiling level that would 400.
    """
    prov = _resolve_provider_alias(str(provider_id or "").strip().lower())
    return prov in _KNOWN_REASONING_PROVIDERS


def _is_pre_adaptive_anthropic(bare_model: str) -> bool:
    """True for Claude models that predate the adaptive-thinking (4.6+) generation.

    Adaptive models (Opus/Sonnet 4.6+, 4.7, …) accept 'max'; earlier manual-thinking
    Claudes (3.x and 4.0–4.5) do not and must degrade 'max' to xhigh rather than
    fall through the manual-thinking budget table to its 8k default.

    Handles the ID shapes the Anthropic adapter uses:
      - claude-3-opus / claude-3-5-sonnet / claude-3-7-sonnet   → pre-adaptive
      - claude-sonnet-4-5 / claude-haiku-4-5                     → pre-adaptive (4.5)
      - claude-sonnet-4-20250514 (date-stamped 4.0 build)        → pre-adaptive
      - claude-opus-4.6 / claude-sonnet-4.6 / claude-opus-4.7    → adaptive
      - claude-opus-latest / unversioned                        → adaptive (flagship)
    """
    import re as _re
    m = (bare_model or "").lower()
    if "claude" not in m:
        return False
    # Claude 3.x family is always pre-adaptive.
    if _re.search(r"claude-3\b", m) or _re.search(r"claude-3[.\-]", m):
        return True
    # Find a major[.-]minor version. A date-stamp (>=6 digits) is NOT a minor
    # version — treat "4-20250514" as major 4 with no real minor (a 4.0 build).
    match = _re.search(r"(\d+)[.\-](\d{1,2})(?!\d)", m)
    if not match:
        # A bare major like "claude-sonnet-4" or "...-4-20250514" (date stamp
        # consumed no minor): major-4 with no minor is a pre-adaptive 4.0 build.
        major_only = _re.search(r"[-.](\d+)(?:[-.]\d{6,})?(?:\b|-)", m)
        if major_only:
            return int(major_only.group(1)) < 5  # 4.x (no minor) → pre-adaptive; ≥5 → adaptive
        # Unversioned / *-latest → treat as current flagship (adaptive).
        return False
    major, minor = int(match.group(1)), int(match.group(2))
    return (major, minor) < (4, 6)


def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]:
    """Fallback when hermes_cli is unavailable."""
    model = _strip_provider_hint_for_reasoning(model_id).lower()
    provider = _resolve_provider_alias(str(provider_id or "").strip().lower())
    if not model or provider in {"cursor-acp", "copilot-acp"}:
        return []
    bare = model.rsplit("/", 1)[-1]
    if provider == "openai-codex" and bare.startswith(("gpt-5", "o1", "o3", "o4")):
        if bare.startswith(("o1", "o3", "o4")):
            return ["low", "medium", "high"]
        return _filter_reasoning_efforts_for_provider(
            list(VALID_REASONING_EFFORTS), model, provider
        )
    if provider in {"copilot", "github-copilot"}:
        if bare.startswith(("gpt-5", "o1", "o3", "o4")):
            if bare.startswith(("o1", "o3", "o4")):
                return ["low", "medium", "high"]
            return list(VALID_REASONING_EFFORTS)
    prefixes = (
        "deepseek/",
        "anthropic/",
        "openai/",
        "x-ai/",
        "google/gemini-2",
        "google/gemma-4",
        "qwen/qwen3",
        "tencent/hy3-preview",
        "xiaomi/",
    )
    if any(model.startswith(prefix) for prefix in prefixes):
        return list(VALID_REASONING_EFFORTS)
    if _nested_gateway_route_reasoning(model):
        return list(VALID_REASONING_EFFORTS)
    # Named custom providers often rewrite model ids with dots, underscores, or
    # extra vendor namespaces. Normalize those shapes before applying family-level
    # reasoning heuristics so "deepseek.v3.2", "deepseek_v4_flash", and
    # "vendor.deepseek.v3.2" are treated consistently.
    if any(_candidate_supports_reasoning(candidate) for candidate in _reasoning_name_candidates(bare)):
        return list(VALID_REASONING_EFFORTS)
    return []


def _models_dev_reasoning_efforts(model_id: str, provider_id: str) -> list[str] | None:
    """Return reasoning efforts from Hermes Agent model metadata when known.

    ``None`` means the metadata source is unavailable or has no answer, so the
    caller should continue to compatibility fallbacks. A concrete list (including
    ``[]``) is authoritative.
    """
    model = _strip_provider_hint_for_reasoning(model_id)
    provider = str(provider_id or "").strip().lower()
    if not model or not provider:
        return None

    try:
        from agent.models_dev import get_model_capabilities
    except Exception:
        return None

    try:
        capabilities = get_model_capabilities(provider=provider, model=model)
    except Exception:
        return None
    if capabilities is None:
        return None

    supports_reasoning = getattr(capabilities, "supports_reasoning", None)
    if supports_reasoning is True:
        return _filter_reasoning_efforts_for_provider(
            list(VALID_REASONING_EFFORTS), model, provider
        )
    if supports_reasoning is False:
        return []
    return None


class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
    """urllib redirect handler that refuses to follow any redirect.

    Used by the LM Studio reasoning probe so a 3xx from the probe URL can never
    forward the ``Authorization`` header (the configured LM Studio key) to a
    redirected, possibly attacker-controlled host. ``redirect_request``
    returning ``None`` makes urllib raise the original 3xx as an ``HTTPError``,
    which the probe swallows. (#3837 security review)
    """

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def _get_lmstudio_reasoning_probe_api_key() -> str | None:
    """Resolve the LM Studio key for reasoning probes with WebUI precedence."""
    config_data = cfg
    model_cfg = config_data.get("model") or {}
    if isinstance(model_cfg, dict):
        active_provider = str(model_cfg.get("provider") or "").strip().lower()
        model_key = str(model_cfg.get("api_key") or "").strip()
        if active_provider == "lmstudio" and model_key:
            return model_key

    providers_cfg = config_data.get("providers") or {}
    if isinstance(providers_cfg, dict):
        lmstudio_cfg = providers_cfg.get("lmstudio") or {}
        if isinstance(lmstudio_cfg, dict):
            config_key = str(lmstudio_cfg.get("api_key") or "").strip()
            if config_key:
                return config_key

    env_key = str(os.getenv("LM_API_KEY") or "").strip()
    if env_key:
        return env_key

    legacy_env_key = str(os.getenv("LMSTUDIO_API_KEY") or "").strip()
    if legacy_env_key:
        return legacy_env_key

    return None


def _lmstudio_reasoning_probe_options_fallback(
    model: str,
    base_url: str | None,
    *,
    api_key: str | None = None,
    timeout: float = 5.0,
) -> list[str]:
    """Query LM Studio reasoning options without relying on hermes_cli."""
    server_root = str(base_url or "").strip().rstrip("/")
    if server_root.endswith("/v1"):
        server_root = server_root[:-3].rstrip("/")
    if not server_root or not model:
        return []

    headers = {
        "Accept": "application/json",
        "User-Agent": "hermes-webui-reasoning-probe",
    }
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"

    request = urllib.request.Request(
        server_root + "/api/v1/models",
        headers=headers,
        method="GET",
    )
    # SECURITY: never follow redirects on this probe. urllib re-sends request
    # headers (including Authorization: Bearer <lmstudio key>) to the redirect
    # target, so a 3xx from the probe URL could exfiltrate the configured
    # LM Studio credential to an attacker-controlled host. A no-redirect opener
    # turns any 3xx into an HTTPError we swallow below. (#3837 security review)
    opener = urllib.request.build_opener(_NoRedirectHandler)
    try:
        with opener.open(request, timeout=timeout) as response:  # nosec B310
            payload = json.loads(response.read().decode("utf-8", errors="replace"))
    except urllib.error.HTTPError as exc:
        logger.debug(
            "LM Studio reasoning probe at %s failed with HTTP %s",
            server_root,
            exc.code,
        )
        return []
    except Exception as exc:
        logger.debug("LM Studio reasoning probe at %s failed: %s", server_root, exc)
        return []

    raw_models = payload.get("models") if isinstance(payload, dict) else None
    if not isinstance(raw_models, list):
        logger.debug(
            "LM Studio reasoning probe at %s returned malformed payload",
            server_root,
        )
        return []

    for raw in raw_models:
        if not isinstance(raw, dict):
            continue
        if raw.get("key") != model and raw.get("id") != model:
            continue
        caps = raw.get("capabilities")
        reasoning = caps.get("reasoning") if isinstance(caps, dict) else None
        opts = reasoning.get("allowed_options") if isinstance(reasoning, dict) else None
        if isinstance(opts, list):
            return [str(opt).strip().lower() for opt in opts if isinstance(opt, str)]
        return []
    return []


def _lmstudio_model_reasoning_options(
    model: str,
    base_url: str | None,
    *,
    api_key: str | None = None,
    timeout: float = 5.0,
) -> list[str]:
    """Prefer hermes_cli, but keep WebUI reasoning probes working without it.

    SECURITY: when an ``api_key`` is being sent, always use the built-in
    no-redirect fallback probe rather than ``hermes_cli``. The bundled CLI probe
    uses a plain ``urllib.request.urlopen`` that follows redirects and re-sends
    the ``Authorization`` header to the redirect target, which could leak the
    configured LM Studio credential to another host. We can only guarantee
    redirect safety for code we control, so credentialed probes go through
    ``_lmstudio_reasoning_probe_options_fallback`` (no-redirect opener). Keyless
    probes have no credential to leak, so they may use the richer CLI path.
    (#3837 security review)
    """
    if api_key:
        return _lmstudio_reasoning_probe_options_fallback(
            model,
            base_url,
            api_key=api_key,
            timeout=timeout,
        )

    try:
        from api.agent_compat import agent_attr

        _cli_lmstudio_model_reasoning_options = agent_attr(
            "hermes_cli.models", "lmstudio_model_reasoning_options", "hermes_cli.models_local"
        )
    except Exception:
        return _lmstudio_reasoning_probe_options_fallback(
            model,
            base_url,
            api_key=api_key,
            timeout=timeout,
        )

    try:
        return _cli_lmstudio_model_reasoning_options(
            model,
            base_url,
            api_key=api_key,
            timeout=timeout,
        )
    except (TypeError, AttributeError):
        logger.warning(
            "hermes_cli.lmstudio_model_reasoning_options has an unexpected signature; "
            "falling back to the built-in LM Studio reasoning probe",
            exc_info=True,
        )
        return _lmstudio_reasoning_probe_options_fallback(
            model,
            base_url,
            api_key=api_key,
            timeout=timeout,
        )
    except Exception:
        return _lmstudio_reasoning_probe_options_fallback(
            model,
            base_url,
            api_key=api_key,
            timeout=timeout,
        )


def resolve_model_reasoning_efforts(
    model_id: str | None = None,
    provider_id: str | None = None,
    base_url: str | None = None,
) -> list[str]:
    """Return supported reasoning-effort levels for *model_id*, or [] if none.

    Always passes the sourced list through _filter_reasoning_efforts_for_provider
    so the hard provider ceilings (OpenAI-family GPT-5 before 5.6 at xhigh and
    o-series at high; Gemini + pre-adaptive/cloud-hosted Claude at xhigh) are
    applied uniformly. The UI dropdown and coercion therefore agree: ``max`` is
    retained for GPT-5.6 and other models whose native ladder includes it, and
    stripped where it would be rejected or mishandled.
    """
    raw = _resolve_model_reasoning_efforts_impl(model_id, provider_id, base_url)
    if not raw:
        return raw
    # Forced-thinking models (GLM-4.7 on native zai) cannot have reasoning
    # disabled, so the 'none' sentinel must NOT appear in their supported list —
    # otherwise the UI offers an "off" option that has no effect and contradicts
    # the forced-tier contract. (#6219 round-3)
    if _zai_glm_classification(model_id, provider_id) == "forced":
        return []
    # Preserve any explicit 'none' sentinel (valid UI option = "no reasoning");
    # the ceiling filter only knows the reasoning LEVELS.
    had_none = "none" in raw
    filtered = _filter_reasoning_efforts_for_provider(
        [e for e in raw if e != "none"], str(model_id or ""), str(provider_id or "")
    )
    if had_none:
        # Keep 'none' in its original leading position if it was there.
        return ["none", *filtered] if raw and raw[0] == "none" else [*filtered, "none"]
    return filtered


def _configured_reasoning_effort_lists(provider_entry, model_id: str) -> list:
    """Return model-level then provider-level effort lists from config."""
    if not isinstance(provider_entry, dict):
        return []

    configured_lists = []
    models = provider_entry.get("models")
    if isinstance(models, dict):
        model_key = str(model_id or "").strip().lower()
        model_entry = models.get(model_id)
        if not isinstance(model_entry, dict) and model_key:
            model_entry = next(
                (
                    metadata
                    for configured_id, metadata in models.items()
                    if str(configured_id).strip().lower() == model_key
                    and isinstance(metadata, dict)
                ),
                None,
            )
        if isinstance(model_entry, dict):
            configured_lists.append(model_entry.get("reasoning_efforts"))

    configured_lists.append(provider_entry.get("reasoning_efforts"))
    return configured_lists


def _resolve_model_reasoning_efforts_impl(
    model_id: str | None = None,
    provider_id: str | None = None,
    base_url: str | None = None,
) -> list[str]:
    """Return supported reasoning-effort levels for *model_id*, or [] if none."""
    model = str(model_id or "").strip()
    if not model:
        return []

    provider = str(provider_id or "").strip().lower() if provider_id else ""
    resolved_base_url = str(base_url or "").strip() or None
    if not provider:
        try:
            _, provider, resolved_base_url = resolve_model_provider(model)
        except Exception:
            provider = str((cfg.get("model") or {}).get("provider") or "").strip().lower()

    provider = _resolve_provider_alias(provider)

    # IDE-copilot providers never expose reasoning effort options.
    # Guard early so a stray config entry can't override this.
    if provider in {"cursor-acp", "copilot-acp"}:
        return []

    hinted_model = _strip_provider_hint_for_reasoning(model, provider)

    # Master hides reasoning controls for nested image/embedding routes. Keep
    # that hard deny above provider config so an explicit allowlist cannot
    # re-enable controls for routes that should never expose them.
    if _nested_route_reasoning_denied(hinted_model):
        return []

    # 0. Model/provider config: a models.<model>.reasoning_efforts list takes
    # precedence over its provider-level reasoning_efforts list. Explicit valid
    # config is authoritative — no heuristics or models.dev lookup. Invalid or
    # empty model metadata falls through to the provider list, then heuristics.
    _re_lists = []
    try:
        if provider and provider.startswith("custom:"):
            for _entry in _custom_provider_entries():
                if _custom_provider_slug_from_name(_entry.get("name")) == provider:
                    _re_lists = _configured_reasoning_effort_lists(
                        _entry, hinted_model
                    )
                    break
        elif provider:
            _prov_entry = (cfg.get("providers") or {}).get(provider, {})
            if isinstance(_prov_entry, dict):
                _re_lists = _configured_reasoning_effort_lists(
                    _prov_entry, hinted_model
                )
        for _re_list in _re_lists:
            if isinstance(_re_list, list) and _re_list:
                _filtered = [str(x).strip().lower() for x in _re_list
                             if str(x).strip().lower() in {*VALID_REASONING_EFFORTS, "none"}]
                _filtered = list(dict.fromkeys(_filtered))
                if _filtered:
                    return _filtered
    except Exception:
        pass

    if provider in {"copilot", "github-copilot"}:
        try:
            from hermes_cli.models import github_model_reasoning_efforts
        except Exception:
            return _heuristic_reasoning_efforts(hinted_model, provider)
        return _filter_reasoning_efforts_for_provider(
            github_model_reasoning_efforts(hinted_model), hinted_model, provider
        )

    if provider == "lmstudio":
        configured_base = _get_provider_base_url(provider)
        probe_base = resolved_base_url or configured_base
        # SECURITY: only forward the configured LM Studio credential when the
        # probe target is the configured LM Studio endpoint. /api/reasoning
        # accepts a caller-supplied base_url, so a request could otherwise point
        # the probe at an arbitrary host and harvest the stored key. When the
        # caller supplies a base_url that does not normalize to the configured
        # one, probe it WITHOUT a key. (#3837 security review)
        probe_key: str | None = None
        if not resolved_base_url or (
            configured_base
            and _normalize_base_url_for_match(probe_base)
            == _normalize_base_url_for_match(configured_base)
        ):
            probe_key = _get_lmstudio_reasoning_probe_api_key()
        opts = _lmstudio_model_reasoning_options(
            hinted_model,
            probe_base,
            api_key=probe_key,
        )
        normalized = [str(opt).strip().lower() for opt in opts if str(opt).strip()]
        if not normalized or set(normalized).issubset({"off"}):
            return []
        level_opts = [opt for opt in normalized if opt in VALID_REASONING_EFFORTS]
        if level_opts:
            return _filter_reasoning_efforts_for_provider(
                level_opts, hinted_model, provider
            )
        if set(normalized).issubset({"off", "on"}):
            return []
        return []

    # _models_dev_reasoning_efforts already applies the provider/model filter
    # internally, so it is returned as-is here (filtering again would be
    # redundant — the filter is idempotent but the double pass obscures flow).
    metadata_efforts = _models_dev_reasoning_efforts(hinted_model, provider)
    if metadata_efforts is not None:
        return metadata_efforts

    return _heuristic_reasoning_efforts(hinted_model, provider)


def coerce_reasoning_effort_for_model(
    effort: str | None,
    model_id: str | None = None,
    provider_id: str | None = None,
    base_url: str | None = None,
) -> str:
    """Return the closest supported effort for the target model/provider."""
    raw = str(effort or "").strip().lower()
    if not raw:
        return ""
    # Forced-thinking models (GLM-4.7 on native zai) cannot have reasoning
    # disabled at all — a stored 'none' must coerce to '' (provider default =
    # thinking on) so streaming does not build disabled reasoning for a model
    # that forces thinking on regardless. Checked BEFORE the generic 'none'
    # early-return below so the forced-tier contract wins. (#6219 round-3)
    if raw == "none" and _zai_glm_classification(model_id, provider_id) == "forced":
        return ""
    if raw == "none":
        return "none"
    if raw not in VALID_REASONING_EFFORTS:
        return ""
    supported = resolve_model_reasoning_efforts(
        model_id,
        provider_id=provider_id,
        base_url=base_url,
    )
    # Hard provider ceilings must win regardless of what the sourced capability
    # list says. resolve_model_reasoning_efforts() draws from hermes_cli /
    # models.dev / heuristics, and those can (a) return [] for an unrecognized
    # model or (b) wrongly advertise 'max' for a provider
    # whose native ladder tops out lower. _filter_reasoning_efforts_for_provider
    # encodes the known ceilings (OpenAI-family GPT-5 before 5.6, Gemini, and
    # pre-adaptive Anthropic all cap below 'max'); if it actively EXCLUDES the
    # requested level, honor that ceiling and degrade down the ladder even when
    # the sourced list is empty or (mistakenly) includes the level. This keeps a
    # stored/CLI 'max' from reaching an adapter that would silently downgrade it
    # worse than xhigh/high (Gemini→medium, legacy Claude manual-thinking→8k).
    # GPT-5.6 is intentionally not capped. For providers with NO ceiling rule the
    # filter returns the full list unchanged, so genuinely unknown models still
    # preserve the configured effort (#3505 behavior).
    ceiling = _filter_reasoning_efforts_for_provider(
        list(VALID_REASONING_EFFORTS), str(model_id or ""), str(provider_id or "")
    )
    if ceiling and raw not in ceiling:
        ladder = list(VALID_REASONING_EFFORTS)  # ascending: minimal..xhigh..max
        try:
            raw_idx = ladder.index(raw)
        except ValueError:
            raw_idx = None
        if raw_idx is not None:
            for level in reversed(ladder[:raw_idx]):  # strictly lower, highest first
                if level in ceiling:
                    return level
    # An empty list is ambiguous: resolve_model_reasoning_efforts() returns []
    # both for models KNOWN not to support reasoning AND for models we simply
    # don't recognize (custom providers, aggregator-rewritten ids, brand-new
    # releases). Coercion exists to avoid sending a level a KNOWN-incompatible
    # model rejects (e.g. pre-5.6 GPT-5 'max', o1/o3/o4 above 'high') -
    # those paths return a NON-empty clamped set, so the degrade ladder below
    # still applies. When the set is empty we can't tell "unsupported" from
    # "unknown", so preserve the user's configured effort verbatim where it is
    # still valid. (#3505 review)
    #
    # EXCEPTION for 'max' (the #3505 default-deny refinement, maintainer call
    # 2026-07-11): 'max' is ABOVE the universally-safe ceiling 'xhigh'. A
    # genuinely unknown/custom provider will 400 on it. So when the
    # capability list is empty AND the provider is not one we recognize as
    # reasoning-capable, degrade 'max' -> 'xhigh' rather than send an unsupported
    # supra-ceiling level. But do NOT degrade for a RECOGNIZED reasoning provider
    # whose specific model id we simply couldn't resolve (e.g. claude-opus-latest,
    # a brand-new adaptive id) — those genuinely support 'max', and the ceiling
    # filter above already stripped it for any KNOWN-capped model. All other
    # levels (minimal..xhigh) keep the conservative preserve-verbatim behavior.
    #
    # EXCEPTION for the ZAI native-endpoint gate: a pre-5.2 GLM model (incl. the
    # forced-thinking GLM-4.7) is KNOWN not to accept reasoning_effort at all, so
    # any stored level must coerce to "" (send no field) — NOT be preserved
    # verbatim, which Z.AI would silently ignore. This keeps the value actually
    # sent in agreement with the UI (which offers no options for these models).
    if not supported:
        if _zai_glm_reasoning_efforts_supported(model_id, provider_id) is False:
            return ""
        if raw == "max" and not _provider_known_reasoning_capable(provider_id):
            return "xhigh"
        return raw
    if raw in supported:
        return raw
    # Degrade to the closest *lower* supported level instead of silently
    # disabling reasoning. e.g. max -> xhigh -> high, or xhigh -> high when the
    # target model caps below the configured effort. Never escalate.
    ladder = list(VALID_REASONING_EFFORTS)  # ascending: minimal..xhigh..max
    try:
        raw_idx = ladder.index(raw)
    except ValueError:
        return raw
    for level in reversed(ladder[:raw_idx]):  # strictly lower, highest first
        if level in supported:
            return level
    # raw is below every supported level (shouldn't happen for a non-empty set
    # that excludes raw, but be safe): preserve the configured effort rather
    # than blank it.
    return raw


def get_reasoning_status(
    *,
    model_id: str | None = None,
    provider_id: str | None = None,
    base_url: str | None = None,
) -> dict:
    """Return current reasoning configuration from the active profile's
    config.yaml — the same source of truth the CLI reads from.

    Keys:
      - show_reasoning: bool — from ``display.show_reasoning`` (default True)
      - reasoning_effort: str — from ``agent.reasoning_effort`` ('' = default)
    """
    config_data = _load_yaml_config_file(_get_config_path())
    display_cfg = config_data.get("display") or {}
    agent_cfg = config_data.get("agent") or {}
    show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None
    effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None

    resolve_model = model_id
    resolve_provider = provider_id
    resolve_base_url = base_url
    if not resolve_model:
        model_cfg = config_data.get("model") or {}
        if isinstance(model_cfg, dict):
            resolve_model = str(model_cfg.get("default") or "").strip() or None
            if not resolve_provider and model_cfg.get("provider"):
                resolve_provider = str(model_cfg["provider"]).strip()
            if not resolve_base_url and model_cfg.get("base_url"):
                resolve_base_url = str(model_cfg["base_url"]).strip()

    supported_efforts = resolve_model_reasoning_efforts(
        resolve_model,
        provider_id=resolve_provider,
        base_url=resolve_base_url,
    )
    # supports_thinking_toggle: can the user turn thinking on/off at all? An
    # effort-capable model obviously can. The ZAI gate separately exposes the
    # toggle for GLM-4.5–5.1 (which accept `thinking: {"type": ...}` but NOT the
    # `reasoning_effort` ladder), so the composer still renders an On/None control
    # when supported_efforts is empty. Without this, returning [] for those models
    # would hide the entire reasoning chip and silently regress the working
    # thinking on/off control (#6219 round-2 review).
    zai_thinking = _zai_glm_thinking_toggle_supported(
        resolve_model, resolve_provider
    )
    supports_thinking_toggle = bool(supported_efforts) or (zai_thinking is True)
    return {
        # Match CLI default (True if unset in config.yaml)
        "show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True,
        # Report the COERCED effort so boot/status/chip read paths agree with
        # what streaming actually sends. (Codex review of the drop-max alignment.)
        "reasoning_effort": coerce_reasoning_effort_for_model(
            str(effort_raw or "").strip().lower(),
            resolve_model,
            provider_id=resolve_provider,
            base_url=resolve_base_url,
        ),
        "supported_efforts": supported_efforts,
        "supports_reasoning_effort": bool(supported_efforts),
        # Whether the composer should render ANY reasoning control. True for any
        # effort-capable model OR a ZAI GLM model that accepts the thinking
        # toggle but not the effort ladder. False hides the chip entirely.
        "supports_thinking_toggle": supports_thinking_toggle,
    }


def _parse_positive_int_config_value(raw) -> int | None:
    if raw is None:
        return None
    try:
        parsed = int(raw)
    except (TypeError, ValueError):
        return None
    return parsed if parsed > 0 else None


def get_max_tokens_status() -> dict[str, int | None]:
    """Return the Settings-facing max_tokens state from the active profile config.

    ``max_tokens`` is the root override the Settings field owns directly.
    ``max_tokens_fallback`` is the agent-level fallback when the root config
    resolves to ``None``, matching the streaming path exactly.
    ``max_tokens_effective`` is the runtime cap a new streaming turn would
    currently use.
    """
    config_data = _load_yaml_config_file(_get_config_path())
    if not isinstance(config_data, dict):
        return {
            "max_tokens": None,
            "max_tokens_effective": None,
            "max_tokens_fallback": None,
        }

    raw_root_value = config_data.get("max_tokens")
    root_value = _parse_positive_int_config_value(raw_root_value)

    fallback_value = None
    if raw_root_value is None:
        agent_cfg = config_data.get("agent")
        if isinstance(agent_cfg, dict):
            fallback_value = _parse_positive_int_config_value(agent_cfg.get("max_tokens"))

    effective_value = root_value if root_value is not None else fallback_value
    return {
        "max_tokens": root_value,
        "max_tokens_effective": effective_value,
        "max_tokens_fallback": fallback_value,
    }


def set_max_tokens(max_tokens) -> dict[str, int | None]:
    """Persist a root-level ``max_tokens`` override to the active profile config.

    Blank/``None`` clears the root override so ``agent.max_tokens`` can resume.
    Positive integers are written to the active profile's ``config.yaml``.
    Unrelated YAML keys are preserved verbatim.
    """
    if isinstance(max_tokens, str):
        max_tokens = max_tokens.strip()
    clear_root = max_tokens in (None, "")
    parsed_max_tokens = _parse_positive_int_config_value(max_tokens)
    if not clear_root and parsed_max_tokens is None:
        return get_max_tokens_status()

    config_path = _get_config_path()
    should_save = True
    with _cfg_lock:
        config_data = _load_yaml_config_file_raw(config_path)
        if clear_root:
            if "max_tokens" not in config_data:
                should_save = False
            else:
                config_data.pop("max_tokens", None)
        elif parsed_max_tokens is not None:
            config_data["max_tokens"] = parsed_max_tokens
        if should_save:
            _save_yaml_config_file(config_path, config_data)
    if not should_save:
        return get_max_tokens_status()
    reload_config()
    return get_max_tokens_status()


def set_reasoning_display(show: bool) -> dict:
    """Persist ``display.show_reasoning`` to the active profile's config.yaml.

    Mirrors CLI ``/reasoning show|hide``: writes the same key that the CLI
    writes, so the preference is shared across the WebUI and the terminal
    REPL for the same profile.
    """
    config_path = _get_config_path()
    with _cfg_lock:
        config_data = _load_yaml_config_file(config_path)
        display_cfg = config_data.get("display")
        if not isinstance(display_cfg, dict):
            display_cfg = {}
        display_cfg["show_reasoning"] = bool(show)
        config_data["display"] = display_cfg
        _save_yaml_config_file(config_path, config_data)
    reload_config()
    return get_reasoning_status()


def set_reasoning_effort(
    effort: str,
    *,
    model_id: str | None = None,
    provider_id: str | None = None,
    base_url: str | None = None,
) -> dict:
    """Persist ``agent.reasoning_effort`` to the active profile's config.yaml.

    Mirrors CLI ``/reasoning <level>``: same key, same valid values
    (``none`` | ``minimal`` | ``low`` | ``medium`` | ``high`` | ``xhigh`` | ``max``).

    An empty string is accepted as "clear the override" — it removes the
    ``agent.reasoning_effort`` key so the provider default takes effect. This is
    the re-enable path for thinking-toggle-only models (GLM-4.5–5.1 on native
    zai): the dropdown's "Default"/"On" option POSTs ``effort:''`` to switch
    thinking back on after the user selected "None". Without this, the toggle
    would be one-way (off-only) for those models. (#6219 round-3)

    Raises ``ValueError`` on any other unrecognised level so callers can 400.
    """
    raw = str(effort or "").strip().lower()
    if raw and raw != "none" and raw not in VALID_REASONING_EFFORTS:
        raise ValueError(
            f"Unknown reasoning effort '{effort}'. "
            f"Valid: none, {', '.join(VALID_REASONING_EFFORTS)}."
        )
    config_path = _get_config_path()
    with _cfg_lock:
        config_data = _load_yaml_config_file(config_path)
        agent_cfg = config_data.get("agent")
        if not isinstance(agent_cfg, dict):
            agent_cfg = {}
        if raw:
            agent_cfg["reasoning_effort"] = raw
        else:
            # Clear the override so the provider default takes effect (the
            # "Default"/"On" re-enable path for thinking-toggle-only models).
            # Drop the key entirely rather than writing an empty string so the
            # CLI's "is reasoning_effort configured?" check stays simple.
            agent_cfg.pop("reasoning_effort", None)
        config_data["agent"] = agent_cfg
        _save_yaml_config_file(config_path, config_data)
    reload_config()
    return get_reasoning_status(
        model_id=model_id,
        provider_id=provider_id,
        base_url=base_url,
    )


def _public_advanced_model_options(model_cfg: dict) -> dict:
    """Return write-only-safe advanced options from a model config block."""
    if not isinstance(model_cfg, dict):
        model_cfg = {}
    return {
        "base_url": str(model_cfg.get("base_url") or "").strip(),
        "timeout": model_cfg.get("timeout", ""),
        "download_timeout": model_cfg.get("download_timeout", ""),
        "max_concurrency": model_cfg.get("max_concurrency", ""),
        "extra_body": model_cfg.get("extra_body") if isinstance(model_cfg.get("extra_body"), dict) else {},
        "api_key_set": bool(str(model_cfg.get("api_key") or "").strip()),
    }


def _is_openai_family_provider(provider: str | None) -> bool:
    """Return True when a provider should receive OpenAI-family request overrides."""
    if not provider:
        return False
    resolved = str(_resolve_provider_alias(str(provider).strip().lower()))
    return resolved in ("openai", "openai-api", "openai-codex")


def _normalize_openai_family_model_id(model_id: str | None) -> str:
    """Return a model id in the form expected by hermes_cli fast-mode resolution."""
    model = str(model_id or "").strip()
    if not model:
        return ""

    if model.startswith("@") and ":" in model:
        model = model.split(":", 1)[1].strip()

    if "://" in model:
        return model

    if "/" in model:
        provider_hint, candidate = model.split("/", 1)
        if provider_hint.strip().lower() in {"openai", "openai-api", "openai-codex"}:
            model = candidate.strip()
        else:
            return ""

    return model


def _legacy_openai_service_tier_overrides(model_id: str | None, provider: str | None) -> dict:
    """Compatibility fallback for standalone WebUI installs without hermes_cli.

    Normal operation delegates to Hermes Agent model metadata.  This fallback
    preserves the old WebUI behavior when the agent package is unavailable,
    while still failing closed for codex model slugs and foreign provider IDs.
    """
    if not _is_openai_family_provider(provider):
        return {}
    resolved_provider = str(_resolve_provider_alias(str(provider or "").strip().lower()))
    raw_model = str(model_id or "").strip()
    if "://" not in raw_model and "/" in raw_model:
        provider_hint = raw_model.split("/", 1)[0].strip().lower()
        if provider_hint not in {"openai", "openai-api", "openai-codex"}:
            return {}
    normalized_model = _normalize_openai_family_model_id(model_id)
    if not normalized_model:
        if resolved_provider == "openai-codex":
            return {}
        return {"service_tier": "priority"}
    lowered = normalized_model.lower()
    if "codex" in lowered:
        return {}
    if lowered.startswith(("gpt-", "o1", "o3", "o4")):
        return {"service_tier": "priority"}
    return {}


def _resolve_main_model_fast_mode_overrides(model_id: str | None, provider: str | None = None) -> dict:
    """Return provider request overrides for the main model fast-mode setting."""
    normalized_model = _normalize_openai_family_model_id(model_id)
    if not normalized_model:
        return _legacy_openai_service_tier_overrides(model_id, provider)
    try:
        from hermes_cli.models import resolve_fast_mode_overrides
    except Exception:
        logger.debug("Failed to import hermes_cli.models.resolve_fast_mode_overrides; using WebUI compatibility fallback.")
        return _legacy_openai_service_tier_overrides(model_id, provider)
    try:
        resolved = resolve_fast_mode_overrides(normalized_model)
    except Exception:
        logger.debug("Failed to resolve fast-mode overrides for %r; using WebUI compatibility fallback.", normalized_model)
        return _legacy_openai_service_tier_overrides(model_id, provider)
    return resolved if isinstance(resolved, dict) else {}


def _main_model_supports_service_tier(
    model_id: str | None,
    provider: str | None,
) -> bool:
    """Return True when the current main-model selection can use OpenAI service tier."""
    if not _is_openai_family_provider(provider):
        return False
    return (
        str(_resolve_main_model_fast_mode_overrides(model_id, provider).get("service_tier", "")).strip().lower()
        == "priority"
    )


def _model_supports_fast_tier_for_provider(model_id: str | None, provider: str | None) -> bool:
    """Return whether a provider/model entry supports WebUI's service-tier toggle."""
    return _main_model_supports_service_tier(model_id, provider)


def _annotate_fast_tier_model_groups(payload: dict | None) -> dict | None:
    """Add service-tier capability metadata to OpenAI-family model groups."""
    if not isinstance(payload, dict):
        return payload
    groups = payload.get("groups")
    if not isinstance(groups, list):
        return payload
    for group in groups:
        if not isinstance(group, dict):
            continue
        provider_id = str(group.get("provider_id") or "").strip()
        if not _is_openai_family_provider(provider_id):
            continue
        for bucket in ("models", "extra_models"):
            models = group.get(bucket)
            if not isinstance(models, list):
                continue
            for model in models:
                if not isinstance(model, dict):
                    continue
                model_id = str(model.get("id") or "").strip()
                if model_id:
                    model["supports_fast_tier"] = _model_supports_fast_tier_for_provider(model_id, provider_id)
    return payload


def _public_main_service_tier(model_cfg: dict) -> str:
    """Return the saved main-model service tier only for OpenAI-family providers."""
    if not isinstance(model_cfg, dict):
        return ""
    model_id = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
    provider = str(model_cfg.get("provider") or "").strip().lower()
    if not provider:
        _, provider, _ = resolve_model_provider(model_id)
    if not _main_model_supports_service_tier(model_id, provider):
        return ""
    service_tier = str(model_cfg.get("service_tier") or "").strip().lower()
    return "priority" if service_tier == "priority" else ""


def _main_model_request_overrides(
    config_data: dict,
    effective_model: str | None = None,
    effective_provider: str | None = None,
) -> dict:
    """Return supported runtime request overrides for the main chat model.

    When *effective_model* / *effective_provider* are supplied, the
    service-tier gate checks those instead of the saved default model,
    so a per-session model switch to a non-OpenAI provider does not
    leak ``service_tier`` onto an unsupported request.
    """
    if not isinstance(config_data, dict):
        return {}
    model_cfg = config_data.get("model", {})
    if not isinstance(model_cfg, dict):
        return {}
    overrides = {}
    gate_model = effective_model
    gate_provider = effective_provider
    if not gate_model:
        gate_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
    if not gate_provider:
        gate_provider = str(model_cfg.get("provider") or "").strip().lower()
        if not gate_provider:
            _, gate_provider, _ = resolve_model_provider(gate_model)
    if _main_model_supports_service_tier(gate_model, gate_provider):
        service_tier = str(model_cfg.get("service_tier") or "").strip().lower()
        if service_tier == "priority":
            overrides["service_tier"] = "priority"
    extra_body = model_cfg.get("extra_body")
    if isinstance(extra_body, dict) and extra_body:
        overrides["extra_body"] = copy.deepcopy(extra_body)
    return overrides


def _apply_advanced_model_options(model_cfg: dict, advanced: dict | None) -> None:
    """Apply supported advanced model options to a config block in-place."""
    if advanced is None:
        return
    if not isinstance(advanced, dict):
        raise ValueError("advanced model options must be an object")
    if "base_url" in advanced:
        base_url = str(advanced.get("base_url") or "").strip().rstrip("/")
        if base_url:
            model_cfg["base_url"] = base_url
        else:
            model_cfg.pop("base_url", None)
    for field in ("timeout", "download_timeout", "max_concurrency"):
        if field in advanced:
            coerced = _coerce_optional_positive_int(advanced.get(field), field)
            if coerced == "":
                model_cfg.pop(field, None)
            elif coerced is not None:
                model_cfg[field] = coerced
    if "extra_body" in advanced:
        extra_body = advanced.get("extra_body")
        if isinstance(extra_body, str):
            text = extra_body.strip()
            try:
                extra_body = json.loads(text) if text else {}
            except json.JSONDecodeError as exc:
                raise ValueError("extra_body must be valid JSON") from exc
        if extra_body in (None, ""):
            model_cfg.pop("extra_body", None)
        elif isinstance(extra_body, dict):
            if extra_body:
                model_cfg["extra_body"] = extra_body
            else:
                model_cfg.pop("extra_body", None)
        else:
            raise ValueError("extra_body must be a JSON object")
    if "service_tier" in advanced:
        service_tier = str(advanced.get("service_tier") or "").strip().lower()
        if not service_tier or service_tier == "default":
            model_cfg.pop("service_tier", None)
        elif service_tier == "priority":
            model_cfg["service_tier"] = "priority"
        else:
            raise ValueError("service_tier must be one of: default, priority")
    if advanced.get("api_key_clear"):
        model_cfg.pop("api_key", None)
    api_key = str(advanced.get("api_key") or "").strip()
    if api_key:
        model_cfg["api_key"] = api_key


def set_hermes_default_model(model_id: str, provider: str | None = None, advanced: dict | None = None) -> dict:
    """Persist the Hermes default model in config.yaml and reload runtime config."""
    selected_model = str(model_id or "").strip()
    if not selected_model:
        raise ValueError("model is required")

    config_path = _get_config_path()
    # Hold _cfg_lock only around the read-modify-write of the YAML file.
    # reload_config() acquires _cfg_lock internally (it's not reentrant) so
    # it must be called AFTER releasing the lock to avoid deadlock.
    with _cfg_lock:
        config_data = _load_yaml_config_file(config_path)
        model_cfg = config_data.get("model", {})
        if not isinstance(model_cfg, dict):
            model_cfg = {}

        previous_provider = str(model_cfg.get("provider") or "").strip()
        requested_provider = str(provider or "").strip()
        resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(
            selected_model
        )
        # Persist the resolved bare/slash form, NOT the `@provider:` prefix. The
        # prefix is a WebUI-internal routing hint that the hermes-agent CLI does
        # not understand — if we wrote `@nous:anthropic/claude-opus-4.6` to
        # config.yaml, a user who ran `hermes` in the terminal right after
        # saving via WebUI would have the agent send that literal string to the
        # Nous API, which would reject it (Nous expects `anthropic/claude-opus-4.6`,
        # not the prefixed form). The Settings picker handles the resulting
        # CLI-shaped bare form via `_applyModelToDropdown()`'s normalising
        # matcher — see `static/panels.js` (#895).
        persisted_model = str(resolved_model or selected_model).strip()
        persisted_provider = str(requested_provider or resolved_provider or previous_provider or "").strip()
        provider_override_won = bool(requested_provider and requested_provider != str(resolved_provider or "").strip())
        # Never persist the bogus ``local`` value — see #1384. The auto-detect
        # block in ``_build_available_models_uncached`` was rewriting unknown
        # loopback hosts to ``provider: "local"``, which is not registered and
        # broke compression/vision mid-conversation. Route through ``custom``
        # so the agent's auxiliary client uses the ``no-key-required`` path.
        if persisted_provider.lower() == "local":
            persisted_provider = "custom"

        model_cfg["default"] = persisted_model
        if persisted_provider:
            model_cfg["provider"] = persisted_provider

        if resolved_base_url and not provider_override_won:
            model_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/")
        elif persisted_provider != previous_provider:
            if persisted_provider == "openai":
                model_cfg["base_url"] = "https://api.openai.com/v1"
            else:
                # Provider changed and we have no resolved URL for the new one.
                # Drop the previous provider's base_url so New Chat doesn't route
                # to the old endpoint — this MUST also cover custom:* providers
                # (a different custom provider has a different URL); leaving the
                # stale base_url sent requests to the wrong host (#4728).
                model_cfg.pop("base_url", None)

        _apply_advanced_model_options(model_cfg, advanced)
        if not _main_model_supports_service_tier(persisted_model, persisted_provider):
            model_cfg.pop("service_tier", None)

        config_data["model"] = model_cfg
        _save_yaml_config_file(config_path, config_data)
    # Reload outside the lock — reload_config() acquires _cfg_lock itself.
    reload_config()
    # Invalidate the TTL cache so the next /api/models call returns fresh data
    # with the new default model. Do NOT call get_available_models() here —
    # it triggers a live provider fetch (up to 8s) that blocks the HTTP response
    # to the browser, causing a visible freeze on every Settings save (#895).
    invalidate_models_cache()
    return {"ok": True, "model": persisted_model, "provider": persisted_provider or None}


# ── Auxiliary model configuration ──────────────────────────────────────────

# Canonical auxiliary task catalog.
# Keep in sync with hermes_cli/config.py DEFAULT_CONFIG["auxiliary"] and
# hermes_cli/web_server.py _AUX_TASK_SLOTS.
AUXILIARY_TASK_CATALOG: tuple[dict[str, str], ...] = (
    {"key": "vision", "label": "Vision", "description": "image/screenshot analysis"},
    {"key": "web_extract", "label": "Web extract", "description": "web page summarization"},
    {"key": "compression", "label": "Compression", "description": "context summarization"},
    {"key": "approval", "label": "Approval", "description": "smart command approval"},
    {"key": "mcp", "label": "MCP", "description": "MCP tool reasoning"},
    {"key": "title_generation", "label": "Title generation", "description": "session titles"},
    {"key": "skills_hub", "label": "Skills hub", "description": "skills search/install"},
    {"key": "curator", "label": "Curator", "description": "skill-usage review pass"},
    {"key": "kanban_decomposer", "label": "Kanban decomposer", "description": "task decomposition"},
    {"key": "profile_describer", "label": "Profile describer", "description": "profile summaries"},
    {"key": "triage_specifier", "label": "Triage specifier", "description": "issue/task triage specs"},
)

AUX_TASK_SLOTS: tuple[str, ...] = tuple(item["key"] for item in AUXILIARY_TASK_CATALOG)

# Slots removed from the WebUI catalog whose persisted assignments should be
# discarded when the user explicitly resets all auxiliary-model routing.
RETIRED_AUX_TASK_SLOTS: tuple[str, ...] = ("session_search",)


def _aux_task_payload(task_key: str, entry: dict, fallback_label: str = "", fallback_description: str = "") -> dict:
    """Build the API payload row for a single auxiliary task."""
    if not isinstance(entry, dict):
        entry = {}
    return {
        "task": task_key,
        "provider": str(entry.get("provider") or "auto").strip() or "auto",
        "model": str(entry.get("model") or "").strip(),
        "base_url": str(entry.get("base_url") or "").strip(),
        "timeout": entry.get("timeout", ""),
        "download_timeout": entry.get("download_timeout", ""),
        "max_concurrency": entry.get("max_concurrency", ""),
        "extra_body": entry.get("extra_body") if isinstance(entry.get("extra_body"), dict) else {},
        "api_key_set": bool(str(entry.get("api_key") or "").strip()),
        "label": fallback_label,
        "description": fallback_description,
    }


def _iter_auxiliary_task_rows() -> list[dict]:
    """Return canonical auxiliary task payload rows."""
    aux_cfg = cfg.get("auxiliary", {})
    if not isinstance(aux_cfg, dict):
        aux_cfg = {}

    rows: list[dict] = []

    # Canonical, first-class tasks from WebUI's catalog.
    for slot in AUXILIARY_TASK_CATALOG:
        key = str(slot["key"]).strip()
        if not key:
            continue
        rows.append(_aux_task_payload(key, aux_cfg.get(key, {}), slot["label"], slot["description"]))

    return rows


def get_auxiliary_models() -> dict:
    """Return current auxiliary task assignments from config.yaml.

    Shape:
    {
        "tasks": [
            {"task": "vision", "provider": "auto", "model": "", "base_url": ""},
            ...
        ],
        "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7", "service_tier": ""},
    }
    """
    reload_config()
    model_cfg = cfg.get("model", {})
    if not isinstance(model_cfg, dict):
        model_cfg = {}
    main_provider = str(model_cfg.get("provider") or "").strip()
    main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()

    tasks = _iter_auxiliary_task_rows()

    return {
        "tasks": tasks,
        "main": {
            "provider": main_provider,
            "model": main_model,
            "supports_fast_tier": _main_model_supports_service_tier(main_model, main_provider),
            "service_tier": _public_main_service_tier(model_cfg),
            **_public_advanced_model_options(model_cfg),
        },
    }


def _coerce_optional_positive_int(value, field: str):
    if value is None:
        return None
    if isinstance(value, str):
        value = value.strip()
        if value == "":
            return ""
    try:
        number = int(value)
    except (TypeError, ValueError) as exc:
        raise ValueError(f"{field} must be a positive integer") from exc
    if number < 1:
        raise ValueError(f"{field} must be a positive integer")
    return number


def _provider_native_auxiliary_model(provider: str, model: str) -> str:
    """Return the provider-native model stored by an auxiliary slot.

    ``@provider:model`` is a WebUI picker routing token. Auxiliary slots already
    store the selected provider separately, so only an exact matching prefix is
    safe to remove. Reject other qualified forms instead of persisting an
    ambiguous upstream model name.
    """
    provider_id = str(provider or "").strip() or "auto"
    model_id = str(model or "").strip()
    if not model_id.startswith("@") or ":" not in model_id:
        return model_id

    matching_prefix = f"@{provider_id}:"
    if provider_id != "auto" and model_id.startswith(matching_prefix):
        native_model = model_id[len(matching_prefix) :]
        if native_model:
            return native_model

    raise ValueError(
        "provider-qualified auxiliary model must match the selected provider "
        "and include a model name"
    )


def set_auxiliary_model(task: str, provider: str, model: str, advanced: dict | None = None) -> dict:
    """Persist an auxiliary model assignment in config.yaml.

    Special case: task='__reset__' clears all auxiliary slots.
    ``advanced`` may update per-slot fields surfaced behind the WebUI gear menu.
    Sensitive api_key values are write-only: get_auxiliary_models() only reports
    whether one is set.
    """
    provider = str(provider or "").strip() or "auto"
    model = str(model or "").strip()
    config_path = _get_config_path()
    with _cfg_lock:
        config_data = _load_yaml_config_file(config_path)
        if task != "__reset__" and task not in AUX_TASK_SLOTS:
            raise ValueError(f"Unknown auxiliary task slot: {task!r}. Valid: {list(AUX_TASK_SLOTS)}")
        if task == "__reset__":
            # Per-slot reset: set each slot to auto, preserving extra fields
            # (timeout, extra_body, api_key, base_url, download_timeout, etc.)
            aux_cfg = config_data.get("auxiliary", {})
            if not isinstance(aux_cfg, dict):
                aux_cfg = {}
            for retired_slot in RETIRED_AUX_TASK_SLOTS:
                aux_cfg.pop(retired_slot, None)
            for slot in AUX_TASK_SLOTS:
                slot_cfg = aux_cfg.get(slot, {})
                if not isinstance(slot_cfg, dict):
                    slot_cfg = {}
                slot_cfg["provider"] = "auto"
                slot_cfg["model"] = ""
                aux_cfg[slot] = slot_cfg
            config_data["auxiliary"] = aux_cfg
        else:
            aux_cfg = config_data.get("auxiliary", {})
            if not isinstance(aux_cfg, dict):
                aux_cfg = {}
            model = _provider_native_auxiliary_model(provider, model)
            slot_cfg = aux_cfg.get(task, {})
            if not isinstance(slot_cfg, dict):
                slot_cfg = {}
            slot_cfg["provider"] = provider
            slot_cfg["model"] = model
            if provider and (provider.startswith("custom:") or provider == "custom"):
                # Resolve the auxiliary slot's base_url against the SELECTED
                # provider, not the active main provider. A bare
                # resolve_model_provider(model) ignores `provider` and routes the
                # model through whatever main provider is active — so when the
                # selected auxiliary provider (custom:A) and the active main
                # provider (custom:B) both list the same model id, the slot was
                # persisted with provider=custom:A but base_url=B's endpoint
                # (overlapping-id misroute, sibling of the resolve_model_provider
                # fix). For a named custom:<slug> selection, look up that
                # provider's OWN custom_providers[] entry directly. Note we do
                # NOT route through model_with_provider_context here: its
                # @custom:<slug>:model form re-resolves against the AMBIENT
                # module-level `cfg`, while this path must read the in-lock
                # `config_data` snapshot it is about to write. Those two can
                # disagree whenever the cache is stale or the profile path
                # changed, and ambient/global resolution while holding the
                # non-reentrant _cfg_lock is exactly what the direct lookup below
                # exists to avoid. (That qualified form DOES resolve a custom
                # entry's own base_url as of the non-active #1806 fix -- the
                # reason to stay direct here is the lock/snapshot, not a URL the
                # qualified path cannot produce.) Fall back to the bare resolve
                # only for the unnamed `custom` case, which has no own entry.
                resolved_base_url = None
                if provider.startswith("custom:"):
                    # Resolve the selected provider's base_url from the
                    # config_data already loaded under _cfg_lock above. Do NOT
                    # call resolve_custom_provider_connection() / get_config()
                    # here: they re-acquire the non-reentrant _cfg_lock we
                    # already hold, self-deadlocking whenever the cache is stale
                    # or the profile path changed. Use the shared uniqueness
                    # helper on the in-scope dict so this slug-only save fails
                    # closed on a collision (raises AmbiguousCustomProviderError)
                    # exactly like every other path — otherwise the ambiguity
                    # would be swallowed and the wrong endpoint persisted.
                    _cp_match = _unique_custom_provider_entry(
                        config_data.get("custom_providers", []),
                        _custom_provider_slug_key(provider),
                    )
                    if _cp_match is not None:
                        resolved_base_url = str(_cp_match.get("base_url") or "").strip() or None
                if not resolved_base_url:
                    # Best-effort fallback for the unnamed `custom` case (no own
                    # entry). Keep it non-fatal for unexpected errors, but let a
                    # genuine ambiguity propagate so the save fails closed.
                    try:
                        _, _, resolved_base_url = resolve_model_provider(model)
                    except AmbiguousCustomProviderError:
                        raise
                    except Exception:
                        resolved_base_url = None
                if resolved_base_url:
                    slot_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/")
            if advanced is not None:
                try:
                    _apply_advanced_model_options(slot_cfg, advanced)
                except ValueError as exc:
                    msg = str(exc).replace("advanced model options", "advanced auxiliary options")
                    raise ValueError(msg) from exc
            aux_cfg[task] = slot_cfg
            config_data["auxiliary"] = aux_cfg

        _save_yaml_config_file(config_path, config_data)

    reload_config()
    return {"ok": True, "task": task, "provider": provider, "model": model}


# ── TTL cache for get_available_models() ─────────────────────────────────────
_available_models_cache: dict | None = None
_available_models_cache_ts: float = 0.0
_available_models_live_rebuild_ts: float = 0.0
_available_models_cache_source_fingerprint: dict | None = None
_AVAILABLE_MODELS_CACHE_TTL: float = 86400.0  # 24 hours
_SESSION_VISIT_MODELS_FRESHNESS_SECONDS: float = 300.0
_available_models_cache_lock = threading.RLock()  # must be RLock: cold path refactoring moved slow work inside this lock, requiring re-entry
_cache_build_cv = threading.Condition(_available_models_cache_lock)  # shares underlying RLock so notify_all() is safe inside with _available_models_cache_lock
_cache_build_in_progress = False  # True while a cold path is actively building

# Memoized (snapshot_ref, {provider_slug: frozenset(model_ids)}) derived from
# the published models-catalog snapshot. Used by _endpoint_advertised_model_ids
# to answer "did this endpoint actually advertise this exact id?" in O(1) per
# send without rebuilding. Keyed on the snapshot object identity so it is
# recomputed exactly once per catalog publish (the cache is replaced wholesale,
# never mutated) and can never serve stale ids from a superseded catalog.
_advertised_model_ids_memo: tuple | None = None

# Atomic provenance pair: an immutable (snapshot, publisher_fingerprint) tuple
# published together at every catalog publish/invalidate site via
# _sync_models_cache_provenance(). The resolver reads THIS single global with one
# lock-free load so it can never observe a torn snapshot/fingerprint pair (the
# two underlying globals are assigned as separate statements). Reading a tuple is
# atomic under the GIL and, crucially, acquires NO lock — so the per-send
# provenance check introduces no lock-ordering edge (avoids the _cfg_lock ↔
# _available_models_cache_lock deadlock) and never waits behind a catalog rebuild.
_models_cache_provenance: tuple | None = None


def _sync_models_cache_provenance() -> None:
    """Republish the atomic (snapshot, fingerprint) provenance pair.

    MUST be called at every site that assigns ``_available_models_cache`` and
    ``_available_models_cache_source_fingerprint`` (publish and invalidate),
    AFTER both have been set. It snapshots the current pair into one immutable
    tuple so ``_endpoint_advertised_model_ids`` reads both consistently with a
    single lock-free load. A reader that races between an underlying assignment
    and this call sees the PREVIOUS consistent tuple (never a torn pair); once
    this runs, readers see the new consistent pair.
    """
    global _models_cache_provenance
    snap = _available_models_cache
    _models_cache_provenance = (
        (snap, _available_models_cache_source_fingerprint) if snap is not None else None
    )


def _endpoint_advertised_model_ids(provider_id: str | None) -> frozenset | None:
    """Model ids the given provider's group advertised in the current catalog.

    Reads ONLY the already-published in-memory catalog snapshot
    (``_available_models_cache``) — it never builds, live-probes, or touches
    disk, so it is safe to call on the per-turn send hot path. Returns:

      * a ``frozenset`` of the ids advertised by ``provider_id``'s own group
        (bare ids for the active provider, e.g. ``x-ai/grok-4.5``), or
      * ``None`` when the catalog is cold/unbuilt OR the provider has no group.

    ``None`` means "no provenance signal available" — callers MUST treat that as
    "preserve the model id verbatim" so a cache miss never silently strips a
    vendor namespace off an id the user actively selected (#5979). Scoping to
    the provider's OWN group prevents a same-named id in a sibling group (e.g.
    an ``openai/gpt-5.4`` sitting in the OpenRouter group) from masquerading as
    something this custom endpoint advertised.
    """
    global _advertised_model_ids_memo
    # Single lock-free atomic read of the immutable (snapshot, fingerprint) pair
    # published by _sync_models_cache_provenance(). Reading one tuple can never
    # tear, and acquiring no lock means this per-send check adds no lock-ordering
    # edge (no _cfg_lock ↔ _available_models_cache_lock deadlock) and never waits
    # behind a catalog rebuild.
    provenance = _models_cache_provenance
    if provenance is None:
        return None
    snapshot, published_fp = provenance
    if snapshot is None:
        return None
    # Profile-isolation fail-safe (profiles are islands): the catalog cache is a
    # process global, so a concurrently-active profile could have published the
    # snapshot we're now reading. Only trust it for provenance when the
    # fingerprint captured AT PUBLISH TIME still matches the current runtime
    # fingerprint — the ``config_yaml`` axis of that fingerprint is the
    # PROFILE-SPECIFIC config path (_get_config_path -> get_active_hermes_home),
    # so a match guarantees the snapshot belongs to the profile asking. Any
    # mismatch (foreign profile, config edit, stale) returns None so the caller
    # preserves the id verbatim rather than stripping against another profile's
    # catalog.
    try:
        if published_fp != _models_cache_source_fingerprint():
            return None
    except Exception:
        return None  # fingerprint unavailable → no trustworthy provenance
    memo = _advertised_model_ids_memo
    # Identity check (``is``), not id(): holding the snapshot reference in the
    # memo keeps it alive, so a freed-then-reused id() can't cause a false hit.
    if memo is None or memo[0] is not snapshot:
        by_slug: dict[str, frozenset] = {}
        try:
            groups = snapshot.get("groups", []) or []
        except AttributeError:
            return None
        for group in groups:
            if not isinstance(group, dict):
                continue
            slug = str(group.get("provider_id") or "").strip().lower()
            if not slug:
                continue
            # Union BOTH catalog buckets: a provider's models can be split across
            # ``models`` (visible) and ``extra_models`` (overflow) by the picker,
            # so an id the endpoint genuinely advertised may live in either. Only
            # reading ``models`` would miss it and mis-resolve (e.g. leave the
            # #433 bare id unstripped because it sits in extra_models).
            ids = frozenset(
                str(m.get("id"))
                for bucket in ("models", "extra_models")
                for m in (group.get(bucket) or [])
                if isinstance(m, dict) and m.get("id")
            )
            by_slug[slug] = by_slug.get(slug, frozenset()) | ids
        memo = (snapshot, by_slug)
        _advertised_model_ids_memo = memo
    slug = str(provider_id or "").strip().lower()
    return memo[1].get(slug)


# Hard wall-clock budget for a COLD live provider-catalog rebuild when it is
# run from a foreground request path. The live rebuild does one network probe
# per detected provider (Copilot token-exchange HTTPS, OpenRouter /v1/models,
# Nous /models, ...). On a flaky / corp / WSL network any single probe can
# stall for its full per-call timeout (Copilot urllib timeout=10s) and, summed
# across N providers, block the request thread for tens of seconds. This bounds
# the time a foreground caller will wait: past the budget it returns a usable
# fallback (last-known disk cache or a network-free minimal catalog) and lets
# the rebuild finish out-of-band and populate the cache for the next call.
# Set HERMES_WEBUI_MODELS_REBUILD_BUDGET=0 to restore the legacy synchronous
# (unbounded) behaviour.
try:
    _LIVE_REBUILD_BUDGET_SECONDS: float = float(
        os.getenv("HERMES_WEBUI_MODELS_REBUILD_BUDGET", "4") or "4"
    )
except (TypeError, ValueError):
    _LIVE_REBUILD_BUDGET_SECONDS = 4.0


# ── Budget-exceeded warning rate-limit ───────────────────────────────────────
# Q-2979-A3 / Copilot discussion_r3305864400: the live-rebuild-budget-exceeded
# warning at _invoke_models_rebuild's slow-path is potentially high-volume —
# every provider catalog refresh that runs past _LIVE_REBUILD_BUDGET_SECONDS
# emits one, so a hung upstream probe (or a sustained burst of cold callers)
# could flood the log at warning level. Rate-limit per reason: the FIRST
# occurrence in a cooldown window logs at warning; subsequent occurrences in
# the same window log at info (so log signal stays useful but volume bounded).
# Override the default cooldown via HERMES_WEBUI_BUDGET_WARN_COOLDOWN (seconds).
try:
    _BUDGET_WARN_COOLDOWN_SECONDS: float = float(
        os.getenv("HERMES_WEBUI_BUDGET_WARN_COOLDOWN", "300") or "300"
    )
except (TypeError, ValueError):
    _BUDGET_WARN_COOLDOWN_SECONDS = 300.0

_BUDGET_WARN_STATE: dict[str, float] = {}
_BUDGET_WARN_LOCK = threading.Lock()


def _should_warn_budget(reason: str, cooldown_s: float | None = None) -> bool:
    """Return True iff the budget warning for ``reason`` should log at
    warning level (first hit, or last warn-level emit was more than
    ``cooldown_s`` seconds ago). Otherwise False — the caller should demote
    to info for the same payload so the signal is retained but the noise is
    capped. Thread-safe; the cooldown is shared across all live-rebuild
    callers in this process.
    """
    cooldown = (
        _BUDGET_WARN_COOLDOWN_SECONDS if cooldown_s is None else float(cooldown_s)
    )
    now = time.monotonic()
    with _BUDGET_WARN_LOCK:
        last = _BUDGET_WARN_STATE.get(reason)
        if last is None or (now - last) >= cooldown:
            _BUDGET_WARN_STATE[reason] = now
            return True
        return False


def _invoke_models_rebuild(builder):
    """Indirection seam around the cold catalog rebuild.

    Production simply calls ``builder()``. Exists so tests can simulate a
    slow / hanging provider probe without having to reach the closure that
    actually does the per-provider network calls.
    """
    return builder()


def _configured_model_badges_from_static_catalog(
    groups: list[dict],
    *,
    active_provider: str | None,
    default_model: str,
) -> dict[str, dict[str, str]]:
    configured_entries: list[dict[str, str]] = []
    if active_provider and default_model:
        configured_entries.append(
            {
                "provider": active_provider,
                "model": default_model,
                "role": "primary",
                "label": "Primary",
            }
        )

    fallback_cfg = cfg.get("fallback_providers", []) if isinstance(cfg, dict) else []
    if isinstance(fallback_cfg, list):
        for idx, entry in enumerate(fallback_cfg, start=1):
            if not isinstance(entry, dict):
                continue
            provider = _resolve_provider_alias(entry.get("provider"))
            model = str(entry.get("model") or "").strip()
            if not provider or not model:
                continue
            configured_entries.append(
                {
                    "provider": provider,
                    "model": model,
                    "role": "fallback",
                    "label": f"Fallback {idx}",
                }
            )

    option_ids = [
        m.get("id", "")
        for g in groups
        for m in g.get("models", [])
        if m.get("id")
    ]
    option_lookup = {str(opt_id): str(opt_id) for opt_id in option_ids}
    option_provider_lookup = {
        str(m.get("id")): str(g.get("provider_id") or "")
        for g in groups
        for m in g.get("models", [])
        if m.get("id")
    }

    def _norm_static_model_id(model_id: str) -> str:
        s = str(model_id or "").strip().lower()
        stripped_at_provider = False
        if s.startswith("@") and ":" in s:
            colon_idx = s.index(":", 1)
            candidate = s[colon_idx + 1:]
            stripped_at_provider = bool(candidate)
            s = candidate or s
        if "://" not in s:
            if (
                not stripped_at_provider
                and "/" in s
                and ":" in s
                and s.index(":") < s.index("/")
            ):
                s = s[s.index("/") + 1 :] or s
            if "/" in s:
                stripped = s.split("/", 1)[1]
                s = stripped or s
        return s.replace("-", ".")

    norm_lookup: dict[str, list[str]] = {}
    for opt_id in option_ids:
        norm_lookup.setdefault(_norm_static_model_id(opt_id), []).append(opt_id)

    badges: dict[str, dict[str, str]] = {}
    for entry in configured_entries:
        provider = entry["provider"]
        model = entry["model"]
        raw_candidates: list[str] = []
        for candidate in (model, f"@{provider}:{model}"):
            if candidate and candidate not in raw_candidates:
                raw_candidates.append(candidate)

        match_id = None
        for candidate in raw_candidates:
            if (
                candidate in option_lookup
                and option_provider_lookup.get(candidate) == provider
            ):
                match_id = option_lookup[candidate]
                break
        if match_id is None:
            for candidate in raw_candidates:
                normalized = _norm_static_model_id(candidate)
                matches = norm_lookup.get(normalized, [])
                if not matches:
                    continue
                provider_match = next(
                    (m for m in matches if option_provider_lookup.get(m) == provider),
                    None,
                )
                match_id = provider_match or matches[0]
                if match_id:
                    break

        badge_payload = {
            "role": entry["role"],
            "label": entry["label"],
            "provider": provider,
        }
        for candidate in raw_candidates:
            candidate_provider = option_provider_lookup.get(candidate)
            if candidate_provider and candidate_provider != provider:
                continue
            badges[candidate] = badge_payload
        if match_id:
            badges[match_id] = badge_payload

    return badges


def _minimal_static_models_catalog() -> dict:
    """Return the emergency one-model fallback for /api/models."""
    try:
        active_provider = None
        cfg_base_url = ""
        model_cfg = cfg.get("model", {}) if isinstance(cfg, dict) else {}
        if isinstance(model_cfg, dict):
            active_provider = model_cfg.get("provider")
            cfg_base_url = model_cfg.get("base_url", "") or ""
        if active_provider:
            try:
                active_provider = _resolve_configured_provider_id(
                    active_provider, cfg, base_url=cfg_base_url
                )
            except Exception:
                active_provider = str(active_provider or "").strip() or None
        if not active_provider:
            try:
                _ap = _get_auth_store_path()
                if _ap.exists():
                    _store = json.loads(_ap.read_text(encoding="utf-8"))
                    active_provider = (
                        _resolve_configured_provider_id(
                            _store.get("active_provider"), cfg, base_url=cfg_base_url
                        )
                        or None
                    )
            except Exception:
                pass
        default_model = get_effective_default_model(cfg)
        groups: list[dict] = []
        if default_model:
            try:
                label = _get_label_for_model(default_model, [])
            except Exception:
                label = default_model
            groups.append(
                {
                    "provider": "Default",
                    "provider_id": active_provider or "default",
                    "models": [{"id": default_model, "label": label}],
                }
            )
        return _annotate_fast_tier_model_groups({
            "active_provider": active_provider,
            "default_model": default_model,
            "configured_model_badges": {},
            "groups": groups,
            "aliases": {},
        })
    except Exception:
        logger.debug("minimal static models catalog build failed", exc_info=True)
        return {
            "active_provider": None,
            "default_model": "",
            "configured_model_badges": {},
            "groups": [],
            "aliases": {},
        }


def _static_models_catalog_without_live_probes() -> dict:
    """Return a network-free /api/models catalog from local config/auth only."""
    try:
        from api.providers import _provider_has_key

        active_provider = None
        cfg_base_url = ""
        model_cfg = cfg.get("model", {}) if isinstance(cfg, dict) else {}
        if isinstance(model_cfg, dict):
            active_provider = model_cfg.get("provider")
            cfg_base_url = model_cfg.get("base_url", "") or ""
        if active_provider:
            try:
                active_provider = _resolve_configured_provider_id(
                    active_provider,
                    cfg,
                    base_url=cfg_base_url,
                )
            except Exception:
                active_provider = str(active_provider or "").strip() or None

        auth_store: dict = {}
        try:
            auth_store_path = _get_auth_store_path()
            if auth_store_path.exists():
                auth_store = json.loads(auth_store_path.read_text(encoding="utf-8"))
                if not active_provider:
                    active_provider = (
                        _resolve_configured_provider_id(
                            auth_store.get("active_provider"),
                            cfg,
                            base_url=cfg_base_url,
                        )
                        or None
                    )
        except Exception:
            logger.debug("Failed to load auth store for static models catalog", exc_info=True)

        default_model = get_effective_default_model(cfg)
        detected_providers: set[str] = set()
        configured_model_ids: dict[str, list[str]] = {}
        named_custom_groups: dict[str, dict[str, object]] = {}
        custom_group_models: list[dict] = []
        canonical_to_raw_provider_key: dict[str, str] = {}
        providers_cfg = _get_providers_cfg()

        def _append_model_id(provider_id: str | None, model_id: object) -> None:
            pid = _canonicalise_provider_id(provider_id)
            mid = str(model_id or "").strip()
            if not pid or not mid:
                return
            configured_model_ids.setdefault(pid, [])
            if mid not in configured_model_ids[pid]:
                configured_model_ids[pid].append(mid)

        if active_provider:
            detected_providers.add(active_provider)
            _append_model_id(active_provider, default_model)

        try:
            _pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {}
            if isinstance(_pool, dict):
                for _pid, _entries in _pool.items():
                    if not isinstance(_entries, list) or not _entries:
                        continue
                    if any(
                        isinstance(_entry, dict)
                        and not _is_ambient_gh_cli_entry(
                            str(_entry.get("source", "") or ""),
                            str(_entry.get("label", "") or ""),
                            str(_entry.get("key_source", "") or ""),
                        )
                        for _entry in _entries
                    ):
                        detected_providers.add(_resolve_provider_alias(str(_pid)))
        except Exception:
            logger.debug("Failed to inspect auth-store credential pool", exc_info=True)

        if isinstance(providers_cfg, dict):
            for provider_key, provider_cfg in providers_cfg.items():
                canonical = _canonicalise_provider_id(provider_key)
                if not canonical:
                    continue
                is_known_provider = (
                    canonical in _PROVIDER_MODELS
                    or canonical in _PROVIDER_DISPLAY
                    or _is_plugin_model_provider(canonical)
                )
                is_provider_config = isinstance(provider_cfg, dict)
                if not (is_known_provider or is_provider_config):
                    continue
                canonical_to_raw_provider_key.setdefault(canonical, provider_key)
                if isinstance(provider_cfg, dict):
                    has_local_signal = any(
                        str(provider_cfg.get(key) or "").strip()
                        for key in ("api_key", "key_env", "base_url")
                    )
                    provider_models = provider_cfg.get("models")
                    for model_id in _configured_model_ids(provider_models):
                        _append_model_id(canonical, model_id)
                        has_local_signal = True
                    if has_local_signal:
                        detected_providers.add(canonical)

        for provider_id in set(_PROVIDER_MODELS) | set(_PROVIDER_DISPLAY):
            canonical = _canonicalise_provider_id(provider_id)
            if canonical and _provider_has_key(canonical):
                detected_providers.add(canonical)

        # Plugin-only providers (e.g. 9router) are not in the static
        # _PROVIDER_MODELS / _PROVIDER_DISPLAY tables and are detected above
        # only when the user puts them in `providers.<slug>`.  Plugins ship
        # with their own env-var wiring, so an installed-and-keyed plugin
        # provider should also enter the static catalog even without a
        # `providers:` block — otherwise the picker silently drops the
        # group when the live-rebuild cache is cold.
        try:
            for _plugin_pid in list(_plugin_model_provider_profiles().keys()):
                if not _plugin_pid or not _provider_has_key(_plugin_pid):
                    continue
                _canonical = _canonicalise_provider_id(_plugin_pid) or _plugin_pid
                if _canonical:
                    detected_providers.add(_canonical)
        except Exception:
            logger.debug("Plugin provider detection failed in static catalog", exc_info=True)

        fallback_cfg = cfg.get("fallback_providers", []) if isinstance(cfg, dict) else []
        if isinstance(fallback_cfg, list):
            for entry in fallback_cfg:
                if not isinstance(entry, dict):
                    continue
                provider = _resolve_provider_alias(entry.get("provider"))
                if provider:
                    detected_providers.add(provider)
                    _append_model_id(provider, entry.get("model"))

        for entry in _custom_provider_entries(cfg):
            provider_name = str(entry.get("name") or "").strip()
            provider_slug = _custom_provider_slug_from_name(provider_name) or "custom"
            if provider_slug != "custom":
                named_custom_groups.setdefault(
                    provider_slug,
                    {"name": provider_name, "models": []},
                )
            detected_providers.add(provider_slug)

            configured_ids: list[str] = []
            model_id = str(entry.get("model") or "").strip()
            if model_id:
                configured_ids.append(model_id)
            for configured_id in _configured_model_ids(entry.get("models")):
                if configured_id not in configured_ids:
                    configured_ids.append(configured_id)

            for configured_id in configured_ids:
                label = _get_label_for_model(configured_id, [])
                if provider_slug == "custom":
                    custom_group_models.append({"id": configured_id, "label": label})
                else:
                    named_custom_groups[provider_slug]["models"].append(
                        {"id": configured_id, "label": label}
                    )
                _append_model_id(provider_slug, configured_id)

        if cfg_base_url:
            detected_providers.add(
                _named_custom_provider_slug_for_base_url(cfg_base_url, cfg)
                or active_provider
                or "custom"
            )

        if detected_providers:
            detected_providers = {
                _canonicalise_provider_id(provider_id) or provider_id
                for provider_id in detected_providers
                if provider_id
            }

        groups: list[dict] = []
        for pid in sorted(detected_providers):
            if pid.startswith("custom:"):
                custom_group = named_custom_groups.get(pid, {})
                group_models = copy.deepcopy(custom_group.get("models", []))
                if group_models or pid == active_provider:
                    groups.append(
                        {
                            "provider": custom_group.get("name") or pid.replace("custom:", ""),
                            "provider_id": pid,
                            "models": _apply_provider_prefix(
                                group_models,
                                pid,
                                active_provider,
                            ),
                        }
                    )
                continue

            if pid == "custom":
                group_models = copy.deepcopy(custom_group_models)
                for model_id in configured_model_ids.get(pid, []):
                    if not any(m.get("id") == model_id for m in group_models):
                        group_models.append(
                            {"id": model_id, "label": _get_label_for_model(model_id, [])}
                        )
                if group_models or cfg_base_url or pid == active_provider:
                    groups.append(
                        {
                            "provider": _PROVIDER_DISPLAY.get(pid, "Custom"),
                            "provider_id": pid,
                            "models": _apply_provider_prefix(
                                group_models,
                                pid,
                                active_provider,
                            ),
                        }
                    )
                continue

            provider_name = _PROVIDER_DISPLAY.get(pid, pid.replace("-", " ").title())
            raw_key = canonical_to_raw_provider_key.get(pid, pid)
            provider_cfg = _get_provider_cfg(raw_key)
            raw_models = []
            if (
                isinstance(provider_cfg, dict)
                and "models" in provider_cfg
                and not _provider_models_are_discovered_catalog(provider_cfg)
            ):
                raw_models = _configured_model_options(provider_cfg.get("models"))
            if not raw_models:
                raw_models = copy.deepcopy(_PROVIDER_MODELS.get(pid, []))
            # Plugin-only providers (e.g. 9router) are not in _PROVIDER_MODELS
            # and rarely ship a `models:` allowlist in providers.<slug>, so
            # the static catalog above would render them as empty groups that
            # the picker filters out. Fall back to the plugin's own
            # ProviderProfile.fallback_models so the provider surfaces a
            # curated, network-free subset on the cold path. The live
            # rebuild (_build_available_models_uncached) does a full
            # /v1/models fetch and supersedes this view on the next call.
            if not raw_models and _is_plugin_model_provider(pid):
                _plugin_profile = _plugin_model_provider_profiles().get(
                    (pid or "").strip().lower()
                )
                if _plugin_profile is not None:
                    _fallback = getattr(_plugin_profile, "fallback_models", ()) or ()
                    raw_models = [{"id": str(mid), "label": str(mid)} for mid in _fallback]
            for model_id in configured_model_ids.get(pid, []):
                if model_id and not any(m.get("id") == model_id for m in raw_models):
                    raw_models.append(
                        {"id": model_id, "label": _get_label_for_model(model_id, groups)}
                    )
            # Plugin-only providers (e.g. 9router) must enter `groups` even
            # when `raw_models` is empty so the post-loop filter sees them.
            # Without this, the earlier plugin-fallback pass only seeds
            # `raw_models` when `fallback_models` is non-empty; the cold-cache
            # picker still silently drops a keyed plugin with no models yet.
            if raw_models or _is_plugin_model_provider(pid):
                groups.append(
                    {
                        "provider": provider_name,
                        "provider_id": pid,
                        "models": _apply_provider_prefix(raw_models, pid, active_provider),
                    }
                )

        if default_model:
            all_model_ids = {
                str(model.get("id") or "")
                for group in groups
                for model in group.get("models", [])
            }
            if default_model not in all_model_ids and f"@{active_provider}:{default_model}" not in all_model_ids:
                label = _get_label_for_model(default_model, groups)
                target_group = next(
                    (group for group in groups if group.get("provider_id") == active_provider),
                    None,
                )
                if target_group is not None:
                    target_group.setdefault("models", []).insert(0, {"id": default_model, "label": label})
                elif groups:
                    groups.append(
                        {
                            "provider": "Default",
                            "provider_id": active_provider or "default",
                            "models": [{"id": default_model, "label": label}],
                        }
                    )

        _deduplicate_model_ids(groups)
        groups = [
            group
            for group in groups
            if group.get("models")
            or str(group.get("provider_id") or "").startswith("custom:")
            # Keep plugin-only providers visible even when no models surfaced
            # yet (e.g. plugin's fallback_models is empty and live rebuild
            # hasn't completed). Otherwise they silently drop from the
            # picker and look "not installed" — the same 9router-empty-group
            # regression this branch was added to fix.
            or _is_plugin_model_provider(str(group.get("provider_id") or ""))
        ]

        providers_with_keys: set[str] = set()
        try:
            _pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {}
            if isinstance(_pool, dict):
                for _pid in _pool:
                    _canonical = _canonicalise_provider_id(_pid)
                    if _canonical:
                        providers_with_keys.add(_canonical)
        except Exception:
            pass
        try:
            for _pk, _pv in providers_cfg.items():
                if isinstance(_pv, dict) and (
                    _pv.get("api_key")
                    or _pv.get("key_env")
                    or _pv.get("base_url")
                ):
                    _canonical = _canonicalise_provider_id(_pk)
                    if _canonical:
                        providers_with_keys.add(_canonical)
        except Exception:
            pass

        def _group_sort_key(group: dict) -> tuple[int, str]:
            provider_id = str(group.get("provider_id") or "")
            if provider_id == active_provider:
                return (0, provider_id)
            if provider_id.startswith("custom:"):
                return (1, provider_id)
            if provider_id in providers_with_keys:
                return (2, provider_id)
            return (3, provider_id)

        groups.sort(key=_group_sort_key)

        model_aliases: dict[str, str] = {}
        try:
            raw_aliases = cfg.get("model", {}).get("aliases", {})
            if isinstance(raw_aliases, dict):
                model_aliases = {
                    str(k).strip(): str(v).strip()
                    for k, v in raw_aliases.items()
                    if k and v
                }
        except Exception:
            pass

        if not groups and default_model:
            return copy.deepcopy(_minimal_static_models_catalog())

        return _annotate_fast_tier_model_groups({
            "active_provider": active_provider,
            "default_model": default_model,
            "configured_model_badges": _configured_model_badges_from_static_catalog(
                groups,
                active_provider=active_provider,
                default_model=default_model,
            ),
            "groups": groups,
            "aliases": model_aliases,
        })
    except Exception:
        logger.debug("static models catalog build failed", exc_info=True)
        return copy.deepcopy(_minimal_static_models_catalog())

# Cache for credential pool results -- calling load_pool() per-provider per-server
# session is expensive (~10s for zai due to endpoint probing).  The credential pool
# only changes when the user adds/removes credentials, which is rare; a 24h TTL
# is plenty safe and ensures get_available_models() cold paths are fast.
_CREDENTIAL_POOL_CACHE: dict[tuple[str, str], tuple[float, "CredentialPool"]] = {}  # noqa: F821  forward-ref string annotation, resolved at runtime  # (profile_tag, pid) -> (ts, pool)


def _credential_pool_profile_tag() -> str:
    """Active-profile identity for the credential-pool cache key.

    The credential pool is per-Hermes-profile (it lives in that profile's
    auth.json). Keying the process-global cache by provider id ALONE lets a
    pool loaded under profile A satisfy a lookup under profile B in the same
    server process — so a custom provider configured only in A would falsely
    report configured in B (and then 401 at request time). Scoping every
    cache key by the active profile's auth-store path keeps pools from
    crossing profile boundaries.
    """
    try:
        return str(_get_auth_store_path())
    except Exception:
        return ""


def _pool_entry_payloads(provider_id: str) -> list[dict[str, Any]]:
    """Return explicit credential-pool entry payloads for the active profile.

    Readonly profile scopes must not let ``load_pool()`` seed from process env,
    because that can materialize server-default credentials into a named
    profile's auth store. In that mode, read raw auth.json payloads only.
    """
    _pid = _resolve_provider_alias(provider_id)
    if bool(getattr(_thread_ctx, "block_process_env_fallback", False)):
        try:
            from hermes_cli.auth import read_credential_pool as _read_credential_pool

            raw_entries = _read_credential_pool(_pid)
        except ImportError:
            return []
        payloads: list[dict[str, Any]] = []
        for entry in raw_entries:
            if not isinstance(entry, dict):
                continue
            if _is_ambient_gh_cli_entry(
                str(entry.get("source", "") or ""),
                str(entry.get("label", "") or ""),
                str(entry.get("key_source", "") or ""),
            ):
                continue
            payloads.append(dict(entry))
        return payloads

    try:
        from agent.credential_pool import load_pool as _load_pool

        _ck = (_credential_pool_profile_tag(), _pid)
        _cached = _CREDENTIAL_POOL_CACHE.get(_ck)
        if _cached is not None:
            _cp_ts, _cp_pool = _cached
            if (time.time() - _cp_ts) < 86400.0:
                _all_entries = _cp_pool.entries() if _cp_pool is not None and hasattr(_cp_pool, "entries") else []
            else:
                _cp_pool = _load_pool(_pid)
                _CREDENTIAL_POOL_CACHE[_ck] = (time.time(), _cp_pool)
                _all_entries = _cp_pool.entries() if _cp_pool is not None and hasattr(_cp_pool, "entries") else []
        else:
            _cp_pool = _load_pool(_pid)
            _CREDENTIAL_POOL_CACHE[_ck] = (time.time(), _cp_pool)
            _all_entries = _cp_pool.entries() if _cp_pool is not None and hasattr(_cp_pool, "entries") else []
    except ImportError:
        return []

    payloads = []
    for entry in _all_entries:
        if _is_ambient_gh_cli_entry(
            str(getattr(entry, "source", "") or ""),
            str(getattr(entry, "label", "") or ""),
            str(getattr(entry, "key_source", "") or ""),
        ):
            continue
        if hasattr(entry, "to_dict") and callable(entry.to_dict):
            payload = entry.to_dict()
        elif isinstance(entry, dict):
            payload = dict(entry)
        else:
            try:
                payload = dict(vars(entry))
            except TypeError:
                payload = {}
        if not isinstance(payload, dict):
            payload = {}
        payload = dict(payload)
        payload.setdefault("source", str(getattr(entry, "source", "") or ""))
        payload.setdefault("label", str(getattr(entry, "label", "") or ""))
        payload.setdefault("key_source", str(getattr(entry, "key_source", "") or ""))
        runtime_api_key = getattr(entry, "runtime_api_key", None)
        if runtime_api_key:
            payload["runtime_api_key"] = runtime_api_key
        base_url = getattr(entry, "base_url", None)
        if base_url:
            payload["base_url"] = base_url
        inference_base_url = getattr(entry, "inference_base_url", None)
        if inference_base_url:
            payload["inference_base_url"] = inference_base_url
        payloads.append(payload)
    return payloads


def _has_explicit_pool_credentials(provider_id: str) -> bool:
    """Return True when the credential pool has at least one non-ambient entry
    for *provider_id* (i.e. not a gh-cli / GITHUB_TOKEN auto-detect).

    Reuses ``_CREDENTIAL_POOL_CACHE`` so that callers on hot paths (provider
    detection, model listing, live-model fetch) don't pay the ~10s load_pool
    cost more than once per TTL window.
    """
    return bool(_pool_entry_payloads(provider_id))
_provider_models_invalidated_ts: dict[str, float] = {}  # provider_id -> timestamp of last invalidation

# Disk-backed in-memory cache for get_available_models().
# Written to disk on every cache population so the cache survives server restarts.
# Invalidated (file deleted) whenever a provider is added/changed/removed or
# config.yaml changes.  A TTL is still used as a fallback in case the invalidation
# signal is somehow missed, but the cache will always be warm after the first
# page load following a server start.
# Cache file lives inside STATE_DIR so each server instance (different
# HERMES_WEBUI_STATE_DIR / port) has its own file and test runs never
# pollute the production server's cache. Also works on macOS and Windows
# where /dev/shm does not exist.
def _current_webui_version() -> str | None:
    """Lazy resolver for the WebUI version, used to stamp the disk cache (#1633).

    `api.updates` imports `api.config` at module-load time, so we cannot
    `from api.updates import WEBUI_VERSION` at the top of this module without a
    circular import. Instead we resolve lazily on each cache load/save.

    Returns the runtime version string (e.g. ``v0.50.293``) when api.updates
    has been imported, or None if it isn't loaded yet (boot-time corner case
    before the server has finished initializing). A None return is treated as
    "do not stamp / do not validate" by the cache layer so cache reads/writes
    that happen during early init still work — the next call after init will
    stamp normally.
    """
    try:
        # Read attribute via dotted lookup so we don't add an import-time edge.
        import sys as _sys
        mod = _sys.modules.get('api.updates')
        if mod is None:
            return None
        v = getattr(mod, 'WEBUI_VERSION', None)
        return str(v) if v else None
    except Exception:
        return None


# Disk-cache schema version (#1633).
#
# Bumped any time the disk cache shape changes in a backward-incompatible way
# (e.g. new required field, renamed key). Independent of the WebUI version
# stamp — _webui_version forces a rebuild on every release; _schema_version
# guarantees that even if a future release accidentally reuses the same
# WebUI version string (or a debug build doesn't have a version), a structural
# change still invalidates the cache.
_MODELS_CACHE_SCHEMA_VERSION = 3


_models_cache_path = STATE_DIR / "models_cache.json"


def _get_models_cache_path(profile: str | None = None) -> Path:
    """Return the /api/models disk-cache path for the *active* profile (#3957).

    WebUI profile switching is per-client/cookie scoped (issue #798), but the
    models disk cache used to be a single import-time ``STATE_DIR /
    "models_cache.json"`` shared across every profile.  The cache's
    ``_source_fingerprint`` is profile-specific (it hashes the active profile's
    config.yaml + auth.json), so a non-default profile rejected the shared
    snapshot on every read and cold-rebuilt the catalog — the serial live
    provider probes behind that cold build are what pushed ``/api/models`` (and
    the Settings → Providers panel) past the 30s frontend timeout.

    Profile-key the filename so each profile keeps its own warm cache:
      - default / root profile  → ``models_cache.json``  (unchanged path; no
        migration of the existing file)
      - named profile ``<name>`` → ``models_cache.<name>.json``

    The active profile is resolved per-request via ``get_active_profile_name()``
    (thread-local cookie context), falling back to the module-level default
    path if the profiles module is unavailable (very early boot / import cycle).

    The named-profile path is derived from ``_models_cache_path`` (the
    module-level default), not from ``STATE_DIR`` directly, so the path stays
    correct if the default is repointed (e.g. tests monkeypatch
    ``_models_cache_path`` to an isolated tmp file). Pass *profile* to get
    another profile's path (profile delete/create).
    """
    try:
        from api.profiles import get_active_profile_name, _is_root_profile

        name = (profile or get_active_profile_name() or "").strip()
        if not name or _is_root_profile(name):
            return _models_cache_path
        # Defensive filename sanitization: the cookie-derived profile name is
        # already validated by _PROFILE_ID_RE at the request boundary, but keep
        # the on-disk filename safe regardless of how the name was resolved.
        safe = re.sub(r"[^a-z0-9_-]", "_", name.lower())[:64]
        if not safe:
            return _models_cache_path
        # Splice the profile into the default filename: models_cache.json →
        # models_cache.<safe>.json, keeping the default's parent dir + suffix.
        base = _models_cache_path
        return base.with_name(f"{base.stem}.{safe}{base.suffix}")
    except Exception:
        return _models_cache_path


def _get_auth_store_path() -> Path:
    """Return the auth.json path for the active Hermes profile."""
    try:
        from api.profiles import get_active_hermes_home as _gah

        return _gah() / "auth.json"
    except ImportError:
        return _DEFAULT_HERMES_HOME / "auth.json"


def _models_cache_file_fingerprint(path: Path) -> dict:
    """Return non-secret identity metadata for a cache dependency file.

    The /api/models response depends on config.yaml (model/provider defaults)
    and auth.json (active_provider + credential_pool).  The cache only needs
    cheap invalidation signals here, not file contents; never include secrets.
    """
    fingerprint = {"path": str(Path(path).expanduser())}
    try:
        st = Path(path).stat()
    except OSError:
        fingerprint["missing"] = True
        return fingerprint
    fingerprint["mtime_ns"] = st.st_mtime_ns
    fingerprint["size"] = st.st_size
    return fingerprint


# Codex's ~/.codex/models_cache.json is rewritten on Codex's own refresh timer.
# Each rewrite bumps mtime_ns + size, but the payload usually only refreshes
# the volatile timestamp fields (`fetched_at`, plus `updated_at` when present)
# while the model catalog (client_version, etag, models[]) stays identical.
# Fingerprinting that file by stat (#2443's _models_cache_file_fingerprint)
# therefore invalidated the 24h /api/models cache on every Codex refresh, and
# the next session visit paid a full live rebuild whose serial provider probes
# starved the session-open path (#7540).
#
# This is a DENY-list, not an allow-list, on purpose — same safety direction as
# _AUTH_FINGERPRINT_VOLATILE_KEYS: every other field (client_version, etag,
# models, and any future model-affecting key) stays IN the fingerprint, so a
# genuine catalog change still invalidates the cache.
_CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS = frozenset({
    # Whole-file refresh timestamp, rewritten by every Codex models refresh.
    "fetched_at",
    # Same-family save timestamp (mirrors the auth.json deny-list).
    "updated_at",
})


def _strip_volatile_codex_cache_fields(obj):
    """Recursively drop refresh-timestamp-only keys from a Codex cache tree.

    Pure structural transform; never mutates the input. Any key NOT in the
    deny-list is preserved verbatim so real catalog changes still show through
    in the fingerprint.
    """
    if isinstance(obj, dict):
        return {
            k: _strip_volatile_codex_cache_fields(v)
            for k, v in obj.items()
            if k not in _CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS
        }
    if isinstance(obj, list):
        return [_strip_volatile_codex_cache_fields(v) for v in obj]
    return obj


def _codex_models_cache_fingerprint(path: Path) -> dict:
    """Return a content fingerprint of Codex's models_cache.json.

    Unlike _models_cache_file_fingerprint() (mtime_ns + size), this hashes the
    JSON content with the refresh-timestamp fields stripped, so a Codex
    refresh that only bumps `fetched_at` does NOT invalidate the 24h
    /api/models cache and therefore does not force the live rebuild that
    stalled session opens (#7540). A change to anything that actually feeds the
    Codex models we surface (client_version, etag, models[], an unknown future
    field) still changes the hash and correctly busts the cache.

    Failure modes are deliberately conservative — a missing file is recorded,
    and an unreadable/undecodable file falls back to the stat-based fingerprint
    so behaviour is never *less* safe than the stat-only version.
    """
    p = Path(path).expanduser()
    fp: dict = {"path": str(p)}
    try:
        st = p.stat()
    except OSError:
        fp["missing"] = True
        return fp
    try:
        raw = json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        # Unreadable / corrupt / mid-write: keep the stat-based fingerprint.
        # Strictly no less safe than the pre-fix behaviour (every write still
        # invalidates) for this rare path only.
        fp["mtime_ns"] = st.st_mtime_ns
        fp["size"] = st.st_size
        fp["semantic"] = "unparsed-fallback"
        return fp
    try:
        # The recursive strip can raise (e.g. RecursionError on a pathologically
        # deep JSON tree) — keep it inside the fallback try so any transform
        # failure degrades to the stat fingerprint rather than 500ing /api/models.
        stripped = _strip_volatile_codex_cache_fields(raw)
        encoded = json.dumps(
            stripped,
            sort_keys=True,
            separators=(",", ":"),
            ensure_ascii=True,
            default=str,
        ).encode("utf-8")
        fp["semantic_sha256"] = hashlib.sha256(encoded).hexdigest()
    except Exception:
        fp["mtime_ns"] = st.st_mtime_ns
        fp["size"] = st.st_size
        fp["semantic"] = "encode-fallback"
    return fp


def _models_cache_catalog_fingerprint() -> dict:
    """Return non-secret model-catalog identity metadata for cache invalidation.

    The /api/models payload is not only a function of user config/auth files.
    It also depends on the provider/model catalog baked into this module and on
    small local catalogs such as Codex's models_cache.json. Keep this cheap and
    deterministic so a server restart after catalog changes does not keep
    serving an otherwise-valid persisted models_cache.json until the 24h TTL
    expires (#2443).

    The Codex axis uses a *content* fingerprint that excludes the refresh
    timestamp fields (see _codex_models_cache_fingerprint): Codex rewrites
    ~/.codex/models_cache.json on its own timer, bumping mtime_ns + size while
    the model payload stays identical, so a stat-based fingerprint invalidated
    the 24h cache on every Codex refresh and the next session visit paid a live
    rebuild (#7540).
    """
    catalog_payload = {
        "provider_models": _PROVIDER_MODELS,
        "provider_display": _PROVIDER_DISPLAY,
    }
    try:
        encoded = json.dumps(
            catalog_payload,
            sort_keys=True,
            separators=(",", ":"),
            ensure_ascii=True,
            default=str,
        ).encode("utf-8")
        provider_catalog_sha = hashlib.sha256(encoded).hexdigest()
    except Exception:
        provider_catalog_sha = "unavailable"

    codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser()
    return {
        "provider_catalog_sha256": provider_catalog_sha,
        "codex_models_cache": _codex_models_cache_fingerprint(codex_home / "models_cache.json"),
    }


# Credential-rotation fields inside auth.json that churn on a ~14-minute
# period (credential-pool / OAuth token refresh rewrites the whole file) but
# DO NOT change the set of available providers or models that /api/models
# returns. mtime/size-based fingerprinting (#1699's _models_cache_file_
# fingerprint) treats every one of these rewrites as a cache-invalidating
# change, so the 24h models cache is effectively dead — every few minutes a
# tab pays a full cold get_available_models() rebuild (see RCA t_d127953d /
# t_16551f61). We strip ONLY these known-inert fields and fingerprint the
# rest of auth.json by content, so token rotation no longer busts the cache.
#
# This is a DENY-list, not an allow-list, on purpose: a field we don't know
# about stays IN the fingerprint, so any genuine change to provider
# enablement / endpoint / api-base / model-allow (active_provider, a NEW
# credential_pool entry id, base_url, source, label, key_source, auth_type,
# priority, the providers{} block, …) still correctly invalidates the cache.
# The safety invariant is one-directional: excluding these fields can only
# ever make the fingerprint MORE stable, never make it miss a real
# provider/model-set change — because none of these fields feed
# detected_providers / the catalog in _build_available_models_uncached().
_AUTH_FINGERPRINT_VOLATILE_KEYS = frozenset({
    # Secret material — rotates on refresh, never gates the provider/model set.
    "access_token",
    "refresh_token",
    "id_token",
    "api_key",
    "secret",
    "client_secret",  # rotation-only on purpose; not a model-cache differentiator
    # Expiry / liveness — bumped every refresh, derived from the token above.
    "expires_at",
    "expires_at_ms",
    "expires_in",
    # Per-credential status/telemetry — churns on every request, not config.
    "last_status",
    "last_status_at",
    "last_error_code",
    "last_error_reason",
    "last_error_message",
    "last_error_reset_at",
    "request_count",
    # Whole-file save timestamp — rewritten on every _save_auth_store().
    "updated_at",
})


def _strip_volatile_auth_fields(obj):
    """Recursively drop credential-rotation-only keys from an auth.json tree.

    Pure structural transform; never mutates the input. Any key NOT in the
    deny-list is preserved verbatim so real provider/endpoint changes still
    show through in the fingerprint.
    """
    if isinstance(obj, dict):
        return {
            k: _strip_volatile_auth_fields(v)
            for k, v in obj.items()
            if k not in _AUTH_FINGERPRINT_VOLATILE_KEYS
        }
    if isinstance(obj, list):
        return [_strip_volatile_auth_fields(v) for v in obj]
    return obj


def _auth_store_semantic_fingerprint(path: Path) -> dict:
    """Return a content fingerprint of auth.json that ignores token churn.

    Unlike _models_cache_file_fingerprint() (mtime_ns + size), this hashes
    the JSON content with the credential-rotation fields stripped, so the
    ~14-min token-refresh rewrite of auth.json does NOT invalidate the 24h
    /api/models cache. A change to anything that actually affects the
    provider/model set (active_provider, a new credential_pool entry, a
    changed base_url/source/label/auth_type, the providers{} block, …)
    still changes the hash and correctly busts the cache.

    Failure modes are deliberately conservative — if the file is missing we
    record that, and if it can't be read/parsed we fall back to the old
    mtime/size fingerprint so behaviour is never *less* safe than #1699.
    """
    p = Path(path).expanduser()
    fp: dict = {"path": str(p)}
    try:
        st = p.stat()
    except OSError:
        fp["missing"] = True
        return fp
    try:
        raw = json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        # Unreadable / corrupt / mid-write: fall back to the stat-based
        # fingerprint. Strictly no less safe than the pre-fix behaviour
        # (every write still invalidates) for this rare path only.
        fp["mtime_ns"] = st.st_mtime_ns
        fp["size"] = st.st_size
        fp["semantic"] = "unparsed-fallback"
        return fp
    stripped = _strip_volatile_auth_fields(raw)
    try:
        encoded = json.dumps(
            stripped,
            sort_keys=True,
            separators=(",", ":"),
            ensure_ascii=True,
            default=str,
        ).encode("utf-8")
        fp["semantic_sha256"] = hashlib.sha256(encoded).hexdigest()
    except Exception:
        fp["mtime_ns"] = st.st_mtime_ns
        fp["size"] = st.st_size
        fp["semantic"] = "encode-fallback"
    return fp


def _active_profile_home() -> Path:
    try:
        from api.profiles import get_active_hermes_home as _gah

        return _gah()
    except ImportError:
        return _DEFAULT_HERMES_HOME


def _models_cache_env_fingerprint(path: Path) -> list:
    """``[key, HMAC(value)]`` per non-empty ``.env`` entry, parsed like provider detection.

    Values are keyed-hashed with the WebUI signing key so the cache file never holds a secret.
    """
    import hmac
    from api.auth import _signing_key
    from api.providers import _load_env_file

    key = _signing_key()
    return [
        [k, hmac.new(key, v.encode("utf-8"), hashlib.sha256).hexdigest()]
        for k, v in sorted(_load_env_file(Path(path).expanduser()).items())
        if v
    ]


def _declares_model_provider_kind(plugin_dir: Path) -> bool:
    # Same parse as the agent's providers._declares_model_provider_kind: PyYAML, then a line scan.
    for filename in ("plugin.yaml", "plugin.yml"):
        manifest = plugin_dir / filename
        if not manifest.is_file():
            continue
        try:
            text = manifest.read_text(encoding="utf-8", errors="replace")
        except Exception:
            return False
        try:
            import yaml as _yaml

            data = _yaml.safe_load(text)
            if isinstance(data, dict):
                return str(data.get("kind", "")).strip() == "model-provider"
        except Exception:
            pass
        for line in text.splitlines():
            stripped = line.strip()
            if stripped.startswith("#") or ":" not in stripped:
                continue
            key, _, value = stripped.partition(":")
            if key.strip() == "kind":
                return value.strip().strip("\"'") == "model-provider"
        return False
    return False


def _models_cache_plugin_fingerprint(home: Path) -> list:
    """``[dir, file stamps]`` per model-provider plugin, discovered like providers._scan_home_layer."""
    found = []
    plugins_root = Path(home).expanduser() / "plugins"
    for base, flat in ((plugins_root / "model-providers", False), (plugins_root, True)):
        try:
            children = sorted(base.iterdir())
        except OSError:
            continue
        for child in children:
            if not child.is_dir() or child.name.startswith(("_", ".")):
                continue
            if flat and (child.name == "model-providers" or not _declares_model_provider_kind(child)):
                continue
            found.append([str(child.relative_to(plugins_root)), _plugin_tree_stamps(child)])
    return found


def _plugin_tree_stamps(plugin_dir: Path) -> list:
    # The loader execs __init__.py, which may import siblings or read data files; skip bytecode.
    stamps = []
    for root, dirs, files in os.walk(plugin_dir):
        dirs[:] = sorted(d for d in dirs if d != "__pycache__" and not d.startswith("."))
        for name in sorted(files):
            if name.endswith((".pyc", ".pyo")):
                continue
            path = os.path.join(root, name)
            try:
                st = os.stat(path)
            except OSError:
                continue
            stamps.append([os.path.relpath(path, plugin_dir), st.st_mtime_ns, st.st_size])
    return stamps


def _models_cache_source_fingerprint() -> dict:
    """Return the current config/auth/catalog fingerprint for /api/models cache.

    The auth.json axis uses a *content* fingerprint that excludes pure
    credential-rotation fields (see _auth_store_semantic_fingerprint): the
    auth store is rewritten roughly every 14 minutes by token refresh, and
    a stat-based (mtime/size) fingerprint made the 24h cache churn on every
    one of those rewrites (RCA t_16551f61). config.yaml keeps the cheap
    mtime/size fingerprint because it is only rewritten on deliberate user
    edits (which can change anything) and does not churn on a timer.
    """
    home = _active_profile_home()
    return {
        "config_yaml": _models_cache_file_fingerprint(_get_config_path()),
        "auth_json": _auth_store_semantic_fingerprint(_get_auth_store_path()),
        "env": _models_cache_env_fingerprint(home / ".env"),
        "plugins": _models_cache_plugin_fingerprint(home),
        "catalog": _models_cache_catalog_fingerprint(),
    }


def _delete_models_cache_on_disk() -> None:
    try:
        os.unlink(str(_get_models_cache_path()))
    except OSError:
        pass  # already absent


def _is_valid_models_cache(cache: object) -> bool:
    """Return True when a cache payload has the full /api/models shape.

    SHAPE-only check: validates structural correctness of an in-memory or
    on-disk cache. Use _is_loadable_disk_cache() for the strictness needed
    when reading from disk (it adds version-stamp invalidation per #1633).

    Kept loose so in-memory cache writes (which never touch disk and so don't
    need version stamping) can use this validator unchanged.
    """
    if not isinstance(cache, dict):
        return False
    if not {"active_provider", "default_model", "configured_model_badges", "groups"}.issubset(cache):
        return False
    active_provider = cache.get("active_provider")
    return (
        (active_provider is None or isinstance(active_provider, str))
        and isinstance(cache.get("default_model"), str)
        and isinstance(cache.get("configured_model_badges"), dict)
        and isinstance(cache.get("groups"), list)
    )


def _is_loadable_disk_cache(cache: object) -> bool:
    """Return True when an on-disk cache is safe to use after a process boot.

    Adds two checks on top of _is_valid_models_cache (#1633):
      1. ``_schema_version`` matches `_MODELS_CACHE_SCHEMA_VERSION`. A bumped
         schema version unconditionally invalidates older cache files.
      2. ``_webui_version`` matches the current runtime version. Forces a
         rebuild after every release so users see picker-shape fixes
         immediately, instead of waiting up to 24 hours for the TTL to expire.
         If the runtime version cannot be resolved (early-init edge case),
         skip this check rather than wedge the boot.

    Note: ``_webui_version`` is a string equality check, not a semver compare —
    two debug builds with the same `WEBUI_VERSION` string but different actual
    code wouldn't invalidate via this axis. ``_schema_version`` is the
    independent invalidation axis for breaking changes that lack a tag bump;
    bump it whenever the cache shape changes incompatibly.
    """
    if not _is_valid_models_cache(cache):
        return False
    if not isinstance(cache, dict):  # appease type-narrowing — already guarded above
        return False
    cached_schema = cache.get("_schema_version")
    if cached_schema != _MODELS_CACHE_SCHEMA_VERSION:
        # DEBUG telemetry per stage-294 absorption: makes "why did my cache
        # rebuild" investigations one log-grep away.
        logger.debug(
            "models cache rejected: schema=%r vs runtime=%r",
            cached_schema, _MODELS_CACHE_SCHEMA_VERSION,
        )
        return False
    runtime_version = _current_webui_version()
    if runtime_version is not None:
        cached_version = cache.get("_webui_version")
        if not isinstance(cached_version, str) or cached_version != runtime_version:
            logger.debug(
                "models cache rejected: webui_version=%r vs runtime=%r",
                cached_version, runtime_version,
            )
            return False
    cached_sources = cache.get("_source_fingerprint")
    runtime_sources = _models_cache_source_fingerprint()
    if cached_sources != runtime_sources:
        logger.debug(
            "models cache rejected: source_fingerprint=%r vs runtime=%r",
            cached_sources,
            runtime_sources,
        )
        return False
    return True


def _load_models_cache_from_disk() -> dict | None:
    """Load /api/models cache from disk if it exists and has current metadata.

    Adds the per-release version check from #1633: a cache stamped with a
    different WebUI version is treated as missing, forcing a fresh rebuild
    that picks up any picker-shape fixes shipped in the new release. The
    returned dict is the SHAPE-only cache (without the `_webui_version` /
    `_schema_version` stamps) so callers don't have to know about the
    on-disk metadata fields.
    """
    try:
        import json as _j

        cache_path = _get_models_cache_path()
        if not cache_path.exists():
            return None
        with open(cache_path, encoding="utf-8") as f:
            cache = _j.load(f)
        if not _is_loadable_disk_cache(cache):
            return None
        # Strip the disk-only metadata before returning, so the in-memory
        # cache shape stays exactly what the rest of the code expects. The
        # disk save path does not persist `aliases`, so reconstruct them from
        # current config to keep the /api/models.aliases contract intact (a
        # disk-cache hit must not silently drop `/model <alias>` resolution).
        return _annotate_fast_tier_model_groups({
            "active_provider": cache["active_provider"],
            "default_model": cache["default_model"],
            "configured_model_badges": cache["configured_model_badges"],
            "groups": cache["groups"],
            "aliases": (
                cache["aliases"]
                if isinstance(cache.get("aliases"), dict)
                else _model_aliases_from_config()
            ),
        })
    except Exception:
        return None


def _model_aliases_from_config() -> dict[str, str]:
    """Build the normalized model-alias map from current config.

    Mirrors the alias construction used by the live and static catalog paths so
    the `/api/models.aliases` contract is consistent across every catalog source
    (live, static, and the stale-disk fallback, which can't read aliases from a
    disk cache that never persisted them).
    """
    try:
        raw_aliases = cfg.get("model", {}).get("aliases", {})
        if isinstance(raw_aliases, dict):
            return {
                str(k).strip(): str(v).strip()
                for k, v in raw_aliases.items()
                if k and v
            }
    except Exception:
        pass
    return {}


def _load_stale_models_cache_from_disk() -> dict | None:
    """Load a shape-valid stale /api/models disk cache for timeout fallback only.

    The main cache loader enforces metadata stamps for a full cold-path cache hit.
    This helper intentionally does not apply that stricter policy, so we can still
    recover a useful fallback payload when the strict loader rejected cache because
    the WebUI version stamp is stale. It DOES still enforce the schema version (a
    cross-schema cache can have an incompatible groups/badge shape) and the source
    fingerprint: a snapshot built from other config/auth/.env/plugin sources is a
    wrong catalog, not merely an old one, so it is never served.
    """
    try:
        import json as _j

        cache_path = _get_models_cache_path()
        if not cache_path.exists():
            return None
        with open(cache_path, encoding="utf-8") as f:
            cache = _j.load(f)
        if not _is_valid_models_cache(cache):
            return None
        if cache.get("_schema_version") != _MODELS_CACHE_SCHEMA_VERSION:
            return None
        if cache.get("_source_fingerprint") != _models_cache_source_fingerprint():
            return None
        aliases = cache.get("aliases")
        if not isinstance(aliases, dict):
            # The disk cache save path does not persist `aliases`, so a cache
            # read back from disk lacks them. Defaulting to {} would silently
            # break `/model <alias>` slash-command resolution (static/commands.js
            # resolves slash aliases only from /api/models.aliases) for the
            # duration of the over-budget stale fallback. Reconstruct from
            # current config, mirroring the live/static catalog alias build.
            aliases = _model_aliases_from_config()
        return _annotate_fast_tier_model_groups({
            "active_provider": cache["active_provider"],
            "default_model": cache["default_model"],
            "configured_model_badges": cache["configured_model_badges"],
            "groups": cache["groups"],
            "aliases": aliases,
        })
    except Exception:
        return None


def _save_models_cache_to_disk(cache: dict) -> None:
    """Save cache to disk so it survives server restarts.

    Stamps the payload with `_webui_version` and `_schema_version` (#1633) so
    a subsequent process running a different WebUI version, or a future
    release that bumps the schema, will treat the file as invalid and
    rebuild from live provider data on its first /api/models call.

    The version stamp is omitted (not the literal None — the field is just
    skipped) when the runtime version cannot be resolved at the moment of
    save, which would happen only in a very early boot path before
    api.updates is loaded. _is_loadable_disk_cache treats a missing field as
    a mismatch (since runtime_version is non-None on every subsequent call),
    so this is safe — at worst we write one cache file that gets rejected
    once on the next boot.
    """
    try:
        if not _is_valid_models_cache(cache):
            return
        payload = {
            "_schema_version": _MODELS_CACHE_SCHEMA_VERSION,
            "_source_fingerprint": _models_cache_source_fingerprint(),
            "active_provider": cache["active_provider"],
            "default_model": cache["default_model"],
            "configured_model_badges": cache["configured_model_badges"],
            "groups": cache["groups"],
        }
        runtime_version = _current_webui_version()
        if runtime_version is not None:
            payload["_webui_version"] = runtime_version
        cache_path = _get_models_cache_path()
        tmp = str(cache_path) + f".{os.getpid()}.tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(payload, f, indent=2)
        os.rename(tmp, str(cache_path))
    except Exception:
        pass  # Non-fatal -- cache will rebuild on next call


def _get_fresh_memory_models_cache(now: float) -> dict | None:
    """Return a valid fresh in-memory /api/models cache, or clear stale shapes."""
    global _available_models_cache, _available_models_cache_ts
    global _available_models_live_rebuild_ts, _available_models_cache_source_fingerprint
    if _available_models_cache is None:
        return None
    if (now - _available_models_cache_ts) >= _AVAILABLE_MODELS_CACHE_TTL:
        return None
    current_sources = _models_cache_source_fingerprint()
    if _available_models_cache_source_fingerprint != current_sources:
        logger.debug(
            "models memory cache rejected: source_fingerprint=%r vs runtime=%r",
            _available_models_cache_source_fingerprint,
            current_sources,
        )
        _available_models_cache = None
        _available_models_cache_ts = 0.0
        _available_models_live_rebuild_ts = 0.0
        _available_models_cache_source_fingerprint = None
        _sync_models_cache_provenance()
        return None
    if _is_valid_models_cache(_available_models_cache):
        return _annotate_fast_tier_model_groups(copy.deepcopy(_available_models_cache))
    _available_models_cache = None
    _available_models_cache_ts = 0.0
    _available_models_live_rebuild_ts = 0.0
    _available_models_cache_source_fingerprint = None
    _sync_models_cache_provenance()
    return None


def invalidate_models_cache(*, delete_disk: bool = True):
    """Force the TTL cache for get_available_models() to be cleared.

    Call this after modifying config.cfg in-memory (e.g. in tests) so
    the next call to get_available_models() picks up the changes rather
    than returning a stale cached result.

    Also deletes the on-disk cache so that a subsequent cold build does
    not immediately reload a stale disk snapshot and skip the fresh build.
    This is essential for test isolation: without the disk delete, tests
    that call invalidate_models_cache() still get back the previous test's
    result from the disk cache because the disk hit is checked before the memory
    cache rebuild runs.

    ``delete_disk=False`` keeps the fingerprint-guarded disk snapshot (profile switch).
    """
    global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts
    global _available_models_live_rebuild_ts, _available_models_cache_source_fingerprint, _cache_build_cv
    with _available_models_cache_lock:
        _available_models_cache = None
        _available_models_cache_ts = 0.0
        _available_models_live_rebuild_ts = 0.0
        _available_models_cache_source_fingerprint = None
        _sync_models_cache_provenance()
        _cache_build_in_progress = False
        _cache_build_cv.notify_all()
        # Clear the credential pool cache too (all profiles). Without this,
        # tests (and live provider key edits) see a stale CredentialPool from a
        # prior auth_store payload — the test_credential_pool_providers suite was
        # hitting this directly. A full reset is intentionally profile-wide.
        _CREDENTIAL_POOL_CACHE.clear()
    # Also delete the disk cache so the next cold build starts fresh.
    # Disk delete is outside the lock — file I/O shouldn't block other readers.
    if delete_disk:
        _delete_models_cache_on_disk()
    try:
        from api.plugin_providers import invalidate_plugin_model_provider_cache

        invalidate_plugin_model_provider_cache()
    except Exception:
        pass


def invalidate_credential_pool_cache(provider_id: str):
    """Invalidate the credential pool cache for a specific provider.

    Used by the streaming layer's credential self-heal logic (#1401) to
    force a fresh credential pool load after re-reading auth.json.
    """
    global _CREDENTIAL_POOL_CACHE
    with _available_models_cache_lock:
        _cp_tag = _credential_pool_profile_tag()
        _CREDENTIAL_POOL_CACHE.pop((_cp_tag, provider_id), None)
        _CREDENTIAL_POOL_CACHE.pop((_cp_tag, _resolve_provider_alias(provider_id)), None)
    try:
        # api.providers imports from api.config; keep this lazy to avoid
        # import-cycle/module-initialization issues.
        from api.providers import invalidate_account_usage_status_cache

        invalidate_account_usage_status_cache(provider_id)
        invalidate_account_usage_status_cache(_resolve_provider_alias(provider_id))
    except Exception:
        logger.debug("Failed to invalidate account usage status cache", exc_info=True)


def invalidate_provider_models_cache(provider_id: str):
    """Invalidate cached models for a single provider.

    Also invalidates the full cache so that the next get_available_models()
    call rebuilds all groups cleanly (the rebuilt provider is merged with any
    other cached groups from the 24h TTL window).  After the next
    get_available_models() call, _provider_models_invalidated_ts[provider_id]
    is cleared so the provider's fresh models are used.

    Args:
        provider_id: canonical provider id (e.g. 'openai', 'anthropic', 'custom:my-key')
    """
    global _available_models_cache, _available_models_cache_ts
    global _available_models_live_rebuild_ts, _available_models_cache_source_fingerprint, _CREDENTIAL_POOL_CACHE
    with _available_models_cache_lock:
        _available_models_cache = None
        _available_models_cache_ts = 0.0
        _available_models_live_rebuild_ts = 0.0
        _available_models_cache_source_fingerprint = None
        _sync_models_cache_provenance()
        _provider_models_invalidated_ts[provider_id] = time.time()
        # Also evict the credential pool so the next cold path re-loads it.
        # Must evict both the original key and its canonical form (load_pool
        # may be called with either, and both paths cache under their own key),
        # scoped to the active profile's cache key.
        _cp_tag = _credential_pool_profile_tag()
        _CREDENTIAL_POOL_CACHE.pop((_cp_tag, provider_id), None)
        _CREDENTIAL_POOL_CACHE.pop((_cp_tag, _resolve_provider_alias(provider_id)), None)
    _delete_models_cache_on_disk()


def _get_label_for_model(model_id: str, existing_groups: list) -> str:
    """Return a human-friendly label for *model_id*.

    Resolution order:
    1. If the model already appears in *existing_groups* with a label, use it.
    2. Strip @provider: prefix and namespace prefix, then title-case.

    This ensures the injected default model entry in the dropdown always shows
    the same label as the live-fetched or static-catalog version, rather than
    the raw lowercase ID string (#909).
    """
    # Strip @provider: prefix for lookup
    lookup_id = model_id
    if lookup_id.startswith("@") and ":" in lookup_id:
        lookup_id = lookup_id.split(":", 1)[1]

    # Check existing groups for a matching label.
    # Skip slash stripping for URI-scheme IDs (e.g. gpt://folder/model) (#3429).
    _has_scheme = lambda s: "://" in s
    _norm = lambda s: (s.split("/", 1)[-1] if ("/" in s and not _has_scheme(s)) else s).replace("-", ".").lower()
    norm_lookup = _norm(lookup_id)
    for g in existing_groups:
        for m in g.get("models", []):
            if m.get("label") and _norm(str(m.get("id", ""))) == norm_lookup:
                return m["label"]

    # Fall back: strip only the first slash-segment (provider prefix),
    # preserving vendor hierarchy for multi-slash IDs (#3360).
    # Skip for URI-scheme IDs whose slashes are path separators (#3429).
    bare = lookup_id.split("/", 1)[1] if ("/" in lookup_id and not _has_scheme(lookup_id)) else lookup_id
    # Bedrock/Vertex IDs carry a dotted cross-region routing prefix and a vendor
    # namespace -- ``us.anthropic.claude-opus-5``,
    # ``mistral.mistral-large-2407-v1:0`` -- plus sometimes a trailing ``:<n>``
    # provisioned-revision suffix. None of that belongs in a human label, which
    # otherwise reads "Us.anthropic.claude Opus 5" in the turn footer.
    #
    # Only the two documented shapes are stripped, against a CLOSED allow-list.
    # A generic "drop leading letters-only dot segments" loop rewrites any
    # uncatalogued dotted ID: ``deepseek.v3`` renders as "V3" (vendor silently
    # deleted) and ``foo.bar.baz`` as "BAZ".
    #
    # Inlined rather than factored into a module-level helper because the
    # regression harnesses in tests/test_issue3429_* extract this function's
    # source and eval it in isolation; a module-level call would NameError there.
    # Kept in lockstep with ``_stripDottedModelPrefix()`` in static/ui.js --
    # tests/test_dotted_model_label.py drives both from one table.
    if bare and "." in bare and not _has_scheme(bare):
        # ``global`` is a real Bedrock routing head, not just a region code --
        # the catalog at api/config.py:1901-1909 ships six
        # ``global.anthropic.claude-*`` IDs and the routing notes below use that
        # as the canonical Bedrock shape. Omitting it left those labels reading
        # "Global.anthropic.claude Opus 4 7".
        _regions = {"us", "eu", "apac", "global", "us-gov"}
        _vendors = {
            "anthropic", "amazon", "meta", "mistral", "cohere", "ai21",
            "stability", "writer", "deepseek", "qwen", "openai", "google",
            # Bedrock foundation-model vendors added after the first pass. Without
            # these, real IDs rendered with the namespace intact -- "Us.luma.ray 2",
            # "Twelvelabs.marengo Embed 2 7", "Ibm.granite 3 8B Instruct".
            "luma", "twelvelabs", "ibm", "nvidia", "snowflake",
        }
        _segs = bare.split(".")
        _i = 0
        if (len(_segs) - _i >= 3 and _segs[_i].lower() in _regions
                and _segs[_i + 1].lower() in _vendors):
            _i += 1
        if len(_segs) - _i >= 2 and _segs[_i].lower() in _vendors:
            # Dropping the vendor is only safe when what remains still names the
            # model. A bare version remainder (``deepseek.v3``) means the vendor
            # WAS the name.
            _rest = ".".join(_segs[_i + 1:])
            if not re.fullmatch(r"v?\d+(?:[.\-]\d+)*", _rest, re.IGNORECASE):
                _i += 1
        if _i > 0:
            bare = re.sub(r":\d+$", "", ".".join(_segs[_i:]))
    return " ".join(
        w.upper() if (len(w) <= 3 and w.replace(".", "").isalnum() and not w.isdigit()) else w.capitalize()
        for w in bare.replace("_", "-").split("-")
    )


def _read_live_provider_model_ids(provider_id: str) -> list[str]:
    """Return live model IDs from Hermes CLI for a provider, or [] on failure.

    WebUI's static ``_PROVIDER_MODELS`` table is only a fallback.  The agent CLI
    owns the provider registry and catalog-discovery logic, so ordinary picker
    groups should ask ``hermes_cli.models.provider_model_ids()`` first (#1240).
    Provider aliases are tried as a secondary lookup because WebUI keeps a few
    display-facing IDs (for example ``google`` / ``x-ai``) that Hermes CLI may
    normalize internally.
    """
    pid = str(provider_id or "").strip()
    if not pid:
        return []
    try:
        from hermes_cli.models import provider_model_ids as _provider_model_ids
    except Exception:
        return []

    candidates = [pid]
    try:
        alias = _resolve_provider_alias(pid)
    except Exception:
        alias = ""
    if alias and alias not in candidates:
        candidates.append(alias)

    seen: set[str] = set()
    for candidate in candidates:
        try:
            live_ids = _provider_model_ids(candidate) or []
        except Exception:
            logger.debug("Failed to load %s models from hermes_cli", candidate)
            continue
        result: list[str] = []
        for mid in live_ids:
            mid_s = str(mid or "").strip()
            if mid_s and mid_s not in seen:
                seen.add(mid_s)
                result.append(mid_s)
        if result:
            return result
    return []


def _hermes_cli_supports_opencode_go_live_catalog() -> bool:
    """Whether the installed Agent has Go-specific model discovery.

    Hermes core versions before 0.20.5 route ``opencode-go`` through a
    generic public catalog. That lookup can return a convincing non-empty
    list containing models the Go relay rejects with 404, so absence or an
    unparseable/prerelease version must fail closed to WebUI's static Go list.
    """
    try:
        import hermes_cli

        version = str(getattr(hermes_cli, "__version__", "")).strip()
    except Exception:
        return False
    match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", version)
    if not match:
        return False
    return tuple(int(part) for part in match.groups()) >= (0, 20, 5)


def _models_from_live_provider_ids(provider_id: str, live_ids: list[str]) -> list[dict]:
    """Convert Hermes CLI model ids into WebUI picker model entries."""
    formatter = _format_ollama_label if provider_id in ("ollama", "ollama-cloud") else None
    models: list[dict] = []
    seen: set[str] = set()
    for mid in live_ids:
        mid_s = str(mid or "").strip()
        if not mid_s or mid_s in seen:
            continue
        seen.add(mid_s)
        label = formatter(mid_s) if formatter else _get_label_for_model(mid_s, [])
        models.append({"id": mid_s, "label": label})
    return models


def _moa_preset_models_from_config(config_obj: dict | None = None) -> list[dict]:
    """Return enabled MoA presets from local config as picker model entries."""
    source = config_obj if isinstance(config_obj, dict) else cfg
    moa_cfg = source.get("moa") if isinstance(source, dict) else None
    if not isinstance(moa_cfg, dict) or not bool(moa_cfg.get("enabled", True)):
        return []
    presets = moa_cfg.get("presets")
    if not isinstance(presets, dict):
        return []
    models: list[dict] = []
    seen: set[str] = set()
    for name, preset_cfg in presets.items():
        preset_name = str(name or "").strip()
        if not preset_name or preset_name in seen:
            continue
        if isinstance(preset_cfg, dict) and preset_cfg.get("enabled") is False:
            continue
        seen.add(preset_name)
        models.append({"id": preset_name, "label": preset_name})
    return models


def _read_visible_codex_cache_model_ids() -> list[str]:
    """Return visible model slugs from Codex's local models_cache.json.

    The agent's provider_model_ids('openai-codex') intentionally filters IDs
    with ``supported_in_api: false``. Codex CLI still lists some of those models
    in its picker (notably ``gpt-5.3-codex-spark`` from #1680), so the WebUI
    merges this visible local catalog to stay in sync with Codex itself.
    """
    codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser()
    cache_path = codex_home / "models_cache.json"
    try:
        payload = json.loads(cache_path.read_text(encoding="utf-8"))
    except Exception:
        return []

    entries = payload.get("models") if isinstance(payload, dict) else None
    if not isinstance(entries, list):
        return []

    sortable: list[tuple[int, str]] = []
    for item in entries:
        if not isinstance(item, dict):
            continue
        slug = item.get("slug")
        if not isinstance(slug, str) or not slug.strip():
            continue
        visibility = item.get("visibility", "")
        if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"):
            continue
        priority = item.get("priority")
        rank = int(priority) if isinstance(priority, (int, float)) else 10_000
        sortable.append((rank, slug.strip()))

    sortable.sort(key=lambda item: (item[0], item[1]))
    ordered: list[str] = []
    for _, slug in sortable:
        if slug not in ordered:
            ordered.append(slug)
    return ordered


def get_available_models(*, prefer_cache: bool = False, force_refresh: bool = False) -> dict:
    """
    Return available models grouped by provider.

    Discovery order:
      1. Read config.yaml 'model' section for active provider info
      2. Check for known API keys in env or ~/.hermes/.env
      3. Fetch models from custom endpoint if base_url is configured
      4. Fall back to hardcoded model list (OpenRouter-style)

    Returns: {
        'active_provider': str|None,
        'default_model': str,
        'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}]
    }

    ``prefer_cache=True`` resolves WITHOUT ever triggering a live provider
    probe: it serves the warm in-memory cache, then the last-known on-disk
    cache, and only as a last resort a network-free minimal catalog
    (config/auth derived). It NEVER does the per-provider live rebuild (the
    Copilot token-exchange HTTPS call et al.). This is the path a
    server-initiated wakeup turn (Option Z) takes so a cold catalog can never
    block the wakeup chat/start on a flaky network. A normal human request
    leaves this False and keeps the full live-discovery behaviour.

    ``force_refresh=True`` is an internal escape hatch for bounded freshness
    checks that need a real live rebuild while preserving the default cache
    contract for every existing caller.
    """
    global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts
    global _available_models_live_rebuild_ts, _available_models_cache_source_fingerprint, _cache_build_cv
    # Config mtime check — must come before any config reads.
    # (Test #585 verifies _current_mtime appears before active_provider = None)
    try:
        _current_path = _get_config_path()
        _current_mtime = _current_path.stat().st_mtime
    except OSError:
        _current_path = _get_config_path()
        _current_mtime = 0.0
    path_changed = _current_path != _cfg_path
    mtime_stale = _current_mtime != _cfg_mtime
    if path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()):
        reload_config_if_stale()
    # ── COLD PATH helper ─────────────────────────────────────────────────────
    # Extracted so it runs inside _available_models_cache_lock (RLock) to
    # prevent thundering-herd: only one thread rebuilds while others wait.
    def _build_available_models_uncached() -> dict:
        active_provider = None
        default_model = get_effective_default_model(cfg)
        groups = []

        def _norm_model_id(model_id: str) -> str:
            s = str(model_id or "").strip().lower()
            stripped_at_provider = False
            # Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5).
            # Defensive: if the last segment is empty (trailing colon, malformed
            # config), keep the original to avoid collapsing distinct IDs to ''.
            if s.startswith("@") and ":" in s:
                # Strip @provider: prefix, preserving remaining hierarchy including
                # colon-suffixed model IDs like provider/model:free (#3959).
                colon_idx = s.index(":", 1)
                candidate = s[colon_idx + 1:]
                stripped_at_provider = bool(candidate)
                s = candidate or s
            # Skip slash-based stripping for URI-scheme IDs (e.g.
            # gpt://folder/model/latest) whose slashes are path separators,
            # not provider delimiters (#3429).
            if "://" not in s:
                if (
                    not stripped_at_provider
                    and "/" in s
                    and ":" in s
                    and s.index(":") < s.index("/")
                ):
                    s = s[s.index("/") + 1 :] or s
                # Strip only the first slash-segment (provider prefix), preserving
                # any remaining vendor hierarchy.  Using parts[-1] here previously
                # discarded ALL segments except the last, collapsing distinct
                # multi-slash IDs like 'vendor_a/deepseek-v4-pro' and
                # 'vendor_b/deepseek/deepseek-v4-pro' to the same key (#3360).
                if "/" in s:
                    stripped = s.split("/", 1)[1]
                    s = stripped or s
            return s.replace("-", ".")

        def _build_configured_model_badges() -> dict[str, dict[str, str]]:
            configured_entries: list[dict[str, str]] = []
            if active_provider and default_model:
                configured_entries.append(
                    {
                        "provider": active_provider,
                        "model": default_model,
                        "role": "primary",
                        "label": "Primary",
                    }
                )
            fallback_cfg = cfg.get("fallback_providers", [])
            if isinstance(fallback_cfg, list):
                for idx, entry in enumerate(fallback_cfg, start=1):
                    if not isinstance(entry, dict):
                        continue
                    provider = _resolve_provider_alias(entry.get("provider"))
                    model = str(entry.get("model") or "").strip()
                    if not provider or not model:
                        continue
                    configured_entries.append(
                        {
                            "provider": provider,
                            "model": model,
                            "role": "fallback",
                            "label": f"Fallback {idx}",
                        }
                    )

            option_ids = [m.get("id", "") for g in groups for m in g.get("models", []) if m.get("id")]
            option_lookup = {str(opt_id): str(opt_id) for opt_id in option_ids}
            option_provider_lookup = {
                str(m.get("id")): str(g.get("provider_id") or "")
                for g in groups
                for m in g.get("models", [])
                if m.get("id")
            }
            norm_lookup: dict[str, list[str]] = {}
            for opt_id in option_ids:
                norm_lookup.setdefault(_norm_model_id(opt_id), []).append(opt_id)

            badges: dict[str, dict[str, str]] = {}
            for entry in configured_entries:
                provider = entry["provider"]
                model = entry["model"]
                raw_candidates: list[str] = []
                for candidate in (
                    model,
                    f"@{provider}:{model}",
                ):
                    if candidate and candidate not in raw_candidates:
                        raw_candidates.append(candidate)

                match_id = None
                exact_match = next((option_lookup[c] for c in raw_candidates if c in option_lookup), None)
                for candidate in raw_candidates:
                    if candidate in option_lookup and option_provider_lookup.get(candidate) == provider:
                        match_id = option_lookup[candidate]
                        break
                if match_id is None:
                    for candidate in raw_candidates:
                        normalized = _norm_model_id(candidate)
                        matches = norm_lookup.get(normalized, [])
                        if not matches:
                            continue
                        provider_match = next(
                            (m for m in matches if option_provider_lookup.get(m) == provider),
                            None,
                        )
                        match_id = provider_match or exact_match or matches[0]
                        if match_id:
                            break

                badge_payload = {"role": entry["role"], "label": entry["label"], "provider": provider}
                for candidate in raw_candidates:
                    candidate_provider = option_provider_lookup.get(candidate)
                    if candidate_provider and candidate_provider != provider:
                        continue
                    badges[candidate] = badge_payload
                if match_id:
                    badges[match_id] = badge_payload
            return badges

        # 1. Read config.yaml model section
        cfg_base_url = ""  # must be defined before conditional blocks (#117)
        model_cfg = cfg.get("model", {})
        cfg_base_url = ""
        if isinstance(model_cfg, str):
            pass  # default_model already set by get_effective_default_model
        elif isinstance(model_cfg, dict):
            active_provider = model_cfg.get("provider")
            cfg_default = model_cfg.get("default", "")
            cfg_base_url = model_cfg.get("base_url", "")
            if cfg_default:
                default_model = cfg_default

        # Normalize active_provider to its canonical key.  Named custom
        # providers are first-class provider ids in WebUI routing; accept the
        # user-facing name from config.yaml (``provider: ollama-local``) and
        # route it through the same ``custom:<name>`` slug the picker emits.
        if active_provider:
            active_provider = _resolve_configured_provider_id(
                active_provider,
                cfg,
                base_url=cfg_base_url,
            )

        # 2. Read auth store (active_provider fallback + credential_pool inspection)
        auth_store = {}
        auth_store_path = _get_auth_store_path()
        if auth_store_path.exists():
            try:
                import json as _j

                auth_store = _j.loads(auth_store_path.read_text(encoding="utf-8"))
                if not active_provider:
                    active_provider = _resolve_configured_provider_id(
                        auth_store.get("active_provider"),
                        cfg,
                        base_url=cfg_base_url,
                    )
            except Exception:
                logger.debug("Failed to load auth store from %s", auth_store_path)

        # 3. Detect available providers.
        detected_providers = set()
        if active_provider:
            detected_providers.add(active_provider)

        try:
            _pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {}
            if isinstance(_pool, dict) and _pool:
                try:
                    from agent.credential_pool import load_pool as _load_pool

                    for _pid in list(_pool.keys()):
                        try:
                            _canonical_pid = _resolve_provider_alias(str(_pid))
                            # Check credential pool cache first (profile-scoped key
                            # so a pool loaded under another profile can't leak in).
                            _ck = (_credential_pool_profile_tag(), _pid)
                            _cached = _CREDENTIAL_POOL_CACHE.get(_ck)
                            if _cached is not None:
                                _cp_ts, _cp_pool = _cached
                                if (time.time() - _cp_ts) < 86400.0:
                                    _all_entries = _cp_pool.entries()
                                else:
                                    _lp_t0 = time.monotonic()
                                    _cp_pool = _load_pool(_pid)
                                    _CREDENTIAL_POOL_CACHE[_ck] = (time.time(), _cp_pool)
                                    _all_entries = _cp_pool.entries()
                            else:
                                _lp_t0 = time.monotonic()
                                _cp_pool = _load_pool(_pid)
                                _CREDENTIAL_POOL_CACHE[_ck] = (time.time(), _cp_pool)
                                _all_entries = _cp_pool.entries()
                            _explicit = [
                                e for e in _all_entries
                                if not _is_ambient_gh_cli_entry(
                                    str(getattr(e, "source", "") or ""),
                                    str(getattr(e, "label", "") or ""),
                                    str(getattr(e, "key_source", "") or ""),
                                )
                            ]
                            if _explicit and _is_known_model_provider(_canonical_pid):
                                detected_providers.add(_canonical_pid)
                        except Exception:
                            logger.debug("credential_pool.load_pool(%s) failed", _pid)
                except ImportError:
                    for _pid, _entries in _pool.items():
                        if not isinstance(_entries, list) or len(_entries) == 0:
                            continue
                        _has_explicit_cred = any(
                            isinstance(_entry, dict)
                            and not _is_ambient_gh_cli_entry(
                                str(_entry.get("source", "") or ""),
                                str(_entry.get("label", "") or ""),
                                str(_entry.get("key_source", "") or ""),
                            )
                            for _entry in _entries
                        )
                        if _has_explicit_cred:
                            _canonical_pid = _resolve_provider_alias(str(_pid))
                            if _is_known_model_provider(_canonical_pid):
                                detected_providers.add(_canonical_pid)
        except Exception:
            logger.debug("Failed to inspect credential_pool from auth store")

        all_env: dict = {}

        _hermes_auth_used = False
        try:
            from hermes_cli.models import list_available_providers as _lap
            from hermes_cli.auth import get_auth_status as _gas

            for _p in _lap():
                if not _p.get("authenticated"):
                    continue
                try:
                    _src = _gas(_p["id"]).get("key_source", "")
                    if _src == "gh auth token":
                        continue
                except Exception:
                    logger.debug("Failed to get key source for provider %s", _p.get("id", "unknown"))
                detected_providers.add(_p["id"])
            _hermes_auth_used = True

            # Belt-and-braces: list_available_providers() is the primary signal
            # for OAuth providers, but its `authenticated` field can disagree
            # with `get_auth_status(<id>).logged_in` on some hermes_cli versions
            # (the two fields are computed via different code paths). When the
            # disagreement happens for Nous Portal, the Settings → Providers
            # card renders the live catalog (because api/providers.py iterates
            # all OAuth providers regardless of authentication state) but the
            # picker dropdown comes up empty — a confusing asymmetry reported
            # in #1567. Add Nous explicitly when get_auth_status agrees so the
            # picker stays in sync with the providers card.
            try:
                if _gas("nous").get("logged_in"):
                    detected_providers.add("nous")
            except Exception:
                logger.debug("Failed to check Nous Portal auth status")
        except Exception:
            logger.debug("Failed to detect auth providers from hermes")

        if not _hermes_auth_used:
            try:
                from api.profiles import get_active_hermes_home as _gah2

                hermes_env_path = _gah2() / ".env"
            except ImportError:
                hermes_env_path = _DEFAULT_HERMES_HOME / ".env"
            env_keys = {}
            if hermes_env_path.exists():
                try:
                    for line in hermes_env_path.read_text(encoding="utf-8").splitlines():
                        line = line.strip()
                        if line and not line.startswith("#") and "=" in line:
                            k, v = line.split("=", 1)
                            env_keys[k.strip()] = v.strip().strip('"').strip("'")
                except Exception:
                    logger.debug("Failed to parse hermes env file")
            all_env = {**env_keys}
            _anthropic_env_vars = _get_anthropic_fallback_env_vars()
            for k in (
                *_anthropic_env_vars,
                "OPENAI_API_KEY",
                "OPENROUTER_API_KEY",
                "GOOGLE_API_KEY",
                "GEMINI_API_KEY",
                "GLM_API_KEY",
                "KIMI_API_KEY",
                "DEEPSEEK_API_KEY",
                "XIAOMI_API_KEY",
                "OPENCODE_ZEN_API_KEY",
                "OPENCODE_GO_API_KEY",
                "OPENCODE_API_KEY",
                "MINIMAX_API_KEY",
                "MINIMAX_CN_API_KEY",
                "XAI_API_KEY",
                "MISTRAL_API_KEY",
                "AWS_ACCESS_KEY_ID",
                "AWS_SECRET_ACCESS_KEY",
            ):
                val = _thread_local_env_value(k).strip()
                if val:
                    all_env[k] = val
            if any(all_env.get(env_var) for env_var in _anthropic_env_vars):
                detected_providers.add("anthropic")
            if all_env.get("OPENAI_API_KEY"):
                # hermes-agent registers its OPENAI_API_KEY/OPENAI_BASE_URL provider
                # under the slug `openai-api` (there is no bare `openai` in the agent
                # registry — only `openai-api` and `openai-codex`). Detecting `openai`
                # here would emit `@openai:` picker entries the agent can't resolve on
                # the send path, so detect `openai-api` to match the registry (#3443).
                detected_providers.add("openai-api")
                # openai-codex uses ChatGPT OAuth (not OPENAI_API_KEY) for its default endpoint.
                # Detecting it here lets users who have both credentials configured find it in the
                # picker without a manual config.yaml edit. Users without Codex OAuth will see
                # picker entries but hit auth errors at inference time (#1189 known limitation).
                detected_providers.add("openai-codex")
            if all_env.get("OPENROUTER_API_KEY"):
                detected_providers.add("openrouter")
            if all_env.get("GOOGLE_API_KEY"):
                detected_providers.add("google")
            if all_env.get("GEMINI_API_KEY"):
                detected_providers.add("gemini")
            if all_env.get("GLM_API_KEY"):
                detected_providers.add("zai")
            if all_env.get("KIMI_API_KEY"):
                detected_providers.add("kimi-coding")
            if all_env.get("MINIMAX_API_KEY"):
                detected_providers.add("minimax")
            if all_env.get("MINIMAX_CN_API_KEY"):
                detected_providers.add("minimax-cn")
            if all_env.get("DEEPSEEK_API_KEY"):
                detected_providers.add("deepseek")
            if all_env.get("XIAOMI_API_KEY"):
                detected_providers.add("xiaomi")
            if all_env.get("XAI_API_KEY"):
                detected_providers.add("x-ai")
            if all_env.get("MISTRAL_API_KEY"):
                detected_providers.add("mistralai")
            if all_env.get("OPENCODE_ZEN_API_KEY") or all_env.get("OPENCODE_API_KEY"):
                detected_providers.add("opencode-zen")
            if all_env.get("OPENCODE_GO_API_KEY") or all_env.get("OPENCODE_API_KEY"):
                detected_providers.add("opencode-go")
            # AWS Bedrock uses IAM credentials rather than a single API key.
            # Detect when both access key and secret are available (#2720).
            if all_env.get("AWS_ACCESS_KEY_ID") and all_env.get("AWS_SECRET_ACCESS_KEY"):
                detected_providers.add("bedrock")
            # LM Studio: detect via LM_API_KEY + LM_BASE_URL in ~/.hermes/.env
            if all_env.get("LM_API_KEY") and all_env.get("LM_BASE_URL"):
                detected_providers.add("lmstudio")

        # Also detect providers explicitly listed in config.yaml providers section.
        # A user may configure a provider key via config.yaml providers.<name>.api_key
        # without setting the corresponding env var. (#604)
        #
        # Gating: only seed picker groups for keys whose canonical id is known
        # to ``_PROVIDER_MODELS`` / ``_PROVIDER_DISPLAY``, or whose value is a
        # dict-shaped provider config (custom/local). Scalar siblings under
        # ``providers:`` (e.g. ``providers.only_configured: true``) are config
        # flags, not providers, and must not render as phantom picker groups
        # like ``Only-Configured`` (#2399).
        #
        # Canonicalise the id slug here so a user with ``providers.opencode_go``
        # (underscore variant) doesn't see TWO provider groups in the picker —
        # one for the canonical ``opencode-go`` from active_provider detection
        # and a phantom ``Opencode_Go`` group for the config-key form (#1568).
        # The same applies to mixed-case ids like ``OpenCode-Go`` and to
        # legitimate aliases like ``z-ai`` → ``zai``.
        _cfg_providers = _get_providers_cfg()
        # Map canonical provider IDs back to raw config keys so the
        # generic-provider branch can preserve mixed-case/underscore
        # provider_cfg values (#2245).
        _canonical_to_raw_provider_key: dict[str, str] = {}
        if isinstance(_cfg_providers, dict):
            for _pid_key, _provider_cfg in _cfg_providers.items():
                _canonical = _canonicalise_provider_id(_pid_key)
                if not _canonical:
                    continue

                # See the gating comment on the block above. ``_PROVIDER_MODELS``
                # / ``_PROVIDER_DISPLAY`` membership accepts known providers and
                # aliases; ``isinstance(_provider_cfg, dict)`` accepts custom
                # entries that supply their own models/api_key/base_url. (#2399)
                _is_known_provider = (
                    _canonical in _PROVIDER_MODELS
                    or _canonical in _PROVIDER_DISPLAY
                    or _is_plugin_model_provider(_canonical)
                )
                _is_provider_config = isinstance(_provider_cfg, dict)
                _has_provider_route = False
                if _is_provider_config:
                    _has_provider_route = any(
                        str(_provider_cfg.get(_route_key) or "").strip()
                        for _route_key in ("api", "base_url", "api_key", "key_env")
                    )
                # A models-only provider config (no api/base_url/api_key/key_env)
                # is only admitted as evidence when it's the active/configured
                # provider (e.g. the lmstudio-style custom shape from #1970).
                # This must NOT re-open the door for a spurious duplicate alias
                # of a known provider (e.g. ``copilot-2: {name: "copilot",
                # models: {...}}`` from #644/dedup regression) — that case is
                # still rejected because it isn't the active provider and it
                # isn't a route-bearing config in its own right.
                _has_models_only_active_route = (
                    not _has_provider_route
                    and _is_provider_config
                    and isinstance(_provider_cfg.get("models"), (dict, list))
                    and _provider_cfg["models"]
                    and _canonical == _canonicalise_provider_id(active_provider)
                )
                # A known provider listed in config.yaml without route
                # configuration should only appear in the picker when it was
                # already detected from credential sources (env vars, hermes
                # auth, credential pool).  Otherwise a provider with
                # metadata-only entries in config.yaml (e.g.
                # ``openai-api: {name: "OpenAI API"}``) would still render
                # in the model selector after the API key is removed (#6335).
                # Resolve provider aliases on both sides so an alias-named
                # config key (e.g. ``x-ai`` in providers, ``google`` in
                # config.yaml) matches credential evidence reported under the
                # agent's canonical alias (``xai``, ``gemini``) (#6338).
                # Normalise detected_providers entries into the same
                # alias-resolved namespace as _canonical so that a WebUI
                # canonical form in detected_providers (e.g. ``x-ai`` added
                # by a prior loop iteration) also matches (#6338).
                _resolved_detected = {
                    _resolve_provider_alias(_pid) for _pid in detected_providers
                }
                _already_credentialed = (
                    _resolve_provider_alias(_canonical) in _resolved_detected
                    or _canonical in _resolved_detected
                )
                _admit_as_known = _is_known_provider and _already_credentialed
                if not (_admit_as_known or _has_provider_route or _has_models_only_active_route):
                    continue

                _canonical_to_raw_provider_key.setdefault(_canonical, _pid_key)
                detected_providers.add(_canonical)

        def _configured_provider_for_base_url(base_url: object) -> str:
            target = _normalize_base_url_for_match(base_url)
            if not target:
                return ""

            if isinstance(model_cfg, dict):
                model_base_url = _normalize_base_url_for_match(model_cfg.get("base_url"))
                if model_base_url == target:
                    provider_hint = _resolve_configured_provider_id(
                        model_cfg.get("provider"),
                        cfg,
                        base_url=base_url,
                    )
                    if provider_hint:
                        return str(provider_hint).strip().lower()

            providers_cfg = cfg.get("providers", {})
            if isinstance(providers_cfg, dict):
                for provider_key, provider_cfg in providers_cfg.items():
                    if not isinstance(provider_cfg, dict):
                        continue
                    provider_base_url = _normalize_base_url_for_match(
                        provider_cfg.get("base_url")
                    )
                    if provider_base_url == target:
                        provider_hint = _resolve_provider_alias(provider_key)
                        if provider_hint:
                            return str(provider_hint).strip().lower()

            custom_providers_cfg = cfg.get("custom_providers", [])
            if isinstance(custom_providers_cfg, list):
                for entry in custom_providers_cfg:
                    if not isinstance(entry, dict):
                        continue
                    entry_base_url = _normalize_base_url_for_match(entry.get("base_url"))
                    if entry_base_url != target:
                        continue
                    entry_name = str(entry.get("name") or "").strip()
                    if entry_name:
                        return _custom_provider_slug_from_name(entry_name)
                    return "custom"

            return ""

        def _models_endpoint_for_base_url(base_url: str) -> str:
            base = str(base_url or "").strip().rstrip("/")
            if base.endswith("/v1"):
                return base + "/models"
            return base + "/v1/models"

        def _extract_model_entries_from_payload(data: object, provider: str) -> list[dict]:
            models_list = []
            if isinstance(data, dict):
                if "data" in data and isinstance(data["data"], list):
                    models_list = data["data"]
                elif "models" in data and isinstance(data["models"], list):
                    models_list = data["models"]
            models = []
            seen = set()
            for model in models_list:
                if not isinstance(model, dict):
                    continue
                model_id = (
                    model.get("id", "")
                    or model.get("name", "")
                    or model.get("model", "")
                )
                model_name = model.get("name", "") or model.get("model", "") or model_id
                model_id = str(model_id or "").strip()
                model_name = str(model_name or "").strip()
                if not model_id or not model_name or model_id in seen:
                    continue
                seen.add(model_id)
                label = _format_ollama_label(model_id) if provider in ("ollama", "ollama-cloud") else model_name
                models.append({"id": model_id, "label": label})
            return models

        def _custom_endpoint_error(
            provider: str,
            exc: Exception,
            *,
            code: int | None = None,
        ) -> dict:
            provider_label = str(provider or "custom").replace("custom:", "")
            status_code = code if code is not None else getattr(exc, "code", None)
            if status_code in (401, 403):
                return {
                    "kind": "auth",
                    "code": int(status_code),
                    "message": f"Models endpoint returned {status_code} — check the API key for {provider_label}.",
                }
            if isinstance(status_code, int):
                return {
                    "kind": "http",
                    "code": int(status_code),
                    "message": f"Models endpoint returned {status_code} for {provider_label}; see logs.",
                }
            return {
                "kind": "network",
                "code": None,
                "message": f"Models endpoint unreachable for {provider_label}; verify base_url.",
            }

        def _read_custom_endpoint_models(
            base_url: object,
            provider: str,
            *,
            api_key: object = "",
            trusted_base_urls: tuple[object, ...] = (),
        ) -> tuple[list[dict], dict | None]:
            base = str(base_url or "").strip()
            if not base:
                return [], None
            try:
                import ipaddress
                import urllib.error
                import urllib.request
                import socket

                endpoint_url = _models_endpoint_for_base_url(base)
                headers = {}
                key = str(api_key or "").strip()
                if key:
                    headers["Authorization"] = f"Bearer {key}"

                # User-configured custom provider endpoints are explicitly trusted,
                # but keep the same private-IP guard for non-matching targets used by
                # the legacy active model.base_url path.
                _ssrf_trusted_hosts: set[str] = set()
                for trusted in (base, *trusted_base_urls):
                    _cp_parsed = urlparse(
                        str(trusted) if "://" in str(trusted) else f"http://{trusted}"
                    )
                    if _cp_parsed.hostname:
                        _ssrf_trusted_hosts.add(_cp_parsed.hostname.lower())

                parsed_url = urlparse(endpoint_url if "://" in endpoint_url else f"http://{endpoint_url}")
                if parsed_url.scheme not in ("", "http", "https"):
                    raise ValueError(f"Invalid URL scheme: {parsed_url.scheme}")
                if parsed_url.hostname:
                    try:
                        resolved_ips = socket.getaddrinfo(parsed_url.hostname, None)
                        for _, _, _, _, addr in resolved_ips:
                            addr_obj = ipaddress.ip_address(addr[0])
                            if addr_obj.is_private or addr_obj.is_loopback or addr_obj.is_link_local:
                                host_l = (parsed_url.hostname or "").lower()
                                is_known_local = any(
                                    k in host_l
                                    for k in ("ollama", "localhost", "127.0.0.1", "lmstudio", "lm-studio")
                                ) or host_l in _ssrf_trusted_hosts
                                if not is_known_local:
                                    raise ValueError(f"SSRF: resolved hostname to private IP {addr[0]}")
                    except socket.gaierror:
                        pass

                req = urllib.request.Request(endpoint_url, method="GET")
                req.add_header("User-Agent", "OpenAI/Python 1.0")
                for k, v in headers.items():
                    req.add_header(k, v)
                with urllib.request.urlopen(req, timeout=CUSTOM_MODELS_ENDPOINT_TIMEOUT_SECONDS) as response:  # nosec B310
                    data = json.loads(response.read().decode("utf-8"))
                return _extract_model_entries_from_payload(data, provider), None
            except urllib.error.HTTPError as exc:
                error = _custom_endpoint_error(provider, exc, code=getattr(exc, "code", None))
                logger.debug("Custom endpoint models fetch failed for provider %s: %s", provider, error)
                return [], error
            except Exception as exc:
                error = _custom_endpoint_error(provider, exc)
                logger.debug("Custom endpoint unreachable or misconfigured for provider %s: %s", provider, error)
                return [], error

        # 4. Fetch models from custom endpoint if base_url is configured
        auto_detected_models = []
        auto_detected_models_by_provider: dict[str, list[dict]] = {}
        if cfg_base_url:
            base_url = cfg_base_url.strip()
            configured_provider = _configured_provider_for_base_url(base_url)
            provider = configured_provider or "custom"
            provider_from_config = bool(configured_provider)
            parsed = urlparse(base_url if "://" in base_url else f"http://{base_url}")
            host = (parsed.netloc or parsed.path).lower()

            if parsed.hostname and not provider_from_config:
                try:
                    import ipaddress

                    addr = ipaddress.ip_address(parsed.hostname)
                    if addr.is_private or addr.is_loopback or addr.is_link_local:
                        if "ollama" in host or "127.0.0.1" in host or "localhost" in host:
                            provider = "ollama"
                        elif "lmstudio" in host or "lm-studio" in host:
                            provider = "lmstudio"
                        else:
                            # Unknown loopback/private endpoint: route through
                            # the generic ``custom`` provider so the agent's
                            # auxiliary client (compression, vision, web
                            # extraction) takes the OpenAI-compat custom path
                            # with ``no-key-required`` semantics. Writing
                            # ``provider: local`` here used to break
                            # compression mid-conversation because ``local``
                            # is not a registered provider in
                            # ``hermes_cli.auth.PROVIDER_REGISTRY`` — see #1384.
                            provider = "custom"
                except ValueError:
                    pass

            api_key = ""
            if isinstance(model_cfg, dict):
                api_key = (model_cfg.get("api_key") or "").strip()
            if not api_key:
                providers_cfg = cfg.get("providers", {})
                if isinstance(providers_cfg, dict):
                    for provider_key in filter(None, [active_provider, "custom"]):
                        provider_cfg = providers_cfg.get(provider_key, {})
                        if isinstance(provider_cfg, dict):
                            api_key = (provider_cfg.get("api_key") or "").strip()
                            if api_key:
                                break
            if not api_key:
                api_key_vars = (
                    "HERMES_API_KEY",
                    "HERMES_OPENAI_API_KEY",
                    "OPENAI_API_KEY",
                    "LOCAL_API_KEY",
                    "OPENROUTER_API_KEY",
                    "API_KEY",
                )
                for key in api_key_vars:
                    api_key = (all_env.get(key) or _thread_local_env_value(key) or "").strip()
                    if api_key:
                        break

            _trusted_custom_bases: list[object] = [cfg_base_url]
            _custom_providers_for_trust = cfg.get("custom_providers", [])
            if isinstance(_custom_providers_for_trust, list):
                _trusted_custom_bases.extend(
                    _cp.get("base_url")
                    for _cp in _custom_providers_for_trust
                    if isinstance(_cp, dict) and _cp.get("base_url")
                )
            _active_endpoint_models, _active_endpoint_error = _read_custom_endpoint_models(
                base_url,
                provider,
                api_key=api_key,
                trusted_base_urls=tuple(_trusted_custom_bases),
            )
            for auto_model in _active_endpoint_models:
                auto_detected_models.append(auto_model)
                provider_key = provider.lower()
                auto_detected_models_by_provider.setdefault(provider_key, []).append(auto_model)
                detected_providers.add(provider_key)

        _custom_providers_cfg = cfg.get("custom_providers", [])
        _named_custom_groups: dict = {}
        _named_custom_errors: dict[str, dict] = {}
        if isinstance(_custom_providers_cfg, list):
            _seen_custom_ids = set()
            for _cp in _custom_providers_cfg:
                if not isinstance(_cp, dict):
                    continue
                _cp_name = (_cp.get("name") or "").strip()
                _slug = _custom_provider_slug_from_name(_cp_name) if _cp_name else None
                if _slug and _slug not in _named_custom_groups:
                    _named_custom_groups[_slug] = (_cp_name, [])

                _cp_base_url = str(_cp.get("base_url") or "").strip()
                _cp_api_key = str(_cp.get("api_key") or "").strip()
                if not _cp_api_key:
                    _cp_key_env = str(_cp.get("key_env") or "").strip()
                    if _cp_key_env:
                        _cp_api_key = _thread_local_env_value(_cp_key_env).strip()
                # Fallback: check credential pool for both api_key and base_url
                if (not _cp_api_key or not _cp_base_url) and _slug:
                    try:
                        from api.config import _has_explicit_pool_credentials
                        if _has_explicit_pool_credentials(_slug):
                            from agent.credential_pool import load_pool
                            _resolved = _resolve_provider_alias(_slug)
                            _pool = load_pool(_resolved)
                            if _pool:
                                _entry = _pool.select()
                                if _entry:
                                    if not _cp_api_key:
                                        _cp_api_key = getattr(_entry, "runtime_api_key", "") or ""
                                    if not _cp_base_url:
                                        _cp_base_url = str(getattr(_entry, "base_url", "") or "").strip()
                    except ImportError:
                        pass

                if _slug and _cp_base_url:
                    # Check if user has configured models in config.yaml —
                    # configured models take priority over live /v1/models
                    # discovery (same as hermes-agent model_switch.py Section 4
                    # patch). Without this check, ZenMux and similar aggregator
                    # gateways would show hundreds of online models instead of
                    # the user's curated list.
                    _cp_configured_models = _cp.get("models")
                    _cp_has_configured_models = (
                        isinstance(_cp_configured_models, (dict, list))
                        and len(_cp_configured_models) > 0
                    )
                    _live_models = auto_detected_models_by_provider.get(_slug)
                    _live_error = None
                    if _cp_has_configured_models:
                        # Skip the live /v1/models probe when an allowlist
                        # exists — the curated list wins and probe failures
                        # should not surface as a user-facing diagnostic in
                        # that case. Still respect any pre-warm result that
                        # ``auto_detected_models_by_provider`` already
                        # populated (cheap to keep).
                        if _live_models is None:
                            _live_models = []
                    elif _live_models is None:
                        _live_models, _live_error = _read_custom_endpoint_models(
                            _cp_base_url,
                            _slug,
                            api_key=_cp_api_key,
                            trusted_base_urls=(_cp_base_url,),
                        )
                    if _live_error:
                        _named_custom_errors[_slug] = _live_error
                        detected_providers.add(_slug)
                    for _live_model in _live_models:
                        _live_id = str(_live_model.get("id") or "").strip()
                        if not _live_id:
                            continue
                        _dedup_key = f"{_slug}:{_live_id}"
                        if _dedup_key in _seen_custom_ids:
                            continue
                        _seen_custom_ids.add(_dedup_key)
                        detected_providers.add(_slug)
                        _cp_option_id = _live_id
                        if active_provider != _slug and not _cp_option_id.startswith("@"):
                            _cp_option_id = f"@{_slug}:{_cp_option_id}"
                        _named_custom_groups[_slug][1].append(
                            {"id": _cp_option_id, "label": _live_model.get("label") or _get_label_for_model(_live_id, [])}
                        )

                # Collect configured model IDs as a fallback/sticky entry after live discovery.
                _cp_model_ids: list[str] = []
                _cp_model = _cp.get("model", "")
                if _cp_model:
                    _cp_model_ids.append(_cp_model)
                for _cp_model_id in _configured_model_ids(_cp.get("models")):
                    if _cp_model_id not in _cp_model_ids:
                        _cp_model_ids.append(_cp_model_id)

                for _cp_model in _cp_model_ids:
                    _dedup_key = f"{_slug}:{_cp_model}" if _slug else _cp_model
                    if _cp_model and _dedup_key not in _seen_custom_ids:
                        _cp_label = _get_label_for_model(_cp_model, [])
                        _seen_custom_ids.add(_dedup_key)
                        if _slug:
                            detected_providers.add(_slug)
                            _cp_option_id = _cp_model
                            if active_provider != _slug and not _cp_option_id.startswith("@"):
                                _cp_option_id = f"@{_slug}:{_cp_option_id}"
                            _named_custom_groups[_slug][1].append(
                                {"id": _cp_option_id, "label": _cp_label}
                            )
                        else:
                            auto_detected_models.append({"id": _cp_model, "label": _cp_label})
                            detected_providers.add("custom")

        _has_custom_providers = isinstance(_custom_providers_cfg, list) and len(_custom_providers_cfg) > 0
        if active_provider and active_provider != "custom" and not _has_custom_providers:
            detected_providers.discard("custom")
            for _slug in list(detected_providers):
                if _slug.startswith("custom:") and not _has_custom_providers:
                    detected_providers.discard(_slug)
        elif active_provider == "custom" and _has_custom_providers:
            _has_unnamed = any(
                isinstance(_cp, dict) and not (_cp.get("name") or "").strip()
                for _cp in _custom_providers_cfg
            )
            if not _has_unnamed:
                detected_providers.discard("custom")

        _named_custom_slugs = _named_custom_provider_slugs(cfg)
        _base_matched_named_slug = _named_custom_provider_slug_for_base_url(cfg_base_url, cfg)
        if _base_matched_named_slug and _named_custom_slugs:
            for _pid in list(detected_providers):
                _pid_norm = str(_pid or "").strip().lower()
                if _pid_norm.startswith("custom:") and _pid_norm not in _named_custom_slugs:
                    detected_providers.discard(_pid)

        # Filter providers if providers.only_configured is set
        providers_cfg = cfg.get("providers", {})
        only_show_configured = providers_cfg.get("only_configured", False) if isinstance(providers_cfg, dict) else False
        if only_show_configured:
            configured_providers = set()
            if active_provider:
                configured_providers.add(active_provider)
            cfg_providers = cfg.get("providers", {})
            if isinstance(cfg_providers, dict):
                # Canonicalise here too — same rationale as #1568 detection
                # path. Without this, only_show_configured mode could
                # exclude detected ``opencode-go`` because configured_providers
                # only has the underscore-variant key from config.yaml.
                configured_providers.update(
                    _canonicalise_provider_id(k) or k for k in cfg_providers.keys()
                )
            # Only show providers that are both detected and configured
            detected_providers = detected_providers.intersection(configured_providers)

        # Post-collection dedup: re-canonicalise every entry so any path that
        # added a non-canonical id (mixed-case from auth-store, raw config-key,
        # legacy alias) gets folded onto the canonical key. Belt-and-braces for
        # #1568 — protects against future regressions in any of the ~25
        # `detected_providers.add(...)` callsites without auditing each one.
        # The fold is idempotent for already-canonical ids, so safe to run
        # unconditionally.
        if detected_providers:
            _canonicalised_detected = set()
            for _pid in detected_providers:
                _c = _canonicalise_provider_id(_pid) or _pid
                _canonicalised_detected.add(_c)
            detected_providers = _canonicalised_detected

        try:
            _moa_cfg = cfg.get("moa") if isinstance(cfg, dict) else None
            if isinstance(_moa_cfg, dict):
                _moa_enabled = bool(_moa_cfg.get("enabled", True))
                _moa_presets = _moa_cfg.get("presets")
                if _moa_enabled and isinstance(_moa_presets, dict) and _moa_presets:
                    detected_providers.add("moa")
        except Exception:
            logger.debug("Failed to inspect MoA presets for model picker", exc_info=True)

        # 5. Build model groups
        if detected_providers:
            _picker_selected_model_id = (
                (model_cfg.get("model") if isinstance(model_cfg, dict) else None)
                or default_model
                or None
            )

            def _append_picker_group(
                provider_label: str,
                provider_id: str,
                raw_models: list[dict] | None,
                *,
                models_endpoint_error: dict | None = None,
                apply_prefix: bool = True,
                decorate_overflow_label: bool = False,
                allow_empty: bool = False,
            ) -> None:
                picker_models = copy.deepcopy(raw_models or [])
                if _is_openai_family_provider(provider_id):
                    for _model in picker_models:
                        if not isinstance(_model, dict):
                            continue
                        _model_id = str(_model.get("id") or "").strip()
                        if not _model_id:
                            continue
                        _model["supports_fast_tier"] = (
                            str(
                                _resolve_main_model_fast_mode_overrides(_model_id, provider_id).get("service_tier", "")
                            ).strip().lower()
                            == "priority"
                        )
                if apply_prefix:
                    picker_models = _apply_provider_prefix(picker_models, provider_id, active_provider)
                visible_models, extra_models = _split_picker_overflow_models(
                    picker_models,
                    selected_model_id=_picker_selected_model_id,
                    provider_id=provider_id,
                )
                if not (visible_models or extra_models or models_endpoint_error or allow_empty):
                    return
                group_entry = {
                    "provider": provider_label,
                    "provider_id": provider_id,
                    "models": visible_models,
                }
                if decorate_overflow_label and extra_models:
                    group_entry["provider"] = (
                        f"{provider_label} ({len(visible_models)} of {len(visible_models) + len(extra_models)})"
                    )
                if extra_models:
                    group_entry["extra_models"] = extra_models
                if models_endpoint_error:
                    group_entry["models_endpoint_error"] = models_endpoint_error
                groups.append(group_entry)

            for pid in sorted(detected_providers):
                # Custom-provider PIDs are populated above via the
                # _named_custom_groups branch (or skipped intentionally).
                # They MUST NOT fall through to the auto_detected_models
                # fallback below, otherwise the active provider's models
                # get copied into a phantom Custom group with mismatched
                # provider prefixes (#1881).
                if pid.startswith("custom:"):
                    if pid in _named_custom_groups:
                        _nc_display, _nc_models = _named_custom_groups[pid]
                        # If all named-group models were deduped (already auto-detected
                        # from base_url /v1/models), fall back to auto-detected models
                        # instead of silently dropping the group (issue #1619).
                        #
                        # Per Opus advisor on stage-295: the load-bearing fix for the
                        # reporter's symptom is the api/routes.py:/api/models/live
                        # broadening to handle custom:* slugs. This block is defensive
                        # belt-and-braces — under current _named_custom_groups
                        # population logic (atomic add+append inside the same dedup
                        # guard at line ~2640), an empty list shouldn't reach here.
                        # Kept for future-proofing in case the population logic
                        # changes (e.g. supporting model-less custom_providers entries).
                        if not _nc_models:
                            _nc_models = auto_detected_models_by_provider.get(pid, [])
                        if _nc_models or pid in _named_custom_errors:
                            _append_picker_group(
                                _nc_display,
                                pid,
                                _nc_models,
                                models_endpoint_error=_named_custom_errors.get(pid),
                                apply_prefix=False,
                            )
                    continue
                provider_name = _effective_provider_display_name(pid, _PROVIDER_DISPLAY)
                if pid == "openrouter":
                    # OpenRouter has two model surfaces:
                    #   (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
                    #       — the canonical agent-ready list, applies a tool-support filter
                    #       (Kilo-Org/kilocode#9068) that hides image/completion-only models
                    #   (2) free-tier `:free` variants — newly-added models OpenRouter ships
                    #       experimentally that may not yet advertise `tools` in supported_parameters
                    #       (see #1426). These get filtered out of (1) but users want them visible.
                    #
                    # Strategy: take the live curated list as the base, then augment with a
                    # separate live-fetch of OpenRouter's /v1/models filtered to free-tier-only.
                    # Free-tier entries get a "(free)" label suffix so the picker is honest about
                    # what the user is selecting. Falls back to the static _FALLBACK_MODELS list
                    # when both live fetches fail (offline, transient API error, test env).
                    raw_models = []
                    seen_ids = set()
                    try:
                        from hermes_cli.models import (
                            fetch_openrouter_models as _fetch_or_models,
                        )
                        live_curated = _fetch_or_models() or []
                        for mid, _desc in live_curated:
                            if mid and mid not in seen_ids:
                                seen_ids.add(mid)
                                # Ship the friendly display name (e.g. "Ox Alpha")
                                # from the local OpenRouter metadata cache instead
                                # of the raw id, so the picker search matches what
                                # users see in Hermes Desktop (#7228).
                                raw_models.append(
                                    {"id": mid, "label": _openrouter_model_display_name(mid)}
                                )
                    except Exception:
                        logger.warning("Failed to load OpenRouter curated catalog from hermes_cli")

                    # Free-tier live fetch — bypasses the tool-support filter so models
                    # OpenRouter has flagged free but hasn't yet annotated with tools=[]
                    # (or that have tools=[] but the user explicitly wants to try) appear.
                    try:
                        import urllib.request as _urlreq
                        _req = _urlreq.Request(
                            "https://openrouter.ai/api/v1/models",
                            headers={"Accept": "application/json"},
                        )
                        free_tier_models = []
                        selected_free_tier_model = None
                        with _urlreq.urlopen(_req, timeout=8.0) as _resp:
                            _payload = json.loads(_resp.read().decode())
                        for _item in _payload.get("data", []) or []:
                            if not isinstance(_item, dict):
                                continue
                            _mid = str(_item.get("id") or "").strip()
                            if not _mid or _mid in seen_ids:
                                continue
                            _pricing = _item.get("pricing")
                            _is_free = False
                            if (
                                isinstance(_pricing, dict)
                                and "prompt" in _pricing
                                and "completion" in _pricing
                            ):
                                try:
                                    _is_free = (
                                        float(_pricing["prompt"]) == 0
                                        and float(_pricing["completion"]) == 0
                                    )
                                except (TypeError, ValueError):
                                    _is_free = False
                            # Also include explicit `:free` suffix variants
                            _is_free = _is_free or _mid.endswith(":free")
                            if not _is_free:
                                continue
                            _name = (
                                str(_item.get("name") or "").strip() or _mid
                            )
                            # Strip provider prefix from name for display, append (free)
                            _label = _name.split("/")[-1] if "/" in _name else _name
                            if "(free)" not in _label.lower():
                                _label = f"{_label} (free)"
                            _entry = {"id": _mid, "label": _label}
                            free_tier_models.append(_entry)
                            if _model_matches_picker_selection(
                                _mid,
                                _picker_selected_model_id,
                                "openrouter",
                            ):
                                selected_free_tier_model = _entry
                        if len(free_tier_models) > _OPENROUTER_FREE_TIER_AUGMENT_CAP:
                            free_tier_models = free_tier_models[:_OPENROUTER_FREE_TIER_AUGMENT_CAP]
                            if (
                                selected_free_tier_model
                                and not any(
                                    m.get("id") == selected_free_tier_model.get("id")
                                    for m in free_tier_models
                                )
                            ):
                                free_tier_models[-1] = selected_free_tier_model
                        for _entry in free_tier_models:
                            seen_ids.add(_entry["id"])
                            raw_models.append(_entry)
                    except Exception:
                        logger.debug("OpenRouter free-tier live fetch unavailable; using fallback")

                    if not raw_models:
                        # Both live fetches failed — fall back to the curated static list.
                        # Deepcopy so dedup/prefix mutation downstream does not bleed
                        # into the module-level catalog.
                        raw_models = [
                            {"id": m["id"], "label": m["label"]}
                            for m in _FALLBACK_MODELS
                            if m.get("provider") == "OpenRouter"
                        ]

                    _append_picker_group("OpenRouter", "openrouter", raw_models)
                elif pid == "ollama-cloud":
                    raw_models = []
                    try:
                        from hermes_cli.models import provider_model_ids as _provider_model_ids

                        raw_models = [
                            {"id": mid, "label": _format_ollama_label(mid)}
                            for mid in (_provider_model_ids("ollama-cloud") or [])
                        ]
                    except Exception:
                        logger.warning("Failed to load Ollama Cloud models from hermes_cli")

                    if raw_models:
                        _append_picker_group(provider_name, pid, raw_models)
                elif pid == "openai-codex":
                    # Codex account catalogs drift faster than WebUI releases
                    # (for example gpt-5.3-codex-spark in #1680). Ask the
                    # agent's Codex resolver first so /api/models inherits the
                    # live Codex API / local ~/.codex cache / static fallback
                    # chain instead of freezing the picker to WebUI's curated
                    # _PROVIDER_MODELS snapshot.
                    raw_models = []
                    codex_ids = []
                    try:
                        from hermes_cli.models import provider_model_ids as _provider_model_ids

                        codex_ids = [mid for mid in (_provider_model_ids("openai-codex") or []) if mid]
                    except Exception:
                        logger.warning("Failed to load OpenAI Codex models from hermes_cli")

                    for mid in _read_visible_codex_cache_model_ids():
                        if mid not in codex_ids:
                            codex_ids.append(mid)

                    raw_models = [
                        {"id": mid, "label": _get_label_for_model(mid, [])}
                        for mid in codex_ids
                    ]

                    if not raw_models:
                        raw_models = copy.deepcopy(_PROVIDER_MODELS.get("openai-codex", []))

                    if raw_models:
                        _append_picker_group(provider_name, pid, raw_models)
                elif pid == "nous":
                    # Nous Portal exposes a curated catalog (~30 models on most
                    # accounts, up to several hundred for enterprise tiers) via
                    # inference-api.nousresearch.com. Like ollama-cloud, we
                    # live-fetch through hermes_cli.models.provider_model_ids()
                    # rather than relying on the static four-entry list, which
                    # chronically drifts out of date (#1538).
                    #
                    # When the catalog exceeds _NOUS_FEATURED_THRESHOLD (~25)
                    # the picker dropdown gets a curated subset to stay
                    # scannable — the full list is still returned under
                    # "extra_models" for the slash-command autocomplete and
                    # the dynamic-label map (#1567). The optgroup label is
                    # decorated with the truncation count so users know more
                    # exists.
                    raw_models = []
                    live_fetch_failed = False
                    try:
                        from hermes_cli.models import provider_model_ids as _provider_model_ids

                        live_ids = _provider_model_ids("nous") or []
                    except Exception:
                        logger.warning("Failed to load Nous Portal models from hermes_cli")
                        live_ids = []
                        live_fetch_failed = True

                    if live_ids:
                        featured_ids, extras_ids = _build_nous_featured_set(
                            live_ids,
                            selected_model_id=_picker_selected_model_id,
                        )
                        ordered_ids = featured_ids + extras_ids
                        raw_models = [
                            {"id": f"@nous:{mid}", "label": _format_nous_label(mid)}
                            for mid in ordered_ids
                        ]
                    elif not live_fetch_failed:
                        # Live-fetch returned an empty list AND did not raise —
                        # the user is gated as authenticated by detection above
                        # but the catalog endpoint replied with no models.
                        # Showing the static 4-entry curated list here would
                        # contradict the providers card (which always shows
                        # the live catalog) — exactly the asymmetry #1567
                        # reports. Omit the Nous group entirely; the providers
                        # card already tells the truth, and a transient empty
                        # response will self-heal on the next cache rebuild.
                        logger.warning(
                            "Nous Portal authenticated but live-fetch returned empty — "
                            "omitting from picker (will retry on next cache rebuild)"
                        )
                    else:
                        # hermes_cli unavailable / raised — fall back to the
                        # curated 4-entry static list so the picker is never
                        # empty in this degraded state. This matches pre-#1538
                        # behaviour for environments without hermes_cli (test
                        # envs, package mismatches, isolated WebUI builds).
                        raw_models = copy.deepcopy(_PROVIDER_MODELS.get("nous", []))

                    if raw_models:
                        _append_picker_group(
                            provider_name,
                            pid,
                            raw_models,
                            apply_prefix=False,
                            decorate_overflow_label=True,
                        )
                elif pid == "lmstudio":
                    # LM Studio is a local server — fetch live loaded models via
                    # the OpenAI-compatible /v1/models endpoint (#WebUI).
                    #
                    # Two-tier lookup, each in its own try so a failure in one
                    # does not abort the other (the bug pattern that broke
                    # tests/test_issue1527_lmstudio_base_url_classification on
                    # CI environments where hermes_cli isn't importable —
                    # ImportError in the cli tier was hijacking the whole
                    # branch and silently skipping the urlopen fallback).
                    raw_models = []
                    lm_ids: list[str] = []
                    try:
                        from hermes_cli.models import provider_model_ids as _provider_model_ids
                        lm_ids = _provider_model_ids("lmstudio") or []
                    except Exception:
                        logger.debug("hermes_cli LM Studio lookup unavailable; using urlopen fallback")

                    if lm_ids:
                        raw_models = [{"id": mid, "label": mid} for mid in lm_ids]
                    else:
                        # Fallback: fetch /models directly from the configured
                        # base URL. Looks for the URL in either
                        # `cfg["providers"]["lmstudio"]["base_url"]` or
                        # `cfg["model"]["base_url"]` (via _get_provider_base_url),
                        # so the historical model-block config shape still works.
                        lm_cfg = _get_provider_cfg("lmstudio")
                        lm_base_url = _get_provider_base_url("lmstudio") or ""
                        lm_api_key = str(lm_cfg.get("api_key") or "").strip()
                        if lm_base_url:
                            headers = {"User-Agent": "OpenAI/Python 1.0"}
                            if lm_api_key:
                                headers["Authorization"] = f"Bearer {lm_api_key}"
                            endpoint = (lm_base_url + "/models").rstrip("/")
                            try:
                                import urllib.request as _urlreq
                                req = _urlreq.Request(endpoint, method="GET", headers=headers)
                                with _urlreq.urlopen(req, timeout=5) as resp:
                                    lm_data = json.loads(resp.read().decode())
                                for m in (lm_data.get("data") or []):
                                    if isinstance(m, dict):
                                        mid = str(m.get("id") or "").strip()
                                        if mid and {"id": mid, "label": mid} not in raw_models:
                                            raw_models.append({"id": mid, "label": mid})
                            except Exception:
                                logger.debug("LM Studio /models fetch failed at %s", endpoint)

                    if raw_models:
                        _append_picker_group(provider_name, pid, raw_models)
                elif (
                    pid in _PROVIDER_MODELS
                    or pid in _PROVIDER_DISPLAY
                    or pid in _canonical_to_raw_provider_key
                    or _is_plugin_model_provider(pid)
                ):
                    # Look up provider_cfg using the original raw key from
                    # config.yaml so that mixed-case / underscore keys like
                    # ``CLIPpoxy`` or ``snake_case_provider`` still resolve
                    # (#2245).  Fall back to the canonical pid for providers
                    # that appear in _PROVIDER_MODELS but not in cfg.
                    _raw_key = _canonical_to_raw_provider_key.get(pid, pid)
                    provider_cfg = _get_provider_cfg(_raw_key)
                    raw_models = []

                    # User-configured model allowlists are explicit local
                    # source-of-truth for custom/plugin providers, AND for most
                    # built-in Hermes providers (e.g. providers.anthropic.models
                    # is a real picker allowlist — see #644). Copilot is the
                    # exception: it uses providers.copilot.models as a per-model
                    # settings map (reasoning_effort, limits, etc.), so treating
                    # that as an allowlist collapsed the Copilot picker to
                    # whichever model had local settings. Only Copilot skips the
                    # config-models allowlist branch and asks Hermes CLI for the
                    # live catalog first (static _PROVIDER_MODELS is fallback only).
                    _uses_models_as_settings_map = (
                        pid == "copilot"
                        or _provider_models_are_discovered_catalog(provider_cfg)
                    )
                    if (
                        not _uses_models_as_settings_map
                        and isinstance(provider_cfg, dict)
                        and "models" in provider_cfg
                    ):
                        raw_models = _configured_model_options(provider_cfg.get("models"))

                    if not raw_models:
                        if pid == "moa":
                            raw_models = _moa_preset_models_from_config(cfg)
                        elif (
                            pid == "opencode-go"
                            and not _hermes_cli_supports_opencode_go_live_catalog()
                        ):
                            # Before core v0.20.5 this resolver returned the
                            # generic public catalog, including models that
                            # 404 on the Go tier. Use the curated Go fallback.
                            raw_models = []
                        else:
                            raw_models = _models_from_live_provider_ids(
                                pid,
                                _read_live_provider_model_ids(pid),
                            )
                            if (
                                not raw_models
                                and _provider_models_are_discovered_catalog(provider_cfg)
                            ):
                                # A transient live-catalog failure must not drop a
                                # provider's persisted discovered models (the empty
                                # result would then be cached for up to 24h). Fall
                                # back to the configured discovered IDs, ordered
                                # first, merged with any static fallback (deduped).
                                raw_models = _merge_model_option_rows(
                                    _configured_model_options(provider_cfg.get("models")),
                                    copy.deepcopy(_PROVIDER_MODELS.get(pid, [])),
                                )

                    if not raw_models:
                        raw_models = copy.deepcopy(_PROVIDER_MODELS.get(pid, []))

                    detected_models = auto_detected_models_by_provider.get(pid, [])
                    if detected_models and not raw_models:
                        raw_models = copy.deepcopy(detected_models)
                    _append_picker_group(provider_name, pid, raw_models)
                else:
                    detected_models = auto_detected_models_by_provider.get(pid)
                    if detected_models:
                        models_for_group = copy.deepcopy(detected_models)
                    elif auto_detected_models and (pid == "custom" or _is_known_model_provider(pid)):
                        # Don't fall back to the global auto_detected_models
                        # list for the bare "custom" PID when the active
                        # provider is something concrete (e.g. ai-gateway,
                        # openrouter). Those auto-detected entries already
                        # belong to the active provider's group — copying
                        # them into a Custom group too produces phantom
                        # duplicates with mismatched prefixes (#1881).
                        if pid == "custom" and active_provider and active_provider != "custom":
                            models_for_group = []
                        else:
                            models_for_group = copy.deepcopy(auto_detected_models)
                    else:
                        # An unrecognized provider id with no catalog of its
                        # own must NOT be painted with the global
                        # auto_detected_models list. Otherwise a non-model
                        # credential_pool key (the Photon plugin's
                        # photon/photon_project/photon_user entries, #4324) or
                        # any future unknown id renders as a phantom provider
                        # carrying the active endpoint's entire model catalog.
                        # Such ids are dropped upstream by
                        # _is_known_model_provider() in the pool-detection
                        # loop; this omission is belt-and-braces matching the
                        # #1572/#7372 "omit rather than misattribute" posture.
                        models_for_group = []
                    if models_for_group:
                        # Per-group deep copy so subsequent mutation by
                        # _deduplicate_model_ids() (which prefixes ids with
                        # @provider_id:) does not bleed into other groups
                        # that also fall through to this branch (#1511 root
                        # cause: multiple unconfigured providers all sharing
                        # the same auto_detected_models list reference would
                        # see every group's id rewritten to the FIRST
                        # provider's prefix, and labels accumulated every
                        # provider's name).
                        _append_picker_group(
                            provider_name,
                            pid,
                            models_for_group,
                            apply_prefix=False,
                        )
                    elif pid == "custom" and cfg_base_url:
                        # Anonymous custom endpoint: /v1/models probe may have
                        # failed (e.g. llama-server, lightweight relay), but the
                        # chat endpoint itself may still work. Add the group
                        # with an empty model list so the user can type a model
                        # ID manually rather than being blocked by a silent
                        # probe failure (#2542).
                        groups.append(
                            {
                                "provider": provider_name,
                                "provider_id": pid,
                                "models": [],
                            }
                        )
        else:
            if default_model:
                label = _get_label_for_model(default_model, groups)
                groups.append(
                    {"provider": "Default", "provider_id": "default", "models": [{"id": default_model, "label": label}]}
                )

        if default_model:
            # Guard against provider-id values mistakenly stored in
            # ``model.default``. The injection logic below puts ANY string
            # into the picker as a fake option, so a stray provider id
            # surfaces as a self-referential phantom model labelled e.g.
            # ``Opencode GO`` — a 15th entry under the OpenCode Go group
            # (#1568). The user's misconfig is real, but the picker is
            # the wrong surface to surface it; we'd rather skip injection
            # and emit a warning so the underlying config issue is logged.
            _looks_like_provider_id = (
                str(default_model).strip().lower().replace("_", "-") in _PROVIDER_DISPLAY
                or _canonicalise_provider_id(default_model) in _PROVIDER_DISPLAY
            )
            if _looks_like_provider_id:
                logger.warning(
                    "Suspicious model.default value %r — looks like a provider id, "
                    "not a model id. Skipping picker injection. Check `model.default` "
                    "in config.yaml.",
                    default_model,
                )
            else:
                all_ids_norm = {
                    _norm_model_id(m["id"])
                    for g in groups
                    for bucket_name in ("models", "extra_models")
                    for m in g.get(bucket_name, [])
                }
                if _norm_model_id(default_model) not in all_ids_norm:
                    label = _get_label_for_model(default_model, groups)
                    target_display = (
                        _PROVIDER_DISPLAY.get(active_provider, active_provider or "").lower()
                        if active_provider
                        else ""
                    )
                    injected = False
                    for g in groups:
                        if target_display and g.get("provider", "").lower() == target_display:
                            g["models"].insert(0, {"id": default_model, "label": label})
                            injected = True
                            break
                    if not injected and groups:
                        groups.append(
                            {
                                "provider": "Default",
                                "provider_id": active_provider or "default",
                                "models": [{"id": default_model, "label": label}],
                            }
                        )

        # Post-process: ensure model IDs are globally unique across groups.
        # When multiple providers expose the same bare model ID, prefix
        # collisions with @provider_id: so the frontend can distinguish them.
        _deduplicate_model_ids(groups)

        # Defense-in-depth: drop any optgroup that ended up with zero models
        # — those are pure UI noise. A zero-model group typically means a
        # detection path added an id that has no static catalog AND the
        # live-fetch returned empty (#1568 — the user's
        # ``providers.opencode_go`` config-key path produced an empty
        # ``Opencode_Go`` group at the end of the picker before this fix).
        # Custom providers from ``custom_providers`` config are exempt —
        # they may legitimately render with zero entries when the user
        # hasn't filled in models yet but wants the card visible.
        groups = [
            g for g in groups
            if g.get("models")
            or (g.get("provider_id") or "").startswith("custom:")
        ]

        # Sort groups: active provider first, then custom:* providers,
        # then providers with configured keys, then the rest alphabetically.
        _providers_with_keys: set[str] = set()
        try:
            _pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {}
            if isinstance(_pool, dict):
                for _pid in _pool:
                    _providers_with_keys.add(_resolve_provider_alias(str(_pid)))
        except Exception:
            pass
        try:
            _cfg_providers = cfg.get("providers", {})
            if isinstance(_cfg_providers, dict):
                for _pk, _pv in _cfg_providers.items():
                    if isinstance(_pv, dict) and (_pv.get("api_key") or _pv.get("key_env")):
                        _providers_with_keys.add(_resolve_provider_alias(str(_pk)))
        except Exception:
            pass

        def _group_sort_key(g):
            pid = g.get("provider_id") or ""
            if pid == active_provider:
                return (0, pid)
            if pid.startswith("custom:"):
                return (1, pid)
            if pid in _providers_with_keys:
                return (2, pid)
            return (3, pid)
        groups.sort(key=_group_sort_key)

        # 12. Include model aliases so the WebUI frontend can resolve them.
        model_aliases: dict[str, str] = {}
        try:
            raw_aliases = cfg.get("model", {}).get("aliases", {})
            if isinstance(raw_aliases, dict):
                model_aliases = {str(k).strip(): str(v).strip() for k, v in raw_aliases.items() if k and v}
        except Exception:
            pass

        return {
            "active_provider": active_provider,
            "default_model": default_model,
            "configured_model_badges": _build_configured_model_badges(),
            "groups": groups,
            "aliases": model_aliases,
        }

    # ── FAST PATH ─────────────────────────────────────────────────────────────
    # Mark that a build may be in progress BEFORE acquiring the lock.
    # If another thread has already started the cold path, we will wait for
    # its result rather than running the cold path concurrently.
    should_wait = _cache_build_in_progress
    force_refresh_started_at = time.monotonic() if force_refresh else None

    # Check config mtime OUTSIDE the lock so this cheap check doesn't serialize
    # concurrent requests.  Must come before any config reads in the cold path.
    try:
        _current_mtime = Path(_get_config_path()).stat().st_mtime
    except OSError:
        _current_mtime = 0.0
    _cfg_changed = _current_mtime != _cfg_mtime

    # Disk load BEFORE lock: ~0.1ms, lets concurrent requests skip entirely.
    # Then acquire lock and check memory cache.  Cold path runs inside the lock
    # so only one thread rebuilds while others wait.
    disk_groups = None
    stale_disk_groups = None
    if _available_models_cache is None and not force_refresh:
        disk_groups = _load_models_cache_from_disk()
        if disk_groups is None:
            stale_disk_groups = _load_stale_models_cache_from_disk()
    elif force_refresh:
        stale_disk_groups = _load_stale_models_cache_from_disk()

    with _available_models_cache_lock:
        # If another thread is already building, wait for its result instead
        # of re-entering the cold path (avoids duplicate 10s zai load_pool calls).
        if should_wait:
            wait_timeout = 60.0
            if force_refresh and force_refresh_started_at is not None:
                if _LIVE_REBUILD_BUDGET_SECONDS <= 0:
                    # The legacy synchronous path is explicitly unbounded. A
                    # forced refresh follower should keep coalescing behind
                    # that live rebuild instead of giving up after 60s and
                    # duplicating it.
                    wait_timeout = None
                else:
                    wait_timeout = max(
                        0.0,
                        _LIVE_REBUILD_BUDGET_SECONDS - (time.monotonic() - force_refresh_started_at),
                    )
            _cache_build_cv.wait_for(
                lambda: not _cache_build_in_progress,
                timeout=wait_timeout
            )
            cached = _get_fresh_memory_models_cache(time.monotonic())
            if (
                cached is not None
                and (
                    not force_refresh
                    or (
                        force_refresh_started_at is not None
                        and _available_models_live_rebuild_ts >= force_refresh_started_at
                    )
                )
            ):
                return cached
            if force_refresh and _LIVE_REBUILD_BUDGET_SECONDS > 0 and _cache_build_in_progress:
                if stale_disk_groups is not None:
                    return copy.deepcopy(stale_disk_groups)
                return copy.deepcopy(_static_models_catalog_without_live_probes())

        # Reload config if changed
        if _cfg_changed:
            reload_config()
            _available_models_cache = None
            _available_models_cache_ts = 0.0
            _available_models_live_rebuild_ts = 0.0
            _available_models_cache_source_fingerprint = None
            _sync_models_cache_provenance()
            disk_groups = None
            stale_disk_groups = None

        # Serve from memory cache if fresh
        now = time.monotonic()
        cached = _get_fresh_memory_models_cache(now)
        if cached is not None:
            if not force_refresh:
                return cached
            if (
                force_refresh_started_at is not None
                and _available_models_live_rebuild_ts >= force_refresh_started_at
            ):
                return cached

        # A concurrent forced refresh may have started after this caller sampled
        # should_wait but before it acquired the lock. Reuse that in-flight build
        # instead of launching another one, and preserve this caller's budget.
        if (
            force_refresh
            and force_refresh_started_at is not None
            and _cache_build_in_progress
        ):
            remaining_budget = None
            if _LIVE_REBUILD_BUDGET_SECONDS > 0:
                remaining_budget = max(
                    0.0,
                    _LIVE_REBUILD_BUDGET_SECONDS - (time.monotonic() - force_refresh_started_at),
                )
            if remaining_budget is None or remaining_budget > 0:
                _cache_build_cv.wait_for(
                    lambda: not _cache_build_in_progress,
                    timeout=remaining_budget,
                )
                cached = _get_fresh_memory_models_cache(time.monotonic())
                if (
                    cached is not None
                    and _available_models_live_rebuild_ts >= force_refresh_started_at
                ):
                    return cached
            if _cache_build_in_progress and _LIVE_REBUILD_BUDGET_SECONDS > 0:
                if stale_disk_groups is not None:
                    return copy.deepcopy(stale_disk_groups)
                return copy.deepcopy(_static_models_catalog_without_live_probes())

        # Cold path: disk cache hit — use it (fast, no lock contention)
        if disk_groups is not None and not force_refresh:
            _available_models_cache = disk_groups
            _available_models_cache_ts = now
            _available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
            _sync_models_cache_provenance()
            return copy.deepcopy(disk_groups)

        # ── prefer_cache: NEVER run the live provider rebuild ────────────────
        # Server-initiated wakeup turns (Option Z) reach here with a cold
        # cache (the drain thread fires while idle; the catalog warmed by a
        # human's /api/models has expired or was never built). The live
        # rebuild does a Copilot token-exchange HTTPS call per the proven
        # thread-stack; on this WSL/corp network it stalls the wakeup
        # chat/start indefinitely. A wakeup turn does NOT need the full live
        # catalog — _resolve_compatible_session_model_state only needs
        # default_model/active_provider and trusts the persisted session
        # model. Serve a network-free minimal catalog instead and let a later
        # human request do the real live rebuild.
        if prefer_cache:
            # NOTE (Greptile P1): do NOT touch _cache_build_in_progress here.
            # This branch never set the flag (only the cold path below does),
            # and `should_wait` is sampled outside the lock (line ~4964). A
            # concurrent cold-path caller can flip the flag to True after our
            # sample but before we acquire the lock; clearing it here would
            # prematurely release that rebuild's serialization, waking waiters
            # to an empty cache and triggering a second live rebuild. Just
            # serve the network-free minimal catalog and leave the flag alone.
            return copy.deepcopy(_minimal_static_models_catalog())

        # Cold path: full rebuild — only one thread reaches here at a time
        with _cache_build_cv:
            _cache_build_in_progress = True

        # Capture the active per-request profile (#3957). The live provider
        # probe inside the rebuild resolves credentials from os.environ /
        # HERMES_HOME and the disk-cache path/fingerprint from the profile TLS;
        # the detached worker thread below inherits NEITHER, so it must be
        # captured here (on the request thread, where the TLS is valid) and
        # re-bound on the worker. Empty / default for single-profile installs.
        from contextlib import nullcontext as _nullcontext

        _active_profile_name = ""
        _prof_env_request = None
        _prof_scope_worker = None
        try:
            from api.profiles import (
                get_active_profile_name as _gapn,
                profile_env_for_active_request as _prof_env_request,
                profile_scope_for_detached_worker as _prof_scope_worker,
            )
            _active_profile_name = (_gapn() or "").strip()
        except Exception:
            _prof_env_request = None
            _prof_scope_worker = None

        # Legacy synchronous (unbounded) rebuild — opt-in via budget<=0.
        if _LIVE_REBUILD_BUDGET_SECONDS <= 0:
            try:
                # Foreground thread already carries the request-profile TLS;
                # apply the mirrored profile env (no-op for default) for the
                # live probe because provider_model_ids() still has raw
                # os.getenv()/HERMES_HOME readers on this synchronous path.
                _sync_scope = (
                    _prof_env_request("models rebuild (sync)")
                    if _prof_env_request is not None
                    else _nullcontext()
                )
                with _sync_scope:
                    result = _invoke_models_rebuild(_build_available_models_uncached)
            except BaseException:
                # Always reset the flag so waiting threads don't block for 60s
                with _cache_build_cv:
                    _cache_build_in_progress = False
                    _cache_build_cv.notify_all()
                raise
            with _cache_build_cv:
                published_at = time.monotonic()
                _available_models_cache = result
                _available_models_cache_ts = published_at
                _available_models_live_rebuild_ts = published_at
                _available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
                _sync_models_cache_provenance()
            try:
                _save_models_cache_to_disk(result)
            finally:
                with _cache_build_cv:
                    _cache_build_in_progress = False
                    _cache_build_cv.notify_all()
            return copy.deepcopy(result)

        # ── Bounded rebuild (defense-in-depth) ───────────────────────────────
        # The live rebuild does a network probe per provider (Copilot token
        # exchange over HTTPS, OpenRouter/Nous /models, ...). On a flaky / corp
        # / WSL network any single probe can stall for its full per-call
        # timeout and, summed across providers, pin a foreground request
        # thread for tens of seconds (the wakeup-turn / chat-start hang).
        #
        # Run the rebuild on a daemon worker; the foreground waits at most
        # _LIVE_REBUILD_BUDGET_SECONDS.
        #
        # WITHIN budget (the normal fast case): the FOREGROUND publishes the
        # result synchronously and only then returns — preserving the exact
        # pre-existing contract (cache + on-disk file populated by the time
        # get_available_models() returns). The worker stays hands-off.
        #
        # OVER budget (a provider probe is slow/hung): the foreground returns
        # the best fallback immediately and the still-running worker publishes
        # its result out-of-band when it finally finishes, so the next caller
        # gets a warm cache instead of paying the cold rebuild again.
        #
        # ``_publish_models_result`` / ``box["published"]`` ensure exactly one
        # publisher even at the budget boundary (no double write, no lost
        # refresh). The worker only touches _cache_build_cv after the
        # foreground releases the RLock by returning, so no lock inversion.
        build_done = threading.Event()
        budget_exceeded = threading.Event()
        publish_lock = threading.Lock()
        box: dict = {}

        def _publish_models_result(result):
            global _cache_build_in_progress, _available_models_cache
            global _available_models_cache_ts, _available_models_live_rebuild_ts
            global _available_models_cache_source_fingerprint
            with _cache_build_cv:
                published_at = time.monotonic()
                _available_models_cache = result
                _available_models_cache_ts = published_at
                _available_models_live_rebuild_ts = published_at
                _available_models_cache_source_fingerprint = (
                    _models_cache_source_fingerprint()
                )
                _sync_models_cache_provenance()
            try:
                _save_models_cache_to_disk(result)
            except Exception:
                logger.debug("models cache disk save failed", exc_info=True)
            finally:
                with _cache_build_cv:
                    _cache_build_in_progress = False
                    _cache_build_cv.notify_all()

        def _clear_build_in_progress():
            global _cache_build_in_progress
            with _cache_build_cv:
                _cache_build_in_progress = False
                _cache_build_cv.notify_all()

        def _claim_publish() -> bool:
            """Return True iff the caller won the right to publish."""
            with publish_lock:
                if box.get("published"):
                    return False
                box["published"] = True
                return True

        def _rebuild_worker():
            # Re-bind the captured per-request profile on THIS worker thread
            # (#3957): the daemon inherits neither the request-profile TLS nor
            # os.environ, so without this it would probe the default profile's
            # credentials and, over budget, publish the rebuilt catalog to the
            # DEFAULT profile's disk cache. No-op for the default profile.
            _worker_scope = (
                _prof_scope_worker(_active_profile_name, "models rebuild (worker)")
                if _prof_scope_worker is not None
                else _nullcontext()
            )
            with _worker_scope:
                try:
                    box["result"] = _invoke_models_rebuild(_build_available_models_uncached)
                except Exception as exc:  # noqa: BLE001 — propagated to caller
                    box["error"] = exc
                finally:
                    build_done.set()
                    # Only publish out-of-band if the foreground already gave up
                    # (over budget). Within budget the foreground publishes
                    # synchronously, so the worker must NOT touch the cache.
                    # NOTE: the publish (and its disk write + fingerprint) runs
                    # INSIDE this profile scope so the over-budget path writes
                    # the correct profile's cache file.
                    if budget_exceeded.is_set() and _claim_publish():
                        if "result" in box:
                            _publish_models_result(box["result"])
                        else:
                            _clear_build_in_progress()

        _worker = threading.Thread(
            target=_rebuild_worker,
            name="models-catalog-rebuild",
            daemon=True,
        )
        _worker.start()

        if build_done.wait(timeout=_LIVE_REBUILD_BUDGET_SECONDS):
            # Build finished within budget — foreground publishes
            # synchronously, exactly like the legacy path.
            if "error" in box:
                _clear_build_in_progress()
                raise box["error"]
            if _claim_publish():
                _publish_models_result(box["result"])
            return copy.deepcopy(box["result"])

        # Budget elapsed. Mark it so the worker knows it owns out-of-band
        # publication. Handle the tiny race where the build completed between
        # wait() returning False and here: if so, still publish synchronously
        # so this caller honours the cache contract.
        budget_exceeded.set()
        if build_done.is_set() and "error" not in box and "result" in box:
            if _claim_publish():
                _publish_models_result(box["result"])
            return copy.deepcopy(box["result"])

        # Genuinely slow/hung probe: serve the best fallback now; the worker
        # keeps going and refreshes the cache for the next caller.
        # Rate-limit the warning per Q-2979-A3 — see _should_warn_budget; a
        # sustained budget breach demotes to info after the first emit in
        # each cooldown window so log volume stays bounded.
        _budget_log_msg = (
            "live provider-catalog rebuild exceeded %.1fs budget — serving "
            "fallback, refreshing catalog out-of-band"
        )
        if _should_warn_budget("live_rebuild_budget_exceeded"):
            logger.warning(_budget_log_msg, _LIVE_REBUILD_BUDGET_SECONDS)
        else:
            logger.info(_budget_log_msg, _LIVE_REBUILD_BUDGET_SECONDS)
        # ``stale_disk_groups`` is shape-valid but failed the strict metadata
        # checks required for authoritative cold-path use. It was read before
        # acquiring _available_models_cache_lock so this over-budget fallback
        # does not extend the lock hold while the worker is ready to publish.
        if stale_disk_groups is not None:
            return copy.deepcopy(stale_disk_groups)
        return copy.deepcopy(_static_models_catalog_without_live_probes())


def _models_cache_file_age_seconds(cache_path: Path, now: float) -> float | None:
    try:
        return max(0.0, now - cache_path.stat().st_mtime)
    except OSError:
        return None


def warm_models_catalog_provenance_if_cold() -> None:
    """Best-effort, NON-BLOCKING, disk-only publish of catalog provenance.

    The send path (``api/streaming.py``) resolves the wire model via
    ``resolve_model_provider`` without ever building the models catalog, and the
    #1855 chat/start fast path deliberately skips the catalog when a session
    already carries a persisted model+provider — so "cold at send" is the
    designed behaviour after any process restart, memory-TTL expiry, or cache
    invalidation, not a rare race. In that state the custom-proxy provenance
    signal (``_endpoint_advertised_model_ids``) is ``None`` and resolution falls
    to the cold-preserve default; this helper restores the endpoint-advertised
    signal from the durable disk cache so the #433 bare-only-strip stays exact.

    Deliberately does NOT call ``get_available_models(prefer_cache=True)``: even
    in prefer-cache mode that acquires ``_available_models_cache_lock`` and can
    block up to ~60s waiting on an in-flight rebuild (unbounded in synchronous
    rebuild mode) — unacceptable on the send hot path. Instead this:
      * tries the cache lock NON-BLOCKING and returns immediately if it's busy
        (a concurrent rebuild will publish provenance itself);
      * reads ONLY the on-disk cache (no network, no live probe, no rebuild);
      * publishes the snapshot + source fingerprint via the same globals the
        real publish sites use, then ``_sync_models_cache_provenance()``.
    Publishing the fingerprint from the CURRENT runtime is correct: the disk
    cache is validated by schema/version/source-fingerprint on load
    (``_is_loadable_disk_cache``), so a load success means it belongs to this
    profile. Callers must not hold ``_cfg_lock`` (this reads config for the
    fingerprint); the send worker satisfies that.

    Profile isolation: the fast no-op is taken ONLY when the resident provenance
    fingerprint matches the CURRENT profile's runtime fingerprint. The catalog
    globals are process-wide, so a concurrently-active profile B could have left
    its own (or a stale) provenance resident; an unconditional non-``None``
    early return would let B's catalog block profile A from loading A's own valid
    disk cache (A would then resolve against B's advertised ids). Comparing the
    published fingerprint to the current one before short-circuiting closes that
    hole — a mismatch falls through to load THIS profile's disk snapshot.
    """
    global _available_models_cache, _available_models_cache_ts
    global _available_models_cache_source_fingerprint

    def _provenance_is_current() -> bool:
        prov = _models_cache_provenance
        if prov is None:
            return False
        try:
            return prov[1] == _models_cache_source_fingerprint()
        except Exception:
            return False

    if _provenance_is_current():
        return  # already warm for THIS profile — one global read, no work
    got = _available_models_cache_lock.acquire(blocking=False)
    if not got:
        return  # a concurrent build/publish holds the lock; it will publish
    try:
        if _provenance_is_current():
            return  # published for this profile while we waited for the lock
        try:
            disk_groups = _load_models_cache_from_disk()
        except Exception:
            disk_groups = None
        if disk_groups is None:
            return  # no durable cache for this profile → stay cold, preserve verbatim
        _available_models_cache = disk_groups
        _available_models_cache_ts = time.monotonic()
        try:
            _available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
        except Exception:
            _available_models_cache_source_fingerprint = None
        _sync_models_cache_provenance()
    except Exception:
        logger.debug("models catalog provenance warm failed", exc_info=True)
    finally:
        _available_models_cache_lock.release()


def get_available_models_for_session_visit() -> dict:
    """Return /api/models with a short session-visit freshness horizon.

    perf(session-load-latency) Phase 0: this function is the source of the
    multi-second `/api/models?freshness=session_visit` latency. Stage markers
    feed into RequestDiagnostics when called from /api/models; standalone
    callers get the same envelope via the local _stagelog dict.
    """
    import time as _time
    import logging as _logging
    _stagelog: list[tuple[str, float]] = [("enter", _time.monotonic())]
    def _mark(name: str) -> None:
        _stagelog.append((name, _time.monotonic()))
    _logger = _logging.getLogger("api.config")
    # HERMES_DEBUG_SLOW: a numeric value sets the slow-log threshold in ms; any
    # other non-empty (truthy) value — e.g. the documented `HERMES_DEBUG_SLOW=1`
    # / `=true` — means "always log stage timing" (0ms threshold); unset/empty
    # keeps the default 500ms. Must be non-throwing: a nonnumeric truthy value
    # like `true` previously raised ValueError here and 500'd this hot path.
    _slow_raw = (os.environ.get("HERMES_DEBUG_SLOW", "") or "").strip()
    if not _slow_raw:
        _slow_threshold_ms = 500.0
    else:
        try:
            _slow_threshold_ms = float(_slow_raw) or 500.0
        except ValueError:
            # Non-numeric truthy flag (e.g. "true"): always emit stage timing.
            _slow_threshold_ms = 0.0

    global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint
    cache_path = _get_models_cache_path()
    cache_age = _models_cache_file_age_seconds(cache_path, time.time())
    _mark(f"disk_age_check:{cache_age}")
    disk_cached = None
    if cache_age is not None and cache_age < _SESSION_VISIT_MODELS_FRESHNESS_SECONDS:
        _mark("cache_age_within_ttl")
        now_mono = time.monotonic()
        with _available_models_cache_lock:
            cached = _get_fresh_memory_models_cache(now_mono)
            if cached is not None:
                _mark("memory_cache_hit")
                _maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
                return cached
        _mark("memory_cache_miss_loading_disk")
        disk_cached = _load_models_cache_from_disk()
        if disk_cached is not None:
            with _available_models_cache_lock:
                cached = _get_fresh_memory_models_cache(time.monotonic())
                if cached is not None:
                    _mark("disk_then_memory_cache_hit")
                    _maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
                    return cached
                _available_models_cache = copy.deepcopy(disk_cached)
                _available_models_cache_ts = time.monotonic()
                _available_models_cache_source_fingerprint = _models_cache_source_fingerprint()
                _sync_models_cache_provenance()
            _mark("disk_cache_returned")
            _maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
            return copy.deepcopy(disk_cached)

    _mark("cache_age_stale_or_missing")
    stale_cached = disk_cached or _load_stale_models_cache_from_disk()
    _mark(f"stale_cached_loaded:{bool(stale_cached)}")
    try:
        _mark("force_refresh_start")
        result = get_available_models(force_refresh=True)
        _mark("force_refresh_done")
        _maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
        return result
    except Exception:
        _mark("force_refresh_failed")
        logger.debug("session-visit models refresh failed", exc_info=True)
        if stale_cached is not None:
            _mark("stale_fallback_return")
            _maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
            return copy.deepcopy(stale_cached)
        _mark("prefer_cache_fallback")
        _maybe_log_slow_stages(_logger, _stagelog, _slow_threshold_ms, "models.session_visit")
        return get_available_models(prefer_cache=True)


def _maybe_log_slow_stages(
    logger_obj: "logging.Logger",
    stagelog: "list[tuple[str, float]]",
    threshold_ms: float,
    tag: str,
) -> None:
    """perf(session-load-latency) Phase 0: per-stage timing reporter.

    Emits a single log line listing every stage with its delta in ms when
    the function's total wall time crosses ``threshold_ms``. Lives next to
    the model cache code so it has zero coupling with the WebUI request
    layer; called from both ``get_available_models_for_session_visit`` and
    (Phase 1) the chat session load path.
    """
    if len(stagelog) < 2:
        return
    total_ms = (stagelog[-1][1] - stagelog[0][1]) * 1000.0
    if total_ms < threshold_ms:
        return
    parts: list[str] = []
    for i in range(1, len(stagelog)):
        prev_t = stagelog[i - 1][1]
        cur_t = stagelog[i][1]
        parts.append(f"{stagelog[i][0]}={((cur_t - prev_t) * 1000.0):.1f}ms")
    try:
        logger_obj.warning(
            "[SLOW] %s total=%.1fms stages: %s",
            tag,
            total_ms,
            " ".join(parts),
        )
    except Exception:
        # Logging must never break a response path.
        pass


# ── Static file path ─────────────────────────────────────────────────────────


def get_static_root() -> Path:
    return REPO_ROOT / "static"


def get_index_html_path() -> Path:
    return get_static_root() / "index.html"


_INDEX_HTML_PATH = get_index_html_path()

# ── Thread synchronisation ───────────────────────────────────────────────────
LOCK = threading.Lock()
# Max compact Session objects held in the in-memory LRU (issue #3506, #4765, #6351).
# Lighter than the agent cache (no live agent runtime), but still bounded so a
# long-running self-hosted install cannot accumulate every session it ever
# touched in RAM and eventually segfault (the #4765/#2233/#4633 crash cluster).
# The shipped default is tuned for the common single-user install; larger
# deployments can keep raising it through config.yaml or the legacy env fallback.
#
# Precedence for the effective cap is resolved by get_sessions_cache_max():
#   1. config.yaml  webui.sessions_cache_max   (preferred, no new env var)
#   2. HERMES_WEBUI_SESSIONS_MAX env var        (legacy operator override)
#   3. DEFAULT_SESSIONS_CACHE_MAX               (sane bounded default)
DEFAULT_SESSIONS_CACHE_MAX = 100
SESSIONS_MAX = _env_int("HERMES_WEBUI_SESSIONS_MAX", DEFAULT_SESSIONS_CACHE_MAX)


def get_sessions_cache_max(config_data: dict | None = None) -> int:
    """Return the effective in-memory SESSIONS cache cap (issue #4765).

    The bound is configurable through ``webui.sessions_cache_max`` in
    ``config.yaml`` so operators of large self-hosted installs can size the
    cache without editing source or adding a new ``HERMES_*`` env var (this
    project forbids new env vars for non-secret config). A missing, empty,
    non-numeric, or below-1 value falls back to the legacy
    ``HERMES_WEBUI_SESSIONS_MAX`` env override, then to
    ``DEFAULT_SESSIONS_CACHE_MAX`` — a typo can never disable the bound and
    reintroduce unbounded memory growth.

    This is the sole resolution authority and it is side-effect free. The cap
    diagnostics report is published by the code that enforces it; see
    ``_LAST_APPLIED_SESSIONS_CACHE_MAX`` below.
    """
    active_cfg = config_data if isinstance(config_data, dict) else get_config()
    webui_cfg = active_cfg.get("webui", {}) if isinstance(active_cfg, dict) else {}
    if isinstance(webui_cfg, dict):
        raw = webui_cfg.get("sessions_cache_max")
        if raw is not None:
            try:
                value = int(raw)
            except (TypeError, ValueError, OverflowError):
                # OverflowError covers YAML's float infinities (`.inf`, `1e400`),
                # which safe_load resolves to a real float. Without it a typo
                # would escape the fallback and raise out of every caller.
                value = None
            if value is not None and value >= 1:
                return value
    # config.yaml did not specify a valid cap: honor the legacy env override
    # (already parsed into SESSIONS_MAX) and finally the hardened default.
    if isinstance(SESSIONS_MAX, int) and SESSIONS_MAX >= 1:
        return SESSIONS_MAX
    return DEFAULT_SESSIONS_CACHE_MAX


# The cap api/models.py::_evict_sessions_over_cap() last enforced. That function
# publishes it after its own fallback and range normalization, so a nonblocking
# diagnostics read reports what eviction applied without re-entering config or
# profile I/O, and nothing else writes this field. Seeded from the config
# reload_config() already loaded at import (see `cfg` above) through the getter's
# dict mode, which reads no file and takes no lock, so the value is right before
# the first eviction pass instead of after it.
_LAST_APPLIED_SESSIONS_CACHE_MAX: int = get_sessions_cache_max(cfg)
CHAT_LOCK = threading.Lock()


class StreamChannel:
    """Broadcast SSE events to every connected browser tab for a stream.

    While no tab is connected, events are buffered so the first/reconnected
    subscriber still receives the stream tail that arrived during the gap.
    Once one or more subscribers are attached, new events are broadcast to all
    of them instead of being consumed destructively by a single queue reader.
    """

    # Cap on the offline replay buffer (drop-oldest). While no tab is subscribed,
    # put_nowait() buffers the stream tail so a first/reconnecting subscriber can
    # catch up. But a client that disconnects without cancelling leaves the turn
    # running with zero subscribers, so an unbounded buffer here grows for the
    # WHOLE turn (a busy turn emits thousands of coalesced token frames) — an OOM
    # risk per abandoned turn (#4633). Bounding to the most recent N frames keeps
    # a reconnecting tab's needed *tail* intact; older dropped frames stay
    # recoverable via the run journal by last_event_id. 8192 is generous enough
    # to hold a long multi-tool turn's backlog while capping worst-case memory to
    # a fixed number of small (event, data, id) tuples — deliberately far above
    # the per-subscriber queue cap below (that queue drops on a *slow* reader;
    # this buffer must survive a legitimate reconnect gap).
    _OFFLINE_BUFFER_MAXLEN = 8192
    # Per-subscriber queue cap (drop-oldest on full). Each connected tab gets its
    # own queue; a slow/backpressured or backgrounded tab used to hold an
    # UNBOUNDED queue.Queue that grew for the WHOLE turn (the producer is the
    # agent token stream), an OOM risk with many tabs × long agentic turns. This
    # caps the per-tab live-broadcast growth to a fixed bound.
    #
    # Bound is intentionally EQUAL to _OFFLINE_BUFFER_MAXLEN, not the much
    # smaller SessionChannel per-subscriber cap of 16. StreamChannel carries the
    # live chat token stream (thousands of frames per turn) and, unlike
    # SessionChannel's low-frequency UI pings, has a reconnect-replay contract:
    # a tab that briefly disconnected must receive the full retained offline
    # tail on resubscribe. Capping below the offline buffer would truncate that
    # replay and force every flaky-network reconnect through the run journal
    # (disk reads) instead of the in-memory fast path. Matching the offline
    # buffer bound preserves that contract while bounding live-broadcast memory
    # to the SAME worst-case #4633 already accepted (a fixed number of small
    # (event, data, id) tuples). The SSE write deadline
    # (SSE_WRITE_DEADLINE_SECONDS) independently breaks a stuck socket within
    # ~20s, so the overflow window is short; this cap bounds it by frame count
    # too. Older frames stay recoverable via the run journal by last_event_id.
    _SUBSCRIBER_QUEUE_MAXSIZE = _OFFLINE_BUFFER_MAXLEN

    def __init__(self):
        self._lock = threading.Lock()
        self._subscribers: list[queue.Queue] = []
        self._offline_buffer: collections.deque = collections.deque(
            maxlen=self._OFFLINE_BUFFER_MAXLEN
        )
        # Frames evicted at the cap from the CURRENT buffer content. Scoped to
        # the buffer, NOT to an attach cycle: it resets exactly when the buffer
        # itself is cleared (first live broadcast), never on subscribe/
        # unsubscribe alone — a drain is a non-destructive copy, so after a
        # transient attach the buffer is STILL truncated and reporting 0 would
        # hand the next subscriber a silently-holed tail. Whether a given
        # reconnect actually NEEDS the evicted frames is decided server-side
        # against offline_first_event_id (its cursor may sit inside the
        # retained tail). Also gates the one-shot eviction log.
        self._offline_dropped = 0
        # Cumulative evictions over the channel's lifetime, never reset — for ops
        # visibility via diagnostic_snapshot().
        self._offline_dropped_total = 0
        # Cumulative per-subscriber queue drops over the channel's lifetime
        # (broadcast + replay paths), never reset — ops visibility for slow tabs.
        self._subscriber_dropped_total = 0
        self._last_event_id: str | None = None

    def subscribe(self) -> queue.Queue:
        q, _snapshot = self.subscribe_with_snapshot()
        return q

    def subscribe_with_snapshot(self) -> tuple[queue.Queue, dict[str, object]]:
        q: queue.Queue = queue.Queue(maxsize=self._SUBSCRIBER_QUEUE_MAXSIZE)
        with self._lock:
            # Replay buffered events to the new subscriber INSIDE the lock so a
            # concurrent put_nowait() can't broadcast a newer event before we
            # finish replaying the older buffered tail. The queue is bounded, so
            # put_nowait raises queue.Full once the cap is reached — drop the
            # OLDEST already-replayed frame and retry, keeping the most recent
            # tail (a reconnecting tab needs the tail; older frames stay
            # recoverable via the run journal by last_event_id). Holding the
            # lock here is safe: no other put_nowait() can interleave. Per Opus
            # advisor on stage-292.
            replayed_dropped = 0
            for item in self._offline_buffer:
                while True:
                    try:
                        q.put_nowait(item)
                        break
                    except queue.Full:
                        # Drop oldest to make room for the newer (more useful)
                        # tail frame. The drained frame is the oldest in this
                        # subscriber's replay window only.
                        try:
                            q.get_nowait()
                        except queue.Empty:
                            # A concurrent consumer drained the queue between
                            # our Full and get_nowait — the queue now has space,
                            # so retry the put instead of dropping `item`. This
                            # path runs under self._lock with a freshly-created
                            # queue (no concurrent consumer), so it is not
                            # reached in practice, but `continue` is the
                            # correct, race-safe rule (see the broadcast path).
                            continue
                        replayed_dropped += 1
            if replayed_dropped:
                self._subscriber_dropped_total += replayed_dropped
                logger.debug(
                    "StreamChannel subscriber replay dropped %d oldest frames "
                    "(cap=%d) while catching up on %d buffered events",
                    replayed_dropped,
                    self._SUBSCRIBER_QUEUE_MAXSIZE,
                    len(self._offline_buffer),
                )
            first = self._offline_buffer[0] if self._offline_buffer else None
            snapshot = {
                "offline_buffered_events": len(self._offline_buffer),
                # Surface eviction so the SSE handler can tell the tail it is
                # about to drain may be truncated (older frames were dropped at
                # the cap) and must be proven contiguous before streaming.
                "offline_dropped_events": self._offline_dropped,
                # Event id of the oldest retained frame: the handler needs run-
                # journal coverage only for (client cursor → this frame); a
                # cursor already inside the retained tail needs no journal.
                "offline_first_event_id": (
                    first[2] if first is not None and len(first) >= 3 else None
                ),
                "last_event_id": self._last_event_id,
            }
            self._subscribers.append(q)
        return q, snapshot

    def unsubscribe(self, q: queue.Queue) -> None:
        with self._lock:
            try:
                self._subscribers.remove(q)
            except ValueError:
                pass

    def note_last_event_id(self, event_id: str | None) -> None:
        """Record the latest journal event id without changing the queue shape."""
        if not event_id:
            return
        with self._lock:
            self._last_event_id = event_id

    def put_nowait(self, item: tuple[str, object] | tuple[str, object, str | None]) -> None:
        event_id = item[2] if len(item) >= 3 else None
        with self._lock:
            if event_id:
                self._last_event_id = event_id
            subscribers = list(self._subscribers)
            if not subscribers:
                # deque(maxlen) evicts the oldest frame automatically when full.
                # Log once on the first eviction (debug: an abandoned/disconnected
                # turn is expected to hit this) and keep a running dropped count
                # for diagnostics.
                if len(self._offline_buffer) >= self._OFFLINE_BUFFER_MAXLEN:
                    if self._offline_dropped == 0:  # first eviction this cycle
                        logger.debug(
                            "StreamChannel offline buffer full (cap=%d); dropping "
                            "oldest frames while no subscriber is connected",
                            self._OFFLINE_BUFFER_MAXLEN,
                        )
                    self._offline_dropped += 1
                    self._offline_dropped_total += 1
                self._offline_buffer.append(item)
                return
            # A subscriber is live: events now broadcast directly, so the offline
            # tail is drained. Reset the per-cycle eviction count (which also
            # re-arms the one-shot log) so the NEXT disconnect/overflow cycle
            # reports and logs its own truncation, not a stale carry-over.
            self._offline_buffer.clear()
            self._offline_dropped = 0
        # Broadcast outside the lock so a slow put_nowait doesn't block other
        # subscribers or producers. The queue is bounded; on queue.Full drop the
        # OLDEST frame and retry so a slow/backpressured tab keeps its most
        # recent tail instead of growing unbounded for the whole turn. Older
        # frames stay recoverable via the run journal by last_event_id. Mirrors
        # SessionChannel.emit's drop-on-full contract.
        broadcast_dropped = 0
        for q in subscribers:
            while True:
                try:
                    q.put_nowait(item)
                    break
                except queue.Full:
                    try:
                        q.get_nowait()
                    except queue.Empty:
                        # A concurrent consumer (the SSE handler thread)
                        # drained the queue between our Full and get_nowait.
                        # The queue now has space — retry the put so `item` is
                        # delivered. `break` here would silently discard `item`,
                        # and if `item` is a terminal frame (stream_end/error/
                        # cancel) the subscriber never receives it and the client
                        # stays attached indefinitely (spinner-forever). Having
                        # space is exactly the condition we want, so continue.
                        continue
                    broadcast_dropped += 1
        if broadcast_dropped:
            with self._lock:
                self._subscriber_dropped_total += broadcast_dropped
            logger.debug(
                "StreamChannel broadcast dropped %d oldest frames across %d "
                "subscriber queue(s) (cap=%d)",
                broadcast_dropped,
                len(subscribers),
                self._SUBSCRIBER_QUEUE_MAXSIZE,
            )

    def _diagnostic_counters_locked(self) -> dict[str, object]:
        """Return the counter dict. CALLER CONTRACT: ``self._lock`` is held."""
        return {
            "subscriber_count": len(self._subscribers),
            "offline_buffered_events": len(self._offline_buffer),
            # Cumulative over the channel lifetime (ops visibility), vs. the
            # per-cycle count subscribe_with_snapshot() reports for truncation.
            "offline_dropped_events": self._offline_dropped_total,
            # Cumulative per-subscriber queue drops (replay + broadcast) over
            # the channel lifetime — surfaces slow/backpressured tabs.
            "subscriber_dropped_events": self._subscriber_dropped_total,
        }

    def diagnostic_snapshot(self) -> dict[str, object]:
        """Return non-sensitive stream observation counters for health checks."""
        with self._lock:
            return self._diagnostic_counters_locked()

    def try_diagnostic_snapshot(self) -> dict[str, object] | None:
        """Return the same counters without waiting, or ``None`` when busy.

        An aggregate health poll must never stall behind one channel's producer
        or subscriber work, so a contended channel is reported as unavailable
        instead of waited on. ``diagnostic_snapshot()`` keeps its blocking
        contract for the per-stream ``/health?deep=1`` view, which needs the
        counters of every stream rather than a best-effort aggregate.
        """
        if not self._lock.acquire(blocking=False):
            return None
        try:
            return self._diagnostic_counters_locked()
        finally:
            self._lock.release()


def create_stream_channel() -> StreamChannel:
    return StreamChannel()


STREAMS: dict = {}
STREAMS_LOCK = threading.Lock()


def peek_stream(stream_id):
    """Lock-disciplined stream queue lookup.

    Writers mutate STREAMS under STREAMS_LOCK (teardown in api/streaming.py,
    the route layer's start/cancel paths); reads must take the same lock so a
    read racing a teardown pop can never observe-and-use a queue the registry
    has already released. Returns the queue or None — callers keep their
    existing None-guard fallbacks.
    """
    with STREAMS_LOCK:
        return STREAMS.get(stream_id)


# stream_id -> session_id owner, populated synchronously before worker startup so
# stream-id authorization does not depend on worker lifecycle registration.
STREAM_SESSION_OWNERS: dict = {}
STREAM_SESSION_OWNERS_LOCK = threading.Lock()
CANCEL_FLAGS: dict = {}
AGENT_INSTANCES: dict = {}  # stream_id -> AIAgent instance for interrupt propagation
STREAM_PARTIAL_TEXT: dict = {}  # stream_id -> partial assistant text accumulated during streaming
STREAM_REASONING_TEXT: dict = {}  # stream_id -> reasoning trace accumulated during streaming (#1361 §A)
STREAM_LIVE_TOOL_CALLS: dict = {}  # stream_id -> live tool calls accumulated during streaming (#1361 §B)
STREAM_GOAL_RELATED: dict = {}  # stream_id -> bool: only evaluate goal for goal-related turns (#1932)
STREAM_LAST_EVENT_ID: dict = {}  # stream_id -> latest journal event_id for `id:` field on live SSE frames (stage-364)
PENDING_GOAL_CONTINUATION: set = set()  # session_ids awaiting a goal continuation turn (#1932)


def register_stream_owner(stream_id: str, session_id: str) -> None:
    """Record the session that owns a stream before worker startup."""
    stream_id = str(stream_id or "").strip()
    session_id = str(session_id or "").strip()
    if not stream_id or not session_id:
        return
    with STREAM_SESSION_OWNERS_LOCK:
        STREAM_SESSION_OWNERS[stream_id] = session_id


def stream_owner_session_id(stream_id: str) -> str | None:
    """Return the synchronously-recorded owner session for a stream, if any."""
    stream_id = str(stream_id or "").strip()
    if not stream_id:
        return None
    with STREAM_SESSION_OWNERS_LOCK:
        owner = STREAM_SESSION_OWNERS.get(stream_id)
    owner = str(owner or "").strip()
    return owner or None


def unregister_stream_owner(stream_id: str) -> None:
    """Forget the pre-worker stream owner once the stream has torn down."""
    stream_id = str(stream_id or "").strip()
    if not stream_id:
        return
    with STREAM_SESSION_OWNERS_LOCK:
        STREAM_SESSION_OWNERS.pop(stream_id, None)


# ── Per-session writeback-ownership registry (#6623 re-gate) ────────────────
# Maps session_id -> stream_id of the turn that currently owns the session's
# writeback. Written whenever a turn is admitted (route layer, next to
# session.active_stream_id), REPLACED when a successor turn is admitted, and
# NEVER cleared by cancel_stream() — cancel eagerly pops STREAMS/ACTIVE_RUNS
# and clears ``active_stream_id``, so a delayed finalizer from an old worker
# cannot tell "the session advanced to a successor" apart from "cancel simply
# cleared the field" by looking at its own (possibly LRU-evicted, detached)
# snapshot. This record survives cancel cleanup: the owning worker's own
# finally clears the entry, and only while it still owns it.
SESSION_WRITEBACK_OWNERS: dict = {}
SESSION_WRITEBACK_OWNERS_LOCK = threading.Lock()


def register_session_writeback_owner(session_id: str, stream_id: str) -> None:
    """Record the stream that currently owns a session's writeback."""
    session_id = str(session_id or "").strip()
    stream_id = str(stream_id or "").strip()
    if not session_id or not stream_id:
        return
    with SESSION_WRITEBACK_OWNERS_LOCK:
        SESSION_WRITEBACK_OWNERS[session_id] = stream_id


def session_writeback_owner(session_id: str) -> str | None:
    """Return the stream that currently owns the session's writeback, if any."""
    session_id = str(session_id or "").strip()
    if not session_id:
        return None
    with SESSION_WRITEBACK_OWNERS_LOCK:
        owner = SESSION_WRITEBACK_OWNERS.get(session_id)
    owner = str(owner or "").strip()
    return owner or None


def clear_session_writeback_owner_if_owned(session_id: str, stream_id: str) -> None:
    """Forget the writeback-ownership entry only while ``stream_id`` still owns it."""
    session_id = str(session_id or "").strip()
    stream_id = str(stream_id or "").strip()
    if not session_id or not stream_id:
        return
    with SESSION_WRITEBACK_OWNERS_LOCK:
        if SESSION_WRITEBACK_OWNERS.get(session_id) == stream_id:
            SESSION_WRITEBACK_OWNERS.pop(session_id, None)


# ── Gateway capability cache ─────────────────────────────────────────────────
# Probes /v1/capabilities once per base_url/api-key pair and caches the result
# for 60 s so guarded-turn routing decisions do not add latency on every chat
# turn.
_GATEWAY_CAPS_CACHE: dict[tuple[str, str], dict] = {}
_GATEWAY_CAPS_LOCK = threading.Lock()
_GATEWAY_CAPS_TTL_S: float = 60.0


def _gateway_caps_probe_timed_out(exc: BaseException) -> bool:
    """Keep slow capability probes on the legacy reachable-but-unsupported path."""
    reason = exc.reason if isinstance(exc, urllib.error.URLError) else exc
    if isinstance(reason, (TimeoutError, socket.timeout)):
        return True
    reason_text = str(reason).lower()
    return "timed out" in reason_text or "timeout" in reason_text


def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
    """Return cached gateway capability flags, probing /v1/capabilities if stale."""
    base_url = str(base_url or "").rstrip("/")
    cache_key = (base_url, str(api_key or ""))
    now = time.time()
    probe_started_at = now
    with _GATEWAY_CAPS_LOCK:
        cached = _GATEWAY_CAPS_CACHE.get(cache_key)
        if cached and now - cached.get("fetched_at", 0) < _GATEWAY_CAPS_TTL_S:
            return cached
    caps = {
        "approval_events": False,
        "run_approval_response": False,
        "approval_identity_v1": False,
        "capabilities_reachable": False,
        "probe_error": None,
        "fetched_at": 0.0,
    }
    try:
        headers = {}
        if api_key:
            headers["Authorization"] = f"Bearer {api_key}"
        req = urllib.request.Request(f"{base_url}/v1/capabilities", headers=headers, method="GET")
        with urllib.request.urlopen(req, timeout=3) as resp:
            caps["capabilities_reachable"] = True
            body = json.loads(resp.read(65536))
        features = body.get("features") if isinstance(body, dict) else {}
        if not isinstance(features, dict):
            features = {}
        caps["approval_events"] = bool(features.get("approval_events"))
        caps["run_approval_response"] = bool(features.get("run_approval_response"))
        caps["approval_identity_v1"] = bool(features.get("approval_identity_v1"))
    except urllib.error.HTTPError as exc:
        caps["capabilities_reachable"] = True
        caps["probe_error"] = f"{type(exc).__name__}: {exc}"
    except urllib.error.URLError as exc:
        if _gateway_caps_probe_timed_out(exc):
            caps["capabilities_reachable"] = True
        caps["probe_error"] = f"{type(exc).__name__}: {exc}"
    except (TimeoutError, socket.timeout) as exc:
        caps["capabilities_reachable"] = True
        caps["probe_error"] = f"{type(exc).__name__}: {exc}"
    except OSError as exc:
        caps["probe_error"] = f"{type(exc).__name__}: {exc}"
    except Exception as exc:
        caps["probe_error"] = f"{type(exc).__name__}: {exc}"
    with _GATEWAY_CAPS_LOCK:
        current = _GATEWAY_CAPS_CACHE.get(cache_key)
        if current and current.get("fetched_at", 0) > probe_started_at:
            return current
        caps["fetched_at"] = time.time()
        _GATEWAY_CAPS_CACHE[cache_key] = caps
    return caps


def gateway_approval_unavailable_reason(base_url: str, api_key: str = "") -> str | None:
    """Return why approval support is unavailable, if it is unavailable."""
    caps = get_gateway_caps(base_url, api_key)
    if bool(caps.get("approval_events") and caps.get("run_approval_response")):
        return None
    if not caps.get("capabilities_reachable"):
        return "unreachable"
    return "unsupported"


def gateway_supports_approval(base_url: str, api_key: str = "") -> bool:
    """True only when the gateway advertises both approval_events and run_approval_response."""
    caps = get_gateway_caps(base_url, api_key)
    return bool(caps.get("approval_events") and caps.get("run_approval_response"))


def gateway_supports_approval_identity_v1(base_url: str, api_key: str = "") -> bool:
    """True only when Gateway advertises authoritative approval identities."""
    return bool(get_gateway_caps(base_url, api_key).get("approval_identity_v1"))


def invalidate_gateway_caps(base_url: str | None = None) -> None:
    """Evict capability cache for base_url, or all entries when base_url is None."""
    with _GATEWAY_CAPS_LOCK:
        if base_url is None:
            _GATEWAY_CAPS_CACHE.clear()
        else:
            normalized = str(base_url or "").rstrip("/")
            for cache_key in [key for key in _GATEWAY_CAPS_CACHE if key[0] == normalized]:
                _GATEWAY_CAPS_CACHE.pop(cache_key, None)


# ── notify_on_complete agent-wakeup wiring ─────────────────────────────────
# When terminal(notify_on_complete=true, background=true) fires, the process
# registry pushes a completion event onto tools.process_registry.completion_queue.
# A drain task spawned at WebUI startup (api/background_process.py) reads that
# queue and emits an SSE `process_complete` event to the matching session.
# PROCESS_SESSION_INDEX maps the per-process "session_key" (set in the spawned
# subprocess via HERMES_SESSION_KEY) back to the WebUI session_id that owns it,
# so the drain task can route the event to the right SSE channel.
# PENDING_BG_TASK_COMPLETIONS mirrors PENDING_GOAL_CONTINUATION: server-side
# marker discarded atomically by routes.py when the frontend re-POSTs the
# wakeup_prompt as the next user turn. (process_complete event, agent wakeup fix)
PROCESS_SESSION_INDEX: dict = {}  # process_registry session_key -> WebUI session_id
PROCESS_SESSION_INDEX_LOCK = threading.Lock()
PENDING_BG_TASK_COMPLETIONS: set = set()  # session_ids awaiting a process_complete wakeup turn
BG_TASK_COMPLETE_EVENTS_SEEN: dict = {}  # session_id -> set[process_id] for idempotency
BG_TASK_COMPLETE_EVENTS_SEEN_LOCK = threading.Lock()

# Defer-path fix (fast-bg-task wakeup race): when a completion arrives while a
# turn is active, Option Z's drain branch CANNOT start a turn (would 409). The
# pre-existing PENDING_BG_TASK_COMPLETIONS marker was a bare session_id flag —
# the wakeup_prompt was DISCARDED, and the only consumer (PR #2279 next-turn
# drain) reads completion_queue, which the Option Z drain thread already
# emptied. So for an autonomous agent (no next user turn) the deferred wakeup
# was lost forever. DEFERRED_PROCESS_WAKEUPS persists the actual prompt(s) so a
# turn-teardown idle-hook (api/streaming) can redeliver them once the session
# goes idle — symmetric with the idle branch (idle now → fire now; busy now →
# fire at turn-end). Atomic claim (pop under lock) guarantees single delivery:
# whoever claims first (teardown hook OR next-turn drain) fires; the other
# finds nothing → no double-fire, no wakeup loop.
DEFERRED_PROCESS_WAKEUPS: dict = {}  # session_id -> list[{"process_id", "wakeup_prompt"}]
DEFERRED_PROCESS_WAKEUPS_LOCK = threading.Lock()

# ── Persistent per-session SSE channel (Option X) ──────────────────────────
# A long-lived SSE channel scoped to a WebUI session_id rather than a single
# agent turn (stream_id). Subscribed to by the frontend on session mount,
# torn down on session unmount, and refcounted across tabs. Used to deliver
# events (currently process_complete) that fire while no agent turn is
# active — bridging the gap that PR #2242 + #2279 left when STREAMS has
# already been torn down. The registry lives in api.background_process; this
# constant is the idle-cap before the reaper collects an unsubscribed
# channel. 4h is a defensive ceiling against zombie connections; the
# subscribers-empty grace path (60s) handles ordinary tab-close traffic.
SESSION_CHANNEL_IDLE_TTL_SECS: int = 14400  # 4 hours
SESSION_CHANNEL_SUBSCRIBER_GRACE_SECS: int = 60  # subscribers-empty grace

# Active agent-run registry. This intentionally tracks worker lifecycle rather
# than SSE lifecycle: cancel/reconnect may remove STREAMS while the worker is
# still unwinding, blocked in a provider call, or waiting for delegated work.
ACTIVE_RUNS: dict = {}
ACTIVE_RUNS_LOCK = threading.Lock()
LAST_RUN_FINISHED_AT: float | None = None
SERVER_START_TIME = time.time()


def active_run_is_attachable(run_entry) -> bool:
    """Return whether a run row still represents renderable live work.

    ``ACTIVE_RUNS`` tracks WORKER LIFECYCLE, which is deliberately broader than
    "a turn a browser may attach to": ``cancel_stream()`` leaves the row in
    ``phase="cancelling"`` while the worker unwinds so a successor cannot start
    on top of it. That row is already terminal from the client's perspective —
    its run journal ends in a terminal event — so recovery paths that hand a
    stream id to the renderer must exclude it. Otherwise every fresh
    ``/api/session/stream`` subscription replays ``server_turn_started`` for a
    cancelled run, the client attaches, consumes the terminal event, tears the
    renderer down and resubscribes, and the loop repeats indefinitely.

    Non-dict entries stay attachable so callers that store an opaque marker are
    unaffected; production registrations are dicts carrying ``phase``.
    """
    return not (
        isinstance(run_entry, dict)
        and str(run_entry.get("phase") or "").strip() == "cancelling"
    )


def active_run_cancel_is_stale(
    run_entry,
    *,
    grace_seconds: float,
    now: float | None = None,
) -> bool:
    """Return whether a cancelling worker outlived its bounded unwind window.

    The age anchor is ``cancelled_at`` rather than the original ``started_at``
    so a long-running turn that was just cancelled is never mistaken for an
    orphan; ``started_at`` remains the fallback for rows created before the
    cancellation timestamp existed. Callers own the grace window because the
    tolerated unwind differs per surface.
    """
    if not isinstance(run_entry, dict):
        return False
    if str(run_entry.get("phase") or "").strip() != "cancelling":
        return False
    anchor = run_entry.get("cancelled_at") or run_entry.get("started_at")
    if not anchor:
        return False
    try:
        age = (time.time() if now is None else float(now)) - float(anchor)
        return age >= float(grace_seconds)
    except (TypeError, ValueError):
        return False


def register_active_run(stream_id: str, **metadata) -> None:
    """Mark a WebUI agent worker as alive until its outer finally exits."""
    if not stream_id:
        return
    now = time.time()
    entry = dict(metadata or {})
    entry.setdefault("stream_id", stream_id)
    entry.setdefault("started_at", now)
    entry.setdefault("phase", "running")
    with ACTIVE_RUNS_LOCK:
        ACTIVE_RUNS[stream_id] = entry


def update_active_run(stream_id: str, **metadata) -> None:
    """Update active-run metadata without creating a new run implicitly."""
    if not stream_id:
        return
    with ACTIVE_RUNS_LOCK:
        entry = ACTIVE_RUNS.get(stream_id)
        if entry is not None:
            entry.update(metadata)


def unregister_active_run(stream_id: str) -> None:
    """Remove a worker from the active-run registry and record idle start."""
    if not stream_id:
        return
    global LAST_RUN_FINISHED_AT
    with ACTIVE_RUNS_LOCK:
        ACTIVE_RUNS.pop(stream_id, None)
        LAST_RUN_FINISHED_AT = time.time()
    unregister_stream_owner(stream_id)

# Agent cache: reuse AIAgent across messages in the same WebUI session so that
# _user_turn_count survives between turns.  This mirrors the gateway's
# _agent_cache pattern and is required for injectionFrequency: "first-turn".
# LRU cache with size limit to prevent memory bloat.
# All cache operations (get, set, move_to_end, popitem) are protected by
# SESSION_AGENT_CACHE_LOCK for thread safety in multi-threaded ASGI servers.
import collections
SESSION_AGENT_CACHE: collections.OrderedDict = collections.OrderedDict()  # LRU cache
# Each cached agent pins a full conversation transcript in RAM, so this cap is
# the dominant lever on WebUI resident memory (issue #3506). The default is kept
# deliberately modest -- large/long sessions can each weigh tens of MB, so 50
# live agents could pin >1 GB on a heavily multiplexed install. Operators can
# tune it via HERMES_WEBUI_AGENT_CACHE_MAX without editing source.
SESSION_AGENT_CACHE_MAX = _env_int("HERMES_WEBUI_AGENT_CACHE_MAX", 25)
SESSION_AGENT_CACHE_LOCK = threading.Lock()


def _evict_session_agent(session_id: str) -> None:
    """Remove a cached agent for a session (on delete, clear, or model switch).

    Attempts a lifecycle commit before dropping the agent handle so that
    batch-extraction memory providers can extract any pending work.  If the
    commit fails or there is uncommitted work with no successful commit, the
    lifecycle entry is preserved (not unregistered) so a future commit can
    retry.
    """
    agent = None
    with SESSION_AGENT_CACHE_LOCK:
        entry = SESSION_AGENT_CACHE.pop(session_id, None)
        if entry is not None:
            agent = entry[0] if isinstance(entry, tuple) else None
    if agent is None:
        return
    # A live run for this session may still hold this agent's _session_db (the
    # worker assigns agent._session_db at run start). Never close it out from
    # under an in-flight turn — ACTIVE_RUNS is the authoritative liveness signal
    # (mirrors the worker's own LRU-eviction guard in streaming.py). When a run
    # is live we still drop the cache handle above (harmless — the worker holds
    # a local ref), but skip the lifecycle commit + _session_db.close() so the
    # running turn can finish persisting. Hardens /clear + model-switch eviction
    # too, not just truncate (#5096 Bug D).
    _run_active = False
    try:
        with ACTIVE_RUNS_LOCK:
            for _entry in (ACTIVE_RUNS or {}).values():
                if (_entry or {}).get("session_id") == session_id:
                    _run_active = True
                    break
    except Exception:
        _run_active = False
    if _run_active:
        return
    should_close = True
    try:
        from api.session_lifecycle import commit_session_memory, discard_session, has_uncommitted_work, unregister_agent
        if has_uncommitted_work(session_id):
            commit_session_memory(session_id, agent=agent, wait=True)
        if not has_uncommitted_work(session_id):
            unregister_agent(session_id)
            # Bound the lifecycle dict: drop the entry now that the session has
            # no uncommitted work and the agent handle is gone (issue #3506).
            discard_session(session_id)
        else:
            should_close = False
    except Exception:
        should_close = False
        logger.debug("Lifecycle commit on eviction failed for %s", session_id, exc_info=True)
    if should_close and getattr(agent, '_session_db', None) is not None:
        try:
            agent._session_db.close()
        except Exception:
            logger.debug("Failed to close _session_db on eviction for %s", session_id, exc_info=True)

# ── Thread-local env context ─────────────────────────────────────────────────
# (_thread_ctx + _thread_local_env_value are defined near the top of this module,
# above the config-file section, so _expand_env_vars can reference them at the
# import-time reload_config() without a forward-reference NameError.)


def _set_thread_env(**kwargs):
    _thread_ctx.env = kwargs


def _clear_thread_env():
    _thread_ctx.env = {}


# ── Per-session agent locks ───────────────────────────────────────────────────
# Weak values keep one lock for every overlapping holder/waiter without leaking
# one permanent registry entry per deleted session.  A caller's local reference
# keeps the lock alive for the whole critical section; once no operation can
# still use it, the registry entry disappears automatically.
SESSION_AGENT_LOCKS = weakref.WeakValueDictionary()
SESSION_AGENT_LOCKS_LOCK = threading.Lock()


def _get_session_agent_lock(session_id: str) -> threading.Lock:
    """Return the per-session Lock used to serialize all Session mutations.

    Lock lifecycle invariant:
      - A Lock is created lazily on first access. The weak registry retains it
        while any holder or waiter has a strong reference, then reclaims the
        entry automatically when no overlapping operation can still use it.
      - During context compression the agent may rotate session_id. The
        streaming thread atomically aliases both old and new IDs to the *same*
        Lock object under SESSION_AGENT_LOCKS_LOCK (see streaming.py's
        compression block). Keeping the old alias prevents a late old-ID caller
        from creating a second Lock while an earlier holder or waiter still
        exists. Both weak aliases disappear automatically after all strong
        references to the Lock are released.
      - Lock contract: hold for the in-memory mutation + s.save() only; never
        across network I/O (LLM calls, HTTP requests).
    """
    with SESSION_AGENT_LOCKS_LOCK:
        lock = SESSION_AGENT_LOCKS.get(session_id)
        if lock is None:
            lock = threading.Lock()
            SESSION_AGENT_LOCKS[session_id] = lock
        return lock


def _alias_session_agent_lock(
    old_session_id: str,
    new_session_id: str,
    lock: threading.Lock,
) -> None:
    """Alias a compression continuation to the same live mutation lock.

    Keep the old ID alias while any holder or waiter still references ``lock``.
    Because the registry values are weak, both aliases disappear automatically
    once no overlapping operation can use the pre-compression lock. Removing the
    old alias eagerly would let a late old-ID request create a second lock.
    """
    with SESSION_AGENT_LOCKS_LOCK:
        SESSION_AGENT_LOCKS[old_session_id] = lock
        SESSION_AGENT_LOCKS[new_session_id] = lock


# ── Settings persistence ─────────────────────────────────────────────────────

_SETTINGS_DEFAULTS = {
    "default_workspace": str(DEFAULT_WORKSPACE),
    "onboarding_completed": False,
    "send_key": "enter",  # 'enter', 'ctrl+enter', or 'shift+enter'
    "show_token_usage": False,  # show input/output token badge below assistant messages
    "show_quota_chip": False,  # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media)
    "show_conversation_outline": False,  # show opt-in desktop jump-to-question outline panel
    "show_busy_placeholder_hint": False,  # opt-in busy composer placeholder hint
    "hide_empty_state_suggestions": False,  # hide the default new-chat suggestion buttons
    "hide_empty_state_panel": False,  # hide the complete new-chat welcome panel
    "new_chat_on_workspace_switch": False,  # #5473 opt-in: switching to a DIFFERENT workspace starts a new chat (leaving the current conversation on its original workspace) instead of mutating the current session's workspace in place. Default OFF preserves the shipped in-place-switch behavior.
    "virtualize_transcript": False,  # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings).
    "virtualize_transcript_optin": False,  # #4343 migration marker: True only once the user explicitly enables virtualize_transcript AFTER the default-off flip. A stored virtualize_transcript=True WITHOUT this marker is a stale pre-flip value and is reset to False on load (force-off-for-everyone migration).
    "show_tps": False,  # show tokens-per-second chip in assistant message headers
    "fade_text_effect": False,  # animate newly streamed words with a lightweight fade-in effect
    "show_cli_sessions": True,  # merge CLI/TUI/messaging sessions from state.db into the sidebar by default (#3988); established installs are grandfathered OFF by the load_settings backfill
    "show_claude_code_sessions": True,  # allow filtering Claude Code rows without hiding other imported sources
    "show_cron_sessions": False,  # surface cron sessions in the sidebar (subordinate to show_cli_sessions)
    "show_webhook_sessions": False,  # surface webhook sessions in the sidebar (subordinate to show_cli_sessions)
    "show_kanban_sessions": False,  # surface kanban worker sessions in the sidebar (subordinate to show_cli_sessions)
    "show_previous_messaging_sessions": False,  # show older Telegram/Discord/etc. reset segments
    "sync_to_insights": False,  # mirror WebUI token usage to state.db for /insights
    "check_for_updates": True,  # check if webui/agent repos are behind upstream
    "update_channel": "stable",  # stable | experimental — which release stream to track (stable = soaked/promoted; experimental = every batch)
    "ignore_agent_updates": False,  # keep WebUI update notices but suppress Agent update checks
    "whats_new_summary_enabled": False,  # show an LLM-written What's New summary before diff links
    "tts_enabled": False,
    "tts_auto_read": False,
    "tts_engine": "browser",
    "tts_voice": "",
    "tts_rate": 1.0,
    "tts_pitch": 1.0,
    "voice_mode_button": False,
    "voice_continuous": False,
    "voice_silence_ms": 1800,
    "raw_audio_mode": False,
    "theme": "dark",  # light | dark | system
    "skin": "default",  # accent color skin: default | ares | mono | graphite | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous
    "font_size": "default",  # small | default | large | xlarge
    "session_jump_buttons": False,  # show Start/End transcript jump pills
    "render_user_markdown": False,  # opt-in: render full markdown in user messages (#3870)
    "large_text_paste_as_attachment": True,  # convert very large composer text pastes into .md attachments by default
    "project_quick_create_buttons": False,  # opt-in: show per-project "+" quick-create buttons on sidebar project chips (#4676)
    "structured_code_default_view": "auto",  # JSON/YAML fenced-block default render: auto | on | off (#484 follow-up). auto => Tree when line count >= structured_code_auto_tree_lines, else Raw.
    "structured_code_auto_tree_lines": 10,  # in 'auto' mode, minimum line count to default a JSON/YAML block to Tree view (preserves the original hardcoded >=10 behavior)
    "session_endless_scroll": False,  # auto-load older transcript pages while scrolling upward
    "chat_activity_display_mode": "compact_worklog",  # compact_worklog | transparent_stream | hide_all_activity
    "transparent_stream_event_timestamps": True,  # show per-event timestamp chips inside Transparent Stream
    "auto_scroll_follow": True,  # follow new output to the bottom while streaming (Codex/Claude-Code-style sticky bottom); the user scrolling up unpins and is respected
    "worklog_details_expanded_default": False,  # opt-in: expand Worklog details by default; default remains folded
    "hide_composer_attach": False,  # hide attach button in composer footer
    "hide_composer_saved_prompts": False,  # hide saved prompts button in composer footer
    "hide_composer_mic": False,  # hide dictation mic button in composer footer
    "show_titlebar_profile": False,  # show profile switcher in app titlebar (opt-in)
    "hide_composer_voice_mode": False,  # hide hands-free voice-mode button in composer footer
    "hide_composer_yolo": False,  # hide YOLO chip in composer footer
    "hide_composer_profile": False,  # hide profile chip in composer footer
    "hide_composer_workspace": False,  # hide workspace controls in composer footer/mobile config panel
    "hide_composer_mobile_config": False,  # hide mobile composer config button
    "hide_composer_model": False,  # hide model chip in composer footer/mobile config panel
    "hide_composer_quota_chip": False,  # hide provider quota chip in composer footer
    "hide_composer_reasoning": False,  # hide reasoning chip in composer footer/mobile config panel
    "hide_composer_toolsets": False,  # hide toolsets chip in composer footer
    "hide_composer_status": False,  # hide status text in composer footer
    "hide_composer_context": False,  # hide context indicator in composer footer/mobile config panel
    "hide_composer_bg_badge": False,  # hide background-jobs badge in composer footer
    "pinned_sessions_limit": 3,  # maximum active pinned sessions shown in the sidebar
    "inflight_state_max_sessions": 8,  # max active-stream recovery snapshots kept in browser localStorage
    "inflight_state_max_messages": 24,  # max recent messages kept per recovery snapshot
    "inflight_state_max_tool_calls": 48,  # max recent tool-call records kept per recovery snapshot
    "inflight_state_max_string_chars": 60000,  # max string length kept inside a recovery snapshot field
    "inflight_state_max_json_chars": 1500000,  # max serialized recovery snapshot payload before pruning
    "hidden_tabs": [],  # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
    "tab_order": [],  # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed
    "composer_control_order": [],  # user-defined composer footer control order; invalid/duplicate keys are ignored
    # #7622 (round 3): language is intentionally absent from the defaults so
    # `load_settings()` reports `None` for a fresh install.  This lets the
    # client distinguish "no preference" from an explicit saved choice,
    # so a user who genuinely picked English (and has `language: "en"`
    # persisted on disk) is no longer overridden by the browser hint on
    # their first hydration.  When a user picks a locale in the Settings
    # modal the value is written here and the field is set explicitly.
    "bot_name": os.getenv(
        "HERMES_WEBUI_BOT_NAME", "Hermes"
    ),  # display name for the assistant
    "sound_enabled": False,  # play notification sound when assistant finishes
    "rtl": False,  # right-to-left chat layout (chat messages + composer only)
    "notifications_enabled": False,  # browser notification when tab is in background
    "show_thinking": True,  # show/hide thinking/reasoning blocks in chat view
    "simplified_tool_calling": True,  # legacy compatibility; Worklog renderer remains enabled
    "terminal_auto_expand_on_output": False,  # auto-expand terminal panel when output arrives while collapsed
    "workspace_todos_tab": False,  # show a Todos tab in the workspace panel (right side)
    "api_redact_enabled": True,  # redact sensitive data (API keys, secrets) from API responses
    "dashboard_plugins": {},  # plugin_name -> bool, opt-in per plugin (default off per PF-10b)
    "sidebar_density": "compact",  # compact | detailed
    "auto_title_refresh_every": "0",  # adaptive title refresh: 0=off, 5/10/20=every N exchanges
    "default_message_mode": "steer",  # behavior when sending while agent is running: queue | interrupt | steer
    "password_hash": None,  # PBKDF2-HMAC-SHA256 hash; None = auth disabled
    "auth_disabled_acknowledged": False,  # user acknowledged unauthenticated risk
    "provider_cost_budget": None,
}
_SETTINGS_SPEECH_KEYS = {
    "tts_enabled",
    "tts_auto_read",
    "tts_engine",
    "tts_voice",
    "tts_rate",
    "tts_pitch",
    "voice_mode_button",
    "voice_continuous",
    "voice_silence_ms",
    "raw_audio_mode",
}
_SETTINGS_PERSISTED_SPEECH_KEYS_FIELD = "persisted_speech_keys"
_SETTINGS_LEGACY_DROP_KEYS = {
    "assistant_language",
    "bubble_layout",
    "default_model",
    "activity_feed_expanded_default",
    "simplified_tool_calling",
}
_COMPOSER_CONTROL_ORDER_KEYS = {
    key for key in _SETTINGS_DEFAULTS if key.startswith("hide_composer_")
}
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
_SETTINGS_SKIN_VALUES = {
    "default",
    "ares",
    "mono",
    "graphite",
    "slate",
    "poseidon",
    "sisyphus",
    "charizard",
    "sienna",
    "catppuccin",
    "nous",
    "geist-contrast",
    "zeus",
    "verdigris",
    "neon-soft",
    "neon-paint",
}
_SETTINGS_LEGACY_THEME_MAP = {
    # Legacy full themes now map onto the closest supported theme + accent skin pair.
    "slate": ("dark", "slate"),
    "solarized": ("dark", "poseidon"),
    "monokai": ("dark", "sisyphus"),
    "nord": ("dark", "slate"),
    "oled": ("dark", "default"),
}


def _normalize_appearance(theme, skin) -> tuple[str, str]:
    """Normalize a (theme, skin) pair, migrating legacy theme names.

    Legacy migration table (from `_SETTINGS_LEGACY_THEME_MAP`):

        slate     → ("dark", "slate")
        solarized → ("dark", "poseidon")
        monokai   → ("dark", "sisyphus")
        nord      → ("dark", "slate")
        oled      → ("dark", "default")

    Unknown / custom theme names fall back to ("dark", "default").  This is a
    behavior change vs. the pre-PR-#627 state, where the `theme` field was
    open-ended ("no enum gate -- allows custom themes").  Users who set a
    custom CSS theme via `data-theme` will need to re-apply via skin or
    custom CSS — see CHANGELOG entry for details.

    The same mapping is mirrored in `static/boot.js` (`_LEGACY_THEME_MAP`)
    so client and server normalize identically; keep them in sync.
    """
    raw_theme = theme.strip().lower() if isinstance(theme, str) else ""
    raw_skin = skin.strip().lower() if isinstance(skin, str) else ""
    legacy = _SETTINGS_LEGACY_THEME_MAP.get(raw_theme)
    if legacy:
        next_theme, legacy_skin = legacy
    elif raw_theme in _SETTINGS_THEME_VALUES:
        next_theme, legacy_skin = raw_theme, "default"
    else:
        # Unknown themes used to exist; default to dark so upgrades stay visually stable.
        next_theme, legacy_skin = "dark", "default"
    next_skin = (
        raw_skin
        if raw_skin in _SETTINGS_SKIN_VALUES
        else legacy_skin
    )
    return next_theme, next_skin


def _read_raw_settings_file() -> dict:
    """Read settings.json without applying defaults."""
    try:
        if not SETTINGS_FILE.exists():
            return {}
    except OSError:
        # PermissionError or other OS-level error (e.g. UID mismatch in Docker)
        # Treat as missing rather than failing startup.
        logger.debug("Cannot stat settings file %s (inaccessible?)", SETTINGS_FILE)
        return {}

    try:
        loaded = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
    except Exception:
        logger.debug("Failed to load settings from %s", SETTINGS_FILE)
        return {}
    return loaded if isinstance(loaded, dict) else {}


def _extract_persisted_speech_keys(stored: dict) -> set[str]:
    if not isinstance(stored, dict):
        return set()
    return {key for key in _SETTINGS_SPEECH_KEYS if key in stored}


def persisted_speech_settings_keys() -> list[str]:
    return sorted(_extract_persisted_speech_keys(_read_raw_settings_file()))


def _settings_payload_for_write(settings: dict, persisted_speech_keys: set[str]) -> dict:
    persisted = {
        k: v
        for k, v in settings.items()
        if k not in {"default_model", _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD}
    }
    for speech_key in _SETTINGS_SPEECH_KEYS:
        if speech_key not in persisted_speech_keys:
            persisted.pop(speech_key, None)
    return persisted


def load_settings() -> dict:
    """Load settings from disk, merging with defaults for any missing keys."""
    settings = dict(_SETTINGS_DEFAULTS)
    stored = _read_raw_settings_file()
    if isinstance(stored, dict):
        if (
            "worklog_details_expanded_default" not in stored
            and "activity_feed_expanded_default" in stored
        ):
            settings["worklog_details_expanded_default"] = bool(
                stored.get("activity_feed_expanded_default")
            )
        settings.update(
            {
                k: v
                for k, v in stored.items()
                if k not in _SETTINGS_LEGACY_DROP_KEYS
                and k != _SETTINGS_PERSISTED_SPEECH_KEYS_FIELD
            }
        )
        if (
            "default_message_mode" not in stored
            and "busy_input_mode" in stored
        ):
            settings["default_message_mode"] = stored.get("busy_input_mode")
        settings.pop("busy_input_mode", None)
        # Grandfather established installs OFF for show_cli_sessions (#3988).
        # The default flipped True so NEW users see CLI/TUI/messaging
        # sessions without hunting for the toggle — but an existing user
        # who never opted in should not have their sidebar silently change.
        # Treat the install as established (and pin the old False default)
        # when show_cli_sessions is absent AND the file already carries
        # real user state — either onboarding was completed, or some
        # setting OTHER than a not-yet-completed onboarding flag has been
        # persisted. Keying on "has saved user state" (not just
        # onboarding_completed) also covers a CLI-configured user who
        # tweaked a WebUI setting before running the wizard. A genuinely
        # new / still-mid-onboarding file falls through to the True default.
        _established_keys = [
            k for k in stored
            if k not in ("show_cli_sessions", "onboarding_completed")
        ]
        if "show_cli_sessions" not in stored and (
            bool(stored.get("onboarding_completed")) or _established_keys
        ):
            settings["show_cli_sessions"] = False
        # Force-off-for-everyone migration for virtualize_transcript (#4343).
        # The feature shipped opt-OUT/default-on in #4325, then proved to
        # cause scroll-up flicker on long sessions (variable-height anchor
        # oscillation). It is now EXPERIMENTAL/opt-IN (default off). Any
        # stored virtualize_transcript=True from the #4325 window is a stale
        # pre-flip value and must be reset to off, so 100% of existing users
        # land on off — re-enabling requires an explicit opt-in made AFTER
        # the flip, which writes virtualize_transcript_optin=True alongside.
        # Honor a stored True only when that marker is present.
        if not bool(stored.get("virtualize_transcript_optin")):
            settings["virtualize_transcript"] = False
    # Fall back to the DEFAULTS, not to None, when nothing is stored.
    #
    # `_read_raw_settings_file()` returns {} for a MISSING settings.json, and {}
    # is a dict — so the `isinstance(stored, dict)` arms were always taken,
    # `stored.get("theme")` was None, and `_normalize_appearance(None, None)`
    # fell through to its unknown-theme branch and returned ("dark", "default").
    # `_SETTINGS_DEFAULTS["theme"]` / `["skin"]` were therefore unreachable for
    # the one case they exist to serve: a user with no settings file yet.
    #
    # This is invisible on stock defaults, because dark/default is exactly what
    # the fallback produces — the two paths agree. It only surfaces once the
    # defaults are changed, at which point the dict silently does nothing.
    #
    # Gate on the PAIR, not per field. A per-field `or settings.get(...)` looks
    # equivalent and is not: with a stored legacy theme and no skin, `slate`
    # normalises to ("dark", "slate"), but per-field fallback injects the
    # default skin and yields ("dark", "default") — silently destroying the
    # legacy migration. Same distinction the boot script draws in #6808.
    _has_stored_appearance = isinstance(stored, dict) and (
        "theme" in stored or "skin" in stored
    )
    settings["theme"], settings["skin"] = _normalize_appearance(
        stored.get("theme") if _has_stored_appearance else settings.get("theme"),
        stored.get("skin") if _has_stored_appearance else settings.get("skin"),
    )
    settings["default_model"] = get_effective_default_model()
    try:
        model_cfg = get_config().get("model", {})
        if isinstance(model_cfg, dict) and model_cfg.get("provider"):
            settings["default_model_provider"] = str(model_cfg.get("provider"))
    except Exception:
        logger.debug("Failed to resolve default model provider for settings")
    # #7622/#7730 (round 4): keep the tri-state signal explicit.  `language`
    # is intentionally absent from `_SETTINGS_DEFAULTS` (see above), so
    # without this line a fresh install's returned dict would OMIT the key
    # entirely and the API payload would carry no `language` field.  Emit an
    # explicit `None` (serialized as JSON `null`) so the client receives the
    # three-way signal it trusts: `null` = no preference, "en" = explicitly
    # saved English, any other code = explicitly chosen locale.  Stored
    # values (including a legacy English pick written before this change)
    # win via the merge above and are never touched here.
    settings.setdefault("language", None)
    return settings


_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {
    "password_hash",
    "default_model",
    "simplified_tool_calling",
} | {
    # #7622 (round 3): `language` is intentionally absent from
    # `_SETTINGS_DEFAULTS` so a fresh install returns `None` for
    # `settings["language"]` and the client can distinguish "no
    # preference" from an explicit saved choice.  But the user
    # MUST still be able to pick a locale in the Settings modal,
    # so we add it back to the explicit allow-list here.  The
    # existing BCP-47 validation at save-time still applies.
    "language",
}
_SETTINGS_ENUM_VALUES = {
    "send_key": {"enter", "ctrl+enter", "shift+enter"},
    "sidebar_density": {"compact", "detailed"},
    "update_channel": {"stable", "experimental"},
    "font_size": {"small", "default", "large", "xlarge"},
    "auto_title_refresh_every": {"0", "5", "10", "20"},
    "default_message_mode": {"queue", "interrupt", "steer"},
    "chat_activity_display_mode": {"compact_worklog", "transparent_stream", "hide_all_activity"},
    "structured_code_default_view": {"auto", "on", "off"},
}
_SETTINGS_INT_RANGES = {
    "pinned_sessions_limit": (1, 99),
    "inflight_state_max_sessions": (1, 25),
    "inflight_state_max_messages": (1, 100),
    "inflight_state_max_tool_calls": (1, 200),
    "inflight_state_max_string_chars": (1000, 500000),
    "inflight_state_max_json_chars": (100000, 4000000),
    "structured_code_auto_tree_lines": (1, 1000),
    "voice_silence_ms": (200, 60000),
}
_SETTINGS_FLOAT_RANGES = {
    "tts_rate": (0.5, 2.0),
    "tts_pitch": (0.0, 2.0),
}
_SETTINGS_BOOL_KEYS = {
    "onboarding_completed",
    "show_token_usage",
    "show_quota_chip",
    "show_conversation_outline",
    "show_busy_placeholder_hint",
    "hide_empty_state_suggestions",
    "hide_empty_state_panel",
    "new_chat_on_workspace_switch",
    "virtualize_transcript",
    "virtualize_transcript_optin",
    "show_tps",
    "fade_text_effect",
    "show_cli_sessions",
    "show_claude_code_sessions",
    "show_cron_sessions",
    "show_webhook_sessions",
    "show_kanban_sessions",
    "show_previous_messaging_sessions",
    "sync_to_insights",
    "check_for_updates",
    "ignore_agent_updates",
    "whats_new_summary_enabled",
    "tts_enabled",
    "tts_auto_read",
    "voice_mode_button",
    "voice_continuous",
    "raw_audio_mode",
    "sound_enabled",
    "rtl",
    "notifications_enabled",
    "show_thinking",
    "terminal_auto_expand_on_output",
    "workspace_todos_tab",
    "api_redact_enabled",
    "session_jump_buttons",
    "render_user_markdown",
    "large_text_paste_as_attachment",
    "project_quick_create_buttons",
    "session_endless_scroll",
    "transparent_stream_event_timestamps",
    "auto_scroll_follow",
    "worklog_details_expanded_default",
    "auth_disabled_acknowledged",
    "hide_composer_attach",
    "hide_composer_saved_prompts",
    "hide_composer_mic",
    "show_titlebar_profile",
    "hide_composer_voice_mode",
    "hide_composer_yolo",
    "hide_composer_profile",
    "hide_composer_workspace",
    "hide_composer_mobile_config",
    "hide_composer_model",
    "hide_composer_quota_chip",
    "hide_composer_reasoning",
    "hide_composer_toolsets",
    "hide_composer_status",
    "hide_composer_context",
    "hide_composer_bg_badge",
}
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
_SETTINGS_TTS_ENGINE_RE = __import__("re").compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")

_SETTINGS_WRITE_VERSION = 0
_SETTINGS_WRITE_LOCK = __import__("threading").Lock()


def _atomic_write_settings_text(path: Path, text: str) -> None:
    """Write *text* to *path* atomically (temp file + fsync + os.replace).

    ``settings.json`` was rewritten with a plain ``Path.write_text``, which
    truncates the file in place: a crash or full disk mid-write leaves it
    truncated/empty, so the next start loses every persisted setting (theme,
    workspace, tab order, and the login ``password_hash``). Writing to a
    sibling temp file, fsyncing, then ``os.replace`` keeps the old contents
    intact until the rename commits the new ones in one step.  Mirrors the
    tempfile+fsync+os.replace pattern already used by
    ``webui_session_db.WebUIJsonSessionDB._atomic_write``.

    The existing file's mode is carried onto the replacement: ``os.replace``
    swaps in the temp file's inode, and a plain ``open`` respects the umask
    (typically 0644), so without this an operator-hardened ``settings.json``
    (chmod 0600 because it holds the password hash) would be silently loosened
    on the next save.  New files fall back to the umask-adjusted default.

    A symlinked target is written through to its referent (same follow-through
    as the ``Path.write_text`` this replaces), rather than replacing the link
    itself with a regular file.
    """
    path = Path(path)
    write_path = path.resolve(strict=False) if path.is_symlink() else path
    tmp = write_path.with_name(
        f".{write_path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
    )
    try:
        mode = os.stat(write_path).st_mode & 0o777
    except FileNotFoundError:
        mode = 0o666 & ~_current_umask()
    try:
        with open(tmp, "w", encoding="utf-8") as handle:
            handle.write(text)
            handle.flush()
            os.fsync(handle.fileno())
        os.chmod(tmp, mode)
        os.replace(tmp, write_path)
    finally:
        try:
            os.unlink(tmp)
        except OSError:
            pass


def _current_umask() -> int:
    """Read the process umask without leaving it changed.

    ``os.umask`` has no read-only form (it sets and returns the prior value),
    so we set-then-restore. Called only on the new-``settings.json`` path, which
    is rare; the tiny set-to-0 window is acceptable here (unlike a per-write
    hot path).
    """
    umask = os.umask(0)
    os.umask(umask)
    return umask


def _coerce_provider_cost_budget(value: Any) -> float | None:
    """Normalize a monthly budget to the persisted two-decimal representation."""
    try:
        rounded = round(float(value), 2)
    except (TypeError, ValueError):
        return None
    if not (0 < rounded < 1e9) or not math.isfinite(rounded):
        return None
    return rounded


def save_settings(settings: dict) -> dict:
    """Save settings to disk. Returns the merged settings. Ignores unknown keys."""
    raw_settings = _read_raw_settings_file()
    persisted_speech_keys = _extract_persisted_speech_keys(raw_settings)
    current = load_settings()
    applied_speech_keys: set[str] = set()
    if (
        "worklog_details_expanded_default" not in settings
        and "activity_feed_expanded_default" in settings
    ):
        settings["worklog_details_expanded_default"] = settings.get(
            "activity_feed_expanded_default"
        )
    settings.pop("activity_feed_expanded_default", None)
    if (
        "default_message_mode" not in settings
        and "busy_input_mode" in settings
    ):
        settings["default_message_mode"] = settings.get("busy_input_mode")
    settings.pop("busy_input_mode", None)
    settings.pop("simplified_tool_calling", None)
    pending_theme = current.get("theme")
    pending_skin = current.get("skin")
    theme_was_explicit = False
    skin_was_explicit = False
    # Handle _set_password: hash and store as password_hash
    _password_changed = False
    raw_pw = settings.pop("_set_password", None)
    if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():
        # Use PBKDF2 from auth module (600k iterations) -- never raw SHA-256
        from api.auth import _hash_password

        current["password_hash"] = _hash_password(raw_pw.strip())
        _password_changed = True
    # Handle _clear_password: explicitly disable auth
    if settings.pop("_clear_password", False):
        current["password_hash"] = None
        _password_changed = True
    # Deep-merge dashboard_plugins dict (plugin_name -> bool)
    _dashboard_plugins = settings.get("dashboard_plugins")
    if isinstance(_dashboard_plugins, dict):
        current_dash = current.get("dashboard_plugins", {})
        if isinstance(current_dash, dict):
            # Coerce values to bool + keep only str keys so settings.json can't be
            # polluted with non-bool/non-str junk from a crafted POST.
            current_dash.update({k: bool(v) for k, v in _dashboard_plugins.items() if isinstance(k, str)})
            current["dashboard_plugins"] = current_dash
    for k, v in settings.items():
        key_is_speech = k in _SETTINGS_SPEECH_KEYS
        # dashboard_plugins is deep-merged above (not a flat allowlisted scalar).
        if k == "dashboard_plugins":
            continue
        if k in _SETTINGS_ALLOWED_KEYS:
            if k == "theme":
                if isinstance(v, str) and v.strip():
                    pending_theme = v
                    theme_was_explicit = True
                continue
            if k == "skin":
                if isinstance(v, str) and v.strip():
                    pending_skin = v
                    skin_was_explicit = True
                continue
            # Validate enum-constrained keys
            if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
                continue
            # Validate bounded integer settings.
            if k in _SETTINGS_INT_RANGES:
                try:
                    v = int(v)
                except (TypeError, ValueError):
                    continue
                min_value, max_value = _SETTINGS_INT_RANGES[k]
                if v < min_value or v > max_value:
                    continue
            if k in _SETTINGS_FLOAT_RANGES:
                try:
                    v = float(v)
                except (TypeError, ValueError):
                    continue
                min_value, max_value = _SETTINGS_FLOAT_RANGES[k]
                if not math.isfinite(v) or v < min_value or v > max_value:
                    continue
            if k == "tts_engine":
                if not isinstance(v, str):
                    continue
                v = v.strip()
                if not _SETTINGS_TTS_ENGINE_RE.match(v):
                    continue
            if k == "tts_voice":
                if not isinstance(v, str) or len(v) > 200 or "\x00" in v:
                    continue
            # Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN')
            if k == "language" and (
                not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)
            ):
                continue
            # Validate list-valued ordering settings. Chat/settings stay fixed
            # for tabs; composer ordering only accepts known control keys.
            # Duplicates are collapsed while preserving the first requested order.
            if k in {"hidden_tabs", "tab_order", "composer_control_order"}:
                if not isinstance(v, list):
                    continue
                seen = set()
                cleaned = []
                for s in v:
                    if not isinstance(s, str):
                        continue
                    s = s.strip()
                    if not s or s in seen:
                        continue
                    if k in {"hidden_tabs", "tab_order"} and s in {"chat", "settings"}:
                        continue
                    if k == "composer_control_order" and s not in _COMPOSER_CONTROL_ORDER_KEYS:
                        continue
                    seen.add(s)
                    cleaned.append(s)
                v = cleaned
            if k == "provider_cost_budget":
                if v is None or v == "":
                    current[k] = None
                    continue
                budget = _coerce_provider_cost_budget(v)
                if budget is None:
                    continue
                current[k] = budget
                continue
            # Coerce bool keys
            if k in _SETTINGS_BOOL_KEYS:
                v = bool(v)
            current[k] = v
            if key_is_speech:
                applied_speech_keys.add(k)
    theme_value = pending_theme
    skin_value = pending_skin
    if theme_was_explicit and not skin_was_explicit:
        raw_theme = pending_theme.strip().lower() if isinstance(pending_theme, str) else ""
        if raw_theme not in _SETTINGS_THEME_VALUES:
            skin_value = None
    current["theme"], current["skin"] = _normalize_appearance(theme_value, skin_value)

    current["default_workspace"] = str(
        resolve_default_workspace(current.get("default_workspace"))
    )
    effective_persisted_speech_keys = set(persisted_speech_keys)
    effective_persisted_speech_keys.update(applied_speech_keys)
    persisted = _settings_payload_for_write(current, effective_persisted_speech_keys)
    SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
    _atomic_write_settings_text(
        SETTINGS_FILE,
        json.dumps(persisted, ensure_ascii=False, indent=2),
    )
    global _SETTINGS_WRITE_VERSION
    with _SETTINGS_WRITE_LOCK:
        _SETTINGS_WRITE_VERSION += 1
    # Invalidate the in-memory password hash cache so the next call to
    # get_password_hash() picks up the new value from disk immediately.
    if _password_changed:
        from api.auth import _invalidate_password_hash_cache

        _invalidate_password_hash_cache()
    # Update runtime defaults so new sessions use them immediately
    global DEFAULT_WORKSPACE
    if "default_workspace" in current:
        DEFAULT_WORKSPACE = resolve_default_workspace(current["default_workspace"])
    current["default_model"] = get_effective_default_model()
    return current


# Apply saved settings on startup (override env-derived defaults)
# Exception: if HERMES_WEBUI_DEFAULT_WORKSPACE is explicitly set in the
# environment, it wins over whatever settings.json has stored.  Persisted
# config must never shadow an explicit env-var override (Docker deployments
# rely on this — otherwise deleting settings.json is the only escape).
_startup_settings = load_settings()
try:
    _settings_file_exists = SETTINGS_FILE.exists()
except OSError:
    _settings_file_exists = False
if _settings_file_exists:
    if not os.getenv("HERMES_WEBUI_DEFAULT_WORKSPACE"):
        DEFAULT_WORKSPACE = resolve_default_workspace(
            _startup_settings.get("default_workspace")
        )
    _startup_settings.pop("default_model", None)  # always drop stale value; model comes from config.yaml
    if _startup_settings.get("default_workspace") != str(DEFAULT_WORKSPACE):
        _startup_settings["default_workspace"] = str(DEFAULT_WORKSPACE)
        try:
            startup_persisted_speech_keys = _extract_persisted_speech_keys(
                _read_raw_settings_file()
            )
            _atomic_write_settings_text(
                SETTINGS_FILE,
                json.dumps(
                    _settings_payload_for_write(
                        _startup_settings, startup_persisted_speech_keys
                    ),
                    ensure_ascii=False,
                    indent=2,
                ),
            )
        except Exception:
            pass

# ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
SESSIONS: collections.OrderedDict = collections.OrderedDict()


def get_runtime_diagnostics_snapshot() -> dict[str, dict[str, object]]:
    """Return nonblocking scalar observations owned by the config module."""
    result = {
        "sessions": {"available": False, "resident": 0, "cap": 0},
        "models_cache": {
            "available": False,
            "groups": 0,
            "models": 0,
            "age_seconds": None,
        },
    }
    try:
        if LOCK.acquire(blocking=False):
            try:
                # Held-section discipline: len(), arithmetic, and owner-held
                # scalars only. Never call anything here that can resolve config
                # or a profile, touch the filesystem, import a module, or wait on
                # another lock — the cap is the scalar _evict_sessions_over_cap()
                # published, precisely so this section stays leaf-nonblocking.
                result["sessions"] = {
                    "available": True,
                    "resident": max(0, int(len(SESSIONS))),
                    "cap": max(0, int(_LAST_APPLIED_SESSIONS_CACHE_MAX)),
                }
            finally:
                LOCK.release()
    except Exception:
        pass
    try:
        if _available_models_cache_lock.acquire(blocking=False):
            try:
                # Same held-section discipline: len(), isinstance, float(), and
                # time.monotonic() only. _available_models_cache_lock is an RLock
                # (see its definition), so a nonblocking acquire from a thread
                # that already holds it would report available mid-build; safe
                # here because health collection is never nested inside a
                # catalog build, and nothing may be added that changes that.
                snapshot = _available_models_cache
                groups = snapshot.get("groups") if isinstance(snapshot, dict) else None
                group_count = len(groups) if isinstance(groups, list) else 0
                model_count = 0
                if isinstance(groups, list):
                    for group in groups:
                        if isinstance(group, dict):
                            for bucket in ("models", "extra_models"):
                                models = group.get(bucket)
                                if isinstance(models, list):
                                    model_count += len(models)
                age = None
                if snapshot is not None and _available_models_cache_ts:
                    age = max(0.0, time.monotonic() - float(_available_models_cache_ts))
                result["models_cache"] = {
                    "available": True,
                    "groups": max(0, int(group_count)),
                    "models": max(0, int(model_count)),
                    "age_seconds": age,
                }
            finally:
                _available_models_cache_lock.release()
    except Exception:
        pass
    return result

# ── Profile state initialisation ────────────────────────────────────────────
# Must run after all imports are resolved to correctly patch module-level caches
try:
    from api.profiles import init_profile_state

    init_profile_state()
except ImportError:
    pass  # hermes_cli not available -- default profile only


# Run the provider-model seeder once at import time. Must be at the END of the
# module because _seed_provider_models_from_core() calls _get_label_for_model,
# which is defined ~3000 lines above. Placing the invocation earlier (e.g. right
# after the seeder's def) caused a NameError that the bare except silently
# swallowed — exactly when the seeder had real work to do (#4413).
try:
    _seed_provider_models_from_core()
except ImportError:
    pass  # hermes_cli not available (standalone deployment)
except Exception:
    logger.warning("provider-model seeder failed", exc_info=True)
