Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ class PooledCredential:
agent_key: Optional[str] = None
agent_key_expires_at: Optional[str] = None
request_count: int = 0
# Optional user-assigned name (``hermes auth add <provider> --name <name>``)
# used for manual credential selection via ``config.default_auth``. Unlike
# ``label`` (a display label that singleton seeding may rewrite), ``name``
# is a stable selection key that the user controls. Entries without a
# name keep the legacy auto-rotate behavior.
name: Optional[str] = None
extra: Dict[str, Any] = None # type: ignore[assignment]

def __post_init__(self):
Expand Down Expand Up @@ -486,6 +492,30 @@ def get_pool_strategy(provider: str) -> str:
return STRATEGY_FILL_FIRST


def get_default_auth_name(provider: str) -> Optional[str]:
"""Return the user-selected credential name for a provider, if any.

Reads ``config.yaml``'s ``default_auth`` map (``default_auth:
{<provider>: <credential name>}``). When set, the pool prefers the
credential whose ``name`` matches — manual selection instead of passive
auto-rotation (#76937). Selection falls back to the normal strategy when
the named credential is missing or currently exhausted. A missing or
malformed config returns None, preserving the legacy auto-rotate
behavior for every existing pool.
"""
config = _load_config_safe()
if config is None:
return None
default_auth = config.get("default_auth")
if not isinstance(default_auth, dict):
return None
name = default_auth.get(provider)
if not isinstance(name, str):
return None
name = name.strip()
return name or None


def credential_pool_matches_provider(
pool_or_provider: Any,
provider: Optional[str],
Expand Down Expand Up @@ -588,6 +618,12 @@ def __init__(self, provider: str, entries: List[PooledCredential]):
self._entries = sorted(entries, key=lambda entry: entry.priority)
self._current_id: Optional[str] = None
self._strategy = get_pool_strategy(provider)
# Optional manual-selection name from config ``default_auth``
# (#76937). When set and a matching entry is available, selection is
# pinned to that credential instead of passive auto-rotation.
# Per-session override via HERMES_AUTH_NAME env var takes precedence.
session_auth = os.getenv("HERMES_AUTH_NAME", "").strip()
self._default_auth = session_auth or get_default_auth_name(provider)
self._lock = threading.Lock()
self._active_leases: Dict[str, int] = {}
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
Expand Down Expand Up @@ -1800,13 +1836,47 @@ def _log_no_available_entries(self) -> None:
self._last_no_entries_log_at = now
logger.info("credential pool: no available entries (all exhausted or empty)")

def _narrow_to_default_auth_unlocked(
self, available: List[PooledCredential]
) -> List[PooledCredential]:
"""Pin selection to the ``default_auth`` named credential when possible.

#76937 manual selection: when the user configured a preferred
credential name for this provider, selection is restricted to entries
with that name while one of them is available. If the named entry is
missing or currently exhausted it drops out of ``available`` and the
full list is returned, so the pool falls back to the legacy
auto-rotate behavior instead of failing the request.
"""
if not self._default_auth:
return available
named = [
entry
for entry in available
if (entry.name or "").strip() == self._default_auth
]
if named:
return named
logger.info(
"credential pool: default_auth credential %r for %s unavailable "
"(missing or exhausted) — falling back to auto-rotation",
self._default_auth,
self.provider,
)
return available

def _select_unlocked(self, *, refresh: bool = True) -> Optional[PooledCredential]:
available = self._available_entries(clear_expired=True, refresh=refresh)
if not available:
self._current_id = None
self._log_no_available_entries()
return None

# Manual selection (#76937): pin to the configured default_auth
# credential while it is available; fall back to auto-rotate below
# when it is missing or exhausted.
available = self._narrow_to_default_auth_unlocked(available)

# A successful selection means the pool recovered; re-arm the throttle
# so a later re-exhaustion logs immediately rather than being silenced
# by a window opened during the previous empty stretch.
Expand Down Expand Up @@ -1846,6 +1916,7 @@ def peek(self) -> Optional[PooledCredential]:
if current is not None:
return current
available = self._available_entries()
available = self._narrow_to_default_auth_unlocked(available)
return available[0] if available else None

def mark_exhausted_and_rotate(
Expand Down Expand Up @@ -1996,6 +2067,12 @@ def acquire_lease(self, credential_id: Optional[str] = None) -> Optional[str]:
if not available:
return None

# Manual selection (#76937): pin to the configured default_auth
# credential while it is available; fall back to auto-rotate.
available = self._narrow_to_default_auth_unlocked(available)
if not available:
return None

below_cap = [
entry for entry in available
if self._active_leases.get(entry.id, 0) < self._max_concurrent
Expand Down Expand Up @@ -2379,6 +2456,10 @@ def _env_val(key: str) -> str:
"agent_key_obtained_at": state.get("agent_key_obtained_at"),
"tls": state.get("tls") if isinstance(state.get("tls"), dict) else None,
"label": seeded_label,
# Manual-selection name (#76937) embedded by
# persist_nous_credentials(name=...) — None when unset,
# which _upsert_entry ignores.
"name": state.get("name"),
},
)

Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6034,6 +6034,7 @@ def persist_nous_credentials(
creds: Dict[str, Any],
*,
label: Optional[str] = None,
name: Optional[str] = None,
):
"""Persist Nous OAuth credentials as the singleton provider state
and ensure the credential pool is in sync.
Expand Down Expand Up @@ -6070,6 +6071,11 @@ def persist_nous_credentials(
state = dict(creds)
if label and str(label).strip():
state["label"] = str(label).strip()
# Optional manual-selection name (#76937) — carried through the
# singleton state so ``_seed_from_singletons`` re-applies it on every
# subsequent ``load_pool(\"nous\")``.
if name and str(name).strip():
state["name"] = str(name).strip()

with _auth_store_lock():
auth_store = _load_auth_store()
Expand Down
55 changes: 50 additions & 5 deletions hermes_cli/auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
PooledCredential,
_exhausted_until,
_normalize_custom_pool_name,
get_default_auth_name,
get_pool_strategy,
label_from_token,
list_custom_pool_providers,
Expand Down Expand Up @@ -109,6 +110,19 @@ def _api_key_default_label(count: int) -> str:
return f"api-key-{count}"


def _credential_name(args) -> Optional[str]:
"""Return a user-supplied credential name (``--name``), stripped, or None.

The name is the manual-selection key for #76937: pairing it with
``config.default_auth`` pins the provider's pool to this credential.
"""
name = getattr(args, "name", None)
if not isinstance(name, str):
return None
name = name.strip()
return name or None


def _display_source(source: str) -> str:
return source.split(":", 1)[1] if source.startswith("manual:") else source

Expand Down Expand Up @@ -216,6 +230,7 @@ def auth_add_command(args) -> None:
source=SOURCE_MANUAL,
access_token=token,
base_url=_provider_base_url(provider),
name=_credential_name(args),
)
pool.add_entry(entry)
print(f'Added {provider} credential #{len(pool.entries())}: "{label}"')
Expand All @@ -242,6 +257,7 @@ def auth_add_command(args) -> None:
refresh_token=creds.get("refresh_token"),
expires_at_ms=creds.get("expires_at_ms"),
base_url=_provider_base_url(provider),
name=_credential_name(args),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
Expand Down Expand Up @@ -276,7 +292,11 @@ def auth_add_command(args) -> None:
)
if rehydrated is not None:
custom_label = (getattr(args, "label", None) or "").strip() or None
entry = auth_mod.persist_nous_credentials(rehydrated, label=custom_label)
entry = auth_mod.persist_nous_credentials(
rehydrated,
label=custom_label,
name=_credential_name(args),
)
shown_label = entry.label if entry is not None else label_from_token(
rehydrated.get("access_token", ""), _oauth_default_label(provider, 1),
)
Expand All @@ -300,7 +320,11 @@ def auth_add_command(args) -> None:
# helper embeds this into providers.nous so that label_from_token
# doesn't overwrite it on every subsequent load_pool("nous").
custom_label = (getattr(args, "label", None) or "").strip() or None
entry = auth_mod.persist_nous_credentials(creds, label=custom_label)
entry = auth_mod.persist_nous_credentials(
creds,
label=custom_label,
name=_credential_name(args),
)
shown_label = entry.label if entry is not None else label_from_token(
creds.get("access_token", ""), _oauth_default_label(provider, 1),
)
Expand Down Expand Up @@ -334,6 +358,7 @@ def auth_add_command(args) -> None:
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url"),
last_refresh=creds.get("last_refresh"),
name=_credential_name(args),
)
first_credential = not pool.entries()
pool.add_entry(entry)
Expand Down Expand Up @@ -375,6 +400,7 @@ def auth_add_command(args) -> None:
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url") or auth_mod.DEFAULT_XAI_OAUTH_BASE_URL,
last_refresh=creds.get("last_refresh"),
name=_credential_name(args),
)
first_credential = not pool.entries()
pool.add_entry(entry)
Expand Down Expand Up @@ -402,6 +428,7 @@ def auth_add_command(args) -> None:
source=f"{SOURCE_MANUAL}:qwen_cli",
access_token=creds["api_key"],
base_url=creds.get("base_url"),
name=_credential_name(args),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
Expand All @@ -426,6 +453,7 @@ def auth_add_command(args) -> None:
access_token=creds["access_token"],
refresh_token=creds.get("refresh_token"),
base_url=creds.get("inference_base_url"),
name=_credential_name(args),
)
pool.add_entry(entry)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
Expand All @@ -450,14 +478,21 @@ def auth_list_command(args) -> None:
if not entries:
continue
current = pool.peek()
print(f"{provider} ({len(entries)} credentials):")
default_auth = get_default_auth_name(provider)
header = f"{provider} ({len(entries)} credentials):"
if default_auth:
header += f" [default_auth: {default_auth}]"
print(header)
for idx, entry in enumerate(entries, start=1):
marker = " "
if current is not None and entry.id == current.id:
marker = "← "
status = _format_exhausted_status(entry)
source = _display_source(entry.source)
print(f" #{idx} {entry.label:<20} {entry.auth_type:<7} {source}{status} {marker}".rstrip())
name_tag = f" [name:{entry.name}]" if (entry.name or "").strip() else ""
print(
f" #{idx} {entry.label:<20} {entry.auth_type:<7} {source}{name_tag}{status} {marker}".rstrip()
)
print()


Expand Down Expand Up @@ -698,8 +733,18 @@ def _interactive_add() -> None:
if typed_label:
label = typed_label

name = None
try:
typed_name = input(
"Credential name for manual selection, e.g. 'daily' (optional): "
).strip()
except (EOFError, KeyboardInterrupt):
return
if typed_name:
name = typed_name

auth_add_command(SimpleNamespace(
provider=provider, auth_type=auth_type, label=label, api_key=None,
provider=provider, auth_type=auth_type, label=label, name=name, api_key=None,
portal_url=None, inference_url=None, client_id=None, scope=None,
no_browser=False, timeout=None, insecure=False, ca_bundle=None,
))
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4654,6 +4654,7 @@ def _default_value_for_key(dotted_key: str):
_OPEN_DICT_TOP_LEVEL_KEYS = frozenset({
"providers",
"credential_pool_strategies",
"default_auth",
"mcp_servers",
"hooks",
"quick_commands",
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"providers": {},
"fallback_providers": [],
"credential_pool_strategies": {},
# Manual credential selection (#76937): map provider -> credential name
# (the ``--name`` given to ``hermes auth add``). The provider's pool is
# pinned to that credential while it is available, falling back to
# auto-rotation when it is missing or exhausted.
"default_auth": {},
"toolsets": ["hermes-cli"],
# SQLite journal mode used by every Hermes database opener. WAL is the
# normal default; set DELETE for weak-fsync/shared filesystems where WAL is
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11118,6 +11118,10 @@ def cmd_skills(args):
from hermes_cli.skills_config import skills_command as skills_config_command

skills_config_command(args)
elif getattr(args, "skills_action", None) == "group":
from hermes_cli.skills_groups import group_command

group_command(args)
else:
from hermes_cli.skills_hub import skills_command

Expand Down
Loading