diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 30de65cc0..3268256c9 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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": diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a4eb1cbeb..a2175bed7 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 0353712f3..a1fe9237a 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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 diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 474295ea6..062558cad 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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, } diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 49087b362..fa94d5cab 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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 = [ diff --git a/run_agent.py b/run_agent.py index 4c9798140..ffdf4a33d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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() diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 79a350d7f..f2a488490 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -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