From f4e8772de4326db63e0f3d10a511ad216ddb40e3 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:11:21 -0700 Subject: [PATCH] fix: require oauth creds for native Anthropic --- agent/anthropic_adapter.py | 44 +++++++++--------- run_agent.py | 42 +---------------- tests/test_anthropic_adapter.py | 15 +++++-- tests/test_run_agent.py | 80 --------------------------------- 4 files changed, 35 insertions(+), 146 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 1b7bbe468..ab2842684 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -102,31 +102,15 @@ def build_anthropic_client(api_key: str, base_url: str = None): def read_claude_code_credentials() -> Optional[Dict[str, Any]]: - """Read credentials from Claude Code's config files. + """Read refreshable Claude Code OAuth credentials from ~/.claude/.credentials.json. - Checks two locations (in order): - 1. ~/.claude.json — top-level primaryApiKey (native binary, v2.x) - 2. ~/.claude/.credentials.json — claudeAiOauth block (npm/legacy installs) + This intentionally excludes ~/.claude.json primaryApiKey. Opencode's + subscription flow is OAuth/setup-token based with refreshable credentials, + and native direct Anthropic provider usage should follow that path rather + than auto-detecting Claude's first-party managed key. Returns dict with {accessToken, refreshToken?, expiresAt?} or None. """ - # 1. Native binary (v2.x): ~/.claude.json with top-level primaryApiKey - claude_json = Path.home() / ".claude.json" - if claude_json.exists(): - try: - data = json.loads(claude_json.read_text(encoding="utf-8")) - primary_key = data.get("primaryApiKey", "") - if primary_key: - return { - "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) - - # 2. Legacy/npm installs: ~/.claude/.credentials.json cred_path = Path.home() / ".claude" / ".credentials.json" if cred_path.exists(): try: @@ -147,6 +131,20 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: return None +def read_claude_managed_key() -> Optional[str]: + """Read Claude's native managed key from ~/.claude.json for diagnostics only.""" + claude_json = Path.home() / ".claude.json" + if claude_json.exists(): + try: + data = json.loads(claude_json.read_text(encoding="utf-8")) + primary_key = data.get("primaryApiKey", "") + if isinstance(primary_key, str) and primary_key.strip(): + return primary_key.strip() + except (json.JSONDecodeError, OSError, IOError) as e: + logger.debug("Failed to read ~/.claude.json: %s", e) + return None + + def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool: """Check if Claude Code credentials have a non-expired access token.""" import time @@ -293,6 +291,10 @@ def get_anthropic_token_source(token: Optional[str] = None) -> str: if creds and creds.get("accessToken") == token: return str(creds.get("source") or "claude_code_credentials") + managed_key = read_claude_managed_key() + if managed_key and managed_key == token: + return "claude_json_primary_api_key" + api_key = os.getenv("ANTHROPIC_API_KEY", "").strip() if api_key and api_key == token: return "anthropic_api_key_env" diff --git a/run_agent.py b/run_agent.py index 1264de0f8..419b56929 100644 --- a/run_agent.py +++ b/run_agent.py @@ -511,14 +511,9 @@ class AIAgent: self._anthropic_client = None if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import ( - build_anthropic_client, - resolve_anthropic_token, - get_anthropic_token_source, - ) + from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token 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 @@ -2648,27 +2643,6 @@ 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): @@ -4517,7 +4491,6 @@ 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 @@ -4879,19 +4852,6 @@ 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 1b5800783..e05996baa 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -100,15 +100,13 @@ class TestReadClaudeCodeCredentials: 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): + def test_ignores_primary_api_key_for_native_anthropic_resolution(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" + assert creds is None def test_returns_none_for_missing_file(self, tmp_path, monkeypatch): monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) @@ -160,6 +158,15 @@ class TestResolveAnthropicToken: assert get_anthropic_token_source("sk-ant-api03-primary") == "claude_json_primary_api_key" + def test_does_not_resolve_primary_api_key_as_native_anthropic_token(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 resolve_anthropic_token() is None + 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 23fd68b09..c3673eb1e 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1089,46 +1089,6 @@ 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) @@ -2185,46 +2145,6 @@ 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")),