Skip to content

Commit e2343e2

Browse files
committed
fix: re-order providers,Quick Install, subscription polling
1 parent e0b2bdb commit e2343e2

File tree

4 files changed

+666
-547
lines changed

4 files changed

+666
-547
lines changed

hermes_cli/auth.py

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2556,13 +2556,83 @@ def _nous_device_code_login(
25562556
"agent_key_reused": None,
25572557
"agent_key_obtained_at": None,
25582558
}
2559-
return refresh_nous_oauth_from_state(
2560-
auth_state,
2561-
min_key_ttl_seconds=min_key_ttl_seconds,
2562-
timeout_seconds=timeout_seconds,
2563-
force_refresh=False,
2564-
force_mint=True,
2565-
)
2559+
try:
2560+
return refresh_nous_oauth_from_state(
2561+
auth_state,
2562+
min_key_ttl_seconds=min_key_ttl_seconds,
2563+
timeout_seconds=timeout_seconds,
2564+
force_refresh=False,
2565+
force_mint=True,
2566+
)
2567+
except AuthError as exc:
2568+
if exc.code == "subscription_required":
2569+
auth_state["subscription_required"] = True
2570+
return auth_state
2571+
raise
2572+
2573+
2574+
SUBSCRIPTION_POLL_TIMEOUT_SECONDS = 300
2575+
SUBSCRIPTION_POLL_INTERVAL_SECONDS = 10
2576+
2577+
2578+
def _wait_for_subscription(
2579+
auth_state: Dict[str, Any],
2580+
*,
2581+
timeout_seconds: int = SUBSCRIPTION_POLL_TIMEOUT_SECONDS,
2582+
poll_interval: int = SUBSCRIPTION_POLL_INTERVAL_SECONDS,
2583+
min_key_ttl_seconds: int = 5 * 60,
2584+
) -> Dict[str, Any]:
2585+
"""Poll for subscription activation after successful device auth.
2586+
2587+
Mirrors the device-code polling UX: prints a banner with the subscription
2588+
URL, then checks every ``poll_interval`` seconds whether the agent key
2589+
mint succeeds. Returns the updated auth state on success, raises
2590+
SystemExit on timeout or Ctrl+C.
2591+
"""
2592+
portal_url = auth_state.get("portal_base_url", DEFAULT_NOUS_PORTAL_URL).rstrip("/")
2593+
pricing_url = f"{portal_url}/pricing"
2594+
2595+
print()
2596+
print("Your Nous Portal account does not have an active subscription.")
2597+
print()
2598+
print(f" Subscribe here: {pricing_url}")
2599+
print()
2600+
print("Waiting for subscription activation... (Ctrl+C to cancel)")
2601+
2602+
deadline = time.time() + max(1, timeout_seconds)
2603+
next_check = time.time()
2604+
2605+
while time.time() < deadline:
2606+
remaining = int(deadline - time.time())
2607+
mins, secs = divmod(max(0, remaining), 60)
2608+
print(f" Checking... ({mins}:{secs:02d} remaining)", end="\r", flush=True)
2609+
2610+
if time.time() >= next_check:
2611+
next_check = time.time() + poll_interval
2612+
try:
2613+
updated = refresh_nous_oauth_from_state(
2614+
auth_state,
2615+
min_key_ttl_seconds=min_key_ttl_seconds,
2616+
timeout_seconds=15.0,
2617+
force_refresh=False,
2618+
force_mint=True,
2619+
)
2620+
if updated.get("agent_key"):
2621+
print()
2622+
print("Subscription activated!")
2623+
return updated
2624+
except AuthError as exc:
2625+
if exc.code == "subscription_required":
2626+
pass
2627+
else:
2628+
raise
2629+
2630+
time.sleep(1)
2631+
2632+
print()
2633+
print("Timed out waiting for subscription activation.")
2634+
print(f"Subscribe at {pricing_url}, then run `hermes model` to continue setup.")
2635+
raise SystemExit(1)
25662636

25672637

25682638
def _login_nous(args, pconfig: ProviderConfig) -> None:
@@ -2587,6 +2657,13 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
25872657
ca_bundle=ca_bundle,
25882658
min_key_ttl_seconds=5 * 60,
25892659
)
2660+
2661+
if auth_state.get("subscription_required"):
2662+
auth_state = _wait_for_subscription(
2663+
auth_state,
2664+
min_key_ttl_seconds=5 * 60,
2665+
)
2666+
25902667
inference_base_url = auth_state["inference_base_url"]
25912668
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
25922669

@@ -2610,8 +2687,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
26102687
code="invalid_token",
26112688
)
26122689

2613-
# Use curated model list (same as OpenRouter defaults) instead
2614-
# of the full /models dump which returns hundreds of models.
26152690
from hermes_cli.models import _PROVIDER_MODELS
26162691
model_ids = _PROVIDER_MODELS.get("nous", [])
26172692

hermes_cli/main.py

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -901,7 +901,7 @@ def select_provider_and_model(args=None):
901901
try:
902902
active = resolve_provider("auto")
903903
except AuthError:
904-
active = "openrouter" # no provider yet; show full picker
904+
active = None # no provider yet; default to first in list
905905

906906
# Detect custom endpoint
907907
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
@@ -926,21 +926,25 @@ def select_provider_and_model(args=None):
926926
"huggingface": "Hugging Face",
927927
"custom": "Custom endpoint",
928928
}
929-
active_label = provider_labels.get(active, active)
929+
active_label = provider_labels.get(active, active) if active else "none"
930930

931931
print()
932932
print(f" Current model: {current_model}")
933933
print(f" Active provider: {active_label}")
934934
print()
935935

936-
# Step 1: Provider selection — put active provider first with marker
937-
providers = [
938-
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
936+
# Step 1: Provider selection — top providers shown first, rest behind "More..."
937+
top_providers = [
939938
("nous", "Nous Portal (Nous Research subscription)"),
939+
("openrouter", "OpenRouter (100+ models, pay-per-use)"),
940+
("anthropic", "Anthropic (Claude models — API key or Claude Code)"),
940941
("openai-codex", "OpenAI Codex"),
941-
("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
942942
("copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
943-
("anthropic", "Anthropic (Claude models — API key or Claude Code)"),
943+
("huggingface", "Hugging Face Inference Providers (20+ open models)"),
944+
]
945+
946+
extended_providers = [
947+
("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
944948
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
945949
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
946950
("minimax", "MiniMax (global direct API)"),
@@ -950,7 +954,6 @@ def select_provider_and_model(args=None):
950954
("opencode-go", "OpenCode Go (open models, $10/month subscription)"),
951955
("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
952956
("alibaba", "Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
953-
("huggingface", "Hugging Face Inference Providers (20+ open models)"),
954957
]
955958

956959
# Add user-defined custom providers from config.yaml
@@ -964,44 +967,66 @@ def select_provider_and_model(args=None):
964967
base_url = (entry.get("base_url") or "").strip()
965968
if not name or not base_url:
966969
continue
967-
# Generate a stable key from the name
968970
key = "custom:" + name.lower().replace(" ", "-")
969971
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
970972
saved_model = entry.get("model", "")
971973
model_hint = f" — {saved_model}" if saved_model else ""
972-
providers.append((key, f"{name} ({short_url}){model_hint}"))
974+
top_providers.append((key, f"{name} ({short_url}){model_hint}"))
973975
_custom_provider_map[key] = {
974976
"name": name,
975977
"base_url": base_url,
976978
"api_key": entry.get("api_key", ""),
977979
"model": saved_model,
978980
}
979981

980-
# Always add the manual custom endpoint option last
981-
providers.append(("custom", "Custom endpoint (enter URL manually)"))
982+
top_keys = {k for k, _ in top_providers}
983+
extended_keys = {k for k, _ in extended_providers}
982984

983-
# Add removal option if there are saved custom providers
984-
if _custom_provider_map:
985-
providers.append(("remove-custom", "Remove a saved custom provider"))
985+
# If the active provider is in the extended list, promote it into top
986+
if active and active in extended_keys:
987+
promoted = [(k, l) for k, l in extended_providers if k == active]
988+
extended_providers = [(k, l) for k, l in extended_providers if k != active]
989+
top_providers = promoted + top_providers
990+
top_keys.add(active)
986991

987-
# Reorder so the active provider is at the top
988-
known_keys = {k for k, _ in providers}
989-
active_key = active if active in known_keys else "custom"
992+
# Build the primary menu
990993
ordered = []
991-
for key, label in providers:
992-
if key == active_key:
993-
ordered.insert(0, (key, f"{label} ← currently active"))
994+
default_idx = 0
995+
for key, label in top_providers:
996+
if active and key == active:
997+
ordered.append((key, f"{label} ← currently active"))
998+
default_idx = len(ordered) - 1
994999
else:
9951000
ordered.append((key, label))
1001+
1002+
ordered.append(("more", "More providers..."))
9961003
ordered.append(("cancel", "Cancel"))
9971004

998-
provider_idx = _prompt_provider_choice([label for _, label in ordered])
1005+
provider_idx = _prompt_provider_choice(
1006+
[label for _, label in ordered], default=default_idx,
1007+
)
9991008
if provider_idx is None or ordered[provider_idx][0] == "cancel":
10001009
print("No change.")
10011010
return
10021011

10031012
selected_provider = ordered[provider_idx][0]
10041013

1014+
# "More providers..." — show the extended list
1015+
if selected_provider == "more":
1016+
ext_ordered = list(extended_providers)
1017+
ext_ordered.append(("custom", "Custom endpoint (enter URL manually)"))
1018+
if _custom_provider_map:
1019+
ext_ordered.append(("remove-custom", "Remove a saved custom provider"))
1020+
ext_ordered.append(("cancel", "Cancel"))
1021+
1022+
ext_idx = _prompt_provider_choice(
1023+
[label for _, label in ext_ordered], default=0,
1024+
)
1025+
if ext_idx is None or ext_ordered[ext_idx][0] == "cancel":
1026+
print("No change.")
1027+
return
1028+
selected_provider = ext_ordered[ext_idx][0]
1029+
10051030
# Step 2: Provider-specific setup + model selection
10061031
if selected_provider == "openrouter":
10071032
_model_flow_openrouter(config, current_model)
@@ -1027,34 +1052,33 @@ def select_provider_and_model(args=None):
10271052
_model_flow_api_key_provider(config, selected_provider, current_model)
10281053

10291054

1030-
def _prompt_provider_choice(choices):
1031-
"""Show provider selection menu. Returns index or None."""
1055+
def _prompt_provider_choice(choices, *, default=0):
1056+
"""Show provider selection menu with curses arrow-key navigation.
1057+
1058+
Falls back to a numbered list when curses is unavailable (e.g. piped
1059+
stdin, non-TTY environments). Returns the selected index, or None
1060+
if the user cancels.
1061+
"""
10321062
try:
1033-
from simple_term_menu import TerminalMenu
1034-
menu_items = [f" {c}" for c in choices]
1035-
menu = TerminalMenu(
1036-
menu_items, cursor_index=0,
1037-
menu_cursor="-> ", menu_cursor_style=("fg_green", "bold"),
1038-
menu_highlight_style=("fg_green",),
1039-
cycle_cursor=True, clear_screen=False,
1040-
title="Select provider:",
1041-
)
1042-
idx = menu.show()
1043-
print()
1044-
return idx
1045-
except (ImportError, NotImplementedError):
1063+
from hermes_cli.setup import _curses_prompt_choice
1064+
idx = _curses_prompt_choice("Select provider:", choices, default)
1065+
if idx >= 0:
1066+
print()
1067+
return idx
1068+
except Exception:
10461069
pass
10471070

10481071
# Fallback: numbered list
10491072
print("Select provider:")
10501073
for i, c in enumerate(choices, 1):
1051-
print(f" {i}. {c}")
1074+
marker = "→" if i - 1 == default else " "
1075+
print(f" {marker} {i}. {c}")
10521076
print()
10531077
while True:
10541078
try:
1055-
val = input(f"Choice [1-{len(choices)}]: ").strip()
1079+
val = input(f"Choice [1-{len(choices)}] ({default + 1}): ").strip()
10561080
if not val:
1057-
return None
1081+
return default
10581082
idx = int(val) - 1
10591083
if 0 <= idx < len(choices):
10601084
return idx
@@ -1077,7 +1101,8 @@ def _model_flow_openrouter(config, current_model=""):
10771101
print("Get one at: https://openrouter.ai/keys")
10781102
print()
10791103
try:
1080-
key = input("OpenRouter API key (or Enter to cancel): ").strip()
1104+
import getpass
1105+
key = getpass.getpass("OpenRouter API key (or Enter to cancel): ").strip()
10811106
except (KeyboardInterrupt, EOFError):
10821107
print()
10831108
return
@@ -1294,7 +1319,8 @@ def _model_flow_custom(config):
12941319

12951320
try:
12961321
base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip()
1297-
api_key = input(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip()
1322+
import getpass
1323+
api_key = getpass.getpass(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip()
12981324
except (KeyboardInterrupt, EOFError):
12991325
print("\nCancelled.")
13001326
return
@@ -1803,7 +1829,8 @@ def _model_flow_copilot(config, current_model=""):
18031829
return
18041830
elif choice == "2":
18051831
try:
1806-
new_key = input(" Token (COPILOT_GITHUB_TOKEN): ").strip()
1832+
import getpass
1833+
new_key = getpass.getpass(" Token (COPILOT_GITHUB_TOKEN): ").strip()
18071834
except (KeyboardInterrupt, EOFError):
18081835
print()
18091836
return
@@ -2044,7 +2071,8 @@ def _model_flow_kimi(config, current_model=""):
20442071
print(f"No {pconfig.name} API key configured.")
20452072
if key_env:
20462073
try:
2047-
new_key = input(f"{key_env} (or Enter to cancel): ").strip()
2074+
import getpass
2075+
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
20482076
except (KeyboardInterrupt, EOFError):
20492077
print()
20502078
return
@@ -2138,7 +2166,8 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
21382166
print(f"No {pconfig.name} API key configured.")
21392167
if key_env:
21402168
try:
2141-
new_key = input(f"{key_env} (or Enter to cancel): ").strip()
2169+
import getpass
2170+
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
21422171
except (KeyboardInterrupt, EOFError):
21432172
print()
21442173
return
@@ -2272,7 +2301,8 @@ def _activate_claude_code_credentials_if_available() -> bool:
22722301
print(" If the setup-token was displayed above, paste it here:")
22732302
print()
22742303
try:
2275-
manual_token = input(" Paste setup-token (or Enter to cancel): ").strip()
2304+
import getpass
2305+
manual_token = getpass.getpass(" Paste setup-token (or Enter to cancel): ").strip()
22762306
except (KeyboardInterrupt, EOFError):
22772307
print()
22782308
return False
@@ -2299,7 +2329,8 @@ def _activate_claude_code_credentials_if_available() -> bool:
22992329
print(" Or paste an existing setup-token now (sk-ant-oat-...):")
23002330
print()
23012331
try:
2302-
token = input(" Setup-token (or Enter to cancel): ").strip()
2332+
import getpass
2333+
token = getpass.getpass(" Setup-token (or Enter to cancel): ").strip()
23032334
except (KeyboardInterrupt, EOFError):
23042335
print()
23052336
return False
@@ -2392,7 +2423,8 @@ def _model_flow_anthropic(config, current_model=""):
23922423
print(" Get an API key at: https://console.anthropic.com/settings/keys")
23932424
print()
23942425
try:
2395-
api_key = input(" API key (sk-ant-...): ").strip()
2426+
import getpass
2427+
api_key = getpass.getpass(" API key (sk-ant-...): ").strip()
23962428
except (KeyboardInterrupt, EOFError):
23972429
print()
23982430
return

0 commit comments

Comments
 (0)