mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 04:14:07 +08:00
Compare commits
1 Commits
chore/remo
...
salvage/xi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d94943d6 |
@@ -528,6 +528,23 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
return "/anthropic" in normalized.rstrip("/").lower()
|
||||
|
||||
|
||||
def _is_xiaomi_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""Return True for Xiaomi MiMo's Anthropic-compatible endpoint.
|
||||
|
||||
MiMo's ``/anthropic`` route speaks the Anthropic Messages protocol but
|
||||
requires unsigned thinking blocks synthesized from ``reasoning_content``
|
||||
to round-trip on replayed assistant tool-call messages. Pin the match to
|
||||
Xiaomi's host and the ``/anthropic`` path so OpenAI-compatible MiMo bases
|
||||
and aggregator ``xiaomi/mimo-*`` slugs do not get Anthropic replay semantics.
|
||||
"""
|
||||
if not base_url_host_matches(base_url or "", "xiaomimimo.com"):
|
||||
return False
|
||||
normalized = _normalize_base_url_text(base_url)
|
||||
if not normalized:
|
||||
return False
|
||||
return "/anthropic" in normalized.rstrip("/").lower()
|
||||
|
||||
|
||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
"""Return True for Anthropic-compatible providers that require Bearer auth.
|
||||
|
||||
@@ -2070,12 +2087,13 @@ def _manage_thinking_signatures(
|
||||
"""
|
||||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||||
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
|
||||
# Kimi / DeepSeek share a contract: strip signed Anthropic blocks
|
||||
# (neither upstream can validate Anthropic signatures), preserve unsigned
|
||||
# ones synthesised from reasoning_content. See #13848, #16748.
|
||||
# Kimi / DeepSeek / MiMo share a contract: strip signed Anthropic blocks
|
||||
# (none of these upstreams can validate Anthropic signatures), preserve
|
||||
# unsigned ones synthesised from reasoning_content. See #13848, #16748.
|
||||
_preserve_unsigned_thinking = (
|
||||
_is_kimi_family_endpoint(base_url, model)
|
||||
or _is_deepseek_anthropic_endpoint(base_url)
|
||||
or _is_xiaomi_anthropic_endpoint(base_url)
|
||||
)
|
||||
|
||||
last_assistant_idx = None
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
"""Xiaomi MiMo provider profile."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
xiaomi = ProviderProfile(
|
||||
|
||||
class XiaomiProfile(ProviderProfile):
|
||||
"""Xiaomi MiMo — explicit thinking disable support."""
|
||||
|
||||
def build_api_kwargs_extras(
|
||||
self,
|
||||
*,
|
||||
reasoning_config: dict | None = None,
|
||||
**context: Any,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
if not reasoning_config or not isinstance(reasoning_config, dict):
|
||||
return {}, {}
|
||||
|
||||
effort = str(reasoning_config.get("effort") or "").strip().lower()
|
||||
enabled = reasoning_config.get("enabled", True)
|
||||
if enabled is False or effort == "none":
|
||||
return {"thinking": {"type": "disabled"}}, {}
|
||||
return {}, {}
|
||||
|
||||
|
||||
xiaomi = XiaomiProfile(
|
||||
name="xiaomi",
|
||||
aliases=("mimo", "xiaomi-mimo"),
|
||||
env_vars=("XIAOMI_API_KEY",),
|
||||
base_url="https://api.xiaomimimo.com/v1",
|
||||
supports_health_check=False, # /v1/models returns 401 even with valid key
|
||||
supports_vision=True, # mimo-v2-omni is vision-capable
|
||||
supports_vision=True, # mimo-v2.5 / omni variants are vision-capable
|
||||
supports_vision_tool_messages=False, # rejects list-type tool content (400 "text is not set")
|
||||
)
|
||||
|
||||
|
||||
21
run_agent.py
21
run_agent.py
@@ -4933,11 +4933,10 @@ class AIAgent:
|
||||
``reasoning_content`` on every assistant tool-call message; omitting
|
||||
it causes the next replay to fail with HTTP 400.
|
||||
|
||||
Detection is host-driven, not model-name-driven: aggregators like
|
||||
OpenRouter that re-export Kimi/Moonshot models speak their own
|
||||
protocol and reject ``reasoning_content`` echoes. We only enable the
|
||||
kimi-reasoning replay when the request actually targets a
|
||||
kimi/moonshot endpoint or the dedicated kimi-coding provider.
|
||||
Detection is host/provider-driven, not model-name-driven: aggregators
|
||||
like OpenRouter that re-export thinking models speak their own protocol
|
||||
and reject ``reasoning_content`` echoes. We only enable replay when the
|
||||
request actually targets the provider's native endpoint.
|
||||
"""
|
||||
return (
|
||||
self.provider in {"kimi-coding", "kimi-coding-cn"}
|
||||
@@ -4953,11 +4952,8 @@ class AIAgent:
|
||||
assistant tool-call turn; omitting it causes HTTP 400 when the
|
||||
message is replayed in a subsequent API request (#15250).
|
||||
"""
|
||||
provider = (self.provider or "").lower()
|
||||
model = (self.model or "").lower()
|
||||
return (
|
||||
provider == "deepseek"
|
||||
or "deepseek" in model
|
||||
(getattr(self, "provider", "") or "").lower() == "deepseek"
|
||||
or base_url_host_matches(self.base_url, "api.deepseek.com")
|
||||
)
|
||||
|
||||
@@ -4968,11 +4964,12 @@ class AIAgent:
|
||||
tool-call message when replaying history; omitting it causes HTTP 400.
|
||||
Refs: https://platform.xiaomimimo.com/docs/zh-CN/usage-guide/passing-back-reasoning_content
|
||||
"""
|
||||
provider = (self.provider or "").lower()
|
||||
model = (self.model or "").lower()
|
||||
provider = (getattr(self, "provider", "") or "").lower()
|
||||
model = (getattr(self, "model", "") or "").lower()
|
||||
aggregator_mimo = provider in {"openrouter", "nous"} and model.startswith("xiaomi/mimo-")
|
||||
return (
|
||||
provider == "xiaomi"
|
||||
or "mimo" in model
|
||||
or aggregator_mimo
|
||||
or base_url_host_matches(self.base_url, "api.xiaomimimo.com")
|
||||
or base_url_host_matches(self.base_url, "xiaomimimo.com")
|
||||
)
|
||||
|
||||
@@ -1374,6 +1374,9 @@ AUTHOR_MAP = {
|
||||
# batch salvage (May 2026 LHF run, group 2)
|
||||
"shellybotmoyer@example.com": "shellybotmoyer", # PR #26661 (kanban --severity >=)
|
||||
"coulson@shellybotmoyer.com": "shellybotmoyer", # PR #25576 (credential_pool ISO rehydrate)
|
||||
"pppan2003@gmail.com": "pppan2003", # PR #25379 salvage (Xiaomi MiMo /anthropic thinking replay)
|
||||
"zhao.zr11@protonmail.com": "CallMe1101", # PR #26802 salvage (Xiaomi MiMo /anthropic thinking replay)
|
||||
"morpheu5code@gmail.com": "Morpheu5Code", # PR #26803 (Slack file upload parsing)
|
||||
"258858106+shellybotmoyer@users.noreply.github.com": "shellybotmoyer",
|
||||
"33156212+ether-btc@users.noreply.github.com": "ether-btc", # PR #26632 (memory provider whitespace guard)
|
||||
"Bloomtonjovish@gmail.com": "LifeJiggy", # PR #26516 (paste collapse logging)
|
||||
|
||||
124
tests/agent/test_xiaomi_anthropic_thinking.py
Normal file
124
tests/agent/test_xiaomi_anthropic_thinking.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Regression guard: preserve thinking blocks on Xiaomi MiMo /anthropic.
|
||||
|
||||
Xiaomi MiMo's Anthropic-compatible route requires unsigned thinking blocks
|
||||
synthesised from ``reasoning_content`` to round-trip on replayed assistant
|
||||
tool-call messages. The generic third-party Anthropic path strips thinking
|
||||
blocks, which breaks multi-turn tool-use replay on MiMo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestXiaomiAnthropicPreservesThinking:
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"https://token-plan-cn.xiaomimimo.com/anthropic",
|
||||
"https://token-plan-sgp.xiaomimimo.com/anthropic/",
|
||||
"https://api.xiaomimimo.com/anthropic/v1",
|
||||
"https://API.XiaomiMiMo.com/anthropic",
|
||||
],
|
||||
)
|
||||
def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None:
|
||||
from agent.anthropic_adapter import convert_messages_to_anthropic
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"reasoning_content": "planning the tool call",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "terminal", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
|
||||
]
|
||||
_system, converted = convert_messages_to_anthropic(messages, base_url=base_url)
|
||||
|
||||
assistant_msg = next(m for m in converted if m["role"] == "assistant")
|
||||
thinking_blocks = [
|
||||
b for b in assistant_msg["content"]
|
||||
if isinstance(b, dict) and b.get("type") == "thinking"
|
||||
]
|
||||
assert len(thinking_blocks) == 1
|
||||
assert thinking_blocks[0]["thinking"] == "planning the tool call"
|
||||
assert "signature" not in thinking_blocks[0]
|
||||
|
||||
def test_signed_anthropic_thinking_block_is_stripped(self) -> None:
|
||||
from agent.anthropic_adapter import convert_messages_to_anthropic
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "anthropic-signed payload",
|
||||
"signature": "anthropic-sig-xyz",
|
||||
},
|
||||
{"type": "text", "text": "hello"},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "again"},
|
||||
]
|
||||
_system, converted = convert_messages_to_anthropic(
|
||||
messages,
|
||||
base_url="https://token-plan-cn.xiaomimimo.com/anthropic",
|
||||
)
|
||||
|
||||
assistant_msg = next(m for m in converted if m["role"] == "assistant")
|
||||
thinking_blocks = [
|
||||
b for b in assistant_msg["content"]
|
||||
if isinstance(b, dict) and b.get("type") == "thinking"
|
||||
]
|
||||
assert thinking_blocks == []
|
||||
|
||||
def test_openai_compat_xiaomi_base_is_not_matched(self) -> None:
|
||||
from agent.anthropic_adapter import _is_xiaomi_anthropic_endpoint
|
||||
|
||||
assert _is_xiaomi_anthropic_endpoint("https://api.xiaomimimo.com") is False
|
||||
assert _is_xiaomi_anthropic_endpoint("https://api.xiaomimimo.com/v1") is False
|
||||
assert _is_xiaomi_anthropic_endpoint("https://api.xiaomimimo.com/anthropic") is True
|
||||
assert _is_xiaomi_anthropic_endpoint("https://token-plan-sgp.xiaomimimo.com/anthropic/v1") is True
|
||||
|
||||
def test_lookalike_host_is_not_matched(self) -> None:
|
||||
from agent.anthropic_adapter import _is_xiaomi_anthropic_endpoint
|
||||
|
||||
assert _is_xiaomi_anthropic_endpoint("https://xiaomimimo.com.evil/anthropic") is False
|
||||
assert _is_xiaomi_anthropic_endpoint("https://evil.example.com/xiaomimimo.com/anthropic") is False
|
||||
|
||||
def test_non_xiaomi_third_party_still_strips_all_thinking(self) -> None:
|
||||
from agent.anthropic_adapter import convert_messages_to_anthropic
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"reasoning_content": "r1",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "terminal", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "ok"},
|
||||
]
|
||||
_system, converted = convert_messages_to_anthropic(
|
||||
messages,
|
||||
base_url="https://api.minimax.io/anthropic",
|
||||
)
|
||||
assistant_msg = next(m for m in converted if m["role"] == "assistant")
|
||||
thinking_blocks = [
|
||||
b for b in assistant_msg["content"]
|
||||
if isinstance(b, dict) and b.get("type") == "thinking"
|
||||
]
|
||||
assert thinking_blocks == []
|
||||
@@ -96,6 +96,41 @@ class TestKimiProfile:
|
||||
assert "reasoning_effort" not in tl
|
||||
|
||||
|
||||
class TestXiaomiProfile:
|
||||
def test_alias_lookup(self):
|
||||
p = get_provider_profile("mimo")
|
||||
assert p is not None
|
||||
assert p.name == "xiaomi"
|
||||
|
||||
def test_no_config_omits_thinking(self):
|
||||
p = get_provider_profile("xiaomi")
|
||||
assert p is not None
|
||||
eb, tl = p.build_api_kwargs_extras(reasoning_config=None)
|
||||
assert eb == {}
|
||||
assert tl == {}
|
||||
|
||||
def test_disabled_reasoning_disables_thinking(self):
|
||||
p = get_provider_profile("xiaomi")
|
||||
assert p is not None
|
||||
eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": False})
|
||||
assert eb["thinking"] == {"type": "disabled"}
|
||||
assert tl == {}
|
||||
|
||||
def test_effort_none_disables_thinking(self):
|
||||
p = get_provider_profile("xiaomi")
|
||||
assert p is not None
|
||||
eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": True, "effort": "none"})
|
||||
assert eb["thinking"] == {"type": "disabled"}
|
||||
assert tl == {}
|
||||
|
||||
def test_enabled_reasoning_omits_thinking(self):
|
||||
p = get_provider_profile("xiaomi")
|
||||
assert p is not None
|
||||
eb, tl = p.build_api_kwargs_extras(reasoning_config={"enabled": True, "effort": "high"})
|
||||
assert eb == {}
|
||||
assert tl == {}
|
||||
|
||||
|
||||
class TestOpenRouterProfile:
|
||||
def test_extra_body_with_prefs(self):
|
||||
p = get_provider_profile("openrouter")
|
||||
|
||||
@@ -14,9 +14,10 @@ Fix covers three paths:
|
||||
persisted poisoned.
|
||||
2. ``_copy_reasoning_content_for_api`` — already-poisoned history replays
|
||||
with ``reasoning_content=" "`` injected defensively.
|
||||
3. Detection covers three signals: ``provider == "deepseek"``,
|
||||
``"deepseek" in model``, and ``api.deepseek.com`` host match. The third
|
||||
catches custom-provider setups pointing at DeepSeek.
|
||||
3. Detection is provider/host driven: ``provider == "deepseek"`` or the
|
||||
``api.deepseek.com`` host. Model-name-only matching is intentionally
|
||||
rejected because aggregators re-export DeepSeek slugs but do not accept
|
||||
native ``reasoning_content`` echoes.
|
||||
|
||||
The placeholder is a single space (not empty string) because DeepSeek V4 Pro
|
||||
tightened validation and rejects empty-string reasoning_content with a
|
||||
@@ -72,16 +73,16 @@ def _build_sdk_message(reasoning_content=_ATTR_ABSENT, **extra):
|
||||
|
||||
|
||||
class TestNeedsDeepSeekToolReasoning:
|
||||
"""_needs_deepseek_tool_reasoning() recognises all three detection signals."""
|
||||
"""_needs_deepseek_tool_reasoning() recognises native endpoint signals."""
|
||||
|
||||
def test_provider_deepseek(self) -> None:
|
||||
agent = _make_agent(provider="deepseek", model="deepseek-v4-flash")
|
||||
assert agent._needs_deepseek_tool_reasoning() is True
|
||||
|
||||
def test_model_substring(self) -> None:
|
||||
# Custom provider pointing at DeepSeek with provider='custom'
|
||||
def test_model_substring_without_native_provider_or_host_is_false(self) -> None:
|
||||
# Aggregators can re-export DeepSeek slugs but reject native echoes.
|
||||
agent = _make_agent(provider="custom", model="deepseek-v4-pro")
|
||||
assert agent._needs_deepseek_tool_reasoning() is True
|
||||
assert agent._needs_deepseek_tool_reasoning() is False
|
||||
|
||||
def test_base_url_host(self) -> None:
|
||||
agent = _make_agent(
|
||||
@@ -108,6 +109,50 @@ class TestNeedsDeepSeekToolReasoning:
|
||||
assert agent._needs_deepseek_tool_reasoning() is False
|
||||
|
||||
|
||||
class TestNeedsMiMoToolReasoning:
|
||||
"""_needs_mimo_tool_reasoning() covers native hosts and known aggregators."""
|
||||
|
||||
def test_provider_xiaomi(self) -> None:
|
||||
agent = _make_agent(provider="xiaomi", model="mimo-v2.5-pro")
|
||||
assert agent._needs_mimo_tool_reasoning() is True
|
||||
|
||||
def test_xiaomi_base_url_host(self) -> None:
|
||||
agent = _make_agent(
|
||||
provider="custom",
|
||||
model="some-alias",
|
||||
base_url="https://token-plan-sgp.xiaomimimo.com/v1",
|
||||
)
|
||||
assert agent._needs_mimo_tool_reasoning() is True
|
||||
|
||||
def test_openrouter_xiaomi_slug_is_true(self) -> None:
|
||||
agent = _make_agent(
|
||||
provider="openrouter",
|
||||
model="xiaomi/mimo-v2.5-pro",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
assert agent._needs_mimo_tool_reasoning() is True
|
||||
|
||||
def test_nous_xiaomi_slug_is_true(self) -> None:
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
model="xiaomi/mimo-v2.5-pro",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
)
|
||||
assert agent._needs_mimo_tool_reasoning() is True
|
||||
|
||||
def test_custom_mimo_slug_without_native_host_is_false(self) -> None:
|
||||
agent = _make_agent(provider="custom", model="mimo-v2.5-pro")
|
||||
assert agent._needs_mimo_tool_reasoning() is False
|
||||
|
||||
def test_openrouter_non_mimo_xiaomi_slug_is_false(self) -> None:
|
||||
agent = _make_agent(
|
||||
provider="openrouter",
|
||||
model="xiaomi/other-model",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
assert agent._needs_mimo_tool_reasoning() is False
|
||||
|
||||
|
||||
class TestCopyReasoningContentForApi:
|
||||
"""_copy_reasoning_content_for_api pads reasoning_content for DeepSeek tool-calls."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user