"""Per-provider model-selection wizard flows for ``hermes setup`` / ``hermes model``. Contract: ``select_provider_and_model`` in main.py re-imports every ``_model_flow_*`` here, so tests patching ``hermes_cli.main._model_flow_*`` keep working. main.py-internal helpers (``_prompt_api_key``, ``_save_custom_provider``, ...) and config/auth/models functions are imported lazily inside function bodies: that avoids the main.py import cycle and lets tests patch ``hermes_cli.config.load_config`` etc. at call time. """ from __future__ import annotations from hermes_cli.cli_output import line_input import argparse import os import subprocess import urllib.parse from hermes_cli.config import clear_model_endpoint_credentials from hermes_cli.providers import custom_provider_slug # AWS cross-region inference profile prefixes. A geo-prefixed profile only routes # from endpoints in its own geography (us.* from eu-central-2 is rejected by AWS # regardless of credentials); global.* routes from everywhere. BEDROCK_GEO_PREFIXES = ( "us.", "eu.", "ap.", "apac.", "jp.", "ca.", "sa.", "me.", "af.", ) def bedrock_region_geo_prefix(region_name: str) -> str: """Map an AWS region name to its inference-profile geo prefix ('' = unknown).""" r = (region_name or "").lower() for geo, region_prefixes in ( ("us.", ("us-", "us_gov")), ("eu.", ("eu-",)), ("ap.", ("ap-",)), ("ca.", ("ca-",)), ("sa.", ("sa-",)), ("me.", ("me-",)), ("af.", ("af-",)), ): if r.startswith(region_prefixes): return geo return "" def bedrock_model_routable_from_region(model_id: str, region_name: str) -> bool: """True when *model_id* can be invoked from *region_name*'s endpoint. Bare foundation-model ids and ``global.*`` profiles route from anywhere; geo-prefixed profiles only from their own geography. Unknown regions hide nothing. """ mid = (model_id or "").lower() matched_geo = next((p for p in BEDROCK_GEO_PREFIXES if mid.startswith(p)), None) if matched_geo is None or mid.startswith("global."): return True geo = bedrock_region_geo_prefix(region_name) if not geo: return True if geo == "ap.": # Asia-Pacific regions can carry ap./apac./jp. profile spellings. return matched_geo in ("ap.", "apac.", "jp.") return matched_geo == geo # ── Shared flow helpers ────────────────────────────────────────────────── # All imports below are lazy on purpose (see module docstring). def _existing_api_key_for_model_flow(provider_id: str, pconfig) -> tuple[str, str]: """Resolve an existing wizard credential without changing its storage.""" from hermes_cli.auth import _resolve_api_key_provider_secret return _resolve_api_key_provider_secret(provider_id, pconfig) def _ensure_flow_api_key(provider_id: str, pconfig, *, missing_hint=()) -> tuple[str, str, bool]: """Resolve the stored key, print *missing_hint* lines when none exists, then run ``_prompt_api_key`` (users can replace a stale key in-flow via K/R/C). Returns ``(existing_key, resolved_key, abort)``. """ from hermes_cli.main import _prompt_api_key existing_key, existing_source = _existing_api_key_for_model_flow(provider_id, pconfig) if not existing_key: for line in missing_hint: print(line) resolved, abort = _prompt_api_key( pconfig, existing_key, provider_id=provider_id, existing_source=existing_source ) return existing_key, resolved, abort def _load_config_model_section() -> tuple[dict, dict]: """Return ``(cfg, cfg["model"])`` with the model section coerced to a dict.""" from hermes_cli.config import load_config cfg = load_config() model = cfg.get("model") if not isinstance(model, dict): model = {"default": model} if model else {} cfg["model"] = model return cfg, model def _begin_model_config(selected: str, provider: str) -> tuple[dict, dict]: """Record *selected* as the model choice and open the config model section with ``provider`` set; callers set endpoint fields then ``_commit_model_config``.""" from hermes_cli.auth import _save_model_choice _save_model_choice(selected) cfg, model = _load_config_model_section() model["provider"] = provider return cfg, model def _commit_model_config(cfg: dict) -> None: """Persist *cfg* and deactivate any OAuth provider.""" from hermes_cli.auth import deactivate_provider from hermes_cli.config import save_config save_config(cfg) deactivate_provider() def _ensure_dict_section(cfg: dict, key: str) -> dict: """Return ``cfg[key]`` as a dict, replacing a missing/non-dict value.""" section = cfg.get(key) if not isinstance(section, dict): section = {} cfg[key] = section return section def _pick_model_or_prompt(model_list, prompt: str, **kwargs): """Radio picker when *model_list* is non-empty, else a free-text ``line_input`` (None on Ctrl-C/EOF).""" from hermes_cli.auth import _prompt_model_selection if model_list: return _prompt_model_selection(model_list, **kwargs) try: return line_input(prompt).strip() except (KeyboardInterrupt, EOFError): return None def _run_login(login_fn, *args, **kwargs) -> bool: """Run an OAuth login helper; print the standard failure line and return False on SystemExit / any exception.""" try: login_fn(*args, **kwargs) except SystemExit: print("Login cancelled or failed.") return False except Exception as exc: print(f"Login failed: {exc}") return False return True def _models_dev_merged(provider_id: str, curated) -> list: """models.dev agentic models for *provider_id* plus curated ids not yet listed (case-insensitive). Empty list when models.dev has nothing / is unavailable.""" mdev_models: list = [] try: from agent.models_dev import list_agentic_models mdev_models = list_agentic_models(provider_id) except Exception: pass if not mdev_models: return [] seen = {m.lower() for m in mdev_models} merged = list(mdev_models) for m in curated: if m.lower() not in seen: merged.append(m) seen.add(m.lower()) return merged def _show_curated(model_list) -> None: if model_list: print( f' Showing {len(model_list)} curated models — use "Enter custom model name" for others.' ) def _prune_replaced_custom_model_config_credentials( base_url: str, *, provider_name: str = "", ) -> None: """Drop stale ``model_config`` credentials from inactive custom pools. ``model_config`` means "the credential currently stored under ``model.api_key``". After an explicit custom-endpoint switch, any old custom pool still carrying that source points at the previous endpoint and could be selected before the fresh config. """ try: from agent.credential_pool import ( CUSTOM_POOL_PREFIX, custom_provider_pool_key_candidates, ) from hermes_cli.auth import read_credential_pool, write_credential_pool # A keyed ``providers.`` endpoint stores under the durable slug while # legacy pools keep ``custom:``; every identity the active # endpoint may occupy must be skipped or its own legacy pool gets pruned. active_pool_keys = { str(key).strip().lower() for key in custom_provider_pool_key_candidates( base_url, provider_name=provider_name or None, ) } if not active_pool_keys: return pools = read_credential_pool(None) if not isinstance(pools, dict): return for pool_key, entries in pools.items(): if ( not isinstance(pool_key, str) or not pool_key.startswith(CUSTOM_POOL_PREFIX) or pool_key in active_pool_keys or not isinstance(entries, list) ): continue retained = [] removed_ids = [] changed = False for entry in entries: if isinstance(entry, dict) and entry.get("source") == "model_config": changed = True entry_id = entry.get("id") if entry_id: removed_ids.append(str(entry_id)) continue retained.append(entry) if changed: write_credential_pool(pool_key, retained, removed_ids=removed_ids) except Exception: return def _prompt_auth_credentials_choice(title: str) -> str: """Prompt for reuse / reauthenticate / cancel with the standard radio UI. Returns one of ``"use"``, ``"reauth"``, ``"cancel"``. Falls back to a numbered prompt when curses is unavailable (piped stdin, non-TTY). """ choices = [ "Use existing credentials", "Reauthenticate (new OAuth login)", "Cancel", ] try: from hermes_cli.setup import _curses_prompt_choice idx = _curses_prompt_choice(title, choices, 0) if idx >= 0: print() return ("use", "reauth", "cancel")[idx] except Exception: pass print(title) for i, label in enumerate(choices, 1): marker = "→" if i == 1 else " " print(f" {marker} {i}. {label}") print() try: choice = input(" Choice [1/2/3]: ").strip() except (KeyboardInterrupt, EOFError): choice = "1" if choice == "2": return "reauth" if choice == "3": return "cancel" return "use" def _model_flow_openrouter(config, current_model=""): """OpenRouter provider: ensure API key, then pick model.""" from hermes_constants import OPENROUTER_BASE_URL from hermes_cli.auth import ProviderConfig, _prompt_model_selection # OpenRouter isn't in PROVIDER_REGISTRY so we synthesize a minimal pconfig. pconfig = ProviderConfig( id="openrouter", name="OpenRouter", auth_type="api_key", api_key_env_vars=("OPENROUTER_API_KEY",), ) existing_key, _resolved, abort = _ensure_flow_api_key( "openrouter", pconfig, missing_hint=("Get one at: https://openrouter.ai/keys", "") ) if abort: return from hermes_cli.models import model_ids, get_pricing_for_provider openrouter_models = model_ids(force_refresh=True) # Live pricing is non-blocking — empty dict on failure. pricing = get_pricing_for_provider("openrouter", force_refresh=True) selected = _prompt_model_selection( openrouter_models, current_model=current_model, pricing=pricing, confirm_provider="openrouter", confirm_base_url=OPENROUTER_BASE_URL, confirm_api_key=_resolved or existing_key, ) if selected: cfg, model = _begin_model_config(selected, "openrouter") model["base_url"] = OPENROUTER_BASE_URL model["api_mode"] = "chat_completions" clear_model_endpoint_credentials(model, clear_api_mode=False) _commit_model_config(cfg) print(f"Default model set to: {selected} (via OpenRouter)") else: print("No change.") def _print_moa_preset(name: str, preset: dict) -> None: """Print the full reference-models + aggregator breakdown for a preset.""" print(f" Preset: {name}") print(" Reference models:") for idx, slot in enumerate(preset.get("reference_models") or [], start=1): print(f" {idx}. {slot.get('provider')}:{slot.get('model')}") agg = preset.get("aggregator") or {} print(f" Aggregator: {agg.get('provider')}:{agg.get('model')}") def _model_flow_ai_gateway(config, current_model=""): """Vercel AI Gateway provider: ensure API key, then pick model with pricing.""" from hermes_constants import AI_GATEWAY_BASE_URL from hermes_cli.main import _prompt_api_key from hermes_cli.auth import PROVIDER_REGISTRY, _prompt_model_selection from hermes_cli.config import get_env_value pconfig = PROVIDER_REGISTRY["ai-gateway"] existing_key = get_env_value("AI_GATEWAY_API_KEY") or "" if not existing_key: print( "Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway" ) print("Add a payment method to get $5 in free credits.") print() _resolved, abort = _prompt_api_key(pconfig, existing_key, provider_id="ai-gateway") if abort: return from hermes_cli.models import ai_gateway_model_ids, get_pricing_for_provider models_list = ai_gateway_model_ids(force_refresh=True) pricing = get_pricing_for_provider("ai-gateway", force_refresh=True) selected = _prompt_model_selection( models_list, current_model=current_model, pricing=pricing ) if selected: cfg, model = _begin_model_config(selected, "ai-gateway") model["base_url"] = AI_GATEWAY_BASE_URL model["api_mode"] = "chat_completions" _commit_model_config(cfg) print(f"Default model set to: {selected} (via Vercel AI Gateway)") else: print("No change.") def _model_flow_moa(config, current_model=""): """Mixture of Agents virtual provider: pick a preset, then persist it. No credential step — presets reference already-configured providers. The preset list is always shown (even with one entry), then the full breakdown on selection. """ from hermes_cli.auth import _save_model_choice from hermes_cli.moa_config import normalize_moa_config moa = normalize_moa_config(config.get("moa") if isinstance(config, dict) else {}) presets = moa.get("presets") or {} if not presets: print("No MoA presets configured. Run `hermes moa configure ` first.") return names = list(presets.keys()) default_name = moa.get("default_preset") or names[0] # Rows show the aggregator so the picker is informative before drilling in. rows = [] for n in names: agg = (presets[n].get("aggregator") or {}) agg_label = f"{agg.get('provider')}:{agg.get('model')}" if agg else "" ref_count = len(presets[n].get("reference_models") or []) suffix = " ← default" if n == default_name else "" rows.append(f"{n} (agg {agg_label}, {ref_count} refs){suffix}") default_idx = names.index(default_name) if default_name in names else 0 try: from hermes_cli.setup import _curses_prompt_choice idx = _curses_prompt_choice("Select a Mixture of Agents preset:", rows, default_idx) except Exception: print("Select a Mixture of Agents preset:") for i, row in enumerate(rows, 1): marker = "→" if (i - 1) == default_idx else " " print(f" {marker} {i}. {row}") try: raw = input(f" Choice [1-{len(rows)}]: ").strip() except (KeyboardInterrupt, EOFError): print("No change.") return if not raw: idx = default_idx else: try: idx = max(0, min(len(rows) - 1, int(raw) - 1)) except ValueError: print("No change.") return if idx is None or idx < 0: print("No change.") return selected_name = names[idx] preset = presets[selected_name] cfg, model = _load_config_model_section() model["default"] = selected_name model["provider"] = "moa" # Virtual local provider: drop stale endpoint credentials AND base_url (which # clear_model_endpoint_credentials intentionally leaves alone). clear_model_endpoint_credentials(model, clear_api_mode=True) model.pop("base_url", None) _commit_model_config(cfg) _save_model_choice(selected_name) print() print(f"Default model set to: {selected_name} (via Mixture of Agents)") _print_moa_preset(selected_name, preset) def _nous_login_args(args) -> argparse.Namespace: return argparse.Namespace( portal_url=getattr(args, "portal_url", None), inference_url=getattr(args, "inference_url", None), client_id=getattr(args, "client_id", None), scope=getattr(args, "scope", None), no_browser=bool(getattr(args, "no_browser", False)), timeout=getattr(args, "timeout", None) or 15.0, ca_bundle=getattr(args, "ca_bundle", None), insecure=bool(getattr(args, "insecure", False)), ) def _model_flow_nous(config, current_model="", args=None): """Nous Portal provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_provider_auth_state, _prompt_model_selection, _save_model_choice, _update_config_for_provider, resolve_nous_runtime_credentials, AuthError, format_auth_error, _login_nous, PROVIDER_REGISTRY, ) from hermes_cli.config import ( get_env_value, load_config, save_config, save_env_value, ) from hermes_cli.nous_subscription import prompt_enable_tool_gateway state = get_provider_auth_state("nous") if not state or not state.get("access_token"): print("Not logged into Nous Portal. Starting login...") print() try: _login_nous(_nous_login_args(args), PROVIDER_REGISTRY["nous"]) # Offer Tool Gateway enablement for paid subscribers try: prompt_enable_tool_gateway(load_config() or {}) except Exception: pass except SystemExit: print("Login cancelled or failed.") return except Exception as exc: print(f"Login failed: {exc}") return # login_nous already handles model selection + config update return # Already logged in — the curated list (agentic models users know from # OpenRouter) instead of the hundreds returned by the live /models endpoint. from hermes_cli.models import ( get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, union_with_portal_free_recommendations, union_with_portal_paid_recommendations, ) model_ids = get_curated_nous_model_ids() if not model_ids: print("No curated models available for Nous Portal.") return # Verify credentials are still valid (catches expired sessions early) try: creds = resolve_nous_runtime_credentials() except Exception as exc: relogin = isinstance(exc, AuthError) and exc.relogin_required msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) if relogin: print(f"Session expired: {msg}") print("Re-authenticating with Nous Portal...\n") try: _login_nous(_nous_login_args(None), PROVIDER_REGISTRY["nous"]) except Exception as login_exc: print(f"Re-login failed: {login_exc}") return print(f"Could not verify credentials: {msg}") return pricing = get_pricing_for_provider("nous") # Force fresh account data so recent credit purchases are reflected immediately. free_tier = check_nous_free_tier(force_fresh=True) if not free_tier: try: refreshed_creds = resolve_nous_runtime_credentials( force_refresh=True, ) if refreshed_creds: creds = refreshed_creds except Exception: # Runtime inference has its own paid-entitlement recovery; don't block. pass # Portal URL is needed for upgrade links and the recommendations endpoints. _nous_portal_url = "" try: _nous_state = get_provider_auth_state("nous") if _nous_state: _nous_portal_url = _nous_state.get("portal_base_url", "") except Exception: pass # Free users: augment with the Portal's freeRecommendedModels (so newly launched # free models appear before this build's curated list catches up), then partition # into selectable/unavailable by Portal pricing. Paid users: same idea with # paidRecommendedModels, no partition. unavailable_models: list[str] = [] unavailable_message = "" # Org policy narrows BEFORE the tier split, so a rescued id still has to pass # the free/paid predicate instead of going around it. from hermes_cli.models import nous_policy_allowed_ids, restrict_to_nous_policy _policy_allowed = nous_policy_allowed_ids() if free_tier: try: from hermes_cli.nous_account import ( format_nous_portal_entitlement_message, get_nous_portal_account_info, ) _account_info = get_nous_portal_account_info(force_fresh=True) unavailable_message = ( format_nous_portal_entitlement_message( _account_info, capability="paid Nous models", ) or "" ) except Exception: unavailable_message = "" model_ids, pricing = union_with_portal_free_recommendations( model_ids, pricing, _nous_portal_url, ) else: model_ids, pricing = union_with_portal_paid_recommendations( model_ids, pricing, _nous_portal_url, ) _before_policy = model_ids model_ids = restrict_to_nous_policy( model_ids, _policy_allowed, rescue_empty=True, ) _policy_narrowed = model_ids != _before_policy if free_tier: model_ids, unavailable_models = partition_nous_models_by_tier( model_ids, pricing, free_tier=True ) if not model_ids and not unavailable_models: print("No models available for Nous Portal after filtering.") return if free_tier and not model_ids: print("No free models currently available.") if unavailable_models: from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL _url = (_nous_portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") print(unavailable_message or f"Upgrade at {_url} to access paid models.") return from hermes_cli.nous_account import nous_policy_notice _policy_notice = nous_policy_notice(removed=_policy_narrowed) if _policy_notice: print(_policy_notice) print( f'Showing {len(model_ids)} curated models — use "Enter custom model name" for others.' ) selected = _prompt_model_selection( model_ids, current_model=current_model, pricing=pricing, unavailable_models=unavailable_models, portal_url=_nous_portal_url, unavailable_message=unavailable_message, confirm_provider="nous", confirm_base_url=creds.get("base_url", ""), confirm_api_key=creds.get("api_key", ""), ) if selected: _save_model_choice(selected) inference_url = creds.get("base_url", "") _update_config_for_provider("nous", inference_url) # Reload after the auth helper writes provider state; the incoming config # object may still contain stale custom-provider fields. config = load_config() current_model_cfg = config.get("model") if isinstance(current_model_cfg, dict): model_cfg = dict(current_model_cfg) elif isinstance(current_model_cfg, str) and current_model_cfg.strip(): model_cfg = {"default": current_model_cfg.strip()} else: model_cfg = {} model_cfg["provider"] = "nous" model_cfg["default"] = selected if inference_url and inference_url.strip(): model_cfg["base_url"] = inference_url.rstrip("/") else: model_cfg.pop("base_url", None) clear_model_endpoint_credentials(model_cfg) config["model"] = model_cfg # Clear any custom endpoint that might conflict if get_env_value("OPENAI_BASE_URL"): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") save_config(config) print(f"Default model set to: {selected} (via Nous Portal)") # Offer Tool Gateway enablement for paid subscribers prompt_enable_tool_gateway(config) else: print("No change.") def _model_flow_openai_codex(config, current_model=""): """OpenAI Codex provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_codex_auth_status, _prompt_model_selection, _save_model_choice, _update_config_for_provider, _login_openai_codex, PROVIDER_REGISTRY, DEFAULT_CODEX_BASE_URL, ) from hermes_cli.codex_models import get_codex_model_ids status = get_codex_auth_status() if status.get("logged_in"): print(" OpenAI Codex credentials: ✓") print() choice = _prompt_auth_credentials_choice("OpenAI Codex credentials:") if choice == "reauth": print("Starting a fresh OpenAI Codex login...") print() if not _run_login( _login_openai_codex, argparse.Namespace(), PROVIDER_REGISTRY["openai-codex"], force_new_login=True, ): return status = get_codex_auth_status() if not status.get("logged_in"): print("Login failed.") return elif choice == "cancel": return else: print("Not logged into OpenAI Codex. Starting login...") print() if not _run_login(_login_openai_codex, argparse.Namespace(), PROVIDER_REGISTRY["openai-codex"]): return # Prefer the credential pool (where `hermes auth` stores device_code tokens), # fall back to legacy provider state. _codex_token = None try: _codex_status = get_codex_auth_status() if _codex_status.get("logged_in"): _codex_token = _codex_status.get("api_key") except Exception: pass if not _codex_token: try: from hermes_cli.auth import resolve_codex_runtime_credentials _codex_token = resolve_codex_runtime_credentials().get("api_key") except Exception: pass codex_models = get_codex_model_ids(access_token=_codex_token) selected = _prompt_model_selection( codex_models, current_model=current_model, confirm_provider="openai-codex", confirm_base_url=DEFAULT_CODEX_BASE_URL, confirm_api_key=_codex_token or "", ) if selected: _save_model_choice(selected) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) print(f"Default model set to: {selected} (via OpenAI Codex)") else: print("No change.") def _model_flow_xai_oauth(_config, current_model="", *, args=None): """xAI Grok OAuth (SuperGrok / Premium+) provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_xai_oauth_auth_status, _prompt_model_selection, _save_model_choice, _update_config_for_provider, resolve_xai_oauth_runtime_credentials, _login_xai_oauth, DEFAULT_XAI_OAUTH_BASE_URL, PROVIDER_REGISTRY, ) from hermes_cli.models import provider_model_ids def _login_args(): return argparse.Namespace( no_browser=bool(getattr(args, "no_browser", False)), timeout=getattr(args, "timeout", None), ) status = get_xai_oauth_auth_status() if status.get("logged_in"): print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓") print() choice = _prompt_auth_credentials_choice( "xAI Grok OAuth (SuperGrok / Premium+) credentials:" ) if choice == "reauth": print("Starting a fresh xAI OAuth login...") print() if not _run_login( _login_xai_oauth, _login_args(), PROVIDER_REGISTRY["xai-oauth"], force_new_login=True ): return elif choice == "cancel": return else: print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...") print() if not _run_login(_login_xai_oauth, _login_args(), PROVIDER_REGISTRY["xai-oauth"]): return # ``resolve_xai_oauth_runtime_credentials`` only reads the auth.json singleton, # but credentials may live only in the pool (``hermes auth add xai-oauth``) — # fall back to the default base URL so the picker still completes. base_url = DEFAULT_XAI_OAUTH_BASE_URL try: creds = resolve_xai_oauth_runtime_credentials() base_url = (creds.get("base_url") or "").strip().rstrip("/") or base_url except Exception: pass models = provider_model_ids("xai-oauth") selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-4.6")) if selected: _save_model_choice(selected) _update_config_for_provider("xai-oauth", base_url) print(f"Default model set to: {selected} (via xAI Grok OAuth — SuperGrok / Premium+)") else: print("No change.") def _model_flow_qwen_oauth(_config, current_model=""): """Qwen OAuth provider: reuse local Qwen CLI login, then pick model.""" from hermes_cli.main import _DEFAULT_QWEN_PORTAL_MODELS from hermes_cli.auth import ( get_qwen_auth_status, resolve_qwen_runtime_credentials, _prompt_model_selection, _save_model_choice, _update_config_for_provider, DEFAULT_QWEN_BASE_URL, ) from hermes_cli.models import fetch_api_models status = get_qwen_auth_status() if not status.get("logged_in"): print("Not logged into Qwen CLI OAuth.") print("Run: qwen auth qwen-oauth") auth_file = status.get("auth_file") if auth_file: print(f"Expected credentials file: {auth_file}") if status.get("error"): print(f"Error: {status.get('error')}") return # Try live model discovery, fall back to curated list. models = None try: creds = resolve_qwen_runtime_credentials(refresh_if_expiring=True) models = fetch_api_models(creds["api_key"], creds["base_url"]) except Exception: pass if not models: models = list(_DEFAULT_QWEN_PORTAL_MODELS) default = current_model or (models[0] if models else "qwen3-coder-plus") selected = _prompt_model_selection( models, current_model=default, confirm_provider="qwen-oauth", confirm_base_url=DEFAULT_QWEN_BASE_URL, ) if selected: _save_model_choice(selected) _update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL) print(f"Default model set to: {selected} (via Qwen OAuth)") else: print("No change.") def _model_flow_minimax_oauth(config, current_model="", args=None): """MiniMax OAuth provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_provider_auth_state, _prompt_model_selection, _save_model_choice, _update_config_for_provider, resolve_minimax_oauth_runtime_credentials, AuthError, format_auth_error, _login_minimax_oauth, PROVIDER_REGISTRY, ) state = get_provider_auth_state("minimax-oauth") if not state or not state.get("access_token"): print("Not logged into MiniMax. Starting OAuth login...") print() mock_args = argparse.Namespace( region=getattr(args, "region", None) or "global", no_browser=bool(getattr(args, "no_browser", False)), timeout=getattr(args, "timeout", None) or 15.0, ) if not _run_login(_login_minimax_oauth, mock_args, PROVIDER_REGISTRY["minimax-oauth"]): return try: creds = resolve_minimax_oauth_runtime_credentials() except AuthError as exc: print(format_auth_error(exc)) return from hermes_cli.models import _PROVIDER_MODELS model_ids = _PROVIDER_MODELS.get("minimax-oauth", []) selected = _prompt_model_selection( model_ids, current_model, confirm_provider="minimax-oauth", confirm_base_url=creds["base_url"], ) if not selected: return _save_model_choice(selected) _update_config_for_provider("minimax-oauth", creds["base_url"]) print(f"\u2713 Using MiniMax model: {selected}") def _model_flow_custom(config): """Custom endpoint: collect URL, API key, and model name. Also saves the endpoint to ``custom_providers`` in config.yaml so it appears in the provider menu on subsequent runs. """ from hermes_cli.main import _auto_provider_name, _prompt_custom_api_mode_selection, _save_custom_provider from hermes_cli.auth import _save_model_choice, deactivate_provider from hermes_cli.config import custom_endpoint_key_env, get_env_value, save_env_value from hermes_cli.secret_prompt import masked_secret_prompt current_url = get_env_value("OPENAI_BASE_URL") or "" current_key = get_env_value("OPENAI_API_KEY") or "" print("Custom OpenAI-compatible endpoint configuration:") if current_url: print(f" Current URL: {current_url}") if current_key: print(f" Current key: {current_key[:8]}...") print() try: base_url = line_input( f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: " ).strip() api_key = masked_secret_prompt( f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: " ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return if not base_url and not current_url: print("No URL provided. Cancelled.") return effective_url = base_url or current_url if not effective_url.startswith(("http://", "https://")): print(f"Invalid URL: {effective_url} (must start with http:// or https://)") return effective_key = api_key or current_key # Most local servers (Ollama, vLLM, llama.cpp) need /v1 for OpenAI-compatible # chat completions — offer to append it when the URL looks local without it. _url_lower = effective_url.rstrip("/").lower() _looks_local = any( h in _url_lower for h in ("localhost", "127.0.0.1", "0.0.0.0", ":11434", ":8080", ":5000") ) if _looks_local and not _url_lower.endswith("/v1"): print() print(" Hint: Did you mean to add /v1 at the end?") print(" Most local model servers (Ollama, vLLM, llama.cpp) require it.") print(f" e.g. {effective_url.rstrip('/')}/v1") try: _add_v1 = input(" Add /v1? [Y/n]: ").strip().lower() except (KeyboardInterrupt, EOFError): _add_v1 = "n" if _add_v1 in {"", "y", "yes"}: effective_url = effective_url.rstrip("/") + "/v1" if base_url: base_url = effective_url print(f" Updated URL: {effective_url}") print() from hermes_cli.models import probe_api_models probe = probe_api_models(effective_key, effective_url) if probe.get("used_fallback") and probe.get("resolved_base_url"): print( f"Warning: endpoint verification worked at {probe['resolved_base_url']}/models, " f"not the exact URL you entered. Saving the working base URL instead." ) effective_url = probe["resolved_base_url"] if base_url: base_url = effective_url elif probe.get("models") is not None: print( f"Verified endpoint via {probe.get('probed_url')} " f"({len(probe.get('models') or [])} model(s) visible)" ) else: print( f"Warning: could not verify this endpoint via {probe.get('probed_url')}. " f"Hermes will still save it." ) if probe.get("suggested_base_url"): suggested = probe["suggested_base_url"] if suggested.endswith("/v1"): print( f" If this server expects /v1 in the path, try base URL: {suggested}" ) else: print(f" If /v1 should not be in the base URL, try: {suggested}") # Ask for the API mode explicitly so codex-compatible custom providers don't # silently fall back to chat_completions. current_model_cfg = config.get("model") current_api_mode = "" if isinstance(current_model_cfg, dict): current_api_mode = str(current_model_cfg.get("api_mode") or "").strip() api_mode = _prompt_custom_api_mode_selection( effective_url, current_api_mode=current_api_mode, ) if api_mode: print(f" API mode: {api_mode}") else: print(" API mode: auto-detect") # Select model — use probe results when available, fall back to manual input model_name = "" detected_models = probe.get("models") or [] try: if len(detected_models) == 1: print(f" Detected model: {detected_models[0]}") confirm = input(" Use this model? [Y/n]: ").strip().lower() if confirm in {"", "y", "yes"}: model_name = detected_models[0] else: model_name = line_input("Model name (e.g. gpt-4, llama-3-70b): ").strip() elif len(detected_models) > 1: print(" Available models:") for i, m in enumerate(detected_models, 1): print(f" {i}. {m}") pick = input( f" Select model [1-{len(detected_models)}] or type name: " ).strip() if pick.isdigit() and 1 <= int(pick) <= len(detected_models): model_name = detected_models[int(pick) - 1] elif pick: model_name = pick else: model_name = line_input("Model name (e.g. gpt-4, llama-3-70b): ").strip() context_length_str = line_input( "Context length in tokens [leave blank for auto-detect]: " ).strip() # Display name — shown in the provider menu on future runs default_name = _auto_provider_name(effective_url) display_name = line_input(f"Display name [{default_name}]: ").strip() or default_name except (KeyboardInterrupt, EOFError): print("\nCancelled.") return context_length = None if context_length_str: try: context_length = int( context_length_str.replace(",", "") .replace("k", "000") .replace("K", "000") ) if context_length <= 0: context_length = None except ValueError: print(f"Invalid context length: {context_length_str} — will auto-detect.") context_length = None # The key goes to .env and config.yaml only references it. Keyed on host:port # so two servers on one machine keep separate credentials. custom_key_env = "" if effective_key: _parsed = urllib.parse.urlparse(effective_url) _identity = _parsed.hostname or "" if _parsed.port: _identity = f"{_identity}_{_parsed.port}" custom_key_env = custom_endpoint_key_env(_identity) save_env_value(custom_key_env, effective_key) print(f" API key saved to .env as {custom_key_env}") def _apply_endpoint(model: dict) -> None: model["provider"] = "custom" model["base_url"] = effective_url if custom_key_env: model["api_key"] = f"${{{custom_key_env}}}" if api_mode: model["api_mode"] = api_mode else: model.pop("api_mode", None) if model_name: _save_model_choice(model_name) cfg, model = _load_config_model_section() _apply_endpoint(model) _commit_model_config(cfg) # Sync the caller's config dict so the setup wizard's final save_config(config) # doesn't overwrite model.provider/base_url with its stale values. config["model"] = dict(model) print(f"Default model set to: {model_name} (via {effective_url})") else: if base_url or api_key: deactivate_provider() # Even without a model name, persist the endpoint on the caller's config dict. _caller_model = config.get("model") if not isinstance(_caller_model, dict): _caller_model = {"default": _caller_model} if _caller_model else {} _apply_endpoint(_caller_model) config["model"] = _caller_model print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") # Auto-save to custom_providers so it appears in the menu next time _save_custom_provider( effective_url, effective_key, model_name or "", context_length=context_length, name=display_name, api_mode=api_mode, key_env=custom_key_env, ) _prune_replaced_custom_model_config_credentials( effective_url, provider_name=display_name, ) def _model_flow_azure_foundry(config, current_model=""): """Azure Foundry provider: configure endpoint, auth mode, API mode, and model. Two transports (OpenAI-style ``/v1/chat/completions``, Anthropic-style ``/v1/messages``) and two auth modes: **API key** (``AZURE_FOUNDRY_API_KEY``) or **Microsoft Entra ID** (keyless RBAC via ``azure-identity``; the same ``Azure AI User`` role covers both transports — OpenAI SDK takes a callable ``api_key``, Anthropic gets a bearer-injecting ``httpx.Client`` from :func:`agent.azure_identity_adapter.build_bearer_http_client`). Detection order: ``/anthropic`` URL suffix → Anthropic; ``GET /models`` success → OpenAI-style + model picker; Anthropic Messages probe; manual entry. Context length resolves via :func:`agent.model_metadata.get_model_context_length`. """ from hermes_cli.config import get_env_value, save_env_value from hermes_cli import azure_detect # ── Load current Azure Foundry configuration ───────────────────── model_cfg = config.get("model", {}) if isinstance(model_cfg, dict) and model_cfg.get("provider") == "azure-foundry": current_base_url = str(model_cfg.get("base_url", "") or "") current_api_mode = str(model_cfg.get("api_mode", "") or "") current_auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key" _cur_entra = model_cfg.get("entra") or {} current_entra = _cur_entra if isinstance(_cur_entra, dict) else {} else: current_base_url = "" current_api_mode = "" current_auth_mode = "api_key" current_entra = {} current_api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or "" def _mode_label(mode: str) -> str: return "OpenAI-style" if mode == "chat_completions" else "Anthropic-style" print() print("Azure Foundry Configuration") print("=" * 50) print() print("Azure Foundry can host models with either OpenAI-style or") print("Anthropic-style API endpoints. Hermes will probe your") print("endpoint to auto-detect the transport and the deployed") print("models when possible.") print() if current_base_url: print(f" Current endpoint: {current_base_url}") if current_api_mode: print(f" Current API mode: {_mode_label(current_api_mode)}") if current_auth_mode == "entra_id": print(" Current auth mode: Microsoft Entra ID (keyless)") elif current_api_key: print(f" Current auth mode: API key ({current_api_key[:8]}...)") print() # ── Step 1: endpoint URL ───────────────────────────────────────── try: _placeholder = ( current_base_url or "e.g. https://.openai.azure.com/openai/v1 " "or https://.services.ai.azure.com/anthropic" ) base_url = line_input( f"API endpoint URL [{_placeholder}]: " ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return effective_url = (base_url or current_base_url).rstrip("/") if not effective_url: print("No endpoint URL provided. Cancelled.") return if not effective_url.startswith(("http://", "https://")): print(f"Invalid URL: {effective_url} (must start with http:// or https://)") return # ── Step 2: authentication mode ────────────────────────────────── print() print("Authentication:") print(" 1. API key (AZURE_FOUNDRY_API_KEY in .env)") print(" 2. Microsoft Entra ID (managed identity / workload identity / az login)") print(" Recommended by Microsoft. Works for both OpenAI-style and Anthropic-style endpoints.") print(" Requires the 'Azure AI User' role on the Foundry resource.") try: _auth_default = "2" if current_auth_mode == "entra_id" else "1" auth_choice = ( input(f"Authentication mode [1/2] ({_auth_default}): ").strip() or _auth_default ) except (KeyboardInterrupt, EOFError): print("\nCancelled.") return use_entra = auth_choice == "2" # ── Step 3: credentials (key OR Entra preflight) ───────────────── effective_key: str = "" entra_overrides: dict = {} token_provider = None # callable when entra if use_entra: try: from agent.azure_identity_adapter import ( EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider, describe_active_credential, has_azure_identity_installed, ) except ImportError as exc: print() print(f"⚠ Could not import azure-identity adapter: {exc}") print(" Falling back to API key auth.") use_entra = False if use_entra: print() if not has_azure_identity_installed(): print("◐ The 'azure-identity' package is not installed yet.") print( " Hermes will install it now (the preflight below " "triggers the lazy-install). To skip lazy installs, " "run: pip install azure-identity" ) # Only the optional scope override is persisted; identity selection (tenant, # user-assigned MI, workload identity, SP) stays in AZURE_* SDK env vars. _persisted_scope_override = str(current_entra.get("scope") or "").strip() entra_scope = _persisted_scope_override or SCOPE_AI_AZURE_DEFAULT if _persisted_scope_override: entra_overrides["scope"] = _persisted_scope_override print() print("◐ Probing Microsoft Entra ID credential chain (up to 10s)...") _config = EntraIdentityConfig( scope=entra_scope, ) info = describe_active_credential(config=_config, timeout_seconds=10.0) if info.get("ok"): env_sources = info.get("env_sources") or [] tag = ", ".join(env_sources) if env_sources else "default chain" print(f"✓ Entra ID token acquired ({tag}, scope={entra_scope})") else: err = info.get("error") or "credential chain exhausted" hint = info.get("hint") or ( "Run `az login`, attach a managed identity to this VM, or " "set AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET." ) print(f"⚠ {err}") print(f" Hint: {hint}") try: ans = input("Save Entra config anyway and validate later? [Y/n]: ").strip().lower() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return if ans and ans not in ("y", "yes"): print("Cancelled.") return # Best-effort token provider for the detection probe; on failure the probe # falls back to manual entry. try: token_provider = build_token_provider(config=_config) except Exception as exc: print(f"⚠ Could not build token provider for probing: {exc}") token_provider = None else: print() from hermes_cli.secret_prompt import masked_secret_prompt try: api_key = masked_secret_prompt( f"API key [{current_api_key[:8] + '...' if current_api_key else 'required'}]: " ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return effective_key = api_key or current_api_key if not effective_key: print("No API key provided. Cancelled.") return # ── Step 4: auto-detect transport + models ─────────────────────── print() print("◐ Probing endpoint to auto-detect transport and models...") detection = azure_detect.detect( effective_url, api_key=effective_key, token_provider=token_provider, ) discovered_models: list[str] = list(detection.models) api_mode: str = detection.api_mode or "" if api_mode: print(f"✓ Detected API transport: {_mode_label(api_mode)}") if detection.reason: print(f" ({detection.reason})") if discovered_models: print( f"✓ Found {len(discovered_models)} deployed model(s) on this endpoint" ) else: print(f"⚠ Auto-detection incomplete: {detection.reason}") print() print("Select the API format your Azure Foundry endpoint uses:") print(" 1. OpenAI-style (POST /v1/chat/completions)") print(" For: GPT models, Llama, Mistral, and most open models") print(" 2. Anthropic-style (POST /v1/messages)") print(" For: Claude models deployed via Anthropic API format") try: default_choice = "2" if current_api_mode == "anthropic_messages" else "1" mode_choice = ( input(f"API format [1/2] ({default_choice}): ").strip() or default_choice ) except (KeyboardInterrupt, EOFError): print("\nCancelled.") return api_mode = "anthropic_messages" if mode_choice == "2" else "chat_completions" # ── Step 5: model name ─────────────────────────────────────────── print() effective_model = "" if discovered_models: print("Available models on this endpoint:") for i, mid in enumerate(discovered_models[:30], start=1): print(f" {i:>2}. {mid}") if len(discovered_models) > 30: print( f" ... and {len(discovered_models) - 30} more (type name manually if not shown)" ) print() try: pick = input( f"Pick by number, or type a deployment name [{current_model or discovered_models[0]}]: " ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return if not pick: effective_model = current_model or discovered_models[0] elif pick.isdigit() and 1 <= int(pick) <= min(len(discovered_models), 30): effective_model = discovered_models[int(pick) - 1] else: effective_model = pick else: try: model_name = line_input( f"Model / deployment name [{current_model or 'e.g. gpt-5.4, claude-sonnet-4-6'}]: " ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return effective_model = model_name or current_model if not effective_model: print("No model name provided. Cancelled.") return # ── Step 6: context-length lookup ──────────────────────────────── ctx_len = azure_detect.lookup_context_length( effective_model, effective_url, api_key=effective_key, token_provider=token_provider, ) # ── Step 7: persist ────────────────────────────────────────────── if not use_entra: save_env_value("AZURE_FOUNDRY_API_KEY", effective_key) cfg, model = _load_config_model_section() model["provider"] = "azure-foundry" model["base_url"] = effective_url model["api_mode"] = api_mode model["default"] = effective_model model["auth_mode"] = "entra_id" if use_entra else "api_key" clear_model_endpoint_credentials(model, clear_api_mode=False) # Persist only a non-default Entra scope so config.yaml stays tidy. clean_entra = {k: v for k in ("scope",) if (v := entra_overrides.get(k))} if use_entra and clean_entra: model["entra"] = clean_entra else: model.pop("entra", None) if ctx_len: model["context_length"] = ctx_len _commit_model_config(cfg) config["model"] = dict(model) # Clear conflicting env vars so auxiliary clients don't pick up a stale # OpenAI base URL / key. if get_env_value("OPENAI_BASE_URL"): save_env_value("OPENAI_BASE_URL", "") if get_env_value("OPENAI_API_KEY"): save_env_value("OPENAI_API_KEY", "") auth_label = ( "Microsoft Entra ID (keyless)" if use_entra else "API key" ) print() print("✓ Azure Foundry configured:") print(f" Endpoint: {effective_url}") print(f" API mode: {_mode_label(api_mode)}") print(f" Auth: {auth_label}") print(f" Model: {effective_model}") if ctx_len: print(f" Context length: {ctx_len:,} tokens") else: print(" Context length: not auto-detected (will fall back at runtime)") print() def _model_flow_named_custom(config, provider_info): """Handle a named custom provider from config.yaml custom_providers list. Probes the endpoint's model catalog (native ``/api/tags`` for endpoints conservatively identified as Ollama); a previously saved model is pre-selected and used as the fallback when probing fails. """ from hermes_cli.main import _custom_provider_api_key_config_value, _custom_provider_base_url_config_value, _save_custom_provider from hermes_cli.auth import _save_model_choice from hermes_cli.config import load_config, normalize_extra_headers, save_config from hermes_cli.model_switch import ( _entry_models_discovered, _models_config_is_allowlist, ) from hermes_cli.models import ( fetch_api_models, fetch_ollama_local_models, _get_ollama_native_headers, _normalize_openai_base_url, should_use_ollama_native_catalog, ) name = provider_info["name"] base_url = provider_info["base_url"] api_mode = provider_info.get("api_mode", "") api_key = provider_info.get("api_key", "") key_env = provider_info.get("key_env", "") saved_model = provider_info.get("model", "") provider_key = (provider_info.get("provider_key") or "").strip() # Resolve key from env var if api_key not set directly if not api_key and key_env: api_key = os.environ.get(key_env, "") config_api_key = _custom_provider_api_key_config_value(provider_info, api_key) # ``discover_models: false`` (default True) uses the configured ``models:`` list # verbatim and skips the live probe, so operators can restrict the picker to the # subset their plan serves. Same semantics as the slash-command picker. discover = provider_info.get("discover_models", True) if isinstance(discover, str): discover = discover.lower() not in {"false", "no", "0"} configured_models: list[str] = [] native_catalog_empty = False cfg_models = provider_info.get("models", {}) explicit_catalog = _models_config_is_allowlist( cfg_models, _entry_models_discovered(provider_info) ) if isinstance(cfg_models, dict): configured_models = [ str(m) for m in cfg_models if m not in { "__explicit_model_allowlist__", "__discovered_model_catalog__", } and str(m).strip() ] elif isinstance(cfg_models, list): for model_entry in cfg_models: if isinstance(model_entry, dict): model_id = str(model_entry.get("id") or model_entry.get("model") or "").strip() else: model_id = str(model_entry).strip() if isinstance(model_entry, str) else "" if model_id: configured_models.append(model_id) print(f" Provider: {name}") print(f" URL: {base_url}") if saved_model: print(f" Current: {saved_model}") print() if not discover: # Never probe. The active model is a usable sole choice, not a catalog. models = configured_models or ([saved_model] if saved_model else []) print( "Using configured models (discover_models: false): " f"{len(models)}" ) else: print("Fetching available models...") fetch_kwargs = {"timeout": 8.0} if api_mode: fetch_kwargs["api_mode"] = api_mode native_catalog_provider = ( "ollama" if provider_key.lower() == "ollama" or name.strip().lower() == "ollama" else "custom" ) extra_headers = normalize_extra_headers(provider_info.get("extra_headers")) or {} candidate_headers = _get_ollama_native_headers(base_url, api_key=api_key) for key in tuple(candidate_headers): if any(key.lower() == existing.lower() for existing in extra_headers): del candidate_headers[key] candidate_headers.update(extra_headers) caller_has_authorization = any( key.lower() == "authorization" for key in extra_headers ) if api_key and not caller_has_authorization: for key in tuple(candidate_headers): if key.lower() == "authorization": del candidate_headers[key] candidate_headers["Authorization"] = f"Bearer {api_key}" use_native = should_use_ollama_native_catalog( native_catalog_provider, base_url, headers=candidate_headers or None ) native_headers_arg = candidate_headers or None if use_native else (extra_headers or None) if use_native: if explicit_catalog and configured_models: live_models = configured_models else: live_models = fetch_ollama_local_models( base_url, timeout=8.0, headers=native_headers_arg, ) native_catalog_empty = live_models == [] if live_models is None: live_models = fetch_api_models( api_key, _normalize_openai_base_url(base_url), headers=native_headers_arg, **fetch_kwargs, ) native_catalog_empty = False else: live_models = fetch_api_models( api_key, base_url, headers=native_headers_arg, **fetch_kwargs ) models = ( configured_models if explicit_catalog else [] if native_catalog_empty else (live_models or configured_models) ) # Persist the live catalog to the custom_providers entry so no-probe surfaces # (dashboard, desktop, ACP) show the full list; mirrors model_switch.py's # _save_discovered_models_to_config. A failed save is non-fatal. if live_models: try: from hermes_cli.model_switch import ( _save_discovered_models_to_config, ) _save_discovered_models_to_config( base_url, live_models, api_mode=api_mode, headers=extra_headers or None, ) except Exception: pass if models: default_idx = 0 if saved_model and saved_model in models: default_idx = models.index(saved_model) print(f"Found {len(models)} model(s):\n") try: from hermes_cli.curses_ui import curses_radiolist menu_items = [ f"{m} (current)" if m == saved_model else m for m in models ] + ["Cancel"] idx = curses_radiolist( f"Select model from {name}:", menu_items, selected=default_idx, cancel_returns=-1, searchable=True, ) print() if idx < 0 or idx >= len(models): print("Cancelled.") return model_name = models[idx] except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): for i, m in enumerate(models, 1): suffix = " (current)" if m == saved_model else "" print(f" {i}. {m}{suffix}") print(f" {len(models) + 1}. Cancel") print() try: val = input(f"Choice [1-{len(models) + 1}]: ").strip() if not val: print("Cancelled.") return idx = int(val) - 1 if idx < 0 or idx >= len(models): print("Cancelled.") return model_name = models[idx] except (ValueError, KeyboardInterrupt, EOFError): print("\nCancelled.") return elif saved_model and not native_catalog_empty: print("Could not fetch models from endpoint.") try: model_name = line_input(f"Model name [{saved_model}]: ").strip() or saved_model except (KeyboardInterrupt, EOFError): print("\nCancelled.") return else: print("Could not fetch models from endpoint. Enter model name manually.") try: model_name = line_input("Model name: ").strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return if not model_name: print("No model specified. Cancelled.") return # Activate and save the model to the custom_providers entry _save_model_choice(model_name) cfg, model = _load_config_model_section() if provider_key: model["provider"] = custom_provider_slug(name, provider_key) model.pop("base_url", None) model.pop("api_key", None) else: model["provider"] = "custom" model["base_url"] = _custom_provider_base_url_config_value( provider_info, base_url ) if config_api_key: model["api_key"] = config_api_key # Apply api_mode from custom_providers entry, or clear stale value custom_api_mode = provider_info.get("api_mode", "") if custom_api_mode: model["api_mode"] = custom_api_mode else: model.pop("api_mode", None) # let runtime auto-detect from URL _commit_model_config(cfg) # Persist the selected model back to whichever schema owns this endpoint. if provider_key: cfg = load_config() providers_cfg = cfg.get("providers") if isinstance(providers_cfg, dict): provider_entry = providers_cfg.get(provider_key) if isinstance(provider_entry, dict): provider_entry["default_model"] = model_name # Only persist an inline api_key when the user originally had one # (literal or ``${VAR}``). Entries relying on ``key_env`` must not get # a synthesized api_key — the runtime resolves key_env directly and # writing it would downgrade credential hygiene. original_api_key_ref = str( provider_info.get("api_key_ref", "") or "" ).strip() original_api_key = str(provider_info.get("api_key", "") or "").strip() had_inline_api_key = bool(original_api_key_ref or original_api_key) if ( had_inline_api_key and config_api_key and not str(provider_entry.get("api_key", "") or "").strip() ): provider_entry["api_key"] = config_api_key if key_env and not str(provider_entry.get("key_env", "") or "").strip(): provider_entry["key_env"] = key_env cfg["providers"] = providers_cfg save_config(cfg) else: # Save model name to the custom_providers entry for next time _save_custom_provider(base_url, config_api_key, model_name, api_mode=api_mode) print(f"\n✅ Model set to: {model_name}") print(f" Provider: {name} ({base_url})") def _copilot_model_list(live_ids) -> list: """Live GitHub Copilot ids, or the curated fallback with a warning.""" from hermes_cli.models import _PROVIDER_MODELS if live_ids: model_list = [model_id for model_id in live_ids if model_id] print(f" Found {len(model_list)} model(s) from GitHub Copilot") return model_list model_list = _PROVIDER_MODELS.get("copilot", []) if model_list: print( " ⚠ Could not auto-detect models from GitHub Copilot — showing defaults." ) print(' Use "Enter custom model name" if you do not see your model.') return model_list def _model_flow_copilot(config, current_model=""): """GitHub Copilot flow using env vars, gh CLI, or OAuth device code.""" from hermes_cli.main import _prompt_reasoning_effort_selection from hermes_cli.setup import _current_reasoning_effort, _set_reasoning_effort from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials from hermes_cli.config import save_env_value, load_config from hermes_cli.models import ( fetch_api_models, fetch_github_model_catalog, github_model_reasoning_efforts, copilot_model_api_mode, normalize_copilot_model_id, ) provider_id = "copilot" pconfig = PROVIDER_REGISTRY[provider_id] creds = resolve_api_key_provider_credentials(provider_id) api_key = creds.get("api_key", "") source = creds.get("source", "") if not api_key: print("No GitHub token configured for GitHub Copilot.") print() print(" Supported token types:") print( " → OAuth token (gho_*) via `copilot login` or device code flow" ) print(" → Fine-grained PAT (github_pat_*) with Copilot Requests permission") print(" → GitHub App token (ghu_*) via environment variable") print(" ✗ Classic PAT (ghp_*) NOT supported by Copilot API") print() print(" Options:") print(" 1. Login with GitHub (OAuth device code flow)") print(" 2. Enter a token manually") print(" 3. Cancel") print() try: choice = input(" Choice [1-3]: ").strip() except (KeyboardInterrupt, EOFError): print() return if choice == "1": try: from hermes_cli.copilot_auth import copilot_device_code_login token = copilot_device_code_login() if token: save_env_value("COPILOT_GITHUB_TOKEN", token) print(" Copilot token saved.") print() else: print(" Login cancelled or failed.") return except Exception as exc: print(f" Login failed: {exc}") return elif choice == "2": from hermes_cli.secret_prompt import masked_secret_prompt try: new_key = masked_secret_prompt(" Token (COPILOT_GITHUB_TOKEN): ").strip() except (KeyboardInterrupt, EOFError): print() return if not new_key: print(" Cancelled.") return # Validate token type try: from hermes_cli.copilot_auth import validate_copilot_token valid, msg = validate_copilot_token(new_key) if not valid: print(f" ✗ {msg}") return except ImportError: pass save_env_value("COPILOT_GITHUB_TOKEN", new_key) print(" Token saved.") print() else: print(" Cancelled.") return creds = resolve_api_key_provider_credentials(provider_id) api_key = creds.get("api_key", "") source = creds.get("source", "") else: if source in {"GITHUB_TOKEN", "GH_TOKEN"}: from hermes_cli.env_loader import format_secret_source_suffix bw_suffix = format_secret_source_suffix(source) print(f" GitHub token: {api_key[:8]}... ✓ ({source}{bw_suffix})") elif source == "gh auth token": print(" GitHub token: ✓ (from `gh auth token`)") else: print(" GitHub token: ✓") print() effective_base = pconfig.inference_base_url catalog = fetch_github_model_catalog(api_key) live_models = ( [item.get("id", "") for item in catalog if item.get("id")] if catalog else fetch_api_models(api_key, effective_base) ) def _normalize(mid): return normalize_copilot_model_id(mid, catalog=catalog, api_key=api_key) or mid model_list = _copilot_model_list(live_models) selected = _pick_model_or_prompt( model_list, "Model name: ", current_model=_normalize(current_model), confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=api_key, ) if selected: selected = _normalize(selected) initial_cfg = load_config() current_effort = _current_reasoning_effort(initial_cfg) reasoning_efforts = github_model_reasoning_efforts( selected, catalog=catalog, api_key=api_key, ) selected_effort = None if reasoning_efforts: print(f" {selected} supports reasoning controls.") selected_effort = _prompt_reasoning_effort_selection( reasoning_efforts, current_effort=current_effort ) cfg, model = _begin_model_config(selected, provider_id) model["base_url"] = effective_base model["api_mode"] = copilot_model_api_mode( selected, catalog=catalog, api_key=api_key, ) clear_model_endpoint_credentials(model, clear_api_mode=False) if selected_effort is not None: _set_reasoning_effort(cfg, selected_effort) _commit_model_config(cfg) print(f"Default model set to: {selected} (via {pconfig.name})") if reasoning_efforts: if selected_effort == "none": print("Reasoning disabled for this model.") elif selected_effort: print(f"Reasoning effort set to: {selected_effort}") else: print("No change.") def _model_flow_copilot_acp(config, current_model=""): """GitHub Copilot ACP flow using the local Copilot CLI.""" from hermes_cli.auth import ( PROVIDER_REGISTRY, get_external_process_provider_status, resolve_api_key_provider_credentials, resolve_external_process_provider_credentials, ) from hermes_cli.models import fetch_github_model_catalog, normalize_copilot_model_id del config provider_id = "copilot-acp" pconfig = PROVIDER_REGISTRY[provider_id] status = get_external_process_provider_status(provider_id) resolved_command = ( status.get("resolved_command") or status.get("command") or "copilot" ) effective_base = status.get("base_url") or pconfig.inference_base_url print(" GitHub Copilot ACP delegates Hermes turns to `copilot --acp`.") print(" Hermes currently starts its own ACP subprocess for each request.") print(" Hermes uses your selected model as a hint for the Copilot ACP session.") print(f" Command: {resolved_command}") print(f" Backend marker: {effective_base}") print() try: creds = resolve_external_process_provider_credentials(provider_id) except Exception as exc: print(f" ⚠ {exc}") print( " Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere." ) return effective_base = creds.get("base_url") or effective_base catalog_api_key = "" try: catalog_creds = resolve_api_key_provider_credentials("copilot") catalog_api_key = catalog_creds.get("api_key", "") except Exception: pass catalog = fetch_github_model_catalog(catalog_api_key) def _normalize(mid): return normalize_copilot_model_id(mid, catalog=catalog, api_key=catalog_api_key) or mid model_list = _copilot_model_list( [item.get("id", "") for item in catalog if item.get("id")] if catalog else [] ) selected = _pick_model_or_prompt( model_list, "Model name: ", current_model=_normalize(current_model), confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=catalog_api_key, ) if not selected: print("No change.") return cfg, model = _begin_model_config(_normalize(selected), provider_id) model["base_url"] = effective_base model["api_mode"] = "chat_completions" clear_model_endpoint_credentials(model, clear_api_mode=False) _commit_model_config(cfg) print(f"Default model set to: {model['default']} (via {pconfig.name})") def _model_flow_kimi(config, current_model=""): """Kimi / Moonshot model selection with automatic endpoint routing. - sk-kimi-* keys → api.kimi.com/coding/v1 (Kimi Coding Plan) - Other keys → api.moonshot.ai/v1 (legacy Moonshot) No manual base URL prompt — endpoint is determined by key prefix. """ from hermes_cli.auth import PROVIDER_REGISTRY, KIMI_CODE_BASE_URL from hermes_cli.config import get_env_value, save_env_value from hermes_cli.models import _PROVIDER_MODELS provider_id = "kimi-coding" pconfig = PROVIDER_REGISTRY[provider_id] base_url_env = pconfig.base_url_env_var or "" # Step 1: Check / prompt for API key _, existing_key, abort = _ensure_flow_api_key(provider_id, pconfig) if abort: return # Step 2: Auto-detect endpoint from key prefix is_coding_plan = existing_key.startswith("sk-kimi-") if is_coding_plan: effective_base = KIMI_CODE_BASE_URL print(f" Detected Kimi Coding Plan key → {effective_base}") else: effective_base = pconfig.inference_base_url print(f" Using Moonshot endpoint → {effective_base}") # Clear any manual base URL override so auto-detection works at runtime if base_url_env and get_env_value(base_url_env): save_env_value(base_url_env, "") print() # Step 3: Model selection — show appropriate models for the endpoint model_list = _PROVIDER_MODELS.get("kimi-coding" if is_coding_plan else "moonshot", []) selected = _pick_model_or_prompt( model_list, "Enter model name: ", current_model=current_model, confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=existing_key, ) if selected: cfg, model = _begin_model_config(selected, provider_id) model["base_url"] = effective_base model.pop("api_mode", None) # let runtime auto-detect from URL clear_model_endpoint_credentials(model, clear_api_mode=False) _commit_model_config(cfg) endpoint_label = "Kimi Coding" if is_coding_plan else "Moonshot" print(f"Default model set to: {selected} (via {endpoint_label})") else: print("No change.") def _model_flow_stepfun(config, current_model=""): """StepFun Step Plan flow with region-specific endpoints.""" from hermes_cli.main import _infer_stepfun_region, _prompt_provider_choice, _stepfun_base_url_for_region from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.config import get_env_value, save_env_value from hermes_cli.models import _PROVIDER_MODELS, fetch_api_models provider_id = "stepfun" pconfig = PROVIDER_REGISTRY[provider_id] base_url_env = pconfig.base_url_env_var or "" _, existing_key, abort = _ensure_flow_api_key(provider_id, pconfig) if abort: return current_base = "" if base_url_env: current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "") if not current_base: model_cfg = config.get("model") if isinstance(model_cfg, dict): current_base = str(model_cfg.get("base_url") or "").strip() current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url) region_choices = [ ( "international", f"International ({_stepfun_base_url_for_region('international')})", ), ("china", f"China ({_stepfun_base_url_for_region('china')})"), ] ordered_regions = [] for region_key, label in region_choices: if region_key == current_region: ordered_regions.insert(0, (region_key, f"{label} ← currently active")) else: ordered_regions.append((region_key, label)) ordered_regions.append(("cancel", "Cancel")) region_idx = _prompt_provider_choice([label for _, label in ordered_regions]) if region_idx is None or ordered_regions[region_idx][0] == "cancel": print("No change.") return selected_region = ordered_regions[region_idx][0] effective_base = _stepfun_base_url_for_region(selected_region) if base_url_env: save_env_value(base_url_env, effective_base) live_models = fetch_api_models(existing_key, effective_base) if live_models: model_list = live_models print(f" Found {len(model_list)} model(s) from {pconfig.name} API") else: model_list = _PROVIDER_MODELS.get(provider_id, []) if model_list: print( f" Could not auto-detect models from {pconfig.name} API — " "showing Step Plan fallback catalog." ) selected = _pick_model_or_prompt( model_list, "Model name: ", current_model=current_model, confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=existing_key, ) if selected: cfg, model = _begin_model_config(selected, provider_id) model["base_url"] = effective_base model.pop("api_mode", None) clear_model_endpoint_credentials(model, clear_api_mode=False) _commit_model_config(cfg) config["model"] = dict(model) print(f"Default model set to: {selected} (via {pconfig.name})") else: print("No change.") def _model_flow_bedrock_api_key(config, region, current_model=""): """Bedrock API Key mode — uses the OpenAI-compatible bedrock-mantle endpoint. For developers without an AWS account who received a Bedrock API Key from their AWS admin. Works like any OpenAI-compatible endpoint. """ from hermes_cli.auth import _resolve_api_key_provider_secret, ProviderConfig from hermes_cli.config import save_env_value from hermes_cli.models import _PROVIDER_MODELS mantle_base_url = f"https://bedrock-mantle.{region}.api.aws/v1" # Check env var and credential pool (keys added via `hermes auth`) bedrock_pconfig = ProviderConfig( id="bedrock", name="Bedrock", auth_type="api_key", api_key_env_vars=("AWS_BEARER_TOKEN_BEDROCK",), ) existing_key, existing_source = _resolve_api_key_provider_secret( "bedrock", bedrock_pconfig ) if existing_key: from hermes_cli.env_loader import format_secret_source_suffix source_suffix = format_secret_source_suffix( existing_source or "AWS_BEARER_TOKEN_BEDROCK" ) print(f" Bedrock API Key: {existing_key[:12]}... ✓{source_suffix}") else: print(f" Endpoint: {mantle_base_url}") print() from hermes_cli.secret_prompt import masked_secret_prompt try: api_key = masked_secret_prompt(" Bedrock API Key: ").strip() except (KeyboardInterrupt, EOFError): print() return if not api_key: print(" Cancelled.") return save_env_value("AWS_BEARER_TOKEN_BEDROCK", api_key) existing_key = api_key print(" ✓ API key saved.") print() # Static list — mantle doesn't need boto3 for discovery model_list = _PROVIDER_MODELS.get("bedrock", []) print(f" Showing {len(model_list)} curated models") selected = _pick_model_or_prompt( model_list, " Model ID: ", current_model=current_model, confirm_provider="custom", confirm_base_url=mantle_base_url, confirm_api_key=existing_key, ) if selected: # Save as custom provider pointing to bedrock-mantle cfg, model = _begin_model_config(selected, "custom:bedrock-mantle") clear_model_endpoint_credentials( model, clear_api_mode=True, clear_base_url=True ) # The bearer token rides on a named provider entry: a bare ``provider: custom`` # cannot carry a credential for this host because OPENAI_API_KEY is gated to # openai.com, so requests would go out as "no-key-required". providers = _ensure_dict_section(cfg, "providers") mantle_entry = providers.get("bedrock-mantle") if not isinstance(mantle_entry, dict): mantle_entry = {} mantle_entry["base_url"] = mantle_base_url mantle_entry["key_env"] = "AWS_BEARER_TOKEN_BEDROCK" providers["bedrock-mantle"] = mantle_entry # Also save region in bedrock config for reference _ensure_dict_section(cfg, "bedrock")["region"] = region _commit_model_config(cfg) print(f" Default model set to: {selected} (via Bedrock API Key, {region})") print(f" Endpoint: {mantle_base_url}") else: print(" No change.") def _model_flow_bedrock(config, current_model=""): """AWS Bedrock provider: verify credentials, pick region, discover models. Uses the native Converse API via boto3 — not the OpenAI-compatible endpoint. Auth is the AWS SDK default credential chain (env vars, profile, instance role), so no API key prompt is needed. """ from hermes_cli.models import _PROVIDER_MODELS # 1. Check for AWS credentials try: from agent.bedrock_adapter import ( has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, discover_bedrock_models, ) except ImportError: print(" ✗ boto3 is not installed. Install it with:") print(" pip install boto3") print() return if not has_aws_credentials(): print(" ⚠ No AWS credentials detected via environment variables.") print(" Bedrock will use boto3's default credential chain (IMDS, SSO, etc.)") print() auth_var = resolve_aws_auth_env_var() if auth_var: print(f" AWS credentials: {auth_var} ✓") else: print(" AWS credentials: boto3 default chain (instance role / SSO)") print() # 2. Region selection current_region = resolve_bedrock_region() try: region_input = line_input(f" AWS Region [{current_region}]: ").strip() except (KeyboardInterrupt, EOFError): print() return region = region_input or current_region # 2b. Authentication mode print(" Choose authentication method:") print() print(" 1. IAM credential chain (recommended)") print(" Works with EC2 instance roles, SSO, env vars, aws configure") print(" 2. Bedrock API Key") print(" Enter your Bedrock API Key directly — also supports") print(" team scenarios where an admin distributes keys") print() try: auth_choice = input(" Choice [1]: ").strip() except (KeyboardInterrupt, EOFError): print() return if auth_choice == "2": _model_flow_bedrock_api_key(config, region, current_model) return # 3. Model discovery — try live API first, fall back to static list print(f" Discovering models in {region}...") live_models = discover_bedrock_models(region) if live_models: _EXCLUDE_PREFIXES = ( "stability.", "cohere.embed", "twelvelabs.", "us.stability.", "us.cohere.embed", "us.twelvelabs.", "global.cohere.embed", "global.twelvelabs.", ) _EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision") filtered = [ m for m in live_models if not any(m["id"].startswith(p) for p in _EXCLUDE_PREFIXES) and not any(s in m["id"].lower() for s in _EXCLUDE_SUBSTRINGS) and bedrock_model_routable_from_region(m["id"], region) ] # Deduplicate: prefer inference profiles (geo-prefixed or global.*) # over bare foundation model IDs. _PROFILE_PREFIXES = BEDROCK_GEO_PREFIXES + ("global.",) def _base_id(mid: str) -> str: _pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None) return mid[len(_pp):] if _pp else mid profile_base_ids = { _base_id(m["id"]) for m in filtered if m["id"].startswith(_PROFILE_PREFIXES) } deduped = [ m for m in filtered if m["id"].startswith(_PROFILE_PREFIXES) or m["id"] not in profile_base_ids ] # Recommended models, matched geo-agnostically so an EU (eu.*) or APAC # (apac.*) picker pins its own region's profile rather than a us.* one. _RECOMMENDED_BASES = [ "anthropic.claude-sonnet-4-6", "anthropic.claude-opus-4-6", "anthropic.claude-haiku-4-5", "amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "deepseek.v3", "meta.llama4-maverick", "meta.llama4-scout", ] def _sort_key(m): mid = m["id"] base = _base_id(mid) for i, rec in enumerate(_RECOMMENDED_BASES): if base.startswith(rec): # In-region geo profile beats global.* for the same model return (0, i, 0 if not mid.startswith("global.") else 1, mid) if mid.startswith("global."): return (1, 0, 0, mid) return (2, 0, 0, mid) deduped.sort(key=_sort_key) model_list = [m["id"] for m in deduped] print( f" Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)" ) else: model_list = _PROVIDER_MODELS.get("bedrock", []) if model_list: print( f" Using {len(model_list)} curated models (live discovery unavailable)" ) else: print( " No models found. Check IAM permissions for bedrock:ListFoundationModels." ) return # 4. Model selection selected = _pick_model_or_prompt( model_list, " Model ID: ", current_model=current_model, confirm_provider="bedrock", confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com", ) if selected: cfg, model = _begin_model_config(selected, "bedrock") model["base_url"] = f"https://bedrock-runtime.{region}.amazonaws.com" model.pop("api_mode", None) # bedrock_converse is auto-detected clear_model_endpoint_credentials(model, clear_api_mode=False) _ensure_dict_section(cfg, "bedrock")["region"] = region _commit_model_config(cfg) print(f" Default model set to: {selected} (via AWS Bedrock, {region})") else: print(" No change.") def _model_flow_vertex(config, current_model=""): """Google Vertex AI provider: Gemini via the OpenAI-compatible endpoint. Auth is OAuth2 — short-lived tokens minted from a service-account JSON or Application Default Credentials (ADC). No static API key. The credential *path* lives in .env (VERTEX_CREDENTIALS_PATH / GOOGLE_APPLICATION_CREDENTIALS); project ID and region are non-secret and saved to config.yaml under vertex:. """ from hermes_cli.auth import _prompt_model_selection from hermes_cli.config import load_config, get_env_value from hermes_cli.models import _PROVIDER_MODELS # 1. Credential source detection (fast, no network / no google-auth import). sa_path = ( get_env_value("VERTEX_CREDENTIALS_PATH") or get_env_value("GOOGLE_APPLICATION_CREDENTIALS") or "" ).strip() if sa_path: print(f" Vertex credentials: service account JSON ({sa_path}) ✓") else: print(" Vertex credentials: Application Default Credentials (ADC)") print(" Vertex uses OAuth2, not a static API key. Either:") print(" • run 'gcloud auth application-default login', or") print(" • set VERTEX_CREDENTIALS_PATH in ~/.hermes/.env to a service account JSON") print() vertex_cfg = load_config().get("vertex") if not isinstance(vertex_cfg, dict): vertex_cfg = {} # 2. Project ID (optional — falls back to the project embedded in creds). current_project = str(vertex_cfg.get("project_id") or "").strip() try: project_input = line_input( f" GCP project ID [{current_project or 'from credentials'}]: " ).strip() except (KeyboardInterrupt, EOFError): print() return project_id = project_input or current_project # 3. Region (default global — required for the Gemini 3.x previews). current_region = str(vertex_cfg.get("region") or "global").strip() or "global" try: region_input = line_input(f" Vertex region [{current_region}]: ").strip() except (KeyboardInterrupt, EOFError): print() return region = region_input or current_region # 4. Model selection (curated list — Vertex has no /models listing route). model_list = _PROVIDER_MODELS.get("vertex", []) or [ "google/gemini-3-pro-preview", "google/gemini-3-flash-preview", ] base_url_preview = ( "https://aiplatform.googleapis.com/v1beta1/projects//" f"locations/{region}/endpoints/openapi" if region == "global" else f"https://{region}-aiplatform.googleapis.com/v1beta1/projects//" f"locations/{region}/endpoints/openapi" ) selected = _prompt_model_selection( model_list, current_model=current_model, confirm_provider="vertex", confirm_base_url=base_url_preview, ) if selected: cfg, model = _begin_model_config(selected, "vertex") # base_url is computed at runtime from project+region; do not pin it. model.pop("base_url", None) model.pop("api_mode", None) # chat_completions is the profile default clear_model_endpoint_credentials(model, clear_api_mode=False) vcfg = _ensure_dict_section(cfg, "vertex") vcfg["project_id"] = project_id vcfg["region"] = region _commit_model_config(cfg) print(f" Default model set to: {selected} (via Google Vertex AI, {region})") else: print(" No change.") def _select_zai_endpoint(current_base: str) -> str: """Picker for the four official Z.AI endpoints (sourced from ``ZAI_ENDPOINTS`` in ``hermes_cli.auth`` so it stays in sync with the probe list) plus a custom-proxy option. Returns the selected base URL; *current_base* on cancel/error. """ from hermes_cli.main import _prompt_provider_choice from hermes_cli.auth import ZAI_ENDPOINTS options = [(label, url) for _, url, _, label in ZAI_ENDPOINTS] normalized_current = (current_base or "").strip().rstrip("/") # Default to the active endpoint when known; a custom URL defaults to "Custom proxy". default_idx = 0 for idx, (_, url) in enumerate(options): if normalized_current == url.rstrip("/"): default_idx = idx break else: if normalized_current: default_idx = len(options) choices = [f"{label} ({url})" for label, url in options] choices.append("Custom proxy URL") selected = _prompt_provider_choice( choices, default=default_idx, title="Select Z.AI / GLM endpoint:", ) if selected is None: return current_base if selected == len(options): # Custom proxy URL try: override = line_input(f"Custom base URL [{current_base}]: ").strip() except (KeyboardInterrupt, EOFError): print() return current_base if not override: return current_base if not override.startswith(("http://", "https://")): print(" Invalid URL — must start with http:// or https://. Keeping current value.") return current_base return override.rstrip("/") return options[selected][1].rstrip("/") def _gemini_tier_ok(existing_key: str, pconfig, base_url_env: str) -> bool: """Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash) are exhausted in a handful of agent turns, so refuse a free-tier key. The probe is best-effort; network or auth errors fall through without blocking.""" from hermes_cli.config import get_env_value try: from agent.gemini_native_adapter import probe_gemini_tier except Exception: return True print(" Checking Gemini API tier...") probe_base = ( (get_env_value(base_url_env) if base_url_env else "") or os.getenv(base_url_env or "", "") or pconfig.inference_base_url ) tier = probe_gemini_tier(existing_key, probe_base) if tier == "free": print() print( "❌ This Google API key is on the free tier " "(<= 250 requests/day for gemini-2.5-flash)." ) print( " Hermes typically makes 3-10 API calls per user turn " "(tool iterations + auxiliary tasks)," ) print( " so the free tier is exhausted after a handful of " "messages and cannot sustain" ) print(" an agent session.") print() print( " To use Gemini with Hermes, enable billing on your " "Google Cloud project and regenerate" ) print( " the key in a billing-enabled project: " "https://aistudio.google.com/apikey" ) print() print( " Alternatives with workable free usage: DeepSeek, " "OpenRouter (free models), Groq, Nous." ) print() print("Not saving Gemini as the default provider.") return False if tier == "paid": print(" Tier check: paid ✓") else: # "unknown" (network/auth/unexpected response): don't block; the # runtime 429 handler surfaces free-tier guidance if needed. print(" Tier check: could not verify (proceeding anyway).") print() return True def _api_key_provider_model_list(provider_id: str, pconfig, existing_key: str, key_env: str, effective_base: str) -> list: """Model list for an API-key provider. Resolution order: 1. models.dev registry (cached, filtered for agentic/tool-capable models) 2. Curated static fallback list (offline insurance) 3. Live /models endpoint probe (small providers without models.dev data) LM Studio: live /api/v1/models probe only. Ollama Cloud: merged discovery. """ from hermes_cli.config import get_env_value from hermes_cli.models import _PROVIDER_MODELS, fetch_api_models curated = _PROVIDER_MODELS.get(provider_id, []) api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "") if provider_id == "lmstudio": from hermes_cli.auth import AuthError from hermes_cli.models import fetch_lmstudio_models try: model_list = fetch_lmstudio_models(api_key=api_key_for_probe, base_url=effective_base) except AuthError as exc: print(f" LM Studio rejected the request: {exc}") print(" Set LM_API_KEY (or update it) to match the server's bearer token.") model_list = [] if model_list: print(f" Found {len(model_list)} model(s) from LM Studio") return model_list if provider_id == "ollama-cloud": from hermes_cli.models import fetch_ollama_cloud_models # Force a live refresh so newly released models appear the moment the user # enters their key, not when the disk cache TTL expires. model_list = fetch_ollama_cloud_models(api_key=api_key_for_probe, base_url=effective_base, force_refresh=True) if model_list: print(f" Found {len(model_list)} model(s) from Ollama Cloud") return model_list if provider_id == "opencode-free": # Keyless tier: the curated list is synced against anonymous live probes # (models.dev's cost.input==0 filter lags reality). if curated: print(f' Showing {len(curated)} keyless free models — use "Enter custom model name" for others.') return curated if provider_id == "novita": live_models = fetch_api_models(api_key_for_probe, effective_base) if live_models: print(f" Found {len(live_models)} model(s) from {pconfig.name} API") return live_models model_list = _models_dev_merged(provider_id, curated) if model_list: print(f" Found {len(model_list)} model(s) from models.dev registry") return model_list _show_curated(curated) return curated # models.dev first (tool-capable, noise-filtered), merged with curated so # newly added models still appear. model_list = _models_dev_merged(provider_id, curated) if model_list: print(f" Found {len(model_list)} model(s) from models.dev registry") return model_list if curated and len(curated) >= 8: # Substantial curated list — use it directly, skip live probe _show_curated(curated) return curated live_models = fetch_api_models(api_key_for_probe, effective_base) if live_models and len(live_models) >= len(curated): print(f" Found {len(live_models)} model(s) from {pconfig.name} API") return live_models _show_curated(curated) # may be empty: falls through to raw input return curated def _model_flow_api_key_provider(config, provider_id, current_model=""): """Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).""" from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.config import get_env_value, save_env_value, load_config from hermes_cli.models import ( opencode_model_api_mode, normalize_opencode_model_id, ) pconfig = PROVIDER_REGISTRY[provider_id] key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" base_url_env = pconfig.base_url_env_var or "" is_opencode = provider_id in {"opencode-zen", "opencode-go", "opencode-free"} # OpenCode Free is keyless — the tier is served anonymously and any # unrecognized bearer 401s, so there is no key to prompt for. if provider_id == "opencode-free": print(" OpenCode Free is keyless — no API key or account needed.") existing_key = "" else: _, existing_key, abort = _ensure_flow_api_key(provider_id, pconfig) if abort: return if provider_id == "gemini" and existing_key and not _gemini_tier_ok(existing_key, pconfig, base_url_env): return # Optional base URL override. Precedence: env var → config.yaml model.base_url → # registry default; reading config.yaml keeps a saved remote URL from being # overwritten with localhost when the user just presses Enter. current_base = "" if base_url_env: current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "") if not current_base: try: _m = load_config().get("model") or {} if str(_m.get("provider") or "").strip().lower() == provider_id: current_base = str(_m.get("base_url") or "").strip() except Exception: pass effective_base = current_base or pconfig.inference_base_url if provider_id == "zai": # Four official endpoints with separate billing paths — a picker lets users # match the endpoint to their key type. chosen_base = _select_zai_endpoint(effective_base) if chosen_base and chosen_base != effective_base and base_url_env: save_env_value(base_url_env, chosen_base) effective_base = chosen_base else: try: override = line_input(f"Base URL [{effective_base}]: ").strip() except (KeyboardInterrupt, EOFError): print() override = "" if override and base_url_env: if not override.startswith(("http://", "https://")): print( " Invalid URL — must start with http:// or https://. Keeping current value." ) else: save_env_value(base_url_env, override) effective_base = override model_list = _api_key_provider_model_list(provider_id, pconfig, existing_key, key_env, effective_base) if is_opencode: model_list = [ normalize_opencode_model_id(provider_id, mid) for mid in model_list ] current_model = normalize_opencode_model_id(provider_id, current_model) model_list = list(dict.fromkeys(mid for mid in model_list if mid)) # Per-model pricing when the provider supports it; get_pricing_for_provider() is # memoized and returns {} otherwise — never a blocking fetch beyond the catalog # lookup that already happened above. pricing: dict = {} if model_list: try: from hermes_cli.models import get_pricing_for_provider pricing = get_pricing_for_provider(provider_id) or {} except Exception: pricing = {} selected = _pick_model_or_prompt( model_list, "Model name: ", current_model=current_model, pricing=pricing, confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=existing_key, ) if selected: if is_opencode: selected = normalize_opencode_model_id(provider_id, selected) cfg, model = _begin_model_config(selected, provider_id) model["base_url"] = effective_base clear_model_endpoint_credentials(model, clear_api_mode=False) if is_opencode: model["api_mode"] = opencode_model_api_mode(provider_id, selected) else: model.pop("api_mode", None) _commit_model_config(cfg) print(f"Default model set to: {selected} (via {pconfig.name})") else: print("No change.") def _model_flow_anthropic(config, current_model=""): """Flow for Anthropic provider — OAuth subscription, API key, or Claude Code creds.""" from hermes_cli.main import _run_anthropic_oauth_flow from hermes_cli.auth import get_anthropic_key from hermes_cli.config import save_env_value, save_anthropic_api_key from hermes_cli.models import _PROVIDER_MODELS # Check ALL credential sources existing_key = get_anthropic_key() cc_available = False try: from agent.anthropic_adapter import ( read_claude_code_credentials, is_claude_code_token_valid, _is_oauth_token, ) cc_creds = read_claude_code_credentials() if cc_creds and is_claude_code_token_valid(cc_creds): cc_available = True except Exception: pass # Stale-OAuth guard: an expired OAuth token with no valid cc_creds fallback is # treated as missing so the re-auth path is offered. existing_is_stale_oauth = bool(existing_key and _is_oauth_token(existing_key) and not cc_available) has_creds = (bool(existing_key) and not existing_is_stale_oauth) or cc_available needs_auth = not has_creds if has_creds: if existing_key: from hermes_cli.env_loader import format_secret_source_suffix from hermes_cli.auth import PROVIDER_REGISTRY # Surface which env var supplied the key so Bitwarden users see # "(from Bitwarden)" instead of a key indistinguishable from .env. source_suffix = "" for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars: if os.getenv(var, "").strip() == existing_key: source_suffix = format_secret_source_suffix(var) if source_suffix: break print( f" Anthropic credentials: {existing_key[:12]}... ✓{source_suffix}" ) elif cc_available: print(" Claude Code credentials: ✓ (auto-detected)") print() choice = _prompt_auth_credentials_choice("Anthropic credentials:") if choice == "reauth": needs_auth = True elif choice == "cancel": return # "use" (default): proceed to model selection with existing creds if needs_auth: print() print(" Choose authentication method:") print() print(" 1. Claude Pro/Max subscription (OAuth login)") print(" 2. Anthropic API key (pay-per-token)") print(" 3. Cancel") print() try: choice = input(" Choice [1/2/3]: ").strip() except (KeyboardInterrupt, EOFError): print() return if choice == "1": if not _run_anthropic_oauth_flow(save_env_value): return elif choice == "2": print() print(" Get an API key at: https://platform.claude.com/settings/keys") print() from hermes_cli.secret_prompt import masked_secret_prompt try: api_key = masked_secret_prompt(" API key (sk-ant-...): ").strip() except (KeyboardInterrupt, EOFError): print() return if not api_key: print(" Cancelled.") return save_anthropic_api_key(api_key, save_fn=save_env_value) print(" ✓ API key saved.") else: print(" No change.") return print() selected = _pick_model_or_prompt( _PROVIDER_MODELS.get("anthropic", []), "Model name (e.g., claude-sonnet-4-20250514): ", current_model=current_model, confirm_provider="anthropic", ) if selected: # Clear base_url: resolve_runtime_provider() always hardcodes Anthropic's URL, # and a stale value can contaminate other providers on a later switch. cfg, model = _begin_model_config(selected, "anthropic") model.pop("base_url", None) clear_model_endpoint_credentials(model) _commit_model_config(cfg) print(f"Default model set to: {selected} (via Anthropic)") else: print("No change.")