From 87d3fd38ee74de39ea5c5e1e6580c08caba07281 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:18:45 -0700 Subject: [PATCH 1/7] refactor(hermes_cli): auth.py registry as row table, dedupe secret/prefix checks, compact auth-store layer --- hermes_cli/auth.py | 1046 ++++++++++++++------------------------------ 1 file changed, 334 insertions(+), 712 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index d57845b1ed..0cb8bc1a3a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1,17 +1,13 @@ """Multi-provider authentication system for Hermes Agent. -Architecture: - ``ProviderConfig`` / ``PROVIDER_REGISTRY`` describe every known inference provider. -- The auth store (``~/.hermes/auth.json``) holds per-provider credential state, the credential - pool and suppression markers; ``_auth_store_lock`` / ``_load_auth_store`` / ``_save_auth_store`` - are the only I/O primitives (cross-process flock, atomic 0o600 writes). +- The auth store (``~/.hermes/auth.json``) holds per-provider state, the credential pool and + suppression markers; ``_auth_store_lock`` / ``_load_auth_store`` / ``_save_auth_store`` are the + only I/O primitives (cross-process flock, atomic 0o600 writes). - ``resolve_provider()`` picks the active provider via the documented priority chain. -- ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its runtime-credential resolver, status - builder and terminal-refresh error codes; the flows themselves live in sibling modules - (``auth_nous``, ``auth_codex``, ``auth_xai``, ``auth_qwen``, ``auth_minimax``, ``auth_spotify``) - and are re-imported here so ``hermes_cli.auth.`` stays the public/patchable surface. -- ``get_auth_status()`` / ``resolve_api_key_provider_credentials()`` / ``logout_command()`` are - the generic entry points the CLI, gateway and dashboard call. +- ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its resolver/status builder; the flows live in + ``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify`` and are + re-imported here so ``hermes_cli.auth.`` stays the public/patchable surface. """ from __future__ import annotations @@ -146,11 +142,8 @@ def is_actual_local_base_url(base_url: str) -> bool: def normalize_actual_base_url(base_url: str) -> str: - """Return Actual's OpenAI-compatible base URL. - - Hosted inference lives at api.actual.inc; the Actual client's offline local server binds a - loopback host. Both expose a /v1 surface for the Responses transport. - """ + """Return Actual's OpenAI-compatible base URL (hosted api.actual.inc or the loopback local server; + both expose a /v1 surface for the Responses transport).""" url = str(base_url or "").strip().rstrip("/") if not url: return DEFAULT_ACTUAL_BASE_URL @@ -160,9 +153,7 @@ def normalize_actual_base_url(base_url: str) -> str: path = parsed.path.rstrip("/") except Exception: return url - if host == "api.actual.inc" and path in {"", "/"}: - return url + "/v1" - if is_actual_local_base_url(url) and path in {"", "/"}: + if path in {"", "/"} and (host == "api.actual.inc" or is_actual_local_base_url(url)): return url + "/v1" return url @@ -176,226 +167,115 @@ class ProviderConfig: """Describes a known inference provider.""" id: str name: str - auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "api_key" + auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", "api_key", ... portal_base_url: str = "" inference_base_url: str = "" client_id: str = "" scope: str = "" extra: Dict[str, Any] = field(default_factory=dict) - # For API-key providers: env vars to check (in priority order) - api_key_env_vars: tuple = () - # Optional env var for base URL override - base_url_env_var: str = "" + api_key_env_vars: tuple = () # API-key providers: env vars to check, in priority order + base_url_env_var: str = "" # optional env var overriding the base URL def _api_key_provider( - id: str, - name: str, - inference_base_url: str, - api_key_env_vars: tuple, - base_url_env_var: str = "", - *, - auth_type: str = "api_key", + id: str, name: str, inference_base_url: str, api_key_env_vars: tuple, + base_url_env_var: str = "", auth_type: str = "api_key", ) -> ProviderConfig: """Compact constructor for the common env-var-keyed provider shape.""" return ProviderConfig( - id=id, - name=name, - auth_type=auth_type, - inference_base_url=inference_base_url, - api_key_env_vars=api_key_env_vars, - base_url_env_var=base_url_env_var, + id=id, name=name, auth_type=auth_type, inference_base_url=inference_base_url, + api_key_env_vars=api_key_env_vars, base_url_env_var=base_url_env_var, ) -PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { - "nous": ProviderConfig( - id="nous", - name="Nous Portal", - auth_type="oauth_device_code", - portal_base_url=DEFAULT_NOUS_PORTAL_URL, - inference_base_url=DEFAULT_NOUS_INFERENCE_URL, - client_id=DEFAULT_NOUS_CLIENT_ID, +# Registry rows in priority order (resolve_provider() scans api_key rows in this order). A tuple +# row is ``_api_key_provider(id, name, inference_base_url, api_key_env_vars[, base_url_env_var +# [, auth_type]])``; OAuth / bespoke rows are full ``ProviderConfig`` objects. +_REGISTRY_ROWS: Tuple[Any, ...] = ( + ProviderConfig( + "nous", "Nous Portal", "oauth_device_code", portal_base_url=DEFAULT_NOUS_PORTAL_URL, + inference_base_url=DEFAULT_NOUS_INFERENCE_URL, client_id=DEFAULT_NOUS_CLIENT_ID, scope=DEFAULT_NOUS_SCOPE, ), - "openai-codex": ProviderConfig( - id="openai-codex", - name="OpenAI Codex", - auth_type="oauth_external", - inference_base_url=DEFAULT_CODEX_BASE_URL, - ), - "openai-api": _api_key_provider( - "openai-api", "OpenAI API", "https://api.openai.com/v1", - ("OPENAI_API_KEY",), "OPENAI_BASE_URL", - ), - "xai-oauth": ProviderConfig( - id="xai-oauth", - name="xAI Grok OAuth (SuperGrok / Premium+)", - auth_type="oauth_external", + ProviderConfig("openai-codex", "OpenAI Codex", "oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL), + ("openai-api", "OpenAI API", "https://api.openai.com/v1", ("OPENAI_API_KEY",), "OPENAI_BASE_URL"), + ProviderConfig( + "xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "oauth_external", inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL, ), - "qwen-oauth": ProviderConfig( - id="qwen-oauth", - name="Qwen OAuth", - auth_type="oauth_external", - inference_base_url=DEFAULT_QWEN_BASE_URL, + ProviderConfig("qwen-oauth", "Qwen OAuth", "oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL), + ("lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", ("LM_API_KEY",), "LM_BASE_URL"), + ("copilot", "GitHub Copilot", DEFAULT_GITHUB_MODELS_BASE_URL, + ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), "COPILOT_API_BASE_URL"), + ProviderConfig( + "copilot-acp", "GitHub Copilot ACP", "external_process", + inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL", ), - "lmstudio": _api_key_provider( - "lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", - ("LM_API_KEY",), "LM_BASE_URL", - ), - "copilot": _api_key_provider( - "copilot", "GitHub Copilot", DEFAULT_GITHUB_MODELS_BASE_URL, - ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), "COPILOT_API_BASE_URL", - ), - "copilot-acp": ProviderConfig( - id="copilot-acp", - name="GitHub Copilot ACP", - auth_type="external_process", - inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, - base_url_env_var="COPILOT_ACP_BASE_URL", - ), - "gemini": _api_key_provider( - "gemini", "Google AI Studio", "https://generativelanguage.googleapis.com/v1beta", - ("GOOGLE_API_KEY", "GEMINI_API_KEY"), "GEMINI_BASE_URL", - ), - "zai": _api_key_provider( - "zai", "Z.AI / GLM", "https://api.z.ai/api/paas/v4", - ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "GLM_BASE_URL", - ), - # Legacy platform.moonshot.ai keys use this endpoint (OpenAI-compat). - # sk-kimi- (Kimi Code) keys are auto-redirected to api.kimi.com/coding - # by _resolve_kimi_base_url() below. - "kimi-coding": _api_key_provider( - "kimi-coding", "Kimi / Moonshot", "https://api.moonshot.ai/v1", - ("KIMI_API_KEY", "KIMI_CODING_API_KEY"), "KIMI_BASE_URL", - ), - "kimi-coding-cn": _api_key_provider( - "kimi-coding-cn", "Kimi / Moonshot (China)", "https://api.moonshot.cn/v1", - ("KIMI_CN_API_KEY",), - ), - "stepfun": _api_key_provider( - "stepfun", "StepFun Step Plan", STEPFUN_STEP_PLAN_INTL_BASE_URL, - ("STEPFUN_API_KEY",), "STEPFUN_BASE_URL", - ), - "arcee": _api_key_provider( - "arcee", "Arcee AI", "https://api.arcee.ai/api/v1", - ("ARCEEAI_API_KEY",), "ARCEE_BASE_URL", - ), - "gmi": _api_key_provider( - "gmi", "GMI Cloud", "https://api.gmi-serving.com/v1", - ("GMI_API_KEY",), "GMI_BASE_URL", - ), - "actual": _api_key_provider( - "actual", "Actual Computer", DEFAULT_ACTUAL_BASE_URL, - ("ACTUAL_API_KEY",), "ACTUAL_BASE_URL", - ), - "minimax": _api_key_provider( - "minimax", "MiniMax", "https://api.minimax.io/anthropic", - ("MINIMAX_API_KEY",), "MINIMAX_BASE_URL", - ), - "minimax-oauth": ProviderConfig( - id="minimax-oauth", - name="MiniMax (OAuth \u00b7 minimax.io)", - auth_type="oauth_minimax", - portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, - inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE, - client_id=MINIMAX_OAUTH_CLIENT_ID, - scope=MINIMAX_OAUTH_SCOPE, + ("gemini", "Google AI Studio", "https://generativelanguage.googleapis.com/v1beta", + ("GOOGLE_API_KEY", "GEMINI_API_KEY"), "GEMINI_BASE_URL"), + ("zai", "Z.AI / GLM", "https://api.z.ai/api/paas/v4", + ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "GLM_BASE_URL"), + # Legacy platform.moonshot.ai keys use this endpoint (OpenAI-compat); sk-kimi- (Kimi Code) + # keys are auto-redirected to api.kimi.com/coding by _resolve_kimi_base_url(). + ("kimi-coding", "Kimi / Moonshot", "https://api.moonshot.ai/v1", + ("KIMI_API_KEY", "KIMI_CODING_API_KEY"), "KIMI_BASE_URL"), + ("kimi-coding-cn", "Kimi / Moonshot (China)", "https://api.moonshot.cn/v1", ("KIMI_CN_API_KEY",)), + ("stepfun", "StepFun Step Plan", STEPFUN_STEP_PLAN_INTL_BASE_URL, ("STEPFUN_API_KEY",), "STEPFUN_BASE_URL"), + ("arcee", "Arcee AI", "https://api.arcee.ai/api/v1", ("ARCEEAI_API_KEY",), "ARCEE_BASE_URL"), + ("gmi", "GMI Cloud", "https://api.gmi-serving.com/v1", ("GMI_API_KEY",), "GMI_BASE_URL"), + ("actual", "Actual Computer", DEFAULT_ACTUAL_BASE_URL, ("ACTUAL_API_KEY",), "ACTUAL_BASE_URL"), + ("minimax", "MiniMax", "https://api.minimax.io/anthropic", ("MINIMAX_API_KEY",), "MINIMAX_BASE_URL"), + ProviderConfig( + "minimax-oauth", "MiniMax (OAuth \u00b7 minimax.io)", "oauth_minimax", + portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE, + client_id=MINIMAX_OAUTH_CLIENT_ID, scope=MINIMAX_OAUTH_SCOPE, extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE, "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE}, ), - # CLAUDE_CODE_OAUTH_TOKEN is NOT an API key, despite auth_type="api_key" - # and its place in this tuple (#82154). `claude setup-token` yields an - # `sk-ant-oat01…` OAuth token: sent as `x-api-key` it 401s, and sent as a - # bare Bearer it 429s. It is listed here because this tuple doubles as the - # credential-DISCOVERY list (agent/credential_pool.py builds its env scan - # from it), so removing it would stop Hermes finding a setup-token - # credential at all. The adapter routes such a value down the OAuth path - # on the strength of its prefix, not on this entry. Only ANTHROPIC_API_KEY - # and ANTHROPIC_TOKEN are usable as literal API keys. - "anthropic": _api_key_provider( - "anthropic", "Anthropic", "https://api.anthropic.com", - ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), "ANTHROPIC_BASE_URL", - ), - "alibaba": _api_key_provider( - "alibaba", "Qwen Cloud", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", - ("DASHSCOPE_API_KEY",), "DASHSCOPE_BASE_URL", - ), - "alibaba-coding-plan": _api_key_provider( - "alibaba-coding-plan", "Alibaba Cloud (Coding Plan)", "https://coding-intl.dashscope.aliyuncs.com/v1", - ("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"), "ALIBABA_CODING_PLAN_BASE_URL", - ), - "minimax-cn": _api_key_provider( - "minimax-cn", "MiniMax (China)", "https://api.minimaxi.com/anthropic", - ("MINIMAX_CN_API_KEY",), "MINIMAX_CN_BASE_URL", - ), - "deepseek": _api_key_provider( - "deepseek", "DeepSeek", "https://api.deepseek.com/v1", - ("DEEPSEEK_API_KEY",), "DEEPSEEK_BASE_URL", - ), - "xai": _api_key_provider("xai", "xAI", "https://api.x.ai/v1", ("XAI_API_KEY",), "XAI_BASE_URL"), - "nvidia": _api_key_provider( - "nvidia", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", - ("NVIDIA_API_KEY",), "NVIDIA_BASE_URL", - ), - "ai-gateway": _api_key_provider( - "ai-gateway", "Vercel AI Gateway", "https://ai-gateway.vercel.sh/v1", - ("AI_GATEWAY_API_KEY",), "AI_GATEWAY_BASE_URL", - ), - "opencode-zen": _api_key_provider( - "opencode-zen", "OpenCode Zen", "https://opencode.ai/zen/v1", - ("OPENCODE_ZEN_API_KEY",), "OPENCODE_ZEN_BASE_URL", - ), - # OpenCode Go mixes API surfaces by model: - # - GLM / Kimi use OpenAI-compatible chat completions under /v1 - # - MiniMax models use Anthropic Messages under /v1/messages - # - Qwen 3.7 uses Anthropic Messages under /v1/messages - # Keep the provider base at /v1 and select api_mode per-model. - "opencode-go": _api_key_provider( - "opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", - ("OPENCODE_GO_API_KEY",), "OPENCODE_GO_BASE_URL", - ), - # Deliberately NO api_key_env_vars: the free tier is served - # anonymously (any unrecognized bearer is a 401), so there is no - # secret to configure. Select via `hermes model` / `/model free`. - "opencode-free": _api_key_provider("opencode-free", "OpenCode Free", "https://opencode.ai/zen/v1", ()), - "kilocode": _api_key_provider( - "kilocode", "Kilo Code", "https://api.kilo.ai/api/gateway", - ("KILOCODE_API_KEY",), "KILOCODE_BASE_URL", - ), - "huggingface": _api_key_provider( - "huggingface", "Hugging Face", "https://router.huggingface.co/v1", - ("HF_TOKEN",), "HF_BASE_URL", - ), - "xiaomi": _api_key_provider( - "xiaomi", "Xiaomi MiMo", "https://api.xiaomimimo.com/v1", - ("XIAOMI_API_KEY",), "XIAOMI_BASE_URL", - ), - "tencent-tokenhub": _api_key_provider( - "tencent-tokenhub", "Tencent TokenHub", "https://tokenhub.tencentmaas.com/v1", - ("TOKENHUB_API_KEY",), "TOKENHUB_BASE_URL", - ), - "tencent-tokenplan": _api_key_provider( - "tencent-tokenplan", "Tencent TokenPlan", "https://api.lkeap.cloud.tencent.com/plan/anthropic", - ("TOKENPLAN_API_KEY",), "TOKENPLAN_BASE_URL", - ), - "ollama-cloud": _api_key_provider( - "ollama-cloud", "Ollama Cloud", DEFAULT_OLLAMA_CLOUD_BASE_URL, - ("OLLAMA_API_KEY",), "OLLAMA_BASE_URL", - ), - "bedrock": _api_key_provider( - "bedrock", "AWS Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", - (), "BEDROCK_BASE_URL", auth_type="aws_sdk", - ), - # No static inference_base_url: Vertex's endpoint is computed per - # request from project_id + region (agent/vertex_adapter.py's - # build_vertex_base_url), not a fixed host like the other entries. - "vertex": _api_key_provider("vertex", "Google Vertex AI", "", (), auth_type="vertex"), - "azure-foundry": _api_key_provider( - "azure-foundry", "Azure Foundry", "", - ("AZURE_FOUNDRY_API_KEY",), "AZURE_FOUNDRY_BASE_URL", - ), + # CLAUDE_CODE_OAUTH_TOKEN is NOT an API key despite auth_type="api_key": `claude setup-token` + # yields an `sk-ant-oat01…` OAuth token (401s as x-api-key, 429s as bare Bearer). It stays in + # this tuple because the tuple doubles as the credential-DISCOVERY list (agent/credential_pool.py + # builds its env scan from it); the adapter routes it down the OAuth path by prefix. Only + # ANTHROPIC_API_KEY and ANTHROPIC_TOKEN are usable as literal API keys. + ("anthropic", "Anthropic", "https://api.anthropic.com", + ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), "ANTHROPIC_BASE_URL"), + ("alibaba", "Qwen Cloud", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + ("DASHSCOPE_API_KEY",), "DASHSCOPE_BASE_URL"), + ("alibaba-coding-plan", "Alibaba Cloud (Coding Plan)", "https://coding-intl.dashscope.aliyuncs.com/v1", + ("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY"), "ALIBABA_CODING_PLAN_BASE_URL"), + ("minimax-cn", "MiniMax (China)", "https://api.minimaxi.com/anthropic", ("MINIMAX_CN_API_KEY",), + "MINIMAX_CN_BASE_URL"), + ("deepseek", "DeepSeek", "https://api.deepseek.com/v1", ("DEEPSEEK_API_KEY",), "DEEPSEEK_BASE_URL"), + ("xai", "xAI", "https://api.x.ai/v1", ("XAI_API_KEY",), "XAI_BASE_URL"), + ("nvidia", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", ("NVIDIA_API_KEY",), "NVIDIA_BASE_URL"), + ("ai-gateway", "Vercel AI Gateway", "https://ai-gateway.vercel.sh/v1", ("AI_GATEWAY_API_KEY",), + "AI_GATEWAY_BASE_URL"), + ("opencode-zen", "OpenCode Zen", "https://opencode.ai/zen/v1", ("OPENCODE_ZEN_API_KEY",), + "OPENCODE_ZEN_BASE_URL"), + # OpenCode Go mixes API surfaces by model (GLM/Kimi: OpenAI chat under /v1; MiniMax and + # Qwen 3.7: Anthropic Messages under /v1/messages). Keep the base at /v1; api_mode is per-model. + ("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", ("OPENCODE_GO_API_KEY",), + "OPENCODE_GO_BASE_URL"), + # Deliberately NO api_key_env_vars: the free tier is served anonymously (any unrecognized + # bearer is a 401), so there is no secret to configure. Select via `hermes model` / `/model free`. + ("opencode-free", "OpenCode Free", "https://opencode.ai/zen/v1", ()), + ("kilocode", "Kilo Code", "https://api.kilo.ai/api/gateway", ("KILOCODE_API_KEY",), "KILOCODE_BASE_URL"), + ("huggingface", "Hugging Face", "https://router.huggingface.co/v1", ("HF_TOKEN",), "HF_BASE_URL"), + ("xiaomi", "Xiaomi MiMo", "https://api.xiaomimimo.com/v1", ("XIAOMI_API_KEY",), "XIAOMI_BASE_URL"), + ("tencent-tokenhub", "Tencent TokenHub", "https://tokenhub.tencentmaas.com/v1", ("TOKENHUB_API_KEY",), + "TOKENHUB_BASE_URL"), + ("tencent-tokenplan", "Tencent TokenPlan", "https://api.lkeap.cloud.tencent.com/plan/anthropic", + ("TOKENPLAN_API_KEY",), "TOKENPLAN_BASE_URL"), + ("ollama-cloud", "Ollama Cloud", DEFAULT_OLLAMA_CLOUD_BASE_URL, ("OLLAMA_API_KEY",), "OLLAMA_BASE_URL"), + ("bedrock", "AWS Bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", (), "BEDROCK_BASE_URL", + "aws_sdk"), + # No static inference_base_url: Vertex's endpoint is computed per request from project_id + + # region (agent/vertex_adapter.py build_vertex_base_url), not a fixed host. + ("vertex", "Google Vertex AI", "", (), "", "vertex"), + ("azure-foundry", "Azure Foundry", "", ("AZURE_FOUNDRY_API_KEY",), "AZURE_FOUNDRY_BASE_URL"), +) +PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { + p.id: p for p in (r if isinstance(r, ProviderConfig) else _api_key_provider(*r) for r in _REGISTRY_ROWS) } # Providers handled outside the registry: copilot/kimi/zai have bespoke token refresh here; @@ -412,17 +292,13 @@ def _register_plugin_provider(pp: Any) -> None: (otherwise ``hermes -m `` dies with "Unknown provider" before a client is built). """ if pp.auth_type == "external_process": - pconfig = ProviderConfig( - id=pp.name, name=pp.display_name or pp.name, - auth_type="external_process", inference_base_url=pp.base_url, - ) + pconfig = ProviderConfig(pp.name, pp.display_name or pp.name, "external_process", inference_base_url=pp.base_url) elif pp.auth_type == "api_key" and pp.env_vars and pp.name not in _REGISTRY_PLUGIN_SKIP: is_url = lambda v: v.endswith("_BASE_URL") or v.endswith("_URL") # noqa: E731 - pconfig = ProviderConfig( - id=pp.name, name=pp.display_name or pp.name, auth_type="api_key", - inference_base_url=pp.base_url, - api_key_env_vars=tuple(v for v in pp.env_vars if not is_url(v)) or pp.env_vars, - base_url_env_var=next((v for v in pp.env_vars if is_url(v)), None) or "", + pconfig = _api_key_provider( + pp.name, pp.display_name or pp.name, pp.base_url, + tuple(v for v in pp.env_vars if not is_url(v)) or pp.env_vars, + next((v for v in pp.env_vars if is_url(v)), None) or "", ) else: return @@ -440,25 +316,14 @@ except Exception: pass -# ============================================================================= -# Anthropic Key Helper -# ============================================================================= - def get_anthropic_key() -> str: - """Return the first usable Anthropic credential, or ``""``. + """First usable Anthropic credential (``.env`` preferred over a stale shell export), or ``""``. - Checks both the ``.env`` file and the process environment, preferring ``~/.hermes/.env`` so a - deliberate key rotation isn't shadowed by a stale shell export (matches the api-key resolution - path — see #20591). The order mirrors the ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars`` - tuple: + Order mirrors ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars``. """ from hermes_cli.config import get_env_value_prefer_dotenv - for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars: - value = get_env_value_prefer_dotenv(var) or "" - if value: - return value - return "" + return next((v for v in (get_env_value_prefer_dotenv(var) or "" for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars) if v), "") # ============================================================================= @@ -466,18 +331,8 @@ def get_anthropic_key() -> str: # ============================================================================= _PLACEHOLDER_SECRET_VALUES = { - "*", - "**", - "***", - "changeme", - "your_api_key", - "your_api_key_here", - "your-api-key", - "placeholder", - "example", - "dummy", - "null", - "none", + "*", "**", "***", "changeme", "your_api_key", "your_api_key_here", "your-api-key", + "placeholder", "example", "dummy", "null", "none", } @@ -486,52 +341,48 @@ def has_usable_secret(value: Any, *, min_length: int = 4) -> bool: if not isinstance(value, str): return False cleaned = value.strip() - if len(cleaned) < min_length: - return False - return cleaned.lower() not in _PLACEHOLDER_SECRET_VALUES + return len(cleaned) >= min_length and cleaned.lower() not in _PLACEHOLDER_SECRET_VALUES -# Known API-key prefixes per provider. Only providers listed here get -# prefix validation; everyone else is fail-open (unknown formats pass). -# This exists so an obviously malformed key in .env (truncated paste, wrong -# provider's key in the wrong var, etc.) doesn't silently shadow a valid -# credential-pool entry and produce opaque 401s (#93593). +# Known API-key prefixes per provider. Only listed providers get prefix validation; everyone else +# is fail-open. Keeps an obviously malformed key in .env (truncated paste, wrong provider's key) +# from silently shadowing a valid credential-pool entry and producing opaque 401s. KNOWN_PROVIDER_KEY_PREFIXES: Dict[str, tuple] = { - # All OpenRouter keys are issued as sk-or-... (currently sk-or-v1-). - "openrouter": ("sk-or-",), + "openrouter": ("sk-or-",), # all OpenRouter keys are sk-or-... (currently sk-or-v1-) } def _secret_matches_declared_prefix(provider_id: str, value: str) -> bool: - """Return False only when the provider declares key prefixes and none match. - - Providers without a declared prefix always pass (fail-open): we never hard-reject unknown key - formats, only skip values that provably don't belong to a provider whose key format we know. - """ + """False only when the provider declares key prefixes and none match (fail-open otherwise).""" prefixes = KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id) - if not prefixes: - return True - return any(value.startswith(p) for p in prefixes) + return not prefixes or any(value.startswith(p) for p in prefixes) def _warn_malformed_secret(provider_id: str, source: str) -> None: - prefixes = KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id, ()) logger.warning( "Ignoring %s for provider %r: value does not match the expected key " "prefix (%s). Falling back to the next credential source. Fix or " "remove the malformed key to silence this warning.", - source, - provider_id, - " or ".join(prefixes), + source, provider_id, " or ".join(KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id, ())), ) -def _resolve_api_key_provider_secret( - provider_id: str, pconfig: ProviderConfig -) -> tuple[str, str]: +def _usable_declared_secret(provider_id: str, value: Any, source: str) -> Optional[str]: + """*value* stripped when it is a usable, prefix-valid secret; None (after warning on a provable + prefix mismatch, so it never shadows a later credential source) otherwise.""" + val = str(value or "").strip() + if not has_usable_secret(val): + return None + if not _secret_matches_declared_prefix(provider_id, val): + _warn_malformed_secret(provider_id, source) + return None + return val + + +def _resolve_api_key_provider_secret(provider_id: str, pconfig: ProviderConfig) -> tuple[str, str]: """Resolve an API-key provider's token and indicate where it came from.""" if provider_id == "copilot": - # Use the dedicated copilot auth module for proper token validation + # The dedicated copilot auth module does proper token validation/exchange. try: from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token token, source = resolve_copilot_token() @@ -544,29 +395,21 @@ def _resolve_api_key_provider_secret( pass return "", "" + # Prefer ~/.hermes/.env over os.environ so a deliberate key rotation in .env isn't shadowed by + # a stale shell export inherited from a parent process (Codex CLI, test runners, etc.). from hermes_cli.config import get_env_value_prefer_dotenv for env_var in pconfig.api_key_env_vars: - # Prefer ~/.hermes/.env over os.environ so a deliberate key rotation - # in the user's .env file isn't shadowed by a stale shell export - # inherited from a parent process (Codex CLI, test runners, etc.). - val = (get_env_value_prefer_dotenv(env_var) or "").strip() - if not has_usable_secret(val): - continue - if not _secret_matches_declared_prefix(provider_id, val): - # A provably malformed key (declared prefix mismatch) must not - # shadow a valid credential-pool entry (#93593). Warn and keep - # looking instead of returning it. - _warn_malformed_secret(provider_id, env_var) - continue - return val, env_var + val = _usable_declared_secret(provider_id, get_env_value_prefer_dotenv(env_var), env_var) + if val: + return val, env_var - # Fallback: try credential pool (e.g. zai key stored via auth.json) + # Fallback: credential pool (e.g. zai key stored via auth.json). Prefer the pool's own + # selection (peek) but try the rest too so one malformed entry doesn't block a valid one. + pool_source = f"credential_pool:{provider_id}" try: from agent.credential_pool import load_pool pool = load_pool(provider_id) if pool and pool.has_credentials(): - # Prefer the pool's own selection (peek), but iterate the rest of - # the entries too so one malformed entry doesn't block a valid one. candidates = [] entry = pool.peek() if entry is not None: @@ -579,13 +422,9 @@ def _resolve_api_key_provider_secret( pass for entry in candidates: key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "") - key = str(key).strip() - if not has_usable_secret(key): - continue - if not _secret_matches_declared_prefix(provider_id, key): - _warn_malformed_secret(provider_id, f"credential_pool:{provider_id}") - continue - return key, f"credential_pool:{provider_id}" + val = _usable_declared_secret(provider_id, key, pool_source) + if val: + return val, pool_source except Exception: pass @@ -597,46 +436,13 @@ def _resolve_api_key_provider_secret( # ============================================================================= def is_rate_limited_auth_error(error: Exception) -> bool: - """True when an :class:`AuthError` represents upstream rate-limiting / quota - - These failures are transient and re-authenticating cannot fix them, so callers should show a - "retry later" notice and prefer a fallback chain instead of suggesting ``hermes auth``. - """ - return ( - isinstance(error, AuthError) - and not error.relogin_required - and error.code == CODEX_RATE_LIMITED_CODE - ) + """True when an :class:`AuthError` is upstream rate-limiting / quota: transient, and + re-authenticating cannot fix it, so callers should say "retry later", not ``hermes auth``.""" + return isinstance(error, AuthError) and not error.relogin_required and error.code == CODEX_RATE_LIMITED_CODE -def format_auth_error(error: Exception) -> str: - """Map auth failures to concise user-facing guidance.""" - if not isinstance(error, AuthError): - return str(error) - - # Rate-limit / quota errors are not credential problems — never append the - # "re-authenticate" remediation, which would mislead the operator. - if is_rate_limited_auth_error(error): - return str(error) - - if error.relogin_required: - return f"{error} Run `hermes model` to re-authenticate." - - if error.code in _ENTITLEMENT_ERROR_CODES: - if error.provider == "nous": - return _format_nous_entitlement_auth_error(error) - generic = _GENERIC_ENTITLEMENT_MESSAGES.get(error.code) - if generic: - return generic - - if error.code == "temporarily_unavailable": - return f"{error} Please retry in a few seconds." - - return str(error) - - -# Entitlement failures: Nous gets a Portal-aware message; other providers a fixed -# generic one (or the raw error when no generic text exists for the code). +# Entitlement failures: Nous gets a Portal-aware message; other providers a fixed generic one (or the +# raw error when no generic text exists for the code). _GENERIC_ENTITLEMENT_MESSAGES = { "subscription_required": "No active paid subscription found. Please purchase/activate a subscription, then retry.", "insufficient_credits": "Subscription credits are exhausted. Top up/renew credits, then retry.", @@ -646,6 +452,24 @@ _ENTITLEMENT_ERROR_CODES = frozenset(_GENERIC_ENTITLEMENT_MESSAGES) | { } +def format_auth_error(error: Exception) -> str: + """Map auth failures to concise user-facing guidance.""" + if not isinstance(error, AuthError) or is_rate_limited_auth_error(error): + # Rate-limit / quota errors are not credential problems: never append "re-authenticate". + return str(error) + if error.relogin_required: + return f"{error} Run `hermes model` to re-authenticate." + if error.code in _ENTITLEMENT_ERROR_CODES: + if error.provider == "nous": + return _format_nous_entitlement_auth_error(error) + generic = _GENERIC_ENTITLEMENT_MESSAGES.get(error.code) + if generic: + return generic + if error.code == "temporarily_unavailable": + return f"{error} Please retry in a few seconds." + return str(error) + + def _nonempty_str(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) @@ -656,11 +480,9 @@ def _nonempty_str(value: Any) -> bool: def _auth_file_path() -> Path: path = get_hermes_home() / "auth.json" - # Seat belt: if pytest is running and HERMES_HOME resolves to the real - # user's auth store, refuse rather than silently corrupt it. This catches - # tests that forgot to monkeypatch HERMES_HOME, tests invoked without the - # hermetic conftest, or sandbox escapes via threads/subprocesses. In - # production (no PYTEST_CURRENT_TEST) this is a single dict lookup. + # Seat belt: under pytest, refuse to touch the real user's auth store (catches tests that forgot + # to monkeypatch HERMES_HOME or escaped the hermetic conftest). In production this is one dict + # lookup. if os.environ.get("PYTEST_CURRENT_TEST"): real_home_auth = (Path.home() / ".hermes" / "auth.json").resolve(strict=False) try: @@ -677,53 +499,35 @@ def _auth_file_path() -> Path: def _global_auth_file_path() -> Optional[Path]: - """Return the global-root auth.json when the process is in profile mode. + """Global-root auth.json in profile mode; None when profile and global root are the same dir + (classic mode, or a custom HERMES_HOME that is not a profile). - Returns ``None`` when the profile and global root resolve to the same directory (classic mode, - or custom HERMES_HOME that is not a profile). Used by read-only fallback paths so providers - authed at the root are visible to profile processes that haven't configured them locally. + Read-only fallback path, so no pytest seat belt here: ``_load_global_auth_store()`` wraps the read + in try/except; the write-side seat belt lives on ``_auth_file_path()``. """ try: from hermes_constants import get_default_hermes_root global_root = get_default_hermes_root() except Exception: return None - profile_home = get_hermes_home() - try: - if profile_home.resolve(strict=False) == global_root.resolve(strict=False): - return None - except Exception: - if profile_home == global_root: - return None - # No pytest seat belt here: this is a pure read-only path, and - # ``_load_global_auth_store()`` wraps the read in a try/except so an - # unreadable global file can never break the profile process. The - # write-side seat belt still lives on ``_auth_file_path()`` where it - # belongs (that's what protects the real user's auth store from being - # corrupted by a mis-configured test). - return global_root / "auth.json" + return None if _same_path(get_hermes_home(), global_root) else global_root / "auth.json" def _load_global_auth_store() -> Dict[str, Any]: - """Load the global-root auth store (read-only fallback). - - Returns an empty dict when no global fallback exists (classic mode, or the global auth.json is - absent). Never raises on missing file. - """ + """Load the global-root auth store (read-only fallback, mtime-memoised); ``{}`` when absent or + unreadable — a malformed global store must never break profile reads.""" global _global_auth_store_cache global_path = _global_auth_file_path() if global_path is None or not global_path.exists(): _global_auth_store_cache = None return {} try: - resolved_path = str(global_path.resolve(strict=False)) - mtime_ns = global_path.stat().st_mtime_ns - cache_key: Optional[Tuple[str, int]] = (resolved_path, mtime_ns) + cache_key: Optional[Tuple[str, int]] = (str(global_path.resolve(strict=False)), global_path.stat().st_mtime_ns) except Exception: cache_key = None if cache_key is not None and _global_auth_store_cache is not None: cached_path, cached_mtime, cached_store = _global_auth_store_cache - if cached_path == cache_key[0] and cached_mtime == cache_key[1]: + if (cached_path, cached_mtime) == cache_key: return cached_store if os.environ.get("PYTEST_CURRENT_TEST"): real_home_env = os.environ.get("HOME", "") @@ -738,8 +542,6 @@ def _load_global_auth_store() -> Dict[str, Any]: try: store = _load_auth_store(global_path) except Exception: - # A malformed global store must not break profile reads. The - # profile's own auth store is still authoritative. _global_auth_store_cache = None return {} if cache_key is not None: @@ -747,10 +549,6 @@ def _load_global_auth_store() -> Dict[str, Any]: return store -def _auth_lock_path() -> Path: - return _auth_file_path().with_suffix(".lock") - - _auth_target_lock_holders: Dict[str, threading.local] = {} _auth_target_lock_holders_guard = threading.Lock() @@ -773,18 +571,12 @@ def _auth_lock_holder_for(target_path: Path) -> threading.local: @contextmanager -def _file_lock( - lock_path: Path, - holder: threading.local, - timeout_seconds: float, - timeout_message: str, -): - """Cross-process advisory flock helper. +def _file_lock(lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str): + """Cross-process advisory flock helper, reentrant per-thread via ``holder.depth``. - Reentrant per-thread via ``holder.depth``. Falls back to a depth-only guard when neither - ``fcntl`` nor ``msvcrt`` is available (rare). Callers supply their own ``threading.local`` so - independent locks (e.g. the profile auth store vs the global root store vs the shared Nous - store) track reentrancy separately. + Falls back to a depth-only guard when neither ``fcntl`` nor ``msvcrt`` is available. Callers + supply their own ``threading.local`` so independent locks (profile store vs global root vs the + shared Nous store) track reentrancy separately. """ if getattr(holder, "depth", 0) > 0: holder.depth += 1 @@ -804,17 +596,9 @@ def _file_lock( holder.depth = 0 return - # On Windows, msvcrt.locking needs the file to have content and the - # file pointer at position 0. Ensure the lock file has at least 1 byte. - # Under real concurrency (many threads/processes racing this same - # ensure-content check) this write can collide with another holder's - # msvcrt byte-range lock on the same file and raise PermissionError -- - # uncaught, since it happens before the retry loop below even starts. - # A stress test with 20 concurrent Hermes processes reproduced this - # deterministically on Windows. It's a best-effort convenience write - # (whoever gets there first wins); losing the race here just means the - # lock file already has content, so swallow the failure and proceed - # straight to the acquire-with-retry loop. + # msvcrt.locking needs a non-empty file with the pointer at 0. This convenience write can race + # another holder's byte-range lock and raise PermissionError (reproduced with 20 concurrent + # processes on Windows); losing that race just means the file already has content, so swallow it. if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): try: lock_path.write_text(" ", encoding="utf-8") @@ -841,61 +625,50 @@ def _file_lock( yield finally: holder.depth = 0 - if fcntl: - try: + try: + if fcntl: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - except (OSError, IOError): - pass - elif msvcrt: - try: + elif msvcrt: lock_file.seek(0) msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) - except (OSError, IOError): - pass + except (OSError, IOError): + pass @contextmanager -def _auth_store_lock( - timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, - *, - target_path: Optional[Path] = None, -): +def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None): """Cross-process advisory lock for one auth.json read/write transaction. - ``target_path`` is required for profile-to-global write-throughs. A profile lock does not - protect the distinct global auth store; each path therefore uses its own reentrancy tracker and - kernel lock. + ``target_path`` is required for profile-to-global write-throughs: a profile lock does not protect + the distinct global store, so each path has its own reentrancy tracker and kernel lock. - Lock ordering invariant: when this lock is held together with ``_nous_shared_store_lock``, - acquire ``_auth_store_lock`` FIRST (outer) and the shared Nous lock SECOND (inner). All runtime - refresh paths follow this order; violating it risks deadlock against a concurrent import on the - shared store. + Lock ordering invariant: when held together with ``_nous_shared_store_lock``, acquire + ``_auth_store_lock`` FIRST (outer) and the shared Nous lock SECOND (inner). Violating it risks + deadlock against a concurrent import on the shared store. """ auth_path = target_path if target_path is not None else _auth_file_path() - lock_path = auth_path.with_suffix(".lock") if target_path is not None else _auth_lock_path() with _file_lock( - lock_path, - _auth_lock_holder_for(auth_path), - timeout_seconds, + auth_path.with_suffix(".lock"), _auth_lock_holder_for(auth_path), timeout_seconds, "Timed out waiting for auth store lock", ): yield +def _empty_auth_store() -> Dict[str, Any]: + return {"version": AUTH_STORE_VERSION, "providers": {}} + + def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: auth_file = auth_file or _auth_file_path() if not auth_file.exists(): - return {"version": AUTH_STORE_VERSION, "providers": {}} + return _empty_auth_store() try: raw = json.loads(auth_file.read_text(encoding="utf-8-sig")) except OSError: - # The file exists (checked above) but could not be READ: EMFILE under - # fd exhaustion, EACCES, EIO, a stalled network mount. None of those - # mean the contents are bad, and this module does read-modify-write in - # ~15 places, so degrading to an empty store here is one - # _save_auth_store() away from erasing every stored credential. - # Fail loudly instead and leave the file on disk untouched. + # The file exists but could not be READ (EMFILE, EACCES, EIO, stalled mount). The contents + # are not bad, and this module does read-modify-write in ~15 places, so degrading to an empty + # store here is one _save_auth_store() away from erasing every credential. Fail loudly. logger.warning( "auth: could not read %s, leaving the store on disk untouched " "rather than degrading to an empty one", @@ -903,73 +676,50 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: ) raise except Exception as exc: - # Genuine corruption: unparseable JSON, or bytes that are not UTF-8. + # Genuine corruption: unparseable JSON or non-UTF-8 bytes. Preserve a copy, but never + # advertise a backup that was not written. corrupt_path = auth_file.with_suffix(".json.corrupt") try: shutil.copy2(auth_file, corrupt_path) preserved = True except Exception: preserved = False - logger.debug( - "auth: could not preserve a copy of the corrupt store at %s", - corrupt_path, exc_info=True, - ) - # Never advertise a backup that was not written. + logger.debug("auth: could not preserve a copy of the corrupt store at %s", corrupt_path, exc_info=True) logger.warning( "auth: failed to parse %s (%s), starting with empty store. %s %s", auth_file, exc, "Corrupt file preserved at" if preserved else "A copy could NOT be preserved at", corrupt_path, ) - return {"version": AUTH_STORE_VERSION, "providers": {}} + return _empty_auth_store() - if isinstance(raw, dict) and ( - isinstance(raw.get("providers"), dict) - or isinstance(raw.get("credential_pool"), dict) - ): + if isinstance(raw, dict) and (isinstance(raw.get("providers"), dict) or isinstance(raw.get("credential_pool"), dict)): raw.setdefault("providers", {}) if isinstance(raw.get("providers"), dict): _migrate_stale_nous_portal_url(raw["providers"]) return raw - # Migrate from PR's "systems" format if present - if isinstance(raw, dict) and isinstance(raw.get("systems"), dict): - systems = raw["systems"] - providers = {} - if "nous_portal" in systems: - providers["nous"] = systems["nous_portal"] - return {"version": AUTH_STORE_VERSION, "providers": providers, - "active_provider": "nous" if providers else None} + if isinstance(raw, dict) and isinstance(raw.get("systems"), dict): # legacy "systems" format + providers = {"nous": raw["systems"]["nous_portal"]} if "nous_portal" in raw["systems"] else {} + return {**_empty_auth_store(), "providers": providers, "active_provider": "nous" if providers else None} - return {"version": AUTH_STORE_VERSION, "providers": {}} + return _empty_auth_store() def _write_private_file_atomic( - target: Path, - payload: str, - *, - replace: Optional[Callable[[Any, Any], Any]] = None, - fsync_dir: bool = False, + target: Path, payload: str, *, replace: Optional[Callable[[Any, Any], Any]] = None, fsync_dir: bool = False, ) -> None: """Write *payload* to *target* via a 0o600 temp file + atomic rename. - Creating the temp with ``os.open(O_EXCL, 0o600)`` closes the TOCTOU window where - ``write_text()`` + post-write ``chmod`` briefly exposed tokens at process umask (often 0o644). - Mirrors agent/google_oauth.py (#19673) and tools/mcp_oauth.py (#21148). The per-process random - temp suffix avoids collisions between concurrent writers and stale leftovers from a crashed - prior write. + ``os.open(O_EXCL, 0o600)`` closes the TOCTOU window where ``write_text()`` + post-write ``chmod`` + briefly exposed tokens at process umask. The per-process random temp suffix avoids collisions + between concurrent writers and stale leftovers from a crashed prior write. """ target.parent.mkdir(parents=True, exist_ok=True) - # secure_parent_dir refuses to chmod /, top-level dirs, or the - # hermes-agent install tree (#25821, #93050). - secure_parent_dir(target) + secure_parent_dir(target) # refuses to chmod /, top-level dirs, or the install tree tmp_path = target.with_name(f"{target.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") try: - fd = os.open( - str(tmp_path), - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - stat.S_IRUSR | stat.S_IWUSR, - ) + fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR) with os.fdopen(fd, "w", encoding="utf-8") as handle: handle.write(payload) handle.flush() @@ -994,18 +744,12 @@ def _write_private_file_atomic( def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path: - # target_path=None preserves the existing contract (write the active - # store at _auth_file_path()). An explicit path lets callers persist a - # specific store — e.g. the global-root write-through for rotating xAI - # OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600 - # write so the root auth.json gets the same TOCTOU-safe treatment. + """Atomically persist *auth_store* (0o600, parent tightened to 0o700) to the active store, or to + an explicit *target_path* (e.g. the global-root write-through for rotating xAI OAuth grants).""" auth_file = target_path if target_path is not None else _auth_file_path() auth_store["version"] = AUTH_STORE_VERSION auth_store["updated_at"] = datetime.now(timezone.utc).isoformat() - # Parent dir is tightened to 0o700 inside the writer so siblings can't - # traverse to creds (no-op on Windows; failures ignored). _write_private_file_atomic(auth_file, json.dumps(auth_store, indent=2) + "\n", fsync_dir=True) - # Restrict file permissions to owner only try: auth_file.chmod(stat.S_IRUSR | stat.S_IWUSR) except OSError: @@ -1017,37 +761,36 @@ def _store_section(auth_store: Dict[str, Any], key: str) -> Dict[str, Any]: """Return ``auth_store[key]`` as a dict, replacing a missing/non-dict value in place.""" section = auth_store.get(key) if not isinstance(section, dict): - section = {} - auth_store[key] = section + section = auth_store[key] = {} return section -def _load_provider_state_with_source( - auth_store: Dict[str, Any], - provider_id: str, -) -> tuple[Optional[Dict[str, Any]], Optional[Path]]: - """Return a provider state plus the auth.json path it came from. - - Most callers only need the state, but refresh paths that rotate single-use OAuth refresh tokens - must write the updated token chain back to the same store they read. - """ - state = _provider_state_in(auth_store, provider_id) - if state is not None: - return state, _auth_file_path() - global_state = _provider_state_in(_load_global_auth_store(), provider_id) - if global_state is not None: - return global_state, _global_auth_file_path() - return None, None - - def _provider_state_in(store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]: """Shallow copy of ``store["providers"][provider_id]`` when it is a dict, else None.""" providers = store.get("providers") if store else None - if isinstance(providers, dict): - state = providers.get(provider_id) - if isinstance(state, dict): - return dict(state) - return None + state = providers.get(provider_id) if isinstance(providers, dict) else None + return dict(state) if isinstance(state, dict) else None + + +def _load_provider_state_with_source( + auth_store: Dict[str, Any], provider_id: str, +) -> tuple[Optional[Dict[str, Any]], Optional[Path]]: + """Provider state plus the auth.json path it came from (profile first, then the global root). + + Refresh paths that rotate single-use OAuth refresh tokens must write the updated chain back to + the same store they read. + """ + state = _provider_state_in(auth_store, provider_id) + if state is not None: + return state, _auth_file_path() + global_state = _provider_state_in(_load_global_auth_store(), provider_id) + return (global_state, _global_auth_file_path()) if global_state is not None else (None, None) + + +def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]: + """Provider state; in profile mode falls back to the global-root ``auth.json`` per provider (same + shadowing as ``read_credential_pool``), so profile workers see globally-authed providers.""" + return _load_provider_state_with_source(auth_store, provider_id)[0] @contextmanager @@ -1056,35 +799,24 @@ def _provider_state_transaction(provider_id: str): Profile-backed refresh paths must take the global auth-store lock before any provider-specific shared-store lock. Re-reading the source after the target lock is acquired prevents both stale - refreshes and whole-file lost updates without inverting the documented auth -> shared lock - order. + refreshes and whole-file lost updates without inverting the documented auth -> shared lock order. """ with _auth_store_lock(): auth_store = _load_auth_store() - state, source_path = _load_provider_state_with_source( - auth_store, - provider_id, - ) - active_path = _auth_file_path() - if source_path is None or _same_path(source_path, active_path): + state, source_path = _load_provider_state_with_source(auth_store, provider_id) + if source_path is None or _same_path(source_path, _auth_file_path()): yield auth_store, state, source_path return - with _auth_store_lock(target_path=source_path): - source_state = _provider_state_in(_load_auth_store(source_path), provider_id) - yield auth_store, source_state, source_path + yield auth_store, _provider_state_in(_load_auth_store(source_path), provider_id), source_path -def _load_provider_state(auth_store: Dict[str, Any], provider_id: str) -> Optional[Dict[str, Any]]: - """Return a provider's persisted state. - - In profile mode, falls back to the global-root ``auth.json`` when the profile has no entry for - ``provider_id``. This mirrors the per-provider shadowing already used by - ``read_credential_pool``: workers spawned in a profile can see providers (e.g. ``nous``) that - were only authenticated at global scope. - """ - state, _source_path = _load_provider_state_with_source(auth_store, provider_id) - return state +def _store_provider_state( + auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], *, set_active: bool = True, +) -> None: + _store_section(auth_store, "providers")[provider_id] = state + if set_active: + auth_store["active_provider"] = provider_id def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None: @@ -1100,65 +832,31 @@ def _save_active_provider_state(provider_id: str, state: Dict[str, Any]) -> Path return _save_auth_store(auth_store) -def _save_provider_state_to_source( - auth_store: Dict[str, Any], - provider_id: str, - state: Dict[str, Any], - source_path: Optional[Path], -) -> None: - """Persist provider state back to the auth store it was read from.""" - active_path = _auth_file_path() - if source_path is None or _same_path(source_path, active_path): - _save_provider_state(auth_store, provider_id, state) - _save_auth_store(auth_store) - return - - _persist_provider_state_to_store( - provider_id, - state, - source_path, - set_active=True, - ) - - -def _store_provider_state( - auth_store: Dict[str, Any], - provider_id: str, - state: Dict[str, Any], - *, - set_active: bool = True, -) -> None: - _store_section(auth_store, "providers")[provider_id] = state - if set_active: - auth_store["active_provider"] = provider_id - - def _persist_provider_state_to_store( - provider_id: str, - state: Dict[str, Any], - target_path: Path, - *, - set_active: bool = False, + provider_id: str, state: Dict[str, Any], target_path: Path, *, set_active: bool = False, ) -> Path: """Merge one provider into a specific auth store under that store's lock.""" with _auth_store_lock(target_path=target_path): auth_store = _load_auth_store(target_path) - _store_provider_state( - auth_store, - provider_id, - dict(state), - set_active=set_active, - ) + _store_provider_state(auth_store, provider_id, dict(state), set_active=set_active) return _save_auth_store(auth_store, target_path=target_path) -def mark_provider_active_if_unset(provider_id: str) -> None: - """Set ``active_provider`` to *provider_id* only when none is set yet. +def _save_provider_state_to_source( + auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], source_path: Optional[Path], +) -> None: + """Persist provider state back to the auth store it was read from.""" + if source_path is None or _same_path(source_path, _auth_file_path()): + _save_provider_state(auth_store, provider_id, state) + _save_auth_store(auth_store) + else: + _persist_provider_state_to_store(provider_id, state, source_path, set_active=True) - Used by ``hermes auth add`` OAuth paths that write pool entries directly: the first credential - for a provider must make it active so the setup wizard's credential check does not report - "No inference provider configured". Later adds leave the user's chosen provider untouched. - """ + +def mark_provider_active_if_unset(provider_id: str) -> None: + """Set ``active_provider`` only when none is set yet: the first ``hermes auth add`` credential must + make its provider active (else setup reports "No inference provider configured"); later adds + leave the user's choice untouched.""" with _auth_store_lock(): auth_store = _load_auth_store() if not (auth_store.get("active_provider") or "").strip(): @@ -1179,17 +877,12 @@ def get_auth_provider_display_name(provider_id: str) -> str: def is_runtime_provider_routable(provider_id: str) -> bool: - """Return whether runtime resolution recognizes a provider identity. - - A capability check, not a credential check: same alias/plugin-aware normalization as - ``resolve_provider`` while preserving special runtime identities that live outside the registry. - """ + """Whether runtime resolution recognizes a provider identity (a capability check, not a credential + check): ``resolve_provider`` normalization plus the special runtime identities outside the registry.""" normalized = (provider_id or "").strip().lower() if not normalized: return False - if normalized in {"auto", "openrouter", "custom", "moa"}: - return True - if normalized.startswith("custom:"): + if normalized in {"auto", "openrouter", "custom", "moa"} or normalized.startswith("custom:"): return True try: resolve_provider(normalized) @@ -1201,85 +894,57 @@ def is_runtime_provider_routable(provider_id: str) -> bool: def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: """Return the persisted credential pool, or one provider slice. - In profile mode, the profile's credential pool is authoritative. If a provider has no entries in - the profile, entries from the global-root ``auth.json`` are used as a read-only fallback — so - workers spawned in a profile can see providers that were only authenticated at global scope. - - Profile entries always win: the global fallback only applies per-provider when the profile has - zero entries for that provider. Once the user runs ``hermes auth add `` inside the - profile, profile entries fully shadow global for that provider on the next read. + In profile mode the profile's pool is authoritative; the global-root ``auth.json`` is a read-only + fallback applied per provider ONLY when the profile has zero entries for that provider, so + profile workers can see globally-authed providers while ``hermes auth add`` inside the profile + fully shadows global on the next read. """ - auth_store = _load_auth_store() - pool = auth_store.get("credential_pool") - if not isinstance(pool, dict): - pool = {} - - global_pool: Dict[str, Any] = {} + pool = _load_auth_store().get("credential_pool") + pool = pool if isinstance(pool, dict) else {} global_store = _load_global_auth_store() - maybe_global_pool = global_store.get("credential_pool") if global_store else None - if isinstance(maybe_global_pool, dict): - global_pool = maybe_global_pool + global_pool = global_store.get("credential_pool") if global_store else None + global_pool = global_pool if isinstance(global_pool, dict) else {} if provider_id is None: merged = dict(pool) for gp_key, gp_entries in global_pool.items(): - if not isinstance(gp_entries, list) or not gp_entries: - continue - # Per-provider shadowing: profile wins whenever it has ANY entries. existing = merged.get(gp_key) - if isinstance(existing, list) and existing: - continue - merged[gp_key] = list(gp_entries) + if isinstance(gp_entries, list) and gp_entries and not (isinstance(existing, list) and existing): + merged[gp_key] = list(gp_entries) return merged provider_entries = pool.get(provider_id) if isinstance(provider_entries, list) and provider_entries: return list(provider_entries) - # Profile has no entries for this provider — fall back to global. global_entries = global_pool.get(provider_id) return list(global_entries) if isinstance(global_entries, list) else [] _POOL_STATUS_FIELDS = ( - "last_status", - "last_status_at", - "last_error_code", - "last_error_reason", - "last_error_message", + "last_status", "last_status_at", "last_error_code", "last_error_reason", "last_error_message", "last_error_reset_at", ) -def _merge_disk_cooldown_state( - entry: Dict[str, Any], - disk_entry: Optional[Dict[str, Any]], - provider_id: str, -) -> Dict[str, Any]: +def _merge_disk_cooldown_state(entry: Dict[str, Any], disk_entry: Optional[Dict[str, Any]], provider_id: str) -> Dict[str, Any]: """Keep a newer on-disk cooldown/quarantine over a stale in-memory one. - ``write_credential_pool`` callers persist an in-memory snapshot that may predate another process - marking the same credential exhausted or dead (last-writer-wins lost update). Without this - merge, process B's later rewrite resurrects a rate-limited key as healthy and both processes - resume hammering it. + ``write_credential_pool`` persists an in-memory snapshot that may predate another process marking + the same credential exhausted/dead; without this merge the later rewrite resurrects a rate-limited + key as healthy and both processes resume hammering it. """ if not isinstance(disk_entry, dict): return entry try: from agent.credential_pool import ( - PooledCredential, - STATUS_DEAD, - STATUS_EXHAUSTED, - _exhausted_until, - _parse_absolute_timestamp, + PooledCredential, STATUS_DEAD, STATUS_EXHAUSTED, _exhausted_until, _parse_absolute_timestamp, ) disk_status = disk_entry.get("last_status") if disk_status not in (STATUS_DEAD, STATUS_EXHAUSTED): return entry - # A token change means the caller re-authed/refreshed this entry and - # intentionally cleared its status (e.g. _sync_codex_entry_from_ - # auth_store after a fresh device-code login) — never resurrect the - # old cooldown onto fresh credentials. + # A token change means the caller re-authed this entry and intentionally cleared its status: + # never resurrect the old cooldown onto fresh credentials. mem_access = entry.get("access_token") or "" disk_access = disk_entry.get("access_token") or "" if mem_access and disk_access and mem_access != disk_access: @@ -1289,82 +954,65 @@ def _merge_disk_cooldown_state( if disk_ts <= mem_ts: return entry if disk_status == STATUS_EXHAUSTED: - until = _exhausted_until( - PooledCredential.from_dict(provider_id, disk_entry) - ) + until = _exhausted_until(PooledCredential.from_dict(provider_id, disk_entry)) if until is None or until <= time.time(): return entry - merged_entry = dict(entry) - for status_field in _POOL_STATUS_FIELDS: - merged_entry[status_field] = disk_entry.get(status_field) - return merged_entry + return {**entry, **{f: disk_entry.get(f) for f in _POOL_STATUS_FIELDS}} except Exception: # pragma: no cover - best-effort merge return entry +def _entry_ids(entries: Iterable[Any]) -> Dict[str, Dict[str, Any]]: + return {e.get("id"): e for e in entries if isinstance(e, dict) and e.get("id")} + + def write_credential_pool( - provider_id: str, - entries: List[Dict[str, Any]], - *, - removed_ids: Optional[Iterable[str]] = None, + provider_id: str, entries: List[Dict[str, Any]], *, removed_ids: Optional[Iterable[str]] = None, ) -> Path: """Persist one provider's credential pool under auth.json. - This is the final disk-boundary guard for borrowed/reference-only credentials. Callers may pass - raw dictionaries, so sanitize here even when ``PooledCredential.to_dict()`` already did the same - work upstream. + Final disk-boundary guard for borrowed/reference-only credentials: callers may pass raw dicts, so + sanitize here even when ``PooledCredential.to_dict()`` already did. - Re-read the on-disk pool under the same lock and merge entries present on disk but missing from - ``entries``. Those were added by another process after the caller loaded its in-memory snapshot; - without this merge a later rotation/exhaustion rewrite drops the concurrent credential. + Entries present on disk but missing from *entries* (added by another process after the caller's + snapshot) are merged back in unless listed in *removed_ids*, so a rotation/exhaustion rewrite + never drops a concurrent credential. """ removed = {rid for rid in (removed_ids or ()) if rid} with _auth_store_lock(): auth_store = _load_auth_store() pool = _store_section(auth_store, "credential_pool") - sanitized_entries = [ - sanitize_borrowed_credential_payload(entry, provider_id) - if isinstance(entry, dict) else entry - for entry in entries - ] + sanitized = [sanitize_borrowed_credential_payload(e, provider_id) if isinstance(e, dict) else e for e in entries] existing = pool.get(provider_id) existing_list = existing if isinstance(existing, list) else [] - existing_by_id = { - entry.get("id"): entry - for entry in existing_list - if isinstance(entry, dict) and entry.get("id") - } - new_ids = { - entry.get("id") - for entry in sanitized_entries - if isinstance(entry, dict) and entry.get("id") - } + existing_by_id = _entry_ids(existing_list) + new_ids = set(_entry_ids(sanitized)) merged: List[Dict[str, Any]] = [ - _merge_disk_cooldown_state( - entry, existing_by_id.get(entry.get("id")), provider_id - ) - if isinstance(entry, dict) - else entry - for entry in sanitized_entries + _merge_disk_cooldown_state(e, existing_by_id.get(e.get("id")), provider_id) if isinstance(e, dict) else e + for e in sanitized ] for disk_entry in existing_list: - if not isinstance(disk_entry, dict): - continue - disk_id = disk_entry.get("id") - if not disk_id or disk_id in new_ids or disk_id in removed: - continue - merged.append(sanitize_borrowed_credential_payload(disk_entry, provider_id)) + disk_id = disk_entry.get("id") if isinstance(disk_entry, dict) else None + if disk_id and disk_id not in new_ids and disk_id not in removed: + merged.append(sanitize_borrowed_credential_payload(disk_entry, provider_id)) pool[provider_id] = merged return _save_auth_store(auth_store) -def suppress_credential_source(provider_id: str, source: str) -> None: - """Mark a credential source as suppressed so it won't be re-seeded. +def _suppressed_source_list(suppressed: Dict[str, Any], provider_id: str) -> Optional[List[str]]: + """Canonical (list-form) suppressed sources for *provider_id*; a legacy mapping (keys = source + names) is migrated to the list form in place.""" + raw_sources = suppressed.get(provider_id) + if isinstance(raw_sources, list): + return raw_sources + if isinstance(raw_sources, dict): + suppressed[provider_id] = [str(name) for name in raw_sources] + return suppressed[provider_id] + return None - Older auth stores may represent a provider's suppressed sources as a mapping. Treat its keys as - source names and migrate the value to the canonical list form before appending the requested - source. - """ + +def suppress_credential_source(provider_id: str, source: str) -> None: + """Mark a credential source as suppressed so it won't be re-seeded.""" with _auth_store_lock(): auth_store = _load_auth_store() suppressed = _store_section(auth_store, "suppressed_sources") @@ -1376,24 +1024,10 @@ def suppress_credential_source(provider_id: str, source: str) -> None: _save_auth_store(auth_store) -def _suppressed_source_list(suppressed: Dict[str, Any], provider_id: str) -> Optional[List[str]]: - """Canonical (list-form) suppressed sources for *provider_id*, migrating a legacy mapping in place.""" - raw_sources = suppressed.get(provider_id) - if isinstance(raw_sources, list): - return raw_sources - if isinstance(raw_sources, dict): - provider_list = [str(name) for name in raw_sources] - suppressed[provider_id] = provider_list - return provider_list - return None - - def is_source_suppressed(provider_id: str, source: str) -> bool: """Check if a credential source has been suppressed by the user.""" try: - auth_store = _load_auth_store() - suppressed = auth_store.get("suppressed_sources", {}) - return source in suppressed.get(provider_id, []) + return source in _load_auth_store().get("suppressed_sources", {}).get(provider_id, []) except Exception: return False @@ -1418,20 +1052,13 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool: def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: - """Return persisted auth state for a provider, or None. - - In profile mode, ``_load_provider_state`` already falls back to the global-root ``auth.json`` - per-provider when the profile has no entry — so this is now a thin convenience wrapper. Profile - state always wins when present. - """ - auth_store = _load_auth_store() - return _load_provider_state(auth_store, provider_id) + """Persisted auth state for a provider (profile first, global-root fallback), or None.""" + return _load_provider_state(_load_auth_store(), provider_id) def get_active_provider() -> Optional[str]: """Return the currently active provider ID from auth store.""" - auth_store = _load_auth_store() - return auth_store.get("active_provider") + return _load_auth_store().get("active_provider") def _active_provider_is(normalized: str) -> bool: @@ -1583,15 +1210,13 @@ def is_provider_explicitly_configured(provider_id: str) -> bool: def clear_provider_auth(provider_id: Optional[str] = None) -> bool: - """Clear auth state for a provider. Used by `hermes logout`. If provider_id is None, clears the - active provider. Returns True if something was cleared. - """ + """Clear auth state for a provider (the active one when *provider_id* is None). Used by + ``hermes logout``. Returns True if something was cleared.""" with _auth_store_lock(): auth_store = _load_auth_store() target = provider_id or auth_store.get("active_provider") if not target: return False - cleared = False for section in ("providers", "credential_pool"): entries = _store_section(auth_store, section) @@ -1601,17 +1226,14 @@ def clear_provider_auth(provider_id: Optional[str] = None) -> bool: if auth_store.get("active_provider") == target: auth_store["active_provider"] = None cleared = True - if not cleared: - return False - _save_auth_store(auth_store) - return True + if cleared: + _save_auth_store(auth_store) + return cleared def deactivate_provider() -> None: - """Clear active_provider in auth.json without deleting credentials. Used when the user switches to - a non-OAuth provider (OpenRouter, custom) so auto-resolution doesn't keep picking the OAuth - provider. - """ + """Clear active_provider without deleting credentials: used when the user switches to a non-OAuth + provider (OpenRouter, custom) so auto-resolution doesn't keep picking the OAuth provider.""" with _auth_store_lock(): auth_store = _load_auth_store() auth_store["active_provider"] = None From ec303c9d19af28e8636fce1de3c164c4d712b72f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:36:25 -0700 Subject: [PATCH 2/7] refactor(hermes_cli): auth.py resolve_provider phase helpers, nous token memo helper, compact status/credential resolvers --- hermes_cli/auth.py | 1018 +++++++++++++++++++------------------------- 1 file changed, 428 insertions(+), 590 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0cb8bc1a3a..00b111a177 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -234,9 +234,9 @@ _REGISTRY_ROWS: Tuple[Any, ...] = ( ), # CLAUDE_CODE_OAUTH_TOKEN is NOT an API key despite auth_type="api_key": `claude setup-token` # yields an `sk-ant-oat01…` OAuth token (401s as x-api-key, 429s as bare Bearer). It stays in - # this tuple because the tuple doubles as the credential-DISCOVERY list (agent/credential_pool.py - # builds its env scan from it); the adapter routes it down the OAuth path by prefix. Only - # ANTHROPIC_API_KEY and ANTHROPIC_TOKEN are usable as literal API keys. + # this tuple because the tuple doubles as the credential-DISCOVERY list + # (agent/credential_pool.py builds its env scan from it); the adapter routes it down the OAuth + # path by prefix. Only ANTHROPIC_API_KEY and ANTHROPIC_TOKEN are usable as literal API keys. ("anthropic", "Anthropic", "https://api.anthropic.com", ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"), "ANTHROPIC_BASE_URL"), ("alibaba", "Qwen Cloud", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", @@ -256,8 +256,8 @@ _REGISTRY_ROWS: Tuple[Any, ...] = ( # Qwen 3.7: Anthropic Messages under /v1/messages). Keep the base at /v1; api_mode is per-model. ("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", ("OPENCODE_GO_API_KEY",), "OPENCODE_GO_BASE_URL"), - # Deliberately NO api_key_env_vars: the free tier is served anonymously (any unrecognized - # bearer is a 401), so there is no secret to configure. Select via `hermes model` / `/model free`. + # Deliberately NO api_key_env_vars: the free tier is served anonymously (any unrecognized bearer + # is a 401), so there is no secret to configure. Select via `hermes model` / `/model free`. ("opencode-free", "OpenCode Free", "https://opencode.ai/zen/v1", ()), ("kilocode", "Kilo Code", "https://api.kilo.ai/api/gateway", ("KILOCODE_API_KEY",), "KILOCODE_BASE_URL"), ("huggingface", "Hugging Face", "https://router.huggingface.co/v1", ("HF_TOKEN",), "HF_BASE_URL"), @@ -292,7 +292,9 @@ def _register_plugin_provider(pp: Any) -> None: (otherwise ``hermes -m `` dies with "Unknown provider" before a client is built). """ if pp.auth_type == "external_process": - pconfig = ProviderConfig(pp.name, pp.display_name or pp.name, "external_process", inference_base_url=pp.base_url) + pconfig = ProviderConfig( + pp.name, pp.display_name or pp.name, "external_process", inference_base_url=pp.base_url, + ) elif pp.auth_type == "api_key" and pp.env_vars and pp.name not in _REGISTRY_PLUGIN_SKIP: is_url = lambda v: v.endswith("_BASE_URL") or v.endswith("_URL") # noqa: E731 pconfig = _api_key_provider( @@ -323,7 +325,11 @@ def get_anthropic_key() -> str: """ from hermes_cli.config import get_env_value_prefer_dotenv - return next((v for v in (get_env_value_prefer_dotenv(var) or "" for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars) if v), "") + for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars: + value = get_env_value_prefer_dotenv(var) or "" + if value: + return value + return "" # ============================================================================= @@ -438,11 +444,13 @@ def _resolve_api_key_provider_secret(provider_id: str, pconfig: ProviderConfig) def is_rate_limited_auth_error(error: Exception) -> bool: """True when an :class:`AuthError` is upstream rate-limiting / quota: transient, and re-authenticating cannot fix it, so callers should say "retry later", not ``hermes auth``.""" - return isinstance(error, AuthError) and not error.relogin_required and error.code == CODEX_RATE_LIMITED_CODE + return ( + isinstance(error, AuthError) and not error.relogin_required and error.code == CODEX_RATE_LIMITED_CODE + ) -# Entitlement failures: Nous gets a Portal-aware message; other providers a fixed generic one (or the -# raw error when no generic text exists for the code). +# Entitlement failures: Nous gets a Portal-aware message; other providers a fixed generic one (or +# the raw error when no generic text exists for the code). _GENERIC_ENTITLEMENT_MESSAGES = { "subscription_required": "No active paid subscription found. Please purchase/activate a subscription, then retry.", "insufficient_credits": "Subscription credits are exhausted. Top up/renew credits, then retry.", @@ -502,8 +510,8 @@ def _global_auth_file_path() -> Optional[Path]: """Global-root auth.json in profile mode; None when profile and global root are the same dir (classic mode, or a custom HERMES_HOME that is not a profile). - Read-only fallback path, so no pytest seat belt here: ``_load_global_auth_store()`` wraps the read - in try/except; the write-side seat belt lives on ``_auth_file_path()``. + Read-only fallback path, so no pytest seat belt here: ``_load_global_auth_store()`` wraps the + read in try/except; the write-side seat belt lives on ``_auth_file_path()``. """ try: from hermes_constants import get_default_hermes_root @@ -522,7 +530,9 @@ def _load_global_auth_store() -> Dict[str, Any]: _global_auth_store_cache = None return {} try: - cache_key: Optional[Tuple[str, int]] = (str(global_path.resolve(strict=False)), global_path.stat().st_mtime_ns) + cache_key: Optional[Tuple[str, int]] = ( + str(global_path.resolve(strict=False)), global_path.stat().st_mtime_ns, + ) except Exception: cache_key = None if cache_key is not None and _global_auth_store_cache is not None: @@ -571,7 +581,9 @@ def _auth_lock_holder_for(target_path: Path) -> threading.local: @contextmanager -def _file_lock(lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str): +def _file_lock( + lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str, +): """Cross-process advisory flock helper, reentrant per-thread via ``holder.depth``. Falls back to a depth-only guard when neither ``fcntl`` nor ``msvcrt`` is available. Callers @@ -598,7 +610,8 @@ def _file_lock(lock_path: Path, holder: threading.local, timeout_seconds: float, # msvcrt.locking needs a non-empty file with the pointer at 0. This convenience write can race # another holder's byte-range lock and raise PermissionError (reproduced with 20 concurrent - # processes on Windows); losing that race just means the file already has content, so swallow it. + # processes on Windows); losing that race just means the file already has content, so swallow + # it. if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): try: lock_path.write_text(" ", encoding="utf-8") @@ -636,11 +649,13 @@ def _file_lock(lock_path: Path, holder: threading.local, timeout_seconds: float, @contextmanager -def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None): +def _auth_store_lock( + timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None, +): """Cross-process advisory lock for one auth.json read/write transaction. - ``target_path`` is required for profile-to-global write-throughs: a profile lock does not protect - the distinct global store, so each path has its own reentrancy tracker and kernel lock. + ``target_path`` is required for profile-to-global write-throughs: a profile lock does not + protect the distinct global store, so each path has its own reentrancy tracker and kernel lock. Lock ordering invariant: when held together with ``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST (outer) and the shared Nous lock SECOND (inner). Violating it risks @@ -667,8 +682,9 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: raw = json.loads(auth_file.read_text(encoding="utf-8-sig")) except OSError: # The file exists but could not be READ (EMFILE, EACCES, EIO, stalled mount). The contents - # are not bad, and this module does read-modify-write in ~15 places, so degrading to an empty - # store here is one _save_auth_store() away from erasing every credential. Fail loudly. + # are not bad, and this module does read-modify-write in ~15 places, so degrading to an + # empty store here is one _save_auth_store() away from erasing every credential. Fail + # loudly. logger.warning( "auth: could not read %s, leaving the store on disk untouched " "rather than degrading to an empty one", @@ -684,7 +700,9 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: preserved = True except Exception: preserved = False - logger.debug("auth: could not preserve a copy of the corrupt store at %s", corrupt_path, exc_info=True) + logger.debug( + "auth: could not preserve a copy of the corrupt store at %s", corrupt_path, exc_info=True, + ) logger.warning( "auth: failed to parse %s (%s), starting with empty store. %s %s", auth_file, exc, @@ -693,27 +711,36 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: ) return _empty_auth_store() - if isinstance(raw, dict) and (isinstance(raw.get("providers"), dict) or isinstance(raw.get("credential_pool"), dict)): + if isinstance(raw, dict) and ( + isinstance(raw.get("providers"), dict) or isinstance(raw.get("credential_pool"), dict) + ): raw.setdefault("providers", {}) if isinstance(raw.get("providers"), dict): _migrate_stale_nous_portal_url(raw["providers"]) return raw if isinstance(raw, dict) and isinstance(raw.get("systems"), dict): # legacy "systems" format - providers = {"nous": raw["systems"]["nous_portal"]} if "nous_portal" in raw["systems"] else {} - return {**_empty_auth_store(), "providers": providers, "active_provider": "nous" if providers else None} + systems = raw["systems"] + providers = {"nous": systems["nous_portal"]} if "nous_portal" in systems else {} + return { + **_empty_auth_store(), "providers": providers, "active_provider": "nous" if providers else None, + } return _empty_auth_store() def _write_private_file_atomic( - target: Path, payload: str, *, replace: Optional[Callable[[Any, Any], Any]] = None, fsync_dir: bool = False, + target: Path, + payload: str, + *, + replace: Optional[Callable[[Any, Any], Any]] = None, + fsync_dir: bool = False, ) -> None: """Write *payload* to *target* via a 0o600 temp file + atomic rename. - ``os.open(O_EXCL, 0o600)`` closes the TOCTOU window where ``write_text()`` + post-write ``chmod`` - briefly exposed tokens at process umask. The per-process random temp suffix avoids collisions - between concurrent writers and stale leftovers from a crashed prior write. + ``os.open(O_EXCL, 0o600)`` closes the TOCTOU window where ``write_text()`` + post-write + ``chmod`` briefly exposed tokens at process umask. The per-process random temp suffix avoids + collisions between concurrent writers and stale leftovers from a crashed prior write. """ target.parent.mkdir(parents=True, exist_ok=True) secure_parent_dir(target) # refuses to chmod /, top-level dirs, or the install tree @@ -799,7 +826,8 @@ def _provider_state_transaction(provider_id: str): Profile-backed refresh paths must take the global auth-store lock before any provider-specific shared-store lock. Re-reading the source after the target lock is acquired prevents both stale - refreshes and whole-file lost updates without inverting the documented auth -> shared lock order. + refreshes and whole-file lost updates without inverting the documented auth -> shared lock + order. """ with _auth_store_lock(): auth_store = _load_auth_store() @@ -894,10 +922,10 @@ def is_runtime_provider_routable(provider_id: str) -> bool: def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: """Return the persisted credential pool, or one provider slice. - In profile mode the profile's pool is authoritative; the global-root ``auth.json`` is a read-only - fallback applied per provider ONLY when the profile has zero entries for that provider, so - profile workers can see globally-authed providers while ``hermes auth add`` inside the profile - fully shadows global on the next read. + In profile mode the profile's pool is authoritative; the global-root ``auth.json`` is a + read-only fallback applied per provider ONLY when the profile has zero entries for that + provider, so profile workers can see globally-authed providers while ``hermes auth add`` inside + the profile fully shadows global on the next read. """ pool = _load_auth_store().get("credential_pool") pool = pool if isinstance(pool, dict) else {} @@ -909,8 +937,11 @@ def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: merged = dict(pool) for gp_key, gp_entries in global_pool.items(): existing = merged.get(gp_key) - if isinstance(gp_entries, list) and gp_entries and not (isinstance(existing, list) and existing): - merged[gp_key] = list(gp_entries) + if not isinstance(gp_entries, list) or not gp_entries: + continue + if isinstance(existing, list) and existing: # profile wins whenever it has ANY entries + continue + merged[gp_key] = list(gp_entries) return merged provider_entries = pool.get(provider_id) @@ -926,12 +957,14 @@ _POOL_STATUS_FIELDS = ( ) -def _merge_disk_cooldown_state(entry: Dict[str, Any], disk_entry: Optional[Dict[str, Any]], provider_id: str) -> Dict[str, Any]: +def _merge_disk_cooldown_state( + entry: Dict[str, Any], disk_entry: Optional[Dict[str, Any]], provider_id: str, +) -> Dict[str, Any]: """Keep a newer on-disk cooldown/quarantine over a stale in-memory one. - ``write_credential_pool`` persists an in-memory snapshot that may predate another process marking - the same credential exhausted/dead; without this merge the later rewrite resurrects a rate-limited - key as healthy and both processes resume hammering it. + ``write_credential_pool`` persists an in-memory snapshot that may predate another process + marking the same credential exhausted/dead; without this merge the later rewrite resurrects a + rate-limited key as healthy and both processes resume hammering it. """ if not isinstance(disk_entry, dict): return entry @@ -971,8 +1004,8 @@ def write_credential_pool( ) -> Path: """Persist one provider's credential pool under auth.json. - Final disk-boundary guard for borrowed/reference-only credentials: callers may pass raw dicts, so - sanitize here even when ``PooledCredential.to_dict()`` already did. + Final disk-boundary guard for borrowed/reference-only credentials: callers may pass raw dicts, + so sanitize here even when ``PooledCredential.to_dict()`` already did. Entries present on disk but missing from *entries* (added by another process after the caller's snapshot) are merged back in unless listed in *removed_ids*, so a rotation/exhaustion rewrite @@ -982,13 +1015,17 @@ def write_credential_pool( with _auth_store_lock(): auth_store = _load_auth_store() pool = _store_section(auth_store, "credential_pool") - sanitized = [sanitize_borrowed_credential_payload(e, provider_id) if isinstance(e, dict) else e for e in entries] + sanitized = [ + sanitize_borrowed_credential_payload(e, provider_id) if isinstance(e, dict) else e + for e in entries + ] existing = pool.get(provider_id) existing_list = existing if isinstance(existing, list) else [] existing_by_id = _entry_ids(existing_list) new_ids = set(_entry_ids(sanitized)) merged: List[Dict[str, Any]] = [ - _merge_disk_cooldown_state(e, existing_by_id.get(e.get("id")), provider_id) if isinstance(e, dict) else e + _merge_disk_cooldown_state(e, existing_by_id.get(e.get("id")), provider_id) + if isinstance(e, dict) else e for e in sanitized ] for disk_entry in existing_list: @@ -1066,28 +1103,27 @@ def _active_provider_is(normalized: str) -> bool: return bool(active) and active == normalized +def _slot_selects(slot: Any, normalized: str) -> bool: + return isinstance(slot, dict) and (slot.get("provider") or "").strip().lower() == normalized + + def _config_selects_provider(normalized: str) -> bool: """config.yaml ``model.provider``, or a MoA advisor/aggregator slot naming the provider. - MoA presets are explicit model selections too: a user who configured ``provider: anthropic`` - as a MoA slot has opted into Anthropic credentials for that slot even when the main session - model is another provider. Without this, Claude Code OAuth entries are pruned by - ``credential_pool.load_pool("anthropic")`` and MoA Anthropic advisors fail with "no - ANTHROPIC_API_KEY" while the model picker says Anthropic is logged in. + MoA presets are explicit model selections too: ``provider: anthropic`` in a MoA slot opts into + Anthropic credentials for that slot even when the main model is another provider; otherwise + Claude Code OAuth entries get pruned by ``load_pool("anthropic")`` and MoA advisors fail with + "no ANTHROPIC_API_KEY" while the picker says Anthropic is logged in. """ from hermes_cli.config import load_config cfg = load_config() - model_cfg = cfg.get("model") - if isinstance(model_cfg, dict) and (model_cfg.get("provider") or "").strip().lower() == normalized: + if _slot_selects(cfg.get("model"), normalized): return True - def _slot_matches(slot: Any) -> bool: - return isinstance(slot, dict) and (slot.get("provider") or "").strip().lower() == normalized - def _moa_block_matches(block: Any) -> bool: return isinstance(block, dict) and ( - any(_slot_matches(s) for s in block.get("reference_models") or []) - or _slot_matches(block.get("aggregator")) + any(_slot_selects(s, normalized) for s in block.get("reference_models") or []) + or _slot_selects(block.get("aggregator"), normalized) ) moa_cfg = cfg.get("moa") @@ -1111,15 +1147,18 @@ _EXPLICIT_POOL_SOURCES = frozenset({"device_code", "loopback_pkce", "hermes_pkce _VERTEX_PROVIDER_IDS = ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai") +def _env_secret(name: str) -> bool: + return has_usable_secret(os.getenv(name, "")) + + def _explicit_env_credentials_present(normalized: str) -> bool: """True when the user has pasted an explicit credential env var for *normalized*. - Falls back to the models.dev ``ProviderDef`` when the provider isn't in PROVIDER_REGISTRY - (e.g. openrouter) — both expose ``.auth_type`` / ``.api_key_env_vars`` with the same shape. - AWS SDK providers (Bedrock) have empty ``api_key_env_vars``, so check their explicit env - credentials directly — NOT boto3's full chain: ambient sources like EC2 IMDS / SSO profiles - must not auto-surface, but AWS_BEARER_TOKEN_BEDROCK or an access-key pair in .env is as - explicit as pasting ANTHROPIC_API_KEY. + Falls back to the models.dev ``ProviderDef`` when the provider isn't in PROVIDER_REGISTRY (e.g. + openrouter; same ``.auth_type`` / ``.api_key_env_vars`` shape). AWS SDK providers (Bedrock) have + empty ``api_key_env_vars``, so check their explicit env credentials directly — NOT boto3's full + chain: ambient EC2 IMDS / SSO profiles must not auto-surface, but AWS_BEARER_TOKEN_BEDROCK or an + access-key pair in .env is as explicit as pasting ANTHROPIC_API_KEY. """ pconfig = PROVIDER_REGISTRY.get(normalized) if pconfig is None: @@ -1128,15 +1167,10 @@ def _explicit_env_credentials_present(normalized: str) -> bool: if not pconfig: return False if pconfig.auth_type == "api_key": - return any( - has_usable_secret(os.getenv(env_var, "")) - for env_var in pconfig.api_key_env_vars - if env_var not in _IMPLICIT_ENV_VARS - ) + return any(_env_secret(v) for v in pconfig.api_key_env_vars if v not in _IMPLICIT_ENV_VARS) if pconfig.auth_type == "aws_sdk": - return has_usable_secret(os.getenv("AWS_BEARER_TOKEN_BEDROCK", "")) or ( - has_usable_secret(os.getenv("AWS_ACCESS_KEY_ID", "")) - and has_usable_secret(os.getenv("AWS_SECRET_ACCESS_KEY", "")) + return _env_secret("AWS_BEARER_TOKEN_BEDROCK") or ( + _env_secret("AWS_ACCESS_KEY_ID") and _env_secret("AWS_SECRET_ACCESS_KEY") ) return False @@ -1146,32 +1180,27 @@ def _pool_entry_is_explicit(entry: Any) -> bool: if not isinstance(entry, dict): return False source = str(entry.get("source") or "").strip().lower() - if not source: - return False if source.startswith("env:"): - # A stale env-seeded pool entry survives in auth.json after - # the user deletes the env var (#55790) — only count it when - # the referenced var still resolves to a usable secret NOW. + # A stale env-seeded entry survives in auth.json after the user deletes the env var: only + # count it when the referenced var still resolves to a usable secret NOW. env_var = entry.get("source", "").split(":", 1)[1].strip() - return bool(env_var and has_usable_secret(os.getenv(env_var, ""))) - return source in _EXPLICIT_POOL_SOURCES or source.startswith("manual:") + return bool(env_var and _env_secret(env_var)) + return bool(source) and (source in _EXPLICIT_POOL_SOURCES or source.startswith("manual:")) def _keyless_provider_has_explicit_config(normalized: str) -> bool: """Vertex / Bedrock count as explicit when Hermes-scoped routing config is present. - Uses has_explicit_vertex_config(), NOT has_vertex_credentials() — the latter also counts an - ambient GOOGLE_APPLICATION_CREDENTIALS path (commonly set globally for unrelated GCP work), - which would mark Vertex explicit for users who never set Hermes up for it. Only Hermes-scoped - signals (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH) count here. + Uses has_explicit_vertex_config(), NOT has_vertex_credentials(): the latter also counts an + ambient GOOGLE_APPLICATION_CREDENTIALS path (commonly set for unrelated GCP work). Only + Hermes-scoped signals (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH) count + here. """ if normalized in _VERTEX_PROVIDER_IDS: from agent.vertex_adapter import has_explicit_vertex_config - return bool(has_explicit_vertex_config()) if normalized == "bedrock": from hermes_cli.config import load_config as _load_cfg - bedrock_cfg = _load_cfg().get("bedrock") return isinstance(bedrock_cfg, dict) and bool(str(bedrock_cfg.get("region") or "").strip()) return False @@ -1190,13 +1219,10 @@ _EXPLICIT_CONFIG_CHECKS: Tuple[Tuple[Callable[[str], bool], bool], ...] = ( def is_provider_explicitly_configured(provider_id: str) -> bool: - """Return True only if the user has explicitly configured this provider. - - Explicit = auth.json ``active_provider``, config.yaml ``model.provider`` / MoA slots, a pasted - provider env var, a pool entry from a Hermes-initiated flow, or Hermes-scoped routing config - for keyless cloud-SDK providers. Ambient borrowed credentials (gh CLI, qwen-cli, Claude Code's - ~/.claude/.credentials.json) never count, so they are never used without the user's choice. - """ + """True only if the user explicitly configured this provider: auth.json ``active_provider``, + config.yaml ``model.provider`` / MoA slots, a pasted provider env var, a pool entry from a + Hermes-initiated flow, or Hermes-scoped routing config for keyless cloud-SDK providers. Ambient + borrowed credentials (gh CLI, qwen-cli, ~/.claude/.credentials.json) never count.""" normalized = (provider_id or "").strip().lower() for check, best_effort in _EXPLICIT_CONFIG_CHECKS: try: @@ -1252,12 +1278,9 @@ def _get_config_hint_for_unknown_provider(provider_name: str) -> str: issues = validate_config_structure() if not issues: return "" - lines = ["Config issue detected — run 'hermes doctor' for full diagnostics:"] for ci in issues: - prefix = "ERROR" if ci.severity == "error" else "WARNING" - lines.append(f" [{prefix}] {ci.message}") - # Show first line of hint + lines.append(f" [{'ERROR' if ci.severity == 'error' else 'WARNING'}] {ci.message}") first_hint = ci.hint.splitlines()[0] if ci.hint else "" if first_hint: lines.append(f" → {first_hint}") @@ -1269,13 +1292,10 @@ def _get_config_hint_for_unknown_provider(provider_name: str) -> str: def _refuse_env_adoption_if_config_corrupt() -> None: """Refuse env-key/pool auto-adoption of openrouter while config.yaml is corrupt. - When ``~/.hermes/config.yaml`` EXISTS but fails to parse, ``load_config()`` falls back to - ``DEFAULT_CONFIG`` — so the tier-2 config check above finds no ``model.provider`` and the env- - var sniff / pool probe silently adopts the PAID openrouter provider, even though the user's real - (broken) config may name a completely different provider (e.g. a free local one). - - This probe fires ONLY on the auto path — explicitly requested providers never reach it — and - clears itself as soon as the file changes (a fixed config resolves normally on the next call). + When config.yaml EXISTS but fails to parse, ``load_config()`` falls back to ``DEFAULT_CONFIG``, + so the tier-2 config check finds no ``model.provider`` and the env sniff / pool probe would + silently adopt the PAID openrouter provider although the user's real (broken) config may name a + different one. Fires ONLY on the auto path and clears itself as soon as the file parses again. """ try: from hermes_cli.config import get_active_config_parse_failure, get_config_path @@ -1338,14 +1358,26 @@ _PROVIDER_ALIASES: Dict[str, str] = { } +def _plugin_aliases() -> Dict[str, str]: + """``_PROVIDER_ALIASES`` extended with aliases declared in plugins/model-providers//.""" + aliases = dict(_PROVIDER_ALIASES) + try: + from providers import list_providers as _lp + for _pp in _lp(): + for _alias in _pp.aliases: + aliases.setdefault(_alias, _pp.name) + except Exception: + pass + return aliases + + def _scoped_key_env_reader() -> Callable[[str], str]: """Scope-aware key reader for provider auto-detection. - Under multiplex a secondary profile's API keys live only in its secret scope, not os.environ — - a bare getenv would find nothing and auto-resolution would report "No LLM provider configured" - for every secondary profile (same class as #86905). Catch ONLY ImportError: any other failure - inside auxiliary_client must propagate — silently falling back to os.getenv would reintroduce - the very fail-open this removes, with zero trace. + Under multiplex a secondary profile's API keys live only in its secret scope, not os.environ; a + bare getenv would report "No LLM provider configured" for every secondary profile. Catch ONLY + ImportError: any other failure inside auxiliary_client must propagate, since silently falling + back to os.getenv would reintroduce that fail-open with zero trace. """ try: from agent.auxiliary_client import _scoped_key_env @@ -1361,19 +1393,13 @@ def _scoped_key_env_reader() -> Callable[[str], str]: def _openrouter_auto_detected(scoped_key_env: Callable[[str], str]) -> bool: - """True when an OpenRouter credential exists via env key or the credential pool. - - The pool check covers a key added via `hermes auth add openrouter` (manual pool entry, no env - var). Without it, a pool-only key is invisible to auto-detection — `hermes auth list` shows the - credential while requests go out with no Authorization header (#42130). - """ - if has_usable_secret(scoped_key_env("OPENAI_API_KEY")) or has_usable_secret( - scoped_key_env("OPENROUTER_API_KEY") - ): + """True when an OpenRouter credential exists via env key or the credential pool (a key added via + `hermes auth add openrouter` has no env var; without the pool check it is invisible to + auto-detection and requests go out with no Authorization header).""" + if any(has_usable_secret(scoped_key_env(v)) for v in ("OPENAI_API_KEY", "OPENROUTER_API_KEY")): return True try: from agent.credential_pool import load_pool as _load_pool - return bool(_load_pool("openrouter").has_credentials()) except Exception as e: logger.debug("Could not check OpenRouter credential pool: %s", e) @@ -1383,8 +1409,7 @@ def _openrouter_auto_detected(scoped_key_env: Callable[[str], str]) -> bool: def _logged_in_oauth_active_provider() -> Optional[str]: """auth.json ``active_provider`` when it is a registry provider that reports logged in.""" try: - _store = _load_auth_store() - _maybe = _store.get("active_provider") + _maybe = _load_auth_store().get("active_provider") if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"): return _maybe except Exception as e: @@ -1392,6 +1417,55 @@ def _logged_in_oauth_active_provider() -> Optional[str]: return None +def _config_model_provider() -> Tuple[Any, Optional[str]]: + """``(model_cfg, provider)`` from config.yaml when ``model.provider`` names a registry provider. + + The normal chat/gateway path resolves config.provider upstream in resolve_requested_provider(); + this is the safety net for the lone direct caller (main.py resolve_provider("auto")). + """ + try: + from hermes_cli.config import load_config + + model_cfg = (load_config() or {}).get("model") + if isinstance(model_cfg, dict): + _cfg_provider = model_cfg.get("provider") + if isinstance(_cfg_provider, str) and _cfg_provider.strip().lower() in PROVIDER_REGISTRY: + return model_cfg, _cfg_provider.strip().lower() + return model_cfg, None + except Exception as e: + logger.debug("Could not read config.yaml model.provider for auto-resolution: %s", e) + return None, None + + +# API-key providers never auto-selected from env: GitHub tokens are commonly present for repo/tool +# access and must not hijack inference; LM Studio is a local server whose availability isn't implied +# by LM_API_KEY (may be offline; the no-auth setup uses a placeholder). Both require explicit +# choice. +_NO_AUTO_DETECT_PROVIDERS = frozenset({"copilot", "lmstudio"}) + + +def _env_key_auto_detected( + scoped_key_env: Callable[[str], str], oauth_active: Optional[str], +) -> Optional[str]: + """First registry api_key provider (registry order) with a usable env key, warning when it + preempts a logged-in OAuth provider so a stale key in ~/.hermes/.env never switches silently.""" + for pid, pconfig in PROVIDER_REGISTRY.items(): + if pconfig.auth_type != "api_key" or pid in _NO_AUTO_DETECT_PROVIDERS: + continue + for env_var in pconfig.api_key_env_vars: + if has_usable_secret(scoped_key_env(env_var)): + if oauth_active and oauth_active != pid: + logger.warning( + "Provider resolved to %r via %s, preempting your " + "logged-in OAuth provider %r. If you meant to use the " + "OAuth login, unset %s or set `model.provider` " + "explicitly.", + pid, env_var, oauth_active, env_var, + ) + return pid + return None + + def resolve_provider( requested: Optional[str] = None, *, @@ -1400,35 +1474,18 @@ def resolve_provider( ) -> str: """Determine which inference provider to use. - Priority when requested is "auto"/None — explicit user intent wins over a stale logged-in - OAuth provider (#29285): 1. explicit CLI api_key/base_url -> "openrouter"; 2. config.yaml - ``model.provider``; 3. OPENAI_API_KEY / OPENROUTER_API_KEY env -> "openrouter"; 4. OpenRouter - credential pool; 5. provider-specific env keys; 6. auth.json ``active_provider`` (OAuth); - 7. AWS Bedrock credential chain; 8. AuthError(no_provider_configured). + Priority when requested is "auto"/None — explicit user intent wins over a stale logged-in OAuth + provider: 1. explicit CLI api_key/base_url -> "openrouter"; 2. config.yaml ``model.provider``; + 3. OPENAI_API_KEY / OPENROUTER_API_KEY env -> "openrouter"; 4. OpenRouter credential pool; + 5. provider-specific env keys; 6. auth.json ``active_provider`` (OAuth); 7. AWS Bedrock + credential chain; 8. AuthError(no_provider_configured). """ normalized = (requested or "auto").strip().lower() + normalized = _plugin_aliases().get(normalized, normalized) - # Normalize provider aliases. Extend with aliases declared in - # plugins/model-providers// that aren't already mapped. - aliases = dict(_PROVIDER_ALIASES) - try: - from providers import list_providers as _lp - for _pp in _lp(): - for _alias in _pp.aliases: - if _alias not in aliases: - aliases[_alias] = _pp.name - except Exception: - pass - normalized = aliases.get(normalized, normalized) - - if normalized == "openrouter": - return "openrouter" - if normalized == "custom": - return "custom" - if normalized in PROVIDER_REGISTRY: + if normalized in ("openrouter", "custom") or normalized in PROVIDER_REGISTRY: return normalized if normalized != "auto": - # Check for common config.yaml issues that cause this error _config_hint = _get_config_hint_for_unknown_provider(normalized) msg = f"Unknown provider '{normalized}'." if _config_hint: @@ -1437,74 +1494,28 @@ def resolve_provider( msg += " Check 'hermes model' for available providers, or run 'hermes doctor' to diagnose config issues." raise AuthError(msg, code="invalid_provider") - # Explicit one-off CLI creds always mean openrouter/custom - if explicit_api_key or explicit_base_url: + if explicit_api_key or explicit_base_url: # one-off CLI creds always mean openrouter/custom return "openrouter" - # Tier 2. The normal chat/gateway path resolves config.provider upstream in - # resolve_requested_provider() before ever reaching "auto"; this duplicate - # check is the safety net for the lone direct caller (main.py resolve_provider - # ("auto")) and any future bypass of that stage. - _model_cfg: Any = None - try: - from hermes_cli.config import load_config - - _model_cfg = (load_config() or {}).get("model") - if isinstance(_model_cfg, dict): - _cfg_provider = _model_cfg.get("provider") - if isinstance(_cfg_provider, str) and _cfg_provider.strip().lower() in PROVIDER_REGISTRY: - return _cfg_provider.strip().lower() - except Exception as e: - logger.debug("Could not read config.yaml model.provider for auto-resolution: %s", e) + _model_cfg, cfg_provider = _config_model_provider() + if cfg_provider: + return cfg_provider _scoped_key_env = _scoped_key_env_reader() - - # Tiers 3-4: OPENAI/OPENROUTER env keys, then the OpenRouter credential pool. if _openrouter_auto_detected(_scoped_key_env): _refuse_env_adoption_if_config_corrupt() return "openrouter" - # Determine the logged-in OAuth provider up front so the env-key loop below - # can WARN when an exported API key preempts it (#29285 transparency). The - # actual OAuth fallback (tier 6) still happens later if nothing else matches. + # Determined up front so the env-key tier can warn when an exported key preempts it; the actual + # OAuth fallback still happens after the env-key tier. _oauth_active = _logged_in_oauth_active_provider() + env_pid = _env_key_auto_detected(_scoped_key_env, _oauth_active) + if env_pid: + return env_pid - # Auto-detect API-key providers by checking their env vars - for pid, pconfig in PROVIDER_REGISTRY.items(): - if pconfig.auth_type != "api_key": - continue - # GitHub tokens are commonly present for repo/tool access but should not - # hijack inference auto-selection unless the user explicitly chooses - # Copilot/GitHub Models as the provider. LM Studio is a local server - # whose availability isn't implied by LM_API_KEY presence (it may be - # offline, and the no-auth setup uses a placeholder value), so it - # also requires explicit selection. - if pid in {"copilot", "lmstudio"}: - continue - for env_var in pconfig.api_key_env_vars: - if has_usable_secret(_scoped_key_env(env_var)): - # An exported API key now wins over a logged-in OAuth provider - # (the #29285 fix). Surface that so a user who deliberately uses - # OAuth but has a stale key in ~/.hermes/.env isn't silently - # switched without knowing why. - if _oauth_active and _oauth_active != pid: - logger.warning( - "Provider resolved to %r via %s, preempting your " - "logged-in OAuth provider %r. If you meant to use the " - "OAuth login, unset %s or set `model.provider` " - "explicitly.", - pid, env_var, _oauth_active, env_var, - ) - return pid - - # Logged-in OAuth provider (auth.json `active_provider`) — a LAST-RESORT - # fallback, chosen only when the user expressed no other preference above. - # Previously this sat ABOVE the env-var/config checks, so a stale OAuth - # login silently overrode an explicit `model.provider` or an exported API - # key (#29285). Demoted here so explicit intent always wins. + # Logged-in OAuth provider is a LAST-RESORT fallback (it used to sit above the env/config + # checks, so a stale login silently overrode explicit intent). if _oauth_active: - # Surface the silent-override case the issue reported: a populated - # `model` config that lacks a `provider` key falls through to OAuth. if isinstance(_model_cfg, dict) and _model_cfg and not _model_cfg.get("provider"): logger.warning( "Provider resolved to logged-in OAuth provider %r because " @@ -1514,14 +1525,14 @@ def resolve_provider( ) return _oauth_active - # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars). - # This runs after API-key providers so explicit keys always win. + # AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars); after API-key providers + # so explicit keys always win. try: from agent.bedrock_adapter import has_aws_credentials if has_aws_credentials(): return "bedrock" except ImportError: - pass # boto3 not installed — skip Bedrock auto-detection + pass # boto3 not installed raise AuthError( "No inference provider configured. Run 'hermes model' to choose a " @@ -1541,9 +1552,7 @@ def _utc_now_z() -> str: def _parse_iso_timestamp(value: Any) -> Optional[float]: - if not isinstance(value, str) or not value: - return None - text = value.strip() + text = value.strip() if isinstance(value, str) else "" if not text: return None if text.endswith("Z"): @@ -1559,25 +1568,16 @@ def _parse_iso_timestamp(value: Any) -> Optional[float]: def _is_expiring(expires_at_iso: Any, skew_seconds: int) -> bool: expires_epoch = _parse_iso_timestamp(expires_at_iso) - if expires_epoch is None: - return True - return expires_epoch <= (time.time() + skew_seconds) + return expires_epoch is None or expires_epoch <= (time.time() + skew_seconds) def _tls_state_from_verify(verify: Any) -> Dict[str, Any]: """Persistable ``tls`` block derived from an httpx ``verify`` value.""" - return { - "insecure": verify is False, - "ca_bundle": verify if isinstance(verify, str) else None, - } + return {"insecure": verify is False, "ca_bundle": verify if isinstance(verify, str) else None} def _last_auth_error_marker( - provider: str, - error: "AuthError", - *, - reason: str, - default_code: Optional[str] = None, + provider: str, error: "AuthError", *, reason: str, default_code: Optional[str] = None, ) -> Dict[str, Any]: """The ``last_auth_error`` record persisted when dead OAuth material is quarantined.""" return { @@ -1592,12 +1592,10 @@ def _last_auth_error_marker( _FLAT_OAUTH_TOKEN_KEYS = ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at") -def _quarantine_flat_oauth_state(state: Dict[str, Any], provider: str, exc: "AuthError") -> None: - """Strip dead tokens from a flat OAuth state after a terminal runtime refresh failure. - Mirrors the Nous / xAI / Codex quarantine pattern so subsequent calls fail fast without a - network retry. - """ +def _quarantine_flat_oauth_state(state: Dict[str, Any], provider: str, exc: "AuthError") -> None: + """Strip dead tokens from a flat OAuth state after a terminal runtime refresh failure so + subsequent calls fail fast without a network retry (mirrors the Nous / xAI / Codex pattern).""" for _k in _FLAT_OAUTH_TOKEN_KEYS: state.pop(_k, None) state["last_auth_error"] = _last_auth_error_marker( @@ -1607,41 +1605,51 @@ def _quarantine_flat_oauth_state(state: Dict[str, Any], provider: str, exc: "Aut def _coerce_ttl_seconds(expires_in: Any) -> int: try: - ttl = int(expires_in) + return max(0, int(expires_in)) except Exception: - ttl = 0 - return max(0, ttl) + return 0 def _optional_base_url(value: Any) -> Optional[str]: - if not isinstance(value, str): - return None - cleaned = value.strip().rstrip("/") - return cleaned if cleaned else None + cleaned = value.strip().rstrip("/") if isinstance(value, str) else "" + return cleaned or None -# Allowlist of valid Nous Portal hosts. A portal_base_url outside this -# set is treated as a misconfiguration and falls back to the default. -# "localhost" / "127.0.0.1" are valid for local development and testing. +# Valid Nous Portal hosts; a stored portal_base_url outside this set is a misconfiguration and falls +# back to the default. localhost / 127.0.0.1 are for local development and testing. _NOUS_PORTAL_ALLOWED_HOSTS: FrozenSet[str] = frozenset({ - "portal.nousresearch.com", - "localhost", - "127.0.0.1", + "portal.nousresearch.com", "localhost", "127.0.0.1", }) -# Per-process memo for resolve_nous_access_token. Startup runs -# check_tool_availability once per managed-tool check_fn (browser, image_gen, -# etc.), and each one independently triggers a ~15s blocking token-refresh -# network call when the stored token is expired. On a slow/constrained host that -# serial burst stretches startup to many minutes. A short-TTL memo collapses the -# burst into a single network round-trip; callers that need freshness use -# separate flows (force_fresh / refresh_nous_oauth_pure) and are unaffected. +# Per-process memo for resolve_nous_access_token. Startup runs check_tool_availability once per +# managed-tool check_fn (browser, image_gen, ...) and each independently triggers a ~15s blocking +# refresh when the stored token is expired; a short-TTL memo collapses that burst into one network +# round-trip. Callers needing freshness use separate flows (force_fresh / refresh_nous_oauth_pure). _RESOLVE_TOKEN_CACHE_LOCK = threading.Lock() _RESOLVE_TOKEN_CACHE: "tuple[float, str] | None" = None _RESOLVE_TOKEN_CACHE_TTL_S = 5.0 +def _nous_portal_base_url(state: Dict[str, Any]) -> str: + """HERMES_PORTAL_BASE_URL / NOUS_PORTAL_BASE_URL is the trusted operator override and wins + OUTRIGHT, bypassing the host allowlist (which exists to reject an untrusted network-provided + value, not one the operator configured). Otherwise the stored/default value, allowlist-gated.""" + env_portal_override = _nous_portal_env_override() + if env_portal_override: + return env_portal_override.rstrip("/") + portal_base_url = _optional_base_url(state.get("portal_base_url")) or DEFAULT_NOUS_PORTAL_URL + portal_base_url = portal_base_url.rstrip("/") + host = urlparse(portal_base_url).hostname + if host and host not in _NOUS_PORTAL_ALLOWED_HOSTS: + logger.warning( + "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", + portal_base_url, host, + ) + return DEFAULT_NOUS_PORTAL_URL + return portal_base_url + + def resolve_nous_access_token( *, timeout_seconds: float = 15.0, @@ -1651,133 +1659,84 @@ def resolve_nous_access_token( ) -> str: """Resolve a refresh-aware Nous Portal access token for managed tool gateways.""" global _RESOLVE_TOKEN_CACHE - # Memo: collapse the startup burst of managed-tool check_fns into one - # network refresh. Only cache a successful, non-forced resolution for a - # short window; force_fresh / error paths bypass and don't populate it. - if not insecure and ca_bundle is None: + # Only a default-TLS resolution is memoised; error paths never populate the memo. + memoable = not insecure and ca_bundle is None + if memoable: with _RESOLVE_TOKEN_CACHE_LOCK: if _RESOLVE_TOKEN_CACHE is not None: cached_at, cached_token = _RESOLVE_TOKEN_CACHE if (time.monotonic() - cached_at) < _RESOLVE_TOKEN_CACHE_TTL_S: return cached_token - with _provider_state_transaction("nous") as ( - auth_store, - state, - state_source_path, - ): + def _memo(token: str) -> str: + global _RESOLVE_TOKEN_CACHE + if memoable: + with _RESOLVE_TOKEN_CACHE_LOCK: + _RESOLVE_TOKEN_CACHE = (time.monotonic(), token) + return token + + with _provider_state_transaction("nous") as (auth_store, state, state_source_path): if not state: raise _nous_err("Hermes is not logged into Nous Portal.", relogin=True) - - # HERMES_PORTAL_BASE_URL / NOUS_PORTAL_BASE_URL is the trusted - # operator/deployment override (mirrors NOUS_INFERENCE_BASE_URL) and - # must win OUTRIGHT — including over a stored value — and bypass the - # host allowlist entirely, since the allowlist exists to reject an - # untrusted network-provided value, not one the operator configured. - # Only fall through to the stored/default value + allowlist gate when - # no override is set. - env_portal_override = _nous_portal_env_override() - if env_portal_override: - portal_base_url = env_portal_override.rstrip("/") - else: - portal_base_url = ( - _optional_base_url(state.get("portal_base_url")) - or DEFAULT_NOUS_PORTAL_URL - ).rstrip("/") - - parsed_portal_url = urlparse(portal_base_url) - if parsed_portal_url.hostname and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS: - logger.warning( - "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", - portal_base_url, parsed_portal_url.hostname, - ) - portal_base_url = DEFAULT_NOUS_PORTAL_URL - + portal_base_url = _nous_portal_base_url(state) client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state) + persist = lambda: _save_provider_state_to_source( # noqa: E731 + auth_store, "nous", state, state_source_path, + ) - with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): + lock_timeout = max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) + with _nous_shared_store_lock(timeout_seconds=lock_timeout): merged_shared = _merge_shared_nous_oauth_state(state) access_token = state.get("access_token") refresh_token = state.get("refresh_token") if not isinstance(access_token, str) or not access_token: - raise _nous_err( - "No access token found for Nous Portal login.", - relogin=True, - ) + raise _nous_err("No access token found for Nous Portal login.", relogin=True) if not _is_expiring(state.get("expires_at"), refresh_skew_seconds): if merged_shared: - _save_provider_state_to_source(auth_store, "nous", state, state_source_path) - # Populate the memo on the valid-token fast path too: the - # startup burst usually finds a *valid* token, but each - # check_fn call still pays two cross-process file locks and - # state reads to reach this return. The token has at least - # refresh_skew_seconds (>= 120s) of life here, so a 5s memo - # can never serve an expired token. - if not insecure and ca_bundle is None: - with _RESOLVE_TOKEN_CACHE_LOCK: - _RESOLVE_TOKEN_CACHE = (time.monotonic(), access_token) - return access_token + persist() + # Memoise the valid-token fast path too: each check_fn otherwise pays two + # cross-process file locks to get here. The token has >= refresh_skew_seconds (>= + # 120s) of life, so a 5s memo can never serve an expired token. + return _memo(access_token) if not isinstance(refresh_token, str) or not refresh_token: - raise _nous_err( - "Session expired and no refresh token is available.", - relogin=True, - ) + raise _nous_err("Session expired and no refresh token is available.", relogin=True) timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) with httpx.Client( - timeout=timeout, - headers={"Accept": "application/json"}, - verify=verify, + timeout=timeout, headers={"Accept": "application/json"}, verify=verify, ) as client: refreshed = _refresh_nous_or_quarantine( - client=client, - auth_store=auth_store, - state=state, - portal_base_url=portal_base_url, - client_id=client_id, - refresh_token=refresh_token, - reason="managed_access_token_refresh_failure", - persist=lambda: _save_provider_state_to_source( - auth_store, "nous", state, state_source_path - ), + client=client, auth_store=auth_store, state=state, portal_base_url=portal_base_url, + client_id=client_id, refresh_token=refresh_token, + reason="managed_access_token_refresh_failure", persist=persist, ) _apply_nous_refreshed_tokens(state, refreshed, refresh_token) state["portal_base_url"] = portal_base_url state["client_id"] = client_id state["tls"] = _tls_state_from_verify(verify) - _save_provider_state_to_source(auth_store, "nous", state, state_source_path) + persist() _write_shared_nous_state(state) - resolved = state["access_token"] - if not insecure and ca_bundle is None: - with _RESOLVE_TOKEN_CACHE_LOCK: - _RESOLVE_TOKEN_CACHE = (time.monotonic(), resolved) - return resolved + return _memo(state["access_token"]) # ============================================================================= # Status helpers # ============================================================================= - -# ── Process-level memo for get_nous_auth_status() ── -# get_nous_auth_status() validates state by calling resolve_nous_runtime_credentials(), -# which does a synchronous OAuth refresh POST to portal.nousresearch.com. That can take -# ~350ms even on the failure path, and read-only UI surfaces (`hermes tools`, status panels, -# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms" -# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning -# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json -# path + mtime so that profile switches do not share a process memo and -# `hermes auth login/logout/add/remove` invalidate naturally on the next call. +# Process-level memo for get_nous_auth_status(): it validates state via +# resolve_nous_runtime_credentials(), a synchronous refresh POST (~350ms even on failure), and +# read-only UI surfaces (`hermes tools`, status panels) call it many times per render — once ~31x in +# one menu paint, burning single-use refresh tokens. Keyed on auth.json path + mtime so profile +# switches don't share a memo and login/logout/add/remove invalidate naturally. _NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds _nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None -# mtime-keyed memo for _load_global_auth_store(): (path, mtime_ns, store). -# Same invalidation contract as _nous_auth_status_cache — the global auth -# file changes only when a global-scope auth write touches it. +# mtime-keyed memo for _load_global_auth_store(): (path, mtime_ns, store). Same invalidation +# contract. _global_auth_store_cache: Optional[Tuple[str, int, Dict[str, Any]]] = None @@ -1794,27 +1753,17 @@ def _auth_file_cache_key() -> Tuple[str, Optional[float]]: def invalidate_nous_auth_status_cache() -> None: - """Clear the get_nous_auth_status() process-level memo. - - Call from code paths that mutate Nous auth state without going through - ``resolve_nous_runtime_credentials()`` (e.g. tests). Login/logout touch auth.json, so the - mtime check invalidates them automatically; this is the belt-and-braces option. - """ + """Clear the get_nous_auth_status() memo (for code paths that mutate Nous auth state without + touching auth.json, e.g. tests; login/logout invalidate via the mtime check automatically).""" global _nous_auth_status_cache _nous_auth_status_cache = None def get_nous_auth_status() -> Dict[str, Any]: - """Status snapshot for Nous auth. + """Status snapshot for Nous auth, memoised ~15s keyed on the auth.json mtime. - Prefer the auth-store provider state, because that is the live source of truth for refresh - operations. When provider state exists, validate it by resolving runtime credentials so revoked - refresh sessions do not show up as a healthy login. - - The returned snapshot is memoised for ~15s keyed on the auth.json mtime, so menu/status surfaces - that ask repeatedly don't trigger one refresh POST per call. Login/logout flows write to - auth.json and therefore invalidate the cache automatically; tests can also call - ``invalidate_nous_auth_status_cache()`` explicitly. + Prefers the auth-store provider state (the live source of truth for refresh) and validates it by + resolving runtime credentials so revoked refresh sessions do not show up as a healthy login. """ global _nous_auth_status_cache now = time.monotonic() @@ -1823,12 +1772,10 @@ def get_nous_auth_status() -> Dict[str, Any]: if cached is not None: cached_at, cached_auth_file_key, cached_mtime, cached_status = cached if ( - cached_auth_file_key == auth_file_key - and cached_mtime == mtime + (cached_auth_file_key, cached_mtime) == (auth_file_key, mtime) and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL ): return dict(cached_status) - status = _compute_nous_auth_status() _nous_auth_status_cache = (now, auth_file_key, mtime, dict(status)) return status @@ -1846,10 +1793,9 @@ class OAuthProviderFlow: provider_id: str resolve_fn: str status_fn: str - # AuthError codes after which retrying the same refresh token cannot succeed. - terminal_refresh_codes: FrozenSet[str] = frozenset() - # ``hermes logout`` with no active provider falls back to config.yaml ``model.provider`` - # only for providers whose credentials live in auth.json. + terminal_refresh_codes: FrozenSet[str] = frozenset() # retrying the same refresh token cannot succeed + # ``hermes logout`` with no active provider falls back to config.yaml ``model.provider`` only + # for providers whose credentials live in auth.json. logout_from_config: bool = False def resolve(self, **kwargs: Any) -> Dict[str, Any]: @@ -1860,10 +1806,8 @@ class OAuthProviderFlow: def is_terminal_refresh_error(self, exc: Exception) -> bool: return ( - isinstance(exc, AuthError) - and exc.provider == self.provider_id - and exc.code in self.terminal_refresh_codes - and bool(exc.relogin_required) + isinstance(exc, AuthError) and exc.provider == self.provider_id + and exc.code in self.terminal_refresh_codes and bool(exc.relogin_required) ) @@ -1884,8 +1828,12 @@ OAUTH_PROVIDER_FLOWS: Dict[str, OAuthProviderFlow] = { terminal_refresh_codes=frozenset({"xai_refresh_failed", "xai_auth_missing_refresh_token"}), logout_from_config=True, ), - "qwen-oauth": OAuthProviderFlow("qwen-oauth", "resolve_qwen_runtime_credentials", "get_qwen_auth_status"), - "minimax-oauth": OAuthProviderFlow("minimax-oauth", "resolve_minimax_oauth_runtime_credentials", "get_minimax_oauth_auth_status"), + "qwen-oauth": OAuthProviderFlow( + "qwen-oauth", "resolve_qwen_runtime_credentials", "get_qwen_auth_status", + ), + "minimax-oauth": OAuthProviderFlow( + "minimax-oauth", "resolve_minimax_oauth_runtime_credentials", "get_minimax_oauth_auth_status", + ), } @@ -1929,21 +1877,16 @@ def _codex_pool_rate_limited_status() -> Optional[Dict[str, Any]]: def get_codex_auth_status() -> Dict[str, Any]: """Status snapshot for Codex auth (pool first, then legacy provider state).""" return _pool_first_oauth_status( - "openai-codex", - is_expiring=_codex_access_token_is_expiring, - auth_mode="chatgpt", - resolve=resolve_codex_runtime_credentials, - on_pool_miss=_codex_pool_rate_limited_status, + "openai-codex", is_expiring=_codex_access_token_is_expiring, auth_mode="chatgpt", + resolve=resolve_codex_runtime_credentials, on_pool_miss=_codex_pool_rate_limited_status, ) def get_xai_oauth_auth_status() -> Dict[str, Any]: + # auth_mode is display/telemetry only; device-code is the only xAI OAuth flow, so report it + # unconditionally (auth.json may still carry a legacy ``oauth_pkce`` label). return _pool_first_oauth_status( - "xai-oauth", - is_expiring=_xai_access_token_is_expiring, - # Display/telemetry only. Device-code is the only xAI OAuth flow, so report it - # unconditionally (auth.json may still carry a legacy ``oauth_pkce`` label). - auth_mode="oauth_device_code", + "xai-oauth", is_expiring=_xai_access_token_is_expiring, auth_mode="oauth_device_code", resolve=resolve_xai_oauth_runtime_credentials, ) @@ -1952,73 +1895,57 @@ def _provider_env_base_url(pconfig: ProviderConfig) -> str: return os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" +def _provider_is_keyless(provider_id: str) -> bool: + """HermesOverlay keyless flag — the same source the provider catalog and GUI contract tests use.""" + try: + from hermes_cli.providers import HERMES_OVERLAYS + return bool(getattr(HERMES_OVERLAYS.get(provider_id), "keyless", False)) + except Exception: + return False + + def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: """Status snapshot for API-key providers (z.ai, Kimi, MiniMax).""" pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "api_key": return {"configured": False} - - # Keyless providers (opencode-free) are served anonymously: no credential - # exists, so every install counts as configured/logged in. Derived from - # the HermesOverlay keyless flag — the same source the provider catalog - # and GUI contract tests use. - try: - from hermes_cli.providers import HERMES_OVERLAYS - _overlay = HERMES_OVERLAYS.get(provider_id) - except Exception: - _overlay = None - if _overlay is not None and getattr(_overlay, "keyless", False): - return { - "configured": True, - "provider": provider_id, - "name": pconfig.name, - "key_source": "keyless", - "base_url": pconfig.inference_base_url, - "logged_in": True, - } + status = { + "configured": True, "provider": provider_id, "name": pconfig.name, "key_source": "keyless", + "base_url": pconfig.inference_base_url, "logged_in": True, + } + if _provider_is_keyless(provider_id): + # Keyless providers (opencode-free) are served anonymously: every install counts as + # configured. + return status api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) env_url = _provider_env_base_url(pconfig) - if provider_id in {"kimi-coding", "kimi-coding-cn"}: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) - elif env_url: - base_url = env_url else: - base_url = pconfig.inference_base_url - + base_url = env_url or pconfig.inference_base_url + actual_local_noauth = False if provider_id == "actual": base_url = normalize_actual_base_url(base_url) - - actual_local_noauth = ( - provider_id == "actual" - and not api_key - and is_actual_local_base_url(base_url) + actual_local_noauth = not api_key and is_actual_local_base_url(base_url) + configured = bool(api_key) or actual_local_noauth + status.update( + configured=configured, base_url=base_url, + key_source=key_source or ("local-offline" if actual_local_noauth else ""), + logged_in=configured, # compat with the OAuth status shape ) - - return { - "configured": bool(api_key) or actual_local_noauth, - "provider": provider_id, - "name": pconfig.name, - "key_source": key_source or ("local-offline" if actual_local_noauth else ""), - "base_url": base_url, - "logged_in": bool(api_key) or actual_local_noauth, # compat with OAuth status shape - } + return status def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[str]]: - """Best-effort POSITIVE evidence that an external-process provider's CLI - is authenticated. + """Best-effort POSITIVE evidence ``(verified, source)`` that an external-process provider's CLI is + authenticated. - Returns ``(verified, source)``. ``verified`` is only ever True on hard - evidence (a supported env token, or a known on-disk credential store). - False means "not verifiable from here", NOT "signed out" — the Copilot - CLI may hold its session in an OS keychain Hermes can't read. Callers - must therefore treat False as unknown, never as proof of absence. - - Deliberately subprocess-free: this runs from status endpoints and pickers, - and spawning ``gh auth token`` there re-creates the cold-start stall - (#60800) that copilot_auth.py works to avoid. + ``verified`` is only True on hard evidence (a supported env token or a known on-disk credential + store). False means "not verifiable from here", NOT "signed out" — the Copilot CLI may hold its + session in an OS keychain Hermes can't read. Deliberately subprocess-free: this runs from status + endpoints and pickers, and spawning ``gh auth token`` there re-creates the cold-start stall + copilot_auth.py works to avoid. """ if provider_id != "copilot-acp": return False, None @@ -2031,31 +1958,24 @@ def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[st return True, f"env: {env_var}" except Exception as exc: logger.debug("copilot-acp env token evidence check failed: %s", exc) - # 2. The Copilot CLI's own plaintext token store (~/.copilot/config.json, - # written by `copilot login` when no OS keychain is available). The file - # is JSONC — strip //-comment lines before parsing. + # 2. The Copilot CLI's own plaintext token store (written by `copilot login` when no OS keychain + # is available). The file is JSONC — strip //-comment lines before parsing. try: cli_config = os.path.expanduser("~/.copilot/config.json") if os.path.isfile(cli_config): with open(cli_config, "r", encoding="utf-8", errors="ignore") as fh: raw = "\n".join( - line for line in fh.read().splitlines() - if not line.lstrip().startswith("//") + line for line in fh.read().splitlines() if not line.lstrip().startswith("//") ) - data = json.loads(raw) if raw.strip() else {} - tokens = data.get("copilotTokens") + tokens = (json.loads(raw) if raw.strip() else {}).get("copilotTokens") if isinstance(tokens, dict) and any( isinstance(v, str) and v.strip() for v in tokens.values() ): return True, "~/.copilot/config.json" except Exception as exc: logger.debug("copilot-acp CLI config evidence check failed: %s", exc) - # 3. Known on-disk GitHub Copilot credential stores (the same locations - # models.py already fingerprints as external credential files). - for cred_path in ( - "~/.config/github-copilot/hosts.json", - "~/.config/github-copilot/apps.json", - ): + # 3. Known on-disk GitHub Copilot credential stores (the same files models.py fingerprints). + for cred_path in ("~/.config/github-copilot/hosts.json", "~/.config/github-copilot/apps.json"): try: expanded = os.path.expanduser(cred_path) if os.path.isfile(expanded) and os.path.getsize(expanded) > 2: @@ -2068,64 +1988,48 @@ def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[st def _external_process_spec( pconfig: ProviderConfig, ) -> tuple[str, List[str], str, Optional[str], tuple[str, ...]]: - """``(command, args, base_url, resolved_command, command_env_vars)`` for a - subprocess-backed (ACP) provider. + """``(command, args, base_url, resolved_command, command_env_vars)`` for a subprocess-backed (ACP) + provider. - How to launch the CLI comes from the provider's own profile, so a provider - shipped outside this tree describes its binary/args instead of inheriting - another vendor's. copilot-acp's values live in its profile, which is why - HERMES_COPILOT_ACP_COMMAND / COPILOT_CLI_PATH / HERMES_COPILOT_ACP_ARGS - keep working unchanged. + How to launch the CLI comes from the provider's own profile, so a provider shipped outside this + tree describes its binary/args instead of inheriting another vendor's (copilot-acp's + HERMES_COPILOT_ACP_COMMAND / COPILOT_CLI_PATH / HERMES_COPILOT_ACP_ARGS live in its profile). """ - base_url = os.getenv(pconfig.base_url_env_var, "").strip() if pconfig.base_url_env_var else "" - if not base_url: - base_url = pconfig.inference_base_url - + base_url = _provider_env_base_url(pconfig) or pconfig.inference_base_url try: from providers import get_provider_profile as _get_provider_profile - profile = _get_provider_profile(pconfig.id) except Exception: profile = None command_env_vars = tuple(getattr(profile, "process_command_env_vars", ()) or ()) args_env_var = str(getattr(profile, "process_args_env_var", "") or "") - command = next((v for v in (os.getenv(var, "").strip() for var in command_env_vars) if v), "") if not command: command = str(getattr(profile, "process_command", "") or "") raw_args = os.getenv(args_env_var, "").strip() if args_env_var else "" args = shlex.split(raw_args) if raw_args else list(getattr(profile, "process_args", ()) or []) - resolved_command = shutil.which(command) if command else None - return command, args, base_url, resolved_command, command_env_vars + return command, args, base_url, shutil.which(command) if command else None, command_env_vars def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: """Status snapshot for providers that run a local subprocess. - ``configured``/``logged_in`` stay structural (the executable resolves or a - TCP endpoint is set) because the spawned subprocess owns its real auth. - ``auth_verified``/``auth_source`` carry positive credential evidence when - Hermes can actually see some — absence of evidence is not absence of auth. + ``configured``/``logged_in`` stay structural (the executable resolves or a TCP endpoint is set) + because the spawned subprocess owns its real auth. ``auth_verified``/``auth_source`` carry + positive credential evidence when Hermes can see some — absence of evidence is not absence of + auth. """ pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "external_process": return {"configured": False} - command, args, base_url, resolved_command, _ = _external_process_spec(pconfig) available = bool(resolved_command or base_url.startswith("acp+tcp://")) auth_verified, auth_source = _external_process_auth_evidence(provider_id) return { - "configured": available, - "provider": provider_id, - "name": pconfig.name, - "command": command, - "args": args, - "resolved_command": resolved_command, - "base_url": base_url, - "logged_in": available, - "auth_verified": auth_verified, - "auth_source": auth_source, + "configured": available, "provider": provider_id, "name": pconfig.name, "command": command, + "args": args, "resolved_command": resolved_command, "base_url": base_url, + "logged_in": available, "auth_verified": auth_verified, "auth_source": auth_source, } @@ -2139,14 +2043,10 @@ def _get_aws_sdk_auth_status(target: str) -> Dict[str, Any]: def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: - """Generic auth status dispatcher. - - Per-provider OAuth status builders come from ``OAUTH_PROVIDER_FLOWS`` (plus Spotify / - Azure Foundry bespoke builders); everything else dispatches on the registry ``auth_type`` - so a provider class (e.g. every external-process ACP backend) gets a real status instead - of the ``{"logged_in": False}`` fallthrough. Builders are looked up by NAME at call time - so tests that patch ``hermes_cli.auth.get_*_auth_status`` still apply. - """ + """Generic auth status dispatcher: bespoke builders (``OAUTH_PROVIDER_FLOWS`` plus Spotify / + Azure Foundry) first, then the registry ``auth_type`` so a whole provider class (e.g. every + external-process ACP backend) gets a real status. Builders are looked up by NAME at call time so + tests that patch ``hermes_cli.auth.get_*_auth_status`` still apply.""" target = (provider_id or get_active_provider() or "").strip().lower() if not target: return {"logged_in": False} @@ -2174,14 +2074,12 @@ _STATUS_BY_AUTH_TYPE: Dict[str, str] = { def _get_azure_foundry_auth_status() -> Dict[str, Any]: - """Return structural auth status for Azure Foundry. + """Structural auth status for Azure Foundry. - * ``auth_mode == "entra_id"`` AND ``azure-identity`` is importable (we do NOT mint a token here; - ``hermes doctor`` runs the live probe and reports whether the credential chain can acquire one). - * ``auth_mode == "api_key"`` (default) AND ``AZURE_FOUNDRY_API_KEY`` is set with a usable value. - - Never invokes the Entra credential chain — keeps CLI startup latency flat regardless of token- - service / az login state. + * ``auth_mode == "entra_id"``: ``azure-identity`` importable (no token is minted here; ``hermes + doctor`` runs the live probe). Never invokes the Entra credential chain, keeping CLI startup + latency flat regardless of token-service / az login state. + * ``auth_mode == "api_key"`` (default): ``AZURE_FOUNDRY_API_KEY`` set with a usable value. """ info: Dict[str, Any] = {"provider": "azure-foundry"} try: @@ -2202,41 +2100,30 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]: if auth_mode == "entra_id": try: from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - has_azure_identity_installed, + EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed, ) installed = has_azure_identity_installed() entra_cfg = {} if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict): entra_cfg = model_cfg["entra"] - identity_config = EntraIdentityConfig.from_dict( - entra_cfg, - default_scope=SCOPE_AI_AZURE_DEFAULT, - ) - info["azure_identity_installed"] = installed - info["scope"] = identity_config.scope - info["credential_probe"] = "not_run" - info["credential_verified"] = False - info["logged_in"] = bool(installed) - if not installed: - info["hint"] = ( + identity_config = EntraIdentityConfig.from_dict(entra_cfg, default_scope=SCOPE_AI_AZURE_DEFAULT) + info.update( + azure_identity_installed=installed, scope=identity_config.scope, credential_probe="not_run", + credential_verified=False, logged_in=bool(installed), + hint=( + "azure-identity is installed; live credential validation " + "is skipped here. Run `hermes doctor` to verify token acquisition." + ) if installed else ( "azure-identity not installed. Install with: " "pip install azure-identity (or rely on Hermes' " "lazy-install at first use)." - ) - else: - info["hint"] = ( - "azure-identity is installed; live credential validation " - "is skipped here. Run `hermes doctor` to verify token acquisition." - ) - return info + ), + ) except Exception as exc: info["logged_in"] = False info["error"] = f"azure-identity check failed: {exc}" - return info + return info - # api_key mode (default) try: api_key = get_env_value_prefer_dotenv("AZURE_FOUNDRY_API_KEY") or "" except Exception: @@ -2250,17 +2137,14 @@ def _default_api_key_base_url(api_key: str, default: str, env_url: str) -> str: def _copilot_runtime_base_url(api_key: str, default: str, env_url: str) -> str: - """Copilot's API base comes from the token-exchange response (endpoints.api, with a - proxy-ep fallback), which is authoritative for Enterprise / proxied accounts. Falls back - to the registry default; the caller's non-empty guard keeps chat inference from ever - resolving an empty base URL (#50252).""" + """Copilot's API base comes from the token-exchange response (endpoints.api, proxy-ep fallback), + authoritative for Enterprise / proxied accounts; falls back to the registry default.""" base_url = _default_api_key_base_url(api_key, default, env_url) try: from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token raw_token, _ = resolve_copilot_token() if raw_token: - _, resolved = get_copilot_api_token(raw_token) - resolved = (resolved or "").strip() + resolved = (get_copilot_api_token(raw_token)[1] or "").strip() if resolved: base_url = resolved except Exception as exc: @@ -2294,27 +2178,21 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if not pconfig or pconfig.auth_type != "api_key": raise AuthError( f"Provider '{provider_id}' is not an API-key provider.", - provider=provider_id, - code="invalid_provider", + provider=provider_id, code="invalid_provider", ) api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) - - # No-auth LM Studio: substitute a placeholder so runtime / auxiliary_client - # see the local server as configured. doctor still reports unconfigured - # because get_api_key_provider_status uses the raw secret resolver. + # No-auth LM Studio: a placeholder so runtime / auxiliary_client see the local server as + # configured. doctor still reports unconfigured because the status path uses the raw secret. if not api_key and provider_id == "lmstudio": api_key = LMSTUDIO_NOAUTH_PLACEHOLDER key_source = key_source or "default" env_url = _provider_env_base_url(pconfig) - base_url = _API_KEY_BASE_URL_RESOLVERS.get(provider_id, _default_api_key_base_url)( - api_key, pconfig.inference_base_url, env_url - ) - - # Last-resort guard: an API-key provider must never hand back an empty - # base URL (a set-but-empty COPILOT_API_BASE_URL or similar env override - # otherwise wedges chat inference — #50252). + resolve_url = _API_KEY_BASE_URL_RESOLVERS.get(provider_id, _default_api_key_base_url) + base_url = resolve_url(api_key, pconfig.inference_base_url, env_url) + # An API-key provider must never hand back an empty base URL (a set-but-empty + # COPILOT_API_BASE_URL or similar env override otherwise wedges chat inference). if not _nonempty_str(base_url): base_url = pconfig.inference_base_url @@ -2323,9 +2201,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: key_source = key_source or "local-offline" return { - "provider": provider_id, - "api_key": api_key, - "base_url": base_url.rstrip("/"), + "provider": provider_id, "api_key": api_key, "base_url": base_url.rstrip("/"), "source": key_source or "default", } @@ -2336,26 +2212,22 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, if not pconfig or pconfig.auth_type != "external_process": raise AuthError( f"Provider '{provider_id}' is not an external-process provider.", - provider=provider_id, - code="invalid_provider", + provider=provider_id, code="invalid_provider", ) command, args, base_url, resolved_command, command_env_vars = _external_process_spec(pconfig) if not resolved_command and not base_url.startswith("acp+tcp://"): - _hint = ( - " or set " + "/".join(command_env_vars) if command_env_vars else "" - ) + _hint = " or set " + "/".join(command_env_vars) if command_env_vars else "" raise AuthError( f"Could not find the '{provider_id}' CLI command " f"'{command or '(none configured)'}'. Install it{_hint}.", provider=provider_id, code="missing_external_process_cli", ) - return { "provider": provider_id, - # Placeholder credential: the subprocess owns real auth. Keyed on the - # provider id so each external-process provider gets a distinct value. + # Placeholder credential: the subprocess owns real auth. Keyed on the provider id so each + # external-process provider gets a distinct value. "api_key": pconfig.id or provider_id, "base_url": base_url.rstrip("/"), "command": resolved_command or command, @@ -2369,28 +2241,22 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, # ============================================================================= def _update_config_for_provider( - provider_id: str, - inference_base_url: str, - default_model: Optional[str] = None, + provider_id: str, inference_base_url: str, default_model: Optional[str] = None, ) -> Path: """Update config.yaml and auth.json to reflect the active provider. - When *default_model* is provided the function also writes it as the ``model.default`` value. - This prevents a race condition where the gateway (which re-reads config per-message) picks up - the new provider before the caller has finished model selection, resulting in a mismatched - model/provider (e.g. an OpenRouter-style ``vendor/model`` name sent to a direct API). + *default_model*, when given, is written as ``model.default`` in the same step so the gateway + (which re-reads config per message) can't pick up the new provider before model selection + finishes and send an OpenRouter-style ``vendor/model`` name to a direct API. """ - # Set active_provider in auth.json so auto-resolution picks this provider - with _auth_store_lock(): + with _auth_store_lock(): # so auto-resolution picks this provider auth_store = _load_auth_store() auth_store["active_provider"] = provider_id _save_auth_store(auth_store) - # Update config.yaml model section config_path = get_config_path() config_path.parent.mkdir(parents=True, exist_ok=True) require_readable_config_before_write(config_path) - config = read_raw_config() current_model = config.get("model") @@ -2405,26 +2271,21 @@ def _update_config_for_provider( if inference_base_url and inference_base_url.strip(): model_cfg["base_url"] = inference_base_url.rstrip("/") else: - # Clear stale base_url to prevent contamination when switching providers - model_cfg.pop("base_url", None) + model_cfg.pop("base_url", None) # clear stale base_url when switching providers - # Clear stale endpoint credentials left over from a previous custom provider. - # Built-in providers resolve credentials from env/auth state, not inline - # model.api_key. + # Built-in providers resolve credentials from env/auth state, not inline model.api_key left + # over from a previous custom provider. from hermes_cli.config import clear_model_endpoint_credentials - clear_model_endpoint_credentials(model_cfg) - # When switching to a non-OpenRouter provider, ensure model.default is - # valid for the new provider. An OpenRouter-formatted name like - # "anthropic/claude-opus-4.6" will fail on direct-API providers. + # An OpenRouter-formatted default like "anthropic/claude-opus-4.6" fails on direct-API + # providers. if default_model: cur_default = model_cfg.get("default", "") if not cur_default or "/" in cur_default: model_cfg["default"] = default_model config["model"] = model_cfg - atomic_yaml_write(config_path, config, sort_keys=False) return config_path @@ -2435,23 +2296,14 @@ def _get_config_provider() -> Optional[str]: config = read_raw_config() except Exception: return None - if not config: - return None - model = config.get("model") - if not isinstance(model, dict): - return None - provider = model.get("provider") - if not isinstance(provider, str): - return None - provider = provider.strip().lower() - return provider or None + model = config.get("model") if config else None + provider = model.get("provider") if isinstance(model, dict) else None + return (provider.strip().lower() or None) if isinstance(provider, str) else None def _config_provider_matches(provider_id: Optional[str]) -> bool: """Return True when config.yaml currently selects *provider_id*.""" - if not provider_id: - return False - return _get_config_provider() == provider_id.strip().lower() + return bool(provider_id) and _get_config_provider() == provider_id.strip().lower() def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool: @@ -2463,12 +2315,9 @@ def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool: def _logout_default_provider_from_config() -> Optional[str]: - """Fallback logout target when auth.json has no active provider. - - That left users stuck when auth state had already been cleared but config.yaml still selected an - OAuth provider such as openai-codex for the agent model: there was no active auth provider to - target, so logout printed "No provider is currently logged in" and never reset model.provider. - """ + """Fallback logout target when auth.json has no active provider but config.yaml still selects an + OAuth provider (e.g. openai-codex) — otherwise logout said "No provider is currently logged in" + and never reset model.provider.""" provider = _get_config_provider() flow = OAUTH_PROVIDER_FLOWS.get(provider or "") return provider if flow and flow.logout_from_config else None @@ -2480,11 +2329,9 @@ def _reset_config_provider() -> Path: if not config_path.exists(): return config_path require_readable_config_before_write(config_path) - config = read_raw_config() if not config: return config_path - model = config.get("model") if isinstance(model, dict): model["provider"] = "auto" @@ -2502,9 +2349,6 @@ def login_command(args) -> None: raise SystemExit(0) -# ==================== MiniMax Portal OAuth ==================== - - def get_minimax_oauth_auth_status() -> Dict[str, Any]: """Return auth status dict for MiniMax OAuth provider.""" state = get_provider_auth_state("minimax-oauth") @@ -2516,40 +2360,34 @@ def get_minimax_oauth_auth_status() -> Dict[str, Any]: except Exception: token_valid = bool(state.get("access_token")) return { - "logged_in": token_valid, - "provider": "minimax-oauth", - "region": state.get("region", "global"), - "expires_at": state.get("expires_at"), + "logged_in": token_valid, "provider": "minimax-oauth", + "region": state.get("region", "global"), "expires_at": state.get("expires_at"), } def logout_command(args) -> None: """Clear auth state for a provider.""" provider_id = getattr(args, "provider", None) - if provider_id and not is_known_auth_provider(provider_id): print(f"Unknown provider: {provider_id}") raise SystemExit(1) - active = get_active_provider() - target = provider_id or active or _logout_default_provider_from_config() - + target = provider_id or get_active_provider() or _logout_default_provider_from_config() if not target: print("No provider is currently logged in.") return should_reset_config = _should_reset_config_provider_on_logout(target) provider_name = get_auth_provider_display_name(target) - - if clear_provider_auth(target) or should_reset_config: - if should_reset_config: - _reset_config_provider() - print(f"Logged out of {provider_name}.") - if should_reset_config and os.getenv("OPENROUTER_API_KEY"): - print("Hermes will use OpenRouter for inference.") - elif should_reset_config: - print("Run `hermes model` or configure an API key to use Hermes.") - else: - print("Model provider configuration was unchanged.") - else: + if not (clear_provider_auth(target) or should_reset_config): print(f"No auth state found for {provider_name}.") + return + if should_reset_config: + _reset_config_provider() + print(f"Logged out of {provider_name}.") + if should_reset_config and os.getenv("OPENROUTER_API_KEY"): + print("Hermes will use OpenRouter for inference.") + elif should_reset_config: + print("Run `hermes model` or configure an API key to use Hermes.") + else: + print("Model provider configuration was unchanged.") From d205d9c90d575604f4fae19f6161ed99d8184550 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:58:37 -0700 Subject: [PATCH 3/7] refactor(hermes_cli): compact auth_commands/auth_constants, hug closers and reflow comments across auth modules --- hermes_cli/auth.py | 256 ++++++++++++----------------------- hermes_cli/auth_commands.py | 251 ++++++++++------------------------ hermes_cli/auth_constants.py | 66 +++------ 3 files changed, 176 insertions(+), 397 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 00b111a177..b8bd8aee10 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -31,33 +31,25 @@ from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tup from urllib.parse import urlparse from hermes_cli.config import ( - get_hermes_home, - get_config_path, - read_raw_config, - require_readable_config_before_write, -) + get_hermes_home, get_config_path, read_raw_config, require_readable_config_before_write) from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir from agent.credential_persistence import sanitize_borrowed_credential_payload from utils import atomic_replace, atomic_yaml_write, env_float, is_truthy_value # noqa: F401 (env_float: agent.credential_pool reads auth_mod.env_float) from hermes_cli.auth_zai_kimi import ( # noqa: F401 re-exported KIMI_CODE_BASE_URL, ZAI_ENDPOINTS, _normalize_lmstudio_runtime_base_url, _resolve_kimi_base_url, - _resolve_zai_base_url, detect_zai_endpoint, -) + _resolve_zai_base_url, detect_zai_endpoint) from hermes_cli.auth_model_picker import ( # noqa: F401 re-exported - _prompt_model_selection, _save_model_choice, -) + _prompt_model_selection, _save_model_choice) from hermes_cli.auth_device_flow import ( # noqa: F401 re-exported _can_open_graphical_browser, _default_verify, _is_remote_session, _nous_device_auth_timeout_message, _offer_existing_oauth_credentials, _poll_device_token_generic, _poll_for_token, _print_device_code_instructions, _print_login_success, _print_loopback_ssh_hint, _prompt_yes_no, _request_device_code, - _resolve_verify, _ssh_user_at_host, -) + _resolve_verify, _ssh_user_at_host) from hermes_cli.auth_oauth_grants import ( # noqa: F401 re-exported SINGLE_USE_REFRESH_POOL_PROVIDERS, _oauth_heal_clean_marks, _oauth_heal_notices, consume_oauth_heal_notices, heal_forked_single_use_oauth_grants, - strip_cloned_single_use_oauth_grants, -) + strip_cloned_single_use_oauth_grants) from hermes_cli.auth_nous import ( # noqa: F401 re-exported NOUS_SESSION_TERMINAL, NOUS_SESSION_UNKNOWN, NOUS_SESSION_VALID, _ALLOWED_NOUS_INFERENCE_HOSTS, _agent_key_is_usable, _apply_nous_refreshed_tokens, _assert_nous_inference_jwt_usable, @@ -71,38 +63,32 @@ from hermes_cli.auth_nous import ( # noqa: F401 re-exported _token_fingerprint, _try_import_shared_nous_state, _validate_nous_inference_url_from_network, _write_shared_nous_state, fetch_nous_models, get_nous_auth_status_local, get_nous_session_validity, persist_nous_credentials, refresh_nous_oauth_from_state, - resolve_nous_runtime_credentials, step_up_nous_billing_scope, -) + resolve_nous_runtime_credentials, step_up_nous_billing_scope) from hermes_cli.auth_minimax import ( # noqa: F401 re-exported _MINIMAX_OAUTH_ERROR_BODY_LIMIT, _login_minimax_oauth, _minimax_oauth_login, _minimax_pkce_pair, _minimax_poll_token, _minimax_post_form, _minimax_request_user_code, _minimax_resolve_token_expiry_unix, _minimax_response_error_text, _minimax_save_auth_state, _refresh_minimax_oauth_state, build_minimax_oauth_token_provider, - resolve_minimax_oauth_runtime_credentials, -) + resolve_minimax_oauth_runtime_credentials) from hermes_cli.auth_xai import ( # noqa: F401 re-exported _login_xai_oauth, _read_xai_oauth_tokens, _refresh_xai_oauth_tokens, _save_xai_oauth_tokens, _write_through_xai_oauth_to_global_root, _xai_access_token_is_expiring, _xai_oauth_device_code_login, _xai_oauth_discovery, _xai_oauth_poll_device_token, _xai_oauth_request_device_code, _xai_proactive_refresh_skew_seconds, - _xai_validate_inference_base_url, refresh_xai_oauth_pure, resolve_xai_oauth_runtime_credentials, -) + _xai_validate_inference_base_url, refresh_xai_oauth_pure, resolve_xai_oauth_runtime_credentials) from hermes_cli.auth_codex import ( # noqa: F401 re-exported _codex_access_token_is_expiring, _codex_device_code_login, _codex_http_client, _codex_pool_rate_limit_status, _codex_quota_probe_cache, _codex_usage_probe_url, _import_codex_cli_tokens, _is_codex_rate_limit_shaped, _login_openai_codex, _probe_codex_quota_restored, _read_codex_tokens, _refresh_codex_auth_tokens, _save_codex_tokens, - clear_codex_pool_quota_cooldowns, refresh_codex_oauth_pure, resolve_codex_runtime_credentials, -) + clear_codex_pool_quota_cooldowns, refresh_codex_oauth_pure, resolve_codex_runtime_credentials) from hermes_cli.auth_spotify import ( # noqa: F401 re-exported _refresh_spotify_oauth_state, get_spotify_auth_status, login_spotify_command, - resolve_spotify_runtime_credentials, -) + resolve_spotify_runtime_credentials) from hermes_cli.auth_qwen import ( # noqa: F401 re-exported _qwen_access_token_is_expiring, _qwen_cli_auth_path, _read_qwen_cli_tokens, _refresh_qwen_cli_tokens, _save_qwen_cli_tokens, get_qwen_auth_status, - resolve_qwen_runtime_credentials, -) + resolve_qwen_runtime_credentials) from hermes_cli.auth_constants import ( # noqa: F401 re-exported _decode_jwt_claims, AUTH_STORE_VERSION, AUTH_LOCK_TIMEOUT_SECONDS, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_CLIENT_ID, NOUS_BILLING_MANAGE_SCOPE, @@ -118,8 +104,7 @@ from hermes_cli.auth_constants import ( # noqa: F401 re-exported XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, DEFAULT_SPOTIFY_ACCOUNTS_BASE_URL, DEFAULT_SPOTIFY_API_BASE_URL, SPOTIFY_DOCS_URL, DEFAULT_SPOTIFY_SCOPE, SERVICE_PROVIDER_NAMES, LMSTUDIO_NOAUTH_PLACEHOLDER, - ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, CODEX_RATE_LIMITED_CODE, AuthError, _nous_err, httpx, -) + ACTUAL_LOCAL_NOAUTH_PLACEHOLDER, CODEX_RATE_LIMITED_CODE, AuthError, _nous_err, httpx) logger = logging.getLogger(__name__) @@ -179,13 +164,11 @@ class ProviderConfig: def _api_key_provider( id: str, name: str, inference_base_url: str, api_key_env_vars: tuple, - base_url_env_var: str = "", auth_type: str = "api_key", -) -> ProviderConfig: + base_url_env_var: str = "", auth_type: str = "api_key") -> ProviderConfig: """Compact constructor for the common env-var-keyed provider shape.""" return ProviderConfig( id=id, name=name, auth_type=auth_type, inference_base_url=inference_base_url, - api_key_env_vars=api_key_env_vars, base_url_env_var=base_url_env_var, - ) + api_key_env_vars=api_key_env_vars, base_url_env_var=base_url_env_var) # Registry rows in priority order (resolve_provider() scans api_key rows in this order). A tuple @@ -195,22 +178,19 @@ _REGISTRY_ROWS: Tuple[Any, ...] = ( ProviderConfig( "nous", "Nous Portal", "oauth_device_code", portal_base_url=DEFAULT_NOUS_PORTAL_URL, inference_base_url=DEFAULT_NOUS_INFERENCE_URL, client_id=DEFAULT_NOUS_CLIENT_ID, - scope=DEFAULT_NOUS_SCOPE, - ), + scope=DEFAULT_NOUS_SCOPE), ProviderConfig("openai-codex", "OpenAI Codex", "oauth_external", inference_base_url=DEFAULT_CODEX_BASE_URL), ("openai-api", "OpenAI API", "https://api.openai.com/v1", ("OPENAI_API_KEY",), "OPENAI_BASE_URL"), ProviderConfig( "xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "oauth_external", - inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL, - ), + inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL), ProviderConfig("qwen-oauth", "Qwen OAuth", "oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL), ("lmstudio", "LM Studio", "http://127.0.0.1:1234/v1", ("LM_API_KEY",), "LM_BASE_URL"), ("copilot", "GitHub Copilot", DEFAULT_GITHUB_MODELS_BASE_URL, ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), "COPILOT_API_BASE_URL"), ProviderConfig( "copilot-acp", "GitHub Copilot ACP", "external_process", - inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL", - ), + inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL, base_url_env_var="COPILOT_ACP_BASE_URL"), ("gemini", "Google AI Studio", "https://generativelanguage.googleapis.com/v1beta", ("GOOGLE_API_KEY", "GEMINI_API_KEY"), "GEMINI_BASE_URL"), ("zai", "Z.AI / GLM", "https://api.z.ai/api/paas/v4", @@ -230,8 +210,7 @@ _REGISTRY_ROWS: Tuple[Any, ...] = ( portal_base_url=MINIMAX_OAUTH_GLOBAL_BASE, inference_base_url=MINIMAX_OAUTH_GLOBAL_INFERENCE, client_id=MINIMAX_OAUTH_CLIENT_ID, scope=MINIMAX_OAUTH_SCOPE, extra={"region": "global", "cn_portal_base_url": MINIMAX_OAUTH_CN_BASE, - "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE}, - ), + "cn_inference_base_url": MINIMAX_OAUTH_CN_INFERENCE}), # CLAUDE_CODE_OAUTH_TOKEN is NOT an API key despite auth_type="api_key": `claude setup-token` # yields an `sk-ant-oat01…` OAuth token (401s as x-api-key, 429s as bare Bearer). It stays in # this tuple because the tuple doubles as the credential-DISCOVERY list @@ -272,8 +251,7 @@ _REGISTRY_ROWS: Tuple[Any, ...] = ( # No static inference_base_url: Vertex's endpoint is computed per request from project_id + # region (agent/vertex_adapter.py build_vertex_base_url), not a fixed host. ("vertex", "Google Vertex AI", "", (), "", "vertex"), - ("azure-foundry", "Azure Foundry", "", ("AZURE_FOUNDRY_API_KEY",), "AZURE_FOUNDRY_BASE_URL"), -) + ("azure-foundry", "Azure Foundry", "", ("AZURE_FOUNDRY_API_KEY",), "AZURE_FOUNDRY_BASE_URL")) PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { p.id: p for p in (r if isinstance(r, ProviderConfig) else _api_key_provider(*r) for r in _REGISTRY_ROWS) } @@ -293,15 +271,13 @@ def _register_plugin_provider(pp: Any) -> None: """ if pp.auth_type == "external_process": pconfig = ProviderConfig( - pp.name, pp.display_name or pp.name, "external_process", inference_base_url=pp.base_url, - ) + pp.name, pp.display_name or pp.name, "external_process", inference_base_url=pp.base_url) elif pp.auth_type == "api_key" and pp.env_vars and pp.name not in _REGISTRY_PLUGIN_SKIP: is_url = lambda v: v.endswith("_BASE_URL") or v.endswith("_URL") # noqa: E731 pconfig = _api_key_provider( pp.name, pp.display_name or pp.name, pp.base_url, tuple(v for v in pp.env_vars if not is_url(v)) or pp.env_vars, - next((v for v in pp.env_vars if is_url(v)), None) or "", - ) + next((v for v in pp.env_vars if is_url(v)), None) or "") else: return PROVIDER_REGISTRY[pp.name] = pconfig @@ -338,8 +314,7 @@ def get_anthropic_key() -> str: _PLACEHOLDER_SECRET_VALUES = { "*", "**", "***", "changeme", "your_api_key", "your_api_key_here", "your-api-key", - "placeholder", "example", "dummy", "null", "none", -} + "placeholder", "example", "dummy", "null", "none"} def has_usable_secret(value: Any, *, min_length: int = 4) -> bool: @@ -369,8 +344,7 @@ def _warn_malformed_secret(provider_id: str, source: str) -> None: "Ignoring %s for provider %r: value does not match the expected key " "prefix (%s). Falling back to the next credential source. Fix or " "remove the malformed key to silence this warning.", - source, provider_id, " or ".join(KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id, ())), - ) + source, provider_id, " or ".join(KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id, ()))) def _usable_declared_secret(provider_id: str, value: Any, source: str) -> Optional[str]: @@ -453,11 +427,9 @@ def is_rate_limited_auth_error(error: Exception) -> bool: # the raw error when no generic text exists for the code). _GENERIC_ENTITLEMENT_MESSAGES = { "subscription_required": "No active paid subscription found. Please purchase/activate a subscription, then retry.", - "insufficient_credits": "Subscription credits are exhausted. Top up/renew credits, then retry.", -} + "insufficient_credits": "Subscription credits are exhausted. Top up/renew credits, then retry."} _ENTITLEMENT_ERROR_CODES = frozenset(_GENERIC_ENTITLEMENT_MESSAGES) | { - "subscription_expired", "no_usable_credits", "account_missing", "member_spend_cap_exceeded", -} + "subscription_expired", "no_usable_credits", "account_missing", "member_spend_cap_exceeded"} def format_auth_error(error: Exception) -> str: @@ -501,8 +473,7 @@ def _auth_file_path() -> Path: raise RuntimeError( f"Refusing to touch real user auth store during test run: {path}. " "Set HERMES_HOME to a tmp_path in your test fixture, or run " - "via scripts/run_tests.sh for hermetic CI-parity env." - ) + "via scripts/run_tests.sh for hermetic CI-parity env.") return path @@ -531,8 +502,7 @@ def _load_global_auth_store() -> Dict[str, Any]: return {} try: cache_key: Optional[Tuple[str, int]] = ( - str(global_path.resolve(strict=False)), global_path.stat().st_mtime_ns, - ) + str(global_path.resolve(strict=False)), global_path.stat().st_mtime_ns) except Exception: cache_key = None if cache_key is not None and _global_auth_store_cache is not None: @@ -582,8 +552,7 @@ def _auth_lock_holder_for(target_path: Path) -> threading.local: @contextmanager def _file_lock( - lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str, -): + lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str): """Cross-process advisory flock helper, reentrant per-thread via ``holder.depth``. Falls back to a depth-only guard when neither ``fcntl`` nor ``msvcrt`` is available. Callers @@ -650,8 +619,7 @@ def _file_lock( @contextmanager def _auth_store_lock( - timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None, -): + timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS, *, target_path: Optional[Path] = None): """Cross-process advisory lock for one auth.json read/write transaction. ``target_path`` is required for profile-to-global write-throughs: a profile lock does not @@ -664,8 +632,7 @@ def _auth_store_lock( auth_path = target_path if target_path is not None else _auth_file_path() with _file_lock( auth_path.with_suffix(".lock"), _auth_lock_holder_for(auth_path), timeout_seconds, - "Timed out waiting for auth store lock", - ): + "Timed out waiting for auth store lock"): yield @@ -688,8 +655,7 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: logger.warning( "auth: could not read %s, leaving the store on disk untouched " "rather than degrading to an empty one", - auth_file, exc_info=True, - ) + auth_file, exc_info=True) raise except Exception as exc: # Genuine corruption: unparseable JSON or non-UTF-8 bytes. Preserve a copy, but never @@ -707,13 +673,11 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: "auth: failed to parse %s (%s), starting with empty store. %s %s", auth_file, exc, "Corrupt file preserved at" if preserved else "A copy could NOT be preserved at", - corrupt_path, - ) + corrupt_path) return _empty_auth_store() if isinstance(raw, dict) and ( - isinstance(raw.get("providers"), dict) or isinstance(raw.get("credential_pool"), dict) - ): + isinstance(raw.get("providers"), dict) or isinstance(raw.get("credential_pool"), dict)): raw.setdefault("providers", {}) if isinstance(raw.get("providers"), dict): _migrate_stale_nous_portal_url(raw["providers"]) @@ -734,8 +698,7 @@ def _write_private_file_atomic( payload: str, *, replace: Optional[Callable[[Any, Any], Any]] = None, - fsync_dir: bool = False, -) -> None: + fsync_dir: bool = False) -> None: """Write *payload* to *target* via a 0o600 temp file + atomic rename. ``os.open(O_EXCL, 0o600)`` closes the TOCTOU window where ``write_text()`` + post-write @@ -953,8 +916,7 @@ def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: _POOL_STATUS_FIELDS = ( "last_status", "last_status_at", "last_error_code", "last_error_reason", "last_error_message", - "last_error_reset_at", -) + "last_error_reset_at") def _merge_disk_cooldown_state( @@ -1017,8 +979,7 @@ def write_credential_pool( pool = _store_section(auth_store, "credential_pool") sanitized = [ sanitize_borrowed_credential_payload(e, provider_id) if isinstance(e, dict) else e - for e in entries - ] + for e in entries] existing = pool.get(provider_id) existing_list = existing if isinstance(existing, list) else [] existing_by_id = _entry_ids(existing_list) @@ -1026,8 +987,7 @@ def write_credential_pool( merged: List[Dict[str, Any]] = [ _merge_disk_cooldown_state(e, existing_by_id.get(e.get("id")), provider_id) if isinstance(e, dict) else e - for e in sanitized - ] + for e in sanitized] for disk_entry in existing_list: disk_id = disk_entry.get("id") if isinstance(disk_entry, dict) else None if disk_id and disk_id not in new_ids and disk_id not in removed: @@ -1123,16 +1083,14 @@ def _config_selects_provider(normalized: str) -> bool: def _moa_block_matches(block: Any) -> bool: return isinstance(block, dict) and ( any(_slot_selects(s, normalized) for s in block.get("reference_models") or []) - or _slot_selects(block.get("aggregator"), normalized) - ) + or _slot_selects(block.get("aggregator"), normalized)) moa_cfg = cfg.get("moa") if not isinstance(moa_cfg, dict): return False presets = moa_cfg.get("presets") return _moa_block_matches(moa_cfg) or ( - isinstance(presets, dict) and any(_moa_block_matches(p) for p in presets.values()) - ) + isinstance(presets, dict) and any(_moa_block_matches(p) for p in presets.values())) def _explicit_pool_entry_present(normalized: str) -> bool: @@ -1170,8 +1128,7 @@ def _explicit_env_credentials_present(normalized: str) -> bool: return any(_env_secret(v) for v in pconfig.api_key_env_vars if v not in _IMPLICIT_ENV_VARS) if pconfig.auth_type == "aws_sdk": return _env_secret("AWS_BEARER_TOKEN_BEDROCK") or ( - _env_secret("AWS_ACCESS_KEY_ID") and _env_secret("AWS_SECRET_ACCESS_KEY") - ) + _env_secret("AWS_ACCESS_KEY_ID") and _env_secret("AWS_SECRET_ACCESS_KEY")) return False @@ -1214,8 +1171,7 @@ _EXPLICIT_CONFIG_CHECKS: Tuple[Tuple[Callable[[str], bool], bool], ...] = ( (_config_selects_provider, True), (_explicit_env_credentials_present, False), (_explicit_pool_entry_present, True), - (_keyless_provider_has_explicit_config, True), -) + (_keyless_provider_has_explicit_config, True)) def is_provider_explicitly_configured(provider_id: str) -> bool: @@ -1311,8 +1267,7 @@ def _refuse_env_adoption_if_config_corrupt() -> None: f"config.yaml at {path} is corrupt ({err}) — refusing to auto-select " f"an inference provider from environment keys. Fix the YAML (a backup " f"was saved next to it) or run hermes setup.", - code="corrupt_config", - ) + code="corrupt_config") # Provider aliases accepted by resolve_provider(). Plugin-declared aliases @@ -1354,8 +1309,7 @@ _PROVIDER_ALIASES: Dict[str, str] = { # Local server aliases — route through the generic custom provider "ollama": "custom", "ollama_cloud": "ollama-cloud", "vllm": "custom", "llamacpp": "custom", - "llama.cpp": "custom", "llama-cpp": "custom", -} + "llama.cpp": "custom", "llama-cpp": "custom"} def _plugin_aliases() -> Dict[str, str]: @@ -1387,8 +1341,7 @@ def _scoped_key_env_reader() -> Callable[[str], str]: "agent.auxiliary_client unavailable (%s); provider auto-detection " "will read keys from the process environment only — under " "multiplex, secondary profiles may report 'No LLM provider'.", - "import failed", - ) + "import failed") return lambda name: os.getenv(name) or "" @@ -1445,8 +1398,7 @@ _NO_AUTO_DETECT_PROVIDERS = frozenset({"copilot", "lmstudio"}) def _env_key_auto_detected( - scoped_key_env: Callable[[str], str], oauth_active: Optional[str], -) -> Optional[str]: + scoped_key_env: Callable[[str], str], oauth_active: Optional[str]) -> Optional[str]: """First registry api_key provider (registry order) with a usable env key, warning when it preempts a logged-in OAuth provider so a stale key in ~/.hermes/.env never switches silently.""" for pid, pconfig in PROVIDER_REGISTRY.items(): @@ -1460,8 +1412,7 @@ def _env_key_auto_detected( "logged-in OAuth provider %r. If you meant to use the " "OAuth login, unset %s or set `model.provider` " "explicitly.", - pid, env_var, oauth_active, env_var, - ) + pid, env_var, oauth_active, env_var) return pid return None @@ -1470,8 +1421,7 @@ def resolve_provider( requested: Optional[str] = None, *, explicit_api_key: Optional[str] = None, - explicit_base_url: Optional[str] = None, -) -> str: + explicit_base_url: Optional[str] = None) -> str: """Determine which inference provider to use. Priority when requested is "auto"/None — explicit user intent wins over a stale logged-in OAuth @@ -1521,8 +1471,7 @@ def resolve_provider( "Provider resolved to logged-in OAuth provider %r because " "config.yaml `model` has no `provider` key. If you meant a " "different provider, set `model.provider` explicitly.", - _oauth_active, - ) + _oauth_active) return _oauth_active # AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars); after API-key providers @@ -1538,8 +1487,7 @@ def resolve_provider( "No inference provider configured. Run 'hermes model' to choose a " "provider and model, or set an API key (OPENROUTER_API_KEY, " "OPENAI_API_KEY, etc.) in ~/.hermes/.env.", - code="no_provider_configured", - ) + code="no_provider_configured") # ============================================================================= @@ -1586,8 +1534,7 @@ def _last_auth_error_marker( "message": str(error), "reason": reason, "relogin_required": True, - "at": datetime.now(timezone.utc).isoformat(), - } + "at": datetime.now(timezone.utc).isoformat()} _FLAT_OAUTH_TOKEN_KEYS = ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at") @@ -1599,8 +1546,7 @@ def _quarantine_flat_oauth_state(state: Dict[str, Any], provider: str, exc: "Aut for _k in _FLAT_OAUTH_TOKEN_KEYS: state.pop(_k, None) state["last_auth_error"] = _last_auth_error_marker( - provider, exc, reason="runtime_refresh_failure", default_code="refresh_failed", - ) + provider, exc, reason="runtime_refresh_failure", default_code="refresh_failed") def _coerce_ttl_seconds(expires_in: Any) -> int: @@ -1618,8 +1564,7 @@ def _optional_base_url(value: Any) -> Optional[str]: # Valid Nous Portal hosts; a stored portal_base_url outside this set is a misconfiguration and falls # back to the default. localhost / 127.0.0.1 are for local development and testing. _NOUS_PORTAL_ALLOWED_HOSTS: FrozenSet[str] = frozenset({ - "portal.nousresearch.com", "localhost", "127.0.0.1", -}) + "portal.nousresearch.com", "localhost", "127.0.0.1"}) # Per-process memo for resolve_nous_access_token. Startup runs check_tool_availability once per @@ -1644,8 +1589,7 @@ def _nous_portal_base_url(state: Dict[str, Any]) -> str: if host and host not in _NOUS_PORTAL_ALLOWED_HOSTS: logger.warning( "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", - portal_base_url, host, - ) + portal_base_url, host) return DEFAULT_NOUS_PORTAL_URL return portal_base_url @@ -1655,8 +1599,7 @@ def resolve_nous_access_token( timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, - refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS, -) -> str: + refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS) -> str: """Resolve a refresh-aware Nous Portal access token for managed tool gateways.""" global _RESOLVE_TOKEN_CACHE # Only a default-TLS resolution is memoised; error paths never populate the memo. @@ -1682,8 +1625,7 @@ def resolve_nous_access_token( client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state) persist = lambda: _save_provider_state_to_source( # noqa: E731 - auth_store, "nous", state, state_source_path, - ) + auth_store, "nous", state, state_source_path) lock_timeout = max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) with _nous_shared_store_lock(timeout_seconds=lock_timeout): @@ -1711,8 +1653,7 @@ def resolve_nous_access_token( refreshed = _refresh_nous_or_quarantine( client=client, auth_store=auth_store, state=state, portal_base_url=portal_base_url, client_id=client_id, refresh_token=refresh_token, - reason="managed_access_token_refresh_failure", persist=persist, - ) + reason="managed_access_token_refresh_failure", persist=persist) _apply_nous_refreshed_tokens(state, refreshed, refresh_token) state["portal_base_url"] = portal_base_url @@ -1773,8 +1714,7 @@ def get_nous_auth_status() -> Dict[str, Any]: cached_at, cached_auth_file_key, cached_mtime, cached_status = cached if ( (cached_auth_file_key, cached_mtime) == (auth_file_key, mtime) - and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL - ): + and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL): return dict(cached_status) status = _compute_nous_auth_status() _nous_auth_status_cache = (now, auth_file_key, mtime, dict(status)) @@ -1807,8 +1747,7 @@ class OAuthProviderFlow: def is_terminal_refresh_error(self, exc: Exception) -> bool: return ( isinstance(exc, AuthError) and exc.provider == self.provider_id - and exc.code in self.terminal_refresh_codes and bool(exc.relogin_required) - ) + and exc.code in self.terminal_refresh_codes and bool(exc.relogin_required)) _OAUTH_GRANT_DEAD_CODES = frozenset({"invalid_grant", "invalid_token", "refresh_token_reused"}) @@ -1816,24 +1755,19 @@ _OAUTH_GRANT_DEAD_CODES = frozenset({"invalid_grant", "invalid_token", "refresh_ OAUTH_PROVIDER_FLOWS: Dict[str, OAuthProviderFlow] = { "nous": OAuthProviderFlow( "nous", "resolve_nous_runtime_credentials", "get_nous_auth_status", - terminal_refresh_codes=_OAUTH_GRANT_DEAD_CODES, logout_from_config=True, - ), + terminal_refresh_codes=_OAUTH_GRANT_DEAD_CODES, logout_from_config=True), "openai-codex": OAuthProviderFlow( "openai-codex", "resolve_codex_runtime_credentials", "get_codex_auth_status", terminal_refresh_codes=_OAUTH_GRANT_DEAD_CODES | {"codex_refresh_failed", "codex_auth_missing_refresh_token"}, - logout_from_config=True, - ), + logout_from_config=True), "xai-oauth": OAuthProviderFlow( "xai-oauth", "resolve_xai_oauth_runtime_credentials", "get_xai_oauth_auth_status", terminal_refresh_codes=frozenset({"xai_refresh_failed", "xai_auth_missing_refresh_token"}), - logout_from_config=True, - ), + logout_from_config=True), "qwen-oauth": OAuthProviderFlow( - "qwen-oauth", "resolve_qwen_runtime_credentials", "get_qwen_auth_status", - ), + "qwen-oauth", "resolve_qwen_runtime_credentials", "get_qwen_auth_status"), "minimax-oauth": OAuthProviderFlow( - "minimax-oauth", "resolve_minimax_oauth_runtime_credentials", "get_minimax_oauth_auth_status", - ), + "minimax-oauth", "resolve_minimax_oauth_runtime_credentials", "get_minimax_oauth_auth_status"), } @@ -1868,18 +1802,15 @@ def _codex_pool_rate_limited_status() -> Optional[Dict[str, Any]]: "error_code": CODEX_RATE_LIMITED_CODE, "error": ( rate_limit.get("message") - or "Codex provider quota exhausted; retry after the usage limit resets." - ), - "reset_at": rate_limit.get("reset_at"), - } + or "Codex provider quota exhausted; retry after the usage limit resets."), + "reset_at": rate_limit.get("reset_at")} def get_codex_auth_status() -> Dict[str, Any]: """Status snapshot for Codex auth (pool first, then legacy provider state).""" return _pool_first_oauth_status( "openai-codex", is_expiring=_codex_access_token_is_expiring, auth_mode="chatgpt", - resolve=resolve_codex_runtime_credentials, on_pool_miss=_codex_pool_rate_limited_status, - ) + resolve=resolve_codex_runtime_credentials, on_pool_miss=_codex_pool_rate_limited_status) def get_xai_oauth_auth_status() -> Dict[str, Any]: @@ -1887,8 +1818,7 @@ def get_xai_oauth_auth_status() -> Dict[str, Any]: # unconditionally (auth.json may still carry a legacy ``oauth_pkce`` label). return _pool_first_oauth_status( "xai-oauth", is_expiring=_xai_access_token_is_expiring, auth_mode="oauth_device_code", - resolve=resolve_xai_oauth_runtime_credentials, - ) + resolve=resolve_xai_oauth_runtime_credentials) def _provider_env_base_url(pconfig: ProviderConfig) -> str: @@ -1911,8 +1841,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: return {"configured": False} status = { "configured": True, "provider": provider_id, "name": pconfig.name, "key_source": "keyless", - "base_url": pconfig.inference_base_url, "logged_in": True, - } + "base_url": pconfig.inference_base_url, "logged_in": True} if _provider_is_keyless(provider_id): # Keyless providers (opencode-free) are served anonymously: every install counts as # configured. @@ -1965,12 +1894,10 @@ def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[st if os.path.isfile(cli_config): with open(cli_config, "r", encoding="utf-8", errors="ignore") as fh: raw = "\n".join( - line for line in fh.read().splitlines() if not line.lstrip().startswith("//") - ) + line for line in fh.read().splitlines() if not line.lstrip().startswith("//")) tokens = (json.loads(raw) if raw.strip() else {}).get("copilotTokens") if isinstance(tokens, dict) and any( - isinstance(v, str) and v.strip() for v in tokens.values() - ): + isinstance(v, str) and v.strip() for v in tokens.values()): return True, "~/.copilot/config.json" except Exception as exc: logger.debug("copilot-acp CLI config evidence check failed: %s", exc) @@ -1986,8 +1913,7 @@ def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[st def _external_process_spec( - pconfig: ProviderConfig, -) -> tuple[str, List[str], str, Optional[str], tuple[str, ...]]: + pconfig: ProviderConfig) -> tuple[str, List[str], str, Optional[str], tuple[str, ...]]: """``(command, args, base_url, resolved_command, command_env_vars)`` for a subprocess-backed (ACP) provider. @@ -2029,8 +1955,7 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: return { "configured": available, "provider": provider_id, "name": pconfig.name, "command": command, "args": args, "resolved_command": resolved_command, "base_url": base_url, - "logged_in": available, "auth_verified": auth_verified, "auth_source": auth_source, - } + "logged_in": available, "auth_verified": auth_verified, "auth_source": auth_source} def _get_aws_sdk_auth_status(target: str) -> Dict[str, Any]: @@ -2064,13 +1989,11 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: _BESPOKE_STATUS_FUNCTIONS: Dict[str, str] = { **{pid: flow.status_fn for pid, flow in OAUTH_PROVIDER_FLOWS.items()}, "spotify": "get_spotify_auth_status", - "azure-foundry": "_get_azure_foundry_auth_status", -} + "azure-foundry": "_get_azure_foundry_auth_status"} _STATUS_BY_AUTH_TYPE: Dict[str, str] = { "external_process": "get_external_process_provider_status", "api_key": "get_api_key_provider_status", - "aws_sdk": "_get_aws_sdk_auth_status", -} + "aws_sdk": "_get_aws_sdk_auth_status"} def _get_azure_foundry_auth_status() -> Dict[str, Any]: @@ -2100,8 +2023,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]: if auth_mode == "entra_id": try: from agent.azure_identity_adapter import ( - EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed, - ) + EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed) installed = has_azure_identity_installed() entra_cfg = {} if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict): @@ -2116,9 +2038,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]: ) if installed else ( "azure-identity not installed. Install with: " "pip install azure-identity (or rely on Hermes' " - "lazy-install at first use)." - ), - ) + "lazy-install at first use).")) except Exception as exc: info["logged_in"] = False info["error"] = f"azure-identity check failed: {exc}" @@ -2168,8 +2088,7 @@ _API_KEY_BASE_URL_RESOLVERS: Dict[str, Callable[[str, str, str], str]] = { "zai": _resolve_zai_base_url, "copilot": _copilot_runtime_base_url, "lmstudio": _lmstudio_runtime_base_url, - "actual": _actual_runtime_base_url, -} + "actual": _actual_runtime_base_url} def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: @@ -2178,8 +2097,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if not pconfig or pconfig.auth_type != "api_key": raise AuthError( f"Provider '{provider_id}' is not an API-key provider.", - provider=provider_id, code="invalid_provider", - ) + provider=provider_id, code="invalid_provider") api_key, key_source = _resolve_api_key_provider_secret(provider_id, pconfig) # No-auth LM Studio: a placeholder so runtime / auxiliary_client see the local server as @@ -2202,8 +2120,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: return { "provider": provider_id, "api_key": api_key, "base_url": base_url.rstrip("/"), - "source": key_source or "default", - } + "source": key_source or "default"} def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]: @@ -2212,8 +2129,7 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, if not pconfig or pconfig.auth_type != "external_process": raise AuthError( f"Provider '{provider_id}' is not an external-process provider.", - provider=provider_id, code="invalid_provider", - ) + provider=provider_id, code="invalid_provider") command, args, base_url, resolved_command, command_env_vars = _external_process_spec(pconfig) if not resolved_command and not base_url.startswith("acp+tcp://"): @@ -2222,8 +2138,7 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, f"Could not find the '{provider_id}' CLI command " f"'{command or '(none configured)'}'. Install it{_hint}.", provider=provider_id, - code="missing_external_process_cli", - ) + code="missing_external_process_cli") return { "provider": provider_id, # Placeholder credential: the subprocess owns real auth. Keyed on the provider id so each @@ -2232,8 +2147,7 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, "base_url": base_url.rstrip("/"), "command": resolved_command or command, "args": args, - "source": "process", - } + "source": "process"} # ============================================================================= @@ -2241,8 +2155,7 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, # ============================================================================= def _update_config_for_provider( - provider_id: str, inference_base_url: str, default_model: Optional[str] = None, -) -> Path: + provider_id: str, inference_base_url: str, default_model: Optional[str] = None) -> Path: """Update config.yaml and auth.json to reflect the active provider. *default_model*, when given, is written as ``model.default`` in the same step so the gateway @@ -2361,8 +2274,7 @@ def get_minimax_oauth_auth_status() -> Dict[str, Any]: token_valid = bool(state.get("access_token")) return { "logged_in": token_valid, "provider": "minimax-oauth", - "region": state.get("region", "global"), "expires_at": state.get("expires_at"), - } + "region": state.get("region", "global"), "expires_at": state.get("expires_at")} def logout_command(args) -> None: diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 883191f337..1b0af02c99 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -28,8 +28,7 @@ from agent.credential_pool import ( get_pool_strategy, label_from_token, list_custom_pool_providers, - load_pool, -) + load_pool) import hermes_cli.auth as auth_mod from hermes_cli.auth import PROVIDER_REGISTRY from hermes_constants import OPENROUTER_BASE_URL @@ -50,17 +49,14 @@ def _get_custom_provider_entries() -> list[dict]: return [] result: list[dict] = [] for entry in get_compatible_custom_providers(config): - if not isinstance(entry, dict): - continue - name = entry.get("name") + name = entry.get("name") if isinstance(entry, dict) else None if not isinstance(name, str) or not name.strip(): continue result.append({ **entry, "name": name.strip(), "pool_key": f"{CUSTOM_POOL_PREFIX}{_normalize_custom_pool_name(name)}", - "provider_key": str(entry.get("provider_key", "") or "").strip(), - }) + "provider_key": str(entry.get("provider_key", "") or "").strip()}) return result @@ -69,11 +65,7 @@ def _configured_provider_entry(provider: str) -> dict | None: normalized = (provider or "").strip().lower() if not normalized or normalized.startswith(CUSTOM_POOL_PREFIX): return None - for entry in _get_custom_provider_entries(): - provider_key = str(entry.get("provider_key") or "").strip().lower() - if provider_key and provider_key == normalized: - return entry - return None + return next((e for e in _get_custom_provider_entries() if e["provider_key"].lower() == normalized), None) def _resolve_custom_provider_input(raw: str) -> str | None: @@ -81,14 +73,12 @@ def _resolve_custom_provider_input(raw: str) -> str | None: normalized = (raw or "").strip().lower().replace(" ", "-") if not normalized: return None - # Direct match on 'custom:name' format if normalized.startswith(CUSTOM_POOL_PREFIX): return normalized for entry in _get_custom_provider_entries(): - # ``providers:`` entries already have a durable runtime slug. Keep - # credentials under that slug instead of leaking the legacy - # ``custom:`` compatibility identity into auth.json and discovery. - provider_key = entry["provider_key"].strip().lower() + # ``providers:`` entries already have a durable runtime slug; keep credentials under it + # instead of leaking the legacy ``custom:`` identity into auth.json and discovery. + provider_key = entry["provider_key"].lower() if provider_key and provider_key == normalized: return provider_key if _normalize_custom_pool_name(entry["name"]) == normalized: @@ -97,20 +87,14 @@ def _resolve_custom_provider_input(raw: str) -> str | None: _PROVIDER_ALIASES = { - "or": "openrouter", - "open-router": "openrouter", - "grok-oauth": "xai-oauth", - "xai-oauth": "xai-oauth", - "x-ai-oauth": "xai-oauth", - "xai-grok-oauth": "xai-oauth", + "or": "openrouter", "open-router": "openrouter", + "grok-oauth": "xai-oauth", "xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth", } def _normalize_provider(provider: str) -> str: normalized = (provider or "").strip().lower() - if normalized in _PROVIDER_ALIASES: - return _PROVIDER_ALIASES[normalized] - return _resolve_custom_provider_input(normalized) or normalized + return _PROVIDER_ALIASES.get(normalized) or _resolve_custom_provider_input(normalized) or normalized def _migrate_legacy_custom_pool_key(provider: str, legacy_key: str) -> None: @@ -126,11 +110,7 @@ def _migrate_legacy_custom_pool_key(provider: str, legacy_key: str) -> None: current_entries = credential_pool.get(provider) merged = list(current_entries) if isinstance(current_entries, list) else [] - known_ids = { - entry.get("id") - for entry in merged - if isinstance(entry, dict) and entry.get("id") - } + known_ids = {e.get("id") for e in merged if isinstance(e, dict) and e.get("id")} for entry in legacy_entries: entry_id = entry.get("id") if isinstance(entry, dict) else None if entry_id and entry_id in known_ids: @@ -170,8 +150,7 @@ def _is_known_provider(provider: str, configured_provider: dict | None) -> bool: provider in PROVIDER_REGISTRY or provider == "openrouter" or provider.startswith(CUSTOM_POOL_PREFIX) - or configured_provider is not None - ) + or configured_provider is not None) def _display_source(source: str) -> str: @@ -185,8 +164,7 @@ _EXHAUSTED_CLASSES = ( ("rate limit", "usage limit", "quota", "too many requests")), ("auth failed", False, {401, 403}, ("invalid_token", "invalid_grant", "unauthorized", "forbidden", "auth"), - ("unauthorized", "forbidden", "expired", "revoked", "invalid token", "authentication")), -) + ("unauthorized", "forbidden", "expired", "revoked", "invalid token", "authentication"))) def _classify_exhausted_status(entry) -> tuple[str, bool]: @@ -206,21 +184,22 @@ def _format_exhausted_status(entry) -> str: reason = getattr(entry, "last_error_reason", None) reason_text = f" {reason}" if isinstance(reason, str) and reason.strip() else "" code = f" ({entry.last_error_code})" if entry.last_error_code else "" + head = f" {label}{reason_text}{code}" if not show_retry_window: - return f" {label}{reason_text}{code} (re-auth may be required)" + return f"{head} (re-auth may be required)" exhausted_until = _exhausted_until(entry) if exhausted_until is None: - return f" {label}{reason_text}{code}" + return head remaining = max(0, int(math.ceil(exhausted_until - time.time()))) if remaining <= 0: - return f" {label}{reason_text}{code} (ready to retry)" + return f"{head} (ready to retry)" minutes, seconds = divmod(remaining, 60) hours, minutes = divmod(minutes, 60) days, hours = divmod(hours, 24) parts = [(days, "d"), (hours, "h"), (minutes, "m"), (seconds, "s")] first = next(i for i, (value, _) in enumerate(parts) if value or i == 3) wait = " ".join(f"{value}{unit}" for value, unit in parts[first:first + 2]) - return f" {label}{reason_text}{code} ({wait} left)" + return f"{head} ({wait} left)" def _anthropic_oauth_login(args) -> dict: @@ -257,9 +236,7 @@ _OAUTH_ADD_SPECS: dict[str, _OAuthAddSpec] = { fields=lambda creds, provider: { "refresh_token": creds.get("refresh_token"), "expires_at_ms": creds.get("expires_at_ms"), - "base_url": _provider_base_url(provider), - }, - ), + "base_url": _provider_base_url(provider)}), "openai-codex": _OAuthAddSpec( login=lambda args: auth_mod._codex_device_code_login(), token=lambda creds: creds["tokens"]["access_token"], @@ -267,67 +244,52 @@ _OAUTH_ADD_SPECS: dict[str, _OAuthAddSpec] = { fields=lambda creds, provider: { "refresh_token": creds["tokens"].get("refresh_token"), "base_url": creds.get("base_url"), - "last_refresh": creds.get("last_refresh"), - }, - activate_first=True, - ), + "last_refresh": creds.get("last_refresh")}, + activate_first=True), "xai-oauth": _OAuthAddSpec( login=lambda args: auth_mod._xai_oauth_device_code_login( timeout_seconds=getattr(args, "timeout", None) or 20.0, - open_browser=not getattr(args, "no_browser", False), - ), + open_browser=not getattr(args, "no_browser", False)), token=lambda creds: creds["tokens"]["access_token"], source=SOURCE_MANUAL_DEVICE_CODE, fields=lambda creds, provider: { "refresh_token": creds["tokens"].get("refresh_token"), "base_url": creds.get("base_url") or auth_mod.DEFAULT_XAI_OAUTH_BASE_URL, - "last_refresh": creds.get("last_refresh"), - }, - activate_first=True, - ), + "last_refresh": creds.get("last_refresh")}, + activate_first=True), "qwen-oauth": _OAuthAddSpec( login=_qwen_oauth_login, token=lambda creds: creds["api_key"], source=f"{SOURCE_MANUAL}:qwen_cli", - fields=lambda creds, provider: {"base_url": creds.get("base_url")}, - ), + fields=lambda creds, provider: {"base_url": creds.get("base_url")}), "minimax-oauth": _OAuthAddSpec( login=lambda args: auth_mod._minimax_oauth_login( open_browser=not getattr(args, "no_browser", False), - timeout_seconds=getattr(args, "timeout", None) or 15.0, - ), + timeout_seconds=getattr(args, "timeout", None) or 15.0), token=lambda creds: creds["access_token"], source=f"{SOURCE_MANUAL}:minimax_oauth", fields=lambda creds, provider: { - "refresh_token": creds.get("refresh_token"), - "base_url": creds.get("inference_base_url"), - }, - ), + "refresh_token": creds.get("refresh_token"), "base_url": creds.get("inference_base_url")}), } def _add_nous_oauth_credential(args, provider: str) -> None: """``hermes auth add nous --type oauth``: shared-credential import, else device-code login.""" custom_label = (getattr(args, "label", None) or "").strip() or None + timeout = getattr(args, "timeout", None) or 15.0 def _persist(creds: dict, what: str) -> None: - # Honor `--label ` so nous matches other providers' UX. The - # helper embeds this into providers.nous so that label_from_token - # doesn't overwrite it on every subsequent load_pool("nous"). + # `--label` is embedded into providers.nous so label_from_token doesn't overwrite it on every + # subsequent load_pool("nous"). entry = auth_mod.persist_nous_credentials(creds, label=custom_label) shown_label = entry.label if entry is not None else label_from_token( - creds.get("access_token", ""), f"{provider}-oauth-1", - ) + creds.get("access_token", ""), f"{provider}-oauth-1") print(f'{what} {provider} OAuth {"device-code " if what == "Saved" else ""}credentials: "{shown_label}"') - # Codex-style auto-import: if a shared Nous credential lives at - # /shared/nous_auth.json (written by any previous - # successful login), offer to import it instead of running the - # full device-code flow. This makes `hermes --profile - # auth add nous --type oauth` a one-tap operation for users who - # run multiple profiles. - shared = auth_mod._read_shared_nous_state() - if shared: + # Codex-style auto-import: a shared Nous credential at /shared/nous_auth.json + # (written by any previous login) makes `hermes --profile auth add nous --type oauth` + # a one-tap operation for multi-profile users. + if auth_mod._read_shared_nous_state(): try: found = f"Found existing Nous OAuth credentials at {auth_mod._nous_shared_store_path()}" except RuntimeError: @@ -340,14 +302,11 @@ def _add_nous_oauth_credential(args, provider: str) -> None: do_import = "y" if do_import in {"", "y", "yes"}: print("Rehydrating Nous session from shared credentials...") - rehydrated = auth_mod._try_import_shared_nous_state( - timeout_seconds=getattr(args, "timeout", None) or 15.0, - ) + rehydrated = auth_mod._try_import_shared_nous_state(timeout_seconds=timeout) if rehydrated is not None: _persist(rehydrated, "Imported") return - # Rehydrate failed (expired refresh_token, portal down, etc.) - # — fall through to device-code flow. + # Expired refresh_token, portal down, etc. — fall through to device-code. print("Could not refresh shared credentials — falling back to device-code login.") creds = auth_mod._nous_device_code_login( @@ -356,10 +315,9 @@ def _add_nous_oauth_credential(args, provider: str) -> None: client_id=getattr(args, "client_id", None), scope=getattr(args, "scope", None), open_browser=not getattr(args, "no_browser", False), - timeout_seconds=getattr(args, "timeout", None) or 15.0, + timeout_seconds=timeout, insecure=bool(getattr(args, "insecure", False)), - ca_bundle=getattr(args, "ca_bundle", None), - ) + ca_bundle=getattr(args, "ca_bundle", None)) _persist(creds, "Saved") @@ -394,8 +352,7 @@ def _add_api_key_credential(args, provider: str, pool) -> None: priority=0, source=SOURCE_MANUAL, access_token=token, - base_url=_provider_base_url(provider), - ) + base_url=_provider_base_url(provider)) pool.add_entry(entry) print(f'Added {provider} credential #{len(pool.entries())}: "{label}"') @@ -408,22 +365,21 @@ def auth_add_command(args) -> None: if configured_provider is not None: _migrate_legacy_custom_pool_key(provider, configured_provider["pool_key"]) + is_custom = provider.startswith(CUSTOM_POOL_PREFIX) requested_type = str(getattr(args, "auth_type", "") or "").strip().lower() if requested_type in {AUTH_TYPE_API_KEY, "api-key"}: requested_type = AUTH_TYPE_API_KEY if not requested_type: - oauth_default = provider in _OAUTH_CAPABLE_PROVIDERS and not provider.startswith(CUSTOM_POOL_PREFIX) + oauth_default = provider in _OAUTH_CAPABLE_PROVIDERS and not is_custom requested_type = AUTH_TYPE_OAUTH if oauth_default else AUTH_TYPE_API_KEY pool = load_pool(provider) - - if not provider.startswith(CUSTOM_POOL_PREFIX): + if not is_custom: _unsuppress_provider_sources(provider) if requested_type == AUTH_TYPE_API_KEY: _add_api_key_credential(args, provider, pool) return - if provider == "nous": _add_nous_oauth_credential(args, provider) return @@ -435,30 +391,17 @@ def auth_add_command(args) -> None: creds = spec.login(args) token = spec.token(creds) label = (getattr(args, "label", None) or "").strip() or label_from_token( - token, f"{provider}-oauth-{len(pool.entries()) + 1}", - ) - # Every provider gets a distinct, self-contained pool entry per account - # instead of routing through a singleton save path. The singleton - # round-trip collapsed every added account into the latest login: a - # second ``hermes auth add `` overwrote the first account's - # singleton-mirrored entry rather than creating an independent one - # (#39236). ``manual:*`` entries refresh from their own token pair, so - # they need no singleton shadow. + token, f"{provider}-oauth-{len(pool.entries()) + 1}") + # Every account gets a distinct, self-contained pool entry instead of routing through a + # singleton save path (which collapsed every added account into the latest login). + # ``manual:*`` entries refresh from their own token pair, so they need no singleton shadow. entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=spec.source, - access_token=token, - **spec.fields(creds, provider), - ) + provider=provider, id=uuid.uuid4().hex[:6], label=label, auth_type=AUTH_TYPE_OAUTH, priority=0, + source=spec.source, access_token=token, **spec.fields(creds, provider)) first_credential = not pool.entries() pool.add_entry(entry) - # Adding the first Codex/xAI credential should make it the active - # provider (the old singleton save path did this implicitly via - # _save_provider_state). Subsequent adds leave the active provider as-is. + # The first Codex/xAI credential becomes the active provider (as the old singleton save path + # did implicitly); subsequent adds leave the active provider as-is. if spec.activate_first and first_credential: auth_mod.mark_provider_active_if_unset(provider) print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') @@ -470,21 +413,12 @@ def auth_list_command(args) -> None: providers = [provider_filter] else: credential_pool = auth_mod._load_auth_store().get("credential_pool") - persisted_providers = ( - credential_pool.keys() if isinstance(credential_pool, dict) else () - ) - configured_providers = ( - entry["provider_key"] - for entry in _get_custom_provider_entries() - if entry["provider_key"] - ) providers = sorted({ *PROVIDER_REGISTRY.keys(), "openrouter", *list_custom_pool_providers(), - *configured_providers, - *persisted_providers, - }) + *(e["provider_key"] for e in _get_custom_provider_entries() if e["provider_key"]), + *(credential_pool.keys() if isinstance(credential_pool, dict) else ())}) for provider in providers: pool = load_pool(provider) entries = pool.entries() @@ -520,20 +454,14 @@ def auth_remove_command(args) -> None: raise SystemExit(f'No credential matching "{target}" for provider {provider}.') print(f"Removed {provider} credential #{index} ({removed.label})") - # Unified removal dispatch. Every credential source Hermes reads from - # (env vars, external OAuth files, auth.json blocks, custom config) - # has a RemovalStep registered in agent.credential_sources. The step - # handles its source-specific cleanup and we centralise suppression + - # user-facing output here so every source behaves identically from - # the user's perspective. + # Every credential source Hermes reads from (env vars, external OAuth files, auth.json blocks, + # custom config) has a RemovalStep in agent.credential_sources; it does the source-specific + # cleanup while suppression + user-facing output are centralised here. from agent.credential_sources import find_removal_step step = find_removal_step(provider, removed.source) - if step is None: - # Unregistered source — e.g. "manual", which has nothing external - # to clean up. The pool entry is already gone; we're done. + if step is None: # unregistered source, e.g. "manual": nothing external to clean up return - result = step.remove_fn(provider, removed) for line in result.cleaned: print(line) @@ -555,16 +483,13 @@ def auth_status_command(args) -> None: if not provider: raise SystemExit("Provider is required. Example: `hermes auth status spotify`.") if provider in auth_mod.SINGLE_USE_REFRESH_POOL_PROVIDERS: - # load_pool() runs the forked-grant heal (#100339); do it before the - # status read so the report reflects the consolidated grant. - load_pool(provider) + load_pool(provider) # runs the forked-grant heal first so the report reflects the consolidated grant status = auth_mod.get_auth_status(provider) _print_oauth_heal_notices() if not status.get("logged_in"): reason = status.get("error") print(f"{provider}: logged out" + (f" ({reason})" if reason else "")) return - print(f"{provider}: logged in") for key in ("auth_type", "client_id", "redirect_uri", "scope", "expires_at", "api_base_url"): value = status.get(key) @@ -616,14 +541,10 @@ def _print_azure_entra_status() -> None: model_cfg = cfg.get("model") if isinstance(cfg, dict) else None if not isinstance(model_cfg, dict) or ( str(model_cfg.get("provider") or "").strip().lower() != "azure-foundry" - or str(model_cfg.get("auth_mode") or "").strip().lower() != "entra_id" - ): + or str(model_cfg.get("auth_mode") or "").strip().lower() != "entra_id"): return from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - describe_active_credential, - has_azure_identity_installed, + EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, describe_active_credential, has_azure_identity_installed, ) base_url = str(model_cfg.get("base_url") or "").strip() entra = model_cfg.get("entra") or {} @@ -635,9 +556,7 @@ def _print_azure_entra_status() -> None: print(" Status: ⚠ azure-identity not installed " "(pip install azure-identity)") else: - info = describe_active_credential( - config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0 - ) + info = describe_active_credential(config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0) env_sources = info.get("env_sources") or [] if info.get("ok"): print(f" Status: ✓ token acquired ({', '.join(env_sources) if env_sources else 'default chain'})") @@ -652,40 +571,27 @@ def _print_azure_entra_status() -> None: def _interactive_auth() -> None: """Interactive credential pool management when `hermes auth` is called bare.""" - # Show current pool status first print("Credential Pool Status") print("=" * 50) - auth_list_command(SimpleNamespace(provider=None)) _print_bedrock_status() _print_azure_entra_status() print() - # Main menu choices = [ - "Add a credential", - "Remove a credential", - "Reset cooldowns for a provider", - "Set rotation strategy for a provider", - "Exit", - ] + "Add a credential", "Remove a credential", "Reset cooldowns for a provider", + "Set rotation strategy for a provider", "Exit"] print("What would you like to do?") for i, choice in enumerate(choices, 1): print(f" {i}. {choice}") - try: raw = input("\nChoice: ").strip() except (EOFError, KeyboardInterrupt): return - if not raw or raw == str(len(choices)): return - handler = { - "1": _interactive_add, - "2": _interactive_remove, - "3": _interactive_reset, - "4": _interactive_strategy, + "1": _interactive_add, "2": _interactive_remove, "3": _interactive_reset, "4": _interactive_strategy, }.get(raw) if handler is not None: handler() @@ -711,7 +617,6 @@ def _interactive_add() -> None: if not _is_known_provider(provider, configured_provider): raise SystemExit(f"Unknown provider: {provider}") - # For OAuth-capable providers, ask which type auth_type = "api_key" if provider in _OAUTH_CAPABLE_PROVIDERS: print(f"\n{provider} supports both API keys and OAuth login.") @@ -732,8 +637,7 @@ def _interactive_add() -> None: auth_add_command(SimpleNamespace( provider=provider, auth_type=auth_type, label=label, api_key=None, portal_url=None, inference_url=None, client_id=None, scope=None, - no_browser=False, timeout=None, insecure=False, ca_bundle=None, - )) + no_browser=False, timeout=None, insecure=False, ca_bundle=None)) def _interactive_remove() -> None: @@ -742,20 +646,14 @@ def _interactive_remove() -> None: if not pool.has_credentials(): print(f"No credentials for {provider}.") return - - # Show entries with indices for i, e in enumerate(pool.entries(), 1): - exhausted = _format_exhausted_status(e) - print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{exhausted} [id:{e.id}]") - + print(f" #{i} {e.label:25s} {e.auth_type:10s} {e.source}{_format_exhausted_status(e)} [id:{e.id}]") try: raw = line_input("Remove #, id, or label (blank to cancel): ").strip() except (EOFError, KeyboardInterrupt): return - if not raw: - return - - auth_remove_command(SimpleNamespace(provider=provider, target=raw)) + if raw: + auth_remove_command(SimpleNamespace(provider=provider, target=raw)) def _interactive_reset() -> None: @@ -766,8 +664,7 @@ _STRATEGY_DESCRIPTIONS = { STRATEGY_FILL_FIRST: "Use first key until exhausted, then next", STRATEGY_ROUND_ROBIN: "Cycle through keys evenly", STRATEGY_LEAST_USED: "Always pick the least-used key", - STRATEGY_RANDOM: "Random selection", -} + STRATEGY_RANDOM: "Random selection"} def _interactive_strategy() -> None: @@ -780,14 +677,12 @@ def _interactive_strategy() -> None: for i, s in enumerate(strategies, 1): marker = " ←" if s == current else "" print(f" {i}. {s:15s} — {_STRATEGY_DESCRIPTIONS[s]}{marker}") - try: raw = input("\nStrategy [1-4]: ").strip() except (EOFError, KeyboardInterrupt): return if not raw: return - try: strategy = strategies[int(raw) - 1] except (ValueError, IndexError): @@ -812,14 +707,12 @@ _AUTH_ACTIONS = { "reset": auth_reset_command, "status": auth_status_command, "logout": auth_logout_command, - "spotify": auth_spotify_command, -} + "spotify": auth_spotify_command} def auth_command(args) -> None: handler = _AUTH_ACTIONS.get(getattr(args, "auth_action", "")) if handler is not None: handler(args) - return - # No subcommand — launch interactive mode - _interactive_auth() + else: + _interactive_auth() # no subcommand diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index 98b086b245..c140503a2d 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -10,14 +10,11 @@ import base64 import json from typing import Any, Callable, Dict, Optional -# httpx is imported lazily: it costs ~30ms at import time and hermes_cli.auth -# is on the interactive-CLI startup path via credential_pool → auxiliary_client -# → cli_commands_mixin, where no HTTP request is ever made before first use. -# The proxy resolves to the real module on first attribute access; every -# consumer in this file uses `httpx.` so the swap is transparent. -# Annotations like ``httpx.Client`` stay valid: `from __future__ import -# annotations` (above) keeps them unevaluated at runtime, and the -# TYPE_CHECKING import gives static checkers the real module. +# httpx is imported lazily: it costs ~30ms and hermes_cli.auth is on the interactive-CLI startup path +# (credential_pool -> auxiliary_client -> cli_commands_mixin) where no request is made before first +# use. The proxy resolves to the real module on first attribute access; ``from __future__ import +# annotations`` keeps ``httpx.Client`` annotations unevaluated and TYPE_CHECKING gives static +# checkers the real module. import importlib as _importlib from typing import TYPE_CHECKING @@ -40,8 +37,8 @@ else: def __getattr__(self, name): return getattr(self._resolve(), name) - # Forward set/del to the real module so monkeypatch.setattr - # ("hermes_cli.auth.httpx.Client", ...) keeps working in tests. + # Forward set/del to the real module so monkeypatch.setattr("hermes_cli.auth.httpx.Client", + # ...) keeps working in tests. def __setattr__(self, name, value): setattr(self._resolve(), name, value) @@ -70,10 +67,7 @@ ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" -_FORM_JSON_HEADERS = { - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", -} +_FORM_JSON_HEADERS = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" DEFAULT_XAI_OAUTH_BASE_URL = "https://api.x.ai/v1" MINIMAX_OAUTH_CLIENT_ID = "78257093-7e40-4613-99e0-527b14b39113" @@ -105,11 +99,9 @@ XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration" XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access" XAI_OAUTH_DEVICE_CODE_URL = f"{XAI_OAUTH_ISSUER}/oauth2/device/code" -# xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in -# current SuperGrok flows). A two-minute refresh window is too narrow for -# gateway/cron workloads that may only touch the provider every 30 minutes, -# leaving brief but noisy credential-expiry gaps. Refresh up to one hour -# early so ordinary runtime calls keep the token warm without user reauth. +# xAI/Grok OAuth access tokens are short-lived (~6h). A two-minute refresh window leaves noisy +# credential-expiry gaps for gateway/cron workloads that touch the provider every ~30 min, so refresh +# up to an hour early. XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600 QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token" @@ -123,32 +115,19 @@ SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh" DEFAULT_SPOTIFY_SCOPE = " ".join(( - "user-modify-playback-state", - "user-read-playback-state", - "user-read-currently-playing", - "user-read-recently-played", - "playlist-read-private", - "playlist-read-collaborative", - "playlist-modify-public", - "playlist-modify-private", - "user-library-read", - "user-library-modify", + "user-modify-playback-state", "user-read-playback-state", "user-read-currently-playing", + "user-read-recently-played", "playlist-read-private", "playlist-read-collaborative", + "playlist-modify-public", "playlist-modify-private", "user-library-read", "user-library-modify", )) -SERVICE_PROVIDER_NAMES: Dict[str, str] = { - "spotify": "Spotify", -} +SERVICE_PROVIDER_NAMES: Dict[str, str] = {"spotify": "Spotify"} -# LM Studio's default no-auth mode still requires *some* non-empty bearer for -# the API-key code paths (auxiliary_client, runtime resolver) to treat the -# provider as configured. This sentinel is sent only to LM Studio, never to -# any remote service. +# LM Studio's default no-auth mode still needs *some* non-empty bearer for the API-key code paths to +# treat the provider as configured. Sent only to LM Studio, never to a remote service. LMSTUDIO_NOAUTH_PLACEHOLDER = "dummy-lm-api-key" ACTUAL_LOCAL_NOAUTH_PLACEHOLDER = "dummy-actual-local-api-key" - -# Error code marking upstream rate-limit / usage-quota exhaustion (HTTP 429). -# Such failures are transient and re-authenticating cannot resolve them, so -# they must be kept distinct from missing/expired-credential errors. +# Upstream rate-limit / usage-quota exhaustion (HTTP 429): transient, re-authenticating cannot resolve +# it, so it must stay distinct from missing/expired-credential errors. CODEX_RATE_LIMITED_CODE = "codex_rate_limited" @@ -156,12 +135,7 @@ class AuthError(RuntimeError): """Structured auth error with UX mapping hints.""" def __init__( - self, - message: str, - *, - provider: str = "", - code: Optional[str] = None, - relogin_required: bool = False, + self, message: str, *, provider: str = "", code: Optional[str] = None, relogin_required: bool = False, ) -> None: super().__init__(message) self.provider = provider From 64b9c4393d35c0132ec41cc54d80dc5ddcb50a02 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:17:44 -0700 Subject: [PATCH 4/7] refactor(hermes_cli): auth.py unify kernel lock/unlock, pack explicit-check table --- hermes_cli/auth.py | 89 +++++++++++++++++-------------------- hermes_cli/auth_commands.py | 24 ++++------ 2 files changed, 49 insertions(+), 64 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index b8bd8aee10..7d17a7feec 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -23,7 +23,7 @@ import time import uuid import webbrowser # noqa: F401 (tests patch auth_mod.webbrowser.open; same module object) -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -550,6 +550,15 @@ def _auth_lock_holder_for(target_path: Path) -> threading.local: return _auth_target_lock_holders.setdefault(key, threading.local()) +def _kernel_lock(lock_file: Any, acquire: bool) -> None: + """Non-blocking exclusive flock (fcntl) or 1-byte msvcrt lock at offset 0; ``acquire=False`` releases.""" + if fcntl: + fcntl.flock(lock_file.fileno(), (fcntl.LOCK_EX | fcntl.LOCK_NB) if acquire else fcntl.LOCK_UN) + else: + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK if acquire else msvcrt.LK_UNLCK, 1) + + @contextmanager def _file_lock( lock_path: Path, holder: threading.local, timeout_seconds: float, timeout_message: str): @@ -568,53 +577,39 @@ def _file_lock( return lock_path.parent.mkdir(parents=True, exist_ok=True) - - if fcntl is None and msvcrt is None: - holder.depth = 1 - try: - yield - finally: - holder.depth = 0 - return - - # msvcrt.locking needs a non-empty file with the pointer at 0. This convenience write can race - # another holder's byte-range lock and raise PermissionError (reproduced with 20 concurrent - # processes on Windows); losing that race just means the file already has content, so swallow - # it. - if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): - try: - lock_path.write_text(" ", encoding="utf-8") - except (OSError, PermissionError): - pass - - with lock_path.open("r+" if msvcrt else "a+", encoding="utf-8") as lock_file: - deadline = time.monotonic() + max(1.0, timeout_seconds) - while True: - try: - if fcntl: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - else: - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) - break - except (BlockingIOError, OSError, PermissionError): - if time.monotonic() >= deadline: - raise TimeoutError(timeout_message) - time.sleep(0.05) + with ExitStack() as stack: + lock_file = None + if fcntl is not None or msvcrt is not None: + # msvcrt.locking needs a non-empty file with the pointer at 0. This convenience write can + # race another holder's byte-range lock and raise PermissionError (reproduced with 20 + # concurrent processes on Windows); losing the race just means the file already has + # content, so swallow it. + if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): + try: + lock_path.write_text(" ", encoding="utf-8") + except (OSError, PermissionError): + pass + lock_file = stack.enter_context(lock_path.open("r+" if msvcrt else "a+", encoding="utf-8")) + deadline = time.monotonic() + max(1.0, timeout_seconds) + while True: + try: + _kernel_lock(lock_file, True) + break + except (BlockingIOError, OSError, PermissionError): + if time.monotonic() >= deadline: + raise TimeoutError(timeout_message) + time.sleep(0.05) holder.depth = 1 try: yield finally: holder.depth = 0 - try: - if fcntl: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - elif msvcrt: - lock_file.seek(0) - msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) - except (OSError, IOError): - pass + if lock_file is not None: + try: + _kernel_lock(lock_file, False) + except (OSError, IOError): + pass @contextmanager @@ -1167,11 +1162,10 @@ def _keyless_provider_has_explicit_config(normalized: str) -> bool: # exception as "no"; the env-var check is NOT best-effort — a failure there must surface rather # than let a later, weaker signal decide. _EXPLICIT_CONFIG_CHECKS: Tuple[Tuple[Callable[[str], bool], bool], ...] = ( - (_active_provider_is, True), - (_config_selects_provider, True), - (_explicit_env_credentials_present, False), - (_explicit_pool_entry_present, True), - (_keyless_provider_has_explicit_config, True)) + (_active_provider_is, True), (_config_selects_provider, True), + (_explicit_env_credentials_present, False), (_explicit_pool_entry_present, True), + (_keyless_provider_has_explicit_config, True), +) def is_provider_explicitly_configured(provider_id: str) -> bool: @@ -1566,7 +1560,6 @@ def _optional_base_url(value: Any) -> Optional[str]: _NOUS_PORTAL_ALLOWED_HOSTS: FrozenSet[str] = frozenset({ "portal.nousresearch.com", "localhost", "127.0.0.1"}) - # Per-process memo for resolve_nous_access_token. Startup runs check_tool_availability once per # managed-tool check_fn (browser, image_gen, ...) and each independently triggers a ~15s blocking # refresh when the stored token is expired; a short-TTL memo collapses that burst into one network diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 1b0af02c99..88dd5ddbba 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -312,12 +312,10 @@ def _add_nous_oauth_credential(args, provider: str) -> None: creds = auth_mod._nous_device_code_login( portal_base_url=getattr(args, "portal_url", None), inference_base_url=getattr(args, "inference_url", None), - client_id=getattr(args, "client_id", None), - scope=getattr(args, "scope", None), - open_browser=not getattr(args, "no_browser", False), - timeout_seconds=timeout, - insecure=bool(getattr(args, "insecure", False)), - ca_bundle=getattr(args, "ca_bundle", None)) + client_id=getattr(args, "client_id", None), scope=getattr(args, "scope", None), + open_browser=not getattr(args, "no_browser", False), timeout_seconds=timeout, + insecure=bool(getattr(args, "insecure", False)), ca_bundle=getattr(args, "ca_bundle", None), + ) _persist(creds, "Saved") @@ -345,14 +343,9 @@ def _add_api_key_credential(args, provider: str, pool) -> None: label = line_input(f"Label (optional, default: {default_label}): ").strip() label = label or default_label entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_API_KEY, - priority=0, - source=SOURCE_MANUAL, - access_token=token, - base_url=_provider_base_url(provider)) + provider=provider, id=uuid.uuid4().hex[:6], label=label, auth_type=AUTH_TYPE_API_KEY, + priority=0, source=SOURCE_MANUAL, access_token=token, base_url=_provider_base_url(provider), + ) pool.add_entry(entry) print(f'Added {provider} credential #{len(pool.entries())}: "{label}"') @@ -553,8 +546,7 @@ def _print_azure_entra_status() -> None: print(f" Endpoint: {base_url or '(not configured)'}") print(f" Scope: {scope}") if not has_azure_identity_installed(): - print(" Status: ⚠ azure-identity not installed " - "(pip install azure-identity)") + print(" Status: ⚠ azure-identity not installed (pip install azure-identity)") else: info = describe_active_credential(config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0) env_sources = info.get("env_sources") or [] From d11334c899048b13eeb2cd3a881cbcb6c4d760a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:25:06 -0700 Subject: [PATCH 5/7] refactor(hermes_cli): auth section banners as one-liners --- hermes_cli/auth.py | 32 ++++++++------------------------ hermes_cli/auth_constants.py | 4 +--- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 7d17a7feec..a00518195e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -143,9 +143,7 @@ def normalize_actual_base_url(base_url: str) -> str: return url -# ============================================================================= -# Provider Registry -# ============================================================================= +# ── Provider Registry ─────────────────────────────────────────────────────────────────────────────── @dataclass class ProviderConfig: @@ -308,9 +306,7 @@ def get_anthropic_key() -> str: return "" -# ============================================================================= -# Secret validation -# ============================================================================= +# ── Secret validation ─────────────────────────────────────────────────────────────────────────────── _PLACEHOLDER_SECRET_VALUES = { "*", "**", "***", "changeme", "your_api_key", "your_api_key_here", "your-api-key", @@ -411,9 +407,7 @@ def _resolve_api_key_provider_secret(provider_id: str, pconfig: ProviderConfig) return "", "" -# ============================================================================= -# Error formatting (AuthError itself lives in auth_constants) -# ============================================================================= +# ── Error formatting (AuthError itself lives in auth_constants) ───────────────────────────────────── def is_rate_limited_auth_error(error: Exception) -> bool: """True when an :class:`AuthError` is upstream rate-limiting / quota: transient, and @@ -454,9 +448,7 @@ def _nonempty_str(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) -# ============================================================================= -# Auth Store — persistence layer for ~/.hermes/auth.json -# ============================================================================= +# ── Auth Store — persistence layer for ~/.hermes/auth.json ────────────────────────────────────────── def _auth_file_path() -> Path: path = get_hermes_home() / "auth.json" @@ -1216,9 +1208,7 @@ def deactivate_provider() -> None: _save_auth_store(auth_store) -# ============================================================================= -# Provider Resolution — picks which provider to use -# ============================================================================= +# ── Provider Resolution — picks which provider to use ─────────────────────────────────────────────── def _get_config_hint_for_unknown_provider(provider_name: str) -> str: @@ -1484,9 +1474,7 @@ def resolve_provider( code="no_provider_configured") -# ============================================================================= -# Timestamp / TTL helpers -# ============================================================================= +# ── Timestamp / TTL helpers ───────────────────────────────────────────────────────────────────────── def _utc_now_z() -> str: """Current UTC time as an ISO-8601 string with a ``Z`` suffix (last_refresh format).""" @@ -1657,9 +1645,7 @@ def resolve_nous_access_token( return _memo(state["access_token"]) -# ============================================================================= -# Status helpers -# ============================================================================= +# ── Status helpers ────────────────────────────────────────────────────────────────────────────────── # Process-level memo for get_nous_auth_status(): it validates state via # resolve_nous_runtime_credentials(), a synchronous refresh POST (~350ms even on failure), and @@ -2143,9 +2129,7 @@ def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, "source": "process"} -# ============================================================================= -# CLI Commands — login / logout -# ============================================================================= +# ── CLI Commands — login / logout ─────────────────────────────────────────────────────────────────── def _update_config_for_provider( provider_id: str, inference_base_url: str, default_model: Optional[str] = None) -> Path: diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index c140503a2d..2e4d379850 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -47,9 +47,7 @@ else: httpx = _LazyHttpx() -# ============================================================================= -# Constants -# ============================================================================= +# ── Constants ─────────────────────────────────────────────────────────────────────────────────────── AUTH_STORE_VERSION = 1 AUTH_LOCK_TIMEOUT_SECONDS = 15.0 From 3b48dbca465c2684f822d8aedb4b442661c8fbf4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:34:40 -0700 Subject: [PATCH 6/7] refactor(hermes_cli): fold lone docstring closers in auth modules --- hermes_cli/auth.py | 72 ++++++++++++------------------------ hermes_cli/auth_constants.py | 3 +- 2 files changed, 25 insertions(+), 50 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index a00518195e..124e491c3f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -7,8 +7,7 @@ - ``resolve_provider()`` picks the active provider via the documented priority chain. - ``OAUTH_PROVIDER_FLOWS`` maps each OAuth provider to its resolver/status builder; the flows live in ``auth_nous``/``auth_codex``/``auth_xai``/``auth_qwen``/``auth_minimax``/``auth_spotify`` and are - re-imported here so ``hermes_cli.auth.`` stays the public/patchable surface. -""" + re-imported here so ``hermes_cli.auth.`` stays the public/patchable surface.""" from __future__ import annotations @@ -295,8 +294,7 @@ except Exception: def get_anthropic_key() -> str: """First usable Anthropic credential (``.env`` preferred over a stale shell export), or ``""``. - Order mirrors ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars``. - """ + Order mirrors ``PROVIDER_REGISTRY["anthropic"].api_key_env_vars``.""" from hermes_cli.config import get_env_value_prefer_dotenv for var in PROVIDER_REGISTRY["anthropic"].api_key_env_vars: @@ -474,8 +472,7 @@ def _global_auth_file_path() -> Optional[Path]: (classic mode, or a custom HERMES_HOME that is not a profile). Read-only fallback path, so no pytest seat belt here: ``_load_global_auth_store()`` wraps the - read in try/except; the write-side seat belt lives on ``_auth_file_path()``. - """ + read in try/except; the write-side seat belt lives on ``_auth_file_path()``.""" try: from hermes_constants import get_default_hermes_root global_root = get_default_hermes_root() @@ -558,8 +555,7 @@ def _file_lock( Falls back to a depth-only guard when neither ``fcntl`` nor ``msvcrt`` is available. Callers supply their own ``threading.local`` so independent locks (profile store vs global root vs the - shared Nous store) track reentrancy separately. - """ + shared Nous store) track reentrancy separately.""" if getattr(holder, "depth", 0) > 0: holder.depth += 1 try: @@ -614,8 +610,7 @@ def _auth_store_lock( Lock ordering invariant: when held together with ``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST (outer) and the shared Nous lock SECOND (inner). Violating it risks - deadlock against a concurrent import on the shared store. - """ + deadlock against a concurrent import on the shared store.""" auth_path = target_path if target_path is not None else _auth_file_path() with _file_lock( auth_path.with_suffix(".lock"), _auth_lock_holder_for(auth_path), timeout_seconds, @@ -690,8 +685,7 @@ def _write_private_file_atomic( ``os.open(O_EXCL, 0o600)`` closes the TOCTOU window where ``write_text()`` + post-write ``chmod`` briefly exposed tokens at process umask. The per-process random temp suffix avoids - collisions between concurrent writers and stale leftovers from a crashed prior write. - """ + collisions between concurrent writers and stale leftovers from a crashed prior write.""" target.parent.mkdir(parents=True, exist_ok=True) secure_parent_dir(target) # refuses to chmod /, top-level dirs, or the install tree tmp_path = target.with_name(f"{target.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}") @@ -755,8 +749,7 @@ def _load_provider_state_with_source( """Provider state plus the auth.json path it came from (profile first, then the global root). Refresh paths that rotate single-use OAuth refresh tokens must write the updated chain back to - the same store they read. - """ + the same store they read.""" state = _provider_state_in(auth_store, provider_id) if state is not None: return state, _auth_file_path() @@ -777,8 +770,7 @@ def _provider_state_transaction(provider_id: str): Profile-backed refresh paths must take the global auth-store lock before any provider-specific shared-store lock. Re-reading the source after the target lock is acquired prevents both stale refreshes and whole-file lost updates without inverting the documented auth -> shared lock - order. - """ + order.""" with _auth_store_lock(): auth_store = _load_auth_store() state, source_path = _load_provider_state_with_source(auth_store, provider_id) @@ -875,8 +867,7 @@ def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: In profile mode the profile's pool is authoritative; the global-root ``auth.json`` is a read-only fallback applied per provider ONLY when the profile has zero entries for that provider, so profile workers can see globally-authed providers while ``hermes auth add`` inside - the profile fully shadows global on the next read. - """ + the profile fully shadows global on the next read.""" pool = _load_auth_store().get("credential_pool") pool = pool if isinstance(pool, dict) else {} global_store = _load_global_auth_store() @@ -913,8 +904,7 @@ def _merge_disk_cooldown_state( ``write_credential_pool`` persists an in-memory snapshot that may predate another process marking the same credential exhausted/dead; without this merge the later rewrite resurrects a - rate-limited key as healthy and both processes resume hammering it. - """ + rate-limited key as healthy and both processes resume hammering it.""" if not isinstance(disk_entry, dict): return entry try: @@ -958,8 +948,7 @@ def write_credential_pool( Entries present on disk but missing from *entries* (added by another process after the caller's snapshot) are merged back in unless listed in *removed_ids*, so a rotation/exhaustion rewrite - never drops a concurrent credential. - """ + never drops a concurrent credential.""" removed = {rid for rid in (removed_ids or ()) if rid} with _auth_store_lock(): auth_store = _load_auth_store() @@ -1060,8 +1049,7 @@ def _config_selects_provider(normalized: str) -> bool: MoA presets are explicit model selections too: ``provider: anthropic`` in a MoA slot opts into Anthropic credentials for that slot even when the main model is another provider; otherwise Claude Code OAuth entries get pruned by ``load_pool("anthropic")`` and MoA advisors fail with - "no ANTHROPIC_API_KEY" while the picker says Anthropic is logged in. - """ + "no ANTHROPIC_API_KEY" while the picker says Anthropic is logged in.""" from hermes_cli.config import load_config cfg = load_config() if _slot_selects(cfg.get("model"), normalized): @@ -1103,8 +1091,7 @@ def _explicit_env_credentials_present(normalized: str) -> bool: openrouter; same ``.auth_type`` / ``.api_key_env_vars`` shape). AWS SDK providers (Bedrock) have empty ``api_key_env_vars``, so check their explicit env credentials directly — NOT boto3's full chain: ambient EC2 IMDS / SSO profiles must not auto-surface, but AWS_BEARER_TOKEN_BEDROCK or an - access-key pair in .env is as explicit as pasting ANTHROPIC_API_KEY. - """ + access-key pair in .env is as explicit as pasting ANTHROPIC_API_KEY.""" pconfig = PROVIDER_REGISTRY.get(normalized) if pconfig is None: from hermes_cli.providers import get_provider @@ -1138,8 +1125,7 @@ def _keyless_provider_has_explicit_config(normalized: str) -> bool: Uses has_explicit_vertex_config(), NOT has_vertex_credentials(): the latter also counts an ambient GOOGLE_APPLICATION_CREDENTIALS path (commonly set for unrelated GCP work). Only Hermes-scoped signals (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH) count - here. - """ + here.""" if normalized in _VERTEX_PROVIDER_IDS: from agent.vertex_adapter import has_explicit_vertex_config return bool(has_explicit_vertex_config()) @@ -1315,8 +1301,7 @@ def _scoped_key_env_reader() -> Callable[[str], str]: Under multiplex a secondary profile's API keys live only in its secret scope, not os.environ; a bare getenv would report "No LLM provider configured" for every secondary profile. Catch ONLY ImportError: any other failure inside auxiliary_client must propagate, since silently falling - back to os.getenv would reintroduce that fail-open with zero trace. - """ + back to os.getenv would reintroduce that fail-open with zero trace.""" try: from agent.auxiliary_client import _scoped_key_env return _scoped_key_env @@ -1358,8 +1343,7 @@ def _config_model_provider() -> Tuple[Any, Optional[str]]: """``(model_cfg, provider)`` from config.yaml when ``model.provider`` names a registry provider. The normal chat/gateway path resolves config.provider upstream in resolve_requested_provider(); - this is the safety net for the lone direct caller (main.py resolve_provider("auto")). - """ + this is the safety net for the lone direct caller (main.py resolve_provider("auto")).""" try: from hermes_cli.config import load_config @@ -1412,8 +1396,7 @@ def resolve_provider( provider: 1. explicit CLI api_key/base_url -> "openrouter"; 2. config.yaml ``model.provider``; 3. OPENAI_API_KEY / OPENROUTER_API_KEY env -> "openrouter"; 4. OpenRouter credential pool; 5. provider-specific env keys; 6. auth.json ``active_provider`` (OAuth); 7. AWS Bedrock - credential chain; 8. AuthError(no_provider_configured). - """ + credential chain; 8. AuthError(no_provider_configured).""" normalized = (requested or "auto").strip().lower() normalized = _plugin_aliases().get(normalized, normalized) @@ -1683,8 +1666,7 @@ def get_nous_auth_status() -> Dict[str, Any]: """Status snapshot for Nous auth, memoised ~15s keyed on the auth.json mtime. Prefers the auth-store provider state (the live source of truth for refresh) and validates it by - resolving runtime credentials so revoked refresh sessions do not show up as a healthy login. - """ + resolving runtime credentials so revoked refresh sessions do not show up as a healthy login.""" global _nous_auth_status_cache now = time.monotonic() auth_file_key, mtime = _auth_file_cache_key() @@ -1706,8 +1688,7 @@ class OAuthProviderFlow: Entries name module-level callables (strings) rather than binding them, so ``monkeypatch.setattr("hermes_cli.auth.resolve_codex_runtime_credentials", ...)`` and friends - keep intercepting: ``resolve()`` / ``status()`` look the name up in this module at call time. - """ + keep intercepting: ``resolve()`` / ``status()`` look the name up in this module at call time.""" provider_id: str resolve_fn: str @@ -1853,8 +1834,7 @@ def _external_process_auth_evidence(provider_id: str) -> tuple[bool, Optional[st store). False means "not verifiable from here", NOT "signed out" — the Copilot CLI may hold its session in an OS keychain Hermes can't read. Deliberately subprocess-free: this runs from status endpoints and pickers, and spawning ``gh auth token`` there re-creates the cold-start stall - copilot_auth.py works to avoid. - """ + copilot_auth.py works to avoid.""" if provider_id != "copilot-acp": return False, None # 1. Supported env tokens — the same vars the Copilot CLI itself honors. @@ -1898,8 +1878,7 @@ def _external_process_spec( How to launch the CLI comes from the provider's own profile, so a provider shipped outside this tree describes its binary/args instead of inheriting another vendor's (copilot-acp's - HERMES_COPILOT_ACP_COMMAND / COPILOT_CLI_PATH / HERMES_COPILOT_ACP_ARGS live in its profile). - """ + HERMES_COPILOT_ACP_COMMAND / COPILOT_CLI_PATH / HERMES_COPILOT_ACP_ARGS live in its profile).""" base_url = _provider_env_base_url(pconfig) or pconfig.inference_base_url try: from providers import get_provider_profile as _get_provider_profile @@ -1923,8 +1902,7 @@ def get_external_process_provider_status(provider_id: str) -> Dict[str, Any]: ``configured``/``logged_in`` stay structural (the executable resolves or a TCP endpoint is set) because the spawned subprocess owns its real auth. ``auth_verified``/``auth_source`` carry positive credential evidence when Hermes can see some — absence of evidence is not absence of - auth. - """ + auth.""" pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "external_process": return {"configured": False} @@ -1981,8 +1959,7 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]: * ``auth_mode == "entra_id"``: ``azure-identity`` importable (no token is minted here; ``hermes doctor`` runs the live probe). Never invokes the Entra credential chain, keeping CLI startup latency flat regardless of token-service / az login state. - * ``auth_mode == "api_key"`` (default): ``AZURE_FOUNDRY_API_KEY`` set with a usable value. - """ + * ``auth_mode == "api_key"`` (default): ``AZURE_FOUNDRY_API_KEY`` set with a usable value.""" info: Dict[str, Any] = {"provider": "azure-foundry"} try: from hermes_cli.config import load_config, get_env_value_prefer_dotenv @@ -2137,8 +2114,7 @@ def _update_config_for_provider( *default_model*, when given, is written as ``model.default`` in the same step so the gateway (which re-reads config per message) can't pick up the new provider before model selection - finishes and send an OpenRouter-style ``vendor/model`` name to a direct API. - """ + finishes and send an OpenRouter-style ``vendor/model`` name to a direct API.""" with _auth_store_lock(): # so auto-resolution picks this provider auth_store = _load_auth_store() auth_store["active_provider"] = provider_id diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index 2e4d379850..35d51a96fb 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -1,8 +1,7 @@ """Shared constants, the lazy ``httpx`` proxy and :class:`AuthError` for the auth package. Pure leaf: imports nothing from ``hermes_cli.auth`` so the per-provider modules -(``auth_nous``, ``auth_codex``, ...) can import it at module scope without cycles. -""" +(``auth_nous``, ``auth_codex``, ...) can import it at module scope without cycles.""" from __future__ import annotations From fd318113e6925866906d38e9208820b1a7a7b14a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:43:22 -0700 Subject: [PATCH 7/7] refactor(hermes_cli): hug remaining closers in auth modules --- hermes_cli/auth.py | 3 +-- hermes_cli/auth_commands.py | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 124e491c3f..30f3624d23 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1142,8 +1142,7 @@ def _keyless_provider_has_explicit_config(normalized: str) -> bool: _EXPLICIT_CONFIG_CHECKS: Tuple[Tuple[Callable[[str], bool], bool], ...] = ( (_active_provider_is, True), (_config_selects_provider, True), (_explicit_env_credentials_present, False), (_explicit_pool_entry_present, True), - (_keyless_provider_has_explicit_config, True), -) + (_keyless_provider_has_explicit_config, True)) def is_provider_explicitly_configured(provider_id: str) -> bool: diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 88dd5ddbba..ab84b105dd 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -314,8 +314,7 @@ def _add_nous_oauth_credential(args, provider: str) -> None: inference_base_url=getattr(args, "inference_url", None), client_id=getattr(args, "client_id", None), scope=getattr(args, "scope", None), open_browser=not getattr(args, "no_browser", False), timeout_seconds=timeout, - insecure=bool(getattr(args, "insecure", False)), ca_bundle=getattr(args, "ca_bundle", None), - ) + insecure=bool(getattr(args, "insecure", False)), ca_bundle=getattr(args, "ca_bundle", None)) _persist(creds, "Saved") @@ -344,8 +343,7 @@ def _add_api_key_credential(args, provider: str, pool) -> None: label = label or default_label entry = PooledCredential( provider=provider, id=uuid.uuid4().hex[:6], label=label, auth_type=AUTH_TYPE_API_KEY, - priority=0, source=SOURCE_MANUAL, access_token=token, base_url=_provider_base_url(provider), - ) + priority=0, source=SOURCE_MANUAL, access_token=token, base_url=_provider_base_url(provider)) pool.add_entry(entry) print(f'Added {provider} credential #{len(pool.entries())}: "{label}"')