Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = api_key or "moa-virtual-provider"
agent.base_url = "moa://local"
agent._client_kwargs = {}
Expand Down
5 changes: 5 additions & 0 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).
Expand All @@ -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 {})

Expand Down
2 changes: 2 additions & 0 deletions agent/moa_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/agent/test_moa_slot_api_mode.py
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions tests/agent/test_moa_switch_api_mode.py
Original file line number Diff line number Diff line change
@@ -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 = "old-key"
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


@pytest.mark.parametrize(
"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="moa-virtual-provider",
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"