mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-20 05:01:30 +00:00
Add pooled same-provider credential fallback
This commit is contained in:
parent
934fbe3c06
commit
b17e5c101d
18 changed files with 2872 additions and 195 deletions
|
|
@ -537,7 +537,11 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
|||
except Exception:
|
||||
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
||||
|
||||
if isinstance(raw, dict) and isinstance(raw.get("providers"), dict):
|
||||
if isinstance(raw, dict) and (
|
||||
isinstance(raw.get("providers"), dict)
|
||||
or isinstance(raw.get("credential_pool"), dict)
|
||||
):
|
||||
raw.setdefault("providers", {})
|
||||
return raw
|
||||
|
||||
# Migrate from PR's "systems" format if present
|
||||
|
|
@ -605,6 +609,30 @@ def _save_provider_state(auth_store: Dict[str, Any], provider_id: str, state: Di
|
|||
auth_store["active_provider"] = provider_id
|
||||
|
||||
|
||||
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return the persisted credential pool, or one provider slice."""
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
if provider_id is None:
|
||||
return dict(pool)
|
||||
provider_entries = pool.get(provider_id)
|
||||
return list(provider_entries) if isinstance(provider_entries, list) else []
|
||||
|
||||
|
||||
def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
|
||||
"""Persist one provider's credential pool under auth.json."""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
auth_store["credential_pool"] = pool
|
||||
pool[provider_id] = list(entries)
|
||||
return _save_auth_store(auth_store)
|
||||
|
||||
|
||||
def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return persisted auth state for a provider, or None."""
|
||||
auth_store = _load_auth_store()
|
||||
|
|
@ -878,15 +906,14 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None
|
|||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def _refresh_codex_auth_tokens(
|
||||
tokens: Dict[str, str],
|
||||
timeout_seconds: float,
|
||||
) -> Dict[str, str]:
|
||||
"""Refresh Codex access token using the refresh token.
|
||||
|
||||
Saves the new tokens to Hermes auth store automatically.
|
||||
"""
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
def refresh_codex_oauth_pure(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
*,
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> Dict[str, Any]:
|
||||
"""Refresh Codex OAuth tokens without mutating Hermes auth state."""
|
||||
del access_token # Access token is only used by callers to decide whether to refresh.
|
||||
if not isinstance(refresh_token, str) or not refresh_token.strip():
|
||||
raise AuthError(
|
||||
"Codex auth is missing refresh_token. Run `hermes login` to re-authenticate.",
|
||||
|
|
@ -941,8 +968,8 @@ def _refresh_codex_auth_tokens(
|
|||
relogin_required=True,
|
||||
) from exc
|
||||
|
||||
access_token = refresh_payload.get("access_token")
|
||||
if not isinstance(access_token, str) or not access_token.strip():
|
||||
refreshed_access = refresh_payload.get("access_token")
|
||||
if not isinstance(refreshed_access, str) or not refreshed_access.strip():
|
||||
raise AuthError(
|
||||
"Codex token refresh response was missing access_token.",
|
||||
provider="openai-codex",
|
||||
|
|
@ -950,11 +977,33 @@ def _refresh_codex_auth_tokens(
|
|||
relogin_required=True,
|
||||
)
|
||||
|
||||
updated_tokens = dict(tokens)
|
||||
updated_tokens["access_token"] = access_token.strip()
|
||||
updated = {
|
||||
"access_token": refreshed_access.strip(),
|
||||
"refresh_token": refresh_token.strip(),
|
||||
"last_refresh": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
next_refresh = refresh_payload.get("refresh_token")
|
||||
if isinstance(next_refresh, str) and next_refresh.strip():
|
||||
updated_tokens["refresh_token"] = next_refresh.strip()
|
||||
updated["refresh_token"] = next_refresh.strip()
|
||||
return updated
|
||||
|
||||
|
||||
def _refresh_codex_auth_tokens(
|
||||
tokens: Dict[str, str],
|
||||
timeout_seconds: float,
|
||||
) -> Dict[str, str]:
|
||||
"""Refresh Codex access token using the refresh token.
|
||||
|
||||
Saves the new tokens to Hermes auth store automatically.
|
||||
"""
|
||||
refreshed = refresh_codex_oauth_pure(
|
||||
str(tokens.get("access_token", "") or ""),
|
||||
str(tokens.get("refresh_token", "") or ""),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
updated_tokens = dict(tokens)
|
||||
updated_tokens["access_token"] = refreshed["access_token"]
|
||||
updated_tokens["refresh_token"] = refreshed["refresh_token"]
|
||||
|
||||
_save_codex_tokens(updated_tokens)
|
||||
return updated_tokens
|
||||
|
|
@ -1293,6 +1342,91 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
|
|||
return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds)
|
||||
|
||||
|
||||
def refresh_nous_oauth_pure(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
client_id: str,
|
||||
portal_base_url: str,
|
||||
inference_base_url: str,
|
||||
*,
|
||||
token_type: str = "Bearer",
|
||||
scope: str = DEFAULT_NOUS_SCOPE,
|
||||
obtained_at: Optional[str] = None,
|
||||
expires_at: Optional[str] = None,
|
||||
agent_key: Optional[str] = None,
|
||||
agent_key_expires_at: Optional[str] = None,
|
||||
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
timeout_seconds: float = 15.0,
|
||||
insecure: Optional[bool] = None,
|
||||
ca_bundle: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
force_mint: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Refresh Nous OAuth state without mutating auth.json."""
|
||||
state: Dict[str, Any] = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id or DEFAULT_NOUS_CLIENT_ID,
|
||||
"portal_base_url": (portal_base_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/"),
|
||||
"inference_base_url": (inference_base_url or DEFAULT_NOUS_INFERENCE_URL).rstrip("/"),
|
||||
"token_type": token_type or "Bearer",
|
||||
"scope": scope or DEFAULT_NOUS_SCOPE,
|
||||
"obtained_at": obtained_at,
|
||||
"expires_at": expires_at,
|
||||
"agent_key": agent_key,
|
||||
"agent_key_expires_at": agent_key_expires_at,
|
||||
"tls": {
|
||||
"insecure": bool(insecure),
|
||||
"ca_bundle": ca_bundle,
|
||||
},
|
||||
}
|
||||
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
|
||||
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
|
||||
|
||||
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
|
||||
if force_refresh or _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS):
|
||||
refreshed = _refresh_access_token(
|
||||
client=client,
|
||||
portal_base_url=state["portal_base_url"],
|
||||
client_id=state["client_id"],
|
||||
refresh_token=state["refresh_token"],
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or state["refresh_token"]
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
state["inference_base_url"] = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
|
||||
if force_mint or not _agent_key_is_usable(state, max(60, int(min_key_ttl_seconds))):
|
||||
mint_payload = _mint_agent_key(
|
||||
client=client,
|
||||
portal_base_url=state["portal_base_url"],
|
||||
access_token=state["access_token"],
|
||||
min_ttl_seconds=min_key_ttl_seconds,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
state["agent_key"] = mint_payload.get("api_key")
|
||||
state["agent_key_id"] = mint_payload.get("key_id")
|
||||
state["agent_key_expires_at"] = mint_payload.get("expires_at")
|
||||
state["agent_key_expires_in"] = mint_payload.get("expires_in")
|
||||
state["agent_key_reused"] = bool(mint_payload.get("reused", False))
|
||||
state["agent_key_obtained_at"] = now.isoformat()
|
||||
minted_url = _optional_base_url(mint_payload.get("inference_base_url"))
|
||||
if minted_url:
|
||||
state["inference_base_url"] = minted_url
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def resolve_nous_runtime_credentials(
|
||||
*,
|
||||
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||
|
|
@ -2159,34 +2293,36 @@ def _codex_device_code_login() -> Dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
"""Nous Portal device authorization flow."""
|
||||
def _nous_device_code_login(
|
||||
*,
|
||||
portal_base_url: Optional[str] = None,
|
||||
inference_base_url: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
open_browser: bool = True,
|
||||
timeout_seconds: float = 15.0,
|
||||
insecure: bool = False,
|
||||
ca_bundle: Optional[str] = None,
|
||||
min_key_ttl_seconds: int = 5 * 60,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the Nous device-code flow and return full OAuth state without persisting."""
|
||||
pconfig = PROVIDER_REGISTRY["nous"]
|
||||
portal_base_url = (
|
||||
getattr(args, "portal_url", None)
|
||||
portal_base_url
|
||||
or os.getenv("HERMES_PORTAL_BASE_URL")
|
||||
or os.getenv("NOUS_PORTAL_BASE_URL")
|
||||
or pconfig.portal_base_url
|
||||
).rstrip("/")
|
||||
requested_inference_url = (
|
||||
getattr(args, "inference_url", None)
|
||||
inference_base_url
|
||||
or os.getenv("NOUS_INFERENCE_BASE_URL")
|
||||
or pconfig.inference_base_url
|
||||
).rstrip("/")
|
||||
client_id = getattr(args, "client_id", None) or pconfig.client_id
|
||||
scope = getattr(args, "scope", None) or pconfig.scope
|
||||
open_browser = not getattr(args, "no_browser", False)
|
||||
timeout_seconds = getattr(args, "timeout", None) or 15.0
|
||||
client_id = client_id or pconfig.client_id
|
||||
scope = scope or pconfig.scope
|
||||
timeout = httpx.Timeout(timeout_seconds)
|
||||
|
||||
insecure = bool(getattr(args, "insecure", False))
|
||||
ca_bundle = (
|
||||
getattr(args, "ca_bundle", None)
|
||||
or os.getenv("HERMES_CA_BUNDLE")
|
||||
or os.getenv("SSL_CERT_FILE")
|
||||
)
|
||||
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
|
||||
|
||||
# Skip browser open in SSH sessions
|
||||
if _is_remote_session():
|
||||
open_browser = False
|
||||
|
||||
|
|
@ -2197,74 +2333,121 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||
elif ca_bundle:
|
||||
print(f"TLS verification: custom CA bundle ({ca_bundle})")
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
|
||||
device_data = _request_device_code(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, scope=scope,
|
||||
)
|
||||
|
||||
verification_url = str(device_data["verification_uri_complete"])
|
||||
user_code = str(device_data["user_code"])
|
||||
expires_in = int(device_data["expires_in"])
|
||||
interval = int(device_data["interval"])
|
||||
|
||||
print()
|
||||
print("To continue:")
|
||||
print(f" 1. Open: {verification_url}")
|
||||
print(f" 2. If prompted, enter code: {user_code}")
|
||||
|
||||
if open_browser:
|
||||
opened = webbrowser.open(verification_url)
|
||||
if opened:
|
||||
print(" (Opened browser for verification)")
|
||||
else:
|
||||
print(" Could not open browser automatically — use the URL above.")
|
||||
|
||||
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
||||
print(f"Waiting for approval (polling every {effective_interval}s)...")
|
||||
|
||||
token_data = _poll_for_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, device_code=str(device_data["device_code"]),
|
||||
expires_in=expires_in, poll_interval=interval,
|
||||
)
|
||||
|
||||
# Process token response
|
||||
now = datetime.now(timezone.utc)
|
||||
token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0))
|
||||
expires_at = now.timestamp() + token_expires_in
|
||||
inference_base_url = (
|
||||
_optional_base_url(token_data.get("inference_base_url"))
|
||||
or requested_inference_url
|
||||
with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client:
|
||||
device_data = _request_device_code(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
scope=scope,
|
||||
)
|
||||
if inference_base_url != requested_inference_url:
|
||||
print(f"Using portal-provided inference URL: {inference_base_url}")
|
||||
|
||||
auth_state = {
|
||||
"portal_base_url": portal_base_url,
|
||||
"inference_base_url": inference_base_url,
|
||||
"client_id": client_id,
|
||||
"scope": token_data.get("scope") or scope,
|
||||
"token_type": token_data.get("token_type", "Bearer"),
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data.get("refresh_token"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
|
||||
"expires_in": token_expires_in,
|
||||
"tls": {
|
||||
"insecure": verify is False,
|
||||
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||
},
|
||||
"agent_key": None,
|
||||
"agent_key_id": None,
|
||||
"agent_key_expires_at": None,
|
||||
"agent_key_expires_in": None,
|
||||
"agent_key_reused": None,
|
||||
"agent_key_obtained_at": None,
|
||||
}
|
||||
verification_url = str(device_data["verification_uri_complete"])
|
||||
user_code = str(device_data["user_code"])
|
||||
expires_in = int(device_data["expires_in"])
|
||||
interval = int(device_data["interval"])
|
||||
|
||||
print()
|
||||
print("To continue:")
|
||||
print(f" 1. Open: {verification_url}")
|
||||
print(f" 2. If prompted, enter code: {user_code}")
|
||||
|
||||
if open_browser:
|
||||
opened = webbrowser.open(verification_url)
|
||||
if opened:
|
||||
print(" (Opened browser for verification)")
|
||||
else:
|
||||
print(" Could not open browser automatically — use the URL above.")
|
||||
|
||||
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
||||
print(f"Waiting for approval (polling every {effective_interval}s)...")
|
||||
|
||||
token_data = _poll_for_token(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
device_code=str(device_data["device_code"]),
|
||||
expires_in=expires_in,
|
||||
poll_interval=interval,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0))
|
||||
expires_at = now.timestamp() + token_expires_in
|
||||
resolved_inference_url = (
|
||||
_optional_base_url(token_data.get("inference_base_url"))
|
||||
or requested_inference_url
|
||||
)
|
||||
if resolved_inference_url != requested_inference_url:
|
||||
print(f"Using portal-provided inference URL: {resolved_inference_url}")
|
||||
|
||||
auth_state = {
|
||||
"portal_base_url": portal_base_url,
|
||||
"inference_base_url": resolved_inference_url,
|
||||
"client_id": client_id,
|
||||
"scope": token_data.get("scope") or scope,
|
||||
"token_type": token_data.get("token_type", "Bearer"),
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data.get("refresh_token"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
|
||||
"expires_in": token_expires_in,
|
||||
"tls": {
|
||||
"insecure": verify is False,
|
||||
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||
},
|
||||
"agent_key": None,
|
||||
"agent_key_id": None,
|
||||
"agent_key_expires_at": None,
|
||||
"agent_key_expires_in": None,
|
||||
"agent_key_reused": None,
|
||||
"agent_key_obtained_at": None,
|
||||
}
|
||||
return refresh_nous_oauth_pure(
|
||||
auth_state["access_token"],
|
||||
auth_state["refresh_token"],
|
||||
auth_state["client_id"],
|
||||
auth_state["portal_base_url"],
|
||||
auth_state["inference_base_url"],
|
||||
token_type=auth_state["token_type"],
|
||||
scope=auth_state["scope"],
|
||||
obtained_at=auth_state["obtained_at"],
|
||||
expires_at=auth_state["expires_at"],
|
||||
agent_key=auth_state["agent_key"],
|
||||
agent_key_expires_at=auth_state["agent_key_expires_at"],
|
||||
min_key_ttl_seconds=min_key_ttl_seconds,
|
||||
timeout_seconds=timeout_seconds,
|
||||
insecure=insecure,
|
||||
ca_bundle=ca_bundle,
|
||||
force_refresh=False,
|
||||
force_mint=True,
|
||||
)
|
||||
|
||||
|
||||
def _login_nous(args, pconfig: ProviderConfig) -> None:
|
||||
"""Nous Portal device authorization flow."""
|
||||
timeout_seconds = getattr(args, "timeout", None) or 15.0
|
||||
insecure = bool(getattr(args, "insecure", False))
|
||||
ca_bundle = (
|
||||
getattr(args, "ca_bundle", None)
|
||||
or os.getenv("HERMES_CA_BUNDLE")
|
||||
or os.getenv("SSL_CERT_FILE")
|
||||
)
|
||||
|
||||
try:
|
||||
auth_state = _nous_device_code_login(
|
||||
portal_base_url=getattr(args, "portal_url", None) or pconfig.portal_base_url,
|
||||
inference_base_url=getattr(args, "inference_url", None) or pconfig.inference_base_url,
|
||||
client_id=getattr(args, "client_id", None) or pconfig.client_id,
|
||||
scope=getattr(args, "scope", None) or pconfig.scope,
|
||||
open_browser=not getattr(args, "no_browser", False),
|
||||
timeout_seconds=timeout_seconds,
|
||||
insecure=insecure,
|
||||
ca_bundle=ca_bundle,
|
||||
min_key_ttl_seconds=5 * 60,
|
||||
)
|
||||
inference_base_url = auth_state["inference_base_url"]
|
||||
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
|
||||
|
||||
# Save auth state
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
_save_provider_state(auth_store, "nous", auth_state)
|
||||
|
|
@ -2276,21 +2459,17 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||
print(f" Auth state: {saved_to}")
|
||||
print(f" Config updated: {config_path} (model.provider=nous)")
|
||||
|
||||
# Mint an initial agent key and list available models
|
||||
try:
|
||||
runtime_creds = resolve_nous_runtime_credentials(
|
||||
min_key_ttl_seconds=5 * 60,
|
||||
timeout_seconds=timeout_seconds,
|
||||
insecure=insecure, ca_bundle=ca_bundle,
|
||||
)
|
||||
runtime_key = runtime_creds.get("api_key")
|
||||
runtime_base_url = runtime_creds.get("base_url") or inference_base_url
|
||||
runtime_key = auth_state.get("agent_key") or auth_state.get("access_token")
|
||||
if not isinstance(runtime_key, str) or not runtime_key:
|
||||
raise AuthError("No runtime API key available to fetch models",
|
||||
provider="nous", code="invalid_token")
|
||||
raise AuthError(
|
||||
"No runtime API key available to fetch models",
|
||||
provider="nous",
|
||||
code="invalid_token",
|
||||
)
|
||||
|
||||
model_ids = fetch_nous_models(
|
||||
inference_base_url=runtime_base_url,
|
||||
inference_base_url=inference_base_url,
|
||||
api_key=runtime_key,
|
||||
timeout_seconds=timeout_seconds,
|
||||
verify=verify,
|
||||
|
|
|
|||
238
hermes_cli/auth_commands.py
Normal file
238
hermes_cli/auth_commands.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""Credential-pool auth subcommands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from getpass import getpass
|
||||
import uuid
|
||||
|
||||
from agent.credential_pool import PooledCredential, load_pool
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
|
||||
def _normalize_provider(provider: str) -> str:
|
||||
normalized = (provider or "").strip().lower()
|
||||
if normalized in {"or", "open-router"}:
|
||||
return "openrouter"
|
||||
return normalized
|
||||
|
||||
|
||||
def _provider_base_url(provider: str) -> str:
|
||||
if provider == "openrouter":
|
||||
return "https://openrouter.ai/api/v1"
|
||||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
return pconfig.inference_base_url if pconfig else ""
|
||||
|
||||
|
||||
def _derive_label(token: str, fallback: str) -> str:
|
||||
claims = auth_mod._decode_jwt_claims(token)
|
||||
for key in ("email", "preferred_username", "upn"):
|
||||
value = claims.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def _oauth_default_label(provider: str, count: int) -> str:
|
||||
return f"{provider}-oauth-{count}"
|
||||
|
||||
|
||||
def _api_key_default_label(count: int) -> str:
|
||||
return f"api-key-{count}"
|
||||
|
||||
|
||||
def _display_source(source: str) -> str:
|
||||
return source.split(":", 1)[1] if source.startswith("manual:") else source
|
||||
|
||||
|
||||
def auth_add_command(args) -> None:
|
||||
provider = _normalize_provider(getattr(args, "provider", ""))
|
||||
if provider not in PROVIDER_REGISTRY and provider != "openrouter":
|
||||
raise SystemExit(f"Unknown provider: {provider}")
|
||||
|
||||
requested_type = str(getattr(args, "auth_type", "") or "").strip().lower()
|
||||
if requested_type in {"api_key", "api-key"}:
|
||||
requested_type = "api_key"
|
||||
if not requested_type:
|
||||
requested_type = "oauth" if provider in {"anthropic", "nous", "openai-codex"} else "api_key"
|
||||
|
||||
pool = load_pool(provider)
|
||||
|
||||
if requested_type == "api_key":
|
||||
token = (getattr(args, "api_key", None) or "").strip()
|
||||
if not token:
|
||||
token = getpass("Paste your API key: ").strip()
|
||||
if not token:
|
||||
raise SystemExit("No API key provided.")
|
||||
default_label = _api_key_default_label(len(pool.entries()) + 1)
|
||||
label = (getattr(args, "label", None) or "").strip()
|
||||
if not label:
|
||||
label = input(f"Label (optional, default: {default_label}): ").strip() or default_label
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type="api_key",
|
||||
priority=0,
|
||||
source="manual",
|
||||
access_token=token,
|
||||
base_url=_provider_base_url(provider),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} credential #{len(pool.entries())}: "{label}"')
|
||||
return
|
||||
|
||||
if provider == "anthropic":
|
||||
from agent import anthropic_adapter as anthropic_mod
|
||||
|
||||
creds = anthropic_mod.run_hermes_oauth_login_pure()
|
||||
if not creds:
|
||||
raise SystemExit("Anthropic OAuth login did not return credentials.")
|
||||
label = (getattr(args, "label", None) or "").strip() or _derive_label(
|
||||
creds["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type="oauth",
|
||||
priority=0,
|
||||
source="manual:hermes_pkce",
|
||||
access_token=creds["access_token"],
|
||||
refresh_token=creds.get("refresh_token"),
|
||||
expires_at_ms=creds.get("expires_at_ms"),
|
||||
base_url=_provider_base_url(provider),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "nous":
|
||||
creds = auth_mod._nous_device_code_login(
|
||||
portal_base_url=getattr(args, "portal_url", None),
|
||||
inference_base_url=getattr(args, "inference_url", None),
|
||||
client_id=getattr(args, "client_id", None),
|
||||
scope=getattr(args, "scope", None),
|
||||
open_browser=not getattr(args, "no_browser", False),
|
||||
timeout_seconds=getattr(args, "timeout", None) or 15.0,
|
||||
insecure=bool(getattr(args, "insecure", False)),
|
||||
ca_bundle=getattr(args, "ca_bundle", None),
|
||||
min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))),
|
||||
)
|
||||
label = (getattr(args, "label", None) or "").strip() or _derive_label(
|
||||
creds.get("access_token", ""),
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type="oauth",
|
||||
priority=0,
|
||||
source="manual:device_code",
|
||||
access_token=creds["access_token"],
|
||||
refresh_token=creds.get("refresh_token"),
|
||||
expires_at=creds.get("expires_at"),
|
||||
token_type=creds.get("token_type"),
|
||||
scope=creds.get("scope"),
|
||||
client_id=creds.get("client_id"),
|
||||
portal_base_url=creds.get("portal_base_url"),
|
||||
inference_base_url=creds.get("inference_base_url"),
|
||||
obtained_at=creds.get("obtained_at"),
|
||||
expires_in=creds.get("expires_in"),
|
||||
agent_key=creds.get("agent_key"),
|
||||
agent_key_id=creds.get("agent_key_id"),
|
||||
agent_key_expires_at=creds.get("agent_key_expires_at"),
|
||||
agent_key_expires_in=creds.get("agent_key_expires_in"),
|
||||
agent_key_reused=creds.get("agent_key_reused"),
|
||||
agent_key_obtained_at=creds.get("agent_key_obtained_at"),
|
||||
tls=creds.get("tls"),
|
||||
base_url=creds.get("inference_base_url"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "openai-codex":
|
||||
creds = auth_mod._codex_device_code_login()
|
||||
label = (getattr(args, "label", None) or "").strip() or _derive_label(
|
||||
creds["tokens"]["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type="oauth",
|
||||
priority=0,
|
||||
source="manual:device_code",
|
||||
access_token=creds["tokens"]["access_token"],
|
||||
refresh_token=creds["tokens"].get("refresh_token"),
|
||||
base_url=creds.get("base_url"),
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
raise SystemExit(f"`hermes auth add {provider}` is not implemented for auth type {requested_type} yet.")
|
||||
|
||||
|
||||
def auth_list_command(args) -> None:
|
||||
provider_filter = _normalize_provider(getattr(args, "provider", "") or "")
|
||||
providers = [provider_filter] if provider_filter else sorted({
|
||||
*PROVIDER_REGISTRY.keys(),
|
||||
"openrouter",
|
||||
})
|
||||
for provider in providers:
|
||||
pool = load_pool(provider)
|
||||
entries = pool.entries()
|
||||
if not entries:
|
||||
continue
|
||||
current = pool.select()
|
||||
print(f"{provider} ({len(entries)} credentials):")
|
||||
for idx, entry in enumerate(entries, start=1):
|
||||
marker = " "
|
||||
if current is not None and entry.id == current.id:
|
||||
marker = "← "
|
||||
status = ""
|
||||
if entry.last_status == "exhausted":
|
||||
status = f" exhausted ({entry.last_error_code})"
|
||||
source = _display_source(entry.source)
|
||||
print(f" #{idx} {entry.label:<20} {entry.auth_type:<7} {source}{status} {marker}".rstrip())
|
||||
print()
|
||||
|
||||
|
||||
def auth_remove_command(args) -> None:
|
||||
provider = _normalize_provider(getattr(args, "provider", ""))
|
||||
index = int(getattr(args, "index"))
|
||||
pool = load_pool(provider)
|
||||
removed = pool.remove_index(index)
|
||||
if removed is None:
|
||||
raise SystemExit(f"No credential #{index} for provider {provider}.")
|
||||
print(f"Removed {provider} credential #{index} ({removed.label})")
|
||||
|
||||
|
||||
def auth_reset_command(args) -> None:
|
||||
provider = _normalize_provider(getattr(args, "provider", ""))
|
||||
pool = load_pool(provider)
|
||||
count = pool.reset_statuses()
|
||||
print(f"Reset status on {count} {provider} credentials")
|
||||
|
||||
|
||||
def auth_command(args) -> None:
|
||||
action = getattr(args, "auth_action", "")
|
||||
if action == "add":
|
||||
auth_add_command(args)
|
||||
return
|
||||
if action == "list":
|
||||
auth_list_command(args)
|
||||
return
|
||||
if action == "remove":
|
||||
auth_remove_command(args)
|
||||
return
|
||||
if action == "reset":
|
||||
auth_reset_command(args)
|
||||
return
|
||||
raise SystemExit("Usage: hermes auth [add|list|remove|reset] ...")
|
||||
|
|
@ -2310,6 +2310,12 @@ def cmd_logout(args):
|
|||
logout_command(args)
|
||||
|
||||
|
||||
def cmd_auth(args):
|
||||
"""Manage pooled credentials."""
|
||||
from hermes_cli.auth_commands import auth_command
|
||||
auth_command(args)
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
"""Show status of all components."""
|
||||
from hermes_cli.status import show_status
|
||||
|
|
@ -2980,7 +2986,7 @@ def _coalesce_session_name_args(argv: list) -> list:
|
|||
or a known top-level subcommand.
|
||||
"""
|
||||
_SUBCOMMANDS = {
|
||||
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
|
||||
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth",
|
||||
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
||||
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
||||
}
|
||||
|
|
@ -3021,6 +3027,10 @@ Examples:
|
|||
hermes --resume <session_id> Resume a specific session by ID
|
||||
hermes setup Run setup wizard
|
||||
hermes logout Clear stored authentication
|
||||
hermes auth add <provider> Add a pooled credential
|
||||
hermes auth list List pooled credentials
|
||||
hermes auth remove <p> <n> Remove pooled credential by index
|
||||
hermes auth reset <provider> Clear exhaustion status for a provider
|
||||
hermes model Select default model
|
||||
hermes config View configuration
|
||||
hermes config edit Edit config in $EDITOR
|
||||
|
|
@ -3334,6 +3344,33 @@ For more help on a command:
|
|||
)
|
||||
logout_parser.set_defaults(func=cmd_logout)
|
||||
|
||||
auth_parser = subparsers.add_parser(
|
||||
"auth",
|
||||
help="Manage pooled provider credentials",
|
||||
)
|
||||
auth_subparsers = auth_parser.add_subparsers(dest="auth_action")
|
||||
auth_add = auth_subparsers.add_parser("add", help="Add a pooled credential")
|
||||
auth_add.add_argument("provider", help="Provider id (for example: anthropic, openai-codex, openrouter)")
|
||||
auth_add.add_argument("--type", dest="auth_type", choices=["oauth", "api-key", "api_key"], help="Credential type to add")
|
||||
auth_add.add_argument("--label", help="Optional display label")
|
||||
auth_add.add_argument("--api-key", help="API key value (otherwise prompted securely)")
|
||||
auth_add.add_argument("--portal-url", help="Nous portal base URL")
|
||||
auth_add.add_argument("--inference-url", help="Nous inference base URL")
|
||||
auth_add.add_argument("--client-id", help="OAuth client id")
|
||||
auth_add.add_argument("--scope", help="OAuth scope override")
|
||||
auth_add.add_argument("--no-browser", action="store_true", help="Do not auto-open a browser for OAuth login")
|
||||
auth_add.add_argument("--timeout", type=float, help="OAuth/network timeout in seconds")
|
||||
auth_add.add_argument("--insecure", action="store_true", help="Disable TLS verification for OAuth login")
|
||||
auth_add.add_argument("--ca-bundle", help="Custom CA bundle for OAuth login")
|
||||
auth_list = auth_subparsers.add_parser("list", help="List pooled credentials")
|
||||
auth_list.add_argument("provider", nargs="?", help="Optional provider filter")
|
||||
auth_remove = auth_subparsers.add_parser("remove", help="Remove a pooled credential by index")
|
||||
auth_remove.add_argument("provider", help="Provider id")
|
||||
auth_remove.add_argument("index", type=int, help="1-based credential index")
|
||||
auth_reset = auth_subparsers.add_parser("reset", help="Clear exhaustion status for all credentials for a provider")
|
||||
auth_reset.add_argument("provider", help="Provider id")
|
||||
auth_parser.set_defaults(func=cmd_auth)
|
||||
|
||||
# =========================================================================
|
||||
# status command
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import os
|
|||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
from agent.credential_pool import load_pool
|
||||
from hermes_cli.auth import (
|
||||
AuthError,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
PROVIDER_REGISTRY,
|
||||
format_auth_error,
|
||||
resolve_provider,
|
||||
|
|
@ -106,6 +108,48 @@ def _parse_api_mode(raw: Any) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _resolve_runtime_from_pool_entry(
|
||||
*,
|
||||
provider: str,
|
||||
entry: Any,
|
||||
requested_provider: str,
|
||||
model_cfg: Optional[Dict[str, Any]] = None,
|
||||
pool: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
model_cfg = model_cfg or _get_model_config()
|
||||
base_url = (getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or "").rstrip("/")
|
||||
api_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
|
||||
api_mode = "chat_completions"
|
||||
if provider == "openai-codex":
|
||||
api_mode = "codex_responses"
|
||||
base_url = base_url or DEFAULT_CODEX_BASE_URL
|
||||
elif provider == "anthropic":
|
||||
api_mode = "anthropic_messages"
|
||||
base_url = base_url or "https://api.anthropic.com"
|
||||
elif provider == "nous":
|
||||
api_mode = "chat_completions"
|
||||
elif provider == "copilot":
|
||||
api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""))
|
||||
else:
|
||||
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
|
||||
if configured_mode:
|
||||
api_mode = configured_mode
|
||||
elif base_url.rstrip("/").endswith("/anthropic") or provider in ("minimax", "minimax-cn"):
|
||||
api_mode = "anthropic_messages"
|
||||
if base_url.rstrip("/").endswith("/v1"):
|
||||
base_url = base_url.rstrip("/")[:-3] + "/anthropic"
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": api_mode,
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
"source": getattr(entry, "source", "pool"),
|
||||
"credential_pool": pool,
|
||||
"requested_provider": requested_provider,
|
||||
}
|
||||
|
||||
|
||||
def resolve_requested_provider(requested: Optional[str] = None) -> str:
|
||||
"""Resolve provider request from explicit arg, config, then env."""
|
||||
if requested and requested.strip():
|
||||
|
|
@ -313,6 +357,38 @@ def resolve_runtime_provider(
|
|||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=explicit_base_url,
|
||||
)
|
||||
model_cfg = _get_model_config()
|
||||
|
||||
should_use_pool = provider != "openrouter"
|
||||
if provider == "openrouter":
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
cfg_base_url = str(model_cfg.get("base_url") or "").strip()
|
||||
env_openai_base_url = os.getenv("OPENAI_BASE_URL", "").strip()
|
||||
has_custom_endpoint = bool(explicit_base_url or env_openai_base_url)
|
||||
if cfg_base_url and cfg_provider in {"auto", "custom"}:
|
||||
has_custom_endpoint = True
|
||||
should_use_pool = requested_provider == "openrouter" and not has_custom_endpoint
|
||||
|
||||
try:
|
||||
pool = load_pool(provider) if should_use_pool else None
|
||||
except Exception:
|
||||
pool = None
|
||||
if pool and pool.has_credentials():
|
||||
entry = pool.select()
|
||||
pool_api_key = ""
|
||||
if entry is not None:
|
||||
pool_api_key = (
|
||||
getattr(entry, "runtime_api_key", None)
|
||||
or getattr(entry, "access_token", "")
|
||||
)
|
||||
if entry is not None and pool_api_key:
|
||||
return _resolve_runtime_from_pool_entry(
|
||||
provider=provider,
|
||||
entry=entry,
|
||||
requested_provider=requested_provider,
|
||||
model_cfg=model_cfg,
|
||||
pool=pool,
|
||||
)
|
||||
|
||||
if provider == "nous":
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
|
|
@ -385,7 +461,6 @@ def resolve_runtime_provider(
|
|||
pconfig = PROVIDER_REGISTRY.get(provider)
|
||||
if pconfig and pconfig.auth_type == "api_key":
|
||||
creds = resolve_api_key_provider_credentials(provider)
|
||||
model_cfg = _get_model_config()
|
||||
base_url = creds.get("base_url", "").rstrip("/")
|
||||
api_mode = "chat_completions"
|
||||
if provider == "copilot":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue