Merge pull request #1360 from NousResearch/hermes/hermes-aa701810
fix: refresh Anthropic OAuth before stale env tokens
This commit is contained in:
commit
fc5443d854
12 changed files with 351 additions and 35 deletions
|
|
@ -236,6 +236,43 @@ def _write_claude_code_credentials(access_token: str, refresh_token: str, expire
|
||||||
logger.debug("Failed to write refreshed credentials: %s", e)
|
logger.debug("Failed to write refreshed credentials: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||||
|
"""Resolve a token from Claude Code credential files, refreshing if needed."""
|
||||||
|
creds = creds or read_claude_code_credentials()
|
||||||
|
if creds and is_claude_code_token_valid(creds):
|
||||||
|
logger.debug("Using Claude Code credentials (auto-detected)")
|
||||||
|
return creds["accessToken"]
|
||||||
|
if creds:
|
||||||
|
logger.debug("Claude Code credentials expired — attempting refresh")
|
||||||
|
refreshed = _refresh_oauth_token(creds)
|
||||||
|
if refreshed:
|
||||||
|
return refreshed
|
||||||
|
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
"""Prefer Claude Code creds when a persisted env OAuth token would shadow refresh.
|
||||||
|
|
||||||
|
Hermes historically persisted setup tokens into ANTHROPIC_TOKEN. That makes
|
||||||
|
later refresh impossible because the static env token wins before we ever
|
||||||
|
inspect Claude Code's refreshable credential file. If we have a refreshable
|
||||||
|
Claude Code credential record, prefer it over the static env OAuth token.
|
||||||
|
"""
|
||||||
|
if not env_token or not _is_oauth_token(env_token) or not isinstance(creds, dict):
|
||||||
|
return None
|
||||||
|
if not creds.get("refreshToken"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
resolved = _resolve_claude_code_token_from_credentials(creds)
|
||||||
|
if resolved and resolved != env_token:
|
||||||
|
logger.debug(
|
||||||
|
"Preferring Claude Code credential file over static env OAuth token so refresh can proceed"
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def resolve_anthropic_token() -> Optional[str]:
|
def resolve_anthropic_token() -> Optional[str]:
|
||||||
"""Resolve an Anthropic token from all available sources.
|
"""Resolve an Anthropic token from all available sources.
|
||||||
|
|
||||||
|
|
@ -248,28 +285,28 @@ def resolve_anthropic_token() -> Optional[str]:
|
||||||
|
|
||||||
Returns the token string or None.
|
Returns the token string or None.
|
||||||
"""
|
"""
|
||||||
|
creds = read_claude_code_credentials()
|
||||||
|
|
||||||
# 1. Hermes-managed OAuth/setup token env var
|
# 1. Hermes-managed OAuth/setup token env var
|
||||||
token = os.getenv("ANTHROPIC_TOKEN", "").strip()
|
token = os.getenv("ANTHROPIC_TOKEN", "").strip()
|
||||||
if token:
|
if token:
|
||||||
|
preferred = _prefer_refreshable_claude_code_token(token, creds)
|
||||||
|
if preferred:
|
||||||
|
return preferred
|
||||||
return token
|
return token
|
||||||
|
|
||||||
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
# 2. CLAUDE_CODE_OAUTH_TOKEN (used by Claude Code for setup-tokens)
|
||||||
cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
cc_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
||||||
if cc_token:
|
if cc_token:
|
||||||
|
preferred = _prefer_refreshable_claude_code_token(cc_token, creds)
|
||||||
|
if preferred:
|
||||||
|
return preferred
|
||||||
return cc_token
|
return cc_token
|
||||||
|
|
||||||
# 3. Claude Code credential file
|
# 3. Claude Code credential file
|
||||||
creds = read_claude_code_credentials()
|
resolved_claude_token = _resolve_claude_code_token_from_credentials(creds)
|
||||||
if creds and is_claude_code_token_valid(creds):
|
if resolved_claude_token:
|
||||||
logger.debug("Using Claude Code credentials (auto-detected)")
|
return resolved_claude_token
|
||||||
return creds["accessToken"]
|
|
||||||
elif creds:
|
|
||||||
# Token expired — attempt to refresh
|
|
||||||
logger.debug("Claude Code credentials expired — attempting refresh")
|
|
||||||
refreshed = _refresh_oauth_token(creds)
|
|
||||||
if refreshed:
|
|
||||||
return refreshed
|
|
||||||
logger.debug("Token refresh failed — re-run 'claude setup-token' to reauthenticate")
|
|
||||||
|
|
||||||
# 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
# 4. Regular API key, or a legacy OAuth token saved in ANTHROPIC_API_KEY.
|
||||||
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
# This remains as a compatibility fallback for pre-migration Hermes configs.
|
||||||
|
|
|
||||||
|
|
@ -1092,6 +1092,13 @@ def save_anthropic_oauth_token(value: str, save_fn=None):
|
||||||
writer("ANTHROPIC_API_KEY", "")
|
writer("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
def use_anthropic_claude_code_credentials(save_fn=None):
|
||||||
|
"""Use Claude Code's own credential files instead of persisting env tokens."""
|
||||||
|
writer = save_fn or save_env_value
|
||||||
|
writer("ANTHROPIC_TOKEN", "")
|
||||||
|
writer("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
def save_anthropic_api_key(value: str, save_fn=None):
|
def save_anthropic_api_key(value: str, save_fn=None):
|
||||||
"""Persist an Anthropic API key and clear the OAuth/setup-token slot."""
|
"""Persist an Anthropic API key and clear the OAuth/setup-token slot."""
|
||||||
writer = save_fn or save_env_value
|
writer = save_fn or save_env_value
|
||||||
|
|
|
||||||
|
|
@ -1591,8 +1591,30 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||||
|
|
||||||
def _run_anthropic_oauth_flow(save_env_value):
|
def _run_anthropic_oauth_flow(save_env_value):
|
||||||
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
|
"""Run the Claude OAuth setup-token flow. Returns True if credentials were saved."""
|
||||||
from agent.anthropic_adapter import run_oauth_setup_token
|
from agent.anthropic_adapter import (
|
||||||
from hermes_cli.config import save_anthropic_oauth_token
|
run_oauth_setup_token,
|
||||||
|
read_claude_code_credentials,
|
||||||
|
is_claude_code_token_valid,
|
||||||
|
)
|
||||||
|
from hermes_cli.config import (
|
||||||
|
save_anthropic_oauth_token,
|
||||||
|
use_anthropic_claude_code_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _activate_claude_code_credentials_if_available() -> bool:
|
||||||
|
try:
|
||||||
|
creds = read_claude_code_credentials()
|
||||||
|
except Exception:
|
||||||
|
creds = None
|
||||||
|
if creds and (
|
||||||
|
is_claude_code_token_valid(creds)
|
||||||
|
or bool(creds.get("refreshToken"))
|
||||||
|
):
|
||||||
|
use_anthropic_claude_code_credentials(save_fn=save_env_value)
|
||||||
|
print(" ✓ Claude Code credentials linked.")
|
||||||
|
print(" Hermes will use Claude's credential store directly instead of copying a setup-token into ~/.hermes/.env.")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print()
|
print()
|
||||||
|
|
@ -1601,6 +1623,8 @@ def _run_anthropic_oauth_flow(save_env_value):
|
||||||
print()
|
print()
|
||||||
token = run_oauth_setup_token()
|
token = run_oauth_setup_token()
|
||||||
if token:
|
if token:
|
||||||
|
if _activate_claude_code_credentials_if_available():
|
||||||
|
return True
|
||||||
save_anthropic_oauth_token(token, save_fn=save_env_value)
|
save_anthropic_oauth_token(token, save_fn=save_env_value)
|
||||||
print(" ✓ OAuth credentials saved.")
|
print(" ✓ OAuth credentials saved.")
|
||||||
return True
|
return True
|
||||||
|
|
|
||||||
53
run_agent.py
53
run_agent.py
|
|
@ -2613,6 +2613,43 @@ class AIAgent:
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _try_refresh_anthropic_client_credentials(self) -> bool:
|
||||||
|
if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client
|
||||||
|
|
||||||
|
new_token = resolve_anthropic_token()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Anthropic credential refresh failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(new_token, str) or not new_token.strip():
|
||||||
|
return False
|
||||||
|
new_token = new_token.strip()
|
||||||
|
if new_token == self._anthropic_api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._anthropic_client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._anthropic_client = build_anthropic_client(new_token, getattr(self, "_anthropic_base_url", None))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to rebuild Anthropic client after credential refresh: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._anthropic_api_key = new_token
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _anthropic_messages_create(self, api_kwargs: dict):
|
||||||
|
if self.api_mode == "anthropic_messages":
|
||||||
|
self._try_refresh_anthropic_client_credentials()
|
||||||
|
return self._anthropic_client.messages.create(**api_kwargs)
|
||||||
|
|
||||||
def _interruptible_api_call(self, api_kwargs: dict):
|
def _interruptible_api_call(self, api_kwargs: dict):
|
||||||
"""
|
"""
|
||||||
Run the API call in a background thread so the main conversation loop
|
Run the API call in a background thread so the main conversation loop
|
||||||
|
|
@ -2629,7 +2666,7 @@ class AIAgent:
|
||||||
if self.api_mode == "codex_responses":
|
if self.api_mode == "codex_responses":
|
||||||
result["response"] = self._run_codex_stream(api_kwargs)
|
result["response"] = self._run_codex_stream(api_kwargs)
|
||||||
elif self.api_mode == "anthropic_messages":
|
elif self.api_mode == "anthropic_messages":
|
||||||
result["response"] = self._anthropic_client.messages.create(**api_kwargs)
|
result["response"] = self._anthropic_messages_create(api_kwargs)
|
||||||
else:
|
else:
|
||||||
result["response"] = self.client.chat.completions.create(**api_kwargs)
|
result["response"] = self.client.chat.completions.create(**api_kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -3267,7 +3304,7 @@ class AIAgent:
|
||||||
tools=[memory_tool_def], max_tokens=5120,
|
tools=[memory_tool_def], max_tokens=5120,
|
||||||
reasoning_config=None,
|
reasoning_config=None,
|
||||||
)
|
)
|
||||||
response = self._anthropic_client.messages.create(**ant_kwargs)
|
response = self._anthropic_messages_create(ant_kwargs)
|
||||||
elif not _aux_available:
|
elif not _aux_available:
|
||||||
api_kwargs = {
|
api_kwargs = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
|
|
@ -4018,7 +4055,7 @@ class AIAgent:
|
||||||
from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar
|
from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar
|
||||||
_ant_kw = _bak(model=self.model, messages=api_messages, tools=None,
|
_ant_kw = _bak(model=self.model, messages=api_messages, tools=None,
|
||||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config)
|
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config)
|
||||||
summary_response = self._anthropic_client.messages.create(**_ant_kw)
|
summary_response = self._anthropic_messages_create(_ant_kw)
|
||||||
_msg, _ = _nar(summary_response)
|
_msg, _ = _nar(summary_response)
|
||||||
final_response = (_msg.content or "").strip()
|
final_response = (_msg.content or "").strip()
|
||||||
else:
|
else:
|
||||||
|
|
@ -4048,7 +4085,7 @@ class AIAgent:
|
||||||
from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2
|
from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2
|
||||||
_ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None,
|
_ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None,
|
||||||
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config)
|
max_tokens=self.max_tokens, reasoning_config=self.reasoning_config)
|
||||||
retry_response = self._anthropic_client.messages.create(**_ant_kw2)
|
retry_response = self._anthropic_messages_create(_ant_kw2)
|
||||||
_retry_msg, _ = _nar2(retry_response)
|
_retry_msg, _ = _nar2(retry_response)
|
||||||
final_response = (_retry_msg.content or "").strip()
|
final_response = (_retry_msg.content or "").strip()
|
||||||
else:
|
else:
|
||||||
|
|
@ -4822,12 +4859,8 @@ class AIAgent:
|
||||||
and not anthropic_auth_retry_attempted
|
and not anthropic_auth_retry_attempted
|
||||||
):
|
):
|
||||||
anthropic_auth_retry_attempted = True
|
anthropic_auth_retry_attempted = True
|
||||||
# Try re-reading Claude Code credentials (they may have been refreshed)
|
from agent.anthropic_adapter import _is_oauth_token
|
||||||
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client, _is_oauth_token
|
if self._try_refresh_anthropic_client_credentials():
|
||||||
new_token = resolve_anthropic_token()
|
|
||||||
if new_token and new_token != self._anthropic_api_key:
|
|
||||||
self._anthropic_api_key = new_token
|
|
||||||
self._anthropic_client = build_anthropic_client(new_token, getattr(self, "_anthropic_base_url", None))
|
|
||||||
print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
|
print(f"{self.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...")
|
||||||
continue
|
continue
|
||||||
# Credential refresh didn't help — show diagnostic info
|
# Credential refresh didn't help — show diagnostic info
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,33 @@ class TestResolveAnthropicToken:
|
||||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
assert resolve_anthropic_token() == "cc-auto-token"
|
assert resolve_anthropic_token() == "cc-auto-token"
|
||||||
|
|
||||||
|
def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token")
|
||||||
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||||
|
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||||
|
cred_file.parent.mkdir(parents=True)
|
||||||
|
cred_file.write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "cc-auto-token",
|
||||||
|
"refreshToken": "refresh-token",
|
||||||
|
"expiresAt": int(time.time() * 1000) + 3600_000,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
assert resolve_anthropic_token() == "cc-auto-token"
|
||||||
|
|
||||||
|
def test_keeps_static_anthropic_token_when_only_non_refreshable_claude_key_exists(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-static-token")
|
||||||
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||||
|
claude_json = tmp_path / ".claude.json"
|
||||||
|
claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"}))
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
assert resolve_anthropic_token() == "sk-ant-oat01-static-token"
|
||||||
|
|
||||||
|
|
||||||
class TestRefreshOauthToken:
|
class TestRefreshOauthToken:
|
||||||
def test_returns_none_without_refresh_token(self):
|
def test_returns_none_without_refresh_token(self):
|
||||||
|
|
@ -279,6 +306,27 @@ class TestResolveWithRefresh:
|
||||||
|
|
||||||
assert result == "refreshed-token"
|
assert result == "refreshed-token"
|
||||||
|
|
||||||
|
def test_static_env_oauth_token_does_not_block_refreshable_claude_creds(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-expired-env-token")
|
||||||
|
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
|
||||||
|
|
||||||
|
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||||
|
cred_file.parent.mkdir(parents=True)
|
||||||
|
cred_file.write_text(json.dumps({
|
||||||
|
"claudeAiOauth": {
|
||||||
|
"accessToken": "expired-claude-creds-token",
|
||||||
|
"refreshToken": "valid-refresh",
|
||||||
|
"expiresAt": int(time.time() * 1000) - 3600_000,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
||||||
|
with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"):
|
||||||
|
result = resolve_anthropic_token()
|
||||||
|
|
||||||
|
assert result == "refreshed-token"
|
||||||
|
|
||||||
|
|
||||||
class TestRunOauthSetupToken:
|
class TestRunOauthSetupToken:
|
||||||
def test_raises_when_claude_not_installed(self, monkeypatch):
|
def test_raises_when_claude_not_installed(self, monkeypatch):
|
||||||
|
|
|
||||||
51
tests/test_anthropic_oauth_flow.py
Normal file
51
tests/test_anthropic_oauth_flow.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Tests for Anthropic OAuth setup flow behavior."""
|
||||||
|
|
||||||
|
from hermes_cli.config import load_env, save_env_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agent.anthropic_adapter.run_oauth_setup_token",
|
||||||
|
lambda: "sk-ant-oat01-from-claude-setup",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agent.anthropic_adapter.read_claude_code_credentials",
|
||||||
|
lambda: {
|
||||||
|
"accessToken": "cc-access-token",
|
||||||
|
"refreshToken": "cc-refresh-token",
|
||||||
|
"expiresAt": 9999999999999,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agent.anthropic_adapter.is_claude_code_token_valid",
|
||||||
|
lambda creds: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
from hermes_cli.main import _run_anthropic_oauth_flow
|
||||||
|
|
||||||
|
save_env_value("ANTHROPIC_TOKEN", "stale-env-token")
|
||||||
|
assert _run_anthropic_oauth_flow(save_env_value) is True
|
||||||
|
|
||||||
|
env_vars = load_env()
|
||||||
|
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||||
|
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "Claude Code credentials linked" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None)
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
|
||||||
|
monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False)
|
||||||
|
monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
|
||||||
|
|
||||||
|
from hermes_cli.main import _run_anthropic_oauth_flow
|
||||||
|
|
||||||
|
assert _run_anthropic_oauth_flow(save_env_value) is True
|
||||||
|
|
||||||
|
env_vars = load_env()
|
||||||
|
assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token"
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "Setup-token saved" in output
|
||||||
|
|
@ -17,6 +17,21 @@ def test_save_anthropic_oauth_token_uses_token_slot_and_clears_api_key(tmp_path,
|
||||||
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_use_anthropic_claude_code_credentials_clears_env_slots(tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "hermes"
|
||||||
|
home.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
|
|
||||||
|
from hermes_cli.config import save_anthropic_oauth_token, use_anthropic_claude_code_credentials
|
||||||
|
|
||||||
|
save_anthropic_oauth_token("sk-ant-oat01-token")
|
||||||
|
use_anthropic_claude_code_credentials()
|
||||||
|
|
||||||
|
env_vars = load_env()
|
||||||
|
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||||
|
assert env_vars["ANTHROPIC_API_KEY"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch):
|
def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, monkeypatch):
|
||||||
home = tmp_path / "hermes"
|
home = tmp_path / "hermes"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
|
|
@ -24,8 +39,8 @@ def test_save_anthropic_api_key_uses_api_key_slot_and_clears_token(tmp_path, mon
|
||||||
|
|
||||||
from hermes_cli.config import save_anthropic_api_key
|
from hermes_cli.config import save_anthropic_api_key
|
||||||
|
|
||||||
save_anthropic_api_key("sk-ant-api03-test-key")
|
save_anthropic_api_key("sk-ant-api03-key")
|
||||||
|
|
||||||
env_vars = load_env()
|
env_vars = load_env()
|
||||||
assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-test-key"
|
assert env_vars["ANTHROPIC_API_KEY"] == "sk-ant-api03-key"
|
||||||
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
assert env_vars["ANTHROPIC_TOKEN"] == ""
|
||||||
|
|
|
||||||
|
|
@ -2085,6 +2085,92 @@ class TestAnthropicBaseUrlPassthrough:
|
||||||
assert not passed_url or passed_url is None
|
assert not passed_url or passed_url is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnthropicCredentialRefresh:
|
||||||
|
def test_try_refresh_anthropic_client_credentials_rebuilds_client(self):
|
||||||
|
with (
|
||||||
|
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||||
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||||
|
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build,
|
||||||
|
):
|
||||||
|
old_client = MagicMock()
|
||||||
|
new_client = MagicMock()
|
||||||
|
mock_build.side_effect = [old_client, new_client]
|
||||||
|
agent = AIAgent(
|
||||||
|
api_key="sk-ant-oat01-stale-token",
|
||||||
|
api_mode="anthropic_messages",
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
agent._anthropic_client = old_client
|
||||||
|
agent._anthropic_api_key = "sk-ant-oat01-stale-token"
|
||||||
|
agent._anthropic_base_url = "https://api.anthropic.com"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-fresh-token"),
|
||||||
|
patch("agent.anthropic_adapter.build_anthropic_client", return_value=new_client) as rebuild,
|
||||||
|
):
|
||||||
|
assert agent._try_refresh_anthropic_client_credentials() is True
|
||||||
|
|
||||||
|
old_client.close.assert_called_once()
|
||||||
|
rebuild.assert_called_once_with("sk-ant-oat01-fresh-token", "https://api.anthropic.com")
|
||||||
|
assert agent._anthropic_client is new_client
|
||||||
|
assert agent._anthropic_api_key == "sk-ant-oat01-fresh-token"
|
||||||
|
|
||||||
|
def test_try_refresh_anthropic_client_credentials_returns_false_when_token_unchanged(self):
|
||||||
|
with (
|
||||||
|
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||||
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||||
|
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
||||||
|
):
|
||||||
|
agent = AIAgent(
|
||||||
|
api_key="sk-ant-oat01-same-token",
|
||||||
|
api_mode="anthropic_messages",
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
old_client = MagicMock()
|
||||||
|
agent._anthropic_client = old_client
|
||||||
|
agent._anthropic_api_key = "sk-ant-oat01-same-token"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-same-token"),
|
||||||
|
patch("agent.anthropic_adapter.build_anthropic_client") as rebuild,
|
||||||
|
):
|
||||||
|
assert agent._try_refresh_anthropic_client_credentials() is False
|
||||||
|
|
||||||
|
old_client.close.assert_not_called()
|
||||||
|
rebuild.assert_not_called()
|
||||||
|
|
||||||
|
def test_anthropic_messages_create_preflights_refresh(self):
|
||||||
|
with (
|
||||||
|
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||||
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||||
|
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
|
||||||
|
):
|
||||||
|
agent = AIAgent(
|
||||||
|
api_key="sk-ant-oat01-current-token",
|
||||||
|
api_mode="anthropic_messages",
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = SimpleNamespace(content=[])
|
||||||
|
agent._anthropic_client = MagicMock()
|
||||||
|
agent._anthropic_client.messages.create.return_value = response
|
||||||
|
|
||||||
|
with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=True) as refresh:
|
||||||
|
result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"})
|
||||||
|
|
||||||
|
refresh.assert_called_once_with()
|
||||||
|
agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514")
|
||||||
|
assert result is response
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# _streaming_api_call tests
|
# _streaming_api_call tests
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,13 @@ When provider resolution selects `anthropic`, Hermes uses:
|
||||||
- the native Anthropic Messages API
|
- the native Anthropic Messages API
|
||||||
- `agent/anthropic_adapter.py` for translation
|
- `agent/anthropic_adapter.py` for translation
|
||||||
|
|
||||||
|
Credential resolution for native Anthropic now prefers refreshable Claude Code credentials over copied env tokens when both are present. In practice that means:
|
||||||
|
|
||||||
|
- Claude Code credential files are treated as the preferred source when they include refreshable auth
|
||||||
|
- manual `ANTHROPIC_TOKEN` / `CLAUDE_CODE_OAUTH_TOKEN` values still work as explicit overrides
|
||||||
|
- Hermes preflights Anthropic credential refresh before native Messages API calls
|
||||||
|
- Hermes still retries once on a 401 after rebuilding the Anthropic client, as a fallback path
|
||||||
|
|
||||||
## OpenAI Codex path
|
## OpenAI Codex path
|
||||||
|
|
||||||
Codex uses a separate Responses API path:
|
Codex uses a separate Responses API path:
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ hermes setup # Or configure everything at once
|
||||||
|----------|-----------|---------------|
|
|----------|-----------|---------------|
|
||||||
| **Nous Portal** | Subscription-based, zero-config | OAuth login via `hermes model` |
|
| **Nous Portal** | Subscription-based, zero-config | OAuth login via `hermes model` |
|
||||||
| **OpenAI Codex** | ChatGPT OAuth, uses Codex models | Device code auth via `hermes model` |
|
| **OpenAI Codex** | ChatGPT OAuth, uses Codex models | Device code auth via `hermes model` |
|
||||||
| **Anthropic** | Claude models directly (Pro/Max or API key) | API key or Claude Code setup-token |
|
| **Anthropic** | Claude models directly (Pro/Max or API key) | `hermes model` with Claude Code auth, or an Anthropic API key |
|
||||||
| **OpenRouter** | Multi-provider routing across many models | Enter your API key |
|
| **OpenRouter** | Multi-provider routing across many models | Enter your API key |
|
||||||
| **Z.AI** | GLM / Zhipu-hosted models | Set `GLM_API_KEY` / `ZAI_API_KEY` |
|
| **Z.AI** | GLM / Zhipu-hosted models | Set `GLM_API_KEY` / `ZAI_API_KEY` |
|
||||||
| **Kimi / Moonshot** | Moonshot-hosted coding and chat models | Set `KIMI_API_KEY` |
|
| **Kimi / Moonshot** | Moonshot-hosted coding and chat models | Set `KIMI_API_KEY` |
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||||
| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/v1`) |
|
| `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/v1`) |
|
||||||
| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) |
|
| `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) |
|
||||||
| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/v1`) |
|
| `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/v1`) |
|
||||||
| `ANTHROPIC_API_KEY` | Anthropic API key or setup-token ([console.anthropic.com](https://console.anthropic.com/)) |
|
| `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) |
|
||||||
| `ANTHROPIC_TOKEN` | Anthropic OAuth/setup token (alternative to `ANTHROPIC_API_KEY`) |
|
| `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override |
|
||||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code setup-token (same as `ANTHROPIC_TOKEN`) |
|
| `CLAUDE_CODE_OAUTH_TOKEN` | Explicit Claude Code token override if you export one manually |
|
||||||
| `HERMES_MODEL` | Preferred model name (checked before `LLM_MODEL`, used by gateway) |
|
| `HERMES_MODEL` | Preferred model name (checked before `LLM_MODEL`, used by gateway) |
|
||||||
| `LLM_MODEL` | Default model name (fallback when not set in config.yaml) |
|
| `LLM_MODEL` | Default model name (fallback when not set in config.yaml) |
|
||||||
| `VOICE_TOOLS_OPENAI_KEY` | OpenAI key for OpenAI speech-to-text and text-to-speech providers |
|
| `VOICE_TOOLS_OPENAI_KEY` | OpenAI key for OpenAI speech-to-text and text-to-speech providers |
|
||||||
|
|
@ -36,6 +36,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||||
|
|
||||||
## Provider Auth (OAuth)
|
## Provider Auth (OAuth)
|
||||||
|
|
||||||
|
For native Anthropic auth, Hermes prefers Claude Code's own credential files when they exist because those credentials can refresh automatically. Environment variables such as `ANTHROPIC_TOKEN` remain useful as manual overrides, but they are no longer the preferred path for Claude Pro/Max login.
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) |
|
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) |
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| **Nous Portal** | `hermes model` (OAuth, subscription-based) |
|
| **Nous Portal** | `hermes model` (OAuth, subscription-based) |
|
||||||
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
|
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
|
||||||
| **Anthropic** | `hermes model` (API key, setup-token, or Claude Code auto-detect) |
|
| **Anthropic** | `hermes model` (Claude Pro/Max via Claude Code auth, Anthropic API key, or manual setup-token) |
|
||||||
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
|
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
|
||||||
| **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) |
|
| **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) |
|
||||||
| **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) |
|
| **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) |
|
||||||
|
|
@ -85,17 +85,23 @@ Use Claude models directly through the Anthropic API — no OpenRouter proxy nee
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# With an API key (pay-per-token)
|
# With an API key (pay-per-token)
|
||||||
export ANTHROPIC_API_KEY=sk-ant-api03-...
|
export ANTHROPIC_API_KEY=***
|
||||||
hermes chat --provider anthropic --model claude-sonnet-4-6
|
hermes chat --provider anthropic --model claude-sonnet-4-6
|
||||||
|
|
||||||
# With a Claude Code setup-token (Pro/Max subscription)
|
# Preferred: authenticate through `hermes model`
|
||||||
export ANTHROPIC_API_KEY=sk-ant-oat01-... # from 'claude setup-token'
|
# Hermes will use Claude Code's credential store directly when available
|
||||||
|
hermes model
|
||||||
|
|
||||||
|
# Manual override with a setup-token (fallback / legacy)
|
||||||
|
export ANTHROPIC_TOKEN=*** # setup-token or manual OAuth token
|
||||||
hermes chat --provider anthropic
|
hermes chat --provider anthropic
|
||||||
|
|
||||||
# Auto-detect Claude Code credentials (if you have Claude Code installed)
|
# Auto-detect Claude Code credentials (if you already use Claude Code)
|
||||||
hermes chat --provider anthropic # reads ~/.claude.json automatically
|
hermes chat --provider anthropic # reads Claude Code credential files automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When you choose Anthropic OAuth through `hermes model`, Hermes prefers Claude Code's own credential store over copying the token into `~/.hermes/.env`. That keeps refreshable Claude credentials refreshable.
|
||||||
|
|
||||||
Or set it permanently:
|
Or set it permanently:
|
||||||
```yaml
|
```yaml
|
||||||
model:
|
model:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue