fix: preflight Anthropic auth and prefer Claude store
This commit is contained in:
parent
e052c74727
commit
70ea13eb40
6 changed files with 135 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1586,8 +1586,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()
|
||||||
|
|
@ -1596,6 +1618,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
|
||||||
|
|
|
||||||
13
run_agent.py
13
run_agent.py
|
|
@ -2645,6 +2645,11 @@ class AIAgent:
|
||||||
self._anthropic_api_key = new_token
|
self._anthropic_api_key = new_token
|
||||||
return True
|
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
|
||||||
|
|
@ -2661,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:
|
||||||
|
|
@ -3299,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,
|
||||||
|
|
@ -4050,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:
|
||||||
|
|
@ -4080,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:
|
||||||
|
|
|
||||||
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"] == ""
|
||||||
|
|
|
||||||
|
|
@ -2145,6 +2145,31 @@ class TestAnthropicCredentialRefresh:
|
||||||
old_client.close.assert_not_called()
|
old_client.close.assert_not_called()
|
||||||
rebuild.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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue