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
- 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
Open PRs0
Fixes on record
-
#55579 fix(moa): forward slot api_mode + pin chat_completions on live MoA switch
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.pyShow 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
Closed issues · 2
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.00means 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 LabAt a glance
- Subsystem
- agent
- Merged PRs
- #55579
- Closed issues
- 2
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 30 Jun 2026