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:
teknium1 2026-03-12 17:14:22 -07:00
parent cd4e995d54
commit 4068f20ce9
7 changed files with 46 additions and 24 deletions

View file

@ -39,8 +39,18 @@ _OAUTH_ONLY_BETAS = [
def _is_oauth_token(key: str) -> bool:
"""Check if the key is an OAuth access/setup token (not a regular API key)."""
return key.startswith("sk-ant-oat")
"""Check if the key is an OAuth/setup token (not a regular Console API key).
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):
@ -240,13 +250,21 @@ def convert_messages_to_anthropic(
for tc in m.get("tool_calls", []):
fn = tc.get("function", {})
args = fn.get("arguments", "{}")
try:
parsed_args = json.loads(args) if isinstance(args, str) else args
except (json.JSONDecodeError, ValueError):
parsed_args = {}
blocks.append({
"type": "tool_use",
"id": tc.get("id", ""),
"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
if role == "tool":

View file

@ -449,21 +449,6 @@ def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
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]]:
codex_token = _read_codex_access_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,
# LLaVA, Pixtral, etc.) support vision — skipping them entirely
# caused silent failures for local-only users.
for try_fn in (_try_openrouter, _try_nous, _try_anthropic,
_try_codex, _try_custom_endpoint):
for try_fn in (_try_openrouter, _try_nous, _try_codex,
_try_custom_endpoint):
client, model = try_fn()
if client is not None:
return client, model

View file

@ -290,7 +290,9 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
"haiku" not in m, # then haiku
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

View file

@ -157,11 +157,16 @@ def resolve_runtime_provider(
if provider == "anthropic":
from agent.anthropic_adapter import 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 {
"provider": "anthropic",
"api_mode": "anthropic_messages",
"base_url": "https://api.anthropic.com",
"api_key": token or "",
"api_key": token,
"source": "env",
"requested_provider": requested_provider,
}

View file

@ -1028,7 +1028,8 @@ def setup_model_provider(config: dict):
if existing_key:
print_info(f"Current credentials: {existing_key[:12]}...")
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)):
auth_choices = [

View file

@ -3553,6 +3553,7 @@ class AIAgent:
compression_attempts = 0
max_compression_attempts = 3
codex_auth_retry_attempted = False
anthropic_auth_retry_attempted = False
nous_auth_retry_attempted = False
restart_with_compressed_messages = False
restart_with_length_continuation = False
@ -3892,7 +3893,9 @@ class AIAgent:
self.api_mode == "anthropic_messages"
and status_code == 401
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)
from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client
new_token = resolve_anthropic_token()

View file

@ -33,6 +33,14 @@ class TestIsOAuthToken:
def test_api_key(self):
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):
assert _is_oauth_token("") is False