From db362dbd4c0e6f1b4b3a8dd5a8b2688aa18eec66 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:14:20 -0700 Subject: [PATCH 1/3] feat: add native Anthropic auxiliary vision --- agent/anthropic_adapter.py | 72 ++++++- agent/auxiliary_client.py | 198 ++++++++++++++++-- hermes_cli/setup.py | 8 +- tests/agent/test_auxiliary_client.py | 67 +++++- tests/hermes_cli/test_setup_model_provider.py | 16 ++ tests/test_anthropic_adapter.py | 50 +++++ tools/vision_tools.py | 5 +- 7 files changed, 386 insertions(+), 30 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 39efa219..ad3dfe58 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -391,6 +391,68 @@ def _sanitize_tool_id(tool_id: str) -> str: return sanitized or "tool_0" +def _convert_openai_image_part_to_anthropic(part: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Convert an OpenAI-style image block to Anthropic's image source format.""" + image_data = part.get("image_url", {}) + url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data) + if not isinstance(url, str) or not url.strip(): + return None + url = url.strip() + + if url.startswith("data:"): + header, sep, data = url.partition(",") + if sep and ";base64" in header: + media_type = header[5:].split(";", 1)[0] or "image/png" + return { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": data, + }, + } + + if url.startswith("http://") or url.startswith("https://"): + return { + "type": "image", + "source": { + "type": "url", + "url": url, + }, + } + + return None + + +def _convert_user_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]: + if isinstance(part, dict): + ptype = part.get("type") + if ptype == "text": + block = {"type": "text", "text": part.get("text", "")} + if isinstance(part.get("cache_control"), dict): + block["cache_control"] = dict(part["cache_control"]) + return block + if ptype == "image_url": + return _convert_openai_image_part_to_anthropic(part) + if ptype == "image" and part.get("source"): + return dict(part) + if ptype == "image" and part.get("data"): + media_type = part.get("mimeType") or part.get("media_type") or "image/png" + return { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": part.get("data", ""), + }, + } + if ptype == "tool_result": + return dict(part) + elif part is not None: + return {"type": "text", "text": str(part)} + return None + + def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: """Convert OpenAI tool definitions to Anthropic format.""" if not tools: @@ -495,7 +557,15 @@ def convert_messages_to_anthropic( continue # Regular user message - result.append({"role": "user", "content": content}) + if isinstance(content, list): + converted_blocks = [] + for part in content: + converted = _convert_user_content_part_to_anthropic(part) + if converted is not None: + converted_blocks.append(converted) + result.append({"role": "user", "content": converted_blocks or [{"type": "text", "text": ""}]}) + else: + result.append({"role": "user", "content": content}) # Strip orphaned tool_use blocks (no matching tool_result follows) tool_result_ids = set() diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index dd8f22bb..7794889c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1,4 +1,4 @@ -"""Shared auxiliary OpenAI client for cheap/fast side tasks. +"""Shared auxiliary client router for side tasks. Provides a single resolution chain so every consumer (context compression, session search, web extraction, vision analysis, browser vision) picks up @@ -10,21 +10,21 @@ Resolution order for text tasks (auto mode): 3. Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) 4. Codex OAuth (Responses API via chatgpt.com with gpt-5.3-codex, wrapped to look like a chat.completions client) - 5. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN) - — checked via PROVIDER_REGISTRY entries with auth_type='api_key' - 6. None + 5. Native Anthropic + 6. Direct API-key providers (z.ai/GLM, Kimi/Moonshot, MiniMax, MiniMax-CN) + 7. None Resolution order for vision/multimodal tasks (auto mode): - 1. OpenRouter - 2. Nous Portal - 3. Codex OAuth (gpt-5.3-codex supports vision via Responses API) - 4. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.) - 5. None (API-key providers like z.ai/Kimi/MiniMax are skipped — - they may not support multimodal) + 1. Selected main provider, if it is one of the supported vision backends below + 2. OpenRouter + 3. Nous Portal + 4. Codex OAuth (gpt-5.3-codex supports vision via Responses API) + 5. Native Anthropic + 6. Custom endpoint (for local vision models: Qwen-VL, LLaVA, Pixtral, etc.) + 7. None Per-task provider overrides (e.g. AUXILIARY_VISION_PROVIDER, -CONTEXT_COMPRESSION_PROVIDER) can force a specific provider for each task: -"openrouter", "nous", "codex", or "main" (= steps 3-5). +CONTEXT_COMPRESSION_PROVIDER) can force a specific provider for each task. Default "auto" follows the chains above. Per-task model overrides (e.g. AUXILIARY_VISION_MODEL, @@ -74,6 +74,7 @@ auxiliary_is_nous: bool = False _OPENROUTER_MODEL = "google/gemini-3-flash-preview" _NOUS_MODEL = "gemini-3-flash" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" +_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" _AUTH_JSON_PATH = get_hermes_home() / "auth.json" # Codex fallback: uses the Responses API (the only endpoint the Codex @@ -309,6 +310,114 @@ class AsyncCodexAuxiliaryClient: self.base_url = sync_wrapper.base_url +class _AnthropicCompletionsAdapter: + """OpenAI-client-compatible adapter for Anthropic Messages API.""" + + def __init__(self, real_client: Any, model: str): + self._client = real_client + self._model = model + + def create(self, **kwargs) -> Any: + from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response + + messages = kwargs.get("messages", []) + model = kwargs.get("model", self._model) + tools = kwargs.get("tools") + tool_choice = kwargs.get("tool_choice") + max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000 + temperature = kwargs.get("temperature") + + normalized_tool_choice = None + if isinstance(tool_choice, str): + normalized_tool_choice = tool_choice + elif isinstance(tool_choice, dict): + choice_type = str(tool_choice.get("type", "")).lower() + if choice_type == "function": + normalized_tool_choice = tool_choice.get("function", {}).get("name") + elif choice_type in {"auto", "required", "none"}: + normalized_tool_choice = choice_type + + anthropic_kwargs = build_anthropic_kwargs( + model=model, + messages=messages, + tools=tools, + max_tokens=max_tokens, + reasoning_config=None, + tool_choice=normalized_tool_choice, + ) + if temperature is not None: + anthropic_kwargs["temperature"] = temperature + + response = self._client.messages.create(**anthropic_kwargs) + assistant_message, finish_reason = normalize_anthropic_response(response) + + usage = None + if hasattr(response, "usage") and response.usage: + prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0 + completion_tokens = getattr(response.usage, "output_tokens", 0) or 0 + total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens) + usage = SimpleNamespace( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + ) + + choice = SimpleNamespace( + index=0, + message=assistant_message, + finish_reason=finish_reason, + ) + return SimpleNamespace( + choices=[choice], + model=model, + usage=usage, + ) + + +class _AnthropicChatShim: + def __init__(self, adapter: _AnthropicCompletionsAdapter): + self.completions = adapter + + +class AnthropicAuxiliaryClient: + """OpenAI-client-compatible wrapper over a native Anthropic client.""" + + def __init__(self, real_client: Any, model: str, api_key: str, base_url: str): + self._real_client = real_client + adapter = _AnthropicCompletionsAdapter(real_client, model) + self.chat = _AnthropicChatShim(adapter) + self.api_key = api_key + self.base_url = base_url + + def close(self): + close_fn = getattr(self._real_client, "close", None) + if callable(close_fn): + close_fn() + + +class _AsyncAnthropicCompletionsAdapter: + def __init__(self, sync_adapter: _AnthropicCompletionsAdapter): + self._sync = sync_adapter + + async def create(self, **kwargs) -> Any: + import asyncio + return await asyncio.to_thread(self._sync.create, **kwargs) + + +class _AsyncAnthropicChatShim: + def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter): + self.completions = adapter + + +class AsyncAnthropicAuxiliaryClient: + def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"): + sync_adapter = sync_wrapper.chat.completions + async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter) + self.chat = _AsyncAnthropicChatShim(async_adapter) + self.api_key = sync_wrapper.api_key + self.base_url = sync_wrapper.base_url + + def _read_nous_auth() -> Optional[dict]: """Read and validate ~/.hermes/auth.json for an active Nous provider. @@ -380,6 +489,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]: break if not api_key: continue + if provider_id == "anthropic": + return _try_anthropic() + # Resolve base URL (with optional env-var override) # Kimi Code keys (sk-kimi-) need api.kimi.com/coding/v1 env_url = "" @@ -484,6 +596,22 @@ def _try_codex() -> Tuple[Optional[Any], Optional[str]]: return CodexAuxiliaryClient(real_client, _CODEX_AUX_MODEL), _CODEX_AUX_MODEL +def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: + try: + from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token + except ImportError: + return None, None + + token = resolve_anthropic_token() + if not token: + return None, None + + model = _API_KEY_PROVIDER_AUX_MODELS.get("anthropic", "claude-haiku-4-5-20251001") + logger.debug("Auxiliary client: Anthropic native (%s)", model) + real_client = build_anthropic_client(token, _ANTHROPIC_DEFAULT_BASE_URL) + return AnthropicAuxiliaryClient(real_client, model, token, _ANTHROPIC_DEFAULT_BASE_URL), model + + def _resolve_forced_provider(forced: str) -> Tuple[Optional[OpenAI], Optional[str]]: """Resolve a specific forced provider. Returns (None, None) if creds missing.""" if forced == "openrouter": @@ -546,6 +674,8 @@ def _to_async_client(sync_client, model: str): if isinstance(sync_client, CodexAuxiliaryClient): return AsyncCodexAuxiliaryClient(sync_client), model + if isinstance(sync_client, AnthropicAuxiliaryClient): + return AsyncAnthropicAuxiliaryClient(sync_client), model async_kwargs = { "api_key": sync_client.api_key, @@ -686,6 +816,14 @@ def resolve_provider_client( return None, None if pconfig.auth_type == "api_key": + if provider == "anthropic": + client, default_model = _try_anthropic() + if client is None: + logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found") + return None, None + final_model = model or default_model + return (_to_async_client(client, final_model) if async_mode else (client, final_model)) + # Find the first configured API key api_key = "" for env_var in pconfig.api_key_env_vars: @@ -772,6 +910,7 @@ _VISION_AUTO_PROVIDER_ORDER = ( "openrouter", "nous", "openai-codex", + "anthropic", "custom", ) @@ -793,6 +932,8 @@ def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Option return _try_nous() if provider == "openai-codex": return _try_codex() + if provider == "anthropic": + return _try_anthropic() if provider == "custom": return _try_custom_endpoint() return None, None @@ -802,19 +943,36 @@ def _strict_vision_backend_available(provider: str) -> bool: return _resolve_strict_vision_backend(provider)[0] is not None +def _preferred_main_vision_provider() -> Optional[str]: + """Return the selected main provider when it is also a supported vision backend.""" + try: + from hermes_cli.config import load_config + + config = load_config() + model_cfg = config.get("model", {}) + if isinstance(model_cfg, dict): + provider = _normalize_vision_provider(model_cfg.get("provider", "")) + if provider in _VISION_AUTO_PROVIDER_ORDER: + return provider + except Exception: + pass + return None + + def get_available_vision_backends() -> List[str]: """Return the currently available vision backends in auto-selection order. This is the single source of truth for setup, tool gating, and runtime - auto-routing of vision tasks. Phase 1 keeps the auto list conservative: - OpenRouter, Nous Portal, Codex OAuth, then custom OpenAI-compatible - endpoints. Explicit provider overrides can still route elsewhere. + auto-routing of vision tasks. The selected main provider is preferred when + it is also a known-good vision backend; otherwise Hermes falls back through + the standard conservative order. """ - return [ - provider - for provider in _VISION_AUTO_PROVIDER_ORDER - if _strict_vision_backend_available(provider) - ] + ordered = list(_VISION_AUTO_PROVIDER_ORDER) + preferred = _preferred_main_vision_provider() + if preferred in ordered: + ordered.remove(preferred) + ordered.insert(0, preferred) + return [provider for provider in ordered if _strict_vision_backend_available(provider)] def resolve_vision_provider_client( diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 051de13c..fcaff67c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1268,11 +1268,9 @@ def setup_model_provider(config: dict): _vision_needs_setup = not bool(_vision_backends) - if selected_provider in {"openrouter", "nous", "openai-codex"}: - # If the user just selected one of our known-good vision backends during - # setup, treat vision as covered. Auth/setup failure returns earlier. - _vision_needs_setup = False - elif selected_provider == "custom" and "custom" in _vision_backends: + if selected_provider in _vision_backends: + # If the user just selected a backend Hermes can already use for + # vision, treat it as covered. Auth/setup failure returns earlier. _vision_needs_setup = False if _vision_needs_setup: diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 57c73eb8..925772fa 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -10,6 +10,8 @@ import pytest from agent.auxiliary_client import ( get_text_auxiliary_client, get_vision_auxiliary_client, + get_available_vision_backends, + resolve_provider_client, auxiliary_max_tokens_param, _read_codex_access_token, _get_auxiliary_provider, @@ -24,6 +26,7 @@ def _clean_env(monkeypatch): for key in ( "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL", "LLM_MODEL", "NOUS_INFERENCE_BASE_URL", + "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", # Per-task provider/model overrides "AUXILIARY_VISION_PROVIDER", "AUXILIARY_VISION_MODEL", "AUXILIARY_WEB_EXTRACT_PROVIDER", "AUXILIARY_WEB_EXTRACT_MODEL", @@ -164,14 +167,74 @@ class TestGetTextAuxiliaryClient: class TestVisionClientFallback: - """Vision client auto mode only tries OpenRouter + Nous (multimodal-capable).""" + """Vision client auto mode resolves known-good multimodal backends.""" def test_vision_returns_none_without_any_credentials(self): - with patch("agent.auxiliary_client._read_nous_auth", return_value=None): + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.auxiliary_client._try_anthropic", return_value=(None, None)), + ): client, model = get_vision_auxiliary_client() assert client is None assert model is None + def test_vision_auto_includes_anthropic_when_configured(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + ): + backends = get_available_vision_backends() + + assert "anthropic" in backends + + def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + ): + client, model = resolve_provider_client("anthropic") + + assert client is not None + assert client.__class__.__name__ == "AnthropicAuxiliaryClient" + assert model == "claude-haiku-4-5-20251001" + + def test_vision_auto_uses_anthropic_when_no_higher_priority_backend(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + ): + client, model = get_vision_auxiliary_client() + + assert client is not None + assert client.__class__.__name__ == "AnthropicAuxiliaryClient" + assert model == "claude-haiku-4-5-20251001" + + def test_selected_anthropic_provider_is_preferred_for_vision_auto(self, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + + def fake_load_config(): + return {"model": {"provider": "anthropic", "default": "claude-sonnet-4-6"}} + + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-key"), + patch("agent.auxiliary_client.OpenAI") as mock_openai, + patch("hermes_cli.config.load_config", fake_load_config), + ): + client, model = get_vision_auxiliary_client() + + assert client is not None + assert client.__class__.__name__ == "AnthropicAuxiliaryClient" + assert model == "claude-haiku-4-5-20251001" + def test_vision_auto_includes_codex(self, codex_auth_dir): """Codex supports vision (gpt-5.3-codex), so auto mode should use it.""" with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index 8f7063ec..34b49106 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -111,6 +111,7 @@ def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tm monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) setup_model_provider(config) save_config(config) @@ -149,6 +150,7 @@ def test_setup_keep_current_anthropic_can_configure_openai_vision_default(tmp_pa monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) setup_model_provider(config) env = _read_env(tmp_path) @@ -224,3 +226,17 @@ def test_setup_summary_marks_codex_auth_as_vision_available(tmp_path, monkeypatc assert "missing run 'hermes setup' to configure" not in output assert "Mixture of Agents" in output assert "missing OPENROUTER_API_KEY" in output + + +def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + monkeypatch.setattr("shutil.which", lambda _name: None) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: ["anthropic"]) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Vision (image analysis)" in output + assert "missing run 'hermes setup' to configure" not in output diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 541d8e2b..1bc3af2e 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -567,6 +567,56 @@ class TestConvertMessages: assert tool_block["content"] == "result" assert tool_block["cache_control"] == {"type": "ephemeral"} + def test_converts_data_url_image_to_anthropic_image_block(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,ZmFrZQ=="}, + }, + ], + } + ] + + _, result = convert_messages_to_anthropic(messages) + blocks = result[0]["content"] + assert blocks[0] == {"type": "text", "text": "Describe this image"} + assert blocks[1] == { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "ZmFrZQ==", + }, + } + + def test_converts_remote_image_url_to_anthropic_image_block(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this image"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/cat.png"}, + }, + ], + } + ] + + _, result = convert_messages_to_anthropic(messages) + blocks = result[0]["content"] + assert blocks[1] == { + "type": "image", + "source": { + "type": "url", + "url": "https://example.com/cat.png", + }, + } + def test_empty_cached_assistant_tool_turn_converts_without_empty_text_block(self): messages = apply_anthropic_cache_control([ {"role": "system", "content": "System prompt"}, diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 37fb8fe4..954ffd28 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -3,7 +3,8 @@ Vision Tools Module This module provides vision analysis tools that work with image URLs. -Uses Gemini 3 Flash Preview via OpenRouter API for intelligent image understanding. +Uses the centralized auxiliary vision router, which can select OpenRouter, +Nous, Codex, native Anthropic, or a custom OpenAI-compatible endpoint. Available tools: - vision_analyze_tool: Analyze images from URLs with custom prompts @@ -409,7 +410,7 @@ if __name__ == "__main__": if not api_available: print("❌ No auxiliary vision model available") - print("Set OPENROUTER_API_KEY or configure Nous Portal to enable vision tools.") + print("Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint).") exit(1) else: print("✅ Vision model available") From db9e512424c8e87b6c86b1386804772454a5346a Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 21:44:39 -0700 Subject: [PATCH 2/3] fix: fall back from managed Anthropic keys --- agent/anthropic_adapter.py | 27 +++++++++++ run_agent.py | 42 ++++++++++++++++- tests/test_anthropic_adapter.py | 29 ++++++++++-- tests/test_run_agent.py | 80 +++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 5 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index ad3dfe58..1b7bbe46 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -121,6 +121,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: "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) @@ -138,6 +139,7 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: "accessToken": access_token, "refreshToken": oauth_data.get("refreshToken", ""), "expiresAt": oauth_data.get("expiresAt", 0), + "source": "claude_code_credentials_file", } except (json.JSONDecodeError, OSError, IOError) as 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 +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]: """Resolve an Anthropic token from all available sources. diff --git a/run_agent.py b/run_agent.py index 419b5692..1264de0f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -511,9 +511,14 @@ class AIAgent: self._anthropic_client = None 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 "" 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 @@ -2643,6 +2648,27 @@ 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): @@ -4491,6 +4517,7 @@ 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 @@ -4852,6 +4879,19 @@ 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 diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 1bc3af2e..1b580078 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -16,6 +16,7 @@ from agent.anthropic_adapter import ( build_anthropic_kwargs, convert_messages_to_anthropic, convert_tools_to_anthropic, + get_anthropic_token_source, is_claude_code_token_valid, normalize_anthropic_response, normalize_model_name, @@ -87,16 +88,27 @@ class TestReadClaudeCodeCredentials: cred_file.parent.mkdir(parents=True) cred_file.write_text(json.dumps({ "claudeAiOauth": { - "accessToken": "sk-ant-oat01-test-token", - "refreshToken": "sk-ant-ort01-refresh", + "accessToken": "sk-ant-oat01-token", + "refreshToken": "sk-ant-oat01-refresh", "expiresAt": int(time.time() * 1000) + 3600_000, } })) 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-oat01-test-token" - assert creds["refreshToken"] == "sk-ant-ort01-refresh" + assert creds["accessToken"] == "sk-ant-oat01-token" + 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): monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) @@ -139,6 +151,15 @@ class TestResolveAnthropicToken: monkeypatch.setenv("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): monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index c3673eb1..23fd68b0 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1089,6 +1089,46 @@ 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) @@ -2145,6 +2185,46 @@ 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")), From f4e8772de4326db63e0f3d10a511ad216ddb40e3 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 22:11:21 -0700 Subject: [PATCH 3/3] fix: require oauth creds for native Anthropic --- agent/anthropic_adapter.py | 44 +++++++++--------- run_agent.py | 42 +---------------- tests/test_anthropic_adapter.py | 15 +++++-- tests/test_run_agent.py | 80 --------------------------------- 4 files changed, 35 insertions(+), 146 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 1b7bbe46..ab284268 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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" diff --git a/run_agent.py b/run_agent.py index 1264de0f..419b5692 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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 diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 1b580078..e05996ba 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -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) diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 23fd68b0..c3673eb1 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -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")),