mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 08:21:50 +08:00
Compare commits
4 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2783b5eff4 | ||
|
|
cb0cbd03cb | ||
|
|
ed94a5149d | ||
|
|
0541482bcb |
@@ -20,6 +20,7 @@ from hermes_cli.auth import (
|
|||||||
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||||
KIMI_CODE_BASE_URL,
|
KIMI_CODE_BASE_URL,
|
||||||
PROVIDER_REGISTRY,
|
PROVIDER_REGISTRY,
|
||||||
|
_auth_store_lock,
|
||||||
_codex_access_token_is_expiring,
|
_codex_access_token_is_expiring,
|
||||||
_decode_jwt_claims,
|
_decode_jwt_claims,
|
||||||
_import_codex_cli_tokens,
|
_import_codex_cli_tokens,
|
||||||
@@ -27,6 +28,8 @@ from hermes_cli.auth import (
|
|||||||
_load_provider_state,
|
_load_provider_state,
|
||||||
_resolve_kimi_base_url,
|
_resolve_kimi_base_url,
|
||||||
_resolve_zai_base_url,
|
_resolve_zai_base_url,
|
||||||
|
_save_auth_store,
|
||||||
|
_save_provider_state,
|
||||||
read_credential_pool,
|
read_credential_pool,
|
||||||
write_credential_pool,
|
write_credential_pool,
|
||||||
)
|
)
|
||||||
@@ -479,6 +482,67 @@ class CredentialPool:
|
|||||||
logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
|
logger.debug("Failed to sync from ~/.codex/auth.json: %s", exc)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
def _sync_device_code_entry_to_auth_store(self, entry: PooledCredential) -> None:
|
||||||
|
"""Write refreshed pool entry tokens back to auth.json providers.
|
||||||
|
|
||||||
|
After a pool-level refresh, the pool entry has fresh tokens but
|
||||||
|
auth.json's ``providers.<id>`` still holds the pre-refresh state.
|
||||||
|
On the next ``load_pool()``, ``_seed_from_singletons()`` reads that
|
||||||
|
stale state and can overwrite the fresh pool entry — potentially
|
||||||
|
re-seeding a consumed single-use refresh token.
|
||||||
|
|
||||||
|
Applies to any OAuth provider whose singleton lives in auth.json
|
||||||
|
(currently Nous and OpenAI Codex).
|
||||||
|
"""
|
||||||
|
if entry.source != "device_code":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with _auth_store_lock():
|
||||||
|
auth_store = _load_auth_store()
|
||||||
|
if self.provider == "nous":
|
||||||
|
state = _load_provider_state(auth_store, "nous")
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
state["access_token"] = entry.access_token
|
||||||
|
if entry.refresh_token:
|
||||||
|
state["refresh_token"] = entry.refresh_token
|
||||||
|
if entry.expires_at:
|
||||||
|
state["expires_at"] = entry.expires_at
|
||||||
|
if entry.agent_key:
|
||||||
|
state["agent_key"] = entry.agent_key
|
||||||
|
if entry.agent_key_expires_at:
|
||||||
|
state["agent_key_expires_at"] = entry.agent_key_expires_at
|
||||||
|
for extra_key in ("obtained_at", "expires_in", "agent_key_id",
|
||||||
|
"agent_key_expires_in", "agent_key_reused",
|
||||||
|
"agent_key_obtained_at"):
|
||||||
|
val = entry.extra.get(extra_key)
|
||||||
|
if val is not None:
|
||||||
|
state[extra_key] = val
|
||||||
|
if entry.inference_base_url:
|
||||||
|
state["inference_base_url"] = entry.inference_base_url
|
||||||
|
_save_provider_state(auth_store, "nous", state)
|
||||||
|
|
||||||
|
elif self.provider == "openai-codex":
|
||||||
|
state = _load_provider_state(auth_store, "openai-codex")
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
return
|
||||||
|
tokens = state.get("tokens")
|
||||||
|
if not isinstance(tokens, dict):
|
||||||
|
return
|
||||||
|
tokens["access_token"] = entry.access_token
|
||||||
|
if entry.refresh_token:
|
||||||
|
tokens["refresh_token"] = entry.refresh_token
|
||||||
|
if entry.last_refresh:
|
||||||
|
state["last_refresh"] = entry.last_refresh
|
||||||
|
_save_provider_state(auth_store, "openai-codex", state)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
_save_auth_store(auth_store)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to sync %s pool entry back to auth store: %s", self.provider, exc)
|
||||||
|
|
||||||
def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
|
def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[PooledCredential]:
|
||||||
if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token:
|
if entry.auth_type != AUTH_TYPE_OAUTH or not entry.refresh_token:
|
||||||
if force:
|
if force:
|
||||||
@@ -513,6 +577,13 @@ class CredentialPool:
|
|||||||
except Exception as wexc:
|
except Exception as wexc:
|
||||||
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
|
||||||
elif self.provider == "openai-codex":
|
elif self.provider == "openai-codex":
|
||||||
|
# Proactively sync from ~/.codex/auth.json before refresh.
|
||||||
|
# The Codex CLI (or another Hermes profile) may have already
|
||||||
|
# consumed our refresh_token. Syncing first avoids a
|
||||||
|
# "refresh_token_reused" error when the CLI has a newer pair.
|
||||||
|
synced = self._sync_codex_entry_from_cli(entry)
|
||||||
|
if synced is not entry:
|
||||||
|
entry = synced
|
||||||
refreshed = auth_mod.refresh_codex_oauth_pure(
|
refreshed = auth_mod.refresh_codex_oauth_pure(
|
||||||
entry.access_token,
|
entry.access_token,
|
||||||
entry.refresh_token,
|
entry.refresh_token,
|
||||||
@@ -598,6 +669,37 @@ class CredentialPool:
|
|||||||
# Credentials file had a valid (non-expired) token — use it directly
|
# Credentials file had a valid (non-expired) token — use it directly
|
||||||
logger.debug("Credentials file has valid token, using without refresh")
|
logger.debug("Credentials file has valid token, using without refresh")
|
||||||
return synced
|
return synced
|
||||||
|
# For openai-codex: the refresh_token may have been consumed by
|
||||||
|
# the Codex CLI between our proactive sync and the refresh call.
|
||||||
|
# Re-sync and retry once.
|
||||||
|
if self.provider == "openai-codex":
|
||||||
|
synced = self._sync_codex_entry_from_cli(entry)
|
||||||
|
if synced.refresh_token != entry.refresh_token:
|
||||||
|
logger.debug("Retrying Codex refresh with synced token from ~/.codex/auth.json")
|
||||||
|
try:
|
||||||
|
refreshed = auth_mod.refresh_codex_oauth_pure(
|
||||||
|
synced.access_token,
|
||||||
|
synced.refresh_token,
|
||||||
|
)
|
||||||
|
updated = replace(
|
||||||
|
synced,
|
||||||
|
access_token=refreshed["access_token"],
|
||||||
|
refresh_token=refreshed["refresh_token"],
|
||||||
|
last_refresh=refreshed.get("last_refresh"),
|
||||||
|
last_status=STATUS_OK,
|
||||||
|
last_status_at=None,
|
||||||
|
last_error_code=None,
|
||||||
|
)
|
||||||
|
self._replace_entry(synced, updated)
|
||||||
|
self._persist()
|
||||||
|
self._sync_device_code_entry_to_auth_store(updated)
|
||||||
|
return updated
|
||||||
|
except Exception as retry_exc:
|
||||||
|
logger.debug("Codex retry refresh also failed: %s", retry_exc)
|
||||||
|
elif not self._entry_needs_refresh(synced):
|
||||||
|
logger.debug("Codex CLI has valid token, using without refresh")
|
||||||
|
self._sync_device_code_entry_to_auth_store(synced)
|
||||||
|
return synced
|
||||||
self._mark_exhausted(entry, None)
|
self._mark_exhausted(entry, None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -612,6 +714,10 @@ class CredentialPool:
|
|||||||
)
|
)
|
||||||
self._replace_entry(entry, updated)
|
self._replace_entry(entry, updated)
|
||||||
self._persist()
|
self._persist()
|
||||||
|
# Sync refreshed tokens back to auth.json providers so that
|
||||||
|
# _seed_from_singletons() on the next load_pool() sees fresh state
|
||||||
|
# instead of re-seeding stale/consumed tokens.
|
||||||
|
self._sync_device_code_entry_to_auth_store(updated)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
|
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from hermes_cli.auth import (
|
|||||||
DEFAULT_CODEX_BASE_URL,
|
DEFAULT_CODEX_BASE_URL,
|
||||||
DEFAULT_QWEN_BASE_URL,
|
DEFAULT_QWEN_BASE_URL,
|
||||||
PROVIDER_REGISTRY,
|
PROVIDER_REGISTRY,
|
||||||
|
_agent_key_is_usable,
|
||||||
format_auth_error,
|
format_auth_error,
|
||||||
resolve_provider,
|
resolve_provider,
|
||||||
resolve_nous_runtime_credentials,
|
resolve_nous_runtime_credentials,
|
||||||
@@ -644,6 +645,21 @@ def resolve_runtime_provider(
|
|||||||
getattr(entry, "runtime_api_key", None)
|
getattr(entry, "runtime_api_key", None)
|
||||||
or getattr(entry, "access_token", "")
|
or getattr(entry, "access_token", "")
|
||||||
)
|
)
|
||||||
|
# For Nous, the pool entry's runtime_api_key is the agent_key — a
|
||||||
|
# short-lived inference credential (~30 min TTL). The pool doesn't
|
||||||
|
# refresh it during selection (that would trigger network calls in
|
||||||
|
# non-runtime contexts like `hermes auth list`). If the key is
|
||||||
|
# expired, clear pool_api_key so we fall through to
|
||||||
|
# resolve_nous_runtime_credentials() which handles refresh + mint.
|
||||||
|
if provider == "nous" and entry is not None and pool_api_key:
|
||||||
|
min_ttl = max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")))
|
||||||
|
nous_state = {
|
||||||
|
"agent_key": getattr(entry, "agent_key", None),
|
||||||
|
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
|
||||||
|
}
|
||||||
|
if not _agent_key_is_usable(nous_state, min_ttl):
|
||||||
|
logger.debug("Nous pool entry agent_key expired/missing, falling through to runtime resolution")
|
||||||
|
pool_api_key = ""
|
||||||
if entry is not None and pool_api_key:
|
if entry is not None and pool_api_key:
|
||||||
return _resolve_runtime_from_pool_entry(
|
return _resolve_runtime_from_pool_entry(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
|
|||||||
Reference in New Issue
Block a user