{"repo":"NousResearch/hermes-agent","public_id":"54259","display_label":"MoA routing, base_url, and api_mode bug fixes","summary":"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.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55579},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-30T10:39:51+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":54259,"pr_number":55579,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54379,"pr_number":55579,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":54259,"title":"MoA gateway: primary/acting call resolves to base_url=moa://local -> 404 -> fallback (references + aggregator OK)","url":null,"state":"closed"},{"kind":"issue","number":54669,"title":"bug(moa): top-level moa.enabled: true silently poisons model.base_url with moa://local","url":null,"state":"closed"},{"kind":"pr","number":54500,"title":"fix(agent): reset api_mode to chat_completions when switching to MoA (#54259)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55579","number":55579},{"kind":"pr","value":"#54259","number":54259}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":54700,"title":"fix(agent): preserve real base_url when provider is moa","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54669","number":54669},{"kind":"pr","value":"#54669","number":54669}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55579,"title":"fix(moa): forward slot api_mode + pin chat_completions on live MoA switch","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -1625,6 +1625,18 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo\n         if (new_provider or \"\").strip().lower() == \"moa\":\n             from agent.moa_loop import MoAClient\n \n+            # The MoA virtual provider speaks only chat.completions via the\n+            # MoAClient facade — the aggregator's real transport\n+            # (codex_responses / anthropic_messages) is resolved and applied\n+            # *inside* the reference/aggregator fan-out, never on the outer\n+            # primary call. determine_api_mode(\"moa\", ...) above may have left\n+            # api_mode set to the aggregator's transport; if the conversation\n+            # loop sees that, it dispatches client.responses.create (which the\n+            # facade has no .responses for) and the call falls through to the\n+            # moa://local placeholder → HTTP 404 → fallback to a reference\n+            # model. Pin chat_completions here so the primary call always goes\n+            # through MoAClient.chat.completions, matching agent_init.py.\n+            agent.api_mode = \"chat_completions\"\n             agent.api_key = [redacted:secret-assignment] or \"moa-virtual-provider\"\n             agent.base_url = \"moa://local\"\n             agent._client_kwargs = {}"]},{"file":"agent/auxiliary_client.py","hunks":["@@ -5680,6 +5680,7 @@ def call_llm(\n     tools: list = None,\n     timeout: float = None,\n     extra_body: dict = None,\n+    api_mode: str = None,\n ) -> Any:\n     \"\"\"Centralized synchronous LLM call.\n ","@@ -5692,6 +5693,8 @@ def call_llm(\n               Reads provider:model from config/env. Ignored if provider is set.\n         provider: Explicit provider override.\n         model: Explicit model override.\n+        api_mode: Explicit API mode override (e.g. \"codex_responses\",\n+              \"anthropic_messages\"). Takes precedence over task config.\n         messages: Chat messages list.\n         temperature: Sampling temperature (None = provider default).\n         max_tokens: Max output tokens (handles max_tokens vs max_completion_tokens).","@@ -5707,6 +5710,8 @@ def call_llm(\n     \"\"\"\n     resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(\n         task, provider, model, base_url, api_key)\n+    if api_mode:\n+        resolved_api_mode = api_mode\n     effective_extra_body = _get_task_extra_body(task)\n     effective_extra_body.update(extra_body or {})\n "]},{"file":"agent/moa_loop.py","hunks":["@@ -109,6 +109,8 @@ def _slot_runtime(slot: dict[str, str]) -> dict[str, Any]:\n             out[\"base_url\"] = rt[\"base_url\"]\n         if rt.get(\"api_key\"):\n             out[\"api_key\"] = rt[\"api_key\"]\n+        if rt.get(\"api_mode\"):\n+            out[\"api_mode\"] = rt[\"api_mode\"]\n     except Exception as exc:  # pragma: no cover - defensive\n         logger.debug(\"MoA slot runtime resolution failed for %s: %s\", _slot_label(slot), exc)\n     return out"]},{"file":"tests/agent/test_moa_slot_api_mode.py","hunks":["@@ -0,0 +1,75 @@\n+\"\"\"Tests for MoA slot_runtime api_mode propagation (issue #54379).\n+\n+Verify that _slot_runtime passes the resolved api_mode through to call_llm,\n+so reference slots using providers that require a specific API surface\n+(e.g. Copilot GPT-5.x → codex_responses) get routed correctly.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n+class TestSlotRuntimeApiMode:\n+    \"\"\"_slot_runtime should include api_mode when resolve_runtime_provider returns it.\"\"\"\n+\n+    @patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\")\n+    def test_slot_runtime_includes_api_mode(self, mock_resolve):\n+        \"\"\"api_mode from resolve_runtime_provider is forwarded in output dict.\"\"\"\n+        mock_resolve.return_value = {\n+            \"provider\": \"copilot\",\n+            \"model\": \"gpt-5.5\",\n+            \"base_url\": \"https://api.githubcopilot.com\",\n+            \"api_key\": \"test-key\",\n+            \"api_mode\": \"codex_responses\",\n+        }\n+        from agent.moa_loop import _slot_runtime\n+\n+        result = _slot_runtime({\"provider\": \"copilot\", \"model\": \"gpt-5.5\"})\n+        assert result[\"api_mode\"] == \"codex_responses\"\n+        assert result[\"base_url\"] == \"https://api.githubcopilot.com\"\n+        assert result[\"api_key\"] == \"test-key\"\n+\n+    @patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\")\n+    def test_slot_runtime_omits_api_mode_when_absent(self, mock_resolve):\n+        \"\"\"When resolve_runtime_provider does not return api_mode, output omits it.\"\"\"\n+        mock_resolve.return_value = {\n+            \"provider\": \"openai\",\n+            \"model\": \"gpt-4o\",\n+            \"base_url\": \"https://api.openai.com/v1\",\n+            \"api_key\": \"test-key\",\n+        }\n+        from agent.moa_loop import _slot_runtime\n+\n+        result = _slot_runtime({\"provider\": \"openai\", \"model\": \"gpt-4o\"})\n+        assert \"api_mode\" not in result\n+\n+    @patch(\"hermes_cli.runtime_provider.resolve_runtime_provider\")\n+    def test_slot_runtime_omits_api_mode_when_empty(self, mock_resolve):\n+        \"\"\"Empty string api_mode is treated as absent.\"\"\"\n+        mock_resolve.return_value = {\n+            \"provider\": \"copilot\",\n+            \"model\": \"gpt-5.5\",\n+            \"base_url\": \"https://api.githubcopilot.com\",\n+            \"api_key\": \"test-key\",\n+            \"api_mode\": \"\",\n+        }\n+        from agent.moa_loop import _slot_runtime\n+\n+        result = _slot_runtime({\"provider\": \"copilot\", \"model\": \"gpt-5.5\"})\n+        assert \"api_mode\" not in result\n+\n+\n+class TestCallLlmApiMode:\n+    \"\"\"call_llm should accept and forward api_mode parameter.\"\"\"\n+\n+    def test_call_llm_accepts_api_mode_kwarg(self):\n+        \"\"\"call_llm signature includes api_mode parameter.\"\"\"\n+        import inspect\n+        from agent.auxiliary_client import call_llm\n+\n+        sig = inspect.signature(call_llm)\n+        assert \"api_mode\" in sig.parameters\n+        assert sig.parameters[\"api_mode\"].default is None"]},{"file":"tests/agent/test_moa_switch_api_mode.py","hunks":["@@ -0,0 +1,84 @@\n+\"\"\"Regression test for MoA primary-call routing on persisted preset switches.\n+\n+Issue #54259 / #54669: switching a live agent to a MoA preset (the gateway\n+``/model <preset>`` path) built the MoAClient facade but left ``agent.api_mode``\n+set to whatever ``determine_api_mode`` / the resolved aggregator transport\n+produced (e.g. ``codex_responses`` or ``anthropic_messages``). The conversation\n+loop dispatches on ``agent.api_mode``, so a non-chat_completions value made it\n+call ``client.responses.create`` — which the MoAClient facade has no\n+``.responses`` for — and the call fell through to the ``moa://local``\n+placeholder, 404'd three times, then fell back to a reference model.\n+\n+``agent_init.py`` already pins ``api_mode = \"chat_completions\"`` for\n+``provider == \"moa\"``; ``switch_model`` (the live in-place swap) must do the\n+same so the primary call always routes through ``MoAClient.chat.completions``.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import types\n+\n+import pytest\n+\n+\n+def _make_fake_agent():\n+    \"\"\"A minimal stand-in carrying only the attributes switch_model touches.\"\"\"\n+    agent = types.SimpleNamespace()\n+    agent.model = \"minimax-m3\"\n+    agent.provider = \"opencode-go\"\n+    agent.api_mode = \"anthropic_messages\"\n+    agent.api_key = \"[redacted:secret-assignment]\"\n+    agent.base_url = \"https://old.example/v1\"\n+    agent.client = object()\n+    agent._client_kwargs = {\"base_url\": \"https://old.example/v1\"}\n+    agent._config_context_length = 123456\n+    agent._transport_cache = {}\n+    agent.quiet_mode = True\n+    return agent\n+\n+\n[redacted:email](\n+    \"incoming_api_mode\",\n+    [\"codex_responses\", \"anthropic_messages\", \"chat_completions\", \"\"],\n+)\n+def test_switch_to_moa_pins_chat_completions(monkeypatch, incoming_api_mode):\n+    \"\"\"Switching to provider=moa must force api_mode=chat_completions.\n+\n+    No matter what transport the resolver/aggregator implies for the preset,\n+    the outer agent.api_mode must end up chat_completions so the conversation\n+    loop dispatches through the MoAClient chat.completions facade rather than\n+    .responses.create against the moa://local placeholder.\n+    \"\"\"\n+    from agent import agent_runtime_helpers as arh\n+\n+    # Neutralize the post-swap machinery that needs a real AIAgent (credential\n+    # pool reload, context-compressor refresh, primary-runtime bookkeeping).\n+    # We only assert the api_mode invariant set in the moa client-build branch.\n+    monkeypatch.setattr(arh, \"load_pool\", lambda *a, **k: None, raising=False)\n+\n+    agent = _make_fake_agent()\n+    try:\n+        arh.switch_model(\n+            agent,\n+            new_model=\"frontier\",\n+            new_provider=\"moa\",\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"moa://local\",\n+            api_mode=incoming_api_mode,\n+        )\n+    except Exception:\n+        # switch_model does post-swap work (compressor, pool, runtime) that may\n+        # raise against a fake agent. The runtime-field swap — including the\n+        # api_mode pin in the moa branch — happens before any of that, so the\n+        # invariant we care about is already set even if a later step blew up.\n+        pass\n+\n+    assert agent.provider == \"moa\"\n+    assert agent.base_url == \"moa://local\"\n+    assert agent.api_mode == \"chat_completions\", (\n+        f\"MoA switch left api_mode={agent.api_mode!r}; the primary call would \"\n+        \"dispatch .responses.create / anthropic_messages against moa://local \"\n+        \"instead of MoAClient.chat.completions (issue #54259).\"\n+    )\n+    # The MoAClient facade should be installed as the client.\n+    assert type(agent.client).__name__ == \"MoAClient\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}