/ 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}.")