fix: fall back from managed Anthropic keys
This commit is contained in:
parent
db362dbd4c
commit
db9e512424
4 changed files with 173 additions and 5 deletions
|
|
@ -121,6 +121,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||||
"accessToken": primary_key,
|
"accessToken": primary_key,
|
||||||
"refreshToken": "",
|
"refreshToken": "",
|
||||||
"expiresAt": 0, # Managed keys don't have a user-visible expiry
|
"expiresAt": 0, # Managed keys don't have a user-visible expiry
|
||||||
|
"source": "claude_json_primary_api_key",
|
||||||
}
|
}
|
||||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||||
logger.debug("Failed to read ~/.claude.json: %s", 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,
|
"accessToken": access_token,
|
||||||
"refreshToken": oauth_data.get("refreshToken", ""),
|
"refreshToken": oauth_data.get("refreshToken", ""),
|
||||||
"expiresAt": oauth_data.get("expiresAt", 0),
|
"expiresAt": oauth_data.get("expiresAt", 0),
|
||||||
|
"source": "claude_code_credentials_file",
|
||||||
}
|
}
|
||||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||||
logger.debug("Failed to read ~/.claude/.credentials.json: %s", 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
|
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]:
|
def resolve_anthropic_token() -> Optional[str]:
|
||||||
"""Resolve an Anthropic token from all available sources.
|
"""Resolve an Anthropic token from all available sources.
|
||||||
|
|
||||||
|
|
|
||||||
42
run_agent.py
42
run_agent.py
|
|
@ -511,9 +511,14 @@ class AIAgent:
|
||||||
self._anthropic_client = None
|
self._anthropic_client = None
|
||||||
|
|
||||||
if self.api_mode == "anthropic_messages":
|
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 ""
|
effective_key = api_key or resolve_anthropic_token() or ""
|
||||||
self._anthropic_api_key = effective_key
|
self._anthropic_api_key = effective_key
|
||||||
|
self._anthropic_auth_source = get_anthropic_token_source(effective_key)
|
||||||
self._anthropic_base_url = base_url
|
self._anthropic_base_url = base_url
|
||||||
self._anthropic_client = build_anthropic_client(effective_key, base_url)
|
self._anthropic_client = build_anthropic_client(effective_key, base_url)
|
||||||
# No OpenAI client needed for Anthropic mode
|
# No OpenAI client needed for Anthropic mode
|
||||||
|
|
@ -2643,6 +2648,27 @@ class AIAgent:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self._anthropic_api_key = new_token
|
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
|
return True
|
||||||
|
|
||||||
def _anthropic_messages_create(self, api_kwargs: dict):
|
def _anthropic_messages_create(self, api_kwargs: dict):
|
||||||
|
|
@ -4491,6 +4517,7 @@ class AIAgent:
|
||||||
max_compression_attempts = 3
|
max_compression_attempts = 3
|
||||||
codex_auth_retry_attempted = False
|
codex_auth_retry_attempted = False
|
||||||
anthropic_auth_retry_attempted = False
|
anthropic_auth_retry_attempted = False
|
||||||
|
anthropic_managed_key_model_fallback_attempted = False
|
||||||
nous_auth_retry_attempted = False
|
nous_auth_retry_attempted = False
|
||||||
restart_with_compressed_messages = False
|
restart_with_compressed_messages = False
|
||||||
restart_with_length_continuation = False
|
restart_with_length_continuation = False
|
||||||
|
|
@ -4852,6 +4879,19 @@ class AIAgent:
|
||||||
if self._try_refresh_nous_client_credentials(force=True):
|
if self._try_refresh_nous_client_credentials(force=True):
|
||||||
print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...")
|
print(f"{self.log_prefix}🔐 Nous agent key refreshed after 401. Retrying request...")
|
||||||
continue
|
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 (
|
if (
|
||||||
self.api_mode == "anthropic_messages"
|
self.api_mode == "anthropic_messages"
|
||||||
and status_code == 401
|
and status_code == 401
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from agent.anthropic_adapter import (
|
||||||
build_anthropic_kwargs,
|
build_anthropic_kwargs,
|
||||||
convert_messages_to_anthropic,
|
convert_messages_to_anthropic,
|
||||||
convert_tools_to_anthropic,
|
convert_tools_to_anthropic,
|
||||||
|
get_anthropic_token_source,
|
||||||
is_claude_code_token_valid,
|
is_claude_code_token_valid,
|
||||||
normalize_anthropic_response,
|
normalize_anthropic_response,
|
||||||
normalize_model_name,
|
normalize_model_name,
|
||||||
|
|
@ -87,16 +88,27 @@ class TestReadClaudeCodeCredentials:
|
||||||
cred_file.parent.mkdir(parents=True)
|
cred_file.parent.mkdir(parents=True)
|
||||||
cred_file.write_text(json.dumps({
|
cred_file.write_text(json.dumps({
|
||||||
"claudeAiOauth": {
|
"claudeAiOauth": {
|
||||||
"accessToken": "sk-ant-oat01-test-token",
|
"accessToken": "sk-ant-oat01-token",
|
||||||
"refreshToken": "sk-ant-ort01-refresh",
|
"refreshToken": "sk-ant-oat01-refresh",
|
||||||
"expiresAt": int(time.time() * 1000) + 3600_000,
|
"expiresAt": int(time.time() * 1000) + 3600_000,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
creds = read_claude_code_credentials()
|
creds = read_claude_code_credentials()
|
||||||
assert creds is not None
|
assert creds is not None
|
||||||
assert creds["accessToken"] == "sk-ant-oat01-test-token"
|
assert creds["accessToken"] == "sk-ant-oat01-token"
|
||||||
assert creds["refreshToken"] == "sk-ant-ort01-refresh"
|
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):
|
def test_returns_none_for_missing_file(self, tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||||
|
|
@ -139,6 +151,15 @@ class TestResolveAnthropicToken:
|
||||||
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
|
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
|
||||||
assert resolve_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):
|
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.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
|
||||||
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
|
||||||
|
|
|
||||||
|
|
@ -1089,6 +1089,46 @@ class TestRunConversation:
|
||||||
assert result["completed"] is True
|
assert result["completed"] is True
|
||||||
assert result["final_response"] == "Recovered after remint"
|
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):
|
def test_context_compression_triggered(self, agent):
|
||||||
"""When compressor says should_compress, compression runs."""
|
"""When compressor says should_compress, compression runs."""
|
||||||
self._setup_agent(agent)
|
self._setup_agent(agent)
|
||||||
|
|
@ -2145,6 +2185,46 @@ class TestAnthropicCredentialRefresh:
|
||||||
old_client.close.assert_not_called()
|
old_client.close.assert_not_called()
|
||||||
rebuild.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):
|
def test_anthropic_messages_create_preflights_refresh(self):
|
||||||
with (
|
with (
|
||||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue