fix(cli): route keyless first run into provider onboarding instead of a broken chat

A completely unconfigured install previously booted into a working-looking
chat (banner showed model 'unknown'), accepted a message, spun ~30s, then
failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose —
and never offered setup.

- HermesCLI.run() now probes provider readiness at startup (TTY only) and
  offers the shared provider picker (hermes model flow, which fronts Quick
  Setup / Nous Portal OAuth) when nothing is configured. Decline is
  respected; picker state re-syncs into the live CLI so the next turn works
  without a restart.
- New silent probe _runtime_credentials_ready(): no printing, no state
  mutation; handles keyless local endpoints and callable bearer providers.
- The empty-api-key error is provider-aware: names the actual resolved
  provider and points at 'hermes model' / 'hermes setup' instead of
  hardcoding OPENROUTER_API_KEY.
- Banner: unconfigured installs render 'no model configured — run /model'
  in red instead of the silent 'unknown' model slug.

Consumer-onboarding audit finding #2 (sev 5), Aug 2026.
This commit is contained in:
Teknium
2026-08-01 14:48:24 -07:00
parent cc0af6b9e8
commit d7522118ef
4 changed files with 385 additions and 9 deletions

View File

@@ -111,8 +111,13 @@ class CLIAgentSetupMixin:
base_url, _source,
)
else:
print("\n⚠️ Provider resolver returned an empty API key. "
"Set OPENROUTER_API_KEY or run: hermes setup")
_prov = (resolved_provider or self.requested_provider or "").strip()
if _prov and _prov != "auto":
print(f"\n⚠️ No API key found for provider '{_prov}'.")
else:
print("\n⚠️ No inference provider is configured.")
print(" Run 'hermes model' to choose a provider, or "
"'hermes setup' for first-time setup.")
return False
if not isinstance(base_url, str) or not base_url:
print("\n⚠️ Provider resolver returned an empty base URL. "
@@ -179,6 +184,104 @@ class CLIAgentSetupMixin:
return True
def _runtime_credentials_ready(self) -> bool:
"""Silently probe whether any inference provider can be resolved.
Unlike ``_ensure_runtime_credentials`` this never prints and never
mutates CLI state — it exists so the interactive first-run path can
detect a completely unconfigured install *before* the user types a
message into a chat that cannot work (#62935-adjacent UX class:
keyless first run must route into onboarding, not a broken chat).
"""
from hermes_cli.runtime_provider import resolve_runtime_provider
try:
runtime = resolve_runtime_provider(
requested=self.requested_provider,
explicit_api_key=self._explicit_api_key,
explicit_base_url=self._explicit_base_url,
)
except Exception:
return False
if not isinstance(runtime, dict):
return False
api_key = runtime.get("api_key")
base_url = runtime.get("base_url")
if callable(api_key) and not isinstance(api_key, str):
return bool(base_url)
if isinstance(api_key, str) and api_key:
return bool(base_url)
# Keyless custom/local endpoints (ollama, llama.cpp, vLLM…) are fine.
return bool(
isinstance(base_url, str)
and base_url
and "openrouter.ai" not in base_url
)
def _offer_first_run_setup(self) -> bool:
"""Offer the provider picker when no provider is configured at all.
Called from the interactive startup path when
``_runtime_credentials_ready()`` is False and stdin is a TTY. Runs the
exact same flow as ``hermes model`` (which fronts Quick Setup / Nous
Portal OAuth as the first, recommended option) so there is a single
source of truth for provider onboarding. Returns True when a provider
was configured.
"""
from cli import _cprint, logger
_cprint("")
_cprint("⚕ No inference provider is configured yet — let's fix that.")
_cprint(" You'll pick a provider (Nous Portal OAuth is the fastest; "
"no API key needed) and a model.")
try:
answer = input(" Set up a provider now? [Y/n]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
answer = "n"
if answer in {"n", "no"}:
_cprint(" Skipped. Run 'hermes model' or 'hermes setup' any time.")
return False
try:
from hermes_cli.main import select_provider_and_model
select_provider_and_model()
except (KeyboardInterrupt, EOFError, SystemExit):
print()
_cprint(" Setup cancelled. Run 'hermes model' any time.")
return False
except Exception as exc:
logger.debug("first-run provider setup failed: %s", exc)
_cprint(f" ⚠️ Provider setup failed: {exc}")
_cprint(" Run 'hermes model' to try again.")
return False
# Re-sync CLI state from what the picker persisted so the very next
# turn uses the new provider without a restart.
try:
from hermes_cli.config import load_config
_model_cfg = (load_config().get("model") or {})
if isinstance(_model_cfg, dict):
_new_provider = (_model_cfg.get("provider") or "").strip()
if _new_provider:
self.requested_provider = _new_provider
_new_model = (
_model_cfg.get("default") or _model_cfg.get("model") or ""
).strip()
if _new_model:
self.model = _new_model
except Exception as exc:
logger.debug("first-run config re-sync failed: %s", exc)
# Force credential re-resolution + agent rebuild on next use.
self.agent = None
self._active_agent_route_signature = None
if self._runtime_credentials_ready():
_cprint(" ✓ Provider configured — you're ready to chat.")
return True
_cprint(" Provider setup didn't complete. Run 'hermes model' to retry.")
return False
def _resolve_turn_agent_config(self, user_message: str) -> dict:
"""Build the effective model/runtime config for a single user turn.