534 lines
24 KiB
Python
534 lines
24 KiB
Python
"""Tests for Anthropic-specific auxiliary client behaviour.
|
|
|
|
Covers:
|
|
- OAuth vs API-key flag propagation (_try_anthropic → AnthropicAuxiliaryClient)
|
|
- explicit_api_key propagation through resolve_provider_client → _try_anthropic
|
|
- Expired Codex token fallback to Anthropic
|
|
- Vision client fallback with Anthropic
|
|
- Auth refresh retry for Anthropic clients
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from agent.auxiliary_client import (
|
|
_try_anthropic,
|
|
AnthropicAuxiliaryClient,
|
|
resolve_provider_client,
|
|
_read_codex_access_token,
|
|
_resolve_auto,
|
|
get_available_vision_backends,
|
|
call_llm,
|
|
async_call_llm,
|
|
)
|
|
|
|
|
|
class TestAnthropicOAuthFlag:
|
|
"""Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client."""
|
|
|
|
def test_oauth_token_sets_flag(self, monkeypatch):
|
|
"""OAuth tokens (sk-ant-oat01-*) should create client with is_oauth=True."""
|
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-token")
|
|
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
|
|
mock_build.return_value = MagicMock()
|
|
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
|
|
client, model = _try_anthropic()
|
|
assert client is not None
|
|
assert isinstance(client, AnthropicAuxiliaryClient)
|
|
# The adapter inside should have is_oauth=True
|
|
adapter = client.chat.completions
|
|
assert adapter._is_oauth is True
|
|
|
|
def test_api_key_no_oauth_flag(self, monkeypatch):
|
|
"""Regular API keys (sk-ant-api-*) should create client with is_oauth=False."""
|
|
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant-api03-testkey1234"), \
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
|
|
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
|
mock_build.return_value = MagicMock()
|
|
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
|
|
client, model = _try_anthropic()
|
|
assert client is not None
|
|
assert isinstance(client, AnthropicAuxiliaryClient)
|
|
adapter = client.chat.completions
|
|
assert adapter._is_oauth is False
|
|
|
|
def test_pool_entry_takes_priority_over_legacy_resolution(self):
|
|
class _Entry:
|
|
access_token = "sk-ant-oat01-pooled"
|
|
base_url = "https://api.anthropic.com"
|
|
|
|
class _Pool:
|
|
def has_credentials(self):
|
|
return True
|
|
|
|
def select(self):
|
|
return _Entry()
|
|
|
|
with (
|
|
patch("agent.auxiliary_client.load_pool", return_value=_Pool()),
|
|
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", side_effect=AssertionError("legacy path should not run")),
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()) as mock_build,
|
|
):
|
|
from agent.auxiliary_client import _try_anthropic
|
|
|
|
client, model = _try_anthropic()
|
|
|
|
assert client is not None
|
|
assert model == "claude-haiku-4-5-20251001"
|
|
assert mock_build.call_args.args[0] == "sk-ant-oat01-pooled"
|
|
|
|
|
|
class TestAnthropicExplicitApiKey:
|
|
"""Test that explicit_api_key is correctly propagated to _try_anthropic().
|
|
|
|
Parity with the OpenRouter fix in #18768: resolve_provider_client() passes
|
|
explicit_api_key to _try_openrouter(), but the anthropic branch was not
|
|
updated — _try_anthropic() always fell back to resolve_anthropic_token()
|
|
even when an explicit key was supplied (e.g. from a fallback_model entry).
|
|
"""
|
|
|
|
def test_try_anthropic_uses_explicit_api_key_over_env(self):
|
|
"""_try_anthropic(explicit_api_key) must use the supplied key, not the env fallback."""
|
|
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
|
|
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
|
mock_build.return_value = MagicMock()
|
|
from agent.auxiliary_client import _try_anthropic
|
|
client, model = _try_anthropic("explicit-pool-key")
|
|
assert client is not None
|
|
assert mock_build.call_args.args[0] == "explicit-pool-key", (
|
|
f"Expected explicit_api_key to be passed, got: {mock_build.call_args.args[0]}"
|
|
)
|
|
assert mock_build.call_args.args[0] != "env-fallback-key"
|
|
|
|
def test_try_anthropic_without_explicit_key_falls_back_to_resolve(self):
|
|
"""Without explicit_api_key, _try_anthropic falls back to resolve_anthropic_token."""
|
|
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
|
|
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
|
mock_build.return_value = MagicMock()
|
|
from agent.auxiliary_client import _try_anthropic
|
|
client, model = _try_anthropic()
|
|
assert client is not None
|
|
assert mock_build.call_args.args[0] == "env-fallback-key"
|
|
|
|
def test_resolve_provider_client_passes_explicit_api_key_to_anthropic(self):
|
|
"""resolve_provider_client(provider='anthropic', explicit_api_key=...) must propagate the key."""
|
|
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-key"), \
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
|
|
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
|
mock_build.return_value = MagicMock()
|
|
client, model = resolve_provider_client(
|
|
provider="anthropic",
|
|
explicit_api_key="explicit-fallback-key",
|
|
)
|
|
assert client is not None
|
|
assert mock_build.call_args.args[0] == "explicit-fallback-key", (
|
|
"resolve_provider_client must forward explicit_api_key to _try_anthropic()"
|
|
)
|
|
|
|
|
|
class TestExpiredCodexFallback:
|
|
"""Test that expired Codex tokens don't block the auto chain."""
|
|
|
|
def test_expired_codex_falls_through_to_next(self, tmp_path, monkeypatch):
|
|
"""When Codex token is expired, auto chain should skip it and try next provider."""
|
|
import base64
|
|
import time as _time
|
|
|
|
# Expired Codex JWT
|
|
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
|
|
payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode()
|
|
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
|
|
expired_jwt = f"{header}.{payload}.fakesig"
|
|
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
(hermes_home / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {
|
|
"openai-codex": {
|
|
"tokens": {"access_token": expired_jwt, "refresh_token": "***"},
|
|
},
|
|
},
|
|
}))
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
# Set up Anthropic as fallback
|
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant...back")
|
|
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
|
|
mock_build.return_value = MagicMock()
|
|
client, model = _resolve_auto()
|
|
# Should NOT be Codex, should be Anthropic (or another available provider)
|
|
assert not isinstance(client, type(None)), "Should find a provider after expired Codex"
|
|
|
|
|
|
def test_expired_codex_openrouter_wins(self, tmp_path, monkeypatch):
|
|
"""With expired Codex + OpenRouter key, OpenRouter should win (1st in chain)."""
|
|
import base64
|
|
import time as _time
|
|
|
|
# Belt-and-suspenders: _try_openrouter marks openrouter unhealthy
|
|
# when OPENROUTER_API_KEY is absent (which the preceding test in
|
|
# this class exercises). The file-level _clean_env autouse fixture
|
|
# clears the cache, but fixture ordering with the conftest
|
|
# _hermetic_environment autouse can leave a narrow window where
|
|
# the mark reappears. Explicitly clear here so this test is
|
|
# independent of run order.
|
|
import agent.auxiliary_client as _aux_mod
|
|
_aux_mod._aux_unhealthy_until.clear()
|
|
_aux_mod._aux_unhealthy_logged_at.clear()
|
|
|
|
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
|
|
payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode()
|
|
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
|
|
expired_jwt = f"{header}.{payload}.fakesig"
|
|
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
(hermes_home / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {
|
|
"openai-codex": {
|
|
"tokens": {"access_token": expired_jwt, "refresh_token": "***"},
|
|
},
|
|
},
|
|
}))
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
|
|
|
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
|
mock_openai.return_value = MagicMock()
|
|
client, model = _resolve_auto()
|
|
assert client is not None
|
|
# OpenRouter is 1st in chain, should win
|
|
mock_openai.assert_called()
|
|
|
|
def test_expired_codex_custom_endpoint_wins(self, tmp_path, monkeypatch):
|
|
"""With expired Codex + custom endpoint (Ollama), custom should win (3rd in chain)."""
|
|
import base64
|
|
import time as _time
|
|
|
|
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
|
|
payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode()
|
|
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
|
|
expired_jwt = f"{header}.{payload}.fakesig"
|
|
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
(hermes_home / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {
|
|
"openai-codex": {
|
|
"tokens": {"access_token": expired_jwt, "refresh_token": "***"},
|
|
},
|
|
},
|
|
}))
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
# Simulate Ollama or custom endpoint
|
|
with patch("agent.auxiliary_client._resolve_custom_runtime",
|
|
return_value=("http://localhost:11434/v1", "sk-dummy")):
|
|
with patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
|
mock_openai.return_value = MagicMock()
|
|
client, model = _resolve_auto()
|
|
assert client is not None
|
|
|
|
|
|
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
|
|
"""OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*)."""
|
|
# Mock resolve_anthropic_token to return an OAuth-style token
|
|
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"), \
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
|
|
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
|
|
mock_build.return_value = MagicMock()
|
|
client, model = _try_anthropic()
|
|
assert client is not None, "Should resolve token"
|
|
adapter = client.chat.completions
|
|
assert adapter._is_oauth is True, "Non-sk-ant-api token should set is_oauth=True"
|
|
|
|
def test_jwt_missing_exp_passes_through(self, tmp_path, monkeypatch):
|
|
"""JWT with valid JSON but no exp claim should pass through."""
|
|
import base64
|
|
header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode()
|
|
payload_data = json.dumps({"sub": "user123"}).encode() # no exp
|
|
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
|
|
no_exp_jwt = f"{header}.{payload}.fakesig"
|
|
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
(hermes_home / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {
|
|
"openai-codex": {
|
|
"tokens": {"access_token": no_exp_jwt, "refresh_token": "***"},
|
|
},
|
|
},
|
|
}))
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
result = _read_codex_access_token()
|
|
assert result == no_exp_jwt, "JWT without exp should pass through"
|
|
|
|
def test_jwt_invalid_json_payload_passes_through(self, tmp_path, monkeypatch):
|
|
"""JWT with valid base64 but invalid JSON payload should pass through."""
|
|
import base64
|
|
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
|
|
payload = base64.urlsafe_b64encode(b"not-json-content").rstrip(b"=").decode()
|
|
bad_jwt = f"{header}.{payload}.fakesig"
|
|
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
(hermes_home / "auth.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"providers": {
|
|
"openai-codex": {
|
|
"tokens": {"access_token": bad_jwt, "refresh_token": "***"},
|
|
},
|
|
},
|
|
}))
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
result = _read_codex_access_token()
|
|
assert result == bad_jwt, "JWT with invalid JSON payload should pass through"
|
|
|
|
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
|
|
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant...oken")
|
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
|
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
|
|
mock_build.return_value = MagicMock()
|
|
client, model = _try_anthropic()
|
|
assert client is not None
|
|
adapter = client.chat.completions
|
|
assert adapter._is_oauth is True
|
|
|
|
|
|
class TestVisionClientFallback:
|
|
"""Vision client auto mode resolves known-good multimodal backends."""
|
|
|
|
def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch):
|
|
"""Active provider appears in available backends when credentials exist."""
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
|
|
with (
|
|
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
|
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
|
|
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
|
|
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"),
|
|
):
|
|
backends = get_available_vision_backends()
|
|
|
|
assert "anthropic" in backends
|
|
|
|
def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypatch):
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "***")
|
|
with (
|
|
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
|
|
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
|
|
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"),
|
|
):
|
|
client, model = resolve_provider_client("anthropic")
|
|
|
|
assert client is not None
|
|
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"
|
|
assert model == "claude-haiku-4-5-20251001"
|
|
|
|
|
|
class _AuxAuth401(Exception):
|
|
status_code = 401
|
|
|
|
def __init__(self, message="Provided authentication token is expired"):
|
|
super().__init__(message)
|
|
|
|
|
|
class _DummyResponse:
|
|
def __init__(self, text="ok"):
|
|
self.choices = [MagicMock(message=MagicMock(content=text))]
|
|
|
|
|
|
class _FailingThenSuccessCompletions:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def create(self, **kwargs):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
raise _AuxAuth401()
|
|
return _DummyResponse("sync-ok")
|
|
|
|
|
|
class _AsyncFailingThenSuccessCompletions:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
async def create(self, **kwargs):
|
|
self.calls += 1
|
|
if self.calls == 1:
|
|
raise _AuxAuth401()
|
|
return _DummyResponse("async-ok")
|
|
|
|
|
|
class TestAuxiliaryAuthRefreshRetry:
|
|
def test_call_llm_refreshes_codex_on_401_for_vision(self):
|
|
failing_client = MagicMock()
|
|
failing_client.base_url = "https://chatgpt.com/backend-api/codex"
|
|
failing_client.chat.completions = _FailingThenSuccessCompletions()
|
|
|
|
fresh_client = MagicMock()
|
|
fresh_client.base_url = "https://chatgpt.com/backend-api/codex"
|
|
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-sync")
|
|
|
|
with (
|
|
patch(
|
|
"agent.auxiliary_client.resolve_vision_provider_client",
|
|
side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")],
|
|
),
|
|
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
|
|
):
|
|
resp = call_llm(
|
|
task="vision",
|
|
provider="openai-codex",
|
|
model="gpt-5.4",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
|
|
assert resp.choices[0].message.content == "fresh-sync"
|
|
mock_refresh.assert_called_once_with("openai-codex")
|
|
|
|
def test_call_llm_refreshes_codex_on_401_for_non_vision(self):
|
|
stale_client = MagicMock()
|
|
stale_client.base_url = "https://chatgpt.com/backend-api/codex"
|
|
stale_client.chat.completions.create.side_effect = _AuxAuth401("stale codex token")
|
|
|
|
fresh_client = MagicMock()
|
|
fresh_client.base_url = "https://chatgpt.com/backend-api/codex"
|
|
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-non-vision")
|
|
|
|
with (
|
|
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("openai-codex", "gpt-5.4", None, None, None)),
|
|
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.4"), (fresh_client, "gpt-5.4")]),
|
|
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
|
|
):
|
|
resp = call_llm(
|
|
task="compression",
|
|
provider="openai-codex",
|
|
model="gpt-5.4",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
|
|
assert resp.choices[0].message.content == "fresh-non-vision"
|
|
mock_refresh.assert_called_once_with("openai-codex")
|
|
assert stale_client.chat.completions.create.call_count == 1
|
|
assert fresh_client.chat.completions.create.call_count == 1
|
|
|
|
def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
|
|
stale_client = MagicMock()
|
|
stale_client.base_url = "https://api.anthropic.com"
|
|
stale_client.chat.completions.create.side_effect = _AuxAuth401("anthropic token expired")
|
|
|
|
fresh_client = MagicMock()
|
|
fresh_client.base_url = "https://api.anthropic.com"
|
|
fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-anthropic")
|
|
|
|
with (
|
|
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)),
|
|
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]),
|
|
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
|
|
):
|
|
resp = call_llm(
|
|
task="compression",
|
|
provider="anthropic",
|
|
model="claude-haiku-4-5-20251001",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
|
|
assert resp.choices[0].message.content == "fresh-anthropic"
|
|
mock_refresh.assert_called_once_with("anthropic")
|
|
assert stale_client.chat.completions.create.call_count == 1
|
|
assert fresh_client.chat.completions.create.call_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_call_llm_refreshes_codex_on_401_for_vision(self):
|
|
failing_client = MagicMock()
|
|
failing_client.base_url = "https://chatgpt.com/backend-api/codex"
|
|
failing_client.chat.completions = _AsyncFailingThenSuccessCompletions()
|
|
|
|
fresh_client = MagicMock()
|
|
fresh_client.base_url = "https://chatgpt.com/backend-api/codex"
|
|
fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async"))
|
|
|
|
with (
|
|
patch(
|
|
"agent.auxiliary_client.resolve_vision_provider_client",
|
|
side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")],
|
|
),
|
|
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
|
|
):
|
|
resp = await async_call_llm(
|
|
task="vision",
|
|
provider="openai-codex",
|
|
model="gpt-5.4",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
|
|
assert resp.choices[0].message.content == "fresh-async"
|
|
mock_refresh.assert_called_once_with("openai-codex")
|
|
|
|
def test_refresh_provider_credentials_force_refreshes_anthropic_oauth_and_evicts_cache(self, monkeypatch):
|
|
stale_client = MagicMock()
|
|
cache_key = ("anthropic", False, None, None, None)
|
|
|
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "")
|
|
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "")
|
|
|
|
with (
|
|
patch("agent.auxiliary_client._client_cache", {cache_key: (stale_client, "claude-haiku-4-5-20251001", None)}),
|
|
patch("hermes_agent_anthropic.adapter.read_claude_code_credentials", return_value={
|
|
"accessToken": "expired-token",
|
|
"refreshToken": "refresh-token",
|
|
"expiresAt": 0,
|
|
}),
|
|
patch("hermes_agent_anthropic.adapter.refresh_anthropic_oauth_pure", return_value={
|
|
"access_token": "***",
|
|
"refresh_token": "***",
|
|
"expires_at_ms": 9999999999999,
|
|
}) as mock_refresh_oauth,
|
|
patch("hermes_agent_anthropic.adapter._write_claude_code_credentials") as mock_write,
|
|
):
|
|
from agent.auxiliary_client import _refresh_provider_credentials
|
|
|
|
assert _refresh_provider_credentials("anthropic") is True
|
|
|
|
mock_refresh_oauth.assert_called_once_with("refresh-token", use_json=False)
|
|
mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999)
|
|
stale_client.close.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self):
|
|
stale_client = MagicMock()
|
|
stale_client.base_url = "https://api.anthropic.com"
|
|
stale_client.chat.completions.create = AsyncMock(side_effect=_AuxAuth401("anthropic token expired"))
|
|
|
|
fresh_client = MagicMock()
|
|
fresh_client.base_url = "https://api.anthropic.com"
|
|
fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async-anthropic"))
|
|
|
|
with (
|
|
patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)),
|
|
patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]),
|
|
patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh,
|
|
):
|
|
resp = await async_call_llm(
|
|
task="compression",
|
|
provider="anthropic",
|
|
model="claude-haiku-4-5-20251001",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
|
|
assert resp.choices[0].message.content == "fresh-async-anthropic"
|
|
mock_refresh.assert_called_once_with("anthropic")
|
|
assert stale_client.chat.completions.create.await_count == 1
|
|
assert fresh_client.chat.completions.create.await_count == 1
|