/ hermes_cli / auth.py
auth.py
   1  """
   2  Multi-provider authentication system for Hermes Agent.
   3  
   4  Supports OAuth device code flows (Nous Portal, future: OpenAI Codex) and
   5  traditional API key providers (OpenRouter, custom endpoints). Auth state
   6  is persisted in ~/.hermes/auth.json with cross-process file locking.
   7  
   8  Architecture:
   9  - ProviderConfig registry defines known OAuth providers
  10  - Auth store (auth.json) holds per-provider credential state
  11  - resolve_provider() picks the active provider via priority chain
  12  - resolve_*_runtime_credentials() handles token refresh and key minting
  13  - logout_command() is the CLI entry point for clearing auth
  14  """
  15  
  16  from __future__ import annotations
  17  
  18  import json
  19  import logging
  20  import os
  21  import shutil
  22  import shlex
  23  import ssl
  24  import stat
  25  import sys
  26  import base64
  27  import hashlib
  28  import subprocess
  29  import threading
  30  import time
  31  import uuid
  32  import webbrowser
  33  from contextlib import contextmanager
  34  from dataclasses import dataclass, field
  35  from datetime import datetime, timezone
  36  from http.server import BaseHTTPRequestHandler, HTTPServer
  37  from pathlib import Path
  38  from typing import Any, Dict, List, Optional
  39  from urllib.parse import parse_qs, urlencode, urlparse
  40  
  41  import httpx
  42  import yaml
  43  
  44  from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
  45  from hermes_constants import OPENROUTER_BASE_URL
  46  from utils import atomic_replace, atomic_yaml_write, is_truthy_value
  47  
  48  logger = logging.getLogger(__name__)
  49  
  50  try:
  51      import fcntl
  52  except Exception:
  53      fcntl = None
  54  try:
  55      import msvcrt
  56  except Exception:
  57      msvcrt = None
  58  
  59  # =============================================================================
  60  # Constants
  61  # =============================================================================
  62  
  63  AUTH_STORE_VERSION = 1
  64  AUTH_LOCK_TIMEOUT_SECONDS = 15.0
  65  
  66  # Nous Portal defaults
  67  DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com"
  68  DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1"
  69  DEFAULT_NOUS_CLIENT_ID = "hermes-cli"
  70  DEFAULT_NOUS_SCOPE = "inference:mint_agent_key"
  71  DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60  # 30 minutes
  72  ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120       # refresh 2 min before expiry
  73  DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1     # poll at most every 1s
  74  DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
  75  MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113"
  76  MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"
  77  MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"
  78  MINIMAX_OAUTH_GLOBAL_BASE = "https://api.minimax.io"
  79  MINIMAX_OAUTH_CN_BASE = "https://api.minimaxi.com"
  80  MINIMAX_OAUTH_GLOBAL_INFERENCE = "https://api.minimax.io/anthropic"
  81  MINIMAX_OAUTH_CN_INFERENCE = "https://api.minimaxi.com/anthropic"
  82  MINIMAX_OAUTH_REFRESH_SKEW_SECONDS = 60
  83  DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
  84  DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
  85  DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
  86  DEFAULT_OPENCODE_ACP_BASE_URL = "acp://opencode"
  87  DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1"
  88  STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1"
  89  STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1"
  90  CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
  91  CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
  92  CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
  93  QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
  94  QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
  95  QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
  96  DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL = "https://accounts.spotify.com"
  97  DEFAULT_SPOTIFY_API_BASE_URL = "https://api.spotify.com/v1"
  98  DEFAULT_SPOTIFY_REDIRECT_URI = "http://127.0.0.1:43827/spotify/callback"
  99  SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify"
 100  SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard"
 101  SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
 102  DEFAULT_SPOTIFY_SCOPE = " ".join((
 103      "user-modify-playback-state",
 104      "user-read-playback-state",
 105      "user-read-currently-playing",
 106      "user-read-recently-played",
 107      "playlist-read-private",
 108      "playlist-read-collaborative",
 109      "playlist-modify-public",
 110      "playlist-modify-private",
 111      "user-library-read",
 112      "user-library-modify",
 113  ))
 114  SERVICE_PROVIDER_NAMES: Dict[str, str] = {
 115      "spotify": "Spotify",
 116  }
 117  
 118  # Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend)
 119  DEFAULT_GEMINI_CLOUDCODE_BASE_URL = "cloudcode-pa://google"
 120  GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60  # refresh 60s before expiry
 121  
 122  # LM Studio's default no-auth mode still requires *some* non-empty bearer for
 123  # the API-key code paths (auxiliary_client, runtime resolver) to treat the
 124  # provider as configured. This sentinel is sent only to LM Studio, never to
 125  # any remote service.
 126  LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key"
 127  
 128  
 129  # =============================================================================
 130  # Provider Registry
 131  # =============================================================================
 132  
 133  @dataclass
 134  class ProviderConfig:
 135      """Describes a known inference provider."""
 136      id: str
 137      name: str
 138      auth_type: str  # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key"
 139      portal_base_url: str = ""
 140      inference_base_url: str = ""
 141      client_id: str = ""
 142      scope: str = ""
 143      extra: Dict[str, Any] = field(default_factory=dict)
 144      # For API-key providers: env vars to check (in priority order)
 145      api_key_env_vars: tuple = ()
 146      # Optional env var for base URL override
 147      base_url_env_var: str = ""
 148  
 149  
 150  PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
 151      "nous": ProviderConfig(
 152          id="nous",
 153          name="Nous Portal",
 154          auth_type="oauth_device_code",
 155          portal_base_url=DEFAULT_NOUS_PORTAL_URL,
 156          inference_base_url=DEFAULT_NOUS_INFERENCE_URL,
 157          client_id=DEFAULT_NOUS_CLIENT_ID,
 158          scope=DEFAULT_NOUS_SCOPE,
 159      ),
 160      "openai-codex": ProviderConfig(
 161          id="openai-codex",
 162          name="OpenAI Codex",
 163          auth_type="oauth_external",
 164          inference_base_url=DEFAULT_CODEX_BASE_URL,
 165      ),
 166      "qwen-oauth": ProviderConfig(
 167          id="qwen-oauth",
 168          name="Qwen OAuth",
 169          auth_type="oauth_external",
 170          inference_base_url=DEFAULT_QWEN_BASE_URL,
 171      ),
 172      "google-gemini-cli": ProviderConfig(
 173          id="google-gemini-cli",
 174          name="Google Gemini (OAuth)",
 175          auth_type="oauth_external",
 176          inference_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
 177      ),
 178      "lmstudio": ProviderConfig(
 179          id="lmstudio",
 180          name="LM Studio",
 181          auth_type="api_key",
 182          inference_base_url="http://127.0.0.1:1234/v1",
 183          api_key_env_vars=("LM_API_KEY",),
 184          base_url_env_var="LM_BASE_URL",
 185      ),
 186      "copilot": ProviderConfig(
 187          id="copilot",
 188          name="GitHub Copilot",
 189          auth_type="api_key",
 190          inference_base_url=DEFAULT_GITHUB_MODELS_BASE_URL,
 191          api_key_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
 192          base_url_env_var="COPILOT_API_BASE_URL",
 193      ),
 194      "copilot-acp": ProviderConfig(
 195          id="copilot-acp",
 196          name="GitHub Copilot ACP",
 197          auth_type="external_process",
 198          inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,
 199          base_url_env_var="COPILOT_ACP_BASE_URL",
 200      ),
 201      "opencode-kimi-oauth": ProviderConfig(
 202          id="opencode-kimi-oauth",
 203          name="OpenCode Kimi OAuth",
 204          auth_type="external_process",
 205          inference_base_url=DEFAULT_OPENCODE_ACP_BASE_URL,
 206          base_url_env_var="OPENCODE_KIMI_ACP_BASE_URL",
 207          extra={
 208              "command_env_var": "HERMES_OPENCODE_ACP_COMMAND",
 209              "path_env_var": "OPENCODE_CLI_PATH",
 210              "args_env_var": "HERMES_OPENCODE_ACP_ARGS",
 211              "default_command": "opencode",
 212              "default_args": ["acp"],
 213              "auth_provider": "kimi-for-coding-oauth",
 214              "missing_command_message": (
 215                  "Could not find the OpenCode CLI command. Install OpenCode "
 216                  "with `npm i -g opencode-ai@latest` or set OPENCODE_CLI_PATH."
 217              ),
 218              "missing_auth_message": (
 219                  "OpenCode Kimi OAuth credentials were not found. Run "
 220                  "`opencode auth login -p kimi-for-coding-oauth` first."
 221              ),
 222          },
 223      ),
 224      "gemini": ProviderConfig(
 225          id="gemini",
 226          name="Google AI Studio",
 227          auth_type="api_key",
 228          inference_base_url="https://generativelanguage.googleapis.com/v1beta",
 229          api_key_env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
 230          base_url_env_var="GEMINI_BASE_URL",
 231      ),
 232      "zai": ProviderConfig(
 233          id="zai",
 234          name="Z.AI / GLM",
 235          auth_type="api_key",
 236          inference_base_url="https://api.z.ai/api/paas/v4",
 237          api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
 238          base_url_env_var="GLM_BASE_URL",
 239      ),
 240      "kimi-coding": ProviderConfig(
 241          id="kimi-coding",
 242          name="Kimi / Moonshot",
 243          auth_type="api_key",
 244          # Legacy platform.moonshot.ai keys use this endpoint (OpenAI-compat).
 245          # sk-kimi- (Kimi Code) keys are auto-redirected to api.kimi.com/coding
 246          # by _resolve_kimi_base_url() below.
 247          inference_base_url="https://api.moonshot.ai/v1",
 248          api_key_env_vars=("KIMI_API_KEY", "KIMI_CODING_API_KEY"),
 249          base_url_env_var="KIMI_BASE_URL",
 250      ),
 251      "kimi-coding-cn": ProviderConfig(
 252          id="kimi-coding-cn",
 253          name="Kimi / Moonshot (China)",
 254          auth_type="api_key",
 255          inference_base_url="https://api.moonshot.cn/v1",
 256          api_key_env_vars=("KIMI_CN_API_KEY",),
 257      ),
 258      "stepfun": ProviderConfig(
 259          id="stepfun",
 260          name="StepFun Step Plan",
 261          auth_type="api_key",
 262          inference_base_url=STEPFUN_STEP_PLAN_INTL_BASE_URL,
 263          api_key_env_vars=("STEPFUN_API_KEY",),
 264          base_url_env_var="STEPFUN_BASE_URL",
 265      ),
 266      "arcee": ProviderConfig(
 267          id="arcee",
 268          name="Arcee AI",
 269          auth_type="api_key",
 270          inference_base_url="https://api.arcee.ai/api/v1",
 271          api_key_env_vars=("ARCEEAI_API_KEY",),
 272          base_url_env_var="ARCEE_BASE_URL",
 273      ),
 274      "gmi": ProviderConfig(
 275          id="gmi",
 276          name="GMI Cloud",
 277          auth_type="api_key",
 278          inference_base_url="https://api.gmi-serving.com/v1",
 279          api_key_env_vars=("GMI_API_KEY",),
 280          base_url_env_var="GMI_BASE_URL",
 281      ),
 282      "minimax": ProviderConfig(
 283          id="minimax",
 284          name="MiniMax",
 285          auth_type="api_key",
 286          inference_base_url="https://api.minimax.io/anthropic",
 287          api_key_env_vars=("MINIMAX_API_KEY",),
 288          base_url_env_var="MINIMAX_BASE_URL",
 289      ),
 290      "minimax-oauth": ProviderConfig(
 291          id="minimax-oauth",
 292          name="MiniMax (OAuth \u00b7 minimax.io)",
 293          auth_type="oauth_minimax",
 294          portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE,
 295          inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE,
 296          client_id=MINIMAX_OAUTH_CLIENT_ID,
 297          scope=MINIMAX_OAUTH_SCOPE,
 298          extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE,
 299                 "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE},
 300      ),
 301      "anthropic": ProviderConfig(
 302          id="anthropic",
 303          name="Anthropic",
 304          auth_type="api_key",
 305          inference_base_url="https://api.anthropic.com",
 306          api_key_env_vars=("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
 307          base_url_env_var="ANTHROPIC_BASE_URL",
 308      ),
 309      "alibaba": ProviderConfig(
 310          id="alibaba",
 311          name="Alibaba Cloud (DashScope)",
 312          auth_type="api_key",
 313          inference_base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
 314          api_key_env_vars=("DASHSCOPE_API_KEY",),
 315          base_url_env_var="DASHSCOPE_BASE_URL",
 316      ),
 317      "alibaba-coding-plan": ProviderConfig(
 318          id="alibaba-coding-plan",
 319          name="Alibaba Cloud (Coding Plan)",
 320          auth_type="api_key",
 321          inference_base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
 322          api_key_env_vars=("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"),
 323          base_url_env_var="ALIBABA_CODING_PLAN_BASE_URL",
 324      ),
 325      "minimax-cn": ProviderConfig(
 326          id="minimax-cn",
 327          name="MiniMax (China)",
 328          auth_type="api_key",
 329          inference_base_url="https://api.minimaxi.com/anthropic",
 330          api_key_env_vars=("MINIMAX_CN_API_KEY",),
 331          base_url_env_var="MINIMAX_CN_BASE_URL",
 332      ),
 333      "deepseek": ProviderConfig(
 334          id="deepseek",
 335          name="DeepSeek",
 336          auth_type="api_key",
 337          inference_base_url="https://api.deepseek.com/v1",
 338          api_key_env_vars=("DEEPSEEK_API_KEY",),
 339          base_url_env_var="DEEPSEEK_BASE_URL",
 340      ),
 341      "xai": ProviderConfig(
 342          id="xai",
 343          name="xAI",
 344          auth_type="api_key",
 345          inference_base_url="https://api.x.ai/v1",
 346          api_key_env_vars=("XAI_API_KEY",),
 347          base_url_env_var="XAI_BASE_URL",
 348      ),
 349      "nvidia": ProviderConfig(
 350          id="nvidia",
 351          name="NVIDIA NIM",
 352          auth_type="api_key",
 353          inference_base_url="https://integrate.api.nvidia.com/v1",
 354          api_key_env_vars=("NVIDIA_API_KEY",),
 355          base_url_env_var="NVIDIA_BASE_URL",
 356      ),
 357      "ai-gateway": ProviderConfig(
 358          id="ai-gateway",
 359          name="Vercel AI Gateway",
 360          auth_type="api_key",
 361          inference_base_url="https://ai-gateway.vercel.sh/v1",
 362          api_key_env_vars=("AI_GATEWAY_API_KEY",),
 363          base_url_env_var="AI_GATEWAY_BASE_URL",
 364      ),
 365      "opencode-zen": ProviderConfig(
 366          id="opencode-zen",
 367          name="OpenCode Zen",
 368          auth_type="api_key",
 369          inference_base_url="https://opencode.ai/zen/v1",
 370          api_key_env_vars=("OPENCODE_ZEN_API_KEY",),
 371          base_url_env_var="OPENCODE_ZEN_BASE_URL",
 372      ),
 373      "opencode-go": ProviderConfig(
 374          id="opencode-go",
 375          name="OpenCode Go",
 376          auth_type="api_key",
 377          # OpenCode Go mixes API surfaces by model:
 378          # - GLM / Kimi use OpenAI-compatible chat completions under /v1
 379          # - MiniMax models use Anthropic Messages under /v1/messages
 380          # Keep the provider base at /v1 and select api_mode per-model.
 381          inference_base_url="https://opencode.ai/zen/go/v1",
 382          api_key_env_vars=("OPENCODE_GO_API_KEY",),
 383          base_url_env_var="OPENCODE_GO_BASE_URL",
 384      ),
 385      "kilocode": ProviderConfig(
 386          id="kilocode",
 387          name="Kilo Code",
 388          auth_type="api_key",
 389          inference_base_url="https://api.kilo.ai/api/gateway",
 390          api_key_env_vars=("KILOCODE_API_KEY",),
 391          base_url_env_var="KILOCODE_BASE_URL",
 392      ),
 393      "huggingface": ProviderConfig(
 394          id="huggingface",
 395          name="Hugging Face",
 396          auth_type="api_key",
 397          inference_base_url="https://router.huggingface.co/v1",
 398          api_key_env_vars=("HF_TOKEN",),
 399          base_url_env_var="HF_BASE_URL",
 400      ),
 401      "xiaomi": ProviderConfig(
 402          id="xiaomi",
 403          name="Xiaomi MiMo",
 404          auth_type="api_key",
 405          inference_base_url="https://api.xiaomimimo.com/v1",
 406          api_key_env_vars=("XIAOMI_API_KEY",),
 407          base_url_env_var="XIAOMI_BASE_URL",
 408      ),
 409      "tencent-tokenhub": ProviderConfig(
 410          id="tencent-tokenhub",
 411          name="Tencent TokenHub",
 412          auth_type="api_key",
 413          inference_base_url="https://tokenhub.tencentmaas.com/v1",
 414          api_key_env_vars=("TOKENHUB_API_KEY",),
 415          base_url_env_var="TOKENHUB_BASE_URL",
 416      ),
 417      "ollama-cloud": ProviderConfig(
 418          id="ollama-cloud",
 419          name="Ollama Cloud",
 420          auth_type="api_key",
 421          inference_base_url=DEFAULT_OLLAMA_CLOUD_BASE_URL,
 422          api_key_env_vars=("OLLAMA_API_KEY",),
 423          base_url_env_var="OLLAMA_BASE_URL",
 424      ),
 425      "bedrock": ProviderConfig(
 426          id="bedrock",
 427          name="AWS Bedrock",
 428          auth_type="aws_sdk",
 429          inference_base_url="https://bedrock-runtime.us-east-1.amazonaws.com",
 430          api_key_env_vars=(),
 431          base_url_env_var="BEDROCK_BASE_URL",
 432      ),
 433      "azure-foundry": ProviderConfig(
 434          id="azure-foundry",
 435          name="Azure Foundry",
 436          auth_type="api_key",
 437          inference_base_url="",  # User-provided endpoint
 438          api_key_env_vars=("AZURE_FOUNDRY_API_KEY",),
 439          base_url_env_var="AZURE_FOUNDRY_BASE_URL",
 440      ),
 441  }
 442  
 443  
 444  # =============================================================================
 445  # Anthropic Key Helper
 446  # =============================================================================
 447  
 448  def get_anthropic_key() -> str:
 449      """Return the first usable Anthropic credential, or ``""``.
 450  
 451      Checks both the ``.env`` file (via ``get_env_value``) and the process
 452      environment (``os.getenv``).  The fallback order mirrors the
 453      ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars`` tuple:
 454  
 455          ANTHROPIC_API_KEY -> ANTHROPIC_TOKEN -> CLAUDE_CODE_OAUTH_TOKEN
 456      """
 457      from hermes_cli.config import get_env_value
 458  
 459      for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars:
 460          value = get_env_value(var) or os.getenv(var, "")
 461          if value:
 462              return value
 463      return ""
 464  
 465  
 466  # =============================================================================
 467  # Kimi Code Endpoint Detection
 468  # =============================================================================
 469  
 470  # Kimi Code (kimi.com/code) issues keys prefixed "sk-kimi-" that only work
 471  # on api.kimi.com/coding.  Legacy keys from platform.moonshot.ai work on
 472  # api.moonshot.ai/v1 (the old default).  Auto-detect when user hasn't set
 473  # KIMI_BASE_URL explicitly.
 474  #
 475  # Note: the base URL intentionally has NO /v1 suffix.  The /coding endpoint
 476  # speaks the Anthropic Messages protocol, and the anthropic SDK appends
 477  # "/v1/messages" internally — so "/coding" + SDK suffix → "/coding/v1/messages"
 478  # (the correct target). Using "/coding/v1" here would produce
 479  # "/coding/v1/v1/messages" (a 404).
 480  KIMI_CODE_BASE_URL = "https://api.kimi.com/coding"
 481  
 482  
 483  def _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str:
 484      """Return the correct Kimi base URL based on the API key prefix.
 485  
 486      If the user has explicitly set KIMI_BASE_URL, that always wins.
 487      Otherwise, sk-kimi- prefixed keys route to api.kimi.com/coding/v1.
 488      """
 489      if env_override:
 490          return env_override
 491      # No key → nothing to infer from.  Return default without inspecting.
 492      if not api_key:
 493          return default_url
 494      if api_key.startswith("sk-kimi-"):
 495          return KIMI_CODE_BASE_URL
 496      return default_url
 497  
 498  
 499  
 500  _PLACEHOLDER_SECRET_VALUES = {
 501      "*",
 502      "**",
 503      "***",
 504      "changeme",
 505      "your_api_key",
 506      "your-api-key",
 507      "placeholder",
 508      "example",
 509      "dummy",
 510      "null",
 511      "none",
 512  }
 513  
 514  
 515  def has_usable_secret(value: Any, *, min_length: int = 4) -> bool:
 516      """Return True when a configured secret looks usable, not empty/placeholder."""
 517      if not isinstance(value, str):
 518          return False
 519      cleaned = value.strip()
 520      if len(cleaned) < min_length:
 521          return False
 522      if cleaned.lower() in _PLACEHOLDER_SECRET_VALUES:
 523          return False
 524      return True
 525  
 526  
 527  def _resolve_api_key_provider_secret(
 528      provider_id: str, pconfig: ProviderConfig
 529  ) -> tuple[str, str]:
 530      """Resolve an API-key provider's token and indicate where it came from."""
 531      if provider_id == "copilot":
 532          # Use the dedicated copilot auth module for proper token validation
 533          try:
 534              from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
 535              token, source = resolve_copilot_token()
 536              if token:
 537                  return get_copilot_api_token(token), source
 538          except ValueError as exc:
 539              logger.warning("Copilot token validation failed: %s", exc)
 540          except Exception:
 541              pass
 542          return "", ""
 543  
 544      from hermes_cli.config import get_env_value
 545      for env_var in pconfig.api_key_env_vars:
 546          # Check both os.environ and ~/.hermes/.env file
 547          val = (get_env_value(env_var) or "").strip()
 548          if has_usable_secret(val):
 549              return val, env_var
 550  
 551      # Fallback: try credential pool (e.g. zai key stored via auth.json)
 552      try:
 553          from agent.credential_pool import load_pool
 554          pool = load_pool(provider_id)
 555          if pool and pool.has_credentials():
 556              entry = pool.peek()
 557              if entry:
 558                  key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "")
 559                  key = str(key).strip()
 560                  if has_usable_secret(key):
 561                      return key, f"credential_pool:{provider_id}"
 562      except Exception:
 563          pass
 564  
 565      return "", ""
 566  
 567  
 568  # =============================================================================
 569  # Z.AI Endpoint Detection
 570  # =============================================================================
 571  
 572  # Z.AI has separate billing for general vs coding plans, and global vs China
 573  # endpoints.  A key that works on one may return "Insufficient balance" on
 574  # another.  We probe at setup time and store the working endpoint.
 575  # Each entry lists candidate models to try in order — newer coding plan accounts
 576  # may only have access to recent models (glm-5.1, glm-5v-turbo) while older
 577  # ones still use glm-4.7.
 578  
 579  ZAI_ENDPOINTS = [
 580      # (id, base_url, probe_models, label)
 581      ("global",        "https://api.z.ai/api/paas/v4",        ["glm-5"],   "Global"),
 582      ("cn",            "https://open.bigmodel.cn/api/paas/v4", ["glm-5"],   "China"),
 583      ("coding-global", "https://api.z.ai/api/coding/paas/v4",  ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
 584      ("coding-cn",     "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
 585  ]
 586  
 587  
 588  def detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str, str]]:
 589      """Probe z.ai endpoints to find one that accepts this API key.
 590  
 591      Returns {"id": ..., "base_url": ..., "model": ..., "label": ...} for the
 592      first working endpoint, or None if all fail.  For endpoints with multiple
 593      candidate models, tries each in order and returns the first that succeeds.
 594      """
 595      for ep_id, base_url, probe_models, label in ZAI_ENDPOINTS:
 596          for model in probe_models:
 597              try:
 598                  resp = httpx.post(
 599                      f"{base_url}/chat/completions",
 600                      headers={
 601                          "Authorization": f"Bearer {api_key}",
 602                          "Content-Type": "application/json",
 603                      },
 604                      json={
 605                          "model": model,
 606                          "stream": False,
 607                          "max_tokens": 1,
 608                          "messages": [{"role": "user", "content": "ping"}],
 609                      },
 610                      timeout=timeout,
 611                  )
 612                  if resp.status_code == 200:
 613                      logger.debug("Z.AI endpoint probe: %s (%s) model=%s OK", ep_id, base_url, model)
 614                      return {
 615                          "id": ep_id,
 616                          "base_url": base_url,
 617                          "model": model,
 618                          "label": label,
 619                      }
 620                  logger.debug("Z.AI endpoint probe: %s model=%s returned %s", ep_id, model, resp.status_code)
 621              except Exception as exc:
 622                  logger.debug("Z.AI endpoint probe: %s model=%s failed: %s", ep_id, model, exc)
 623      return None
 624  
 625  
 626  def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str:
 627      """Return the correct Z.AI base URL by probing endpoints.
 628  
 629      If the user has explicitly set GLM_BASE_URL, that always wins.
 630      Otherwise, probe the candidate endpoints to find one that accepts the
 631      key.  The detected endpoint is cached in provider state (auth.json) keyed
 632      on a hash of the API key so subsequent starts skip the probe.
 633      """
 634      if env_override:
 635          return env_override
 636  
 637      # No API key set → don't probe (would fire N×M HTTPS requests with an
 638      # empty Bearer token, all returning 401).  This path is hit during
 639      # auxiliary-client auto-detection when the user has no Z.AI credentials
 640      # at all — the caller discards the result immediately, so the probe is
 641      # pure latency for every AIAgent construction.
 642      if not api_key:
 643          return default_url
 644  
 645      # Check provider-state cache for a previously-detected endpoint.
 646      auth_store = _load_auth_store()
 647      state = _load_provider_state(auth_store, "zai") or {}
 648      cached = state.get("detected_endpoint")
 649      if isinstance(cached, dict) and cached.get("base_url"):
 650          key_hash = cached.get("key_hash", "")
 651          if key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]:
 652              logger.debug("Z.AI: using cached endpoint %s", cached["base_url"])
 653              return cached["base_url"]
 654  
 655      # Probe — may take up to ~8s per endpoint.
 656      detected = detect_zai_endpoint(api_key)
 657      if detected and detected.get("base_url"):
 658          # Persist the detection result keyed on the API key hash.
 659          key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
 660          state["detected_endpoint"] = {
 661              "base_url": detected["base_url"],
 662              "endpoint_id": detected.get("id", ""),
 663              "model": detected.get("model", ""),
 664              "label": detected.get("label", ""),
 665              "key_hash": key_hash,
 666          }
 667          _save_provider_state(auth_store, "zai", state)
 668          logger.info("Z.AI: auto-detected endpoint %s (%s)", detected["label"], detected["base_url"])
 669          return detected["base_url"]
 670  
 671      logger.debug("Z.AI: probe failed, falling back to default %s", default_url)
 672      return default_url
 673  
 674  
 675  # =============================================================================
 676  # Error Types
 677  # =============================================================================
 678  
 679  class AuthError(RuntimeError):
 680      """Structured auth error with UX mapping hints."""
 681  
 682      def __init__(
 683          self,
 684          message: str,
 685          *,
 686          provider: str = "",
 687          code: Optional[str] = None,
 688          relogin_required: bool = False,
 689      ) -> None:
 690          super().__init__(message)
 691          self.provider = provider
 692          self.code = code
 693          self.relogin_required = relogin_required
 694  
 695  
 696  def format_auth_error(error: Exception) -> str:
 697      """Map auth failures to concise user-facing guidance."""
 698      if not isinstance(error, AuthError):
 699          return str(error)
 700  
 701      if error.relogin_required:
 702          return f"{error} Run `hermes model` to re-authenticate."
 703  
 704      if error.code == "subscription_required":
 705          return (
 706              "No active paid subscription found on Nous Portal. "
 707              "Please purchase/activate a subscription, then retry."
 708          )
 709  
 710      if error.code == "insufficient_credits":
 711          return (
 712              "Subscription credits are exhausted. "
 713              "Top up/renew credits in Nous Portal, then retry."
 714          )
 715  
 716      if error.code == "temporarily_unavailable":
 717          return f"{error} Please retry in a few seconds."
 718  
 719      return str(error)
 720  
 721  
 722  def _token_fingerprint(token: Any) -> Optional[str]:
 723      """Return a short hash fingerprint for telemetry without leaking token bytes."""
 724      if not isinstance(token, str):
 725          return None
 726      cleaned = token.strip()
 727      if not cleaned:
 728          return None
 729      return hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:12]
 730  
 731  
 732  def _oauth_trace_enabled() -> bool:
 733      raw = os.getenv("HERMES_OAUTH_TRACE", "").strip().lower()
 734      return raw in {"1", "true", "yes", "on"}
 735  
 736  
 737  def _oauth_trace(event: str, *, sequence_id: Optional[str] = None, **fields: Any) -> None:
 738      if not _oauth_trace_enabled():
 739          return
 740      payload: Dict[str, Any] = {"event": event}
 741      if sequence_id:
 742          payload["sequence_id"] = sequence_id
 743      payload.update(fields)
 744      logger.info("oauth_trace %s", json.dumps(payload, sort_keys=True, ensure_ascii=False))
 745  
 746  
 747  # =============================================================================
 748  # Auth Store — persistence layer for ~/.hermes/auth.json
 749  # =============================================================================
 750  
 751  def _auth_file_path() -> Path:
 752      path = get_hermes_home() / "auth.json"
 753      # Seat belt: if pytest is running and HERMES_HOME resolves to the real
 754      # user's auth store, refuse rather than silently corrupt it. This catches
 755      # tests that forgot to monkeypatch HERMES_HOME, tests invoked without the
 756      # hermetic conftest, or sandbox escapes via threads/subprocesses. In
 757      # production (no PYTEST_CURRENT_TEST) this is a single dict lookup.
 758      if os.environ.get("PYTEST_CURRENT_TEST"):
 759          real_home_auth = (Path.home() / ".hermes" / "auth.json").resolve(strict=False)
 760          try:
 761              resolved = path.resolve(strict=False)
 762          except Exception:
 763              resolved = path
 764          if resolved == real_home_auth:
 765              raise RuntimeError(
 766                  f"Refusing to touch real user auth store during test run: {path}. "
 767                  "Set HERMES_HOME to a tmp_path in your test fixture, or run "
 768                  "via scripts/run_tests.sh for hermetic CI-parity env."
 769              )
 770      return path
 771  
 772  
 773  def _auth_lock_path() -> Path:
 774      return _auth_file_path().with_suffix(".lock")
 775  
 776  
 777  _auth_lock_holder = threading.local()
 778  
 779  @contextmanager
 780  def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
 781      """Cross-process advisory lock for auth.json reads+writes.  Reentrant."""
 782      # Reentrant: if this thread already holds the lock, just yield.
 783      if getattr(_auth_lock_holder, "depth", 0) > 0:
 784          _auth_lock_holder.depth += 1
 785          try:
 786              yield
 787          finally:
 788              _auth_lock_holder.depth -= 1
 789          return
 790  
 791      lock_path = _auth_lock_path()
 792      lock_path.parent.mkdir(parents=True, exist_ok=True)
 793  
 794      if fcntl is None and msvcrt is None:
 795          _auth_lock_holder.depth = 1
 796          try:
 797              yield
 798          finally:
 799              _auth_lock_holder.depth = 0
 800          return
 801  
 802      # On Windows, msvcrt.locking needs the file to have content and the
 803      # file pointer at position 0.  Ensure the lock file has at least 1 byte.
 804      if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
 805          lock_path.write_text(" ", encoding="utf-8")
 806  
 807      with lock_path.open("r+" if msvcrt else "a+") as lock_file:
 808          deadline = time.time() + max(1.0, timeout_seconds)
 809          while True:
 810              try:
 811                  if fcntl:
 812                      fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
 813                  else:
 814                      lock_file.seek(0)
 815                      msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
 816                  break
 817              except (BlockingIOError, OSError, PermissionError):
 818                  if time.time() >= deadline:
 819                      raise TimeoutError("Timed out waiting for auth store lock")
 820                  time.sleep(0.05)
 821  
 822          _auth_lock_holder.depth = 1
 823          try:
 824              yield
 825          finally:
 826              _auth_lock_holder.depth = 0
 827              if fcntl:
 828                  fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
 829              elif msvcrt:
 830                  try:
 831                      lock_file.seek(0)
 832                      msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
 833                  except (OSError, IOError):
 834                      pass
 835  
 836  
 837  def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
 838      auth_file = auth_file or _auth_file_path()
 839      if not auth_file.exists():
 840          return {"version": AUTH_STORE_VERSION, "providers": {}}
 841  
 842      try:
 843          raw = json.loads(auth_file.read_text())
 844      except Exception as exc:
 845          corrupt_path = auth_file.with_suffix(".json.corrupt")
 846          try:
 847              import shutil
 848              shutil.copy2(auth_file, corrupt_path)
 849          except Exception:
 850              pass
 851          logger.warning(
 852              "auth: failed to parse %s (%s) — starting with empty store. "
 853              "Corrupt file preserved at %s",
 854              auth_file, exc, corrupt_path,
 855          )
 856          return {"version": AUTH_STORE_VERSION, "providers": {}}
 857  
 858      if isinstance(raw, dict) and (
 859          isinstance(raw.get("providers"), dict)
 860          or isinstance(raw.get("credential_pool"), dict)
 861      ):
 862          raw.setdefault("providers", {})
 863          return raw
 864  
 865      # Migrate from PR's "systems" format if present
 866      if isinstance(raw, dict) and isinstance(raw.get("systems"), dict):
 867          systems = raw["systems"]
 868          providers = {}
 869          if "nous_portal" in systems:
 870              providers["nous"] = systems["nous_portal"]
 871          return {"version": AUTH_STORE_VERSION, "providers": providers,
 872                  "active_provider": "nous" if providers else None}
 873  
 874      return {"version": AUTH_STORE_VERSION, "providers": {}}
 875  
 876  
 877  def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
 878      auth_file = _auth_file_path()
 879      auth_file.parent.mkdir(parents=True, exist_ok=True)
 880      auth_store["version"] = AUTH_STORE_VERSION
 881      auth_store["updated_at"] = datetime.now(timezone.utc).isoformat()
 882      payload = json.dumps(auth_store, indent=2) + "\n"
 883      tmp_path = auth_file.with_name(f"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
 884      try:
 885          with tmp_path.open("w", encoding="utf-8") as handle:
 886              handle.write(payload)
 887              handle.flush()
 888              os.fsync(handle.fileno())
 889          atomic_replace(tmp_path, auth_file)
 890          try:
 891              dir_fd = os.open(str(auth_file.parent), os.O_RDONLY)
 892          except OSError:
 893              dir_fd = None
 894          if dir_fd is not None:
 895              try:
 896                  os.fsync(dir_fd)
 897              finally:
 898                  os.close(dir_fd)
 899      finally:
 900          try:
 901              if tmp_path.exists():
 902                  tmp_path.unlink()
 903          except OSError:
 904              pass
 905      # Restrict file permissions to owner only
 906      try:
 907          auth_file.chmod(stat.S_IRUSR | stat.S_IWUSR)
 908      except OSError:
 909          pass
 910      return auth_file
 911  
 912  
 913  def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]:
 914      providers = auth_store.get("providers")
 915      if not isinstance(providers, dict):
 916          return None
 917      state = providers.get(provider_id)
 918      return dict(state) if isinstance(state, dict) else None
 919  
 920  
 921  def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None:
 922      providers = auth_store.setdefault("providers", {})
 923      if not isinstance(providers, dict):
 924          auth_store["providers"] = {}
 925          providers = auth_store["providers"]
 926      providers[provider_id] = state
 927      auth_store["active_provider"] = provider_id
 928  
 929  
 930  def _store_provider_state(
 931      auth_store: Dict[str, Any],
 932      provider_id: str,
 933      state: Dict[str, Any],
 934      *,
 935      set_active: bool = True,
 936  ) -> None:
 937      providers = auth_store.setdefault("providers", {})
 938      if not isinstance(providers, dict):
 939          auth_store["providers"] = {}
 940          providers = auth_store["providers"]
 941      providers[provider_id] = state
 942      if set_active:
 943          auth_store["active_provider"] = provider_id
 944  
 945  
 946  def is_known_auth_provider(provider_id: str) -> bool:
 947      normalized = (provider_id or "").strip().lower()
 948      return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES
 949  
 950  
 951  def get_auth_provider_display_name(provider_id: str) -> str:
 952      normalized = (provider_id or "").strip().lower()
 953      if normalized in PROVIDER_REGISTRY:
 954          return PROVIDER_REGISTRY[normalized].name
 955      return SERVICE_PROVIDER_NAMES.get(normalized, provider_id)
 956  
 957  
 958  def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
 959      """Return the persisted credential pool, or one provider slice."""
 960      auth_store = _load_auth_store()
 961      pool = auth_store.get("credential_pool")
 962      if not isinstance(pool, dict):
 963          pool = {}
 964      if provider_id is None:
 965          return dict(pool)
 966      provider_entries = pool.get(provider_id)
 967      return list(provider_entries) if isinstance(provider_entries, list) else []
 968  
 969  
 970  def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
 971      """Persist one provider's credential pool under auth.json."""
 972      with _auth_store_lock():
 973          auth_store = _load_auth_store()
 974          pool = auth_store.get("credential_pool")
 975          if not isinstance(pool, dict):
 976              pool = {}
 977              auth_store["credential_pool"] = pool
 978          pool[provider_id] = list(entries)
 979          return _save_auth_store(auth_store)
 980  
 981  
 982  def suppress_credential_source(provider_id: str, source: str) -> None:
 983      """Mark a credential source as suppressed so it won't be re-seeded."""
 984      with _auth_store_lock():
 985          auth_store = _load_auth_store()
 986          suppressed = auth_store.setdefault("suppressed_sources", {})
 987          provider_list = suppressed.setdefault(provider_id, [])
 988          if source not in provider_list:
 989              provider_list.append(source)
 990          _save_auth_store(auth_store)
 991  
 992  
 993  def is_source_suppressed(provider_id: str, source: str) -> bool:
 994      """Check if a credential source has been suppressed by the user."""
 995      try:
 996          auth_store = _load_auth_store()
 997          suppressed = auth_store.get("suppressed_sources", {})
 998          return source in suppressed.get(provider_id, [])
 999      except Exception:
1000          return False
1001  
1002  
1003  def unsuppress_credential_source(provider_id: str, source: str) -> bool:
1004      """Clear a suppression marker so the source will be re-seeded on the next load.
1005  
1006      Returns True if a marker was cleared, False if no marker existed.
1007      """
1008      with _auth_store_lock():
1009          auth_store = _load_auth_store()
1010          suppressed = auth_store.get("suppressed_sources")
1011          if not isinstance(suppressed, dict):
1012              return False
1013          provider_list = suppressed.get(provider_id)
1014          if not isinstance(provider_list, list) or source not in provider_list:
1015              return False
1016          provider_list.remove(source)
1017          if not provider_list:
1018              suppressed.pop(provider_id, None)
1019          if not suppressed:
1020              auth_store.pop("suppressed_sources", None)
1021          _save_auth_store(auth_store)
1022          return True
1023  
1024  
1025  def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
1026      """Return persisted auth state for a provider, or None."""
1027      auth_store = _load_auth_store()
1028      return _load_provider_state(auth_store, provider_id)
1029  
1030  
1031  def get_active_provider() -> Optional[str]:
1032      """Return the currently active provider ID from auth store."""
1033      auth_store = _load_auth_store()
1034      return auth_store.get("active_provider")
1035  
1036  
1037  def is_provider_explicitly_configured(provider_id: str) -> bool:
1038      """Return True only if the user has explicitly configured this provider.
1039  
1040      Checks:
1041        1. active_provider in auth.json matches
1042        2. model.provider in config.yaml matches
1043        3. Provider-specific env vars are set (e.g. ANTHROPIC_API_KEY)
1044  
1045      This is used to gate auto-discovery of external credentials (e.g.
1046      Claude Code's ~/.claude/.credentials.json) so they are never used
1047      without the user's explicit choice.  See PR #4210 for the same
1048      pattern applied to the setup wizard gate.
1049      """
1050      normalized = (provider_id or "").strip().lower()
1051  
1052      # 1. Check auth.json active_provider
1053      try:
1054          auth_store = _load_auth_store()
1055          active = (auth_store.get("active_provider") or "").strip().lower()
1056          if active and active == normalized:
1057              return True
1058      except Exception:
1059          pass
1060  
1061      # 2. Check config.yaml model.provider
1062      try:
1063          from hermes_cli.config import load_config
1064          cfg = load_config()
1065          model_cfg = cfg.get("model")
1066          if isinstance(model_cfg, dict):
1067              cfg_provider = (model_cfg.get("provider") or "").strip().lower()
1068              if cfg_provider == normalized:
1069                  return True
1070      except Exception:
1071          pass
1072  
1073      # 3. Check provider-specific env vars
1074      # Exclude CLAUDE_CODE_OAUTH_TOKEN — it's set by Claude Code itself,
1075      # not by the user explicitly configuring anthropic in Hermes.
1076      _IMPLICIT_ENV_VARS = {"CLAUDE_CODE_OAUTH_TOKEN"}
1077      pconfig = PROVIDER_REGISTRY.get(normalized)
1078      if pconfig and pconfig.auth_type == "api_key":
1079          for env_var in pconfig.api_key_env_vars:
1080              if env_var in _IMPLICIT_ENV_VARS:
1081                  continue
1082              if has_usable_secret(os.getenv(env_var, "")):
1083                  return True
1084  
1085      return False
1086  
1087  
1088  def clear_provider_auth(provider_id: Optional[str] = None) -> bool:
1089      """
1090      Clear auth state for a provider. Used by `hermes logout`.
1091      If provider_id is None, clears the active provider.
1092      Returns True if something was cleared.
1093      """
1094      with _auth_store_lock():
1095          auth_store = _load_auth_store()
1096          target = provider_id or auth_store.get("active_provider")
1097          if not target:
1098              return False
1099  
1100          providers = auth_store.get("providers", {})
1101          if not isinstance(providers, dict):
1102              providers = {}
1103              auth_store["providers"] = providers
1104  
1105          pool = auth_store.get("credential_pool")
1106          if not isinstance(pool, dict):
1107              pool = {}
1108              auth_store["credential_pool"] = pool
1109  
1110          cleared = False
1111          if target in providers:
1112              del providers[target]
1113              cleared = True
1114          if target in pool:
1115              del pool[target]
1116              cleared = True
1117  
1118          if auth_store.get("active_provider") == target:
1119              auth_store["active_provider"] = None
1120              cleared = True
1121  
1122          if not cleared:
1123              return False
1124          _save_auth_store(auth_store)
1125      return True
1126  
1127  
1128  def deactivate_provider() -> None:
1129      """
1130      Clear active_provider in auth.json without deleting credentials.
1131      Used when the user switches to a non-OAuth provider (OpenRouter, custom)
1132      so auto-resolution doesn't keep picking the OAuth provider.
1133      """
1134      with _auth_store_lock():
1135          auth_store = _load_auth_store()
1136          auth_store["active_provider"] = None
1137          _save_auth_store(auth_store)
1138  
1139  
1140  # =============================================================================
1141  # Provider Resolution — picks which provider to use
1142  # =============================================================================
1143  
1144  
1145  def _get_config_hint_for_unknown_provider(provider_name: str) -> str:
1146      """Return a helpful hint string when provider resolution fails.
1147  
1148      Checks for common config.yaml mistakes (malformed custom_providers, etc.)
1149      and returns a human-readable diagnostic, or empty string if nothing found.
1150      """
1151      try:
1152          from hermes_cli.config import validate_config_structure
1153          issues = validate_config_structure()
1154          if not issues:
1155              return ""
1156  
1157          lines = ["Config issue detected — run 'hermes doctor' for full diagnostics:"]
1158          for ci in issues:
1159              prefix = "ERROR" if ci.severity == "error" else "WARNING"
1160              lines.append(f"  [{prefix}] {ci.message}")
1161              # Show first line of hint
1162              first_hint = ci.hint.splitlines()[0] if ci.hint else ""
1163              if first_hint:
1164                  lines.append(f"    → {first_hint}")
1165          return "\n".join(lines)
1166      except Exception:
1167          return ""
1168  
1169  
1170  def resolve_provider(
1171      requested: Optional[str] = None,
1172      *,
1173      explicit_api_key: Optional[str] = None,
1174      explicit_base_url: Optional[str] = None,
1175  ) -> str:
1176      """
1177      Determine which inference provider to use.
1178  
1179      Priority (when requested="auto" or None):
1180      1. active_provider in auth.json with valid credentials
1181      2. Explicit CLI api_key/base_url -> "openrouter"
1182      3. OPENAI_API_KEY or OPENROUTER_API_KEY env vars -> "openrouter"
1183      4. Provider-specific API keys (GLM, Kimi, MiniMax) -> that provider
1184      5. Fallback: "openrouter"
1185      """
1186      normalized = (requested or "auto").strip().lower()
1187  
1188      # Normalize provider aliases
1189      _PROVIDER_ALIASES = {
1190          "glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
1191          "google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
1192          "x-ai": "xai", "x.ai": "xai", "grok": "xai",
1193          "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
1194          "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
1195          "step": "stepfun", "stepfun-coding-plan": "stepfun",
1196          "arcee-ai": "arcee", "arceeai": "arcee",
1197          "gmi-cloud": "gmi", "gmicloud": "gmi",
1198          "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
1199          "minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
1200          "alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
1201          "alibaba_coding_plan": "alibaba-coding-plan",
1202          "claude": "anthropic", "claude-code": "anthropic",
1203          "github": "copilot", "github-copilot": "copilot",
1204          "github-models": "copilot", "github-model": "copilot",
1205          "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
1206          "opencode-kimi": "opencode-kimi-oauth", "kimi-oauth": "opencode-kimi-oauth",
1207          "kimi-for-coding-oauth": "opencode-kimi-oauth",
1208          "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
1209          "opencode": "opencode-zen", "zen": "opencode-zen",
1210          "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli",
1211          "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
1212          "mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
1213          "tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
1214          "tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub",
1215          "aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
1216          "go": "opencode-go", "opencode-go-sub": "opencode-go",
1217          "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
1218          "lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio",
1219          # Local server aliases — route through the generic custom provider
1220          "ollama": "custom", "ollama_cloud": "ollama-cloud",
1221          "vllm": "custom", "llamacpp": "custom",
1222          "llama.cpp": "custom", "llama-cpp": "custom",
1223      }
1224      normalized = _PROVIDER_ALIASES.get(normalized, normalized)
1225  
1226      if normalized == "openrouter":
1227          return "openrouter"
1228      if normalized == "custom":
1229          return "custom"
1230      if normalized in PROVIDER_REGISTRY:
1231          return normalized
1232      if normalized != "auto":
1233          # Check for common config.yaml issues that cause this error
1234          _config_hint = _get_config_hint_for_unknown_provider(normalized)
1235          msg = f"Unknown provider '{normalized}'."
1236          if _config_hint:
1237              msg += f"\n\n{_config_hint}"
1238          else:
1239              msg += " Check 'hermes model' for available providers, or run 'hermes doctor' to diagnose config issues."
1240          raise AuthError(msg, code="invalid_provider")
1241  
1242      # Explicit one-off CLI creds always mean openrouter/custom
1243      if explicit_api_key or explicit_base_url:
1244          return "openrouter"
1245  
1246      # Check auth store for an active OAuth provider
1247      try:
1248          auth_store = _load_auth_store()
1249          active = auth_store.get("active_provider")
1250          if active and active in PROVIDER_REGISTRY:
1251              status = get_auth_status(active)
1252              if status.get("logged_in"):
1253                  return active
1254      except Exception as e:
1255          logger.debug("Could not detect active auth provider: %s", e)
1256  
1257      if has_usable_secret(os.getenv("OPENAI_API_KEY")) or has_usable_secret(os.getenv("OPENROUTER_API_KEY")):
1258          return "openrouter"
1259  
1260      # Auto-detect API-key providers by checking their env vars
1261      for pid, pconfig in PROVIDER_REGISTRY.items():
1262          if pconfig.auth_type != "api_key":
1263              continue
1264          # GitHub tokens are commonly present for repo/tool access but should not
1265          # hijack inference auto-selection unless the user explicitly chooses
1266          # Copilot/GitHub Models as the provider. LM Studio is a local server
1267          # whose availability isn't implied by LM_API_KEY presence (it may be
1268          # offline, and the no-auth setup uses a placeholder value), so it
1269          # also requires explicit selection.
1270          if pid in ("copilot", "lmstudio"):
1271              continue
1272          for env_var in pconfig.api_key_env_vars:
1273              if has_usable_secret(os.getenv(env_var, "")):
1274                  return pid
1275  
1276      # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).
1277      # This runs after API-key providers so explicit keys always win.
1278      try:
1279          from agent.bedrock_adapter import has_aws_credentials
1280          if has_aws_credentials():
1281              return "bedrock"
1282      except ImportError:
1283          pass  # boto3 not installed — skip Bedrock auto-detection
1284  
1285      raise AuthError(
1286          "No inference provider configured. Run 'hermes model' to choose a "
1287          "provider and model, or set an API key (OPENROUTER_API_KEY, "
1288          "OPENAI_API_KEY, etc.) in ~/.hermes/.env.",
1289          code="no_provider_configured",
1290      )
1291  
1292  
1293  # =============================================================================
1294  # Timestamp / TTL helpers
1295  # =============================================================================
1296  
1297  def _parse_iso_timestamp(value: Any) -> Optional[float]:
1298      if not isinstance(value, str) or not value:
1299          return None
1300      text = value.strip()
1301      if not text:
1302          return None
1303      if text.endswith("Z"):
1304          text = text[:-1] + "+00:00"
1305      try:
1306          parsed = datetime.fromisoformat(text)
1307      except Exception:
1308          return None
1309      if parsed.tzinfo is None:
1310          parsed = parsed.replace(tzinfo=timezone.utc)
1311      return parsed.timestamp()
1312  
1313  
1314  def _is_expiring(expires_at_iso: Any, skew_seconds: int) -> bool:
1315      expires_epoch = _parse_iso_timestamp(expires_at_iso)
1316      if expires_epoch is None:
1317          return True
1318      return expires_epoch <= (time.time() + skew_seconds)
1319  
1320  
1321  def _coerce_ttl_seconds(expires_in: Any) -> int:
1322      try:
1323          ttl = int(expires_in)
1324      except Exception:
1325          ttl = 0
1326      return max(0, ttl)
1327  
1328  
1329  def _optional_base_url(value: Any) -> Optional[str]:
1330      if not isinstance(value, str):
1331          return None
1332      cleaned = value.strip().rstrip("/")
1333      return cleaned if cleaned else None
1334  
1335  
1336  def _decode_jwt_claims(token: Any) -> Dict[str, Any]:
1337      if not isinstance(token, str) or token.count(".") != 2:
1338          return {}
1339      payload = token.split(".")[1]
1340      payload += "=" * ((4 - len(payload) % 4) % 4)
1341      try:
1342          raw = base64.urlsafe_b64decode(payload.encode("utf-8"))
1343          claims = json.loads(raw.decode("utf-8"))
1344      except Exception:
1345          return {}
1346      return claims if isinstance(claims, dict) else {}
1347  
1348  
1349  def _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> bool:
1350      claims = _decode_jwt_claims(access_token)
1351      exp = claims.get("exp")
1352      if not isinstance(exp, (int, float)):
1353          return False
1354      return float(exp) <= (time.time() + max(0, int(skew_seconds)))
1355  
1356  
1357  def _qwen_cli_auth_path() -> Path:
1358      return Path.home() / ".qwen" / "oauth_creds.json"
1359  
1360  
1361  def _read_qwen_cli_tokens() -> Dict[str, Any]:
1362      auth_path = _qwen_cli_auth_path()
1363      if not auth_path.exists():
1364          raise AuthError(
1365              "Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.",
1366              provider="qwen-oauth",
1367              code="qwen_auth_missing",
1368          )
1369      try:
1370          data = json.loads(auth_path.read_text(encoding="utf-8"))
1371      except Exception as exc:
1372          raise AuthError(
1373              f"Failed to read Qwen CLI credentials from {auth_path}: {exc}",
1374              provider="qwen-oauth",
1375              code="qwen_auth_read_failed",
1376          ) from exc
1377      if not isinstance(data, dict):
1378          raise AuthError(
1379              f"Invalid Qwen CLI credentials in {auth_path}.",
1380              provider="qwen-oauth",
1381              code="qwen_auth_invalid",
1382          )
1383      return data
1384  
1385  
1386  def _save_qwen_cli_tokens(tokens: Dict[str, Any]) -> Path:
1387      auth_path = _qwen_cli_auth_path()
1388      auth_path.parent.mkdir(parents=True, exist_ok=True)
1389      tmp_path = auth_path.with_suffix(".tmp")
1390      tmp_path.write_text(json.dumps(tokens, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1391      os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
1392      tmp_path.replace(auth_path)
1393      return auth_path
1394  
1395  
1396  def _qwen_access_token_is_expiring(expiry_date_ms: Any, skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
1397      try:
1398          expiry_ms = int(expiry_date_ms)
1399      except Exception:
1400          return True
1401      return (time.time() + max(0, int(skew_seconds))) * 1000 >= expiry_ms
1402  
1403  
1404  def _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20.0) -> Dict[str, Any]:
1405      refresh_token = str(tokens.get("refresh_token", "") or "").strip()
1406      if not refresh_token:
1407          raise AuthError(
1408              "Qwen OAuth refresh token missing. Re-run 'qwen auth qwen-oauth'.",
1409              provider="qwen-oauth",
1410              code="qwen_refresh_token_missing",
1411          )
1412  
1413      try:
1414          response = httpx.post(
1415              QWEN_OAUTH_TOKEN_URL,
1416              headers={
1417                  "Content-Type": "application/x-www-form-urlencoded",
1418                  "Accept": "application/json",
1419              },
1420              data={
1421                  "grant_type": "refresh_token",
1422                  "refresh_token": refresh_token,
1423                  "client_id": QWEN_OAUTH_CLIENT_ID,
1424              },
1425              timeout=timeout_seconds,
1426          )
1427      except Exception as exc:
1428          raise AuthError(
1429              f"Qwen OAuth refresh failed: {exc}",
1430              provider="qwen-oauth",
1431              code="qwen_refresh_failed",
1432          ) from exc
1433  
1434      if response.status_code >= 400:
1435          body = response.text.strip()
1436          raise AuthError(
1437              "Qwen OAuth refresh failed. Re-run 'qwen auth qwen-oauth'."
1438              + (f" Response: {body}" if body else ""),
1439              provider="qwen-oauth",
1440              code="qwen_refresh_failed",
1441          )
1442  
1443      try:
1444          payload = response.json()
1445      except Exception as exc:
1446          raise AuthError(
1447              f"Qwen OAuth refresh returned invalid JSON: {exc}",
1448              provider="qwen-oauth",
1449              code="qwen_refresh_invalid_json",
1450          ) from exc
1451  
1452      if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
1453          raise AuthError(
1454              "Qwen OAuth refresh response missing access_token.",
1455              provider="qwen-oauth",
1456              code="qwen_refresh_invalid_response",
1457          )
1458  
1459      expires_in = payload.get("expires_in")
1460      try:
1461          expires_in_seconds = int(expires_in)
1462      except Exception:
1463          expires_in_seconds = 6 * 60 * 60
1464  
1465      refreshed = {
1466          "access_token": str(payload.get("access_token", "") or "").strip(),
1467          "refresh_token": str(payload.get("refresh_token", refresh_token) or refresh_token).strip(),
1468          "token_type": str(payload.get("token_type", tokens.get("token_type", "Bearer")) or "Bearer").strip() or "Bearer",
1469          "resource_url": str(payload.get("resource_url", tokens.get("resource_url", "portal.qwen.ai")) or "portal.qwen.ai").strip(),
1470          "expiry_date": int(time.time() * 1000) + max(1, expires_in_seconds) * 1000,
1471      }
1472      _save_qwen_cli_tokens(refreshed)
1473      return refreshed
1474  
1475  
1476  def resolve_qwen_runtime_credentials(
1477      *,
1478      force_refresh: bool = False,
1479      refresh_if_expiring: bool = True,
1480      refresh_skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
1481  ) -> Dict[str, Any]:
1482      tokens = _read_qwen_cli_tokens()
1483      access_token = str(tokens.get("access_token", "") or "").strip()
1484      should_refresh = bool(force_refresh)
1485      if not should_refresh and refresh_if_expiring:
1486          should_refresh = _qwen_access_token_is_expiring(tokens.get("expiry_date"), refresh_skew_seconds)
1487      if should_refresh:
1488          tokens = _refresh_qwen_cli_tokens(tokens)
1489          access_token = str(tokens.get("access_token", "") or "").strip()
1490      if not access_token:
1491          raise AuthError(
1492              "Qwen OAuth access token missing. Re-run 'qwen auth qwen-oauth'.",
1493              provider="qwen-oauth",
1494              code="qwen_access_token_missing",
1495          )
1496  
1497      base_url = os.getenv("HERMES_QWEN_BASE_URL", "").strip().rstrip("/") or DEFAULT_QWEN_BASE_URL
1498      return {
1499          "provider": "qwen-oauth",
1500          "base_url": base_url,
1501          "api_key": access_token,
1502          "source": "qwen-cli",
1503          "expires_at_ms": tokens.get("expiry_date"),
1504          "auth_file": str(_qwen_cli_auth_path()),
1505      }
1506  
1507  
1508  def get_qwen_auth_status() -> Dict[str, Any]:
1509      auth_path = _qwen_cli_auth_path()
1510      try:
1511          creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)
1512          return {
1513              "logged_in": True,
1514              "auth_file": str(auth_path),
1515              "source": creds.get("source"),
1516              "api_key": creds.get("api_key"),
1517              "expires_at_ms": creds.get("expires_at_ms"),
1518          }
1519      except AuthError as exc:
1520          return {
1521              "logged_in": False,
1522              "auth_file": str(auth_path),
1523              "error": str(exc),
1524          }
1525  
1526  
1527  # =============================================================================
1528  # Google Gemini OAuth (google-gemini-cli) — PKCE flow + Cloud Code Assist.
1529  #
1530  # Tokens live in ~/.hermes/auth/google_oauth.json (managed by agent.google_oauth).
1531  # The `base_url` here is the marker "cloudcode-pa://google" that run_agent.py
1532  # uses to construct a GeminiCloudCodeClient instead of the default OpenAI SDK.
1533  # Actual HTTP traffic goes to https://cloudcode-pa.googleapis.com/v1internal:*.
1534  # =============================================================================
1535  
1536  def resolve_gemini_oauth_runtime_credentials(
1537      *,
1538      force_refresh: bool = False,
1539  ) -> Dict[str, Any]:
1540      """Resolve runtime OAuth creds for google-gemini-cli."""
1541      try:
1542          from agent.google_oauth import (
1543              GoogleOAuthError,
1544              _credentials_path,
1545              get_valid_access_token,
1546              load_credentials,
1547          )
1548      except ImportError as exc:
1549          raise AuthError(
1550              f"agent.google_oauth is not importable: {exc}",
1551              provider="google-gemini-cli",
1552              code="google_oauth_module_missing",
1553          ) from exc
1554  
1555      try:
1556          access_token = get_valid_access_token(force_refresh=force_refresh)
1557      except GoogleOAuthError as exc:
1558          raise AuthError(
1559              str(exc),
1560              provider="google-gemini-cli",
1561              code=exc.code,
1562          ) from exc
1563  
1564      creds = load_credentials()
1565      base_url = DEFAULT_GEMINI_CLOUDCODE_BASE_URL
1566      return {
1567          "provider": "google-gemini-cli",
1568          "base_url": base_url,
1569          "api_key": access_token,
1570          "source": "google-oauth",
1571          "expires_at_ms": (creds.expires_ms if creds else None),
1572          "auth_file": str(_credentials_path()),
1573          "email": (creds.email if creds else "") or "",
1574          "project_id": (creds.project_id if creds else "") or "",
1575      }
1576  
1577  
1578  def get_gemini_oauth_auth_status() -> Dict[str, Any]:
1579      """Return a status dict for `hermes auth list` / `hermes status`."""
1580      try:
1581          from agent.google_oauth import _credentials_path, load_credentials
1582      except ImportError:
1583          return {"logged_in": False, "error": "agent.google_oauth unavailable"}
1584      auth_path = _credentials_path()
1585      creds = load_credentials()
1586      if creds is None or not creds.access_token:
1587          return {
1588              "logged_in": False,
1589              "auth_file": str(auth_path),
1590              "error": "not logged in",
1591          }
1592      return {
1593          "logged_in": True,
1594          "auth_file": str(auth_path),
1595          "source": "google-oauth",
1596          "api_key": creds.access_token,
1597          "expires_at_ms": creds.expires_ms,
1598          "email": creds.email,
1599          "project_id": creds.project_id,
1600      }
1601  # Spotify auth — PKCE tokens stored in ~/.hermes/auth.json
1602  # =============================================================================
1603  
1604  
1605  def _spotify_scope_list(raw_scope: Optional[str] = None) -> List[str]:
1606      scope_text = (raw_scope or DEFAULT_SPOTIFY_SCOPE).strip()
1607      scopes = [part for part in scope_text.split() if part]
1608      seen: set[str] = set()
1609      ordered: List[str] = []
1610      for scope in scopes:
1611          if scope not in seen:
1612              seen.add(scope)
1613              ordered.append(scope)
1614      return ordered
1615  
1616  
1617  def _spotify_scope_string(raw_scope: Optional[str] = None) -> str:
1618      return " ".join(_spotify_scope_list(raw_scope))
1619  
1620  
1621  def _spotify_client_id(
1622      explicit: Optional[str] = None,
1623      state: Optional[Dict[str, Any]] = None,
1624  ) -> str:
1625      from hermes_cli.config import get_env_value
1626  
1627      candidates = (
1628          explicit,
1629          get_env_value("HERMES_SPOTIFY_CLIENT_ID"),
1630          get_env_value("SPOTIFY_CLIENT_ID"),
1631          state.get("client_id") if isinstance(state, dict) else None,
1632      )
1633      for candidate in candidates:
1634          cleaned = str(candidate or "").strip()
1635          if cleaned:
1636              return cleaned
1637      raise AuthError(
1638          "Spotify client_id is required. Set HERMES_SPOTIFY_CLIENT_ID or pass --client-id.",
1639          provider="spotify",
1640          code="spotify_client_id_missing",
1641      )
1642  
1643  
1644  def _spotify_redirect_uri(
1645      explicit: Optional[str] = None,
1646      state: Optional[Dict[str, Any]] = None,
1647  ) -> str:
1648      from hermes_cli.config import get_env_value
1649  
1650      candidates = (
1651          explicit,
1652          get_env_value("HERMES_SPOTIFY_REDIRECT_URI"),
1653          get_env_value("SPOTIFY_REDIRECT_URI"),
1654          state.get("redirect_uri") if isinstance(state, dict) else None,
1655          DEFAULT_SPOTIFY_REDIRECT_URI,
1656      )
1657      for candidate in candidates:
1658          cleaned = str(candidate or "").strip()
1659          if cleaned:
1660              return cleaned
1661      return DEFAULT_SPOTIFY_REDIRECT_URI
1662  
1663  
1664  def _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str:
1665      from hermes_cli.config import get_env_value
1666  
1667      candidates = (
1668          get_env_value("HERMES_SPOTIFY_API_BASE_URL"),
1669          state.get("api_base_url") if isinstance(state, dict) else None,
1670          DEFAULT_SPOTIFY_API_BASE_URL,
1671      )
1672      for candidate in candidates:
1673          cleaned = str(candidate or "").strip().rstrip("/")
1674          if cleaned:
1675              return cleaned
1676      return DEFAULT_SPOTIFY_API_BASE_URL
1677  
1678  
1679  def _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str:
1680      from hermes_cli.config import get_env_value
1681  
1682      candidates = (
1683          get_env_value("HERMES_SPOTIFY_ACCOUNTS_BASE_URL"),
1684          state.get("accounts_base_url") if isinstance(state, dict) else None,
1685          DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL,
1686      )
1687      for candidate in candidates:
1688          cleaned = str(candidate or "").strip().rstrip("/")
1689          if cleaned:
1690              return cleaned
1691      return DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL
1692  
1693  
1694  def _spotify_code_verifier(length: int = 64) -> str:
1695      raw = base64.urlsafe_b64encode(os.urandom(length)).decode("ascii")
1696      return raw.rstrip("=")[:128]
1697  
1698  
1699  def _spotify_code_challenge(code_verifier: str) -> str:
1700      digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
1701      return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
1702  
1703  
1704  def _spotify_build_authorize_url(
1705      *,
1706      client_id: str,
1707      redirect_uri: str,
1708      scope: str,
1709      state: str,
1710      code_challenge: str,
1711      accounts_base_url: str,
1712  ) -> str:
1713      query = urlencode({
1714          "client_id": client_id,
1715          "response_type": "code",
1716          "redirect_uri": redirect_uri,
1717          "scope": scope,
1718          "state": state,
1719          "code_challenge_method": "S256",
1720          "code_challenge": code_challenge,
1721      })
1722      return f"{accounts_base_url}/authorize?{query}"
1723  
1724  
1725  def _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]:
1726      parsed = urlparse(redirect_uri)
1727      if parsed.scheme != "http":
1728          raise AuthError(
1729              "Spotify PKCE redirect_uri must use http://localhost or http://127.0.0.1.",
1730              provider="spotify",
1731              code="spotify_redirect_invalid",
1732          )
1733      host = parsed.hostname or ""
1734      if host not in {"127.0.0.1", "localhost"}:
1735          raise AuthError(
1736              "Spotify PKCE redirect_uri must point to localhost or 127.0.0.1.",
1737              provider="spotify",
1738              code="spotify_redirect_invalid",
1739          )
1740      if not parsed.port:
1741          raise AuthError(
1742              "Spotify PKCE redirect_uri must include an explicit localhost port.",
1743              provider="spotify",
1744              code="spotify_redirect_invalid",
1745          )
1746      return host, parsed.port, parsed.path or "/"
1747  
1748  
1749  def _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]:
1750      result: dict[str, Any] = {
1751          "code": None,
1752          "state": None,
1753          "error": None,
1754          "error_description": None,
1755      }
1756  
1757      class _SpotifyCallbackHandler(BaseHTTPRequestHandler):
1758          def do_GET(self) -> None:  # noqa: N802
1759              parsed = urlparse(self.path)
1760              if parsed.path != expected_path:
1761                  self.send_response(404)
1762                  self.end_headers()
1763                  self.wfile.write(b"Not found.")
1764                  return
1765  
1766              params = parse_qs(parsed.query)
1767              result["code"] = params.get("code", [None])[0]
1768              result["state"] = params.get("state", [None])[0]
1769              result["error"] = params.get("error", [None])[0]
1770              result["error_description"] = params.get("error_description", [None])[0]
1771  
1772              self.send_response(200)
1773              self.send_header("Content-Type", "text/html; charset=utf-8")
1774              self.end_headers()
1775              if result["error"]:
1776                  body = "<html><body><h1>Spotify authorization failed.</h1>You can close this tab.</body></html>"
1777              else:
1778                  body = "<html><body><h1>Spotify authorization received.</h1>You can close this tab.</body></html>"
1779              self.wfile.write(body.encode("utf-8"))
1780  
1781          def log_message(self, format: str, *args: Any) -> None:  # noqa: A003
1782              return
1783  
1784      return _SpotifyCallbackHandler, result
1785  
1786  
1787  def _spotify_wait_for_callback(
1788      redirect_uri: str,
1789      *,
1790      timeout_seconds: float = 180.0,
1791  ) -> dict[str, Any]:
1792      host, port, path = _spotify_validate_redirect_uri(redirect_uri)
1793      handler_cls, result = _make_spotify_callback_handler(path)
1794  
1795      class _ReuseHTTPServer(HTTPServer):
1796          allow_reuse_address = True
1797  
1798      try:
1799          server = _ReuseHTTPServer((host, port), handler_cls)
1800      except OSError as exc:
1801          raise AuthError(
1802              f"Could not bind Spotify callback server on {host}:{port}: {exc}",
1803              provider="spotify",
1804              code="spotify_callback_bind_failed",
1805          ) from exc
1806  
1807      thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True)
1808      thread.start()
1809      deadline = time.time() + max(5.0, timeout_seconds)
1810      try:
1811          while time.time() < deadline:
1812              if result["code"] or result["error"]:
1813                  return result
1814              time.sleep(0.1)
1815      finally:
1816          server.shutdown()
1817          server.server_close()
1818          thread.join(timeout=1.0)
1819      raise AuthError(
1820          "Spotify authorization timed out waiting for the local callback.",
1821          provider="spotify",
1822          code="spotify_callback_timeout",
1823      )
1824  
1825  
1826  def _spotify_token_payload_to_state(
1827      token_payload: Dict[str, Any],
1828      *,
1829      client_id: str,
1830      redirect_uri: str,
1831      requested_scope: str,
1832      accounts_base_url: str,
1833      api_base_url: str,
1834      previous_state: Optional[Dict[str, Any]] = None,
1835  ) -> Dict[str, Any]:
1836      now = datetime.now(timezone.utc)
1837      expires_in = _coerce_ttl_seconds(token_payload.get("expires_in", 0))
1838      expires_at = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
1839      state = dict(previous_state or {})
1840      state.update({
1841          "client_id": client_id,
1842          "redirect_uri": redirect_uri,
1843          "accounts_base_url": accounts_base_url,
1844          "api_base_url": api_base_url,
1845          "scope": requested_scope,
1846          "granted_scope": str(token_payload.get("scope") or requested_scope).strip(),
1847          "token_type": str(token_payload.get("token_type", "Bearer") or "Bearer").strip() or "Bearer",
1848          "access_token": str(token_payload.get("access_token", "") or "").strip(),
1849          "refresh_token": str(
1850              token_payload.get("refresh_token")
1851              or state.get("refresh_token")
1852              or ""
1853          ).strip(),
1854          "obtained_at": now.isoformat(),
1855          "expires_at": expires_at.isoformat(),
1856          "expires_in": expires_in,
1857          "auth_type": "oauth_pkce",
1858      })
1859      return state
1860  
1861  
1862  def _spotify_exchange_code_for_tokens(
1863      *,
1864      client_id: str,
1865      code: str,
1866      redirect_uri: str,
1867      code_verifier: str,
1868      accounts_base_url: str,
1869      timeout_seconds: float = 20.0,
1870  ) -> Dict[str, Any]:
1871      try:
1872          response = httpx.post(
1873              f"{accounts_base_url}/api/token",
1874              headers={"Content-Type": "application/x-www-form-urlencoded"},
1875              data={
1876                  "client_id": client_id,
1877                  "grant_type": "authorization_code",
1878                  "code": code,
1879                  "redirect_uri": redirect_uri,
1880                  "code_verifier": code_verifier,
1881              },
1882              timeout=timeout_seconds,
1883          )
1884      except Exception as exc:
1885          raise AuthError(
1886              f"Spotify token exchange failed: {exc}",
1887              provider="spotify",
1888              code="spotify_token_exchange_failed",
1889          ) from exc
1890  
1891      if response.status_code >= 400:
1892          detail = response.text.strip()
1893          raise AuthError(
1894              "Spotify token exchange failed."
1895              + (f" Response: {detail}" if detail else ""),
1896              provider="spotify",
1897              code="spotify_token_exchange_failed",
1898          )
1899      payload = response.json()
1900      if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
1901          raise AuthError(
1902              "Spotify token response did not include an access_token.",
1903              provider="spotify",
1904              code="spotify_token_exchange_invalid",
1905          )
1906      return payload
1907  
1908  
1909  def _refresh_spotify_oauth_state(
1910      state: Dict[str, Any],
1911      *,
1912      timeout_seconds: float = 20.0,
1913  ) -> Dict[str, Any]:
1914      refresh_token = str(state.get("refresh_token", "") or "").strip()
1915      if not refresh_token:
1916          raise AuthError(
1917              "Spotify refresh token missing. Run `hermes auth spotify` again.",
1918              provider="spotify",
1919              code="spotify_refresh_token_missing",
1920              relogin_required=True,
1921          )
1922  
1923      client_id = _spotify_client_id(state=state)
1924      accounts_base_url = _spotify_accounts_base_url(state)
1925      try:
1926          response = httpx.post(
1927              f"{accounts_base_url}/api/token",
1928              headers={"Content-Type": "application/x-www-form-urlencoded"},
1929              data={
1930                  "grant_type": "refresh_token",
1931                  "refresh_token": refresh_token,
1932                  "client_id": client_id,
1933              },
1934              timeout=timeout_seconds,
1935          )
1936      except Exception as exc:
1937          raise AuthError(
1938              f"Spotify token refresh failed: {exc}",
1939              provider="spotify",
1940              code="spotify_refresh_failed",
1941          ) from exc
1942  
1943      if response.status_code >= 400:
1944          detail = response.text.strip()
1945          raise AuthError(
1946              "Spotify token refresh failed. Run `hermes auth spotify` again."
1947              + (f" Response: {detail}" if detail else ""),
1948              provider="spotify",
1949              code="spotify_refresh_failed",
1950              relogin_required=True,
1951          )
1952  
1953      payload = response.json()
1954      if not isinstance(payload, dict) or not str(payload.get("access_token", "") or "").strip():
1955          raise AuthError(
1956              "Spotify refresh response did not include an access_token.",
1957              provider="spotify",
1958              code="spotify_refresh_invalid",
1959              relogin_required=True,
1960          )
1961  
1962      return _spotify_token_payload_to_state(
1963          payload,
1964          client_id=client_id,
1965          redirect_uri=_spotify_redirect_uri(state=state),
1966          requested_scope=str(state.get("scope") or DEFAULT_SPOTIFY_SCOPE),
1967          accounts_base_url=accounts_base_url,
1968          api_base_url=_spotify_api_base_url(state),
1969          previous_state=state,
1970      )
1971  
1972  
1973  def resolve_spotify_runtime_credentials(
1974      *,
1975      force_refresh: bool = False,
1976      refresh_if_expiring: bool = True,
1977      refresh_skew_seconds: int = SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
1978  ) -> Dict[str, Any]:
1979      with _auth_store_lock():
1980          auth_store = _load_auth_store()
1981          state = _load_provider_state(auth_store, "spotify")
1982          if not state:
1983              raise AuthError(
1984                  "Spotify is not authenticated. Run `hermes auth spotify` first.",
1985                  provider="spotify",
1986                  code="spotify_auth_missing",
1987                  relogin_required=True,
1988              )
1989  
1990          should_refresh = bool(force_refresh)
1991          if not should_refresh and refresh_if_expiring:
1992              should_refresh = _is_expiring(state.get("expires_at"), refresh_skew_seconds)
1993          if should_refresh:
1994              state = _refresh_spotify_oauth_state(state)
1995              _store_provider_state(auth_store, "spotify", state, set_active=False)
1996              _save_auth_store(auth_store)
1997  
1998      access_token = str(state.get("access_token", "") or "").strip()
1999      if not access_token:
2000          raise AuthError(
2001              "Spotify access token missing. Run `hermes auth spotify` again.",
2002              provider="spotify",
2003              code="spotify_access_token_missing",
2004              relogin_required=True,
2005          )
2006  
2007      return {
2008          "provider": "spotify",
2009          "access_token": access_token,
2010          "api_key": access_token,
2011          "token_type": str(state.get("token_type", "Bearer") or "Bearer"),
2012          "base_url": _spotify_api_base_url(state),
2013          "scope": str(state.get("granted_scope") or state.get("scope") or "").strip(),
2014          "client_id": _spotify_client_id(state=state),
2015          "redirect_uri": _spotify_redirect_uri(state=state),
2016          "expires_at": state.get("expires_at"),
2017          "refresh_token": str(state.get("refresh_token", "") or "").strip(),
2018      }
2019  
2020  
2021  def get_spotify_auth_status() -> Dict[str, Any]:
2022      state = get_provider_auth_state("spotify")
2023      if not state:
2024          return {"logged_in": False}
2025  
2026      expires_at = state.get("expires_at")
2027      refresh_token = str(state.get("refresh_token", "") or "").strip()
2028      return {
2029          "logged_in": bool(refresh_token or not _is_expiring(expires_at, 0)),
2030          "auth_type": state.get("auth_type", "oauth_pkce"),
2031          "client_id": state.get("client_id"),
2032          "redirect_uri": state.get("redirect_uri"),
2033          "scope": state.get("granted_scope") or state.get("scope"),
2034          "expires_at": expires_at,
2035          "api_base_url": state.get("api_base_url"),
2036          "has_refresh_token": bool(refresh_token),
2037      }
2038  
2039  
2040  def _spotify_interactive_setup(redirect_uri_hint: str) -> str:
2041      """Walk the user through creating a Spotify developer app, persist the
2042      resulting client_id to ~/.hermes/.env, and return it.
2043  
2044      Raises SystemExit if the user aborts or submits an empty value.
2045      """
2046      from hermes_cli.config import save_env_value
2047  
2048      print()
2049      print("=" * 70)
2050      print("Spotify first-time setup")
2051      print("=" * 70)
2052      print()
2053      print("Spotify requires every user to register their own lightweight")
2054      print("developer app. This takes about two minutes and only has to be")
2055      print("done once per machine.")
2056      print()
2057      print(f"Full guide: {SPOTIFY_DOCS_URL}")
2058      print()
2059      print("Steps:")
2060      print(f"  1. Opening {SPOTIFY_DASHBOARD_URL} in your browser...")
2061      print("  2. Click 'Create app' and fill in:")
2062      print("       App name:     anything (e.g. hermes-agent)")
2063      print("       Description:  anything")
2064      print(f"       Redirect URI: {redirect_uri_hint}")
2065      print("       API/SDK:      Web API")
2066      print("  3. Agree to the terms, click Save.")
2067      print("  4. Open the app's Settings page and copy the Client ID.")
2068      print("  5. Paste it below.")
2069      print()
2070  
2071      if not _is_remote_session():
2072          try:
2073              webbrowser.open(SPOTIFY_DASHBOARD_URL)
2074          except Exception:
2075              pass
2076  
2077      try:
2078          raw = input("Spotify Client ID: ").strip()
2079      except (EOFError, KeyboardInterrupt):
2080          print()
2081          raise SystemExit("Spotify setup cancelled.")
2082  
2083      if not raw:
2084          print()
2085          print(f"No Client ID entered. See {SPOTIFY_DOCS_URL} for the full guide.")
2086          raise SystemExit("Spotify setup cancelled: empty Client ID.")
2087  
2088      # Persist so subsequent `hermes auth spotify` runs skip the wizard.
2089      save_env_value("HERMES_SPOTIFY_CLIENT_ID", raw)
2090      # Only persist the redirect URI if it's non-default, to avoid pinning
2091      # users to a value the default might later change to.
2092      if redirect_uri_hint and redirect_uri_hint != DEFAULT_SPOTIFY_REDIRECT_URI:
2093          save_env_value("HERMES_SPOTIFY_REDIRECT_URI", redirect_uri_hint)
2094  
2095      print()
2096      print("Saved HERMES_SPOTIFY_CLIENT_ID to ~/.hermes/.env")
2097      print()
2098      return raw
2099  
2100  
2101  def login_spotify_command(args) -> None:
2102      existing_state = get_provider_auth_state("spotify") or {}
2103  
2104      # Interactive wizard: if no client_id is configured anywhere, walk the
2105      # user through creating the Spotify developer app instead of crashing
2106      # with "HERMES_SPOTIFY_CLIENT_ID is required".
2107      explicit_client_id = getattr(args, "client_id", None)
2108      try:
2109          client_id = _spotify_client_id(explicit_client_id, existing_state)
2110      except AuthError as exc:
2111          if getattr(exc, "code", "") != "spotify_client_id_missing":
2112              raise
2113          client_id = _spotify_interactive_setup(
2114              redirect_uri_hint=getattr(args, "redirect_uri", None) or DEFAULT_SPOTIFY_REDIRECT_URI,
2115          )
2116  
2117      redirect_uri = _spotify_redirect_uri(getattr(args, "redirect_uri", None), existing_state)
2118      scope = _spotify_scope_string(getattr(args, "scope", None) or existing_state.get("scope"))
2119      accounts_base_url = _spotify_accounts_base_url(existing_state)
2120      api_base_url = _spotify_api_base_url(existing_state)
2121      open_browser = not getattr(args, "no_browser", False)
2122  
2123      code_verifier = _spotify_code_verifier()
2124      code_challenge = _spotify_code_challenge(code_verifier)
2125      state_nonce = uuid.uuid4().hex
2126      authorize_url = _spotify_build_authorize_url(
2127          client_id=client_id,
2128          redirect_uri=redirect_uri,
2129          scope=scope,
2130          state=state_nonce,
2131          code_challenge=code_challenge,
2132          accounts_base_url=accounts_base_url,
2133      )
2134  
2135      print("Starting Spotify PKCE login...")
2136      print(f"Client ID: {client_id}")
2137      print(f"Redirect URI: {redirect_uri}")
2138      print("Make sure this redirect URI is allow-listed in your Spotify app settings.")
2139      print()
2140      print("Open this URL to authorize Hermes:")
2141      print(authorize_url)
2142      print()
2143      print(f"Full setup guide: {SPOTIFY_DOCS_URL}")
2144      print()
2145  
2146      if open_browser and not _is_remote_session():
2147          try:
2148              opened = webbrowser.open(authorize_url)
2149          except Exception:
2150              opened = False
2151          if opened:
2152              print("Browser opened for Spotify authorization.")
2153          else:
2154              print("Could not open the browser automatically; use the URL above.")
2155  
2156      callback = _spotify_wait_for_callback(
2157          redirect_uri,
2158          timeout_seconds=float(getattr(args, "timeout", None) or 180.0),
2159      )
2160      if callback.get("error"):
2161          detail = callback.get("error_description") or callback["error"]
2162          raise SystemExit(f"Spotify authorization failed: {detail}")
2163      if callback.get("state") != state_nonce:
2164          raise SystemExit("Spotify authorization failed: state mismatch.")
2165  
2166      token_payload = _spotify_exchange_code_for_tokens(
2167          client_id=client_id,
2168          code=str(callback.get("code") or ""),
2169          redirect_uri=redirect_uri,
2170          code_verifier=code_verifier,
2171          accounts_base_url=accounts_base_url,
2172          timeout_seconds=float(getattr(args, "timeout", None) or 20.0),
2173      )
2174      spotify_state = _spotify_token_payload_to_state(
2175          token_payload,
2176          client_id=client_id,
2177          redirect_uri=redirect_uri,
2178          requested_scope=scope,
2179          accounts_base_url=accounts_base_url,
2180          api_base_url=api_base_url,
2181      )
2182  
2183      with _auth_store_lock():
2184          auth_store = _load_auth_store()
2185          _store_provider_state(auth_store, "spotify", spotify_state, set_active=False)
2186          saved_to = _save_auth_store(auth_store)
2187  
2188      print("Spotify login successful!")
2189      print(f"  Auth state: {saved_to}")
2190      print("  Provider state saved under providers.spotify")
2191      print(f"  Docs: {SPOTIFY_DOCS_URL}")
2192  
2193  # =============================================================================
2194  # SSH / remote session detection
2195  # =============================================================================
2196  
2197  def _is_remote_session() -> bool:
2198      """Detect if running in an SSH session where webbrowser.open() won't work."""
2199      return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"))
2200  
2201  
2202  # =============================================================================
2203  # OpenAI Codex auth — tokens stored in ~/.hermes/auth.json (not ~/.codex/)
2204  #
2205  # Hermes maintains its own Codex OAuth session separate from the Codex CLI
2206  # and VS Code extension. This prevents refresh token rotation conflicts
2207  # where one app's refresh invalidates the other's session.
2208  # =============================================================================
2209  
2210  def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]:
2211      """Read Codex OAuth tokens from Hermes auth store (~/.hermes/auth.json).
2212      
2213      Returns dict with 'tokens' (access_token, refresh_token) and 'last_refresh'.
2214      Raises AuthError if no Codex tokens are stored.
2215      """
2216      if _lock:
2217          with _auth_store_lock():
2218              auth_store = _load_auth_store()
2219      else:
2220          auth_store = _load_auth_store()
2221      state = _load_provider_state(auth_store, "openai-codex")
2222      if not state:
2223          raise AuthError(
2224              "No Codex credentials stored. Run `hermes auth` to authenticate.",
2225              provider="openai-codex",
2226              code="codex_auth_missing",
2227              relogin_required=True,
2228          )
2229      tokens = state.get("tokens")
2230      if not isinstance(tokens, dict):
2231          raise AuthError(
2232              "Codex auth state is missing tokens. Run `hermes auth` to re-authenticate.",
2233              provider="openai-codex",
2234              code="codex_auth_invalid_shape",
2235              relogin_required=True,
2236          )
2237      access_token = tokens.get("access_token")
2238      refresh_token = tokens.get("refresh_token")
2239      if not isinstance(access_token, str) or not access_token.strip():
2240          raise AuthError(
2241              "Codex auth is missing access_token. Run `hermes auth` to re-authenticate.",
2242              provider="openai-codex",
2243              code="codex_auth_missing_access_token",
2244              relogin_required=True,
2245          )
2246      if not isinstance(refresh_token, str) or not refresh_token.strip():
2247          raise AuthError(
2248              "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.",
2249              provider="openai-codex",
2250              code="codex_auth_missing_refresh_token",
2251              relogin_required=True,
2252          )
2253      return {
2254          "tokens": tokens,
2255          "last_refresh": state.get("last_refresh"),
2256      }
2257  
2258  
2259  def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None:
2260      """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json)."""
2261      if last_refresh is None:
2262          last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
2263      with _auth_store_lock():
2264          auth_store = _load_auth_store()
2265          state = _load_provider_state(auth_store, "openai-codex") or {}
2266          state["tokens"] = tokens
2267          state["last_refresh"] = last_refresh
2268          state["auth_mode"] = "chatgpt"
2269          _save_provider_state(auth_store, "openai-codex", state)
2270          _save_auth_store(auth_store)
2271  
2272  
2273  def refresh_codex_oauth_pure(
2274      access_token: str,
2275      refresh_token: str,
2276      *,
2277      timeout_seconds: float = 20.0,
2278  ) -> Dict[str, Any]:
2279      """Refresh Codex OAuth tokens without mutating Hermes auth state."""
2280      del access_token  # Access token is only used by callers to decide whether to refresh.
2281      if not isinstance(refresh_token, str) or not refresh_token.strip():
2282          raise AuthError(
2283              "Codex auth is missing refresh_token. Run `hermes auth` to re-authenticate.",
2284              provider="openai-codex",
2285              code="codex_auth_missing_refresh_token",
2286              relogin_required=True,
2287          )
2288  
2289      timeout = httpx.Timeout(max(5.0, float(timeout_seconds)))
2290      with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}) as client:
2291          response = client.post(
2292              CODEX_OAUTH_TOKEN_URL,
2293              headers={"Content-Type": "application/x-www-form-urlencoded"},
2294              data={
2295                  "grant_type": "refresh_token",
2296                  "refresh_token": refresh_token,
2297                  "client_id": CODEX_OAUTH_CLIENT_ID,
2298              },
2299          )
2300  
2301      if response.status_code != 200:
2302          code = "codex_refresh_failed"
2303          message = f"Codex token refresh failed with status {response.status_code}."
2304          relogin_required = False
2305          try:
2306              err = response.json()
2307              if isinstance(err, dict):
2308                  err_obj = err.get("error")
2309                  # OpenAI shape: {"error": {"code": "...", "message": "...", "type": "..."}}
2310                  if isinstance(err_obj, dict):
2311                      nested_code = err_obj.get("code") or err_obj.get("type")
2312                      if isinstance(nested_code, str) and nested_code.strip():
2313                          code = nested_code.strip()
2314                      nested_msg = err_obj.get("message")
2315                      if isinstance(nested_msg, str) and nested_msg.strip():
2316                          message = f"Codex token refresh failed: {nested_msg.strip()}"
2317                  # OAuth spec shape: {"error": "code_str", "error_description": "..."}
2318                  elif isinstance(err_obj, str) and err_obj.strip():
2319                      code = err_obj.strip()
2320                      err_desc = err.get("error_description") or err.get("message")
2321                      if isinstance(err_desc, str) and err_desc.strip():
2322                          message = f"Codex token refresh failed: {err_desc.strip()}"
2323          except Exception:
2324              pass
2325          if code in {"invalid_grant", "invalid_token", "invalid_request"}:
2326              relogin_required = True
2327          if code == "refresh_token_reused":
2328              message = (
2329                  "Codex refresh token was already consumed by another client "
2330                  "(e.g. Codex CLI or VS Code extension). "
2331                  "Run `codex` in your terminal to generate fresh tokens, "
2332                  "then run `hermes auth` to re-authenticate."
2333              )
2334              relogin_required = True
2335          # A 401/403 from the token endpoint always means the refresh token
2336          # is invalid/expired — force relogin even if the body error code
2337          # wasn't one of the known strings above.
2338          if response.status_code in (401, 403) and not relogin_required:
2339              relogin_required = True
2340          raise AuthError(
2341              message,
2342              provider="openai-codex",
2343              code=code,
2344              relogin_required=relogin_required,
2345          )
2346  
2347      try:
2348          refresh_payload = response.json()
2349      except Exception as exc:
2350          raise AuthError(
2351              "Codex token refresh returned invalid JSON.",
2352              provider="openai-codex",
2353              code="codex_refresh_invalid_json",
2354              relogin_required=True,
2355          ) from exc
2356  
2357      refreshed_access = refresh_payload.get("access_token")
2358      if not isinstance(refreshed_access, str) or not refreshed_access.strip():
2359          raise AuthError(
2360              "Codex token refresh response was missing access_token.",
2361              provider="openai-codex",
2362              code="codex_refresh_missing_access_token",
2363              relogin_required=True,
2364          )
2365  
2366      updated = {
2367          "access_token": refreshed_access.strip(),
2368          "refresh_token": refresh_token.strip(),
2369          "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
2370      }
2371      next_refresh = refresh_payload.get("refresh_token")
2372      if isinstance(next_refresh, str) and next_refresh.strip():
2373          updated["refresh_token"] = next_refresh.strip()
2374      return updated
2375  
2376  
2377  def _refresh_codex_auth_tokens(
2378      tokens: Dict[str, str],
2379      timeout_seconds: float,
2380  ) -> Dict[str, str]:
2381      """Refresh Codex access token using the refresh token.
2382      
2383      Saves the new tokens to Hermes auth store automatically.
2384      """
2385      refreshed = refresh_codex_oauth_pure(
2386          str(tokens.get("access_token", "") or ""),
2387          str(tokens.get("refresh_token", "") or ""),
2388          timeout_seconds=timeout_seconds,
2389      )
2390      updated_tokens = dict(tokens)
2391      updated_tokens["access_token"] = refreshed["access_token"]
2392      updated_tokens["refresh_token"] = refreshed["refresh_token"]
2393  
2394      _save_codex_tokens(updated_tokens)
2395      return updated_tokens
2396  
2397  
2398  def _import_codex_cli_tokens() -> Optional[Dict[str, str]]:
2399      """Try to read tokens from ~/.codex/auth.json (Codex CLI shared file).
2400      
2401      Returns tokens dict if valid and not expired, None otherwise.
2402      Does NOT write to the shared file.
2403      """
2404      codex_home = os.getenv("CODEX_HOME", "").strip()
2405      if not codex_home:
2406          codex_home = str(Path.home() / ".codex")
2407      auth_path = Path(codex_home).expanduser() / "auth.json"
2408      if not auth_path.is_file():
2409          return None
2410      try:
2411          payload = json.loads(auth_path.read_text())
2412          tokens = payload.get("tokens")
2413          if not isinstance(tokens, dict):
2414              return None
2415          access_token = tokens.get("access_token")
2416          refresh_token = tokens.get("refresh_token")
2417          if not access_token or not refresh_token:
2418              return None
2419          # Reject expired tokens — importing stale tokens from ~/.codex/
2420          # that can't be refreshed leaves the user stuck with "Login successful!"
2421          # but no working credentials.
2422          if _codex_access_token_is_expiring(access_token, 0):
2423              logger.debug(
2424                  "Codex CLI tokens at %s are expired — skipping import.", auth_path,
2425              )
2426              return None
2427          return dict(tokens)
2428      except Exception:
2429          return None
2430  
2431  
2432  def resolve_codex_runtime_credentials(
2433      *,
2434      force_refresh: bool = False,
2435      refresh_if_expiring: bool = True,
2436      refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
2437  ) -> Dict[str, Any]:
2438      """Resolve runtime credentials from Hermes's own Codex token store."""
2439      data = _read_codex_tokens()
2440      tokens = dict(data["tokens"])
2441      access_token = str(tokens.get("access_token", "") or "").strip()
2442      refresh_timeout_seconds = float(os.getenv("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", "20"))
2443  
2444      should_refresh = bool(force_refresh)
2445      if (not should_refresh) and refresh_if_expiring:
2446          should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds)
2447      if should_refresh:
2448          # Re-read under lock to avoid racing with other Hermes processes
2449          with _auth_store_lock(timeout_seconds=max(float(AUTH_LOCK_TIMEOUT_SECONDS), refresh_timeout_seconds + 5.0)):
2450              data = _read_codex_tokens(_lock=False)
2451              tokens = dict(data["tokens"])
2452              access_token = str(tokens.get("access_token", "") or "").strip()
2453  
2454              should_refresh = bool(force_refresh)
2455              if (not should_refresh) and refresh_if_expiring:
2456                  should_refresh = _codex_access_token_is_expiring(access_token, refresh_skew_seconds)
2457  
2458              if should_refresh:
2459                  tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds)
2460                  access_token = str(tokens.get("access_token", "") or "").strip()
2461  
2462      base_url = (
2463          os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
2464          or DEFAULT_CODEX_BASE_URL
2465      )
2466  
2467      return {
2468          "provider": "openai-codex",
2469          "base_url": base_url,
2470          "api_key": access_token,
2471          "source": "hermes-auth-store",
2472          "last_refresh": data.get("last_refresh"),
2473          "auth_mode": "chatgpt",
2474      }
2475  
2476  
2477  # =============================================================================
2478  # TLS verification helper
2479  # =============================================================================
2480  
2481  def _default_verify() -> bool | ssl.SSLContext:
2482      """Platform-aware default SSL verify for httpx clients.
2483  
2484      On macOS with Homebrew Python, the system OpenSSL cannot locate the
2485      system trust store and valid public certs fail verification. When
2486      certifi is importable we pin its bundle explicitly; elsewhere we
2487      defer to httpx's built-in default (certifi via its own dependency).
2488      Mirrors the weixin fix in 3a0ec1d93.
2489      """
2490      if sys.platform == "darwin":
2491          try:
2492              import certifi
2493              return ssl.create_default_context(cafile=certifi.where())
2494          except ImportError:
2495              pass
2496      return True
2497  
2498  
2499  def _resolve_verify(
2500      *,
2501      insecure: Optional[bool] = None,
2502      ca_bundle: Optional[str] = None,
2503      auth_state: Optional[Dict[str, Any]] = None,
2504  ) -> bool | ssl.SSLContext:
2505      tls_state = auth_state.get("tls") if isinstance(auth_state, dict) else {}
2506      tls_state = tls_state if isinstance(tls_state, dict) else {}
2507  
2508      effective_insecure = (
2509          is_truthy_value(insecure, default=False) if insecure is not None
2510          else is_truthy_value(tls_state.get("insecure", False), default=False)
2511      )
2512      effective_ca = (
2513          ca_bundle
2514          or tls_state.get("ca_bundle")
2515          or os.getenv("HERMES_CA_BUNDLE")
2516          or os.getenv("SSL_CERT_FILE")
2517          or os.getenv("REQUESTS_CA_BUNDLE")
2518      )
2519  
2520      if effective_insecure:
2521          return False
2522      if effective_ca:
2523          ca_path = str(effective_ca)
2524          if not os.path.isfile(ca_path):
2525              logger.warning(
2526                  "CA bundle path does not exist: %s — falling back to default certificates",
2527                  ca_path,
2528              )
2529              return _default_verify()
2530          return ssl.create_default_context(cafile=ca_path)
2531      return _default_verify()
2532  
2533  
2534  # =============================================================================
2535  # OAuth Device Code Flow — generic, parameterized by provider
2536  # =============================================================================
2537  
2538  def _request_device_code(
2539      client: httpx.Client,
2540      portal_base_url: str,
2541      client_id: str,
2542      scope: Optional[str],
2543  ) -> Dict[str, Any]:
2544      """POST to the device code endpoint. Returns device_code, user_code, etc."""
2545      response = client.post(
2546          f"{portal_base_url}/api/oauth/device/code",
2547          data={
2548              "client_id": client_id,
2549              **({"scope": scope} if scope else {}),
2550          },
2551      )
2552      response.raise_for_status()
2553      data = response.json()
2554  
2555      required_fields = [
2556          "device_code", "user_code", "verification_uri",
2557          "verification_uri_complete", "expires_in", "interval",
2558      ]
2559      missing = [f for f in required_fields if f not in data]
2560      if missing:
2561          raise ValueError(f"Device code response missing fields: {', '.join(missing)}")
2562      return data
2563  
2564  
2565  def _poll_for_token(
2566      client: httpx.Client,
2567      portal_base_url: str,
2568      client_id: str,
2569      device_code: str,
2570      expires_in: int,
2571      poll_interval: int,
2572  ) -> Dict[str, Any]:
2573      """Poll the token endpoint until the user approves or the code expires."""
2574      deadline = time.time() + max(1, expires_in)
2575      current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
2576  
2577      while time.time() < deadline:
2578          response = client.post(
2579              f"{portal_base_url}/api/oauth/token",
2580              data={
2581                  "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
2582                  "client_id": client_id,
2583                  "device_code": device_code,
2584              },
2585          )
2586  
2587          if response.status_code == 200:
2588              payload = response.json()
2589              if "access_token" not in payload:
2590                  raise ValueError("Token response did not include access_token")
2591              return payload
2592  
2593          try:
2594              error_payload = response.json()
2595          except Exception:
2596              response.raise_for_status()
2597              raise RuntimeError("Token endpoint returned a non-JSON error response")
2598  
2599          error_code = error_payload.get("error", "")
2600          if error_code == "authorization_pending":
2601              time.sleep(current_interval)
2602              continue
2603          if error_code == "slow_down":
2604              current_interval = min(current_interval + 1, 30)
2605              time.sleep(current_interval)
2606              continue
2607  
2608          description = error_payload.get("error_description") or "Unknown authentication error"
2609          raise RuntimeError(f"{error_code}: {description}")
2610  
2611      raise TimeoutError("Timed out waiting for device authorization")
2612  
2613  
2614  # =============================================================================
2615  # Nous Portal — token refresh, agent key minting, model discovery
2616  # =============================================================================
2617  
2618  # -----------------------------------------------------------------------------
2619  # Shared Nous token store — lets OAuth credentials persist across profiles
2620  # so a new `hermes --profile <name> auth add nous --type oauth` can one-tap
2621  # import instead of running the full device-code flow every time.
2622  #
2623  # File lives at ${HERMES_SHARED_AUTH_DIR}/nous_auth.json, defaulting to
2624  # ~/.hermes/shared/nous_auth.json. It is OUTSIDE any named profile's
2625  # HERMES_HOME so named profiles (which typically live under
2626  # ~/.hermes/profiles/<name>/) all see the same file.
2627  #
2628  # Written on successful login and on every runtime refresh so the stored
2629  # refresh_token stays current even if one profile refreshes and rotates it.
2630  # If ever the stored refresh_token does go stale server-side, import fails
2631  # gracefully and the user falls back to the normal device-code flow.
2632  # -----------------------------------------------------------------------------
2633  
2634  NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
2635  
2636  
2637  def _nous_shared_auth_dir() -> Path:
2638      """Resolve the directory that holds the shared Nous token store.
2639  
2640      Honors ``HERMES_SHARED_AUTH_DIR`` so tests can redirect it to a tmp
2641      path without touching the real user's home. Defaults to
2642      ``~/.hermes/shared/``.
2643      """
2644      override = os.getenv("HERMES_SHARED_AUTH_DIR", "").strip()
2645      if override:
2646          return Path(override).expanduser()
2647      return Path.home() / ".hermes" / "shared"
2648  
2649  
2650  def _nous_shared_store_path() -> Path:
2651      path = _nous_shared_auth_dir() / NOUS_SHARED_STORE_FILENAME
2652      # Seat belt: if pytest is running and this resolves to a path under the
2653      # real user's home, refuse rather than silently corrupt cross-profile
2654      # state. Tests must set HERMES_SHARED_AUTH_DIR to a tmp_path (conftest
2655      # does not do this automatically — mirror the _auth_file_path() guard
2656      # so forgetting to set it fails loudly instead of writing to the real
2657      # shared store).
2658      if os.environ.get("PYTEST_CURRENT_TEST"):
2659          real_home_shared = (
2660              Path.home() / ".hermes" / "shared" / NOUS_SHARED_STORE_FILENAME
2661          ).resolve(strict=False)
2662          try:
2663              resolved = path.resolve(strict=False)
2664          except Exception:
2665              resolved = path
2666          if resolved == real_home_shared:
2667              raise RuntimeError(
2668                  f"Refusing to touch real user shared Nous auth store during test run: "
2669                  f"{path}. Set HERMES_SHARED_AUTH_DIR to a tmp_path in your test fixture."
2670              )
2671      return path
2672  
2673  
2674  def _write_shared_nous_state(state: Dict[str, Any]) -> None:
2675      """Persist a minimal copy of the Nous OAuth state to the shared store.
2676  
2677      Best-effort: any failure is swallowed after logging. The shared store
2678      is a convenience layer; the per-profile auth.json remains the source
2679      of truth.
2680  
2681      We deliberately omit the short-lived ``agent_key`` (24h TTL, profile-
2682      specific) — only the long-lived OAuth tokens are cross-profile useful.
2683      """
2684      refresh_token = state.get("refresh_token")
2685      access_token = state.get("access_token")
2686      if not (isinstance(refresh_token, str) and refresh_token.strip()):
2687          # No refresh_token = nothing worth sharing across profiles
2688          return
2689      if not (isinstance(access_token, str) and access_token.strip()):
2690          return
2691  
2692      shared = {
2693          "_schema": 1,
2694          "access_token": access_token,
2695          "refresh_token": refresh_token,
2696          "token_type": state.get("token_type") or "Bearer",
2697          "scope": state.get("scope") or DEFAULT_NOUS_SCOPE,
2698          "client_id": state.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
2699          "portal_base_url": state.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
2700          "inference_base_url": state.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
2701          "obtained_at": state.get("obtained_at"),
2702          "expires_at": state.get("expires_at"),
2703          "updated_at": datetime.now(timezone.utc).isoformat(),
2704      }
2705      try:
2706          path = _nous_shared_store_path()
2707          path.parent.mkdir(parents=True, exist_ok=True)
2708          tmp = path.with_suffix(path.suffix + ".tmp")
2709          tmp.write_text(json.dumps(shared, indent=2, sort_keys=True))
2710          try:
2711              os.chmod(tmp, 0o600)
2712          except OSError:
2713              pass
2714          os.replace(tmp, path)
2715          _oauth_trace(
2716              "nous_shared_store_written",
2717              path=str(path),
2718              refresh_token_fp=_token_fingerprint(refresh_token),
2719          )
2720      except Exception as exc:
2721          logger.debug("Failed to write shared Nous auth store: %s", exc)
2722  
2723  
2724  def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
2725      """Return the shared Nous OAuth state if present and well-formed.
2726  
2727      Returns ``None`` when the file is missing, unreadable, malformed, or
2728      lacks required fields. Callers should treat ``None`` as "no shared
2729      credentials available — fall through to device-code".
2730      """
2731      try:
2732          path = _nous_shared_store_path()
2733      except RuntimeError:
2734          # Test seat belt tripped — treat as missing
2735          return None
2736      if not path.is_file():
2737          return None
2738      try:
2739          payload = json.loads(path.read_text())
2740      except (OSError, ValueError) as exc:
2741          logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
2742          return None
2743      if not isinstance(payload, dict):
2744          return None
2745      refresh_token = payload.get("refresh_token")
2746      access_token = payload.get("access_token")
2747      if not (isinstance(refresh_token, str) and refresh_token.strip()):
2748          return None
2749      if not (isinstance(access_token, str) and access_token.strip()):
2750          return None
2751      return payload
2752  
2753  
2754  def _try_import_shared_nous_state(
2755      *,
2756      timeout_seconds: float = 15.0,
2757      min_key_ttl_seconds: int = 5 * 60,
2758  ) -> Optional[Dict[str, Any]]:
2759      """Attempt to rehydrate Nous OAuth state from the shared store.
2760  
2761      Reads the shared file (if present), runs a forced refresh+mint using
2762      the stored refresh_token to produce a fresh access_token + agent_key
2763      scoped to this profile, and returns the full auth_state dict ready
2764      for ``persist_nous_credentials()``.
2765  
2766      Returns ``None`` when no shared state is available or the rehydrate
2767      fails for any reason (expired refresh_token, portal unreachable,
2768      etc.) — caller should then fall through to the normal device-code
2769      flow.
2770      """
2771      shared = _read_shared_nous_state()
2772      if not shared:
2773          return None
2774  
2775      # Build a full state dict so refresh_nous_oauth_from_state has every
2776      # field it needs. force_refresh=True gets us a fresh access_token
2777      # for this profile; force_mint=True gets us a fresh agent_key.
2778      state: Dict[str, Any] = {
2779          "access_token": shared.get("access_token"),
2780          "refresh_token": shared.get("refresh_token"),
2781          "client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
2782          "portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
2783          "inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
2784          "token_type": shared.get("token_type") or "Bearer",
2785          "scope": shared.get("scope") or DEFAULT_NOUS_SCOPE,
2786          "obtained_at": shared.get("obtained_at"),
2787          "expires_at": shared.get("expires_at"),
2788          "agent_key": None,
2789          "agent_key_expires_at": None,
2790          "tls": {"insecure": False, "ca_bundle": None},
2791      }
2792  
2793      try:
2794          refreshed = refresh_nous_oauth_from_state(
2795              state,
2796              min_key_ttl_seconds=min_key_ttl_seconds,
2797              timeout_seconds=timeout_seconds,
2798              force_refresh=True,
2799              force_mint=True,
2800          )
2801      except AuthError as exc:
2802          _oauth_trace(
2803              "nous_shared_import_failed",
2804              error_type=type(exc).__name__,
2805              error_code=getattr(exc, "code", None),
2806          )
2807          logger.debug("Shared Nous import failed: %s", exc)
2808          return None
2809      except Exception as exc:
2810          _oauth_trace(
2811              "nous_shared_import_failed",
2812              error_type=type(exc).__name__,
2813          )
2814          logger.debug("Shared Nous import failed: %s", exc)
2815          return None
2816  
2817      return refreshed
2818  
2819  
2820  def _refresh_access_token(
2821      *,
2822      client: httpx.Client,
2823      portal_base_url: str,
2824      client_id: str,
2825      refresh_token: str,
2826  ) -> Dict[str, Any]:
2827      response = client.post(
2828          f"{portal_base_url}/api/oauth/token",
2829          data={
2830              "grant_type": "refresh_token",
2831              "client_id": client_id,
2832              "refresh_token": refresh_token,
2833          },
2834      )
2835  
2836      if response.status_code == 200:
2837          payload = response.json()
2838          if "access_token" not in payload:
2839              raise AuthError("Refresh response missing access_token",
2840                              provider="nous", code="invalid_token", relogin_required=True)
2841          return payload
2842  
2843      try:
2844          error_payload = response.json()
2845      except Exception as exc:
2846          raise AuthError("Refresh token exchange failed",
2847                          provider="nous", relogin_required=True) from exc
2848  
2849      code = str(error_payload.get("error", "invalid_grant"))
2850      description = str(error_payload.get("error_description") or "Refresh token exchange failed")
2851      relogin = code in {"invalid_grant", "invalid_token"}
2852  
2853      # Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal
2854      # server and surface an actionable message.  This fires when an external
2855      # process (health-check script, monitoring tool, custom self-heal hook)
2856      # called POST /api/oauth/token with Hermes's refresh_token without
2857      # persisting the rotated token back to auth.json — the server then
2858      # retires the original RT, Hermes's next refresh uses it, and the whole
2859      # session chain gets revoked as a token-theft signal (#15099).
2860      lowered = description.lower()
2861      if "reuse" in lowered or "reuse detected" in lowered:
2862          description = (
2863              "Nous Portal detected refresh-token reuse and revoked this session.\n"
2864              "This usually means an external process (monitoring script, "
2865              "custom self-heal hook, or another Hermes install sharing "
2866              "~/.hermes/auth.json) called POST /api/oauth/token with Hermes's "
2867              "refresh token without persisting the rotated token back.\n"
2868              "Nous refresh tokens are single-use — only Hermes may call the "
2869              "refresh endpoint. For health checks, use `hermes auth status` "
2870              "instead.\n"
2871              "Re-authenticate with: hermes auth add nous"
2872          )
2873  
2874      raise AuthError(description, provider="nous", code=code, relogin_required=relogin)
2875  
2876  
2877  def _mint_agent_key(
2878      *,
2879      client: httpx.Client,
2880      portal_base_url: str,
2881      access_token: str,
2882      min_ttl_seconds: int,
2883  ) -> Dict[str, Any]:
2884      """Mint (or reuse) a short-lived inference API key."""
2885      response = client.post(
2886          f"{portal_base_url}/api/oauth/agent-key",
2887          headers={"Authorization": f"Bearer {access_token}"},
2888          json={"min_ttl_seconds": max(60, int(min_ttl_seconds))},
2889      )
2890  
2891      if response.status_code == 200:
2892          payload = response.json()
2893          if "api_key" not in payload:
2894              raise AuthError("Mint response missing api_key",
2895                              provider="nous", code="server_error")
2896          return payload
2897  
2898      try:
2899          error_payload = response.json()
2900      except Exception as exc:
2901          raise AuthError("Agent key mint request failed",
2902                          provider="nous", code="server_error") from exc
2903  
2904      code = str(error_payload.get("error", "server_error"))
2905      description = str(error_payload.get("error_description") or "Agent key mint request failed")
2906      relogin = code in {"invalid_token", "invalid_grant"}
2907      raise AuthError(description, provider="nous", code=code, relogin_required=relogin)
2908  
2909  
2910  def fetch_nous_models(
2911      *,
2912      inference_base_url: str,
2913      api_key: str,
2914      timeout_seconds: float = 15.0,
2915      verify: bool | str = True,
2916  ) -> List[str]:
2917      """Fetch available model IDs from the Nous inference API."""
2918      timeout = httpx.Timeout(timeout_seconds)
2919      with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
2920          response = client.get(
2921              f"{inference_base_url.rstrip('/')}/models",
2922              headers={"Authorization": f"Bearer {api_key}"},
2923          )
2924  
2925      if response.status_code != 200:
2926          description = f"/models request failed with status {response.status_code}"
2927          try:
2928              err = response.json()
2929              description = str(err.get("error_description") or err.get("error") or description)
2930          except Exception as e:
2931              logger.debug("Could not parse error response JSON: %s", e)
2932          raise AuthError(description, provider="nous", code="models_fetch_failed")
2933  
2934      payload = response.json()
2935      data = payload.get("data")
2936      if not isinstance(data, list):
2937          return []
2938  
2939      model_ids: List[str] = []
2940      for item in data:
2941          if not isinstance(item, dict):
2942              continue
2943          model_id = item.get("id")
2944          if isinstance(model_id, str) and model_id.strip():
2945              mid = model_id.strip()
2946              # Skip Hermes models — they're not reliable for agentic tool-calling
2947              if "hermes" in mid.lower():
2948                  continue
2949              model_ids.append(mid)
2950  
2951      # Sort: prefer opus > pro > haiku/flash > sonnet (sonnet is cheap/fast,
2952      # users who want the best model should see opus first).
2953      def _model_priority(mid: str) -> tuple:
2954          low = mid.lower()
2955          if "opus" in low:
2956              return (0, mid)
2957          if "pro" in low and "sonnet" not in low:
2958              return (1, mid)
2959          if "sonnet" in low:
2960              return (3, mid)
2961          return (2, mid)
2962  
2963      model_ids.sort(key=_model_priority)
2964      return list(dict.fromkeys(model_ids))
2965  
2966  
2967  def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
2968      key = state.get("agent_key")
2969      if not isinstance(key, str) or not key.strip():
2970          return False
2971      return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds)
2972  
2973  
2974  def resolve_nous_access_token(
2975      *,
2976      timeout_seconds: float = 15.0,
2977      insecure: Optional[bool] = None,
2978      ca_bundle: Optional[str] = None,
2979      refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
2980  ) -> str:
2981      """Resolve a refresh-aware Nous Portal access token for managed tool gateways."""
2982      with _auth_store_lock():
2983          auth_store = _load_auth_store()
2984          state = _load_provider_state(auth_store, "nous")
2985  
2986          if not state:
2987              raise AuthError(
2988                  "Hermes is not logged into Nous Portal.",
2989                  provider="nous",
2990                  relogin_required=True,
2991              )
2992  
2993          portal_base_url = (
2994              _optional_base_url(state.get("portal_base_url"))
2995              or os.getenv("HERMES_PORTAL_BASE_URL")
2996              or os.getenv("NOUS_PORTAL_BASE_URL")
2997              or DEFAULT_NOUS_PORTAL_URL
2998          ).rstrip("/")
2999          client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
3000          verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
3001  
3002          access_token = state.get("access_token")
3003          refresh_token = state.get("refresh_token")
3004          if not isinstance(access_token, str) or not access_token:
3005              raise AuthError(
3006                  "No access token found for Nous Portal login.",
3007                  provider="nous",
3008                  relogin_required=True,
3009              )
3010  
3011          if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
3012              return access_token
3013  
3014          if not isinstance(refresh_token, str) or not refresh_token:
3015              raise AuthError(
3016                  "Session expired and no refresh token is available.",
3017                  provider="nous",
3018                  relogin_required=True,
3019              )
3020  
3021          timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
3022          with httpx.Client(
3023              timeout=timeout,
3024              headers={"Accept": "application/json"},
3025              verify=verify,
3026          ) as client:
3027              refreshed = _refresh_access_token(
3028                  client=client,
3029                  portal_base_url=portal_base_url,
3030                  client_id=client_id,
3031                  refresh_token=refresh_token,
3032              )
3033  
3034          now = datetime.now(timezone.utc)
3035          access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
3036          state["access_token"] = refreshed["access_token"]
3037          state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
3038          state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
3039          state["scope"] = refreshed.get("scope") or state.get("scope")
3040          state["obtained_at"] = now.isoformat()
3041          state["expires_in"] = access_ttl
3042          state["expires_at"] = datetime.fromtimestamp(
3043              now.timestamp() + access_ttl,
3044              tz=timezone.utc,
3045          ).isoformat()
3046          state["portal_base_url"] = portal_base_url
3047          state["client_id"] = client_id
3048          state["tls"] = {
3049              "insecure": verify is False,
3050              "ca_bundle": verify if isinstance(verify, str) else None,
3051          }
3052          _save_provider_state(auth_store, "nous", state)
3053          _save_auth_store(auth_store)
3054          return state["access_token"]
3055  
3056  
3057  def refresh_nous_oauth_pure(
3058      access_token: str,
3059      refresh_token: str,
3060      client_id: str,
3061      portal_base_url: str,
3062      inference_base_url: str,
3063      *,
3064      token_type: str = "Bearer",
3065      scope: str = DEFAULT_NOUS_SCOPE,
3066      obtained_at: Optional[str] = None,
3067      expires_at: Optional[str] = None,
3068      agent_key: Optional[str] = None,
3069      agent_key_expires_at: Optional[str] = None,
3070      min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
3071      timeout_seconds: float = 15.0,
3072      insecure: Optional[bool] = None,
3073      ca_bundle: Optional[str] = None,
3074      force_refresh: bool = False,
3075      force_mint: bool = False,
3076  ) -> Dict[str, Any]:
3077      """Refresh Nous OAuth state without mutating auth.json."""
3078      state: Dict[str, Any] = {
3079          "access_token": access_token,
3080          "refresh_token": refresh_token,
3081          "client_id": client_id or DEFAULT_NOUS_CLIENT_ID,
3082          "portal_base_url": (portal_base_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/"),
3083          "inference_base_url": (inference_base_url or DEFAULT_NOUS_INFERENCE_URL).rstrip("/"),
3084          "token_type": token_type or "Bearer",
3085          "scope": scope or DEFAULT_NOUS_SCOPE,
3086          "obtained_at": obtained_at,
3087          "expires_at": expires_at,
3088          "agent_key": agent_key,
3089          "agent_key_expires_at": agent_key_expires_at,
3090          "tls": {
3091              "insecure": bool(insecure),
3092              "ca_bundle": ca_bundle,
3093          },
3094      }
3095      verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
3096      timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
3097  
3098      with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
3099          if force_refresh or _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS):
3100              refreshed = _refresh_access_token(
3101                  client=client,
3102                  portal_base_url=state["portal_base_url"],
3103                  client_id=state["client_id"],
3104                  refresh_token=state["refresh_token"],
3105              )
3106              now = datetime.now(timezone.utc)
3107              access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
3108              state["access_token"] = refreshed["access_token"]
3109              state["refresh_token"] = refreshed.get("refresh_token") or state["refresh_token"]
3110              state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
3111              state["scope"] = refreshed.get("scope") or state.get("scope")
3112              refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
3113              if refreshed_url:
3114                  state["inference_base_url"] = refreshed_url
3115              state["obtained_at"] = now.isoformat()
3116              state["expires_in"] = access_ttl
3117              state["expires_at"] = datetime.fromtimestamp(
3118                  now.timestamp() + access_ttl, tz=timezone.utc
3119              ).isoformat()
3120  
3121          if force_mint or not _agent_key_is_usable(state, max(60, int(min_key_ttl_seconds))):
3122              mint_payload = _mint_agent_key(
3123                  client=client,
3124                  portal_base_url=state["portal_base_url"],
3125                  access_token=state["access_token"],
3126                  min_ttl_seconds=min_key_ttl_seconds,
3127              )
3128              now = datetime.now(timezone.utc)
3129              state["agent_key"] = mint_payload.get("api_key")
3130              state["agent_key_id"] = mint_payload.get("key_id")
3131              state["agent_key_expires_at"] = mint_payload.get("expires_at")
3132              state["agent_key_expires_in"] = mint_payload.get("expires_in")
3133              state["agent_key_reused"] = bool(mint_payload.get("reused", False))
3134              state["agent_key_obtained_at"] = now.isoformat()
3135              minted_url = _optional_base_url(mint_payload.get("inference_base_url"))
3136              if minted_url:
3137                  state["inference_base_url"] = minted_url
3138  
3139      return state
3140  
3141  
3142  def refresh_nous_oauth_from_state(
3143      state: Dict[str, Any],
3144      *,
3145      min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
3146      timeout_seconds: float = 15.0,
3147      force_refresh: bool = False,
3148      force_mint: bool = False,
3149  ) -> Dict[str, Any]:
3150      """Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure."""
3151      tls = state.get("tls") or {}
3152      return refresh_nous_oauth_pure(
3153          state.get("access_token", ""),
3154          state.get("refresh_token", ""),
3155          state.get("client_id", "hermes-cli"),
3156          state.get("portal_base_url", DEFAULT_NOUS_PORTAL_URL),
3157          state.get("inference_base_url", DEFAULT_NOUS_INFERENCE_URL),
3158          token_type=state.get("token_type", "Bearer"),
3159          scope=state.get("scope", DEFAULT_NOUS_SCOPE),
3160          obtained_at=state.get("obtained_at"),
3161          expires_at=state.get("expires_at"),
3162          agent_key=state.get("agent_key"),
3163          agent_key_expires_at=state.get("agent_key_expires_at"),
3164          min_key_ttl_seconds=min_key_ttl_seconds,
3165          timeout_seconds=timeout_seconds,
3166          insecure=tls.get("insecure"),
3167          ca_bundle=tls.get("ca_bundle"),
3168          force_refresh=force_refresh,
3169          force_mint=force_mint,
3170      )
3171  
3172  
3173  NOUS_DEVICE_CODE_SOURCE = "device_code"
3174  
3175  
3176  def persist_nous_credentials(
3177      creds: Dict[str, Any],
3178      *,
3179      label: Optional[str] = None,
3180  ):
3181      """Persist minted Nous OAuth credentials as the singleton provider state
3182      and ensure the credential pool is in sync.
3183  
3184      Nous credentials are read at runtime from two independent locations:
3185  
3186      - ``providers.nous``: singleton state read by
3187        ``resolve_nous_runtime_credentials()`` during 401 recovery and by
3188        ``_seed_from_singletons()`` during pool load.
3189      - ``credential_pool.nous``: used by the runtime ``pool.select()`` path.
3190  
3191      Historically ``hermes auth add nous`` wrote a ``manual:device_code`` pool
3192      entry only, skipping ``providers.nous``.  When the 24h agent_key TTL
3193      expired, the recovery path read the empty singleton state and raised
3194      ``AuthError`` silently (``logger.debug`` at INFO level).
3195  
3196      This helper writes ``providers.nous`` then calls ``load_pool("nous")`` so
3197      ``_seed_from_singletons`` materialises the canonical ``device_code`` pool
3198      entry from the singleton.  Re-running login upserts the same entry in
3199      place; the pool never accumulates duplicate device_code rows.
3200  
3201      ``label`` is an optional user-chosen display name (from
3202      ``hermes auth add nous --label <name>``).  It gets embedded in the
3203      singleton state so that ``_seed_from_singletons`` uses it as the pool
3204      entry's label on every subsequent ``load_pool("nous")`` instead of the
3205      auto-derived token fingerprint.  When ``None``, the auto-derived label
3206      via ``label_from_token`` is used (unchanged default behaviour).
3207  
3208      Returns the upserted :class:`PooledCredential` entry (or ``None`` if
3209      seeding somehow produced no match — shouldn't happen).
3210      """
3211      from agent.credential_pool import load_pool
3212  
3213      state = dict(creds)
3214      if label and str(label).strip():
3215          state["label"] = str(label).strip()
3216  
3217      with _auth_store_lock():
3218          auth_store = _load_auth_store()
3219          _save_provider_state(auth_store, "nous", state)
3220          _save_auth_store(auth_store)
3221  
3222      # Mirror to the shared store so a new profile can one-tap import
3223      # these credentials via `hermes auth add nous --type oauth`. Best-
3224      # effort: any I/O failure is logged and swallowed (the per-profile
3225      # auth.json is still the source of truth).
3226      _write_shared_nous_state(state)
3227  
3228      pool = load_pool("nous")
3229      return next(
3230          (e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE),
3231          None,
3232      )
3233  
3234  
3235  def resolve_nous_runtime_credentials(
3236      *,
3237      min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
3238      timeout_seconds: float = 15.0,
3239      insecure: Optional[bool] = None,
3240      ca_bundle: Optional[str] = None,
3241      force_mint: bool = False,
3242  ) -> Dict[str, Any]:
3243      """
3244      Resolve Nous inference credentials for runtime use.
3245  
3246      Ensures access_token is valid (refreshes if needed) and a short-lived
3247      inference key is present with minimum TTL (mints/reuses as needed).
3248      Concurrent processes coordinate through the auth store file lock.
3249  
3250      Returns dict with: provider, base_url, api_key, key_id, expires_at,
3251      expires_in, source ("cache" or "portal").
3252      """
3253      min_key_ttl_seconds = max(60, int(min_key_ttl_seconds))
3254      sequence_id = uuid.uuid4().hex[:12]
3255  
3256      with _auth_store_lock():
3257          auth_store = _load_auth_store()
3258          state = _load_provider_state(auth_store, "nous")
3259  
3260          if not state:
3261              raise AuthError("Hermes is not logged into Nous Portal.",
3262                              provider="nous", relogin_required=True)
3263  
3264          portal_base_url = (
3265              _optional_base_url(state.get("portal_base_url"))
3266              or os.getenv("HERMES_PORTAL_BASE_URL")
3267              or os.getenv("NOUS_PORTAL_BASE_URL")
3268              or DEFAULT_NOUS_PORTAL_URL
3269          ).rstrip("/")
3270          inference_base_url = (
3271              _optional_base_url(state.get("inference_base_url"))
3272              or os.getenv("NOUS_INFERENCE_BASE_URL")
3273              or DEFAULT_NOUS_INFERENCE_URL
3274          ).rstrip("/")
3275          client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
3276  
3277          def _persist_state(reason: str) -> None:
3278              try:
3279                  _save_provider_state(auth_store, "nous", state)
3280                  _save_auth_store(auth_store)
3281              except Exception as exc:
3282                  _oauth_trace(
3283                      "nous_state_persist_failed",
3284                      sequence_id=sequence_id,
3285                      reason=reason,
3286                      error_type=type(exc).__name__,
3287                  )
3288                  raise
3289              _oauth_trace(
3290                  "nous_state_persisted",
3291                  sequence_id=sequence_id,
3292                  reason=reason,
3293                  refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
3294                  access_token_fp=_token_fingerprint(state.get("access_token")),
3295              )
3296              # Mirror post-refresh state to the shared store so sibling
3297              # profiles don't hold stale refresh_tokens after rotation.
3298              # Best-effort — any failure is logged and swallowed inside
3299              # _write_shared_nous_state.
3300              _write_shared_nous_state(state)
3301  
3302          verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
3303          timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
3304          _oauth_trace(
3305              "nous_runtime_credentials_start",
3306              sequence_id=sequence_id,
3307              force_mint=bool(force_mint),
3308              min_key_ttl_seconds=min_key_ttl_seconds,
3309              refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
3310          )
3311  
3312          with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
3313              access_token = state.get("access_token")
3314              refresh_token = state.get("refresh_token")
3315  
3316              if not isinstance(access_token, str) or not access_token:
3317                  raise AuthError("No access token found for Nous Portal login.",
3318                                  provider="nous", relogin_required=True)
3319  
3320              # Step 1: refresh access token if expiring
3321              if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS):
3322                  if not isinstance(refresh_token, str) or not refresh_token:
3323                      raise AuthError("Session expired and no refresh token is available.",
3324                                      provider="nous", relogin_required=True)
3325  
3326                  _oauth_trace(
3327                      "refresh_start",
3328                      sequence_id=sequence_id,
3329                      reason="access_expiring",
3330                      refresh_token_fp=_token_fingerprint(refresh_token),
3331                  )
3332                  refreshed = _refresh_access_token(
3333                      client=client, portal_base_url=portal_base_url,
3334                      client_id=client_id, refresh_token=refresh_token,
3335                  )
3336                  now = datetime.now(timezone.utc)
3337                  access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
3338                  previous_refresh_token = refresh_token
3339                  state["access_token"] = refreshed["access_token"]
3340                  state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
3341                  state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
3342                  state["scope"] = refreshed.get("scope") or state.get("scope")
3343                  refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
3344                  if refreshed_url:
3345                      inference_base_url = refreshed_url
3346                  state["obtained_at"] = now.isoformat()
3347                  state["expires_in"] = access_ttl
3348                  state["expires_at"] = datetime.fromtimestamp(
3349                      now.timestamp() + access_ttl, tz=timezone.utc
3350                  ).isoformat()
3351                  access_token = state["access_token"]
3352                  refresh_token = state["refresh_token"]
3353                  _oauth_trace(
3354                      "refresh_success",
3355                      sequence_id=sequence_id,
3356                      reason="access_expiring",
3357                      previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
3358                      new_refresh_token_fp=_token_fingerprint(refresh_token),
3359                  )
3360                  # Persist immediately so downstream mint failures cannot drop rotated refresh tokens.
3361                  _persist_state("post_refresh_access_expiring")
3362  
3363              # Step 2: mint agent key if missing/expiring
3364              used_cached_key = False
3365              mint_payload: Optional[Dict[str, Any]] = None
3366  
3367              if not force_mint and _agent_key_is_usable(state, min_key_ttl_seconds):
3368                  used_cached_key = True
3369                  _oauth_trace("agent_key_reuse", sequence_id=sequence_id)
3370              else:
3371                  try:
3372                      _oauth_trace(
3373                          "mint_start",
3374                          sequence_id=sequence_id,
3375                          access_token_fp=_token_fingerprint(access_token),
3376                      )
3377                      mint_payload = _mint_agent_key(
3378                          client=client, portal_base_url=portal_base_url,
3379                          access_token=access_token, min_ttl_seconds=min_key_ttl_seconds,
3380                      )
3381                  except AuthError as exc:
3382                      _oauth_trace(
3383                          "mint_error",
3384                          sequence_id=sequence_id,
3385                          code=exc.code,
3386                      )
3387                      # Retry path: access token may be stale server-side despite local checks
3388                      latest_refresh_token = state.get("refresh_token")
3389                      if (
3390                          exc.code in {"invalid_token", "invalid_grant"}
3391                          and isinstance(latest_refresh_token, str)
3392                          and latest_refresh_token
3393                      ):
3394                          _oauth_trace(
3395                              "refresh_start",
3396                              sequence_id=sequence_id,
3397                              reason="mint_retry_after_invalid_token",
3398                              refresh_token_fp=_token_fingerprint(latest_refresh_token),
3399                          )
3400                          refreshed = _refresh_access_token(
3401                              client=client, portal_base_url=portal_base_url,
3402                              client_id=client_id, refresh_token=latest_refresh_token,
3403                          )
3404                          now = datetime.now(timezone.utc)
3405                          access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
3406                          state["access_token"] = refreshed["access_token"]
3407                          state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token
3408                          state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
3409                          state["scope"] = refreshed.get("scope") or state.get("scope")
3410                          refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
3411                          if refreshed_url:
3412                              inference_base_url = refreshed_url
3413                          state["obtained_at"] = now.isoformat()
3414                          state["expires_in"] = access_ttl
3415                          state["expires_at"] = datetime.fromtimestamp(
3416                              now.timestamp() + access_ttl, tz=timezone.utc
3417                          ).isoformat()
3418                          access_token = state["access_token"]
3419                          refresh_token = state["refresh_token"]
3420                          _oauth_trace(
3421                              "refresh_success",
3422                              sequence_id=sequence_id,
3423                              reason="mint_retry_after_invalid_token",
3424                              previous_refresh_token_fp=_token_fingerprint(latest_refresh_token),
3425                              new_refresh_token_fp=_token_fingerprint(refresh_token),
3426                          )
3427                          # Persist retry refresh immediately for crash safety and cross-process visibility.
3428                          _persist_state("post_refresh_mint_retry")
3429  
3430                          mint_payload = _mint_agent_key(
3431                              client=client, portal_base_url=portal_base_url,
3432                              access_token=access_token, min_ttl_seconds=min_key_ttl_seconds,
3433                          )
3434                      else:
3435                          raise
3436  
3437              if mint_payload is not None:
3438                  now = datetime.now(timezone.utc)
3439                  state["agent_key"] = mint_payload.get("api_key")
3440                  state["agent_key_id"] = mint_payload.get("key_id")
3441                  state["agent_key_expires_at"] = mint_payload.get("expires_at")
3442                  state["agent_key_expires_in"] = mint_payload.get("expires_in")
3443                  state["agent_key_reused"] = bool(mint_payload.get("reused", False))
3444                  state["agent_key_obtained_at"] = now.isoformat()
3445                  minted_url = _optional_base_url(mint_payload.get("inference_base_url"))
3446                  if minted_url:
3447                      inference_base_url = minted_url
3448                  _oauth_trace(
3449                      "mint_success",
3450                      sequence_id=sequence_id,
3451                      reused=bool(mint_payload.get("reused", False)),
3452                  )
3453  
3454              # Persist routing and TLS metadata for non-interactive refresh/mint
3455              state["portal_base_url"] = portal_base_url
3456              state["inference_base_url"] = inference_base_url
3457              state["client_id"] = client_id
3458              state["tls"] = {
3459                  "insecure": verify is False,
3460                  "ca_bundle": verify if isinstance(verify, str) else None,
3461              }
3462  
3463          _persist_state("resolve_nous_runtime_credentials_final")
3464  
3465      api_key = state.get("agent_key")
3466      if not isinstance(api_key, str) or not api_key:
3467          raise AuthError("Failed to resolve a Nous inference API key",
3468                          provider="nous", code="server_error")
3469  
3470      expires_at = state.get("agent_key_expires_at")
3471      expires_epoch = _parse_iso_timestamp(expires_at)
3472      expires_in = (
3473          max(0, int(expires_epoch - time.time()))
3474          if expires_epoch is not None
3475          else _coerce_ttl_seconds(state.get("agent_key_expires_in"))
3476      )
3477  
3478      return {
3479          "provider": "nous",
3480          "base_url": inference_base_url,
3481          "api_key": api_key,
3482          "key_id": state.get("agent_key_id"),
3483          "expires_at": expires_at,
3484          "expires_in": expires_in,
3485          "source": "cache" if used_cached_key else "portal",
3486      }
3487  
3488  
3489  # =============================================================================
3490  # Status helpers
3491  # =============================================================================
3492  
3493  def _empty_nous_auth_status() -> Dict[str, Any]:
3494      return {
3495          "logged_in": False,
3496          "portal_base_url": None,
3497          "inference_base_url": None,
3498          "access_expires_at": None,
3499          "agent_key_expires_at": None,
3500          "has_refresh_token": False,
3501      }
3502  
3503  
3504  def _snapshot_nous_pool_status() -> Dict[str, Any]:
3505      """Best-effort status from the credential pool.
3506  
3507      This is a fallback only. The auth-store provider state is the runtime source
3508      of truth because it is what ``resolve_nous_runtime_credentials()`` refreshes
3509      and mints against.
3510      """
3511      try:
3512          from agent.credential_pool import load_pool
3513  
3514          pool = load_pool("nous")
3515          if not pool or not pool.has_credentials():
3516              return _empty_nous_auth_status()
3517  
3518          entries = list(pool.entries())
3519          if not entries:
3520              return _empty_nous_auth_status()
3521  
3522          def _entry_sort_key(entry: Any) -> tuple[float, float, int]:
3523              agent_exp = _parse_iso_timestamp(getattr(entry, "agent_key_expires_at", None)) or 0.0
3524              access_exp = _parse_iso_timestamp(getattr(entry, "expires_at", None)) or 0.0
3525              priority = int(getattr(entry, "priority", 0) or 0)
3526              return (agent_exp, access_exp, -priority)
3527  
3528          entry = max(entries, key=_entry_sort_key)
3529          access_token = (
3530              getattr(entry, "access_token", None)
3531              or getattr(entry, "runtime_api_key", "")
3532          )
3533          if not access_token:
3534              return _empty_nous_auth_status()
3535  
3536          return {
3537              "logged_in": True,
3538              "portal_base_url": getattr(entry, "portal_base_url", None)
3539              or getattr(entry, "base_url", None),
3540              "inference_base_url": getattr(entry, "inference_base_url", None)
3541              or getattr(entry, "base_url", None),
3542              "access_token": access_token,
3543              "access_expires_at": getattr(entry, "expires_at", None),
3544              "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
3545              "has_refresh_token": bool(getattr(entry, "refresh_token", None)),
3546              "source": f"pool:{getattr(entry, 'label', 'unknown')}",
3547          }
3548      except Exception:
3549          return _empty_nous_auth_status()
3550  
3551  
3552  def get_nous_auth_status() -> Dict[str, Any]:
3553      """Status snapshot for Nous auth.
3554  
3555      Prefer the auth-store provider state, because that is the live source of
3556      truth for refresh + mint operations. When provider state exists, validate it
3557      by resolving runtime credentials so revoked refresh sessions do not show up
3558      as a healthy login. If provider state is absent, fall back to the credential
3559      pool for the just-logged-in / not-yet-promoted case.
3560      """
3561      state = get_provider_auth_state("nous")
3562      if state:
3563          base_status = {
3564              "logged_in": bool(state.get("access_token")),
3565              "portal_base_url": state.get("portal_base_url"),
3566              "inference_base_url": state.get("inference_base_url"),
3567              "access_expires_at": state.get("expires_at"),
3568              "agent_key_expires_at": state.get("agent_key_expires_at"),
3569              "has_refresh_token": bool(state.get("refresh_token")),
3570              "access_token": state.get("access_token"),
3571              "source": "auth_store",
3572          }
3573          try:
3574              creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=60)
3575              refreshed_state = get_provider_auth_state("nous") or state
3576              base_status.update(
3577                  {
3578                      "logged_in": True,
3579                      "portal_base_url": refreshed_state.get("portal_base_url") or base_status.get("portal_base_url"),
3580                      "inference_base_url": creds.get("base_url")
3581                      or refreshed_state.get("inference_base_url")
3582                      or base_status.get("inference_base_url"),
3583                      "access_expires_at": refreshed_state.get("expires_at") or base_status.get("access_expires_at"),
3584                      "agent_key_expires_at": creds.get("expires_at")
3585                      or refreshed_state.get("agent_key_expires_at")
3586                      or base_status.get("agent_key_expires_at"),
3587                      "has_refresh_token": bool(refreshed_state.get("refresh_token")),
3588                      "source": f"runtime:{creds.get('source', 'portal')}",
3589                      "key_id": creds.get("key_id"),
3590                  }
3591              )
3592              return base_status
3593          except AuthError as exc:
3594              base_status.update({
3595                  "logged_in": False,
3596                  "error": str(exc),
3597                  "relogin_required": bool(getattr(exc, "relogin_required", False)),
3598                  "error_code": getattr(exc, "code", None),
3599              })
3600              return base_status
3601  
3602      return _snapshot_nous_pool_status()
3603  
3604  
3605  def get_codex_auth_status() -> Dict[str, Any]:
3606      """Status snapshot for Codex auth.
3607      
3608      Checks the credential pool first (where `hermes auth` stores credentials),
3609      then falls back to the legacy provider state.
3610      """
3611      # Check credential pool first — this is where `hermes auth` and
3612      # `hermes model` store device_code tokens.
3613      try:
3614          from agent.credential_pool import load_pool
3615          pool = load_pool("openai-codex")
3616          if pool and pool.has_credentials():
3617              entry = pool.select()
3618              if entry is not None:
3619                  api_key = (
3620                      getattr(entry, "runtime_api_key", None)
3621                      or getattr(entry, "access_token", "")
3622                  )
3623                  if api_key and not _codex_access_token_is_expiring(api_key, 0):
3624                      return {
3625                          "logged_in": True,
3626                          "auth_store": str(_auth_file_path()),
3627                          "last_refresh": getattr(entry, "last_refresh", None),
3628                          "auth_mode": "chatgpt",
3629                          "source": f"pool:{getattr(entry, 'label', 'unknown')}",
3630                          "api_key": api_key,
3631                      }
3632      except Exception:
3633          pass
3634  
3635      # Fall back to legacy provider state
3636      try:
3637          creds = resolve_codex_runtime_credentials()
3638          return {
3639              "logged_in": True,
3640              "auth_store": str(_auth_file_path()),
3641              "last_refresh": creds.get("last_refresh"),
3642              "auth_mode": creds.get("auth_mode"),
3643              "source": creds.get("source"),
3644              "api_key": creds.get("api_key"),
3645          }
3646      except AuthError as exc:
3647          return {
3648              "logged_in": False,
3649              "auth_store": str(_auth_file_path()),
3650              "error": str(exc),
3651          }
3652  
3653  
3654  def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]:
3655      """Status snapshot for API-key providers (z.ai, Kimi, MiniMax)."""
3656      pconfig = PROVIDER_REGISTRY.get(provider_id)
3657      if not pconfig or pconfig.auth_type != "api_key":
3658          return {"configured": False}
3659  
3660      api_key = ""
3661      key_source = ""
3662      api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
3663  
3664      env_url = ""
3665      if pconfig.base_url_env_var:
3666          env_url = os.getenv(pconfig.base_url_env_var, "").strip()
3667  
3668      if provider_id in ("kimi-coding", "kimi-coding-cn"):
3669          base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url)
3670      elif env_url:
3671          base_url = env_url
3672      else:
3673          base_url = pconfig.inference_base_url
3674  
3675      return {
3676          "configured": bool(api_key),
3677          "provider": provider_id,
3678          "name": pconfig.name,
3679          "key_source": key_source,
3680          "base_url": base_url,
3681          "logged_in": bool(api_key),  # compat with OAuth status shape
3682      }
3683  
3684  
3685  def _external_process_command_spec(provider_id: str, pconfig: ProviderConfig) -> Dict[str, Any]:
3686      """Resolve command/args/base-url conventions for subprocess-backed providers."""
3687      if provider_id == "opencode-kimi-oauth":
3688          extra = pconfig.extra or {}
3689          command = (
3690              os.getenv(str(extra.get("command_env_var") or ""), "").strip()
3691              or os.getenv(str(extra.get("path_env_var") or ""), "").strip()
3692              or str(extra.get("default_command") or "opencode")
3693          )
3694          raw_args = os.getenv(str(extra.get("args_env_var") or ""), "").strip()
3695          default_args = list(extra.get("default_args") or ["acp"])
3696          args = shlex.split(raw_args) if raw_args else default_args
3697          base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
3698          if not base_url:
3699              base_url = pconfig.inference_base_url
3700          return {
3701              "command": command,
3702              "args": args,
3703              "base_url": base_url,
3704              "auth_provider": str(extra.get("auth_provider") or "kimi-for-coding-oauth"),
3705              "missing_command_message": str(extra.get("missing_command_message") or "Could not find OpenCode CLI."),
3706              "missing_auth_message": str(extra.get("missing_auth_message") or "OpenCode OAuth credentials were not found."),
3707          }
3708  
3709      command = (
3710          os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
3711          or os.getenv("COPILOT_CLI_PATH", "").strip()
3712          or "copilot"
3713      )
3714      raw_args = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
3715      args = shlex.split(raw_args) if raw_args else ["--acp", "--stdio"]
3716      base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else ""
3717      if not base_url:
3718          base_url = pconfig.inference_base_url
3719      return {
3720          "command": command,
3721          "args": args,
3722          "base_url": base_url,
3723          "auth_provider": "",
3724          "missing_command_message": (
3725              f"Could not find the Copilot CLI command '{command}'. "
3726              "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH."
3727          ),
3728          "missing_auth_message": "",
3729      }
3730  
3731  
3732  def _opencode_kimi_oauth_logged_in(command: str, auth_provider: str) -> bool:
3733      """Return True when OpenCode reports the Kimi OAuth credential.
3734  
3735      This intentionally shells out to `opencode auth list` instead of parsing
3736      ~/.local/share/opencode/auth.json so Hermes never reads or logs OAuth token
3737      material. The command output lists provider ids only.
3738      """
3739      if not command:
3740          return False
3741      try:
3742          proc = subprocess.run(
3743              [command, "auth", "list"],
3744              capture_output=True,
3745              text=True,
3746              timeout=5,
3747              check=False,
3748          )
3749      except Exception:
3750          return False
3751      if proc.returncode != 0:
3752          return False
3753      output = f"{proc.stdout or ''}\n{proc.stderr or ''}"
3754      return auth_provider in output
3755  
3756  
3757  def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]:
3758      """Status snapshot for providers that run a local subprocess."""
3759      pconfig = PROVIDER_REGISTRY.get(provider_id)
3760      if not pconfig or pconfig.auth_type != "external_process":
3761          return {"configured": False}
3762  
3763      spec = _external_process_command_spec(provider_id, pconfig)
3764      command = str(spec.get("command") or "")
3765      args = list(spec.get("args") or [])
3766      base_url = str(spec.get("base_url") or "")
3767  
3768      resolved_command = shutil.which(command) if command else None
3769      command_available = bool(resolved_command or base_url.startswith("acp+tcp://"))
3770      logged_in = command_available
3771      auth_provider = str(spec.get("auth_provider") or "")
3772      if provider_id == "opencode-kimi-oauth" and not base_url.startswith("acp+tcp://"):
3773          logged_in = bool(resolved_command and _opencode_kimi_oauth_logged_in(resolved_command, auth_provider))
3774  
3775      return {
3776          "configured": bool(command_available and logged_in),
3777          "provider": provider_id,
3778          "name": pconfig.name,
3779          "command": command,
3780          "args": args,
3781          "resolved_command": resolved_command,
3782          "base_url": base_url,
3783          "logged_in": bool(logged_in),
3784      }
3785  
3786  
3787  def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
3788      """Generic auth status dispatcher."""
3789      target = provider_id or get_active_provider()
3790      if target == "spotify":
3791          return get_spotify_auth_status()
3792      if target == "nous":
3793          return get_nous_auth_status()
3794      if target == "openai-codex":
3795          return get_codex_auth_status()
3796      if target == "qwen-oauth":
3797          return get_qwen_auth_status()
3798      if target == "google-gemini-cli":
3799          return get_gemini_oauth_auth_status()
3800      pconfig = PROVIDER_REGISTRY.get(target)
3801      if pconfig and pconfig.auth_type == "external_process":
3802          return get_external_process_provider_status(target)
3803      # API-key providers
3804      if pconfig and pconfig.auth_type == "api_key":
3805          return get_api_key_provider_status(target)
3806      # AWS SDK providers (Bedrock) — check via boto3 credential chain
3807      if pconfig and pconfig.auth_type == "aws_sdk":
3808          try:
3809              from agent.bedrock_adapter import has_aws_credentials
3810              return {"logged_in": has_aws_credentials(), "provider": target}
3811          except ImportError:
3812              return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
3813      return {"logged_in": False}
3814  
3815  
3816  def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
3817      """Resolve API key and base URL for an API-key provider.
3818  
3819      Returns dict with: provider, api_key, base_url, source.
3820      """
3821      pconfig = PROVIDER_REGISTRY.get(provider_id)
3822      if not pconfig or pconfig.auth_type != "api_key":
3823          raise AuthError(
3824              f"Provider '{provider_id}' is not an API-key provider.",
3825              provider=provider_id,
3826              code="invalid_provider",
3827          )
3828  
3829      api_key = ""
3830      key_source = ""
3831      api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig)
3832  
3833      # No-auth LM Studio: substitute a placeholder so runtime / auxiliary_client
3834      # see the local server as configured. doctor still reports unconfigured
3835      # because get_api_key_provider_status uses the raw secret resolver.
3836      if not api_key and provider_id == "lmstudio":
3837          api_key = LMSTUDIO_NOAUTH_PLACEHOLDER
3838          key_source = key_source or "default"
3839  
3840      env_url = ""
3841      if pconfig.base_url_env_var:
3842          env_url = os.getenv(pconfig.base_url_env_var, "").strip()
3843  
3844      if provider_id in ("kimi-coding", "kimi-coding-cn"):
3845          base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url)
3846      elif provider_id == "zai":
3847          base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url)
3848      elif env_url:
3849          base_url = env_url.rstrip("/")
3850      else:
3851          base_url = pconfig.inference_base_url
3852  
3853      return {
3854          "provider": provider_id,
3855          "api_key": api_key,
3856          "base_url": base_url.rstrip("/"),
3857          "source": key_source or "default",
3858      }
3859  
3860  
3861  def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]:
3862      """Resolve runtime details for local subprocess-backed providers."""
3863      pconfig = PROVIDER_REGISTRY.get(provider_id)
3864      if not pconfig or pconfig.auth_type != "external_process":
3865          raise AuthError(
3866              f"Provider '{provider_id}' is not an external-process provider.",
3867              provider=provider_id,
3868              code="invalid_provider",
3869          )
3870  
3871      spec = _external_process_command_spec(provider_id, pconfig)
3872      command = str(spec.get("command") or "")
3873      args = list(spec.get("args") or [])
3874      base_url = str(spec.get("base_url") or pconfig.inference_base_url or "")
3875      resolved_command = shutil.which(command) if command else None
3876      if not resolved_command and not base_url.startswith("acp+tcp://"):
3877          code = "missing_opencode_cli" if provider_id == "opencode-kimi-oauth" else "missing_copilot_cli"
3878          raise AuthError(
3879              str(spec.get("missing_command_message") or f"Could not find command '{command}'."),
3880              provider=provider_id,
3881              code=code,
3882          )
3883  
3884      auth_provider = str(spec.get("auth_provider") or "")
3885      if (
3886          provider_id == "opencode-kimi-oauth"
3887          and not base_url.startswith("acp+tcp://")
3888          and not _opencode_kimi_oauth_logged_in(resolved_command or command, auth_provider)
3889      ):
3890          raise AuthError(
3891              str(spec.get("missing_auth_message") or "OpenCode OAuth credentials were not found."),
3892              provider=provider_id,
3893              code="missing_opencode_kimi_oauth",
3894          )
3895  
3896      return {
3897          "provider": provider_id,
3898          "api_key": "***" if provider_id == "opencode-kimi-oauth" else provider_id,
3899          "base_url": base_url.rstrip("/"),
3900          "command": resolved_command or command,
3901          "args": args,
3902          "source": "process",
3903      }
3904  
3905  
3906  # =============================================================================
3907  # CLI Commands — login / logout
3908  # =============================================================================
3909  
3910  def _update_config_for_provider(
3911      provider_id: str,
3912      inference_base_url: str,
3913      default_model: Optional[str] = None,
3914  ) -> Path:
3915      """Update config.yaml and auth.json to reflect the active provider.
3916  
3917      When *default_model* is provided the function also writes it as the
3918      ``model.default`` value.  This prevents a race condition where the
3919      gateway (which re-reads config per-message) picks up the new provider
3920      before the caller has finished model selection, resulting in a
3921      mismatched model/provider (e.g. ``anthropic/claude-opus-4.6`` sent to
3922      MiniMax's API).
3923      """
3924      # Set active_provider in auth.json so auto-resolution picks this provider
3925      with _auth_store_lock():
3926          auth_store = _load_auth_store()
3927          auth_store["active_provider"] = provider_id
3928          _save_auth_store(auth_store)
3929  
3930      # Update config.yaml model section
3931      config_path = get_config_path()
3932      config_path.parent.mkdir(parents=True, exist_ok=True)
3933  
3934      config = read_raw_config()
3935  
3936      current_model = config.get("model")
3937      if isinstance(current_model, dict):
3938          model_cfg = dict(current_model)
3939      elif isinstance(current_model, str) and current_model.strip():
3940          model_cfg = {"default": current_model.strip()}
3941      else:
3942          model_cfg = {}
3943  
3944      model_cfg["provider"] = provider_id
3945      if inference_base_url and inference_base_url.strip():
3946          model_cfg["base_url"] = inference_base_url.rstrip("/")
3947      else:
3948          # Clear stale base_url to prevent contamination when switching providers
3949          model_cfg.pop("base_url", None)
3950  
3951      # Clear stale api_key/api_mode left over from a previous custom provider.
3952      # When the user switches from e.g. a MiniMax custom endpoint
3953      # (api_mode=anthropic_messages, api_key=mxp-...) to a built-in provider
3954      # (e.g. OpenRouter), the stale api_key/api_mode would override the new
3955      # provider's credentials and transport choice.  Built-in providers that
3956      # need a specific api_mode (copilot, xai) set it at request-resolution
3957      # time via `_copilot_runtime_api_mode` / `_detect_api_mode_for_url`, so
3958      # removing the persisted value here is safe.
3959      model_cfg.pop("api_key", None)
3960      model_cfg.pop("api_mode", None)
3961  
3962      # When switching to a non-OpenRouter provider, ensure model.default is
3963      # valid for the new provider.  An OpenRouter-formatted name like
3964      # "anthropic/claude-opus-4.6" will fail on direct-API providers.
3965      if default_model:
3966          cur_default = model_cfg.get("default", "")
3967          if not cur_default or "/" in cur_default:
3968              model_cfg["default"] = default_model
3969  
3970      config["model"] = model_cfg
3971  
3972      atomic_yaml_write(config_path, config, sort_keys=False)
3973      return config_path
3974  
3975  
3976  def _get_config_provider() -> Optional[str]:
3977      """Return model.provider from config.yaml, normalized, if present."""
3978      try:
3979          config = read_raw_config()
3980      except Exception:
3981          return None
3982      if not config:
3983          return None
3984      model = config.get("model")
3985      if not isinstance(model, dict):
3986          return None
3987      provider = model.get("provider")
3988      if not isinstance(provider, str):
3989          return None
3990      provider = provider.strip().lower()
3991      return provider or None
3992  
3993  
3994  def _config_provider_matches(provider_id: Optional[str]) -> bool:
3995      """Return True when config.yaml currently selects *provider_id*."""
3996      if not provider_id:
3997          return False
3998      return _get_config_provider() == provider_id.strip().lower()
3999  
4000  
4001  def _logout_default_provider_from_config() -> Optional[str]:
4002      """Fallback logout target when auth.json has no active provider.
4003  
4004      `hermes logout` historically keyed off auth.json.active_provider only.
4005      That left users stuck when auth state had already been cleared but
4006      config.yaml still selected an OAuth provider such as openai-codex for the
4007      agent model: there was no active auth provider to target, so logout printed
4008      "No provider is currently logged in" and never reset model.provider.
4009      """
4010      provider = _get_config_provider()
4011      if provider in {"nous", "openai-codex"}:
4012          return provider
4013      return None
4014  
4015  
4016  def _reset_config_provider() -> Path:
4017      """Reset config.yaml provider back to auto after logout."""
4018      config_path = get_config_path()
4019      if not config_path.exists():
4020          return config_path
4021  
4022      config = read_raw_config()
4023      if not config:
4024          return config_path
4025  
4026      model = config.get("model")
4027      if isinstance(model, dict):
4028          model["provider"] = "auto"
4029          if "base_url" in model:
4030              model["base_url"] = OPENROUTER_BASE_URL
4031      atomic_yaml_write(config_path, config, sort_keys=False)
4032      return config_path
4033  
4034  
4035  def _prompt_model_selection(
4036      model_ids: List[str],
4037      current_model: str = "",
4038      pricing: Optional[Dict[str, Dict[str, str]]] = None,
4039      unavailable_models: Optional[List[str]] = None,
4040      portal_url: str = "",
4041  ) -> Optional[str]:
4042      """Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
4043  
4044      If *pricing* is provided (``{model_id: {prompt, completion}}``), a compact
4045      price indicator is shown next to each model in aligned columns.
4046  
4047      If *unavailable_models* is provided, those models are shown grayed out
4048      and unselectable, with an upgrade link to *portal_url*.
4049      """
4050      from hermes_cli.models import _format_price_per_mtok
4051  
4052      _unavailable = unavailable_models or []
4053  
4054      # Reorder: current model first, then the rest (deduplicated)
4055      ordered = []
4056      if current_model and current_model in model_ids:
4057          ordered.append(current_model)
4058      for mid in model_ids:
4059          if mid not in ordered:
4060              ordered.append(mid)
4061  
4062      # All models for column-width computation (selectable + unavailable)
4063      all_models = list(ordered) + list(_unavailable)
4064  
4065      # Column-aligned labels when pricing is available
4066      has_pricing = bool(pricing and any(pricing.get(m) for m in all_models))
4067      name_col = max((len(m) for m in all_models), default=0) + 2 if has_pricing else 0
4068  
4069      # Pre-compute formatted prices and dynamic column widths
4070      _price_cache: dict[str, tuple[str, str, str]] = {}
4071      price_col = 3  # minimum width
4072      cache_col = 0  # only set if any model has cache pricing
4073      has_cache = False
4074      if has_pricing:
4075          for mid in all_models:
4076              p = pricing.get(mid)  # type: ignore[union-attr]
4077              if p:
4078                  inp = _format_price_per_mtok(p.get("prompt", ""))
4079                  out = _format_price_per_mtok(p.get("completion", ""))
4080                  cache_read = p.get("input_cache_read", "")
4081                  cache = _format_price_per_mtok(cache_read) if cache_read else ""
4082                  if cache:
4083                      has_cache = True
4084              else:
4085                  inp, out, cache = "", "", ""
4086              _price_cache[mid] = (inp, out, cache)
4087              price_col = max(price_col, len(inp), len(out))
4088              cache_col = max(cache_col, len(cache))
4089          if has_cache:
4090              cache_col = max(cache_col, 5)  # minimum: "Cache" header
4091  
4092      def _label(mid):
4093          if has_pricing:
4094              inp, out, cache = _price_cache.get(mid, ("", "", ""))
4095              price_part = f" {inp:>{price_col}}  {out:>{price_col}}"
4096              if has_cache:
4097                  price_part += f"  {cache:>{cache_col}}"
4098              base = f"{mid:<{name_col}}{price_part}"
4099          else:
4100              base = mid
4101          if mid == current_model:
4102              base += "  ← currently in use"
4103          return base
4104  
4105      # Default cursor on the current model (index 0 if it was reordered to top)
4106      default_idx = 0
4107  
4108      # Build a pricing header hint for the menu title
4109      menu_title = "Select default model:"
4110      if has_pricing:
4111          # Align the header with the model column.
4112          # Each choice is "  {label}" (2 spaces) and simple_term_menu prepends
4113          # a 3-char cursor region ("-> " or "   "), so content starts at col 5.
4114          pad = " " * 5
4115          header = f"\n{pad}{'':>{name_col}} {'In':>{price_col}}  {'Out':>{price_col}}"
4116          if has_cache:
4117              header += f"  {'Cache':>{cache_col}}"
4118          menu_title += header + "  /Mtok"
4119  
4120      # ANSI escape for dim text
4121      _DIM = "\033[2m"
4122      _RESET = "\033[0m"
4123  
4124      # Try arrow-key menu first, fall back to number input
4125      try:
4126          from simple_term_menu import TerminalMenu
4127  
4128          choices = [f"  {_label(mid)}" for mid in ordered]
4129          choices.append("  Enter custom model name")
4130          choices.append("  Skip (keep current)")
4131  
4132          # Print the unavailable block BEFORE the menu via regular print().
4133          # simple_term_menu pads title lines to terminal width (causes wrapping),
4134          # so we keep the title minimal and use stdout for the static block.
4135          # clear_screen=False means our printed output stays visible above.
4136          _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
4137          if _unavailable:
4138              print(menu_title)
4139              print()
4140              for mid in _unavailable:
4141                  print(f"{_DIM}     {_label(mid)}{_RESET}")
4142              print()
4143              print(f"{_DIM}  ── Upgrade at {_upgrade_url} for paid models ──{_RESET}")
4144              print()
4145              effective_title = "Available free models:"
4146          else:
4147              effective_title = menu_title
4148  
4149          menu = TerminalMenu(
4150              choices,
4151              cursor_index=default_idx,
4152              menu_cursor="-> ",
4153              menu_cursor_style=("fg_green", "bold"),
4154              menu_highlight_style=("fg_green",),
4155              cycle_cursor=True,
4156              clear_screen=False,
4157              title=effective_title,
4158          )
4159          idx = menu.show()
4160          from hermes_cli.curses_ui import flush_stdin
4161          flush_stdin()
4162          if idx is None:
4163              return None
4164          print()
4165          if idx < len(ordered):
4166              return ordered[idx]
4167          elif idx == len(ordered):
4168              custom = input("Enter model name: ").strip()
4169              return custom if custom else None
4170          return None
4171      except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
4172          pass
4173  
4174      # Fallback: numbered list
4175      print(menu_title)
4176      num_width = len(str(len(ordered) + 2))
4177      for i, mid in enumerate(ordered, 1):
4178          print(f"  {i:>{num_width}}. {_label(mid)}")
4179      n = len(ordered)
4180      print(f"  {n + 1:>{num_width}}. Enter custom model name")
4181      print(f"  {n + 2:>{num_width}}. Skip (keep current)")
4182  
4183      if _unavailable:
4184          _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
4185          print()
4186          print(f"  {_DIM}── Unavailable models (requires paid tier — upgrade at {_upgrade_url}) ──{_RESET}")
4187          for mid in _unavailable:
4188              print(f"  {'':>{num_width}}  {_DIM}{_label(mid)}{_RESET}")
4189      print()
4190  
4191      while True:
4192          try:
4193              choice = input(f"Choice [1-{n + 2}] (default: skip): ").strip()
4194              if not choice:
4195                  return None
4196              idx = int(choice)
4197              if 1 <= idx <= n:
4198                  return ordered[idx - 1]
4199              elif idx == n + 1:
4200                  custom = input("Enter model name: ").strip()
4201                  return custom if custom else None
4202              elif idx == n + 2:
4203                  return None
4204              print(f"Please enter 1-{n + 2}")
4205          except ValueError:
4206              print("Please enter a number")
4207          except (KeyboardInterrupt, EOFError):
4208              return None
4209  
4210  
4211  def _save_model_choice(model_id: str) -> None:
4212      """Save the selected model to config.yaml (single source of truth).
4213  
4214      The model is stored in config.yaml only — NOT in .env.  This avoids
4215      conflicts in multi-agent setups where env vars would stomp each other.
4216      """
4217      from hermes_cli.config import save_config, load_config
4218  
4219      config = load_config()
4220      # Always use dict format so provider/base_url can be stored alongside
4221      if isinstance(config.get("model"), dict):
4222          config["model"]["default"] = model_id
4223      else:
4224          config["model"] = {"default": model_id}
4225      save_config(config)
4226  
4227  
4228  def login_command(args) -> None:
4229      """Deprecated: use 'hermes model' or 'hermes setup' instead."""
4230      print("The 'hermes login' command has been removed.")
4231      print("Use 'hermes auth' to manage credentials,")
4232      print("'hermes model' to select a provider, or 'hermes setup' for full setup.")
4233      raise SystemExit(0)
4234  
4235  
4236  def _login_openai_codex(
4237      args,
4238      pconfig: ProviderConfig,
4239      *,
4240      force_new_login: bool = False,
4241  ) -> None:
4242      """OpenAI Codex login via device code flow. Tokens stored in ~/.hermes/auth.json."""
4243  
4244      del args, pconfig  # kept for parity with other provider login helpers
4245  
4246      # Check for existing Hermes-owned credentials
4247      if not force_new_login:
4248          try:
4249              existing = resolve_codex_runtime_credentials()
4250              # Verify the resolved token is actually usable (not expired).
4251              # resolve_codex_runtime_credentials attempts refresh, so if we get
4252              # here the token should be valid — but double-check before telling
4253              # the user "Login successful!".
4254              _resolved_key = existing.get("api_key", "")
4255              if isinstance(_resolved_key, str) and _resolved_key and not _codex_access_token_is_expiring(_resolved_key, 60):
4256                  print("Existing Codex credentials found in Hermes auth store.")
4257                  try:
4258                      reuse = input("Use existing credentials? [Y/n]: ").strip().lower()
4259                  except (EOFError, KeyboardInterrupt):
4260                      reuse = "y"
4261                  if reuse in ("", "y", "yes"):
4262                      config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL))
4263                      print()
4264                      print("Login successful!")
4265                      print(f"  Config updated: {config_path} (model.provider=openai-codex)")
4266                      return
4267              else:
4268                  print("Existing Codex credentials are expired. Starting fresh login...")
4269          except AuthError:
4270              pass
4271  
4272      # Check for existing Codex CLI tokens we can import
4273      if not force_new_login:
4274          cli_tokens = _import_codex_cli_tokens()
4275          if cli_tokens:
4276              print("Found existing Codex CLI credentials at ~/.codex/auth.json")
4277              print("Hermes will create its own session to avoid conflicts with Codex CLI / VS Code.")
4278              try:
4279                  do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower()
4280              except (EOFError, KeyboardInterrupt):
4281                  do_import = "n"
4282              if do_import in ("y", "yes"):
4283                  _save_codex_tokens(cli_tokens)
4284                  base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL
4285                  config_path = _update_config_for_provider("openai-codex", base_url)
4286                  print()
4287                  print("Credentials imported. Note: if Codex CLI refreshes its token,")
4288                  print("Hermes will keep working independently with its own session.")
4289                  print(f"  Config updated: {config_path} (model.provider=openai-codex)")
4290                  return
4291  
4292      # Run a fresh device code flow — Hermes gets its own OAuth session
4293      print()
4294      print("Signing in to OpenAI Codex...")
4295      print("(Hermes creates its own session — won't affect Codex CLI or VS Code)")
4296      print()
4297  
4298      creds = _codex_device_code_login()
4299  
4300      # Save tokens to Hermes auth store
4301      _save_codex_tokens(creds["tokens"], creds.get("last_refresh"))
4302      config_path = _update_config_for_provider("openai-codex", creds.get("base_url", DEFAULT_CODEX_BASE_URL))
4303      print()
4304      print("Login successful!")
4305      from hermes_constants import display_hermes_home as _dhh
4306      print(f"  Auth state: {_dhh()}/auth.json")
4307      print(f"  Config updated: {config_path} (model.provider=openai-codex)")
4308  
4309  
4310  def _codex_device_code_login() -> Dict[str, Any]:
4311      """Run the OpenAI device code login flow and return credentials dict."""
4312      import time as _time
4313  
4314      issuer = "https://auth.openai.com"
4315      client_id = CODEX_OAUTH_CLIENT_ID
4316  
4317      # Step 1: Request device code
4318      try:
4319          with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
4320              resp = client.post(
4321                  f"{issuer}/api/accounts/deviceauth/usercode",
4322                  json={"client_id": client_id},
4323                  headers={"Content-Type": "application/json"},
4324              )
4325      except Exception as exc:
4326          raise AuthError(
4327              f"Failed to request device code: {exc}",
4328              provider="openai-codex", code="device_code_request_failed",
4329          )
4330  
4331      if resp.status_code != 200:
4332          raise AuthError(
4333              f"Device code request returned status {resp.status_code}.",
4334              provider="openai-codex", code="device_code_request_error",
4335          )
4336  
4337      device_data = resp.json()
4338      user_code = device_data.get("user_code", "")
4339      device_auth_id = device_data.get("device_auth_id", "")
4340      poll_interval = max(3, int(device_data.get("interval", "5")))
4341  
4342      if not user_code or not device_auth_id:
4343          raise AuthError(
4344              "Device code response missing required fields.",
4345              provider="openai-codex", code="device_code_incomplete",
4346          )
4347  
4348      # Step 2: Show user the code
4349      print("To continue, follow these steps:\n")
4350      print("  1. Open this URL in your browser:")
4351      print(f"     \033[94m{issuer}/codex/device\033[0m\n")
4352      print("  2. Enter this code:")
4353      print(f"     \033[94m{user_code}\033[0m\n")
4354      print("Waiting for sign-in... (press Ctrl+C to cancel)")
4355  
4356      # Step 3: Poll for authorization code
4357      max_wait = 15 * 60  # 15 minutes
4358      start = _time.monotonic()
4359      code_resp = None
4360  
4361      try:
4362          with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
4363              while _time.monotonic() - start < max_wait:
4364                  _time.sleep(poll_interval)
4365                  poll_resp = client.post(
4366                      f"{issuer}/api/accounts/deviceauth/token",
4367                      json={"device_auth_id": device_auth_id, "user_code": user_code},
4368                      headers={"Content-Type": "application/json"},
4369                  )
4370  
4371                  if poll_resp.status_code == 200:
4372                      code_resp = poll_resp.json()
4373                      break
4374                  elif poll_resp.status_code in (403, 404):
4375                      continue  # User hasn't completed login yet
4376                  else:
4377                      raise AuthError(
4378                          f"Device auth polling returned status {poll_resp.status_code}.",
4379                          provider="openai-codex", code="device_code_poll_error",
4380                      )
4381      except KeyboardInterrupt:
4382          print("\nLogin cancelled.")
4383          raise SystemExit(130)
4384  
4385      if code_resp is None:
4386          raise AuthError(
4387              "Login timed out after 15 minutes.",
4388              provider="openai-codex", code="device_code_timeout",
4389          )
4390  
4391      # Step 4: Exchange authorization code for tokens
4392      authorization_code = code_resp.get("authorization_code", "")
4393      code_verifier = code_resp.get("code_verifier", "")
4394      redirect_uri = f"{issuer}/deviceauth/callback"
4395  
4396      if not authorization_code or not code_verifier:
4397          raise AuthError(
4398              "Device auth response missing authorization_code or code_verifier.",
4399              provider="openai-codex", code="device_code_incomplete_exchange",
4400          )
4401  
4402      try:
4403          with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
4404              token_resp = client.post(
4405                  CODEX_OAUTH_TOKEN_URL,
4406                  data={
4407                      "grant_type": "authorization_code",
4408                      "code": authorization_code,
4409                      "redirect_uri": redirect_uri,
4410                      "client_id": client_id,
4411                      "code_verifier": code_verifier,
4412                  },
4413                  headers={"Content-Type": "application/x-www-form-urlencoded"},
4414              )
4415      except Exception as exc:
4416          raise AuthError(
4417              f"Token exchange failed: {exc}",
4418              provider="openai-codex", code="token_exchange_failed",
4419          )
4420  
4421      if token_resp.status_code != 200:
4422          raise AuthError(
4423              f"Token exchange returned status {token_resp.status_code}.",
4424              provider="openai-codex", code="token_exchange_error",
4425          )
4426  
4427      tokens = token_resp.json()
4428      access_token = tokens.get("access_token", "")
4429      refresh_token = tokens.get("refresh_token", "")
4430  
4431      if not access_token:
4432          raise AuthError(
4433              "Token exchange did not return an access_token.",
4434              provider="openai-codex", code="token_exchange_no_access_token",
4435          )
4436  
4437      # Return tokens for the caller to persist (no longer writes to ~/.codex/)
4438      base_url = (
4439          os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
4440          or DEFAULT_CODEX_BASE_URL
4441      )
4442  
4443      return {
4444          "tokens": {
4445              "access_token": access_token,
4446              "refresh_token": refresh_token,
4447          },
4448          "base_url": base_url,
4449          "last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
4450          "auth_mode": "chatgpt",
4451          "source": "device-code",
4452      }
4453  
4454  
4455  # ==================== MiniMax Portal OAuth ====================
4456  
4457  def _minimax_pkce_pair() -> tuple:
4458      """Generate (code_verifier, code_challenge_S256, state) for MiniMax OAuth."""
4459      import secrets
4460      verifier = secrets.token_urlsafe(64)[:96]
4461      challenge = base64.urlsafe_b64encode(
4462          hashlib.sha256(verifier.encode()).digest()
4463      ).decode().rstrip("=")
4464      state = secrets.token_urlsafe(16)
4465      return verifier, challenge, state
4466  
4467  
4468  def _minimax_request_user_code(
4469      client: httpx.Client, *, portal_base_url: str, client_id: str,
4470      code_challenge: str, state: str,
4471  ) -> Dict[str, Any]:
4472      response = client.post(
4473          f"{portal_base_url}/oauth/code",
4474          data={
4475              "response_type": "code",
4476              "client_id": client_id,
4477              "scope": MINIMAX_OAUTH_SCOPE,
4478              "code_challenge": code_challenge,
4479              "code_challenge_method": "S256",
4480              "state": state,
4481          },
4482          headers={
4483              "Content-Type": "application/x-www-form-urlencoded",
4484              "Accept": "application/json",
4485              "x-request-id": str(uuid.uuid4()),
4486          },
4487      )
4488      if response.status_code != 200:
4489          raise AuthError(
4490              f"MiniMax OAuth authorization failed: {response.text or response.reason_phrase}",
4491              provider="minimax-oauth", code="authorization_failed",
4492          )
4493      payload = response.json()
4494      for required_field in ("user_code", "verification_uri", "expired_in"):
4495          if required_field not in payload:
4496              raise AuthError(
4497                  f"MiniMax OAuth response missing field: {required_field}",
4498                  provider="minimax-oauth", code="authorization_incomplete",
4499              )
4500      if payload.get("state") != state:
4501          raise AuthError(
4502              "MiniMax OAuth state mismatch (possible CSRF).",
4503              provider="minimax-oauth", code="state_mismatch",
4504          )
4505      return payload
4506  
4507  
4508  def _minimax_poll_token(
4509      client: httpx.Client, *, portal_base_url: str, client_id: str,
4510      user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
4511  ) -> Dict[str, Any]:
4512      # OpenClaw treats expired_in as a unix-ms timestamp (Date.now() < expireTimeMs).
4513      # Defensive parsing: if it's small enough to be a duration, treat as seconds.
4514      import time as _time
4515      now_ms = int(_time.time() * 1000)
4516      if expired_in > now_ms // 2:
4517          # Looks like a unix-ms timestamp.
4518          deadline = expired_in / 1000.0
4519      else:
4520          # Treat as duration in seconds from now.
4521          deadline = _time.time() + max(1, expired_in)
4522      interval = max(2.0, (interval_ms or 2000) / 1000.0)
4523  
4524      while _time.time() < deadline:
4525          response = client.post(
4526              f"{portal_base_url}/oauth/token",
4527              data={
4528                  "grant_type": MINIMAX_OAUTH_GRANT_TYPE,
4529                  "client_id": client_id,
4530                  "user_code": user_code,
4531                  "code_verifier": code_verifier,
4532              },
4533              headers={
4534                  "Content-Type": "application/x-www-form-urlencoded",
4535                  "Accept": "application/json",
4536              },
4537          )
4538          try:
4539              payload = response.json() if response.text else {}
4540          except Exception:
4541              payload = {}
4542  
4543          if response.status_code != 200:
4544              msg = (payload.get("base_resp", {}) or {}).get("status_msg") or response.text
4545              raise AuthError(
4546                  f"MiniMax OAuth error: {msg or 'unknown'}",
4547                  provider="minimax-oauth", code="token_exchange_failed",
4548              )
4549  
4550          status = payload.get("status")
4551          if status == "error":
4552              raise AuthError(
4553                  "MiniMax OAuth reported an error. Please try again later.",
4554                  provider="minimax-oauth", code="authorization_denied",
4555              )
4556          if status == "success":
4557              if not all(payload.get(k) for k in ("access_token", "refresh_token", "expired_in")):
4558                  raise AuthError(
4559                      "MiniMax OAuth success payload missing required token fields.",
4560                      provider="minimax-oauth", code="token_incomplete",
4561                  )
4562              return payload
4563          # "pending" or any other status -> keep polling
4564          _time.sleep(interval)
4565  
4566      raise AuthError(
4567          "MiniMax OAuth timed out before authorization completed.",
4568          provider="minimax-oauth", code="timeout",
4569      )
4570  
4571  
4572  def _minimax_save_auth_state(auth_state: Dict[str, Any]) -> None:
4573      """Persist MiniMax OAuth state to Hermes auth store (~/.hermes/auth.json)."""
4574      with _auth_store_lock():
4575          auth_store = _load_auth_store()
4576          _save_provider_state(auth_store, "minimax-oauth", auth_state)
4577          _save_auth_store(auth_store)
4578  
4579  
4580  def _minimax_oauth_login(
4581      *, region: str = "global", open_browser: bool = True,
4582      timeout_seconds: float = 15.0,
4583  ) -> Dict[str, Any]:
4584      """Run MiniMax OAuth flow, persist tokens, return auth state dict."""
4585      pconfig = PROVIDER_REGISTRY["minimax-oauth"]
4586      if region == "cn":
4587          portal_base_url = pconfig.extra["cn_portal_base_url"]
4588          inference_base_url = pconfig.extra["cn_inference_base_url"]
4589      else:
4590          portal_base_url = pconfig.portal_base_url
4591          inference_base_url = pconfig.inference_base_url
4592  
4593      verifier, challenge, state = _minimax_pkce_pair()
4594  
4595      if _is_remote_session():
4596          open_browser = False
4597  
4598      print(f"Starting Hermes login via MiniMax ({region}) OAuth...")
4599      print(f"Portal: {portal_base_url}")
4600  
4601      with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
4602                        headers={"Accept": "application/json"},
4603                        follow_redirects=True) as client:
4604          code_data = _minimax_request_user_code(
4605              client, portal_base_url=portal_base_url,
4606              client_id=pconfig.client_id,
4607              code_challenge=challenge, state=state,
4608          )
4609          verification_url = str(code_data["verification_uri"])
4610          user_code = str(code_data["user_code"])
4611  
4612          print()
4613          print("To continue:")
4614          print(f"  1. Open: {verification_url}")
4615          print(f"  2. If prompted, enter code: {user_code}")
4616          if open_browser:
4617              if webbrowser.open(verification_url):
4618                  print("  (Opened browser for verification)")
4619              else:
4620                  print("  Could not open browser automatically -- use the URL above.")
4621  
4622          interval_raw = code_data.get("interval")
4623          interval_ms = int(interval_raw) if interval_raw is not None else None
4624          print("Waiting for approval...")
4625  
4626          token_data = _minimax_poll_token(
4627              client, portal_base_url=portal_base_url,
4628              client_id=pconfig.client_id,
4629              user_code=user_code, code_verifier=verifier,
4630              expired_in=int(code_data["expired_in"]),
4631              interval_ms=interval_ms,
4632          )
4633  
4634      now = datetime.now(timezone.utc)
4635      expires_in_s = int(token_data["expired_in"])
4636      expires_at = now.timestamp() + expires_in_s
4637  
4638      auth_state = {
4639          "provider": "minimax-oauth",
4640          "region": region,
4641          "portal_base_url": portal_base_url,
4642          "inference_base_url": inference_base_url,
4643          "client_id": pconfig.client_id,
4644          "scope": MINIMAX_OAUTH_SCOPE,
4645          "token_type": token_data.get("token_type", "Bearer"),
4646          "access_token": token_data["access_token"],
4647          "refresh_token": token_data["refresh_token"],
4648          "resource_url": token_data.get("resource_url"),
4649          "obtained_at": now.isoformat(),
4650          "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
4651          "expires_in": expires_in_s,
4652      }
4653  
4654      _minimax_save_auth_state(auth_state)
4655      print("\u2713 MiniMax OAuth login successful.")
4656      if msg := token_data.get("notification_message"):
4657          print(f"Note from MiniMax: {msg}")
4658      return auth_state
4659  
4660  
4661  def _refresh_minimax_oauth_state(
4662      state: Dict[str, Any], *, timeout_seconds: float = 15.0,
4663      force: bool = False,
4664  ) -> Dict[str, Any]:
4665      """Refresh MiniMax OAuth access token if close to expiry (or forced)."""
4666      if not state.get("refresh_token"):
4667          raise AuthError(
4668              "MiniMax OAuth state has no refresh_token; please re-login.",
4669              provider="minimax-oauth", code="no_refresh_token", relogin_required=True,
4670          )
4671      try:
4672          expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
4673      except Exception:
4674          expires_at = 0.0
4675      now = time.time()
4676      if not force and (expires_at - now) > MINIMAX_OAUTH_REFRESH_SKEW_SECONDS:
4677          return state
4678  
4679      portal_base_url = state["portal_base_url"]
4680      with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
4681                        follow_redirects=True) as client:
4682          response = client.post(
4683              f"{portal_base_url}/oauth/token",
4684              data={
4685                  "grant_type": "refresh_token",
4686                  "client_id": state["client_id"],
4687                  "refresh_token": state["refresh_token"],
4688              },
4689              headers={
4690                  "Content-Type": "application/x-www-form-urlencoded",
4691                  "Accept": "application/json",
4692              },
4693          )
4694      if response.status_code != 200:
4695          body = response.text.lower()
4696          relogin = any(m in body for m in
4697                        ("invalid_grant", "refresh_token_reused", "invalid_refresh_token"))
4698          raise AuthError(
4699              f"MiniMax OAuth refresh failed: {response.text or response.reason_phrase}",
4700              provider="minimax-oauth", code="refresh_failed",
4701              relogin_required=relogin,
4702          )
4703      payload = response.json()
4704      if payload.get("status") != "success":
4705          raise AuthError(
4706              "MiniMax OAuth refresh did not return success.",
4707              provider="minimax-oauth", code="refresh_failed",
4708              relogin_required=True,
4709          )
4710      now_dt = datetime.now(timezone.utc)
4711      expires_in_s = int(payload["expired_in"])
4712      new_state = dict(state)
4713      new_state.update({
4714          "access_token": payload["access_token"],
4715          "refresh_token": payload.get("refresh_token", state["refresh_token"]),
4716          "obtained_at": now_dt.isoformat(),
4717          "expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
4718                                               tz=timezone.utc).isoformat(),
4719          "expires_in": expires_in_s,
4720      })
4721      _minimax_save_auth_state(new_state)
4722      return new_state
4723  
4724  
4725  def resolve_minimax_oauth_runtime_credentials(
4726      *, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
4727  ) -> Dict[str, Any]:
4728      """Return {provider, api_key, base_url, source} for minimax-oauth."""
4729      state = get_provider_auth_state("minimax-oauth")
4730      if not state or not state.get("access_token"):
4731          raise AuthError(
4732              "Not logged into MiniMax OAuth. Run `hermes model` and select "
4733              "MiniMax (OAuth).",
4734              provider="minimax-oauth", code="not_logged_in", relogin_required=True,
4735          )
4736      state = _refresh_minimax_oauth_state(state)
4737      return {
4738          "provider": "minimax-oauth",
4739          "api_key": state["access_token"],
4740          "base_url": state["inference_base_url"].rstrip("/"),
4741          "source": "oauth",
4742      }
4743  
4744  
4745  def get_minimax_oauth_auth_status() -> Dict[str, Any]:
4746      """Return auth status dict for MiniMax OAuth provider."""
4747      state = get_provider_auth_state("minimax-oauth")
4748      if not state or not state.get("access_token"):
4749          return {"logged_in": False, "provider": "minimax-oauth"}
4750      try:
4751          expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
4752          token_valid = (expires_at - time.time()) > 0
4753      except Exception:
4754          token_valid = bool(state.get("access_token"))
4755      return {
4756          "logged_in": token_valid,
4757          "provider": "minimax-oauth",
4758          "region": state.get("region", "global"),
4759          "expires_at": state.get("expires_at"),
4760      }
4761  
4762  
4763  def _login_minimax_oauth(args, pconfig: ProviderConfig) -> None:
4764      """CLI entry for MiniMax OAuth login."""
4765      region = getattr(args, "region", None) or "global"
4766      open_browser = not getattr(args, "no_browser", False)
4767      timeout = getattr(args, "timeout", None) or 15.0
4768      try:
4769          _minimax_oauth_login(
4770              region=region, open_browser=open_browser, timeout_seconds=timeout,
4771          )
4772      except AuthError as exc:
4773          print(format_auth_error(exc))
4774          raise SystemExit(1)
4775  
4776  
4777  def _nous_device_code_login(
4778      *,
4779      portal_base_url: Optional[str] = None,
4780      inference_base_url: Optional[str] = None,
4781      client_id: Optional[str] = None,
4782      scope: Optional[str] = None,
4783      open_browser: bool = True,
4784      timeout_seconds: float = 15.0,
4785      insecure: bool = False,
4786      ca_bundle: Optional[str] = None,
4787      min_key_ttl_seconds: int = 5 * 60,
4788  ) -> Dict[str, Any]:
4789      """Run the Nous device-code flow and return full OAuth state without persisting."""
4790      pconfig = PROVIDER_REGISTRY["nous"]
4791      portal_base_url = (
4792          portal_base_url
4793          or os.getenv("HERMES_PORTAL_BASE_URL")
4794          or os.getenv("NOUS_PORTAL_BASE_URL")
4795          or pconfig.portal_base_url
4796      ).rstrip("/")
4797      requested_inference_url = (
4798          inference_base_url
4799          or os.getenv("NOUS_INFERENCE_BASE_URL")
4800          or pconfig.inference_base_url
4801      ).rstrip("/")
4802      client_id = client_id or pconfig.client_id
4803      scope = scope or pconfig.scope
4804      timeout = httpx.Timeout(timeout_seconds)
4805      verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
4806  
4807      if _is_remote_session():
4808          open_browser = False
4809  
4810      print(f"Starting Hermes login via {pconfig.name}...")
4811      print(f"Portal: {portal_base_url}")
4812      if insecure:
4813          print("TLS verification: disabled (--insecure)")
4814      elif ca_bundle:
4815          print(f"TLS verification: custom CA bundle ({ca_bundle})")
4816  
4817      with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
4818          device_data = _request_device_code(
4819              client=client,
4820              portal_base_url=portal_base_url,
4821              client_id=client_id,
4822              scope=scope,
4823          )
4824  
4825          verification_url = str(device_data["verification_uri_complete"])
4826          user_code = str(device_data["user_code"])
4827          expires_in = int(device_data["expires_in"])
4828          interval = int(device_data["interval"])
4829  
4830          print()
4831          print("To continue:")
4832          print(f"  1. Open: {verification_url}")
4833          print(f"  2. If prompted, enter code: {user_code}")
4834  
4835          if open_browser:
4836              opened = webbrowser.open(verification_url)
4837              if opened:
4838                  print("  (Opened browser for verification)")
4839              else:
4840                  print("  Could not open browser automatically — use the URL above.")
4841  
4842          effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
4843          print(f"Waiting for approval (polling every {effective_interval}s)...")
4844  
4845          token_data = _poll_for_token(
4846              client=client,
4847              portal_base_url=portal_base_url,
4848              client_id=client_id,
4849              device_code=str(device_data["device_code"]),
4850              expires_in=expires_in,
4851              poll_interval=interval,
4852          )
4853  
4854      now = datetime.now(timezone.utc)
4855      token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0))
4856      expires_at = now.timestamp() + token_expires_in
4857      resolved_inference_url = (
4858          _optional_base_url(token_data.get("inference_base_url"))
4859          or requested_inference_url
4860      )
4861      if resolved_inference_url != requested_inference_url:
4862          print(f"Using portal-provided inference URL: {resolved_inference_url}")
4863  
4864      auth_state = {
4865          "portal_base_url": portal_base_url,
4866          "inference_base_url": resolved_inference_url,
4867          "client_id": client_id,
4868          "scope": token_data.get("scope") or scope,
4869          "token_type": token_data.get("token_type", "Bearer"),
4870          "access_token": token_data["access_token"],
4871          "refresh_token": token_data.get("refresh_token"),
4872          "obtained_at": now.isoformat(),
4873          "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
4874          "expires_in": token_expires_in,
4875          "tls": {
4876              "insecure": verify is False,
4877              "ca_bundle": verify if isinstance(verify, str) else None,
4878          },
4879          "agent_key": None,
4880          "agent_key_id": None,
4881          "agent_key_expires_at": None,
4882          "agent_key_expires_in": None,
4883          "agent_key_reused": None,
4884          "agent_key_obtained_at": None,
4885      }
4886      try:
4887          return refresh_nous_oauth_from_state(
4888              auth_state,
4889              min_key_ttl_seconds=min_key_ttl_seconds,
4890              timeout_seconds=timeout_seconds,
4891              force_refresh=False,
4892              force_mint=True,
4893          )
4894      except AuthError as exc:
4895          if exc.code == "subscription_required":
4896              portal_url = auth_state.get(
4897                  "portal_base_url", DEFAULT_NOUS_PORTAL_URL
4898              ).rstrip("/")
4899              print()
4900              print("Your Nous Portal account does not have an active subscription.")
4901              print(f"  Subscribe here: {portal_url}/billing")
4902              print()
4903              print("After subscribing, run `hermes model` again to finish setup.")
4904              raise SystemExit(1)
4905          raise
4906  
4907  
4908  def _login_nous(args, pconfig: ProviderConfig) -> None:
4909      """Nous Portal device authorization flow."""
4910      timeout_seconds = getattr(args, "timeout", None) or 15.0
4911      insecure = bool(getattr(args, "insecure", False))
4912      ca_bundle = (
4913          getattr(args, "ca_bundle", None)
4914          or os.getenv("HERMES_CA_BUNDLE")
4915          or os.getenv("SSL_CERT_FILE")
4916      )
4917  
4918      try:
4919          auth_state = None
4920  
4921          # Codex-style auto-import: before launching a fresh device-code
4922          # flow, check the shared store for an existing Nous credential
4923          # from any other profile. If present, offer to rehydrate it.
4924          shared = _read_shared_nous_state()
4925          if shared:
4926              try:
4927                  shared_path = _nous_shared_store_path()
4928              except RuntimeError:
4929                  shared_path = None
4930              print()
4931              if shared_path:
4932                  print(f"Found existing Nous OAuth credentials at {shared_path}")
4933              else:
4934                  print("Found existing shared Nous OAuth credentials")
4935              try:
4936                  do_import = input("Import these credentials? [Y/n]: ").strip().lower()
4937              except (EOFError, KeyboardInterrupt):
4938                  do_import = "y"
4939              if do_import in ("", "y", "yes"):
4940                  print("Rehydrating Nous session from shared credentials...")
4941                  auth_state = _try_import_shared_nous_state(
4942                      timeout_seconds=timeout_seconds,
4943                      min_key_ttl_seconds=5 * 60,
4944                  )
4945                  if auth_state is None:
4946                      print("Could not refresh shared credentials — falling back to device-code login.")
4947  
4948          if auth_state is None:
4949              auth_state = _nous_device_code_login(
4950                  portal_base_url=getattr(args, "portal_url", None),
4951                  inference_base_url=getattr(args, "inference_url", None),
4952                  client_id=getattr(args, "client_id", None) or pconfig.client_id,
4953                  scope=getattr(args, "scope", None) or pconfig.scope,
4954                  open_browser=not getattr(args, "no_browser", False),
4955                  timeout_seconds=timeout_seconds,
4956                  insecure=insecure,
4957                  ca_bundle=ca_bundle,
4958                  min_key_ttl_seconds=5 * 60,
4959              )
4960  
4961          inference_base_url = auth_state["inference_base_url"]
4962  
4963          # Snapshot the prior active_provider BEFORE _save_provider_state
4964          # overwrites it to "nous".  If the user picks "Skip (keep current)"
4965          # during model selection below, we restore this so the user's previous
4966          # provider (e.g. openrouter) is preserved.
4967          with _auth_store_lock():
4968              _prior_store = _load_auth_store()
4969              prior_active_provider = _prior_store.get("active_provider")
4970  
4971          with _auth_store_lock():
4972              auth_store = _load_auth_store()
4973              _save_provider_state(auth_store, "nous", auth_state)
4974              saved_to = _save_auth_store(auth_store)
4975  
4976          # Mirror to the shared store so other profiles can one-tap import
4977          # these credentials. Best-effort: any I/O failure is logged and
4978          # swallowed inside the helper.
4979          _write_shared_nous_state(auth_state)
4980  
4981          print()
4982          print("Login successful!")
4983          print(f"  Auth state: {saved_to}")
4984  
4985          # Resolve model BEFORE writing provider to config.yaml so we never
4986          # leave the config in a half-updated state (provider=nous but model
4987          # still set to the previous provider's model, e.g. opus from
4988          # OpenRouter).  The auth.json active_provider was already set above.
4989          selected_model = None
4990          try:
4991              runtime_key = auth_state.get("agent_key") or auth_state.get("access_token")
4992              if not isinstance(runtime_key, str) or not runtime_key:
4993                  raise AuthError(
4994                      "No runtime API key available to fetch models",
4995                      provider="nous",
4996                      code="invalid_token",
4997                  )
4998  
4999              from hermes_cli.models import (
5000                  get_curated_nous_model_ids, get_pricing_for_provider,
5001                  check_nous_free_tier, partition_nous_models_by_tier,
5002              )
5003              model_ids = get_curated_nous_model_ids()
5004  
5005              print()
5006              unavailable_models: list = []
5007              if model_ids:
5008                  pricing = get_pricing_for_provider("nous")
5009                  free_tier = check_nous_free_tier()
5010                  if free_tier:
5011                      model_ids, unavailable_models = partition_nous_models_by_tier(
5012                          model_ids, pricing, free_tier=True,
5013                      )
5014              _portal = auth_state.get("portal_base_url", "")
5015              if model_ids:
5016                  print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
5017                  selected_model = _prompt_model_selection(
5018                      model_ids, pricing=pricing,
5019                      unavailable_models=unavailable_models,
5020                      portal_url=_portal,
5021                  )
5022              elif unavailable_models:
5023                  _url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
5024                  print("No free models currently available.")
5025                  print(f"Upgrade at {_url} to access paid models.")
5026              else:
5027                  print("No curated models available for Nous Portal.")
5028          except Exception as exc:
5029              message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
5030              print()
5031              print(f"Login succeeded, but could not fetch available models. Reason: {message}")
5032  
5033          # Write provider + model atomically so config is never mismatched.
5034          # If no model was selected (user picked "Skip (keep current)",
5035          # model list fetch failed, or no curated models were available),
5036          # preserve the user's previous provider — don't silently switch
5037          # them to Nous with a mismatched model.  The Nous OAuth tokens
5038          # stay saved for future use.
5039          if not selected_model:
5040              # Restore the prior active_provider that _save_provider_state
5041              # overwrote to "nous".  config.yaml model.provider is left
5042              # untouched, so the user's previous provider is fully preserved.
5043              with _auth_store_lock():
5044                  auth_store = _load_auth_store()
5045                  if prior_active_provider:
5046                      auth_store["active_provider"] = prior_active_provider
5047                  else:
5048                      auth_store.pop("active_provider", None)
5049                  _save_auth_store(auth_store)
5050              print()
5051              print("No provider change. Nous credentials saved for future use.")
5052              print("  Run `hermes model` again to switch to Nous Portal.")
5053              return
5054  
5055          config_path = _update_config_for_provider(
5056              "nous", inference_base_url, default_model=selected_model,
5057          )
5058          if selected_model:
5059              _save_model_choice(selected_model)
5060              print(f"Default model set to: {selected_model}")
5061          print(f"  Config updated: {config_path} (model.provider=nous)")
5062  
5063      except KeyboardInterrupt:
5064          print("\nLogin cancelled.")
5065          raise SystemExit(130)
5066      except Exception as exc:
5067          print(f"Login failed: {exc}")
5068          raise SystemExit(1)
5069  
5070  
5071  def logout_command(args) -> None:
5072      """Clear auth state for a provider."""
5073      provider_id = getattr(args, "provider", None)
5074  
5075      if provider_id and not is_known_auth_provider(provider_id):
5076          print(f"Unknown provider: {provider_id}")
5077          raise SystemExit(1)
5078  
5079      active = get_active_provider()
5080      target = provider_id or active or _logout_default_provider_from_config()
5081  
5082      if not target:
5083          print("No provider is currently logged in.")
5084          return
5085  
5086      config_matches = _config_provider_matches(target)
5087      provider_name = get_auth_provider_display_name(target)
5088  
5089      if clear_provider_auth(target) or config_matches:
5090          _reset_config_provider()
5091          print(f"Logged out of {provider_name}.")
5092          if os.getenv("OPENROUTER_API_KEY"):
5093              print("Hermes will use OpenRouter for inference.")
5094          else:
5095              print("Run `hermes model` or configure an API key to use Hermes.")
5096      else:
5097          print(f"No auth state found for {provider_name}.")