fix: require oauth creds for native Anthropic

This commit is contained in:
teknium1
2026-03-14 22:11:21 -07:00
parent db9e512424
commit f4e8772de4
4 changed files with 35 additions and 146 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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")),