mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
LEFT_CORE rows gain three generic fields for a model-provider plugin: - providers: the ids and aliases the plugin's profiles register. A name nothing resolves to gets the install line appended on every "unknown provider" surface (runtime resolution / Desktop error text via auth.resolve_provider, /model, `hermes model`, `hermes auth add`); resolve_runtime_provider installs the plugin first for a home that uses it (the CLI and Desktop resolve the provider before the agent whose start runs the migration exists); and hermes_cli.providers.get_provider never lets the models.dev catalog stand in for such a name while the plugin is not loaded (models.dev lists `github-copilot` at api.githubcopilot.com with GITHUB_TOKEN: it would have sent a raw GitHub token there). - profile_env: env names core kept profile-managed. env_loader's _PROFILE_MANAGED_ENV_KEYS and config._EXTRA_ENV_KEYS read them statically (env_loader runs before discovery), and the generic env writers refuse them while the plugin is absent (the executable/argv authority its profile declares once installed). - notice: one more per-home sentence on every migration outcome. First consumer: the github-copilot row (providers copilot + copilot-acp and every alias). It is in use when copilot/copilot-acp is a provider the home routes through: model, fallback chain, auxiliary tasks, delegation and its fallback chain, MoA presets, cron job pins, auth.json active_provider, or HERMES_INFERENCE_PROVIDER. A GH_TOKEN alone is not use. COPILOT_GITHUB_TOKEN and the two base-URL overrides stay out of children as provider env (Tier 2), as when core shipped Copilot. Its notice tells a profile with no token the plugin reads (no COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN, no `manual` credential-pool row or providers.copilot login: a gh_cli pool row was seeded from `gh auth token`) to run `hermes auth add copilot`; the plugin no longer reads gh or the Copilot CLI's token files. Read-only, never runs gh. The unknown-provider hint helper moves out of auth.py (over the size cap) into auth_error_copy.unknown_provider_hint.
164 lines
7.4 KiB
Python
164 lines
7.4 KiB
Python
"""Plain-language copy for sign-in and provider-setup failures (CLI).
|
|
|
|
One table of ``(predicate, lead sentence)`` classifies an exception into a sentence a first-time
|
|
user can act on; the raw exception is demoted to a ``Details:`` line so nothing is lost for
|
|
support. Callers print ``sign_in_failure_lines(...)`` / ``provider_setup_failure_lines(...)``
|
|
line by line.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable, Sequence, Tuple
|
|
|
|
# httpx class names and stdlib bases that mean "the request never got a usable answer".
|
|
_NETWORK_ERROR_TYPES = frozenset({
|
|
"ConnectError", "ConnectTimeout", "ReadTimeout", "PoolTimeout", "WriteTimeout", "TimeoutException",
|
|
"RemoteProtocolError", "ReadError", "ProxyError", "UnsupportedProtocol", "NetworkError",
|
|
})
|
|
|
|
# OAuth device-flow error codes (RFC 8628 §3.5) -> plain copy. ``{retry}`` is the retry command.
|
|
DEVICE_FLOW_ERROR_COPY = {
|
|
"expired_token": (
|
|
"The sign-in code expired before it was approved in the browser. Run `{retry}` to get a new code."),
|
|
"access_denied": (
|
|
"Sign-in was declined in the browser. Run `{retry}` to try again, or `hermes model` to pick a "
|
|
"different provider."),
|
|
"invalid_grant": (
|
|
"The sign-in code was not accepted by the server. Run `{retry}` to get a new code."),
|
|
"invalid_client": (
|
|
"The server did not recognize this copy of Hermes. Run `hermes update`, then `{retry}` again."),
|
|
}
|
|
|
|
|
|
class DeviceCodeExpired(TimeoutError):
|
|
"""The device code ran out before the user approved it in the browser (not a network timeout)."""
|
|
|
|
code = "device_code_timeout"
|
|
|
|
|
|
# ``AuthError.code`` values the device-code pollers raise when the code runs out.
|
|
_DEVICE_CODE_EXPIRED_CODES = frozenset({"device_code_timeout", "timeout"})
|
|
|
|
|
|
class SignInCopyError(RuntimeError):
|
|
"""Exception whose ``str()`` is already user copy (lead line + ``Details:`` line)."""
|
|
|
|
def __init__(self, message: str, *, oauth_error_code: str = "") -> None:
|
|
super().__init__(message)
|
|
self.oauth_error_code = oauth_error_code
|
|
|
|
|
|
def is_network_error(exc: BaseException) -> bool:
|
|
"""True for connection/DNS/timeout failures from httpx, requests or the stdlib."""
|
|
if isinstance(exc, (SignInCopyError, DeviceCodeExpired)):
|
|
return False
|
|
names = {cls.__name__ for cls in type(exc).__mro__}
|
|
return bool(names & _NETWORK_ERROR_TYPES) or isinstance(exc, (ConnectionError, TimeoutError))
|
|
|
|
|
|
def is_device_code_expired(exc: BaseException) -> bool:
|
|
"""True when a device-code sign-in ended because its code ran out (any provider)."""
|
|
return (getattr(exc, "code", None) in _DEVICE_CODE_EXPIRED_CODES
|
|
or getattr(exc, "oauth_error_code", "") == "expired_token")
|
|
|
|
|
|
def is_cancelled(exc: BaseException) -> bool:
|
|
return isinstance(exc, (KeyboardInterrupt, EOFError)) or (
|
|
isinstance(exc, SystemExit) and exc.code in (130, None, 0))
|
|
|
|
|
|
def device_flow_error(code: str, description: str, *, retry_command: str = "hermes portal") -> SignInCopyError:
|
|
"""Exception for an OAuth device-flow error code whose text is already user-facing.
|
|
|
|
Unknown codes keep the server's description as the lead (it is the only information available)
|
|
but still name the retry command.
|
|
"""
|
|
lead = DEVICE_FLOW_ERROR_COPY.get(code, "").format(retry=retry_command)
|
|
if not lead:
|
|
lead = (f"Sign-in did not complete: {description or 'the server rejected the request'}. "
|
|
f"Run `{retry_command}` to try again.")
|
|
details = f"{code}: {description}" if code else description
|
|
return SignInCopyError(f"{lead}\n Details: {details}" if details else lead, oauth_error_code=code)
|
|
|
|
|
|
def _details_line(exc: BaseException) -> str:
|
|
text = str(exc).strip() or type(exc).__name__
|
|
return f" Details: {text}"
|
|
|
|
|
|
_Rule = tuple[Callable[[BaseException], bool], str]
|
|
|
|
|
|
def _classify(exc: BaseException, rules: Sequence[_Rule], other: str) -> str:
|
|
return next((copy for pred, copy in rules if pred(exc)), other)
|
|
|
|
|
|
def sign_in_failure_lines(
|
|
exc: BaseException, *, service_host: str = "portal.nousresearch.com", retry_command: str = "hermes portal",
|
|
) -> list:
|
|
"""Lines to print when a device-code / browser sign-in fails for any non-timeout reason."""
|
|
from hermes_cli.observability.shared_metrics_setup import note_sign_in_failure
|
|
|
|
note_sign_in_failure(exc)
|
|
if isinstance(exc, SignInCopyError):
|
|
return str(exc).splitlines()
|
|
rules: Sequence[_Rule] = (
|
|
(is_cancelled, "Sign-in was cancelled. Run `{retry}` when you want to try again."),
|
|
(is_network_error,
|
|
"Could not sign in: Hermes could not reach {host}. Check your internet connection or proxy, "
|
|
"then run `{retry}` again."),
|
|
)
|
|
lead = _classify(
|
|
exc, rules,
|
|
"Could not sign in. Run `{retry}` to try again, or `hermes model` to pick a different provider.")
|
|
lines = [lead.format(host=service_host, retry=retry_command)]
|
|
if not is_cancelled(exc):
|
|
lines.append(_details_line(exc))
|
|
return lines
|
|
|
|
|
|
def provider_setup_failure_lines(exc: BaseException, *, retry_command: str = "hermes model") -> list:
|
|
"""Lines to print when the setup wizard's provider step fails: reason, that nothing was saved,
|
|
and how to retry."""
|
|
nothing_saved = (
|
|
"Your provider settings were not changed. Continue the wizard now and run "
|
|
f"`{retry_command}` afterwards to try again.")
|
|
if isinstance(exc, SignInCopyError):
|
|
lead, *details = str(exc).splitlines()
|
|
return [f"Could not finish connecting a provider: {lead[0].lower()}{lead[1:]}", nothing_saved, *details]
|
|
rules: Sequence[_Rule] = (
|
|
(is_cancelled, "sign-in was cancelled"),
|
|
(is_network_error, "no internet connection, or the provider could not be reached"),
|
|
)
|
|
reason = _classify(exc, rules, "something went wrong while talking to the provider")
|
|
lines = [f"Could not finish connecting a provider ({reason}).", nothing_saved]
|
|
if not is_cancelled(exc):
|
|
lines.append(_details_line(exc))
|
|
return lines
|
|
|
|
|
|
def unknown_provider_hint(provider_name: str) -> str:
|
|
"""Hint for an ``Unknown provider`` error from the resolver: the install line of a provider that left
|
|
core for a plugin, the replacement for a removed provider, else the config issues that cause it."""
|
|
from hermes_cli.left_core_migration import provider_install_hint
|
|
if moved := provider_install_hint(provider_name):
|
|
return moved
|
|
if str(provider_name or "").strip().lower() in {"opencode-free", "free", "opencode_free"}:
|
|
return ("OpenCode discontinued anonymous free-tier access outside its own client "
|
|
"(relay 403s FreeTierError), so the keyless 'opencode-free' provider was removed. "
|
|
"Switch to 'opencode-zen' (pay-as-you-go, OPENCODE_ZEN_API_KEY) or 'opencode-go' "
|
|
"($10/mo subscription, OPENCODE_GO_API_KEY) via 'hermes model'.")
|
|
try:
|
|
from hermes_cli.config import validate_config_structure
|
|
issues = validate_config_structure()
|
|
if not issues:
|
|
return ""
|
|
lines = ["Config issue detected — run 'hermes doctor' for full diagnostics:"]
|
|
for ci in issues:
|
|
lines.append(f" [{'ERROR' if ci.severity == 'error' else 'WARNING'}] {ci.message}")
|
|
if ci.hint and ci.hint.splitlines()[0]:
|
|
lines.append(f" → {ci.hint.splitlines()[0]}")
|
|
return "\n".join(lines)
|
|
except Exception: # health: allow BLE001 -- moved verbatim from auth.py; a hint never masks the error it explains
|
|
return ""
|