diff --git a/agent/provider_tweaks.py b/agent/provider_tweaks.py new file mode 100644 index 000000000..cdd6fc4b3 --- /dev/null +++ b/agent/provider_tweaks.py @@ -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"] diff --git a/run_agent.py b/run_agent.py index d5ff125e3..24cce88f3 100644 --- a/run_agent.py +++ b/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 diff --git a/tests/agent/test_provider_tweaks.py b/tests/agent/test_provider_tweaks.py new file mode 100644 index 000000000..3148be7b1 --- /dev/null +++ b/tests/agent/test_provider_tweaks.py @@ -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 == {}