MoA routing, base_url, and api_mode bug fixes

NousResearch/hermes-agent · cluster 54259 · agent

Data as of

Nothing open on GitHub. Fix merged in #55579. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #55579. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs address bugs and features in the Mixture-of-Agents (MoA) integration, specifically around incorrect base_url assignment, api_mode handling during MoA switches, gateway routing failures, and exposing MoA presets as selectable models.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #55579 fix(moa): forward slot api_mode + pin chat_completions on live MoA switch

    merged to main

    files: agent/agent_runtime_helpers.py, agent/auxiliary_client.py, agent/moa_loop.py, tests/agent/test_moa_slot_api_mode.py, tests/agent/test_moa_switch_api_mode.py

    Show the diff
    • agent/agent_runtime_helpers.py
      @@ -1625,6 +1625,18 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
               if (new_provider or "").strip().lower() == "moa":
                   from agent.moa_loop import MoAClient
       
      +            # The MoA virtual provider speaks only chat.completions via the
      +            # MoAClient facade — the aggregator's real transport
      +            # (codex_responses / anthropic_messages) is resolved and applied
      +            # *inside* the reference/aggregator fan-out, never on the outer
      +            # primary call. determine_api_mode("moa", ...) above may have left
      +            # api_mode set to the aggregator's transport; if the conversation
      +            # loop sees that, it dispatches client.responses.create (which the
      +            # facade has no .responses for) and the call falls through to the
      +            # moa://local placeholder → HTTP 404 → fallback to a reference
      +            # model. Pin chat_completions here so the primary call always goes
      +            # through MoAClient.chat.completions, matching agent_init.py.
      +            agent.api_mode = "chat_completions"
                   agent.api_key = [redacted:secret-assignment] or "moa-virtual-provider"
                   agent.base_url = "moa://local"
                   agent._client_kwargs = {}
    • agent/auxiliary_client.py
      @@ -5680,6 +5680,7 @@ def call_llm(
           tools: list = None,
           timeout: float = None,
           extra_body: dict = None,
      +    api_mode: str = None,
       ) -> Any:
           """Centralized synchronous LLM call.
       
      @@ -5692,6 +5693,8 @@ def call_llm(
                     Reads provider:model from config/env. Ignored if provider is set.
               provider: Explicit provider override.
               model: Explicit model override.
      +        api_mode: Explicit API mode override (e.g. "codex_responses",
      +              "anthropic_messages"). Takes precedence over task config.
               messages: Chat messages list.
               temperature: Sampling temperature (None = provider default).
               max_tokens: Max output tokens (handles max_tokens vs max_completion_tokens).
      @@ -5707,6 +5710,8 @@ def call_llm(
           """
           resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(
               task, provider, model, base_url, api_key)
      +    if api_mode:
      +        resolved_api_mode = api_mode
           effective_extra_body = _get_task_extra_body(task)
           effective_extra_body.update(extra_body or {})
       
    • agent/moa_loop.py
      @@ -109,6 +109,8 @@ def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:
                   out["base_url"] = rt["base_url"]
               if rt.get("api_key"):
                   out["api_key"] = rt["api_key"]
      +        if rt.get("api_mode"):
      +            out["api_mode"] = rt["api_mode"]
           except Exception as exc:  # pragma: no cover - defensive
               logger.debug("MoA slot runtime resolution failed for %s: %s", _slot_label(slot), exc)
           return out
    • tests/agent/test_moa_slot_api_mode.py
      @@ -0,0 +1,75 @@
      +"""Tests for MoA slot_runtime api_mode propagation (issue #54379).
      +
      +Verify that _slot_runtime passes the resolved api_mode through to call_llm,
      +so reference slots using providers that require a specific API surface
      +(e.g. Copilot GPT-5.x → codex_responses) get routed correctly.
      +"""
      +
      +from __future__ import annotations
      +
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +
      +class TestSlotRuntimeApiMode:
      +    """_slot_runtime should include api_mode when resolve_runtime_provider returns it."""
      +
      +    @patch("hermes_cli.runtime_provider.resolve_runtime_provider")
      +    def test_slot_runtime_includes_api_mode(self, mock_resolve):
      +        """api_mode from resolve_runtime_provider is forwarded in output dict."""
      +        mock_resolve.return_value = {
      +            "provider": "copilot",
      +            "model": "gpt-5.5",
      +            "base_url": "https://api.githubcopilot.com",
      +            "api_key": "test-key",
      +            "api_mode": "codex_responses",
      +        }
      +        from agent.moa_loop import _slot_runtime
      +
      +        result = _slot_runtime({"provider": "copilot", "model": "gpt-5.5"})
      +        assert result["api_mode"] == "codex_responses"
      +        assert result["base_url"] == "https://api.githubcopilot.com"
      +        assert result["api_key"] == "test-key"
      +
      +    @patch("hermes_cli.runtime_provider.resolve_runtime_provider")
      +    def test_slot_runtime_omits_api_mode_when_absent(self, mock_resolve):
      +        """When resolve_runtime_provider does not return api_mode, output omits it."""
      +        mock_resolve.return_value = {
      +            "provider": "openai",
      +            "model": "gpt-4o",
      +            "base_url": "https://api.openai.com/v1",
      +            "api_key": "test-key",
      +        }
      +        from agent.moa_loop import _slot_runtime
      +
      +        result = _slot_runtime({"provider": "openai", "model": "gpt-4o"})
      +        assert "api_mode" not in result
      +
      +    @patch("hermes_cli.runtime_provider.resolve_runtime_provider")
      +    def test_slot_runtime_omits_api_mode_when_empty(self, mock_resolve):
      +        """Empty string api_mode is treated as absent."""
      +        mock_resolve.return_value = {
      +            "provider": "copilot",
      +            "model": "gpt-5.5",
      +            "base_url": "https://api.githubcopilot.com",
      +            "api_key": "test-key",
      +            "api_mode": "",
      +        }
      +        from agent.moa_loop import _slot_runtime
      +
      +        result = _slot_runtime({"provider": "copilot", "model": "gpt-5.5"})
      +        assert "api_mode" not in result
      +
      +
      +class TestCallLlmApiMode:
      +    """call_llm should accept and forward api_mode parameter."""
      +
      +    def test_call_llm_accepts_api_mode_kwarg(self):
      +        """call_llm signature includes api_mode parameter."""
      +        import inspect
      +        from agent.auxiliary_client import call_llm
      +
      +        sig = inspect.signature(call_llm)
      +        assert "api_mode" in sig.parameters
      +        assert sig.parameters["api_mode"].default is None
    • tests/agent/test_moa_switch_api_mode.py
      @@ -0,0 +1,84 @@
      +"""Regression test for MoA primary-call routing on persisted preset switches.
      +
      +Issue #54259 / #54669: switching a live agent to a MoA preset (the gateway
      +``/model <preset>`` path) built the MoAClient facade but left ``agent.api_mode``
      +set to whatever ``determine_api_mode`` / the resolved aggregator transport
      +produced (e.g. ``codex_responses`` or ``anthropic_messages``). The conversation
      +loop dispatches on ``agent.api_mode``, so a non-chat_completions value made it
      +call ``client.responses.create`` — which the MoAClient facade has no
      +``.responses`` for — and the call fell through to the ``moa://local``
      +placeholder, 404'd three times, then fell back to a reference model.
      +
      +``agent_init.py`` already pins ``api_mode = "chat_completions"`` for
      +``provider == "moa"``; ``switch_model`` (the live in-place swap) must do the
      +same so the primary call always routes through ``MoAClient.chat.completions``.
      +"""
      +
      +from __future__ import annotations
      +
      +import types
      +
      +import pytest
      +
      +
      +def _make_fake_agent():
      +    """A minimal stand-in carrying only the attributes switch_model touches."""
      +    agent = types.SimpleNamespace()
      +    agent.model = "minimax-m3"
      +    agent.provider = "opencode-go"
      +    agent.api_mode = "anthropic_messages"
      +    agent.api_key = "[redacted:secret-assignment]"
      +    agent.base_url = "https://old.example/v1"
      +    agent.client = object()
      +    agent._client_kwargs = {"base_url": "https://old.example/v1"}
      +    agent._config_context_length = 123456
      +    agent._transport_cache = {}
      +    agent.quiet_mode = True
      +    return agent
      +
      +
      [redacted:email](
      +    "incoming_api_mode",
      +    ["codex_responses", "anthropic_messages", "chat_completions", ""],
      +)
      +def test_switch_to_moa_pins_chat_completions(monkeypatch, incoming_api_mode):
      +    """Switching to provider=moa must force api_mode=chat_completions.
      +
      +    No matter what transport the resolver/aggregator implies for the preset,
      +    the outer agent.api_mode must end up chat_completions so the conversation
      +    loop dispatches through the MoAClient chat.completions facade rather than
      +    .responses.create against the moa://local placeholder.
      +    """
      +    from agent import agent_runtime_helpers as arh
      +
      +    # Neutralize the post-swap machinery that needs a real AIAgent (credential
      +    # pool reload, context-compressor refresh, primary-runtime bookkeeping).
      +    # We only assert the api_mode invariant set in the moa client-build branch.
      +    monkeypatch.setattr(arh, "load_pool", lambda *a, **k: None, raising=False)
      +
      +    agent = _make_fake_agent()
      +    try:
      +        arh.switch_model(
      +            agent,
      +            new_model="frontier",
      +            new_provider="moa",
      +            api_key="[redacted:secret-assignment]",
      +            base_url="moa://local",
      +            api_mode=incoming_api_mode,
      +        )
      +    except Exception:
      +        # switch_model does post-swap work (compressor, pool, runtime) that may
      +        # raise against a fake agent. The runtime-field swap — including the
      +        # api_mode pin in the moa branch — happens before any of that, so the
      +        # invariant we care about is already set even if a later step blew up.
      +        pass
      +
      +    assert agent.provider == "moa"
      +    assert agent.base_url == "moa://local"
      +    assert agent.api_mode == "chat_completions", (
      +        f"MoA switch left api_mode={agent.api_mode!r}; the primary call would "
      +        "dispatch .responses.create / anthropic_messages against moa://local "
      +        "instead of MoAClient.chat.completions (issue #54259)."
      +    )
      +    # The MoAClient facade should be installed as the client.
      +    assert type(agent.client).__name__ == "MoAClient"

2 PRs closed without merging

From the close comments: done elsewhere 1 · other 1

  • #54500 fix(agent): reset api_mode to chat_completions when switching to MoA (#54259) · done elsewhere → #55579, #54259
  • #54700 fix(agent): preserve real base_url when provider is moa · other → #54669
Closed issues · 2
  • Closed#54259MoA gateway: primary/acting call resolves to base_url=moa://local -> 404 -> fallback (references + aggregator OK)
  • Closed#54669bug(moa): top-level moa.enabled: true silently poisons model.base_url with moa://local
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#55579
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
30 Jun 2026

For your agent

The same facts, typed.