diff --git a/hermes_cli/moa_cmd.py b/hermes_cli/moa_cmd.py index 753fc5fda0..46a28b3518 100644 --- a/hermes_cli/moa_cmd.py +++ b/hermes_cli/moa_cmd.py @@ -12,7 +12,6 @@ from hermes_cli.moa_config import DEFAULT_MOA_PRESET_NAME, normalize_moa_config def _prompt_choice(title: str, rows: list[str], default: int = 0) -> int: try: from hermes_cli.curses_ui import curses_radiolist - return curses_radiolist(title, rows, selected=default, cancel_returns=default) except Exception: for idx, row in enumerate(rows, start=1): @@ -37,8 +36,7 @@ def _model_options() -> list[dict[str, Any]]: canonical_order=True, pricing=True, capabilities=True, - max_models=200, - ) + max_models=200) providers = payload.get("providers") or [] return [p for p in providers if p.get("slug") and str(p.get("slug")).strip().lower() != "moa" and p.get("models")] @@ -72,8 +70,7 @@ def _print_config(config: dict[str, Any]) -> None: print(f"Default: {cfg['default_preset']}") print(f"Active in config: {cfg.get('active_preset') or '(off)'}") for name, preset in cfg["presets"].items(): - marker = "*" if name == cfg["default_preset"] else " " - print(f"\n{marker} {name}") + print(f"\n{'*' if name == cfg['default_preset'] else ' '} {name}") print(" Reference models:") for idx, slot in enumerate(preset["reference_models"], start=1): print(f" {idx}. {_format_slot(slot)}") @@ -142,8 +139,7 @@ _SUBCOMMANDS = { "ls": _cmd_list, "config": _cmd_configure, "configure": _cmd_configure, - "delete": _cmd_delete, -} + "delete": _cmd_delete} def cmd_moa(args) -> None: diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index 8b1eddc45f..46e6508617 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -13,13 +13,10 @@ DEFAULT_MOA_PRESET_NAME = "default" DEFAULT_MOA_REFERENCE_MODELS: list[dict[str, str]] = [ {"provider": "openai-codex", "model": "gpt-5.5"}, - {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}, -] + {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}] DEFAULT_MOA_AGGREGATOR: dict[str, str] = { - "provider": "openrouter", - "model": "anthropic/claude-opus-4.8", -} + "provider": "openrouter", "model": "anthropic/claude-opus-4.8"} DEFAULT_MOA_REFERENCE_TIMEOUT: float | None = None @@ -32,8 +29,7 @@ def _coerce_number(value: Any, cast, default=None, *, positive: bool = False): """Coerce ``value`` with ``cast`` (float/int); ``default`` when unset/blank/invalid. ``int`` also accepts float-looking strings ("3.0"). With ``positive`` the result must be > 0 - (and finite for floats) or ``default`` is returned. - """ + (and finite for floats) or ``default`` is returned.""" if value is None or value == "": return default try: @@ -53,27 +49,21 @@ def _coerce_number(value: Any, cast, default=None, *, positive: bool = False): def _coerce_reference_timeout(value: Any) -> float | None: """Finite positive advisor timeout, or None to inherit ``auxiliary.moa_reference.timeout``. - No artificial cap: long-thinking advisor models legitimately run far beyond five minutes. - """ + No artificial cap: long-thinking advisor models legitimately run far beyond five minutes.""" if isinstance(value, bool): return DEFAULT_MOA_REFERENCE_TIMEOUT return _coerce_number(value, float, DEFAULT_MOA_REFERENCE_TIMEOUT, positive=True) def _coerce_fanout(value: Any) -> str: - """Normalize the fan-out cadence; unknown values fall back to default. - - Canonical values are ``per_iteration``, ``user_turn``, and ``every_n:`` (N >= 2); the - mapping form ``{mode: every_n, n: N}`` from hand-edited YAML is normalized to the string so the - rest of the pipeline sees one shape. ``every_n:1`` collapses to ``per_iteration``; anything - unparseable falls back to ``user_turn`` (the cheapest cadence). - """ + """Normalize the fan-out cadence to ``per_iteration`` | ``user_turn`` | ``every_n:`` (N >= 2); + the mapping form ``{mode: every_n, n: N}`` becomes the string, ``every_n:1`` collapses to + ``per_iteration``, anything unparseable falls back to ``user_turn`` (the cheapest cadence).""" def _every_n(n: int) -> str: return f"every_n:{n}" if n >= 2 else ("per_iteration" if n == 1 else "user_turn") if isinstance(value, dict): - # Mapping form: {mode: every_n, n: 3}. Non-every_n mapping modes fall - # through to the string path below (e.g. {mode: user_turn}). + # Non-every_n mapping modes fall through to the string path (e.g. {mode: user_turn}). mode = str(value.get("mode") or "").strip().lower() if mode == "every_n": return _every_n(_coerce_number(value.get("n"), int, 0)) @@ -88,13 +78,11 @@ def _coerce_fanout(value: Any) -> str: def coerce_privacy_filter(value: Any) -> str: - """Normalize ``moa.privacy_filter`` to '' (off), 'display', or 'full'. + """Normalize ``moa.privacy_filter`` to '' (off, the default), 'display', or 'full'. - - ``''`` (empty string): filter off — the default. ``false``/``None``/ unknown values land here - so a hand-edited config degrades to prior behavior (tolerant-read contract). - ``'display'``: - redact user-visible surfaces only — the reference blocks shown in the UI and the saved MoA trace - records. - """ + ``false``/``None``/unknown values land on '' so a hand-edited config degrades to prior + behavior. 'display' redacts user-visible surfaces only (reference blocks in the UI and saved + MoA trace records).""" if value is True: return "full" if value is None or value is False: @@ -104,9 +92,8 @@ def coerce_privacy_filter(value: Any) -> str: def _clean_reasoning_effort(value: Any) -> str | None: - """Return a canonical per-slot reasoning effort, or None when unset/invalid.""" + """Canonical per-slot reasoning effort, or None when unset/invalid.""" from hermes_constants import parse_reasoning_effort - parsed = None if value is None or value is True else parse_reasoning_effort(value) if parsed is None: return None @@ -125,12 +112,10 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: def _slot_problem(slot: Any) -> str | None: - """Return a human-readable problem for a slot ``_clean_slot`` would drop. + """Human-readable problem for a slot ``_clean_slot`` would drop; None when complete and valid. - None means the slot is complete and valid. Mirrors ``_clean_slot`` exactly so the write-boundary - validator (``validate_moa_payload``) and the tolerant runtime normalizer can never disagree - about what is acceptable. - """ + Mirrors ``_clean_slot`` exactly so the write-boundary validator (``validate_moa_payload``) and + the tolerant runtime normalizer can never disagree about what is acceptable.""" if not isinstance(slot, dict): return "must be an object with 'provider' and 'model'" provider = str(slot.get("provider") or "").strip() @@ -141,29 +126,23 @@ def _slot_problem(slot: Any) -> str | None: return "provider is required" if not model: return f"model is required (provider '{provider}' has no model selected)" - # MoA is a virtual provider whose presets are themselves MoA runs. Allowing - # one as a reference or aggregator slot would create a recursive MoA tree - # (the runtime guards in moa_loop.py skip references / raise on aggregators, - # but that surfaces only mid-turn). Reject it here so it can never be saved. + # MoA is a virtual provider whose presets are themselves MoA runs; allowing one as a slot + # would create a recursive MoA tree that the runtime guards only catch mid-turn. if provider.lower() == "moa": return "the Mixture of Agents provider cannot be used inside a preset (recursive MoA)" return None def _clean_slot(slot: Any, *, include_enabled: bool = False) -> dict[str, Any] | None: - # Any slot ``_slot_problem`` rejects (non-dict, missing provider/model, recursive - # ``moa`` provider) is dropped, falling back to the preset's defaults. + # Any slot ``_slot_problem`` rejects is dropped, falling back to the preset's defaults. if _slot_problem(slot) is not None: return None clean: dict[str, Any] = {"provider": str(slot["provider"]).strip(), "model": str(slot["model"]).strip()} effort = _clean_reasoning_effort(slot.get("reasoning_effort")) if effort: clean["reasoning_effort"] = effort - # Optional per-slot max_tokens: overrides the preset-level - # reference_max_tokens for this specific reference model. None (the - # default) = no cap, so existing slots are unaffected. Allows tuning - # each advisor's output length independently — useful when one model - # is verbose and another is terse. + # Optional per-slot max_tokens overrides the preset-level reference_max_tokens for this + # advisor; None (default) = no cap. slot_mt = _coerce_number(slot.get("max_tokens"), int, positive=True) if slot_mt is not None: clean["max_tokens"] = slot_mt @@ -172,17 +151,24 @@ def _clean_slot(slot: Any, *, include_enabled: bool = False) -> dict[str, Any] | return clean +def _reference_slots(raw_refs: Any) -> list: + """``reference_models`` as a list: a JSON string (hand-edited config.yaml) is decoded, a single + mapping is wrapped, and any other scalar / bad type degrades to ``[]`` instead of crashing.""" + if isinstance(raw_refs, str): + try: + raw_refs = json.loads(raw_refs) + except (json.JSONDecodeError, ValueError): + raw_refs = [] + if not isinstance(raw_refs, list): + raw_refs = [raw_refs] if isinstance(raw_refs, dict) else [] + return raw_refs + def validate_moa_payload(raw: Any) -> list[str]: - """Return the problems ``normalize_moa_config`` would silently paper over. + """Return the problems ``normalize_moa_config`` would silently paper over (empty = safe to save). - ``normalize_moa_config`` is deliberately tolerant: at *read* time a hand-edited config must - degrade to defaults rather than crash the agent. That same tolerance at *write* time is a - corruption engine — a client that sends a half-filled slot gets its whole preset silently - replaced with the hardcoded defaults (#64156). - - Returns a list of human-readable problems; empty means safe to save. - """ + Read-time tolerance (a hand-edited config degrades to defaults instead of crashing) is a + corruption engine at write time: a half-filled slot would silently replace the whole preset.""" if not isinstance(raw, dict): return ["MoA config must be an object"] @@ -208,7 +194,6 @@ def validate_moa_payload(raw: Any) -> list[str]: agg_issue = _slot_problem(preset.get("aggregator")) if agg_issue: problems.append(f"preset '{label}' aggregator: {agg_issue}") - return problems @@ -216,20 +201,9 @@ def _normalize_preset(raw: Any) -> dict[str, Any]: if not isinstance(raw, dict): raw = {} - raw_refs = raw.get("reference_models") - # reference_models may be a JSON string (hand-edited config.yaml) or a list. - if isinstance(raw_refs, str): - try: - raw_refs = json.loads(raw_refs) - except (json.JSONDecodeError, ValueError): - raw_refs = [] - if not isinstance(raw_refs, list): - # A hand-edited scalar / single mapping (or a bad type) must degrade to - # defaults instead of crashing the iteration, mirroring the tolerance - # for the scalar fields below (reference_temperature / max_tokens). - raw_refs = [raw_refs] if isinstance(raw_refs, dict) else [] - refs = [item for item in (_clean_slot(item, include_enabled=True) for item in raw_refs) if item is not None] - + cleaned = (_clean_slot(item, include_enabled=True) for item in _reference_slots(raw.get("reference_models"))) + refs = [item for item in cleaned if item is not None] + policy = str(raw.get("degraded_reference_policy") or "loud").strip().lower() return { "enabled": _coerce_bool(raw.get("enabled"), True), "reference_models": refs or _default_reference_models(), @@ -239,38 +213,20 @@ def _normalize_preset(raw: Any) -> dict[str, Any]: "aggregator_temperature": _coerce_number(raw.get("aggregator_temperature"), float), "reference_timeout": _coerce_reference_timeout(raw.get("reference_timeout")), # Failed-advisor disclosure policy; unknown values fail loud. - "degraded_reference_policy": policy if (policy := str(raw.get("degraded_reference_policy") or "loud").strip().lower()) in {"loud", "silent"} else "loud", + "degraded_reference_policy": policy if policy in {"loud", "silent"} else "loud", "max_tokens": _coerce_number(raw.get("max_tokens"), int, 4096), - # Optional cap on how much each reference ADVISOR may generate per turn. - # None (default) = uncapped: advisors write full-length advice, matching - # prior behavior so existing presets are unchanged. Set a value (e.g. - # 600) to make advisors give concise advice — the dominant MoA latency - # is advisor generation (turn latency correlates ~0.88 with output - # tokens), and the aggregator only needs the gist of each advisor's - # judgement, so capping roughly halves per-turn wall time. Does NOT cap - # the acting aggregator (its output is the user-visible answer). + # Per-turn cap on each reference ADVISOR (never the acting aggregator). None = uncapped; + # advisor generation dominates MoA latency, so e.g. 600 roughly halves wall time. "reference_max_tokens": _coerce_number(raw.get("reference_max_tokens"), int, positive=True), - # When the reference fan-out runs. "user_turn" (default) runs the - # advisors ONCE per user turn (the original MoA shape, and the - # cheapest cadence — #67199): the aggregator gets their upfront - # plan-level advice, then acts alone for the rest of the tool loop. - # "per_iteration" re-runs the advisors whenever the advisory view - # changes — i.e. every tool iteration, so advice tracks live task - # state at the cost of multiplying advisor spend by tool-loop depth. - # "every_n:" (N >= 2) is the middle ground: advisors run on the - # first iteration of each user turn and every Nth tool iteration - # after it; in-between iterations reuse the cached guidance from the - # last advisor run. Also accepts the mapping form - # {mode: every_n, n: N}, normalized to the canonical string. - "fanout": _coerce_fanout(raw.get("fanout")), - } + # "user_turn" (default, cheapest): advisors run ONCE per user turn; "per_iteration": every + # tool iteration; "every_n:": first iteration of each turn and every Nth after. + "fanout": _coerce_fanout(raw.get("fanout"))} _FLAT_PRESET_KEYS = ( "reference_models", "aggregator", "reference_temperature", "aggregator_temperature", "reference_timeout", "degraded_reference_policy", "max_tokens", "reference_max_tokens", - "fanout", "enabled", -) + "fanout", "enabled") def normalize_moa_config(raw: Any) -> dict[str, Any]: @@ -295,18 +251,14 @@ def normalize_moa_config(raw: Any) -> dict[str, Any]: active_name = str(raw.get("active_preset") or "").strip() if active_name not in presets: active_name = "" - return { "default_preset": default_name, "active_preset": active_name, "presets": presets, # Compatibility/flattened view for existing dashboard/desktop callers. **{key: deepcopy(presets[default_name][key]) for key in _FLAT_PRESET_KEYS}, - # MoA-level (not per-preset) toggles ride at the top level alongside - # save_traces. privacy_filter: '' (off, default) | 'display' | 'full' - # — see coerce_privacy_filter for the semantics of each mode. - "privacy_filter": coerce_privacy_filter(raw.get("privacy_filter")), - } + # MoA-level (not per-preset) toggle; see coerce_privacy_filter for the modes. + "privacy_filter": coerce_privacy_filter(raw.get("privacy_filter"))} def resolve_moa_preset(config: Any, name: str | None = None) -> dict[str, Any]: @@ -315,23 +267,20 @@ def resolve_moa_preset(config: Any, name: str | None = None) -> dict[str, Any]: preset = cfg["presets"].get(preset_name) if preset is None: from agent.errors import MoAPresetNotFoundError - available = ", ".join(cfg["presets"]) or "(none)" raise MoAPresetNotFoundError( f"MoA preset '{preset_name}' was not found. Available presets: " - f"{available}. Run `hermes moa list`." - ) + f"{available}. Run `hermes moa list`.") return deepcopy(preset) def exact_moa_preset_name(config: Any, text: str) -> str | None: """Return the preset name iff ``text`` exactly matches an *enabled* preset. - Used by the no-explicit-provider switch path to recognize a bare ``/model ``. Because - the match is implicit it honors the per-preset ``enabled`` opt-out: a plain model switch that - collides with a disabled preset's name must not silently pivot onto the MoA provider. Explicit - ``--provider moa`` / picker selection bypasses this, so disabled presets stay reachable. - """ + Used by the no-explicit-provider switch path for a bare ``/model ``. Because the match + is implicit it honors the per-preset ``enabled`` opt-out: a plain model switch that collides + with a disabled preset's name must not silently pivot onto the MoA provider. Explicit + ``--provider moa`` / picker selection bypasses this, so disabled presets stay reachable.""" wanted = str(text or "").strip() if not wanted: return None diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 0c126618c3..32e9f4a515 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1,22 +1,8 @@ -"""Shared model-switching logic for CLI and gateway /model commands. +"""Shared model-switching logic for the CLI and gateway /model commands. -Both the CLI (cli.py) and gateway (gateway/run.py) /model handlers -share the same core pipeline: - - parse flags -> alias resolution -> provider resolution -> - credential resolution -> normalize model name -> - metadata lookup -> build result - -This module ties together the foundation layers: - -- ``agent.models_dev`` -- models.dev catalog, ModelInfo, ProviderInfo -- ``hermes_cli.providers`` -- canonical provider identity + overlays -- ``hermes_cli.model_normalize`` -- per-provider name formatting - -Provider switching uses the ``--provider`` flag exclusively. -No colon-based ``provider:model`` syntax — colons are reserved for -OpenRouter variant suffixes (``:free``, ``:extended``, ``:fast``). -""" +Pipeline: parse flags -> alias resolution -> provider resolution -> credential resolution -> +normalize model name -> metadata lookup -> build result. Provider switching uses ``--provider`` +exclusively; colons are reserved for OpenRouter variant suffixes (``:free``, ``:extended``).""" from __future__ import annotations @@ -27,120 +13,72 @@ from dataclasses import dataclass, field from typing import Any, NamedTuple, Optional from hermes_cli.providers import ( - ProviderDef, - custom_provider_aliases, - determine_api_mode, - get_label, - host_mandated_api_mode, - is_aggregator, - resolve_provider_full, -) -from hermes_cli.model_normalize import ( - normalize_model_for_provider, -) + ProviderDef, custom_provider_aliases, determine_api_mode, get_label, host_mandated_api_mode, + is_aggregator, resolve_provider_full) +from hermes_cli.model_normalize import normalize_model_for_provider from agent.models_dev import ( - ModelCapabilities, - ModelInfo, - get_model_capabilities, - get_model_info, - list_provider_models, -) + ModelCapabilities, ModelInfo, get_model_capabilities, get_model_info, list_provider_models) from utils import base_url_hostname, base_url_origin -from hermes_cli.model_switch_providers import ( # noqa: F401 (re-exported; callers/tests patch hermes_cli.model_switch.) - _NativePickerModelList, - _collect_authed_provider_slugs, - _credential_pool_is_usable, - _fetch_picker_live_models, - _picker_prewarm_done, - _prefetch_provider_models_parallel, - _save_discovered_models_to_config, - list_authenticated_providers, - list_picker_providers, - prewarm_picker_cache_async, -) +# Re-exported: callers/tests patch hermes_cli.model_switch.. +from hermes_cli.model_switch_providers import ( # noqa: F401 + _NativePickerModelList, _collect_authed_provider_slugs, _credential_pool_is_usable, + _fetch_picker_live_models, _picker_prewarm_done, _prefetch_provider_models_parallel, + _save_discovered_models_to_config, list_authenticated_providers, list_picker_providers, + prewarm_picker_cache_async) logger = logging.getLogger(__name__) def _declared_model_ids(value: Any) -> list[str]: - """Return configured model IDs from supported config shapes. - - Accepts: - - ``{"model-id": {...}}`` - - ``["model-a", "model-b"]`` - - ``[{"id": "model-a"}, {"name": "model-b"}]`` - - ``"model-a"`` - """ + """Configured model IDs from ``{"id": {...}}``, ``["a", "b"]``, ``[{"id"|"name": ...}]`` or ``"a"``.""" + if isinstance(value, str): + candidates: Any = [value] + elif isinstance(value, dict): + # Pre-fix Hermes wrote sentinel keys inside the user-facing ``models`` mapping. + candidates = (k for k in value if k not in ("__explicit_model_allowlist__", "__discovered_model_catalog__")) + elif isinstance(value, (list, tuple)): + candidates = (_declared_item_id(item) if isinstance(item, dict) else item for item in value) + else: + return [] ids: list[str] = [] seen: set[str] = set() - - def _add(candidate: Any) -> None: + for candidate in candidates: if not isinstance(candidate, str): - return + continue # non-str items are dropped model_id = candidate.strip() - if not model_id: - return - lowered = model_id.lower() - if lowered in seen: - return - seen.add(lowered) - ids.append(model_id) - - if isinstance(value, str): - _add(value) - elif isinstance(value, dict): - # Backward compat: pre-fix Hermes wrote sentinel keys inside the - # user-facing ``models`` mapping. Never list them as model IDs. - for model_id in value: - if model_id not in ("__explicit_model_allowlist__", "__discovered_model_catalog__"): - _add(model_id) - elif isinstance(value, (list, tuple)): - for item in value: - if isinstance(item, dict): - model_id = item.get("id") - if not isinstance(model_id, str) or not model_id.strip(): - model_id = item.get("name") - _add(model_id) - else: - _add(item) # non-str items are dropped by _add + if model_id and model_id.lower() not in seen: + seen.add(model_id.lower()) + ids.append(model_id) return ids +def _declared_item_id(item: dict) -> Any: + """``id`` of a ``[{"id": ...}]`` entry, falling back to ``name`` when blank/missing.""" + model_id = item.get("id") + return model_id if isinstance(model_id, str) and model_id.strip() else item.get("name") + + def _entry_models_discovered(entry: Any) -> bool: """True when the entry's ``models`` mapping was auto-discovered by Hermes. - The current shape is an entry-level ``models_discovered: true`` sibling of - ``models``. Older Hermes versions wrote an in-mapping - ``__discovered_model_catalog__: true`` sentinel instead — accept that on - read for backward compatibility (the next discovery save migrates the - entry to the clean shape). - """ + Current shape: entry-level ``models_discovered: true``. Older versions wrote an in-mapping + ``__discovered_model_catalog__: true`` sentinel — accepted on read (the next save migrates it).""" if not isinstance(entry, dict): return False models = entry.get("models") return entry.get("models_discovered") is True or ( - isinstance(models, dict) and models.get("__discovered_model_catalog__") is True - ) + isinstance(models, dict) and models.get("__discovered_model_catalog__") is True) def _models_config_is_allowlist(value: Any, discovered: bool = False) -> bool: - """Return True when ``models:`` is an intentional ID allowlist. + """True when ``models:`` is an intentional ID allowlist. - A mapping like ``{model_id: {context_length: N}}`` is per-model *metadata* - written by ``_save_custom_provider`` / the ``hermes model`` wizard — not a - catalog narrow. Treating that shape as an allowlist made Desktop/Telegram - pickers show only the saved default for local Ollama (no ``api_key``), - while ``hermes model`` still live-probed the full ``/v1/models`` list. - Refresh could not help because the same gate skipped probing. - - List/string shapes remain allowlists for no-key endpoints. To pin a - dict-shaped catalog, set ``discover_models: false``. - - ``discovered`` is the entry-level ``models_discovered`` flag (see - ``_entry_models_discovered``): a catalog Hermes itself persisted after a - successful probe is never a user pin, whatever its shape. - """ + A mapping like ``{model_id: {context_length: N}}`` is per-model *metadata* written by + ``_save_custom_provider`` / the wizard, not a catalog narrow (treating it as one made GUI + pickers show only the saved default for keyless Ollama while the CLI live-probed). List and + string shapes remain allowlists for no-key endpoints; pin a dict catalog with + ``discover_models: false``. A catalog Hermes itself persisted (``discovered``) is never a pin.""" if discovered: return False if isinstance(value, str): @@ -152,108 +90,55 @@ def _models_config_is_allowlist(value: Any, discovered: bool = False) -> bool: def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]: """ProviderDef for a direct ``model.provider: custom`` endpoint.""" - base_url = str(current_base_url or "").strip() + base_url = _clean(current_base_url) if not base_url: return None return ProviderDef( - id="custom", - name="Custom endpoint", - transport="openai_chat", - api_key_env_vars=(), - base_url=base_url, - is_aggregator=False, - auth_type="api_key", - source="model-config", - ) + id="custom", name="Custom endpoint", transport="openai_chat", api_key_env_vars=(), + base_url=base_url, is_aggregator=False, auth_type="api_key", source="model-config") -# --------------------------------------------------------------------------- -# Non-agentic model warning -# --------------------------------------------------------------------------- +# --- Non-agentic model warning _HERMES_MODEL_WARNING = ( "Nous Research Hermes 3 & 4 models are NOT agentic and are not designed " "for use with Hermes Agent. They lack the tool-calling capabilities " "required for agent workflows. Consider using an agentic model instead " - "(Claude, GPT, Gemini, DeepSeek, etc.)." -) + "(Claude, GPT, Gemini, DeepSeek, etc.).") -# Match only the real Nous Research Hermes 3 / Hermes 4 chat families. -# The previous substring check (`"hermes" in name.lower()`) false-positived on -# unrelated local Modelfiles like ``hermes-brain:qwen3-14b-ctx16k`` that just -# happen to carry "hermes" in their tag but are fully tool-capable. -# -# Positive examples the regex must match: -# NousResearch/Hermes-3-Llama-3.1-70B, hermes-4-405b, openrouter/hermes3:70b -# Negative examples it must NOT match: -# hermes-brain:qwen3-14b-ctx16k, qwen3:14b, claude-opus-4-6 -_NOUS_HERMES_NON_AGENTIC_RE = re.compile( - r"(?:^|[/:])hermes[-_ ]?[34](?:[-_.:]|$)", - re.IGNORECASE, -) +# Match only the real Nous Research Hermes 3 / 4 chat families; a bare substring check +# false-positived on tool-capable local Modelfiles like ``hermes-brain:qwen3-14b-ctx16k``. +# match: NousResearch/Hermes-3-Llama-3.1-70B, hermes-4-405b, openrouter/hermes3:70b +# no match: hermes-brain:qwen3-14b-ctx16k, qwen3:14b, claude-opus-4-6 +_NOUS_HERMES_NON_AGENTIC_RE = re.compile(r"(?:^|[/:])hermes[-_ ]?[34](?:[-_.:]|$)", re.IGNORECASE) -# Opaque internal model-ID display -# --------------------------------------------------------------------------- -# Some proxies (notably Palantir Foundry's LLM-proxy) identify models by -# resource-instance IDs that are deeply nested, verbose, and pure noise to -# read in CLI status output, e.g.: -# -# ri.language-model-service..language-model.anthropic-claude-4-7-opus -# -# The provider_label (e.g. "palantir-claude46") already carries the routing -# context, so the only useful information left in the opaque ID is the -# trailing slug. Strip the boilerplate prefix for *display* — never for -# wire-side comparison, persistence, config writes, alias lookup, or -# anything that round-trips back into the API. -# -# Match by substring on a known prefix so we never accidentally truncate -# a legitimate model name that happens to contain dots. - -_OPAQUE_MODEL_PREFIXES: tuple[str, ...] = ( - "ri.language-model-service..language-model.", -) +# Opaque proxy model IDs (Palantir Foundry: ``ri.language-model-service..language-model.``) +# are noise in status output; the provider_label already carries the routing context. Stripped +# for DISPLAY ONLY — never for wire-side comparison, persistence, config writes or alias lookup. +_OPAQUE_MODEL_PREFIXES: tuple[str, ...] = ("ri.language-model-service..language-model.",) def format_model_for_display(model_name: str) -> str: - """Return a human-friendly form of *model_name* for CLI status output. - - Strips known opaque proxy prefixes (Palantir Foundry's - ``ri.language-model-service..language-model.*``) and returns the - trailing slug. Falls through to the original string for everything - else, so real model IDs (``claude-4-7-opus-20260101``, - ``gpt-5-4``, ``meta-llama/Llama-3.3-70B-Instruct``) are untouched. - - This is a DISPLAY-ONLY helper. Do NOT use the return value for any - wire-side operation — the proxy expects the full opaque ID, and - callers that compare or persist must keep the original. - """ + """Human-friendly form of *model_name* for CLI status output (display only, never wire-side).""" for prefix in _OPAQUE_MODEL_PREFIXES: if model_name and model_name.startswith(prefix): return model_name[len(prefix):] or model_name return model_name -# --------------------------------------------------------------------------- def is_nous_hermes_non_agentic(model_name: str) -> bool: - """Return True if *model_name* is a real Nous Hermes 3/4 chat model. - - Used to decide whether to surface the non-agentic warning at startup. - Callers in :mod:`cli.py` and here should go through this single helper - so the two sites don't drift. - """ + """True if *model_name* is a real Nous Hermes 3/4 chat model (single owner; cli.py uses it too).""" return bool(model_name and _NOUS_HERMES_NON_AGENTIC_RE.search(model_name)) def _check_hermes_model_warning(model_name: str) -> str: - """Return a warning string if *model_name* is a Nous Hermes 3/4 chat model.""" + """Warning string if *model_name* is a Nous Hermes 3/4 chat model, else "".""" return _HERMES_MODEL_WARNING if is_nous_hermes_non_agentic(model_name) else "" -# --------------------------------------------------------------------------- -# Model aliases -- short names -> (vendor, family) with NO version numbers. -# Resolved dynamically against the live models.dev catalog. -# --------------------------------------------------------------------------- +# --- Model aliases -- short names -> (vendor, family) with NO version numbers, +# resolved dynamically against the live models.dev catalog. class ModelIdentity(NamedTuple): """Vendor slug and family prefix used for catalog resolution.""" @@ -282,93 +167,61 @@ MODEL_ALIASES: dict[str, ModelIdentity] = { "glm": ModelIdentity("z-ai", "glm"), "step": ModelIdentity("stepfun", "step"), "mimo": ModelIdentity("xiaomi", "mimo"), - "trinity": ModelIdentity("arcee-ai", "trinity"), -} + "trinity": ModelIdentity("arcee-ai", "trinity")} -# --------------------------------------------------------------------------- -# Direct aliases — exact model+provider+base_url for endpoints that aren't -# in the models.dev catalog (e.g. Ollama Cloud, local servers). -# Checked BEFORE catalog resolution. Format: -# alias -> (model_id, provider, base_url) -# These can also be loaded from config.yaml ``model_aliases:`` section. -# --------------------------------------------------------------------------- +# --- Direct aliases — exact model+provider+base_url for endpoints outside the +# models.dev catalog (Ollama Cloud, local servers). Checked BEFORE catalog +# resolution; loaded from config.yaml ``model_aliases:`` / ``model.aliases``. class DirectAlias(NamedTuple): """Exact model mapping that bypasses catalog resolution. - ``api_key`` / ``key_env`` carry the alias endpoint's OWN credential. - Without them the switch keeps whatever key the *default* provider - resolved, which 401s against the alias host and sends that provider's - secret to an unrelated third party (#83612). - """ + ``api_key`` / ``key_env`` carry the alias endpoint's OWN credential. Without them the switch + would keep the *default* provider's key, which 401s against the alias host and sends that + provider's secret to an unrelated third party. Both default so positional + ``DirectAlias(model, provider, base_url)`` keeps working.""" model: str provider: str base_url: str - # Defaulted so existing positional construction — - # ``DirectAlias(model, provider, base_url)`` — keeps working for callers - # and for the string-format aliases built below. api_key: str = "" key_env: str = "" -# Built-in direct aliases (can be extended via config.yaml model_aliases:) +# Built-in direct aliases (extended via config.yaml model_aliases:) _BUILTIN_DIRECT_ALIASES: dict[str, DirectAlias] = {} + +def _clean(value: Any) -> str: + """``str(value or "").strip()`` — the config-field normaliser used throughout this module.""" + return str(value or "").strip() + # Merged dict (builtins + user config); populated by _load_direct_aliases() DIRECT_ALIASES: dict[str, DirectAlias] = {} def _load_direct_aliases() -> dict[str, DirectAlias]: - """Load direct aliases from config.yaml ``model_aliases:`` section. + """Load direct aliases from config.yaml. - Config format:: - - model_aliases: - qwen: - model: "qwen3.5:397b" - provider: custom - base_url: "https://ollama.com/v1" - minimax: - model: "minimax-m2.7" - provider: custom - base_url: "https://ollama.com/v1" - theta: - model: "theta-1" - provider: custom - base_url: "https://theta.example.com/v1" - api_key: "sk-..." # literal, or "${THETA_API_KEY}" - key_env: "THETA_API_KEY" # read from the environment instead - - ``api_key``/``key_env`` are the alias endpoint's own credential. When - neither is set the key is resolved from the alias HOST, never from the - previously active provider (#83612). - - Also reads ``model.aliases`` (set by ``hermes config set model.aliases.xxx`` - or hand-written). String entries (``ds-flash: deepseek/deepseek-v4-flash``) - are converted into DirectAlias objects with the provider parsed from the - ``provider/`` prefix in the value; if no slash, the current provider is - used. Dict entries use the same shape as ``model_aliases:`` (``model``, - ``provider``, ``base_url`` keys). - """ + ``model_aliases:`` entries are dicts (``model``, ``provider``, ``base_url``, optional + ``api_key`` — literal or ``"${VAR}"`` — / ``key_env``); with neither credential field the key + is resolved from the alias HOST, never from the previously active provider. ``model.aliases`` + never overrides ``model_aliases``; its string entries (``ds-flash: deepseek/deepseek-v4-flash``) + take the provider from the ``provider/`` prefix, else the current provider.""" merged = dict(_BUILTIN_DIRECT_ALIASES) try: from hermes_cli.config import load_config cfg = load_config() - # model_aliases: dict entries carry their own credential fields. user_aliases = cfg.get("model_aliases") if isinstance(user_aliases, dict): for name, entry in user_aliases.items(): if isinstance(entry, dict) and entry.get("model", ""): merged[name.strip().lower()] = DirectAlias( model=entry.get("model", ""), provider=entry.get("provider", "custom"), - base_url=entry.get("base_url", ""), - api_key=str(entry.get("api_key", "") or "").strip(), - key_env=str(entry.get("key_env", "") or "").strip(), - ) + base_url=entry.get("base_url", ""), api_key=_clean(entry.get("api_key", "")), + key_env=_clean(entry.get("key_env", ""))) - # model.aliases (config set / hand-written): never override model_aliases entries. model_section = cfg.get("model", {}) simple_aliases = model_section.get("aliases") if isinstance(model_section, dict) else None if isinstance(simple_aliases, dict): @@ -378,53 +231,36 @@ def _load_direct_aliases() -> dict[str, DirectAlias]: if not key or key in merged: continue if isinstance(value, dict): - # Dict form mirrors ``model_aliases:`` (localqwen: {model: - # qwen3.5:4b, provider: custom}); honoring it keeps aliases - # with an explicit provider from being silently dropped. - model = str(value.get("model") or "").strip() + model = _clean(value.get("model")) if model: - provider = str(value.get("provider") or "").strip() merged[key] = DirectAlias( - model=model, provider=provider or current_provider or "custom", - base_url=str(value.get("base_url") or "").strip(), - ) + model=model, provider=_clean(value.get("provider")) or current_provider or "custom", + base_url=_clean(value.get("base_url"))) elif isinstance(value, str) and value.strip(): val = value.strip() provider, model = val.split("/", 1) if "/" in val else (current_provider, val) merged[key] = DirectAlias( - model=model.strip(), provider=provider.strip() or current_provider, base_url="", - ) + model=model.strip(), provider=provider.strip() or current_provider, base_url="") except Exception: pass return merged -# Identity of the config the cached aliases were built from. The cache is -# process-global but its source is profile-local, so it must be keyed or the -# first profile to resolve an alias pins its definitions — and, since entries -# carry `api_key`, its credentials — for every later profile in the process. -# Same shape `load_config()` already keys its own cache on, so a profile -# switch (HERMES_HOME moves, so the path moves) and a config/key rotation -# (mtime/size move) both invalidate. +# Identity of the config the cached aliases were built from. The cache is process-global but its +# source is profile-local: unkeyed, the first profile to resolve an alias would pin its definitions +# — and, since entries carry `api_key`, its credentials — for every later profile. Same shape +# `load_config()` keys on, so a profile switch (path) and a key rotation (mtime/size) both invalidate. _DIRECT_ALIAS_IDENTITY: Optional[tuple] = None -# A copy of what this loader last produced. Callers and tests seed -# DIRECT_ALIASES both by rebinding the module attribute AND by editing it in -# place, so neither the object's identity nor a "did we load" flag can tell -# our own stale cache from someone else's contents. Comparing against what we -# actually wrote does: if the dict no longer holds it, the entries are not -# ours to discard. +# Copy of what the loader last produced. Callers and tests seed DIRECT_ALIASES both by rebinding +# and by editing in place, so only comparing against what we wrote tells our stale cache from +# someone else's contents. _DIRECT_ALIAS_LOADED: Optional[dict] = None def _direct_alias_source_identity() -> Optional[tuple]: - """Identity of the active profile's alias source, or None if unknowable. - - None means "do not reuse the cache" — a source we cannot identify must - not be assumed to be the one already loaded. - """ + """Identity of the active profile's alias source; None means "do not reuse the cache".""" try: from hermes_constants import get_config_path - path = get_config_path() except Exception: return None @@ -441,23 +277,17 @@ def _direct_alias_source_identity() -> Optional[tuple]: def _ensure_direct_aliases() -> None: """Load direct aliases for the ACTIVE profile, caching per config identity. - Mutates the existing DIRECT_ALIASES dict in place rather than rebinding - the module attribute. This keeps `from hermes_cli.model_switch import - DIRECT_ALIASES` references valid in callers — rebinding would leave them - pointing at a stale empty dict. - """ + Mutates DIRECT_ALIASES in place (never rebinds) so ``from ... import DIRECT_ALIASES`` + references in callers stay valid.""" global _DIRECT_ALIAS_IDENTITY, _DIRECT_ALIAS_LOADED identity = _direct_alias_source_identity() if DIRECT_ALIASES and ( - # Contents are not what we loaded — seeded or edited by a caller. - # Not ours to discard. + # Contents are not what we loaded — seeded or edited by a caller. Not ours to discard. DIRECT_ALIASES != _DIRECT_ALIAS_LOADED # Ours, and still the same config file at the same signature. - or (identity is not None and identity == _DIRECT_ALIAS_IDENTITY) - ): + or (identity is not None and identity == _DIRECT_ALIAS_IDENTITY)): return loaded = _load_direct_aliases() - # clear()+update() rather than a rebind: callers hold this exact dict. DIRECT_ALIASES.clear() DIRECT_ALIASES.update(loaded) _DIRECT_ALIAS_IDENTITY = identity @@ -467,18 +297,9 @@ def _ensure_direct_aliases() -> None: def direct_alias_api_key(alias: DirectAlias) -> str: """Resolve a direct alias's own credential, or "" when it has none. - Precedence, highest first — ``api_key`` always wins over ``key_env``, so - an entry carrying both is not ambiguous: - - 1. ``api_key: "${VAR}"`` — indirection, read from the environment. - 2. ``api_key: "sk-..."`` — literal. - 3. ``key_env: VAR`` — read from the environment. - 4. otherwise "" — the caller resolves from the alias host instead. - Environment reads go through the per-profile secret scope for the same - reason the user-provider branch does: a raw ``os.environ`` read hands - this profile whatever key the process env holds — another profile's, - under the multiplexed gateway. - """ + Precedence: ``api_key: "${VAR}"`` (env indirection) > literal ``api_key`` > ``key_env``. + Env reads go through the per-profile secret scope: a raw ``os.environ`` read hands this + profile whatever key the process env holds — another profile's, under the multiplexed gateway.""" raw = (alias.api_key or "").strip() if raw.startswith("${") and raw.endswith("}"): return _scoped_key_env(raw[2:-1].strip()) @@ -488,36 +309,27 @@ def direct_alias_api_key(alias: DirectAlias) -> str: def direct_alias_runtime_request(alias: DirectAlias) -> tuple[str, Optional[str]]: - """Return ``(requested_provider, explicit_api_key)`` for resolving *alias*. + """``(requested_provider, explicit_api_key)`` for resolving *alias*. - Single owner of the invariant that a URL-bearing direct alias resolves its - credential for the alias HOST, never for its provider label. A label like - ``anthropic`` on an unrelated URL would otherwise reach that provider's - explicit-runtime branch, keep the foreign URL, and fall back to the live - vendor token. Bare ``custom`` is host-gated (#28660), so an authoritative - URL still resolves its vendor key and a foreign one resolves none. - - An alias with no base_url keeps its label: there is no foreign host to - protect against, and the label is the only routing information there is. - """ + Single owner of the invariant that a URL-bearing direct alias resolves its credential for + the alias HOST, never for its provider label: a label like ``anthropic`` on an unrelated URL + would otherwise reach that provider's explicit-runtime branch and put the live vendor token + on the foreign wire. Bare ``custom`` is host-gated, so an authoritative URL still resolves + its vendor key and a foreign one resolves none. An alias with no base_url keeps its label — + there is no foreign host, and the label is the only routing information.""" return ("custom" if alias.base_url else (alias.provider or "custom")), direct_alias_api_key(alias) or None -# Hosts where plaintext HTTP is not a downgrade — a local server has no -# network hop to intercept. +# Hosts where plaintext HTTP is not a downgrade — no network hop to intercept. _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"}) def _may_reuse_session_credential(session_base_url: str, alias_base_url: str) -> bool: """Whether the session's key may follow a switch to *alias_base_url*. - Same hostname is NOT sufficient to authorise handing a bearer secret to a - new URL. ``http://h`` and ``https://h:8443`` are different origins and - different trust boundaries, so an alias that keeps the hostname but drops - the scheme would otherwise put a live session credential on the wire in - the clear. Require an identical (scheme, host, port), and refuse plaintext - outside loopback. - """ + Same hostname is NOT sufficient: ``http://h`` and ``https://h:8443`` are different trust + boundaries, and an alias that drops the scheme would put a live bearer secret on the wire in + the clear. Require an identical (scheme, host, port) and refuse plaintext outside loopback.""" session = base_url_origin(session_base_url) alias = base_url_origin(alias_base_url) if not session[1] or session != alias: @@ -528,7 +340,6 @@ def _may_reuse_session_credential(session_base_url: str, alias_base_url: str) -> class StartupModelRoute(NamedTuple): """Model/provider pair resolved before an agent is constructed.""" - model: str provider: str = "" base_url: str = "" @@ -536,30 +347,18 @@ class StartupModelRoute(NamedTuple): def resolve_startup_model_route( - raw_model: str, - *, - explicit_provider: str = "", - current_provider: str = "", + raw_model: str, *, explicit_provider: str = "", current_provider: str = "", user_providers: Optional[dict] = None, - custom_providers: Optional[list] = None, -) -> Optional[StartupModelRoute]: + custom_providers: Optional[list] = None) -> Optional[StartupModelRoute]: """Resolve aliases and configured ``provider/model`` input at startup. - ``HermesCLI`` is constructed before the interactive ``/model`` pipeline - runs. Keeping this small resolver at the same boundary as - ``DIRECT_ALIASES`` prevents startup from attaching the configured default - provider to an explicitly requested model. Provider/model strings are - consumed only for providers present in user configuration; aggregator - namespaces remain untouched. - - ``current_provider`` is the provider the session would otherwise use - (config ``model.provider`` / ``--provider``). When it is a routing - aggregator and the raw string is an aggregator-native slug - (``anthropic/claude-opus-4.6`` on OpenRouter), the input stays on the - aggregator — bare vendor slugs resolve WITHIN the aggregator first and a - ``providers:`` block for the same vendor must not steal the route. - """ - raw = str(raw_model or "").strip() + ``HermesCLI`` is constructed before the interactive ``/model`` pipeline runs; resolving here + keeps startup from attaching the configured default provider to an explicitly requested + model. ``provider/model`` strings are consumed only for providers present in user config. When + ``current_provider`` is a routing aggregator and the raw string is an aggregator-native slug + (``anthropic/claude-opus-4.6`` on OpenRouter) the input stays on the aggregator — a + ``providers:`` block for the same vendor must not steal the route.""" + raw = _clean(raw_model) if not raw: return None @@ -567,18 +366,13 @@ def resolve_startup_model_route( direct = DIRECT_ALIASES.get(raw.lower()) if direct is not None: if explicit_provider: - # An explicit --provider wins over the alias's own label; the - # alias contributes model/base_url only. + # An explicit --provider wins over the alias's own label; the alias contributes + # model/base_url only. return StartupModelRoute(model=direct.model, provider=explicit_provider, base_url=direct.base_url) - # Resolve through the SAME owner the interactive /model and oneshot - # paths use: a URL-bearing alias must resolve its credential for the - # alias HOST, never for its provider label — a label like ``anthropic`` - # on a foreign URL would otherwise reach that provider's explicit-runtime - # branch and put the live vendor token on the foreign wire (#28660). + # Same owner as the interactive /model and oneshot paths: credential for the alias HOST. alias_provider, alias_key = direct_alias_runtime_request(direct) return StartupModelRoute( - model=direct.model, provider=alias_provider, base_url=direct.base_url, api_key=alias_key or "", - ) + model=direct.model, provider=alias_provider, base_url=direct.base_url, api_key=alias_key or "") if explicit_provider or "/" not in raw: return None @@ -586,16 +380,11 @@ def resolve_startup_model_route( if not prefix or not model: return None - # Aggregator-native slugs stay on the aggregator. A user on OpenRouter - # whose config also has a ``providers.anthropic`` block must NOT have - # ``anthropic/claude-opus-4.6`` silently rerouted to native Anthropic. if current_provider: try: from hermes_cli.providers import is_routing_aggregator, normalize_provider as _norm_prov - if is_routing_aggregator(_norm_prov(current_provider)): from hermes_cli.models import _find_openrouter_slug - if _find_openrouter_slug(raw): return None except Exception: @@ -605,11 +394,9 @@ def resolve_startup_model_route( configured.update( f"custom:{entry.get('name', '').strip().lower()}" for entry in (custom_providers or []) - if isinstance(entry, dict) and str(entry.get("name") or "").strip() - ) + if isinstance(entry, dict) and _clean(entry.get("name"))) try: from hermes_cli.models import normalize_provider - canonical = normalize_provider(prefix) except Exception: canonical = prefix.lower() @@ -623,14 +410,11 @@ def resolve_startup_model_route( return None if is_aggregator(canonical) else StartupModelRoute(model=model, provider=provider) -# --------------------------------------------------------------------------- -# Result dataclasses -# --------------------------------------------------------------------------- +# --- Result dataclasses @dataclass class ModelSwitchResult: """Result of a model switch attempt.""" - success: bool new_model: str = "" target_provider: str = "" @@ -652,7 +436,6 @@ class ModelSwitchResult: @dataclass(frozen=True) class ModelFlagParseResult: """Parsed flags for a /model command.""" - model_input: str explicit_provider: str = "" is_global: bool = False @@ -661,111 +444,59 @@ class ModelFlagParseResult: is_once: bool = False -# --------------------------------------------------------------------------- -# Flag parsing -# --------------------------------------------------------------------------- +# --- Flag parsing _BOOL_FLAGS = {"--global": "is_global", "--session": "is_session", "--refresh": "force_refresh", "--once": "is_once"} def parse_model_flags_detailed(raw_args: str) -> ModelFlagParseResult: - """Parse flags from /model command args. + """Parse /model flags: ``--provider X``, ``--global``, ``--session``, ``--refresh``, ``--once``. - Returns a :class:`ModelFlagParseResult`. ``--once`` is intentionally - parsed here but interpreted by each caller because each frontend has its - own live-session restore hook. - - ``is_global`` and ``is_session`` are independent flag presences; the - *effective* persistence decision is resolved by - :func:`resolve_persist_behavior` so the config-gated default - (``model.persist_switch_by_default``) is applied in one place. - - Examples:: - - "sonnet" -> ("sonnet", "", False, False, False) - "sonnet --global" -> ("sonnet", "", True, False, False) - "sonnet --session" -> ("sonnet", "", False, False, True) - "sonnet --once" -> is_once=True - "sonnet --provider anthropic" -> ("sonnet", "anthropic", False, False, False) - "--provider my-ollama" -> ("", "my-ollama", False, False, False) - "--refresh" -> ("", "", False, True, False) - "sonnet --provider anthropic --global" -> ("sonnet", "anthropic", True, False, False) - """ - # Normalize Unicode dashes (Telegram/iOS auto-converts -- to em/en dash): - # a single Unicode dash before a flag keyword becomes "--". + ``--once`` is parsed here but interpreted by each caller (each frontend has its own + live-session restore hook). ``is_global`` / ``is_session`` are raw flag presences; the + effective persistence decision belongs to :func:`resolve_persist_behavior`.""" + # Telegram/iOS auto-convert ``--`` to an em/en dash: normalize a single Unicode dash before + # a flag keyword. raw_args = re.sub(r'[\u2012\u2013\u2014\u2015](provider|global|session|refresh|once)', r'--\1', raw_args) - # Keep this hand-rolled because model IDs may contain colons/slashes and - # the historical parser did not require shell quoting. + # Hand-rolled: model IDs may contain colons/slashes and the historical parser did not + # require shell quoting. flags = dict.fromkeys(_BOOL_FLAGS.values(), False) explicit_provider = "" - parts = raw_args.split() - i = 0 filtered: list[str] = [] - while i < len(parts): - if parts[i] in _BOOL_FLAGS: - flags[_BOOL_FLAGS[parts[i]]] = True - elif parts[i] == "--provider" and i + 1 < len(parts): - explicit_provider = parts[i + 1] - i += 1 + tokens = iter(raw_args.split()) + for tok in tokens: + if tok in _BOOL_FLAGS: + flags[_BOOL_FLAGS[tok]] = True + elif tok == "--provider" and (value := next(tokens, None)) is not None: + explicit_provider = value else: - filtered.append(parts[i]) - i += 1 - + filtered.append(tok) # a trailing bare ``--provider`` stays part of the model text return ModelFlagParseResult(model_input=" ".join(filtered).strip(), explicit_provider=explicit_provider, **flags) def parse_model_flags(raw_args: str) -> tuple[str, str, bool, bool, bool]: - """Parse legacy /model flags and return the historical 5-tuple. - - New call sites that care about ``--once`` should use - :func:`parse_model_flags_detailed`. - """ + """Legacy 5-tuple ``(model_input, explicit_provider, is_global, force_refresh, is_session)``.""" p = parse_model_flags_detailed(raw_args) return (p.model_input, p.explicit_provider, p.is_global, p.force_refresh, p.is_session) def resolve_persist_behavior( - is_global: bool, - is_session: bool, - is_once: bool = False, - explicit_provider: str = "", -) -> bool: + is_global: bool, is_session: bool, is_once: bool = False, explicit_provider: str = "") -> bool: """Decide whether a ``/model`` switch should persist to ``config.yaml``. - Resolution order: - - 1. ``--once`` explicitly opts out → ``False`` (next turn only). - 2. ``--session`` explicitly opts out → ``False`` (this session only). - 3. ``--global`` explicitly opts in → ``True``. - 4. No default configured yet (neither ``model.default`` nor - ``model.provider`` set — a fresh install whose first-ever pick this - is) → ``True``. Without a persisted provider, ``resolve_provider`` - falls through to whatever ``*_API_KEY`` env var is lying around on - the next launch (#86414), so the first pick becomes the default - instead of evaporating. Applies to every surface (CLI, gateway, - Desktop picker) so no client has to hardcode ``--global``. - 5. ``--provider`` given without an explicit persist flag → ``False`` - (session only). Provider switches are typically exploratory — the - user is trying a different backend for this conversation, not - reconfiguring the default. ``--global`` can still force persist. - 6. Otherwise defer to ``model.persist_switch_by_default`` in - ``config.yaml`` (defaults to ``False``: a plain ``/model `` - affects only the current session). Users who want the old - persist-by-default behavior can set the key to ``true``; a one-off - ``--global`` always persists. - - The config read is defensive: on a fresh install ``model`` may be a - flat string rather than a dict, in which case the built-in default - (``False``) applies. - """ + Order: ``--once`` / ``--session`` -> False; ``--global`` -> True; no default configured yet + (neither ``model.default`` nor ``model.provider`` — a fresh install's first pick) -> True, so + the pick does not evaporate into whatever ``*_API_KEY`` is lying around on the next launch; + ``--provider`` without a persist flag -> False (exploratory); else + ``model.persist_switch_by_default`` (default False). A flat-string ``model`` IS a configured + default; an unreadable config -> False.""" if is_once or is_session: return False if is_global: return True try: from hermes_cli.config import load_config - model_cfg = load_config().get("model") except Exception: return False @@ -775,49 +506,33 @@ def resolve_persist_behavior( if explicit_provider: return False return bool(model_cfg.get("persist_switch_by_default", False)) - # Flat-string form: a non-empty string IS a configured default. return not model_cfg -# --------------------------------------------------------------------------- -# Single-owner /model request parsing + effective-model resolution -# --------------------------------------------------------------------------- -# -# Historically each surface (cli.py, gateway/slash_commands.py, -# tui_gateway/server.py) re-implemented flag parsing + conflict checks, and -# each resolution surface (gateway/run.py, gateway/platforms/api_server.py) -# re-implemented the session-override > channel/session > global precedence. -# Commit 7dd00bb47d had to re-fix the api_server discarding session-persisted -# models precisely because the precedence rule lived in two places. The -# helpers below are the ONE owner; surfaces map error codes to their own -# user-facing copy but never re-derive the semantics. +# --- Single-owner /model request parsing + effective-model resolution. Surfaces +# (cli.py, gateway/slash_commands.py, tui_gateway/server.py, api_server.py) +# map error codes to their own copy but never re-derive the semantics. # Error codes emitted by parse_model_switch_args(). MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL = "once_with_global" MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET = "once_requires_target" -# Canonical (surface-neutral) error copy. Surfaces prepend their own -# decoration (" ✗ " in the CLI, "❌ " in the gateway) but MUST NOT change -# the core sentence — it is shared user-visible copy. +# Canonical (surface-neutral) error copy. Surfaces prepend their own decoration (" ✗ " in the +# CLI, "❌ " in the gateway) but MUST NOT change the core sentence — it is shared user-visible copy. MODEL_SWITCH_ERROR_TEXT = { MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL: "/model --once cannot be combined with --global", - MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET: "/model --once requires a model or provider.", -} + MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET: "/model --once requires a model or provider."} @dataclass(frozen=True) class ModelSwitchRequest: """A fully parsed /model command request. - ``scope`` is the *requested* persistence scope derived purely from the - flags: ``"once"`` | ``"session"`` | ``"global"`` | ``"default"`` (no - explicit scope flag; the effective decision then belongs to - :func:`resolve_persist_behavior`, which also reads config). - - ``errors`` carries error *codes* (see ``MODEL_SWITCH_ERR_*``); surfaces - render them via :data:`MODEL_SWITCH_ERROR_TEXT` plus their own prefix. - """ - + ``scope`` is the *requested* persistence scope from the flags alone: ``"once"`` | + ``"session"`` | ``"global"`` | ``"default"`` (the effective decision then belongs to + :func:`resolve_persist_behavior`). ``errors`` carries ``MODEL_SWITCH_ERR_*`` codes rendered + via :data:`MODEL_SWITCH_ERROR_TEXT`. ``model_input`` keeps it a drop-in for + :class:`ModelFlagParseResult` consumers.""" raw: str target: str explicit_provider: str = "" @@ -828,46 +543,21 @@ class ModelSwitchRequest: scope: str = "default" errors: tuple = () - # Compat properties so a ModelSwitchRequest can be passed anywhere a - # ModelFlagParseResult was accepted (e.g. tui_gateway._apply_model_switch). @property def model_input(self) -> str: return self.target - @property - def flags(self) -> "ModelFlagParseResult": - return ModelFlagParseResult( - model_input=self.target, - explicit_provider=self.explicit_provider, - is_global=self.is_global, - force_refresh=self.force_refresh, - is_session=self.is_session, - is_once=self.is_once, - ) - def error_messages(self) -> list: """Canonical (undercorated) error strings for this request.""" return [MODEL_SWITCH_ERROR_TEXT[code] for code in self.errors] def parse_model_switch_args(raw: str) -> ModelSwitchRequest: - """Parse a raw /model argument string into a :class:`ModelSwitchRequest`. + """The ONE parser for every /model surface: tokenization plus flag-conflict validation. - The ONE parser for every /model surface. Wraps - :func:`parse_model_flags_detailed` (tokenization + Unicode-dash - normalization) and layers on the flag-conflict validation that cli.py, - gateway/slash_commands.py, and tui_gateway/server.py each used to - re-implement: - - * ``--once`` + ``--global`` → ``MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL`` - * ``--once`` with no model and no ``--provider`` - → ``MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET`` - - Model targets pass through untouched: bare names (``sonnet``), - aggregator slugs (``vendor/model``), and colon forms (``vendor:model``) - are all resolved later by :func:`switch_model` (aggregator-aware — bare - names resolve WITHIN the current aggregator first). - """ + ``--once`` + ``--global`` -> ``MODEL_SWITCH_ERR_ONCE_WITH_GLOBAL``; ``--once`` with neither + a model nor ``--provider`` -> ``MODEL_SWITCH_ERR_ONCE_REQUIRES_TARGET``. Targets pass through + untouched (bare names, ``vendor/model``, ``vendor:model``) for :func:`switch_model`.""" raw = str(raw or "") parsed = parse_model_flags_detailed(raw) @@ -879,18 +569,10 @@ def parse_model_switch_args(raw: str) -> ModelSwitchRequest: # First matching flag wins: once > session > global > default. scope = next((name for name, on in (("once", parsed.is_once), ("session", parsed.is_session), ("global", parsed.is_global)) if on), "default") - return ModelSwitchRequest( - raw=raw, - target=parsed.model_input, - explicit_provider=parsed.explicit_provider, - is_global=parsed.is_global, - is_session=parsed.is_session, - is_once=parsed.is_once, - force_refresh=parsed.force_refresh, - scope=scope, - errors=tuple(errors), - ) + raw=raw, target=parsed.model_input, scope=scope, errors=tuple(errors), + **{f: getattr(parsed, f) + for f in ("explicit_provider", "is_global", "is_session", "is_once", "force_refresh")}) def _effective_model_candidate(value: Any) -> str: @@ -898,31 +580,18 @@ def _effective_model_candidate(value: Any) -> str: if isinstance(value, str): return value.strip() if isinstance(value, dict): - return str(value.get("model") or "").strip() - model_attr = getattr(value, "model", None) # None value -> getattr default -> "" - return str(model_attr or "").strip() if model_attr is not None else "" + return _clean(value.get("model")) + model_attr = getattr(value, "model", None) + return _clean(model_attr) if model_attr is not None else "" def resolve_effective_model( - session_overrides: Any = None, - channel_config: Any = None, - global_config: Any = "", -) -> str: + session_overrides: Any = None, channel_config: Any = None, global_config: Any = "") -> str: """Resolve the effective model: session override > channel > global. - The single owner of the precedence rule that gateway/run.py - (``_resolve_model_for_channel`` / ``_apply_session_model_override``) and - gateway/platforms/api_server.py (``_create_agent``'s session-override / - session-persisted-model branches) each encoded independently — the - divergence commit 7dd00bb47d had to close. A user-issued ``/model`` - (session override) always wins over per-channel/session-persisted - configuration, which wins over the global default. - - Each argument may be a plain model string, a dict with a ``"model"`` - key (a gateway ``_session_model_overrides`` entry), or an object with a - ``.model`` attribute (a ``ChannelOverride``). Empty/None entries fall - through to the next tier. - """ + Single owner of the precedence rule gateway/run.py and api_server.py each used to encode. + Each argument may be a model string, a dict with a ``"model"`` key, or an object with a + ``.model`` attribute; empty entries fall through to the next tier.""" for tier in (session_overrides, channel_config, global_config): candidate = _effective_model_candidate(tier) if candidate: @@ -930,117 +599,80 @@ def resolve_effective_model( return "" -# --------------------------------------------------------------------------- -# Alias resolution -# --------------------------------------------------------------------------- +# --- Alias resolution def _model_sort_key(model_id: str, prefix: str) -> tuple: - """Sort key for model version preference. + """Sort key preferring higher versions after the family prefix, then ranked suffix tokens. - Extracts version numbers after the family prefix and returns a sort key - that prefers higher versions. Suffix tokens (``pro``, ``omni``, etc.) - are used as tiebreakers, with common quality indicators ranked. - - Examples (with prefix ``"mimo"``):: - - mimo-v2.5-pro → (-2.5, 0, 'pro') # highest version wins - mimo-v2.5 → (-2.5, 1, '') # no suffix = lower than pro - mimo-v2-pro → (-2.0, 0, 'pro') - mimo-v2-omni → (-2.0, 1, 'omni') - mimo-v2-flash → (-2.0, 1, 'flash') - """ + With prefix ``"mimo"``: ``mimo-v2.5-pro`` -> (-2.5, 0, 'pro'), ``mimo-v2.5`` -> (-2.5, 1, ''), + ``mimo-v2-omni`` -> (-2.0, 1, 'omni').""" # Strip the prefix (and optional "/" separator for aggregator slugs) - rest = model_id[len(prefix):] - if rest.startswith("/"): - rest = rest[1:] - rest = rest.lstrip("-").strip() - - # Parse version and suffix from the remainder. - # "v2.5-pro" → version [2.5], suffix "pro" - # "-omni" → version [], suffix "omni" - # State machine: start → in_version → between → in_suffix - nums: list[float] = [] - suffix_buf = "" - state = "start" - num_buf = "" - - def _flush() -> None: - nonlocal num_buf - if num_buf: - try: - nums.append(float(num_buf.rstrip("."))) - except ValueError: - pass - num_buf = "" - - for ch in rest: - if state == "in_suffix": - suffix_buf += ch - elif state == "in_version": - if ch.isdigit(): - num_buf += ch - elif ch == ".": - if "." in num_buf: - _flush() # second dot: start a new version component - else: - num_buf += ch - else: - _flush() - if ch in "-_": - state = "between" - else: - state = "in_suffix" - suffix_buf += ch - else: # "start" / "between": skip separators, enter version on v/digit, else suffix - if ch in "vV": - state = "in_version" - elif ch.isdigit(): - state = "in_version" - num_buf = ch - elif ch not in "-_.": - state = "in_suffix" - suffix_buf += ch - - # Flush remaining buffer (strip trailing dots — "5.4." → "5.4") - if state == "in_version": - _flush() - + rest = model_id[len(prefix):].removeprefix("/").lstrip("-").strip() + nums, suffix_buf = _split_version_suffix(rest) suffix = suffix_buf.lower().strip("-_.").strip() - # YYYYMMDD date stamps (claude-opus-4-20250514) are snapshot markers, not - # version components, and would dwarf real point versions (20250514 > 8); - # keep them as a trailing tiebreaker so bare IDs sort before their dated - # snapshots and newer snapshots before older. The 19_000_101 threshold - # reclassifies only 8-digit stamps (mistral-large-2411, gpt-4-0613 keep - # sorting as versions). + # YYYYMMDD date stamps (claude-opus-4-20250514) are snapshot markers, not version components, + # and would dwarf real point versions; keep them as a trailing tiebreaker so bare IDs sort + # before their dated snapshots and newer snapshots before older. The 19_000_101 threshold + # reclassifies only 8-digit stamps (mistral-large-2411, gpt-4-0613 keep sorting as versions). version_key = tuple(-n for n in nums if n < 19_000_101) # negate: higher sorts first date_stamp = max((n for n in nums if n >= 19_000_101), default=0.0) date_key = (0.0, 0.0) if date_stamp == 0.0 else (1.0, -date_stamp) - # Suffix quality: pro/max/plus/turbo (0) > no suffix / omni / flash / mini (1). - # "sol" is the flagship tier of the GPT-5.6 series (sol > terra > luna); - # without it `/model gpt` would tiebreak alphabetically onto luna, the - # cheapest. It is a series codename, not a generic quality word — revisit - # if another vendor ships a non-flagship "-sol". + # Suffix quality: pro/max/plus/turbo (0) > no suffix / omni / flash / mini (1). "sol" is the + # flagship tier of the GPT-5.6 series (sol > terra > luna); without it `/model gpt` would + # tiebreak alphabetically onto luna, the cheapest. Revisit if a vendor ships a non-flagship "-sol". suffix_rank = 0 if suffix in ("pro", "max", "plus", "turbo", "sol") else 1 return version_key + (suffix_rank, suffix) + date_key +def _split_version_suffix(rest: str) -> tuple[list[float], str]: + """``"v2.5-pro"`` -> ``([2.5], "pro")``; ``"-omni"`` -> ``([], "omni")``. + + Version tokens are ``v``-optional digit/dot runs separated by ``-``/``_``; a second dot inside + a run starts a new component; the first character that is neither starts the suffix.""" + nums: list[float] = [] + run, pos = "", 0 + + def _flush() -> None: + nonlocal run + try: + nums.append(float(run.rstrip("."))) + except ValueError: + pass + run = "" + + while pos < len(rest): + ch = rest[pos] + if ch in "-_.": + pos += 1 + continue + if not (ch in "vV" or ch.isdigit()): + break + if ch in "vV": + pos += 1 + while pos < len(rest) and (rest[pos].isdigit() or rest[pos] == "."): + if rest[pos] == "." and "." in run: + _flush() + else: + run += rest[pos] + pos += 1 + _flush() + if pos < len(rest) and rest[pos] not in "-_": + break + return nums, rest[pos:] + + class AmbiguousAliasError(Exception): """Alias family-matches multiple catalog models; caller must disambiguate. - Raised by :func:`resolve_alias` instead of silently picking one candidate - via version-sort heuristics. ``candidates`` is sorted best-guess-first - (see :func:`_model_sort_key`) for display purposes only. - """ - + Raised by :func:`resolve_alias` instead of silently picking one via version-sort heuristics. + ``candidates`` is sorted best-guess-first (see :func:`_model_sort_key`) for display only.""" def __init__(self, alias: str, provider: str, candidates: list[str]): self.alias = alias self.provider = provider self.candidates = candidates - super().__init__( - f"alias {alias!r} matches {len(candidates)} models on {provider}" - ) + super().__init__(f"alias {alias!r} matches {len(candidates)} models on {provider}") def _ambiguous_alias_message(err: "AmbiguousAliasError") -> str: @@ -1052,37 +684,25 @@ def _ambiguous_alias_message(err: "AmbiguousAliasError") -> str: return ( f"'{err.alias}' matches {len(err.candidates)} models on " f"{err.provider} — not switching automatically:\n{lines}{more}\n" - f"Pick one with /model ." - ) + f"Pick one with /model .") -def resolve_alias( - raw_input: str, - current_provider: str, -) -> Optional[tuple[str, str, str]]: +def resolve_alias(raw_input: str, current_provider: str) -> Optional[tuple[str, str, str]]: """Resolve a short alias against the current provider's catalog. - Looks up *raw_input* in :data:`MODEL_ALIASES`, then searches the - current provider's models.dev catalog for the model whose ID starts - with ``vendor/family`` (or just ``family`` for non-aggregator - providers) and has the **highest version**. - - Returns: - ``(provider, resolved_model_id, alias_name)`` if a match is - found on the current provider, or ``None`` if the alias doesn't - exist or no matching model is available. - """ + Direct aliases (and reverse lookup by exact model id) win; then :data:`MODEL_ALIASES` is + matched against the provider's models.dev catalog by ``vendor/family`` prefix (``family`` + for non-aggregators). Returns ``(provider, resolved_model_id, alias_name)`` or None; raises + :class:`AmbiguousAliasError` when several catalog models match.""" key = raw_input.strip().lower() - # Check direct aliases first (exact model+provider+base_url mappings) _ensure_direct_aliases() direct = DIRECT_ALIASES.get(key) if direct is not None: return (direct.provider, direct.model, key) - # Reverse lookup: match by model ID so full names (e.g. "kimi-k2.5", - # "glm-4.7") route through direct aliases instead of falling through - # to the catalog/OpenRouter. + # Reverse lookup so full names ("kimi-k2.5") route through direct aliases instead of + # falling through to the catalog/OpenRouter. for alias_name, da in DIRECT_ALIASES.items(): if da.model.lower() == key: return (da.provider, da.model, alias_name) @@ -1093,9 +713,7 @@ def resolve_alias( vendor, family = identity - # Build catalog from models.dev, then merge in static _PROVIDER_MODELS - # entries that models.dev may be missing (e.g. newly added models not - # yet synced to the registry). + # models.dev catalog merged with static _PROVIDER_MODELS entries it may be missing. catalog = list_provider_models(current_provider) try: from hermes_cli.models import _PROVIDER_MODELS @@ -1104,17 +722,14 @@ def resolve_alias( except Exception: pass - # Aggregator catalogs are vendor/model-name; direct providers are bare family names. prefix = f"{vendor}/{family}" if is_aggregator(current_provider) else family matches = [mid for mid in catalog if mid.lower().startswith(prefix.lower())] if not matches: return None - # Sort by version descending (best guess first) for display, but NEVER - # silently pick among multiple candidates: version-sort heuristics have - # repeatedly guessed wrong (dated snapshots outranking point releases, - # suffix tiebreaks landing on the cheapest tier). One match = resolve; - # several = make the user choose. + # Version-sort for display, but NEVER silently pick among multiple candidates: the + # heuristics have repeatedly guessed wrong (dated snapshots outranking point releases, + # suffix tiebreaks landing on the cheapest tier). matches.sort(key=lambda m: _model_sort_key(m, prefix)) if len(matches) > 1: raise AmbiguousAliasError(key, current_provider, matches) @@ -1122,76 +737,44 @@ def resolve_alias( def get_authenticated_provider_slugs( - current_provider: str = "", - user_providers: dict = None, - custom_providers: list | None = None, + current_provider: str = "", user_providers: dict = None, custom_providers: list | None = None ) -> list[str]: - """Return slugs of providers that have credentials. - - Uses ``list_authenticated_providers()`` which is backed by the models.dev - in-memory cache (1 hr TTL) — no extra network cost. - """ + """Slugs of providers that have credentials (models.dev in-memory cache; no extra network cost).""" try: return [p["slug"] for p in list_authenticated_providers( current_provider=current_provider, user_providers=user_providers, - custom_providers=custom_providers, max_models=0, - )] + custom_providers=custom_providers, max_models=0)] except Exception: return [] def _resolve_alias_fallback( - raw_input: str, - authenticated_providers: list[str] = (), -) -> Optional[tuple[str, str, str]]: - """Try to resolve an alias on the user's authenticated providers. + raw_input: str, authenticated_providers: list[str] = ()) -> Optional[tuple[str, str, str]]: + """Resolve an alias on the user's authenticated providers (``("openrouter", "nous")`` when none given). - Falls back to ``("openrouter", "nous")`` only when no authenticated - providers are supplied (backwards compat for non-interactive callers). - """ - # AmbiguousAliasError propagates: the alias exists on this provider, the - # user just has to choose — trying the next provider instead would silently - # switch them somewhere they didn't ask to go. - return next((r for r in (resolve_alias(raw_input, p) for p in authenticated_providers or ("openrouter", "nous")) - if r is not None), None) + AmbiguousAliasError propagates: the alias exists on this provider, the user just has to + choose — trying the next provider would silently switch them somewhere they didn't ask for.""" + results = (resolve_alias(raw_input, p) for p in authenticated_providers or ("openrouter", "nous")) + return next((r for r in results if r is not None), None) def resolve_display_context_length( - model: str, - provider: str, - base_url: str = "", - api_key: str = "", - model_info: Optional[ModelInfo] = None, - custom_providers: list | None = None, - config_context_length: int | None = None, - configured_model: str | None = None, + model: str, provider: str, base_url: str = "", api_key: str = "", + model_info: Optional[ModelInfo] = None, custom_providers: list | None = None, + config_context_length: int | None = None, configured_model: str | None = None, configured_provider: str | None = None, - configured_base_url: str | None = None, -) -> Optional[int]: - """Resolve the context length to show in /model output. + configured_base_url: str | None = None) -> Optional[int]: + """Context length to show in /model output. - models.dev reports per-vendor context (e.g. gpt-5.5 = 1.05M on openai) - but provider-enforced limits can be lower (e.g. Codex OAuth caps the - same slug at 272k). The authoritative source is - ``agent.model_metadata.get_model_context_length`` which already knows - about Codex OAuth, Copilot, Nous, and falls back to models.dev for the - rest. - - When ``custom_providers`` is provided, per-model ``context_length`` - overrides from ``custom_providers[].models..context_length`` are - honored — this closes #15779 where ``/model`` switch ignored user-set - overrides. - - Prefer the provider-aware value; fall back to ``model_info.context_window`` - only if the resolver returns nothing. - """ + models.dev reports per-vendor context but provider-enforced limits can be lower (Codex OAuth + caps gpt-5.5 at 272k), so ``agent.model_metadata.get_model_context_length`` is authoritative + (it also honors ``custom_providers[].models..context_length``); ``model_info.context_window`` + is the fallback. A ``config_context_length`` pin is dropped when the route changed.""" if config_context_length is not None and (configured_model or configured_provider or configured_base_url): try: from hermes_cli.route_identity import should_clear_context_pin - if should_clear_context_pin( - configured_model, model, configured_base_url, base_url, configured_provider, provider, - ): + configured_model, model, configured_base_url, base_url, configured_provider, provider): config_context_length = None except Exception: config_context_length = None @@ -1200,8 +783,7 @@ def resolve_display_context_length( from agent.model_metadata import get_model_context_length ctx = get_model_context_length( model, base_url=base_url or "", api_key=api_key or "", provider=provider or None, - custom_providers=custom_providers, config_context_length=config_context_length, - ) + custom_providers=custom_providers, config_context_length=config_context_length) if ctx: return int(ctx) except Exception: @@ -1211,67 +793,22 @@ def resolve_display_context_length( return None -async def resolve_display_context_length_async( - model: str, - provider: str, - base_url: str = "", - api_key: str = "", - model_info: Optional[ModelInfo] = None, - custom_providers: list | None = None, - config_context_length: int | None = None, - configured_model: str | None = None, - configured_provider: str | None = None, - configured_base_url: str | None = None, -) -> Optional[int]: - """Async variant of :func:`resolve_display_context_length`. - - The sync version runs two blocking chains: the route comparison in - ``should_clear_context_pin`` and the full provider probe ladder in - ``get_model_context_length`` (blocking ``requests`` calls to Anthropic - ``/v1/models``, Copilot, Nous, Codex, GMI, Ollama, models.dev and - OpenRouter). Async gateway handlers must not run either on the event - loop — see ``agent.model_metadata.get_model_context_length_async`` and - ``hermes_cli.route_identity.should_clear_context_pin_async``, which - offload the same chains for the message path. - - Shares all logic with the sync version — no code duplication. - """ +async def resolve_display_context_length_async(model: str, provider: str, **kwargs) -> Optional[int]: + """Thread-offloaded :func:`resolve_display_context_length` (same keyword arguments) — the sync + version runs blocking provider probes that async gateway handlers must not run on the loop.""" import asyncio - - return await asyncio.to_thread( - resolve_display_context_length, model, provider, base_url=base_url, api_key=api_key, - model_info=model_info, custom_providers=custom_providers, config_context_length=config_context_length, - configured_model=configured_model, configured_provider=configured_provider, - configured_base_url=configured_base_url, - ) + return await asyncio.to_thread(resolve_display_context_length, model, provider, **kwargs) -# --------------------------------------------------------------------------- -# Configured-provider detection for typed model names -# --------------------------------------------------------------------------- - +# --- Configured-provider detection for typed model names def _configured_provider_matches( - model_name: str, - user_providers: Optional[dict], - custom_providers: Optional[list], + model_name: str, user_providers: Optional[dict], custom_providers: Optional[list] ) -> dict[str, str]: - """Return ``{provider_slug: canonical_model_id}`` for every configured - provider whose declared models contain an exact (case-insensitive) match - for ``model_name``. - - Used by :func:`switch_model` to route a *typed* model name to the provider - that actually declares it in user/custom provider config, instead of - leaving it on the current provider. Without this, a model declared under - ``providers.`` / ``custom_providers`` but typed while the current - provider is ``openai-codex`` stays on Codex and is soft-accepted as an - unknown hidden Codex model (#45006). - - Matching is exact (case-insensitive); the configured spelling is returned - so the downstream validation/override path sees the canonical id. Only the - explicitly-declared model collections are scanned (``models``, the singular - ``model``, and ``default_model``) — never fuzzy/family matching. - """ + """``{provider_slug: canonical_model_id}`` for every configured provider whose declared models + (``models``, ``model``, ``default_model`` — exact, case-insensitive, never fuzzy) contain + ``model_name``, so a typed name routes to the provider that declares it instead of being + soft-accepted by the current provider (openai-codex) as an unknown hidden model.""" if not model_name or not model_name.strip(): return {} target = model_name.strip().lower() @@ -1280,89 +817,69 @@ def _configured_provider_matches( if isinstance(user_providers, dict): candidates += [(slug, cfg) for slug, cfg in user_providers.items() if isinstance(slug, str) and isinstance(cfg, dict)] - if isinstance(custom_providers, list): - candidates += [(f"custom:{e['name']}", e) for e in custom_providers - if isinstance(e, dict) and isinstance(e.get("name"), str) and e["name"].strip()] + candidates += [(f"custom:{e['name']}", e) for e in _custom_entries(custom_providers) + if isinstance(e.get("name"), str) and e["name"].strip()] matches: dict[str, str] = {} for slug, cfg in candidates: - if slug in matches: - continue hit = next((mid for key in ("models", "model", "default_model") for mid in _declared_model_ids(cfg.get(key)) if mid.lower() == target), None) if hit: - matches[slug] = hit + matches.setdefault(slug, hit) # first declaration wins return matches -def _resolve_named_custom_model_id( - model_name: str, - target_provider: str, - custom_providers: Optional[list], -) -> str: - """Map a picker-prefixed custom model selection to its configured ID.""" - provider = str(target_provider or "").strip().lower() +def _resolve_named_custom_model_id(model_name: str, target_provider: str, custom_providers: Optional[list]) -> str: + """Map a picker-prefixed custom model selection (``prefix/model``) to its configured ID.""" + provider = _clean(target_provider).lower() if not provider.startswith("custom:") or "/" not in model_name: return model_name - prefix, candidate = model_name.split("/", 1) - prefix = prefix.strip().lower() - candidate = candidate.strip() + prefix, candidate = (part.strip() for part in model_name.split("/", 1)) if not prefix or not candidate: return model_name - - for entry in custom_providers or []: - if not isinstance(entry, dict): - continue - entry_slugs = custom_provider_aliases(str(entry.get("name") or ""), str(entry.get("provider_key") or "")) - if provider in entry_slugs and f"custom:{prefix}" in entry_slugs: + for entry in _custom_entries(custom_providers): + entry_slugs = _entry_aliases(entry) + if provider in entry_slugs and f"custom:{prefix.lower()}" in entry_slugs: for model_id in _declared_model_ids(entry.get("models")): if model_id.lower() == candidate.lower(): return model_id return model_name -# --------------------------------------------------------------------------- -# Core model-switching pipeline -# --------------------------------------------------------------------------- - -def _switch_fail(is_global: bool, message: str, **fields) -> ModelSwitchResult: - return ModelSwitchResult(success=False, is_global=is_global, error_message=message, **fields) +def _custom_entries(custom_providers: Any) -> list[dict]: + """The dict-shaped entries of a ``custom_providers:`` list (anything else is ignored).""" + return [e for e in custom_providers if isinstance(e, dict)] if isinstance(custom_providers, list) else [] -def _runtime_creds(fallback_headers: dict, **kwargs) -> tuple[str, str, str, dict]: - """``resolve_runtime_provider`` unpacked as ``(api_key, base_url, api_mode, - extra_headers)``; ``extra_headers`` falls back to *fallback_headers*.""" - from hermes_cli.runtime_provider import resolve_runtime_provider +def _entry_aliases(entry: dict) -> frozenset[str]: + return custom_provider_aliases(str(entry.get("name") or ""), str(entry.get("provider_key") or "")) - rt = resolve_runtime_provider(**kwargs) - return rt.get("api_key", ""), rt.get("base_url", ""), rt.get("api_mode", ""), rt.get("extra_headers") or fallback_headers +# --- Core model-switching pipeline def _entry_configured_key(cfg: dict, read_env) -> str: """Inline ``api_key`` (a ``${VAR}`` template resolves via *read_env*), else ``key_env``/``api_key_env`` via *read_env*.""" - key = str(cfg.get("api_key", "") or "").strip() + key = _clean(cfg.get("api_key", "")) if key.startswith("${") and key.endswith("}"): key = read_env(key[2:-1]) if not key: - key_env = str(cfg.get("key_env") or cfg.get("api_key_env") or "").strip() + key_env = _clean(cfg.get("key_env") or cfg.get("api_key_env")) key = read_env(key_env) if key_env else "" return key def _ollama_configured_base() -> tuple[dict, str]: from hermes_cli.models import _get_provider_config_dict - cfg = _get_provider_config_dict("ollama") - return cfg, str(cfg.get("base_url") or cfg.get("api") or cfg.get("url") or "").strip() + return cfg, _clean(cfg.get("base_url") or cfg.get("api") or cfg.get("url")) def _unknown_provider_message(explicit_provider: str) -> str: msg = ( f"Unknown provider '{explicit_provider}'. Check 'hermes model' for available " - f"providers, or define it in config.yaml under 'providers:'." - ) + f"providers, or define it in config.yaml under 'providers:'.") try: # Surface common config issues that cause provider resolution failures from hermes_cli.config import validate_config_structure issues = validate_config_structure() @@ -1376,25 +893,19 @@ def _unknown_provider_message(explicit_provider: str) -> str: def _aggregator_alias_error( explicit_provider: str, target_provider: str, current_provider: str, user_providers, custom_providers, ) -> str: - """Guard against silent aggregator hops: a vendor alias like bare "openai" - resolves to an aggregator ("openrouter"); if that aggregator has no - credentials, refuse instead of switching the user onto an unauthed endpoint - (HTTP 401) and point at the real direct provider.""" + """Guard against silent aggregator hops: a vendor alias like bare "openai" resolves to an + aggregator ("openrouter"); if that aggregator has no credentials, refuse instead of switching + the user onto an unauthed endpoint (HTTP 401) and point at the real direct provider.""" from hermes_cli.models import _AGGREGATOR_PROVIDERS from hermes_cli.providers import ALIASES - explicit_norm = explicit_provider.strip().lower() alias_target = ALIASES.get(explicit_norm) if not ( - alias_target - and alias_target == target_provider - and target_provider != explicit_norm - and target_provider in _AGGREGATOR_PROVIDERS - ): + alias_target and alias_target == target_provider and target_provider != explicit_norm + and target_provider in _AGGREGATOR_PROVIDERS): return "" authed = get_authenticated_provider_slugs( - current_provider=current_provider, user_providers=user_providers, custom_providers=custom_providers, - ) + current_provider=current_provider, user_providers=user_providers, custom_providers=custom_providers) if target_provider in authed: return "" suggestions = [s for s in authed if s.startswith(explicit_norm) and s != explicit_norm] @@ -1402,112 +913,86 @@ def _aggregator_alias_error( return ( f"Provider '{explicit_norm}' is an alias that routes " f"through {get_label(target_provider)}, which " - f"has no credentials configured.{hint}" - ) + f"has no credentials configured.{hint}") def _aggregator_catalog_match(new_model: str, catalog: list) -> str | None: """Exact (case-insensitive) match on full id, then on the bare part after ``vendor/``.""" - new_model_lower = new_model.lower() - for mid in catalog: - if mid.lower() == new_model_lower: - return mid - for mid in catalog: - if "/" in mid and mid.split("/", 1)[1].lower() == new_model_lower: - return mid - return None + wanted = new_model.lower() + return next((mid for mid in catalog if mid.lower() == wanted), None) or next( + (mid for mid in catalog if "/" in mid and mid.split("/", 1)[1].lower() == wanted), None) def _config_declares_model( - new_model: str, target_provider: str, base_url: str, user_providers, custom_providers, -) -> bool: - """A model declared in the user's ``providers:``/``custom_providers:`` config - is accepted even when the remote /v1/models does not list it (cloud/aliased - models). Custom entries match by slug alias or by base_url.""" + new_model: str, target_provider: str, base_url: str, user_providers, custom_providers) -> bool: + """A model declared in the user's ``providers:``/``custom_providers:`` config is accepted even + when the remote /v1/models does not list it (cloud/aliased models). Custom entries match by + slug alias or by base_url.""" if user_providers: from hermes_cli.config import is_provider_enabled cfg = user_providers.get(target_provider) if cfg is not None and is_provider_enabled(cfg) and new_model in _declared_model_ids(cfg.get("models", {})): return True - for entry in custom_providers if isinstance(custom_providers, list) else []: - if not isinstance(entry, dict): - continue - entry_aliases = custom_provider_aliases(str(entry.get("name", "") or ""), str(entry.get("provider_key") or "")) - if (target_provider.lower() in entry_aliases or entry.get("base_url", "") == base_url) and ( + for entry in _custom_entries(custom_providers): + if (target_provider.lower() in _entry_aliases(entry) or entry.get("base_url", "") == base_url) and ( new_model == entry.get("model", "") or new_model in _declared_model_ids(entry.get("models", {})) ): return True return False -def _apply_direct_alias_endpoint( - da: DirectAlias, target_provider: str, new_model: str, api_key: str, base_url: str, -) -> tuple[str, str, dict | None, bool]: - """Route a direct alias to its own base_url and decide its credential. +def _apply_direct_alias_endpoint(st: "_Switch", da: DirectAlias) -> None: + """Route a direct alias to its own base_url and decide its credential (mutates ``st``). - Returns ``(api_key, base_url, validation_headers_override, suppress_ollama_headers)`` - where a ``None`` headers override means "leave as is". - - Credentials were resolved against the DEFAULT provider; carrying that key - onto the alias's endpoint both 401s and ships the default provider's secret - to an unrelated host. The alias's own endpoint decides: its declared key when - it has one; the session key only when the alias points at the SAME ORIGIN - it was resolved for; otherwise a fresh resolution against the alias - base_url (whose env-key fallbacks are gated on authoritative hosts, so - OLLAMA_API_KEY still resolves for ollama.com while OPENROUTER_API_KEY never - reaches an unrelated host). - """ + Credentials were resolved against the DEFAULT provider; carrying that key onto the alias + endpoint both 401s and ships the default provider's secret to an unrelated host. The alias's + own endpoint decides: its declared key; else the session key only for the SAME ORIGIN; else a + fresh resolution against the alias base_url (env-key fallbacks are host-gated: OLLAMA_API_KEY + resolves for ollama.com, OPENROUTER_API_KEY never reaches an unrelated host).""" from hermes_cli.models import _same_ollama_native_root from hermes_cli.runtime_provider import resolve_runtime_provider - alias_key = direct_alias_api_key(da) + same_host = _may_reuse_session_credential(st.base_url, da.base_url) if alias_key: - base_url, api_key = da.base_url, alias_key - elif api_key and api_key != "no-key-required" and _may_reuse_session_credential(base_url, da.base_url): - # Same origin: the key is host-appropriate and re-resolving would only - # repeat the work (incl. a second local-endpoint /models probe). - base_url = da.base_url + st.base_url, st.api_key = da.base_url, alias_key + elif st.api_key and st.api_key != "no-key-required" and same_host: + # Same origin: the key is host-appropriate and re-resolving would only repeat the work. + st.base_url = da.base_url else: try: req, explicit = direct_alias_runtime_request(da) alias_runtime = resolve_runtime_provider( - requested=req, explicit_api_key=explicit, explicit_base_url=da.base_url, target_model=new_model, - ) + requested=req, explicit_api_key=explicit, explicit_base_url=da.base_url, target_model=st.new_model) except Exception: alias_runtime = {} - same_host = _may_reuse_session_credential(base_url, da.base_url) - base_url = alias_runtime.get("base_url", "") or da.base_url - # The resolver reports "no key found" as the `no-key-required` - # placeholder; normalise so a same-host credential still outranks it. + st.base_url = alias_runtime.get("base_url", "") or da.base_url + # The resolver reports "no key found" as the `no-key-required` placeholder; normalise so + # a same-host credential still outranks it. resolved_key = alias_runtime.get("api_key", "") if resolved_key == "no-key-required": resolved_key = "" - api_key = resolved_key or (api_key if same_host else "") or "no-key-required" + st.api_key = resolved_key or (st.api_key if same_host else "") or "no-key-required" - headers_override = None - suppress = False - # providers.ollama refinement: pick up the configured key only for the - # configured native root; drop key and provider-level headers for any other - # origin. Skipped when the alias declared its own credential (explicit - # api_key/key_env outranks a provider-level config key). - if not alias_key and target_provider.strip().lower() == "ollama": + # providers.ollama refinement: pick up the configured key only for the configured native + # root; drop key and provider-level headers for any other origin. Skipped when the alias + # declared its own credential (explicit api_key/key_env outranks a provider-level config key). + if not alias_key and st.target_provider.strip().lower() == "ollama": ollama_cfg, ollama_cfg_base = _ollama_configured_base() - if ollama_cfg_base and _same_ollama_native_root(base_url, ollama_cfg_base): + if ollama_cfg_base and _same_ollama_native_root(st.base_url, ollama_cfg_base): configured_key = _entry_configured_key(ollama_cfg, lambda n: os.environ.get(n, "").strip()) if configured_key: - api_key = configured_key + st.api_key = configured_key else: - # Different origin, or no configured root to safely associate the - # provider-level headers with. - headers_override, suppress, api_key = {}, True, "no-key-required" - return api_key or "no-key-required", base_url, headers_override, suppress + # Different origin, or no configured root to safely associate the headers with. + st.validation_headers, st.suppress_ollama_headers, st.api_key = {}, True, "no-key-required" + st.api_key = st.api_key or "no-key-required" + st.api_mode = "" # clear so determine_api_mode re-detects from URL def _moa_default_preset() -> str: try: from hermes_cli.config import load_config from hermes_cli.moa_config import normalize_moa_config - return normalize_moa_config(load_config().get("moa") or {})["default_preset"] except Exception: return "default" @@ -1517,13 +1002,10 @@ def _moa_default_preset() -> str: class _Switch: """Mutable state threaded through the ``switch_model`` steps. - The routing steps settle ``target_provider`` / ``new_model`` / - ``resolved_alias`` (and may promote a config-routed ``providers.`` to - ``explicit_provider`` so the credential step resolves its block); the - credential step fills ``api_key`` / ``base_url`` / ``api_mode`` / - ``validation_headers``. - """ - + The routing steps settle ``target_provider`` / ``new_model`` / ``resolved_alias`` (and may + promote a config-routed ``providers.`` to ``explicit_provider`` so the credential step + resolves its block); the credential step fills ``api_key`` / ``base_url`` / ``api_mode`` / + ``validation_headers``.""" raw_input: str current_provider: str current_model: str @@ -1545,48 +1027,55 @@ class _Switch: validation: dict = field(default_factory=dict) def fail(self, message: str, **fields) -> ModelSwitchResult: - return _switch_fail(self.is_global, message, **fields) + return ModelSwitchResult(success=False, is_global=self.is_global, error_message=message, **fields) + + def fail_on_target(self, message: str) -> ModelSwitchResult: + """Failure carrying the already-settled ``target_provider`` / ``provider_label``.""" + return self.fail(message, target_provider=self.target_provider, provider_label=self.provider_label) @property def provider_changed(self) -> bool: return self.target_provider != self.current_provider + def resolve_runtime(self, **kwargs) -> None: + """Fill api_key / base_url / api_mode / validation_headers from ``resolve_runtime_provider`` + for ``new_model``; headers keep their current value when the resolver returns none.""" + from hermes_cli.runtime_provider import resolve_runtime_provider + rt = resolve_runtime_provider(target_model=self.new_model, **kwargs) + self.api_key, self.base_url = rt.get("api_key", ""), rt.get("base_url", "") + self.api_mode = rt.get("api_mode", "") + self.validation_headers = rt.get("extra_headers") or self.validation_headers + def _route_explicit_provider(st: _Switch) -> Optional[ModelSwitchResult]: - """PATH A (``--provider`` given): resolve the provider, auto-detect a model - from a local endpoint when none was typed, then resolve the alias on the - TARGET provider.""" + """PATH A (``--provider`` given): resolve the provider, auto-detect a model from a local + endpoint when none was typed, then resolve the alias on the TARGET provider.""" pdef = resolve_provider_full(st.explicit_provider, st.user_providers, st.custom_providers) if pdef is None and st.explicit_provider.strip().lower() == "custom": pdef = _bare_custom_provider_def(st.current_base_url) if pdef is None: return st.fail(_unknown_provider_message(st.explicit_provider)) - st.target_provider = pdef.id + st.target_provider, st.provider_label = pdef.id, pdef.name # label is re-derived in the credential step if st.target_provider == "moa" and not st.new_model: st.new_model = _moa_default_preset() agg_err = _aggregator_alias_error( - st.explicit_provider, st.target_provider, st.current_provider, st.user_providers, st.custom_providers, - ) + st.explicit_provider, st.target_provider, st.current_provider, st.user_providers, st.custom_providers) if agg_err: - return st.fail(agg_err, target_provider=st.target_provider, provider_label=pdef.name) + return st.fail_on_target(agg_err) if not st.new_model: if not pdef.base_url: - return st.fail( + return st.fail_on_target( f"Provider '{pdef.name}' has no base URL configured. " - f"Specify a model: /model --provider {st.explicit_provider}", - target_provider=st.target_provider, provider_label=pdef.name, - ) + f"Specify a model: /model --provider {st.explicit_provider}") from hermes_cli.runtime_provider import _auto_detect_local_model st.new_model = _auto_detect_local_model(pdef.base_url) if not st.new_model: - return st.fail( + return st.fail_on_target( f"No model detected on {pdef.name} ({pdef.base_url}). " - f"Specify the model explicitly: /model --provider {st.explicit_provider}", - target_provider=st.target_provider, provider_label=pdef.name, - ) + f"Specify the model explicitly: /model --provider {st.explicit_provider}") try: alias_result = resolve_alias(st.new_model, st.target_provider) @@ -1598,8 +1087,7 @@ def _route_explicit_provider(st: _Switch) -> Optional[ModelSwitchResult]: def _route_alias_fallback(st: _Switch, key: str) -> Optional[ModelSwitchResult]: - """Step b: the alias exists but not on the current provider -> try the - user's authenticated providers.""" + """Step b: the alias exists but not on the current provider -> try the user's authenticated providers.""" authed = get_authenticated_provider_slugs( current_provider=st.current_provider, user_providers=st.user_providers, custom_providers=st.custom_providers, ) @@ -1612,29 +1100,22 @@ def _route_alias_fallback(st: _Switch, key: str) -> Optional[ModelSwitchResult]: return st.fail( f"Alias '{key}' maps to {identity.vendor}/{identity.family} " f"but no matching model was found in any provider catalog. " - f"Try specifying the full model name.", - ) + f"Try specifying the full model name.") st.target_provider, st.new_model, st.resolved_alias = fallback_result logger.debug( - "Alias '%s' resolved via fallback to %s on %s", st.resolved_alias, st.new_model, st.target_provider, - ) + "Alias '%s' resolved via fallback to %s on %s", st.resolved_alias, st.new_model, st.target_provider) return None def _convert_vendor_colon_slug(st: _Switch) -> None: - """Step c: on an aggregator, ``vendor:model`` -> ``vendor/model``. Only - without a slash: with one, the colon is a variant tag (:free, :extended, - :fast) that must be preserved.""" + """Step c: on an aggregator, ``vendor:model`` -> ``vendor/model``. Only without a slash: with + one, the colon is a variant tag (:free, :extended, :fast) that must be preserved.""" raw_input = st.raw_input colon_pos = raw_input.find(":") cur_norm = str(st.current_provider).strip().lower() if ( - colon_pos > 0 - and "/" not in raw_input - and is_aggregator(st.current_provider) - and not cur_norm.startswith("custom") - and cur_norm != "ollama" - ): + colon_pos > 0 and "/" not in raw_input and is_aggregator(st.current_provider) + and not cur_norm.startswith("custom") and cur_norm != "ollama"): left = raw_input[:colon_pos].strip().lower() right = raw_input[colon_pos + 1:].strip() if left and right: @@ -1643,10 +1124,10 @@ def _convert_vendor_colon_slug(st: _Switch) -> None: def _route_configured_provider(st: _Switch) -> Optional[ModelSwitchResult] | bool: - """Step d.5: a model declared in user/custom provider config routes there - BEFORE detect_provider_for_model() guesses from static catalogs and before a - soft-accepting current provider (openai-codex) can swallow it as an unknown - hidden model. Returns a failure result, ``True`` when routed, else ``False``.""" + """Step d.5: a model declared in user/custom provider config routes there BEFORE + detect_provider_for_model() guesses from static catalogs and before a soft-accepting current + provider (openai-codex) can swallow it as an unknown hidden model. Returns a failure result, + ``True`` when routed, else ``False``.""" cfg_matches = _configured_provider_matches(st.new_model, st.user_providers, st.custom_providers) if not cfg_matches: return False @@ -1658,63 +1139,49 @@ def _route_configured_provider(st: _Switch) -> Optional[ModelSwitchResult] | boo return st.fail( f"'{st.new_model}' is declared by multiple configured " f"providers ({', '.join(match_slugs)}). Re-run with " - f"--provider to choose which one to use.", - ) + f"--provider to choose which one to use.") st.target_provider = match_slugs[0] st.new_model = cfg_matches[st.target_provider] logger.debug("Configured-provider detection routed '%s' to %s", st.new_model, st.target_provider) - # providers. endpoints resolve in the credential block via - # resolve_user_provider(), which is gated on explicit_provider; custom:* - # slugs resolve at runtime directly. + # providers. endpoints resolve in the credential block via resolve_user_provider(), + # which is gated on explicit_provider; custom:* slugs resolve at runtime directly. if isinstance(st.user_providers, dict) and st.target_provider in st.user_providers: st.explicit_provider = st.target_provider return True def _route_from_model_input(st: _Switch) -> Optional[ModelSwitchResult]: - """PATH B (no ``--provider``): MoA preset / alias on the current provider - (a) -> alias fallback (b) or ``vendor:model`` conversion (c) -> aggregator - catalog search (d) -> configured-provider match (d.5) -> - detect_provider_for_model() as last resort (e).""" + """PATH B (no ``--provider``): MoA preset / alias on the current provider (a) -> alias + fallback (b) or ``vendor:model`` conversion (c) -> aggregator catalog search (d) -> + configured-provider match (d.5) -> detect_provider_for_model() as last resort (e).""" + from hermes_cli.models import detect_provider_for_model raw_input, current_provider = st.raw_input, st.current_provider try: from hermes_cli.config import load_config from hermes_cli.moa_config import exact_moa_preset_name, normalize_moa_config - moa_match = exact_moa_preset_name(normalize_moa_config(load_config().get("moa") or {}), raw_input) except Exception: moa_match = None # MoA config unreadable: fall through to plain alias resolution if moa_match: st.target_provider, st.new_model, st.resolved_alias = "moa", moa_match, "" - return _route_after_alias(st, resolved=True) - try: - alias_result = resolve_alias(raw_input, current_provider) - except AmbiguousAliasError as err: - return st.fail(_ambiguous_alias_message(err)) - return _route_after_alias(st, alias_result=alias_result) - - -def _route_after_alias(st: _Switch, alias_result=None, resolved: bool = False) -> Optional[ModelSwitchResult]: - """PATH B steps (b)-(e) once the MoA preset / alias lookup (a) is settled.""" - from hermes_cli.models import detect_provider_for_model - - raw_input, current_provider = st.raw_input, st.current_provider - if resolved: - pass - elif alias_result is not None: - st.target_provider, st.new_model, st.resolved_alias = alias_result - logger.debug("Alias '%s' resolved to %s on %s", st.resolved_alias, st.new_model, st.target_provider) - elif raw_input.strip().lower() in MODEL_ALIASES: - fail = _route_alias_fallback(st, raw_input.strip().lower()) - if fail is not None: - return fail else: - _convert_vendor_colon_slug(st) + try: + alias_result = resolve_alias(raw_input, current_provider) + except AmbiguousAliasError as err: + return st.fail(_ambiguous_alias_message(err)) + if alias_result is not None: + st.target_provider, st.new_model, st.resolved_alias = alias_result + logger.debug("Alias '%s' resolved to %s on %s", st.resolved_alias, st.new_model, st.target_provider) + elif raw_input.strip().lower() in MODEL_ALIASES: + fail = _route_alias_fallback(st, raw_input.strip().lower()) + if fail is not None: + return fail + else: + _convert_vendor_colon_slug(st) - # Step d: if the CURRENT provider's live catalog resolved the model, step e - # must not second-guess and switch providers — flat-namespace resellers - # (opencode-go/zen) return bare ids that coincidentally match native - # providers' static catalogs. + # Step d: if the CURRENT provider's live catalog resolved the model, step e must not + # second-guess and switch providers — flat-namespace resellers (opencode-go/zen) return bare + # ids that coincidentally match native providers' static catalogs. resolved_in_current_catalog = False if is_aggregator(st.target_provider) and not st.resolved_alias: catalog = list_provider_models(st.target_provider) @@ -1723,26 +1190,16 @@ def _route_after_alias(st: _Switch, alias_result=None, resolved: bool = False) - if matched is not None: st.new_model, resolved_in_current_catalog = matched, True - # Step d.5 — deliberately NOT gated on ``not is_custom``. - config_routed = False - if not st.resolved_alias and not resolved_in_current_catalog and st.target_provider == current_provider: - config_routed = _route_configured_provider(st) - if isinstance(config_routed, ModelSwitchResult): - return config_routed - - # Step e + # Steps d.5 / e only apply while the request is still unrouted on the current provider. + if st.resolved_alias or resolved_in_current_catalog or st.target_provider != current_provider: + return None + config_routed = _route_configured_provider(st) # d.5 — deliberately NOT gated on ``not is_custom`` + if isinstance(config_routed, ModelSwitchResult): + return config_routed is_custom = ( - current_provider in {"custom", "local"} - or current_provider.startswith("custom:") - or base_url_hostname(st.current_base_url or "") in ("localhost", "127.0.0.1") - ) - if ( - st.target_provider == current_provider - and not is_custom - and not st.resolved_alias - and not resolved_in_current_catalog - and not config_routed - ): + current_provider in {"custom", "local"} or current_provider.startswith("custom:") + or base_url_hostname(st.current_base_url or "") in ("localhost", "127.0.0.1")) + if not config_routed and not is_custom: # e detected = detect_provider_for_model(st.new_model, current_provider) if detected: st.target_provider, st.new_model = detected @@ -1763,11 +1220,9 @@ def _switch_provider_label(st: _Switch) -> str: def _creds_for_switched_provider(st: _Switch) -> Optional[ModelSwitchResult]: """Credentials when the provider changed or ``--provider`` was given. - ``providers.`` blocks carry their own base_url + transport + key - reference; resolve_runtime_provider() resolves by provider NAME and would - re-resolve a block named "openai" from scratch (or hop to an aggregator), - so use the pdef's endpoint directly. - """ + ``providers.`` blocks carry their own base_url + transport + key reference; + resolve_runtime_provider() resolves by provider NAME and would re-resolve a block named + "openai" from scratch (or hop to an aggregator), so use the pdef's endpoint directly.""" user_pdef = None explicit_norm = st.explicit_provider.strip().lower() if st.explicit_provider and st.user_providers: @@ -1776,21 +1231,13 @@ def _creds_for_switched_provider(st: _Switch) -> Optional[ModelSwitchResult]: or resolve_user_provider(st.target_provider, st.user_providers)) if user_pdef is not None and user_pdef.base_url: ucfg = st.user_providers.get(explicit_norm) or st.user_providers.get(st.target_provider) or {} - # Key reads go through the per-profile secret scope: a raw os.environ - # read would hand this profile another profile's key under the - # multiplexed gateway. + # Key reads go through the per-profile secret scope (multiplexed gateway). ukey = _entry_configured_key(ucfg, _scoped_key_env) st.validation_headers = _extra_headers_from_config(ucfg) try: - api_key, base_url, st.api_mode, st.validation_headers = _runtime_creds( - st.validation_headers, - requested=st.target_provider, - explicit_api_key=ukey or None, - explicit_base_url=user_pdef.base_url, - target_model=st.new_model, - ) - st.api_key = api_key or ukey - st.base_url = base_url or user_pdef.base_url + st.resolve_runtime( + requested=st.target_provider, explicit_api_key=ukey or None, explicit_base_url=user_pdef.base_url) + st.api_key, st.base_url = st.api_key or ukey, st.base_url or user_pdef.base_url except Exception: st.api_key, st.base_url, st.api_mode = ukey, user_pdef.base_url, "" elif st.target_provider == "custom" and st.current_base_url: @@ -1798,24 +1245,17 @@ def _creds_for_switched_provider(st: _Switch) -> Optional[ModelSwitchResult]: st.api_mode = determine_api_mode(st.target_provider, st.base_url) else: try: - st.api_key, st.base_url, st.api_mode, st.validation_headers = _runtime_creds( - st.validation_headers, requested=st.target_provider, target_model=st.new_model, - ) + st.resolve_runtime(requested=st.target_provider) except Exception as e: - return st.fail( - f"Could not resolve credentials for provider '{st.provider_label}': {e}", - target_provider=st.target_provider, provider_label=st.provider_label, - ) + return st.fail_on_target(f"Could not resolve credentials for provider '{st.provider_label}': {e}") return None def _creds_for_current_provider(st: _Switch) -> None: - """Credentials when staying on the current provider. Mid-session - ``/model `` on a local Ollama-compatible endpoint keeps the endpoint - in use; re-resolving bare ``custom`` from config can fall through to an - unrelated default provider.""" + """Credentials when staying on the current provider. Mid-session ``/model `` on a local + Ollama-compatible endpoint keeps the endpoint in use; re-resolving bare ``custom`` from config + can fall through to an unrelated default provider.""" from hermes_cli.models import _get_ollama_request_headers, _same_ollama_native_root - keep_current_ollama_endpoint = False ollama_headers: dict[str, str] = {} if st.current_provider == "custom" and st.current_base_url: @@ -1823,14 +1263,13 @@ def _creds_for_current_provider(st: _Switch) -> None: from hermes_cli.models import should_use_ollama_native_catalog ollama_headers = _get_ollama_request_headers() _, configured_ollama_base = _ollama_configured_base() - # Provider-level Ollama headers only belong to the configured - # native root; without one there is no safe origin for them. + # Provider-level Ollama headers only belong to the configured native root; without + # one there is no safe origin for them. if not configured_ollama_base or not _same_ollama_native_root(st.current_base_url, configured_ollama_base): ollama_headers = {} st.suppress_ollama_headers = True keep_current_ollama_endpoint = should_use_ollama_native_catalog( - st.current_provider, st.current_base_url, headers=ollama_headers, - ) + st.current_provider, st.current_base_url, headers=ollama_headers) except (ImportError, OSError, RuntimeError, TypeError, ValueError): keep_current_ollama_endpoint = False if keep_current_ollama_endpoint: @@ -1840,16 +1279,14 @@ def _creds_for_current_provider(st: _Switch) -> None: st.validation_headers = ollama_headers else: try: - st.api_key, st.base_url, st.api_mode, st.validation_headers = _runtime_creds( - st.validation_headers, requested=st.current_provider, target_model=st.new_model, - ) + st.resolve_runtime(requested=st.current_provider) except Exception: pass def _resolve_switch_credentials(st: _Switch) -> Optional[ModelSwitchResult]: - """COMMON PATH part 1: credentials, direct-alias endpoint override, and the - api_mode for the final (provider, base_url) before validation.""" + """COMMON PATH part 1: credentials, direct-alias endpoint override, and the api_mode for the + final (provider, base_url) before validation.""" st.provider_label = _switch_provider_label(st) st.api_key, st.base_url = st.current_api_key, st.current_base_url if st.provider_changed or st.explicit_provider: @@ -1864,31 +1301,22 @@ def _resolve_switch_credentials(st: _Switch) -> Optional[ModelSwitchResult]: _ensure_direct_aliases() da = DIRECT_ALIASES.get(st.resolved_alias) if da is not None and da.base_url: - st.api_key, st.base_url, headers_override, suppress = _apply_direct_alias_endpoint( - da, st.target_provider, st.new_model, st.api_key, st.base_url, - ) - st.api_mode = "" # clear so determine_api_mode re-detects from URL - if headers_override is not None: - st.validation_headers = headers_override - if suppress: - st.suppress_ollama_headers = True + _apply_direct_alias_endpoint(st, da) - # Fills an empty mode (alias cleared it) and overrides a STALE mode carried - # from previous session state when the host mandates one wire protocol - # (e.g. gpt-5.x on api.openai.com would otherwise 400 on tools+reasoning). + # Fills an empty mode (alias cleared it) and overrides a STALE mode carried from previous + # session state when the host mandates one wire protocol (e.g. gpt-5.x on api.openai.com + # would otherwise 400 on tools+reasoning). mandated_mode = host_mandated_api_mode(st.base_url) if mandated_mode is not None: st.api_mode = mandated_mode - elif not st.api_mode: - st.api_mode = determine_api_mode(st.target_provider, st.base_url) + st.api_mode = st.api_mode or determine_api_mode(st.target_provider, st.base_url) return None def _validate_switch(st: _Switch) -> Optional[ModelSwitchResult]: - """COMMON PATH part 2: normalize the model name for the target provider, - validate it, and accept config-declared models the remote catalog lacks.""" + """COMMON PATH part 2: normalize the model name for the target provider, validate it, and + accept config-declared models the remote catalog lacks.""" from hermes_cli.models import _get_ollama_request_headers, validate_requested_model - st.new_model = _resolve_named_custom_model_id(st.new_model, st.target_provider, st.custom_providers) st.new_model = normalize_model_for_provider(st.new_model, st.target_provider) @@ -1897,60 +1325,51 @@ def _validate_switch(st: _Switch) -> Optional[ModelSwitchResult]: else: headers = st.validation_headers or ( _extra_headers_from_config(st.user_providers.get(st.target_provider)) - if st.user_providers and st.target_provider in st.user_providers - else None - ) + if st.user_providers and st.target_provider in st.user_providers else None) try: validation = validate_requested_model( st.new_model, st.target_provider, api_key=st.api_key, base_url=st.base_url, - api_mode=st.api_mode or None, headers=headers, - ) + api_mode=st.api_mode or None, headers=headers) except Exception as e: validation = {"accepted": False, "persist": False, "recognized": False, "message": f"Could not validate `{st.new_model}`: {e}"} if not validation.get("accepted"): - if _config_declares_model(st.new_model, st.target_provider, st.base_url, st.user_providers, st.custom_providers): - validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")} - else: + if not _config_declares_model( + st.new_model, st.target_provider, st.base_url, st.user_providers, st.custom_providers): return st.fail( validation.get("message", "Invalid model"), - new_model=st.new_model, target_provider=st.target_provider, provider_label=st.provider_label, - ) - if validation.get("corrected_model"): - st.new_model = validation["corrected_model"] + new_model=st.new_model, target_provider=st.target_provider, provider_label=st.provider_label) + validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")} + st.new_model = validation.get("corrected_model") or st.new_model st.validation = validation return None def _copilot_api_mode(provider: str, model: str, api_key: str) -> str: from hermes_cli.models import copilot_model_api_mode - return copilot_model_api_mode(model, api_key=api_key) def _opencode_api_mode(provider: str, model: str, api_key: str) -> str: from hermes_cli.models import opencode_model_api_mode - return opencode_model_api_mode(provider, model) def _nous_api_mode(provider: str, model: str, api_key: str) -> str: - # Portal serves anthropic/* on /v1/messages and everything else on - # /chat/completions; re-derive from the FINAL model so alias clears / - # empty fallbacks cannot leave Claude on the OpenAI wire. + # Portal serves anthropic/* on /v1/messages and everything else on /chat/completions; + # re-derive from the FINAL model so alias clears / empty fallbacks cannot leave Claude on the + # OpenAI wire. from hermes_cli.providers import nous_api_mode - return nous_api_mode(model) -# Per-provider api_mode overrides applied after validation, keyed on the final -# target provider (the key sets are disjoint, so exactly one — or none — fires). +# Per-provider api_mode overrides applied after validation, keyed on the final target provider +# (the key sets are disjoint, so exactly one — or none — fires). _PROVIDER_API_MODE_OVERRIDES: dict[str, Any] = { **dict.fromkeys(("copilot", "github-copilot"), _copilot_api_mode), **dict.fromkeys(("opencode-zen", "opencode-go", "opencode"), _opencode_api_mode), - **dict.fromkeys(("nous", "nous-portal", "nousresearch"), _nous_api_mode), -} + **dict.fromkeys(("nous", "nous-portal", "nousresearch"), _nous_api_mode)} def _build_switch_result(st: _Switch) -> ModelSwitchResult: @@ -1961,10 +1380,9 @@ def _build_switch_result(st: _Switch) -> ModelSwitchResult: if not st.api_mode: st.api_mode = determine_api_mode(st.target_provider, st.base_url, model=st.new_model) - # OpenCode base URLs end with /v1 for OpenAI-compatible models but the - # Anthropic SDK prepends its own /v1/messages: strip for anthropic_messages, - # re-append for chat_completions/codex_responses (mirrors - # resolve_runtime_provider; either direction alone breaks the other family). + # OpenCode base URLs end with /v1 for OpenAI-compatible models but the Anthropic SDK prepends + # its own /v1/messages: strip for anthropic_messages, re-append for + # chat_completions/codex_responses (mirrors resolve_runtime_provider). from hermes_cli.models import normalize_opencode_base_url, opencode_provider_family if opencode_provider_family(st.target_provider) is not None and isinstance(st.base_url, str): st.base_url = normalize_opencode_base_url(st.target_provider, st.api_mode, st.base_url) @@ -1972,18 +1390,14 @@ def _build_switch_result(st: _Switch) -> ModelSwitchResult: capabilities = get_model_capabilities(st.target_provider, st.new_model, allow_network=True) from agent.native_compaction import resolve_native_compaction_capabilities runtime_capabilities = resolve_native_compaction_capabilities( - model=st.new_model, - base_url=st.base_url, - provider=st.target_provider, - is_codex_backend=st.target_provider.strip().lower() == "openai-codex", - ) + model=st.new_model, base_url=st.base_url, provider=st.target_provider, + is_codex_backend=st.target_provider.strip().lower() == "openai-codex") model_info = get_model_info(st.target_provider, st.new_model, allow_network=True) warnings = [w for w in (st.validation.get("message"), _check_hermes_model_warning(st.new_model)) if w] - # Carry the switched provider's request_overrides (custom_providers - # ``extra_body`` such as chat_template_kwargs) so the gateway applies them - # like the default-provider path does. + # Carry the switched provider's request_overrides (custom_providers ``extra_body`` such as + # chat_template_kwargs) so the gateway applies them like the default-provider path does. request_overrides = None try: from hermes_cli.runtime_provider import _get_named_custom_provider, _custom_provider_request_overrides @@ -1991,56 +1405,31 @@ def _build_switch_result(st: _Switch) -> ModelSwitchResult: request_overrides = _custom_provider_request_overrides(cp_for_ro) or None if cp_for_ro else None except Exception: request_overrides = None - return ModelSwitchResult( - success=True, - new_model=st.new_model, - target_provider=st.target_provider, - provider_changed=st.provider_changed, - api_key=st.api_key, - base_url=st.base_url, - api_mode=st.api_mode, - request_overrides=dict(request_overrides or {}), - warning_message=" | ".join(warnings) if warnings else "", - provider_label=st.provider_label, - resolved_via_alias=st.resolved_alias, - capabilities=capabilities, - runtime_capabilities={k: v for k, v in runtime_capabilities.items() - if isinstance(k, str) and isinstance(v, bool)}, - model_info=model_info, - is_global=st.is_global, - ) + success=True, new_model=st.new_model, target_provider=st.target_provider, + provider_changed=st.provider_changed, api_key=st.api_key, base_url=st.base_url, api_mode=st.api_mode, + request_overrides=dict(request_overrides or {}), warning_message=" | ".join(warnings) if warnings else "", + provider_label=st.provider_label, resolved_via_alias=st.resolved_alias, capabilities=capabilities, + runtime_capabilities={ + k: v for k, v in runtime_capabilities.items() if isinstance(k, str) and isinstance(v, bool)}, + model_info=model_info, is_global=st.is_global) def switch_model( - raw_input: str, - current_provider: str, - current_model: str, - current_base_url: str = "", - current_api_key: str = "", - is_global: bool = False, - explicit_provider: str = "", - user_providers: dict = None, - custom_providers: list | None = None, -) -> ModelSwitchResult: + raw_input: str, current_provider: str, current_model: str, current_base_url: str = "", + current_api_key: str = "", is_global: bool = False, explicit_provider: str = "", + user_providers: dict = None, custom_providers: list | None = None) -> ModelSwitchResult: """Core model-switching pipeline shared between CLI and gateway. - Resolution chain: route the request (:func:`_route_explicit_provider` when - ``--provider`` was given, else :func:`_route_from_model_input`) -> - :func:`_resolve_switch_credentials` -> :func:`_validate_switch` -> - :func:`_build_switch_result`. Each step returns a failure - :class:`ModelSwitchResult` to stop the chain, or ``None`` to continue. - - ``explicit_provider`` comes from the --provider flag (empty = none); - ``user_providers`` / ``custom_providers`` are the ``providers:`` dict and - ``custom_providers:`` list from config.yaml. - """ + Route (PATH A with ``--provider``, else PATH B) -> credentials -> validation -> result; each + step returns a failure :class:`ModelSwitchResult` to stop the chain, or ``None`` to continue. + ``user_providers`` / ``custom_providers`` are the config.yaml ``providers:`` dict and + ``custom_providers:`` list.""" st = _Switch( raw_input=raw_input, current_provider=current_provider, current_model=current_model, current_base_url=current_base_url, current_api_key=current_api_key, is_global=is_global, explicit_provider=explicit_provider, user_providers=user_providers, custom_providers=custom_providers, - new_model=raw_input.strip(), target_provider=current_provider, - ) + new_model=raw_input.strip(), target_provider=current_provider) route = _route_explicit_provider if explicit_provider else _route_from_model_input for step in (route, _resolve_switch_credentials, _validate_switch): fail = step(st) @@ -2053,27 +1442,19 @@ def _extra_headers_from_config(entry: Any) -> dict[str, str]: if not isinstance(entry, dict): return {} from hermes_cli.config import normalize_extra_headers - return normalize_extra_headers(entry.get("extra_headers")) def _scoped_key_env(name: str) -> str: """Read a provider key env var through the per-profile secret scope. - The multiplexed gateway installs a secret scope per turn; a raw - ``os.environ`` read hands the current profile whatever key happens to be - in the process environment — another profile's, in a multiplexer. That is - the class swept in 854007d1c for the fallback/aux key reads; the picker's - ``key_env`` reads were not covered. - - Identical to ``os.getenv`` when multiplexing is off. A fail-closed - ``UnscopedSecretError`` (multiplexing on, no scope installed) means "no - credential visible for this profile here", which is exactly how the picker - already treats a missing key. - """ + The multiplexed gateway installs a secret scope per turn; a raw ``os.environ`` read hands the + current profile whatever key happens to be in the process environment — another profile's. + Identical to ``os.getenv`` when multiplexing is off. A fail-closed ``UnscopedSecretError`` + (multiplexing on, no scope installed) means "no credential visible for this profile here", + which is exactly how the picker already treats a missing key.""" try: from agent.secret_scope import get_secret - return (get_secret(name, "") or "").strip() if name else "" except Exception: return "" diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index 9353ba10d5..a80b6b6392 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -1,8 +1,8 @@ -"""Picker provider listing: credential discovery, curated/live model lists, row builders for list_authenticated_providers / list_picker_providers, and the parallel cache prefetch. +"""Picker provider listing: credential discovery, curated/live model lists, row builders for +list_authenticated_providers / list_picker_providers, and the parallel cache prefetch. Split out of ``hermes_cli/model_switch.py``; every moved name is re-imported there so -``hermes_cli.model_switch.`` keeps resolving (and monkeypatching) as before. -""" +``hermes_cli.model_switch.`` keeps resolving (and monkeypatching) as before.""" from __future__ import annotations @@ -13,46 +13,28 @@ import time import threading as _threading from dataclasses import dataclass, field from typing import Any, List, Optional -from hermes_cli.providers import ( - custom_provider_aliases, - custom_provider_slug, - get_label, -) +from hermes_cli.providers import custom_provider_aliases, custom_provider_slug, get_label from utils import base_url_host_matches # Log-record parity with the origin module. logger = logging.getLogger("hermes_cli.model_switch") - -# Providers whose picker model list should NOT be capped by max_models. -# OpenCode Zen / Go are aggregators whose full catalogs (70+ models each) must -# be visible so users can pick any model they have access to. +# Aggregators whose full catalogs (70+ models) must stay visible: never capped by max_models. _UNCAPPED_PICKER_PROVIDERS: frozenset[str] = frozenset({"opencode-zen", "opencode-go"}) def _save_discovered_models_to_config( - api_url: str, - model_ids: list[str], - *, - api_mode: Optional[str] = None, - headers: Optional[dict[str, str]] = None, -) -> None: - """Persist discovered models into ``custom_providers`` in config.yaml. + api_url: str, model_ids: list[str], *, api_mode: Optional[str] = None, + headers: Optional[dict[str, str]] = None) -> None: + """Persist a successful ``/v1/models`` probe into the matching ``custom_providers`` entry. - Called after a successful ``/v1/models`` probe so that the next read - with ``discover_models: false`` uses the cached list instead of a stale - or minimal manually-configured subset. - - Matches entries by ``base_url`` (trailing-slash-normalised). A failed - config write is swallowed — the picker still shows the live models for - this session. - """ + Matches by base_url (slash-normalised), api_mode and headers. A failed config write is + swallowed — the picker still shows the live models for this session.""" from hermes_cli.model_switch import _extra_headers_from_config if not api_url or not model_ids: return try: from hermes_cli.config import load_config, save_config - cfg = load_config() providers = cfg.get("custom_providers") or [] if not isinstance(providers, list): @@ -64,49 +46,11 @@ def _save_discovered_models_to_config( if not isinstance(entry, dict): continue entry_url = (entry.get("base_url", "") or entry.get("url", "")).strip() - if entry_url.rstrip("/").lower() != norm_url: + if entry_url.rstrip("/").lower() != norm_url or _entry_api_mode(entry) != api_mode: continue - entry_mode = str( - entry.get("api_mode") or entry.get("transport") or "" - ).strip().lower() or None - if entry_mode != api_mode: + if headers is not None and _extra_headers_from_config(entry) != headers: continue - if headers is not None: - entry_headers = _extra_headers_from_config(entry) - if entry_headers != headers: - continue - existing = entry.get("models") - legacy_discovered = ( - isinstance(existing, dict) - and existing.get("__discovered_model_catalog__") is True - ) - entry_discovered = ( - entry.get("models_discovered") is True or legacy_discovered - ) - # Preserve per-model metadata: when ``models`` is a mapping - # (e.g. ``{"model-a": {"context_length": 8192}}``) or a list of - # dicts (e.g. ``[{"id": "model-a", "context_length": 8192}]``), - # the user has curated metadata per model — do not replace it. - # A mapping Hermes itself discovered (``models_discovered: true`` - # or the legacy in-mapping sentinel) is ours to refresh. - if isinstance(existing, dict) and not entry_discovered: - continue - if isinstance(existing, list) and any( - isinstance(m, dict) for m in existing - ): - continue - # Only update when models are stale — avoids unnecessary - # config writes on every picker open. A legacy-shape entry - # (sentinel inside ``models``) is always rewritten so the next - # save migrates it to the clean entry-level flag. - if isinstance(existing, list) and existing == model_ids: - continue - if ( - isinstance(existing, dict) - and entry_discovered - and not legacy_discovered - and list(existing) == model_ids - ): + if not _discovered_catalog_stale(entry, model_ids): continue entry["models"] = {model_id: {} for model_id in model_ids} entry["models_discovered"] = True @@ -119,15 +63,21 @@ def _save_discovered_models_to_config( pass -_MODEL_DISCOVERY_ERRORS = ( - ImportError, - OSError, - RuntimeError, - TimeoutError, - TypeError, - ValueError, - http.client.HTTPException, -) +def _discovered_catalog_stale(entry: dict, model_ids: list[str]) -> bool: + """Whether a live probe may overwrite ``entry["models"]``. + + A ``models`` mapping or list of dicts is user-curated per-model metadata — never replaced. + A mapping Hermes itself discovered (entry flag or legacy in-mapping sentinel) is ours to + refresh, but only when stale; a legacy-shape entry is always rewritten so the save migrates + it to the clean entry-level flag.""" + existing = entry.get("models") + legacy_discovered = isinstance(existing, dict) and existing.get("__discovered_model_catalog__") is True + entry_discovered = entry.get("models_discovered") is True or legacy_discovered + if isinstance(existing, dict): + return entry_discovered and (legacy_discovered or list(existing) != model_ids) + if isinstance(existing, list): + return not any(isinstance(m, dict) for m in existing) and existing != model_ids + return True class _NativePickerModelList(list[str]): @@ -135,90 +85,59 @@ class _NativePickerModelList(list[str]): def _fetch_picker_live_models( - api_key: str, - api_url: str, - native_catalog_provider: str, - preserve_native_models: bool, - headers: dict[str, str] | None = None, - timeout: float = 5.0, - api_mode: str | None = None, -) -> list[str] | None: + api_key: str, api_url: str, native_catalog_provider: str, preserve_native_models: bool, + headers: dict[str, str] | None = None, timeout: float = 5.0, + api_mode: str | None = None) -> list[str] | None: """Fetch picker models with native Ollama and cached generic discovery.""" from hermes_cli.models import ( - _get_ollama_native_headers, - _normalize_openai_base_url, - cached_fetch_api_models, - fetch_ollama_local_models, - should_use_ollama_native_catalog, - ) + _get_ollama_native_headers, _normalize_openai_base_url, cached_fetch_api_models, + fetch_ollama_local_models, should_use_ollama_native_catalog) candidate_headers = _get_ollama_native_headers(api_url, api_key=api_key) - caller_has_authorization = any( - key.lower() == "authorization" for key in (headers or {}) - ) - if caller_has_authorization: + + def _drop(pred) -> None: for key in tuple(candidate_headers): - if key.lower() == "authorization": + if pred(key.lower()): del candidate_headers[key] + + caller_has_authorization = any(key.lower() == "authorization" for key in (headers or {})) if headers: - for key in tuple(candidate_headers): - if any(key.lower() == existing.lower() for existing in headers): - del candidate_headers[key] + lowered = {existing.lower() for existing in headers} + _drop(lambda k: k in lowered) candidate_headers.update(headers) if api_key and not caller_has_authorization: - for key in tuple(candidate_headers): - if key.lower() == "authorization": - del candidate_headers[key] + _drop(lambda k: k == "authorization") candidate_headers["Authorization"] = f"Bearer {api_key}" use_native = should_use_ollama_native_catalog( - native_catalog_provider, api_url, headers=candidate_headers or None - ) + native_catalog_provider, api_url, headers=candidate_headers or None) resolved_headers = candidate_headers or None if use_native else headers if use_native: if preserve_native_models: return None - native_models = fetch_ollama_local_models( - api_url, timeout=timeout, headers=resolved_headers - ) + native_models = fetch_ollama_local_models(api_url, timeout=timeout, headers=resolved_headers) if native_models is not None: return _NativePickerModelList(native_models) - # A failed native probe is not authoritative: retry the cached generic - # OpenAI-compatible catalog before reporting no models. - return cached_fetch_api_models( - api_key, - _normalize_openai_base_url(api_url), - timeout=timeout, - headers=resolved_headers, - api_mode=api_mode, - ) + # A failed native probe is not authoritative: retry the cached generic catalog. + api_url = _normalize_openai_base_url(api_url) generic_models = cached_fetch_api_models( - api_key, - api_url, - timeout=timeout, - headers=resolved_headers, - api_mode=api_mode, - ) - return generic_models if generic_models else None + api_key, api_url, timeout=timeout, headers=resolved_headers, api_mode=api_mode) + return generic_models if generic_models or use_native else None -# Process-level guard so the picker prewarm thread is spawned at most once per -# process — mirrors run_agent's _openrouter_prewarm_done. Without a guard a +# Process-level guard: the prewarm thread is spawned at most once per process, otherwise a # long-lived process (or repeated triggers) would leak one OS thread per call. _picker_prewarm_done = _threading.Event() def _credential_pool_is_usable(provider: str, *, raw_pool_present: bool = False) -> bool: - """Return whether *provider* has a credential that can be selected now. + """Whether *provider* has a credential that can be selected now. - ``auth.json`` historically allowed opaque token-style pool values that do - not deserialize into ``PooledCredential`` entries. Preserve visibility for - those legacy values, but when a real pool exists its availability state is - authoritative: an all-exhausted/dead pool is not authenticated. - """ + Legacy opaque ``auth.json`` pool values that do not deserialize into ``PooledCredential`` + stay visible (``raw_pool_present``); a real pool's availability is authoritative — an + all-exhausted/dead pool is not authenticated.""" try: from agent.credential_pool import load_pool - pool = load_pool(provider) if pool.has_credentials(): return pool.has_available() @@ -228,24 +147,11 @@ def _credential_pool_is_usable(provider: str, *, raw_pool_present: bool = False) def prewarm_picker_cache_async() -> Optional["_threading.Thread"]: - """Warm the provider-models disk cache in a background daemon thread. + """Warm ``provider_models_cache.json`` in a daemon thread by running the picker path once. - The no-args ``/model`` picker calls ``list_authenticated_providers()``, - which fetches each authenticated provider's live ``/v1/models`` list on a - cold/stale cache. Those fetches are independent HTTP round-trips but run - serially, so the first ``/model`` open in a session (or any open after the - 1h cache TTL expires) blocks ~1-2s on the user's critical path. - - This pre-warms that exact path off-thread during idle session time: it - runs ``list_authenticated_providers()`` once, which populates - ``provider_models_cache.json`` for every authed provider. By the time the - user types ``/model``, the picker hits the warm disk cache and renders in - ~100ms. - - Fire-and-forget. Process-level Event guard ensures it runs at most once. - Fully exception-isolated — a slow or offline provider can never affect the - session. Returns the spawned thread (for tests) or None if already warmed. - """ + The first ``/model`` open (or the first after the 1h TTL) otherwise blocks ~1-2s on serial + live ``/v1/models`` fetches. Fire-and-forget, at most once per process, fully + exception-isolated. Returns the thread (for tests) or None if already warmed.""" from hermes_cli.model_switch import list_authenticated_providers if _picker_prewarm_done.is_set(): return None @@ -254,21 +160,14 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]: def _warm() -> None: try: from hermes_cli.inventory import load_picker_context - ctx = load_picker_context() - # Calling this is what populates cached_provider_model_ids() -> - # provider_models_cache.json for each authed provider. We discard - # the result; the side effect (warm disk cache) is the point. + # The result is discarded; the warm disk cache is the point. list_authenticated_providers( - current_provider=ctx.current_provider, - current_base_url=ctx.current_base_url, - current_model=ctx.current_model, - user_providers=ctx.user_providers, + current_provider=ctx.current_provider, current_base_url=ctx.current_base_url, + current_model=ctx.current_model, user_providers=ctx.user_providers, custom_providers=ctx.custom_providers, - excluded_providers=ctx.excluded_providers or [], - ) + excluded_providers=ctx.excluded_providers or []) except Exception: - # Best-effort warmup — never surface errors into the session. logger.debug("picker cache prewarm failed", exc_info=True) t = _threading.Thread(target=_warm, daemon=True, name="picker-cache-prewarm") @@ -276,42 +175,18 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]: return t -_PARALLEL_PREFETCH_WORKERS = 8 - - def _prefetch_provider_models_parallel(provider_slugs: list[str]) -> None: - """Fetch model catalogs for multiple providers in parallel. + """Fetch stale/missing provider catalogs in parallel before the serial picker loop. - Run before the picker build loop: when the 1h disk cache lapses (or on a - cold first open) ``list_authenticated_providers`` would otherwise call - ``cached_provider_model_ids`` serially, blocking 1-8s per provider on a live - /v1/models round-trip (15-30s+ with 10+ providers); after the prefetch the - loop hits warm entries and total wait is the slowest single provider. - - Only providers whose cache entry is stale or missing are fetched; fresh - entries are skipped to avoid unnecessary network calls. Each worker uses - :func:`update_provider_cache_entry` (thread-safe) to persist its result, - so concurrent writes to ``provider_models_cache.json`` don't clobber each - other. - - :param provider_slugs: Hermes provider IDs to prefetch (e.g. ``["openrouter", - "anthropic", "deepseek"]``). Unknown providers are silently skipped. - """ - from hermes_cli.models import cached_provider_model_ids - - # Quick-stale-check: skip providers whose cache is already fresh so we - # don't waste network calls on a warm cache. We check staleness the same - # way cached_provider_model_ids does internally: load the cache, compare - # age to TTL. This is a read-only check — if the cache file changes - # between this check and the actual fetch, cached_provider_model_ids will - # still do the right thing (it re-reads the cache internally). + On a cold cache the serial loop would block 1-8s per provider; after the prefetch the wait is + the slowest single provider. Each worker re-persists through the thread-safe + ``update_provider_cache_entry`` so concurrent writes cannot clobber each other.""" from hermes_cli.models import ( - _load_provider_models_cache, - _credential_fingerprint, - _PROVIDER_MODELS_CACHE_TTL, - normalize_provider, - ) + _PROVIDER_MODELS_CACHE_TTL, _credential_fingerprint, _load_provider_models_cache, + cached_provider_model_ids, normalize_provider) + # Read-only staleness check mirroring cached_provider_model_ids (which re-reads the cache + # itself, so a concurrent change between check and fetch is harmless). now = time.time() stale_slugs: list[str] = [] cache = _load_provider_models_cache() @@ -320,29 +195,22 @@ def _prefetch_provider_models_parallel(provider_slugs: list[str]) -> None: if not normalized: continue entry = cache.get(normalized) - fp = _credential_fingerprint(normalized) if ( - isinstance(entry, dict) - and entry.get("fp") == fp - and isinstance(entry.get("models"), list) - and entry["models"] - ): - age = now - float(entry.get("at", 0)) - if age < _PROVIDER_MODELS_CACHE_TTL: - continue # fresh, skip + isinstance(entry, dict) and entry.get("fp") == _credential_fingerprint(normalized) + and isinstance(entry.get("models"), list) and entry["models"] + and now - float(entry.get("at", 0)) < _PROVIDER_MODELS_CACHE_TTL): + continue stale_slugs.append(normalized) if not stale_slugs: return import concurrent.futures - def _fetch_one(slug: str) -> None: try: models = cached_provider_model_ids(slug, force_refresh=True) - # cached_provider_model_ids already persists the result, but in a - # non-locked read-modify-write. Re-persist via the thread-safe - # path to guarantee no lost writes under concurrency. + # cached_provider_model_ids persists via a non-locked read-modify-write; re-persist + # through the locked path so no write is lost under concurrency. if models: from hermes_cli.models import update_provider_cache_entry update_provider_cache_entry(slug, models) @@ -350,40 +218,44 @@ def _prefetch_provider_models_parallel(provider_slugs: list[str]) -> None: pass # best-effort; picker falls back to curated list with concurrent.futures.ThreadPoolExecutor( - max_workers=min(_PARALLEL_PREFETCH_WORKERS, len(stale_slugs)), - thread_name_prefix="model-cache-prefetch", + max_workers=min(8, len(stale_slugs)), thread_name_prefix="model-cache-prefetch", ) as executor: list(executor.map(_fetch_one, stale_slugs)) +def _any_env(env_vars, read_env=os.environ.get) -> bool: + return any(read_env(ev) for ev in env_vars) + + +def _skip(seen: set, excluded: set, *keys: str) -> bool: + """True when any of *keys* (lowercased) is already emitted or excluded.""" + lowered = [k.lower() for k in keys] + return any(k in seen for k in lowered) or any(k in excluded for k in lowered) + + def _iter_builtin_candidates(models_dev_data: dict, excluded: set, seen: set): """Yield ``(hermes_id, mdev_id, pconfig, env_vars)`` for section-1 rows. - Skips vendor names that are aliases routing through an aggregator (bare - "openai" -> "openrouter": emitting them would silently switch a user onto an - endpoint they may have no key for), hermes_ids that are aliases of another - canonical profile ("kimi" -> "kimi-coding"), non-api_key auth types (section - 2 handles them with auth-store checks), and providers Hermes cannot route. - PROVIDER_REGISTRY env var names win over models.dev's (which can be wrong). - """ + Skips vendor names that alias through an aggregator (bare "openai" -> "openrouter" would + silently switch a user onto an endpoint they may have no key for), aliases of another canonical + profile ("kimi" -> "kimi-coding"), non-api_key auth types (section 2 handles them) and + unroutable providers. PROVIDER_REGISTRY env var names win over models.dev's.""" from agent.models_dev import PROVIDER_TO_MODELS_DEV from hermes_cli.auth import PROVIDER_REGISTRY, is_runtime_provider_routable from hermes_cli.models import _AGGREGATOR_PROVIDERS from hermes_cli.providers import ALIASES - for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items(): alias_target = ALIASES.get(hermes_id) if alias_target and alias_target != hermes_id and alias_target in _AGGREGATOR_PROVIDERS: continue - canonical = hermes_id try: from providers import get_provider_profile prof = get_provider_profile(hermes_id) - if prof is not None: - canonical = prof.name + if prof is not None and prof.name != hermes_id: + continue except Exception: pass - if canonical != hermes_id or hermes_id.lower() in seen: + if hermes_id.lower() in seen: continue if hermes_id.lower() in excluded or mdev_id.lower() in excluded: continue @@ -391,17 +263,11 @@ def _iter_builtin_candidates(models_dev_data: dict, excluded: set, seen: set): if not isinstance(pdata, dict): continue pconfig = PROVIDER_REGISTRY.get(hermes_id) - if pconfig and pconfig.auth_type != "api_key": + if (pconfig and pconfig.auth_type != "api_key") or not is_runtime_provider_routable(hermes_id): continue - if not is_runtime_provider_routable(hermes_id): - continue - if pconfig and pconfig.api_key_env_vars: - env_vars = list(pconfig.api_key_env_vars) - else: - env_vars = pdata.get("env", []) - if not isinstance(env_vars, list): - continue - yield hermes_id, mdev_id, pconfig, env_vars + env_vars = list(pconfig.api_key_env_vars) if pconfig and pconfig.api_key_env_vars else pdata.get("env", []) + if isinstance(env_vars, list): + yield hermes_id, mdev_id, pconfig, env_vars def _auth_store_has_provider(*keys: str) -> bool: @@ -441,12 +307,9 @@ def _pool_usable(slug: str) -> bool: def _overlay_has_env_creds(pid: str, hermes_slug: str, overlay, read_env) -> bool: """Section-2 env/SDK credential check shared by the picker and the prefetch scan. - Vertex authenticates via OAuth2 (service-account JSON / ADC), not an API - key, so it gets its own probe; otherwise the provider is hidden from the - picker even when fully configured. - """ + Vertex authenticates via OAuth2 (service-account JSON / ADC), not an API key, so it gets its + own probe; otherwise the provider is hidden from the picker even when fully configured.""" from hermes_cli.auth import PROVIDER_REGISTRY - has_creds = False if overlay.auth_type == "vertex": try: @@ -455,11 +318,11 @@ def _overlay_has_env_creds(pid: str, hermes_slug: str, overlay, read_env) -> boo except Exception as exc: logger.debug("Vertex credential check failed: %s", exc) elif overlay.extra_env_vars: - has_creds = any(read_env(ev) for ev in overlay.extra_env_vars) + has_creds = _any_env(overlay.extra_env_vars, read_env) if not has_creds and overlay.auth_type == "api_key": for key in (pid, hermes_slug): pcfg = PROVIDER_REGISTRY.get(key) - if pcfg and pcfg.api_key_env_vars and any(read_env(ev) for ev in pcfg.api_key_env_vars): + if pcfg and pcfg.api_key_env_vars and _any_env(pcfg.api_key_env_vars, read_env): return True return has_creds @@ -467,31 +330,20 @@ def _overlay_has_env_creds(pid: str, hermes_slug: str, overlay, read_env) -> boo def _has_fast_aws_sdk_signal() -> bool: """True when explicit AWS auth config is present in the environment. - Deliberately avoids botocore's full credential chain: picker discovery runs - for non-Bedrock providers too, and botocore may probe EC2 IMDS - (169.254.169.254) on local machines before returning no credentials. - """ - env = os.environ - if env.get("AWS_BEARER_TOKEN_BEDROCK", "").strip(): - return True - if env.get("AWS_ACCESS_KEY_ID", "").strip() and env.get("AWS_SECRET_ACCESS_KEY", "").strip(): - return True - return any( - env.get(name, "").strip() - for name in ( - "AWS_PROFILE", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", - "AWS_CONTAINER_CREDENTIALS_FULL_URI", - "AWS_WEB_IDENTITY_TOKEN_FILE", - ) - ) + Deliberately avoids botocore's full credential chain: picker discovery runs for non-Bedrock + providers too, and botocore may probe EC2 IMDS (169.254.169.254) before giving up.""" + def _set(name: str) -> bool: + return bool(os.environ.get(name, "").strip()) + return ( + _set("AWS_BEARER_TOKEN_BEDROCK") + or (_set("AWS_ACCESS_KEY_ID") and _set("AWS_SECRET_ACCESS_KEY")) + or any(_set(name) for name in ( + "AWS_PROFILE", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_WEB_IDENTITY_TOKEN_FILE"))) def _has_aws_sdk_creds_for_listing(slug: str, current_provider: str) -> bool: - """Credential check for AWS SDK providers in non-runtime discovery. - - The full boto3 chain is only consulted for the *current* provider. - """ + """AWS SDK credential check; the full boto3 chain is only consulted for the *current* provider.""" if _has_fast_aws_sdk_signal(): return True if str(slug or "").strip().lower() != str(current_provider or "").strip().lower(): @@ -508,59 +360,47 @@ def _is_aws_sdk(pconfig) -> bool: def _live_or_curated_ids(slug: str, curated: dict, *fallback_keys: str, merge_models_dev: bool = True) -> list: - """Unified pathway: ``cached_provider_model_ids`` so the /model picker sees the - SAME list ``hermes model`` builds (disk-cached), falling back to the curated - static list (merged with models.dev for preferred providers) when live is empty. - """ + """``cached_provider_model_ids`` (the SAME disk-cached list ``hermes model`` builds), falling + back to the curated list (merged with models.dev for preferred providers) when live is empty.""" from hermes_cli.models import _MODELS_DEV_PREFERRED, _merge_with_models_dev, cached_provider_model_ids - model_ids = cached_provider_model_ids(slug) if not model_ids: - for key in fallback_keys or (slug,): - model_ids = curated.get(key, []) - if model_ids: - break + model_ids = _first_curated(curated, fallback_keys or (slug,)) if merge_models_dev and slug in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(slug, model_ids) return model_ids -def _aws_live_or_curated_ids(slug: str, curated: dict, *fallback_keys: str) -> list: - """Bedrock: live discovery reflects the active region (eu.*, ap.*) rather than - the static us.* list; any failure falls back to the curated list.""" - from hermes_cli.models import cached_provider_model_ids +def _first_curated(curated: dict, keys) -> list: + """First non-empty curated list under *keys*; the last key's (possibly empty) value otherwise.""" + model_ids: list = [] + for key in keys: + model_ids = curated.get(key, []) + if model_ids: + break + return model_ids - fallback_keys = fallback_keys or (slug,) + +def _aws_live_or_curated_ids(slug: str, curated: dict, *fallback_keys: str) -> list: + """Bedrock: live discovery reflects the active region (eu.*, ap.*) rather than the static + us.* list; any failure falls back to the curated list.""" try: - ids = cached_provider_model_ids(slug) - if ids: - return ids + return _live_or_curated_ids(slug, curated, *fallback_keys, merge_models_dev=False) or [] except Exception: - pass - for key in fallback_keys: - ids = curated.get(key, []) - if ids: - return ids - return [] + return _first_curated(curated, fallback_keys or (slug,)) or [] def _nous_picker_model_ids(curated: dict, force_fresh_nous_tier: bool) -> list: - """Nous serves a huge alphabetical live catalog; the picker shows ONLY the - curated agentic list, augmented with the Portal's free/paid recommendations - (so newly launched models surface without a CLI release) and narrowed by org - policy. Mirrors ``_model_flow_nous`` so GUI pickers match the CLI. A failed - recommendation fetch still yields a policy-filtered curated list. - """ + """Nous serves a huge live catalog; the picker shows ONLY the curated agentic list, augmented + with the Portal's free/paid recommendations (new models surface without a CLI release) and + narrowed by org policy. Mirrors ``_model_flow_nous`` so GUI pickers match the CLI. A failed + recommendation fetch still yields a policy-filtered curated list.""" model_ids = curated.get("nous", []) try: from hermes_cli.models import ( - get_pricing_for_provider, - check_nous_free_tier, - union_with_portal_free_recommendations, - union_with_portal_paid_recommendations, - ) + get_pricing_for_provider, check_nous_free_tier, union_with_portal_free_recommendations, + union_with_portal_paid_recommendations) from hermes_cli.auth import get_provider_auth_state - pricing = get_pricing_for_provider("nous") or {} try: portal = (get_provider_auth_state("nous") or {}).get("portal_base_url", "") or "" @@ -574,7 +414,6 @@ def _nous_picker_model_ids(curated: dict, force_fresh_nous_tier: bool) -> list: pass try: from hermes_cli.models import nous_policy_allowed_ids, restrict_to_nous_policy - model_ids = restrict_to_nous_policy(model_ids, nous_policy_allowed_ids(), rescue_empty=True) except Exception: pass @@ -588,24 +427,43 @@ def _cap_models(model_ids: list, max_models: int | None, slug: str = "") -> list return model_ids[:max_models] +def _absorb_entry_models(grp: dict, entry: dict, active_model: Any) -> None: + """Fold one config entry's models into its group: the active selection first, then the declared + ``models:`` ids. The active selection alone never suppresses discovery, and a dict-shaped + ``models:`` is metadata rather than an allowlist (see ``_models_config_is_allowlist``), so only + list/string shapes pin the row.""" + from hermes_cli.model_switch import _declared_model_ids, _entry_models_discovered, _models_config_is_allowlist + _extend_unique(grp["models"], [active_model]) + models_field = entry.get("models") + if _models_config_is_allowlist(models_field, _entry_models_discovered(entry)): + grp["has_explicit_models"] = True + _extend_unique(grp["models"], _declared_model_ids(models_field)) + + +def _extend_unique(target: list, items) -> None: + """Append each truthy item of *items* not already in *target* (order preserved).""" + for item in items: + if item and item not in target: + target.append(item) + + def _norm_url(url: Any) -> str: return str(url or "").strip().rstrip("/").lower() def _entry_base_url(entry: dict, keys: tuple = ("base_url", "url", "api")) -> str: - for key in keys: - value = entry.get(key, "") - if value: - return value - return "" + return next((entry.get(key, "") for key in keys if entry.get(key, "")), "") def _entry_api_mode(entry: dict) -> str | None: return str(entry.get("api_mode") or entry.get("transport") or "").strip().lower() or None -def _credential_identity(inline_api_key: str, key_env: str) -> str: - return inline_api_key if inline_api_key else (f"env:{key_env}" if key_env else "") +def _entry_credentials(entry: dict, *key_env_keys: str) -> tuple[str, str, str]: + """``(inline_api_key, key_env, identity)`` — identity is the inline key, else ``env:``, else "".""" + inline_api_key = str(entry.get("api_key", "") or "").strip() + key_env = str(next((entry.get(k) for k in key_env_keys if entry.get(k)), "")).strip() + return inline_api_key, key_env, inline_api_key or (f"env:{key_env}" if key_env else "") def _discover_flag(entry: dict): @@ -618,41 +476,39 @@ def _discover_flag(entry: dict): def _display_prefix(name: str) -> str: """Text before the per-model separator Hermes's own writer uses ("—" / " - ").""" - for sep in ("—", " - "): - if sep in name: - return name.split(sep)[0].strip() - return name + return next((name.split(sep)[0].strip() for sep in ("—", " - ") if sep in name), name) + + +def _group_display_name(display_name: str) -> str: + """Section-3 row label: strip the per-model suffix and trailing version tokens ("Palantir + Claude 4.7 Opus" -> "Palantir Claude") — cut at the first token containing a digit, only when + >= 2 words remain (avoids over-trimming).""" + grp_display = _display_prefix(display_name) + toks = grp_display.split() + cut_at = next((i for i, t in enumerate(toks) if any(c.isdigit() for c in t.strip(".,()"))), None) + if cut_at is not None and cut_at >= 2: + grp_display = " ".join(toks[:cut_at]).strip() + return grp_display or display_name def _discover_endpoint_models( - api_key: str, - api_url: str, - native_catalog_provider: str, - has_explicit_models: bool, - *, - headers: dict | None, - api_mode: str | None, - probe_live: bool, - discovery_allowed: bool, - for_picker: bool, -) -> tuple[list | None, bool]: + api_key: str, api_url: str, native_catalog_provider: str, has_explicit_models: bool, *, + headers: dict | None, api_mode: str | None, probe_live: bool, discovery_allowed: bool, + for_picker: bool) -> tuple[list | None, bool]: """Return ``(models, native_catalog_empty)`` for a custom endpoint row. - ``probe_live`` runs the native-aware picker fetch; otherwise, when discovery - is allowed, a warm same-fingerprint cache entry still serves the full catalog - with no round-trip. ``has_explicit_models`` gates the *probe* (a network-cost - guard for keyless endpoints that declare a catalog), never the cache read — - applying it to the read re-pins the endpoint to its declared subset. Returns - ``(None, False)`` when nothing usable was found. - """ + ``probe_live`` runs the native-aware picker fetch; otherwise, when discovery is allowed, a + warm same-fingerprint cache entry still serves the full catalog with no round-trip. + ``has_explicit_models`` gates the *probe* (a network-cost guard for keyless endpoints that + declare a catalog), never the cache read — applying it to the read re-pins the endpoint to + its declared subset. Returns ``(None, False)`` when nothing usable was found.""" from hermes_cli.model_switch import _fetch_picker_live_models timeout = 1.5 if for_picker else 5.0 if probe_live: try: live_models = _fetch_picker_live_models( api_key, api_url, native_catalog_provider, has_explicit_models, - headers=headers, timeout=timeout, api_mode=api_mode, - ) + headers=headers, timeout=timeout, api_mode=api_mode) is_native = isinstance(live_models, _NativePickerModelList) if live_models is not None and (live_models or not has_explicit_models or is_native): return live_models, (is_native and not live_models) @@ -661,88 +517,71 @@ def _discover_endpoint_models( elif discovery_allowed: try: from hermes_cli.models import cached_fetch_api_models - cached_models = cached_fetch_api_models( api_key, api_url, cache_only=True, timeout=timeout, headers=headers, api_mode=api_mode, ) if cached_models: return cached_models, False - except _MODEL_DISCOVERY_ERRORS: + except (ImportError, OSError, RuntimeError, TimeoutError, TypeError, ValueError, http.client.HTTPException): pass return None, False def _collect_authed_provider_slugs( - models_dev_data: dict, - curated: dict[str, list[str]], - excluded: list[str], -) -> list[str]: + models_dev_data: dict, curated: dict[str, list[str]], excluded: list[str]) -> list[str]: """Quick-scan which providers have credentials, without fetching model lists. - Mirrors the credential checks of sections 1, 2 and 2b of - :func:`list_authenticated_providers` but never calls - ``cached_provider_model_ids``; the result feeds - :func:`_prefetch_provider_models_parallel`. Env vars are read through the - per-profile secret scope. AWS SDK providers are skipped (heavier detection). - """ + Mirrors the credential checks of sections 1, 2 and 2b of :func:`list_authenticated_providers` + but never calls ``cached_provider_model_ids``; feeds :func:`_prefetch_provider_models_parallel`. + Env vars are read through the per-profile secret scope. AWS SDK providers are skipped + (heavier detection).""" from hermes_cli.model_switch import _scoped_key_env from agent.models_dev import PROVIDER_TO_MODELS_DEV from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.providers import HERMES_OVERLAYS from hermes_cli.models import CANONICAL_PROVIDERS - excluded_set = {str(p).strip().lower() for p in excluded if p} slugs: list[str] = [] seen: set[str] = set() + def _emit(slug: str, *keys: str) -> None: + slugs.append(slug) + seen.update(k.lower() for k in keys) + for hermes_id, _mdev_id, _pconfig, env_vars in _iter_builtin_candidates(models_dev_data, excluded_set, seen): - if any(_scoped_key_env(ev) for ev in env_vars) or _raw_pool_usable(hermes_id): - slugs.append(hermes_id) - seen.add(hermes_id.lower()) + if _any_env(env_vars, _scoped_key_env) or _raw_pool_usable(hermes_id): + _emit(hermes_id, hermes_id) mdev_to_hermes = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()} for pid, overlay in HERMES_OVERLAYS.items(): hermes_slug = mdev_to_hermes.get(pid, pid) - if pid.lower() in seen or hermes_slug.lower() in seen: - continue - if pid.lower() in excluded_set or hermes_slug.lower() in excluded_set: - continue - if overlay.auth_type == "aws_sdk": + if _skip(seen, excluded_set, pid, hermes_slug) or overlay.auth_type == "aws_sdk": continue if ( _overlay_has_env_creds(pid, hermes_slug, overlay, _scoped_key_env) - or _auth_store_has_provider(pid, hermes_slug) - or _pool_usable(hermes_slug) - ): - slugs.append(hermes_slug) - seen.add(pid.lower()) - seen.add(hermes_slug.lower()) + or _auth_store_has_provider(pid, hermes_slug) or _pool_usable(hermes_slug)): + _emit(hermes_slug, pid, hermes_slug) for cp in CANONICAL_PROVIDERS: - if cp.slug.lower() in seen or cp.slug.lower() in excluded_set: + if _skip(seen, excluded_set, cp.slug): continue cp_config = PROVIDER_REGISTRY.get(cp.slug) has_creds = bool( - cp_config and cp_config.api_key_env_vars and any(_scoped_key_env(ev) for ev in cp_config.api_key_env_vars) - ) + cp_config and cp_config.api_key_env_vars and _any_env(cp_config.api_key_env_vars, _scoped_key_env)) if has_creds or _auth_store_has_provider(cp.slug) or _pool_usable(cp.slug): - slugs.append(cp.slug) - seen.add(cp.slug.lower()) + _emit(cp.slug, cp.slug) - # Nous excluded: its picker branch builds from the curated list and never - # reads the api_key-only cache entry a prefetch would write. + # Nous excluded: its picker branch builds from the curated list and never reads the + # api_key-only cache entry a prefetch would write. return [s for s in slugs if s != "nous"] @dataclass class _PickerBuild: - """Mutable state threaded through the ``list_authenticated_providers`` sections: - 1 built-ins mapped to models.dev, 2 Hermes-only overlays (nous, openai-codex, - copilot, opencode-*), 2b canonical providers missed by 1/2 (keeps /model in sync - with `hermes model`), 3 ``providers:`` entries + 3b the bare active custom - endpoint, 4 ``custom_providers:`` entries. Every ``hermes_cli.auth/models`` import - in the row builders stays lazy so tests can patch those modules.""" - + """State threaded through the ``list_authenticated_providers`` sections: 1 built-ins mapped to + models.dev, 2 Hermes-only overlays, 2b canonical providers missed by 1/2, 3 ``providers:`` + entries + 3b the bare active custom endpoint, 4 ``custom_providers:`` entries. Row-builder + imports of ``hermes_cli.auth/models`` stay lazy so tests can patch those modules.""" current_provider: str current_base_url: str current_model: str @@ -756,30 +595,26 @@ class _PickerBuild: curated: dict results: list = field(default_factory=list) seen_slugs: set = field(default_factory=set) # lowercase-normalized to catch case variants - # Effective base URLs of every built-in row, so section 4 hides - # ``custom_providers`` entries that duplicate a built-in endpoint. + # Effective base URLs of every built-in row: section 4 hides ``custom_providers`` duplicates. builtin_endpoints: set = field(default_factory=set) - # (display_name, base_url) pairs emitted by section 3 so section 4 skips - # overlapping ``custom_providers`` rows (callers often pass both). + # (display_name, base_url) pairs from section 3 so section 4 skips overlapping rows. section3_pairs: set = field(default_factory=set) - current_provider_norm: str = field(init=False) - current_base_url_norm: str = field(init=False) - def __post_init__(self): - self.current_provider_norm = self.current_provider.lower() - self.current_base_url_norm = self.current_base_url.rstrip("/").lower() + @property + def current_provider_norm(self) -> str: + return self.current_provider.lower() + + @property + def current_base_url_norm(self) -> str: + return self.current_base_url.rstrip("/").lower() def can_probe_custom(self, *, row_is_current: bool) -> bool: return bool(self.probe_custom_providers or (self.probe_current_custom_provider and row_is_current)) def record_builtin_endpoint(self, slug: str) -> None: - """Prefer the live env override (e.g. DASHSCOPE_BASE_URL) over the static - inference_base_url so dedup matches what a user typing that URL into - custom_providers would actually hit.""" - try: - from hermes_cli.auth import PROVIDER_REGISTRY - except Exception: - return + """Prefer the live env override (e.g. DASHSCOPE_BASE_URL) over the static inference_base_url + so dedup matches what a user typing that URL into custom_providers would actually hit.""" + from hermes_cli.auth import PROVIDER_REGISTRY pcfg = PROVIDER_REGISTRY.get(slug) if not pcfg: return @@ -788,112 +623,150 @@ class _PickerBuild: if normed: self.builtin_endpoints.add(normed) - def add_builtin_row(self, slug: str, name: str, is_current: bool, model_ids: list, source: str, *, uncapped_ok: bool = True) -> None: + def add_builtin_row( + self, slug: str, name: str, is_current: bool, model_ids: list, source: str, *, uncapped_ok: bool = True, + ) -> None: self.results.append({ - "slug": slug, - "name": name, - "is_current": is_current, - "is_user_defined": False, + "slug": slug, "name": name, "is_current": is_current, "is_user_defined": False, "models": _cap_models(model_ids, self.max_models, slug if uncapped_ok else ""), - "total_models": len(model_ids), - "source": source, - }) + "total_models": len(model_ids), "source": source}) self.seen_slugs.add(slug.lower()) self.record_builtin_endpoint(slug) + def add_endpoint_row( + self, slug: str, name: str, api_url: str, models: list, is_current: bool, native_catalog_empty: bool, + *, source: str = "user-config", shown: list | None = None) -> None: + """Append a user-defined endpoint row (sections 3, 3b, 4).""" + self.results.append({ + "slug": slug, "name": name, "is_current": is_current, "is_user_defined": True, + "models": models if shown is None else shown, "total_models": len(models), "source": source, + "api_url": api_url, "native_catalog_empty": native_catalog_empty}) + self.seen_slugs.add(slug.lower()) + + def record_section3_pair(self, name: str, url_norm: str) -> bool: + """Remember a (display_name, base_url) pair for section-4 dedup; False when either is blank.""" + pair = (str(name).strip().lower(), url_norm) + if not (pair[0] and pair[1]): + return False + self.section3_pairs.add(pair) + return True + + def endpoint_is_current(self, slug: str, aliases: set, url_norm: str, *, url_match_ok: bool = True) -> bool: + """Row is current by slug/alias, or (bare ``custom`` provider) by matching base_url.""" + return ( + str(slug).strip().lower() == self.current_provider_norm + or self.current_provider_norm in aliases + or ( + self.current_provider_norm == "custom" and bool(self.current_base_url_norm) + and url_norm == self.current_base_url_norm and url_match_ok)) + + def discover_endpoint( + self, api_key: str, api_url: str, native_provider: str, has_explicit_models: bool, *, + headers: dict | None, api_mode: str | None, discovery_allowed: bool, is_current: bool, + ) -> tuple[list | None, bool, bool]: + """Probe policy shared by sections 3 and 4 (returns ``(models, native_empty, probed)``): + with an api_key live /models is the source of truth (replaces the partial ``models:`` + subset); without one, an allowlist-shaped ``models:`` narrows a public endpoint and skips + the probe. A dict-shaped ``models:`` is metadata, so still probe; pin with + ``discover_models: false``.""" + probe_live = ( + discovery_allowed and (bool(api_key) or not has_explicit_models) + and self.can_probe_custom(row_is_current=is_current)) + discovered, native_catalog_empty = _discover_endpoint_models( + api_key, api_url, native_provider, has_explicit_models, + headers=headers, api_mode=api_mode, probe_live=probe_live, + discovery_allowed=discovery_allowed, for_picker=self.for_picker) + return discovered, native_catalog_empty, probe_live + def _lap_builtin_rows(b: _PickerBuild, data: dict, user_providers: dict) -> None: """Section 1: models.dev-mapped providers with api_key auth.""" from hermes_cli.model_switch import _declared_model_ids from agent.models_dev import get_provider_info - for hermes_id, mdev_id, pconfig, env_vars in _iter_builtin_candidates(data, b.excluded, b.seen_slugs): - if not (any(os.environ.get(ev) for ev in env_vars) or _raw_pool_usable(hermes_id)): + if not (_any_env(env_vars) or _raw_pool_usable(hermes_id)): continue model_ids = _live_or_curated_ids(hermes_id, b.curated) - # A providers..models block extends the discovered catalog; - # section 3 cannot emit it later because this row owns the slug. + # A providers..models block extends the discovered catalog; section 3 cannot + # emit it later because this row owns the slug. configured = user_providers.get(hermes_id) if isinstance(user_providers, dict) else None configured_models = _declared_model_ids(configured.get("models")) if isinstance(configured, dict) else [] model_ids = list(dict.fromkeys([*configured_models, *model_ids])) pinfo = get_provider_info(mdev_id) display_name = pconfig.name if pconfig and pconfig.name else (pinfo.name if pinfo else mdev_id) b.add_builtin_row( - hermes_id, display_name, b.current_provider in (hermes_id, mdev_id), model_ids, "built-in", - ) + hermes_id, display_name, b.current_provider in (hermes_id, mdev_id), model_ids, "built-in") + + +def _overlay_has_creds(b: _PickerBuild, pid: str, hermes_slug: str, overlay) -> bool: + """Section-2 credential ladder: env/SDK, external-process executable, auth store, pool, + anthropic's external credential files.""" + from hermes_cli.model_switch import _credential_pool_is_usable + if overlay.keyless: + return True # served anonymously (opencode-free) + if overlay.auth_type == "aws_sdk": + has_creds = _has_aws_sdk_creds_for_listing(hermes_slug, b.current_provider) + else: + has_creds = _overlay_has_env_creds(pid, hermes_slug, overlay, os.environ.get) + # External-process providers (copilot-acp) hold no key/token/pool entry by design — the + # spawned ACP subprocess brings its own auth. "Configured" means the executable resolves. + if not has_creds and overlay.auth_type == "external_process": + try: + from hermes_cli.auth import get_auth_status + _ext_status = get_auth_status(hermes_slug) or {} + has_creds = bool(_ext_status.get("logged_in") or _ext_status.get("configured")) + except Exception as exc: + logger.debug("External-process check failed for %s: %s", pid, exc) + # Auth store / credential pool cover OAuth providers AND api_key providers that also support + # OAuth (anthropic via Claude Code credential files). + has_creds = has_creds or _auth_store_has_provider(pid, hermes_slug) + if not has_creds: + # Full auto-seeding pool check catches external stores (Codex CLI ~/.codex/auth.json) + # not yet in auth.json. + try: + if _credential_pool_is_usable(hermes_slug): + has_creds = True + elif b.for_picker: + # Show providers whose pool is entirely in cooldown: limits are per-model for + # many providers, so another model may work. + try: + from agent.credential_pool import load_pool + has_creds = load_pool(hermes_slug).has_credentials() + except Exception: + pass + except Exception as exc: + logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) + if not has_creds and hermes_slug == "anthropic": + # The pool gates anthropic behind is_provider_explicitly_configured() (aux tasks must not + # consume Claude Code tokens); the picker is discovery-oriented, so read the files directly. + try: + from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials + hermes_creds = read_hermes_oauth_credentials() + cc_creds = read_claude_code_credentials() + if (hermes_creds and hermes_creds.get("accessToken")) or (cc_creds and cc_creds.get("accessToken")): + has_creds = True + except Exception as exc: + logger.debug("Anthropic external creds check failed: %s", exc) + return has_creds def _lap_overlay_rows(b: _PickerBuild, data: dict) -> None: """Section 2: Hermes-only providers (nous, openai-codex, copilot, opencode-go, ...).""" - from hermes_cli.model_switch import _credential_pool_is_usable from agent.models_dev import PROVIDER_TO_MODELS_DEV from hermes_cli.providers import HERMES_OVERLAYS - # HERMES_OVERLAYS keys may be models.dev IDs ("github-copilot") while - # config.yaml uses Hermes IDs ("copilot"). + # HERMES_OVERLAYS keys may be models.dev IDs ("github-copilot") while config.yaml uses + # Hermes IDs ("copilot"). mdev_to_hermes = {v: k for k, v in PROVIDER_TO_MODELS_DEV.items()} for pid, overlay in HERMES_OVERLAYS.items(): hermes_slug = mdev_to_hermes.get(pid, pid) - if pid.lower() in b.seen_slugs or hermes_slug.lower() in b.seen_slugs: + if _skip(b.seen_slugs, b.excluded, pid, hermes_slug): continue - if pid.lower() in b.excluded or hermes_slug.lower() in b.excluded: + if not _overlay_has_creds(b, pid, hermes_slug, overlay): continue - - if getattr(overlay, "keyless", False): - has_creds = True # served anonymously (opencode-free) - elif overlay.auth_type == "aws_sdk": - has_creds = _has_aws_sdk_creds_for_listing(hermes_slug, b.current_provider) - else: - has_creds = _overlay_has_env_creds(pid, hermes_slug, overlay, os.environ.get) - # External-process providers (copilot-acp) hold no key/token/pool entry by - # design — the spawned ACP subprocess brings its own auth. "Configured" - # means the executable resolves, which is what get_auth_status() reports; - # without this the has_creds filter hides the provider from every picker. - if not has_creds and overlay.auth_type == "external_process": - try: - from hermes_cli.auth import get_auth_status - _ext_status = get_auth_status(hermes_slug) or {} - has_creds = bool(_ext_status.get("logged_in") or _ext_status.get("configured")) - except Exception as exc: - logger.debug("External-process check failed for %s: %s", pid, exc) - # Auth store / credential pool cover OAuth providers AND api_key providers - # that also support OAuth (anthropic via Claude Code credential files). - if not has_creds: - has_creds = _auth_store_has_provider(pid, hermes_slug) - if not has_creds: - # Full auto-seeding pool check catches external stores (Codex CLI - # ~/.codex/auth.json) not yet in auth.json. - try: - if _credential_pool_is_usable(hermes_slug): - has_creds = True - elif b.for_picker: - # Show providers whose pool is entirely in cooldown: limits are - # per-model for many providers, so another model may work. - try: - from agent.credential_pool import load_pool - has_creds = load_pool(hermes_slug).has_credentials() - except Exception: - pass - except Exception as exc: - logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) - if not has_creds and hermes_slug == "anthropic": - # The pool gates anthropic behind is_provider_explicitly_configured() - # (aux tasks must not consume Claude Code tokens); the picker is - # discovery-oriented, so read the external credential files directly. - try: - from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials - hermes_creds = read_hermes_oauth_credentials() - cc_creds = read_claude_code_credentials() - if (hermes_creds and hermes_creds.get("accessToken")) or (cc_creds and cc_creds.get("accessToken")): - has_creds = True - except Exception as exc: - logger.debug("Anthropic external creds check failed: %s", exc) - if not has_creds: - continue - if hermes_slug in {"openai-codex", "copilot", "copilot-acp"}: - # Live OAuth-backed discovery so Pro-only Codex slugs not in the static - # catalog appear; falls back to curated when unreachable. + # Live OAuth-backed discovery so Pro-only Codex slugs not in the static catalog + # appear; falls back to curated when unreachable. from hermes_cli.models import cached_provider_model_ids model_ids = cached_provider_model_ids(hermes_slug) elif overlay.auth_type == "aws_sdk": @@ -903,38 +776,31 @@ def _lap_overlay_rows(b: _PickerBuild, data: dict) -> None: else: model_ids = _live_or_curated_ids(hermes_slug, b.curated, hermes_slug, pid) b.add_builtin_row( - hermes_slug, get_label(hermes_slug), b.current_provider in (hermes_slug, pid), model_ids, "hermes", - ) + hermes_slug, get_label(hermes_slug), b.current_provider in (hermes_slug, pid), model_ids, "hermes") b.seen_slugs.add(pid.lower()) def _lap_canonical_rows(b: _PickerBuild) -> None: """Section 2b: CANONICAL_PROVIDERS missed by sections 1/2.""" from hermes_cli.auth import PROVIDER_REGISTRY - try: - from hermes_cli.models import CANONICAL_PROVIDERS - except ImportError: - CANONICAL_PROVIDERS = [] - + from hermes_cli.models import CANONICAL_PROVIDERS for cp in CANONICAL_PROVIDERS: - if cp.slug.lower() in b.seen_slugs or cp.slug.lower() in b.excluded: + if _skip(b.seen_slugs, b.excluded, cp.slug): continue cp_config = PROVIDER_REGISTRY.get(cp.slug) has_creds = False if cp_config and cp_config.api_key_env_vars: lit = {ev for ev in cp_config.api_key_env_vars if os.environ.get(ev)} has_creds = bool(lit) - # A regional "-cn" twin lit only by key vars shared with its non-CN - # sibling is a phantom row: hide it unless it is the current provider, - # and only when it has a dedicated var of its own the user could set. + # A regional "-cn" twin lit only by key vars shared with its non-CN sibling is a + # phantom row: hide it unless it is the current provider, and only when it has a + # dedicated var of its own the user could set. sib = PROVIDER_REGISTRY.get(cp.slug[:-3]) if cp.slug.endswith("-cn") else None sib_vars = set(sib.api_key_env_vars) if sib else set() if lit and lit <= sib_vars < set(cp_config.api_key_env_vars) and cp.slug != b.current_provider: continue - if not has_creds: - has_creds = _auth_store_has_provider(cp.slug) or _pool_usable(cp.slug) - if not has_creds and _is_aws_sdk(cp_config): - has_creds = _has_aws_sdk_creds_for_listing(cp.slug, b.current_provider) + has_creds = has_creds or _auth_store_has_provider(cp.slug) or _pool_usable(cp.slug) or ( + _is_aws_sdk(cp_config) and _has_aws_sdk_creds_for_listing(cp.slug, b.current_provider)) if not has_creds: continue if _is_aws_sdk(cp_config): @@ -942,192 +808,103 @@ def _lap_canonical_rows(b: _PickerBuild) -> None: else: model_ids = _live_or_curated_ids(cp.slug, b.curated, merge_models_dev=False) b.add_builtin_row( - cp.slug, cp.label, cp.slug == b.current_provider, model_ids, "canonical", uncapped_ok=False, - ) + cp.slug, cp.label, cp.slug == b.current_provider, model_ids, "canonical", uncapped_ok=False) def _lap_user_provider_rows(b: _PickerBuild, user_providers: dict) -> None: - """Section 3: ``providers:`` dict entries, grouped by (api_url, credential, - api_mode, extra_headers) so keyed providers on one endpoint with the same - wire protocol collapse into one row (e.g. two Palantir Claude entries -> - one "Palantir Claude" row); a different key_env/api_mode/headers keeps - distinct rows since the wire protocol or tenant differs.""" - from hermes_cli.model_switch import _declared_model_ids, _entry_models_discovered, _extra_headers_from_config, _models_config_is_allowlist, _scoped_key_env - from collections import OrderedDict + """Section 3: ``providers:`` dict entries, grouped by (api_url, credential, api_mode, + extra_headers) so keyed providers on one endpoint with the same wire protocol collapse into + one row (two Palantir Claude entries -> one "Palantir Claude" row); a different + key_env/api_mode/headers keeps distinct rows since the wire protocol or tenant differs.""" + from hermes_cli.model_switch import _extra_headers_from_config, _scoped_key_env from hermes_cli.config import coerce_provider_id, is_provider_enabled - - ep_groups: "OrderedDict[tuple, dict]" = OrderedDict() + ep_groups: dict[tuple, dict] = {} for ep_name, ep_cfg in user_providers.items(): - if not isinstance(ep_cfg, dict) or not is_provider_enabled(ep_cfg): - continue - if ep_name.lower() in b.seen_slugs: + if not isinstance(ep_cfg, dict) or not is_provider_enabled(ep_cfg) or ep_name.lower() in b.seen_slugs: continue display_name = coerce_provider_id(ep_cfg.get("name")) or ep_name api_url = _entry_base_url(ep_cfg, ("base_url", "api", "url")) - key_env = str(ep_cfg.get("key_env") or ep_cfg.get("api_key_env") or "").strip() - inline_api_key = str(ep_cfg.get("api_key", "") or "").strip() - api_mode = _entry_api_mode(ep_cfg) - headers_identity = tuple(sorted(_extra_headers_from_config(ep_cfg).items())) - group_key = (_norm_url(api_url), _credential_identity(inline_api_key, key_env), api_mode, headers_identity) - - # ``default_model`` is the legacy key; ``model`` matches custom_providers. - default_model = ep_cfg.get("default_model", "") or ep_cfg.get("model", "") - entry_models = [default_model] if default_model else [] - for model_id in _declared_model_ids(ep_cfg.get("models", [])): - if model_id not in entry_models: - entry_models.append(model_id) + inline_api_key, key_env, cred_identity = _entry_credentials(ep_cfg, "key_env", "api_key_env") + headers = _extra_headers_from_config(ep_cfg) + group_key = (_norm_url(api_url), cred_identity, _entry_api_mode(ep_cfg), tuple(sorted(headers.items()))) if group_key not in ep_groups: - # Strip the per-model suffix and trailing version tokens ("Palantir - # Claude 4.7 Opus" -> "Palantir Claude"): cut at the first token with - # a digit, only when >=2 words remain (avoids over-trimming). - grp_display = _display_prefix(display_name) - toks = grp_display.split() - cut_at = next((i for i, t in enumerate(toks) if any(c.isdigit() for c in t.strip(".,()"))), None) - if cut_at is not None and cut_at >= 2: - grp_display = " ".join(toks[:cut_at]).strip() + # slug = first ep_name encountered; probe key from the first member (inline api_key, + # else key_env through the per-profile secret scope). ep_groups[group_key] = { - "slug": ep_name, # first ep_name encountered - "name": grp_display or display_name, - "api_url": api_url, - "models": [], - "has_explicit_models": False, - "ep_cfg": ep_cfg, - "raw_names": [], - "aliases": set(), - } + "slug": ep_name, "name": _group_display_name(display_name), "api_url": api_url, "models": [], + "has_explicit_models": False, "api_key": inline_api_key or _scoped_key_env(key_env), + "headers": headers, "api_mode": ep_cfg.get("api_mode"), + "discovery_allowed": bool(api_url) and _discover_flag(ep_cfg), "raw_names": [], "aliases": set()} grp = ep_groups[group_key] - for m in entry_models: - if m and m not in grp["models"]: - grp["models"].append(m) - # A singular default_model/model is only the active selection and must - # not suppress discovery; dict-shaped ``models:`` is context_length - # metadata, not an allowlist — see ``_models_config_is_allowlist``. - if _models_config_is_allowlist(ep_cfg.get("models"), _entry_models_discovered(ep_cfg)): - grp["has_explicit_models"] = True + # ``default_model`` is the legacy key; ``model`` matches custom_providers. + _absorb_entry_models(grp, ep_cfg, ep_cfg.get("default_model", "") or ep_cfg.get("model", "")) grp["raw_names"].append(display_name) grp["aliases"].update(custom_provider_aliases(display_name, str(ep_name))) for grp in ep_groups.values(): - ep_cfg, ep_name, display_name, api_url = grp["ep_cfg"], grp["slug"], grp["name"], grp["api_url"] + ep_name, display_name, api_url = grp["slug"], grp["name"], grp["api_url"] models_list = list(grp["models"]) - # Official OpenAI rows often have base_url but no models: dict — avoid a - # misleading zero count. + # Official OpenAI rows often have base_url but no models: dict — avoid a misleading zero count. if not models_list and base_url_host_matches(str(api_url).strip().lower(), "api.openai.com"): models_list = list(b.curated.get("openai") or []) - # Probe policy (mirrors section 4): with an api_key always probe; without - # one, skip only when an allowlist-shaped ``models:`` narrows the endpoint. - api_key = str(ep_cfg.get("api_key", "") or "").strip() - if not api_key: - key_env = str(ep_cfg.get("key_env") or ep_cfg.get("api_key_env") or "").strip() - api_key = _scoped_key_env(key_env) if key_env else "" - has_explicit_models = bool(grp.get("has_explicit_models")) ep_url_norm = _norm_url(api_url) - ep_aliases = {str(alias).lower() for alias in grp.get("aliases", set())} - is_current = ( - str(ep_name).strip().lower() == b.current_provider_norm - or b.current_provider_norm in ep_aliases - or ( - b.current_provider_norm == "custom" - and bool(b.current_base_url_norm) - and ep_url_norm == b.current_base_url_norm - ) - ) - discovery_allowed = bool(api_url) and _discover_flag(ep_cfg) - discovered, native_catalog_empty = _discover_endpoint_models( - api_key, - api_url, + ep_aliases = {str(alias).lower() for alias in grp["aliases"]} + is_current = b.endpoint_is_current(ep_name, ep_aliases, ep_url_norm) + discovered, native_catalog_empty, _ = b.discover_endpoint( + grp["api_key"], api_url, ep_name if str(ep_name).strip().lower() in {"ollama", "custom:ollama"} else "custom", - has_explicit_models, - headers=_extra_headers_from_config(ep_cfg) or None, - api_mode=ep_cfg.get("api_mode"), - probe_live=( - discovery_allowed - and (bool(api_key) or not has_explicit_models) - and b.can_probe_custom(row_is_current=is_current) - ), - discovery_allowed=discovery_allowed, - for_picker=b.for_picker, - ) + grp["has_explicit_models"], headers=grp["headers"] or None, api_mode=grp["api_mode"], + discovery_allowed=grp["discovery_allowed"], is_current=is_current) if discovered is not None: models_list = discovered - b.results.append({ - "slug": ep_name, - "name": display_name, - "is_current": is_current, - "is_user_defined": True, - "models": models_list, - "total_models": len(models_list) if models_list else 0, - "source": "user-config", - "api_url": api_url, - "native_catalog_empty": native_catalog_empty, - }) - b.seen_slugs.add(ep_name.lower()) + b.add_endpoint_row(ep_name, display_name, api_url, models_list, is_current, native_catalog_empty) b.seen_slugs.update(ep_aliases) - # Record every raw member name so section 4 can match per-model - # custom_providers rows even though the group label was collapsed. - for raw_name in grp.get("raw_names") or [display_name]: - pair = (str(raw_name).strip().lower(), ep_url_norm) - if pair[0] and pair[1]: - b.section3_pairs.add(pair) + # Record every raw member name so section 4 can match per-model custom_providers rows + # even though the group label was collapsed. + for raw_name in grp["raw_names"] or [display_name]: + if b.record_section3_pair(raw_name, ep_url_norm): b.seen_slugs.add(custom_provider_slug(raw_name).lower()) - pair = (str(display_name).strip().lower(), ep_url_norm) - if pair[0] and pair[1]: - b.section3_pairs.add(pair) + b.record_section3_pair(display_name, ep_url_norm) def _lap_bare_custom_row(b: _PickerBuild, custom_providers: list | None) -> None: """Section 3b: ``model.provider: custom`` + ``model.base_url`` with no named - providers:/custom_providers row — surface it so /model does not look like it - ignored config.yaml.""" + providers:/custom_providers row — surface it so /model does not look like it ignored + config.yaml.""" if not (b.current_provider_norm == "custom" and b.current_base_url and "custom" not in b.seen_slugs): return if any( isinstance(cp, dict) and _norm_url(_entry_base_url(cp)) == _norm_url(b.current_base_url) - for cp in (custom_providers or []) - ): + for cp in (custom_providers or [])): return api_url = str(b.current_base_url).strip().rstrip("/") models = [b.current_model] if b.current_model else [] native_catalog_empty = False try: discovered, native_catalog_empty = _discover_endpoint_models( - "", api_url, "custom", False, - headers=None, api_mode=None, - probe_live=bool(b.refresh or b.probe_current_custom_provider), - discovery_allowed=True, - for_picker=b.for_picker, - ) + "", api_url, "custom", False, headers=None, api_mode=None, + probe_live=bool(b.refresh or b.probe_current_custom_provider), discovery_allowed=True, + for_picker=b.for_picker) if discovered is not None: models = discovered except Exception: pass - b.results.append({ - "slug": "custom", - "name": "Custom endpoint", - "is_current": True, - "is_user_defined": True, - "models": _cap_models(models, b.max_models), - "total_models": len(models), - "source": "model-config", - "api_url": api_url, - "native_catalog_empty": native_catalog_empty, - }) - b.seen_slugs.add("custom") + b.add_endpoint_row( + "custom", "Custom endpoint", api_url, models, True, native_catalog_empty, + source="model-config", shown=_cap_models(models, b.max_models)) def _lap_custom_provider_rows(b: _PickerBuild, custom_providers: list) -> None: - """Section 4: ``custom_providers:`` entries (one model each) grouped into one - row per (endpoint, credential identity, api_mode, extra_headers, display - prefix). Four "Ollama — X" entries on one host become one "Ollama" row; - distinct prefixes sharing a proxy URL keep their own rows.""" - from hermes_cli.model_switch import _declared_model_ids, _entry_models_discovered, _extra_headers_from_config, _models_config_is_allowlist, _save_discovered_models_to_config, _scoped_key_env - from collections import OrderedDict + """Section 4: ``custom_providers:`` entries (one model each) grouped into one row per + (endpoint, credential identity, api_mode, extra_headers, display prefix). Four "Ollama — X" + entries on one host become one "Ollama" row; distinct prefixes sharing a proxy URL keep + their own rows.""" + from hermes_cli.model_switch import _extra_headers_from_config, _save_discovered_models_to_config, _scoped_key_env from hermes_cli.config import coerce_provider_id - - groups: "OrderedDict[tuple, dict]" = OrderedDict() + groups: dict[tuple, dict] = {} for entry in custom_providers: if not isinstance(entry, dict): continue @@ -1135,62 +912,36 @@ def _lap_custom_provider_rows(b: _PickerBuild, custom_providers: list) -> None: api_url = str(_entry_base_url(entry) or "").strip().rstrip("/") if not raw_name or not api_url: continue - inline_api_key = str(entry.get("api_key") or "").strip() - key_env = str(entry.get("key_env") or "").strip() + inline_api_key, key_env, cred_identity = _entry_credentials(entry, "key_env") api_key = inline_api_key or _scoped_key_env(key_env) api_mode = _entry_api_mode(entry) discover = _discover_flag(entry) entry_extra_headers = _extra_headers_from_config(entry) prefix = _display_prefix(raw_name) - group_key = ( - api_url, _credential_identity(inline_api_key, key_env), api_mode, - tuple(sorted(entry_extra_headers.items())), prefix.lower(), - ) - if group_key not in groups: - display_name = prefix or raw_name - groups[group_key] = { - "slug": custom_provider_slug(display_name, str(entry.get("provider_key") or "").strip()), - "name": display_name, - "api_url": api_url, - "api_key": api_key, - "models": [], - "has_explicit_models": False, - "discover_models": discover, - "api_mode": api_mode, - "extra_headers": entry_extra_headers, - "aliases": set(), - } - else: - if api_key and not groups[group_key].get("api_key"): - groups[group_key]["api_key"] = api_key - if not discover: # one opt-out pins the whole grouped row - groups[group_key]["discover_models"] = False - grp = groups[group_key] - grp["aliases"].update(custom_provider_aliases(raw_name, str(entry.get("provider_key") or ""))) - # ``model:`` is only the active selection; every configured model lives - # under ``models:`` (dict written by _save_custom_provider). - default_model = (entry.get("model") or "").strip() - if default_model and default_model not in grp["models"]: - grp["models"].append(default_model) - models_field = entry.get("models", {}) - if _models_config_is_allowlist(models_field, _entry_models_discovered(entry)): - grp["has_explicit_models"] = True - for model_id in _declared_model_ids(models_field): - if model_id not in grp["models"]: - grp["models"].append(model_id) + provider_key = str(entry.get("provider_key") or "").strip() + group_key = (api_url, cred_identity, api_mode, tuple(sorted(entry_extra_headers.items())), prefix.lower()) + display_name = prefix or raw_name + grp = groups.setdefault(group_key, { + "slug": custom_provider_slug(display_name, provider_key), "name": display_name, + "api_url": api_url, "api_key": "", "models": [], "has_explicit_models": False, + "discover_models": True, "api_mode": api_mode, "extra_headers": entry_extra_headers, + "aliases": set()}) + grp["api_key"] = grp["api_key"] or api_key # first member with a key wins + grp["discover_models"] = grp["discover_models"] and discover # one opt-out pins the whole row + grp["aliases"].update(custom_provider_aliases(raw_name, provider_key)) + # ``model:`` is only the active selection; every configured model lives under ``models:``. + _absorb_entry_models(grp, entry, (entry.get("model") or "").strip()) section4_slugs: set = set() current_url_group_count = sum( 1 for grp in groups.values() - if b.current_base_url_norm and _norm_url(grp["api_url"]) == b.current_base_url_norm - ) + if b.current_base_url_norm and _norm_url(grp["api_url"]) == b.current_base_url_norm) for grp in groups.values(): api_url, api_key, slug = grp["api_url"], grp.get("api_key", ""), grp["slug"] # Slug claimed by a built-in/overlay/providers: row -> skip (don't shadow). if slug.lower() in b.seen_slugs and slug.lower() not in section4_slugs: continue - # Two custom endpoints with the same cleaned name: suffix a counter so - # both stay visible. + # Two custom endpoints with the same cleaned name: suffix a counter so both stay visible. if slug.lower() in section4_slugs: base_slug, n = slug, 2 while f"{base_slug}-{n}".lower() in b.seen_slugs: @@ -1201,83 +952,45 @@ def _lap_custom_provider_rows(b: _PickerBuild, custom_providers: list) -> None: pair_key = (str(grp["name"]).strip().lower(), grp_url_norm) if pair_key[0] and pair_key[1] and pair_key in b.section3_pairs: continue - # A built-in row already represents this endpoint (e.g. "my-dashscope" - # vs the alibaba-coding-plan row): keep the built-in, hide the shadow. + # A built-in row already represents this endpoint (e.g. "my-dashscope" vs the + # alibaba-coding-plan row): keep the built-in, hide the shadow. if grp_url_norm and grp_url_norm in b.builtin_endpoints: continue - is_current = ( - slug.lower() == b.current_provider_norm - or b.current_provider_norm in {str(alias).lower() for alias in grp.get("aliases", set())} - ) or ( - b.current_provider_norm == "custom" - and bool(b.current_base_url_norm) - and grp_url_norm == b.current_base_url_norm - and current_url_group_count == 1 - ) - # Probe policy: with an api_key live /models is the source of truth (replace - # the partial ``models:`` subset); without one, an allowlist-shaped - # ``models:`` narrows a public endpoint and skips the probe. A dict-shaped - # ``models:`` is metadata, so still probe; pin with discover_models: false. - has_explicit_models = bool(grp.get("has_explicit_models")) - discovery_allowed = bool(api_url) and grp.get("discover_models", True) - probe_live = ( - discovery_allowed - and (bool(api_key) or not has_explicit_models) - and b.can_probe_custom(row_is_current=is_current) - ) - discovered, native_catalog_empty = _discover_endpoint_models( - api_key, - api_url, + is_current = b.endpoint_is_current( + slug, {str(alias).lower() for alias in grp["aliases"]}, grp_url_norm, + url_match_ok=current_url_group_count == 1) + discovered, native_catalog_empty, probe_live = b.discover_endpoint( + api_key, api_url, "ollama" if "ollama" in {str(slug).strip().lower(), str(grp.get("name") or "").strip().lower()} else "custom", - has_explicit_models, - headers=grp.get("extra_headers") or None, - api_mode=grp.get("api_mode"), - probe_live=probe_live, - discovery_allowed=discovery_allowed, - for_picker=b.for_picker, - ) + bool(grp.get("has_explicit_models")), headers=grp.get("extra_headers") or None, + api_mode=grp.get("api_mode"), discovery_allowed=bool(api_url) and grp.get("discover_models", True), + is_current=is_current) if discovered is not None: grp["models"] = discovered - if probe_live: - # A successful live probe persists the catalog for no-probe surfaces. + if probe_live: # a successful live probe persists the catalog for no-probe surfaces try: _save_discovered_models_to_config( - api_url, discovered, api_mode=grp.get("api_mode"), headers=grp.get("extra_headers") or None, - ) + api_url, discovered, api_mode=grp.get("api_mode"), headers=grp.get("extra_headers") or None) except Exception: pass - b.results.append({ - "slug": slug, - "name": grp["name"], - "is_current": is_current, - "is_user_defined": True, - "models": grp["models"], - "total_models": len(grp["models"]), - "source": "user-config", - "api_url": grp["api_url"], - "native_catalog_empty": native_catalog_empty, - }) - b.seen_slugs.add(slug.lower()) + b.add_endpoint_row(slug, grp["name"], grp["api_url"], grp["models"], is_current, native_catalog_empty) section4_slugs.add(slug.lower()) def _build_curated_lists(current_provider: str, current_base_url: str, current_model: str) -> dict[str, list[str]]: - """Curated model lists keyed by hermes provider id, plus the dynamic ones - (nous manifest, Ollama Cloud, LM Studio live probe).""" + """Curated model lists keyed by hermes provider id, plus the dynamic ones (nous manifest, + Ollama Cloud, LM Studio live probe).""" from hermes_cli.models import OPENROUTER_MODELS, _PROVIDER_MODELS, get_curated_nous_model_ids - curated: dict[str, list[str]] = dict(_PROVIDER_MODELS) curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS] - # Remote model-catalog manifest so new Portal models surface without a - # release; falls back to the in-repo snapshot when unreachable. + # Remote manifest so new Portal models surface without a release; in-repo snapshot fallback. curated["nous"] = get_curated_nous_model_ids() if "ollama-cloud" not in curated: from hermes_cli.models import fetch_ollama_cloud_models curated["ollama-cloud"] = fetch_ollama_cloud_models() - # LM Studio has no static catalog: probe its native endpoint live. Base URL - # precedence: LM_BASE_URL > active config base_url (when current) > default. - # On auth rejection / unreachable, fall back to the current model so the - # picker still shows something offline. + # LM Studio has no static catalog: probe its native endpoint live. Base URL precedence: + # LM_BASE_URL > active config base_url (when current) > default. On auth rejection / + # unreachable, fall back to the current model so the picker still shows something offline. is_current_lmstudio = current_provider.strip().lower() == "lmstudio" if "lmstudio" not in curated and (os.environ.get("LM_API_KEY") or os.environ.get("LM_BASE_URL") or is_current_lmstudio): from hermes_cli.models import fetch_lmstudio_models @@ -1285,8 +998,7 @@ def _build_curated_lists(current_provider: str, current_base_url: str, current_m lm_base = ( os.environ.get("LM_BASE_URL") or (current_base_url if is_current_lmstudio and current_base_url else None) - or "http://127.0.0.1:1234/v1" - ) + or "http://127.0.0.1:1234/v1") try: live = fetch_lmstudio_models(api_key=os.environ.get("LM_API_KEY", ""), base_url=lm_base, timeout=1.5) except AuthError: @@ -1298,47 +1010,27 @@ def _build_curated_lists(current_provider: str, current_base_url: str, current_m def list_authenticated_providers( - current_provider: str = "", - current_base_url: str = "", - user_providers: dict = None, - custom_providers: list | None = None, - *, - force_fresh_nous_tier: bool = False, - max_models: int | None = None, - current_model: str = "", - refresh: bool = False, - probe_custom_providers: bool = True, - probe_current_custom_provider: bool = False, - for_picker: bool = False, - excluded_providers: list | None = None, -) -> List[dict]: - """Detect which providers have credentials and list their curated models. + current_provider: str = "", current_base_url: str = "", user_providers: dict = None, + custom_providers: list | None = None, *, force_fresh_nous_tier: bool = False, + max_models: int | None = None, current_model: str = "", refresh: bool = False, + probe_custom_providers: bool = True, probe_current_custom_provider: bool = False, + for_picker: bool = False, excluded_providers: list | None = None) -> List[dict]: + """Detect which providers have credentials and list their curated (not full models.dev) models. - Uses the curated lists from hermes_cli/models.py (OPENROUTER_MODELS, - _PROVIDER_MODELS) — hand-picked agentic models, NOT the full models.dev - catalog. Only providers with API keys set or user-defined endpoints appear. - - Returns a list of dicts: ``slug`` (the --provider value), ``name``, - ``is_current``, ``is_user_defined``, ``models`` (up to max_models), - ``total_models``, ``source`` ("built-in", "hermes", "canonical", - "user-config", "model-config"). - - ``force_fresh_nous_tier`` bypasses the short Nous tier cache for explicit - account-sensitive flows; picker opens should leave it false. - ``refresh`` busts the per-provider model-id disk cache up front so every row - re-fetches live — for an explicit user "refresh models" action only. - ``probe_custom_providers`` controls live ``/models`` discovery for saved - custom endpoints (default true for CLI parity; GUI opens pass false). - ``probe_current_custom_provider`` probes only the currently-selected custom - endpoint so its list matches without blocking on offline ones. - """ + Returns dicts with ``slug`` (the --provider value), ``name``, ``is_current``, + ``is_user_defined``, ``models`` (up to max_models), ``total_models``, ``source`` + ("built-in", "hermes", "canonical", "user-config", "model-config"). + ``force_fresh_nous_tier`` bypasses the short Nous tier cache (account-sensitive flows only); + ``refresh`` busts the model-id disk cache up front (explicit user action only); + ``probe_custom_providers`` enables live ``/models`` discovery for saved custom endpoints (CLI + true, GUI false); ``probe_current_custom_provider`` probes only the selected custom endpoint.""" from hermes_cli.model_switch import _collect_authed_provider_slugs, _prefetch_provider_models_parallel from agent.models_dev import fetch_models_dev from hermes_cli.config import coerce_provider_id, stringify_provider_map - # Explicit refresh: drop every cached model-id list so the calls below all - # re-fetch live. A stale cache can fall back to the curated static list when - # its live fetch fails, silently dropping live-only models the user had seen. + # Explicit refresh: drop every cached list so the calls below re-fetch live. A stale cache + # can fall back to the curated static list when its live fetch fails, silently dropping + # live-only models the user had seen. if refresh: try: from hermes_cli.models import clear_provider_models_cache @@ -1353,26 +1045,18 @@ def list_authenticated_providers( user_providers = stringify_provider_map(user_providers) data = fetch_models_dev() + # A single excluded entry like ``copilot`` hides the provider under every key it surfaces + # as (hermes_id / mdev_id / canonical slug). b = _PickerBuild( - current_provider=current_provider, - current_base_url=current_base_url, - current_model=current_model, - max_models=max_models, - for_picker=for_picker, - force_fresh_nous_tier=force_fresh_nous_tier, - probe_custom_providers=probe_custom_providers, - probe_current_custom_provider=probe_current_custom_provider, - refresh=refresh, - # A single entry like ``copilot`` hides the provider under every key it - # surfaces as (hermes_id / mdev_id / canonical slug). - excluded={str(p).strip().lower() for p in (excluded_providers or []) if p}, - curated=_build_curated_lists(current_provider, current_base_url, current_model), - ) + current_provider=current_provider, current_base_url=current_base_url, current_model=current_model, + max_models=max_models, for_picker=for_picker, force_fresh_nous_tier=force_fresh_nous_tier, + probe_custom_providers=probe_custom_providers, probe_current_custom_provider=probe_current_custom_provider, + refresh=refresh, excluded={str(p).strip().lower() for p in (excluded_providers or []) if p}, + curated=_build_curated_lists(current_provider, current_base_url, current_model)) - # Warm the disk cache in parallel before the serial section loops, which - # otherwise stack 15-30s of live /v1/models round-trips on a cold cache. - # Skipped when refresh=True (serial path force-refreshes) and for <=3 - # providers (serial is fast enough; avoids thread-pool overhead). + # Warm the disk cache in parallel before the serial section loops (otherwise 15-30s of live + # round-trips on a cold cache). Skipped when refresh=True (serial path force-refreshes) and + # for <=3 providers (serial is fast enough; avoids thread-pool overhead). prefetch_slugs = [] if refresh else _collect_authed_provider_slugs(data, b.curated, excluded_providers or []) if len(prefetch_slugs) > 3: try: @@ -1388,31 +1072,30 @@ def list_authenticated_providers( _lap_bare_custom_row(b, custom_providers) if custom_providers and isinstance(custom_providers, list): _lap_custom_provider_rows(b, custom_providers) - results = b.results + return _finalize_picker_rows(b.results, user_providers, current_model) - # ``providers..enabled: false`` post-filter covers built-in rows - # (sections 1-2) that bypass the per-section gate; matched by slug and - # ``provider_id``. + +def _finalize_picker_rows(results: list, user_providers, current_model: str) -> list: + """Post-passes: drop ``providers..enabled: false`` rows, inject the current model, sort.""" + # The enabled post-filter covers built-in rows (sections 1-2) that bypass the per-section + # gate; matched by slug and ``provider_id``. try: from hermes_cli.config import is_provider_enabled if isinstance(user_providers, dict): disabled = { - str(name).strip().lower() - for name, cfg in user_providers.items() - if isinstance(cfg, dict) and not is_provider_enabled(cfg) - } + str(name).strip().lower() for name, cfg in user_providers.items() + if isinstance(cfg, dict) and not is_provider_enabled(cfg)} if disabled: results = [ r for r in results if str(r.get("provider_id", "")).strip().lower() not in disabled - and str(r.get("slug", "")).strip().lower() not in disabled - ] + and str(r.get("slug", "")).strip().lower() not in disabled] except Exception: pass - # A custom/uncurated model set via `/model /` would be - # invisible in every picker (main and MoA slot pickers read these rows); - # inject it at the front of the current provider's row as a uniform post-pass. + # A custom/uncurated model set via `/model /` would be invisible in every + # picker (main and MoA slot pickers read these rows); inject it at the front of the current + # provider's row. if current_model: for row in results: if not row.get("is_current") or row.get("native_catalog_empty"): @@ -1431,15 +1114,11 @@ def list_authenticated_providers( def _prepend_moa_picker_provider(providers: List[dict], current_provider: str = "") -> List[dict]: """Add the virtual MoA provider row used by interactive model pickers. - ``list_authenticated_providers()`` only returns real/auth-backed providers. - The CLI model inventory adds MoA separately so named presets appear next to - normal providers; gateway pickers call ``list_picker_providers()`` directly, - so they need the same virtual row here. Reuse the inventory's single row - builder so the row shape stays defined in one place. - """ + ``list_authenticated_providers()`` only returns real/auth-backed providers; the CLI inventory + adds MoA separately, so gateway pickers need the same virtual row here. Reuses the + inventory's single row builder so the row shape stays defined in one place.""" try: from hermes_cli.inventory import _moa_provider_row - moa_row = _moa_provider_row(current_provider) if moa_row is None: return providers @@ -1449,67 +1128,35 @@ def _prepend_moa_picker_provider(providers: List[dict], current_provider: str = def list_picker_providers( - current_provider: str = "", - current_base_url: str = "", - user_providers: dict = None, - custom_providers: list | None = None, - max_models: int | None = None, - current_model: str = "", - include_moa: bool = False, - excluded_providers: list | None = None, -) -> List[dict]: + current_provider: str = "", current_base_url: str = "", user_providers: dict = None, + custom_providers: list | None = None, max_models: int | None = None, current_model: str = "", + include_moa: bool = False, excluded_providers: list | None = None) -> List[dict]: """Interactive-picker variant of :func:`list_authenticated_providers`. - Post-processes the base list so the ``/model`` picker (Telegram/Discord - inline keyboards) only surfaces models that are actually callable in the - current install: - - - OpenRouter's model list is replaced with the output of - :func:`hermes_cli.models.fetch_openrouter_models`, which filters the - curated ``OPENROUTER_MODELS`` snapshot against the live OpenRouter - catalog. IDs the live catalog no longer carries drop out, so the - picker never offers a model the user can't call. - - Provider rows whose model list ends up empty are dropped, except - custom endpoints (``is_user_defined=True`` with an ``api_url``) where - the user may supply their own model set through config. - - All other providers and metadata fields are passed through unchanged. - The typed ``/model `` path is unaffected -- only the interactive - picker payload is narrowed. - """ + OpenRouter's list is replaced with :func:`hermes_cli.models.fetch_openrouter_models` (curated + snapshot filtered against the live catalog) and rows left with no models are dropped — except + custom endpoints, where the user may supply their own model set through config.""" from hermes_cli.model_switch import list_authenticated_providers from hermes_cli.models import fetch_openrouter_models - providers = list_authenticated_providers( - current_provider=current_provider, - current_base_url=current_base_url, - user_providers=user_providers, - custom_providers=custom_providers, - max_models=max_models, - current_model=current_model, - for_picker=True, - excluded_providers=excluded_providers, - ) + current_provider=current_provider, current_base_url=current_base_url, + user_providers=user_providers, custom_providers=custom_providers, max_models=max_models, + current_model=current_model, for_picker=True, excluded_providers=excluded_providers) if include_moa: providers = _prepend_moa_picker_provider(providers, current_provider=current_provider) filtered: List[dict] = [] for p in providers: - slug = str(p.get("slug", "")).lower() - if slug == "openrouter": + if str(p.get("slug", "")).lower() == "openrouter": try: - live = fetch_openrouter_models() - live_ids = [mid for mid, _ in live] + live_ids = [mid for mid, _ in fetch_openrouter_models()] except Exception: live_ids = list(p.get("models", [])) p = dict(p) p["models"] = live_ids[:max_models] if max_models is not None else live_ids p["total_models"] = len(live_ids) - has_models = bool(p.get("models")) is_custom_endpoint = bool(p.get("is_user_defined")) and bool(p.get("api_url")) - if not has_models and not is_custom_endpoint: - continue - filtered.append(p) - + if p.get("models") or is_custom_endpoint: + filtered.append(p) return filtered