2934 lines
121 KiB
Python
2934 lines
121 KiB
Python
"""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).
|
||
- ``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.<name>`` 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.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import shlex
|
||
import stat
|
||
import threading
|
||
import time
|
||
import uuid
|
||
import webbrowser # noqa: F401 (tests patch auth_mod.webbrowser.open; same module object)
|
||
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tuple
|
||
from urllib.parse import urlparse
|
||
|
||
from hermes_cli.config import (
|
||
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,
|
||
)
|
||
from hermes_cli.auth_model_picker import ( # noqa: F401 re-exported
|
||
_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,
|
||
)
|
||
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,
|
||
)
|
||
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,
|
||
_compute_nous_auth_status, _format_nous_entitlement_auth_error, _healed_nous_inference_url,
|
||
_login_nous, _merge_shared_nous_oauth_state, _migrate_stale_nous_portal_url,
|
||
_nous_device_code_login, _nous_inference_env_override, _nous_invoke_jwt_is_usable,
|
||
_nous_invoke_jwt_status, _nous_portal_env_override, _nous_shared_store_lock,
|
||
_nous_shared_store_path, _pool_first_oauth_status, _quarantine_nous_oauth_state,
|
||
_quarantine_nous_pool_entries, _read_shared_nous_state, _refresh_access_token,
|
||
_refresh_nous_or_quarantine, _select_nous_invoke_jwt, _sync_nous_pool_from_auth_store,
|
||
_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,
|
||
)
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
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,
|
||
DEFAULT_NOUS_SCOPE, NOUS_DEVICE_CODE_SOURCE, NOUS_AUTH_PATH_INVOKE_JWT,
|
||
ACCESS_TOKEN_REFRESH_SKEW_SECONDS, NOUS_INVOKE_JWT_MIN_TTL_SECONDS, DEFAULT_CODEX_BASE_URL,
|
||
DEFAULT_XAI_OAUTH_BASE_URL, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_SCOPE,
|
||
MINIMAX_OAUTH_GLOBAL_BASE, MINIMAX_OAUTH_CN_BASE, MINIMAX_OAUTH_GLOBAL_INFERENCE,
|
||
MINIMAX_OAUTH_CN_INFERENCE, MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, DEFAULT_QWEN_BASE_URL,
|
||
DEFAULT_GITHUB_MODELS_BASE_URL, DEFAULT_COPILOT_ACP_BASE_URL, DEFAULT_OLLAMA_CLOUD_BASE_URL,
|
||
DEFAULT_ACTUAL_BASE_URL, DEFAULT_ACTUAL_LOCAL_BASE_URL, STEPFUN_STEP_PLAN_INTL_BASE_URL,
|
||
STEPFUN_STEP_PLAN_CN_BASE_URL, CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL,
|
||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, XAI_OAUTH_CLIENT_ID, XAI_OAUTH_SCOPE,
|
||
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,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
try:
|
||
import fcntl
|
||
except Exception:
|
||
fcntl = None
|
||
try:
|
||
import msvcrt
|
||
except Exception:
|
||
msvcrt = None
|
||
|
||
def is_actual_local_base_url(base_url: str) -> bool:
|
||
"""Return True for Actual's loopback local API endpoint."""
|
||
try:
|
||
host = (urlparse(base_url or "").hostname or "").lower().rstrip(".")
|
||
except Exception:
|
||
return False
|
||
return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||
|
||
|
||
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.
|
||
"""
|
||
url = str(base_url or "").strip().rstrip("/")
|
||
if not url:
|
||
return DEFAULT_ACTUAL_BASE_URL
|
||
try:
|
||
parsed = urlparse(url)
|
||
host = (parsed.hostname or "").lower().rstrip(".")
|
||
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 {"", "/"}:
|
||
return url + "/v1"
|
||
return url
|
||
|
||
|
||
# =============================================================================
|
||
# Provider Registry
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class ProviderConfig:
|
||
"""Describes a known inference provider."""
|
||
id: str
|
||
name: str
|
||
auth_type: str # "oauth_device_code", "oauth_external", "oauth_minimax", or "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 = ""
|
||
|
||
|
||
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:
|
||
"""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,
|
||
)
|
||
|
||
|
||
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,
|
||
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",
|
||
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,
|
||
),
|
||
"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,
|
||
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",
|
||
),
|
||
}
|
||
|
||
# Providers handled outside the registry: copilot/kimi/zai have bespoke token refresh here;
|
||
# openrouter/custom are aggregator/user-supplied and runtime_provider relies on
|
||
# ``openrouter not in PROVIDER_REGISTRY``.
|
||
_REGISTRY_PLUGIN_SKIP = frozenset({"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"})
|
||
|
||
|
||
def _register_plugin_provider(pp: Any) -> None:
|
||
"""Auto-register one providers/ profile (plugins/model-providers/<name>/) not declared above.
|
||
|
||
External-process providers (an ACP CLI over stdio) have no API-key env vars; registering them is
|
||
what lets a provider shipped outside this tree pass ``resolve_provider()``'s known-provider gate
|
||
(otherwise ``hermes -m <that provider>`` 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,
|
||
)
|
||
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 "",
|
||
)
|
||
else:
|
||
return
|
||
PROVIDER_REGISTRY[pp.name] = pconfig
|
||
for alias in pp.aliases: # so resolve_provider() resolves them too
|
||
PROVIDER_REGISTRY.setdefault(alias, pconfig)
|
||
|
||
|
||
try:
|
||
from providers import list_providers as _list_providers_for_registry
|
||
for _pp in _list_providers_for_registry():
|
||
if _pp.name not in PROVIDER_REGISTRY:
|
||
_register_plugin_provider(_pp)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# =============================================================================
|
||
# Anthropic Key Helper
|
||
# =============================================================================
|
||
|
||
def get_anthropic_key() -> str:
|
||
"""Return the first usable Anthropic credential, 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:
|
||
"""
|
||
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 ""
|
||
|
||
|
||
# =============================================================================
|
||
# Secret validation
|
||
# =============================================================================
|
||
|
||
_PLACEHOLDER_SECRET_VALUES = {
|
||
"*",
|
||
"**",
|
||
"***",
|
||
"changeme",
|
||
"your_api_key",
|
||
"your_api_key_here",
|
||
"your-api-key",
|
||
"placeholder",
|
||
"example",
|
||
"dummy",
|
||
"null",
|
||
"none",
|
||
}
|
||
|
||
|
||
def has_usable_secret(value: Any, *, min_length: int = 4) -> bool:
|
||
"""Return True when a configured secret looks usable, not empty/placeholder."""
|
||
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
|
||
|
||
|
||
# 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_PROVIDER_KEY_PREFIXES: Dict[str, tuple] = {
|
||
# All OpenRouter keys are issued as sk-or-... (currently sk-or-v1-).
|
||
"openrouter": ("sk-or-",),
|
||
}
|
||
|
||
|
||
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.
|
||
"""
|
||
prefixes = KNOWN_PROVIDER_KEY_PREFIXES.get(provider_id)
|
||
if not prefixes:
|
||
return True
|
||
return 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),
|
||
)
|
||
|
||
|
||
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
|
||
try:
|
||
from hermes_cli.copilot_auth import resolve_copilot_token, get_copilot_api_token
|
||
token, source = resolve_copilot_token()
|
||
if token:
|
||
api_token, _base_url = get_copilot_api_token(token)
|
||
return api_token, source
|
||
except ValueError as exc:
|
||
logger.warning("Copilot token validation failed: %s", exc)
|
||
except Exception:
|
||
pass
|
||
return "", ""
|
||
|
||
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
|
||
|
||
# Fallback: try credential pool (e.g. zai key stored via auth.json)
|
||
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:
|
||
candidates.append(entry)
|
||
try:
|
||
for extra in pool.entries():
|
||
if extra is not None and all(extra is not c for c in candidates):
|
||
candidates.append(extra)
|
||
except Exception:
|
||
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}"
|
||
except Exception:
|
||
pass
|
||
|
||
return "", ""
|
||
|
||
|
||
# =============================================================================
|
||
# Error formatting (AuthError itself lives in auth_constants)
|
||
# =============================================================================
|
||
|
||
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
|
||
)
|
||
|
||
|
||
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).
|
||
_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.",
|
||
}
|
||
_ENTITLEMENT_ERROR_CODES = frozenset(_GENERIC_ENTITLEMENT_MESSAGES) | {
|
||
"subscription_expired", "no_usable_credits", "account_missing", "member_spend_cap_exceeded",
|
||
}
|
||
|
||
|
||
def _nonempty_str(value: Any) -> bool:
|
||
return isinstance(value, str) and bool(value.strip())
|
||
|
||
|
||
# =============================================================================
|
||
# Auth Store — persistence layer for ~/.hermes/auth.json
|
||
# =============================================================================
|
||
|
||
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.
|
||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||
real_home_auth = (Path.home() / ".hermes" / "auth.json").resolve(strict=False)
|
||
try:
|
||
resolved = path.resolve(strict=False)
|
||
except Exception:
|
||
resolved = path
|
||
if resolved == real_home_auth:
|
||
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."
|
||
)
|
||
return path
|
||
|
||
|
||
def _global_auth_file_path() -> Optional[Path]:
|
||
"""Return the global-root auth.json when the process is in profile mode.
|
||
|
||
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.
|
||
"""
|
||
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"
|
||
|
||
|
||
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.
|
||
"""
|
||
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)
|
||
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]:
|
||
return cached_store
|
||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||
real_home_env = os.environ.get("HOME", "")
|
||
if real_home_env:
|
||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||
try:
|
||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||
_global_auth_store_cache = None
|
||
return {}
|
||
except Exception:
|
||
pass
|
||
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:
|
||
_global_auth_store_cache = (cache_key[0], cache_key[1], store)
|
||
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()
|
||
|
||
|
||
def _same_path(left: Path, right: Path) -> bool:
|
||
try:
|
||
return left.resolve(strict=False) == right.resolve(strict=False)
|
||
except Exception:
|
||
return left == right
|
||
|
||
|
||
def _auth_lock_holder_for(target_path: Path) -> threading.local:
|
||
"""Return a reentrancy tracker keyed to one canonical auth-store path."""
|
||
try:
|
||
key = str(target_path.resolve(strict=False))
|
||
except Exception:
|
||
key = str(target_path)
|
||
with _auth_target_lock_holders_guard:
|
||
return _auth_target_lock_holders.setdefault(key, threading.local())
|
||
|
||
|
||
@contextmanager
|
||
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 (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.
|
||
"""
|
||
if getattr(holder, "depth", 0) > 0:
|
||
holder.depth += 1
|
||
try:
|
||
yield
|
||
finally:
|
||
holder.depth -= 1
|
||
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
|
||
|
||
# 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.
|
||
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)
|
||
|
||
holder.depth = 1
|
||
try:
|
||
yield
|
||
finally:
|
||
holder.depth = 0
|
||
if fcntl:
|
||
try:
|
||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||
except (OSError, IOError):
|
||
pass
|
||
elif msvcrt:
|
||
try:
|
||
lock_file.seek(0)
|
||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||
except (OSError, IOError):
|
||
pass
|
||
|
||
|
||
@contextmanager
|
||
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.
|
||
|
||
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.
|
||
"""
|
||
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,
|
||
"Timed out waiting for auth store lock",
|
||
):
|
||
yield
|
||
|
||
|
||
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": {}}
|
||
|
||
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.
|
||
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,
|
||
)
|
||
raise
|
||
except Exception as exc:
|
||
# Genuine corruption: unparseable JSON, or bytes that are not UTF-8.
|
||
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.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": {}}
|
||
|
||
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}
|
||
|
||
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
||
|
||
|
||
def _write_private_file_atomic(
|
||
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.
|
||
"""
|
||
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)
|
||
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,
|
||
)
|
||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||
handle.write(payload)
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
(replace or atomic_replace)(tmp_path, target)
|
||
if fsync_dir:
|
||
try:
|
||
dir_fd = os.open(str(target.parent), os.O_RDONLY)
|
||
except OSError:
|
||
dir_fd = None
|
||
if dir_fd is not None:
|
||
try:
|
||
os.fsync(dir_fd)
|
||
finally:
|
||
os.close(dir_fd)
|
||
finally:
|
||
try:
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
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.
|
||
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:
|
||
pass
|
||
return auth_file
|
||
|
||
|
||
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
|
||
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
|
||
|
||
|
||
@contextmanager
|
||
def _provider_state_transaction(provider_id: str):
|
||
"""Lock the active auth store and any global fallback source in order.
|
||
|
||
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.
|
||
"""
|
||
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):
|
||
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
|
||
|
||
|
||
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 _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any]) -> None:
|
||
"""Write *state* under ``providers`` and make *provider_id* the active provider."""
|
||
_store_provider_state(auth_store, provider_id, state, set_active=True)
|
||
|
||
|
||
def _save_active_provider_state(provider_id: str, state: Dict[str, Any]) -> Path:
|
||
"""Lock, load, write *state* as the active provider, save. Returns the auth store path."""
|
||
with _auth_store_lock():
|
||
auth_store = _load_auth_store()
|
||
_save_provider_state(auth_store, provider_id, state)
|
||
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,
|
||
) -> 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,
|
||
)
|
||
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.
|
||
|
||
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.
|
||
"""
|
||
with _auth_store_lock():
|
||
auth_store = _load_auth_store()
|
||
if not (auth_store.get("active_provider") or "").strip():
|
||
auth_store["active_provider"] = provider_id
|
||
_save_auth_store(auth_store)
|
||
|
||
|
||
def is_known_auth_provider(provider_id: str) -> bool:
|
||
normalized = (provider_id or "").strip().lower()
|
||
return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES
|
||
|
||
|
||
def get_auth_provider_display_name(provider_id: str) -> str:
|
||
normalized = (provider_id or "").strip().lower()
|
||
if normalized in PROVIDER_REGISTRY:
|
||
return PROVIDER_REGISTRY[normalized].name
|
||
return SERVICE_PROVIDER_NAMES.get(normalized, provider_id)
|
||
|
||
|
||
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.
|
||
"""
|
||
normalized = (provider_id or "").strip().lower()
|
||
if not normalized:
|
||
return False
|
||
if normalized in {"auto", "openrouter", "custom", "moa"}:
|
||
return True
|
||
if normalized.startswith("custom:"):
|
||
return True
|
||
try:
|
||
resolve_provider(normalized)
|
||
except AuthError:
|
||
return False
|
||
return True
|
||
|
||
|
||
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 <provider>`` inside the
|
||
profile, profile entries fully shadow global for that provider 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] = {}
|
||
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
|
||
|
||
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)
|
||
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_error_reset_at",
|
||
)
|
||
|
||
|
||
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.
|
||
"""
|
||
if not isinstance(disk_entry, dict):
|
||
return entry
|
||
try:
|
||
from agent.credential_pool import (
|
||
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.
|
||
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:
|
||
return entry
|
||
disk_ts = _parse_absolute_timestamp(disk_entry.get("last_status_at")) or 0.0
|
||
mem_ts = _parse_absolute_timestamp(entry.get("last_status_at")) or 0.0
|
||
if disk_ts <= mem_ts:
|
||
return entry
|
||
if disk_status == STATUS_EXHAUSTED:
|
||
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
|
||
except Exception: # pragma: no cover - best-effort merge
|
||
return entry
|
||
|
||
|
||
def write_credential_pool(
|
||
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.
|
||
|
||
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.
|
||
"""
|
||
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
|
||
]
|
||
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")
|
||
}
|
||
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
|
||
]
|
||
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))
|
||
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.
|
||
|
||
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.
|
||
"""
|
||
with _auth_store_lock():
|
||
auth_store = _load_auth_store()
|
||
suppressed = _store_section(auth_store, "suppressed_sources")
|
||
provider_list = _suppressed_source_list(suppressed, provider_id)
|
||
if provider_list is None:
|
||
provider_list = suppressed[provider_id] = []
|
||
if source not in provider_list:
|
||
provider_list.append(source)
|
||
_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, [])
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def unsuppress_credential_source(provider_id: str, source: str) -> bool:
|
||
"""Clear a suppression marker so the source will be re-seeded on the next load."""
|
||
with _auth_store_lock():
|
||
auth_store = _load_auth_store()
|
||
suppressed = auth_store.get("suppressed_sources")
|
||
if not isinstance(suppressed, dict):
|
||
return False
|
||
provider_list = _suppressed_source_list(suppressed, provider_id)
|
||
if provider_list is None or source not in provider_list:
|
||
return False
|
||
provider_list.remove(source)
|
||
if not provider_list:
|
||
suppressed.pop(provider_id, None)
|
||
if not suppressed:
|
||
auth_store.pop("suppressed_sources", None)
|
||
_save_auth_store(auth_store)
|
||
return True
|
||
|
||
|
||
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)
|
||
|
||
|
||
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")
|
||
|
||
|
||
def _active_provider_is(normalized: str) -> bool:
|
||
active = (_load_auth_store().get("active_provider") or "").strip().lower()
|
||
return bool(active) and active == 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.
|
||
"""
|
||
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:
|
||
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"))
|
||
)
|
||
|
||
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())
|
||
)
|
||
|
||
|
||
def _explicit_pool_entry_present(normalized: str) -> bool:
|
||
"""Pool rows from EXPLICIT Hermes flows (manual add / device-code / PKCE) or live env keys;
|
||
ambient borrowed sources (gh_cli / claude_code / qwen-cli) are deliberately excluded."""
|
||
return any(_pool_entry_is_explicit(entry) for entry in read_credential_pool(normalized))
|
||
|
||
|
||
# Set by Claude Code itself, not by the user explicitly configuring anthropic in Hermes.
|
||
_IMPLICIT_ENV_VARS = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"})
|
||
_EXPLICIT_POOL_SOURCES = frozenset({"device_code", "loopback_pkce", "hermes_pkce", "manual"})
|
||
_VERTEX_PROVIDER_IDS = ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai")
|
||
|
||
|
||
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.
|
||
"""
|
||
pconfig = PROVIDER_REGISTRY.get(normalized)
|
||
if pconfig is None:
|
||
from hermes_cli.providers import get_provider
|
||
pconfig = get_provider(normalized)
|
||
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
|
||
)
|
||
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 False
|
||
|
||
|
||
def _pool_entry_is_explicit(entry: Any) -> bool:
|
||
"""True for pool rows the user created via an explicit Hermes flow (or a still-live env key)."""
|
||
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.
|
||
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:")
|
||
|
||
|
||
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.
|
||
"""
|
||
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
|
||
|
||
|
||
# Ordered explicit-configuration checks: ``(check, best_effort)``. Best-effort checks treat an
|
||
# 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),
|
||
)
|
||
|
||
|
||
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.
|
||
"""
|
||
normalized = (provider_id or "").strip().lower()
|
||
for check, best_effort in _EXPLICIT_CONFIG_CHECKS:
|
||
try:
|
||
if check(normalized):
|
||
return True
|
||
except Exception as exc:
|
||
if not best_effort:
|
||
raise
|
||
logger.debug("explicit-config check %s failed for %s: %s", check.__name__, provider_id, exc)
|
||
return False
|
||
|
||
|
||
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.
|
||
"""
|
||
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)
|
||
if target in entries:
|
||
del entries[target]
|
||
cleared = True
|
||
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
|
||
|
||
|
||
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.
|
||
"""
|
||
with _auth_store_lock():
|
||
auth_store = _load_auth_store()
|
||
auth_store["active_provider"] = None
|
||
_save_auth_store(auth_store)
|
||
|
||
|
||
# =============================================================================
|
||
# Provider Resolution — picks which provider to use
|
||
# =============================================================================
|
||
|
||
|
||
def _get_config_hint_for_unknown_provider(provider_name: str) -> str:
|
||
"""Return a helpful hint string when provider resolution fails."""
|
||
try:
|
||
from hermes_cli.config import validate_config_structure
|
||
issues = validate_config_structure()
|
||
if not issues:
|
||
return ""
|
||
|
||
lines = ["Config issue detected — run 'hermes doctor' for full diagnostics:"]
|
||
for ci in issues:
|
||
prefix = "ERROR" if ci.severity == "error" else "WARNING"
|
||
lines.append(f" [{prefix}] {ci.message}")
|
||
# Show first line of hint
|
||
first_hint = ci.hint.splitlines()[0] if ci.hint else ""
|
||
if first_hint:
|
||
lines.append(f" → {first_hint}")
|
||
return "\n".join(lines)
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
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).
|
||
"""
|
||
try:
|
||
from hermes_cli.config import get_active_config_parse_failure, get_config_path
|
||
|
||
err = get_active_config_parse_failure()
|
||
if not err:
|
||
return
|
||
path = get_config_path()
|
||
except Exception as e:
|
||
logger.debug("Could not probe config parse-failure state: %s", e)
|
||
return
|
||
raise AuthError(
|
||
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",
|
||
)
|
||
|
||
|
||
# Provider aliases accepted by resolve_provider(). Plugin-declared aliases
|
||
# (plugins/model-providers/<name>/) are layered on at call time; this hardcoded
|
||
# table remains authoritative for existing names.
|
||
_PROVIDER_ALIASES: Dict[str, str] = {
|
||
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
|
||
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
|
||
"x-ai": "xai", "x.ai": "xai", "grok": "xai",
|
||
"xai-oauth": "xai-oauth", "x-ai-oauth": "xai-oauth",
|
||
"grok-oauth": "xai-oauth", "xai-grok-oauth": "xai-oauth",
|
||
"kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding",
|
||
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
|
||
"step": "stepfun", "stepfun-coding-plan": "stepfun",
|
||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||
"gmi-cloud": "gmi", "gmicloud": "gmi",
|
||
"actual-computer": "actual", "actualcomputer": "actual", "aci": "actual",
|
||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||
"minimax-portal": "minimax-oauth", "minimax-global": "minimax-oauth", "minimax_oauth": "minimax-oauth",
|
||
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
|
||
"alibaba_coding_plan": "alibaba-coding-plan",
|
||
"claude": "anthropic", "claude-code": "anthropic",
|
||
"github": "copilot", "github-copilot": "copilot",
|
||
"github-models": "copilot", "github-model": "copilot",
|
||
"github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp",
|
||
"aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
|
||
"opencode": "opencode-zen", "zen": "opencode-zen",
|
||
"free": "opencode-free", "opencode_free": "opencode-free",
|
||
"qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth",
|
||
"hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface",
|
||
"mimo": "xiaomi", "xiaomi-mimo": "xiaomi",
|
||
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
|
||
"tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub",
|
||
"tokenplan": "tencent-tokenplan", "tencent-lkeap": "tencent-tokenplan",
|
||
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
|
||
"go": "opencode-go", "opencode-go-sub": "opencode-go",
|
||
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
|
||
"lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio",
|
||
# 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",
|
||
}
|
||
|
||
|
||
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.
|
||
"""
|
||
try:
|
||
from agent.auxiliary_client import _scoped_key_env
|
||
return _scoped_key_env
|
||
except ImportError:
|
||
logger.warning(
|
||
"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",
|
||
)
|
||
return lambda name: os.getenv(name) or ""
|
||
|
||
|
||
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")
|
||
):
|
||
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)
|
||
return False
|
||
|
||
|
||
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")
|
||
if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"):
|
||
return _maybe
|
||
except Exception as e:
|
||
logger.debug("Could not pre-read active auth provider: %s", e)
|
||
return None
|
||
|
||
|
||
def resolve_provider(
|
||
requested: Optional[str] = None,
|
||
*,
|
||
explicit_api_key: Optional[str] = None,
|
||
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 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).
|
||
"""
|
||
normalized = (requested or "auto").strip().lower()
|
||
|
||
# Normalize provider aliases. Extend with aliases declared in
|
||
# plugins/model-providers/<name>/ 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:
|
||
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:
|
||
msg += f"\n\n{_config_hint}"
|
||
else:
|
||
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:
|
||
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)
|
||
|
||
_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.
|
||
_oauth_active = _logged_in_oauth_active_provider()
|
||
|
||
# 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.
|
||
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 "
|
||
"config.yaml `model` has no `provider` key. If you meant a "
|
||
"different provider, set `model.provider` explicitly.",
|
||
_oauth_active,
|
||
)
|
||
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.
|
||
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
|
||
|
||
raise AuthError(
|
||
"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",
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Timestamp / TTL helpers
|
||
# =============================================================================
|
||
|
||
def _utc_now_z() -> str:
|
||
"""Current UTC time as an ISO-8601 string with a ``Z`` suffix (last_refresh format)."""
|
||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||
|
||
|
||
def _parse_iso_timestamp(value: Any) -> Optional[float]:
|
||
if not isinstance(value, str) or not value:
|
||
return None
|
||
text = value.strip()
|
||
if not text:
|
||
return None
|
||
if text.endswith("Z"):
|
||
text = text[:-1] + "+00:00"
|
||
try:
|
||
parsed = datetime.fromisoformat(text)
|
||
except Exception:
|
||
return None
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||
return parsed.timestamp()
|
||
|
||
|
||
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)
|
||
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
def _last_auth_error_marker(
|
||
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 {
|
||
"provider": provider,
|
||
"code": error.code if default_code is None else (error.code or default_code),
|
||
"message": str(error),
|
||
"reason": reason,
|
||
"relogin_required": True,
|
||
"at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
|
||
|
||
_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.
|
||
"""
|
||
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",
|
||
)
|
||
|
||
|
||
def _coerce_ttl_seconds(expires_in: Any) -> int:
|
||
try:
|
||
ttl = int(expires_in)
|
||
except Exception:
|
||
ttl = 0
|
||
return max(0, ttl)
|
||
|
||
|
||
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
|
||
|
||
|
||
# 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.
|
||
_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,
|
||
# 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.
|
||
_RESOLVE_TOKEN_CACHE_LOCK = threading.Lock()
|
||
_RESOLVE_TOKEN_CACHE: "tuple[float, str] | None" = None
|
||
_RESOLVE_TOKEN_CACHE_TTL_S = 5.0
|
||
|
||
|
||
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:
|
||
"""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:
|
||
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,
|
||
):
|
||
|
||
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
|
||
|
||
client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
|
||
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
|
||
|
||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||
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,
|
||
)
|
||
|
||
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
|
||
|
||
if not isinstance(refresh_token, str) or not refresh_token:
|
||
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,
|
||
) 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
|
||
),
|
||
)
|
||
|
||
_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)
|
||
_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
|
||
|
||
|
||
# =============================================================================
|
||
# 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.
|
||
_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.
|
||
_global_auth_store_cache: Optional[Tuple[str, int, Dict[str, Any]]] = None
|
||
|
||
|
||
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
|
||
auth_file = _auth_file_path()
|
||
try:
|
||
auth_file_key = str(auth_file.resolve(strict=False))
|
||
except Exception:
|
||
auth_file_key = str(auth_file)
|
||
try:
|
||
return auth_file_key, auth_file.stat().st_mtime
|
||
except Exception: # missing file included: key without an mtime
|
||
return auth_file_key, None
|
||
|
||
|
||
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.
|
||
"""
|
||
global _nous_auth_status_cache
|
||
_nous_auth_status_cache = None
|
||
|
||
|
||
def get_nous_auth_status() -> Dict[str, Any]:
|
||
"""Status snapshot for Nous auth.
|
||
|
||
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.
|
||
"""
|
||
global _nous_auth_status_cache
|
||
now = time.monotonic()
|
||
auth_file_key, mtime = _auth_file_cache_key()
|
||
cached = _nous_auth_status_cache
|
||
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
|
||
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
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OAuthProviderFlow:
|
||
"""Per-provider OAuth plumbing, keyed by provider id in ``OAUTH_PROVIDER_FLOWS``.
|
||
|
||
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.
|
||
"""
|
||
|
||
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.
|
||
logout_from_config: bool = False
|
||
|
||
def resolve(self, **kwargs: Any) -> Dict[str, Any]:
|
||
return globals()[self.resolve_fn](**kwargs)
|
||
|
||
def status(self) -> Dict[str, Any]:
|
||
return globals()[self.status_fn]()
|
||
|
||
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)
|
||
)
|
||
|
||
|
||
_OAUTH_GRANT_DEAD_CODES = frozenset({"invalid_grant", "invalid_token", "refresh_token_reused"})
|
||
|
||
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,
|
||
),
|
||
"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,
|
||
),
|
||
"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,
|
||
),
|
||
"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"),
|
||
}
|
||
|
||
|
||
def _is_terminal_refresh_error(exc: Exception, provider: str) -> bool:
|
||
"""True when retrying the same *provider* refresh token cannot succeed."""
|
||
return OAUTH_PROVIDER_FLOWS[provider].is_terminal_refresh_error(exc)
|
||
|
||
|
||
def _is_terminal_nous_refresh_error(exc: Exception) -> bool:
|
||
return _is_terminal_refresh_error(exc, "nous")
|
||
|
||
|
||
def _is_terminal_xai_oauth_refresh_error(exc: Exception) -> bool:
|
||
return _is_terminal_refresh_error(exc, "xai-oauth")
|
||
|
||
|
||
def _is_terminal_codex_oauth_refresh_error(exc: Exception) -> bool:
|
||
return _is_terminal_refresh_error(exc, "openai-codex")
|
||
|
||
|
||
def _codex_pool_rate_limited_status() -> Optional[Dict[str, Any]]:
|
||
rate_limit = _codex_pool_rate_limit_status()
|
||
if not rate_limit:
|
||
return None
|
||
return {
|
||
"logged_in": True,
|
||
"auth_store": str(_auth_file_path()),
|
||
"last_refresh": rate_limit.get("last_refresh"),
|
||
"auth_mode": "chatgpt",
|
||
"source": f"pool:{rate_limit.get('label') or 'unknown'}",
|
||
"rate_limited": True,
|
||
"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"),
|
||
}
|
||
|
||
|
||
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,
|
||
)
|
||
|
||
|
||
def get_xai_oauth_auth_status() -> Dict[str, Any]:
|
||
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",
|
||
resolve=resolve_xai_oauth_runtime_credentials,
|
||
)
|
||
|
||
|
||
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 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,
|
||
}
|
||
|
||
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
|
||
|
||
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)
|
||
)
|
||
|
||
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
|
||
}
|
||
|
||
|
||
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.
|
||
|
||
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.
|
||
"""
|
||
if provider_id != "copilot-acp":
|
||
return False, None
|
||
# 1. Supported env tokens — the same vars the Copilot CLI itself honors.
|
||
try:
|
||
from hermes_cli.copilot_auth import COPILOT_ENV_VARS, validate_copilot_token
|
||
for env_var in COPILOT_ENV_VARS:
|
||
val = os.getenv(env_var, "").strip()
|
||
if val and validate_copilot_token(val)[0]:
|
||
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.
|
||
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("//")
|
||
)
|
||
data = json.loads(raw) if raw.strip() else {}
|
||
tokens = data.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",
|
||
):
|
||
try:
|
||
expanded = os.path.expanduser(cred_path)
|
||
if os.path.isfile(expanded) and os.path.getsize(expanded) > 2:
|
||
return True, cred_path
|
||
except OSError:
|
||
continue
|
||
return False, None
|
||
|
||
|
||
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.
|
||
|
||
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.
|
||
"""
|
||
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
|
||
|
||
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
|
||
|
||
|
||
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.
|
||
"""
|
||
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,
|
||
}
|
||
|
||
|
||
def _get_aws_sdk_auth_status(target: str) -> Dict[str, Any]:
|
||
"""AWS SDK providers (Bedrock) — check via boto3 credential chain."""
|
||
try:
|
||
from agent.bedrock_adapter import has_aws_credentials
|
||
return {"logged_in": has_aws_credentials(), "provider": target}
|
||
except ImportError:
|
||
return {"logged_in": False, "provider": target, "error": "boto3 not installed"}
|
||
|
||
|
||
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.
|
||
"""
|
||
target = (provider_id or get_active_provider() or "").strip().lower()
|
||
if not target:
|
||
return {"logged_in": False}
|
||
status_fn_name = _BESPOKE_STATUS_FUNCTIONS.get(target)
|
||
if status_fn_name:
|
||
return globals()[status_fn_name]()
|
||
pconfig = PROVIDER_REGISTRY.get(target)
|
||
if pconfig and pconfig.auth_type in _STATUS_BY_AUTH_TYPE:
|
||
return globals()[_STATUS_BY_AUTH_TYPE[pconfig.auth_type]](target)
|
||
return {"logged_in": False}
|
||
|
||
|
||
# Bespoke status builders (name -> looked up in this module at call time) win over the
|
||
# auth_type-keyed fallbacks below.
|
||
_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",
|
||
}
|
||
_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",
|
||
}
|
||
|
||
|
||
def _get_azure_foundry_auth_status() -> Dict[str, Any]:
|
||
"""Return 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.
|
||
"""
|
||
info: Dict[str, Any] = {"provider": "azure-foundry"}
|
||
try:
|
||
from hermes_cli.config import load_config, get_env_value_prefer_dotenv
|
||
cfg = load_config()
|
||
except Exception:
|
||
cfg = {}
|
||
|
||
model_cfg = cfg.get("model") if isinstance(cfg, dict) else None
|
||
auth_mode = "api_key"
|
||
base_url = ""
|
||
if isinstance(model_cfg, dict):
|
||
auth_mode = str(model_cfg.get("auth_mode") or "api_key").strip().lower() or "api_key"
|
||
base_url = str(model_cfg.get("base_url") or "").strip()
|
||
info["auth_mode"] = auth_mode
|
||
info["base_url"] = base_url
|
||
|
||
if auth_mode == "entra_id":
|
||
try:
|
||
from agent.azure_identity_adapter import (
|
||
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"] = (
|
||
"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
|
||
|
||
# api_key mode (default)
|
||
try:
|
||
api_key = get_env_value_prefer_dotenv("AZURE_FOUNDRY_API_KEY") or ""
|
||
except Exception:
|
||
api_key = os.getenv("AZURE_FOUNDRY_API_KEY", "")
|
||
info["logged_in"] = has_usable_secret(api_key)
|
||
return info
|
||
|
||
|
||
def _default_api_key_base_url(api_key: str, default: str, env_url: str) -> str:
|
||
return env_url.rstrip("/") if env_url else default
|
||
|
||
|
||
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)."""
|
||
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()
|
||
if resolved:
|
||
base_url = resolved
|
||
except Exception as exc:
|
||
logger.debug("Copilot base URL resolution fell back to default: %s", exc)
|
||
return base_url
|
||
|
||
|
||
def _lmstudio_runtime_base_url(api_key: str, default: str, env_url: str) -> str:
|
||
return _normalize_lmstudio_runtime_base_url(_default_api_key_base_url(api_key, default, env_url))
|
||
|
||
|
||
def _actual_runtime_base_url(api_key: str, default: str, env_url: str) -> str:
|
||
return normalize_actual_base_url(_default_api_key_base_url(api_key, default, env_url))
|
||
|
||
|
||
# Providers whose runtime base URL is not simply env-override-or-registry-default:
|
||
# ``(api_key, registry_default, env_override) -> base_url``.
|
||
_API_KEY_BASE_URL_RESOLVERS: Dict[str, Callable[[str, str, str], str]] = {
|
||
"kimi-coding": _resolve_kimi_base_url,
|
||
"kimi-coding-cn": _resolve_kimi_base_url,
|
||
"zai": _resolve_zai_base_url,
|
||
"copilot": _copilot_runtime_base_url,
|
||
"lmstudio": _lmstudio_runtime_base_url,
|
||
"actual": _actual_runtime_base_url,
|
||
}
|
||
|
||
|
||
def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
||
"""Resolve API key and base URL for an API-key provider."""
|
||
pconfig = PROVIDER_REGISTRY.get(provider_id)
|
||
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",
|
||
)
|
||
|
||
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.
|
||
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).
|
||
if not _nonempty_str(base_url):
|
||
base_url = pconfig.inference_base_url
|
||
|
||
if not api_key and provider_id == "actual" and is_actual_local_base_url(base_url):
|
||
api_key = ACTUAL_LOCAL_NOAUTH_PLACEHOLDER
|
||
key_source = key_source or "local-offline"
|
||
|
||
return {
|
||
"provider": provider_id,
|
||
"api_key": api_key,
|
||
"base_url": base_url.rstrip("/"),
|
||
"source": key_source or "default",
|
||
}
|
||
|
||
|
||
def resolve_external_process_provider_credentials(provider_id: str) -> Dict[str, Any]:
|
||
"""Resolve runtime details for local subprocess-backed providers."""
|
||
pconfig = PROVIDER_REGISTRY.get(provider_id)
|
||
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",
|
||
)
|
||
|
||
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 ""
|
||
)
|
||
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.
|
||
"api_key": pconfig.id or provider_id,
|
||
"base_url": base_url.rstrip("/"),
|
||
"command": resolved_command or command,
|
||
"args": args,
|
||
"source": "process",
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# CLI Commands — login / logout
|
||
# =============================================================================
|
||
|
||
def _update_config_for_provider(
|
||
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).
|
||
"""
|
||
# Set active_provider in auth.json so auto-resolution picks this provider
|
||
with _auth_store_lock():
|
||
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")
|
||
if isinstance(current_model, dict):
|
||
model_cfg = dict(current_model)
|
||
elif _nonempty_str(current_model):
|
||
model_cfg = {"default": current_model.strip()}
|
||
else:
|
||
model_cfg = {}
|
||
|
||
model_cfg["provider"] = provider_id
|
||
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)
|
||
|
||
# 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.
|
||
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.
|
||
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
|
||
|
||
|
||
def _get_config_provider() -> Optional[str]:
|
||
"""Return model.provider from config.yaml, normalized, if present."""
|
||
try:
|
||
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
|
||
|
||
|
||
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()
|
||
|
||
|
||
def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool:
|
||
"""Return True when logout should reset the model provider config."""
|
||
if not provider_id:
|
||
return False
|
||
normalized = provider_id.strip().lower()
|
||
return normalized in PROVIDER_REGISTRY and _config_provider_matches(normalized)
|
||
|
||
|
||
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.
|
||
"""
|
||
provider = _get_config_provider()
|
||
flow = OAUTH_PROVIDER_FLOWS.get(provider or "")
|
||
return provider if flow and flow.logout_from_config else None
|
||
|
||
|
||
def _reset_config_provider() -> Path:
|
||
"""Reset config.yaml provider back to auto after logout."""
|
||
config_path = get_config_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"
|
||
if "base_url" in model:
|
||
model["base_url"] = OPENROUTER_BASE_URL
|
||
atomic_yaml_write(config_path, config, sort_keys=False)
|
||
return config_path
|
||
|
||
|
||
def login_command(args) -> None:
|
||
"""Deprecated: use 'hermes model' or 'hermes setup' instead."""
|
||
print("The 'hermes login' command has been removed.")
|
||
print("Use 'hermes auth' to manage credentials,")
|
||
print("'hermes model' to select a provider, or 'hermes setup' for full setup.")
|
||
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")
|
||
if not state or not state.get("access_token"):
|
||
return {"logged_in": False, "provider": "minimax-oauth"}
|
||
try:
|
||
expires_at = datetime.fromisoformat(state.get("expires_at", "")).timestamp()
|
||
token_valid = (expires_at - time.time()) > 0
|
||
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"),
|
||
}
|
||
|
||
|
||
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()
|
||
|
||
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:
|
||
print(f"No auth state found for {provider_name}.")
|