hermes-agent/tests/agent/test_moa_aggregator_cost_slot.py
Teknium aa605b66c8
fix(moa): price aggregator turn at its real model so session cost isn't advisor-only (#56394)
On the MoA path agent.model/provider are the virtual preset name (e.g.
"closed") and "moa", which have no pricing entry. estimate_usage_cost()
returned None for the aggregator turn, so the `if amount_usd is not None`
guard skipped it and the session's estimated_cost_usd reflected only the
advisor fan-out — a ~50% undercount when the aggregator does the full acting
loop (verified: $0.91 advisor-only vs $1.96 true, aggregator = 54%).

MoAChatCompletions.create() now stashes the resolved aggregator slot as
last_aggregator_slot (exposed via MoAClient); conversation_loop reads it to
price the aggregator turn at its real model/provider. cost_source flips from
'none' to 'provider_models_api'.
2026-07-01 06:02:33 -07:00

100 lines
3.2 KiB
Python

"""Tests for MoA aggregator-slot exposure used by session cost accounting.
Regression guard for the ~50% MoA cost undercount: on the MoA path the
agent's model/provider are the virtual preset name (e.g. "closed") and "moa",
which have no pricing entry. Session cost accounting must price the
aggregator's acting turn at its REAL model/provider, read from the MoA
client's ``last_aggregator_slot``. Before the fix that slot did not exist and
the aggregator's spend (often >50% of the turn) was silently dropped, leaving
the session cost as advisor-fan-out only.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
def _response(content="ok"):
message = SimpleNamespace(content=content, tool_calls=[])
choice = SimpleNamespace(message=message, finish_reason="stop")
return SimpleNamespace(choices=[choice], usage=None, model="fake")
@pytest.fixture
def moa_config(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
"""
moa:
default_preset: closed
presets:
closed:
enabled: true
reference_models:
- provider: openrouter
model: anthropic/claude-opus-4.8
- provider: openrouter
model: openai/gpt-5.5
aggregator:
provider: openrouter
model: anthropic/claude-opus-4.8
""".strip(),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def test_create_populates_last_aggregator_slot(moa_config, monkeypatch):
"""After a create() turn, last_aggregator_slot carries the REAL aggregator
model/provider — not the virtual preset name."""
from agent.moa_loop import MoAChatCompletions
def fake_call_llm(**kwargs):
return _response("acted" if kwargs.get("task") != "moa_reference" else "advice")
monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm)
facade = MoAChatCompletions("closed")
# Slot is unset before any turn runs.
assert facade.last_aggregator_slot is None
facade.create(
model="closed",
messages=[{"role": "user", "content": "clean the db"}],
)
slot = facade.last_aggregator_slot
assert slot is not None
# The virtual preset name / "moa" must NOT leak into the priced slot.
assert slot["model"] == "anthropic/claude-opus-4.8"
assert slot["provider"] == "openrouter"
assert slot["model"] != "closed"
def test_client_exposes_last_aggregator_slot(moa_config, monkeypatch):
"""MoAClient delegates last_aggregator_slot to its completions facade so
session accounting can read it without touching internals."""
from agent.moa_loop import MoAClient
def fake_call_llm(**kwargs):
return _response("acted" if kwargs.get("task") != "moa_reference" else "advice")
monkeypatch.setattr("agent.moa_loop.call_llm", fake_call_llm)
client = MoAClient("closed")
assert client.last_aggregator_slot is None
client.chat.completions.create(
model="closed",
messages=[{"role": "user", "content": "clean the db"}],
)
slot = client.last_aggregator_slot
assert slot is not None
assert slot["model"] == "anthropic/claude-opus-4.8"
assert slot["provider"] == "openrouter"