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
89 changes: 76 additions & 13 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,12 +618,11 @@ 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)
# RLock: the mutation primitives below (_replace_entry/_persist)
# self-acquire this lock so the DEFERRED single-use-token refresh
# path (which runs network I/O outside the lock by design) still
# serializes its pool mutations. In-lock callers re-acquire
# reentrantly at negligible cost.
self._lock = threading.RLock()
# 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.
self._default_auth = get_default_auth_name(provider)
self._lock = threading.Lock()
self._active_leases: Dict[str, int] = {}
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
# Monotonic timestamp of the last "no available entries" log, used to
Expand Down Expand Up @@ -1903,18 +1932,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 _select_unlocked(self, *, refresh: bool = True) -> Tuple[Optional[PooledCredential], List[tuple]]:
"""Select the best available credential entry.

Returns ``(entry, pending_refresh)`` where *pending_refresh* contains
single-use-token entries that must be refreshed outside the lock.
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.
"""
available, pending_refresh = self._available_entries(clear_expired=True, refresh=refresh)
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, pending_refresh

# 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please apply this narrowing to acquire_lease() as well. tools/delegate_tool.py:1992 leases child credentials through that method, and its current implementation chooses from all available entries, so delegated tasks can bypass the selected credential.

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 @@ -1953,7 +2011,8 @@ def peek(self) -> Optional[PooledCredential]:
current = self._current_unlocked()
if current is not None:
return current
available, _pending = self._available_entries()
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 @@ -2506,6 +2565,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 @@ -6086,6 +6086,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 @@ -6122,6 +6123,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
5 changes: 5 additions & 0 deletions hermes_cli/subcommands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None:
help="Credential type to add",
)
auth_add.add_argument("--label", help="Optional display label")
auth_add.add_argument(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only names credentials at creation time. #76937 asks for hermes chat ... --auth <name> selection per task/session, but this diff adds no session-level selector or propagation into agent construction; a profile-wide config preference cannot let concurrent sessions choose different keys.

"--name",
help="Optional credential name for manual selection "
"(set config default_auth.<provider> to this name to pin the pool to it)",
)
auth_add.add_argument(
"--api-key", help="API key value (otherwise prompted securely)"
)
Expand Down
Loading
Loading