refactor(hermes_cli): AST-neutral layout compaction of provider/platform setup flows

This commit is contained in:
Teknium
2026-09-02 20:03:18 -07:00
parent 113f04616b
commit b5f96ab3e5
7 changed files with 110 additions and 276 deletions
+24 -50
View File
@@ -34,8 +34,7 @@ def cmd_whatsapp(args):
print(" 1. Separate bot number (recommended)")
print(" People message the bot's number directly — cleanest experience.")
print(
" Requires a second phone number with WhatsApp installed on a device."
)
" Requires a second phone number with WhatsApp installed on a device.")
print()
print(" 2. Personal number (self-chat)")
print(" You message yourself to talk to the agent.")
@@ -71,8 +70,7 @@ def cmd_whatsapp(args):
else:
wa_mode = current_mode
mode_label = (
"separate bot number" if wa_mode == "bot" else "personal number (self-chat)"
)
"separate bot number" if wa_mode == "bot" else "personal number (self-chat)")
print(f"\n✓ Mode: {mode_label}")
# ── Step 2: Mode is selected, will enable WhatsApp only after pairing ──
@@ -99,8 +97,7 @@ def cmd_whatsapp(args):
if response.lower() in {"y", "yes"}:
if wa_mode == "bot":
phone = line_input(
" Phone numbers that can message the bot (comma-separated): "
).strip()
" Phone numbers that can message the bot (comma-separated): ").strip()
else:
phone = line_input(" Your phone number (e.g. 15551234567): ").strip()
if phone:
@@ -111,8 +108,7 @@ def cmd_whatsapp(args):
if wa_mode == "bot":
print(" Who should be allowed to message the bot?")
phone = line_input(
" Phone numbers (comma-separated, or * for anyone): "
).strip()
" Phone numbers (comma-separated, or * for anyone): ").strip()
else:
phone = line_input(" Your phone number (e.g. 15551234567): ").strip()
if phone:
@@ -132,8 +128,7 @@ def cmd_whatsapp(args):
if not (bridge_dir / "node_modules").exists():
print(
"\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..."
)
"\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)...")
npm = find_node_executable("npm")
if not npm:
print(" ✗ npm not found on PATH — install Node.js first")
@@ -147,8 +142,7 @@ def cmd_whatsapp(args):
text=True,
encoding="utf-8",
errors="replace",
env=with_hermes_node_path(),
)
env=with_hermes_node_path())
except KeyboardInterrupt:
print("\n ✗ Install cancelled")
return
@@ -170,8 +164,7 @@ def cmd_whatsapp(args):
print("✓ Existing WhatsApp session found")
try:
response = input(
"\n Re-pair? This will clear the existing session. [y/N] "
).strip()
"\n Re-pair? This will clear the existing session. [y/N] ").strip()
except (EOFError, KeyboardInterrupt):
response = "n"
if response.lower() in {"y", "yes"}:
@@ -209,11 +202,9 @@ def cmd_whatsapp(args):
str(bridge_script),
"--pair-only",
"--session",
str(session_dir),
],
str(session_dir)],
cwd=str(bridge_dir),
env=with_hermes_node_path(),
)
env=with_hermes_node_path())
except KeyboardInterrupt:
pass
@@ -290,8 +281,7 @@ def cmd_sync(args):
"\n"
"Shared with your team:\n"
" propose <skill> Share a skill with your organisation",
file=sys.stderr,
)
file=sys.stderr)
return 1
if sub == "device":
@@ -308,8 +298,7 @@ def cmd_sync(args):
print(
"New commits from this device will use this label; existing "
"commits keep their previous one.",
file=sys.stderr,
)
file=sys.stderr)
return 0
# No --name: print the current (creating a default on first use).
print(ssc.stable_device_id())
@@ -331,8 +320,7 @@ def cmd_sync(args):
print(
f"Shared '{name}' with your organisation — an admin needs to "
f"approve it (proposal #{result.get('proposal_id')}). It is "
f"not live for the team until then."
)
f"not live for the team until then.")
else:
print(f"Added '{name}' to your organisation's shared skills.")
return 0
@@ -346,8 +334,7 @@ def cmd_sync(args):
f"'{skill}' is not sync-eligible (bundled, hub-installed, "
f"external, or not found). Only agent-created / user-authored "
f"skills under ~/.hermes/skills/ can sync.",
file=sys.stderr,
)
file=sys.stderr)
return 1
set_sync(skill, sub == "enable")
print(f"sync {'enabled' if sub == 'enable' else 'disabled'} for '{skill}'.")
@@ -365,41 +352,34 @@ def cmd_sync(args):
f"\nOrg skills: {n} shared skill(s) from your organisation "
f"(your role: {status.get('org_role')}). They load alongside "
f"your own, labeled by origin, and you can edit them.",
file=sys.stderr,
)
file=sys.stderr)
if modified:
print(
f" {len(modified)} with local edits not yet shared: "
f"{', '.join(modified)}\n"
f" Share them back with `hermes sync propose <skill>`. "
f"Org updates will not overwrite them.",
file=sys.stderr,
)
file=sys.stderr)
elif status.get("logged_in"):
print(
"\nOrg skills: not applicable — this account isn't a member "
"of a shared organisation.",
file=sys.stderr,
)
file=sys.stderr)
if not status.get("logged_in"):
print("\nNot logged into Nous Portal — sync is inert.", file=sys.stderr)
elif not status.get("nous_admin"):
print(
"\nSync is not enabled for your account yet.",
file=sys.stderr,
)
"\nSync is not enabled for your account yet.", file=sys.stderr)
elif not status.get("feature_enabled"):
print(
"\nSync feature is off for this instance (set HERMES_SYNC_ENABLED=1 "
"or config.yaml sync.enabled: true). Sync is inert.",
file=sys.stderr,
)
file=sys.stderr)
elif not status.get("base_url"):
print(
"\nNo sync base URL configured (config.yaml sync.base_url or "
"HERMES_SYNC_BASE_URL). Sync is inert.",
file=sys.stderr,
)
file=sys.stderr)
return 0
# pull / push / now — enforce the gate up front with a clear message.
@@ -410,16 +390,13 @@ def cmd_sync(args):
return 1
if not identity.get("nous_admin"):
print(
"sync unavailable: not enabled for your account yet.",
file=sys.stderr,
)
"sync unavailable: not enabled for your account yet.", file=sys.stderr)
return 1
if not ssc.resolve_sync_base_url():
print(
"sync inert: no sync base URL configured (config.yaml sync.base_url "
"or HERMES_SYNC_BASE_URL).",
file=sys.stderr,
)
file=sys.stderr)
return 1
try:
@@ -433,8 +410,7 @@ def cmd_sync(args):
print(
f"org: refreshed {n} shared skill(s) from your "
f"organisation.",
file=sys.stderr,
)
file=sys.stderr)
clashes = org_result.get("conflicted") or []
if clashes:
print(
@@ -444,8 +420,7 @@ def cmd_sync(args):
f" Your local version is intact. Review it, then "
f"either propose it or delete the local copy and pull "
f"again to take the org version.",
file=sys.stderr,
)
file=sys.stderr)
elif sub == "push":
result = ssc.push_skills(identity=identity, message="hermes sync push")
elif sub == "now":
@@ -481,8 +456,7 @@ def cmd_slack(args):
" command registered as a native slash\n"
"\n"
"Run `hermes slack manifest -h` for details.",
file=sys.stderr,
)
file=sys.stderr)
return 1
if sub == "manifest":
+53 -160
View File
@@ -32,8 +32,7 @@ _GENERIC_API_KEY_PROVIDERS = frozenset({
"openai-api", "gemini", "deepseek", "xai", "zai", "kimi-coding-cn",
"minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go",
"opencode-free", "alibaba", "huggingface", "xiaomi", "arcee", "gmi",
"nvidia", "ollama-cloud", "tencent-tokenhub", "tencent-tokenplan", "lmstudio",
})
"nvidia", "ollama-cloud", "tencent-tokenhub", "tencent-tokenplan", "lmstudio"})
def _clear_stale_openai_base_url():
@@ -62,8 +61,7 @@ def _clear_stale_openai_base_url():
print(
f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)"
if len(stale_url) > 40
else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})"
)
else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})")
# (task_key, display_name, short_description)
@@ -80,8 +78,7 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
("profile_describer", "Profile describer", "auto profile descriptions"),
("curator", "Curator", "skill-usage review pass"),
]
("curator", "Curator", "skill-usage review pass")]
# Special non-auxiliary task surfaced in the same picker: subagent delegation.
@@ -154,8 +151,7 @@ def _delegation_cfg_as_task(cfg: dict) -> dict:
"provider": str(d.get("provider") or "").strip(),
"model": str(d.get("model") or "").strip(),
"base_url": str(d.get("base_url") or "").strip(),
"api_key": str(d.get("api_key") or "").strip(),
}
"api_key": str(d.get("api_key") or "").strip()}
def _aux_task_display_name(task: str) -> str:
@@ -166,13 +162,7 @@ def _aux_task_display_name(task: str) -> str:
def _save_aux_choice(
task: str,
*,
provider: str,
model: str = "",
base_url: str = "",
api_key: str = "",
) -> None:
task: str, *, provider: str, model: str = "", base_url: str = "", api_key: str = "") -> None:
"""Persist an auxiliary task's provider/model to config.yaml.
Only writes the four routing fields — timeout, download_timeout, and any
@@ -287,8 +277,7 @@ def _aux_config_menu() -> None:
# Build the task menu with current settings inline
all_tasks = _all_aux_tasks()
menu_tasks = all_tasks + [
(_DELEGATION_TASK_KEY, _DELEGATION_TASK_NAME, _DELEGATION_TASK_DESC)
]
(_DELEGATION_TASK_KEY, _DELEGATION_TASK_NAME, _DELEGATION_TASK_DESC)]
name_col = max(len(name) for _, name, _ in menu_tasks) + 2
desc_col = max(len(desc) for _, _, desc in menu_tasks) + 4
entries: list[tuple[str, str]] = []
@@ -297,20 +286,16 @@ def _aux_config_menu() -> None:
task_cfg = _delegation_cfg_as_task(cfg)
else:
task_cfg = (
aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
)
aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {})
current = _format_aux_current(task_cfg)
label = (
f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
)
f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}")
entries.append((task_key, label))
entries.append(("__reset__", "Reset all to auto"))
entries.append(("__back__", "Back"))
idx = _prompt_provider_choice(
[label for _, label in entries],
default=0,
)
[label for _, label in entries], default=0)
if idx is None:
return
key = entries[idx][0]
@@ -360,8 +345,7 @@ def _aux_select_for_task(task: str) -> None:
providers = build_aux_picker_rows(
current_provider=current_provider,
current_model=current_model,
current_base_url=current_base_url,
)
current_base_url=current_base_url)
except Exception as exc:
print(f"Could not detect authenticated providers: {exc}")
providers = []
@@ -369,22 +353,14 @@ def _aux_select_for_task(task: str) -> None:
entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models)
# "auto" always first
auto_marker = (
" ← current" if current_provider == "auto" and not current_base_url else ""
)
" ← current" if current_provider == "auto" and not current_base_url else "")
auto_label = (
"auto (inherit main agent)"
if task == _DELEGATION_TASK_KEY
else "auto (recommended)"
)
"auto (inherit main agent)" if task == _DELEGATION_TASK_KEY else "auto (recommended)")
entries.append(("__auto__", f"{auto_label}{auto_marker}", []))
entries.extend(
format_aux_picker_entries(
providers,
current_provider=current_provider,
current_base_url=current_base_url,
)
)
providers, current_provider=current_provider, current_base_url=current_base_url))
# Custom endpoint (raw base_url)
custom_marker = " ← current" if current_base_url else ""
@@ -417,11 +393,7 @@ def _aux_select_for_task(task: str) -> None:
def _aux_flow_provider_model(
task: str,
provider_slug: str,
curated_models: list,
current_model: str = "",
) -> None:
task: str, provider_slug: str, curated_models: list, current_model: str = "") -> None:
"""Prompt for a model under an already-authenticated provider, save to aux."""
from hermes_cli.auth import _prompt_model_selection
from hermes_cli.models import get_pricing_for_provider
@@ -451,18 +423,14 @@ def _aux_flow_provider_model(
selected = val or ""
else:
selected = _prompt_model_selection(
model_list,
current_model=current_model,
pricing=pricing,
confirm_provider=provider_slug,
model_list, current_model=current_model, pricing=pricing, confirm_provider=provider_slug
)
if selected is None:
print("No change.")
return
_save_aux_choice(
task, provider=provider_slug, model=selected or "", base_url="", api_key=""
)
task, provider=provider_slug, model=selected or "", base_url="", api_key="")
if selected:
print(f"{display_name}: {provider_slug} · {selected}")
else:
@@ -483,8 +451,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print()
try:
url_prompt = (
f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
)
f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: ")
url = line_input(url_prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
@@ -497,8 +464,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
model_prompt = (
f"Model slug (optional) [{current_model}]: "
if current_model
else "Model slug (optional): "
)
else "Model slug (optional): ")
model = line_input(model_prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
@@ -506,19 +472,13 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
model = model or current_model
try:
api_key = masked_secret_prompt(
"API key (optional, blank = use OPENAI_API_KEY): "
).strip()
"API key (optional, blank = use OPENAI_API_KEY): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
_save_aux_choice(
task,
provider="custom",
model=model,
base_url=url,
api_key=api_key,
)
task, provider="custom", model=model, base_url=url, api_key=api_key)
short_url = url.replace("https://", "").replace("http://", "").rstrip("/")
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
@@ -563,9 +523,7 @@ def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
_DEFAULT_QWEN_PORTAL_MODELS = [
"qwen3-coder-plus",
"qwen3-coder",
]
"qwen3-coder-plus", "qwen3-coder"]
def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "") -> Optional[str]:
@@ -583,24 +541,19 @@ def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "")
(
"",
"Auto-detect",
"Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints.",
),
"Use Hermes URL heuristics; best for standard OpenAI-compatible endpoints."),
(
"chat_completions",
"Chat Completions",
"Use /chat/completions for standard OpenAI-compatible servers.",
),
"Use /chat/completions for standard OpenAI-compatible servers."),
(
"codex_responses",
"Responses / Codex",
"Use /responses for Codex-compatible tool-calling backends.",
),
"Use /responses for Codex-compatible tool-calling backends."),
(
"anthropic_messages",
"Anthropic Messages",
"Use /v1/messages for Anthropic-compatible endpoints.",
),
]
"Use /v1/messages for Anthropic-compatible endpoints.")]
print()
print("Select API compatibility mode:")
@@ -616,8 +569,7 @@ def _prompt_custom_api_mode_selection(base_url: str, current_api_mode: str = "")
try:
raw = input(
"Choice [1-4, Enter to keep current/detected]: "
).strip().lower()
"Choice [1-4, Enter to keep current/detected]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
raise
@@ -681,9 +633,7 @@ def _custom_provider_base_url_config_value(provider_info, resolved_base_url=""):
def _save_custom_provider(
base_url, api_key="", model="", context_length=None, name=None, api_mode=None,
key_env=""
):
base_url, api_key="", model="", context_length=None, name=None, api_mode=None, key_env=""):
"""Save a custom endpoint to custom_providers in config.yaml.
Deduplicates by base_url — if the URL already exists, updates the
@@ -781,11 +731,7 @@ def _remove_custom_provider(config):
from hermes_cli.curses_ui import curses_radiolist
idx = curses_radiolist(
"Select provider to remove:",
list(choices),
selected=0,
cancel_returns=-1,
)
"Select provider to remove:", list(choices), selected=0, cancel_returns=-1)
print()
if idx < 0:
idx = None
@@ -807,18 +753,14 @@ def _remove_custom_provider(config):
cfg["custom_providers"] = providers
save_config(cfg)
removed_name = (
removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed)
)
removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed))
print(f'✅ Removed "{removed_name}" from custom providers.')
def _prompt_reasoning_effort_selection(efforts, current_effort=""):
"""Prompt for a reasoning effort. Returns effort, 'none', or None to keep current."""
deduped = list(
dict.fromkeys(
str(effort).strip().lower() for effort in efforts if str(effort).strip()
)
)
dict.fromkeys( str(effort).strip().lower() for effort in efforts if str(effort).strip() ))
canonical_order = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
ordered = [effort for effort in canonical_order if effort in deduped]
ordered.extend(effort for effort in deduped if effort not in canonical_order)
@@ -849,11 +791,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
choices.append(disable_label)
choices.append(skip_label)
idx = curses_radiolist(
"Select reasoning effort:",
choices,
selected=default_idx,
cancel_returns=-1,
)
"Select reasoning effort:", choices, selected=default_idx, cancel_returns=-1)
if idx < 0:
return None
print()
@@ -893,11 +831,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
def _prompt_api_key(
pconfig,
existing_key: str,
provider_id: str = "",
existing_source: str = "",
) -> tuple:
pconfig, existing_key: str, provider_id: str = "", existing_source: str = "") -> tuple:
"""Shared API-key entry point for ``hermes setup`` / ``hermes model``.
Handles both first-time entry and the already-configured case. When a key
@@ -955,8 +889,7 @@ def _prompt_api_key(
menu = (
" [K]eep / [R]eplace (default K): "
if pool_backed
else " [K]eep / [R]eplace / [C]lear (default K): "
)
else " [K]eep / [R]eplace / [C]lear (default K): ")
try:
choice = input(menu).strip().lower()
except (KeyboardInterrupt, EOFError):
@@ -977,8 +910,7 @@ def _prompt_api_key(
if choice.startswith("c") and not pool_backed:
save_env_value(key_env, "")
print(
f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again."
)
f" API key cleared. Re-run `hermes setup` to configure {pconfig.name} again.")
return "", True
# Keep (default, or any other input)
@@ -996,28 +928,18 @@ def _infer_stepfun_region(base_url: str) -> str:
def _stepfun_base_url_for_region(region: str) -> str:
from hermes_cli.auth import (
STEPFUN_STEP_PLAN_CN_BASE_URL,
STEPFUN_STEP_PLAN_INTL_BASE_URL,
)
STEPFUN_STEP_PLAN_CN_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL)
return (
STEPFUN_STEP_PLAN_CN_BASE_URL
if region == "china"
else STEPFUN_STEP_PLAN_INTL_BASE_URL
)
STEPFUN_STEP_PLAN_CN_BASE_URL if region == "china" else STEPFUN_STEP_PLAN_INTL_BASE_URL)
def _run_anthropic_oauth_flow(save_env_value):
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
from agent.anthropic_adapter import (
run_oauth_setup_token,
read_claude_code_credentials,
is_claude_code_token_valid,
)
run_oauth_setup_token, read_claude_code_credentials, is_claude_code_token_valid)
from hermes_cli.config import (
save_anthropic_oauth_token,
use_anthropic_claude_code_credentials,
)
save_anthropic_oauth_token, use_anthropic_claude_code_credentials)
def _activate_claude_code_credentials_if_available() -> bool:
try:
@@ -1025,8 +947,7 @@ def _run_anthropic_oauth_flow(save_env_value):
except Exception:
creds = None
if creds and (
is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))
):
is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))):
use_anthropic_claude_code_credentials(save_fn=save_env_value)
print(" ✓ Claude Code credentials linked.")
from hermes_constants import display_hermes_home as _dhh_fn
@@ -1058,8 +979,7 @@ def _run_anthropic_oauth_flow(save_env_value):
try:
manual_token = masked_secret_prompt(
" Paste setup-token (or Enter to cancel): "
).strip()
" Paste setup-token (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return False
@@ -1118,12 +1038,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
raw_cfg = read_raw_config()
def _record_raw(
name: str,
provider_key: str,
model: str,
api_key: str,
base_url: str,
) -> None:
name: str, provider_key: str, model: str, api_key: str, base_url: str) -> None:
template = str(api_key or "").strip()
base_template = str(base_url or "").strip()
name = str(name or "").strip()
@@ -1138,8 +1053,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
identities.extend(((name.lower(),), (name.lower(), model)))
if provider_key:
identities.extend(
((provider_key.lower(),), (provider_key.lower(), model))
)
((provider_key.lower(),), (provider_key.lower(), model)))
if "${" in template:
for identity in identities:
raw_api_key_refs.setdefault(identity, template)
@@ -1159,8 +1073,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
raw_entry.get("api_key", ""),
raw_entry.get("base_url", "")
or raw_entry.get("url", "")
or raw_entry.get("api", ""),
)
or raw_entry.get("api", ""))
raw_providers = raw_cfg.get("providers")
if isinstance(raw_providers, dict):
for raw_key, raw_entry in raw_providers.items():
@@ -1173,24 +1086,15 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
raw_entry.get("api_key", ""),
raw_entry.get("base_url", "")
or raw_entry.get("url", "")
or raw_entry.get("api", ""),
)
or raw_entry.get("api", ""))
def _lookup_ref(
refs: dict[tuple, str],
name: str,
provider_key: str,
model: str,
) -> str:
refs: dict[tuple, str], name: str, provider_key: str, model: str) -> str:
name_lc = str(name or "").strip().lower()
pkey_lc = str(provider_key or "").strip().lower()
model = str(model or "").strip()
for identity in (
(pkey_lc, model),
(pkey_lc,),
(name_lc, model),
(name_lc,),
):
(pkey_lc, model), (pkey_lc,), (name_lc, model), (name_lc,)):
if identity[0] and identity in refs:
return refs[identity]
return ""
@@ -1218,12 +1122,9 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]:
"api_mode": entry.get("api_mode", ""),
"provider_key": provider_key,
"api_key_ref": _lookup_ref(
raw_api_key_refs, name, provider_key, entry.get("model", "")
),
raw_api_key_refs, name, provider_key, entry.get("model", "")),
"base_url_ref": _lookup_ref(
raw_base_url_refs, name, provider_key, entry.get("model", "")
),
}
raw_base_url_refs, name, provider_key, entry.get("model", ""))}
return custom_provider_map
@@ -1231,8 +1132,7 @@ def _build_provider_picker_rows(
config: dict,
active: str,
provider_labels: dict[str, str],
custom_provider_map: dict[str, dict[str, str]],
) -> tuple[list[tuple[str, str, list[str]]], int]:
custom_provider_map: dict[str, dict[str, str]]) -> tuple[list[tuple[str, str, list[str]]], int]:
"""Rows for the ``hermes model`` provider picker plus the pre-selected index.
Canonical providers are folded into display groups (see PROVIDER_GROUPS in
@@ -1243,11 +1143,7 @@ def _build_provider_picker_rows(
the CLI hides the same providers the gateway/TUI pickers do.
"""
from hermes_cli.models import (
CANONICAL_PROVIDERS,
_PROVIDER_ALIASES,
group_providers,
provider_group_for_slug,
)
CANONICAL_PROVIDERS, _PROVIDER_ALIASES, group_providers, provider_group_for_slug)
_custom_provider_map = custom_provider_map
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
@@ -1259,8 +1155,7 @@ def _build_provider_picker_rows(
_cli_excluded = {
str(p).strip().lower()
for p in (config.get("model_catalog", {}) or {}).get("excluded_providers") or []
if p
}
if p}
if _cli_excluded:
_alias_to_canon = _PROVIDER_ALIASES
_names_for: dict[str, set[str]] = {}
@@ -1270,8 +1165,7 @@ def _build_provider_picker_rows(
_names_for.setdefault(_canon, {_canon.lower()}).add(_alias.lower())
_visible_slugs = [
p.slug for p in CANONICAL_PROVIDERS
if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded
]
if not _names_for.get(p.slug, {p.slug.lower()}) & _cli_excluded]
else:
_visible_slugs = [p.slug for p in CANONICAL_PROVIDERS]
grouped_rows = group_providers(_visible_slugs)
@@ -1320,8 +1214,7 @@ def _build_provider_picker_rows(
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(
config.get("custom_providers")
)
config.get("custom_providers"))
if _has_saved_custom_list:
ordered.append(("remove-custom", "Remove a saved custom provider", []))
ordered.append(("aux-config", "Configure auxiliary models...", []))
+25 -50
View File
@@ -38,22 +38,18 @@ from hermes_cli.model_setup_flows_common import ( # noqa: F401
_prune_replaced_custom_model_config_credentials,
_run_login,
_say,
_show_curated,
)
_show_curated)
from hermes_cli.model_setup_flows_custom import ( # noqa: F401
_model_flow_custom,
_model_flow_named_custom,
)
_model_flow_named_custom)
from hermes_cli.model_setup_flows_azure import ( # noqa: F401
_model_flow_azure_foundry,
)
_model_flow_azure_foundry)
from hermes_cli.model_setup_flows_bedrock import ( # noqa: F401
BEDROCK_GEO_PREFIXES,
bedrock_region_geo_prefix,
bedrock_model_routable_from_region,
_model_flow_bedrock_api_key,
_model_flow_bedrock,
)
_model_flow_bedrock)
def _model_flow_openrouter(config, current_model=""):
@@ -64,8 +60,7 @@ def _model_flow_openrouter(config, current_model=""):
# 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", "")
)
"openrouter", pconfig, missing_hint=("Get one at: https://openrouter.ai/keys", ""))
if abort:
return
@@ -76,8 +71,7 @@ def _model_flow_openrouter(config, current_model=""):
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,
)
confirm_base_url=OPENROUTER_BASE_URL, confirm_api_key=_resolved or existing_key)
_finish_model(selected, "openrouter", f"Default model set to: {selected} (via OpenRouter)",
base_url=OPENROUTER_BASE_URL, api_mode="chat_completions")
@@ -185,8 +179,7 @@ def _nous_login_args(args) -> argparse.Namespace:
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)),
)
insecure=bool(getattr(args, "insecure", False)))
def _nous_model_catalog(free_tier: bool, portal_url: str, model_ids: list, pricing: dict):
@@ -197,8 +190,7 @@ def _nous_model_catalog(free_tier: bool, portal_url: str, model_ids: list, prici
"""
from hermes_cli.models import (
nous_policy_allowed_ids, partition_nous_models_by_tier, restrict_to_nous_policy,
union_with_portal_free_recommendations, union_with_portal_paid_recommendations,
)
union_with_portal_free_recommendations, union_with_portal_paid_recommendations)
# Free users: augment with the Portal's freeRecommendedModels (so newly launched
# free models appear before this build's curated list catches up), then partition
@@ -324,8 +316,7 @@ def _model_flow_nous(config, current_model="", args=None):
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", ""),
)
confirm_base_url=creds.get("base_url", ""), confirm_api_key=creds.get("api_key", ""))
if not selected:
print("No change.")
return
@@ -393,8 +384,7 @@ def _model_flow_openai_codex(config, current_model=""):
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 "",
)
confirm_base_url=DEFAULT_CODEX_BASE_URL, confirm_api_key=_codex_token or "")
_activate_provider_model(selected, "openai-codex", DEFAULT_CODEX_BASE_URL,
f"Default model set to: {selected} (via OpenAI Codex)")
@@ -403,15 +393,13 @@ 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, resolve_xai_oauth_runtime_credentials, _login_xai_oauth,
DEFAULT_XAI_OAUTH_BASE_URL, PROVIDER_REGISTRY,
)
DEFAULT_XAI_OAUTH_BASE_URL, PROVIDER_REGISTRY)
from hermes_cli.models import provider_model_ids
login_args = argparse.Namespace(no_browser=bool(getattr(args, "no_browser", False)), timeout=getattr(args, "timeout", None))
if not _oauth_gate(
bool(get_xai_oauth_auth_status().get("logged_in")), "xAI Grok OAuth (SuperGrok / Premium+)", _login_xai_oauth,
login_args, PROVIDER_REGISTRY["xai-oauth"], fresh_name="xAI OAuth",
):
login_args, PROVIDER_REGISTRY["xai-oauth"], fresh_name="xAI OAuth"):
return
# ``resolve_xai_oauth_runtime_credentials`` only reads the auth.json singleton,
@@ -466,8 +454,7 @@ 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, resolve_minimax_oauth_runtime_credentials, AuthError,
format_auth_error, _login_minimax_oauth, PROVIDER_REGISTRY,
)
format_auth_error, _login_minimax_oauth, PROVIDER_REGISTRY)
state = get_provider_auth_state("minimax-oauth")
if not state or not state.get("access_token"):
@@ -475,8 +462,7 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
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,
)
timeout=getattr(args, "timeout", None) or 15.0)
if not _run_login(_login_minimax_oauth, mock_args, PROVIDER_REGISTRY["minimax-oauth"]):
return
@@ -567,8 +553,7 @@ def _model_flow_copilot(config, current_model=""):
from hermes_cli.config import 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,
)
normalize_copilot_model_id)
provider_id = "copilot"
pconfig = PROVIDER_REGISTRY[provider_id]
@@ -600,8 +585,7 @@ def _model_flow_copilot(config, current_model=""):
selected = _pick_model_or_prompt(
_copilot_model_list(live_models), "Model name: ", current_model=_normalize(current_model),
confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=api_key,
)
confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=api_key)
if not selected:
print("No change.")
return
@@ -631,8 +615,7 @@ 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,
)
resolve_external_process_provider_credentials)
from hermes_cli.models import fetch_github_model_catalog, normalize_copilot_model_id
del config
@@ -666,8 +649,7 @@ def _model_flow_copilot_acp(config, current_model=""):
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,
)
confirm_base_url=effective_base, confirm_api_key=catalog_api_key)
if selected:
selected = _normalize(selected)
_finish_model(selected, provider_id, f"Default model set to: {selected} (via {pconfig.name})",
@@ -712,8 +694,7 @@ def _model_flow_kimi(config, current_model=""):
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,
)
confirm_base_url=effective_base, confirm_api_key=existing_key)
# api_mode is dropped so the runtime auto-detects it from the URL.
_finish_model(selected, provider_id, f"Default model set to: {selected} (via {'Kimi Coding' if is_coding_plan else 'Moonshot'})",
base_url=effective_base, drop_api_mode=True)
@@ -745,8 +726,7 @@ def _model_flow_stepfun(config, current_model=""):
region_choices = [
("international", f"International ({_stepfun_base_url_for_region('international')})"),
("china", f"China ({_stepfun_base_url_for_region('china')})"),
]
("china", f"China ({_stepfun_base_url_for_region('china')})")]
ordered_regions = []
for region_key, label in region_choices:
if region_key == current_region:
@@ -773,8 +753,7 @@ def _model_flow_stepfun(config, current_model=""):
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,
)
confirm_base_url=effective_base, confirm_api_key=existing_key)
model = _finish_model(selected, provider_id, f"Default model set to: {selected} (via {pconfig.name})",
base_url=effective_base, drop_api_mode=True)
if model is not None:
@@ -877,8 +856,7 @@ _GEMINI_FREE_TIER_NOTICE = (
" To use Gemini with Hermes, enable billing on your Google Cloud project and regenerate",
" the key in a billing-enabled project: https://aistudio.google.com/apikey", "",
" Alternatives with workable free usage: DeepSeek, OpenRouter (free models), Groq, Nous.", "",
"Not saving Gemini as the default provider.",
)
"Not saving Gemini as the default provider.")
def _gemini_tier_ok(existing_key: str, pconfig, base_url_env: str) -> bool:
@@ -1050,16 +1028,14 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
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,
)
confirm_base_url=effective_base, confirm_api_key=existing_key)
if selected and is_opencode:
selected = normalize_opencode_model_id(provider_id, selected)
# OpenCode pins its api_mode; everyone else drops it so the runtime auto-detects.
_finish_model(
selected, provider_id, f"Default model set to: {selected} (via {pconfig.name})", base_url=effective_base,
api_mode=opencode_model_api_mode(provider_id, selected) if selected and is_opencode else None,
drop_api_mode=not is_opencode,
)
drop_api_mode=not is_opencode)
def _model_flow_anthropic(config, current_model=""):
@@ -1137,8 +1113,7 @@ def _model_flow_anthropic(config, current_model=""):
selected = _pick_model_or_prompt(
_PROVIDER_MODELS.get("anthropic", []), "Model name (e.g., claude-sonnet-4-20250514): ",
current_model=current_model, confirm_provider="anthropic",
)
current_model=current_model, confirm_provider="anthropic")
# Clear base_url: resolve_runtime_provider() always hardcodes Anthropic's URL,
# and a stale value can contaminate other providers on a later switch.
_finish_model(selected, "anthropic", f"Default model set to: {selected} (via Anthropic)", drop_base_url=True, drop_api_mode=True)
+1 -2
View File
@@ -21,8 +21,7 @@ def _azure_entra_preflight(current_entra: dict):
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider, describe_active_credential,
has_azure_identity_installed,
)
has_azure_identity_installed)
except ImportError as exc:
_say("", f"⚠ Could not import azure-identity adapter: {exc}", " Falling back to API key auth.")
return False
+3 -6
View File
@@ -8,8 +8,7 @@ Prompt strings and config write order are behavior.
from __future__ import annotations
from hermes_cli.model_setup_flows_common import (
_ask, _ensure_dict_section, _finish_model, _pick_model_or_prompt, _say,
)
_ask, _ensure_dict_section, _finish_model, _pick_model_or_prompt, _say)
# AWS cross-region inference profile prefixes. A geo-prefixed profile only routes
@@ -86,8 +85,7 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
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,
)
confirm_base_url=mantle_base_url, confirm_api_key=existing_key)
def _finish(cfg, _model):
# The bearer token rides on a named provider entry: a bare ``provider: custom``
@@ -139,8 +137,7 @@ def _bedrock_text_model_ids(live_models: list, region: str) -> list[str]:
m for m in live_models
if not any(m["id"].startswith(p) for p in _BEDROCK_EXCLUDE_PREFIXES)
and not any(s in m["id"].lower() for s in _BEDROCK_EXCLUDE_SUBSTRINGS)
and bedrock_model_routable_from_region(m["id"], region)
]
and bedrock_model_routable_from_region(m["id"], region)]
# Deduplicate: prefer inference profiles (geo-prefixed or global.*) over bare foundation model IDs.
profile_base_ids = {_base_id(m["id"]) for m in filtered if m["id"].startswith(_BEDROCK_PROFILE_PREFIXES)}
deduped = [m for m in filtered if m["id"].startswith(_BEDROCK_PROFILE_PREFIXES) or m["id"] not in profile_base_ids]
+2 -4
View File
@@ -252,15 +252,13 @@ def _prune_replaced_custom_model_config_credentials(base_url: str, *, provider_n
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)
):
or not isinstance(entries, list)):
continue
retained = [e for e in entries if not (isinstance(e, dict) and e.get("source") == "model_config")]
if len(retained) != len(entries):
removed_ids = [
str(e["id"]) for e in entries
if isinstance(e, dict) and e.get("source") == "model_config" and e.get("id")
]
if isinstance(e, dict) and e.get("source") == "model_config" and e.get("id")]
write_credential_pool(pool_key, retained, removed_ids=removed_ids)
except Exception:
return
+2 -4
View File
@@ -15,8 +15,7 @@ from hermes_cli.cli_output import line_input
from hermes_cli.providers import custom_provider_slug
from hermes_cli.model_setup_flows_common import (
_HTTP, _ask, _commit_model_config, _load_config_model_section,
_prune_replaced_custom_model_config_credentials, _say,
)
_prune_replaced_custom_model_config_credentials, _say)
def _parse_context_length(text: str):
@@ -214,8 +213,7 @@ def _discover_named_custom_models(provider_info: dict, api_key: str, configured_
from hermes_cli.config import normalize_extra_headers
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,
)
should_use_ollama_native_catalog)
name, base_url = provider_info["name"], provider_info["base_url"]
api_mode = provider_info.get("api_mode", "")