{"repo":"NousResearch/hermes-agent","public_id":"17057","display_label":"Kimi-compatible custom endpoints and Anthropic thinking suppression","summary":"These members all concern Kimi-compatible custom endpoints using the Anthropic messages mode, where an issue reports a problem after a tool call with thinking enabled. Two pull requests propose suppressing or broadening suppression of thinking for such endpoints; their outcomes are not established here.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17455,"merged_at":"2026-04-29T13:35:42+00:00"},"resolution":{"state":"resolved","by":"pr-17455"},"freshness":{"state":"stale","last_verified_at":"2026-04-29T13:35:42+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":17057,"pr_number":17455,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-29T13:35:42+00:00"}],"members":[{"kind":"issue","number":17057,"title":"Custom Kimi-compatible endpoint with api_mode=anthropic_messages fails after tool call when thinking is enabled","url":null,"state":"closed"},{"kind":"pr","number":17065,"title":"fix(anthropic): suppress thinking on Kimi-compatible custom endpoints (#17057)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":17455,"title":"fix(anthropic): broaden Kimi thinking-suppression to custom endpoints","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","tests/agent/test_kimi_coding_anthropic_thinking.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -20,7 +20,7 @@\n \n from hermes_constants import get_hermes_home\n from typing import Any, Dict, List, Optional, Tuple\n-from utils import normalize_proxy_env_vars\n+from utils import base_url_host_matches, normalize_proxy_env_vars\n \n # NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls\n # ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.)","@@ -365,6 +365,61 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:\n     return normalized.rstrip(\"/\").lower().startswith(\"https://api.kimi.com/coding\")\n \n \n+# Model-name prefixes that identify the Kimi / Moonshot family.  Covers\n+# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``\n+# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``\n+# Matched case-insensitively against the post-``normalize_model_name`` form,\n+# so a caller's ``provider/vendor/model`` slug is handled the same as a\n+# bare name.\n+_KIMI_FAMILY_MODEL_PREFIXES = (\n+    \"kimi-\", \"kimi_\",\n+    \"moonshot-\", \"moonshot_\",\n+    \"k1.\", \"k1-\",\n+    \"k2.\", \"k2-\",\n+    \"k25\", \"k2.5\",\n+)\n+\n+\n+def _model_name_is_kimi_family(model: str | None) -> bool:\n+    if not isinstance(model, str):\n+        return False\n+    m = model.strip().lower()\n+    if not m:\n+        return False\n+    # Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)\n+    if \"/\" in m:\n+        m = m.rsplit(\"/\", 1)[-1]\n+    return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)\n+\n+\n+def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> bool:\n+    \"\"\"Return True for any Kimi / Moonshot Anthropic-Messages-speaking endpoint.\n+\n+    Broader than ``_is_kimi_coding_endpoint`` — matches:\n+\n+    - Kimi's official ``/coding`` URL (legacy check, preserved)\n+    - Any ``api.kimi.com`` / ``moonshot.ai`` / ``moonshot.cn`` host\n+    - Custom or proxied endpoints whose *model* name is in the Kimi / Moonshot\n+      family (``kimi-*``, ``moonshot-*``, ``k1.*``, ``k2.*``, …).  Users with\n+      ``api_mode: anthropic_messages`` on a private gateway fronting Kimi\n+      fall into this branch — the upstream still enforces Kimi's thinking\n+      semantics (reasoning_content required on every replayed tool-call\n+      message) regardless of the gateway's hostname.\n+\n+    Used to decide whether to drop Anthropic's ``thinking`` kwarg and to\n+    preserve unsigned reasoning_content-derived thinking blocks on replay.\n+    See hermes-agent#13848, #17057.\n+    \"\"\"\n+    if _is_kimi_coding_endpoint(base_url):\n+        return True\n+    for _domain in (\"api.kimi.com\", \"moonshot.ai\", \"moonshot.cn\"):\n+        if base_url_host_matches(base_url or \"\", _domain):\n+            return True\n+    if _model_name_is_kimi_family(model):\n+        return True\n+    return False\n+\n+\n def _requires_bearer_auth(base_url: str | None) -> bool:\n     \"\"\"Return True for Anthropic-compatible providers that require Bearer auth.\n ","@@ -1265,6 +1320,7 @@ def _convert_content_to_anthropic(content: Any) -> Any:\n def convert_messages_to_anthropic(\n     messages: List[Dict],\n     base_url: str | None = None,\n+    model: str | None = None,\n ) -> Tuple[Optional[Any], List[Dict]]:\n     \"\"\"Convert OpenAI-format messages to Anthropic format.\n ","@@ -1276,6 +1332,12 @@ def convert_messages_to_anthropic(\n     endpoint, all thinking block signatures are stripped.  Signatures are\n     Anthropic-proprietary — third-party endpoints cannot validate them and will\n     reject them with HTTP 400 \"Invalid signature in thinking block\".\n+\n+    When *model* is provided and matches the Kimi / Moonshot family (or\n+    *base_url* is a Kimi / Moonshot host), unsigned thinking blocks\n+    synthesised from ``reasoning_content`` are preserved on replayed\n+    assistant tool-call messages — Kimi requires the field to exist, even\n+    if empty.\n     \"\"\"\n     system = None\n     result = []","@@ -1504,7 +1566,7 @@ def convert_messages_to_anthropic(\n     #    cache markers can interfere with signature validation.\n     _THINKING_TYPES = frozenset((\"thinking\", \"redacted_thinking\"))\n     _is_third_party = _is_third_party_anthropic_endpoint(base_url)\n-    _is_kimi = _is_kimi_coding_endpoint(base_url)\n+    _is_kimi = _is_kimi_family_endpoint(base_url, model)\n \n     last_assistant_idx = None\n     for i in range(len(result) - 1, -1, -1):","@@ -1627,7 +1689,9 @@ def build_anthropic_kwargs(\n     Currently only supported on native Anthropic endpoints (not third-party\n     compatible ones).\n     \"\"\"\n-    system, anthropic_messages = convert_messages_to_anthropic(messages, base_url=base_url)\n+    system, anthropic_messages = convert_messages_to_anthropic(\n+        messages, base_url=base_url, model=model\n+    )\n     anthropic_tools = convert_tools_to_anthropic(tools) if tools else []\n \n     model = normalize_model_name(model, preserve_dots=preserve_dots)","@@ -1733,7 +1797,7 @@ def build_anthropic_kwargs(\n     # silently hides reasoning text that Hermes surfaces in its CLI. We\n     # request \"summarized\" so the reasoning blocks stay populated — matching\n     # 4.6 behavior and preserving the activity-feed UX during long tool runs.\n-    _is_kimi_coding = _is_kimi_coding_endpoint(base_url)\n+    _is_kimi_coding = _is_kimi_family_endpoint(base_url, model)\n     if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding:\n         if reasoning_config.get(\"enabled\") is not False and \"haiku\" not in model.lower():\n             effort = str(reasoning_config.get(\"effort\", \"medium\")).lower()"]},{"file":"tests/agent/test_kimi_coding_anthropic_thinking.py","hunks":["@@ -94,13 +94,16 @@ def test_native_anthropic_still_gets_thinking(self) -> None:\n         )\n         assert \"thinking\" in kwargs\n \n-    def test_kimi_root_endpoint_unaffected(self) -> None:\n-        \"\"\"Only the /coding route is special-cased — plain api.kimi.com is not.\n-\n-        ``api.kimi.com`` without ``/coding`` uses the chat_completions transport\n-        (see runtime_provider._detect_api_mode_for_url); build_anthropic_kwargs\n-        should never see it, but if it somehow does we should not suppress\n-        thinking there — that path has different semantics.\n+    def test_kimi_root_endpoint_via_anthropic_transport_omits_thinking(self) -> None:\n+        \"\"\"Plain ``api.kimi.com`` hit via the Anthropic transport also omits thinking.\n+\n+        Auto-detection routes ``api.kimi.com/v1`` to ``chat_completions`` by\n+        default, but users can explicitly configure\n+        ``api_mode: anthropic_messages`` against any Kimi host.  The upstream\n+        validation (reasoning_content required on replayed tool-call\n+        messages) is the same regardless of URL path, so the thinking\n+        suppression must apply to every Kimi host, not just ``/coding``.\n+        See #17057.\n         \"\"\"\n         from agent.anthropic_adapter import build_anthropic_kwargs\n ","@@ -112,4 +115,98 @@ def test_kimi_root_endpoint_unaffected(self) -> None:\n             reasoning_config={\"enabled\": True, \"effort\": \"medium\"},\n             base_url=\"https://api.kimi.com/v1\",\n         )\n+        assert \"thinking\" not in kwargs\n+\n+    # ── #17057: custom / proxied Kimi-compatible endpoints ──────────\n+    @pytest.mark.parametrize(\n+        \"base_url,model\",\n+        [\n+            # Custom host with Kimi-family model — the reporter's case\n+            (\"http://my-kimi-proxy.internal\", \"kimi-2.6\"),\n+            (\"https://llm.example.com/anthropic\", \"kimi-k2.5\"),\n+            (\"https://llm.example.com/anthropic\", \"moonshot-v1-8k\"),\n+            (\"https://llm.example.com/anthropic\", \"kimi_thinking\"),\n+            (\"https://llm.example.com/anthropic\", \"moonshotai/kimi-k2.5\"),\n+            # Official Moonshot host (previously uncovered)\n+            (\"https://api.moonshot.ai/anthropic\", \"moonshot-v1-32k\"),\n+            (\"https://api.moonshot.cn/anthropic\", \"moonshot-v1-32k\"),\n+        ],\n+    )\n+    def test_kimi_family_custom_endpoint_omits_thinking(\n+        self, base_url: str, model: str\n+    ) -> None:\n+        \"\"\"Custom / proxied Kimi endpoints must also strip Anthropic thinking.\"\"\"\n+        from agent.anthropic_adapter import build_anthropic_kwargs\n+\n+        kwargs = build_anthropic_kwargs(\n+            model=model,\n+            messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+            tools=None,\n+            max_tokens=4096,\n+            reasoning_config={\"enabled\": True, \"effort\": \"medium\"},\n+            base_url=base_url,\n+        )\n+        assert \"thinking\" not in kwargs, (\n+            f\"Kimi-family endpoint ({base_url}, {model}) must not receive \"\n+            f\"Anthropic thinking — upstream validates reasoning_content on \"\n+            f\"replayed tool-call history we don't preserve.\"\n+        )\n+        assert \"output_config\" not in kwargs\n+\n+    def test_custom_endpoint_non_kimi_model_keeps_thinking(self) -> None:\n+        \"\"\"Custom endpoint with a non-Kimi model must keep thinking intact.\n+\n+        Guards against over-broad model-family matching — only model names\n+        starting with a Kimi/Moonshot prefix should trigger suppression.\n+        \"\"\"\n+        from agent.anthropic_adapter import build_anthropic_kwargs\n+\n+        kwargs = build_anthropic_kwargs(\n+            model=\"MiniMax-M2.7\",\n+            messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+            tools=None,\n+            max_tokens=4096,\n+            reasoning_config={\"enabled\": True, \"effort\": \"medium\"},\n+            base_url=\"https://my-llm-proxy.example.com/anthropic\",\n+        )\n         assert \"thinking\" in kwargs\n+        assert kwargs[\"thinking\"][\"type\"] == \"enabled\"\n+\n+    def test_kimi_family_replay_preserves_unsigned_thinking(self) -> None:\n+        \"\"\"On a custom Kimi endpoint, unsigned reasoning_content thinking\n+        blocks must survive the third-party signature-stripping pass so\n+        the upstream's message-history validation passes.\n+        \"\"\"\n+        from agent.anthropic_adapter import convert_messages_to_anthropic\n+\n+        messages = [\n+            {\"role\": \"user\", \"content\": \"hi\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"planning the tool call\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"skill_view\", \"arguments\": \"{}\"},\n+                    }\n+                ],\n+            },\n+            {\"role\": \"tool\", \"tool_call_id\": \"call_1\", \"content\": \"ok\"},\n+        ]\n+        _, converted = convert_messages_to_anthropic(\n+            messages,\n+            base_url=\"http://my-kimi-proxy.internal\",\n+            model=\"kimi-2.6\",\n+        )\n+        # The assistant message still carries the unsigned thinking block\n+        # synthesised from reasoning_content (required by Kimi's history\n+        # validation).  A plain third-party endpoint would have stripped it.\n+        assistant_msg = next(m for m in converted if m[\"role\"] == \"assistant\")\n+        assistant_blocks = assistant_msg[\"content\"]\n+        thinking_blocks = [\n+            b for b in assistant_blocks\n+            if isinstance(b, dict) and b.get(\"type\") == \"thinking\"\n+        ]\n+        assert len(thinking_blocks) == 1\n+        assert thinking_blocks[0][\"thinking\"] == \"planning the tool call\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}