{"repo": "bermudalocals/hermes-agent-lite", "files": {"hermes_cli/auth.py": {"160ae748d243940671329e425cc67a3f82c2a2a0": {"t": 1785595312.5518577, "n": 166839, "h": "596f619153f60470", "c": "\"\"\"\nMulti-provider authentication system for Hermes Agent.\n\nSupports OAuth device code flows (Nous Portal, future: OpenAI Codex) and\ntraditional API key providers (OpenRouter, custom endpoints). Auth state\nis persisted in ~/.hermes/auth.json with cross-process file locking.\n\nArchitecture:\n- ProviderConfig registry defines known OAuth providers\n- Auth store (auth.json) holds per-provider credential state\n- resolve_provider() picks the active provider via priority chain\n- resolve_*_runtime_credentials() handles token refresh and key minting\n- logout_command() is the CLI entry point for clearing auth\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport shutil\nimport shlex\nimport ssl\nimport stat\nimport sys\nimport base64\nimport hashlib\nimport subprocess\nimport threading\nimport time\nimport uuid\nimport webbrowser\nfrom contextlib import contextmanager\nfrom dataclasses import dataclass, field\nfrom datetime import datetime, timezone\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\nfrom urllib.parse import parse_qs, urlencode, urlparse\n\nimport httpx\nimport yaml\n\nfrom hermes_cli.config import get_hermes_home, get_config_path, read_raw_config\nfrom hermes_constants import OPENROUTER_BASE_URL\n\nlogger = logging.getLogger(__name__)\n\ntry:\n import fcntl\nexcept Exception:\n fcntl = None\ntry:\n import msvcrt\nexcept Exception:\n msvcrt = None\n\n# =============================================================================\n# Constants\n# =============================================================================\n\nAUTH_STORE_VERSION = 1\nAUTH_LOCK_TIMEOUT_SECONDS = 15.0\n\n# Nous Portal defaults\nDEFAULT_NOUS_PORTAL_URL = \"https://portal.nousresearch.com\"\nDEFAULT_NOUS_INFERENCE_URL = \"https://inference-api.nousresearch.com/v1\"\nDEFAULT_NOUS_CLIENT_ID = \"hermes-cli\"\nDEFAULT_NOUS_SCOPE = \"inference:mint_agent_key\"\nDEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes\nACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry\nDEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s\nDEFAULT_CODEX_BASE_URL = \"https://chatgpt.com/backend-api/codex\"\nDEFAULT_QWEN_BASE_URL = \"https://portal.qwen.ai/v1\"\nDEFAULT_GITHUB_MODELS_BASE_URL = \"https://api.githubcopilot.com\"\nDEFAULT_COPILOT_ACP_BASE_URL = \"acp://copilot\"\nDEFAULT_OLLAMA_CLOUD_BASE_URL = \"https://ollama.com/v1\"\nSTEPFUN_STEP_PLAN_INTL_BASE_URL = \"https://api.stepfun.ai/step_plan/v1\"\nSTEPFUN_STEP_PLAN_CN_BASE_URL = \"https://api.stepfun.com/step_plan/v1\"\nCODEX_OAUTH_CLIENT_ID = \"app_EMoamEEZ73f0CkXaXp7hrann\"\nCODEX_OAUTH_TOKEN_URL = \"https://auth.openai.com/oauth/token\"\nCODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120\nQWEN_OAUTH_CLIENT_ID = \"f0304373b74a44d2b584a3fb70ca9e56\"\nQWEN_OAUTH_TOKEN_URL = \"https://chat.qwen.ai/api/v1/oauth2/token\"\nQWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120\nDEFAULT_SPOTIFY_ACCOUNTS_BASE_URL = \"https://accounts.spotify.com\"\nDEFAULT_SPOTIFY_API_BASE_URL = \"https://api.spotify.com/v1\"\nDEFAULT_SPOTIFY_REDIRECT_URI = \"http://127.0.0.1:43827/spotify/callback\"\nSPOTIFY_DOCS_URL = \"https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify\"\nSPOTIFY_DASHBOARD_URL = \"https://developer.spotify.com/dashboard\"\nSPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120\nDEFAULT_SPOTIFY_SCOPE = \" \".join((\n \"user-modify-playback-state\",\n \"user-read-playback-state\",\n \"user-read-currently-playing\",\n \"user-read-recently-played\",\n \"playlist-read-private\",\n \"playlist-read-collaborative\",\n \"playlist-modify-public\",\n \"playlist-modify-private\",\n \"user-library-read\",\n \"user-library-modify\",\n))\nSERVICE_PROVIDER_NAMES: Dict[str, str] = {\n \"spotify\": \"Spotify\",\n}\n\n# Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend)\nDEFAULT_GEMINI_CLOUDCODE_BASE_URL = \"cloudcode-pa://google\"\nGEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 # refresh 60s before expiry\n\n\n# =============================================================================\n# Provider Registry\n# =============================================================================\n\n@dataclass\nclass ProviderConfig:\n \"\"\"Describes a known inference provider.\"\"\"\n id: str\n name: str\n auth_type: str # \"oauth_device_code\", \"oauth_external\", or \"api_key\"\n portal_base_url: str = \"\"\n inference_base_url: str = \"\"\n client_id: str = \"\"\n scope: str = \"\"\n extra: Dict[str, Any] = field(default_factory=dict)\n # For API-key providers: env vars to check (in priority order)\n api_key_env_vars: tuple = ()\n # Optional env var for base URL override\n base_url_env_var: str = \"\"\n\n\nPROVIDER_REGISTRY: Dict[str, ProviderConfig] = {\n \"nous\": ProviderConfig(\n id=\"nous\",\n name=\"Nous Portal\",\n auth_type=\"oauth_device_code\",\n portal_base_url=DEFAULT_NOUS_PORTAL_URL,\n inference_base_url=DEFAULT_NOUS_INFERENCE_URL,\n client_id=DEFAULT_NOUS_CLIENT_ID,\n scope=DEFAULT_NOUS_SCOPE,\n ),\n \"openai-codex\": ProviderConfig(\n id=\"openai-codex\",\n name=\"OpenAI Codex\",\n auth_type=\"oauth_external\",\n inference_base_url=DEFAULT_CODEX_BASE_URL,\n ),\n \"qwen-oauth\": ProviderConfig(\n id=\"qwen-oauth\",\n name=\"Qwen OAuth\",\n auth_type=\"oauth_external\",\n inference_base_url=DEFAULT_QWEN_BASE_URL,\n ),\n \"google-gemini-cli\": ProviderConfig(\n id=\"google-gemini-cli\",\n name=\"Google Gemini (OAuth)\",\n auth_type=\"oauth_external\",\n inference_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,\n ),\n \"copilot\": ProviderConfig(\n id=\"copilot\",\n name=\"GitHub Copilot\",\n auth_type=\"api_key\",\n inference_base_url=DEFAULT_GITHUB_MODELS_BASE_URL,\n api_key_env_vars=(\"COPILOT_GITHUB_TOKEN\", \"GH_TOKEN\", \"GITHUB_TOKEN\"),\n base_url_env_var=\"COPILOT_API_BASE_URL\",\n ),\n \"copilot-acp\": ProviderConfig(\n id=\"copilot-acp\",\n name=\"GitHub Copilot ACP\",\n auth_type=\"external_process\",\n inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,\n base_url_env_var=\"COPILOT_ACP_BASE_URL\",\n ),\n \"gemini\": ProviderConfig(\n id=\"gemini\",\n name=\"Google AI Studio\",\n auth_type=\"api_key\",\n inference_base_url=\"https://generativelanguage.googleapis.com/v1beta\",\n api_key_env_vars=(\"GOOGLE_API_KEY\", \"GEMINI_API_KEY\"),\n base_url_env_var=\"GEMINI_BASE_URL\",\n ),\n \"zai\": ProviderConfig(\n id=\"zai\",\n name=\"Z.AI / GLM\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.z.ai/api/paas/v4\",\n api_key_env_vars=(\"GLM_API_KEY\", \"ZAI_API_KEY\", \"Z_AI_API_KEY\"),\n base_url_env_var=\"GLM_BASE_URL\",\n ),\n \"kimi-coding\": ProviderConfig(\n id=\"kimi-coding\",\n name=\"Kimi / Moonshot\",\n auth_type=\"api_key\",\n # Legacy platform.moonshot.ai keys use this endpoint (OpenAI-compat).\n # sk-kimi- (Kimi Code) keys are auto-redirected to api.kimi.com/coding\n # by _resolve_kimi_base_url() below.\n inference_base_url=\"https://api.moonshot.ai/v1\",\n api_key_env_vars=(\"KIMI_API_KEY\", \"KIMI_CODING_API_KEY\"),\n base_url_env_var=\"KIMI_BASE_URL\",\n ),\n \"kimi-coding-cn\": ProviderConfig(\n id=\"kimi-coding-cn\",\n name=\"Kimi / Moonshot (China)\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.moonshot.cn/v1\",\n api_key_env_vars=(\"KIMI_CN_API_KEY\",),\n ),\n \"stepfun\": ProviderConfig(\n id=\"stepfun\",\n name=\"StepFun Step Plan\",\n auth_type=\"api_key\",\n inference_base_url=STEPFUN_STEP_PLAN_INTL_BASE_URL,\n api_key_env_vars=(\"STEPFUN_API_KEY\",),\n base_url_env_var=\"STEPFUN_BASE_URL\",\n ),\n \"arcee\": ProviderConfig(\n id=\"arcee\",\n name=\"Arcee AI\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.arcee.ai/api/v1\",\n api_key_env_vars=(\"ARCEEAI_API_KEY\",),\n base_url_env_var=\"ARCEE_BASE_URL\",\n ),\n \"minimax\": ProviderConfig(\n id=\"minimax\",\n name=\"MiniMax\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.minimax.io/anthropic\",\n api_key_env_vars=(\"MINIMAX_API_KEY\",),\n base_url_env_var=\"MINIMAX_BASE_URL\",\n ),\n \"anthropic\": ProviderConfig(\n id=\"anthropic\",\n name=\"Anthropic\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.anthropic.com\",\n api_key_env_vars=(\"ANTHROPIC_API_KEY\", \"ANTHROPIC_TOKEN\", \"CLAUDE_CODE_OAUTH_TOKEN\"),\n base_url_env_var=\"ANTHROPIC_BASE_URL\",\n ),\n \"alibaba\": ProviderConfig(\n id=\"alibaba\",\n name=\"Alibaba Cloud (DashScope)\",\n auth_type=\"api_key\",\n inference_base_url=\"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n api_key_env_vars=(\"DASHSCOPE_API_KEY\",),\n base_url_env_var=\"DASHSCOPE_BASE_URL\",\n ),\n \"alibaba-coding-plan\": ProviderConfig(\n id=\"alibaba-coding-plan\",\n name=\"Alibaba Cloud (Coding Plan)\",\n auth_type=\"api_key\",\n inference_base_url=\"https://coding-intl.dashscope.aliyuncs.com/v1\",\n api_key_env_vars=(\"ALIBABA_CODING_PLAN_API_KEY\", \"DASHSCOPE_API_KEY\"),\n base_url_env_var=\"ALIBABA_CODING_PLAN_BASE_URL\",\n ),\n \"minimax-cn\": ProviderConfig(\n id=\"minimax-cn\",\n name=\"MiniMax (China)\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.minimaxi.com/anthropic\",\n api_key_env_vars=(\"MINIMAX_CN_API_KEY\",),\n base_url_env_var=\"MINIMAX_CN_BASE_URL\",\n ),\n \"deepseek\": ProviderConfig(\n id=\"deepseek\",\n name=\"DeepSeek\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.deepseek.com/v1\",\n api_key_env_vars=(\"DEEPSEEK_API_KEY\",),\n base_url_env_var=\"DEEPSEEK_BASE_URL\",\n ),\n \"xai\": ProviderConfig(\n id=\"xai\",\n name=\"xAI\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.x.ai/v1\",\n api_key_env_vars=(\"XAI_API_KEY\",),\n base_url_env_var=\"XAI_BASE_URL\",\n ),\n \"nvidia\": ProviderConfig(\n id=\"nvidia\",\n name=\"NVIDIA NIM\",\n auth_type=\"api_key\",\n inference_base_url=\"https://integrate.api.nvidia.com/v1\",\n api_key_env_vars=(\"NVIDIA_API_KEY\",),\n base_url_env_var=\"NVIDIA_BASE_URL\",\n ),\n \"ai-gateway\": ProviderConfig(\n id=\"ai-gateway\",\n name=\"Vercel AI Gateway\",\n auth_type=\"api_key\",\n inference_base_url=\"https://ai-gateway.vercel.sh/v1\",\n api_key_env_vars=(\"AI_GATEWAY_API_KEY\",),\n base_url_env_var=\"AI_GATEWAY_BASE_URL\",\n ),\n \"opencode-zen\": ProviderConfig(\n id=\"opencode-zen\",\n name=\"OpenCode Zen\",\n auth_type=\"api_key\",\n inference_base_url=\"https://opencode.ai/zen/v1\",\n api_key_env_vars=(\"OPENCODE_ZEN_API_KEY\",),\n base_url_env_var=\"OPENCODE_ZEN_BASE_URL\",\n ),\n \"opencode-go\": ProviderConfig(\n id=\"opencode-go\",\n name=\"OpenCode Go\",\n auth_type=\"api_key\",\n # OpenCode Go mixes API surfaces by model:\n # - GLM / Kimi use OpenAI-compatible chat completions under /v1\n # - MiniMax models use Anthropic Messages under /v1/messages\n # Keep the provider base at /v1 and select api_mode per-model.\n inference_base_url=\"https://opencode.ai/zen/go/v1\",\n api_key_env_vars=(\"OPENCODE_GO_API_KEY\",),\n base_url_env_var=\"OPENCODE_GO_BASE_URL\",\n ),\n \"kilocode\": ProviderConfig(\n id=\"kilocode\",\n name=\"Kilo Code\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.kilo.ai/api/gateway\",\n api_key_env_vars=(\"KILOCODE_API_KEY\",),\n base_url_env_var=\"KILOCODE_BASE_URL\",\n ),\n \"huggingface\": ProviderConfig(\n id=\"huggingface\",\n name=\"Hugging Face\",\n auth_type=\"api_key\",\n inference_base_url=\"https://router.huggingface.co/v1\",\n api_key_env_vars=(\"HF_TOKEN\",),\n base_url_env_var=\"HF_BASE_URL\",\n ),\n \"xiaomi\": ProviderConfig(\n id=\"xiaomi\",\n name=\"Xiaomi MiMo\",\n auth_type=\"api_key\",\n inference_base_url=\"https://api.xiaomimimo.com/v1\",\n api_key_env_vars=(\"XIAOMI_API_KEY\",),\n base_url_env_var=\"XIAOMI_BASE_URL\",\n ),\n \"ollama-cloud\": ProviderConfig(\n id=\"ollama-cloud\",\n name=\"Ollama Cloud\",\n auth_type=\"api_key\",\n inference_base_url=DEFAULT_OLLAMA_CLOUD_BASE_URL,\n api_key_env_vars=(\"OLLAMA_API_KEY\",),\n base_url_env_var=\"OLLAMA_BASE_URL\",\n ),\n \"bedrock\": ProviderConfig(\n id=\"bedrock\",\n name=\"AWS Bedrock\",\n auth_type=\"aws_sdk\",\n inference_base_url=\"https://bedrock-runtime.us-east-1.amazonaws.com\",\n api_key_env_vars=(),\n base_url_env_var=\"BEDROCK_BASE_URL\",\n ),\n}\n\n\n# =============================================================================\n# Anthropic Key Helper\n# =============================================================================\n\ndef get_anthropic_key() -> str:\n \"\"\"Return the first usable Anthropic credential, or ``\"\"``.\n\n Checks both the ``.env`` file (via ``get_env_value``) and the process\n environment (``os.getenv``). The fallback order mirrors the\n ``PROVIDER_REGISTRY[\"anthropic\"].api_key_env_vars`` tuple:\n\n ANTHROPIC_API_KEY -> ANTHROPIC_TOKEN -> CLAUDE_CODE_OAUTH_TOKEN\n \"\"\"\n from hermes_cli.config import get_env_value\n\n for var in PROVIDER_REGISTRY[\"anthropic\"].api_key_env_vars:\n value = get_env_value(var) or os.getenv(var, \"\")\n if value:\n return value\n return \"\"\n\n\n# =============================================================================\n# Kimi Code Endpoint Detection\n# =============================================================================\n\n# Kimi Code (kimi.com/code) issues keys prefixed \"sk-kimi-\" that only work\n# on api.kimi.com/coding. Legacy keys from platform.moonshot.ai work on\n# api.moonshot.ai/v1 (the old default). Auto-detect when user hasn't set\n# KIMI_BASE_URL explicitly.\n#\n# Note: the base URL intentionally has NO /v1 suffix. The /coding endpoint\n# speaks the Anthropic Messages protocol, and the anthropic SDK appends\n# \"/v1/messages\" internally — so \"/coding\" + SDK suffix → \"/coding/v1/messages\"\n# (the correct target). Using \"/coding/v1\" here would produce\n# \"/coding/v1/v1/messages\" (a 404).\nKIMI_CODE_BASE_URL = \"https://api.kimi.com/coding\"\n\n\ndef _resolve_kimi_base_url(api_key: str, default_url: str, env_override: str) -> str:\n \"\"\"Return the correct Kimi base URL based on the API key prefix.\n\n If the user has explicitly set KIMI_BASE_URL, that always wins.\n Otherwise, sk-kimi- prefixed keys route to api.kimi.com/coding/v1.\n \"\"\"\n if env_override:\n return env_override\n # No key → nothing to infer from. Return default without inspecting.\n if not api_key:\n return default_url\n if api_key.startswith(\"sk-kimi-\"):\n return KIMI_CODE_BASE_URL\n return default_url\n\n\n\n_PLACEHOLDER_SECRET_VALUES = {\n \"*\",\n \"**\",\n \"***\",\n \"changeme\",\n \"your_api_key\",\n \"your-api-key\",\n \"placeholder\",\n \"example\",\n \"dummy\",\n \"null\",\n \"none\",\n}\n\n\ndef has_usable_secret(value: Any, *, min_length: int = 4) -> bool:\n \"\"\"Return True when a configured secret looks usable, not empty/placeholder.\"\"\"\n if not isinstance(value, str):\n return False\n cleaned = value.strip()\n if len(cleaned) < min_length:\n return False\n if cleaned.lower() in _PLACEHOLDER_SECRET_VALUES:\n return False\n return True\n\n\ndef _resolve_api_key_provider_secret(\n provider_id: str, pconfig: ProviderConfig\n) -> tuple[str, str]:\n \"\"\"Resolve an API-key provider's token and indicate where it came from.\"\"\"\n if provider_id == \"copilot\":\n # Use the dedicated copilot auth module for proper token validation\n try:\n from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token\n token, source = resolve_copilot_token()\n if token:\n return get_copilot_api_token(token), source\n except ValueError as exc:\n logger.warning(\"Copilot token validation failed: %s\", exc)\n except Exception:\n pass\n return \"\", \"\"\n\n for env_var in pconfig.api_key_env_vars:\n val = os.getenv(env_var, \"\").strip()\n if has_usable_secret(val):\n return val, env_var\n\n return \"\", \"\"\n\n\n# =============================================================================\n# Z.AI Endpoint Detection\n# =============================================================================\n\n# Z.AI has separate billing for general vs coding plans, and global vs China\n# endpoints. A key that works on one may return \"Insufficient balance\" on\n# another. We probe at setup time and store the working endpoint.\n# Each entry lists candidate models to try in order — newer coding plan accounts\n# may only have access to recent models (glm-5.1, glm-5v-turbo) while older\n# ones still use glm-4.7.\n\nZAI_ENDPOINTS = [\n # (id, base_url, probe_models, label)\n (\"global\", \"https://api.z.ai/api/paas/v4\", [\"glm-5\"], \"Global\"),\n (\"cn\", \"https://open.bigmodel.cn/api/paas/v4\", [\"glm-5\"], \"China\"),\n (\"coding-global\", \"https://api.z.ai/api/coding/paas/v4\", [\"glm-5.1\", \"glm-5v-turbo\", \"glm-4.7\"], \"Global (Coding Plan)\"),\n (\"coding-cn\", \"https://open.bigmodel.cn/api/coding/paas/v4\", [\"glm-5.1\", \"glm-5v-turbo\", \"glm-4.7\"], \"China (Coding Plan)\"),\n]\n\n\ndef detect_zai_endpoint(api_key: str, timeout: float = 8.0) -> Optional[Dict[str, str]]:\n \"\"\"Probe z.ai endpoints to find one that accepts this API key.\n\n Returns {\"id\": ..., \"base_url\": ..., \"model\": ..., \"label\": ...} for the\n first working endpoint, or None if all fail. For endpoints with multiple\n candidate models, tries each in order and returns the first that succeeds.\n \"\"\"\n for ep_id, base_url, probe_models, label in ZAI_ENDPOINTS:\n for model in probe_models:\n try:\n resp = httpx.post(\n f\"{base_url}/chat/completions\",\n headers={\n \"Authorization\": f\"Bearer {api_key}\",\n \"Content-Type\": \"application/json\",\n },\n json={\n \"model\": model,\n \"stream\": False,\n \"max_tokens\": 1,\n \"messages\": [{\"role\": \"user\", \"content\": \"ping\"}],\n },\n timeout=timeout,\n )\n if resp.status_code == 200:\n logger.debug(\"Z.AI endpoint probe: %s (%s) model=%s OK\", ep_id, base_url, model)\n return {\n \"id\": ep_id,\n \"base_url\": base_url,\n \"model\": model,\n \"label\": label,\n }\n logger.debug(\"Z.AI endpoint probe: %s model=%s returned %s\", ep_id, model, resp.status_code)\n except Exception as exc:\n logger.debug(\"Z.AI endpoint probe: %s model=%s failed: %s\", ep_id, model, exc)\n return None\n\n\ndef _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> str:\n \"\"\"Return the correct Z.AI base URL by probing endpoints.\n\n If the user has explicitly set GLM_BASE_URL, that always wins.\n Otherwise, probe the candidate endpoints to find one that accepts the\n key. The detected endpoint is cached in provider state (auth.json) keyed\n on a hash of the API key so subsequent starts skip the probe.\n \"\"\"\n if env_override:\n return env_override\n\n # No API key set → don't probe (would fire N×M HTTPS requests with an\n # empty Bearer token, all returning 401). This path is hit during\n # auxiliary-client auto-detection when the user has no Z.AI credentials\n # at all — the caller discards the result immediately, so the probe is\n # pure latency for every AIAgent construction.\n if not api_key:\n return default_url\n\n # Check provider-state cache for a previously-detected endpoint.\n auth_store = _load_auth_store()\n state = _load_provider_state(auth_store, \"zai\") or {}\n cached = state.get(\"detected_endpoint\")\n if isinstance(cached, dict) and cached.get(\"base_url\"):\n key_hash = cached.get(\"key_hash\", \"\")\n if key_hash == hashlib.sha256(api_key.encode()).hexdigest()[:16]:\n logger.debug(\"Z.AI: using cached endpoint %s\", cached[\"base_url\"])\n return cached[\"base_url\"]\n\n # Probe — may take up to ~8s per endpoint.\n detected = detect_zai_endpoint(api_key)\n if detected and detected.get(\"base_url\"):\n # Persist the detection result keyed on the API key hash.\n key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]\n state[\"detected_endpoint\"] = {\n \"base_url\": detected[\"base_url\"],\n \"endpoint_id\": detected.get(\"id\", \"\"),\n \"model\": detected.get(\"model\", \"\"),\n \"label\": detected.get(\"label\", \"\"),\n \"key_hash\": key_hash,\n }\n _save_provider_state(auth_store, \"zai\", state)\n logger.info(\"Z.AI: auto-detected endpoint %s (%s)\", detected[\"label\"], detected[\"base_url\"])\n return detected[\"base_url\"]\n\n logger.debug(\"Z.AI: probe failed, falling back to default %s\", default_url)\n return default_url\n\n\n# =============================================================================\n# Error Types\n# =============================================================================\n\nclass AuthError(RuntimeError):\n \"\"\"Structured auth error with UX mapping hints.\"\"\"\n\n def __init__(\n self,\n message: str,\n *,\n provider: str = \"\",\n code: Optional[str] = None,\n relogin_required: bool = False,\n ) -> None:\n super().__init__(message)\n self.provider = provider\n self.code = code\n self.relogin_required = relogin_required\n\n\ndef format_auth_error(error: Exception) -> str:\n \"\"\"Map auth failures to concise user-facing guidance.\"\"\"\n if not isinstance(error, AuthError):\n return str(error)\n\n if error.relogin_required:\n return f\"{error} Run `hermes model` to re-authenticate.\"\n\n if error.code == \"subscription_required\":\n return (\n \"No active paid subscription found on Nous Portal. \"\n \"Please purchase/activate a subscription, then retry.\"\n )\n\n if error.code == \"insufficient_credits\":\n return (\n \"Subscription credits are exhausted. \"\n \"Top up/renew credits in Nous Portal, then retry.\"\n )\n\n if error.code == \"temporarily_unavailable\":\n return f\"{error} Please retry in a few seconds.\"\n\n return str(error)\n\n\ndef _token_fingerprint(token: Any) -> Optional[str]:\n \"\"\"Return a short hash fingerprint for telemetry without leaking token bytes.\"\"\"\n if not isinstance(token, str):\n return None\n cleaned = token.strip()\n if not cleaned:\n return None\n return hashlib.sha256(cleaned.encode(\"utf-8\")).hexdigest()[:12]\n\n\ndef _oauth_trace_enabled() -> bool:\n raw = os.getenv(\"HERMES_OAUTH_TRACE\", \"\").strip().lower()\n return raw in {\"1\", \"true\", \"yes\", \"on\"}\n\n\ndef _oauth_trace(event: str, *, sequence_id: Optional[str] = None, **fields: Any) -> None:\n if not _oauth_trace_enabled():\n return\n payload: Dict[str, Any] = {\"event\": event}\n if sequence_id:\n payload[\"sequence_id\"] = sequence_id\n payload.update(fields)\n logger.info(\"oauth_trace %s\", json.dumps(payload, sort_keys=True, ensure_ascii=False))\n\n\n# =============================================================================\n# Auth Store — persistence layer for ~/.hermes/auth.json\n# =============================================================================\n\ndef _auth_file_path() -> Path:\n path = get_hermes_home() / \"auth.json\"\n # Seat belt: if pytest is running and HERMES_HOME resolves to the real\n # user's auth store, refuse rather than silently corrupt it. This catches\n # tests that forgot to monkeypatch HERMES_HOME, tests invoked without the\n # hermetic conftest, or sandbox escapes via threads/subprocesses. In\n # production (no PYTEST_CURRENT_TEST) this is a single dict lookup.\n if os.environ.get(\"PYTEST_CURRENT_TEST\"):\n real_home_auth = (Path.home() / \".hermes\" / \"auth.json\").resolve(strict=False)\n try:\n resolved = path.resolve(strict=False)\n except Exception:\n resolved = path\n if resolved == real_home_auth:\n raise RuntimeError(\n f\"Refusing to touch real user auth store during test run: {path}. \"\n \"Set HERMES_HOME to a tmp_path in your test fixture, or run \"\n \"via scripts/run_tests.sh for hermetic CI-parity env.\"\n )\n return path\n\n\ndef _auth_lock_path() -> Path:\n return _auth_file_path().with_suffix(\".lock\")\n\n\n_auth_lock_holder = threading.local()\n\n@contextmanager\ndef _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):\n \"\"\"Cross-process advisory lock for auth.json reads+writes. Reentrant.\"\"\"\n # Reentrant: if this thread already holds the lock, just yield.\n if getattr(_auth_lock_holder, \"depth\", 0) > 0:\n _auth_lock_holder.depth += 1\n try:\n yield\n finally:\n _auth_lock_holder.depth -= 1\n return\n\n lock_path = _auth_lock_path()\n lock_path.parent.mkdir(parents=True, exist_ok=True)\n\n if fcntl is None and msvcrt is None:\n _auth_lock_holder.depth = 1\n try:\n yield\n finally:\n _auth_lock_holder.depth = 0\n return\n\n # On Windows, msvcrt.locking needs the file to have content and the\n # file pointer at position 0. Ensure the lock file has at least 1 byte.\n if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):\n lock_path.write_text(\" \", encoding=\"utf-8\")\n\n with lock_path.open(\"r+\" if msvcrt else \"a+\") as lock_file:\n deadline = time.time() + max(1.0, timeout_seconds)\n while True:\n try:\n if fcntl:\n fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)\n else:\n lock_file.seek(0)\n msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)\n break\n except (BlockingIOError, OSError, PermissionError):\n if time.time() >= deadline:\n raise TimeoutError(\"Timed out waiting for auth store lock\")\n time.sleep(0.05)\n\n _auth_lock_holder.depth = 1\n try:\n yield\n finally:\n _auth_lock_holder.depth = 0\n if fcntl:\n fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)\n elif msvcrt:\n try:\n lock_file.seek(0)\n msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)\n except (OSError, IOError):\n pass\n\n\ndef _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:\n auth_file = auth_file or _auth_file_path()\n if not auth_file.exists():\n return {\"version\": AUTH_STORE_VERSION, \"providers\": {}}\n\n try:\n raw = json.loads(auth_file.read_text())\n except Exception as exc:\n corrupt_path = auth_file.with_suffix(\".json.corrupt\")\n try:\n import shutil\n shutil.copy2(auth_file, corrupt_path)\n except Exception:\n pass\n logger.warning(\n \"auth: failed to parse %s (%s) — starting with empty store. \"\n \"Corrupt file preserved at %s\",\n auth_file, exc, corrupt_path,\n )\n return {\"version\": AUTH_STORE_VERSION, \"providers\": {}}\n\n if isinstance(raw, dict) and (\n isinstance(raw.get(\"providers\"), dict)\n or isinstance(raw.get(\"credential_pool\"), dict)\n ):\n raw.setdefault(\"providers\", {})\n return raw\n\n # Migrate from PR's \"systems\" format if present\n if isinstance(raw, dict) and isinstance(raw.get(\"systems\"), dict):\n systems = raw[\"systems\"]\n providers = {}\n if \"nous_portal\" in systems:\n providers[\"nous\"] = systems[\"nous_portal\"]\n return {\"version\": AUTH_STORE_VERSION, \"providers\": providers,\n \"active_provider\": \"nous\" if providers else None}\n\n return {\"version\": AUTH_STORE_VERSION, \"providers\": {}}\n\n\ndef _save_auth_store(auth_store: Dict[str, Any]) -> Path:\n auth_file = _auth_file_path()\n auth_file.parent.mkdir(parents=True, exist_ok=True)\n auth_store[\"version\"] = AUTH_STORE_VERSION\n auth_store[\"updated_at\"] = datetime.now(timezone.utc).isoformat()\n payload = json.dumps(auth_store, indent=2) + \"\\n\"\n tmp_path = auth_file.with_name(f\"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}\")\n try:\n with tmp_path.open(\"w\", encoding=\"utf-8\") as handle:\n handle.write(payload)\n handle.flush()\n os.fsync(handle.fileno())\n os.replace(tmp_path, auth_file)\n try:\n dir_fd = os.open(str(auth_file.parent), os.O_RDONLY)\n except OSError:\n dir_fd = None\n if dir_fd is not None:\n try:\n os.fsync(dir_fd)\n finally:\n os.close(dir_fd)\n finally:\n try:\n if tmp_path.exists():\n tmp_path.unlink()\n except OSError:\n pass\n # Restrict file permissions to owner only\n try:\n auth_file.chmod(stat.S_IRUSR | stat.S_IWUSR)\n except OSError:\n pass\n return auth_file\n\n\ndef _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]:\n providers = auth_store.get(\"providers\")\n if not isinstance(providers, dict):\n return None\n state = providers.get(provider_id)\n return dict(state) if isinstance(state, dict) else None\n\n\ndef _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None:\n providers = auth_store.setdefault(\"providers\", {})\n if not isinstance(providers, dict):\n auth_store[\"providers\"] = {}\n providers = auth_store[\"providers\"]\n providers[provider_id] = state\n auth_store[\"active_provider\"] = provider_id\n\n\ndef _store_provider_state(\n auth_store: Dict[str, Any],\n provider_id: str,\n state: Dict[str, Any],\n *,\n set_active: bool = True,\n) -> None:\n providers = auth_store.setdefault(\"providers\", {})\n if not isinstance(providers, dict):\n auth_store[\"providers\"] = {}\n providers = auth_store[\"providers\"]\n providers[provider_id] = state\n if set_active:\n auth_store[\"active_provider\"] = provider_id\n\n\ndef is_known_auth_provider(provider_id: str) -> bool:\n normalized = (provider_id or \"\").strip().lower()\n return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES\n\n\ndef get_auth_provider_display_name(provider_id: str) -> str:\n normalized = (provider_id or \"\").strip().lower()\n if normalized in PROVIDER_REGISTRY:\n return PROVIDER_REGISTRY[normalized].name\n return SERVICE_PROVIDER_NAMES.get(normalized, provider_id)\n\n\ndef read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:\n \"\"\"Return the persisted credential pool, or one provider slice.\"\"\"\n auth_store = _load_auth_store()\n pool = auth_store.get(\"credential_pool\")\n if not isinstance(pool, dict):\n pool = {}\n if provider_id is None:\n return dict(pool)\n provider_entries = pool.get(provider_id)\n return list(provider_entries) if isinstance(provider_entries, list) else []\n\n\ndef write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:\n \"\"\"Persist one provider's credential pool under auth.json.\"\"\"\n with _auth_store_lock():\n auth_store = _load_auth_store()\n pool = auth_store.get(\"credential_pool\")\n if not isinstance(pool, dict):\n pool = {}\n auth_store[\"credential_pool\"] = pool\n pool[provider_id] = list(entries)\n return _save_auth_store(auth_store)\n\n\ndef suppress_credential_source(provider_id: str, source: str) -> None:\n \"\"\"Mark a credential source as suppressed so it won't be re-seeded.\"\"\"\n with _auth_store_lock():\n auth_store = _load_auth_store()\n suppressed = auth_store.setdefault(\"suppressed_sources\", {})\n provider_list = suppressed.setdefault(provider_id, [])\n if source not in provider_list:\n provider_list.append(source)\n _save_auth_store(auth_store)\n\n\ndef is_source_suppressed(provider_id: str, source: str) -> bool:\n \"\"\"Check if a credential source has been suppressed by the user.\"\"\"\n try:\n auth_store = _load_auth_store()\n suppressed = auth_store.get(\"suppressed_sources\", {})\n return source in suppressed.get(provider_id, [])\n except Exception:\n return False\n\n\ndef unsuppress_credential_source(provider_id: str, source: str) -> bool:\n \"\"\"Clear a suppression marker so the source will be re-seeded on the next load.\n\n Returns True if a marker was cleared, False if no marker existed.\n \"\"\"\n with _auth_store_lock():\n auth_store = _load_auth_store()\n suppressed = auth_store.get(\"suppressed_sources\")\n if not isinstance(suppressed, dict):\n return False\n provider_list = suppressed.get(provider_id)\n if not isinstance(provider_list, list) or source not in provider_list:\n return False\n provider_list.remove(source)\n if not provider_list:\n suppressed.pop(provider_id, None)\n if not suppressed:\n auth_store.pop(\"suppressed_sources\", None)\n _save_auth_store(auth_store)\n return True\n\n\ndef get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:\n \"\"\"Return persisted auth state for a provider, or None.\"\"\"\n auth_store = _load_auth_store()\n return _load_provider_state(auth_store, provider_id)\n\n\ndef get_active_provider() -> Optional[str]:\n \"\"\"Return the currently active provider ID from auth store.\"\"\"\n auth_store = _load_auth_store()\n return auth_store.get(\"active_provider\")\n\n\ndef is_provider_explicitly_configured(provider_id: str) -> bool:\n \"\"\"Return True only if the user has explicitly configured this provider.\n\n Checks:\n 1. active_provider in auth.json matches\n 2. model.provider in config.yaml matches\n 3. Provider-specific env vars are set (e.g. ANTHROPIC_API_KEY)\n\n This is used to gate auto-discovery of external credentials (e.g.\n Claude Code's ~/.claude/.credentials.json) so they are never used\n without the user's explicit choice. See PR #4210 for the same\n pattern applied to the setup wizard gate.\n \"\"\"\n normalized = (provider_id or \"\").strip().lower()\n\n # 1. Check auth.json active_provider\n try:\n auth_store = _load_auth_store()\n active = (auth_store.get(\"active_provider\") or \"\").strip().lower()\n if active and active == normalized:\n return True\n except Exception:\n pass\n\n # 2. Check config.yaml model.provider\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\")\n if isinstance(model_cfg, dict):\n cfg_provider = (model_cfg.get(\"provider\") or \"\").strip().lower()\n if cfg_provider == normalized:\n return True\n except Exception:\n pass\n\n # 3. Check provider-specific env vars\n # Exclude CLAUDE_CODE_OAUTH_TOKEN — it's set by Claude Code itself,\n # not by the user explicitly configuring anthropic in Hermes.\n _IMPLICIT_ENV_VARS = {\"CLAUDE_CODE_OAUTH_TOKEN\"}\n pconfig = PROVIDER_REGISTRY.get(normalized)\n if pconfig and pconfig.auth_type == \"api_key\":\n for env_var in pconfig.api_key_env_vars:\n if env_var in _IMPLICIT_ENV_VARS:\n continue\n if has_usable_secret(os.getenv(env_var, \"\")):\n return True\n\n return False\n\n\ndef clear_provider_auth(provider_id: Optional[str] = None) -> bool:\n \"\"\"\n Clear auth state for a provider. Used by `hermes logout`.\n If provider_id is None, clears the active provider.\n Returns True if something was cleared.\n \"\"\"\n with _auth_store_lock():\n auth_store = _load_auth_store()\n target = provider_id or auth_store.get(\"active_provider\")\n if not target:\n return False\n\n providers = auth_store.get(\"providers\", {})\n if not isinstance(providers, dict):\n providers = {}\n auth_store[\"providers\"] = providers\n\n pool = auth_store.get(\"credential_pool\")\n if not isinstance(pool, dict):\n pool = {}\n auth_store[\"credential_pool\"] = pool\n\n cleared = False\n if target in providers:\n del providers[target]\n cleared = True\n if target in pool:\n del pool[target]\n cleared = True\n\n if auth_store.get(\"active_provider\") == target:\n auth_store[\"active_provider\"] = None\n cleared = True\n\n if not cleared:\n return False\n _save_auth_store(auth_store)\n return True\n\n\ndef deactivate_provider() -> None:\n \"\"\"\n Clear active_provider in auth.json without deleting credentials.\n Used when the user switches to a non-OAuth provider (OpenRouter, custom)\n so auto-resolution doesn't keep picking the OAuth provider.\n \"\"\"\n with _auth_store_lock():\n auth_store = _load_auth_store()\n auth_store[\"active_provider\"] = None\n _save_auth_store(auth_store)\n\n\n# =============================================================================\n# Provider Resolution — picks which provider to use\n# =============================================================================\n\n\ndef _get_config_hint_for_unknown_provider(provider_name: str) -> str:\n \"\"\"Return a helpful hint string when provider resolution fails.\n\n Checks for common config.yaml mistakes (malformed custom_providers, etc.)\n and returns a human-readable diagnostic, or empty string if nothing found.\n \"\"\"\n try:\n from hermes_cli.config import validate_config_structure\n issues = validate_config_structure()\n if not issues:\n return \"\"\n\n lines = [\"Config issue detected — run 'hermes doctor' for full diagnostics:\"]\n for ci in issues:\n prefix = \"ERROR\" if ci.severity == \"error\" else \"WARNING\"\n lines.append(f\" [{prefix}] {ci.message}\")\n # Show first line of hint\n first_hint = ci.hint.splitlines()[0] if ci.hint else \"\"\n if first_hint:\n lines.append(f\" → {first_hint}\")\n return \"\\n\".join(lines)\n except Exception:\n return \"\"\n\n\ndef resolve_provider(\n requested: Optional[str] = None,\n *,\n explicit_api_key: Optional[str] = None,\n explicit_base_url: Optional[str] = None,\n) -> str:\n \"\"\"\n Determine which inference provider to use.\n\n Priority (when requested=\"auto\" or None):\n 1. active_provider in auth.json with valid credentials\n 2. Explicit CLI api_key/base_url -> \"openrouter\"\n 3. OPENAI_API_KEY or OPENROUTER_API_KEY env vars -> \"openrouter\"\n 4. Provider-specific API keys (GLM, Kimi, MiniMax) -> that provider\n 5. Fallback: \"openrouter\"\n \"\"\"\n normalized = (requested or \"auto\").strip().lower()\n\n # Normalize provider aliases\n _PROVIDER_ALIASES = {\n \"glm\": \"zai\", \"z-ai\": \"zai\", \"z.ai\": \"zai\", \"zhipu\": \"zai\",\n \"google\": \"gemini\", \"google-gemini\": \"gemini\", \"google-ai-studio\": \"gemini\",\n \"x-ai\": \"xai\", \"x.ai\": \"xai\", \"grok\": \"xai\",\n \"kimi\": \"kimi-coding\", \"kimi-for-coding\": \"kimi-coding\", \"moonshot\": \"kimi-coding\",\n \"kimi-cn\": \"kimi-coding-cn\", \"moonshot-cn\": \"kimi-coding-cn\",\n \"step\": \"stepfun\", \"stepfun-coding-plan\": \"stepfun\",\n \"arcee-ai\": \"arcee\", \"arceeai\": \"arcee\",\n \"minimax-china\": \"minimax-cn\", \"minimax_cn\": \"minimax-cn\",\n \"alibaba_coding\": \"alibaba-coding-plan\", \"alibaba-coding\": \"alibaba-coding-plan\",\n \"alibaba_coding_plan\": \"alibaba-coding-plan\",\n \"claude\": \"anthropic\", \"claude-code\": \"anthropic\",\n \"github\": \"copilot\", \"github-copilot\": \"copilot\",\n \"github-models\": \"copilot\", \"github-model\": \"copilot\",\n \"github-copilot-acp\": \"copilot-acp\", \"copilot-acp-agent\": \"copilot-acp\",\n \"aigateway\": \"ai-gateway\", \"vercel\": \"ai-gateway\", \"vercel-ai-gateway\": \"ai-gateway\",\n \"opencode\": \"opencode-zen\", \"zen\": \"opencode-zen\",\n \"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\",\n \"hf\": \"huggingface\", \"hugging-face\": \"huggingface\", \"huggingface-hub\": \"huggingface\",\n \"mimo\": \"xiaomi\", \"xiaomi-mimo\": \"xiaomi\",\n \"aws\": \"bedrock\", \"aws-bedrock\": \"bedrock\", \"amazon-bedrock\": \"bedrock\", \"amazon\": \"bedrock\",\n \"go\": \"opencode-go\", \"opencode-go-sub\": \"opencode-go\",\n \"kilo\": \"kilocode\", \"kilo-code\": \"kilocode\", \"kilo-gateway\": \"kilocode\",\n # Local server aliases — route through the generic custom provider\n \"lmstudio\": \"custom\", \"lm-studio\": \"custom\", \"lm_studio\": \"custom\",\n \"ollama\": \"custom\", \"ollama_cloud\": \"ollama-cloud\",\n \"vllm\": \"custom\", \"llamacpp\": \"custom\",\n \"llama.cpp\": \"custom\", \"llama-cpp\": \"custom\",\n }\n normalized = _PROVIDER_ALIASES.get(normalized, normalized)\n\n if normalized == \"openrouter\":\n return \"openrouter\"\n if normalized == \"custom\":\n return \"custom\"\n if normalized in PROVIDER_REGISTRY:\n return normalized\n if normalized != \"auto\":\n # Check for common config.yaml issues that cause this error\n _config_hint = _get_config_hint_for_unknown_provider(normalized)\n msg = f\"Unknown provider '{normalized}'.\"\n if _config_hint:\n msg += f\"\\n\\n{_config_hint}\"\n else:\n msg += \" Check 'hermes model' for available providers, or run 'hermes doctor' to diagnose config issues.\"\n raise AuthError(msg, code=\"invalid_provider\")\n\n # Explicit one-off CLI creds always mean openrouter/custom\n if explicit_api_key or explicit_base_url:\n return \"openrouter\"\n\n # Check auth store for an active OAuth provider\n try:\n auth_store = _load_auth_store()\n active = auth_store.get(\"active_provider\")\n if active and active in PROVIDER_REGISTRY:\n status = get_auth_status(active)\n if status.get(\"logged_in\"):\n return active\n except Exception as e:\n logger.debug(\"Could not detect active auth provider: %s\", e)\n\n if has_usable_secret(os.getenv(\"OPENAI_API_KEY\")) or has_usable_secret(os.getenv(\"OPENROUTER_API_KEY\")):\n return \"openrouter\"\n\n # Auto-detect API-key providers by checking their env vars\n for pid, pconfig in PROVIDER_REGISTRY.items():\n if pconfig.auth_type != \"api_key\":\n continue\n # GitHub tokens are commonly present for repo/tool access but should not\n # hijack inference auto-selection unless the user explicitly chooses\n # Copilot/GitHub Models as the provider.\n if pid == \"copilot\":\n continue\n for env_var in pconfig.api_key_env_vars:\n if has_usable_secret(os.getenv(env_var, \"\")):\n return pid\n\n # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars).\n # This runs after API-key providers so explicit keys always win.\n try:\n from agent.bedrock_adapter import has_aws_credentials\n if has_aws_credentials():\n return \"bedrock\"\n except ImportError:\n pass # boto3 not installed — skip Bedrock auto-detection\n\n raise AuthError(\n \"No inference provider configured. Run 'hermes model' to choose a \"\n \"provider and model, or set an API key (OPENROUTER_API_KEY, \"\n \"OPENAI_API_KEY, etc.) in ~/.hermes/.env.\",\n code=\"no_provider_configured\",\n )\n\n\n# =============================================================================\n# Timestamp / TTL helpers\n# =============================================================================\n\ndef _parse_iso_timestamp(value: Any) -> Optional[float]:\n if not isinstance(value, str) or not value:\n return None\n text = value.strip()\n if not text:\n return None\n if text.endswith(\"Z\"):\n text = text[:-1] + \"+00:00\"\n try:\n parsed = datetime.fromisoformat(text)\n except Exception:\n return None\n if parsed.tzinfo is None:\n parsed = parsed.replace(tzinfo=timezone.utc)\n return parsed.timestamp()\n\n\ndef _is_expiring(expires_at_iso: Any, skew_seconds: int) -> bool:\n expires_epoch = _parse_iso_timestamp(expires_at_iso)\n if expires_epoch is None:\n return True\n return expires_epoch <= (time.time() + skew_seconds)\n\n\ndef _coerce_ttl_seconds(expires_in: Any) -> int:\n try:\n ttl = int(expires_in)\n except Exception:\n ttl = 0\n return max(0, ttl)\n\n\ndef _optional_base_url(value: Any) -> Optional[str]:\n if not isinstance(value, str):\n return None\n cleaned = value.strip().rstrip(\"/\")\n return cleaned if cleaned else None\n\n\ndef _decode_jwt_claims(token: Any) -> Dict[str, Any]:\n if not isinstance(token, str) or token.count(\".\") != 2:\n return {}\n payload = token.split(\".\")[1]\n payload += \"=\" * ((4 - len(payload) % 4) % 4)\n try:\n raw = base64.urlsafe_b64decode(payload.encode(\"utf-8\"))\n claims = json.loads(raw.decode(\"utf-8\"))\n except Exception:\n return {}\n return claims if isinstance(claims, dict) else {}\n\n\ndef _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> bool:\n claims = _decode_jwt_claims(access_token)\n exp = claims.get(\"exp\")\n if not isinstance(exp, (int, float)):\n return False\n return float(exp) <= (time.time() + max(0, int(skew_seconds)))\n\n\ndef _qwen_cli_auth_path() -> Path:\n return Path.home() / \".qwen\" / \"oauth_creds.json\"\n\n\ndef _read_qwen_cli_tokens() -> Dict[str, Any]:\n auth_path = _qwen_cli_auth_path()\n if not auth_path.exists():\n raise AuthError(\n \"Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.\",\n provider=\"qwen-oauth\",\n code=\"qwen_auth_missing\",\n )\n try:\n data = json.loads(auth_path.read_text(encoding=\"utf-8\"))\n except Exception as exc:\n raise AuthError(\n f\"Failed to read Qwen CLI credentials from {auth_path}: {exc}\",\n provider=\"qwen-oauth\",\n code=\"qwen_auth_read_failed\",\n ) from exc\n if not isinstance(data, dict):\n raise AuthError(\n f\"Invalid Qwen CLI credentials in {auth_path}.\",\n provider=\"qwen-oauth\",\n code=\"qwen_auth_invalid\",\n )\n return data\n\n\ndef _save_qwen_cli_tokens(tokens: Dict[str, Any]) -> Path:\n auth_path = _qwen_cli_auth_path()\n auth_path.parent.mkdir(parents=True, exist_ok=True)\n tmp_path = auth_path.with_suffix(\".tmp\")\n tmp_path.write_text(json.dumps(tokens, indent=2, sort_keys=True) + \"\\n\", encoding=\"utf-8\")\n os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)\n tmp_path.replace(auth_path)\n return auth_path\n\n\ndef _qwen_access_token_is_expiring(expiry_date_ms: Any, skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:\n try:\n expiry_ms = int(expiry_date_ms)\n except Exception:\n return True\n return (time.time() + max(0, int(skew_seconds))) * 1000 >= expiry_ms\n\n\ndef _refresh_qwen_cli_tokens(tokens: Dict[str, Any], timeout_seconds: float = 20.0) -> Dict[str, Any]:\n refresh_token = str(tokens.get(\"refresh_token\", \"\") or \"\").strip()\n if not refresh_token:\n raise AuthError(\n \"Qwen OAuth refresh token missing. Re-run 'qwen auth qwen-oauth'.\",\n provider=\"qwen-oauth\",\n code=\"qwen_refresh_token_missing\",\n )\n\n try:\n response = httpx.post(\n QWEN_OAUTH_TOKEN_URL,\n headers={\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n \"Accept\": \"application/json\",\n },\n data={\n \"grant_type\": \"refresh_token\",\n \"refresh_token\": refresh_token,\n \"client_id\": QWEN_OAUTH_CLIENT_ID,\n },\n timeout=timeout_seconds,\n )\n except Exception as exc:\n raise AuthError(\n f\"Qwen OAuth refresh failed: {exc}\",\n provider=\"qwen-oauth\",\n code=\"qwen_refresh_failed\",\n ) from exc\n\n if response.status_code >= 400:\n body = response.text.strip()\n raise AuthError(\n \"Qwen OAuth refresh failed. Re-run 'qwen auth qwen-oauth'.\"\n + (f\" Response: {body}\" if body else \"\"),\n provider=\"qwen-oauth\",\n code=\"qwen_refresh_failed\",\n )\n\n try:\n payload = response.json()\n except Exception as exc:\n raise AuthError(\n f\"Qwen OAuth refresh returned invalid JSON: {exc}\",\n provider=\"qwen-oauth\",\n code=\"qwen_refresh_invalid_json\",\n ) from exc\n\n if not isinstance(payload, dict) or not str(payload.get(\"access_token\", \"\") or \"\").strip():\n raise AuthError(\n \"Qwen OAuth refresh response missing access_token.\",\n provider=\"qwen-oauth\",\n code=\"qwen_refresh_invalid_response\",\n )\n\n expires_in = payload.get(\"expires_in\")\n try:\n expires_in_seconds = int(expires_in)\n except Exception:\n expires_in_seconds = 6 * 60 * 60\n\n refreshed = {\n \"access_token\": str(payload.get(\"access_token\", \"\") or \"\").strip(),\n \"refresh_token\": str(payload.get(\"refresh_token\", refresh_token) or refresh_token).strip(),\n \"token_type\": str(payload.get(\"token_type\", tokens.get(\"token_type\", \"Bearer\")) or \"Bearer\").strip() or \"Bearer\",\n \"resource_url\": str(payload.get(\"resource_url\", tokens.get(\"resource_url\", \"portal.qwen.ai\")) or \"portal.qwen.ai\").strip(),\n \"expiry_date\": int(time.time() * 1000) + max(1, expires_in_seconds) * 1000,\n }\n _save_qwen_cli_tokens(refreshed)\n return refreshed\n\n\ndef resolve_qwen_runtime_credentials(\n *,\n force_refresh: bool = False,\n refresh_if_expiring: bool = True,\n refresh_skew_seconds: int = QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,\n) -> Dict[str, Any]:\n tokens = _read_qwen_cli_tokens()\n access_token = str(tokens.get(\"access_token\", \"\") or \"\").strip()\n should_refresh = bool(force_refresh)\n if not should_refresh and refresh_if_expiring:\n should_refresh = _qwen_access_token_is_expiring(tokens.get(\"expiry_date\"), refresh_skew_seconds)\n if should_refresh:\n tokens = _refresh_qwen_cli_tokens(tokens)\n access_token = str(tokens.get(\"access_token\", \"\") or \"\").strip()\n if not access_token:\n raise AuthError(\n \"Qwen OAuth access token missing. Re-run 'qwen auth qwen-oauth'.\",\n provider=\"qwen-oauth\",\n code=\"qwen_access_token_missing\",\n )\n\n base_url = os.getenv(\"HERMES_QWEN_BASE_URL\", \"\").strip().rstrip(\"/\") or DEFAULT_QWEN_BASE_URL\n return {\n \"provider\": \"qwen-oauth\",\n \"base_url\": base_url,\n \"api_key\": access_token,\n \"source\": \"qwen-cli\",\n \"expires_at_ms\": tokens.get(\"expiry_date\"),\n \"auth_file\": str(_qwen_cli_auth_path()),\n }\n\n\ndef get_qwen_auth_status() -> Dict[str, Any]:\n auth_path = _qwen_cli_auth_path()\n try:\n creds = resolve_qwen_runtime_credentials(refresh_if_expiring=False)\n return {\n \"logged_in\": True,\n \"auth_file\": str(auth_path),\n \"source\": creds.get(\"source\"),\n \"api_key\": creds.get(\"api_key\"),\n \"expires_at_ms\": creds.get(\"expires_at_ms\"),\n }\n except AuthError as exc:\n return {\n \"logged_in\": False,\n \"auth_file\": str(auth_path),\n \"error\": str(exc),\n }\n\n\n# =============================================================================\n# Google Gemini OAuth (google-gemini-cli) — PKCE flow + Cloud Code Assist.\n#\n# Tokens live in ~/.hermes/auth/google_oauth.json (managed by agent.google_oauth).\n# The `base_url` here is the marker \"cloudcode-pa://google\" that run_agent.py\n# uses to construct a GeminiCloudCodeClient instead of the default OpenAI SDK.\n# Actual HTTP traffic goes to https://cloudcode-pa.googleapis.com/v1internal:*.\n# =============================================================================\n\ndef resolve_gemini_oauth_runtime_credentials(\n *,\n force_refresh: bool = False,\n) -> Dict[str, Any]:\n \"\"\"Resolve runtime OAuth creds for google-gemini-cli.\"\"\"\n try:\n from agent.google_oauth import (\n GoogleOAuthError,\n _credentials_path,\n get_valid_access_token,\n load_credentials,\n )\n except ImportError as exc:\n raise AuthError(\n f\"agent.google_oauth is not importable: {exc}\",\n provider=\"google-gemini-cli\",\n code=\"google_oauth_module_missing\",\n ) from exc\n\n try:\n access_token = get_valid_access_token(force_refresh=force_refresh)\n except GoogleOAuthError as exc:\n raise AuthError(\n str(exc),\n provider=\"google-gemini-cli\",\n code=exc.code,\n ) from exc\n\n creds = load_credentials()\n base_url = DEFAULT_GEMINI_CLOUDCODE_BASE_URL\n return {\n \"provider\": \"google-gemini-cli\",\n \"base_url\": base_url,\n \"api_key\": access_token,\n \"source\": \"google-oauth\",\n \"expires_at_ms\": (creds.expires_ms if creds else None),\n \"auth_file\": str(_credentials_path()),\n \"email\": (creds.email if creds else \"\") or \"\",\n \"project_id\": (creds.project_id if creds else \"\") or \"\",\n }\n\n\ndef get_gemini_oauth_auth_status() -> Dict[str, Any]:\n \"\"\"Return a status dict for `hermes auth list` / `hermes status`.\"\"\"\n try:\n from agent.google_oauth import _credentials_path, load_credentials\n except ImportError:\n return {\"logged_in\": False, \"error\": \"agent.google_oauth unavailable\"}\n auth_path = _credentials_path()\n creds = load_credentials()\n if creds is None or not creds.access_token:\n return {\n \"logged_in\": False,\n \"auth_file\": str(auth_path),\n \"error\": \"not logged in\",\n }\n return {\n \"logged_in\": True,\n \"auth_file\": str(auth_path),\n \"source\": \"google-oauth\",\n \"api_key\": creds.access_token,\n \"expires_at_ms\": creds.expires_ms,\n \"email\": creds.email,\n \"project_id\": creds.project_id,\n }\n# Spotify auth — PKCE tokens stored in ~/.hermes/auth.json\n# =============================================================================\n\n\ndef _spotify_scope_list(raw_scope: Optional[str] = None) -> List[str]:\n scope_text = (raw_scope or DEFAULT_SPOTIFY_SCOPE).strip()\n scopes = [part for part in scope_text.split() if part]\n seen: set[str] = set()\n ordered: List[str] = []\n for scope in scopes:\n if scope not in seen:\n seen.add(scope)\n ordered.append(scope)\n return ordered\n\n\ndef _spotify_scope_string(raw_scope: Optional[str] = None) -> str:\n return \" \".join(_spotify_scope_list(raw_scope))\n\n\ndef _spotify_client_id(\n explicit: Optional[str] = None,\n state: Optional[Dict[str, Any]] = None,\n) -> str:\n from hermes_cli.config import get_env_value\n\n candidates = (\n explicit,\n get_env_value(\"HERMES_SPOTIFY_CLIENT_ID\"),\n get_env_value(\"SPOTIFY_CLIENT_ID\"),\n state.get(\"client_id\") if isinstance(state, dict) else None,\n )\n for candidate in candidates:\n cleaned = str(candidate or \"\").strip()\n if cleaned:\n return cleaned\n raise AuthError(\n \"Spotify client_id is required. Set HERMES_SPOTIFY_CLIENT_ID or pass --client-id.\",\n provider=\"spotify\",\n code=\"spotify_client_id_missing\",\n )\n\n\ndef _spotify_redirect_uri(\n explicit: Optional[str] = None,\n state: Optional[Dict[str, Any]] = None,\n) -> str:\n from hermes_cli.config import get_env_value\n\n candidates = (\n explicit,\n get_env_value(\"HERMES_SPOTIFY_REDIRECT_URI\"),\n get_env_value(\"SPOTIFY_REDIRECT_URI\"),\n state.get(\"redirect_uri\") if isinstance(state, dict) else None,\n DEFAULT_SPOTIFY_REDIRECT_URI,\n )\n for candidate in candidates:\n cleaned = str(candidate or \"\").strip()\n if cleaned:\n return cleaned\n return DEFAULT_SPOTIFY_REDIRECT_URI\n\n\ndef _spotify_api_base_url(state: Optional[Dict[str, Any]] = None) -> str:\n from hermes_cli.config import get_env_value\n\n candidates = (\n get_env_value(\"HERMES_SPOTIFY_API_BASE_URL\"),\n state.get(\"api_base_url\") if isinstance(state, dict) else None,\n DEFAULT_SPOTIFY_API_BASE_URL,\n )\n for candidate in candidates:\n cleaned = str(candidate or \"\").strip().rstrip(\"/\")\n if cleaned:\n return cleaned\n return DEFAULT_SPOTIFY_API_BASE_URL\n\n\ndef _spotify_accounts_base_url(state: Optional[Dict[str, Any]] = None) -> str:\n from hermes_cli.config import get_env_value\n\n candidates = (\n get_env_value(\"HERMES_SPOTIFY_ACCOUNTS_BASE_URL\"),\n state.get(\"accounts_base_url\") if isinstance(state, dict) else None,\n DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL,\n )\n for candidate in candidates:\n cleaned = str(candidate or \"\").strip().rstrip(\"/\")\n if cleaned:\n return cleaned\n return DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL\n\n\ndef _spotify_code_verifier(length: int = 64) -> str:\n raw = base64.urlsafe_b64encode(os.urandom(length)).decode(\"ascii\")\n return raw.rstrip(\"=\")[:128]\n\n\ndef _spotify_code_challenge(code_verifier: str) -> str:\n digest = hashlib.sha256(code_verifier.encode(\"utf-8\")).digest()\n return base64.urlsafe_b64encode(digest).decode(\"ascii\").rstrip(\"=\")\n\n\ndef _spotify_build_authorize_url(\n *,\n client_id: str,\n redirect_uri: str,\n scope: str,\n state: str,\n code_challenge: str,\n accounts_base_url: str,\n) -> str:\n query = urlencode({\n \"client_id\": client_id,\n \"response_type\": \"code\",\n \"redirect_uri\": redirect_uri,\n \"scope\": scope,\n \"state\": state,\n \"code_challenge_method\": \"S256\",\n \"code_challenge\": code_challenge,\n })\n return f\"{accounts_base_url}/authorize?{query}\"\n\n\ndef _spotify_validate_redirect_uri(redirect_uri: str) -> tuple[str, int, str]:\n parsed = urlparse(redirect_uri)\n if parsed.scheme != \"http\":\n raise AuthError(\n \"Spotify PKCE redirect_uri must use http://localhost or http://127.0.0.1.\",\n provider=\"spotify\",\n code=\"spotify_redirect_invalid\",\n )\n host = parsed.hostname or \"\"\n if host not in {\"127.0.0.1\", \"localhost\"}:\n raise AuthError(\n \"Spotify PKCE redirect_uri must point to localhost or 127.0.0.1.\",\n provider=\"spotify\",\n code=\"spotify_redirect_invalid\",\n )\n if not parsed.port:\n raise AuthError(\n \"Spotify PKCE redirect_uri must include an explicit localhost port.\",\n provider=\"spotify\",\n code=\"spotify_redirect_invalid\",\n )\n return host, parsed.port, parsed.path or \"/\"\n\n\ndef _make_spotify_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequestHandler], dict[str, Any]]:\n result: dict[str, Any] = {\n \"code\": None,\n \"state\": None,\n \"error\": None,\n \"error_description\": None,\n }\n\n class _SpotifyCallbackHandler(BaseHTTPRequestHandler):\n def do_GET(self) -> None: # noqa: N802\n parsed = urlparse(self.path)\n if parsed.path != expected_path:\n self.send_response(404)\n self.end_headers()\n self.wfile.write(b\"Not found.\")\n return\n\n params = parse_qs(parsed.query)\n result[\"code\"] = params.get(\"code\", [None])[0]\n result[\"state\"] = params.get(\"state\", [None])[0]\n result[\"error\"] = params.get(\"error\", [None])[0]\n result[\"error_description\"] = params.get(\"error_description\", [None])[0]\n\n self.send_response(200)\n self.send_header(\"Content-Type\", \"text/html; charset=utf-8\")\n self.end_headers()\n if result[\"error\"]:\n body = \"