fix(anthropic): deep scan fixes — auth, retries, edge cases
Fixes from comprehensive code review and cross-referencing with clawdbot/OpenCode implementations: CRITICAL: - Add one-shot guard (anthropic_auth_retry_attempted) to prevent infinite 401 retry loops when credentials keep changing - Fix _is_oauth_token(): managed keys from ~/.claude.json are NOT regular API keys (don't start with sk-ant-api). Inverted the logic: only sk-ant-api* is treated as API key auth, everything else uses Bearer auth + oauth beta headers HIGH: - Wrap json.loads(args) in try/except in message conversion — malformed tool_call arguments no longer crash the entire conversation - Raise AuthError in runtime_provider when no Anthropic token found (was silently passing empty string, causing confusing API errors) - Remove broken _try_anthropic() from auxiliary vision chain — the centralized router creates an OpenAI client for api_key providers which doesn't work with Anthropic's Messages API MEDIUM: - Handle empty assistant message content — Anthropic rejects empty content blocks, now inserts '(empty)' placeholder - Fix setup.py existing_key logic — set to 'KEEP' sentinel instead of None to prevent falling through to the auth choice prompt - Add debug logging to _fetch_anthropic_models on failure Tests: 43 adapter tests (2 new for token detection), 3197 total passed
This commit is contained in:
parent
cd4e995d54
commit
4068f20ce9
7 changed files with 46 additions and 24 deletions
|
|
@ -39,8 +39,18 @@ _OAUTH_ONLY_BETAS = [
|
||||||
|
|
||||||
|
|
||||||
def _is_oauth_token(key: str) -> bool:
|
def _is_oauth_token(key: str) -> bool:
|
||||||
"""Check if the key is an OAuth access/setup token (not a regular API key)."""
|
"""Check if the key is an OAuth/setup token (not a regular Console API key).
|
||||||
return key.startswith("sk-ant-oat")
|
|
||||||
|
Regular API keys start with 'sk-ant-api'. Everything else (setup-tokens
|
||||||
|
starting with 'sk-ant-oat', managed keys, JWTs, etc.) needs Bearer auth.
|
||||||
|
"""
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
# Regular Console API keys use x-api-key header
|
||||||
|
if key.startswith("sk-ant-api"):
|
||||||
|
return False
|
||||||
|
# Everything else (setup-tokens, managed keys, JWTs) uses Bearer auth
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_anthropic_client(api_key: str, base_url: str = None):
|
def build_anthropic_client(api_key: str, base_url: str = None):
|
||||||
|
|
@ -240,13 +250,21 @@ def convert_messages_to_anthropic(
|
||||||
for tc in m.get("tool_calls", []):
|
for tc in m.get("tool_calls", []):
|
||||||
fn = tc.get("function", {})
|
fn = tc.get("function", {})
|
||||||
args = fn.get("arguments", "{}")
|
args = fn.get("arguments", "{}")
|
||||||
|
try:
|
||||||
|
parsed_args = json.loads(args) if isinstance(args, str) else args
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
parsed_args = {}
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": tc.get("id", ""),
|
"id": tc.get("id", ""),
|
||||||
"name": fn.get("name", ""),
|
"name": fn.get("name", ""),
|
||||||
"input": json.loads(args) if isinstance(args, str) else args,
|
"input": parsed_args,
|
||||||
})
|
})
|
||||||
result.append({"role": "assistant", "content": blocks or content})
|
# Anthropic rejects empty assistant content
|
||||||
|
effective = blocks or content
|
||||||
|
if not effective or effective == "":
|
||||||
|
effective = [{"type": "text", "text": "(empty)"}]
|
||||||
|
result.append({"role": "assistant", "content": effective})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
|
|
|
||||||
|
|
@ -449,21 +449,6 @@ def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||||
return OpenAI(api_key=custom_key, base_url=custom_base), model
|
return OpenAI(api_key=custom_key, base_url=custom_base), model
|
||||||
|
|
||||||
|
|
||||||
_ANTHROPIC_VISION_MODEL = "claude-sonnet-4-20250514"
|
|
||||||
|
|
||||||
|
|
||||||
def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
|
|
||||||
"""Try Anthropic credentials for auxiliary tasks (vision-capable)."""
|
|
||||||
from agent.anthropic_adapter import resolve_anthropic_token
|
|
||||||
token = resolve_anthropic_token()
|
|
||||||
if not token:
|
|
||||||
return None, None
|
|
||||||
# Return a simple wrapper that indicates Anthropic is available.
|
|
||||||
# The actual client is created by resolve_provider_client("anthropic").
|
|
||||||
logger.debug("Auxiliary client: Anthropic (%s)", _ANTHROPIC_VISION_MODEL)
|
|
||||||
return resolve_provider_client("anthropic", model=_ANTHROPIC_VISION_MODEL)
|
|
||||||
|
|
||||||
|
|
||||||
def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
|
def _try_codex() -> Tuple[Optional[Any], Optional[str]]:
|
||||||
codex_token = _read_codex_access_token()
|
codex_token = _read_codex_access_token()
|
||||||
if not codex_token:
|
if not codex_token:
|
||||||
|
|
@ -768,8 +753,8 @@ def get_vision_auxiliary_client() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||||
# back to the user's custom endpoint. Many local models (Qwen-VL,
|
# back to the user's custom endpoint. Many local models (Qwen-VL,
|
||||||
# LLaVA, Pixtral, etc.) support vision — skipping them entirely
|
# LLaVA, Pixtral, etc.) support vision — skipping them entirely
|
||||||
# caused silent failures for local-only users.
|
# caused silent failures for local-only users.
|
||||||
for try_fn in (_try_openrouter, _try_nous, _try_anthropic,
|
for try_fn in (_try_openrouter, _try_nous, _try_codex,
|
||||||
_try_codex, _try_custom_endpoint):
|
_try_custom_endpoint):
|
||||||
client, model = try_fn()
|
client, model = try_fn()
|
||||||
if client is not None:
|
if client is not None:
|
||||||
return client, model
|
return client, model
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,9 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
|
||||||
"haiku" not in m, # then haiku
|
"haiku" not in m, # then haiku
|
||||||
m, # alphabetical within tier
|
m, # alphabetical within tier
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,11 +157,16 @@ def resolve_runtime_provider(
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
from agent.anthropic_adapter import resolve_anthropic_token
|
from agent.anthropic_adapter import resolve_anthropic_token
|
||||||
token = resolve_anthropic_token()
|
token = resolve_anthropic_token()
|
||||||
|
if not token:
|
||||||
|
raise AuthError(
|
||||||
|
"No Anthropic credentials found. Set ANTHROPIC_API_KEY, "
|
||||||
|
"run 'claude setup-token', or authenticate with 'claude /login'."
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"api_mode": "anthropic_messages",
|
"api_mode": "anthropic_messages",
|
||||||
"base_url": "https://api.anthropic.com",
|
"base_url": "https://api.anthropic.com",
|
||||||
"api_key": token or "",
|
"api_key": token,
|
||||||
"source": "env",
|
"source": "env",
|
||||||
"requested_provider": requested_provider,
|
"requested_provider": requested_provider,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1028,7 +1028,8 @@ def setup_model_provider(config: dict):
|
||||||
if existing_key:
|
if existing_key:
|
||||||
print_info(f"Current credentials: {existing_key[:12]}...")
|
print_info(f"Current credentials: {existing_key[:12]}...")
|
||||||
if not prompt_yes_no("Update credentials?", False):
|
if not prompt_yes_no("Update credentials?", False):
|
||||||
existing_key = None # skip — keep existing
|
# User wants to keep existing — skip auth prompt entirely
|
||||||
|
existing_key = "KEEP" # truthy sentinel to skip auth choice
|
||||||
|
|
||||||
if not existing_key and not (cc_creds and is_claude_code_token_valid(cc_creds)):
|
if not existing_key and not (cc_creds and is_claude_code_token_valid(cc_creds)):
|
||||||
auth_choices = [
|
auth_choices = [
|
||||||
|
|
|
||||||
|
|
@ -3553,6 +3553,7 @@ class AIAgent:
|
||||||
compression_attempts = 0
|
compression_attempts = 0
|
||||||
max_compression_attempts = 3
|
max_compression_attempts = 3
|
||||||
codex_auth_retry_attempted = False
|
codex_auth_retry_attempted = False
|
||||||
|
anthropic_auth_retry_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
|
||||||
|
|
@ -3892,7 +3893,9 @@ class AIAgent:
|
||||||
self.api_mode == "anthropic_messages"
|
self.api_mode == "anthropic_messages"
|
||||||
and status_code == 401
|
and status_code == 401
|
||||||
and hasattr(self, '_anthropic_api_key')
|
and hasattr(self, '_anthropic_api_key')
|
||||||
|
and not anthropic_auth_retry_attempted
|
||||||
):
|
):
|
||||||
|
anthropic_auth_retry_attempted = True
|
||||||
# Try re-reading Claude Code credentials (they may have been refreshed)
|
# Try re-reading Claude Code credentials (they may have been refreshed)
|
||||||
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client
|
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client
|
||||||
new_token = resolve_anthropic_token()
|
new_token = resolve_anthropic_token()
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ class TestIsOAuthToken:
|
||||||
def test_api_key(self):
|
def test_api_key(self):
|
||||||
assert _is_oauth_token("sk-ant-api03-abcdef1234567890") is False
|
assert _is_oauth_token("sk-ant-api03-abcdef1234567890") is False
|
||||||
|
|
||||||
|
def test_managed_key(self):
|
||||||
|
# Managed keys from ~/.claude.json are NOT regular API keys
|
||||||
|
assert _is_oauth_token("ou1R1z-ft0A-bDeZ9wAA") is True
|
||||||
|
|
||||||
|
def test_jwt_token(self):
|
||||||
|
# JWTs from OAuth flow
|
||||||
|
assert _is_oauth_token("eyJhbGciOiJSUzI1NiJ9.test") is True
|
||||||
|
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
assert _is_oauth_token("") is False
|
assert _is_oauth_token("") is False
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue