mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(streaming): route minimax/* on OpenRouter away from broken direct endpoint
The direct Minimax OpenRouter endpoint silently drops tool-call streams on tool-calling workflows (MiniMax-M2#109, reproduced 4/4 times on 2026-04-18: zero content, no finish_reason, silent close at ~40s). PR #12072 surfaced the failure to the user; this PR avoids it entirely by routing minimax/* requests to Fireworks / NovitaAI / Google-Vertex / AtlasCloud / Together by default. New module agent/provider_tweaks.py centralizes known-broken-endpoint avoidance with a single registry entry per upstream bug. User-supplied provider preferences (provider_sort, providers_allowed/ignored/order) always win — tweaks only fill in defaults where absent, and a user who sets 'only' is fully opted out. Wired into both provider_preferences build sites in run_agent.py (main chat loop + iteration-summary call). Only applies when base_url targets openrouter.ai. Validation | | Before | After | |---|---|---| | minimax/minimax-m2.7 tool-call stream on OR (direct endpoint) | 0/4 success | 4/4 on Fireworks | | extra_body.provider injected for minimax/* on OpenRouter | no | ignore=[minimax] order=[fireworks,novitaai,google-vertex,atlascloud,together] | | extra_body.provider for anthropic/* on OpenRouter | unchanged | unchanged | | extra_body.provider for minimax/* on api.minimax.io | unchanged | unchanged | | User-supplied {only:[minimax]} | unchanged | unchanged (explicit opt-in honoured) | | tests/agent/test_provider_tweaks.py | n/a | 23 passed | | tests/run_agent/test_streaming.py | 26 passed | 26 passed | Live e2e sanity (real OpenRouter call): 89.6s clean response via Fireworks, with `extra_body.provider={'ignore': ['minimax'], 'order': ['fireworks',...]}` confirmed in the outgoing request.
This commit is contained in:
parent
3b69b2fd61
commit
c4cc0f3bd0
3 changed files with 321 additions and 0 deletions
135
agent/provider_tweaks.py
Normal file
135
agent/provider_tweaks.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Provider-specific OpenRouter routing tweaks.
|
||||
|
||||
Central registry for known-buggy OpenRouter endpoints whose upstream providers
|
||||
silently drop tool-call streams, stall mid-arguments, or otherwise fail in ways
|
||||
that are not user-configurable. Consumed by ``run_agent.py`` when building
|
||||
``provider_preferences`` for chat completion requests against OpenRouter.
|
||||
|
||||
Design principles:
|
||||
|
||||
* Only applies to OpenRouter ``base_url`` — other provider chains route through
|
||||
different infrastructure and may not have the same endpoint issues.
|
||||
* User-provided preferences always win. We only layer defaults in where the
|
||||
user hasn't specified ``only``, ``order``, or ``ignore``.
|
||||
* Additions must be backed by a concrete upstream-bug reference (vendor repo
|
||||
issue, reproducible empirical evidence) — this is not for speculative
|
||||
provider preferences.
|
||||
|
||||
Registry format (``_KNOWN_BROKEN_ROUTES``):
|
||||
key: lowercase model-slug substring that identifies the affected family
|
||||
value: {
|
||||
"ignore": [list of OpenRouter provider tags to skip, e.g. "minimax"],
|
||||
"order": [list of OpenRouter provider tags to prefer in order],
|
||||
"reason": "human-readable one-liner used in logs",
|
||||
"ref": "issue/PR reference for the upstream bug",
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Ordered list: first matching entry wins. Match is substring-in-lower-model.
|
||||
_KNOWN_BROKEN_ROUTES: List[Dict[str, Any]] = [
|
||||
{
|
||||
# MiniMax direct OpenRouter endpoint has documented non-terminating
|
||||
# streams on tool-calling workflows (MiniMax-M2 issue #109, Apr 2026;
|
||||
# OpenClaw #1622). Empirically reproduced 4/4 times on 2026-04-18:
|
||||
# streaming a write_file tool call returned zero bytes and closed
|
||||
# silently at ~40s from both minimax/fp8 and minimax/highspeed tags.
|
||||
# Fireworks, Together, NovitaAI, Google-Vertex, AtlasCloud all work.
|
||||
"match": "minimax/",
|
||||
"ignore": ["minimax"],
|
||||
"order": [
|
||||
"fireworks", # m2.7: best throughput + uptime
|
||||
"novitaai", # m2: best tool-call error rate (0.19%)
|
||||
"google-vertex", # m2: fastest latency
|
||||
"atlascloud",
|
||||
"together", # fp4 quant — last resort
|
||||
],
|
||||
"reason": "Minimax direct endpoint drops tool-call streams",
|
||||
"ref": "MiniMax-M2#109, OpenClaw#1622, Hermes-PR#12072",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_provider_tweaks(model: Optional[str], base_url: Optional[str]) -> Dict[str, Any]:
|
||||
"""Return known-broken-endpoint tweaks for a given model/base_url pair.
|
||||
|
||||
Returns an empty dict when no tweaks apply (non-OpenRouter endpoint,
|
||||
unknown model, etc.) so callers can do ``if tweaks:`` cheaply.
|
||||
|
||||
Returned keys when applicable:
|
||||
ignore: list[str] — OpenRouter provider tags to exclude
|
||||
order: list[str] — OpenRouter provider tags to prefer in order
|
||||
reason: str — human-readable reason (for logging)
|
||||
ref: str — upstream bug reference (for logging)
|
||||
"""
|
||||
if not model or not base_url:
|
||||
return {}
|
||||
url_lower = base_url.lower()
|
||||
# Only OpenRouter-compatible endpoints understand the ``provider`` object.
|
||||
if "openrouter.ai" not in url_lower:
|
||||
return {}
|
||||
model_lower = model.lower()
|
||||
for entry in _KNOWN_BROKEN_ROUTES:
|
||||
if entry["match"] in model_lower:
|
||||
return {
|
||||
"ignore": list(entry.get("ignore") or []),
|
||||
"order": list(entry.get("order") or []),
|
||||
"reason": entry.get("reason", ""),
|
||||
"ref": entry.get("ref", ""),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def merge_provider_tweaks(
|
||||
provider_preferences: Dict[str, Any],
|
||||
tweaks: Dict[str, Any],
|
||||
*,
|
||||
log_label: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge auto-tweaks into user-supplied provider preferences.
|
||||
|
||||
User-provided fields always win — this function never overrides ``only``,
|
||||
``ignore``, or ``order`` that the user has already set. It only supplies
|
||||
defaults where those fields are absent.
|
||||
|
||||
When the user has set ``only`` (whitelist mode), the tweaks are fully
|
||||
ignored: a whitelist already constrains routing to a known-good subset,
|
||||
and layering ``ignore``/``order`` on top would be confusing.
|
||||
|
||||
Emits a single INFO log line when tweaks are actually applied so the
|
||||
behaviour is visible in agent.log without spamming every request.
|
||||
"""
|
||||
if not tweaks:
|
||||
return provider_preferences or {}
|
||||
result = dict(provider_preferences or {})
|
||||
# Whitelist already narrows routing — don't layer on.
|
||||
if result.get("only"):
|
||||
return result
|
||||
|
||||
applied: List[str] = []
|
||||
if tweaks.get("ignore") and "ignore" not in result:
|
||||
result["ignore"] = list(tweaks["ignore"])
|
||||
applied.append(f"ignore={tweaks['ignore']}")
|
||||
if tweaks.get("order") and "order" not in result:
|
||||
result["order"] = list(tweaks["order"])
|
||||
applied.append(f"order={tweaks['order']}")
|
||||
|
||||
if applied:
|
||||
logger.info(
|
||||
"Provider tweaks applied%s: %s (reason: %s; ref: %s)",
|
||||
f" [{log_label}]" if log_label else "",
|
||||
", ".join(applied),
|
||||
tweaks.get("reason", "?"),
|
||||
tweaks.get("ref", "?"),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["get_provider_tweaks", "merge_provider_tweaks"]
|
||||
19
run_agent.py
19
run_agent.py
|
|
@ -96,6 +96,7 @@ from agent.subdirectory_hints import SubdirectoryHintTracker
|
|||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from agent.provider_tweaks import get_provider_tweaks, merge_provider_tweaks
|
||||
from agent.display import (
|
||||
KawaiiSpinner, build_tool_preview as _build_tool_preview,
|
||||
get_cute_tool_message as _get_cute_tool_message_impl,
|
||||
|
|
@ -6958,6 +6959,16 @@ class AIAgent:
|
|||
# specific. Only send to OpenRouter-compatible endpoints.
|
||||
# TODO: Nous Portal will add transparent proxy support — re-enable
|
||||
# for _is_nous when their backend is updated.
|
||||
if _is_openrouter:
|
||||
# Apply known-broken-endpoint tweaks (e.g. skip Minimax direct
|
||||
# endpoint on minimax/* models where tool-call streams stall —
|
||||
# MiniMax-M2#109, Hermes-PR#12072). User-supplied preferences
|
||||
# always win; these only supply defaults where absent.
|
||||
_tweaks = get_provider_tweaks(self.model, self._base_url)
|
||||
if _tweaks:
|
||||
provider_preferences = merge_provider_tweaks(
|
||||
provider_preferences, _tweaks,
|
||||
)
|
||||
if provider_preferences and _is_openrouter:
|
||||
extra_body["provider"] = provider_preferences
|
||||
_is_nous = "nousresearch" in self._base_url_lower
|
||||
|
|
@ -8414,6 +8425,14 @@ class AIAgent:
|
|||
provider_preferences["order"] = self.providers_order
|
||||
if self.provider_sort:
|
||||
provider_preferences["sort"] = self.provider_sort
|
||||
# Apply known-broken-endpoint tweaks (OpenRouter only).
|
||||
if "openrouter.ai" in self._base_url_lower:
|
||||
_tweaks = get_provider_tweaks(self.model, self._base_url)
|
||||
if _tweaks:
|
||||
provider_preferences = merge_provider_tweaks(
|
||||
provider_preferences, _tweaks,
|
||||
log_label="iteration_summary",
|
||||
)
|
||||
if provider_preferences:
|
||||
summary_extra_body["provider"] = provider_preferences
|
||||
|
||||
|
|
|
|||
167
tests/agent/test_provider_tweaks.py
Normal file
167
tests/agent/test_provider_tweaks.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Unit tests for agent.provider_tweaks."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.provider_tweaks import get_provider_tweaks, merge_provider_tweaks
|
||||
|
||||
|
||||
# ── get_provider_tweaks ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetProviderTweaks:
|
||||
def test_returns_empty_for_non_openrouter_base_url(self):
|
||||
assert get_provider_tweaks("minimax/minimax-m2.7", "https://api.minimax.io/v1") == {}
|
||||
|
||||
def test_returns_empty_for_missing_base_url(self):
|
||||
assert get_provider_tweaks("minimax/minimax-m2.7", None) == {}
|
||||
assert get_provider_tweaks("minimax/minimax-m2.7", "") == {}
|
||||
|
||||
def test_returns_empty_for_missing_model(self):
|
||||
assert get_provider_tweaks(None, "https://openrouter.ai/api/v1") == {}
|
||||
assert get_provider_tweaks("", "https://openrouter.ai/api/v1") == {}
|
||||
|
||||
def test_returns_empty_for_unmatched_model_on_openrouter(self):
|
||||
assert get_provider_tweaks("anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1") == {}
|
||||
assert get_provider_tweaks("openai/gpt-5.4", "https://openrouter.ai/api/v1") == {}
|
||||
assert get_provider_tweaks("deepseek/deepseek-chat", "https://openrouter.ai/api/v1") == {}
|
||||
|
||||
def test_minimax_m27_on_openrouter_gets_ignore_and_order(self):
|
||||
t = get_provider_tweaks("minimax/minimax-m2.7", "https://openrouter.ai/api/v1")
|
||||
assert t != {}
|
||||
assert t["ignore"] == ["minimax"]
|
||||
assert "fireworks" in t["order"]
|
||||
assert "novitaai" in t["order"]
|
||||
assert t["ref"]
|
||||
assert t["reason"]
|
||||
|
||||
def test_minimax_m2_base_also_matches(self):
|
||||
t = get_provider_tweaks("minimax/minimax-m2", "https://openrouter.ai/api/v1")
|
||||
assert t["ignore"] == ["minimax"]
|
||||
|
||||
def test_minimax_m21_also_matches(self):
|
||||
t = get_provider_tweaks("minimax/minimax-m2.1", "https://openrouter.ai/api/v1")
|
||||
assert t["ignore"] == ["minimax"]
|
||||
|
||||
def test_case_insensitive_model_match(self):
|
||||
t = get_provider_tweaks("MiniMax/MiniMax-M2.7", "https://openrouter.ai/api/v1")
|
||||
assert t["ignore"] == ["minimax"]
|
||||
|
||||
def test_case_insensitive_base_url_match(self):
|
||||
t = get_provider_tweaks("minimax/minimax-m2.7", "https://OpenRouter.AI/api/v1")
|
||||
assert t["ignore"] == ["minimax"]
|
||||
|
||||
def test_openrouter_subpath_still_matched(self):
|
||||
# Proxied OpenRouter deployments still route through openrouter.ai
|
||||
t = get_provider_tweaks("minimax/minimax-m2.7", "https://proxy.example.com/openrouter.ai/api/v1")
|
||||
assert t["ignore"] == ["minimax"]
|
||||
|
||||
def test_returned_lists_are_copies_not_references(self):
|
||||
t1 = get_provider_tweaks("minimax/minimax-m2.7", "https://openrouter.ai/api/v1")
|
||||
t1["ignore"].append("pwned")
|
||||
t2 = get_provider_tweaks("minimax/minimax-m2.7", "https://openrouter.ai/api/v1")
|
||||
assert "pwned" not in t2["ignore"]
|
||||
|
||||
|
||||
# ── merge_provider_tweaks ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMergeProviderTweaks:
|
||||
def test_empty_tweaks_returns_input_unchanged(self):
|
||||
assert merge_provider_tweaks({"order": ["x"]}, {}) == {"order": ["x"]}
|
||||
assert merge_provider_tweaks(None, {}) == {}
|
||||
assert merge_provider_tweaks({}, {}) == {}
|
||||
|
||||
def test_empty_preferences_gets_tweak_defaults(self):
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merged = merge_provider_tweaks({}, t)
|
||||
assert merged["ignore"] == ["minimax"]
|
||||
assert merged["order"] == ["fireworks"]
|
||||
|
||||
def test_none_preferences_gets_tweak_defaults(self):
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merged = merge_provider_tweaks(None, t)
|
||||
assert merged["ignore"] == ["minimax"]
|
||||
assert merged["order"] == ["fireworks"]
|
||||
|
||||
def test_user_ignore_wins_over_tweaks(self):
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merged = merge_provider_tweaks({"ignore": ["together"]}, t)
|
||||
assert merged["ignore"] == ["together"]
|
||||
# But order still filled from tweaks since user didn't set it
|
||||
assert merged["order"] == ["fireworks"]
|
||||
|
||||
def test_user_order_wins_over_tweaks(self):
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merged = merge_provider_tweaks({"order": ["novitaai"]}, t)
|
||||
assert merged["order"] == ["novitaai"]
|
||||
# But ignore still filled from tweaks
|
||||
assert merged["ignore"] == ["minimax"]
|
||||
|
||||
def test_user_only_disables_all_tweaks(self):
|
||||
"""When user whitelists specific providers, don't layer ignore/order on top."""
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merged = merge_provider_tweaks({"only": ["minimax"]}, t)
|
||||
assert merged == {"only": ["minimax"]}
|
||||
assert "ignore" not in merged
|
||||
assert "order" not in merged
|
||||
|
||||
def test_does_not_mutate_input_preferences(self):
|
||||
prefs = {"order": ["novitaai"]}
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merge_provider_tweaks(prefs, t)
|
||||
# Input wasn't modified
|
||||
assert prefs == {"order": ["novitaai"]}
|
||||
assert "ignore" not in prefs
|
||||
|
||||
def test_does_not_mutate_input_tweaks(self):
|
||||
prefs = {}
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merged = merge_provider_tweaks(prefs, t)
|
||||
merged["ignore"].append("pwned")
|
||||
assert t["ignore"] == ["minimax"]
|
||||
|
||||
def test_preserves_unrelated_preference_keys(self):
|
||||
t = {"ignore": ["minimax"], "order": ["fireworks"], "reason": "x", "ref": "y"}
|
||||
merged = merge_provider_tweaks(
|
||||
{"sort": "throughput", "data_collection": "deny"},
|
||||
t,
|
||||
)
|
||||
assert merged["sort"] == "throughput"
|
||||
assert merged["data_collection"] == "deny"
|
||||
assert merged["ignore"] == ["minimax"]
|
||||
assert merged["order"] == ["fireworks"]
|
||||
|
||||
|
||||
# ── Integration: the concrete MiniMax case from PR #12072 ──────────────────
|
||||
|
||||
|
||||
class TestMinimaxConcreteCase:
|
||||
"""End-to-end behaviour for the exact scenario that motivated this module.
|
||||
|
||||
MiniMax direct OpenRouter endpoint has documented non-terminating streams
|
||||
on tool-calling workloads (MiniMax-M2 issue #109). The tweaks module
|
||||
must automatically route `minimax/*` requests away from that endpoint
|
||||
on OpenRouter, while leaving user-supplied preferences untouched.
|
||||
"""
|
||||
|
||||
def test_fresh_minimax_request_gets_fireworks_routing(self):
|
||||
t = get_provider_tweaks("minimax/minimax-m2.7", "https://openrouter.ai/api/v1")
|
||||
merged = merge_provider_tweaks({}, t)
|
||||
assert "minimax" in merged["ignore"]
|
||||
# Fireworks is the empirically-best provider for m2.7 (confirmed
|
||||
# 2026-04-18: 99% uptime, 75 tok/s p50, clean tool-call streams)
|
||||
assert merged["order"][0] == "fireworks"
|
||||
|
||||
def test_user_force_minimax_still_honoured(self):
|
||||
"""User who explicitly wants to test the broken endpoint gets it."""
|
||||
t = get_provider_tweaks("minimax/minimax-m2.7", "https://openrouter.ai/api/v1")
|
||||
merged = merge_provider_tweaks({"only": ["minimax"]}, t)
|
||||
# User's explicit whitelist wins, even though we "know better".
|
||||
assert merged == {"only": ["minimax"]}
|
||||
|
||||
def test_anthropic_model_on_openrouter_unaffected(self):
|
||||
"""Tweaks only trigger on the specific buggy model family."""
|
||||
t = get_provider_tweaks("anthropic/claude-sonnet-4.6", "https://openrouter.ai/api/v1")
|
||||
assert t == {}
|
||||
merged = merge_provider_tweaks({}, t)
|
||||
assert merged == {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue