From db9e512424c8e87b6c86b1386804772454a5346a Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:44:39 -0700 Subject: [PATCH] fix: fall back from managed Anthropic keys --- agent/anthropic_adapter.py | 27 +++++++++++ run_agent.py | 42 ++++++++++++++++- tests/test_anthropic_adapter.py | 29 ++++++++++-- tests/test_run_agent.py | 80 +++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ad3dfe582..1b7bbe468 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -121,6 +121,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: "accessToken": primary_key, "refreshToken": "", "expiresAt": 0, # Managed keys don't have a user-visible expiry + "source": "claude_json_primary_api_key", } except (json.JSONDecodeError, OSError, IOError) as e: logger.debug("Failed to read ~/.claude.json: %s", e) @@ -138,6 +139,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: "accessToken": access_token, "refreshToken": oauth_data.get("refreshToken", ""), "expiresAt": oauth_data.get("expiresAt", 0), + "source": "claude_code_credentials_file", } except (json.JSONDecodeError, OSError, IOError) as e: logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) @@ -273,6 +275,31 @@ def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[s return None +def get_anthropic_token_source(token: Optional[str] = None) -> str: + """Best-effort source classification for an Anthropic credential token.""" + token = (token or "").strip() + if not token: + return "none" + + env_token = os.getenv("ANTHROPIC_TOKEN", "").strip() + if env_token and env_token == token: + return "anthropic_token_env" + + cc_env_token = os.getenv("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + if cc_env_token and cc_env_token == token: + return "claude_code_oauth_token_env" + + creds = read_claude_code_credentials() + if creds and creds.get("accessToken") == token: + return str(creds.get("source") or "claude_code_credentials") + + api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() + if api_key and api_key == token: + return "anthropic_api_key_env" + + return "unknown" + + def resolve_anthropic_token() -> Optional[str]: """Resolve an Anthropic token from all available sources. diff --git a/run_agent.py b/run_agent.py index 419b56929..1264de0f8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -511,9 +511,14 @@ class AIAgent: self._anthropic_client = None if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_anthropic_token, + get_anthropic_token_source, + ) effective_key = api_key or resolve_anthropic_token() or "" self._anthropic_api_key = effective_key + self._anthropic_auth_source = get_anthropic_token_source(effective_key) self._anthropic_base_url = base_url self._anthropic_client = build_anthropic_client(effective_key, base_url) # No OpenAI client needed for Anthropic mode @@ -2643,6 +2648,27 @@ class AIAgent: return False self._anthropic_api_key = new_token + try: + from agent.anthropic_adapter import get_anthropic_token_source + self._anthropic_auth_source = get_anthropic_token_source(new_token) + except Exception: + pass + return True + + def _try_fallback_anthropic_managed_key_model(self) -> bool: + if self.api_mode != "anthropic_messages": + return False + if getattr(self, "_anthropic_auth_source", "") != "claude_json_primary_api_key": + return False + current_model = str(getattr(self, "model", "") or "").lower() + if not any(name in current_model for name in ("sonnet", "opus")): + return False + + fallback_model = "claude-haiku-4-5-20251001" + if current_model == fallback_model: + return False + + self.model = fallback_model return True def _anthropic_messages_create(self, api_kwargs: dict): @@ -4491,6 +4517,7 @@ class AIAgent: max_compression_attempts = 3 codex_auth_retry_attempted = False anthropic_auth_retry_attempted = False + anthropic_managed_key_model_fallback_attempted = False nous_auth_retry_attempted = False restart_with_compressed_messages = False restart_with_length_continuation = False @@ -4852,6 +4879,19 @@ class AIAgent: if self._try_refresh_nous_client_credentials(force=True): print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...") continue + if ( + self.api_mode == "anthropic_messages" + and status_code == 500 + and not anthropic_managed_key_model_fallback_attempted + ): + anthropic_managed_key_model_fallback_attempted = True + if self._try_fallback_anthropic_managed_key_model(): + print( + f"{self.log_prefix}⚠️ Claude native managed key hit Anthropic 500 on Sonnet/Opus. " + f"Falling back to claude-haiku-4-5-20251001 and retrying..." + ) + continue + if ( self.api_mode == "anthropic_messages" and status_code == 401 diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 1bc3af2e1..1b5800783 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -16,6 +16,7 @@ from agent.anthropic_adapter import ( build_anthropic_kwargs, convert_messages_to_anthropic, convert_tools_to_anthropic, + get_anthropic_token_source, is_claude_code_token_valid, normalize_anthropic_response, normalize_model_name, @@ -87,16 +88,27 @@ class TestReadClaudeCodeCredentials: cred_file.parent.mkdir(parents=True) cred_file.write_text(json.dumps({ "claudeAiOauth": { - "accessToken": "sk-ant-oat01-test-token", - "refreshToken": "sk-ant-ort01-refresh", + "accessToken": "sk-ant-oat01-token", + "refreshToken": "sk-ant-oat01-refresh", "expiresAt": int(time.time() * 1000) + 3600_000, } })) monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) creds = read_claude_code_credentials() assert creds is not None - assert creds["accessToken"] == "sk-ant-oat01-test-token" - assert creds["refreshToken"] == "sk-ant-ort01-refresh" + assert creds["accessToken"] == "sk-ant-oat01-token" + assert creds["refreshToken"] == "sk-ant-oat01-refresh" + assert creds["source"] == "claude_code_credentials_file" + + def test_reads_primary_api_key_with_source(self, tmp_path, monkeypatch): + claude_json = tmp_path / ".claude.json" + claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + creds = read_claude_code_credentials() + assert creds is not None + assert creds["accessToken"] == "sk-ant-api03-primary" + assert creds["source"] == "claude_json_primary_api_key" def test_returns_none_for_missing_file(self, tmp_path, monkeypatch): monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) @@ -139,6 +151,15 @@ class TestResolveAnthropicToken: monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken") assert resolve_anthropic_token() == "sk-ant-oat01-mytoken" + def test_reports_claude_json_primary_key_source(self, monkeypatch, tmp_path): + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + (tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) + monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + + assert get_anthropic_token_source("sk-ant-api03-primary") == "claude_json_primary_api_key" + def test_falls_back_to_api_key_when_no_oauth_sources_exist(self, monkeypatch, tmp_path): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index c3673eb1e..23fd68b09 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1089,6 +1089,46 @@ class TestRunConversation: assert result["completed"] is True assert result["final_response"] == "Recovered after remint" + def test_anthropic_managed_key_500_falls_back_to_haiku_and_retries(self, agent): + self._setup_agent(agent) + agent.provider = "anthropic" + agent.api_mode = "anthropic_messages" + agent.model = "claude-sonnet-4-6" + agent._anthropic_auth_source = "claude_json_primary_api_key" + agent._anthropic_api_key = "sk-ant-api03-primary" + + calls = {"api": 0} + + class _ServerError(RuntimeError): + def __init__(self): + super().__init__("Error code: 500 - internal server error") + self.status_code = 500 + + anthropic_response = SimpleNamespace( + content=[SimpleNamespace(type="text", text="Recovered with haiku")], + stop_reason="end_turn", + usage=None, + ) + + def _fake_api_call(api_kwargs): + calls["api"] += 1 + if calls["api"] == 1: + raise _ServerError() + return anthropic_response + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + ): + result = agent.run_conversation("hello") + + assert calls["api"] == 2 + assert agent.model == "claude-haiku-4-5-20251001" + assert result["completed"] is True + assert result["final_response"] == "Recovered with haiku" + def test_context_compression_triggered(self, agent): """When compressor says should_compress, compression runs.""" self._setup_agent(agent) @@ -2145,6 +2185,46 @@ class TestAnthropicCredentialRefresh: old_client.close.assert_not_called() rebuild.assert_not_called() + def test_try_fallback_anthropic_managed_key_model_switches_sonnet_to_haiku(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-api03-primary", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent.model = "claude-sonnet-4-6" + agent._anthropic_auth_source = "claude_json_primary_api_key" + + assert agent._try_fallback_anthropic_managed_key_model() is True + assert agent.model == "claude-haiku-4-5-20251001" + + def test_try_fallback_anthropic_managed_key_model_ignores_normal_api_keys(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-api03-real-api-key", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent.model = "claude-sonnet-4-6" + agent._anthropic_auth_source = "anthropic_api_key_env" + + assert agent._try_fallback_anthropic_managed_key_model() is False + assert agent.model == "claude-sonnet-4-6" + def test_anthropic_messages_create_preflights_refresh(self): with ( patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),