# NousResearch/hermes-agent · MoA slot provider identity and api_mode propagation

## Open now

- Nothing open on GitHub.

These issues and fixes concern Mixture-of-Agents (MoA) reference and aggregator slots losing provider identity information—particularly api_mode, credentials, and routing—when passed through call_llm, causing errors like 400 and 404 responses. The work involves diagnosing the drops and refactoring how slot runtime context is forwarded and preserved.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #55579. Verify it addresses your case.
- **Recommended:** read PR #55579 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-55579`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.36`: the share of member 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.

## Closed / duplicate issues

- #54379: MoA reference slots drop resolved api_mode → Copilot GPT-5.x references 400 with unsupported_api_for_model
- #55268: MoA aggregator returns HTTP 404 when its endpoint uses Anthropic Messages on an unrecognized host (api_mode dropped)

## Prior & competing PRs

- **#55269: fix(moa): forward resolved api_mode for MoA aggregator and reference slots**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54384`, `#54384`, `#54384`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#55579: fix(moa): forward slot api_mode + pin chat_completions on live MoA switch**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `agent/agent_runtime_helpers.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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).
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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"
    ```
- **#53706: fix(auxiliary): segregate copilot transports in the client cache key**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `fb403a3a730f2a4eab1cfc60ffe854f7ea7c4159`, `#56889`
- **#54384: fix(moa): propagate api_mode from slot runtime to call_llm (#54379)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `d76ca3a7f`, `#55579`, `#55268`, `#55579`, `#54259`, `#54669`

## Fix status

- Merged to main in PR #55579 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-30`.
- Subsystem: `agent`
