"""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 ( resolve_provider_client, _read_codex_access_token, _resolve_auto, get_available_vision_backends, call_llm, async_call_llm, ) from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic from agent.anthropic_aux import AnthropicAuxiliaryClient 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 hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic from agent.anthropic_aux import 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("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)): mock_build.return_value = MagicMock() from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic from agent.anthropic_aux import 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.credential_pool.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 hermes_agent_anthropic.resolve import resolve_auxiliary_client as _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("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)): mock_build.return_value = MagicMock() from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic client, model = _try_anthropic(explicit_api_key="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("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)): mock_build.return_value = MagicMock() from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _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("hermes_agent_anthropic.resolve._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="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"), \ patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \ patch("hermes_agent_anthropic.resolve._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", "eyJhbG...test.sig") # JWT → is_oauth=True 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="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"), ): 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="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"), ): 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": "fresh-token", "refresh_token": "refresh-token-2", "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