{"repo":"NousResearch/hermes-agent","public_id":"7661","display_label":"Anthropic base URL and auxiliary provider routing fixes","summary":"These items address bugs in how base URLs are constructed and passed for Anthropic-compatible providers, including stripping or preserving /v1 suffixes and ensuring auxiliary/vision clients correctly inherit base_url, api_key, and api_mode from the main model configuration.","subsystem":"agent","counts":{"members":39,"open_issues":1,"open_issues_distinct":1,"open_prs":3,"closed_issues":14,"merged_prs":3,"attempt_prs":21},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12846},"resolution":{"state":"partially_resolved","by":"pr-15059,pr-85466"},"freshness":{"state":"verified","last_verified_at":"2026-08-13T17:32:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.04259109311740889},"fix_edges":[{"issue_number":7366,"pr_number":12846,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7661,"pr_number":12846,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":8294,"pr_number":12846,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":13051,"pr_number":15059,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":15033,"pr_number":15059,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":16254,"pr_number":85466,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61256,"pr_number":85466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":83642,"pr_number":85466,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":7661,"title":"[bug]: Custom auxiliary endpoints ignore api_mode: anthropic_messages","url":null,"state":"closed"},{"kind":"issue","number":9711,"title":"Named custom providers ignore api_mode, always create OpenAI client","url":null,"state":"closed"},{"kind":"issue","number":11059,"title":"[Bug] Cron sub-agent path collision: /v4/v1/messages (OpenAI + Anthropic URL concatenation)","url":null,"state":"closed"},{"kind":"issue","number":15033,"title":"Named custom providers with api_mode: anthropic_messages fail with 404 in auxiliary tasks","url":null,"state":"closed"},{"kind":"issue","number":16254,"title":"[Bug]: 'custom = OpenAI' hardcoded assumption: explicit_base_url path ignores api_mode (sibling of #7661/#15033, plus key_env never parsed in aux config)","url":null,"state":"closed"},{"kind":"issue","number":17086,"title":"Bug: auxiliary tasks fail with HTTP 404 when using anthropic_messages custom endpoint — URL rewrite strips /anthropic/ path","url":null,"state":"closed"},{"kind":"issue","number":17387,"title":"fix: use /anthropic endpoint for MiniMax-CN auxiliary tasks","url":null,"state":"closed"},{"kind":"issue","number":19753,"title":"Auxiliary title generation 404 on custom Anthropic-mode providers (URL double-/v1)","url":null,"state":"open"},{"kind":"issue","number":20514,"title":"`auxiliary.vision.provider: auto` causes HTTP 404 with minimax-cn base_url inheritance","url":null,"state":"closed"},{"kind":"issue","number":21297,"title":"kimi-coding provider returns HTTP 404 on every request — double /v1 in Anthropic Messages URL","url":null,"state":"closed"},{"kind":"issue","number":24833,"title":"Anthropic provider 404s when base_url includes /v1 (SDK adds its own)","url":null,"state":"closed"},{"kind":"issue","number":25104,"title":"[Bug]: kimi-coding Anthropic Messages runtime keeps /coding/v1 and produces /v1/v1/messages","url":null,"state":"closed"},{"kind":"issue","number":32686,"title":"bug(desktop): PROVIDER_BASE_URLS.anthropic hardcoded with /v1 suffix overwrites config.yaml base_url on every restart","url":null,"state":"closed"},{"kind":"issue","number":41211,"title":"Bug: auxiliary title_generation requests /v1/v1/messages (404) when provider base_url ends in /anthropic","url":null,"state":"closed"},{"kind":"issue","number":48991,"title":"[Bug] auxiliary.vision provider=auto does not inherit base_url/api_key from main model for custom providers","url":null,"state":"closed"},{"kind":"pr","number":7648,"title":"fix(aux): honor api_mode for custom auxiliary endpoints","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#12846","number":12846}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":7766,"title":"fix: honor api_mode: anthropic_messages for custom auxiliary endpoints (#7661)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7648","number":7648},{"kind":"pr","value":"#7648","number":7648}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":12846,"title":"fix(anthropic): complete third-party Anthropic-compatible provider support","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","hermes_cli/auth.py","hermes_cli/runtime_provider.py","run_agent.py","scripts/release.py","tests/agent/test_auxiliary_client_anthropic_custom.py","tests/hermes_cli/test_detect_api_mode_for_url.py","tests/hermes_cli/test_update_config_clears_custom_fields.py","tests/run_agent/test_anthropic_prompt_cache_policy.py","tests/run_agent/test_anthropic_third_party_oauth_guard.py","tests/run_agent/test_anthropic_truncation_continuation.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1098,7 +1098,7 @@ def _validate_base_url(base_url: str) -> None:\n         ) from exc\n \n \n-def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:\n+def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:\n     runtime = _resolve_custom_runtime()\n     if len(runtime) == 2:\n         custom_base, custom_key = runtime","@@ -1114,6 +1114,23 @@ def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:\n     if custom_mode == \"codex_responses\":\n         real_client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n         return CodexAuxiliaryClient(real_client, model), model\n+    if custom_mode == \"anthropic_messages\":\n+        # Third-party Anthropic-compatible gateway (MiniMax, Zhipu GLM,\n+        # LiteLLM proxies, etc.).  Must NEVER be treated as OAuth —\n+        # Anthropic OAuth claims only apply to api.anthropic.com.\n+        try:\n+            from agent.anthropic_adapter import build_anthropic_client\n+            real_client = build_anthropic_client(custom_key, custom_base)\n+        except ImportError:\n+            logger.warning(\n+                \"Custom endpoint declares api_mode=anthropic_messages but the \"\n+                \"anthropic SDK is not installed — falling back to OpenAI-wire.\"\n+            )\n+            return OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base), model\n+        return (\n+            AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False),\n+            model,\n+        )\n     return OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base), model\n \n "]},{"file":"hermes_cli/auth.py","hunks":["@@ -2732,6 +2732,17 @@ def _update_config_for_provider(\n         # Clear stale base_url to prevent contamination when switching providers\n         model_cfg.pop(\"base_url\", None)\n \n+    # Clear stale api_key/api_mode left over from a previous custom provider.\n+    # When the user switches from e.g. a MiniMax custom endpoint\n+    # (api_mode=anthropic_messages, api_key=[redacted:secret-assignment] to a built-in provider\n+    # (e.g. OpenRouter), the stale api_key/api_mode would override the new\n+    # provider's credentials and transport choice.  Built-in providers that\n+    # need a specific api_mode (copilot, xai) set it at request-resolution\n+    # time via `_copilot_runtime_api_mode` / `_detect_api_mode_for_url`, so\n+    # removing the persisted value here is safe.\n+    model_cfg.pop(\"api_key\", None)\n+    model_cfg.pop(\"api_mode\", None)\n+\n     # When switching to a non-OpenRouter provider, ensure model.default is\n     # valid for the new provider.  An OpenRouter-formatted name like\n     # \"anthropic/claude-opus-4.6\" will fail on direct-API providers."]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -38,14 +38,21 @@ def _normalize_custom_provider_name(value: str) -> str:\n def _detect_api_mode_for_url(base_url: str) -> Optional[str]:\n     \"\"\"Auto-detect api_mode from the resolved base URL.\n \n-    Direct api.openai.com endpoints need the Responses API for GPT-5.x\n-    tool calls with reasoning (chat/completions returns 400).\n+    - Direct api.openai.com endpoints need the Responses API for GPT-5.x\n+      tool calls with reasoning (chat/completions returns 400).\n+    - Third-party Anthropic-compatible gateways (MiniMax, Zhipu GLM,\n+      LiteLLM proxies, etc.) conventionally expose the native Anthropic\n+      protocol under a ``/anthropic`` suffix — treat those as\n+      ``anthropic_messages`` transport instead of the default\n+      ``chat_completions``.\n     \"\"\"\n     normalized = (base_url or \"\").strip().lower().rstrip(\"/\")\n     if \"api.x.ai\" in normalized:\n         return \"codex_responses\"\n     if \"api.openai.com\" in normalized and \"openrouter\" not in normalized:\n         return \"codex_responses\"\n+    if normalized.endswith(\"/anthropic\"):\n+        return \"anthropic_messages\"\n     return None\n \n ","@@ -194,8 +201,12 @@ def _resolve_runtime_from_pool_entry(\n         elif provider in (\"opencode-zen\", \"opencode-go\"):\n             from hermes_cli.models import opencode_model_api_mode\n             api_mode = opencode_model_api_mode(provider, model_cfg.get(\"default\", \"\"))\n-        elif base_url.rstrip(\"/\").endswith(\"/anthropic\"):\n-            api_mode = \"anthropic_messages\"\n+        else:\n+            # Auto-detect Anthropic-compatible endpoints (/anthropic suffix,\n+            # api.openai.com → codex_responses, api.x.ai → codex_responses).\n+            detected = _detect_api_mode_for_url(base_url)\n+            if detected:\n+                api_mode = detected\n \n     # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the\n     # Anthropic SDK prepends its own /v1/messages to the base_url.  Strip the","@@ -642,8 +653,11 @@ def _resolve_explicit_runtime(\n             configured_mode = _parse_api_mode(model_cfg.get(\"api_mode\"))\n             if configured_mode:\n                 api_mode = configured_mode\n-            elif base_url.rstrip(\"/\").endswith(\"/anthropic\"):\n-                api_mode = \"anthropic_messages\"\n+            else:\n+                # Auto-detect Anthropic-compatible endpoints (/anthropic suffix).\n+                detected = _detect_api_mode_for_url(base_url)\n+                if detected:\n+                    api_mode = detected\n \n         return {\n             \"provider\": provider,","@@ -965,10 +979,13 @@ def resolve_runtime_provider(\n             elif provider in (\"opencode-zen\", \"opencode-go\"):\n                 from hermes_cli.models import opencode_model_api_mode\n                 api_mode = opencode_model_api_mode(provider, model_cfg.get(\"default\", \"\"))\n-            # Auto-detect Anthropic-compatible endpoints by URL convention\n-            # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)\n-            elif base_url.rstrip(\"/\").endswith(\"/anthropic\"):\n-                api_mode = \"anthropic_messages\"\n+            else:\n+                # Auto-detect Anthropic-compatible endpoints by URL convention\n+                # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)\n+                # plus api.openai.com → codex_responses and api.x.ai → codex_responses.\n+                detected = _detect_api_mode_for_url(base_url)\n+                if detected:\n+                    api_mode = detected\n         # Strip trailing /v1 for OpenCode Anthropic models (see comment above).\n         if api_mode == \"anthropic_messages\" and provider in (\"opencode-zen\", \"opencode-go\"):\n             base_url = re.sub(r\"/v1/?$\", \"\", base_url)"]},{"file":"run_agent.py","hunks":["@@ -892,13 +892,15 @@ def __init__(\n         self.prefill_messages = prefill_messages or []  # Prefilled conversation turns\n         self._force_ascii_payload = False\n         \n-        # Anthropic prompt caching: auto-enabled for Claude models via OpenRouter.\n-        # Reduces input costs by ~75% on multi-turn conversations by caching the\n-        # conversation prefix. Uses system_and_3 strategy (4 breakpoints).\n-        is_openrouter = self._is_openrouter_url()\n-        is_claude = \"claude\" in self.model.lower()\n-        is_native_anthropic = self.api_mode == \"anthropic_messages\" and self.provider == \"anthropic\"\n-        self._use_prompt_caching = (is_openrouter and is_claude) or is_native_anthropic\n+        # Anthropic prompt caching: auto-enabled for Claude models on native\n+        # Anthropic, OpenRouter, and third-party gateways that speak the\n+        # Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces\n+        # input costs by ~75% on multi-turn conversations. Uses system_and_3\n+        # strategy (4 breakpoints). See ``_anthropic_prompt_cache_policy``\n+        # for the layout-vs-transport decision.\n+        self._use_prompt_caching, self._use_native_cache_layout = (\n+            self._anthropic_prompt_cache_policy()\n+        )\n         self._cache_ttl = \"5m\"  # Default 5-minute TTL (1.25x write cost)\n         \n         # Iteration budget: the LLM is only notified when it actually exhausts","@@ -1013,8 +1015,15 @@ def __init__(\n                 self.api_key = [redacted:secret-assignment]\n                 self._anthropic_api_key = [redacted:secret-assignment]\n                 self._anthropic_base_url = base_url\n+                # Only mark the session as OAuth-authenticated when the token\n+                # genuinely belongs to native Anthropic.  Third-party providers\n+                # (MiniMax, Kimi, GLM, LiteLLM proxies) that accept the\n+                # Anthropic protocol must never trip OAuth code paths — doing\n+                # so injects Claude-Code identity headers and system prompts\n+                # that cause 401/403 on their endpoints.  Guards #1739 and\n+                # the third-party identity-injection bug.\n                 from agent.anthropic_adapter import _is_oauth_token as _is_oat\n-                self._is_anthropic_oauth = _is_oat(effective_key)\n+                self._is_anthropic_oauth = _is_oat(effective_key) if _is_native_anthropic else False\n                 self._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)\n                 # No OpenAI client needed for Anthropic mode\n                 self.client = None","@@ -1227,7 +1236,12 @@ def __init__(\n         \n         # Show prompt caching status\n         if self._use_prompt_caching and not self.quiet_mode:\n-            source = \"native Anthropic\" if is_native_anthropic else \"Claude via OpenRouter\"\n+            if self._use_native_cache_layout and self.provider == \"anthropic\":\n+                source = \"native Anthropic\"\n+            elif self._use_native_cache_layout:\n+                source = \"Anthropic-compatible endpoint\"\n+            else:\n+                source = \"Claude via OpenRouter\"\n             print(f\"💾 Prompt caching: ENABLED ({source}, {self._cache_ttl} TTL)\")\n         \n         # Session logging setup - auto-save conversation trajectories for debugging","@@ -1701,6 +1715,7 @@ def __init__(\n             \"api_key\": getattr(self, \"api_key\", \"\"),\n             \"client_kwargs\": dict(self._client_kwargs),\n             \"use_prompt_caching\": self._use_prompt_caching,\n+            \"use_native_cache_layout\": self._use_native_cache_layout,\n             # Context engine state that _try_activate_fallback() overwrites.\n             # Use getattr for model/base_url/api_key/provider since plugin\n             # engines may not have these (they're ContextCompressor-specific).","@@ -1822,7 +1837,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod\n                 effective_key, self._anthropic_base_url,\n                 timeout=get_provider_request_timeout(self.provider, self.model),\n             )\n-            self._is_anthropic_oauth = _is_oauth_token(effective_key)\n+            self._is_anthropic_oauth = _is_oauth_token(effective_key) if _is_native_anthropic else False\n             self.client = None\n             self._client_kwargs = {}\n         else:","@@ -1842,10 +1857,13 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod\n             )\n \n         # ── Re-evaluate prompt caching ──\n-        is_native_anthropic = api_mode == \"anthropic_messages\" and new_provider == \"anthropic\"\n-        self._use_prompt_caching = (\n-            (\"openrouter\" in (self.base_url or \"\").lower() and \"claude\" in new_model.lower())\n-            or is_native_anthropic\n+        self._use_prompt_caching, self._use_native_cache_layout = (\n+            self._anthropic_prompt_cache_policy(\n+                provider=new_provider,\n+                base_url=self.base_url,\n+                api_mode=api_mode,\n+                model=new_model,\n+            )\n         )\n \n         # ── Update context compressor ──","@@ -1880,6 +1898,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod\n             \"api_key\": getattr(self, \"api_key\", \"\"),\n             \"client_kwargs\": dict(self._client_kwargs),\n             \"use_prompt_caching\": self._use_prompt_caching,\n+            \"use_native_cache_layout\": self._use_native_cache_layout,\n             \"compressor_model\": getattr(_cc, \"model\", self.model) if _cc else self.model,\n             \"compressor_base_url\": getattr(_cc, \"base_url\", self.base_url) if _cc else self.base_url,\n             \"compressor_api_key\": getattr(_cc, \"api_key\", \"\") if _cc else \"\",","@@ -2143,6 +2162,55 @@ def _is_openrouter_url(self) -> bool:\n         \"\"\"Return True when the base URL targets OpenRouter.\"\"\"\n         return \"openrouter\" in self._base_url_lower\n \n+    def _anthropic_prompt_cache_policy(\n+        self,\n+        *,\n+        provider: Optional[str] = None,\n+        base_url: Optional[str] = None,\n+        api_mode: Optional[str] = None,\n+        model: Optional[str] = None,\n+    ) -> tuple[bool, bool]:\n+        \"\"\"Decide whether to apply Anthropic prompt caching and which layout to use.\n+\n+        Returns ``(should_cache, use_native_layout)``:\n+          * ``should_cache`` — inject ``cache_control`` breakpoints for this\n+            request (applies to OpenRouter Claude, native Anthropic, and\n+            third-party gateways that speak the native Anthropic protocol).\n+          * ``use_native_layout`` — place markers on the *inner* content\n+            blocks (native Anthropic accepts and requires this layout);\n+            when False markers go on the message envelope (OpenRouter and\n+            OpenAI-wire proxies expect the looser layout).\n+\n+        Third-party providers using the native Anthropic transport\n+        (``api_mode == 'anthropic_messages'`` + Claude-named model) get\n+        caching with the native layout so they benefit from the same\n+        cost reduction as direct Anthropic callers, provided their\n+        gateway implements the Anthropic cache_control contract\n+        (MiniMax, Zhipu GLM, LiteLLM's Anthropic proxy mode all do).\n+        \"\"\"\n+        eff_provider = (provider if provider is not None else self.provider) or \"\"\n+        eff_base_url = base_url if base_url is not None else (self.base_url or \"\")\n+        eff_api_mode = api_mode if api_mode is not None else (self.api_mode or \"\")\n+        eff_model = (model if model is not None else self.model) or \"\"\n+\n+        base_lower = eff_base_url.lower()\n+        is_claude = \"claude\" in eff_model.lower()\n+        is_openrouter = \"openrouter\" in base_lower\n+        is_anthropic_wire = eff_api_mode == \"anthropic_messages\"\n+        is_native_anthropic = (\n+            is_anthropic_wire\n+            and (eff_provider == \"anthropic\" or \"api.anthropic.com\" in base_lower)\n+        )\n+\n+        if is_native_anthropic:\n+            return True, True\n+        if is_openrouter and is_claude:\n+            return True, False\n+        if is_anthropic_wire and is_claude:\n+            # Third-party Anthropic-compatible gateway.\n+            return True, True\n+        return False, False\n+\n     @staticmethod\n     def _model_requires_responses_api(model: str) -> bool:\n         \"\"\"Return True for models that require the Responses API path.","@@ -5322,9 +5390,12 @@ def _try_refresh_anthropic_client_credentials(self) -> bool:\n             return False\n \n         self._anthropic_api_key = [redacted:secret-assignment]\n-        # Update OAuth flag — token type may have changed (API key ↔ OAuth)\n+        # Update OAuth flag — token type may have changed (API key ↔ OAuth).\n+        # Only treat as OAuth on native Anthropic; third-party endpoints using\n+        # the Anthropic protocol must not trip OAuth paths (#1739 & third-party\n+        # identity-injection guard).\n         from agent.anthropic_adapter import _is_oauth_token\n-        self._is_anthropic_oauth = _is_oauth_token(new_token)\n+        self._is_anthropic_oauth = _is_oauth_token(new_token) if self.provider == \"anthropic\" else False\n         return True\n \n     def _apply_client_headers_for_base_url(self, base_url: str) -> None:","@@ -5367,7 +5438,7 @@ def _swap_credential(self, entry) -> None:\n                 runtime_key, runtime_base,\n                 timeout=get_provider_request_timeout(self.provider, self.model),\n             )\n-            self._is_anthropic_oauth = _is_oauth_token(runtime_key)\n+            self._is_anthropic_oauth = _is_oauth_token(runtime_key) if self.provider == \"anthropic\" else False\n             self.api_key = [redacted:secret-assignment]\n             self.base_url = runtime_base\n             return","@@ -6523,7 +6594,7 @@ def _try_activate_fallback(self) -> bool:\n                 self._anthropic_client = build_anthropic_client(\n                     effective_key, self._anthropic_base_url, timeout=_fb_timeout,\n                 )\n-                self._is_anthropic_oauth = _is_oauth_token(effective_key)\n+                self._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == \"anthropic\" else False\n                 self.client = None\n                 self._client_kwargs = {}\n             else:","@@ -6554,10 +6625,13 @@ def _try_activate_fallback(self) -> bool:\n                     self._replace_primary_openai_client(reason=\"fallback_timeout_apply\")\n \n             # Re-evaluate prompt caching for the new provider/model\n-            is_native_anthropic = fb_api_mode == \"anthropic_messages\" and fb_provider == \"anthropic\"\n-            self._use_prompt_caching = (\n-                (\"openrouter\" in fb_base_url.lower() and \"claude\" in fb_model.lower())\n-                or is_native_anthropic\n+            self._use_prompt_caching, self._use_native_cache_layout = (\n+                self._anthropic_prompt_cache_policy(\n+                    provider=fb_provider,\n+                    base_url=fb_base_url,\n+                    api_mode=fb_api_mode,\n+                    model=fb_model,\n+                )\n             )\n \n             # Update context compressor limits for the fallback model.","@@ -6617,6 +6691,12 @@ def _restore_primary_runtime(self) -> bool:\n             self.api_key = rt[\"api_key\"]\n             self._client_kwargs = dict(rt[\"client_kwargs\"])\n             self._use_prompt_caching = rt[\"use_prompt_caching\"]\n+            # Default to native layout when the restored snapshot predates the\n+            # native-vs-proxy split (older sessions saved before this PR).\n+            self._use_native_cache_layout = rt.get(\n+                \"use_native_cache_layout\",\n+                self.api_mode == \"anthropic_messages\" and self.provider == \"anthropic\",\n+            )\n \n             # ── Rebuild client for the primary provider ──\n             if self.api_mode == \"anthropic_messages\":","@@ -9317,12 +9397,19 @@ def run_conversation(\n                 for idx, pfm in enumerate(self.prefill_messages):\n                     api_messages.insert(sys_offset + idx, pfm.copy())\n \n-            # Apply Anthropic prompt caching for Claude models via OpenRouter.\n-            # Auto-detected: if model name contains \"claude\" and base_url is OpenRouter,\n-            # inject cache_control breakpoints (system + last 3 messages) to reduce\n-            # input token costs by ~75% on multi-turn conversations.\n+            # Apply Anthropic prompt caching for Claude models on native\n+            # Anthropic, OpenRouter, and third-party Anthropic-compatible\n+            # gateways. Auto-detected: if ``_use_prompt_caching`` is set,\n+            # inject cache_control breakpoints (system + last 3 messages)\n+            # to reduce input token costs by ~75% on multi-turn\n+            # conversations. Layout is chosen per endpoint by\n+            # ``_anthropic_prompt_cache_policy``.\n             if self._use_prompt_caching:\n-                api_messages = apply_anthropic_cache_control(api_messages, cache_ttl=self._cache_ttl, native_anthropic=(self.api_mode == 'anthropic_messages'))\n+                api_messages = apply_anthropic_cache_control(\n+                    api_messages,\n+                    cache_ttl=self._cache_ttl,\n+                    native_anthropic=self._use_native_cache_layout,\n+                )\n \n             # Safety net: strip orphaned tool results / add stubs for missing\n             # results before sending to the API.  Runs unconditionally — not","@@ -9779,25 +9866,30 @@ def _stop_spinner():\n                     if finish_reason == \"length\":\n                         self._vprint(f\"{self.log_prefix}⚠️  Response truncated (finish_reason='length') - model hit max output tokens\", force=True)\n \n+                        # Normalize the truncated response to a single OpenAI-style\n+                        # message shape so text-continuation and tool-call retry\n+                        # work uniformly across chat_completions, bedrock_converse,\n+                        # and anthropic_messages.  For Anthropic we use the same\n+                        # adapter the agent loop already relies on so the rebuilt\n+                        # interim assistant message is byte-identical to what\n+                        # would have been appended in the non-truncated path.\n+                        _trunc_msg = None\n+                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\"):\n+                            _trunc_msg = response.choices[0].message if (hasattr(response, \"choices\") and response.choices) else None\n+                        elif self.api_mode == \"anthropic_messages\":\n+                            from agent.anthropic_adapter import normalize_anthropic_response\n+                            _trunc_msg, _ = normalize_anthropic_response(\n+                                response, strip_tool_prefix=self._is_anthropic_oauth\n+                            )\n+\n+                        _trunc_content = getattr(_trunc_msg, \"content\", None) if _trunc_msg else None\n+                        _trunc_has_tool_calls = bool(getattr(_trunc_msg, \"tool_calls\", None)) if _trunc_msg else False\n+\n                         # ── Detect thinking-budget exhaustion ──────────────\n                         # When the model spends ALL output tokens on reasoning\n                         # and has none left for the response, continuation\n                         # retries are pointless.  Detect this early and give a\n                         # targeted error instead of wasting 3 API calls.\n-                        _trunc_content = None\n-                        _trunc_has_tool_calls = False\n-                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\"):\n-                            _trunc_msg = response.choices[0].message if (hasattr(response, \"choices\") and response.choices) else None\n-                            _trunc_content = getattr(_trunc_msg, \"content\", None) if _trunc_msg else None\n-                            _trunc_has_tool_calls = bool(getattr(_trunc_msg, \"tool_calls\", None)) if _trunc_msg else False\n-                        elif self.api_mode == \"anthropic_messages\":\n-                            # Anthropic response.content is a list of blocks\n-                            _text_parts = []\n-                            for _blk in getattr(response, \"content\", []):\n-                                if getattr(_blk, \"type\", None) == \"text\":\n-                                    _text_parts.append(getattr(_blk, \"text\", \"\"))\n-                            _trunc_content = \"\\n\".join(_text_parts) if _text_parts else None\n-\n                         # A response is \"thinking exhausted\" only when the model\n                         # actually produced reasoning blocks but no visible text after\n                         # them.  Models that do not use <think> tags (e.g. GLM-4.7 on","@@ -9854,9 +9946,9 @@ def _stop_spinner():\n                                 \"error\": _exhaust_error,\n                             }\n \n-                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\"):\n-                            assistant_message = response.choices[0].message\n-                            if not assistant_message.tool_calls:\n+                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\", \"anthropic_messages\"):\n+                            assistant_message = _trunc_msg\n+                            if assistant_message is not None and not _trunc_has_tool_calls:\n                                 length_continue_retries += 1\n                                 interim_msg = self._build_assistant_message(assistant_message, finish_reason)\n                                 messages.append(interim_msg)","@@ -9894,9 +9986,9 @@ def _stop_spinner():\n                                     \"error\": \"Response remained truncated after 3 continuation attempts\",\n                                 }\n \n-                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\"):\n-                            assistant_message = response.choices[0].message\n-                            if assistant_message.tool_calls:\n+                        if self.api_mode in (\"chat_completions\", \"bedrock_converse\", \"anthropic_messages\"):\n+                            assistant_message = _trunc_msg\n+                            if assistant_message is not None and _trunc_has_tool_calls:\n                                 if truncated_tool_call_retries < 1:\n                                     truncated_tool_call_retries += 1\n                                     self._vprint("]},{"file":"scripts/release.py","hunks":["@@ -80,6 +80,13 @@\n     \"[redacted:email]\": \"nish3451\",\n     \"[redacted:email]\": \"Mibayy\",\n     \"[redacted:email]\": \"sgaofen\",\n+    \"[redacted:email]\": \"nocoo\",\n+    \"[redacted:email]\": \"n-WN\",\n+    \"[redacted:email]\": \"keyuyuan\",\n+    \"[redacted:email]\": \"Only-Code-A\",\n+    \"[redacted:email]\": \"TroyMitchell911\",\n+    \"[redacted:email]\": \"zhongyueming1121\",\n+    \"[redacted:email]\": \"hansnow\",\n     # contributors (manual mapping from git names)\n     \"[redacted:email]\": \"asheriif\",\n     \"[redacted:email]\": \"liujinkun2025\","]},{"file":"tests/agent/test_auxiliary_client_anthropic_custom.py","hunks":["@@ -0,0 +1,107 @@\n+\"\"\"Tests for agent.auxiliary_client._try_custom_endpoint's anthropic_messages branch.\n+\n+When a user configures a custom endpoint with ``api_mode: anthropic_messages``\n+(e.g. MiniMax, Zhipu GLM, LiteLLM in Anthropic-proxy mode), auxiliary tasks\n+(compression, web_extract, session_search, title generation) must use the\n+native Anthropic transport rather than being silently downgraded to an\n+OpenAI-wire client that speaks the wrong protocol.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def _clean_env(monkeypatch):\n+    for key in (\n+        \"OPENAI_API_KEY\", \"OPENAI_BASE_URL\",\n+        \"ANTHROPIC_API_KEY\", \"ANTHROPIC_TOKEN\",\n+    ):\n+        monkeypatch.delenv(key, raising=False)\n+\n+\n+def _install_anthropic_adapter_mocks():\n+    \"\"\"Patch build_anthropic_client so the test doesn't need the SDK.\"\"\"\n+    fake_client = MagicMock(name=\"anthropic_client\")\n+    return patch(\n+        \"agent.anthropic_adapter.build_anthropic_client\",\n+        return_value=fake_client,\n+    ), fake_client\n+\n+\n+def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():\n+    \"\"\"api_mode=anthropic_messages → returns AnthropicAuxiliaryClient, not OpenAI.\"\"\"\n+    from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient\n+\n+    with patch(\n+        \"agent.auxiliary_client._resolve_custom_runtime\",\n+        return_value=(\n+            \"https://api.minimax.io/anthropic\",\n+            \"minimax-key\",\n+            \"anthropic_messages\",\n+        ),\n+    ), patch(\n+        \"agent.auxiliary_client._read_main_model\",\n+        return_value=\"claude-sonnet-4-6\",\n+    ):\n+        adapter_patch, fake_client = _install_anthropic_adapter_mocks()\n+        with adapter_patch:\n+            client, model = _try_custom_endpoint()\n+\n+    assert isinstance(client, AnthropicAuxiliaryClient), (\n+        \"Custom endpoint with api_mode=anthropic_messages must return the \"\n+        f\"native Anthropic wrapper, got {type(client).__name__}\"\n+    )\n+    assert model == \"claude-sonnet-4-6\"\n+    # Wrapper should NOT be marked as OAuth — third-party endpoints are\n+    # always API-key authenticated.\n+    assert client.api_key == \"minimax-key\"\n+    assert client.base_url == \"https://api.minimax.io/anthropic\"\n+\n+\n+def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():\n+    \"\"\"Graceful degradation when anthropic SDK is unavailable.\"\"\"\n+    from agent.auxiliary_client import _try_custom_endpoint\n+\n+    import_error = ImportError(\"anthropic package not installed\")\n+\n+    with patch(\n+        \"agent.auxiliary_client._resolve_custom_runtime\",\n+        return_value=(\"https://api.minimax.io/anthropic\", \"k\", \"anthropic_messages\"),\n+    ), patch(\n+        \"agent.auxiliary_client._read_main_model\",\n+        return_value=\"claude-sonnet-4-6\",\n+    ), patch(\n+        \"agent.anthropic_adapter.build_anthropic_client\",\n+        side_effect=import_error,\n+    ):\n+        client, model = _try_custom_endpoint()\n+\n+    # Should fall back to an OpenAI-wire client rather than returning\n+    # (None, None) — the tool still needs to do *something*.\n+    assert client is not None\n+    assert model == \"claude-sonnet-4-6\"\n+    # OpenAI client, not AnthropicAuxiliaryClient.\n+    from agent.auxiliary_client import AnthropicAuxiliaryClient\n+    assert not isinstance(client, AnthropicAuxiliaryClient)\n+\n+\n+def test_custom_endpoint_chat_completions_still_uses_openai_wire():\n+    \"\"\"Regression: default path (no api_mode) must remain OpenAI client.\"\"\"\n+    from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient\n+\n+    with patch(\n+        \"agent.auxiliary_client._resolve_custom_runtime\",\n+        return_value=(\"https://api.example.com/v1\", \"key\", None),\n+    ), patch(\n+        \"agent.auxiliary_client._read_main_model\",\n+        return_value=\"my-model\",\n+    ):\n+        client, model = _try_custom_endpoint()\n+\n+    assert client is not None\n+    assert model == \"my-model\"\n+    assert not isinstance(client, AnthropicAuxiliaryClient)"]},{"file":"tests/hermes_cli/test_detect_api_mode_for_url.py","hunks":["@@ -0,0 +1,70 @@\n+\"\"\"Tests for hermes_cli.runtime_provider._detect_api_mode_for_url.\n+\n+The helper maps base URLs to api_modes for three cases:\n+  * api.openai.com  → codex_responses\n+  * api.x.ai        → codex_responses\n+  * */anthropic     → anthropic_messages (third-party gateways like MiniMax,\n+                                          Zhipu GLM, LiteLLM proxies)\n+\n+Consolidating the /anthropic detection in this helper (instead of three\n+inline ``endswith`` checks spread across _resolve_runtime_from_pool_entry,\n+the explicit-provider path, and the api-key-provider path) means every\n+future update to the detection logic lives in one place.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from hermes_cli.runtime_provider import _detect_api_mode_for_url\n+\n+\n+class TestCodexResponsesDetection:\n+    def test_openai_api_returns_codex_responses(self):\n+        assert _detect_api_mode_for_url(\"https://api.openai.com/v1\") == \"codex_responses\"\n+\n+    def test_xai_api_returns_codex_responses(self):\n+        assert _detect_api_mode_for_url(\"https://api.x.ai/v1\") == \"codex_responses\"\n+\n+    def test_openrouter_is_not_codex_responses(self):\n+        # api.openai.com check must exclude openrouter (which routes to openai-hosted models).\n+        assert _detect_api_mode_for_url(\"https://openrouter.ai/api/v1\") is None\n+\n+\n+class TestAnthropicMessagesDetection:\n+    \"\"\"Third-party gateways that speak the Anthropic protocol under /anthropic.\"\"\"\n+\n+    def test_minimax_anthropic_endpoint(self):\n+        assert _detect_api_mode_for_url(\"https://api.minimax.io/anthropic\") == \"anthropic_messages\"\n+\n+    def test_minimax_cn_anthropic_endpoint(self):\n+        assert _detect_api_mode_for_url(\"https://api.minimaxi.com/anthropic\") == \"anthropic_messages\"\n+\n+    def test_dashscope_anthropic_endpoint(self):\n+        assert (\n+            _detect_api_mode_for_url(\"https://dashscope.aliyuncs.com/api/v2/apps/anthropic\")\n+            == \"anthropic_messages\"\n+        )\n+\n+    def test_trailing_slash_tolerated(self):\n+        assert _detect_api_mode_for_url(\"https://api.minimax.io/anthropic/\") == \"anthropic_messages\"\n+\n+    def test_uppercase_path_tolerated(self):\n+        assert _detect_api_mode_for_url(\"https://API.MINIMAX.IO/Anthropic\") == \"anthropic_messages\"\n+\n+    def test_anthropic_in_middle_of_path_does_not_match(self):\n+        # The helper requires ``/anthropic`` as the path SUFFIX, not anywhere.\n+        # Protects against false positives on e.g. /anthropic/v1/models.\n+        assert _detect_api_mode_for_url(\"https://api.example.com/anthropic/v1\") is None\n+\n+\n+class TestDefaultCase:\n+    def test_generic_url_returns_none(self):\n+        assert _detect_api_mode_for_url(\"https://api.together.xyz/v1\") is None\n+\n+    def test_empty_string_returns_none(self):\n+        assert _detect_api_mode_for_url(\"\") is None\n+\n+    def test_none_returns_none(self):\n+        assert _detect_api_mode_for_url(None) is None\n+\n+    def test_localhost_returns_none(self):\n+        assert _detect_api_mode_for_url(\"http://localhost:11434/v1\") is None"]},{"file":"tests/hermes_cli/test_update_config_clears_custom_fields.py","hunks":["@@ -0,0 +1,84 @@\n+\"\"\"Tests for hermes_cli.auth._update_config_for_provider clearing stale fields.\n+\n+When the user switches from a custom provider (e.g. MiniMax with\n+``api_mode: anthropic_messages``, ``api_key: [redacted:secret-assignment] to a built-in\n+provider (e.g. OpenRouter), the stale ``api_key`` and ``api_mode`` would\n+otherwise override the new provider's credentials and transport choice.\n+\n+Built-in providers that legitimately need a specific ``api_mode`` (copilot,\n+xai) compute it at request-resolution time in\n+``_copilot_runtime_api_mode`` / ``_detect_api_mode_for_url``, so removing\n+the persisted value here is safe.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import yaml\n+\n+from hermes_cli.auth import _update_config_for_provider\n+from hermes_cli.config import get_config_path\n+\n+\n+def _read_model_cfg() -> dict:\n+    path = get_config_path()\n+    if not path.exists():\n+        return {}\n+    data = yaml.safe_load(path.read_text()) or {}\n+    model = data.get(\"model\", {})\n+    return model if isinstance(model, dict) else {}\n+\n+\n+def _seed_custom_provider_config(api_mode: str = \"anthropic_messages\") -> None:\n+    \"\"\"Write a config.yaml mimicking a user on a MiniMax-style custom provider.\"\"\"\n+    path = get_config_path()\n+    path.parent.mkdir(parents=True, exist_ok=True)\n+    path.write_text(\n+        yaml.safe_dump(\n+            {\n+                \"model\": {\n+                    \"provider\": \"custom\",\n+                    \"base_url\": \"https://api.minimax.io/anthropic\",\n+                    \"api_key\": \"mxp-stale-key\",\n+                    \"api_mode\": api_mode,\n+                    \"default\": \"claude-sonnet-4-6\",\n+                }\n+            },\n+            sort_keys=False,\n+        )\n+    )\n+\n+\n+class TestUpdateConfigForProviderClearsStaleCustomFields:\n+    def test_switching_to_openrouter_clears_api_key_and_api_mode(self):\n+        _seed_custom_provider_config()\n+\n+        _update_config_for_provider(\n+            \"openrouter\",\n+            \"https://openrouter.ai/api/v1\",\n+            default_model=\"anthropic/claude-sonnet-4.6\",\n+        )\n+\n+        model_cfg = _read_model_cfg()\n+        assert model_cfg.get(\"provider\") == \"openrouter\"\n+        assert model_cfg.get(\"base_url\") == \"https://openrouter.ai/api/v1\"\n+        assert \"api_key\" not in model_cfg, (\n+            \"Stale custom api_key would leak into OpenRouter requests — must be cleared\"\n+        )\n+        assert \"api_mode\" not in model_cfg, (\n+            \"Stale api_mode=anthropic_messages from MiniMax would mis-route \"\n+            \"OpenRouter requests to the Anthropic SDK — must be cleared\"\n+        )\n+\n+    def test_switching_to_nous_clears_stale_api_mode(self):\n+        _seed_custom_provider_config()\n+        _update_config_for_provider(\"nous\", \"https://inference-api.nousresearch.com/v1\")\n+        model_cfg = _read_model_cfg()\n+        assert model_cfg.get(\"provider\") == \"nous\"\n+        assert \"api_mode\" not in model_cfg\n+        assert \"api_key\" not in model_cfg\n+\n+    def test_switching_clears_codex_responses_api_mode(self):\n+        \"\"\"Also covers codex_responses, not just anthropic_messages.\"\"\"\n+        _seed_custom_provider_config(api_mode=\"codex_responses\")\n+        _update_config_for_provider(\"openrouter\", \"https://openrouter.ai/api/v1\")\n+        assert \"api_mode\" not in _read_model_cfg()"]},{"file":"tests/run_agent/test_anthropic_prompt_cache_policy.py","hunks":["@@ -0,0 +1,152 @@\n+\"\"\"Tests for AIAgent._anthropic_prompt_cache_policy().\n+\n+The policy returns ``(should_cache, use_native_layout)`` for five endpoint\n+classes. The test matrix pins the decision for each so a regression (e.g.\n+silently dropping caching on third-party Anthropic gateways, or applying\n+the native layout on OpenRouter) surfaces loudly.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import MagicMock\n+\n+from run_agent import AIAgent\n+\n+\n+def _make_agent(\n+    *,\n+    provider: str = \"openrouter\",\n+    base_url: str = \"https://openrouter.ai/api/v1\",\n+    api_mode: str = \"chat_completions\",\n+    model: str = \"anthropic/claude-sonnet-4.6\",\n+) -> AIAgent:\n+    agent = AIAgent.__new__(AIAgent)\n+    agent.provider = provider\n+    agent.base_url = base_url\n+    agent.api_mode = api_mode\n+    agent.model = model\n+    agent._base_url_lower = (base_url or \"\").lower()\n+    agent.client = MagicMock()\n+    agent.quiet_mode = True\n+    return agent\n+\n+\n+class TestNativeAnthropic:\n+    def test_claude_on_native_anthropic_caches_with_native_layout(self):\n+        agent = _make_agent(\n+            provider=\"anthropic\",\n+            base_url=\"https://api.anthropic.com\",\n+            api_mode=\"anthropic_messages\",\n+            model=\"claude-sonnet-4-6\",\n+        )\n+        assert agent._anthropic_prompt_cache_policy() == (True, True)\n+\n+    def test_api_anthropic_host_detected_even_when_provider_label_differs(self):\n+        # Some pool configurations label native Anthropic as \"anthropic-direct\"\n+        # or similar; falling back to hostname keeps caching on.\n+        agent = _make_agent(\n+            provider=\"anthropic-direct\",\n+            base_url=\"https://api.anthropic.com\",\n+            api_mode=\"anthropic_messages\",\n+            model=\"claude-opus-4.6\",\n+        )\n+        assert agent._anthropic_prompt_cache_policy() == (True, True)\n+\n+\n+class TestOpenRouter:\n+    def test_claude_on_openrouter_caches_with_envelope_layout(self):\n+        agent = _make_agent(\n+            provider=\"openrouter\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            api_mode=\"chat_completions\",\n+            model=\"anthropic/claude-sonnet-4.6\",\n+        )\n+        should, native = agent._anthropic_prompt_cache_policy()\n+        assert should is True\n+        assert native is False  # OpenRouter uses envelope layout\n+\n+    def test_non_claude_on_openrouter_does_not_cache(self):\n+        agent = _make_agent(\n+            provider=\"openrouter\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            api_mode=\"chat_completions\",\n+            model=\"openai/gpt-5.4\",\n+        )\n+        assert agent._anthropic_prompt_cache_policy() == (False, False)\n+\n+\n+class TestThirdPartyAnthropicGateway:\n+    \"\"\"Third-party gateways speaking the Anthropic protocol (MiniMax, Zhipu GLM, LiteLLM).\"\"\"\n+\n+    def test_minimax_claude_via_anthropic_messages(self):\n+        agent = _make_agent(\n+            provider=\"custom\",\n+            base_url=\"https://api.minimax.io/anthropic\",\n+            api_mode=\"anthropic_messages\",\n+            model=\"claude-sonnet-4-6\",\n+        )\n+        should, native = agent._anthropic_prompt_cache_policy()\n+        assert should is True, \"Third-party Anthropic gateway with Claude must cache\"\n+        assert native is True, \"Third-party Anthropic gateway uses native cache_control layout\"\n+\n+    def test_third_party_without_claude_name_does_not_cache(self):\n+        # A provider exposing e.g. GLM via anthropic_messages transport — we\n+        # don't know whether it supports cache_control, so stay conservative.\n+        agent = _make_agent(\n+            provider=\"custom\",\n+            base_url=\"https://api.minimax.io/anthropic\",\n+            api_mode=\"anthropic_messages\",\n+            model=\"minimax-m2.7\",\n+        )\n+        assert agent._anthropic_prompt_cache_policy() == (False, False)\n+\n+\n+class TestOpenAIWireFormatOnCustomProvider:\n+    \"\"\"A custom provider using chat_completions (OpenAI wire) should NOT get caching.\"\"\"\n+\n+    def test_custom_openai_wire_does_not_cache_even_with_claude_name(self):\n+        # This is the blocklist risk #9621 failed to avoid: sending\n+        # cache_control fields in OpenAI-wire JSON can trip strict providers\n+        # that reject unknown keys.  Stay off unless the transport is\n+        # explicitly anthropic_messages or the aggregator is OpenRouter.\n+        agent = _make_agent(\n+            provider=\"custom\",\n+            base_url=\"https://api.fireworks.ai/inference/v1\",\n+            api_mode=\"chat_completions\",\n+            model=\"claude-sonnet-4\",\n+        )\n+        assert agent._anthropic_prompt_cache_policy() == (False, False)\n+\n+\n+class TestExplicitOverrides:\n+    \"\"\"Policy accepts keyword overrides for switch_model / fallback activation.\"\"\"\n+\n+    def test_overrides_take_precedence_over_self(self):\n+        agent = _make_agent(\n+            provider=\"openrouter\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            api_mode=\"chat_completions\",\n+            model=\"openai/gpt-5.4\",\n+        )\n+        # Simulate switch_model evaluating cache policy for a Claude target\n+        # before self.model is mutated.\n+        should, native = agent._anthropic_prompt_cache_policy(\n+            model=\"anthropic/claude-sonnet-4.6\",\n+        )\n+        assert (should, native) == (True, False)\n+\n+    def test_fallback_target_evaluated_independently(self):\n+        # Starting on native Anthropic but falling back to OpenRouter.\n+        agent = _make_agent(\n+            provider=\"anthropic\",\n+            base_url=\"https://api.anthropic.com\",\n+            api_mode=\"anthropic_messages\",\n+            model=\"claude-opus-4.6\",\n+        )\n+        should, native = agent._anthropic_prompt_cache_policy(\n+            provider=\"openrouter\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            api_mode=\"chat_completions\",\n+            model=\"anthropic/claude-sonnet-4.6\",\n+        )\n+        assert (should, native) == (True, False)"]},{"file":"tests/run_agent/test_anthropic_third_party_oauth_guard.py","hunks":["@@ -0,0 +1,182 @@\n+\"\"\"Tests for ``_is_anthropic_oauth`` guard against third-party Anthropic-compatible providers.\n+\n+The invariant: ``self._is_anthropic_oauth`` must only ever be True when\n+``self.provider == 'anthropic'`` (native Anthropic).  Third-party providers\n+that speak the Anthropic protocol (MiniMax, Zhipu GLM, Alibaba DashScope,\n+Kimi, LiteLLM proxies, etc.) must never trip OAuth code paths — doing so\n+injects Claude-Code identity headers and system prompts that cause\n+401/403 from those endpoints.\n+\n+This test class covers all FIVE sites that assign ``_is_anthropic_oauth``:\n+\n+1. ``AIAgent.__init__``                              (line ~1022)\n+2. ``AIAgent.switch_model``                          (line ~1832)\n+3. ``AIAgent._try_refresh_anthropic_client_credentials`` (line ~5335)\n+4. ``AIAgent._swap_credential``                      (line ~5378)\n+5. ``AIAgent._try_activate_fallback``                (line ~6536)\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+\n+\n+# A plausible-looking OAuth token (``sk-ant-`` without the ``-api`` suffix).\n+_OAUTH_LIKE_TOKEN = \"[redacted:secret-assignment]\"\n+_API_KEY_TOKEN = \"[redacted:secret-assignment]\"\n+\n+\n[redacted:email]\n+def agent():\n+    \"\"\"Minimal AIAgent construction, skipping tool discovery.\"\"\"\n+    with (\n+        patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        a = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+        a.client = MagicMock()\n+        return a\n+\n+\n+class TestOAuthFlagOnRefresh:\n+    \"\"\"Site 3 — _try_refresh_anthropic_client_credentials.\"\"\"\n+\n+    def test_third_party_provider_refresh_is_noop(self, agent):\n+        \"\"\"Refresh path returns False immediately when provider != anthropic — the\n+        OAuth flag can never be mutated for third-party providers. Double-defended\n+        by the per-assignment guard at line ~5393 so future refactors can't\n+        reintroduce the bug.\"\"\"\n+        agent.api_mode = \"anthropic_messages\"\n+        agent.provider = \"minimax\"          # ← third-party\n+        agent._anthropic_api_key = \"***\"\n+        agent._anthropic_client = MagicMock()\n+        agent._is_anthropic_oauth = False\n+\n+        with (\n+            patch(\"agent.anthropic_adapter.resolve_anthropic_token\",\n+                  return_value=_OAUTH_LIKE_TOKEN),\n+            patch(\"agent.anthropic_adapter.build_anthropic_client\",\n+                  return_value=MagicMock()),\n+        ):\n+            result = agent._try_refresh_anthropic_client_credentials()\n+\n+        # The function short-circuits on non-anthropic providers.\n+        assert result is False\n+        # And the flag is untouched regardless.\n+        assert agent._is_anthropic_oauth is False\n+\n+    def test_native_anthropic_preserves_existing_oauth_behaviour(self, agent):\n+        \"\"\"Regression: native anthropic with OAuth token still flips flag to True.\"\"\"\n+        agent.api_mode = \"anthropic_messages\"\n+        agent.provider = \"anthropic\"\n+        agent._anthropic_api_key = \"***\"\n+        agent._anthropic_client = MagicMock()\n+        agent._is_anthropic_oauth = False\n+\n+        with (\n+            patch(\"agent.anthropic_adapter.resolve_anthropic_token\",\n+                  return_value=_OAUTH_LIKE_TOKEN),\n+            patch(\"agent.anthropic_adapter.build_anthropic_client\",\n+                  return_value=MagicMock()),\n+        ):\n+            result = agent._try_refresh_anthropic_client_credentials()\n+\n+        assert result is True\n+        assert agent._is_anthropic_oauth is True\n+\n+\n+class TestOAuthFlagOnCredentialSwap:\n+    \"\"\"Site 4 — _swap_credential (credential pool rotation).\"\"\"\n+\n+    def test_pool_swap_on_third_party_never_flips_oauth(self, agent):\n+        agent.api_mode = \"anthropic_messages\"\n+        agent.provider = \"glm\"              # ← Zhipu GLM via /anthropic\n+        agent._anthropic_api_key = \"[redacted:secret-assignment]\"\n+        agent._anthropic_base_url = \"https://open.bigmodel.cn/api/anthropic\"\n+        agent._anthropic_client = MagicMock()\n+        agent._is_anthropic_oauth = False\n+\n+        entry = MagicMock()\n+        entry.runtime_api_key = [redacted:secret-assignment]\n+        entry.runtime_base_url = \"https://open.bigmodel.cn/api/anthropic\"\n+\n+        with patch(\"agent.anthropic_adapter.build_anthropic_client\",\n+                   return_value=MagicMock()):\n+            agent._swap_credential(entry)\n+\n+        assert agent._is_anthropic_oauth is False\n+\n+\n+class TestOAuthFlagOnConstruction:\n+    \"\"\"Site 1 — AIAgent.__init__ on a third-party anthropic_messages provider.\"\"\"\n+\n+    def test_minimax_init_does_not_flip_oauth(self):\n+        with (\n+            patch(\"run_agent.get_tool_definitions\", return_value=[]),\n+            patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+            patch(\"agent.anthropic_adapter.build_anthropic_client\",\n+                  return_value=MagicMock()),\n+            # Simulate a stale ANTHROPIC_TOKEN in the env — the init code\n+            # MUST NOT fall back to it when provider != anthropic.\n+            patch(\"agent.anthropic_adapter.resolve_anthropic_token\",\n+                  return_value=_OAUTH_LIKE_TOKEN),\n+        ):\n+            agent = AIAgent(\n+                api_key=\"[redacted:secret-assignment]\",\n+                base_url=\"https://api.minimax.io/anthropic\",\n+                provider=\"minimax\",\n+                api_mode=\"anthropic_messages\",\n+                model=\"claude-sonnet-4-6\",\n+                quiet_mode=True,\n+                skip_context_files=True,\n+                skip_memory=True,\n+            )\n+\n+        # The effective key should be the explicit minimax-key, not the\n+        # stale Anthropic OAuth token, and the OAuth flag must be False.\n+        assert agent._anthropic_api_key == \"minimax-key-1234\"\n+        assert agent._is_anthropic_oauth is False\n+\n+\n+class TestOAuthFlagOnFallbackActivation:\n+    \"\"\"Site 5 — _try_activate_fallback targeting a third-party Anthropic endpoint.\"\"\"\n+\n+    def test_fallback_to_third_party_does_not_flip_oauth(self, agent):\n+        \"\"\"Directly mimic the post-fallback assignment at line ~6537.\"\"\"\n+        from agent.anthropic_adapter import _is_oauth_token\n+\n+        # Emulate the relevant lines of _try_activate_fallback without\n+        # running the entire recovery stack (which pulls in streaming,\n+        # sessions, etc.).\n+        fb_provider = \"minimax\"\n+        effective_key = _OAUTH_LIKE_TOKEN\n+        agent._is_anthropic_oauth = (\n+            _is_oauth_token(effective_key) if fb_provider == \"anthropic\" else False\n+        )\n+        assert agent._is_anthropic_oauth is False\n+\n+\n+class TestApiKeyTokensAlwaysSafe:\n+    \"\"\"Regression: plain API-key shapes must always resolve to non-OAuth, any provider.\"\"\"\n+\n+    def test_native_anthropic_with_api_key_token(self):\n+        from agent.anthropic_adapter import _is_oauth_token\n+        assert _is_oauth_token(_API_KEY_TOKEN) is False\n+\n+    def test_third_party_key_shape(self):\n+        from agent.anthropic_adapter import _is_oauth_token\n+        # Third-party key shapes (MiniMax 'mxp-...', GLM 'glm.sess.', etc.)\n+        # already return False from _is_oauth_token; the guard adds a second\n+        # defense line in case future token formats accidentally look OAuth-y.\n+        assert _is_oauth_token(\"mxp-abcdef123\") is False"]},{"file":"tests/run_agent/test_anthropic_truncation_continuation.py","hunks":["@@ -0,0 +1,114 @@\n+\"\"\"Regression test for anthropic_messages truncation continuation.\n+\n+When an Anthropic response hits ``stop_reason: max_tokens`` (mapped to\n+``finish_reason == 'length'`` in run_agent), the agent must retry with\n+a continuation prompt — the same behavior it has always had for\n+chat_completions and bedrock_converse.  Before this PR, the\n+``if self.api_mode in ('chat_completions', 'bedrock_converse'):`` guard\n+silently dropped Anthropic-wire truncations on the floor, returning a\n+half-finished response with no retry.\n+\n+We don't exercise the full agent loop here (it's 3000 lines of inference,\n+streaming, plugin hooks, etc.) — instead we verify the normalization\n+adapter produces exactly the shape the continuation block now consumes.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+\n+def _make_anthropic_text_block(text: str) -> SimpleNamespace:\n+    return SimpleNamespace(type=\"text\", text=text)\n+\n+\n+def _make_anthropic_tool_use_block(name: str = \"my_tool\") -> SimpleNamespace:\n+    return SimpleNamespace(\n+        type=\"tool_use\",\n+        id=\"toolu_01\",\n+        name=name,\n+        input={\"foo\": \"bar\"},\n+    )\n+\n+\n+def _make_anthropic_response(blocks, stop_reason: str = \"max_tokens\"):\n+    return SimpleNamespace(\n+        id=\"msg_01\",\n+        type=\"message\",\n+        role=\"assistant\",\n+        model=\"claude-sonnet-4-6\",\n+        content=blocks,\n+        stop_reason=stop_reason,\n+        stop_sequence=None,\n+        usage=SimpleNamespace(input_tokens=100, output_tokens=200),\n+    )\n+\n+\n+class TestTruncatedAnthropicResponseNormalization:\n+    \"\"\"normalize_anthropic_response() gives us the shape _build_assistant_message expects.\"\"\"\n+\n+    def test_text_only_truncation_produces_text_content_no_tool_calls(self):\n+        \"\"\"Pure-text Anthropic truncation → continuation path should fire.\"\"\"\n+        from agent.anthropic_adapter import normalize_anthropic_response\n+\n+        response = _make_anthropic_response(\n+            [_make_anthropic_text_block(\"partial response that was cut off\")]\n+        )\n+        msg, finish = normalize_anthropic_response(response)\n+\n+        # The continuation block checks these two attributes:\n+        #   assistant_message.content  → appended to truncated_response_prefix\n+        #   assistant_message.tool_calls → guards the text-retry branch\n+        assert msg.content is not None\n+        assert \"partial response\" in msg.content\n+        assert not msg.tool_calls, (\n+            \"Pure-text truncation must have no tool_calls so the text-continuation \"\n+            \"branch (not the tool-retry branch) fires\"\n+        )\n+        assert finish == \"length\", \"max_tokens stop_reason must map to OpenAI-style 'length'\"\n+\n+    def test_truncated_tool_call_produces_tool_calls(self):\n+        \"\"\"Tool-use truncation → tool-call retry path should fire.\"\"\"\n+        from agent.anthropic_adapter import normalize_anthropic_response\n+\n+        response = _make_anthropic_response(\n+            [\n+                _make_anthropic_text_block(\"thinking...\"),\n+                _make_anthropic_tool_use_block(),\n+            ]\n+        )\n+        msg, finish = normalize_anthropic_response(response)\n+\n+        assert bool(msg.tool_calls), (\n+            \"Truncation mid-tool_use must expose tool_calls so the \"\n+            \"tool-call retry branch fires instead of text continuation\"\n+        )\n+        assert finish == \"length\"\n+\n+    def test_empty_content_does_not_crash(self):\n+        \"\"\"Empty response.content — defensive: treat as a truncation with no text.\"\"\"\n+        from agent.anthropic_adapter import normalize_anthropic_response\n+\n+        response = _make_anthropic_response([])\n+        msg, finish = normalize_anthropic_response(response)\n+        # Depending on the adapter, content may be \"\" or None — both are\n+        # acceptable; what matters is no exception.\n+        assert msg is not None\n+        assert not msg.tool_calls\n+\n+\n+class TestContinuationLogicBranching:\n+    \"\"\"Symbolic check that the api_mode gate now includes anthropic_messages.\"\"\"\n+\n+    @pytest.mark.parametrize(\"api_mode\", [\"chat_completions\", \"bedrock_converse\", \"anthropic_messages\"])\n+    def test_all_three_api_modes_hit_continuation_branch(self, api_mode):\n+        # The guard in run_agent.py is:\n+        #   if self.api_mode in (\"chat_completions\", \"bedrock_converse\", \"anthropic_messages\"):\n+        assert api_mode in (\"chat_completions\", \"bedrock_converse\", \"anthropic_messages\")\n+\n+    def test_codex_responses_still_excluded(self):\n+        # codex_responses has its own truncation path (not continuation-based)\n+        # and should NOT be routed through the shared block.\n+        assert \"codex_responses\" not in (\"chat_completions\", \"bedrock_converse\", \"anthropic_messages\")\n"]}]},{"kind":"pr","number":15059,"title":"fix(aux-client): honor api_mode: anthropic_messages for named custom providers","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","hermes_cli/runtime_provider.py","tests/agent/test_auxiliary_named_custom_providers.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -1736,7 +1736,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n                        \"but no endpoint credentials found\")\n         return None, None\n \n-    # ── Named custom providers (config.yaml custom_providers list) ───\n+    # ── Named custom providers (config.yaml providers dict / custom_providers list) ───\n     try:\n         from hermes_cli.runtime_provider import _get_named_custom_provider\n         custom_entry = _get_named_custom_provider(provider)","@@ -1747,16 +1747,51 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = \"\"):\n             if not custom_key and custom_key_env:\n                 custom_key = os.getenv(custom_key_env, \"\").strip()\n             custom_key = custom_key or \"no-key-required\"\n+            # An explicit per-task api_mode override (from _resolve_task_provider_model)\n+            # wins; otherwise fall back to what the provider entry declared.\n+            entry_api_mode = (api_mode or custom_entry.get(\"api_mode\") or \"\").strip()\n             if custom_base:\n                 final_model = _normalize_resolved_model(\n                     model or custom_entry.get(\"model\") or _read_main_model() or \"gpt-4o-mini\",\n                     provider,\n                 )\n-                client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n-                client = _wrap_if_needed(client, final_model, custom_base)\n                 logger.debug(\n-                    \"resolve_provider_client: named custom provider %r (%s)\",\n-                    provider, final_model)\n+                    \"resolve_provider_client: named custom provider %r (%s, api_mode=%s)\",\n+                    provider, final_model, entry_api_mode or \"chat_completions\")\n+                # anthropic_messages: route through the Anthropic Messages API\n+                # via AnthropicAuxiliaryClient. Mirrors the anonymous-custom\n+                # branch in _try_custom_endpoint(). See #15033.\n+                if entry_api_mode == \"anthropic_messages\":\n+                    try:\n+                        from agent.anthropic_adapter import build_anthropic_client\n+                        real_client = build_anthropic_client(custom_key, custom_base)\n+                    except ImportError:\n+                        logger.warning(\n+                            \"Named custom provider %r declares api_mode=\"\n+                            \"anthropic_messages but the anthropic SDK is not \"\n+                            \"installed — falling back to OpenAI-wire.\",\n+                            provider,\n+                        )\n+                        client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n+                        return (_to_async_client(client, final_model) if async_mode\n+                                else (client, final_model))\n+                    sync_anthropic = AnthropicAuxiliaryClient(\n+                        real_client, final_model, custom_key, custom_base, is_oauth=False,\n+                    )\n+                    if async_mode:\n+                        return AsyncAnthropicAuxiliaryClient(sync_anthropic), final_model\n+                    return sync_anthropic, final_model\n+                client = OpenAI(api_key=[redacted:secret-assignment] base_url=custom_base)\n+                # codex_responses or inherited auto-detect (via _wrap_if_needed).\n+                # _wrap_if_needed reads the closed-over `api_mode` (the task-level\n+                # override). Named-provider entry api_mode=codex_responses also\n+                # flows through here.\n+                if entry_api_mode == \"codex_responses\" and not isinstance(\n+                    client, CodexAuxiliaryClient\n+                ):\n+                    client = CodexAuxiliaryClient(client, final_model)\n+                else:\n+                    client = _wrap_if_needed(client, final_model, custom_base)\n                 return (_to_async_client(client, final_model) if async_mode\n                         else (client, final_model))\n             logger.warning("]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -323,12 +323,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n                 # Found match by provider key\n                 base_url = entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n                 if base_url:\n-                    return {\n+                    result = {\n                         \"name\": entry.get(\"name\", ep_name),\n                         \"base_url\": base_url.strip(),\n                         \"api_key\": resolved_api_key,\n                         \"model\": entry.get(\"default_model\", \"\"),\n                     }\n+                    api_mode = _parse_api_mode(entry.get(\"api_mode\"))\n+                    if api_mode:\n+                        result[\"api_mode\"] = api_mode\n+                    return result\n             # Also check the 'name' field if present\n             display_name = entry.get(\"name\", \"\")\n             if display_name:","@@ -337,12 +341,16 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An\n                     # Found match by display name\n                     base_url = entry.get(\"api\") or entry.get(\"url\") or entry.get(\"base_url\") or \"\"\n                     if base_url:\n-                        return {\n+                        result = {\n                             \"name\": display_name,\n                             \"base_url\": base_url.strip(),\n                             \"api_key\": resolved_api_key,\n                             \"model\": entry.get(\"default_model\", \"\"),\n                         }\n+                        api_mode = _parse_api_mode(entry.get(\"api_mode\"))\n+                        if api_mode:\n+                            result[\"api_mode\"] = api_mode\n+                        return result\n \n     # Fall back to custom_providers: list (legacy format)\n     custom_providers = config.get(\"custom_providers\")"]},{"file":"tests/agent/test_auxiliary_named_custom_providers.py","hunks":["@@ -252,3 +252,158 @@ def test_explicit_provider_passes_api_mode(self, tmp_path):\n         mock_gcc.assert_called_once()\n         _, kwargs = mock_gcc.call_args\n         assert kwargs.get(\"api_mode\") == \"chat_completions\"\n+\n+\n+class TestProvidersDictApiModeAnthropicMessages:\n+    \"\"\"Regression guard for #15033.\n+\n+    Named providers declared under the ``providers:`` dict with\n+    ``api_mode: anthropic_messages`` must route auxiliary calls through\n+    the Anthropic Messages API (via AnthropicAuxiliaryClient), not\n+    through an OpenAI chat-completions client.\n+\n+    The bug had two halves: the providers-dict branch of\n+    ``_get_named_custom_provider`` dropped the ``api_mode`` field, and\n+    ``resolve_provider_client``'s named-custom branch never read it.\n+    \"\"\"\n+\n+    def test_providers_dict_propagates_api_mode(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"MYRELAY_API_KEY\", \"sk-test\")\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"myrelay\": {\n+                    \"name\": \"myrelay\",\n+                    \"base_url\": \"https://example-relay.test/anthropic\",\n+                    \"key_env\": \"MYRELAY_API_KEY\",\n+                    \"api_mode\": \"anthropic_messages\",\n+                    \"default_model\": \"claude-opus-4-7\",\n+                },\n+            },\n+        })\n+        from hermes_cli.runtime_provider import _get_named_custom_provider\n+        entry = _get_named_custom_provider(\"myrelay\")\n+        assert entry is not None\n+        assert entry.get(\"api_mode\") == \"anthropic_messages\"\n+        assert entry.get(\"base_url\") == \"https://example-relay.test/anthropic\"\n+        assert entry.get(\"api_key\") == \"sk-test\"\n+\n+    def test_providers_dict_invalid_api_mode_is_dropped(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"weird\": {\n+                    \"name\": \"weird\",\n+                    \"base_url\": \"https://example.test\",\n+                    \"api_mode\": \"bogus_nonsense\",\n+                    \"default_model\": \"x\",\n+                },\n+            },\n+        })\n+        from hermes_cli.runtime_provider import _get_named_custom_provider\n+        entry = _get_named_custom_provider(\"weird\")\n+        assert entry is not None\n+        assert \"api_mode\" not in entry\n+\n+    def test_providers_dict_without_api_mode_is_unchanged(self, tmp_path):\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"localchat\": {\n+                    \"name\": \"localchat\",\n+                    \"base_url\": \"http://127.0.0.1:1234/v1\",\n+                    \"api_key\": \"local-key\",\n+                    \"default_model\": \"llama-3\",\n+                },\n+            },\n+        })\n+        from hermes_cli.runtime_provider import _get_named_custom_provider\n+        entry = _get_named_custom_provider(\"localchat\")\n+        assert entry is not None\n+        assert \"api_mode\" not in entry\n+\n+    def test_resolve_provider_client_returns_anthropic_client(self, tmp_path, monkeypatch):\n+        \"\"\"Named custom provider with api_mode=anthropic_messages must\n+        route through AnthropicAuxiliaryClient.\"\"\"\n+        monkeypatch.setenv(\"MYRELAY_API_KEY\", \"sk-test\")\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"myrelay\": {\n+                    \"name\": \"myrelay\",\n+                    \"base_url\": \"https://example-relay.test/anthropic\",\n+                    \"key_env\": \"MYRELAY_API_KEY\",\n+                    \"api_mode\": \"anthropic_messages\",\n+                    \"default_model\": \"claude-opus-4-7\",\n+                },\n+            },\n+        })\n+        from agent.auxiliary_client import (\n+            resolve_provider_client,\n+            AnthropicAuxiliaryClient,\n+            AsyncAnthropicAuxiliaryClient,\n+        )\n+        sync_client, sync_model = resolve_provider_client(\"myrelay\", async_mode=False)\n+        assert isinstance(sync_client, AnthropicAuxiliaryClient), (\n+            f\"expected AnthropicAuxiliaryClient, got {type(sync_client).__name__}\"\n+        )\n+        assert sync_model == \"claude-opus-4-7\"\n+\n+        async_client, async_model = resolve_provider_client(\"myrelay\", async_mode=True)\n+        assert isinstance(async_client, AsyncAnthropicAuxiliaryClient), (\n+            f\"expected AsyncAnthropicAuxiliaryClient, got {type(async_client).__name__}\"\n+        )\n+        assert async_model == \"claude-opus-4-7\"\n+\n+    def test_aux_task_override_routes_named_provider_to_anthropic(self, tmp_path, monkeypatch):\n+        \"\"\"The full chain: auxiliary.<task>.provider: myrelay with\n+        api_mode anthropic_messages must produce an Anthropic client.\"\"\"\n+        monkeypatch.setenv(\"MYRELAY_API_KEY\", \"sk-test\")\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"myrelay\": {\n+                    \"name\": \"myrelay\",\n+                    \"base_url\": \"https://example-relay.test/anthropic\",\n+                    \"key_env\": \"MYRELAY_API_KEY\",\n+                    \"api_mode\": \"anthropic_messages\",\n+                    \"default_model\": \"claude-opus-4-7\",\n+                },\n+            },\n+            \"auxiliary\": {\n+                \"flush_memories\": {\n+                    \"provider\": \"myrelay\",\n+                    \"model\": \"claude-sonnet-4.6\",\n+                },\n+            },\n+            \"model\": {\"provider\": \"openrouter\", \"default\": \"anthropic/claude-sonnet-4.6\"},\n+        })\n+        from agent.auxiliary_client import (\n+            get_async_text_auxiliary_client,\n+            get_text_auxiliary_client,\n+            AnthropicAuxiliaryClient,\n+            AsyncAnthropicAuxiliaryClient,\n+        )\n+        async_client, async_model = get_async_text_auxiliary_client(\"flush_memories\")\n+        assert isinstance(async_client, AsyncAnthropicAuxiliaryClient)\n+        assert async_model == \"claude-sonnet-4.6\"\n+\n+        sync_client, sync_model = get_text_auxiliary_client(\"flush_memories\")\n+        assert isinstance(sync_client, AnthropicAuxiliaryClient)\n+        assert sync_model == \"claude-sonnet-4.6\"\n+\n+    def test_provider_without_api_mode_still_uses_openai(self, tmp_path):\n+        \"\"\"Named providers that don't declare api_mode should still go\n+        through the plain OpenAI-wire path (no regression).\"\"\"\n+        _write_config(tmp_path, {\n+            \"providers\": {\n+                \"localchat\": {\n+                    \"name\": \"localchat\",\n+                    \"base_url\": \"http://127.0.0.1:1234/v1\",\n+                    \"api_key\": \"local-key\",\n+                    \"default_model\": \"llama-3\",\n+                },\n+            },\n+        })\n+        from agent.auxiliary_client import resolve_provider_client\n+        from openai import OpenAI, AsyncOpenAI\n+        sync_client, _ = resolve_provider_client(\"localchat\", async_mode=False)\n+        # sync returns the raw OpenAI client\n+        assert isinstance(sync_client, OpenAI)\n+        async_client, _ = resolve_provider_client(\"localchat\", async_mode=True)\n+        assert isinstance(async_client, AsyncOpenAI)\n"]}]},{"kind":"pr","number":16256,"title":"fix(aux-client): honor api_mode in explicit_base_url path; read key_env in task config (#16254)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":17360,"title":"fix(auxiliary): explicit_base_url path now respects api_mode=anthropic_messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16256","number":16256}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":17528,"title":"fix(agent): use /anthropic endpoint for MiniMax-CN auxiliary tasks","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4e296dcdd","number":0},{"kind":"pr","value":"#17467","number":17467},{"kind":"pr","value":"#17467","number":17467}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":19772,"title":"fix(auxiliary): pass raw explicit_base_url to _wrap_if_needed for Anthropic transport","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":20554,"title":"fix(agent): rewrite /anthropic base URL for OpenAI fallback in custom endpoint","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":21308,"title":"fix(provider): kimi-coding double /v1 in Anthropic Messages URL","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15200","number":15200}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":22289,"title":"fix(auxiliary): strip /v1 from Kimi base URL before Anthropic SDK wraps it","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"413990c94537e9c9da973bb21a6afcd332400b91","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23260,"title":"fix(kimi): strip trailing v1 for anthropic endpoints","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#15200","number":15200},{"kind":"pr","value":"#21297","number":21297}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":24873,"title":"fix(anthropic): strip native SDK /v1 base URL suffix","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24876","number":24876}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24877,"title":"fix(anthropic): strip native base url v1 suffix","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25135,"title":"fix: normalize Kimi runtime base URL","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1d8b9e645","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37183,"title":"fix(aux-client): preserve /anthropic for MiniMax M-series auxiliary routes","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":37656,"title":"fix(anthropic): strip trailing /v1 from base_url in build_anthropic_client (#24833)","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":37705,"title":"fix(provider): select anthropic base_url for dual-section named providers in sub-sessions (#11059)","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":41253,"title":"fix: ignore stale auxiliary api_mode on anthropic endpoints","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":48417,"title":"fix(auxiliary): preserve Anthropic custom base URLs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"85e6232a0716fd6df5f061d0e1eae55ff40d1e2c","number":0},{"kind":"pr","value":"#48418","number":48418}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":48418,"title":"fix(auxiliary): preserve raw base_url for anthropic_messages in anonymous custom branch (#19753)","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#48417","number":48417},{"kind":"pr","value":"#48417","number":48417}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":49012,"title":"fix(agent): inherit main runtime connection fields for auto vision","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"25aa626cb42cfec620083ea69fe12e8afe171ff7","number":0},{"kind":"pr","value":"#48991","number":48991},{"kind":"pr","value":"#43254","number":43254}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":74983,"title":"fix(auxiliary): preserve raw base_url for anthropic_messages in anonymous custom branch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48418","number":48418},{"kind":"pr","value":"#48418","number":48418}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":85466,"title":"fix(auxiliary): keep /anthropic base_url for anthropic_messages custom endpoints (salvage #64891)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","tests/agent/test_auxiliary_explicit_base_anthropic.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}